From ae22590ccc50aefd37213ac1948ffd15b09bea1e Mon Sep 17 00:00:00 2001 From: Kevin Lu <79181817+kevdevlu@users.noreply.github.com> Date: Fri, 24 Jul 2026 00:36:19 -0700 Subject: [PATCH 01/21] Add ranking, ranking-deck, and image SDCs Ports the az_ranking paragraph's rendering into three profile-level Single Directory Components, usable directly in Drupal Canvas as well as from the Paragraphs pipeline in a later phase: - ranking: a single text ranking card - ranking-deck: a responsive CSS grid wrapper for multiple cards/images, replacing the Bootstrap row/col composition Canvas has no editor UI for - image: a layout- and accessibility-aware image, also closing MAR-104 (decorative image component). Uses a plain string prop shape that Canvas's own media_library shape-matching recognizes, giving a real media-library picker without requiring the Canvas module to render on Canvas-less sites. Adds drupal/canvas to composer.json so the Tugboat PR preview build installs Canvas for review; to be removed in a follow-up commit once review is done. Co-Authored-By: Claude Sonnet 5 --- components/image/image.component.yml | 53 +++++ components/image/image.css | 70 +++++++ components/image/image.twig | 54 ++++++ .../ranking-deck/ranking-deck.component.yml | 67 +++++++ components/ranking-deck/ranking-deck.css | 72 +++++++ components/ranking-deck/ranking-deck.twig | 43 +++++ components/ranking/ranking.component.yml | 182 ++++++++++++++++++ components/ranking/ranking.css | 88 +++++++++ components/ranking/ranking.twig | 154 +++++++++++++++ composer.json | 1 + 10 files changed, 784 insertions(+) create mode 100644 components/image/image.component.yml create mode 100644 components/image/image.css create mode 100644 components/image/image.twig create mode 100644 components/ranking-deck/ranking-deck.component.yml create mode 100644 components/ranking-deck/ranking-deck.css create mode 100644 components/ranking-deck/ranking-deck.twig create mode 100644 components/ranking/ranking.component.yml create mode 100644 components/ranking/ranking.css create mode 100644 components/ranking/ranking.twig diff --git a/components/image/image.component.yml b/components/image/image.component.yml new file mode 100644 index 0000000000..9b1d94cd5e --- /dev/null +++ b/components/image/image.component.yml @@ -0,0 +1,53 @@ +$schema: https://git.drupalcode.org/project/drupal/-/raw/HEAD/core/assets/schemas/v1/metadata.schema.json +name: Image +status: experimental +description: A layout- and accessibility-aware image, backed by the media library. Works standalone on a page, or dropped into a Ranking Deck alongside Ranking cards. +props: + type: object + properties: + src: + type: string + title: Image + description: Pick an image from the media library. + format: uri + contentMediaType: image/* + x-allowed-schemes: + - public + examples: + - public://canyon_running.jpg + decorative: + type: boolean + title: Decorative + description: Hide this image from screen readers and other assistive technology, and ignore Alternative Text. Only enable for purely decorative images that carry no information. + examples: + - false + alt: + type: string + title: Alternative Text + description: Describes the image for screen readers and other assistive technology. Ignored when Decorative is enabled. + examples: + - Canyon Running + width_span: + type: string + title: Width Span + description: How many grid columns this image spans when placed inside a Ranking Deck (or any other CSS grid layout). Has no effect outside a grid layout. + enum: + - '1' + - '2' + - '3' + - '4' + meta:enum: + '1': 1 column + '2': 2 columns (default) + '3': 3 columns + '4': 4 columns + examples: + - '2' + utility_classes: + type: array + title: Utility Classes + description: Additional Bootstrap utility classes for the image wrapper, e.g. bottom spacing (mb-4) before the next item on the page. Not needed when the image is placed inside a Ranking Deck. + items: + type: string + examples: + - - mb-0 diff --git a/components/image/image.css b/components/image/image.css new file mode 100644 index 0000000000..4a3c6b9c94 --- /dev/null +++ b/components/image/image.css @@ -0,0 +1,70 @@ +/** + * Image component. + * + * A layout wrapper around a media-library-backed . `grid-column: span N` + * only has an effect when this component is placed inside a CSS grid + * container (for example, the Ranking Deck component) — it is inert + * everywhere else, so the same width_span prop works whether the image is + * placed inside a deck or standalone on a page. + * + * Known limitation: if width_span is set wider than the number of columns + * the surrounding grid currently has (e.g. span 4 inside a 2-column + * Ranking Deck), the browser may create extra implicit grid tracks rather + * than clamping — this has not been visually verified. Editors should + * pick a span that fits the deck's current per-row setting. + */ + +.az-image { + position: relative; + overflow: hidden; + /* Matches .az-ranking-sdc's own responsive min-height (ranking.css) so an + image-only row (or an image taller than its siblings) has a sensible + floor, and so the image never dictates the row's height (see img rule + below) — same technique the legacy .ranking-image-wrapper used. + Small viewports. */ + min-height: 190px; +} + +/* Medium viewports. */ +@media (min-width: 768px) { + .az-image { + min-height: 230px; + } +} + +/* Large viewports. */ +@media (min-width: 992px) { + .az-image { + min-height: 260px; + } +} + +.az-image img { + /* Absolutely positioned so the image's own intrinsic aspect ratio never + affects the grid row's height — the row is sized by its other content + (Ranking cards), and this image crops via object-fit to fill whatever + height that produces, exactly like the legacy .ranking-image-wrapper. */ + position: absolute; + top: 0; + left: 0; + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.az-image--span-1 { + grid-column: span 1; +} + +.az-image--span-2 { + grid-column: span 2; +} + +.az-image--span-3 { + grid-column: span 3; +} + +.az-image--span-4 { + grid-column: span 4; +} diff --git a/components/image/image.twig b/components/image/image.twig new file mode 100644 index 0000000000..0952c0efdf --- /dev/null +++ b/components/image/image.twig @@ -0,0 +1,54 @@ +{# +/** + * @file + * Template for the az_quickstart Image component. + * + * Renders an directly from a media-library-backed prop — no slot, no + * Canvas dependency. The `src` prop's shape (type: string, format: uri, + * contentMediaType: image/*, x-allowed-schemes: [public]) is recognized by + * Drupal core's media_library module via a canvas_storable_prop_shape_alter + * hook Canvas ships — that's what gives editors a real media-library picker + * in Canvas, with no canvas:image / canvas.module $ref needed, so this stays + * safe to render on sites without the Canvas module installed. + * + * Props: + * - src: Image URI, picked via the media library in Canvas. Resolves to a + * Drupal stream-wrapper URI (public://...), not a browser-loadable URL — + * converted via the file_url() Twig function before rendering. + * - decorative: When true, forces empty alt text AND hides the image from + * assistive technology via aria-hidden. Defaults to false. + * - alt: Alternative text. Ignored when decorative is true. + * - width_span: 1-4. Grid columns this image spans when placed inside a + * Ranking Deck (or any CSS grid layout). No effect outside a grid + * context. Defaults to 2. + * - utility_classes: Additional Bootstrap utility classes for the wrapper + * (e.g. bottom spacing). Not needed when placed inside a Ranking Deck. + */ +#} +{% set attributes = attributes|default(create_attribute()) %} + +{% set decorative = decorative|default(false) %} +{% set alt = decorative ? '' : alt|default('') %} + +{% set width_span = width_span|default('2') in ['1', '2', '3', '4'] ? width_span|default('2') : '2' %} + +{% set utility_classes = utility_classes|default([]) %} +{% if utility_classes is not iterable %} + {% set utility_classes = [utility_classes] %} +{% endif %} + +{% set root_classes = ['az-image', 'az-image--span-' ~ width_span] %} +{% if utility_classes %} + {% set root_classes = root_classes|merge(utility_classes) %} +{% endif %} + +{% set root_attributes = attributes.addClass(root_classes) %} +{% if decorative %} + {% set root_attributes = root_attributes.setAttribute('aria-hidden', 'true') %} +{% endif %} + + + {% if src|default('') %} + {{ alt }} + {% endif %} + diff --git a/components/ranking-deck/ranking-deck.component.yml b/components/ranking-deck/ranking-deck.component.yml new file mode 100644 index 0000000000..5f0cf7312f --- /dev/null +++ b/components/ranking-deck/ranking-deck.component.yml @@ -0,0 +1,67 @@ +$schema: https://git.drupalcode.org/project/drupal/-/raw/HEAD/core/assets/schemas/v1/metadata.schema.json +name: Ranking Deck +status: experimental +description: A responsive grid of Ranking cards with per-breakpoint columns and consistent spacing. Drop Ranking components into the rankings slot. +props: + type: object + properties: + columns_desktop: + type: string + title: Rankings per row on desktop + description: How many rankings appear per row on desktop. Additional rankings wrap to a new row. + enum: + - '1' + - '2' + - '3' + - '4' + meta:enum: + '1': '1' + '2': '2' + '3': '3' + '4': 4 (default) + examples: + - '4' + columns_tablet: + type: string + title: Rankings per row on tablet + description: How many rankings appear per row on tablet. + enum: + - '1' + - '2' + - '3' + - '4' + meta:enum: + '1': 1 (default) + '2': '2' + '3': '3' + '4': '4' + examples: + - '1' + columns_phone: + type: string + title: Rankings per row on phone + description: How many rankings appear per row on phone. + enum: + - '1' + - '2' + - '3' + - '4' + meta:enum: + '1': 1 (default) + '2': '2' + '3': '3' + '4': '4' + examples: + - '1' + utility_classes: + type: array + title: Utility Classes + description: Additional Bootstrap utility classes for the deck wrapper, e.g. bottom spacing (mb-4) before the next item on the page. + items: + type: string + examples: + - - mb-4 +slots: + rankings: + title: Rankings + description: The Ranking cards (or other content) laid out by the deck grid. diff --git a/components/ranking-deck/ranking-deck.css b/components/ranking-deck/ranking-deck.css new file mode 100644 index 0000000000..e534870c9d --- /dev/null +++ b/components/ranking-deck/ranking-deck.css @@ -0,0 +1,72 @@ +/** + * Ranking Deck component. + * + * Responsive CSS grid for Ranking cards. The grid (not the cards) owns the + * layout and spacing: per-breakpoint column counts come from modifier + * classes, and the gap matches the legacy pb-4 rhythm (1.5rem). Grid items + * stretch by default, giving equal-height cards per row. + */ + +.az-ranking-deck { + display: grid; + gap: 1.5rem; +} + +.az-ranking-deck > * { + min-width: 0; +} + +/* Phone (base). */ +.az-ranking-deck--phone-1 { + grid-template-columns: repeat(1, 1fr); +} + +.az-ranking-deck--phone-2 { + grid-template-columns: repeat(2, 1fr); +} + +.az-ranking-deck--phone-3 { + grid-template-columns: repeat(3, 1fr); +} + +.az-ranking-deck--phone-4 { + grid-template-columns: repeat(4, 1fr); +} + +/* Tablet. */ +@media (min-width: 768px) { + .az-ranking-deck--tablet-1 { + grid-template-columns: repeat(1, 1fr); + } + + .az-ranking-deck--tablet-2 { + grid-template-columns: repeat(2, 1fr); + } + + .az-ranking-deck--tablet-3 { + grid-template-columns: repeat(3, 1fr); + } + + .az-ranking-deck--tablet-4 { + grid-template-columns: repeat(4, 1fr); + } +} + +/* Desktop. */ +@media (min-width: 992px) { + .az-ranking-deck--desktop-1 { + grid-template-columns: repeat(1, 1fr); + } + + .az-ranking-deck--desktop-2 { + grid-template-columns: repeat(2, 1fr); + } + + .az-ranking-deck--desktop-3 { + grid-template-columns: repeat(3, 1fr); + } + + .az-ranking-deck--desktop-4 { + grid-template-columns: repeat(4, 1fr); + } +} diff --git a/components/ranking-deck/ranking-deck.twig b/components/ranking-deck/ranking-deck.twig new file mode 100644 index 0000000000..6694117e1c --- /dev/null +++ b/components/ranking-deck/ranking-deck.twig @@ -0,0 +1,43 @@ +{# +/** + * @file + * Template for the az_quickstart Ranking Deck component. + * + * Lays out Ranking cards in a responsive CSS grid with consistent spacing. + * + * Props: + * - columns_desktop: Rankings per row on desktop (1-4). Defaults to 4. + * - columns_tablet: Rankings per row on tablet (1-4). Defaults to 1. + * - columns_phone: Rankings per row on phone (1-4). Defaults to 1. + * - utility_classes: Additional Bootstrap utility classes for the deck + * wrapper (e.g. bottom spacing). + * + * Slots: + * - rankings: The Ranking cards (or other content) placed in the grid. + */ +#} +{% set attributes = attributes|default(create_attribute()) %} + +{# Runtime guards: coerce invalid or missing values to safe defaults. #} +{% set allowed = ['1', '2', '3', '4'] %} +{% set columns_desktop = columns_desktop|default('4') in allowed ? columns_desktop|default('4') : '4' %} +{% set columns_tablet = columns_tablet|default('1') in allowed ? columns_tablet|default('1') : '1' %} +{% set columns_phone = columns_phone|default('1') in allowed ? columns_phone|default('1') : '1' %} +{% set utility_classes = utility_classes|default([]) %} +{% if utility_classes is not iterable %} + {% set utility_classes = [utility_classes] %} +{% endif %} + +{% set deck_classes = [ + 'az-ranking-deck', + 'az-ranking-deck--desktop-' ~ columns_desktop, + 'az-ranking-deck--tablet-' ~ columns_tablet, + 'az-ranking-deck--phone-' ~ columns_phone, +] %} +{% if utility_classes %} + {% set deck_classes = deck_classes|merge(utility_classes) %} +{% endif %} + + + {{ rankings }} + diff --git a/components/ranking/ranking.component.yml b/components/ranking/ranking.component.yml new file mode 100644 index 0000000000..af8bcf2470 --- /dev/null +++ b/components/ranking/ranking.component.yml @@ -0,0 +1,182 @@ +$schema: https://git.drupalcode.org/project/drupal/-/raw/HEAD/core/assets/schemas/v1/metadata.schema.json +name: Ranking +status: experimental +description: A University of Arizona ranking card with configurable background, hover, and link behavior. For an image card, use the Image component instead (drop it into a Ranking Deck alongside Ranking cards). +props: + type: object + properties: + heading: + type: string + title: Heading + description: Main ranking value. + examples: + - TOP 1% + description: + type: string + title: Description + description: Supporting line displayed below the heading. + examples: + - of World Universities + source: + type: string + title: Source + description: Source or attribution displayed at the bottom of the card. Line breaks are preserved. + examples: + - Center for World University Rankings, 2025 + heading_level: + type: string + title: Heading Level + description: Semantic heading tag for the ranking value. + enum: + - h2 + - h3 + - h4 + - h5 + - h6 + meta:enum: + h2: H2 + h3: H3 (default) + h4: H4 + h5: H5 + h6: H6 + examples: + - h3 + header_style: + type: string + title: Header Style + description: Bold or thin lettering for the heading. + enum: + - bold + - thin + meta:enum: + bold: Bold headers (default) + thin: Thin headers + examples: + - bold + alignment: + type: string + title: Content Alignment + description: Aligns the card content left or centered. + enum: + - left + - center + meta:enum: + left: Left aligned (default) + center: Center aligned + examples: + - left + clickable: + type: boolean + title: Clickable + description: Make the whole card a link to the link URL. + examples: + - true + hover_effect: + type: boolean + title: Hover Effect + description: Contrasting hover colors. Only applies when the card is clickable. + examples: + - true + hover_background: + type: string + title: Background Color (when Hover Effect is On) + description: Background color used when the hover effect is active. + enum: + - chili + - blue + - sky + - cool-gray + - oasis + meta:enum: + chili: Chili (default) + blue: Arizona Blue + sky: Sky + cool-gray: Cool Gray + oasis: Oasis + examples: + - chili + background: + type: string + title: Background Color (when Hover Effect is Off) + description: Card background color. Ignored when the hover effect is active. + enum: + - chili + - blue + - sky + - oasis + - azurite + - cool-gray + - warm-gray + - white + - transparent + meta:enum: + chili: Chili (default) + blue: Arizona Blue + sky: Sky + oasis: Oasis + azurite: Azurite + cool-gray: Cool Gray + warm-gray: Warm Gray + white: White + transparent: Transparent + examples: + - chili + font_color: + type: string + title: Font Color (transparent background only) + description: Text color used when the background is transparent. Ignored otherwise. + enum: + - midnight + - black + - white + - az-blue + meta:enum: + midnight: Midnight (default) + black: Black + white: White + az-blue: Arizona Blue + examples: + - midnight + link_url: + type: string + title: Link URL + description: Destination for the card link or the styled link. + format: uri-reference + examples: + - /about/rankings + link_title: + type: string + title: Link Title + description: Link text when the card is not clickable. Falls back to the source text. + examples: + - See all rankings + link_style: + type: string + title: Link Style + description: Style of the link. Ignored when the card is clickable with the hover effect. + enum: + - hidden + - text-link + - btn-red + - btn-blue + - btn-outline-red + - btn-outline-blue + - btn-outline-white + meta:enum: + hidden: Hidden link title + text-link: Text link + btn-red: Red button (default) + btn-blue: Blue button + btn-outline-red: Red outline button + btn-outline-blue: Blue outline button + btn-outline-white: White outline button + examples: + - btn-red + utility_classes: + type: array + title: Utility Classes + description: Additional Bootstrap utility classes for the card root, e.g. bottom spacing (mb-0) before the next item on the page. Not needed when the card is placed inside a Ranking Deck. + items: + type: string + examples: + - - mb-0 diff --git a/components/ranking/ranking.css b/components/ranking/ranking.css new file mode 100644 index 0000000000..b62783f2f1 --- /dev/null +++ b/components/ranking/ranking.css @@ -0,0 +1,88 @@ +/** + * Ranking component. + * + * Ported from modules/custom/az_ranking/css/az-ranking.css and + * css/az-ranking-image.css with namespaced selectors so the component can + * coexist with the legacy paragraph styles until the paragraph template + * delegates to this component. Color tokens come from Arizona Bootstrap. + */ + +.az-ranking-sdc { + position: relative; + /* Small viewports. */ + min-height: 190px; + /* Overrides the .card default (--bs-border-radius, 0.375rem) to match the + Ranking Card component (components/ranking-card in az_storybook) for + visual consistency across the two ranking designs. Neither the legacy + az_ranking module nor this port ever set this deliberately before; the + mockup calls for 1rem. */ + border-radius: 1rem; +} + +/* Medium viewports. */ +@media (min-width: 768px) { + .az-ranking-sdc { + min-height: 230px; + } +} + +/* Large viewports. */ +@media (min-width: 992px) { + .az-ranking-sdc { + min-height: 260px; + } +} + +/* Keep visually-hidden link titles in flow so stretched-link covers the card. */ +.az-ranking-sdc .card-body .visually-hidden { + display: block; + position: static !important; +} + +/* + * Preset hover colors, paired to the hover background color. + * !important is required to out-rank the text-bg-* utility colors. + */ +.text-bg-chili.az-ranking-sdc--bold-hover:hover * { + background-color: var(--bs-white) !important; + color: RGBA(var(--bs-chili-rgb), var(--bs-bg-opacity, 1)) !important; +} + +.text-bg-blue.az-ranking-sdc--bold-hover:hover * { + background-color: var(--bs-white) !important; + color: RGBA(var(--bs-blue-rgb), var(--bs-bg-opacity, 1)) !important; +} + +.bg-sky.az-ranking-sdc--bold-hover:hover * { + background-color: RGBA(var(--bs-blue-rgb), var(--bs-bg-opacity, 1)) !important; + color: RGBA(var(--bs-sky-rgb), var(--bs-bg-opacity, 1)) !important; +} + +.bg-cool-gray.az-ranking-sdc--bold-hover:hover * { + background-color: RGBA(var(--bs-azurite-rgb), var(--bs-bg-opacity, 1)) !important; + color: RGBA(var(--bs-cool-gray-rgb), var(--bs-bg-opacity, 1)) !important; +} + +.bg-oasis.az-ranking-sdc--bold-hover:hover * { + background-color: RGBA(var(--bs-midnight-rgb), var(--bs-bg-opacity, 1)) !important; + color: RGBA(var(--bs-oasis-rgb), var(--bs-bg-opacity, 1)) !important; +} + +/* + * Transparent-background font color overrides. Links keep their own colors. + */ +.az-ranking-sdc--text-white *:not(a) { + color: var(--bs-white) !important; +} + +.az-ranking-sdc--text-black *:not(a) { + color: var(--bs-black) !important; +} + +.az-ranking-sdc--text-az-blue *:not(a) { + color: RGBA(var(--bs-blue-rgb), var(--bs-bg-opacity, 1)); +} + +.az-ranking-sdc--text-midnight *:not(a) { + color: RGBA(var(--bs-midnight-rgb), var(--bs-bg-opacity, 1)) !important; +} diff --git a/components/ranking/ranking.twig b/components/ranking/ranking.twig new file mode 100644 index 0000000000..93d2e4a192 --- /dev/null +++ b/components/ranking/ranking.twig @@ -0,0 +1,154 @@ +{# +/** + * @file + * Template for the az_quickstart Ranking component. + * + * For an image card, use the Image component instead (place it in a Ranking + * Deck alongside Ranking cards — see components/image). + * + * Props: + * - heading: Main ranking value. + * - description: Supporting line displayed below the heading. + * - source: Attribution text; newlines are preserved. + * - heading_level: Heading tag, h2-h6. Defaults to h3. + * - header_style: bold | thin. Defaults to bold. + * - alignment: left | center. Defaults to left. + * - background: chili | blue | sky | oasis | azurite | cool-gray | warm-gray | + * white | transparent. Defaults to chili. Ignored when hover effect is active. + * - font_color: midnight | black | white | az-blue. Only used when background + * is transparent. Defaults to midnight. + * - clickable: Whole card links to link_url. + * - hover_effect: Contrasting hover colors; only honored when clickable. + * - hover_background: chili | blue | sky | cool-gray | oasis. Defaults to chili. + * - link_url: Link destination. + * - link_title: Link text when the card is not clickable; falls back to source. + * - link_style: hidden | text-link | btn-red | btn-blue | btn-outline-red | + * btn-outline-blue | btn-outline-white. Defaults to btn-red. Ignored when + * the card is clickable with the hover effect. + * - utility_classes: Additional Bootstrap utility classes for the card root + * (e.g. bottom spacing). Not needed when the card is placed inside a + * Ranking Deck. + */ +#} +{% set attributes = attributes|default(create_attribute()) %} + +{# Runtime guards: coerce invalid or missing values to safe defaults. #} +{% set heading_level = heading_level|default('h3') %} +{% if heading_level not in ['h2', 'h3', 'h4', 'h5', 'h6'] %} + {% set heading_level = 'h3' %} +{% endif %} +{% set header_style = header_style|default('bold') in ['bold', 'thin'] ? header_style|default('bold') : 'bold' %} +{% set alignment = alignment|default('left') in ['left', 'center'] ? alignment|default('left') : 'left' %} + +{% set background_classes = { + 'chili': 'text-bg-chili', + 'blue': 'text-bg-blue', + 'sky': 'bg-sky', + 'oasis': 'bg-oasis', + 'azurite': 'text-bg-azurite', + 'cool-gray': 'bg-cool-gray', + 'warm-gray': 'bg-warm-gray', + 'white': 'bg-white', + 'transparent': 'bg-transparent', +} %} +{% set background = background|default('chili') in background_classes|keys ? background|default('chili') : 'chili' %} + +{% set font_color = font_color|default('midnight') in ['midnight', 'black', 'white', 'az-blue'] ? font_color|default('midnight') : 'midnight' %} + +{% set clickable = clickable|default(false) %} +{# Hover effect requires a clickable card. #} +{% set hover_effect = clickable ? hover_effect|default(false) : false %} +{% set hover_background = hover_background|default('chili') in ['chili', 'blue', 'sky', 'cool-gray', 'oasis'] ? hover_background|default('chili') : 'chili' %} + +{% set link_style_classes = { + 'hidden': 'visually-hidden', + 'text-link': 'link mt-2', + 'btn-red': 'w-100 btn btn-red mt-2', + 'btn-blue': 'w-100 btn btn-blue mt-2', + 'btn-outline-red': 'w-100 btn btn-outline-red mt-2', + 'btn-outline-blue': 'w-100 btn btn-outline-blue mt-2', + 'btn-outline-white': 'w-100 btn btn-outline-white mt-2', +} %} +{% set link_style = link_style|default('btn-red') in link_style_classes|keys ? link_style|default('btn-red') : 'btn-red' %} +{% set link_classes = link_style_classes[link_style] %} +{# Text links need a darker color on light oasis/sky backgrounds. #} +{% if link_style == 'text-link' and background in ['oasis', 'sky'] %} + {% set link_classes = link_classes ~ ' text-midnight' %} +{% endif %} + +{% set utility_classes = utility_classes|default([]) %} +{% if utility_classes is not iterable %} + {% set utility_classes = [utility_classes] %} +{% endif %} + +{# Text contrast overrides for light backgrounds. #} +{% set text_overrides = { + 'sky': 'text-midnight', + 'cool-gray': 'text-azurite', + 'warm-gray': 'text-midnight', + 'white': 'text-midnight', + 'oasis': 'text-midnight', +} %} +{% set hover_text_overrides = { + 'sky': 'text-midnight', + 'cool-gray': 'text-azurite', + 'oasis': 'text-midnight', +} %} +{% if hover_effect %} + {% set text_override = hover_text_overrides[hover_background]|default('') %} +{% else %} + {% set text_override = text_overrides[background]|default('') %} +{% endif %} + +{% set root_classes = ['az-ranking-sdc', 'card', 'h-100', 'border-0', 'overflow-hidden'] %} +{% set root_classes = root_classes|merge([alignment == 'center' ? 'text-center' : 'text-left']) %} +{% if hover_effect %} + {% set root_classes = root_classes|merge([background_classes[hover_background], 'az-ranking-sdc--bold-hover', 'hover']) %} +{% else %} + {% set root_classes = root_classes|merge([background_classes[background]]) %} + {% if background == 'transparent' %} + {% set root_classes = root_classes|merge(['az-ranking-sdc--text-' ~ font_color]) %} + {% endif %} + {% if clickable and link_url|default('') %} + {% set root_classes = root_classes|merge(['az-ranking-sdc--with-link', 'hover']) %} + {% endif %} +{% endif %} +{% if clickable %} + {% set root_classes = root_classes|merge(['shadow']) %} +{% endif %} +{% if utility_classes %} + {% set root_classes = root_classes|merge(utility_classes) %} +{% endif %} + + +
+
+
+ {% if heading|default('') %} + <{{ heading_level }} class="display-4 m-0 az-ranking-sdc__heading{{ header_style == 'bold' ? ' fw-bolder' : '' }}{{ text_override ? ' ' ~ text_override : '' }}"> + {% if clickable %} + {{ heading }} + {% else %} + {{ heading }} + {% endif %} + + {% endif %} + {% if description|default('') %} +

{{ description }}

+ {% endif %} +
+ {% if source|default('') %} +
+ {{ source|nl2br }} +
+ {% endif %} +
+ {% if link_url|default('') %} + {% if hover_effect %} + + {% else %} + {{ link_title|default(source|default('')) }} + {% endif %} + {% endif %} +
+ diff --git a/composer.json b/composer.json index 1cd9264e9d..62ccd5efe0 100644 --- a/composer.json +++ b/composer.json @@ -44,6 +44,7 @@ "drupal/bootstrap_barrio": "5.5.20", "drupal/bootstrap_utilities": "3.0.1", "drupal/calendar_link": "3.0.4", + "drupal/canvas": "^1.8", "drupal/captcha": "2.0.10", "drupal/cas": "3.1.0", "drupal/chosen": "5.0.6", From aae1e131c1b9856a33260b7d450de1c262945876 Mon Sep 17 00:00:00 2001 From: Kevin Lu <79181817+kevdevlu@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:23:14 -0700 Subject: [PATCH 02/21] Wire az_ranking paragraph rendering through the new SDCs (#5813) AZRankingDefaultFormatter now renders through az_quickstart:ranking, az_quickstart:image, and az_quickstart:ranking-deck instead of the legacy #theme => az_ranking render array, so paragraph-authored and Canvas-composed rankings share the same markup. Legacy stored values (backgrounds, link styles, per-breakpoint column widths) are mapped to SDC prop tokens via new class-constant lookup tables; link resolution keeps legacy's exact three-branch logic, gated on the raw stored link_uri so placeholder '#' links keep their button. AZRankingWidget's own edit-form preview (#theme => az_ranking) is untouched, since it's a separate, still-legacy code path. image's width_span became three per-breakpoint props (desktop/tablet/phone), replacing a single dynamic value, because CSS Grid has no way to clamp a span against its container's actual column count (w3c/csswg-drafts#5852) - the formatter now clamps each breakpoint's span against the sibling ranking-deck's actual configured columns in PHP instead. ranking-deck and image also get new default columns/spans (1/2/4 and 1/2/2 phone/tablet/ desktop) for standalone Canvas placement. ranking's source prop gains a pattern making it Canvas-editable as a multi-line textarea, matching legacy's line-break-controlled attribution text; ranking.twig's existing nl2br rendering needed no change. --- components/image/image.component.yml | 38 +- components/image/image.css | 68 ++- components/image/image.twig | 27 +- .../ranking-deck/ranking-deck.component.yml | 6 +- components/ranking-deck/ranking-deck.twig | 4 +- components/ranking/ranking.component.yml | 3 +- .../az_ranking/src/AZRankingImageHelper.php | 34 ++ .../AZRankingDefaultFormatter.php | 495 +++++++++--------- 8 files changed, 389 insertions(+), 286 deletions(-) diff --git a/components/image/image.component.yml b/components/image/image.component.yml index 9b1d94cd5e..504174c614 100644 --- a/components/image/image.component.yml +++ b/components/image/image.component.yml @@ -27,10 +27,10 @@ props: description: Describes the image for screen readers and other assistive technology. Ignored when Decorative is enabled. examples: - Canyon Running - width_span: + width_span_desktop: type: string - title: Width Span - description: How many grid columns this image spans when placed inside a Ranking Deck (or any other CSS grid layout). Has no effect outside a grid layout. + title: Width Span (Desktop) + description: How many grid columns this image spans on desktop when placed inside a Ranking Deck (or any other CSS grid layout). Has no effect outside a grid layout. Keep at or below the deck's "Rankings per row on desktop" setting, or the image will overflow into extra columns. enum: - '1' - '2' @@ -43,6 +43,38 @@ props: '4': 4 columns examples: - '2' + width_span_tablet: + type: string + title: Width Span (Tablet) + description: How many grid columns this image spans on tablet. Keep at or below the deck's "Rankings per row on tablet" setting, or the image will overflow into extra columns. + enum: + - '1' + - '2' + - '3' + - '4' + meta:enum: + '1': 1 column + '2': 2 columns (default) + '3': 3 columns + '4': 4 columns + examples: + - '2' + width_span_phone: + type: string + title: Width Span (Phone) + description: How many grid columns this image spans on phone. Keep at or below the deck's "Rankings per row on phone" setting, or the image will overflow into extra columns. + enum: + - '1' + - '2' + - '3' + - '4' + meta:enum: + '1': 1 column (default) + '2': 2 columns + '3': 3 columns + '4': 4 columns + examples: + - '1' utility_classes: type: array title: Utility Classes diff --git a/components/image/image.css b/components/image/image.css index 4a3c6b9c94..7510da9bbe 100644 --- a/components/image/image.css +++ b/components/image/image.css @@ -4,14 +4,23 @@ * A layout wrapper around a media-library-backed . `grid-column: span N` * only has an effect when this component is placed inside a CSS grid * container (for example, the Ranking Deck component) — it is inert - * everywhere else, so the same width_span prop works whether the image is + * everywhere else, so the same width_span_* props work whether the image is * placed inside a deck or standalone on a page. * - * Known limitation: if width_span is set wider than the number of columns - * the surrounding grid currently has (e.g. span 4 inside a 2-column - * Ranking Deck), the browser may create extra implicit grid tracks rather - * than clamping — this has not been visually verified. Editors should - * pick a span that fits the deck's current per-row setting. + * Width span is deliberately THREE separate props (desktop/tablet/phone), + * not one dynamic value, and this is not a stylistic choice — CSS Grid has + * no way for a grid item to clamp its own span against its container's + * actual column count. That was confirmed to be a genuine, still-open CSS + * Working Group spec gap, not a browser-support question: + * https://github.com/w3c/csswg-drafts/issues/5852 ("Ability to clamp track + * spanning"). A grid item whose span exceeds its container's explicit + * track count gets an IMPLICIT extra track instead of clamping, which also + * squeezes every sibling card in that row, not just the image. + * + * Each width_span_* prop defaults to Ranking Deck's own matching default + * column count (desktop 2, tablet 2, phone 1), so an image is always safe + * out of the box. Editors who configure a deck with more columns at a given + * breakpoint can explicitly raise that breakpoint's span to match. */ .az-image { @@ -53,18 +62,57 @@ object-fit: cover; } -.az-image--span-1 { +/* Phone (base). */ +.az-image--span-phone-1 { grid-column: span 1; } -.az-image--span-2 { +.az-image--span-phone-2 { grid-column: span 2; } -.az-image--span-3 { +.az-image--span-phone-3 { grid-column: span 3; } -.az-image--span-4 { +.az-image--span-phone-4 { grid-column: span 4; } + +/* Tablet. */ +@media (min-width: 768px) { + .az-image--span-tablet-1 { + grid-column: span 1; + } + + .az-image--span-tablet-2 { + grid-column: span 2; + } + + .az-image--span-tablet-3 { + grid-column: span 3; + } + + .az-image--span-tablet-4 { + grid-column: span 4; + } +} + +/* Desktop. */ +@media (min-width: 992px) { + .az-image--span-desktop-1 { + grid-column: span 1; + } + + .az-image--span-desktop-2 { + grid-column: span 2; + } + + .az-image--span-desktop-3 { + grid-column: span 3; + } + + .az-image--span-desktop-4 { + grid-column: span 4; + } +} diff --git a/components/image/image.twig b/components/image/image.twig index 0952c0efdf..2cf8f2c542 100644 --- a/components/image/image.twig +++ b/components/image/image.twig @@ -18,9 +18,18 @@ * - decorative: When true, forces empty alt text AND hides the image from * assistive technology via aria-hidden. Defaults to false. * - alt: Alternative text. Ignored when decorative is true. - * - width_span: 1-4. Grid columns this image spans when placed inside a - * Ranking Deck (or any CSS grid layout). No effect outside a grid - * context. Defaults to 2. + * - width_span_desktop / width_span_tablet / width_span_phone: 1-4 each. + * Grid columns this image spans at each breakpoint when placed inside a + * Ranking Deck (or any CSS grid layout) — deliberately per-breakpoint, + * not a single dynamic value, because CSS Grid has no way for a grid + * item to clamp its own span against its container's actual column + * count (a confirmed, still-open CSS spec gap, not a browser-support + * issue: https://github.com/w3c/csswg-drafts/issues/5852). No effect + * outside a grid context. Defaults: desktop 2, tablet 2, phone 1 — + * matching Ranking Deck's own default columns, so an image is always + * safe out of the box. Keep each at or below the deck's matching + * "Rankings per row" setting, or the image will overflow into extra + * columns at that breakpoint. * - utility_classes: Additional Bootstrap utility classes for the wrapper * (e.g. bottom spacing). Not needed when placed inside a Ranking Deck. */ @@ -30,14 +39,22 @@ {% set decorative = decorative|default(false) %} {% set alt = decorative ? '' : alt|default('') %} -{% set width_span = width_span|default('2') in ['1', '2', '3', '4'] ? width_span|default('2') : '2' %} +{% set allowed_spans = ['1', '2', '3', '4'] %} +{% set width_span_desktop = width_span_desktop|default('2') in allowed_spans ? width_span_desktop|default('2') : '2' %} +{% set width_span_tablet = width_span_tablet|default('2') in allowed_spans ? width_span_tablet|default('2') : '2' %} +{% set width_span_phone = width_span_phone|default('1') in allowed_spans ? width_span_phone|default('1') : '1' %} {% set utility_classes = utility_classes|default([]) %} {% if utility_classes is not iterable %} {% set utility_classes = [utility_classes] %} {% endif %} -{% set root_classes = ['az-image', 'az-image--span-' ~ width_span] %} +{% set root_classes = [ + 'az-image', + 'az-image--span-desktop-' ~ width_span_desktop, + 'az-image--span-tablet-' ~ width_span_tablet, + 'az-image--span-phone-' ~ width_span_phone, +] %} {% if utility_classes %} {% set root_classes = root_classes|merge(utility_classes) %} {% endif %} diff --git a/components/ranking-deck/ranking-deck.component.yml b/components/ranking-deck/ranking-deck.component.yml index 5f0cf7312f..026b25a214 100644 --- a/components/ranking-deck/ranking-deck.component.yml +++ b/components/ranking-deck/ranking-deck.component.yml @@ -31,12 +31,12 @@ props: - '3' - '4' meta:enum: - '1': 1 (default) - '2': '2' + '1': '1' + '2': 2 (default) '3': '3' '4': '4' examples: - - '1' + - '2' columns_phone: type: string title: Rankings per row on phone diff --git a/components/ranking-deck/ranking-deck.twig b/components/ranking-deck/ranking-deck.twig index 6694117e1c..3675aad463 100644 --- a/components/ranking-deck/ranking-deck.twig +++ b/components/ranking-deck/ranking-deck.twig @@ -7,7 +7,7 @@ * * Props: * - columns_desktop: Rankings per row on desktop (1-4). Defaults to 4. - * - columns_tablet: Rankings per row on tablet (1-4). Defaults to 1. + * - columns_tablet: Rankings per row on tablet (1-4). Defaults to 2. * - columns_phone: Rankings per row on phone (1-4). Defaults to 1. * - utility_classes: Additional Bootstrap utility classes for the deck * wrapper (e.g. bottom spacing). @@ -21,7 +21,7 @@ {# Runtime guards: coerce invalid or missing values to safe defaults. #} {% set allowed = ['1', '2', '3', '4'] %} {% set columns_desktop = columns_desktop|default('4') in allowed ? columns_desktop|default('4') : '4' %} -{% set columns_tablet = columns_tablet|default('1') in allowed ? columns_tablet|default('1') : '1' %} +{% set columns_tablet = columns_tablet|default('2') in allowed ? columns_tablet|default('2') : '2' %} {% set columns_phone = columns_phone|default('1') in allowed ? columns_phone|default('1') : '1' %} {% set utility_classes = utility_classes|default([]) %} {% if utility_classes is not iterable %} diff --git a/components/ranking/ranking.component.yml b/components/ranking/ranking.component.yml index af8bcf2470..dd11c335d8 100644 --- a/components/ranking/ranking.component.yml +++ b/components/ranking/ranking.component.yml @@ -19,8 +19,9 @@ props: - of World Universities source: type: string + pattern: (.|\r?\n)* title: Source - description: Source or attribution displayed at the bottom of the card. Line breaks are preserved. + description: Source or attribution displayed at the bottom of the card. Use a line break to control exactly where the text wraps, e.g. between "Times Higher Education" and "World University Rankings, 2026". examples: - Center for World University Rankings, 2025 heading_level: diff --git a/modules/custom/az_ranking/src/AZRankingImageHelper.php b/modules/custom/az_ranking/src/AZRankingImageHelper.php index f03364dc46..ac5ee95a2f 100644 --- a/modules/custom/az_ranking/src/AZRankingImageHelper.php +++ b/modules/custom/az_ranking/src/AZRankingImageHelper.php @@ -116,4 +116,38 @@ public function generateImageRenderArray(MediaInterface $media) { return $media_render_array; } + /** + * Get a plain file URI and alt text for the az_quickstart:image SDC. + * + * Unlike generateImageRenderArray(), this does not apply the + * az_ranking_responsive image style or add focal-point positioning data — + * az_quickstart:image takes a plain file URI as a prop, not a themed + * render array, so image style processing and focal-point-aware cropping + * are not available through this path. + * + * @param \Drupal\media\MediaInterface $media + * A Drupal media entity object. + * + * @return array + * An array with 'src' (a public:// URI, or an empty string if the media + * has no image) and 'alt' keys. + */ + public function getImageSourceAndAlt(MediaInterface $media): array { + $media_attributes = $media->get('field_media_az_image')->getValue(); + + if (empty($media_attributes[0]['target_id'])) { + return ['src' => '', 'alt' => '']; + } + + $file = $this->entityTypeManager->getStorage('file')->load($media_attributes[0]['target_id']); + if (!$file) { + return ['src' => '', 'alt' => '']; + } + + return [ + 'src' => $file->getFileUri(), + 'alt' => $media_attributes[0]['alt'] ?? '', + ]; + } + } diff --git a/modules/custom/az_ranking/src/Plugin/Field/FieldFormatter/AZRankingDefaultFormatter.php b/modules/custom/az_ranking/src/Plugin/Field/FieldFormatter/AZRankingDefaultFormatter.php index 7840f871bc..36cf077d40 100644 --- a/modules/custom/az_ranking/src/Plugin/Field/FieldFormatter/AZRankingDefaultFormatter.php +++ b/modules/custom/az_ranking/src/Plugin/Field/FieldFormatter/AZRankingDefaultFormatter.php @@ -16,6 +16,12 @@ /** * Plugin implementation of the 'az_ranking_default' formatter. + * + * Renders the field through the az_quickstart:ranking, az_quickstart:image, + * and az_quickstart:ranking-deck Single Directory Components, so paragraph- + * authored rankings and Canvas-composed rankings share the same markup. + * + * @see https://github.com/az-digital/az_quickstart/issues/5813 */ #[FieldFormatter( id: 'az_ranking_default', @@ -47,6 +53,66 @@ class AZRankingDefaultFormatter extends FormatterBase implements ContainerFactor */ protected $pathValidator; + /** + * Legacy background/hover-background select values, keyed to SDC tokens. + */ + const BACKGROUND_CLASS_MAP = [ + 'text-bg-chili' => 'chili', + 'text-bg-blue' => 'blue', + 'bg-sky' => 'sky', + 'bg-oasis' => 'oasis', + 'text-bg-azurite' => 'azurite', + 'bg-cool-gray' => 'cool-gray', + 'bg-warm-gray' => 'warm-gray', + 'bg-white' => 'white', + 'bg-transparent' => 'transparent', + ]; + + /** + * Legacy font color select values, keyed to SDC tokens. + */ + const FONT_COLOR_CLASS_MAP = [ + 'ranking-text-midnight' => 'midnight', + 'ranking-text-black' => 'black', + 'ranking-text-white' => 'white', + 'ranking-text-az-blue' => 'az-blue', + ]; + + /** + * Legacy link style select values, keyed to SDC tokens. + */ + const LINK_STYLE_CLASS_MAP = [ + 'visually-hidden' => 'hidden', + 'link mt-2' => 'text-link', + 'w-100 btn btn-red mt-2' => 'btn-red', + 'w-100 btn btn-blue mt-2' => 'btn-blue', + 'w-100 btn btn-outline-red mt-2' => 'btn-outline-red', + 'w-100 btn btn-outline-blue mt-2' => 'btn-outline-blue', + 'w-100 btn btn-outline-white mt-2' => 'btn-outline-white', + ]; + + /** + * Legacy per-breakpoint Bootstrap column classes, keyed to column counts. + */ + const DESKTOP_COLUMN_MAP = [ + 'col-lg-12' => '1', + 'col-lg-6' => '2', + 'col-lg-4' => '3', + 'col-lg-3' => '4', + ]; + const TABLET_COLUMN_MAP = [ + 'col-md-12' => '1', + 'col-md-6' => '2', + 'col-md-4' => '3', + 'col-md-3' => '4', + ]; + const PHONE_COLUMN_MAP = [ + 'col-12' => '1', + 'col-6' => '2', + 'col-4' => '3', + 'col-3' => '4', + ]; + /** * {@inheritdoc} */ @@ -104,282 +170,187 @@ public function settingsSummary() { * {@inheritdoc} */ public function viewElements(FieldItemListInterface $items, $langcode) { - $settings = $this->getSettings(); - $element = []; + $rankings = []; + + // Computed before the loop (not after, as an earlier version of this + // method did) because buildImageComponent() needs the deck's actual + // per-breakpoint column counts to clamp each image's width_span_* props + // against them - see that method's docblock for why this matters. + $deck_props = []; + $parent = $items->getEntity(); + if ($parent instanceof ParagraphInterface) { + $behavior_settings = $parent->getAllBehaviorSettings(); + $deck_props = $this->buildDeckProps($behavior_settings['az_rankings_paragraph_behavior'] ?? []); + } - foreach ($items as $delta => $item) { + foreach ($items as $item) { assert($item instanceof AZRankingItem); + $ranking_type = $item->options['ranking_type'] ?? 'standard'; + $rankings[] = $ranking_type === 'image_only' + ? $this->buildImageComponent($item, $deck_props) + : $this->buildRankingComponent($item); + } - // Format title. - $ranking_heading = $item->ranking_heading ?? ''; - $ranking_description = $item->ranking_description ?? ''; + return [ + 0 => [ + '#type' => 'component', + '#component' => 'az_quickstart:ranking-deck', + '#props' => $deck_props, + '#slots' => ['rankings' => $rankings], + ], + ]; + } - $attached = []; - $attached['library'][] = 'az_ranking/az_ranking'; + /** + * Builds an az_quickstart:ranking component render array for one item. + * + * Props like clickable/hover_effect/link_style interactions (e.g. link + * title and style being ignored while clickable) are NOT resolved here — + * ranking.twig's own guards are the single source of truth for that + * behavior, so this only needs to map field/behavior values onto clean + * prop values. + */ + protected function buildRankingComponent(AZRankingItem $item): array { + $props = [ + 'heading' => $item->ranking_heading ?? '', + 'description' => $item->ranking_description ?? '', + 'source' => $item->ranking_source ?? '', + ]; - // Media. - $column_span = $item->options['column_span'] ?? ''; - $media_render_array = []; - if (!empty($item->media)) { - if ($media = $this->entityTypeManager->getStorage('media')->load($item->media)) { - $media_render_array = $this->rankingImageHelper->generateImageRenderArray($media); - $attached['library'][] = 'az_ranking/az_ranking_image'; - } - } + // Gate on the RAW stored link_uri, not the resolved URL string — a bare + // '#' (a common placeholder in demo content) is a real, present link + // that legacy always showed a button for, but Url::fromUserInput('#') + // legitimately stringifies to '' (confirmed empirically, not assumed). + // Checking the resolved string's emptiness instead of the source value + // silently dropped every ranking using such a placeholder link. + if (!empty($item->link_uri)) { + $props['link_url'] = $this->resolveLinkUrl($item->link_uri); + $props['link_title'] = $item->link_title ?? ''; + $props['link_style'] = self::LINK_STYLE_CLASS_MAP[$item->ranking_link_style ?? ''] ?? 'btn-red'; + } - // Define Ranking Variabbles. - $ranking_classes = 'ranking card'; - $ranking_clickable = FALSE; - $ranking_hover_effect = FALSE; - $ranking_source_classes = ''; - $ranking_font_color = ''; - $ranking_defaults = []; - $column_classes = []; - $column_classes[] = 'col-md-4 col-lg-4'; - $parent = $item->getEntity(); - - // Link and link style. - $link_render_array = []; - $link_url = ''; - $link_title = $item->link_title ?? ''; - $ranking_link_style = ''; - if ($item->link_uri) { - if (str_starts_with($item->link_uri ?? '', '/' . PublicStream::basePath())) { - // Link to public file: use fromUri() to get the URL. - $link_url = Url::fromUri(urldecode('base:' . $item->link_uri)); - } - else { - // Check if the link is an anchor within the current page. - if (str_starts_with($item->link_uri ?? '', "#")) { - $link_url = Url::fromUserInput($item->link_uri); - } - else { - $link_url = $this->pathValidator->getUrlIfValid($item->link_uri ?? ''); - } - } - $link_render_array = [ - '#type' => 'link', - '#title' => $link_title ?: ($item->ranking_source ?? ''), - '#url' => $link_url ?: Url::fromRoute(''), - '#attributes' => ['class' => ['']], - ]; - $ranking_link_style = $item->ranking_link_style; - // Link color override. - if (str_contains($ranking_link_style, 'link')) { - if (str_contains($item->options['class'], 'bg-oasis') || - str_contains($item->options['class'], 'bg-sky')) { - $ranking_link_style .= ' text-midnight'; - } - } - $link_render_array['#attributes']['class'] = explode(' ', $ranking_link_style); - if (empty($settings['interactive_links'])) { - $link_render_array['#attributes']['class'][] = 'az-ranking-no-follow'; - $attached['library'][] = 'az_ranking/az_ranking_no_follow'; - } - } + $parent = $item->getEntity(); + if ($parent instanceof ParagraphInterface) { + $behavior_settings = $parent->getAllBehaviorSettings(); + $ranking_defaults = $behavior_settings['az_rankings_paragraph_behavior'] ?? []; + $props['header_style'] = ($ranking_defaults['ranking_header_style'] ?? '') === 'ranking-title-thin' ? 'thin' : 'bold'; + $props['alignment'] = ($ranking_defaults['ranking_alignment'] ?? '') === 'text-center' ? 'center' : 'left'; + $props['clickable'] = !empty($ranking_defaults['ranking_clickable']); + $props['hover_effect'] = !empty($ranking_defaults['ranking_hover_effect']); + } - // Get settings from parent paragraph. - if ($parent instanceof ParagraphInterface) { - // Get the behavior settings for the parent. - $parent_config = $parent->getAllBehaviorSettings(); - // See if the parent behavior defines some ranking-specific settings. - if (!empty($parent_config['az_rankings_paragraph_behavior'])) { - $ranking_defaults = $parent_config['az_rankings_paragraph_behavior']; - - // Set ranking classes according to behavior settings. - $column_classes = []; - if (!empty($ranking_defaults['az_display_settings'])) { - $column_classes[] = $ranking_defaults['az_display_settings']['ranking_width_xs'] ?? 'col-6'; - $column_classes[] = $ranking_defaults['az_display_settings']['ranking_width_sm'] ?? 'col-md-4'; - } - $column_classes[] = $ranking_defaults['ranking_width'] ?? 'col-md-4 col-lg-3'; - $ranking_clickable = $ranking_defaults['ranking_clickable'] ?? FALSE; - $ranking_hover_effect = $ranking_defaults['ranking_hover_effect'] ?? FALSE; - if ($item->options['ranking_type'] !== 'image_only') { - $ranking_classes .= ' ' . ($ranking_defaults['ranking_alignment'] ?? 'text-left'); - } - // Calculate column classes for image based on column_span. - if ($item->options['ranking_type'] === 'image_only' && - !empty($item->options['column_span']) && - ($item->options['column_span'] != '')) { - - // Multiply column classes by column_span value. - $column_span_multiplier = (int) $item->options['column_span']; - if ($column_span_multiplier > 1) { - foreach ($column_classes as $key => $class_string) { - // Handle single classes AND space-separated multiple classes. - $classes = explode(' ', $class_string); - $multiplied_classes = []; - - foreach ($classes as $class) { - if (preg_match('/^col(-\w+)?-(\d+)$/', $class, $matches)) { - $prefix = $matches[1] ?? ''; - $current_width = (int) $matches[2]; - $new_width = min(12, $current_width * $column_span_multiplier); - $multiplied_classes[] = 'col' . $prefix . '-' . $new_width; - } - else { - // Keep non-column classes as-is. - $multiplied_classes[] = $class; - } - } - $column_classes[$key] = implode(' ', $multiplied_classes); - } - } - } - else { - $column_classes[] = $ranking_defaults['ranking_width'] ?? 'col-md-4 col-lg-4'; - } - - // Is the ranking clickable? - if ($ranking_clickable) { - // Whole card is clickable. - $ranking_classes .= ' shadow'; - if (!empty($link_render_array)) { - $link_render_array['#attributes']['class'][] = 'stretched-link'; - } - $link_title = ''; - $ranking_link_style = ''; - if (!empty($ranking_hover_effect)) { - // Add hover effect to ranking card. - if ($item->options['ranking_type'] !== 'image_only') { - $ranking_classes .= ' ranking-bold-hover hover'; - } - } - else { - // No hover effect but ranking is still clickable. - if (!empty($item->link_uri) && $item->options['ranking_type'] !== 'image_only') { - $ranking_classes .= ' ranking-with-link hover'; - } - } - } - // If ranking is not clickable. - else { - $link_title = $item->link_title ?? ''; - $ranking_link_style = $item->ranking_link_style ?? ''; - // Unset hover effect if not clickable. - $ranking_hover_effect = FALSE; - } - } - } - if (!str_contains($item->options['class'], 'bg-transparent')) { - // Add mt-auto class to source on all styles, except bg-transparent. - $ranking_source_classes = 'mt-auto'; - } - else { - $ranking_font_color = ' ' . $item->ranking_font_color; - $ranking_classes .= ' ' . $item->ranking_font_color . ' '; - } + $background_class = $item->options['class'] ?? ''; + $props['background'] = self::BACKGROUND_CLASS_MAP[$background_class] ?? 'chili'; + $props['hover_background'] = self::BACKGROUND_CLASS_MAP[$item->options['hover_class'] ?? ''] ?? 'chili'; + if ($background_class === 'bg-transparent') { + $props['font_color'] = self::FONT_COLOR_CLASS_MAP[$item->ranking_font_color ?? ''] ?? 'midnight'; + } - // Handle class keys that contained multiple classes. - $column_classes = implode(' ', $column_classes); - $column_classes = explode(' ', $column_classes); - $column_classes[] = 'pb-4'; - - // Hover effect takes precedence over non-hover-effect backgrounds. - if ($ranking_hover_effect) { - // Try to read hover-specific value from the item. - $hover_class = ''; - if (!empty($item->options['hover_class'])) { - $hover_class = $item->options['hover_class']; - } - // Fallback to persisted background class if no hover-specific value. - if (empty($hover_class) && !empty($item->options['class'])) { - $hover_class = $item->options['class']; - } - if (!empty($hover_class) && $item->options['ranking_type'] !== 'image_only') { - $ranking_classes .= ' ' . $hover_class; - } - } - // If ranking has no hover effect... - else { - if (!empty($item->options['class']) && $item->options['ranking_type'] !== 'image_only') { - $ranking_classes .= ' ' . $item->options['class']; - } - } + return [ + '#type' => 'component', + '#component' => 'az_quickstart:ranking', + '#props' => $props, + ]; + } - // Set custom text classes based on background color. - $text_color_override = ''; - if (!$ranking_hover_effect) { - if (!empty($item->options['class'])) { - switch (TRUE) { - case str_contains($item->options['class'], 'bg-sky'): - $text_color_override = 'text-midnight'; - break; - - case str_contains($item->options['class'], 'bg-cool-gray'): - $text_color_override = 'text-azurite'; - break; - - case str_contains($item->options['class'], 'bg-warm-gray'): - $text_color_override = 'text-midnight'; - break; - - case str_contains($item->options['class'], 'bg-white'): - $text_color_override = 'text-midnight'; - break; - - case str_contains($item->options['class'], 'bg-oasis'): - $text_color_override = 'text-midnight'; - break; - } - } - } - // Override hover class. - else { - if (!empty($item->options['hover_class'])) { - switch (TRUE) { - case str_contains($item->options['hover_class'], 'bg-sky'): - $text_color_override = 'text-midnight'; - break; - - case str_contains($item->options['hover_class'], 'bg-cool-gray'): - $text_color_override = 'text-azurite'; - break; - - case str_contains($item->options['hover_class'], 'bg-oasis'): - $text_color_override = 'text-midnight'; - break; - } + /** + * Builds an az_quickstart:image component render array for one item. + * + * Unlike the legacy #theme => image_formatter path, this does not apply + * the az_ranking_responsive image style or the custom focal-point JS + * positioning — az_quickstart:image takes a plain file URI, not a themed + * render array, and there is no field/prop for either capability. This is + * a known, disclosed gap versus the legacy image_only rendering, not an + * oversight. + * + * width_span_desktop/tablet/phone are computed here, not just passed + * through legacy's single column_span value, because CSS Grid cannot + * clamp a span against its container's actual column count (a confirmed + * CSS spec gap, not a browser quirk - see image.css's own docblock and + * https://github.com/w3c/csswg-drafts/issues/5852). This reproduces + * legacy's own "min(current row width, column_span)" behavior exactly, + * per breakpoint, using the SAME $deck_props the sibling ranking-deck + * component receives, so the clamp is always correct for whatever the + * paragraph is actually configured to - not a fixed, conservative cap. + * + * @param \Drupal\az_ranking\Plugin\Field\FieldType\AZRankingItem $item + * The field item to build a component for. + * @param array $deck_props + * The az_quickstart:ranking-deck props this item's parent deck will + * receive (columns_desktop/tablet/phone), from buildDeckProps(). + * + * @see \Drupal\az_ranking\AZRankingImageHelper::getImageSourceAndAlt() + */ + protected function buildImageComponent(AZRankingItem $item, array $deck_props): array { + $legacy_span = (int) ($item->options['column_span'] ?? 2); + $props = [ + 'width_span_desktop' => (string) min($legacy_span, (int) ($deck_props['columns_desktop'] ?? 4)), + 'width_span_tablet' => (string) min($legacy_span, (int) ($deck_props['columns_tablet'] ?? 1)), + 'width_span_phone' => (string) min($legacy_span, (int) ($deck_props['columns_phone'] ?? 1)), + ]; + + if (!empty($item->media)) { + $media = $this->entityTypeManager->getStorage('media')->load($item->media); + if ($media) { + $image_data = $this->rankingImageHelper->getImageSourceAndAlt($media); + if ($image_data['src'] !== '') { + $props['src'] = $image_data['src']; + $props['alt'] = $image_data['alt']; } } + } + + return [ + '#type' => 'component', + '#component' => 'az_quickstart:image', + '#props' => $props, + ]; + } + + /** + * Maps the parent paragraph's per-breakpoint column settings to deck props. + */ + protected function buildDeckProps(array $ranking_defaults): array { + $az_display_settings = $ranking_defaults['az_display_settings'] ?? []; + return [ + 'columns_desktop' => self::DESKTOP_COLUMN_MAP[$ranking_defaults['ranking_width'] ?? ''] ?? '4', + 'columns_tablet' => self::TABLET_COLUMN_MAP[$az_display_settings['ranking_width_sm'] ?? ''] ?? '1', + 'columns_phone' => self::PHONE_COLUMN_MAP[$az_display_settings['ranking_width_xs'] ?? ''] ?? '1', + ]; + } - $element[$delta] = [ - '#theme' => 'az_ranking', - '#media' => $media_render_array, - '#column_span' => $column_span, - '#ranking_heading' => $ranking_heading, - '#ranking_clickable' => $ranking_clickable, - '#ranking_hover_effect' => $ranking_hover_effect, - '#ranking_header_style' => $ranking_defaults['ranking_header_style'], - // The ProcessedText element handles cache context & tag bubbling. - // @see \Drupal\filter\Element\ProcessedText::preRenderText() - '#ranking_description' => $ranking_description, - '#ranking_source' => $item->ranking_source, - '#link' => $link_render_array, - '#link_url' => $link_url, - '#link_title' => $link_title, - '#ranking_link_style' => $ranking_link_style, - '#ranking_source_classes' => $ranking_source_classes, - '#ranking_font_color' => $ranking_font_color, - '#text_color_override' => $text_color_override, - '#attributes' => ['class' => $ranking_classes], - '#attached' => $attached, - ]; - - $element['#items'][$delta] = new \stdClass(); - $element['#items'][$delta]->_attributes = [ - 'class' => $column_classes, - ]; - - $element['#attributes']['class'][] = 'content'; - $element['#attributes']['class'][] = 'h-100'; - $element['#attributes']['class'][] = 'row'; - $element['#attributes']['class'][] = 'd-flex'; - $element['#attributes']['class'][] = 'flex-wrap'; + /** + * Resolves a stored link_uri value to a plain URL string, or ''. + * + * Mirrors the URL resolution the legacy formatter already performed + * (public file links, page anchors, and validated internal/external + * paths), only stringified for use as an SDC prop value instead of being + * kept as a Url object for a #type => link render array. + */ + protected function resolveLinkUrl(string $link_uri): string { + if ($link_uri === '') { + return ''; } - return $element; + if (str_starts_with($link_uri, '/' . PublicStream::basePath())) { + return Url::fromUri(urldecode('base:' . $link_uri))->toString(); + } + + if (str_starts_with($link_uri, '#')) { + // Url::fromUserInput('#') is valid but its ->toString() legitimately + // returns '' for a bare fragment (confirmed empirically) - preserve + // the literal anchor directly instead of losing it. A BARE '#' (no + // fragment name) is also rejected by the SDC prop's own + // format: uri-reference validation (confirmed empirically: '#top' + // passes, '#' alone does not) - normalize the empty-fragment case to + // a named one so common placeholder links ('#', used throughout demo + // content) don't fail validation. Same practical behavior (no real + // destination); only the literal href text differs from legacy's '#'. + return $link_uri === '#' ? '#top' : $link_uri; + } + + $url = $this->pathValidator->getUrlIfValid($link_uri); + return $url ? $url->toString() : ''; } } From d0054c6a81c0abee7e09a4e498a93aa2defce09d Mon Sep 17 00:00:00 2001 From: Kevin Lu <79181817+kevdevlu@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:52:45 -0700 Subject: [PATCH 03/21] Rename image SDC to ranking-image; restore WebP/scale image delivery The "Image" name was reserved for a future, more broadly-scoped component; renamed az_quickstart:image to az_quickstart:ranking-image (directory, component ID, name, CSS/JS) with no change to its actual capability - it's still a general-purpose, layout-aware image, not ranking-specific. ranking.component.yml/twig's own cross-references updated to match. Also restores WebP conversion and a size cap (lost when the legacy #theme => image_formatter path was replaced by the plain-URI SDC prop in an earlier commit): a new Drupal\az_media\Twig\ImageStyleTwigExtension adds an image_style Twig filter, applied in ranking-image.twig, so both the paragraph-authored and Canvas-placed pathways get it automatically regardless of who populates the src prop. The az_ranking_responsive image style itself moves from az_ranking to az_media (its name, upscale setting, and WebP conversion left untouched) so a future az_media-only Canvas site doesn't have to depend on az_ranking to get it - confirmed via ConfigManager::uninstall() that this was never a deletion-safety issue, just future-proofing. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QZ6mWfeVEr5qdPLXAEmtqP --- .../ranking-image.component.yml} | 40 ++++- .../ranking-image.css} | 34 ++--- components/ranking-image/ranking-image.js | 137 ++++++++++++++++++ .../ranking-image.twig} | 59 +++++++- components/ranking/ranking.component.yml | 2 +- components/ranking/ranking.twig | 4 +- modules/custom/az_media/az_media.services.yml | 7 + .../image.style.az_ranking_responsive.yml | 0 .../src/Twig/ImageStyleTwigExtension.php | 58 ++++++++ 9 files changed, 311 insertions(+), 30 deletions(-) rename components/{image/image.component.yml => ranking-image/ranking-image.component.yml} (66%) rename components/{image/image.css => ranking-image/ranking-image.css} (83%) create mode 100644 components/ranking-image/ranking-image.js rename components/{image/image.twig => ranking-image/ranking-image.twig} (51%) create mode 100644 modules/custom/az_media/az_media.services.yml rename modules/custom/{az_ranking => az_media}/config/install/image.style.az_ranking_responsive.yml (100%) create mode 100644 modules/custom/az_media/src/Twig/ImageStyleTwigExtension.php diff --git a/components/image/image.component.yml b/components/ranking-image/ranking-image.component.yml similarity index 66% rename from components/image/image.component.yml rename to components/ranking-image/ranking-image.component.yml index 504174c614..f03bed87dd 100644 --- a/components/image/image.component.yml +++ b/components/ranking-image/ranking-image.component.yml @@ -1,5 +1,5 @@ $schema: https://git.drupalcode.org/project/drupal/-/raw/HEAD/core/assets/schemas/v1/metadata.schema.json -name: Image +name: Ranking Image status: experimental description: A layout- and accessibility-aware image, backed by the media library. Works standalone on a page, or dropped into a Ranking Deck alongside Ranking cards. props: @@ -14,7 +14,7 @@ props: x-allowed-schemes: - public examples: - - public://canyon_running.jpg + - public://placeholder-1000x500.png decorative: type: boolean title: Decorative @@ -26,7 +26,7 @@ props: title: Alternative Text description: Describes the image for screen readers and other assistive technology. Ignored when Decorative is enabled. examples: - - Canyon Running + - Placeholder image, 1000 by 500 pixels width_span_desktop: type: string title: Width Span (Desktop) @@ -75,6 +75,36 @@ props: '4': 4 columns examples: - '1' + focal_x: + type: number + title: Focal Point X + description: Horizontal focal point, as a fraction of the image's width from the left (0 = left edge, 1 = right edge). Keeps this point visible when the image is cropped to fill its container. Leave unset for default centered cropping. + minimum: 0 + maximum: 1 + examples: + - 0.5 + focal_y: + type: number + title: Focal Point Y + description: Vertical focal point, as a fraction of the image's height from the top (0 = top edge, 1 = bottom edge). Keeps this point visible when the image is cropped to fill its container. Leave unset for default centered cropping. + minimum: 0 + maximum: 1 + examples: + - 0.5 + original_width: + type: integer + title: Original Width + description: The image's true width in pixels, before any cropping. Required alongside Focal Point X/Y for accurate focal-point cropping; ignored otherwise. + minimum: 1 + examples: + - 1000 + original_height: + type: integer + title: Original Height + description: The image's true height in pixels, before any cropping. Required alongside Focal Point X/Y for accurate focal-point cropping; ignored otherwise. + minimum: 1 + examples: + - 500 utility_classes: type: array title: Utility Classes @@ -83,3 +113,7 @@ props: type: string examples: - - mb-0 +libraryOverrides: + dependencies: + - core/drupal + - core/once diff --git a/components/image/image.css b/components/ranking-image/ranking-image.css similarity index 83% rename from components/image/image.css rename to components/ranking-image/ranking-image.css index 7510da9bbe..9c934b68fe 100644 --- a/components/image/image.css +++ b/components/ranking-image/ranking-image.css @@ -1,5 +1,5 @@ /** - * Image component. + * Ranking Image component. * * A layout wrapper around a media-library-backed . `grid-column: span N` * only has an effect when this component is placed inside a CSS grid @@ -23,7 +23,7 @@ * breakpoint can explicitly raise that breakpoint's span to match. */ -.az-image { +.az-ranking-image { position: relative; overflow: hidden; /* Matches .az-ranking-sdc's own responsive min-height (ranking.css) so an @@ -36,19 +36,19 @@ /* Medium viewports. */ @media (min-width: 768px) { - .az-image { + .az-ranking-image { min-height: 230px; } } /* Large viewports. */ @media (min-width: 992px) { - .az-image { + .az-ranking-image { min-height: 260px; } } -.az-image img { +.az-ranking-image img { /* Absolutely positioned so the image's own intrinsic aspect ratio never affects the grid row's height — the row is sized by its other content (Ranking cards), and this image crops via object-fit to fill whatever @@ -63,56 +63,56 @@ } /* Phone (base). */ -.az-image--span-phone-1 { +.az-ranking-image--span-phone-1 { grid-column: span 1; } -.az-image--span-phone-2 { +.az-ranking-image--span-phone-2 { grid-column: span 2; } -.az-image--span-phone-3 { +.az-ranking-image--span-phone-3 { grid-column: span 3; } -.az-image--span-phone-4 { +.az-ranking-image--span-phone-4 { grid-column: span 4; } /* Tablet. */ @media (min-width: 768px) { - .az-image--span-tablet-1 { + .az-ranking-image--span-tablet-1 { grid-column: span 1; } - .az-image--span-tablet-2 { + .az-ranking-image--span-tablet-2 { grid-column: span 2; } - .az-image--span-tablet-3 { + .az-ranking-image--span-tablet-3 { grid-column: span 3; } - .az-image--span-tablet-4 { + .az-ranking-image--span-tablet-4 { grid-column: span 4; } } /* Desktop. */ @media (min-width: 992px) { - .az-image--span-desktop-1 { + .az-ranking-image--span-desktop-1 { grid-column: span 1; } - .az-image--span-desktop-2 { + .az-ranking-image--span-desktop-2 { grid-column: span 2; } - .az-image--span-desktop-3 { + .az-ranking-image--span-desktop-3 { grid-column: span 3; } - .az-image--span-desktop-4 { + .az-ranking-image--span-desktop-4 { grid-column: span 4; } } diff --git a/components/ranking-image/ranking-image.js b/components/ranking-image/ranking-image.js new file mode 100644 index 0000000000..fc0987d811 --- /dev/null +++ b/components/ranking-image/ranking-image.js @@ -0,0 +1,137 @@ +/** + * @file + * Dynamically calculates object-position for az_quickstart:ranking-image + * based on focal point. + * + * Uses the formula: + * objectPosX = (focalX * imageW - 0.5 * containerW) / (imageW - containerW) + * objectPosY = (focalY * imageH - 0.5 * containerH) / (imageH - containerH) + * + * This ensures the focal point stays centered in the visible area when + * object-fit: cover crops the image. + * + * Same calculation as modules/custom/az_ranking/js/az-ranking-focal-point-calc.js + * (kept as a separate, deliberately duplicated copy there for the az_ranking + * widget's own edit-form preview, which renders through a different markup + * path). This copy targets .az-ranking-image__img and lives on the component + * itself — not in az_ranking — because focal_x/focal_y/original_width/ + * original_height are plain az_quickstart:ranking-image props with no + * dependency on az_ranking, and the component must keep working (focal point + * included) wherever it's placed, Canvas or paragraph-authored, az_ranking + * installed or not. + */ + +((Drupal, once) => { + Drupal.behaviors.azRankingImageFocalPoint = { + attach: (context) => { + const images = once('az-ranking-image-focal-point', '.az-ranking-image__img', context); + + if (images.length === 0) return; + + /** + * Calculate object-position for an image based on focal point and dimensions. + * + * @param {Element} img - Image element. + */ + const calculateObjectPosition = (img) => { + const focalX = parseFloat(img.getAttribute('data-focal-x')); + const focalY = parseFloat(img.getAttribute('data-focal-y')); + + // Skip if no focal point data + if (Number.isNaN(focalX) || Number.isNaN(focalY)) { + return; + } + + // Get container dimensions (the visible area) + const containerW = img.offsetWidth; + const containerH = img.offsetHeight; + + // Get ORIGINAL image dimensions (before any image style scaling). + // Focal points are stored relative to original dimensions. + const originalW = + parseFloat(img.getAttribute('data-original-width')) || + img.naturalWidth; + const originalH = + parseFloat(img.getAttribute('data-original-height')) || + img.naturalHeight; + + // Skip if dimensions not available yet + if (!originalW || !originalH || !containerW || !containerH) return; + + // Calculate aspect ratios to determine crop direction + const imageRatio = originalW / originalH; + const containerRatio = containerW / containerH; + + // Calculate the SCALED dimensions after object-fit: cover. + // object-fit: cover scales the image to fill the container while maintaining aspect ratio. + let scaledW; + let scaledH; + + if (imageRatio > containerRatio) { + // Image is WIDER than container (will be cropped horizontally) + // Scale to match container HEIGHT + scaledH = containerH; + scaledW = containerH * imageRatio; + } else { + // Image is TALLER than container (will be cropped vertically) + // Scale to match container WIDTH + scaledW = containerW; + scaledH = containerW / imageRatio; + } + + let objectPosX; + let objectPosY; + + if (imageRatio > containerRatio) { + // Image is WIDER than container (cropped horizontally - left/right sides cut off) + // Apply formula to X using SCALED dimensions, use focal point directly for Y + objectPosX = + (focalX * scaledW - 0.5 * containerW) / (scaledW - containerW); + objectPosY = focalY; + } else { + // Image is TALLER than container (cropped vertically - top/bottom cut off) + // Use focal point directly for X, apply formula to Y using SCALED dimensions + objectPosX = focalX; + objectPosY = + (focalY * scaledH - 0.5 * containerH) / (scaledH - containerH); + } + + // Convert to percentage and clamp between 0-100% + objectPosX = Math.max(0, Math.min(100, objectPosX * 100)); + objectPosY = Math.max(0, Math.min(100, objectPosY * 100)); + + // Apply to image + img.style.objectPosition = `${objectPosX}% ${objectPosY}%`; + }; + + /** + * Process all images. + */ + const processImages = () => { + images.forEach((img) => { + // If image is already loaded, calculate immediately + if (img.complete && img.naturalWidth > 0) { + calculateObjectPosition(img); + } else { + // Wait for image to load + img.addEventListener('load', () => calculateObjectPosition(img), { + once: true, + }); + } + }); + }; + + // Initial calculation + processImages(); + + // Recalculate on window resize (debounced) + let resizeTimer; + window.addEventListener('resize', () => { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(() => { + images.forEach((img) => calculateObjectPosition(img)); + }, 250); + }); + }, + }; +})(Drupal, once); diff --git a/components/image/image.twig b/components/ranking-image/ranking-image.twig similarity index 51% rename from components/image/image.twig rename to components/ranking-image/ranking-image.twig index 2cf8f2c542..fee7760b5a 100644 --- a/components/image/image.twig +++ b/components/ranking-image/ranking-image.twig @@ -1,7 +1,7 @@ {# /** * @file - * Template for the az_quickstart Image component. + * Template for the az_quickstart Ranking Image component. * * Renders an directly from a media-library-backed prop — no slot, no * Canvas dependency. The `src` prop's shape (type: string, format: uri, @@ -14,10 +14,34 @@ * Props: * - src: Image URI, picked via the media library in Canvas. Resolves to a * Drupal stream-wrapper URI (public://...), not a browser-loadable URL — - * converted via the file_url() Twig function before rendering. + * converted via the image_style Twig filter (az_media's + * ImageStyleTwigExtension), which applies the az_ranking_responsive + * image style (scale + WebP conversion) and falls back to plain + * file_url()-equivalent behavior if that style is ever unavailable, so a + * missing/misconfigured style degrades gracefully rather than breaking + * the page. This is a genuine, if soft, dependency on az_media (not + * az_ranking) — the filter itself is generic and lives in az_media + * regardless of which style name gets passed to it here. * - decorative: When true, forces empty alt text AND hides the image from * assistive technology via aria-hidden. Defaults to false. * - alt: Alternative text. Ignored when decorative is true. + * - focal_x / focal_y: Focal point as a 0-1 fraction of the image's width/ + * height, kept visible when object-fit: cover crops the image to fill + * its container. Rendered as data-focal-x/data-focal-y attributes and + * applied client-side (ranking-image.js) — no server-side cropping, since the + * image's effective on-screen aspect ratio depends on live CSS Grid + * layout, not a fixed, enumerable set of image styles (same reasoning + * as width_span_* below). Only takes effect when original_width/ + * original_height are also set; otherwise ignored and the image falls + * back to plain centered object-fit: cover cropping. + * - original_width / original_height: The image's true pixel dimensions + * before any cropping, needed because focal points are stored relative + * to the original image, not whatever size ends up displayed. Also + * rendered as native width/height attributes whenever present + * (independent of whether focal_x/focal_y are also set) — gives any + * renderer a correct intrinsic aspect ratio before CSS loads, including + * contexts that may not apply this component's own stylesheet, like + * Canvas's library hover-preview. * - width_span_desktop / width_span_tablet / width_span_phone: 1-4 each. * Grid columns this image spans at each breakpoint when placed inside a * Ranking Deck (or any CSS grid layout) — deliberately per-breakpoint, @@ -39,6 +63,12 @@ {% set decorative = decorative|default(false) %} {% set alt = decorative ? '' : alt|default('') %} +{% set focal_x = focal_x|default(null) %} +{% set focal_y = focal_y|default(null) %} +{% set original_width = original_width|default(null) %} +{% set original_height = original_height|default(null) %} +{% set has_focal_point = focal_x is not null and focal_y is not null and original_width is not null and original_height is not null %} + {% set allowed_spans = ['1', '2', '3', '4'] %} {% set width_span_desktop = width_span_desktop|default('2') in allowed_spans ? width_span_desktop|default('2') : '2' %} {% set width_span_tablet = width_span_tablet|default('2') in allowed_spans ? width_span_tablet|default('2') : '2' %} @@ -50,10 +80,10 @@ {% endif %} {% set root_classes = [ - 'az-image', - 'az-image--span-desktop-' ~ width_span_desktop, - 'az-image--span-tablet-' ~ width_span_tablet, - 'az-image--span-phone-' ~ width_span_phone, + 'az-ranking-image', + 'az-ranking-image--span-desktop-' ~ width_span_desktop, + 'az-ranking-image--span-tablet-' ~ width_span_tablet, + 'az-ranking-image--span-phone-' ~ width_span_phone, ] %} {% if utility_classes %} {% set root_classes = root_classes|merge(utility_classes) %} @@ -66,6 +96,21 @@ {% if src|default('') %} - {{ alt }} + {{ alt }} {% endif %} diff --git a/components/ranking/ranking.component.yml b/components/ranking/ranking.component.yml index dd11c335d8..36f8989604 100644 --- a/components/ranking/ranking.component.yml +++ b/components/ranking/ranking.component.yml @@ -1,7 +1,7 @@ $schema: https://git.drupalcode.org/project/drupal/-/raw/HEAD/core/assets/schemas/v1/metadata.schema.json name: Ranking status: experimental -description: A University of Arizona ranking card with configurable background, hover, and link behavior. For an image card, use the Image component instead (drop it into a Ranking Deck alongside Ranking cards). +description: A University of Arizona ranking card with configurable background, hover, and link behavior. For an image card, use the Ranking Image component instead (drop it into a Ranking Deck alongside Ranking cards). props: type: object properties: diff --git a/components/ranking/ranking.twig b/components/ranking/ranking.twig index 93d2e4a192..ca8e8f2f0b 100644 --- a/components/ranking/ranking.twig +++ b/components/ranking/ranking.twig @@ -3,8 +3,8 @@ * @file * Template for the az_quickstart Ranking component. * - * For an image card, use the Image component instead (place it in a Ranking - * Deck alongside Ranking cards — see components/image). + * For an image card, use the Ranking Image component instead (place it in a + * Ranking Deck alongside Ranking cards — see components/ranking-image). * * Props: * - heading: Main ranking value. diff --git a/modules/custom/az_media/az_media.services.yml b/modules/custom/az_media/az_media.services.yml new file mode 100644 index 0000000000..d4dc569d1b --- /dev/null +++ b/modules/custom/az_media/az_media.services.yml @@ -0,0 +1,7 @@ +services: + az_media.image_style_twig_extension: + class: Drupal\az_media\Twig\ImageStyleTwigExtension + arguments: + - '@file_url_generator' + tags: + - { name: twig.extension } diff --git a/modules/custom/az_ranking/config/install/image.style.az_ranking_responsive.yml b/modules/custom/az_media/config/install/image.style.az_ranking_responsive.yml similarity index 100% rename from modules/custom/az_ranking/config/install/image.style.az_ranking_responsive.yml rename to modules/custom/az_media/config/install/image.style.az_ranking_responsive.yml diff --git a/modules/custom/az_media/src/Twig/ImageStyleTwigExtension.php b/modules/custom/az_media/src/Twig/ImageStyleTwigExtension.php new file mode 100644 index 0000000000..82fe77f8ee --- /dev/null +++ b/modules/custom/az_media/src/Twig/ImageStyleTwigExtension.php @@ -0,0 +1,58 @@ + image_style` / `image_formatter`), which SDC props can't + * carry. Falls back to plain file_url() behavior if the style doesn't + * exist or the URI is empty, so a missing/misconfigured style degrades + * gracefully instead of breaking the page. + */ +class ImageStyleTwigExtension extends AbstractExtension { + + public function __construct( + protected FileUrlGeneratorInterface $fileUrlGenerator, + ) {} + + /** + * {@inheritdoc} + */ + public function getFilters(): array { + return [ + new TwigFilter('image_style', [$this, 'applyImageStyle']), + ]; + } + + /** + * Applies a named image style to a stream-wrapper URI. + * + * @param string|null $uri + * A stream-wrapper URI (e.g. public://foo.jpg), or NULL/empty. + * @param string $style_name + * The image style's machine name. + * + * @return string + * The styled derivative's URL, or a plain file_url()-equivalent URL if + * the named style doesn't exist, or an empty string if $uri is empty. + */ + public function applyImageStyle(?string $uri, string $style_name): string { + if (empty($uri)) { + return ''; + } + $style = ImageStyle::load($style_name); + if ($style) { + return $style->buildUrl($uri); + } + return $this->fileUrlGenerator->generateString($uri); + } + +} From 00b6552e46bd1a81f027167b687907dc9107be1d Mon Sep 17 00:00:00 2001 From: Kevin Lu <79181817+kevdevlu@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:53:03 -0700 Subject: [PATCH 04/21] Ship a placeholder image via az_media for SDC examples az_quickstart:ranking-image's src prop example previously pointed at canyon_running.jpg, a real demo photo only present on sites that enable az_demo (explicitly not for production). Adds a dedicated 1000x500 placeholder PNG shipped as a plain az_media asset instead, copied into public:// via hook_install()/hook_update_N() - a raw filesystem copy, not a managed file or media entity, so it exists on every site (including production) without ever showing up as a selectable option in the Media Library picker. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QZ6mWfeVEr5qdPLXAEmtqP --- modules/custom/az_media/az_media.install | 33 ++++++++++++++++++ .../az_media/images/placeholder-1000x500.png | Bin 0 -> 14093 bytes 2 files changed, 33 insertions(+) create mode 100644 modules/custom/az_media/images/placeholder-1000x500.png diff --git a/modules/custom/az_media/az_media.install b/modules/custom/az_media/az_media.install index b472e61023..0dbef2e12e 100644 --- a/modules/custom/az_media/az_media.install +++ b/modules/custom/az_media/az_media.install @@ -8,6 +8,32 @@ * az_media module. */ +use Drupal\Core\File\FileExists; + +/** + * Implements hook_install(). + */ +function az_media_install() { + _az_media_copy_placeholder_images(); +} + +/** + * Copies az_media's shipped placeholder images into public://. + * + * These are plain files, not media entities - they exist only to back + * SDC prop `examples` values (e.g. az_quickstart:ranking-image's `src`) + * so those examples resolve to a real, renderable public:// file on + * every site, without ever appearing in the Media Library. + */ +function _az_media_copy_placeholder_images() { + $file_system = \Drupal::service('file_system'); + $source_dir = \Drupal::service('extension.list.module')->getPath('az_media') . '/images'; + $filenames = ['placeholder-1000x500.png']; + foreach ($filenames as $filename) { + $file_system->copy("{$source_dir}/{$filename}", "public://{$filename}", FileExists::Replace); + } +} + /** * Implements hook_update_last_removed(). */ @@ -29,3 +55,10 @@ function az_media_update_1021301() { function az_media_update_1130101() { \Drupal::service('module_installer')->install(['media_library_form_element']); } + +/** + * Copy az_media's shipped placeholder images into public:// on existing sites. + */ +function az_media_update_1130102() { + _az_media_copy_placeholder_images(); +} diff --git a/modules/custom/az_media/images/placeholder-1000x500.png b/modules/custom/az_media/images/placeholder-1000x500.png new file mode 100644 index 0000000000000000000000000000000000000000..d2dcfe15b53576eaa520fd56138069c19c42e243 GIT binary patch literal 14093 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 literal 0 HcmV?d00001 From f3d0b80c392bdcf08cc1dd69b84f2a3ca02ef6ca Mon Sep 17 00:00:00 2001 From: Kevin Lu <79181817+kevdevlu@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:53:36 -0700 Subject: [PATCH 05/21] Extract AZRankingComponentBuilder; fix widget preview reorder bug AZRankingComponentBuilder becomes the single source of truth mapping ranking item values to az_quickstart:ranking/ranking-image render arrays, shared by AZRankingDefaultFormatter and AZRankingWidget's live edit-form preview - previously the widget preview still rendered through the legacy #theme => 'az_ranking' template/hook_theme path (az-ranking.html.twig, az_ranking_theme(), plus its own now-dead az-ranking.css/az-ranking-image.css/az-ranking-focal-point-calc.js), duplicating the formatter's class-mapping logic and free to drift out of sync with what actually publishes. Both callers now build a plain values array (AZRankingComponentBuilder::extractItemValues()) instead of passing an AZRankingItem directly, since the widget's preview needs to build that same array from Form API values instead of a field item (see below) - keeping the builder item-agnostic is what lets it work for both. Also fixes az_quickstart#5156: after drag-and-drop reordering a paragraph's ranking items, clicking "Update Preview"/"Add Another Item"/"Remove" could leave previews showing stale data for the wrong row. Root cause: the preview was built from $items[$delta] (stored array order), while its sibling text/select fields are repopulated by the Form API from #value (always correct for the current row, since that's tree-position-based, not stored-order-based). Fixed the way az_quickstart/az_quickstart#5309 fixed it upstream: build the preview from the same Form API-populated #value the fields use, instead of the item, via a new custom Form API element (AZRankingItemElement) whose #process builds the fields and #after_build rebuilds the preview once its siblings have resolved - a real, reusable Element plugin instead of #after_build glue bolted onto Field API's own per-delta wrapper, so the preview keeps direct access to its sibling fields (an element scoped to the preview alone would lose that, since #after_build only sees its own descendants). Also drops the old original_deltas widget- state remap, which #5309 found actively breaks reorder rather than protecting it - table-drag already moves whole rows (fields + preview together) client-side, so no server-side delta remapping is needed. Verified via a real Drupal form-build cycle (\Drupal::formBuilder() ->doBuildForm()) against real paragraph data, not just isolated calls: confirmed #process and #after_build both fire in the correct order and the resulting preview matches the real stored values exactly. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QZ6mWfeVEr5qdPLXAEmtqP --- .../az_ranking/az_ranking.libraries.yml | 10 - modules/custom/az_ranking/az_ranking.module | 30 -- .../custom/az_ranking/az_ranking.services.yml | 7 +- .../az_ranking/css/az-ranking-image.css | 36 -- modules/custom/az_ranking/css/az-ranking.css | 72 ---- .../js/az-ranking-focal-point-calc.js | 131 ------ .../src/AZRankingComponentBuilder.php | 305 +++++++++++++ .../az_ranking/src/AZRankingImageHelper.php | 141 ++---- .../src/Element/AZRankingItemElement.php | 76 ++++ .../AZRankingDefaultFormatter.php | 276 ++---------- .../Field/FieldWidget/AZRankingWidget.php | 405 ++++++------------ .../az_ranking/templates/az-ranking.html.twig | 73 ---- 12 files changed, 605 insertions(+), 957 deletions(-) delete mode 100644 modules/custom/az_ranking/css/az-ranking-image.css delete mode 100644 modules/custom/az_ranking/css/az-ranking.css delete mode 100644 modules/custom/az_ranking/js/az-ranking-focal-point-calc.js create mode 100644 modules/custom/az_ranking/src/AZRankingComponentBuilder.php create mode 100644 modules/custom/az_ranking/src/Element/AZRankingItemElement.php delete mode 100644 modules/custom/az_ranking/templates/az-ranking.html.twig diff --git a/modules/custom/az_ranking/az_ranking.libraries.yml b/modules/custom/az_ranking/az_ranking.libraries.yml index cfdf09357b..518f9f3cec 100644 --- a/modules/custom/az_ranking/az_ranking.libraries.yml +++ b/modules/custom/az_ranking/az_ranking.libraries.yml @@ -2,16 +2,6 @@ az_ranking: css: component: css/az-ranking-widget.css: {} - css/az-ranking.css: {} - dependencies: - - core/drupal - - core/once -az_ranking_image: - css: - component: - css/az-ranking-image.css: {} - js: - js/az-ranking-focal-point-calc.js: {} dependencies: - core/drupal - core/once diff --git a/modules/custom/az_ranking/az_ranking.module b/modules/custom/az_ranking/az_ranking.module index e4e99516d0..0a9931bf43 100644 --- a/modules/custom/az_ranking/az_ranking.module +++ b/modules/custom/az_ranking/az_ranking.module @@ -26,36 +26,6 @@ function az_ranking_help($route_name, RouteMatchInterface $route_match) { } } -/** - * Implements hook_theme(). - */ -function az_ranking_theme($existing, $type, $theme, $path) { - return [ - 'az_ranking' => [ - 'variables' => [ - 'attributes' => [], - 'media' => NULL, - 'column_span' => NULL, - 'ranking_clickable' => NULL, - 'ranking_hover_effect' => NULL, - 'ranking_heading' => NULL, - 'ranking_alignment' => NULL, - 'ranking_header_style' => NULL, - 'ranking_description' => NULL, - 'ranking_source' => NULL, - 'ranking_source_classes' => NULL, - 'ranking_font_color' => NULL, - 'text_color_override' => NULL, - 'link' => NULL, - 'link_url' => NULL, - 'link_title' => NULL, - 'ranking_link_style' => NULL, - ], - 'template' => 'az-ranking', - ], - ]; -} - /** * Implements hook_form_FORM_ID_alter() for media_az_image_edit_form. */ diff --git a/modules/custom/az_ranking/az_ranking.services.yml b/modules/custom/az_ranking/az_ranking.services.yml index 5d02c9e4ce..195584a638 100644 --- a/modules/custom/az_ranking/az_ranking.services.yml +++ b/modules/custom/az_ranking/az_ranking.services.yml @@ -3,5 +3,10 @@ services: class: Drupal\az_ranking\AZRankingImageHelper arguments: - '@entity_type.manager' - - '@renderer' - '@image.factory' + az_ranking.component_builder: + class: Drupal\az_ranking\AZRankingComponentBuilder + arguments: + - '@entity_type.manager' + - '@az_ranking.image' + - '@path.validator' diff --git a/modules/custom/az_ranking/css/az-ranking-image.css b/modules/custom/az_ranking/css/az-ranking-image.css deleted file mode 100644 index d7d79b3840..0000000000 --- a/modules/custom/az_ranking/css/az-ranking-image.css +++ /dev/null @@ -1,36 +0,0 @@ -/** - * AZ Ranking Image Styles - * - * Handles image-only rankings with focal point-based cropping. - * - * Structure: - * - .az-ranking-responsive (parent container with padding from Bootstrap grid) - * - .ranking-image-wrapper (absolutely positioned to ignore parent padding) - * - img (fills wrapper with object-fit: cover) - * - * The nested wrapper approach ensures images don't affect row height while - * respecting the parent container's padding boundaries. - * - * JavaScript (focal-point-picker.js) dynamically calculates object-position - * based on focal point coordinates and image/container dimensions to keep - * the focal point centered and visible. - */ - -.az-ranking-responsive { - position: relative; -} - -.ranking-image-wrapper { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; -} - -.ranking-image-wrapper img { - display: block; - width: 100%; - height: 100%; - object-fit: cover; -} diff --git a/modules/custom/az_ranking/css/az-ranking.css b/modules/custom/az_ranking/css/az-ranking.css deleted file mode 100644 index 2d428bd713..0000000000 --- a/modules/custom/az_ranking/css/az-ranking.css +++ /dev/null @@ -1,72 +0,0 @@ -/* Header styles */ -.header-with-link:hover * { - text-decoration: underline !important; -} - -/* Responsive ranking card styles */ -.az-ranking-responsive { - /* Small viewports. Default: 1 card per row */ - min-height: 190px; -} - -/* Stretched Link fix for hidden cards */ -.card-body .visually-hidden { - display: block; - position: static !important; -} - -/* Medium viewports. Default: 2 cards per row */ -@media (min-width: 768px) { - .az-ranking-responsive { - min-height: 230px; - } -} - -/* Large viewports. Default: 4 cards per row */ -@media (min-width: 992px) { - .az-ranking-responsive { - min-height: 260px; - } -} - -/** - * Pre-set css hover colors based on primary card background color - * - */ -.text-bg-chili.ranking-bold-hover:hover * { - background-color: #ffffff !important; - color: RGBA(var(--bs-chili-rgb), var(--bs-bg-opacity, 1)) !important; -} -.text-bg-blue.ranking-bold-hover:hover * { - background-color: #ffffff !important; - color: RGBA(var(--bs-blue-rgb),var(--bs-bg-opacity,1)) !important; -} -.bg-sky.ranking-bold-hover:hover * { - background-color: RGBA(var(--bs-blue-rgb),var(--bs-bg-opacity,1)) !important; - color: RGBA(var(--bs-sky-rgb),var(--bs-bg-opacity,1)) !important; -} -.bg-cool-gray.ranking-bold-hover:hover * { - background-color: RGBA(var(--bs-azurite-rgb), var(--bs-bg-opacity, 1)) !important; - color: RGBA(var(--bs-cool-gray-rgb), var(--bs-bg-opacity, 1)) !important; -} -.bg-oasis.ranking-bold-hover:hover * { - background-color: RGBA(var(--bs-midnight-rgb),var(--bs-bg-opacity,1)) !important; - color: RGBA(var(--bs-oasis-rgb),var(--bs-bg-opacity,1)) !important; -} - -/** - * Transparent BG Ranking font color overrides, ignoring user-set links - */ - -.ranking-text-white *:not(a) { - color: #ffffff !important; -} -.ranking-text-black *:not(a) { - color: #000000 !important; -} -.ranking-text-az-blue *:not(a) { - color: RGBA(var(--bs-blue-rgb),var(--bs-bg-opacity,1)); -} -.ranking-text-midnight *:not(a) { - color: RGBA(var(--bs-midnight-rgb),var(--bs-bg-opacity,1)) !important; -} \ No newline at end of file diff --git a/modules/custom/az_ranking/js/az-ranking-focal-point-calc.js b/modules/custom/az_ranking/js/az-ranking-focal-point-calc.js deleted file mode 100644 index 11993ecd6a..0000000000 --- a/modules/custom/az_ranking/js/az-ranking-focal-point-calc.js +++ /dev/null @@ -1,131 +0,0 @@ -/** - * @file - * Dynamically calculates object-position for ranking images based on focal point. - * - * Uses the formula: - * objectPosX = (focalX * imageW - 0.5 * containerW) / (imageW - containerW) - * objectPosY = (focalY * imageH - 0.5 * containerH) / (imageH - containerH) - * - * This ensures the focal point stays centered in the visible area when - * object-fit: cover crops the image. - */ - -((Drupal, once) => { - Drupal.behaviors.azRankingFocalPoint = { - attach: (context) => { - const images = once('az-ranking-focal-point', '.ranking-img', context); - - if (images.length === 0) return; - - /** - * Calculate object-position for an image based on focal point and dimensions. - * - * @param {Element} img - Image element. - */ - const calculateObjectPosition = (img) => { - const focalX = parseFloat(img.getAttribute('data-focal-x')); - const focalY = parseFloat(img.getAttribute('data-focal-y')); - - // Skip if no focal point data - if (Number.isNaN(focalX) || Number.isNaN(focalY)) { - // eslint-disable-next-line no-console - console.warn( - '⚠️ No focal point data for image:', - img.getAttribute('src'), - ); - return; - } - - // Get container dimensions (the visible area) - const containerW = img.offsetWidth; - const containerH = img.offsetHeight; - - // Get ORIGINAL image dimensions (before any image style scaling). - // Focal points are stored relative to original dimensions. - const originalW = - parseFloat(img.getAttribute('data-original-width')) || - img.naturalWidth; - const originalH = - parseFloat(img.getAttribute('data-original-height')) || - img.naturalHeight; - - // Skip if dimensions not available yet - if (!originalW || !originalH || !containerW || !containerH) return; - - // Calculate aspect ratios to determine crop direction - const imageRatio = originalW / originalH; - const containerRatio = containerW / containerH; - - // Calculate the SCALED dimensions after object-fit: cover. - // object-fit: cover scales the image to fill the container while maintaining aspect ratio. - let scaledW; - let scaledH; - - if (imageRatio > containerRatio) { - // Image is WIDER than container (will be cropped horizontally) - // Scale to match container HEIGHT - scaledH = containerH; - scaledW = containerH * imageRatio; - } else { - // Image is TALLER than container (will be cropped vertically) - // Scale to match container WIDTH - scaledW = containerW; - scaledH = containerW / imageRatio; - } - - let objectPosX; - let objectPosY; - - if (imageRatio > containerRatio) { - // Image is WIDER than container (cropped horizontally - left/right sides cut off) - // Apply formula to X using SCALED dimensions, use focal point directly for Y - objectPosX = - (focalX * scaledW - 0.5 * containerW) / (scaledW - containerW); - objectPosY = focalY; - } else { - // Image is TALLER than container (cropped vertically - top/bottom cut off) - // Use focal point directly for X, apply formula to Y using SCALED dimensions - objectPosX = focalX; - objectPosY = - (focalY * scaledH - 0.5 * containerH) / (scaledH - containerH); - } - - // Convert to percentage and clamp between 0-100% - objectPosX = Math.max(0, Math.min(100, objectPosX * 100)); - objectPosY = Math.max(0, Math.min(100, objectPosY * 100)); - - // Apply to image - img.style.objectPosition = `${objectPosX}% ${objectPosY}%`; - }; - - /** - * Process all images. - */ - const processImages = () => { - images.forEach((img) => { - // If image is already loaded, calculate immediately - if (img.complete && img.naturalWidth > 0) { - calculateObjectPosition(img); - } else { - // Wait for image to load - img.addEventListener('load', () => calculateObjectPosition(img), { - once: true, - }); - } - }); - }; - - // Initial calculation - processImages(); - - // Recalculate on window resize (debounced) - let resizeTimer; - window.addEventListener('resize', () => { - clearTimeout(resizeTimer); - resizeTimer = setTimeout(() => { - images.forEach((img) => calculateObjectPosition(img)); - }, 250); - }); - }, - }; -})(Drupal, once); diff --git a/modules/custom/az_ranking/src/AZRankingComponentBuilder.php b/modules/custom/az_ranking/src/AZRankingComponentBuilder.php new file mode 100644 index 0000000000..648bd89e4f --- /dev/null +++ b/modules/custom/az_ranking/src/AZRankingComponentBuilder.php @@ -0,0 +1,305 @@ + 'chili', + 'text-bg-blue' => 'blue', + 'bg-sky' => 'sky', + 'bg-oasis' => 'oasis', + 'text-bg-azurite' => 'azurite', + 'bg-cool-gray' => 'cool-gray', + 'bg-warm-gray' => 'warm-gray', + 'bg-white' => 'white', + 'bg-transparent' => 'transparent', + ]; + + /** + * Legacy font color select values, keyed to SDC tokens. + */ + const FONT_COLOR_CLASS_MAP = [ + 'ranking-text-midnight' => 'midnight', + 'ranking-text-black' => 'black', + 'ranking-text-white' => 'white', + 'ranking-text-az-blue' => 'az-blue', + ]; + + /** + * Legacy link style select values, keyed to SDC tokens. + */ + const LINK_STYLE_CLASS_MAP = [ + 'visually-hidden' => 'hidden', + 'link mt-2' => 'text-link', + 'w-100 btn btn-red mt-2' => 'btn-red', + 'w-100 btn btn-blue mt-2' => 'btn-blue', + 'w-100 btn btn-outline-red mt-2' => 'btn-outline-red', + 'w-100 btn btn-outline-blue mt-2' => 'btn-outline-blue', + 'w-100 btn btn-outline-white mt-2' => 'btn-outline-white', + ]; + + /** + * Legacy per-breakpoint Bootstrap column classes, keyed to column counts. + */ + const DESKTOP_COLUMN_MAP = [ + 'col-lg-12' => '1', + 'col-lg-6' => '2', + 'col-lg-4' => '3', + 'col-lg-3' => '4', + ]; + const TABLET_COLUMN_MAP = [ + 'col-md-12' => '1', + 'col-md-6' => '2', + 'col-md-4' => '3', + 'col-md-3' => '4', + ]; + const PHONE_COLUMN_MAP = [ + 'col-12' => '1', + 'col-6' => '2', + 'col-4' => '3', + 'col-3' => '4', + ]; + + /** + * The entity type manager service. + * + * @var \Drupal\Core\Entity\EntityTypeManagerInterface + */ + protected $entityTypeManager; + + /** + * The AZRankingImageHelper service. + * + * @var \Drupal\az_ranking\AZRankingImageHelper + */ + protected $rankingImageHelper; + + /** + * The path validator service. + * + * @var \Drupal\Core\Path\PathValidatorInterface + */ + protected $pathValidator; + + /** + * Constructs a new AZRankingComponentBuilder object. + */ + public function __construct(EntityTypeManagerInterface $entity_type_manager, AZRankingImageHelper $ranking_image_helper, PathValidatorInterface $path_validator) { + $this->entityTypeManager = $entity_type_manager; + $this->rankingImageHelper = $ranking_image_helper; + $this->pathValidator = $path_validator; + } + + /** + * Extracts a plain values array from a real, hydrated AZRankingItem. + * + * The single source of truth for "how does a stored ranking item map onto + * the values array buildRankingComponent()/buildImageComponent() expect." + * AZRankingWidget's live preview builds this same shape itself, from + * Form API #value instead of an item - see this class's own docblock. + */ + public function extractItemValues(AZRankingItem $item): array { + return [ + 'ranking_heading' => $item->ranking_heading ?? '', + 'ranking_description' => $item->ranking_description ?? '', + 'ranking_source' => $item->ranking_source ?? '', + 'link_uri' => $item->link_uri ?? '', + 'link_title' => $item->link_title ?? '', + 'ranking_link_style' => $item->ranking_link_style ?? '', + 'ranking_font_color' => $item->ranking_font_color ?? '', + 'media' => $item->media ?? NULL, + 'options' => is_array($item->options) ? $item->options : [], + ]; + } + + /** + * Builds an az_quickstart:ranking component render array for one item. + * + * Props like clickable/hover_effect/link_style interactions (e.g. link + * title and style being ignored while clickable) are NOT resolved here — + * ranking.twig's own guards are the single source of truth for that + * behavior, so this only needs to map field/behavior values onto clean + * prop values. + * + * @param array $values + * Normalized ranking item values - see extractItemValues(). + * @param array $ranking_defaults + * The parent paragraph's az_rankings_paragraph_behavior settings. + */ + public function buildRankingComponent(array $values, array $ranking_defaults): array { + $props = [ + 'heading' => $values['ranking_heading'] ?? '', + 'description' => $values['ranking_description'] ?? '', + 'source' => $values['ranking_source'] ?? '', + ]; + + // Gate on the RAW stored link_uri, not the resolved URL string — a bare + // '#' (a common placeholder in demo content) is a real, present link + // that legacy always showed a button for, but Url::fromUserInput('#') + // legitimately stringifies to '' (confirmed empirically, not assumed). + // Checking the resolved string's emptiness instead of the source value + // silently dropped every ranking using such a placeholder link. + if (!empty($values['link_uri'])) { + $props['link_url'] = $this->resolveLinkUrl($values['link_uri']); + $props['link_title'] = $values['link_title'] ?? ''; + $props['link_style'] = self::LINK_STYLE_CLASS_MAP[$values['ranking_link_style'] ?? ''] ?? 'btn-red'; + } + + $props['header_style'] = ($ranking_defaults['ranking_header_style'] ?? '') === 'ranking-title-thin' ? 'thin' : 'bold'; + $props['alignment'] = ($ranking_defaults['ranking_alignment'] ?? '') === 'text-center' ? 'center' : 'left'; + $props['clickable'] = !empty($ranking_defaults['ranking_clickable']); + $props['hover_effect'] = !empty($ranking_defaults['ranking_hover_effect']); + + $background_class = $values['options']['class'] ?? ''; + $props['background'] = self::BACKGROUND_CLASS_MAP[$background_class] ?? 'chili'; + $props['hover_background'] = self::BACKGROUND_CLASS_MAP[$values['options']['hover_class'] ?? ''] ?? 'chili'; + if ($background_class === 'bg-transparent') { + $props['font_color'] = self::FONT_COLOR_CLASS_MAP[$values['ranking_font_color'] ?? ''] ?? 'midnight'; + } + + return [ + '#type' => 'component', + '#component' => 'az_quickstart:ranking', + '#props' => $props, + ]; + } + + /** + * Builds an az_quickstart:ranking-image component render array for one item. + * + * Unlike the legacy #theme => image_formatter path, this does not apply + * the az_ranking_responsive image style — az_quickstart:ranking-image + * takes a plain file URI, not a themed render array, so server-side image + * style processing is a known, disclosed gap versus the legacy image_only + * rendering, not an oversight. Focal point data IS passed through (see + * AZRankingImageHelper::getImageSourceAltAndFocalPoint()), so + * focal-point-aware cropping works client-side via the ranking-image + * SDC's own JS. + * + * width_span_desktop/tablet/phone are computed here, not just passed + * through legacy's single column_span value, because CSS Grid cannot + * clamp a span against its container's actual column count (a confirmed + * CSS spec gap, not a browser quirk - see ranking-image.css's own + * docblock and + * https://github.com/w3c/csswg-drafts/issues/5852). This reproduces + * legacy's own "min(current row width, column_span)" behavior exactly, + * per breakpoint, using the SAME $deck_props the sibling ranking-deck + * component receives, so the clamp is always correct for whatever the + * paragraph is actually configured to - not a fixed, conservative cap. + * + * @param array $values + * Normalized ranking item values - see extractItemValues(). + * @param array $deck_props + * The az_quickstart:ranking-deck props this item's parent deck will + * receive (columns_desktop/tablet/phone), from buildDeckProps(). + * + * @see \Drupal\az_ranking\AZRankingImageHelper::getImageSourceAltAndFocalPoint() + */ + public function buildImageComponent(array $values, array $deck_props): array { + $legacy_span = (int) ($values['options']['column_span'] ?? 2); + $props = [ + 'width_span_desktop' => (string) min($legacy_span, (int) ($deck_props['columns_desktop'] ?? 4)), + 'width_span_tablet' => (string) min($legacy_span, (int) ($deck_props['columns_tablet'] ?? 1)), + 'width_span_phone' => (string) min($legacy_span, (int) ($deck_props['columns_phone'] ?? 1)), + ]; + + if (!empty($values['media'])) { + $media = $this->entityTypeManager->getStorage('media')->load($values['media']); + if ($media) { + $image_data = $this->rankingImageHelper->getImageSourceAltAndFocalPoint($media); + if ($image_data['src'] !== '') { + $props['src'] = $image_data['src']; + $props['alt'] = $image_data['alt']; + } + if ($image_data['focal_x'] !== NULL && $image_data['focal_y'] !== NULL) { + $props['focal_x'] = $image_data['focal_x']; + $props['focal_y'] = $image_data['focal_y']; + $props['original_width'] = $image_data['original_width']; + $props['original_height'] = $image_data['original_height']; + } + } + } + + return [ + '#type' => 'component', + '#component' => 'az_quickstart:ranking-image', + '#props' => $props, + ]; + } + + /** + * Maps the parent paragraph's per-breakpoint column settings to deck props. + */ + public function buildDeckProps(array $ranking_defaults): array { + $az_display_settings = $ranking_defaults['az_display_settings'] ?? []; + return [ + 'columns_desktop' => self::DESKTOP_COLUMN_MAP[$ranking_defaults['ranking_width'] ?? ''] ?? '4', + 'columns_tablet' => self::TABLET_COLUMN_MAP[$az_display_settings['ranking_width_sm'] ?? ''] ?? '1', + 'columns_phone' => self::PHONE_COLUMN_MAP[$az_display_settings['ranking_width_xs'] ?? ''] ?? '1', + ]; + } + + /** + * Resolves a stored link_uri value to a plain URL string, or ''. + * + * Mirrors the URL resolution the legacy formatter already performed + * (public file links, page anchors, and validated internal/external + * paths), only stringified for use as an SDC prop value instead of being + * kept as a Url object for a #type => link render array. + */ + protected function resolveLinkUrl(string $link_uri): string { + if ($link_uri === '') { + return ''; + } + + if (str_starts_with($link_uri, '/' . PublicStream::basePath())) { + return Url::fromUri(urldecode('base:' . $link_uri))->toString(); + } + + if (str_starts_with($link_uri, '#')) { + // Url::fromUserInput('#') is valid but its ->toString() legitimately + // returns '' for a bare fragment (confirmed empirically) - preserve + // the literal anchor directly instead of losing it. A BARE '#' (no + // fragment name) is also rejected by the SDC prop's own + // format: uri-reference validation (confirmed empirically: '#top' + // passes, '#' alone does not) - normalize the empty-fragment case to + // a named one so common placeholder links ('#', used throughout demo + // content) don't fail validation. Same practical behavior (no real + // destination); only the literal href text differs from legacy's '#'. + return $link_uri === '#' ? '#top' : $link_uri; + } + + $url = $this->pathValidator->getUrlIfValid($link_uri); + return $url ? $url->toString() : ''; + } + +} diff --git a/modules/custom/az_ranking/src/AZRankingImageHelper.php b/modules/custom/az_ranking/src/AZRankingImageHelper.php index ac5ee95a2f..e5bf1c964a 100644 --- a/modules/custom/az_ranking/src/AZRankingImageHelper.php +++ b/modules/custom/az_ranking/src/AZRankingImageHelper.php @@ -5,7 +5,6 @@ use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Entity\FieldableEntityInterface; use Drupal\Core\Image\ImageFactory; -use Drupal\Core\Render\RendererInterface; use Drupal\media\MediaInterface; /** @@ -20,13 +19,6 @@ class AZRankingImageHelper { */ protected $entityTypeManager; - /** - * Drupal\Core\Render\RendererInterface definition. - * - * @var \Drupal\Core\Render\RendererInterface - */ - protected $renderer; - /** * The image factory service. * @@ -37,117 +29,70 @@ class AZRankingImageHelper { /** * Constructs a new AZRankingImageHelper object. */ - public function __construct(EntityTypeManagerInterface $entity_type_manager, RendererInterface $renderer, ImageFactory $image_factory) { + public function __construct(EntityTypeManagerInterface $entity_type_manager, ImageFactory $image_factory) { $this->entityTypeManager = $entity_type_manager; - $this->renderer = $renderer; $this->imageFactory = $image_factory; } /** - * Prepare an image render array. + * Get a plain file URI, alt text, and focal point data for the ranking-image SDC. + * + * Used for both the published az_quickstart:ranking-image render and the + * widget's own live edit-form preview, via AZRankingComponentBuilder:: + * buildImageComponent() (shared by AZRankingDefaultFormatter and + * AZRankingWidget::rebuildRankingPreview()) — both render through the + * same SDC, so the two can't drift apart. * * @param \Drupal\media\MediaInterface $media * A Drupal media entity object. * * @return array - * An image render array. + * An array with 'src' and 'alt' (empty strings if the media has no + * image), plus 'focal_x', 'focal_y', 'original_width', and + * 'original_height' (all NULL if the media has no focal point set). */ - public function generateImageRenderArray(MediaInterface $media) { - $media_render_array = []; - $media_attributes = $media->get('field_media_az_image')->getValue(); + public function getImageSourceAltAndFocalPoint(MediaInterface $media): array { + $empty = [ + 'src' => '', + 'alt' => '', + 'focal_x' => NULL, + 'focal_y' => NULL, + 'original_width' => NULL, + 'original_height' => NULL, + ]; + $media_attributes = $media->get('field_media_az_image')->getValue(); if (empty($media_attributes[0]['target_id'])) { - return []; + return $empty; } - if ($file = $this->entityTypeManager->getStorage('file')->load($media_attributes[0]['target_id'])) { - $image = new \stdClass(); - $image->title = NULL; - $image->alt = $media_attributes[0]['alt'] ?? ''; - $image->entity = $file; - $image->uri = $file->getFileUri(); - $image->width = NULL; - $image->height = NULL; - - $media_render_array = [ - '#theme' => 'image_formatter', - '#item' => $image, - '#image_style' => 'az_ranking_responsive', - '#item_attributes' => [ - 'class' => ['ranking-img'], - ], - ]; - // Add focal point data attributes for JavaScript to calculate - // object-position dynamically based on container size. - if ($media instanceof FieldableEntityInterface) { - try { - if ($media->hasField('field_focal_point_x') && $media->hasField('field_focal_point_y')) { - if (!$media->get('field_focal_point_x')->isEmpty() && !$media->get('field_focal_point_y')->isEmpty()) { - $focal_x = (float) $media->get('field_focal_point_x')->value; - $focal_y = (float) $media->get('field_focal_point_y')->value; - - // Get original image dimensions for JavaScript calculations. - // When image styles scale the image, naturalWidth/Height in JS - // will be the scaled dimensions, but focal points are relative - // to the original image dimensions. - $original_image = $this->imageFactory->get($file->getFileUri()); - $original_width = $original_image->getWidth(); - $original_height = $original_image->getHeight(); + $file = $this->entityTypeManager->getStorage('file')->load($media_attributes[0]['target_id']); + if (!$file) { + return $empty; + } - // Store focal point as decimal values (0-1) for JavaScript, - // along with original image dimensions. - $media_render_array['#item_attributes'] += [ - 'data-focal-x' => $focal_x, - 'data-focal-y' => $focal_y, - 'data-original-width' => $original_width, - 'data-original-height' => $original_height, - ]; - } + $result = $empty; + $result['src'] = $file->getFileUri(); + $result['alt'] = $media_attributes[0]['alt'] ?? ''; + + if ($media instanceof FieldableEntityInterface) { + try { + if ($media->hasField('field_focal_point_x') && $media->hasField('field_focal_point_y')) { + if (!$media->get('field_focal_point_x')->isEmpty() && !$media->get('field_focal_point_y')->isEmpty()) { + $original_image = $this->imageFactory->get($file->getFileUri()); + $result['focal_x'] = (float) $media->get('field_focal_point_x')->value; + $result['focal_y'] = (float) $media->get('field_focal_point_y')->value; + $result['original_width'] = $original_image->getWidth(); + $result['original_height'] = $original_image->getHeight(); } } - catch (\Throwable $e) { - // Defensive: do not break rendering if fields are not present. - } } - // Add the file entity to the cache dependencies. - // This will clear our cache when this entity updates. - $this->renderer->addCacheableDependency($media_render_array, $file); - } - return $media_render_array; - } - - /** - * Get a plain file URI and alt text for the az_quickstart:image SDC. - * - * Unlike generateImageRenderArray(), this does not apply the - * az_ranking_responsive image style or add focal-point positioning data — - * az_quickstart:image takes a plain file URI as a prop, not a themed - * render array, so image style processing and focal-point-aware cropping - * are not available through this path. - * - * @param \Drupal\media\MediaInterface $media - * A Drupal media entity object. - * - * @return array - * An array with 'src' (a public:// URI, or an empty string if the media - * has no image) and 'alt' keys. - */ - public function getImageSourceAndAlt(MediaInterface $media): array { - $media_attributes = $media->get('field_media_az_image')->getValue(); - - if (empty($media_attributes[0]['target_id'])) { - return ['src' => '', 'alt' => '']; - } - - $file = $this->entityTypeManager->getStorage('file')->load($media_attributes[0]['target_id']); - if (!$file) { - return ['src' => '', 'alt' => '']; + catch (\Throwable $e) { + // Defensive: do not break rendering if fields are not present. + } } - return [ - 'src' => $file->getFileUri(), - 'alt' => $media_attributes[0]['alt'] ?? '', - ]; + return $result; } } diff --git a/modules/custom/az_ranking/src/Element/AZRankingItemElement.php b/modules/custom/az_ranking/src/Element/AZRankingItemElement.php new file mode 100644 index 0000000000..f12b91518e --- /dev/null +++ b/modules/custom/az_ranking/src/Element/AZRankingItemElement.php @@ -0,0 +1,76 @@ + NULL, + '#az_item' => NULL, + '#ranking_defaults' => [], + '#status' => FALSE, + '#delta' => 0, + '#field_name' => '', + '#process' => [[$class, 'processRankingItem']], + '#after_build' => [[$class, 'afterBuildRebuildPreview']], + ]; + } + + /** + * Builds the details fields and the preview placeholder. + * + * Delegates to the widget instance (stashed on #widget by formElement()) + * since building these fields needs several widget instance methods as + * #element_validate/#after_build callbacks + * (validateRankingLink()/addAzRankingContextToMediaEdit()/etc.) that + * only make sense as instance methods, not static ones. + */ + public static function processRankingItem(array $element, FormStateInterface $form_state, &$complete_form) { + /** @var \Drupal\az_ranking\Plugin\Field\FieldWidget\AZRankingWidget $widget */ + $widget = $element['#widget']; + return $widget->buildRankingItemElement($element, $form_state); + } + + /** + * Rebuilds the preview from the Form API-populated field values. + */ + public static function afterBuildRebuildPreview(array $element, FormStateInterface $form_state) { + /** @var \Drupal\az_ranking\Plugin\Field\FieldWidget\AZRankingWidget $widget */ + $widget = $element['#widget']; + return $widget->rebuildRankingPreview($element, $form_state); + } + +} diff --git a/modules/custom/az_ranking/src/Plugin/Field/FieldFormatter/AZRankingDefaultFormatter.php b/modules/custom/az_ranking/src/Plugin/Field/FieldFormatter/AZRankingDefaultFormatter.php index 36cf077d40..87d62bdcd8 100644 --- a/modules/custom/az_ranking/src/Plugin/Field/FieldFormatter/AZRankingDefaultFormatter.php +++ b/modules/custom/az_ranking/src/Plugin/Field/FieldFormatter/AZRankingDefaultFormatter.php @@ -8,18 +8,19 @@ use Drupal\Core\Field\FormatterBase; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; -use Drupal\Core\StreamWrapper\PublicStream; use Drupal\Core\StringTranslation\TranslatableMarkup; -use Drupal\Core\Url; use Drupal\paragraphs\ParagraphInterface; use Symfony\Component\DependencyInjection\ContainerInterface; /** * Plugin implementation of the 'az_ranking_default' formatter. * - * Renders the field through the az_quickstart:ranking, az_quickstart:image, - * and az_quickstart:ranking-deck Single Directory Components, so paragraph- - * authored rankings and Canvas-composed rankings share the same markup. + * Renders the field through the az_quickstart:ranking, + * az_quickstart:ranking-image, and az_quickstart:ranking-deck Single + * Directory Components, so paragraph-authored rankings and Canvas-composed + * rankings share the same markup. + * The actual item-to-props mapping lives in AZRankingComponentBuilder, + * shared with AZRankingWidget's live edit-form preview. * * @see https://github.com/az-digital/az_quickstart/issues/5813 */ @@ -33,85 +34,11 @@ class AZRankingDefaultFormatter extends FormatterBase implements ContainerFactoryPluginInterface { /** - * The entity type manager service. + * The AZRankingComponentBuilder service. * - * @var \Drupal\Core\Entity\EntityTypeManagerInterface + * @var \Drupal\az_ranking\AZRankingComponentBuilder */ - protected $entityTypeManager; - - /** - * The AZRankingImageHelper service. - * - * @var \Drupal\az_ranking\AZRankingImageHelper - */ - protected $rankingImageHelper; - - /** - * Drupal\Core\Path\PathValidator definition. - * - * @var \Drupal\Core\Path\PathValidator - */ - protected $pathValidator; - - /** - * Legacy background/hover-background select values, keyed to SDC tokens. - */ - const BACKGROUND_CLASS_MAP = [ - 'text-bg-chili' => 'chili', - 'text-bg-blue' => 'blue', - 'bg-sky' => 'sky', - 'bg-oasis' => 'oasis', - 'text-bg-azurite' => 'azurite', - 'bg-cool-gray' => 'cool-gray', - 'bg-warm-gray' => 'warm-gray', - 'bg-white' => 'white', - 'bg-transparent' => 'transparent', - ]; - - /** - * Legacy font color select values, keyed to SDC tokens. - */ - const FONT_COLOR_CLASS_MAP = [ - 'ranking-text-midnight' => 'midnight', - 'ranking-text-black' => 'black', - 'ranking-text-white' => 'white', - 'ranking-text-az-blue' => 'az-blue', - ]; - - /** - * Legacy link style select values, keyed to SDC tokens. - */ - const LINK_STYLE_CLASS_MAP = [ - 'visually-hidden' => 'hidden', - 'link mt-2' => 'text-link', - 'w-100 btn btn-red mt-2' => 'btn-red', - 'w-100 btn btn-blue mt-2' => 'btn-blue', - 'w-100 btn btn-outline-red mt-2' => 'btn-outline-red', - 'w-100 btn btn-outline-blue mt-2' => 'btn-outline-blue', - 'w-100 btn btn-outline-white mt-2' => 'btn-outline-white', - ]; - - /** - * Legacy per-breakpoint Bootstrap column classes, keyed to column counts. - */ - const DESKTOP_COLUMN_MAP = [ - 'col-lg-12' => '1', - 'col-lg-6' => '2', - 'col-lg-4' => '3', - 'col-lg-3' => '4', - ]; - const TABLET_COLUMN_MAP = [ - 'col-md-12' => '1', - 'col-md-6' => '2', - 'col-md-4' => '3', - 'col-md-3' => '4', - ]; - const PHONE_COLUMN_MAP = [ - 'col-12' => '1', - 'col-6' => '2', - 'col-4' => '3', - 'col-3' => '4', - ]; + protected $componentBuilder; /** * {@inheritdoc} @@ -124,9 +51,7 @@ public static function create(ContainerInterface $container, array $configuratio $plugin_definition, ); - $instance->rankingImageHelper = $container->get('az_ranking.image'); - $instance->entityTypeManager = $container->get('entity_type.manager'); - $instance->pathValidator = $container->get('path.validator'); + $instance->componentBuilder = $container->get('az_ranking.component_builder'); return $instance; } @@ -171,24 +96,43 @@ public function settingsSummary() { */ public function viewElements(FieldItemListInterface $items, $langcode) { $rankings = []; + $interactive_links = (bool) $this->getSetting('interactive_links'); // Computed before the loop (not after, as an earlier version of this // method did) because buildImageComponent() needs the deck's actual // per-breakpoint column counts to clamp each image's width_span_* props // against them - see that method's docblock for why this matters. $deck_props = []; + $ranking_defaults = []; $parent = $items->getEntity(); if ($parent instanceof ParagraphInterface) { $behavior_settings = $parent->getAllBehaviorSettings(); - $deck_props = $this->buildDeckProps($behavior_settings['az_rankings_paragraph_behavior'] ?? []); + $ranking_defaults = $behavior_settings['az_rankings_paragraph_behavior'] ?? []; + $deck_props = $this->componentBuilder->buildDeckProps($ranking_defaults); } foreach ($items as $item) { assert($item instanceof AZRankingItem); - $ranking_type = $item->options['ranking_type'] ?? 'standard'; - $rankings[] = $ranking_type === 'image_only' - ? $this->buildImageComponent($item, $deck_props) - : $this->buildRankingComponent($item); + $values = $this->componentBuilder->extractItemValues($item); + $ranking_type = $values['options']['ranking_type'] ?? 'standard'; + $ranking = $ranking_type === 'image_only' + ? $this->componentBuilder->buildImageComponent($values, $deck_props) + : $this->componentBuilder->buildRankingComponent($values, $ranking_defaults); + + // "Interactive Links" off: disable navigation on this item's link, + // if it has one (az_quickstart:ranking-image never sets link_url, so this + // never applies to image_only items - matches legacy's own scope, + // which only ever put this on the #type => link element itself). + // Deliberately NOT a ranking.component.yml prop - "disable my own + // links because I'm being viewed in a Paragraphs Preview view mode" + // isn't a property of what a ranking card is, it's specific to one + // admin workflow Canvas has no equivalent of. + if (!$interactive_links && isset($ranking['#props']['link_url'])) { + $ranking['#attributes']['class'][] = 'az-ranking-no-follow'; + $ranking['#attached']['library'][] = 'az_ranking/az_ranking_no_follow'; + } + + $rankings[] = $ranking; } return [ @@ -201,156 +145,4 @@ public function viewElements(FieldItemListInterface $items, $langcode) { ]; } - /** - * Builds an az_quickstart:ranking component render array for one item. - * - * Props like clickable/hover_effect/link_style interactions (e.g. link - * title and style being ignored while clickable) are NOT resolved here — - * ranking.twig's own guards are the single source of truth for that - * behavior, so this only needs to map field/behavior values onto clean - * prop values. - */ - protected function buildRankingComponent(AZRankingItem $item): array { - $props = [ - 'heading' => $item->ranking_heading ?? '', - 'description' => $item->ranking_description ?? '', - 'source' => $item->ranking_source ?? '', - ]; - - // Gate on the RAW stored link_uri, not the resolved URL string — a bare - // '#' (a common placeholder in demo content) is a real, present link - // that legacy always showed a button for, but Url::fromUserInput('#') - // legitimately stringifies to '' (confirmed empirically, not assumed). - // Checking the resolved string's emptiness instead of the source value - // silently dropped every ranking using such a placeholder link. - if (!empty($item->link_uri)) { - $props['link_url'] = $this->resolveLinkUrl($item->link_uri); - $props['link_title'] = $item->link_title ?? ''; - $props['link_style'] = self::LINK_STYLE_CLASS_MAP[$item->ranking_link_style ?? ''] ?? 'btn-red'; - } - - $parent = $item->getEntity(); - if ($parent instanceof ParagraphInterface) { - $behavior_settings = $parent->getAllBehaviorSettings(); - $ranking_defaults = $behavior_settings['az_rankings_paragraph_behavior'] ?? []; - $props['header_style'] = ($ranking_defaults['ranking_header_style'] ?? '') === 'ranking-title-thin' ? 'thin' : 'bold'; - $props['alignment'] = ($ranking_defaults['ranking_alignment'] ?? '') === 'text-center' ? 'center' : 'left'; - $props['clickable'] = !empty($ranking_defaults['ranking_clickable']); - $props['hover_effect'] = !empty($ranking_defaults['ranking_hover_effect']); - } - - $background_class = $item->options['class'] ?? ''; - $props['background'] = self::BACKGROUND_CLASS_MAP[$background_class] ?? 'chili'; - $props['hover_background'] = self::BACKGROUND_CLASS_MAP[$item->options['hover_class'] ?? ''] ?? 'chili'; - if ($background_class === 'bg-transparent') { - $props['font_color'] = self::FONT_COLOR_CLASS_MAP[$item->ranking_font_color ?? ''] ?? 'midnight'; - } - - return [ - '#type' => 'component', - '#component' => 'az_quickstart:ranking', - '#props' => $props, - ]; - } - - /** - * Builds an az_quickstart:image component render array for one item. - * - * Unlike the legacy #theme => image_formatter path, this does not apply - * the az_ranking_responsive image style or the custom focal-point JS - * positioning — az_quickstart:image takes a plain file URI, not a themed - * render array, and there is no field/prop for either capability. This is - * a known, disclosed gap versus the legacy image_only rendering, not an - * oversight. - * - * width_span_desktop/tablet/phone are computed here, not just passed - * through legacy's single column_span value, because CSS Grid cannot - * clamp a span against its container's actual column count (a confirmed - * CSS spec gap, not a browser quirk - see image.css's own docblock and - * https://github.com/w3c/csswg-drafts/issues/5852). This reproduces - * legacy's own "min(current row width, column_span)" behavior exactly, - * per breakpoint, using the SAME $deck_props the sibling ranking-deck - * component receives, so the clamp is always correct for whatever the - * paragraph is actually configured to - not a fixed, conservative cap. - * - * @param \Drupal\az_ranking\Plugin\Field\FieldType\AZRankingItem $item - * The field item to build a component for. - * @param array $deck_props - * The az_quickstart:ranking-deck props this item's parent deck will - * receive (columns_desktop/tablet/phone), from buildDeckProps(). - * - * @see \Drupal\az_ranking\AZRankingImageHelper::getImageSourceAndAlt() - */ - protected function buildImageComponent(AZRankingItem $item, array $deck_props): array { - $legacy_span = (int) ($item->options['column_span'] ?? 2); - $props = [ - 'width_span_desktop' => (string) min($legacy_span, (int) ($deck_props['columns_desktop'] ?? 4)), - 'width_span_tablet' => (string) min($legacy_span, (int) ($deck_props['columns_tablet'] ?? 1)), - 'width_span_phone' => (string) min($legacy_span, (int) ($deck_props['columns_phone'] ?? 1)), - ]; - - if (!empty($item->media)) { - $media = $this->entityTypeManager->getStorage('media')->load($item->media); - if ($media) { - $image_data = $this->rankingImageHelper->getImageSourceAndAlt($media); - if ($image_data['src'] !== '') { - $props['src'] = $image_data['src']; - $props['alt'] = $image_data['alt']; - } - } - } - - return [ - '#type' => 'component', - '#component' => 'az_quickstart:image', - '#props' => $props, - ]; - } - - /** - * Maps the parent paragraph's per-breakpoint column settings to deck props. - */ - protected function buildDeckProps(array $ranking_defaults): array { - $az_display_settings = $ranking_defaults['az_display_settings'] ?? []; - return [ - 'columns_desktop' => self::DESKTOP_COLUMN_MAP[$ranking_defaults['ranking_width'] ?? ''] ?? '4', - 'columns_tablet' => self::TABLET_COLUMN_MAP[$az_display_settings['ranking_width_sm'] ?? ''] ?? '1', - 'columns_phone' => self::PHONE_COLUMN_MAP[$az_display_settings['ranking_width_xs'] ?? ''] ?? '1', - ]; - } - - /** - * Resolves a stored link_uri value to a plain URL string, or ''. - * - * Mirrors the URL resolution the legacy formatter already performed - * (public file links, page anchors, and validated internal/external - * paths), only stringified for use as an SDC prop value instead of being - * kept as a Url object for a #type => link render array. - */ - protected function resolveLinkUrl(string $link_uri): string { - if ($link_uri === '') { - return ''; - } - - if (str_starts_with($link_uri, '/' . PublicStream::basePath())) { - return Url::fromUri(urldecode('base:' . $link_uri))->toString(); - } - - if (str_starts_with($link_uri, '#')) { - // Url::fromUserInput('#') is valid but its ->toString() legitimately - // returns '' for a bare fragment (confirmed empirically) - preserve - // the literal anchor directly instead of losing it. A BARE '#' (no - // fragment name) is also rejected by the SDC prop's own - // format: uri-reference validation (confirmed empirically: '#top' - // passes, '#' alone does not) - normalize the empty-fragment case to - // a named one so common placeholder links ('#', used throughout demo - // content) don't fail validation. Same practical behavior (no real - // destination); only the literal href text differs from legacy's '#'. - return $link_uri === '#' ? '#top' : $link_uri; - } - - $url = $this->pathValidator->getUrlIfValid($link_uri); - return $url ? $url->toString() : ''; - } - } diff --git a/modules/custom/az_ranking/src/Plugin/Field/FieldWidget/AZRankingWidget.php b/modules/custom/az_ranking/src/Plugin/Field/FieldWidget/AZRankingWidget.php index 0272153b30..77670ddb93 100644 --- a/modules/custom/az_ranking/src/Plugin/Field/FieldWidget/AZRankingWidget.php +++ b/modules/custom/az_ranking/src/Plugin/Field/FieldWidget/AZRankingWidget.php @@ -30,13 +30,6 @@ class AZRankingWidget extends WidgetBase { // Default initial text format for rankings. const AZ_RANKING_DEFAULT_TEXT_FORMAT = 'az_standard'; - /** - * The AZRankingImageHelper service. - * - * @var \Drupal\az_ranking\AZRankingImageHelper - */ - protected $rankingImageHelper; - /** * Drupal\Core\Path\PathValidator definition. * @@ -45,11 +38,11 @@ class AZRankingWidget extends WidgetBase { protected $pathValidator; /** - * Drupal\Core\Entity\EntityTypeManagerInterface definition. + * The AZRankingComponentBuilder service. * - * @var \Drupal\Core\Entity\EntityTypeManagerInterface + * @var \Drupal\az_ranking\AZRankingComponentBuilder */ - protected $entityTypeManager; + protected $componentBuilder; /** * {@inheritdoc} @@ -62,9 +55,8 @@ public static function create(ContainerInterface $container, array $configuratio $plugin_definition, ); - $instance->rankingImageHelper = $container->get('az_ranking.image'); $instance->pathValidator = $container->get('path.validator'); - $instance->entityTypeManager = $container->get('entity_type.manager'); + $instance->componentBuilder = $container->get('az_ranking.component_builder'); return $instance; } @@ -118,66 +110,55 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen $widget_state = static::getWidgetState($field_parents, $field_name, $form_state); $status = (isset($widget_state['open_status'][$delta])) ? $widget_state['open_status'][$delta] : FALSE; - // We may have had a deleted row. This shouldn't be necessary to check, but - // The experimental paragraphs widget extracts values before the submit - // handler. - if (isset($widget_state['original_deltas'][$delta]) && ($widget_state['original_deltas'][$delta] !== $delta)) { - $delta = $widget_state['original_deltas'][$delta]; - } - // New field values shouldn't be considered collapsed. if ($item->isEmpty()) { $status = TRUE; } - // Determine current ranking style for preview. - $ranking_classes = 'ranking card'; + // Needed for the unique-ID generation (behavior-settings lookup) and + // for rebuildRankingPreview() (see AZRankingItemElement). $parent = $item->getEntity(); - - // Get settings from parent paragraph. + $ranking_defaults = []; if ($parent instanceof ParagraphInterface) { - // Get the behavior settings for the parent. $parent_config = $parent->getAllBehaviorSettings(); + $ranking_defaults = $parent_config['az_rankings_paragraph_behavior'] ?? []; + } + + // Building the actual fields + preview is delegated to a real Element + // plugin (AZRankingItemElement) instead of happening directly here - + // see that class's docblock for why. #widget carries the widget + // instance through so its own instance methods (element_validate/ + // after_build callbacks used by individual fields, and the two methods + // below) are reachable from the Element class's static callbacks. This + // is safe to cache/serialize across AJAX rebuilds because WidgetBase -> + // PluginBase uses DependencySerializationTrait. + $element['#type'] = 'az_ranking_item'; + $element['#widget'] = $this; + $element['#az_item'] = $item; + $element['#ranking_defaults'] = $ranking_defaults; + $element['#status'] = $status; + $element['#delta'] = $delta; + $element['#field_name'] = $field_name; - // See if the parent behavior defines some ranking-specific settings. - if (!empty($parent_config['az_rankings_paragraph_behavior'])) { - $ranking_defaults = $parent_config['az_rankings_paragraph_behavior']; - $ranking_classes = $ranking_defaults['ranking_hover_style'] ?? 'ranking card'; - } - } - - // Add overflow-hidden class. - $ranking_classes .= ' overflow-hidden'; + $element['#attached']['library'][] = 'az_ranking/az_ranking'; - // Handle hover effect and background classes like the formatter does. - $ranking_hover_effect = FALSE; - if ($parent instanceof ParagraphInterface) { - $parent_config = $parent->getAllBehaviorSettings(); - if (!empty($parent_config['az_rankings_paragraph_behavior'])) { - $ranking_hover_effect = $parent_config['az_rankings_paragraph_behavior']['ranking_hover_effect'] ?? FALSE; - } - } + return $element; + } - // Hover effect takes precedence over non-hover-effect backgrounds. - if ($ranking_hover_effect) { - // Try to read hover-specific value from the item. - $hover_class = ''; - if (!empty($item->options['hover_class'])) { - $hover_class = $item->options['hover_class']; - } - // Fallback to persisted background class if no hover-specific value. - if (empty($hover_class) && !empty($item->options['class'])) { - $hover_class = $item->options['class']; - } - if (!empty($hover_class) && $item->options['ranking_type'] !== 'image_only') { - $ranking_classes .= ' from-hover-effect ' . $hover_class; - } - } - else { - if (!empty($item->options['class']) && $item->options['ranking_type'] !== 'image_only') { - $ranking_classes .= ' non-hover-effect ' . $item->options['class']; - } - } + /** + * Builds the details fields and preview placeholder for a ranking item. + * + * Called from AZRankingItemElement::processRankingItem() (this widget's + * #type => az_ranking_item elements stash $this on #widget for exactly + * this purpose). + */ + public function buildRankingItemElement(array $element, FormStateInterface $form_state): array { + /** @var \Drupal\az_ranking\Plugin\Field\FieldType\AZRankingItem $item */ + $item = $element['#az_item']; + $status = $element['#status']; + $delta = $element['#delta']; + $field_parents = $element['#field_parents']; + $parent = $item->getEntity(); // Wrap everything in a details element. $element['details'] = [ @@ -188,7 +169,7 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen '#attributes' => ['class' => ['az-ranking-widget']], ]; - // When closed, show a preview of the ranking. + // When closed, add a preview wrapper. if (!$status) { $element['preview_wrapper'] = [ '#type' => 'container', @@ -200,8 +181,16 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen '#weight' => -10, ]; - // Build the preview using the helper method. - $element['preview_wrapper']['preview'] = $this->buildRankingPreview($item, $ranking_classes); + // Placeholder - rebuildRankingPreview() populates this from the + // Form API-populated field #values, which stay correct through + // drag-and-drop reorder + AJAX rebuilds. Building it here from $item + // instead (as this used to) reflects $items' stored array order, + // which can drift out of sync with the visual row after a reorder. + $element['preview_wrapper']['preview'] = [ + '#type' => 'component', + '#component' => 'az_quickstart:ranking', + '#props' => [], + ]; } // Create a globally unique ID that includes @@ -446,12 +435,95 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen ], ]; - // Attach the library and return the element. - $element['#attached']['library'][] = 'az_ranking/az_ranking'; + return $element; + } - // Store delta and field name for reference. - $element['#delta'] = $delta; - $element['#field_name'] = $field_name; + /** + * Rebuilds the preview from the Form API-populated field values. + * + * Called from AZRankingItemElement::afterBuildRebuildPreview() (this + * widget's #type => az_ranking_item elements declare that as their own + * #after_build in AZRankingItemElement::getInfo()). + * + * Rebuilds preview_wrapper.preview from the same Form API-populated + * #value the form fields themselves use, instead of $item, so the + * preview stays in sync with its row after drag-and-drop reorder + an + * AJAX rebuild. #after_build runs after this element's children + * (including details' fields) have already gone through the Form API's + * own value-population, so #value here is already correct for wherever + * this element currently sits in the (possibly just-reordered) tree. + * + * @see https://github.com/az-digital/az_quickstart/pull/5309 + */ + public function rebuildRankingPreview(array $element, FormStateInterface $form_state) { + // Nothing to rebuild when the details are open (no preview shown). + if (!isset($element['preview_wrapper']['preview'])) { + return $element; + } + + $details = $element['details'] ?? []; + $ranking_defaults = $element['#ranking_defaults'] ?? []; + + $values = [ + 'ranking_heading' => $details['ranking_heading']['#value'] ?? $details['ranking_heading']['#default_value'] ?? '', + 'ranking_description' => $details['ranking_description']['#value'] ?? $details['ranking_description']['#default_value'] ?? '', + 'ranking_source' => $details['ranking_source']['#value'] ?? $details['ranking_source']['#default_value'] ?? '', + 'link_uri' => $details['link_uri']['#value'] ?? $details['link_uri']['#default_value'] ?? '', + 'link_title' => $details['link_title']['#value'] ?? $details['link_title']['#default_value'] ?? '', + 'ranking_link_style' => $details['ranking_link_style']['#value'] ?? $details['ranking_link_style']['#default_value'] ?? 'w-100 btn btn-red mt-2', + 'ranking_font_color' => $details['ranking_font_color']['#value'] ?? $details['ranking_font_color']['#default_value'] ?? 'ranking-text-midnight', + 'options' => [ + 'class' => $details['options']['#value'] ?? $details['options']['#default_value'] ?? 'text-bg-chili', + 'hover_class' => $details['options_hover_effect']['#value'] ?? $details['options_hover_effect']['#default_value'] ?? 'text-bg-chili', + 'ranking_type' => $details['ranking_type']['#value'] ?? $details['ranking_type']['#default_value'] ?? 'standard', + 'column_span' => $details['column_span']['#value'] ?? $details['column_span']['#default_value'] ?? 2, + ], + ]; + + // az_media_library implements a real #value_callback (see + // Drupal\media_library_form_element\Element\MediaLibrary::valueCallback, + // confirmed by reading it directly), so #value should already be + // reorder-correct here just like the fields above. Still read from raw + // user input as a fallback/cross-check - PR #5309 found the equivalent + // field unreliable via #value/#default_value alone on this same widget + // family, and this is cheap insurance against the same class of bug. + $media_id = NULL; + $delta = $element['#delta']; + $field_name = $element['#field_name']; + $field_parents = $element['#field_parents']; + $user_input = $form_state->getUserInput() ?? []; + $input_path = array_merge($field_parents, [$field_name, $delta, 'details', 'media']); + $media_input = NestedArray::getValue($user_input, $input_path); + if (is_array($media_input) && !empty($media_input['media_library_selection'])) { + $ids = array_filter(explode(',', $media_input['media_library_selection'])); + $media_id = $ids ? (int) reset($ids) : NULL; + } + elseif (is_numeric($media_input)) { + $media_id = (int) $media_input; + } + // Fallback for initial load (no user input for this field yet). + if ($media_id === NULL && $media_input === NULL) { + $media_id = $details['media']['#value'] ?? $details['media']['#default_value'] ?? NULL; + } + $values['media'] = $media_id; + + $ranking_type = $values['options']['ranking_type'] ?? 'standard'; + if ($ranking_type === 'image_only') { + // width_span_* has no visual effect here (this preview is a single + // box, not a grid), but computing deck_props the same way the + // formatter does keeps the props themselves accurate, in case that + // ever changes. + $deck_props = $this->componentBuilder->buildDeckProps($ranking_defaults); + $preview = $this->componentBuilder->buildImageComponent($values, $deck_props); + } + else { + $preview = $this->componentBuilder->buildRankingComponent($values, $ranking_defaults); + } + + $preview['#attributes']['class'][] = 'widget-preview-ranking'; + $preview['#attributes']['style'] = 'transform: scale(0.8); transform-origin: center;'; + + $element['preview_wrapper']['preview'] = $preview; return $element; } @@ -756,201 +828,6 @@ public function massageFormValues(array $values, array $form, FormStateInterface return $values; } - /** - * Build the preview render array for a ranking item. - * - * @param \Drupal\az_ranking\Plugin\Field\FieldType\AZRankingItem $item - * The ranking item. - * @param string $ranking_classes - * The ranking CSS classes. - * - * @return array - * The preview render array. - */ - protected function buildRankingPreview($item, $ranking_classes) { - $parent = $item->getEntity(); - - // Get ranking settings from parent paragraph. - $ranking_hover_effect = FALSE; - $ranking_clickable = FALSE; - $ranking_header_style = NULL; - $ranking_alignment = NULL; - if ($parent instanceof ParagraphInterface) { - $parent_config = $parent->getAllBehaviorSettings(); - if (!empty($parent_config['az_rankings_paragraph_behavior'])) { - $ranking_defaults = $parent_config['az_rankings_paragraph_behavior']; - $ranking_hover_effect = $ranking_defaults['ranking_hover_effect'] ?? FALSE; - $ranking_clickable = $ranking_defaults['ranking_clickable'] ?? FALSE; - $ranking_header_style = $ranking_defaults['ranking_header_style'] ?? NULL; - $ranking_alignment = $ranking_defaults['ranking_alignment'] ?? 'text-left'; - } - } - // Apply paragraph settings found in AZRankingDefaultFormatter. - if ($item->options['ranking_type'] !== 'image_only') { - $ranking_classes .= ' ' . $ranking_alignment; - } - - // Apply clickable ranking styles (like formatter does). - $link_title = $item->link_title ?? ''; - $ranking_link_style = $item->ranking_link_style ?? 'w-100 btn btn-red mt-2'; - - if ($ranking_clickable) { - // Add shadow when ranking is clickable. - $ranking_classes .= ' shadow'; - if (!empty($ranking_hover_effect) && $item->options['ranking_type'] !== 'image_only') { - $ranking_classes .= ' ranking-bold-hover'; - } - } - else { - // Ranking is not clickable. - $ranking_hover_effect = FALSE; - } - - // Link color override. - if (str_contains($ranking_link_style, 'link')) { - if (str_contains($item->options['class'], 'bg-oasis') || - str_contains($item->options['class'], 'bg-sky')) { - $ranking_link_style .= ' text-midnight'; - } - } - // Determine font color and text color override. - $ranking_font_color = $item->ranking_font_color ?? 'ranking-text-midnight'; - $text_color_override = ''; - - // Determine source classes based on background color (like formatter does). - $ranking_source_classes = ''; - $background_class = ''; - - // Get the appropriate background class depending on hover effect. - if ($ranking_hover_effect) { - if (!empty($item->options['hover_class'])) { - $background_class = $item->options['hover_class']; - } - // Fallback to the persisted background class. - if (empty($background_class) && !empty($item->options['class'])) { - $background_class = $item->options['class']; - } - } - else { - $background_class = $item->options['class'] ?? ''; - } - - // Apply mt-auto if NOT transparent background. - if (!str_contains($background_class, 'bg-transparent')) { - $ranking_source_classes = 'mt-auto'; - } - else { - // transparent: apply font color to ranking _font_color and _classes. - $ranking_font_color = ' ' . $item->ranking_font_color; - $ranking_classes .= ' ' . $item->ranking_font_color; - } - - // Set text_color_override based on background color (like formatter does). - if (!$ranking_hover_effect) { - if (!empty($item->options['class'])) { - switch (TRUE) { - case str_contains($item->options['class'], 'bg-sky'): - $text_color_override = 'text-midnight'; - break; - - case str_contains($item->options['class'], 'bg-cool-gray'): - $text_color_override = 'text-azurite'; - break; - - case str_contains($item->options['class'], 'bg-warm-gray'): - $text_color_override = 'text-midnight'; - break; - - case str_contains($item->options['class'], 'bg-white'): - $text_color_override = 'text-midnight'; - break; - - case str_contains($item->options['class'], 'bg-oasis'): - $text_color_override = 'text-midnight'; - break; - } - } - } - else { - // Override hover class. - if (!empty($item->options['hover_class'])) { - switch (TRUE) { - case str_contains($item->options['hover_class'], 'bg-sky'): - $text_color_override = 'text-midnight'; - break; - - case str_contains($item->options['hover_class'], 'bg-cool-gray'): - $text_color_override = 'text-azurite'; - break; - - case str_contains($item->options['hover_class'], 'bg-oasis'): - $text_color_override = 'text-midnight'; - break; - } - } - } - - // Build media render array. - $media_render_array = NULL; - $media_id = $item->media ?? NULL; - if (!empty($media_id)) { - if ($media = $this->entityTypeManager->getStorage('media')->load($media_id)) { - $media_render_array = $this->rankingImageHelper->generateImageRenderArray($media); - } - } - - // Build link render array and URL. - $link_render_array = NULL; - $link_url = NULL; - if ($item->link_uri) { - if (!empty($item->link_uri) && str_starts_with($item->link_uri, '/' . PublicStream::basePath())) { - $link_url = Url::fromUri(urldecode('base:' . $item->link_uri)); - } - else { - $link_url = $this->pathValidator->getUrlIfValid($item->link_uri ?? ''); - } - - if ($link_url) { - $link_classes = explode(' ', $ranking_link_style); - - // Add stretched-link class if ranking is clickable. - if (!empty($ranking_clickable)) { - $link_classes[] = 'stretched-link'; - } - - $link_render_array = [ - '#type' => 'link', - '#title' => $link_title ?: ($item->ranking_source ?? ''), - '#url' => $link_url, - '#attributes' => ['class' => $link_classes], - ]; - } - } - - return [ - '#theme' => 'az_ranking', - '#media' => $media_render_array, - '#ranking_heading' => $item->ranking_heading ?? '', - '#ranking_description' => $item->ranking_description ?? '', - '#ranking_source' => $item->ranking_source ?? '', - '#ranking_header_style' => $ranking_header_style, - '#ranking_alignment' => $ranking_alignment, - '#ranking_hover_effect' => $ranking_hover_effect, - '#ranking_clickable' => $ranking_clickable, - '#ranking_font_color' => $ranking_font_color, - '#text_color_override' => $text_color_override, - '#ranking_link_style' => $ranking_link_style, - '#ranking_source_classes' => $ranking_source_classes, - '#link' => $link_render_array, - '#link_url' => $link_url, - '#link_title' => $link_title, - '#attributes' => [ - 'class' => $ranking_classes . ' widget-preview-ranking', - 'style' => 'transform: scale(0.8); transform-origin: center;', - ], - ]; - } - /** * Add az_ranking_context query parameter to media edit links. */ diff --git a/modules/custom/az_ranking/templates/az-ranking.html.twig b/modules/custom/az_ranking/templates/az-ranking.html.twig deleted file mode 100644 index f2d5b39aa8..0000000000 --- a/modules/custom/az_ranking/templates/az-ranking.html.twig +++ /dev/null @@ -1,73 +0,0 @@ -{# -/** - * @file - * Theme implementation to display an AZ Ranking. - * - * Available variables: - * - attributes: Ranking element wrapper attributes. - * - media: The media field of a ranking. Displays either media, or image. - * - ranking_header_style: The title heading style of a ranking. - * - ranking_heading: The title field of a ranking. - * - ranking_header_style: The header font style choice for a ranking. - * - ranking_description: The body field of a ranking. - * - ranking_source: The source field on a ranking. - * - ranking_source_classes: Classes for the Source field of a ranking. - * - ranking_hover_effect: If we're using the contrasting hover effect on this ranking. - * - ranking_clickable: If the ranking card is clickable. - * - text_color_override: Overriding default text colors. - * - link_url: The link URL only. Used when ranking is clickable. - * - link: The rendered link as a styled link. Cannot be customized when ranking is clickable. - * @ingroup themeable - */ - -#} -{% set attributes = attributes.addClass('h-100', 'az-ranking-responsive', 'border-0', 'overflow-hidden') %} - - -{% if media %} -
- {{ media }} -
-{% else %} -
-
-
- {% if ranking_heading %} - {% if ranking_header_style == "ranking-title-thin" %} -

- {% if ranking_clickable %} - {{ ranking_heading }} - {% else %} - {{ ranking_heading }} - {% endif %} -

- {% else %} -

- {% if ranking_clickable %} - {{ ranking_heading }} - {% else %} - {{ ranking_heading }} - {% endif %} -

- {% endif %} - {% endif %} - {% if ranking_description %} -

{{ ranking_description }}

- {% endif %} -
- {% if ranking_source %} -
- {{ ranking_source|nl2br }} -
- {% endif %} -
- {% if ranking_hover_effect %} - - {% else %} - {% if link %} - {{ link }} - {% endif %} - {% endif %} -
- {% endif %} - From bc9d35196da199915545db882e2730ba721e0b28 Mon Sep 17 00:00:00 2001 From: Kevin Lu <79181817+kevdevlu@users.noreply.github.com> Date: Mon, 27 Jul 2026 04:22:18 -0700 Subject: [PATCH 06/21] Decouple ranking-image focal-point cropping from original width/height ranking-image.js's object-position formula only ever uses the image's width/height as a RATIO, never as absolute values, and az_ranking_responsive's image_scale effect always preserves aspect ratio (even when upscaling) - so the loaded 's own naturalWidth/naturalHeight gives the exact same ratio as the true original, with no need to pass original dimensions down as a prop at all. Switched to using them directly instead of reading data-original-width/data-original-height, which also fixes a latent bug: those attributes, when present with stale values (e.g. a Canvas-placed instance left at its example defaults), would have fed wrong data into the calculation instead of ever falling back. With that dependency gone, original_width/original_height no longer serve any purpose - their only other use (native width/height attributes) turned out to be inert too, since ranking-image.css absolutely-positions the image at 100%/100% of its parent regardless of intrinsic size. Removed the props entirely, along with the AZRankingImageHelper computation that read them from the file (a real file I/O call on every render) and the now-fully-unused ImageFactory dependency it required. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QZ6mWfeVEr5qdPLXAEmtqP --- .../ranking-image/ranking-image.component.yml | 14 ------- components/ranking-image/ranking-image.js | 42 ++++++++++--------- components/ranking-image/ranking-image.twig | 31 ++++---------- .../custom/az_ranking/az_ranking.services.yml | 1 - .../src/AZRankingComponentBuilder.php | 2 - .../az_ranking/src/AZRankingImageHelper.php | 22 ++-------- 6 files changed, 34 insertions(+), 78 deletions(-) diff --git a/components/ranking-image/ranking-image.component.yml b/components/ranking-image/ranking-image.component.yml index f03bed87dd..46b540e8c9 100644 --- a/components/ranking-image/ranking-image.component.yml +++ b/components/ranking-image/ranking-image.component.yml @@ -91,20 +91,6 @@ props: maximum: 1 examples: - 0.5 - original_width: - type: integer - title: Original Width - description: The image's true width in pixels, before any cropping. Required alongside Focal Point X/Y for accurate focal-point cropping; ignored otherwise. - minimum: 1 - examples: - - 1000 - original_height: - type: integer - title: Original Height - description: The image's true height in pixels, before any cropping. Required alongside Focal Point X/Y for accurate focal-point cropping; ignored otherwise. - minimum: 1 - examples: - - 500 utility_classes: type: array title: Utility Classes diff --git a/components/ranking-image/ranking-image.js b/components/ranking-image/ranking-image.js index fc0987d811..80d863ac6c 100644 --- a/components/ranking-image/ranking-image.js +++ b/components/ranking-image/ranking-image.js @@ -8,23 +8,24 @@ * objectPosY = (focalY * imageH - 0.5 * containerH) / (imageH - containerH) * * This ensures the focal point stays centered in the visible area when - * object-fit: cover crops the image. + * object-fit: cover crops the image. imageW/imageH come from the loaded + * 's own naturalWidth/naturalHeight. * - * Same calculation as modules/custom/az_ranking/js/az-ranking-focal-point-calc.js - * (kept as a separate, deliberately duplicated copy there for the az_ranking - * widget's own edit-form preview, which renders through a different markup - * path). This copy targets .az-ranking-image__img and lives on the component - * itself — not in az_ranking — because focal_x/focal_y/original_width/ - * original_height are plain az_quickstart:ranking-image props with no - * dependency on az_ranking, and the component must keep working (focal point - * included) wherever it's placed, Canvas or paragraph-authored, az_ranking - * installed or not. + * Targets .az-ranking-image__img and lives on the component itself, not + * in az_ranking, because focal_x/focal_y are plain az_quickstart: + * ranking-image props with no dependency on az_ranking, and the component + * must keep working (focal point included) wherever it's placed, Canvas + * or paragraph-authored, az_ranking installed or not. */ ((Drupal, once) => { Drupal.behaviors.azRankingImageFocalPoint = { attach: (context) => { - const images = once('az-ranking-image-focal-point', '.az-ranking-image__img', context); + const images = once( + 'az-ranking-image-focal-point', + '.az-ranking-image__img', + context, + ); if (images.length === 0) return; @@ -46,14 +47,17 @@ const containerW = img.offsetWidth; const containerH = img.offsetHeight; - // Get ORIGINAL image dimensions (before any image style scaling). - // Focal points are stored relative to original dimensions. - const originalW = - parseFloat(img.getAttribute('data-original-width')) || - img.naturalWidth; - const originalH = - parseFloat(img.getAttribute('data-original-height')) || - img.naturalHeight; + // Use the loaded (styled-derivative) image's own natural dimensions. + // The formula below only ever uses these as a RATIO + // (imageRatio = originalW / originalH), never as absolute values, + // and az_ranking_responsive's image_scale effect always preserves + // aspect ratio (that's what distinguishes it from + // image_scale_and_crop), even when upscaling - so naturalWidth/ + // naturalHeight of the styled derivative the browser actually + // loaded gives the exact same ratio as the true original, with no + // need to pass original dimensions down as a separate prop at all. + const originalW = img.naturalWidth; + const originalH = img.naturalHeight; // Skip if dimensions not available yet if (!originalW || !originalH || !containerW || !containerH) return; diff --git a/components/ranking-image/ranking-image.twig b/components/ranking-image/ranking-image.twig index fee7760b5a..cc8ddf3e07 100644 --- a/components/ranking-image/ranking-image.twig +++ b/components/ranking-image/ranking-image.twig @@ -19,7 +19,7 @@ * image style (scale + WebP conversion) and falls back to plain * file_url()-equivalent behavior if that style is ever unavailable, so a * missing/misconfigured style degrades gracefully rather than breaking - * the page. This is a genuine, if soft, dependency on az_media (not + * the page. This is a soft dependency on az_media (not * az_ranking) — the filter itself is generic and lives in az_media * regardless of which style name gets passed to it here. * - decorative: When true, forces empty alt text AND hides the image from @@ -28,20 +28,11 @@ * - focal_x / focal_y: Focal point as a 0-1 fraction of the image's width/ * height, kept visible when object-fit: cover crops the image to fill * its container. Rendered as data-focal-x/data-focal-y attributes and - * applied client-side (ranking-image.js) — no server-side cropping, since the - * image's effective on-screen aspect ratio depends on live CSS Grid - * layout, not a fixed, enumerable set of image styles (same reasoning - * as width_span_* below). Only takes effect when original_width/ - * original_height are also set; otherwise ignored and the image falls - * back to plain centered object-fit: cover cropping. - * - original_width / original_height: The image's true pixel dimensions - * before any cropping, needed because focal points are stored relative - * to the original image, not whatever size ends up displayed. Also - * rendered as native width/height attributes whenever present - * (independent of whether focal_x/focal_y are also set) — gives any - * renderer a correct intrinsic aspect ratio before CSS loads, including - * contexts that may not apply this component's own stylesheet, like - * Canvas's library hover-preview. + * applied client-side (ranking-image.js) — no server-side cropping, + * since the image's effective on-screen aspect ratio depends on live + * CSS Grid layout, not a fixed, enumerable set of image styles (same + * reasoning as width_span_* below). ranking-image.js computes the crop + * from the loaded 's own naturalWidth/naturalHeight. * - width_span_desktop / width_span_tablet / width_span_phone: 1-4 each. * Grid columns this image spans at each breakpoint when placed inside a * Ranking Deck (or any CSS grid layout) — deliberately per-breakpoint, @@ -65,9 +56,7 @@ {% set focal_x = focal_x|default(null) %} {% set focal_y = focal_y|default(null) %} -{% set original_width = original_width|default(null) %} -{% set original_height = original_height|default(null) %} -{% set has_focal_point = focal_x is not null and focal_y is not null and original_width is not null and original_height is not null %} +{% set has_focal_point = focal_x is not null and focal_y is not null %} {% set allowed_spans = ['1', '2', '3', '4'] %} {% set width_span_desktop = width_span_desktop|default('2') in allowed_spans ? width_span_desktop|default('2') : '2' %} @@ -101,15 +90,9 @@ alt="{{ alt }}" loading="lazy" class="az-ranking-image__img" - {% if original_width is not null and original_height is not null %} - width="{{ original_width }}" - height="{{ original_height }}" - {% endif %} {% if has_focal_point %} data-focal-x="{{ focal_x }}" data-focal-y="{{ focal_y }}" - data-original-width="{{ original_width }}" - data-original-height="{{ original_height }}" {% endif %} > {% endif %} diff --git a/modules/custom/az_ranking/az_ranking.services.yml b/modules/custom/az_ranking/az_ranking.services.yml index 195584a638..b5369f3723 100644 --- a/modules/custom/az_ranking/az_ranking.services.yml +++ b/modules/custom/az_ranking/az_ranking.services.yml @@ -3,7 +3,6 @@ services: class: Drupal\az_ranking\AZRankingImageHelper arguments: - '@entity_type.manager' - - '@image.factory' az_ranking.component_builder: class: Drupal\az_ranking\AZRankingComponentBuilder arguments: diff --git a/modules/custom/az_ranking/src/AZRankingComponentBuilder.php b/modules/custom/az_ranking/src/AZRankingComponentBuilder.php index 648bd89e4f..e70d80c643 100644 --- a/modules/custom/az_ranking/src/AZRankingComponentBuilder.php +++ b/modules/custom/az_ranking/src/AZRankingComponentBuilder.php @@ -243,8 +243,6 @@ public function buildImageComponent(array $values, array $deck_props): array { if ($image_data['focal_x'] !== NULL && $image_data['focal_y'] !== NULL) { $props['focal_x'] = $image_data['focal_x']; $props['focal_y'] = $image_data['focal_y']; - $props['original_width'] = $image_data['original_width']; - $props['original_height'] = $image_data['original_height']; } } } diff --git a/modules/custom/az_ranking/src/AZRankingImageHelper.php b/modules/custom/az_ranking/src/AZRankingImageHelper.php index e5bf1c964a..10558f5a92 100644 --- a/modules/custom/az_ranking/src/AZRankingImageHelper.php +++ b/modules/custom/az_ranking/src/AZRankingImageHelper.php @@ -4,7 +4,6 @@ use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Entity\FieldableEntityInterface; -use Drupal\Core\Image\ImageFactory; use Drupal\media\MediaInterface; /** @@ -19,23 +18,15 @@ class AZRankingImageHelper { */ protected $entityTypeManager; - /** - * The image factory service. - * - * @var \Drupal\Core\Image\ImageFactory - */ - protected $imageFactory; - /** * Constructs a new AZRankingImageHelper object. */ - public function __construct(EntityTypeManagerInterface $entity_type_manager, ImageFactory $image_factory) { + public function __construct(EntityTypeManagerInterface $entity_type_manager) { $this->entityTypeManager = $entity_type_manager; - $this->imageFactory = $image_factory; } /** - * Get a plain file URI, alt text, and focal point data for the ranking-image SDC. + * Get a plain file URI, alt text, and focal point data for ranking-image. * * Used for both the published az_quickstart:ranking-image render and the * widget's own live edit-form preview, via AZRankingComponentBuilder:: @@ -48,8 +39,8 @@ public function __construct(EntityTypeManagerInterface $entity_type_manager, Ima * * @return array * An array with 'src' and 'alt' (empty strings if the media has no - * image), plus 'focal_x', 'focal_y', 'original_width', and - * 'original_height' (all NULL if the media has no focal point set). + * image), plus 'focal_x' and 'focal_y' (both NULL if the media has no + * focal point set). */ public function getImageSourceAltAndFocalPoint(MediaInterface $media): array { $empty = [ @@ -57,8 +48,6 @@ public function getImageSourceAltAndFocalPoint(MediaInterface $media): array { 'alt' => '', 'focal_x' => NULL, 'focal_y' => NULL, - 'original_width' => NULL, - 'original_height' => NULL, ]; $media_attributes = $media->get('field_media_az_image')->getValue(); @@ -79,11 +68,8 @@ public function getImageSourceAltAndFocalPoint(MediaInterface $media): array { try { if ($media->hasField('field_focal_point_x') && $media->hasField('field_focal_point_y')) { if (!$media->get('field_focal_point_x')->isEmpty() && !$media->get('field_focal_point_y')->isEmpty()) { - $original_image = $this->imageFactory->get($file->getFileUri()); $result['focal_x'] = (float) $media->get('field_focal_point_x')->value; $result['focal_y'] = (float) $media->get('field_focal_point_y')->value; - $result['original_width'] = $original_image->getWidth(); - $result['original_height'] = $original_image->getHeight(); } } } From 2461419c3ca97bc76d4b9ac15251ff2c5750c373 Mon Sep 17 00:00:00 2001 From: Kevin Lu <79181817+kevdevlu@users.noreply.github.com> Date: Mon, 27 Jul 2026 04:22:44 -0700 Subject: [PATCH 07/21] Fix ESLint/PHPCS/PHPStan CI failures - ImageStyleTwigExtension: replace the static ImageStyle::load() call with an injected EntityTypeManagerInterface (DrupalPractice.Objects.GlobalClass.GlobalClass). - AZRankingItemElement: fix an incorrect import - extended the deprecated Drupal\Core\Render\Element\RenderElement (aliased to RenderElementBase locally, which doesn't change which class is actually extended) instead of the real Drupal\Core\Render\Element\RenderElementBase. - AZRankingItem: merge two adjacent docblocks ({@inheritdoc} immediately followed by a bare @todo-only block) into one - the second was missing a short description on its own. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QZ6mWfeVEr5qdPLXAEmtqP --- modules/custom/az_media/az_media.services.yml | 1 + modules/custom/az_media/src/Twig/ImageStyleTwigExtension.php | 5 +++-- .../custom/az_ranking/src/Element/AZRankingItemElement.php | 2 +- .../az_ranking/src/Plugin/Field/FieldType/AZRankingItem.php | 4 +--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/modules/custom/az_media/az_media.services.yml b/modules/custom/az_media/az_media.services.yml index d4dc569d1b..f24b452b83 100644 --- a/modules/custom/az_media/az_media.services.yml +++ b/modules/custom/az_media/az_media.services.yml @@ -3,5 +3,6 @@ services: class: Drupal\az_media\Twig\ImageStyleTwigExtension arguments: - '@file_url_generator' + - '@entity_type.manager' tags: - { name: twig.extension } diff --git a/modules/custom/az_media/src/Twig/ImageStyleTwigExtension.php b/modules/custom/az_media/src/Twig/ImageStyleTwigExtension.php index 82fe77f8ee..7702f826c2 100644 --- a/modules/custom/az_media/src/Twig/ImageStyleTwigExtension.php +++ b/modules/custom/az_media/src/Twig/ImageStyleTwigExtension.php @@ -2,8 +2,8 @@ namespace Drupal\az_media\Twig; +use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\File\FileUrlGeneratorInterface; -use Drupal\image\Entity\ImageStyle; use Twig\Extension\AbstractExtension; use Twig\TwigFilter; @@ -21,6 +21,7 @@ class ImageStyleTwigExtension extends AbstractExtension { public function __construct( protected FileUrlGeneratorInterface $fileUrlGenerator, + protected EntityTypeManagerInterface $entityTypeManager, ) {} /** @@ -48,7 +49,7 @@ public function applyImageStyle(?string $uri, string $style_name): string { if (empty($uri)) { return ''; } - $style = ImageStyle::load($style_name); + $style = $this->entityTypeManager->getStorage('image_style')->load($style_name); if ($style) { return $style->buildUrl($uri); } diff --git a/modules/custom/az_ranking/src/Element/AZRankingItemElement.php b/modules/custom/az_ranking/src/Element/AZRankingItemElement.php index f12b91518e..e5e697d567 100644 --- a/modules/custom/az_ranking/src/Element/AZRankingItemElement.php +++ b/modules/custom/az_ranking/src/Element/AZRankingItemElement.php @@ -4,7 +4,7 @@ use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Render\Attribute\RenderElement; -use Drupal\Core\Render\Element\RenderElement as RenderElementBase; +use Drupal\Core\Render\Element\RenderElementBase; /** * Provides a render element for one az_ranking field item. diff --git a/modules/custom/az_ranking/src/Plugin/Field/FieldType/AZRankingItem.php b/modules/custom/az_ranking/src/Plugin/Field/FieldType/AZRankingItem.php index 9cc83a9430..c263a9d20d 100644 --- a/modules/custom/az_ranking/src/Plugin/Field/FieldType/AZRankingItem.php +++ b/modules/custom/az_ranking/src/Plugin/Field/FieldType/AZRankingItem.php @@ -138,9 +138,7 @@ public static function schema(FieldStorageDefinitionInterface $field_definition) /** * {@inheritdoc} - */ - - /** + * * @todo samplePreview */ public static function generateSampleValue(FieldDefinitionInterface $field_definition) { From ac14e8d67794e3dfeda89dce797e9ecf6438ffec Mon Sep 17 00:00:00 2001 From: Kevin Lu <79181817+kevdevlu@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:33:53 -0700 Subject: [PATCH 08/21] Remove canvas dependency for prod --- composer.json | 1 - 1 file changed, 1 deletion(-) diff --git a/composer.json b/composer.json index 88fb7cd499..ffaaba0a64 100644 --- a/composer.json +++ b/composer.json @@ -44,7 +44,6 @@ "drupal/bootstrap_barrio": "5.5.20", "drupal/bootstrap_utilities": "3.0.1", "drupal/calendar_link": "3.0.4", - "drupal/canvas": "^1.8", "drupal/captcha": "2.0.10", "drupal/cas": "3.1.0", "drupal/chosen": "5.0.6", From 00add74161827cad70f7fb5b0fd557b1f2c6384c Mon Sep 17 00:00:00 2001 From: Kevin Lu <79181817+kevdevlu@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:32:44 -0700 Subject: [PATCH 09/21] Make hover preview in Canvas Library display with correct aspect ratio Co-Authored-By: Claude Sonnet 5 --- components/ranking-image/ranking-image.css | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/components/ranking-image/ranking-image.css b/components/ranking-image/ranking-image.css index 9c934b68fe..2db952a3e5 100644 --- a/components/ranking-image/ranking-image.css +++ b/components/ranking-image/ranking-image.css @@ -34,6 +34,16 @@ min-height: 190px; } +/** + * Matches ranking-image component's public://placeholder-1000x500.png + * placeholder image in the schema. Makes the hover preview in Canvas library + * left panel display with correct aspect ratio (#component-wrapper is the id + * Canvas's ComponentPreview.tsx uses to wrap it with). + */ +#component-wrapper .az-ranking-image { + aspect-ratio: 2 / 1; +} + /* Medium viewports. */ @media (min-width: 768px) { .az-ranking-image { From 2690e510d9a1d52c7b819fca5e7b54d2e887b2f5 Mon Sep 17 00:00:00 2001 From: Kevin Lu <79181817+kevdevlu@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:33:32 -0700 Subject: [PATCH 10/21] Restore media/file cache tags for ranking images The legacy image path bubbled the file entity's cache tags via AZRankingImageHelper::generateImageRenderArray(), which called renderer->addCacheableDependency($build, $file). The SDC port dropped that along with the @renderer service argument. AZRankingItem stores its media reference as a plain integer property, not an entity reference, so Drupal contributes no cache metadata for it automatically and nothing else compensated. Replacing a media entity's image or moving its focal point would not invalidate an already-cached ranking. buildImageComponent() now attaches tags from both the media and file entities. The media entity is tagged even when it carries no image, since alt text and both focal point values live there. Co-Authored-By: Claude Sonnet 5 --- .../src/AZRankingComponentBuilder.php | 34 ++++++++++++++----- .../az_ranking/src/AZRankingImageHelper.php | 10 +++++- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/modules/custom/az_ranking/src/AZRankingComponentBuilder.php b/modules/custom/az_ranking/src/AZRankingComponentBuilder.php index e70d80c643..9412f1cf71 100644 --- a/modules/custom/az_ranking/src/AZRankingComponentBuilder.php +++ b/modules/custom/az_ranking/src/AZRankingComponentBuilder.php @@ -3,6 +3,7 @@ namespace Drupal\az_ranking; use Drupal\az_ranking\Plugin\Field\FieldType\AZRankingItem; +use Drupal\Core\Cache\Cache; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Path\PathValidatorInterface; use Drupal\Core\StreamWrapper\PublicStream; @@ -196,14 +197,18 @@ public function buildRankingComponent(array $values, array $ranking_defaults): a /** * Builds an az_quickstart:ranking-image component render array for one item. * - * Unlike the legacy #theme => image_formatter path, this does not apply - * the az_ranking_responsive image style — az_quickstart:ranking-image - * takes a plain file URI, not a themed render array, so server-side image - * style processing is a known, disclosed gap versus the legacy image_only - * rendering, not an oversight. Focal point data IS passed through (see - * AZRankingImageHelper::getImageSourceAltAndFocalPoint()), so - * focal-point-aware cropping works client-side via the ranking-image - * SDC's own JS. + * Passes a plain file URI as the `src` prop rather than a themed render + * array (which an SDC prop cannot carry). The az_ranking_responsive image + * style is still applied — by ranking-image.twig itself, via az_media's + * `image_style` Twig filter — so scaling and WebP delivery match the + * legacy #theme => image_formatter path. Focal point data is passed + * through as props and applied client-side by the component's own JS. + * + * Cache tags for the media and file entities are attached here because + * AZRankingItem stores its media reference as a plain integer, not an + * entity reference, so nothing upstream contributes them automatically. + * Without this, replacing a media entity's image or moving its focal + * point would not invalidate an already-cached ranking. * * width_span_desktop/tablet/phone are computed here, not just passed * through legacy's single column_span value, because CSS Grid cannot @@ -232,10 +237,17 @@ public function buildImageComponent(array $values, array $deck_props): array { 'width_span_phone' => (string) min($legacy_span, (int) ($deck_props['columns_phone'] ?? 1)), ]; + $cache_tags = []; if (!empty($values['media'])) { $media = $this->entityTypeManager->getStorage('media')->load($values['media']); if ($media) { + // Tag on the media entity itself even when it turns out to carry no + // image: alt text and both focal point values live on the media, so + // editing any of them - or adding an image to a media entity that + // previously had none - has to invalidate this render. + $cache_tags = $media->getCacheTags(); $image_data = $this->rankingImageHelper->getImageSourceAltAndFocalPoint($media); + $cache_tags = Cache::mergeTags($cache_tags, $image_data['cache_tags']); if ($image_data['src'] !== '') { $props['src'] = $image_data['src']; $props['alt'] = $image_data['alt']; @@ -247,11 +259,15 @@ public function buildImageComponent(array $values, array $deck_props): array { } } - return [ + $build = [ '#type' => 'component', '#component' => 'az_quickstart:ranking-image', '#props' => $props, ]; + if ($cache_tags) { + $build['#cache']['tags'] = $cache_tags; + } + return $build; } /** diff --git a/modules/custom/az_ranking/src/AZRankingImageHelper.php b/modules/custom/az_ranking/src/AZRankingImageHelper.php index 10558f5a92..70299ba223 100644 --- a/modules/custom/az_ranking/src/AZRankingImageHelper.php +++ b/modules/custom/az_ranking/src/AZRankingImageHelper.php @@ -40,7 +40,13 @@ public function __construct(EntityTypeManagerInterface $entity_type_manager) { * @return array * An array with 'src' and 'alt' (empty strings if the media has no * image), plus 'focal_x' and 'focal_y' (both NULL if the media has no - * focal point set). + * focal point set), plus 'cache_tags' - the file entity's own cache + * tags, which the caller MUST attach to whatever render array it builds + * from this data. az_ranking stores its media reference as a plain + * integer property (see AZRankingItem::propertyDefinitions()), not an + * entity reference, so Drupal derives no cache metadata for it + * automatically - nothing else in the render pipeline will invalidate a + * cached ranking when the underlying file is replaced. */ public function getImageSourceAltAndFocalPoint(MediaInterface $media): array { $empty = [ @@ -48,6 +54,7 @@ public function getImageSourceAltAndFocalPoint(MediaInterface $media): array { 'alt' => '', 'focal_x' => NULL, 'focal_y' => NULL, + 'cache_tags' => [], ]; $media_attributes = $media->get('field_media_az_image')->getValue(); @@ -63,6 +70,7 @@ public function getImageSourceAltAndFocalPoint(MediaInterface $media): array { $result = $empty; $result['src'] = $file->getFileUri(); $result['alt'] = $media_attributes[0]['alt'] ?? ''; + $result['cache_tags'] = $file->getCacheTags(); if ($media instanceof FieldableEntityInterface) { try { From 87e3a46a465cd8e001334371d77e76f2b434e07b Mon Sep 17 00:00:00 2001 From: Kevin Lu <79181817+kevdevlu@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:09:16 -0700 Subject: [PATCH 11/21] Fall back to the media item's alt text for ranking images An SDC image prop carries only a URI string: Canvas resolves media -> file -> uri and hands the template the last of those. The alt text an author typed in the Media Library modal was therefore not reachable from the component, so an image described once in the library arrived at the component undescribed. Add a `media_alt` Twig filter to az_media that walks that chain backwards, and use it in ranking-image as a fallback: per-placement alt wins, empty falls back to the media item, decorative overrides both. The filter takes the media source field name as an argument so any image-bearing SDC can use it, which is why it lives in az_media rather than in a consuming module. Also remove the `examples` value from the `alt` prop. Canvas uses examples[0] as a prop's stored default value, not as placeholder text, so "Placeholder image, 1000 by 500 pixels" was shipping verbatim as the alt text of any real photograph an author did not edit - an accessibility defect, and one that would also have masked the new fallback by never being empty. No props on this component are required, so component metadata stays valid without it. Retitle both accessibility props to "(this placement)" and say plainly in their descriptions how they relate to the media item's own values. The two were being confused for each other, which is what surfaced the missing carry-over in the first place. Record in the template's docblock that Canvas independence is a hard constraint rather than an incidental property, since this component also renders the legacy az_ranking paragraph type on sites that may never install Canvas. Canvas's `json-schema-definitions://` $ref scheme and `apply_image_style` filter are both off limits here; a missing Twig filter fails at compile time, so there is no graceful degradation. Co-Authored-By: Claude Opus 5 --- .../ranking-image/ranking-image.component.yml | 10 +- components/ranking-image/ranking-image.twig | 29 ++++- modules/custom/az_media/az_media.services.yml | 6 + .../src/Twig/MediaAltTwigExtension.php | 120 ++++++++++++++++++ 4 files changed, 156 insertions(+), 9 deletions(-) create mode 100644 modules/custom/az_media/src/Twig/MediaAltTwigExtension.php diff --git a/components/ranking-image/ranking-image.component.yml b/components/ranking-image/ranking-image.component.yml index 46b540e8c9..b73523f164 100644 --- a/components/ranking-image/ranking-image.component.yml +++ b/components/ranking-image/ranking-image.component.yml @@ -17,16 +17,14 @@ props: - public://placeholder-1000x500.png decorative: type: boolean - title: Decorative - description: Hide this image from screen readers and other assistive technology, and ignore Alternative Text. Only enable for purely decorative images that carry no information. + title: Decorative (this placement) + description: Hides this image from screen readers and other assistive technology, and ignores Alternative Text, for this placement only. Independent of the Decorative setting on the media item. Only enable for purely decorative images that carry no information. examples: - false alt: type: string - title: Alternative Text - description: Describes the image for screen readers and other assistive technology. Ignored when Decorative is enabled. - examples: - - Placeholder image, 1000 by 500 pixels + title: Alternative Text (this placement) + description: Describes this image for screen readers and other assistive technology, for this placement only. Leave empty to use the Alternative text set on the media item in the Media Library. Text entered here overrides it for this placement only, and does not change the Media Library. Ignored when Decorative is enabled. width_span_desktop: type: string title: Width Span (Desktop) diff --git a/components/ranking-image/ranking-image.twig b/components/ranking-image/ranking-image.twig index cc8ddf3e07..c7758eba22 100644 --- a/components/ranking-image/ranking-image.twig +++ b/components/ranking-image/ranking-image.twig @@ -11,6 +11,19 @@ * in Canvas, with no canvas:image / canvas.module $ref needed, so this stays * safe to render on sites without the Canvas module installed. * + * That last point is a hard constraint, not an incidental property: this + * component also renders the legacy az_ranking paragraph type, on sites that + * may never install Canvas. So keep every Canvas-owned mechanism out of this + * template and out of the component's schema — in particular the + * `json-schema-definitions://` $ref scheme and the `apply_image_style` Twig + * filter, both registered by canvas.services.yml. Either one makes this + * template fail on a Canvas-less site, and a missing Twig filter fails at + * compile time (SyntaxError), so there is no graceful degradation. Use + * az_media's own filters instead; if an object-shaped image prop is ever + * wanted, declare it inline and map it with our own + * hook_canvas_storable_prop_shape_alter(), which Canvas invokes even when it + * has no mapping of its own. + * * Props: * - src: Image URI, picked via the media library in Canvas. Resolves to a * Drupal stream-wrapper URI (public://...), not a browser-loadable URL — @@ -23,8 +36,17 @@ * az_ranking) — the filter itself is generic and lives in az_media * regardless of which style name gets passed to it here. * - decorative: When true, forces empty alt text AND hides the image from - * assistive technology via aria-hidden. Defaults to false. - * - alt: Alternative text. Ignored when decorative is true. + * assistive technology via aria-hidden. Defaults to false. Set per + * placement; unrelated to the media item's own Decorative checkbox. + * - alt: Alternative text for this placement. Ignored when decorative is + * true. When left empty, falls back to the alt text stored on the media + * item itself, resolved by az_media's `media_alt` filter — so an image + * described once in the Media Library stays described everywhere, + * while a placement can still override it. Deliberately carries no + * `examples` value: Canvas uses examples[0] as a prop's stored default, + * so any example here would ship verbatim as the alt text of every + * image an author does not edit, and would also mask the fallback by + * never being empty. * - focal_x / focal_y: Focal point as a 0-1 fraction of the image's width/ * height, kept visible when object-fit: cover crops the image to fill * its container. Rendered as data-focal-x/data-focal-y attributes and @@ -52,7 +74,8 @@ {% set attributes = attributes|default(create_attribute()) %} {% set decorative = decorative|default(false) %} -{% set alt = decorative ? '' : alt|default('') %} +{# Per-placement alt wins; empty falls back to the media item's own alt. #} +{% set alt = decorative ? '' : (alt|default('') ?: src|default('')|media_alt) %} {% set focal_x = focal_x|default(null) %} {% set focal_y = focal_y|default(null) %} diff --git a/modules/custom/az_media/az_media.services.yml b/modules/custom/az_media/az_media.services.yml index f24b452b83..e4ea188468 100644 --- a/modules/custom/az_media/az_media.services.yml +++ b/modules/custom/az_media/az_media.services.yml @@ -6,3 +6,9 @@ services: - '@entity_type.manager' tags: - { name: twig.extension } + az_media.media_alt_twig_extension: + class: Drupal\az_media\Twig\MediaAltTwigExtension + arguments: + - '@entity_type.manager' + tags: + - { name: twig.extension } diff --git a/modules/custom/az_media/src/Twig/MediaAltTwigExtension.php b/modules/custom/az_media/src/Twig/MediaAltTwigExtension.php new file mode 100644 index 0000000000..c2135bdff3 --- /dev/null +++ b/modules/custom/az_media/src/Twig/MediaAltTwigExtension.php @@ -0,0 +1,120 @@ + file -> uri and hands the template the last of those. Recovering + * the alt text therefore means walking that chain backwards: uri -> file -> + * media. Deliberately generic (any image-bearing SDC can use it), which is + * why it lives in az_media rather than in a consuming module. + * + * @todo Drop this if an image prop ever carries `alt` directly. + * + * @see \Drupal\az_media\Twig\ImageStyleTwigExtension + */ +class MediaAltTwigExtension extends AbstractExtension { + + /** + * Per-request memo of resolved alt text, keyed by URI. + * + * The lookup costs two entity queries, and a page can place the same image + * many times (a Ranking Deck of image cards, for example). + * + * @var array + */ + protected array $cache = []; + + public function __construct( + protected EntityTypeManagerInterface $entityTypeManager, + ) {} + + /** + * {@inheritdoc} + */ + public function getFilters(): array { + return [ + new TwigFilter('media_alt', [$this, 'mediaAlt']), + ]; + } + + /** + * Looks up the alt text of the media item owning a stream-wrapper URI. + * + * @param string|null $uri + * A stream-wrapper URI (e.g. public://foo.jpg), or NULL/empty. + * @param string $field_name + * The media source image field to search. Defaults to az_media's own + * image field. + * + * @return string + * The media item's alt text, or an empty string when the URI is empty, + * no file or media item matches it, or the alt text is itself empty. + * An empty result is meaningful, not merely a failure: on a media item, + * empty alt text is how decorative_image_widget records "decorative". + * + * @see \Drupal\decorative_image_widget\DecorativeImageWidgetHelper + */ + public function mediaAlt(?string $uri, string $field_name = 'field_media_az_image'): string { + if (empty($uri)) { + return ''; + } + $key = $field_name . ':' . $uri; + if (isset($this->cache[$key])) { + return $this->cache[$key]; + } + $this->cache[$key] = ''; + + // Alt text is a nicety; never let resolving it take down a page. An + // unknown $field_name makes the entity query throw, and a template is + // free to pass one. Degrade to '' like the sibling image_style filter + // degrades to an unstyled URL. + try { + $file_ids = $this->entityTypeManager->getStorage('file')->getQuery() + ->accessCheck(FALSE) + ->condition('uri', $uri) + ->range(0, 1) + ->execute(); + if (!$file_ids) { + return ''; + } + + // A file can in principle be referenced by more than one media item; + // take the lowest ID for a stable, repeatable answer rather than an + // arbitrary one. In practice the Media Library creates one media item + // per upload. + $media_ids = $this->entityTypeManager->getStorage('media')->getQuery() + ->accessCheck(FALSE) + ->condition($field_name . '.target_id', reset($file_ids)) + ->sort('mid') + ->range(0, 1) + ->execute(); + if (!$media_ids) { + return ''; + } + + $media = $this->entityTypeManager->getStorage('media')->load(reset($media_ids)); + if (!$media || !$media->hasField($field_name)) { + return ''; + } + $values = $media->get($field_name)->getValue(); + $this->cache[$key] = (string) ($values[0]['alt'] ?? ''); + } + catch (\Throwable $e) { + return ''; + } + + return $this->cache[$key]; + } + +} From 2d8137722fef2ddeb626e79f5c1909914d9dda3d Mon Sep 17 00:00:00 2001 From: Kevin Lu <79181817+kevdevlu@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:27:44 -0700 Subject: [PATCH 12/21] Internal Placeholder Service --- .../ranking-image/ranking-image.component.yml | 2 +- modules/custom/az_media/az_media.install | 33 ------ modules/custom/az_media/az_media.routing.yml | 2 + modules/custom/az_media/az_media.services.yml | 3 + .../az_media/images/placeholder-1000x500.png | Bin 14093 -> 0 bytes .../src/AZPlaceholderImageGenerator.php | 100 ++++++++++++++++++ .../AZPlaceholderImageController.php | 99 +++++++++++++++++ .../src/Routing/AZPlaceholderRoutes.php | 95 +++++++++++++++++ .../src/Twig/ImageStyleTwigExtension.php | 63 +++++++++-- .../src/Twig/MediaAltTwigExtension.php | 51 +++++---- 10 files changed, 385 insertions(+), 63 deletions(-) create mode 100644 modules/custom/az_media/az_media.routing.yml delete mode 100644 modules/custom/az_media/images/placeholder-1000x500.png create mode 100644 modules/custom/az_media/src/AZPlaceholderImageGenerator.php create mode 100644 modules/custom/az_media/src/Controller/AZPlaceholderImageController.php create mode 100644 modules/custom/az_media/src/Routing/AZPlaceholderRoutes.php diff --git a/components/ranking-image/ranking-image.component.yml b/components/ranking-image/ranking-image.component.yml index b73523f164..6717ad1382 100644 --- a/components/ranking-image/ranking-image.component.yml +++ b/components/ranking-image/ranking-image.component.yml @@ -14,7 +14,7 @@ props: x-allowed-schemes: - public examples: - - public://placeholder-1000x500.png + - public://az-placeholder/1000x500/placeholder.svg decorative: type: boolean title: Decorative (this placement) diff --git a/modules/custom/az_media/az_media.install b/modules/custom/az_media/az_media.install index 0dbef2e12e..b472e61023 100644 --- a/modules/custom/az_media/az_media.install +++ b/modules/custom/az_media/az_media.install @@ -8,32 +8,6 @@ * az_media module. */ -use Drupal\Core\File\FileExists; - -/** - * Implements hook_install(). - */ -function az_media_install() { - _az_media_copy_placeholder_images(); -} - -/** - * Copies az_media's shipped placeholder images into public://. - * - * These are plain files, not media entities - they exist only to back - * SDC prop `examples` values (e.g. az_quickstart:ranking-image's `src`) - * so those examples resolve to a real, renderable public:// file on - * every site, without ever appearing in the Media Library. - */ -function _az_media_copy_placeholder_images() { - $file_system = \Drupal::service('file_system'); - $source_dir = \Drupal::service('extension.list.module')->getPath('az_media') . '/images'; - $filenames = ['placeholder-1000x500.png']; - foreach ($filenames as $filename) { - $file_system->copy("{$source_dir}/{$filename}", "public://{$filename}", FileExists::Replace); - } -} - /** * Implements hook_update_last_removed(). */ @@ -55,10 +29,3 @@ function az_media_update_1021301() { function az_media_update_1130101() { \Drupal::service('module_installer')->install(['media_library_form_element']); } - -/** - * Copy az_media's shipped placeholder images into public:// on existing sites. - */ -function az_media_update_1130102() { - _az_media_copy_placeholder_images(); -} diff --git a/modules/custom/az_media/az_media.routing.yml b/modules/custom/az_media/az_media.routing.yml new file mode 100644 index 0000000000..ab1c90d162 --- /dev/null +++ b/modules/custom/az_media/az_media.routing.yml @@ -0,0 +1,2 @@ +route_callbacks: + - '\Drupal\az_media\Routing\AZPlaceholderRoutes::routes' diff --git a/modules/custom/az_media/az_media.services.yml b/modules/custom/az_media/az_media.services.yml index e4ea188468..51832ffede 100644 --- a/modules/custom/az_media/az_media.services.yml +++ b/modules/custom/az_media/az_media.services.yml @@ -4,6 +4,7 @@ services: arguments: - '@file_url_generator' - '@entity_type.manager' + - '@image.toolkit.manager' tags: - { name: twig.extension } az_media.media_alt_twig_extension: @@ -12,3 +13,5 @@ services: - '@entity_type.manager' tags: - { name: twig.extension } + az_media.placeholder_image_generator: + class: Drupal\az_media\AZPlaceholderImageGenerator diff --git a/modules/custom/az_media/images/placeholder-1000x500.png b/modules/custom/az_media/images/placeholder-1000x500.png deleted file mode 100644 index d2dcfe15b53576eaa520fd56138069c19c42e243..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14093 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 diff --git a/modules/custom/az_media/src/AZPlaceholderImageGenerator.php b/modules/custom/az_media/src/AZPlaceholderImageGenerator.php new file mode 100644 index 0000000000..59f8acf910 --- /dev/null +++ b/modules/custom/az_media/src/AZPlaceholderImageGenerator.php @@ -0,0 +1,100 @@ + self::MAX_DIMENSION) { + return FALSE; + } + } + return TRUE; + } + + /** + * Builds the SVG markup, given width and height. + * + * Using the `int` type here means there's no way for injection to + * happen. Hence we don't need to escape anything. + * + * @param int $width + * Width in pixels. + * @param int $height + * Height in pixels. + * + * @return string + * The SVG document. + */ + public function generate(int $width, int $height): string { + // Build the label. + $label = $width . ' × ' . $height; + + // Figure out the font size. Meet these requirements: + // 1. The label should fit in the placeholder image. + // 2. It should span up to 0.625 of the placeholder's width (what + // placehold.co does), accounting for how many characters are in the + // label and how wide they average (0.52 em each). + // 3. The font size should not exceed 0.42 of the placeholder's height, + // so a short image can't overflow. + $by_width = (0.625 * $width) / (mb_strlen($label) * 0.52); + $by_height = $height * 0.42; + $font_size = max(1, (int) floor(min($by_width, $by_height))); + + // Muted grays. Bold. Label uses whatever sans-serif font the viewer has. + // The 0.52 em measurement is for sans-serif, so spot check if you need to + // change font-family. + return << + + {$label} + + SVG; + } + +} diff --git a/modules/custom/az_media/src/Controller/AZPlaceholderImageController.php b/modules/custom/az_media/src/Controller/AZPlaceholderImageController.php new file mode 100644 index 0000000000..8cb35cf794 --- /dev/null +++ b/modules/custom/az_media/src/Controller/AZPlaceholderImageController.php @@ -0,0 +1,99 @@ +get('az_media.placeholder_image_generator')); + } + + /** + * Generates and returns a placeholder image. + * + * @param string $dimensions + * The requested size as `{width}x{height}`. The route already constrains + * this to two integers of at most four digits. + * + * @return \Symfony\Component\HttpFoundation\Response + * The SVG response. + * + * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException + * When either dimension falls outside the permitted range. + */ + public function deliver(string $dimensions): Response { + [$width, $height] = array_map('intval', explode('x', $dimensions)); + + // If not in the allowed width and height, give a 404 error. Rationale: + // If someone typed 8000x600 (most likely a typo of 800x600), we could + // round down to 4000x600, but the user may not notice until later. So + // we want to fail instead of rounding down. + if (!$this->generator->isValidSize($width, $height)) { + throw new NotFoundHttpException(); + } + + $response = new Response($this->generator->generate($width, $height), Response::HTTP_OK, [ + 'Content-Type' => 'image/svg+xml', + ]); + // Together these set Cache-Control: public, max-age=, immutable. + // public lets shared caches (a CDN, not just the user's browser) keep a + // copy; max-age is how long they may reuse it; immutable means it will + // never change, so they never need to check back with us. Expires says + // the same deadline in an older format that some caches still read. + // + // Drupal has a subscriber that rewrites cache headers on the way out. + // It leaves ours alone only because this is a plain Response and we set + // Cache-Control ourselves. Setting Expires stops it stamping its own + // 1978 date on top. + // + // So if you switch to a CacheableResponse, or drop either header + // (Cache-Control or Expires), the subscriber takes over and the + // year-long cache time disappears with no error to tell you. + // + // @see \Drupal\Core\EventSubscriber\FinishResponseSubscriber::onRespond() + $response->setPublic(); + $response->setMaxAge(self::MAX_AGE); + $response->headers->addCacheControlDirective('immutable'); + $response->setExpires(new \DateTime('@' . (time() + self::MAX_AGE))); + + return $response; + } + +} diff --git a/modules/custom/az_media/src/Routing/AZPlaceholderRoutes.php b/modules/custom/az_media/src/Routing/AZPlaceholderRoutes.php new file mode 100644 index 0000000000..06f4d45c66 --- /dev/null +++ b/modules/custom/az_media/src/Routing/AZPlaceholderRoutes.php @@ -0,0 +1,95 @@ +get('stream_wrapper_manager')); + } + + /** + * Returns the placeholder route. + * + * @return \Symfony\Component\Routing\Route[] + * Route objects keyed by route name. + */ + public function routes(): array { + // Get the local public files directory (whatever public:// is set to). + // Ask for the object behind that alias, since it knows the real folder + // name - sites/default/files unless a site changed it. + $public = $this->streamWrapperManager->getViaScheme('public'); + // If public:// isn't a normal local directory, register nothing. + // For example, a site keeping its public files on something like S3 has + // no local path to build a URL from. + if (!$public instanceof LocalStream) { + return []; + } + $directory_path = $public->getDirectoryPath(); + + // The filename gets its own path segment: {dimensions}/placeholder.svg, + // not {dimensions}.svg. Rationale: when a request comes in, Drupal + // looks for matching routes by swapping whole segments for '%' - for + // our URL it tries .../az-placeholder/%/placeholder.svg. A segment that + // mixes a placeholder with a literal suffix ({dimensions}.svg) never + // shows up in that list, so the route can't be found no matter how well + // its regex matches. Core hits the same wall and works around it with a + // path processor; giving the filename its own segment avoids needing + // one. + // @see \Drupal\Core\Routing\RouteProvider::getCandidateOutlines() + // @see \Drupal\image\PathProcessor\PathProcessorImageStyles + return [ + 'az_media.placeholder_image' => new Route( + '/' . $directory_path . '/' . AZPlaceholderImageGenerator::DIRECTORY . '/{dimensions}/placeholder.svg', + [ + '_controller' => '\Drupal\az_media\Controller\AZPlaceholderImageController::deliver', + ], + [ + // Open to everyone: an tag on a public page loads this URL, + // so there is no user to check permissions against. + '_access' => 'TRUE', + // Only match 1-4 digits, an x, then 1-4 digits. Anything else + // 404s before the controller runs. + 'dimensions' => '\d{1,4}x\d{1,4}', + ] + ), + ]; + } + +} diff --git a/modules/custom/az_media/src/Twig/ImageStyleTwigExtension.php b/modules/custom/az_media/src/Twig/ImageStyleTwigExtension.php index 7702f826c2..5118f929bc 100644 --- a/modules/custom/az_media/src/Twig/ImageStyleTwigExtension.php +++ b/modules/custom/az_media/src/Twig/ImageStyleTwigExtension.php @@ -4,24 +4,35 @@ use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\File\FileUrlGeneratorInterface; +use Drupal\Core\ImageToolkit\ImageToolkitManager; use Twig\Extension\AbstractExtension; use Twig\TwigFilter; /** - * Provides an `image_style` Twig filter for SDCs. + * Provides an `image_style` Twig filter for components. * - * Lets a component template apply a named Drupal image style to a - * stream-wrapper URI without going through a themed render array - * (`#theme => image_style` / `image_formatter`), which SDC props can't - * carry. Falls back to plain file_url() behavior if the style doesn't - * exist or the URI is empty, so a missing/misconfigured style degrades - * gracefully instead of breaking the page. + * A component template gets its image as a URI string like + * public://cactus.jpg. Image styles (resize, crop, convert to WebP) are + * normally applied through a render array, which a component prop can't + * hold. This filter bridges the gap: + * + * @code + * + * @endcode + * + * It hands back the URL of the styled copy. Drupal generates that copy the + * first time someone requests it. + * + * Three cases fall back to the plain file URL instead, so a page never + * breaks over a styling problem: an empty URI, a style name that doesn't + * exist, and a file the image toolkit can't read. */ class ImageStyleTwigExtension extends AbstractExtension { public function __construct( protected FileUrlGeneratorInterface $fileUrlGenerator, protected EntityTypeManagerInterface $entityTypeManager, + protected ImageToolkitManager $imageToolkitManager, ) {} /** @@ -42,13 +53,22 @@ public function getFilters(): array { * The image style's machine name. * * @return string - * The styled derivative's URL, or a plain file_url()-equivalent URL if - * the named style doesn't exist, or an empty string if $uri is empty. + * The styled derivative's URL; a plain file_url()-equivalent URL if the + * named style doesn't exist or the file's format cannot be processed by + * the image toolkit; or an empty string if $uri is empty. */ public function applyImageStyle(?string $uri, string $style_name): string { if (empty($uri)) { return ''; } + // If the toolkit can't read this format, hand back the plain URL. + // For example an SVG: GD only handles png, jpeg, jpg, jpe, gif, webp + // and avif, so public://logo.svg would turn into a logo.svg.webp + // derivative URL that can never be generated and 404s. Serving the SVG + // unstyled is the right answer anyway - a vector scales on its own. + if (!$this->toolkitSupports($uri)) { + return $this->fileUrlGenerator->generateString($uri); + } $style = $this->entityTypeManager->getStorage('image_style')->load($style_name); if ($style) { return $style->buildUrl($uri); @@ -56,4 +76,29 @@ public function applyImageStyle(?string $uri, string $style_name): string { return $this->fileUrlGenerator->generateString($uri); } + /** + * Whether the active image toolkit can process this file's format. + * + * Read from the toolkit rather than hard-coded, so the answer stays correct + * on a site running ImageMagick instead of GD. + * + * @param string $uri + * The file URI to test. + * + * @return bool + * TRUE when the toolkit lists the file's extension as supported. + */ + protected function toolkitSupports(string $uri): bool { + // The `?? $uri` matters: parse_url() treats public://foo.svg as scheme + // plus host with no path, so it returns NULL and there is no extension + // to find. Only a URI with a subdirectory - public://dir/foo.svg - + // gives it a path. Falling back to the whole URI covers the flat case. + $extension = strtolower(pathinfo(parse_url($uri, PHP_URL_PATH) ?? $uri, PATHINFO_EXTENSION)); + if ($extension === '') { + return FALSE; + } + $toolkit = $this->imageToolkitManager->getDefaultToolkit(); + return in_array($extension, $toolkit::getSupportedExtensions(), TRUE); + } + } diff --git a/modules/custom/az_media/src/Twig/MediaAltTwigExtension.php b/modules/custom/az_media/src/Twig/MediaAltTwigExtension.php index c2135bdff3..49166b4e86 100644 --- a/modules/custom/az_media/src/Twig/MediaAltTwigExtension.php +++ b/modules/custom/az_media/src/Twig/MediaAltTwigExtension.php @@ -7,17 +7,24 @@ use Twig\TwigFilter; /** - * Provides a `media_alt` Twig filter for SDCs. + * Provides a `media_alt` Twig filter for components. * - * Resolves the alt text stored on the media item that a stream-wrapper URI - * belongs to, so a component can fall back to the Media Library's own alt - * text when no per-placement override was entered. + * Looks up the alt text an editor typed in the Media Library, so a + * component can use it when nobody filled in alt text for this particular + * placement: * - * An SDC image prop carries only a URI string - Canvas resolves - * media -> file -> uri and hands the template the last of those. Recovering - * the alt text therefore means walking that chain backwards: uri -> file -> - * media. Deliberately generic (any image-bearing SDC can use it), which is - * why it lives in az_media rather than in a consuming module. + * @code + * {% set alt = alt|default('') ?: src|media_alt %} + * @endcode + * + * The lookup is backwards. When an editor picks an image, Canvas walks + * media -> file -> uri and hands the template only the last one, so all we + * have is a string like public://cactus.jpg. Getting to the alt text means + * retracing those steps: find the file with that uri, find the media item + * pointing at that file, then read its alt. + * + * Any component with an image can use this, which is why it lives in + * az_media rather than in one consuming module. * * @todo Drop this if an image prop ever carries `alt` directly. * @@ -26,10 +33,12 @@ class MediaAltTwigExtension extends AbstractExtension { /** - * Per-request memo of resolved alt text, keyed by URI. + * Alt text already looked up on this request, keyed by field name and URI. * - * The lookup costs two entity queries, and a page can place the same image - * many times (a Ranking Deck of image cards, for example). + * Each lookup costs two entity queries and a load, and one page can place + * the same image many times - a Ranking Deck of image cards, for example. + * Empty results get stored too, so a URI with no media item behind it is + * only looked up once. * * @var array */ @@ -75,10 +84,11 @@ public function mediaAlt(?string $uri, string $field_name = 'field_media_az_imag } $this->cache[$key] = ''; - // Alt text is a nicety; never let resolving it take down a page. An - // unknown $field_name makes the entity query throw, and a template is - // free to pass one. Degrade to '' like the sibling image_style filter - // degrades to an unstyled URL. + // Wrap the whole lookup so a failure here can never take down a page. + // For example, a template is free to pass a $field_name that no media + // type has, and the entity query throws on it. Alt text is worth + // having, not worth a white screen - so fall back to '', the same way + // the sibling image_style filter falls back to an unstyled URL. try { $file_ids = $this->entityTypeManager->getStorage('file')->getQuery() ->accessCheck(FALSE) @@ -89,10 +99,11 @@ public function mediaAlt(?string $uri, string $field_name = 'field_media_az_imag return ''; } - // A file can in principle be referenced by more than one media item; - // take the lowest ID for a stable, repeatable answer rather than an - // arbitrary one. In practice the Media Library creates one media item - // per upload. + // Sort by mid so repeated lookups return the same media item. Two + // media items can point at one file if someone uploaded the same + // image twice, and without the sort we would get whichever the + // database handed back first. The Media Library makes one media + // item per upload, so this is rare. $media_ids = $this->entityTypeManager->getStorage('media')->getQuery() ->accessCheck(FALSE) ->condition($field_name . '.target_id', reset($file_ids)) From 1bbbefea4fefd443e073d85c689ff2f7c3f523ae Mon Sep 17 00:00:00 2001 From: Kevin Lu <79181817+kevdevlu@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:54:04 -0700 Subject: [PATCH 13/21] Make ranking-image purely decorative (no alt text) --- .../ranking-image/ranking-image.component.yml | 12 +- components/ranking-image/ranking-image.twig | 27 +--- modules/custom/az_media/az_media.services.yml | 6 - .../src/Twig/MediaAltTwigExtension.php | 131 ------------------ .../src/AZRankingComponentBuilder.php | 11 +- .../az_ranking/src/AZRankingImageHelper.php | 13 +- 6 files changed, 19 insertions(+), 181 deletions(-) delete mode 100644 modules/custom/az_media/src/Twig/MediaAltTwigExtension.php diff --git a/components/ranking-image/ranking-image.component.yml b/components/ranking-image/ranking-image.component.yml index 6717ad1382..582a9e5f4e 100644 --- a/components/ranking-image/ranking-image.component.yml +++ b/components/ranking-image/ranking-image.component.yml @@ -1,7 +1,7 @@ $schema: https://git.drupalcode.org/project/drupal/-/raw/HEAD/core/assets/schemas/v1/metadata.schema.json name: Ranking Image status: experimental -description: A layout- and accessibility-aware image, backed by the media library. Works standalone on a page, or dropped into a Ranking Deck alongside Ranking cards. +description: A decorative, layout-aware image backed by the media library. Works standalone on a page, or dropped into a Ranking Deck alongside Ranking cards. props: type: object properties: @@ -15,16 +15,6 @@ props: - public examples: - public://az-placeholder/1000x500/placeholder.svg - decorative: - type: boolean - title: Decorative (this placement) - description: Hides this image from screen readers and other assistive technology, and ignores Alternative Text, for this placement only. Independent of the Decorative setting on the media item. Only enable for purely decorative images that carry no information. - examples: - - false - alt: - type: string - title: Alternative Text (this placement) - description: Describes this image for screen readers and other assistive technology, for this placement only. Leave empty to use the Alternative text set on the media item in the Media Library. Text entered here overrides it for this placement only, and does not change the Media Library. Ignored when Decorative is enabled. width_span_desktop: type: string title: Width Span (Desktop) diff --git a/components/ranking-image/ranking-image.twig b/components/ranking-image/ranking-image.twig index c7758eba22..427d4acb05 100644 --- a/components/ranking-image/ranking-image.twig +++ b/components/ranking-image/ranking-image.twig @@ -24,6 +24,9 @@ * hook_canvas_storable_prop_shape_alter(), which Canvas invokes even when it * has no mapping of its own. * + * This component is always decorative. It renders alt="" and hides the + * image from screen readers. + * * Props: * - src: Image URI, picked via the media library in Canvas. Resolves to a * Drupal stream-wrapper URI (public://...), not a browser-loadable URL — @@ -35,18 +38,6 @@ * the page. This is a soft dependency on az_media (not * az_ranking) — the filter itself is generic and lives in az_media * regardless of which style name gets passed to it here. - * - decorative: When true, forces empty alt text AND hides the image from - * assistive technology via aria-hidden. Defaults to false. Set per - * placement; unrelated to the media item's own Decorative checkbox. - * - alt: Alternative text for this placement. Ignored when decorative is - * true. When left empty, falls back to the alt text stored on the media - * item itself, resolved by az_media's `media_alt` filter — so an image - * described once in the Media Library stays described everywhere, - * while a placement can still override it. Deliberately carries no - * `examples` value: Canvas uses examples[0] as a prop's stored default, - * so any example here would ship verbatim as the alt text of every - * image an author does not edit, and would also mask the fallback by - * never being empty. * - focal_x / focal_y: Focal point as a 0-1 fraction of the image's width/ * height, kept visible when object-fit: cover crops the image to fill * its container. Rendered as data-focal-x/data-focal-y attributes and @@ -73,10 +64,6 @@ #} {% set attributes = attributes|default(create_attribute()) %} -{% set decorative = decorative|default(false) %} -{# Per-placement alt wins; empty falls back to the media item's own alt. #} -{% set alt = decorative ? '' : (alt|default('') ?: src|default('')|media_alt) %} - {% set focal_x = focal_x|default(null) %} {% set focal_y = focal_y|default(null) %} {% set has_focal_point = focal_x is not null and focal_y is not null %} @@ -101,16 +88,14 @@ {% set root_classes = root_classes|merge(utility_classes) %} {% endif %} -{% set root_attributes = attributes.addClass(root_classes) %} -{% if decorative %} - {% set root_attributes = root_attributes.setAttribute('aria-hidden', 'true') %} -{% endif %} +{# Always decorative - see the @file block above. #} +{% set root_attributes = attributes.addClass(root_classes).setAttribute('aria-hidden', 'true') %} {% if src|default('') %} {{ alt }} file -> uri and hands the template only the last one, so all we - * have is a string like public://cactus.jpg. Getting to the alt text means - * retracing those steps: find the file with that uri, find the media item - * pointing at that file, then read its alt. - * - * Any component with an image can use this, which is why it lives in - * az_media rather than in one consuming module. - * - * @todo Drop this if an image prop ever carries `alt` directly. - * - * @see \Drupal\az_media\Twig\ImageStyleTwigExtension - */ -class MediaAltTwigExtension extends AbstractExtension { - - /** - * Alt text already looked up on this request, keyed by field name and URI. - * - * Each lookup costs two entity queries and a load, and one page can place - * the same image many times - a Ranking Deck of image cards, for example. - * Empty results get stored too, so a URI with no media item behind it is - * only looked up once. - * - * @var array - */ - protected array $cache = []; - - public function __construct( - protected EntityTypeManagerInterface $entityTypeManager, - ) {} - - /** - * {@inheritdoc} - */ - public function getFilters(): array { - return [ - new TwigFilter('media_alt', [$this, 'mediaAlt']), - ]; - } - - /** - * Looks up the alt text of the media item owning a stream-wrapper URI. - * - * @param string|null $uri - * A stream-wrapper URI (e.g. public://foo.jpg), or NULL/empty. - * @param string $field_name - * The media source image field to search. Defaults to az_media's own - * image field. - * - * @return string - * The media item's alt text, or an empty string when the URI is empty, - * no file or media item matches it, or the alt text is itself empty. - * An empty result is meaningful, not merely a failure: on a media item, - * empty alt text is how decorative_image_widget records "decorative". - * - * @see \Drupal\decorative_image_widget\DecorativeImageWidgetHelper - */ - public function mediaAlt(?string $uri, string $field_name = 'field_media_az_image'): string { - if (empty($uri)) { - return ''; - } - $key = $field_name . ':' . $uri; - if (isset($this->cache[$key])) { - return $this->cache[$key]; - } - $this->cache[$key] = ''; - - // Wrap the whole lookup so a failure here can never take down a page. - // For example, a template is free to pass a $field_name that no media - // type has, and the entity query throws on it. Alt text is worth - // having, not worth a white screen - so fall back to '', the same way - // the sibling image_style filter falls back to an unstyled URL. - try { - $file_ids = $this->entityTypeManager->getStorage('file')->getQuery() - ->accessCheck(FALSE) - ->condition('uri', $uri) - ->range(0, 1) - ->execute(); - if (!$file_ids) { - return ''; - } - - // Sort by mid so repeated lookups return the same media item. Two - // media items can point at one file if someone uploaded the same - // image twice, and without the sort we would get whichever the - // database handed back first. The Media Library makes one media - // item per upload, so this is rare. - $media_ids = $this->entityTypeManager->getStorage('media')->getQuery() - ->accessCheck(FALSE) - ->condition($field_name . '.target_id', reset($file_ids)) - ->sort('mid') - ->range(0, 1) - ->execute(); - if (!$media_ids) { - return ''; - } - - $media = $this->entityTypeManager->getStorage('media')->load(reset($media_ids)); - if (!$media || !$media->hasField($field_name)) { - return ''; - } - $values = $media->get($field_name)->getValue(); - $this->cache[$key] = (string) ($values[0]['alt'] ?? ''); - } - catch (\Throwable $e) { - return ''; - } - - return $this->cache[$key]; - } - -} diff --git a/modules/custom/az_ranking/src/AZRankingComponentBuilder.php b/modules/custom/az_ranking/src/AZRankingComponentBuilder.php index 9412f1cf71..261020373c 100644 --- a/modules/custom/az_ranking/src/AZRankingComponentBuilder.php +++ b/modules/custom/az_ranking/src/AZRankingComponentBuilder.php @@ -227,7 +227,7 @@ public function buildRankingComponent(array $values, array $ranking_defaults): a * The az_quickstart:ranking-deck props this item's parent deck will * receive (columns_desktop/tablet/phone), from buildDeckProps(). * - * @see \Drupal\az_ranking\AZRankingImageHelper::getImageSourceAltAndFocalPoint() + * @see \Drupal\az_ranking\AZRankingImageHelper::getImageSourceAndFocalPoint() */ public function buildImageComponent(array $values, array $deck_props): array { $legacy_span = (int) ($values['options']['column_span'] ?? 2); @@ -242,15 +242,14 @@ public function buildImageComponent(array $values, array $deck_props): array { $media = $this->entityTypeManager->getStorage('media')->load($values['media']); if ($media) { // Tag on the media entity itself even when it turns out to carry no - // image: alt text and both focal point values live on the media, so - // editing any of them - or adding an image to a media entity that - // previously had none - has to invalidate this render. + // image: both focal point values live on the media, so editing them + // - or adding an image to a media entity that previously had none - + // has to invalidate this render. $cache_tags = $media->getCacheTags(); - $image_data = $this->rankingImageHelper->getImageSourceAltAndFocalPoint($media); + $image_data = $this->rankingImageHelper->getImageSourceAndFocalPoint($media); $cache_tags = Cache::mergeTags($cache_tags, $image_data['cache_tags']); if ($image_data['src'] !== '') { $props['src'] = $image_data['src']; - $props['alt'] = $image_data['alt']; } if ($image_data['focal_x'] !== NULL && $image_data['focal_y'] !== NULL) { $props['focal_x'] = $image_data['focal_x']; diff --git a/modules/custom/az_ranking/src/AZRankingImageHelper.php b/modules/custom/az_ranking/src/AZRankingImageHelper.php index 70299ba223..f97bd6461d 100644 --- a/modules/custom/az_ranking/src/AZRankingImageHelper.php +++ b/modules/custom/az_ranking/src/AZRankingImageHelper.php @@ -26,7 +26,10 @@ public function __construct(EntityTypeManagerInterface $entity_type_manager) { } /** - * Get a plain file URI, alt text, and focal point data for ranking-image. + * Get a plain file URI and focal point data for ranking-image. + * + * No alt text: ranking-image is always decorative, so the component has + * no alt prop to fill. * * Used for both the published az_quickstart:ranking-image render and the * widget's own live edit-form preview, via AZRankingComponentBuilder:: @@ -38,8 +41,8 @@ public function __construct(EntityTypeManagerInterface $entity_type_manager) { * A Drupal media entity object. * * @return array - * An array with 'src' and 'alt' (empty strings if the media has no - * image), plus 'focal_x' and 'focal_y' (both NULL if the media has no + * An array with 'src' (an empty string if the media has no image), + * plus 'focal_x' and 'focal_y' (both NULL if the media has no * focal point set), plus 'cache_tags' - the file entity's own cache * tags, which the caller MUST attach to whatever render array it builds * from this data. az_ranking stores its media reference as a plain @@ -48,10 +51,9 @@ public function __construct(EntityTypeManagerInterface $entity_type_manager) { * automatically - nothing else in the render pipeline will invalidate a * cached ranking when the underlying file is replaced. */ - public function getImageSourceAltAndFocalPoint(MediaInterface $media): array { + public function getImageSourceAndFocalPoint(MediaInterface $media): array { $empty = [ 'src' => '', - 'alt' => '', 'focal_x' => NULL, 'focal_y' => NULL, 'cache_tags' => [], @@ -69,7 +71,6 @@ public function getImageSourceAltAndFocalPoint(MediaInterface $media): array { $result = $empty; $result['src'] = $file->getFileUri(); - $result['alt'] = $media_attributes[0]['alt'] ?? ''; $result['cache_tags'] = $file->getCacheTags(); if ($media instanceof FieldableEntityInterface) { From 9a85ad7398da7095810106f34d22375e19a13651 Mon Sep 17 00:00:00 2001 From: Kevin Lu <79181817+kevdevlu@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:05:42 -0700 Subject: [PATCH 14/21] heading style adjustments --- components/ranking/ranking.component.yml | 18 +++++++- components/ranking/ranking.css | 41 +++++++++++++++++++ components/ranking/ranking.twig | 23 +++++------ .../Behavior/AZRankingsParagraphBehavior.php | 16 +++++++- .../src/AZRankingComponentBuilder.php | 14 ++++++- 5 files changed, 95 insertions(+), 17 deletions(-) diff --git a/components/ranking/ranking.component.yml b/components/ranking/ranking.component.yml index 36f8989604..dc3b0d9e76 100644 --- a/components/ranking/ranking.component.yml +++ b/components/ranking/ranking.component.yml @@ -45,15 +45,29 @@ props: header_style: type: string title: Header Style - description: Bold or thin lettering for the heading. + description: How heavy the heading lettering is. enum: - bold - thin + - bolder meta:enum: bold: Bold headers (default) thin: Thin headers + bolder: Bolder headers examples: - bold + heading_font: + type: string + title: Heading Font + description: Typeface for the heading. Serif uses Garamond Premier Pro where available, otherwise a system serif. Depending on your site environment, serif might not be available in thin or bolder Header Style. + enum: + - sans + - serif + meta:enum: + sans: Sans-serif (default) + serif: Serif + examples: + - sans alignment: type: string title: Content Alignment @@ -69,7 +83,7 @@ props: clickable: type: boolean title: Clickable - description: Make the whole card a link to the link URL. + description: Make the whole card a link to the Link URL. Has no effect unless a Link URL is set. examples: - true hover_effect: diff --git a/components/ranking/ranking.css b/components/ranking/ranking.css index b62783f2f1..a38e7cdb1d 100644 --- a/components/ranking/ranking.css +++ b/components/ranking/ranking.css @@ -33,6 +33,47 @@ } } +/* + * Heading Styles + */ +.az-ranking-sdc .az-ranking-sdc__heading { + font-size: 58px; + line-height: 1.05; + letter-spacing: 0; +} + +.az-ranking-sdc .az-ranking-sdc__heading, +.az-ranking-sdc.hover:hover .az-ranking-sdc__heading, +.az-ranking-sdc.hover:focus .az-ranking-sdc__heading { + text-decoration-thickness: 2px; + text-underline-offset: 2px; +} + +.az-ranking-sdc .az-ranking-sdc__heading { + font-weight: 400; +} + +.az-ranking-sdc .az-ranking-sdc__heading--bold { + font-weight: 700; +} + +.az-ranking-sdc .az-ranking-sdc__heading--bolder { + font-weight: 900; +} + +.az-ranking-sdc { + --az-ranking-heading-sans: var(--bs-body-font-family); + --az-ranking-heading-serif: garamond-premier-pro, Georgia, "Times New Roman", serif; +} + +.az-ranking-sdc .az-ranking-sdc__heading { + font-family: var(--az-ranking-heading-sans); +} + +.az-ranking-sdc .az-ranking-sdc__heading--serif { + font-family: var(--az-ranking-heading-serif); +} + /* Keep visually-hidden link titles in flow so stretched-link covers the card. */ .az-ranking-sdc .card-body .visually-hidden { display: block; diff --git a/components/ranking/ranking.twig b/components/ranking/ranking.twig index ca8e8f2f0b..480efa8283 100644 --- a/components/ranking/ranking.twig +++ b/components/ranking/ranking.twig @@ -11,14 +11,18 @@ * - description: Supporting line displayed below the heading. * - source: Attribution text; newlines are preserved. * - heading_level: Heading tag, h2-h6. Defaults to h3. - * - header_style: bold | thin. Defaults to bold. + * - header_style: bold | thin | bolder. Defaults to bold. Sets the heading's + * font weight; see ranking.css for the values. + * - heading_font: sans | serif. Defaults to sans. * - alignment: left | center. Defaults to left. * - background: chili | blue | sky | oasis | azurite | cool-gray | warm-gray | * white | transparent. Defaults to chili. Ignored when hover effect is active. * - font_color: midnight | black | white | az-blue. Only used when background * is transparent. Defaults to midnight. - * - clickable: Whole card links to link_url. - * - hover_effect: Contrasting hover colors; only honored when clickable. + * - clickable: Whole card links to link_url. Ignored without a link_url — + * see the guard below. + * - hover_effect: Contrasting hover colors; only honored when clickable, so + * also ignored without a link_url. * - hover_background: chili | blue | sky | cool-gray | oasis. Defaults to chili. * - link_url: Link destination. * - link_title: Link text when the card is not clickable; falls back to source. @@ -37,7 +41,8 @@ {% if heading_level not in ['h2', 'h3', 'h4', 'h5', 'h6'] %} {% set heading_level = 'h3' %} {% endif %} -{% set header_style = header_style|default('bold') in ['bold', 'thin'] ? header_style|default('bold') : 'bold' %} +{% set header_style = header_style|default('bold') in ['bold', 'thin', 'bolder'] ? header_style|default('bold') : 'bold' %} +{% set heading_font = heading_font|default('sans') in ['sans', 'serif'] ? heading_font|default('sans') : 'sans' %} {% set alignment = alignment|default('left') in ['left', 'center'] ? alignment|default('left') : 'left' %} {% set background_classes = { @@ -55,7 +60,7 @@ {% set font_color = font_color|default('midnight') in ['midnight', 'black', 'white', 'az-blue'] ? font_color|default('midnight') : 'midnight' %} -{% set clickable = clickable|default(false) %} +{% set clickable = clickable|default(false) and link_url|default('') %} {# Hover effect requires a clickable card. #} {% set hover_effect = clickable ? hover_effect|default(false) : false %} {% set hover_background = hover_background|default('chili') in ['chili', 'blue', 'sky', 'cool-gray', 'oasis'] ? hover_background|default('chili') : 'chili' %} @@ -125,13 +130,7 @@
{% if heading|default('') %} - <{{ heading_level }} class="display-4 m-0 az-ranking-sdc__heading{{ header_style == 'bold' ? ' fw-bolder' : '' }}{{ text_override ? ' ' ~ text_override : '' }}"> - {% if clickable %} - {{ heading }} - {% else %} - {{ heading }} - {% endif %} - + <{{ heading_level }} class="m-0 az-ranking-sdc__heading{{ header_style != 'thin' ? ' az-ranking-sdc__heading--' ~ header_style : '' }}{{ heading_font == 'serif' ? ' az-ranking-sdc__heading--serif' : '' }}{{ clickable ? ' hover-text-underline' : '' }}{{ text_override ? ' ' ~ text_override : '' }}">{{ heading }} {% endif %} {% if description|default('') %}

{{ description }}

diff --git a/modules/custom/az_paragraphs/src/Plugin/paragraphs/Behavior/AZRankingsParagraphBehavior.php b/modules/custom/az_paragraphs/src/Plugin/paragraphs/Behavior/AZRankingsParagraphBehavior.php index 51dc1e4ec6..ce40ffd81a 100644 --- a/modules/custom/az_paragraphs/src/Plugin/paragraphs/Behavior/AZRankingsParagraphBehavior.php +++ b/modules/custom/az_paragraphs/src/Plugin/paragraphs/Behavior/AZRankingsParagraphBehavior.php @@ -60,11 +60,23 @@ public function buildBehaviorForm(ParagraphInterface $paragraph, array &$form, F '#title' => $this->t('Ranking header style'), '#type' => 'select', '#options' => [ - 'ranking-title-bold' => $this->t('Bold Headers'), 'ranking-title-thin' => $this->t('Thin Headers'), + 'ranking-title-bold' => $this->t('Bold Headers'), + 'ranking-title-bolder' => $this->t('Bolder Headers'), ], '#default_value' => $config['ranking_header_style'] ?? 'ranking-title-bold', - '#description' => $this->t('Uses large bold lettering or thin-styled font for headers'), + '#description' => $this->t('How heavy the heading lettering is. These map to the Header Style options on the Ranking component.'), + ]; + + $form['ranking_heading_font'] = [ + '#title' => $this->t('Ranking heading font'), + '#type' => 'select', + '#options' => [ + 'sans' => $this->t('Sans-serif'), + 'serif' => $this->t('Serif'), + ], + '#default_value' => $config['ranking_heading_font'] ?? 'sans', + '#description' => $this->t('Typeface for ranking headings. Serif uses Garamond Premier Pro where available, otherwise a system serif. Depending on your site environment, serif might not be available in thin or bolder Header Style.'), ]; $form['ranking_clickable'] = [ diff --git a/modules/custom/az_ranking/src/AZRankingComponentBuilder.php b/modules/custom/az_ranking/src/AZRankingComponentBuilder.php index 261020373c..c0fcd5bb94 100644 --- a/modules/custom/az_ranking/src/AZRankingComponentBuilder.php +++ b/modules/custom/az_ranking/src/AZRankingComponentBuilder.php @@ -68,6 +68,17 @@ class AZRankingComponentBuilder { 'w-100 btn btn-outline-white mt-2' => 'btn-outline-white', ]; + /** + * Legacy header style select values, keyed to SDC tokens. + * + * @see \Drupal\az_paragraphs\Plugin\paragraphs\Behavior\AZRankingsParagraphBehavior + */ + const HEADER_STYLE_MAP = [ + 'ranking-title-thin' => 'thin', + 'ranking-title-bold' => 'bold', + 'ranking-title-bolder' => 'bolder', + ]; + /** * Legacy per-breakpoint Bootstrap column classes, keyed to column counts. */ @@ -175,7 +186,8 @@ public function buildRankingComponent(array $values, array $ranking_defaults): a $props['link_style'] = self::LINK_STYLE_CLASS_MAP[$values['ranking_link_style'] ?? ''] ?? 'btn-red'; } - $props['header_style'] = ($ranking_defaults['ranking_header_style'] ?? '') === 'ranking-title-thin' ? 'thin' : 'bold'; + $props['header_style'] = self::HEADER_STYLE_MAP[$ranking_defaults['ranking_header_style'] ?? ''] ?? 'bold'; + $props['heading_font'] = ($ranking_defaults['ranking_heading_font'] ?? '') === 'serif' ? 'serif' : 'sans'; $props['alignment'] = ($ranking_defaults['ranking_alignment'] ?? '') === 'text-center' ? 'center' : 'left'; $props['clickable'] = !empty($ranking_defaults['ranking_clickable']); $props['hover_effect'] = !empty($ranking_defaults['ranking_hover_effect']); From 9cb296195561b9153ddb9e16e245158d8b6e4443 Mon Sep 17 00:00:00 2001 From: Kevin Lu <79181817+kevdevlu@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:34:46 -0700 Subject: [PATCH 15/21] Clarify Comments, Remove dead code, Refactor ranking-image.js --- components/ranking-deck/ranking-deck.css | 17 ++- components/ranking-deck/ranking-deck.twig | 19 ++- components/ranking-image/ranking-image.css | 73 +++++---- components/ranking-image/ranking-image.js | 123 +++++++++------- components/ranking-image/ranking-image.twig | 92 +++++------- components/ranking/ranking.css | 55 ++++--- components/ranking/ranking.twig | 35 ++++- .../src/AZRankingComponentBuilder.php | 139 +++++++++--------- .../az_ranking/src/AZRankingImageHelper.php | 43 +++--- .../src/Element/AZRankingItemElement.php | 37 +++-- .../AZRankingDefaultFormatter.php | 36 ++--- .../Field/FieldWidget/AZRankingWidget.php | 104 +++++-------- 12 files changed, 416 insertions(+), 357 deletions(-) diff --git a/components/ranking-deck/ranking-deck.css b/components/ranking-deck/ranking-deck.css index e534870c9d..536e7009b0 100644 --- a/components/ranking-deck/ranking-deck.css +++ b/components/ranking-deck/ranking-deck.css @@ -1,10 +1,14 @@ /** * Ranking Deck component. * - * Responsive CSS grid for Ranking cards. The grid (not the cards) owns the - * layout and spacing: per-breakpoint column counts come from modifier - * classes, and the gap matches the legacy pb-4 rhythm (1.5rem). Grid items - * stretch by default, giving equal-height cards per row. + * A CSS grid for Ranking cards. The deck owns the layout, not the cards - + * how many columns show at each breakpoint comes from the modifier classes + * below. + * + * The 1.5rem gap matches the spacing pb-4 gave in the legacy markup, kept so + * decks look the same after the port. Grid items stretch to fill their row + * by default, which is what makes cards in a row match heights without + * anything setting a height. */ .az-ranking-deck { @@ -12,6 +16,11 @@ gap: 1.5rem; } +/* + * Grid items start at min-width: auto, so a long unbroken word - a URL in a + * source line, say - can push its column wider than the track it was given + * and skew the whole row. Zero lets the item shrink and wrap instead. + */ .az-ranking-deck > * { min-width: 0; } diff --git a/components/ranking-deck/ranking-deck.twig b/components/ranking-deck/ranking-deck.twig index 3675aad463..2c783e10db 100644 --- a/components/ranking-deck/ranking-deck.twig +++ b/components/ranking-deck/ranking-deck.twig @@ -3,7 +3,15 @@ * @file * Template for the az_quickstart Ranking Deck component. * - * Lays out Ranking cards in a responsive CSS grid with consistent spacing. + * Renders one
that arranges whatever is placed in its `rankings` slot + * into a CSS grid: + * + *
+ * + * The deck owns the layout, not the cards inside it. How many columns show + * at each breakpoint comes from those modifier classes, so a Ranking card + * never has to know how many siblings it has. * * Props: * - columns_desktop: Rankings per row on desktop (1-4). Defaults to 4. @@ -18,7 +26,14 @@ #} {% set attributes = attributes|default(create_attribute()) %} -{# Runtime guards: coerce invalid or missing values to safe defaults. #} +{# + If a column count is missing or is not 1-4, fall back to a safe default. + Rationale: the classes further down are built by sticking the number on + the end of a string, so a value like '7' would produce + az-ranking-deck--desktop-7, which no CSS matches. The next breakpoint down + then wins by default - a bad desktop value leaves the deck on its tablet + count, and only a deck with all three broken drops to one column. +#} {% set allowed = ['1', '2', '3', '4'] %} {% set columns_desktop = columns_desktop|default('4') in allowed ? columns_desktop|default('4') : '4' %} {% set columns_tablet = columns_tablet|default('2') in allowed ? columns_tablet|default('2') : '2' %} diff --git a/components/ranking-image/ranking-image.css b/components/ranking-image/ranking-image.css index 2db952a3e5..9438c08a1f 100644 --- a/components/ranking-image/ranking-image.css +++ b/components/ranking-image/ranking-image.css @@ -1,44 +1,51 @@ /** * Ranking Image component. * - * A layout wrapper around a media-library-backed . `grid-column: span N` - * only has an effect when this component is placed inside a CSS grid - * container (for example, the Ranking Deck component) — it is inert - * everywhere else, so the same width_span_* props work whether the image is - * placed inside a deck or standalone on a page. + * A layout wrapper around a media-library-backed . The width_span_* + * props become `grid-column: span N`, which only does anything when this + * component sits inside a CSS grid - a Ranking Deck, usually. Outside one it + * is inert, so the same props are safe whether the image is in a deck or + * standalone on a page. * - * Width span is deliberately THREE separate props (desktop/tablet/phone), - * not one dynamic value, and this is not a stylistic choice — CSS Grid has - * no way for a grid item to clamp its own span against its container's - * actual column count. That was confirmed to be a genuine, still-open CSS - * Working Group spec gap, not a browser-support question: - * https://github.com/w3c/csswg-drafts/issues/5852 ("Ability to clamp track - * spanning"). A grid item whose span exceeds its container's explicit - * track count gets an IMPLICIT extra track instead of clamping, which also - * squeezes every sibling card in that row, not just the image. + * Why three span props rather than one that adapts: CSS Grid gives a grid + * item no way to clamp its own span against its container's column count. + * That is a gap in the spec, not in browsers - + * https://github.com/w3c/csswg-drafts/issues/5852. An item spanning more + * tracks than exist gets an extra implicit track instead of clamping, which + * squeezes every sibling in that row, not just the image. * - * Each width_span_* prop defaults to Ranking Deck's own matching default - * column count (desktop 2, tablet 2, phone 1), so an image is always safe - * out of the box. Editors who configure a deck with more columns at a given - * breakpoint can explicitly raise that breakpoint's span to match. + * The spans default to 2 / 2 / 1 (desktop / tablet / phone). Tablet and + * phone match Ranking Deck's own defaults; desktop is deliberately below the + * deck's default of 4, since a span can only cause trouble by being too + * large. So an image is safe out of the box. Raise one to match if a deck is + * configured with more columns. */ .az-ranking-image { position: relative; overflow: hidden; - /* Matches .az-ranking-sdc's own responsive min-height (ranking.css) so an - image-only row (or an image taller than its siblings) has a sensible - floor, and so the image never dictates the row's height (see img rule - below) — same technique the legacy .ranking-image-wrapper used. - Small viewports. */ + /* + * The same responsive floor .az-ranking-sdc uses in ranking.css, so a row + * holding only images still has a sensible height, and so the image never + * decides the row height itself (see the img rule below). Legacy got the + * same result from two elements: the floor sat on the parent column + * .az-ranking-responsive, and .ranking-image-wrapper was absolutely + * positioned inside it. This component does both jobs itself. + */ min-height: 190px; } -/** - * Matches ranking-image component's public://placeholder-1000x500.png - * placeholder image in the schema. Makes the hover preview in Canvas library - * left panel display with correct aspect ratio (#component-wrapper is the id - * Canvas's ComponentPreview.tsx uses to wrap it with). +/* + * Give the hover preview in Canvas's Library panel the right shape. + * Rationale: the below is positioned absolutely, so it adds no height + * of its own, and in the panel there is no grid row to borrow a height from + * either - the preview falls back to the min-height above and shows at + * whatever proportions that happens to give. 2/1 is the shape of the + * placeholder the schema points at, + * public://az-placeholder/1000x500/placeholder.svg. + * + * #component-wrapper is the id Canvas wraps previews in; see its + * ComponentPreview.tsx. */ #component-wrapper .az-ranking-image { aspect-ratio: 2 / 1; @@ -59,10 +66,12 @@ } .az-ranking-image img { - /* Absolutely positioned so the image's own intrinsic aspect ratio never - affects the grid row's height — the row is sized by its other content - (Ranking cards), and this image crops via object-fit to fill whatever - height that produces, exactly like the legacy .ranking-image-wrapper. */ + /* + * Positioned absolutely so the image's own proportions never affect how + * tall the grid row gets. The row is sized by its other content - Ranking + * cards - and the image then crops with object-fit to fill whatever height + * that came to. The legacy .ranking-image-wrapper did the same. + */ position: absolute; top: 0; left: 0; diff --git a/components/ranking-image/ranking-image.js b/components/ranking-image/ranking-image.js index 80d863ac6c..ca058051a3 100644 --- a/components/ranking-image/ranking-image.js +++ b/components/ranking-image/ranking-image.js @@ -1,21 +1,16 @@ /** * @file - * Dynamically calculates object-position for az_quickstart:ranking-image - * based on focal point. + * Keeps a ranking image's focal point in view when CSS crops the image. * - * Uses the formula: - * objectPosX = (focalX * imageW - 0.5 * containerW) / (imageW - containerW) - * objectPosY = (focalY * imageH - 0.5 * containerH) / (imageH - containerH) + * ranking-image.css sets object-fit: cover, which fills the box and cuts off + * whatever does not fit. This sets object-position so the point an editor + * picked survives that cut. Runs on .az-ranking-image__img, once on load and + * again after a resize. * - * This ensures the focal point stays centered in the visible area when - * object-fit: cover crops the image. imageW/imageH come from the loaded - * 's own naturalWidth/naturalHeight. - * - * Targets .az-ranking-image__img and lives on the component itself, not - * in az_ranking, because focal_x/focal_y are plain az_quickstart: - * ranking-image props with no dependency on az_ranking, and the component - * must keep working (focal point included) wherever it's placed, Canvas - * or paragraph-authored, az_ranking installed or not. + * It lives with the component rather than in az_ranking because focal_x and + * focal_y are ordinary ranking-image props. The component has to keep + * working wherever it is placed - in Canvas or authored as a paragraph, with + * az_ranking installed or not. */ ((Drupal, once) => { @@ -38,47 +33,46 @@ const focalX = parseFloat(img.getAttribute('data-focal-x')); const focalY = parseFloat(img.getAttribute('data-focal-y')); - // Skip if no focal point data + // No focal point set on this image, so leave object-position alone. if (Number.isNaN(focalX) || Number.isNaN(focalY)) { return; } - // Get container dimensions (the visible area) const containerW = img.offsetWidth; const containerH = img.offsetHeight; - // Use the loaded (styled-derivative) image's own natural dimensions. - // The formula below only ever uses these as a RATIO - // (imageRatio = originalW / originalH), never as absolute values, - // and az_ranking_responsive's image_scale effect always preserves - // aspect ratio (that's what distinguishes it from - // image_scale_and_crop), even when upscaling - so naturalWidth/ - // naturalHeight of the styled derivative the browser actually - // loaded gives the exact same ratio as the true original, with no - // need to pass original dimensions down as a separate prop at all. + /* + * These are the dimensions of the styled derivative the browser + * actually downloaded, not of the original file - and that is fine, + * because everything below uses them only as a ratio. The + * az_ranking_responsive style scales, and scaling keeps the aspect + * ratio (that is what separates image_scale from + * image_scale_and_crop), so the derivative's ratio equals the + * original's. It saves passing the original dimensions down as a + * separate prop. + */ const originalW = img.naturalWidth; const originalH = img.naturalHeight; - // Skip if dimensions not available yet + // Nothing to measure against yet - the image or its box has no size. if (!originalW || !originalH || !containerW || !containerH) return; - // Calculate aspect ratios to determine crop direction const imageRatio = originalW / originalH; const containerRatio = containerW / containerH; - // Calculate the SCALED dimensions after object-fit: cover. - // object-fit: cover scales the image to fill the container while maintaining aspect ratio. + /* + * Work out how big cover made the image. It scales the image - up or + * down - until both sides reach or pass the container, so whichever + * side the image is proportionally longer on is the side that + * overflows and gets cropped. + */ let scaledW; let scaledH; if (imageRatio > containerRatio) { - // Image is WIDER than container (will be cropped horizontally) - // Scale to match container HEIGHT scaledH = containerH; scaledW = containerH * imageRatio; } else { - // Image is TALLER than container (will be cropped vertically) - // Scale to match container WIDTH scaledW = containerW; scaledH = containerW / imageRatio; } @@ -86,38 +80,56 @@ let objectPosX; let objectPosY; - if (imageRatio > containerRatio) { - // Image is WIDER than container (cropped horizontally - left/right sides cut off) - // Apply formula to X using SCALED dimensions, use focal point directly for Y - objectPosX = - (focalX * scaledW - 0.5 * containerW) / (scaledW - containerW); - objectPosY = focalY; - } else { - // Image is TALLER than container (cropped vertically - top/bottom cut off) - // Use focal point directly for X, apply formula to Y using SCALED dimensions - objectPosX = focalX; - objectPosY = - (focalY * scaledH - 0.5 * containerH) / (scaledH - containerH); - } - - // Convert to percentage and clamp between 0-100% + /* + * Work out where to sit the image inside its box. Meet these + * requirements: + * 1. Only an axis with slack can slide. cover leaves at most one + * axis longer than the box; the other already matches it exactly, + * so its focal value passes straight through. + * 2. On a sliding axis, object-position is a share of the slack + * rather than of the image, so the focal point converts into that + * scale: + * pos = (focal * scaled - 0.5 * container) / (scaled - container) + * which lands the focal point in the middle of what stays visible. + * 3. Under half a pixel of slack counts as none. An image whose + * ratio matches its box has nowhere to slide, and dividing by + * that leftover would throw it to an edge instead. + */ + const overflowX = scaledW - containerW; + const overflowY = scaledH - containerH; + + objectPosX = + overflowX > 0.5 + ? (focalX * scaledW - 0.5 * containerW) / overflowX + : focalX; + objectPosY = + overflowY > 0.5 + ? (focalY * scaledH - 0.5 * containerH) / overflowY + : focalY; + + /* + * Clamp because the formula can overshoot when the focal point sits + * near an edge, and object-position past 0-100% would pull the image + * away from the box and leave a gap. + */ objectPosX = Math.max(0, Math.min(100, objectPosX * 100)); objectPosY = Math.max(0, Math.min(100, objectPosY * 100)); - // Apply to image img.style.objectPosition = `${objectPosX}% ${objectPosY}%`; }; /** - * Process all images. + * Positions every image, waiting for any that have not loaded yet. + * + * The calculation needs naturalWidth, which is only there once the + * file has arrived. Anything that has finished loading by the time we + * run is handled on the spot; the rest get a one-shot load listener. */ const processImages = () => { images.forEach((img) => { - // If image is already loaded, calculate immediately if (img.complete && img.naturalWidth > 0) { calculateObjectPosition(img); } else { - // Wait for image to load img.addEventListener('load', () => calculateObjectPosition(img), { once: true, }); @@ -125,10 +137,13 @@ }); }; - // Initial calculation processImages(); - // Recalculate on window resize (debounced) + /* + * A resize changes the container's shape, which changes how much gets + * cropped, so the position has to be worked out again. Debounced + * because a drag fires this continuously. + */ let resizeTimer; window.addEventListener('resize', () => { clearTimeout(resizeTimer); diff --git a/components/ranking-image/ranking-image.twig b/components/ranking-image/ranking-image.twig index 427d4acb05..629edd1c85 100644 --- a/components/ranking-image/ranking-image.twig +++ b/components/ranking-image/ranking-image.twig @@ -3,63 +3,51 @@ * @file * Template for the az_quickstart Ranking Image component. * - * Renders an directly from a media-library-backed prop — no slot, no - * Canvas dependency. The `src` prop's shape (type: string, format: uri, - * contentMediaType: image/*, x-allowed-schemes: [public]) is recognized by - * Drupal core's media_library module via a canvas_storable_prop_shape_alter - * hook Canvas ships — that's what gives editors a real media-library picker - * in Canvas, with no canvas:image / canvas.module $ref needed, so this stays - * safe to render on sites without the Canvas module installed. + * Renders one inside a positioned wrapper: * - * That last point is a hard constraint, not an incidental property: this - * component also renders the legacy az_ranking paragraph type, on sites that - * may never install Canvas. So keep every Canvas-owned mechanism out of this - * template and out of the component's schema — in particular the - * `json-schema-definitions://` $ref scheme and the `apply_image_style` Twig - * filter, both registered by canvas.services.yml. Either one makes this - * template fail on a Canvas-less site, and a missing Twig filter fails at - * compile time (SyntaxError), so there is no graceful degradation. Use - * az_media's own filters instead; if an object-shaped image prop is ever - * wanted, declare it inline and map it with our own - * hook_canvas_storable_prop_shape_alter(), which Canvas invokes even when it + *
+ * + *
+ * + * The image is always decorative - alt="" plus aria-hidden on the wrapper - + * and there is no alt or decorative prop for an editor to set. + * + * Keep Canvas out of this file. The same component renders the az_ranking + * paragraph in the page builder, on sites that may never install Canvas, so + * anything Canvas owns breaks it there. Two to avoid in particular: the + * `json-schema-definitions://` $ref scheme, and the `apply_image_style` Twig + * filter. canvas.services.yml registers both, and a Twig filter that does + * not exist fails when the template compiles, so nothing degrades + * gracefully. Use az_media's filters instead. If an object-shaped image prop + * is ever wanted, declare it inline and map it with our own + * hook_canvas_storable_prop_shape_alter(), which Canvas calls even when it * has no mapping of its own. * - * This component is always decorative. It renders alt="" and hides the - * image from screen readers. + * Editors still get a real media library picker in Canvas without any of + * that. The shape of the `src` prop - string, format: uri, + * contentMediaType: image/*, x-allowed-schemes: [public] - is what core's + * media_library matches on, through a hook Canvas ships. * * Props: - * - src: Image URI, picked via the media library in Canvas. Resolves to a - * Drupal stream-wrapper URI (public://...), not a browser-loadable URL — - * converted via the image_style Twig filter (az_media's - * ImageStyleTwigExtension), which applies the az_ranking_responsive - * image style (scale + WebP conversion) and falls back to plain - * file_url()-equivalent behavior if that style is ever unavailable, so a - * missing/misconfigured style degrades gracefully rather than breaking - * the page. This is a soft dependency on az_media (not - * az_ranking) — the filter itself is generic and lives in az_media - * regardless of which style name gets passed to it here. - * - focal_x / focal_y: Focal point as a 0-1 fraction of the image's width/ - * height, kept visible when object-fit: cover crops the image to fill - * its container. Rendered as data-focal-x/data-focal-y attributes and - * applied client-side (ranking-image.js) — no server-side cropping, - * since the image's effective on-screen aspect ratio depends on live - * CSS Grid layout, not a fixed, enumerable set of image styles (same - * reasoning as width_span_* below). ranking-image.js computes the crop - * from the loaded 's own naturalWidth/naturalHeight. - * - width_span_desktop / width_span_tablet / width_span_phone: 1-4 each. - * Grid columns this image spans at each breakpoint when placed inside a - * Ranking Deck (or any CSS grid layout) — deliberately per-breakpoint, - * not a single dynamic value, because CSS Grid has no way for a grid - * item to clamp its own span against its container's actual column - * count (a confirmed, still-open CSS spec gap, not a browser-support - * issue: https://github.com/w3c/csswg-drafts/issues/5852). No effect - * outside a grid context. Defaults: desktop 2, tablet 2, phone 1 — - * matching Ranking Deck's own default columns, so an image is always - * safe out of the box. Keep each at or below the deck's matching - * "Rankings per row" setting, or the image will overflow into extra - * columns at that breakpoint. - * - utility_classes: Additional Bootstrap utility classes for the wrapper - * (e.g. bottom spacing). Not needed when placed inside a Ranking Deck. + * - src: A stream-wrapper URI like public://cactus.jpg, which a browser + * cannot load on its own. az_media's image_style filter turns it into a + * URL for the az_ranking_responsive style (scale plus WebP), and falls + * back to a plain file URL if that style is missing, so a misconfigured + * style does not take the page down. The filter is generic, which is why + * it lives in az_media rather than az_ranking. + * - focal_x / focal_y: The point to keep in view when object-fit: cover + * crops the image, as a 0-1 fraction of width and height. Rendered as + * data attributes and applied by ranking-image.js. Not cropped server + * side, because how much gets cropped depends on the live grid layout + * rather than on a fixed set of image styles. + * - width_span_desktop / width_span_tablet / width_span_phone: 1-4 grid + * columns to span at each breakpoint. Inert outside a grid. Defaults + * match Ranking Deck's own (2/2/1), so an image is safe out of the box. + * Keep each at or below the deck's "Rankings per row" for that + * breakpoint, or the image spills into an extra column. See + * ranking-image.css for why this is three props and not one. + * - utility_classes: Extra Bootstrap utility classes for the wrapper, e.g. + * bottom spacing. Not needed inside a Ranking Deck. */ #} {% set attributes = attributes|default(create_attribute()) %} diff --git a/components/ranking/ranking.css b/components/ranking/ranking.css index a38e7cdb1d..1cea9050e8 100644 --- a/components/ranking/ranking.css +++ b/components/ranking/ranking.css @@ -1,22 +1,16 @@ /** * Ranking component. * - * Ported from modules/custom/az_ranking/css/az-ranking.css and - * css/az-ranking-image.css with namespaced selectors so the component can - * coexist with the legacy paragraph styles until the paragraph template - * delegates to this component. Color tokens come from Arizona Bootstrap. + * Ported from az_ranking's legacy az-ranking.css and az-ranking-image.css, + * both now deleted. Color tokens come from Arizona Bootstrap. Everything is + * scoped to an .az-ranking-sdc class, and the hover and font-color rules + * carry !important so Bootstrap's utilities don't override them. */ .az-ranking-sdc { position: relative; /* Small viewports. */ min-height: 190px; - /* Overrides the .card default (--bs-border-radius, 0.375rem) to match the - Ranking Card component (components/ranking-card in az_storybook) for - visual consistency across the two ranking designs. Neither the legacy - az_ranking module nor this port ever set this deliberately before; the - mockup calls for 1rem. */ - border-radius: 1rem; } /* Medium viewports. */ @@ -45,8 +39,8 @@ .az-ranking-sdc .az-ranking-sdc__heading, .az-ranking-sdc.hover:hover .az-ranking-sdc__heading, .az-ranking-sdc.hover:focus .az-ranking-sdc__heading { - text-decoration-thickness: 2px; - text-underline-offset: 2px; + text-decoration-thickness: 3px; + text-underline-offset: 3px; } .az-ranking-sdc .az-ranking-sdc__heading { @@ -74,15 +68,40 @@ font-family: var(--az-ranking-heading-serif); } -/* Keep visually-hidden link titles in flow so stretched-link covers the card. */ +/* + * Keep the whole card clickable when the link title is hidden. + * + * The "Hidden link title" style puts Bootstrap's .visually-hidden on the + * same
as .stretched-link. stretched-link's invisible click layer sizes + * itself against the nearest positioned ancestor - normally the card - but + * visually-hidden sets position: absolute on the , so the layer shrinks + * to the 's own 1px box and only that pixel is clickable. Going back to + * static skips the and finds .az-ranking-sdc again. + * + * display: block is the other half: a static is an inline box, and + * width/height do not apply to those, so visually-hidden's 1px sizing would + * be ignored and the text would show. + */ .az-ranking-sdc .card-body .visually-hidden { display: block; position: static !important; } /* - * Preset hover colors, paired to the hover background color. - * !important is required to out-rank the text-bg-* utility colors. + * Hover pairs. Each background gets a specific background and text color + * when the card is hovered. + * + * The `*` reaches every descendant because a card's text sits across the + * heading, description and source elements, and repainting their background + * is what produces the effect - the card root's own color would otherwise + * show through the gaps. + * + * !important is here for the classes that land on those descendants + * directly: the text-midnight / text-azurite contrast utilities ranking.twig + * adds, which carry !important themselves, and the .btn-* colors on the + * link. It is not needed against the card's own text-bg-* utility, since a + * descendant inherits that and any direct declaration already beats an + * inherited value. */ .text-bg-chili.az-ranking-sdc--bold-hover:hover * { background-color: var(--bs-white) !important; @@ -110,7 +129,9 @@ } /* - * Transparent-background font color overrides. Links keep their own colors. + * Font color for transparent cards, which have no text-bg-* utility to + * inherit one from. :not(a) leaves links alone so they keep their own color + * and stay distinguishable from the text around them. */ .az-ranking-sdc--text-white *:not(a) { color: var(--bs-white) !important; @@ -121,7 +142,7 @@ } .az-ranking-sdc--text-az-blue *:not(a) { - color: RGBA(var(--bs-blue-rgb), var(--bs-bg-opacity, 1)); + color: RGBA(var(--bs-blue-rgb), var(--bs-bg-opacity, 1)) !important; } .az-ranking-sdc--text-midnight *:not(a) { diff --git a/components/ranking/ranking.twig b/components/ranking/ranking.twig index 480efa8283..e00b499654 100644 --- a/components/ranking/ranking.twig +++ b/components/ranking/ranking.twig @@ -3,8 +3,29 @@ * @file * Template for the az_quickstart Ranking component. * - * For an image card, use the Ranking Image component instead (place it in a - * Ranking Deck alongside Ranking cards — see components/ranking-image). + * Renders one card - a large heading holding the ranking value, a + * supporting line, and an attribution, on a colored background: + * + *
+ *
+ *
+ *
+ *

TOP 1%

+ *

of World Universities

+ *
+ *
US News, 2026
+ * + * The card-body level is not just padding - ranking.css hangs the + * visually-hidden link fix off it. + * + * The card's own colors and styling are decided here. Callers pass semantic + * values like background: 'blue' or link_style: 'btn-red' and this template + * turns them into Bootstrap classes; the one exception is utility_classes, + * which callers pass through as written. That is what lets one component + * serve both Canvas and the page builder. + * + * For an image card use the Ranking Image component instead, dropped into a + * Ranking Deck alongside these. * * Props: * - heading: Main ranking value. @@ -36,7 +57,15 @@ #} {% set attributes = attributes|default(create_attribute()) %} -{# Runtime guards: coerce invalid or missing values to safe defaults. #} +{# + Fall back to a safe default for anything missing or out of range. + Rationale: these all get interpolated further down. Most land in class + names - 'az-ranking-sdc--text-' ~ font_color, or the heading's + '--' ~ header_style - where a stray value produces a class no CSS matches + and the card quietly loses that piece of its styling. heading_level is the + one that matters more: it is printed as the heading's tag name, so a bad + value there emits broken markup rather than unstyled markup. +#} {% set heading_level = heading_level|default('h3') %} {% if heading_level not in ['h2', 'h3', 'h4', 'h5', 'h6'] %} {% set heading_level = 'h3' %} diff --git a/modules/custom/az_ranking/src/AZRankingComponentBuilder.php b/modules/custom/az_ranking/src/AZRankingComponentBuilder.php index c0fcd5bb94..34326dbac6 100644 --- a/modules/custom/az_ranking/src/AZRankingComponentBuilder.php +++ b/modules/custom/az_ranking/src/AZRankingComponentBuilder.php @@ -10,28 +10,33 @@ use Drupal\Core\Url; /** - * Maps normalized ranking item values to az_quickstart SDC render arrays. + * Turns stored ranking values into render arrays for the ranking components. * - * Shared by AZRankingDefaultFormatter (published rendering) and - * AZRankingWidget (the live edit-form preview), so both render through the - * exact same components and can't drift apart into two implementations. + * Both page builder paths use it: AZRankingDefaultFormatter for the + * published page, and AZRankingWidget for the live preview on the edit form. + * Sharing it is what keeps the preview honest - the two cannot drift into + * separate implementations of the same card. * - * buildRankingComponent()/buildImageComponent() take a plain values array - * rather than an AZRankingItem directly. AZRankingDefaultFormatter always - * has a real, correctly-ordered AZRankingItem and uses extractItemValues() - * to build that array. AZRankingWidget's live preview needs to stay correct - * through drag-and-drop reorder + AJAX rebuilds, which means reading values - * from the Form API's own #value (populated from user input, always correct - * for the current row) instead of $items[$delta] (which reflects stored - * array order and can drift out of sync with the visual row after a - * reorder) - so it builds this same array from form state instead of an - * item. Keeping the builder itself item-agnostic is what lets both callers - * share it. + * The build methods take a plain values array rather than an AZRankingItem. + * Rationale: the formatter always holds a real item in the right order, but + * the widget's preview does not. It has to survive drag-and-drop reordering + * and AJAX rebuilds, so it reads from the Form API's #value, which reflects + * the row the user is actually looking at, instead of $items[$delta], which + * still holds the stored order and goes stale the moment a row moves. + * Staying item-agnostic is what lets one builder serve both callers. */ class AZRankingComponentBuilder { /** - * Legacy background/hover-background select values, keyed to SDC tokens. + * Background select values from the widget, mapped to our tokens. + * + * The keys look like CSS classes because that is literally what the widget + * stores on the item, in options['class'] and options['hover_class'] - the + * legacy template printed the setting straight into a class attribute. The + * components take semantic values instead, so every one of these maps + * translates on the way through. + * + * @see \Drupal\az_ranking\Plugin\Field\FieldWidget\AZRankingWidget */ const BACKGROUND_CLASS_MAP = [ 'text-bg-chili' => 'chili', @@ -46,7 +51,7 @@ class AZRankingComponentBuilder { ]; /** - * Legacy font color select values, keyed to SDC tokens. + * Font color select values from the widget, mapped to our tokens. */ const FONT_COLOR_CLASS_MAP = [ 'ranking-text-midnight' => 'midnight', @@ -56,7 +61,7 @@ class AZRankingComponentBuilder { ]; /** - * Legacy link style select values, keyed to SDC tokens. + * Link style select values from the widget, mapped to our tokens. */ const LINK_STYLE_CLASS_MAP = [ 'visually-hidden' => 'hidden', @@ -69,7 +74,7 @@ class AZRankingComponentBuilder { ]; /** - * Legacy header style select values, keyed to SDC tokens. + * Header style select values from the page builder, mapped to our tokens. * * @see \Drupal\az_paragraphs\Plugin\paragraphs\Behavior\AZRankingsParagraphBehavior */ @@ -80,7 +85,7 @@ class AZRankingComponentBuilder { ]; /** - * Legacy per-breakpoint Bootstrap column classes, keyed to column counts. + * Bootstrap column classes the page builder stores, mapped to counts. */ const DESKTOP_COLUMN_MAP = [ 'col-lg-12' => '1', @@ -156,11 +161,10 @@ public function extractItemValues(AZRankingItem $item): array { /** * Builds an az_quickstart:ranking component render array for one item. * - * Props like clickable/hover_effect/link_style interactions (e.g. link - * title and style being ignored while clickable) are NOT resolved here — - * ranking.twig's own guards are the single source of truth for that - * behavior, so this only needs to map field/behavior values onto clean - * prop values. + * This only translates stored values into props. How those props interact + * - link title and style being ignored while the card is clickable, say - + * is decided by ranking.twig's guards. Keeping it there means Canvas gets + * the same behavior without going anywhere near this class. * * @param array $values * Normalized ranking item values - see extractItemValues(). @@ -174,12 +178,12 @@ public function buildRankingComponent(array $values, array $ranking_defaults): a 'source' => $values['ranking_source'] ?? '', ]; - // Gate on the RAW stored link_uri, not the resolved URL string — a bare - // '#' (a common placeholder in demo content) is a real, present link - // that legacy always showed a button for, but Url::fromUserInput('#') - // legitimately stringifies to '' (confirmed empirically, not assumed). - // Checking the resolved string's emptiness instead of the source value - // silently dropped every ranking using such a placeholder link. + // If a link was stored, pass the link props through. Rationale: the + // question here is whether the editor entered a link at all, which is + // what the raw value answers. resolveLinkUrl() can return '' for a link + // that was entered but no longer resolves - a path to a deleted node, + // say - and those should still get link_title and link_style, so the + // card renders consistently with any other unresolvable link. if (!empty($values['link_uri'])) { $props['link_url'] = $this->resolveLinkUrl($values['link_uri']); $props['link_title'] = $values['link_title'] ?? ''; @@ -209,29 +213,28 @@ public function buildRankingComponent(array $values, array $ranking_defaults): a /** * Builds an az_quickstart:ranking-image component render array for one item. * - * Passes a plain file URI as the `src` prop rather than a themed render - * array (which an SDC prop cannot carry). The az_ranking_responsive image - * style is still applied — by ranking-image.twig itself, via az_media's - * `image_style` Twig filter — so scaling and WebP delivery match the - * legacy #theme => image_formatter path. Focal point data is passed - * through as props and applied client-side by the component's own JS. + * The `src` prop gets a plain file URI, because a component prop cannot + * carry a render array. The az_ranking_responsive style still gets applied + * - ranking-image.twig does it with az_media's image_style filter - so + * scaling and WebP delivery match what #theme => image_formatter used to + * produce. Focal point values ride along as props and are applied in the + * browser by the component's own JS. * - * Cache tags for the media and file entities are attached here because - * AZRankingItem stores its media reference as a plain integer, not an - * entity reference, so nothing upstream contributes them automatically. - * Without this, replacing a media entity's image or moving its focal - * point would not invalidate an already-cached ranking. + * Cache tags for the media and file are attached here because + * AZRankingItem stores its media reference as a plain integer rather than + * an entity reference, so nothing upstream contributes them. Without them, + * swapping a media entity's image or moving its focal point would leave an + * already-cached ranking showing the old one. * - * width_span_desktop/tablet/phone are computed here, not just passed - * through legacy's single column_span value, because CSS Grid cannot - * clamp a span against its container's actual column count (a confirmed - * CSS spec gap, not a browser quirk - see ranking-image.css's own - * docblock and - * https://github.com/w3c/csswg-drafts/issues/5852). This reproduces - * legacy's own "min(current row width, column_span)" behavior exactly, - * per breakpoint, using the SAME $deck_props the sibling ranking-deck - * component receives, so the clamp is always correct for whatever the - * paragraph is actually configured to - not a fixed, conservative cap. + * The three width_span_* props are computed rather than passed straight + * from the single stored column_span. Rationale: CSS Grid gives an item no + * way to clamp its own span against its container's column count, so a + * span of 4 in a 2-column deck grows an extra track and squeezes every + * sibling in that row. Taking min() per breakpoint against the same + * $deck_props the sibling ranking-deck receives reproduces what the legacy + * template did, and stays correct for whatever the paragraph is actually + * set to instead of capping at some safe guess. See ranking-image.css for + * the spec-gap detail. * * @param array $values * Normalized ranking item values - see extractItemValues(). @@ -242,11 +245,11 @@ public function buildRankingComponent(array $values, array $ranking_defaults): a * @see \Drupal\az_ranking\AZRankingImageHelper::getImageSourceAndFocalPoint() */ public function buildImageComponent(array $values, array $deck_props): array { - $legacy_span = (int) ($values['options']['column_span'] ?? 2); + $stored_span = (int) ($values['options']['column_span'] ?? 2); $props = [ - 'width_span_desktop' => (string) min($legacy_span, (int) ($deck_props['columns_desktop'] ?? 4)), - 'width_span_tablet' => (string) min($legacy_span, (int) ($deck_props['columns_tablet'] ?? 1)), - 'width_span_phone' => (string) min($legacy_span, (int) ($deck_props['columns_phone'] ?? 1)), + 'width_span_desktop' => (string) min($stored_span, (int) ($deck_props['columns_desktop'] ?? 4)), + 'width_span_tablet' => (string) min($stored_span, (int) ($deck_props['columns_tablet'] ?? 1)), + 'width_span_phone' => (string) min($stored_span, (int) ($deck_props['columns_phone'] ?? 1)), ]; $cache_tags = []; @@ -296,10 +299,10 @@ public function buildDeckProps(array $ranking_defaults): array { /** * Resolves a stored link_uri value to a plain URL string, or ''. * - * Mirrors the URL resolution the legacy formatter already performed - * (public file links, page anchors, and validated internal/external - * paths), only stringified for use as an SDC prop value instead of being - * kept as a Url object for a #type => link render array. + * Same resolution the formatter did before this port - public file links, + * page anchors, and validated internal or external paths - but returned as + * a string, because a component prop cannot hold the Url object a + * #type => link render array wanted. */ protected function resolveLinkUrl(string $link_uri): string { if ($link_uri === '') { @@ -311,15 +314,13 @@ protected function resolveLinkUrl(string $link_uri): string { } if (str_starts_with($link_uri, '#')) { - // Url::fromUserInput('#') is valid but its ->toString() legitimately - // returns '' for a bare fragment (confirmed empirically) - preserve - // the literal anchor directly instead of losing it. A BARE '#' (no - // fragment name) is also rejected by the SDC prop's own - // format: uri-reference validation (confirmed empirically: '#top' - // passes, '#' alone does not) - normalize the empty-fragment case to - // a named one so common placeholder links ('#', used throughout demo - // content) don't fail validation. Same practical behavior (no real - // destination); only the literal href text differs from legacy's '#'. + // Keep an anchor as written, and turn a bare '#' into '#top'. + // Rationale: two separate things go wrong with '#'. Url::fromUserInput + // resolves it to an empty string, so routing it through Url loses the + // anchor entirely. And the prop's own format: uri-reference validation + // rejects a fragment with no name - '#top' passes where '#' does not. + // Demo content uses '#' as a placeholder all over, so both would bite. + // Neither goes anywhere either way; only the href text differs. return $link_uri === '#' ? '#top' : $link_uri; } diff --git a/modules/custom/az_ranking/src/AZRankingImageHelper.php b/modules/custom/az_ranking/src/AZRankingImageHelper.php index f97bd6461d..807138c025 100644 --- a/modules/custom/az_ranking/src/AZRankingImageHelper.php +++ b/modules/custom/az_ranking/src/AZRankingImageHelper.php @@ -7,7 +7,10 @@ use Drupal\media\MediaInterface; /** - * Class AZRankingImageHelper generates image render arrays for ranking images. + * Pulls the image file and focal point off a ranking's media entity. + * + * Returns plain data, not a render array. Turning that into markup is the + * az_quickstart:ranking-image component's job. */ class AZRankingImageHelper { @@ -26,30 +29,27 @@ public function __construct(EntityTypeManagerInterface $entity_type_manager) { } /** - * Get a plain file URI and focal point data for ranking-image. + * Gets the image file URI and focal point from a ranking's media entity. * - * No alt text: ranking-image is always decorative, so the component has - * no alt prop to fill. - * - * Used for both the published az_quickstart:ranking-image render and the - * widget's own live edit-form preview, via AZRankingComponentBuilder:: - * buildImageComponent() (shared by AZRankingDefaultFormatter and - * AZRankingWidget::rebuildRankingPreview()) — both render through the - * same SDC, so the two can't drift apart. + * The published ranking and the widget's edit-form preview both reach + * this through AZRankingComponentBuilder::buildImageComponent(), so the + * two can't drift apart. * * @param \Drupal\media\MediaInterface $media * A Drupal media entity object. * * @return array - * An array with 'src' (an empty string if the media has no image), - * plus 'focal_x' and 'focal_y' (both NULL if the media has no - * focal point set), plus 'cache_tags' - the file entity's own cache - * tags, which the caller MUST attach to whatever render array it builds - * from this data. az_ranking stores its media reference as a plain - * integer property (see AZRankingItem::propertyDefinitions()), not an - * entity reference, so Drupal derives no cache metadata for it - * automatically - nothing else in the render pipeline will invalidate a - * cached ranking when the underlying file is replaced. + * An array with these keys: + * - 'src': the image's file URI, or an empty string if the media has + * no image on it. + * - 'focal_x', 'focal_y': the focal point, or NULL if none is set. + * - 'cache_tags': the file's cache tags. Attach these to whatever + * render array you build from this data, or a cached ranking will + * keep showing the old picture after someone replaces the file. + * Nothing upstream does it for you: az_ranking stores its media + * reference as a plain integer property (see + * AZRankingItem::propertyDefinitions()) rather than an entity + * reference, so Drupal derives no cache metadata from it. */ public function getImageSourceAndFocalPoint(MediaInterface $media): array { $empty = [ @@ -83,7 +83,10 @@ public function getImageSourceAndFocalPoint(MediaInterface $media): array { } } catch (\Throwable $e) { - // Defensive: do not break rendering if fields are not present. + // If reading the focal point goes wrong, leave focal_x and focal_y + // NULL and carry on. Rationale: with no focal point the image just + // stays centered, which is a fine-looking result. Not worth taking + // the page down over. } } diff --git a/modules/custom/az_ranking/src/Element/AZRankingItemElement.php b/modules/custom/az_ranking/src/Element/AZRankingItemElement.php index e5e697d567..1e4add4c94 100644 --- a/modules/custom/az_ranking/src/Element/AZRankingItemElement.php +++ b/modules/custom/az_ranking/src/Element/AZRankingItemElement.php @@ -9,22 +9,19 @@ /** * Provides a render element for one az_ranking field item. * - * Owns building both the editable fields (details) and the live preview - * (preview_wrapper) together, as one real, reusable Element plugin - - * instead of the fields being built directly in - * AZRankingWidget::formElement() with the preview patched on afterwards - * via #after_build bolted onto Field API's own opaque per-delta wrapper. + * One element builds both halves of a ranking's row in the edit form: the + * editable fields (details) and the live preview (preview_wrapper). + * Previously the fields were built in AZRankingWidget::formElement() and + * the preview was attached afterwards, through #after_build on the wrapper + * Field API generates for each delta. * - * #after_build is still what rebuilds the preview (see - * AZRankingWidget::rebuildRankingPreview()) - a #value_callback doesn't fit - * here, since the preview isn't itself a single resolvable value, it's a - * render array derived from many sibling fields' already-resolved values. - * What changes is WHERE that logic lives: a self-contained Element type - * with direct, local access to its own details/preview_wrapper children - * (preserving the direct-sibling access #after_build needs - scoping a - * custom element to the preview alone would lose that, since #after_build - * only sees its own descendants, not a parent's other children), not glue - * entangled in widget/Field API internals. + * #after_build still does the rebuilding (see + * AZRankingWidget::rebuildRankingPreview()) - a #value_callback wouldn't + * fit, since the preview isn't one resolvable value but a render array + * built from many sibling fields. What changed is where that callback + * sits. It needs those siblings' resolved values, and #after_build only + * sees its own descendants, so it has to hang off a parent of both + * halves. This element is that parent. * * @see \Drupal\az_ranking\Plugin\Field\FieldWidget\AZRankingWidget * @see https://github.com/az-digital/az_quickstart/pull/5309 @@ -52,11 +49,11 @@ public function getInfo() { /** * Builds the details fields and the preview placeholder. * - * Delegates to the widget instance (stashed on #widget by formElement()) - * since building these fields needs several widget instance methods as - * #element_validate/#after_build callbacks - * (validateRankingLink()/addAzRankingContextToMediaEdit()/etc.) that - * only make sense as instance methods, not static ones. + * Hands off to the widget instance stashed on #widget by formElement(). + * Building the fields needs several widget methods as #element_validate + * and #after_build callbacks - validateRankingLink(), + * addAzRankingContextToMediaEdit() and so on - and those are instance + * methods, not static ones. */ public static function processRankingItem(array $element, FormStateInterface $form_state, &$complete_form) { /** @var \Drupal\az_ranking\Plugin\Field\FieldWidget\AZRankingWidget $widget */ diff --git a/modules/custom/az_ranking/src/Plugin/Field/FieldFormatter/AZRankingDefaultFormatter.php b/modules/custom/az_ranking/src/Plugin/Field/FieldFormatter/AZRankingDefaultFormatter.php index 87d62bdcd8..2788a1d9e8 100644 --- a/modules/custom/az_ranking/src/Plugin/Field/FieldFormatter/AZRankingDefaultFormatter.php +++ b/modules/custom/az_ranking/src/Plugin/Field/FieldFormatter/AZRankingDefaultFormatter.php @@ -16,11 +16,12 @@ * Plugin implementation of the 'az_ranking_default' formatter. * * Renders the field through the az_quickstart:ranking, - * az_quickstart:ranking-image, and az_quickstart:ranking-deck Single - * Directory Components, so paragraph-authored rankings and Canvas-composed - * rankings share the same markup. - * The actual item-to-props mapping lives in AZRankingComponentBuilder, - * shared with AZRankingWidget's live edit-form preview. + * az_quickstart:ranking-image and az_quickstart:ranking-deck Single + * Directory Components, so a ranking built in the page builder and one + * composed in Canvas come out as the same markup. + * + * The item-to-props mapping lives in AZRankingComponentBuilder, which + * AZRankingWidget also uses for its live edit-form preview. * * @see https://github.com/az-digital/az_quickstart/issues/5813 */ @@ -98,10 +99,10 @@ public function viewElements(FieldItemListInterface $items, $langcode) { $rankings = []; $interactive_links = (bool) $this->getSetting('interactive_links'); - // Computed before the loop (not after, as an earlier version of this - // method did) because buildImageComponent() needs the deck's actual - // per-breakpoint column counts to clamp each image's width_span_* props - // against them - see that method's docblock for why this matters. + // Build the deck's props first, before the loop. Rationale: + // buildImageComponent() needs the deck's column count at each + // breakpoint so it can clamp every image's width_span_* props against + // it - see that method's docblock. $deck_props = []; $ranking_defaults = []; $parent = $items->getEntity(); @@ -119,14 +120,15 @@ public function viewElements(FieldItemListInterface $items, $langcode) { ? $this->componentBuilder->buildImageComponent($values, $deck_props) : $this->componentBuilder->buildRankingComponent($values, $ranking_defaults); - // "Interactive Links" off: disable navigation on this item's link, - // if it has one (az_quickstart:ranking-image never sets link_url, so this - // never applies to image_only items - matches legacy's own scope, - // which only ever put this on the #type => link element itself). - // Deliberately NOT a ranking.component.yml prop - "disable my own - // links because I'm being viewed in a Paragraphs Preview view mode" - // isn't a property of what a ranking card is, it's specific to one - // admin workflow Canvas has no equivalent of. + // With "Interactive Links" off, stop this item's link navigating - if + // it has one. ranking-image never sets link_url, so image_only items + // are untouched, which matches the legacy template: it only ever put + // this on the #type => link element. + // + // Kept out of ranking.component.yml on purpose. "Disable my own links + // because a Paragraphs Preview view mode is rendering me" says nothing + // about what a ranking card is; it belongs to one admin workflow that + // Canvas has no equivalent of. if (!$interactive_links && isset($ranking['#props']['link_url'])) { $ranking['#attributes']['class'][] = 'az-ranking-no-follow'; $ranking['#attached']['library'][] = 'az_ranking/az_ranking_no_follow'; diff --git a/modules/custom/az_ranking/src/Plugin/Field/FieldWidget/AZRankingWidget.php b/modules/custom/az_ranking/src/Plugin/Field/FieldWidget/AZRankingWidget.php index 77670ddb93..03aa7cfbda 100644 --- a/modules/custom/az_ranking/src/Plugin/Field/FieldWidget/AZRankingWidget.php +++ b/modules/custom/az_ranking/src/Plugin/Field/FieldWidget/AZRankingWidget.php @@ -65,9 +65,9 @@ public static function create(ContainerInterface $container, array $configuratio */ public function form(FieldItemListInterface $items, array &$form, FormStateInterface $form_state, $get_delta = NULL) { - // Create shared settings for widget elements. - // This is necessary because widgets have to be AJAX replaced together, - // And in general we need a place to store shared settings. + // Every row has to be AJAX-replaced as one block, so the wrapper id has + // to be agreed on before any row is built. Widget state is the only + // place both this method and formElement() can reach it. $wrapper_id = Html::getUniqueId('az-ranking-wrapper'); $field_name = $this->fieldDefinition->getName(); $field_parents = $form['#parents']; @@ -79,9 +79,6 @@ public function form(FieldItemListInterface $items, array &$form, FormStateInter $field_state['items_count'] = (!empty($field_state['items_count'])) ? $field_state['items_count'] : max(0, $count - 1); $field_state['array_parents'] = []; - if (empty($field_state['open_status'])) { - $field_state['open_status'] = []; - } // Persist the widget state so formElement() can access it. static::setWidgetState($field_parents, $field_name, $form_state, $field_state); @@ -104,22 +101,18 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen /** @var \Drupal\az_ranking\Plugin\Field\FieldType\AZRankingItem $item */ $item = $items[$delta]; - // Get current collapse status. $field_name = $this->fieldDefinition->getName(); - $field_parents = $element['#field_parents']; - $widget_state = static::getWidgetState($field_parents, $field_name, $form_state); - $status = (isset($widget_state['open_status'][$delta])) ? $widget_state['open_status'][$delta] : FALSE; - // New field values shouldn't be considered collapsed. - if ($item->isEmpty()) { - $status = TRUE; - } + // Start a row open only if it has nothing in it yet, so a new ranking is + // ready to type into and saved ones stay out of the way. + $status = $item->isEmpty(); // Needed for the unique-ID generation (behavior-settings lookup) and // for rebuildRankingPreview() (see AZRankingItemElement). $parent = $item->getEntity(); $ranking_defaults = []; if ($parent instanceof ParagraphInterface) { + // Get the behavior settings for the parent. $parent_config = $parent->getAllBehaviorSettings(); $ranking_defaults = $parent_config['az_rankings_paragraph_behavior'] ?? []; } @@ -164,12 +157,12 @@ public function buildRankingItemElement(array $element, FormStateInterface $form $element['details'] = [ '#type' => 'details', '#title' => $this->t('Edit Ranking'), - // Open when in edit mode, closed when in preview mode. + // Closed rows show the preview instead; see below. '#open' => $status, '#attributes' => ['class' => ['az-ranking-widget']], ]; - // When closed, add a preview wrapper. + // A closed row shows a rendered card in place of its fields. if (!$status) { $element['preview_wrapper'] = [ '#type' => 'container', @@ -177,15 +170,15 @@ public function buildRankingItemElement(array $element, FormStateInterface $form 'class' => ['widget-preview-wrapper'], 'style' => 'max-width: 320px; margin: 10px 0; border: 1px solid #ddd; border-radius: 4px; height: 260px;', ], - // Show before the details element. + // Negative weight puts the preview above the details element. '#weight' => -10, ]; - // Placeholder - rebuildRankingPreview() populates this from the - // Form API-populated field #values, which stay correct through - // drag-and-drop reorder + AJAX rebuilds. Building it here from $item - // instead (as this used to) reflects $items' stored array order, - // which can drift out of sync with the visual row after a reorder. + // Left empty on purpose. rebuildRankingPreview() fills it in later, + // from the Form API #values rather than from $item. Rationale: after a + // drag-and-drop reorder, $items still holds the stored order, so a + // preview built here would show the card that used to be in this + // position. #value reflects the row the editor is actually looking at. $element['preview_wrapper']['preview'] = [ '#type' => 'component', '#component' => 'az_quickstart:ranking', @@ -203,7 +196,10 @@ public function buildRankingItemElement(array $element, FormStateInterface $form $ranking_type_unique_id = 'ranking-type-' . $parent_id . '-' . $field_parents_string . '-' . $delta; $ranking_background_unique_id = 'ranking-bg-' . $parent_id . '-' . $field_parents_string . '-' . $delta; - // Generate unique IDs that match the paragraph behavior. + // These IDs have to match the ones AZRankingsParagraphBehavior builds + // independently, because the #states selectors below reference its + // checkboxes by data attribute. Both sides derive them from the form + // parents so they agree without either passing anything to the other. $ranking_clickable_unique_id = ''; $ranking_hover_effect_unique_id = ''; if ($parent instanceof ParagraphInterface) { @@ -501,7 +497,8 @@ public function rebuildRankingPreview(array $element, FormStateInterface $form_s elseif (is_numeric($media_input)) { $media_id = (int) $media_input; } - // Fallback for initial load (no user input for this field yet). + // First render of a fresh form - no user input exists yet, so fall back + // to what was stored. if ($media_id === NULL && $media_input === NULL) { $media_id = $details['media']['#value'] ?? $details['media']['#default_value'] ?? NULL; } @@ -543,7 +540,6 @@ protected function formMultipleElements(FieldItemListInterface $items, array &$f case FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED: $field_state = static::getWidgetState($parents, $field_name, $form_state); $max = $field_state['items_count']; - // $is_unlimited_not_programmed = !$form_state->isProgrammed(); break; default: @@ -555,20 +551,31 @@ protected function formMultipleElements(FieldItemListInterface $items, array &$f $field_state = static::getWidgetState($parents, $field_name, $form_state); $wrapper_id = $field_state['ajax_wrapper_id'] ?? NULL; - // Check to see if we have delete buttons. for ($delta = 0; $delta <= $max; $delta++) { - // Let's relocate the core remove button if we can. + // Keep the buttons below the "Edit Ranking" details element. Rationale: + // details is built later, by AZRankingItemElement's #process callback, + // so ranking_actions is the first child at this point and would tie + // with it on weight 0 and win. An explicit weight stops the render + // order depending on which of the two happens to be added first. + $elements[$delta]['ranking_actions']['#weight'] = 10; + + // Check to see if we have delete buttons. + // + // Move core's remove button into our own actions area, and match its + // sizing, so it sits beside the Update Preview button added below + // instead of landing in a separate actions area of its own. if (!empty($elements[$delta]['_actions']['delete'])) { $remove = $elements[$delta]['_actions']['delete']; unset($elements[$delta]['_actions']['delete']); - // Relocate the delete button alongside our field collapse button. $elements[$delta]['ranking_actions']['delete'] = $remove; - // Attempt to style it like collapse button. $elements[$delta]['ranking_actions']['delete']['#attributes']['class'][] = 'button--extrasmall'; $elements[$delta]['ranking_actions']['delete']['#attributes']['class'][] = 'ms-3'; } - // Add a "Refresh Preview" button with AJAX. + // The preview only refreshes on an AJAX round trip, so an editor needs + // a way to ask for one without saving. #limit_validation_errors is + // empty because refreshing a half-filled row should show what is there, + // not refuse until every required field is valid. $elements[$delta]['ranking_actions']['refresh_preview'] = [ '#type' => 'submit', '#value' => $this->t('Update Preview'), @@ -587,43 +594,6 @@ protected function formMultipleElements(FieldItemListInterface $items, array &$f return $elements; } - /** - * Submit handler for toggle button. - * - * @param array $form - * The build form. - * @param \Drupal\Core\Form\FormStateInterface $form_state - * The form state. - */ - public function rankingSubmit(array $form, FormStateInterface $form_state) { - - // Get triggering element. - $triggering_element = $form_state->getTriggeringElement(); - $array_parents = $array_parents = array_slice($triggering_element['#array_parents'], 0, -2); - - // Determine delta. - $delta = array_pop($array_parents); - - // Get the widget. - $element = NestedArray::getValue($form, $array_parents); - $field_name = $element['#field_name']; - $field_parents = $element['#field_parents']; - - // Load current widget settings. - $settings = static::getWidgetState($field_parents, $field_name, $form_state); - - // Prepare to toggle state. - $status = TRUE; - if (isset($settings['open_status'][$delta])) { - $status = !$settings['open_status'][$delta]; - } - $settings['open_status'][$delta] = $status; - - // Save new state and rebuild form. - static::setWidgetState($field_parents, $field_name, $form_state, $settings); - $form_state->setRebuild(); - } - /** * Submit handler for refresh preview button. * From a98198bb9f32baee3dd283898dfc0d46db23e0fd Mon Sep 17 00:00:00 2001 From: Kevin Lu <79181817+kevdevlu@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:50:29 -0700 Subject: [PATCH 16/21] Fix empty ranking saving, and Not being able to remove all rankings in a paragraph --- .../FieldFormatter/AZRankingDefaultFormatter.php | 7 +++++++ .../src/Plugin/Field/FieldType/AZRankingItem.php | 13 ++++++++++--- .../Plugin/Field/FieldWidget/AZRankingWidget.php | 12 +++++++++++- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/modules/custom/az_ranking/src/Plugin/Field/FieldFormatter/AZRankingDefaultFormatter.php b/modules/custom/az_ranking/src/Plugin/Field/FieldFormatter/AZRankingDefaultFormatter.php index 2788a1d9e8..6b39f89589 100644 --- a/modules/custom/az_ranking/src/Plugin/Field/FieldFormatter/AZRankingDefaultFormatter.php +++ b/modules/custom/az_ranking/src/Plugin/Field/FieldFormatter/AZRankingDefaultFormatter.php @@ -96,6 +96,13 @@ public function settingsSummary() { * {@inheritdoc} */ public function viewElements(FieldItemListInterface $items, $langcode) { + // Render nothing when every ranking has been removed, rather than an + // empty deck. Carrying on would hand the slot below an empty array, + // which core rejects with a fatal. + if ($items->isEmpty()) { + return []; + } + $rankings = []; $interactive_links = (bool) $this->getSetting('interactive_links'); diff --git a/modules/custom/az_ranking/src/Plugin/Field/FieldType/AZRankingItem.php b/modules/custom/az_ranking/src/Plugin/Field/FieldType/AZRankingItem.php index c263a9d20d..db8839bf2b 100644 --- a/modules/custom/az_ranking/src/Plugin/Field/FieldType/AZRankingItem.php +++ b/modules/custom/az_ranking/src/Plugin/Field/FieldType/AZRankingItem.php @@ -33,6 +33,15 @@ class AZRankingItem extends FieldItemBase { /** * {@inheritdoc} + * + * A ranking is empty when it has no content, whatever styling it carries. + * Only the six content properties are checked; the styling ones - + * ranking_link_style, ranking_font_color, options - are not, because their + * widget selects always submit a default rather than an empty string, and + * a row would never look empty again once the form had been submitted. + * + * Drupal drops empty items on save, so anything counted here is something + * an editor would lose by leaving it blank. */ public function isEmpty() { $ranking_heading = $this->get('ranking_heading')->getValue(); @@ -41,9 +50,7 @@ public function isEmpty() { $ranking_source = $this->get('ranking_source')->getValue(); $link_uri = $this->get('link_uri')->getValue(); $link_title = $this->get('link_title')->getValue(); - $ranking_link_style = $this->get('ranking_link_style')->getValue(); - $ranking_font_color = $this->get('ranking_font_color')->getValue(); - return empty($ranking_heading) && empty($ranking_description) && empty($media) && empty($ranking_source) && empty($link_uri) && empty($link_title) && empty($ranking_link_style) && empty($ranking_font_color); + return empty($ranking_heading) && empty($ranking_description) && empty($media) && empty($ranking_source) && empty($link_uri) && empty($link_title); } /** diff --git a/modules/custom/az_ranking/src/Plugin/Field/FieldWidget/AZRankingWidget.php b/modules/custom/az_ranking/src/Plugin/Field/FieldWidget/AZRankingWidget.php index 03aa7cfbda..886da12b6d 100644 --- a/modules/custom/az_ranking/src/Plugin/Field/FieldWidget/AZRankingWidget.php +++ b/modules/custom/az_ranking/src/Plugin/Field/FieldWidget/AZRankingWidget.php @@ -75,8 +75,18 @@ public function form(FieldItemListInterface $items, array &$form, FormStateInter $field_state['ajax_wrapper_id'] = $wrapper_id; // Remove extra field added on form instantiation for existing content. + // + // items_count is the highest row index, not the number of rows: core + // renders deltas 0 through items_count, so it draws one more row than + // the number stored here. Seeding it one below count($items) is what + // drops that spare blank row. + // + // Only on the first build though - after that the stored value is what + // the add and delete buttons have been adjusting. ?? falls through only + // when the left side is null, so a stored 0 survives; !empty() would + // treat that 0 as unset, and 0 is real here - it means one row. $count = count($items); - $field_state['items_count'] = (!empty($field_state['items_count'])) ? $field_state['items_count'] : max(0, $count - 1); + $field_state['items_count'] = $field_state['items_count'] ?? max(0, $count - 1); $field_state['array_parents'] = []; From 6d35d74a97daedeb9b8299a9cf3897ef28fad17a Mon Sep 17 00:00:00 2001 From: Kevin Lu <79181817+kevdevlu@users.noreply.github.com> Date: Fri, 7 Aug 2026 04:32:46 -0700 Subject: [PATCH 17/21] Ranking styles: description/source typography, card padding, image radius Description and source now match the Ranking Card component in az_storybook: 22px/700/30px and 18px/24px. Card body padding is 24px via p-4, and the description carries mt-1 plus mb-5 on colored backgrounds only - the same condition the source's mt-auto already used, so transparent cards keep the spacing they had. Ranking Image rounds with var(--bs-border-radius), so it follows the site: 1rem on flagship, 0.375rem on vanilla. The img itself inherits that rather than keeping Bootstrap's flat 0.375rem. Ranking Deck rows are grid-auto-rows: 1fr, so cards match heights across the whole deck instead of only within their own row. Co-Authored-By: Claude Opus 5 --- components/ranking-deck/ranking-deck.css | 9 +++++++-- components/ranking-image/ranking-image.css | 5 ++++- components/ranking/ranking.css | 11 +++++++++++ components/ranking/ranking.twig | 9 +++++++-- 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/components/ranking-deck/ranking-deck.css b/components/ranking-deck/ranking-deck.css index 536e7009b0..c9bc8fba83 100644 --- a/components/ranking-deck/ranking-deck.css +++ b/components/ranking-deck/ranking-deck.css @@ -7,13 +7,18 @@ * * The 1.5rem gap matches the spacing pb-4 gave in the legacy markup, kept so * decks look the same after the port. Grid items stretch to fill their row - * by default, which is what makes cards in a row match heights without - * anything setting a height. + * by default, which is what makes cards match heights without anything + * setting a height. */ .az-ranking-deck { display: grid; gap: 1.5rem; + /* + * Make every row as tall as the tallest row, so they look uniform within + * the whole deck. + */ + grid-auto-rows: 1fr; } /* diff --git a/components/ranking-image/ranking-image.css b/components/ranking-image/ranking-image.css index 9438c08a1f..cd9bd571f6 100644 --- a/components/ranking-image/ranking-image.css +++ b/components/ranking-image/ranking-image.css @@ -24,6 +24,7 @@ .az-ranking-image { position: relative; overflow: hidden; + border-radius: var(--bs-border-radius); /* * The same responsive floor .az-ranking-sdc uses in ranking.css, so a row * holding only images still has a sensible height, and so the image never @@ -65,7 +66,8 @@ } } -.az-ranking-image img { +/* Two classes, to beat Bootstrap's img:not(.card-img) rule for border radius */ +.az-ranking-image .az-ranking-image__img { /* * Positioned absolutely so the image's own proportions never affect how * tall the grid row gets. The row is sized by its other content - Ranking @@ -79,6 +81,7 @@ width: 100%; height: 100%; object-fit: cover; + border-radius: inherit; } /* Phone (base). */ diff --git a/components/ranking/ranking.css b/components/ranking/ranking.css index 1cea9050e8..f0ecf0b9ec 100644 --- a/components/ranking/ranking.css +++ b/components/ranking/ranking.css @@ -68,6 +68,17 @@ font-family: var(--az-ranking-heading-serif); } +.az-ranking-sdc .az-ranking-sdc__description { + font-size: 22px; + line-height: 30px; + font-weight: 700; +} + +.az-ranking-sdc .az-ranking-sdc__source { + font-size: 18px; + line-height: 24px; +} + /* * Keep the whole card clickable when the link title is hidden. * diff --git a/components/ranking/ranking.twig b/components/ranking/ranking.twig index e00b499654..11893842fd 100644 --- a/components/ranking/ranking.twig +++ b/components/ranking/ranking.twig @@ -155,14 +155,19 @@ {% endif %} -
+
{% if heading|default('') %} <{{ heading_level }} class="m-0 az-ranking-sdc__heading{{ header_style != 'thin' ? ' az-ranking-sdc__heading--' ~ header_style : '' }}{{ heading_font == 'serif' ? ' az-ranking-sdc__heading--serif' : '' }}{{ clickable ? ' hover-text-underline' : '' }}{{ text_override ? ' ' ~ text_override : '' }}">{{ heading }} {% endif %} + {# + When transparent, we want description and source to have less margin + and be more 'snug' since a large gap without any colored background + looks weird. + #} {% if description|default('') %} -

{{ description }}

+

{{ description }}

{% endif %}
{% if source|default('') %} From 6e7a71311df58aa0a10fcad564a8ff40ac169a15 Mon Sep 17 00:00:00 2001 From: Kevin Lu <79181817+kevdevlu@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:28:03 -0700 Subject: [PATCH 18/21] Prevent TwigFilter name clash with twig_tweak on www_barrio --- components/ranking-image/ranking-image.twig | 2 +- .../custom/az_media/src/Twig/ImageStyleTwigExtension.php | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/components/ranking-image/ranking-image.twig b/components/ranking-image/ranking-image.twig index 629edd1c85..0e99dbddda 100644 --- a/components/ranking-image/ranking-image.twig +++ b/components/ranking-image/ranking-image.twig @@ -82,7 +82,7 @@ {% if src|default('') %} + * * @endcode * * It hands back the URL of the styled copy. Drupal generates that copy the @@ -40,7 +40,7 @@ public function __construct( */ public function getFilters(): array { return [ - new TwigFilter('image_style', [$this, 'applyImageStyle']), + new TwigFilter('az_media_image_style', [$this, 'applyImageStyle']), ]; } From 1c3ebdd1e651556d8efe83302b40576906e2d452 Mon Sep 17 00:00:00 2001 From: Kevin Lu <79181817+kevdevlu@users.noreply.github.com> Date: Sun, 9 Aug 2026 08:50:56 -0700 Subject: [PATCH 19/21] Add pb-4 to ranking decks to match legacy formatter code --- .../Field/FieldFormatter/AZRankingDefaultFormatter.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/modules/custom/az_ranking/src/Plugin/Field/FieldFormatter/AZRankingDefaultFormatter.php b/modules/custom/az_ranking/src/Plugin/Field/FieldFormatter/AZRankingDefaultFormatter.php index 6b39f89589..c006461aa4 100644 --- a/modules/custom/az_ranking/src/Plugin/Field/FieldFormatter/AZRankingDefaultFormatter.php +++ b/modules/custom/az_ranking/src/Plugin/Field/FieldFormatter/AZRankingDefaultFormatter.php @@ -117,6 +117,11 @@ public function viewElements(FieldItemListInterface $items, $langcode) { $behavior_settings = $parent->getAllBehaviorSettings(); $ranking_defaults = $behavior_settings['az_rankings_paragraph_behavior'] ?? []; $deck_props = $this->componentBuilder->buildDeckProps($ranking_defaults); + // Legacy version of this flie used `pb-4` to make a gap between + // rows of rankings. New SDC's use `gap: 1.5rem`, which doesn't fill + // the last row of rankings in a ranking-deck. So we're putting it + // here to match + $deck_props['utility_classes'] = ['pb-4']; } foreach ($items as $item) { From 2b60bd21895d2d98ad8d3afff603f509cda1ce13 Mon Sep 17 00:00:00 2001 From: Kevin Lu <79181817+kevdevlu@users.noreply.github.com> Date: Sun, 9 Aug 2026 08:54:03 -0700 Subject: [PATCH 20/21] Add period to comment to pass phpcs --- .../Field/FieldFormatter/AZRankingDefaultFormatter.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/custom/az_ranking/src/Plugin/Field/FieldFormatter/AZRankingDefaultFormatter.php b/modules/custom/az_ranking/src/Plugin/Field/FieldFormatter/AZRankingDefaultFormatter.php index c006461aa4..4572076176 100644 --- a/modules/custom/az_ranking/src/Plugin/Field/FieldFormatter/AZRankingDefaultFormatter.php +++ b/modules/custom/az_ranking/src/Plugin/Field/FieldFormatter/AZRankingDefaultFormatter.php @@ -117,10 +117,10 @@ public function viewElements(FieldItemListInterface $items, $langcode) { $behavior_settings = $parent->getAllBehaviorSettings(); $ranking_defaults = $behavior_settings['az_rankings_paragraph_behavior'] ?? []; $deck_props = $this->componentBuilder->buildDeckProps($ranking_defaults); - // Legacy version of this flie used `pb-4` to make a gap between - // rows of rankings. New SDC's use `gap: 1.5rem`, which doesn't fill - // the last row of rankings in a ranking-deck. So we're putting it - // here to match + // Legacy version of this flie used `pb-4` to make a gap between rows + // of rankings. New SDC's use `gap: 1.5rem`, which doesn't apply to the + // last row of rankings in a ranking-deck. So we're putting it here to + // match. $deck_props['utility_classes'] = ['pb-4']; } From 477162cdf5521ef8ea3ce16b5e5ff651b5c06861 Mon Sep 17 00:00:00 2001 From: Kevin Lu <79181817+kevdevlu@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:45:14 -0700 Subject: [PATCH 21/21] `issue/5813` Sub-branch: Adopt Canvas's object image shape for ranking-image (#5885) * Adopt Canvas's object image shape for ranking-image Replaces ranking-image's `src` string prop with an `image` object prop (src/alt/width/height), structurally matching Canvas's own well-known image shape. No `$ref` is written, so parsing and validating this component's schema never depends on Canvas being installed - confirmed via PropShape::standardize(), which matches the shape by structure, and Canvas's own image-without-ref test fixture. Canvas now maps the prop to the media library widget with the full {src,alt,width,height} expression, while the paragraph path keeps working with no Canvas present. Image styling moves out of the component and into az_media's new AZImageUrlGenerator, behind an `az_media_image_style` Twig filter. The filter accepts either input form a component prop can arrive in - a public:// URI (paragraph path) or an already-resolved URL (Canvas's src_with_alternate_widths) - by reversing the latter back into a URI. That reversal is what lets one template serve both render paths. The component now uses the profile's existing max_1300x1300 style, which already scales and converts to WebP, so az_ranking_responsive is retired. It deliberately does not upscale: ranking-image.js reads naturalWidth/naturalHeight only as a ratio, and image_scale preserves aspect ratio, so the focal point lands identically whatever the derivative's pixel size, while object-fit: cover does any enlarging in the browser. Also deletes the placeholder image generator, controller and route, no longer needed now that remote `examples` resolve in the Canvas library preview. az_ranking_update_1130601() removes the obsolete az_ranking_responsive style. It lives in az_ranking because that is the module that installed it on every existing site, and it skips deletion when other config still depends on the style. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VCwsxHECkN3i67tkMB7Z6T * Comment refinements * pass phpcs --------- Co-authored-by: Claude Opus 5 --- .../ranking-image/ranking-image.component.yml | 33 +++- components/ranking-image/ranking-image.twig | 47 +++--- modules/custom/az_media/az_media.routing.yml | 2 - modules/custom/az_media/az_media.services.yml | 14 +- .../image.style.az_ranking_responsive.yml | 20 --- .../az_media/src/AZImageUrlGenerator.php | 145 ++++++++++++++++++ .../src/AZPlaceholderImageGenerator.php | 100 ------------ .../AZPlaceholderImageController.php | 99 ------------ .../src/Routing/AZPlaceholderRoutes.php | 95 ------------ .../src/Twig/AZImageUrlTwigExtension.php | 36 +++++ .../src/Twig/ImageStyleTwigExtension.php | 104 ------------- modules/custom/az_ranking/az_ranking.install | 54 +++++++ .../custom/az_ranking/az_ranking.services.yml | 1 + .../src/AZRankingComponentBuilder.php | 15 +- .../az_ranking/src/AZRankingImageHelper.php | 21 ++- 15 files changed, 322 insertions(+), 464 deletions(-) delete mode 100644 modules/custom/az_media/az_media.routing.yml delete mode 100644 modules/custom/az_media/config/install/image.style.az_ranking_responsive.yml create mode 100644 modules/custom/az_media/src/AZImageUrlGenerator.php delete mode 100644 modules/custom/az_media/src/AZPlaceholderImageGenerator.php delete mode 100644 modules/custom/az_media/src/Controller/AZPlaceholderImageController.php delete mode 100644 modules/custom/az_media/src/Routing/AZPlaceholderRoutes.php create mode 100644 modules/custom/az_media/src/Twig/AZImageUrlTwigExtension.php delete mode 100644 modules/custom/az_media/src/Twig/ImageStyleTwigExtension.php create mode 100644 modules/custom/az_ranking/az_ranking.install diff --git a/components/ranking-image/ranking-image.component.yml b/components/ranking-image/ranking-image.component.yml index 582a9e5f4e..a094b3eebb 100644 --- a/components/ranking-image/ranking-image.component.yml +++ b/components/ranking-image/ranking-image.component.yml @@ -5,16 +5,35 @@ description: A decorative, layout-aware image backed by the media library. Works props: type: object properties: - src: - type: string + image: title: Image description: Pick an image from the media library. - format: uri - contentMediaType: image/* - x-allowed-schemes: - - public + type: object + required: + - src + properties: + src: + title: Image URL + type: string + format: uri-reference + contentMediaType: image/* + x-allowed-schemes: + - http + - https + alt: + title: Alternative text + type: string + width: + title: Image width + type: integer + height: + title: Image height + type: integer examples: - - public://az-placeholder/1000x500/placeholder.svg + - src: https://placehold.co/1000x500 + alt: '' + width: 1000 + height: 500 width_span_desktop: type: string title: Width Span (Desktop) diff --git a/components/ranking-image/ranking-image.twig b/components/ranking-image/ranking-image.twig index 0e99dbddda..c7fc8da258 100644 --- a/components/ranking-image/ranking-image.twig +++ b/components/ranking-image/ranking-image.twig @@ -10,31 +10,36 @@ *
* * The image is always decorative - alt="" plus aria-hidden on the wrapper - - * and there is no alt or decorative prop for an editor to set. + * and there is no alt or decorative prop for an editor to set, even though + * the `image` prop carries one. * * Keep Canvas out of this file. The same component renders the az_ranking * paragraph in the page builder, on sites that may never install Canvas, so - * anything Canvas owns breaks it there. Two to avoid in particular: the - * `json-schema-definitions://` $ref scheme, and the `apply_image_style` Twig - * filter. canvas.services.yml registers both, and a Twig filter that does - * not exist fails when the template compiles, so nothing degrades - * gracefully. Use az_media's filters instead. If an object-shaped image prop - * is ever wanted, declare it inline and map it with our own - * hook_canvas_storable_prop_shape_alter(), which Canvas calls even when it - * has no mapping of its own. + * anything Canvas owns breaks it there. In particular, never call the + * `apply_image_style` Twig filter here: canvas.services.yml registers it, + * and a Twig filter that does not exist fails when the template compiles, so + * nothing degrades gracefully. `az_media_image_style`, below, is az_media's + * own - it exists regardless of Canvas. * - * Editors still get a real media library picker in Canvas without any of - * that. The shape of the `src` prop - string, format: uri, - * contentMediaType: image/*, x-allowed-schemes: [public] - is what core's - * media_library matches on, through a hook Canvas ships. + * The `image` prop's object shape (src/alt/width/height) is Canvas's own + * well-known image shape, structurally matched - no `$ref` written here, so + * nothing about parsing or validating this component's schema depends on + * Canvas being installed. Matching it is what gets editors a real media + * library picker in Canvas, with `image.src` resolved from the selected + * media's file. * * Props: - * - src: A stream-wrapper URI like public://cactus.jpg, which a browser - * cannot load on its own. az_media's image_style filter turns it into a - * URL for the az_ranking_responsive style (scale plus WebP), and falls - * back to a plain file URL if that style is missing, so a misconfigured - * style does not take the page down. The filter is generic, which is why - * it lives in az_media rather than az_ranking. + * - image.src: An image URL. AZRankingComponentBuilder resolves one from + * the ranking's media entity; in Canvas it arrives from that component's + * own field mapping. `az_media_image_style` handles either, so this + * template never has to know which built it. image.alt, image.width and + * image.height come with the matched shape but go unused - see above. + * + * max_1300x1300 caps the long edge and converts to WebP. It does not + * upscale, and does not need to: ranking-image.js uses naturalWidth and + * naturalHeight only as a ratio, image_scale keeps that ratio whatever + * the pixel size, and object-fit: cover does any enlarging in the + * browser. * - focal_x / focal_y: The point to keep in view when object-fit: cover * crops the image, as a 0-1 fraction of width and height. Rendered as * data attributes and applied by ranking-image.js. Not cropped server @@ -80,9 +85,9 @@ {% set root_attributes = attributes.addClass(root_classes).setAttribute('aria-hidden', 'true') %} - {% if src|default('') %} + {% if image.src|default('') %} + * @endcode + * + * A component prop holds a plain string, which may be a resolved URL like + * /sites/default/files/cactus.jpg or a stream-wrapper URI like + * public://cactus.jpg. ImageStyle::buildUrl() only takes the URI form, so + * toStreamWrapperUri() converts a URL back into one first. Taking both + * means a template never has to know which form its prop arrived in - + * ranking-image, for one, is handed a URL by AZRankingComponentBuilder on + * the paragraph path and a different URL by Canvas's own field mapping. + * + * Core does the rest. ImageStyleDownloadController generates the styled + * copy the first time someone requests it, then serves it from disk, and + * flushes it when the style changes or the source file is replaced. + */ +class AZImageUrlGenerator { + + public function __construct( + protected StreamWrapperManagerInterface $streamWrapperManager, + protected FileUrlGeneratorInterface $fileUrlGenerator, + protected ImageToolkitManager $imageToolkitManager, + protected RequestStack $requestStack, + protected EntityTypeManagerInterface $entityTypeManager, + ) {} + + /** + * Builds the URL of $src styled with an image style. + * + * Falls back to a plain, unstyled URL rather than breaking the page when + * styling can't apply: $src isn't a local public file, the named style + * doesn't exist, or the image toolkit can't read the file's format (an + * SVG, say - it scales on its own, and a styled derivative of one would + * only 404). + * + * @param string|null $src + * A stream-wrapper URI, or a resolved URL pointing at a local public + * file (its query string, if any, is ignored). + * @param string $style_name + * The image style's machine name. + * + * @return string + * A URL. Empty only if $src was. + */ + public function getImageStyleUrl(?string $src, string $style_name): string { + if (empty($src)) { + return ''; + } + + $uri = $this->toStreamWrapperUri($src); + if ($uri === NULL) { + // If the file isn't a local public one - a remote URL, say - there is + // nothing to style, so hand it back as it came. + return $src; + } + + if (!$this->toolkitSupports($uri)) { + return $this->fileUrlGenerator->generateString($uri); + } + + $style = $this->entityTypeManager->getStorage('image_style')->load($style_name); + if (!$style) { + return $this->fileUrlGenerator->generateString($uri); + } + + return $this->fileUrlGenerator->transformRelative($style->buildUrl($uri)); + } + + /** + * Resolves $src to a public:// URI, or NULL if that's not possible. + * + * Reverses what PublicStream::getLocalPath() does, so a URL like + * /sites/default/files/cactus.jpg comes back as public://cactus.jpg. A + * URI is returned as it came. Copes with a site installed in a + * subdirectory and with an absolute URL carrying a port, and decodes the + * path so Drupal doesn't encode it twice when it builds the styled URL. + */ + protected function toStreamWrapperUri(string $src): ?string { + // Drop a query string - e.g. Canvas's own ?alternateWidths= - before + // testing or reversing it. + $path_only = strtok($src, '?'); + if ($path_only === FALSE) { + $path_only = $src; + } + + if ($this->streamWrapperManager->isValidUri($path_only)) { + return $this->streamWrapperManager->getScheme($path_only) === 'public' ? $path_only : NULL; + } + + $public_base_path = PublicStream::basePath(); + $path_segment = parse_url($path_only, PHP_URL_PATH); + $path = ltrim(is_string($path_segment) ? $path_segment : $path_only, '/'); + $request_base_path = trim($this->requestStack->getCurrentRequest()?->getBasePath() ?? '', '/'); + $prefix = $public_base_path . '/'; + + if (str_starts_with($path, $prefix)) { + $target = substr($path, strlen($prefix)); + } + elseif ($request_base_path !== '' && str_starts_with($path, $request_base_path . '/' . $prefix)) { + $target = substr($path, strlen($request_base_path . '/' . $prefix)); + } + else { + return NULL; + } + + return 'public://' . rawurldecode($target); + } + + /** + * Whether the active image toolkit can process this file's format. + * + * Read from the toolkit rather than hard-coded, so the answer stays + * correct on a site running ImageMagick instead of GD. + */ + protected function toolkitSupports(string $uri): bool { + // The `?? $uri` matters: parse_url() treats public://foo.svg as scheme + // plus host with no path, so it returns NULL and there is no extension + // to find. Only a URI with a subdirectory - public://dir/foo.svg - + // gives it a path. Falling back to the whole URI covers the flat case. + $extension = strtolower(pathinfo(parse_url($uri, PHP_URL_PATH) ?? $uri, PATHINFO_EXTENSION)); + if ($extension === '') { + return FALSE; + } + $toolkit = $this->imageToolkitManager->getDefaultToolkit(); + return in_array($extension, $toolkit::getSupportedExtensions(), TRUE); + } + +} diff --git a/modules/custom/az_media/src/AZPlaceholderImageGenerator.php b/modules/custom/az_media/src/AZPlaceholderImageGenerator.php deleted file mode 100644 index 59f8acf910..0000000000 --- a/modules/custom/az_media/src/AZPlaceholderImageGenerator.php +++ /dev/null @@ -1,100 +0,0 @@ - self::MAX_DIMENSION) { - return FALSE; - } - } - return TRUE; - } - - /** - * Builds the SVG markup, given width and height. - * - * Using the `int` type here means there's no way for injection to - * happen. Hence we don't need to escape anything. - * - * @param int $width - * Width in pixels. - * @param int $height - * Height in pixels. - * - * @return string - * The SVG document. - */ - public function generate(int $width, int $height): string { - // Build the label. - $label = $width . ' × ' . $height; - - // Figure out the font size. Meet these requirements: - // 1. The label should fit in the placeholder image. - // 2. It should span up to 0.625 of the placeholder's width (what - // placehold.co does), accounting for how many characters are in the - // label and how wide they average (0.52 em each). - // 3. The font size should not exceed 0.42 of the placeholder's height, - // so a short image can't overflow. - $by_width = (0.625 * $width) / (mb_strlen($label) * 0.52); - $by_height = $height * 0.42; - $font_size = max(1, (int) floor(min($by_width, $by_height))); - - // Muted grays. Bold. Label uses whatever sans-serif font the viewer has. - // The 0.52 em measurement is for sans-serif, so spot check if you need to - // change font-family. - return << - - {$label} - - SVG; - } - -} diff --git a/modules/custom/az_media/src/Controller/AZPlaceholderImageController.php b/modules/custom/az_media/src/Controller/AZPlaceholderImageController.php deleted file mode 100644 index 8cb35cf794..0000000000 --- a/modules/custom/az_media/src/Controller/AZPlaceholderImageController.php +++ /dev/null @@ -1,99 +0,0 @@ -get('az_media.placeholder_image_generator')); - } - - /** - * Generates and returns a placeholder image. - * - * @param string $dimensions - * The requested size as `{width}x{height}`. The route already constrains - * this to two integers of at most four digits. - * - * @return \Symfony\Component\HttpFoundation\Response - * The SVG response. - * - * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException - * When either dimension falls outside the permitted range. - */ - public function deliver(string $dimensions): Response { - [$width, $height] = array_map('intval', explode('x', $dimensions)); - - // If not in the allowed width and height, give a 404 error. Rationale: - // If someone typed 8000x600 (most likely a typo of 800x600), we could - // round down to 4000x600, but the user may not notice until later. So - // we want to fail instead of rounding down. - if (!$this->generator->isValidSize($width, $height)) { - throw new NotFoundHttpException(); - } - - $response = new Response($this->generator->generate($width, $height), Response::HTTP_OK, [ - 'Content-Type' => 'image/svg+xml', - ]); - // Together these set Cache-Control: public, max-age=
, immutable. - // public lets shared caches (a CDN, not just the user's browser) keep a - // copy; max-age is how long they may reuse it; immutable means it will - // never change, so they never need to check back with us. Expires says - // the same deadline in an older format that some caches still read. - // - // Drupal has a subscriber that rewrites cache headers on the way out. - // It leaves ours alone only because this is a plain Response and we set - // Cache-Control ourselves. Setting Expires stops it stamping its own - // 1978 date on top. - // - // So if you switch to a CacheableResponse, or drop either header - // (Cache-Control or Expires), the subscriber takes over and the - // year-long cache time disappears with no error to tell you. - // - // @see \Drupal\Core\EventSubscriber\FinishResponseSubscriber::onRespond() - $response->setPublic(); - $response->setMaxAge(self::MAX_AGE); - $response->headers->addCacheControlDirective('immutable'); - $response->setExpires(new \DateTime('@' . (time() + self::MAX_AGE))); - - return $response; - } - -} diff --git a/modules/custom/az_media/src/Routing/AZPlaceholderRoutes.php b/modules/custom/az_media/src/Routing/AZPlaceholderRoutes.php deleted file mode 100644 index 06f4d45c66..0000000000 --- a/modules/custom/az_media/src/Routing/AZPlaceholderRoutes.php +++ /dev/null @@ -1,95 +0,0 @@ -get('stream_wrapper_manager')); - } - - /** - * Returns the placeholder route. - * - * @return \Symfony\Component\Routing\Route[] - * Route objects keyed by route name. - */ - public function routes(): array { - // Get the local public files directory (whatever public:// is set to). - // Ask for the object behind that alias, since it knows the real folder - // name - sites/default/files unless a site changed it. - $public = $this->streamWrapperManager->getViaScheme('public'); - // If public:// isn't a normal local directory, register nothing. - // For example, a site keeping its public files on something like S3 has - // no local path to build a URL from. - if (!$public instanceof LocalStream) { - return []; - } - $directory_path = $public->getDirectoryPath(); - - // The filename gets its own path segment: {dimensions}/placeholder.svg, - // not {dimensions}.svg. Rationale: when a request comes in, Drupal - // looks for matching routes by swapping whole segments for '%' - for - // our URL it tries .../az-placeholder/%/placeholder.svg. A segment that - // mixes a placeholder with a literal suffix ({dimensions}.svg) never - // shows up in that list, so the route can't be found no matter how well - // its regex matches. Core hits the same wall and works around it with a - // path processor; giving the filename its own segment avoids needing - // one. - // @see \Drupal\Core\Routing\RouteProvider::getCandidateOutlines() - // @see \Drupal\image\PathProcessor\PathProcessorImageStyles - return [ - 'az_media.placeholder_image' => new Route( - '/' . $directory_path . '/' . AZPlaceholderImageGenerator::DIRECTORY . '/{dimensions}/placeholder.svg', - [ - '_controller' => '\Drupal\az_media\Controller\AZPlaceholderImageController::deliver', - ], - [ - // Open to everyone: an tag on a public page loads this URL, - // so there is no user to check permissions against. - '_access' => 'TRUE', - // Only match 1-4 digits, an x, then 1-4 digits. Anything else - // 404s before the controller runs. - 'dimensions' => '\d{1,4}x\d{1,4}', - ] - ), - ]; - } - -} diff --git a/modules/custom/az_media/src/Twig/AZImageUrlTwigExtension.php b/modules/custom/az_media/src/Twig/AZImageUrlTwigExtension.php new file mode 100644 index 0000000000..f4093748b9 --- /dev/null +++ b/modules/custom/az_media/src/Twig/AZImageUrlTwigExtension.php @@ -0,0 +1,36 @@ + + * @endcode + * + * @see \Drupal\az_media\AZImageUrlGenerator + */ +class AZImageUrlTwigExtension extends AbstractExtension { + + public function __construct( + protected AZImageUrlGenerator $generator, + ) {} + + /** + * {@inheritdoc} + */ + public function getFilters(): array { + return [ + new TwigFilter('az_media_image_style', [$this->generator, 'getImageStyleUrl']), + ]; + } + +} diff --git a/modules/custom/az_media/src/Twig/ImageStyleTwigExtension.php b/modules/custom/az_media/src/Twig/ImageStyleTwigExtension.php deleted file mode 100644 index 461f19ce7b..0000000000 --- a/modules/custom/az_media/src/Twig/ImageStyleTwigExtension.php +++ /dev/null @@ -1,104 +0,0 @@ - - * @endcode - * - * It hands back the URL of the styled copy. Drupal generates that copy the - * first time someone requests it. - * - * Three cases fall back to the plain file URL instead, so a page never - * breaks over a styling problem: an empty URI, a style name that doesn't - * exist, and a file the image toolkit can't read. - */ -class ImageStyleTwigExtension extends AbstractExtension { - - public function __construct( - protected FileUrlGeneratorInterface $fileUrlGenerator, - protected EntityTypeManagerInterface $entityTypeManager, - protected ImageToolkitManager $imageToolkitManager, - ) {} - - /** - * {@inheritdoc} - */ - public function getFilters(): array { - return [ - new TwigFilter('az_media_image_style', [$this, 'applyImageStyle']), - ]; - } - - /** - * Applies a named image style to a stream-wrapper URI. - * - * @param string|null $uri - * A stream-wrapper URI (e.g. public://foo.jpg), or NULL/empty. - * @param string $style_name - * The image style's machine name. - * - * @return string - * The styled derivative's URL; a plain file_url()-equivalent URL if the - * named style doesn't exist or the file's format cannot be processed by - * the image toolkit; or an empty string if $uri is empty. - */ - public function applyImageStyle(?string $uri, string $style_name): string { - if (empty($uri)) { - return ''; - } - // If the toolkit can't read this format, hand back the plain URL. - // For example an SVG: GD only handles png, jpeg, jpg, jpe, gif, webp - // and avif, so public://logo.svg would turn into a logo.svg.webp - // derivative URL that can never be generated and 404s. Serving the SVG - // unstyled is the right answer anyway - a vector scales on its own. - if (!$this->toolkitSupports($uri)) { - return $this->fileUrlGenerator->generateString($uri); - } - $style = $this->entityTypeManager->getStorage('image_style')->load($style_name); - if ($style) { - return $style->buildUrl($uri); - } - return $this->fileUrlGenerator->generateString($uri); - } - - /** - * Whether the active image toolkit can process this file's format. - * - * Read from the toolkit rather than hard-coded, so the answer stays correct - * on a site running ImageMagick instead of GD. - * - * @param string $uri - * The file URI to test. - * - * @return bool - * TRUE when the toolkit lists the file's extension as supported. - */ - protected function toolkitSupports(string $uri): bool { - // The `?? $uri` matters: parse_url() treats public://foo.svg as scheme - // plus host with no path, so it returns NULL and there is no extension - // to find. Only a URI with a subdirectory - public://dir/foo.svg - - // gives it a path. Falling back to the whole URI covers the flat case. - $extension = strtolower(pathinfo(parse_url($uri, PHP_URL_PATH) ?? $uri, PATHINFO_EXTENSION)); - if ($extension === '') { - return FALSE; - } - $toolkit = $this->imageToolkitManager->getDefaultToolkit(); - return in_array($extension, $toolkit::getSupportedExtensions(), TRUE); - } - -} diff --git a/modules/custom/az_ranking/az_ranking.install b/modules/custom/az_ranking/az_ranking.install new file mode 100644 index 0000000000..217357104c --- /dev/null +++ b/modules/custom/az_ranking/az_ranking.install @@ -0,0 +1,54 @@ +findConfigEntityDependencies('config', ['image.style.az_ranking_responsive']); + + if ($dependents) { + $names = array_map( + fn($dependency) => $dependency->getConfigDependencyName(), + $dependents + ); + sort($names); + // This string is shown to whoever runs the update. Don't use t() in an + // update hook. + return 'The az_ranking_responsive image style is unused by the ranking image component, but was kept because other configuration still depends on it: ' + . implode(', ', $names) + . '. Delete it manually once those are updated.'; + } + + $style->delete(); + return NULL; +} diff --git a/modules/custom/az_ranking/az_ranking.services.yml b/modules/custom/az_ranking/az_ranking.services.yml index b5369f3723..36b4c197e7 100644 --- a/modules/custom/az_ranking/az_ranking.services.yml +++ b/modules/custom/az_ranking/az_ranking.services.yml @@ -3,6 +3,7 @@ services: class: Drupal\az_ranking\AZRankingImageHelper arguments: - '@entity_type.manager' + - '@file_url_generator' az_ranking.component_builder: class: Drupal\az_ranking\AZRankingComponentBuilder arguments: diff --git a/modules/custom/az_ranking/src/AZRankingComponentBuilder.php b/modules/custom/az_ranking/src/AZRankingComponentBuilder.php index 34326dbac6..336bac6f49 100644 --- a/modules/custom/az_ranking/src/AZRankingComponentBuilder.php +++ b/modules/custom/az_ranking/src/AZRankingComponentBuilder.php @@ -213,12 +213,13 @@ public function buildRankingComponent(array $values, array $ranking_defaults): a /** * Builds an az_quickstart:ranking-image component render array for one item. * - * The `src` prop gets a plain file URI, because a component prop cannot - * carry a render array. The az_ranking_responsive style still gets applied - * - ranking-image.twig does it with az_media's image_style filter - so - * scaling and WebP delivery match what #theme => image_formatter used to - * produce. Focal point values ride along as props and are applied in the - * browser by the component's own JS. + * The `image` prop is an object ({src}) matching Canvas's own image + * shape, which is what gets the component a media library picker in + * Canvas. See ranking-image.twig's docblock. + * AZRankingImageHelper resolves the URL that goes in it, and + * ranking-image.twig applies the image style, so no image processing + * happens here. Focal point values ride along as separate props and are + * applied in the browser by the component's own JS. * * Cache tags for the media and file are attached here because * AZRankingItem stores its media reference as a plain integer rather than @@ -264,7 +265,7 @@ public function buildImageComponent(array $values, array $deck_props): array { $image_data = $this->rankingImageHelper->getImageSourceAndFocalPoint($media); $cache_tags = Cache::mergeTags($cache_tags, $image_data['cache_tags']); if ($image_data['src'] !== '') { - $props['src'] = $image_data['src']; + $props['image'] = ['src' => $image_data['src']]; } if ($image_data['focal_x'] !== NULL && $image_data['focal_y'] !== NULL) { $props['focal_x'] = $image_data['focal_x']; diff --git a/modules/custom/az_ranking/src/AZRankingImageHelper.php b/modules/custom/az_ranking/src/AZRankingImageHelper.php index 807138c025..40eadbb819 100644 --- a/modules/custom/az_ranking/src/AZRankingImageHelper.php +++ b/modules/custom/az_ranking/src/AZRankingImageHelper.php @@ -4,6 +4,7 @@ use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Entity\FieldableEntityInterface; +use Drupal\Core\File\FileUrlGeneratorInterface; use Drupal\media\MediaInterface; /** @@ -21,11 +22,19 @@ class AZRankingImageHelper { */ protected $entityTypeManager; + /** + * The file URL generator service. + * + * @var \Drupal\Core\File\FileUrlGeneratorInterface + */ + protected $fileUrlGenerator; + /** * Constructs a new AZRankingImageHelper object. */ - public function __construct(EntityTypeManagerInterface $entity_type_manager) { + public function __construct(EntityTypeManagerInterface $entity_type_manager, FileUrlGeneratorInterface $file_url_generator) { $this->entityTypeManager = $entity_type_manager; + $this->fileUrlGenerator = $file_url_generator; } /** @@ -40,8 +49,12 @@ public function __construct(EntityTypeManagerInterface $entity_type_manager) { * * @return array * An array with these keys: - * - 'src': the image's file URI, or an empty string if the media has - * no image on it. + * - 'src': the image's URL, or an empty string when the media has no + * image or its file has gone. Root-relative, like + * /sites/default/files/cactus.jpg. The `image.src` prop is + * `format: uri-reference`, so a path with no scheme is fine; its + * `x-allowed-schemes` list rules out public://, not local files. + * The image style is applied later, in ranking-image.twig. * - 'focal_x', 'focal_y': the focal point, or NULL if none is set. * - 'cache_tags': the file's cache tags. Attach these to whatever * render array you build from this data, or a cached ranking will @@ -70,7 +83,7 @@ public function getImageSourceAndFocalPoint(MediaInterface $media): array { } $result = $empty; - $result['src'] = $file->getFileUri(); + $result['src'] = $this->fileUrlGenerator->generateString($file->getFileUri()); $result['cache_tags'] = $file->getCacheTags(); if ($media instanceof FieldableEntityInterface) {