diff --git a/components/ranking-deck/ranking-deck.component.yml b/components/ranking-deck/ranking-deck.component.yml new file mode 100644 index 0000000000..026b25a214 --- /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' + '2': 2 (default) + '3': '3' + '4': '4' + examples: + - '2' + 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..c9bc8fba83 --- /dev/null +++ b/components/ranking-deck/ranking-deck.css @@ -0,0 +1,86 @@ +/** + * Ranking Deck component. + * + * 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 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; +} + +/* + * 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; +} + +/* 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..2c783e10db --- /dev/null +++ b/components/ranking-deck/ranking-deck.twig @@ -0,0 +1,58 @@ +{# +/** + * @file + * Template for the az_quickstart Ranking Deck component. + * + * 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. + * - 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). + * + * Slots: + * - rankings: The Ranking cards (or other content) placed in the grid. + */ +#} +{% set attributes = attributes|default(create_attribute()) %} + +{# + 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' %} +{% 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-image/ranking-image.component.yml b/components/ranking-image/ranking-image.component.yml new file mode 100644 index 0000000000..a094b3eebb --- /dev/null +++ b/components/ranking-image/ranking-image.component.yml @@ -0,0 +1,112 @@ +$schema: https://git.drupalcode.org/project/drupal/-/raw/HEAD/core/assets/schemas/v1/metadata.schema.json +name: Ranking Image +status: experimental +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: + image: + title: Image + description: Pick an image from the media library. + 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: + - src: https://placehold.co/1000x500 + alt: '' + width: 1000 + height: 500 + width_span_desktop: + type: string + 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' + - '3' + - '4' + meta:enum: + '1': 1 column + '2': 2 columns (default) + '3': 3 columns + '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' + 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 + 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 +libraryOverrides: + dependencies: + - core/drupal + - core/once diff --git a/components/ranking-image/ranking-image.css b/components/ranking-image/ranking-image.css new file mode 100644 index 0000000000..cd9bd571f6 --- /dev/null +++ b/components/ranking-image/ranking-image.css @@ -0,0 +1,140 @@ +/** + * Ranking Image component. + * + * 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. + * + * 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. + * + * 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; + 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 + * 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; +} + +/* + * 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; +} + +/* Medium viewports. */ +@media (min-width: 768px) { + .az-ranking-image { + min-height: 230px; + } +} + +/* Large viewports. */ +@media (min-width: 992px) { + .az-ranking-image { + min-height: 260px; + } +} + +/* 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 + * 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; + display: block; + width: 100%; + height: 100%; + object-fit: cover; + border-radius: inherit; +} + +/* Phone (base). */ +.az-ranking-image--span-phone-1 { + grid-column: span 1; +} + +.az-ranking-image--span-phone-2 { + grid-column: span 2; +} + +.az-ranking-image--span-phone-3 { + grid-column: span 3; +} + +.az-ranking-image--span-phone-4 { + grid-column: span 4; +} + +/* Tablet. */ +@media (min-width: 768px) { + .az-ranking-image--span-tablet-1 { + grid-column: span 1; + } + + .az-ranking-image--span-tablet-2 { + grid-column: span 2; + } + + .az-ranking-image--span-tablet-3 { + grid-column: span 3; + } + + .az-ranking-image--span-tablet-4 { + grid-column: span 4; + } +} + +/* Desktop. */ +@media (min-width: 992px) { + .az-ranking-image--span-desktop-1 { + grid-column: span 1; + } + + .az-ranking-image--span-desktop-2 { + grid-column: span 2; + } + + .az-ranking-image--span-desktop-3 { + grid-column: span 3; + } + + .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..ca058051a3 --- /dev/null +++ b/components/ranking-image/ranking-image.js @@ -0,0 +1,156 @@ +/** + * @file + * Keeps a ranking image's focal point in view when CSS crops the image. + * + * 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. + * + * 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) => { + 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')); + + // No focal point set on this image, so leave object-position alone. + if (Number.isNaN(focalX) || Number.isNaN(focalY)) { + return; + } + + const containerW = img.offsetWidth; + const containerH = img.offsetHeight; + + /* + * 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; + + // Nothing to measure against yet - the image or its box has no size. + if (!originalW || !originalH || !containerW || !containerH) return; + + const imageRatio = originalW / originalH; + const containerRatio = containerW / containerH; + + /* + * 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) { + scaledH = containerH; + scaledW = containerH * imageRatio; + } else { + scaledW = containerW; + scaledH = containerW / imageRatio; + } + + let objectPosX; + let objectPosY; + + /* + * 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)); + + img.style.objectPosition = `${objectPosX}% ${objectPosY}%`; + }; + + /** + * 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 (img.complete && img.naturalWidth > 0) { + calculateObjectPosition(img); + } else { + img.addEventListener('load', () => calculateObjectPosition(img), { + once: true, + }); + } + }); + }; + + processImages(); + + /* + * 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); + resizeTimer = setTimeout(() => { + images.forEach((img) => calculateObjectPosition(img)); + }, 250); + }); + }, + }; +})(Drupal, once); diff --git a/components/ranking-image/ranking-image.twig b/components/ranking-image/ranking-image.twig new file mode 100644 index 0000000000..c7fc8da258 --- /dev/null +++ b/components/ranking-image/ranking-image.twig @@ -0,0 +1,100 @@ +{# +/** + * @file + * Template for the az_quickstart Ranking Image component. + * + * Renders one inside a positioned wrapper: + * + *
+ * + *
+ * + * 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, 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. 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. + * + * 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: + * - 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 + * 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()) %} + +{% 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 %} + +{% 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-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) %} +{% endif %} + +{# Always decorative - see the @file block above. #} +{% set root_attributes = attributes.addClass(root_classes).setAttribute('aria-hidden', 'true') %} + + + {% if image.src|default('') %} + + {% endif %} +
diff --git a/components/ranking/ranking.component.yml b/components/ranking/ranking.component.yml new file mode 100644 index 0000000000..dc3b0d9e76 --- /dev/null +++ b/components/ranking/ranking.component.yml @@ -0,0 +1,197 @@ +$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 Ranking 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 + pattern: (.|\r?\n)* + title: Source + 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: + 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: 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 + 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. Has no effect unless a Link URL is set. + 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..f0ecf0b9ec --- /dev/null +++ b/components/ranking/ranking.css @@ -0,0 +1,161 @@ +/** + * Ranking component. + * + * 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; +} + +/* Medium viewports. */ +@media (min-width: 768px) { + .az-ranking-sdc { + min-height: 230px; + } +} + +/* Large viewports. */ +@media (min-width: 992px) { + .az-ranking-sdc { + min-height: 260px; + } +} + +/* + * 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: 3px; + text-underline-offset: 3px; +} + +.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); +} + +.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. + * + * 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; +} + +/* + * 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; + 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; +} + +/* + * 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; +} + +.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)) !important; +} + +.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..11893842fd --- /dev/null +++ b/components/ranking/ranking.twig @@ -0,0 +1,187 @@ +{# +/** + * @file + * Template for the az_quickstart Ranking component. + * + * 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. + * - 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 | 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. 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. + * - 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()) %} + +{# + 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' %} +{% endif %} +{% 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 = { + '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) 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' %} + +{% 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 %} + + +
+
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..d977ee1858 --- /dev/null +++ b/modules/custom/az_media/az_media.services.yml @@ -0,0 +1,15 @@ +services: + az_media.image_url: + class: Drupal\az_media\AZImageUrlGenerator + arguments: + - '@stream_wrapper_manager' + - '@file_url_generator' + - '@image.toolkit.manager' + - '@request_stack' + - '@entity_type.manager' + az_media.image_url_twig_extension: + class: Drupal\az_media\Twig\AZImageUrlTwigExtension + arguments: + - '@az_media.image_url' + tags: + - { name: twig.extension } diff --git a/modules/custom/az_media/src/AZImageUrlGenerator.php b/modules/custom/az_media/src/AZImageUrlGenerator.php new file mode 100644 index 0000000000..cac9ca8086 --- /dev/null +++ b/modules/custom/az_media/src/AZImageUrlGenerator.php @@ -0,0 +1,145 @@ + + * @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/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_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/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.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..36b4c197e7 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' + - '@file_url_generator' + 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/config/install/image.style.az_ranking_responsive.yml b/modules/custom/az_ranking/config/install/image.style.az_ranking_responsive.yml deleted file mode 100644 index 12813584b1..0000000000 --- a/modules/custom/az_ranking/config/install/image.style.az_ranking_responsive.yml +++ /dev/null @@ -1,20 +0,0 @@ -langcode: en -status: true -dependencies: { } -name: az_ranking_responsive -label: 'AZ Ranking Responsive' -effects: - 3ecac8c9-f804-4edb-bab8-f50cee0cd3e4: - uuid: 3ecac8c9-f804-4edb-bab8-f50cee0cd3e4 - id: image_scale - weight: -10 - data: - width: 1920 - height: 1300 - upscale: true - 9b89b248-2cca-4433-bb75-427cda5535d9: - uuid: 9b89b248-2cca-4433-bb75-427cda5535d9 - id: image_convert - weight: 0 - data: - extension: webp 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..336bac6f49 --- /dev/null +++ b/modules/custom/az_ranking/src/AZRankingComponentBuilder.php @@ -0,0 +1,332 @@ + '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', + ]; + + /** + * Font color select values from the widget, mapped to our tokens. + */ + const FONT_COLOR_CLASS_MAP = [ + 'ranking-text-midnight' => 'midnight', + 'ranking-text-black' => 'black', + 'ranking-text-white' => 'white', + 'ranking-text-az-blue' => 'az-blue', + ]; + + /** + * Link style select values from the widget, mapped to our 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', + ]; + + /** + * Header style select values from the page builder, mapped to our 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', + ]; + + /** + * Bootstrap column classes the page builder stores, mapped to 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. + * + * 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(). + * @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'] ?? '', + ]; + + // 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'] ?? ''; + $props['link_style'] = self::LINK_STYLE_CLASS_MAP[$values['ranking_link_style'] ?? ''] ?? 'btn-red'; + } + + $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']); + + $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. + * + * 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 + * 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. + * + * 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(). + * @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::getImageSourceAndFocalPoint() + */ + public function buildImageComponent(array $values, array $deck_props): array { + $stored_span = (int) ($values['options']['column_span'] ?? 2); + $props = [ + '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 = []; + 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: 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->getImageSourceAndFocalPoint($media); + $cache_tags = Cache::mergeTags($cache_tags, $image_data['cache_tags']); + if ($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']; + $props['focal_y'] = $image_data['focal_y']; + } + } + } + + $build = [ + '#type' => 'component', + '#component' => 'az_quickstart:ranking-image', + '#props' => $props, + ]; + if ($cache_tags) { + $build['#cache']['tags'] = $cache_tags; + } + return $build; + } + + /** + * 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 ''. + * + * 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 === '') { + return ''; + } + + if (str_starts_with($link_uri, '/' . PublicStream::basePath())) { + return Url::fromUri(urldecode('base:' . $link_uri))->toString(); + } + + if (str_starts_with($link_uri, '#')) { + // 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; + } + + $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 f03364dc46..40eadbb819 100644 --- a/modules/custom/az_ranking/src/AZRankingImageHelper.php +++ b/modules/custom/az_ranking/src/AZRankingImageHelper.php @@ -4,12 +4,14 @@ use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Entity\FieldableEntityInterface; -use Drupal\Core\Image\ImageFactory; -use Drupal\Core\Render\RendererInterface; +use Drupal\Core\File\FileUrlGeneratorInterface; 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 { @@ -21,99 +23,87 @@ class AZRankingImageHelper { protected $entityTypeManager; /** - * Drupal\Core\Render\RendererInterface definition. + * The file URL generator service. * - * @var \Drupal\Core\Render\RendererInterface + * @var \Drupal\Core\File\FileUrlGeneratorInterface */ - protected $renderer; - - /** - * The image factory service. - * - * @var \Drupal\Core\Image\ImageFactory - */ - protected $imageFactory; + protected $fileUrlGenerator; /** * Constructs a new AZRankingImageHelper object. */ - public function __construct(EntityTypeManagerInterface $entity_type_manager, RendererInterface $renderer, ImageFactory $image_factory) { + public function __construct(EntityTypeManagerInterface $entity_type_manager, FileUrlGeneratorInterface $file_url_generator) { $this->entityTypeManager = $entity_type_manager; - $this->renderer = $renderer; - $this->imageFactory = $image_factory; + $this->fileUrlGenerator = $file_url_generator; } /** - * Prepare an image render array. + * Gets the image file URI and focal point from a ranking's media entity. + * + * 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 image render array. + * An array with these keys: + * - '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 + * 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 generateImageRenderArray(MediaInterface $media) { - $media_render_array = []; - $media_attributes = $media->get('field_media_az_image')->getValue(); + public function getImageSourceAndFocalPoint(MediaInterface $media): array { + $empty = [ + 'src' => '', + 'focal_x' => NULL, + 'focal_y' => NULL, + 'cache_tags' => [], + ]; + $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; + $file = $this->entityTypeManager->getStorage('file')->load($media_attributes[0]['target_id']); + if (!$file) { + return $empty; + } - // 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(); + $result = $empty; + $result['src'] = $this->fileUrlGenerator->generateString($file->getFileUri()); + $result['cache_tags'] = $file->getCacheTags(); - // 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, - ]; - } + 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()) { + $result['focal_x'] = (float) $media->get('field_focal_point_x')->value; + $result['focal_y'] = (float) $media->get('field_focal_point_y')->value; } } - 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); + catch (\Throwable $e) { + // 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. + } } - return $media_render_array; + + 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..1e4add4c94 --- /dev/null +++ b/modules/custom/az_ranking/src/Element/AZRankingItemElement.php @@ -0,0 +1,73 @@ + 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. + * + * 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 */ + $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 7840f871bc..4572076176 100644 --- a/modules/custom/az_ranking/src/Plugin/Field/FieldFormatter/AZRankingDefaultFormatter.php +++ b/modules/custom/az_ranking/src/Plugin/Field/FieldFormatter/AZRankingDefaultFormatter.php @@ -8,14 +8,22 @@ 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: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 */ #[FieldFormatter( id: 'az_ranking_default', @@ -27,25 +35,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; + protected $componentBuilder; /** * {@inheritdoc} @@ -58,9 +52,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; } @@ -104,282 +96,67 @@ public function settingsSummary() { * {@inheritdoc} */ public function viewElements(FieldItemListInterface $items, $langcode) { - $settings = $this->getSettings(); - $element = []; - - foreach ($items as $delta => $item) { - assert($item instanceof AZRankingItem); - - // Format title. - $ranking_heading = $item->ranking_heading ?? ''; - $ranking_description = $item->ranking_description ?? ''; - - $attached = []; - $attached['library'][] = 'az_ranking/az_ranking'; - - // 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'; - } - } - - // 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'; - } - } - - // 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 . ' '; - } - - // 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']; - } - } - - // 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; + // 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 []; + } - case str_contains($item->options['hover_class'], 'bg-cool-gray'): - $text_color_override = 'text-azurite'; - break; + $rankings = []; + $interactive_links = (bool) $this->getSetting('interactive_links'); + + // 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(); + if ($parent instanceof ParagraphInterface) { + $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 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']; + } - case str_contains($item->options['hover_class'], 'bg-oasis'): - $text_color_override = 'text-midnight'; - break; - } - } + foreach ($items as $item) { + assert($item instanceof AZRankingItem); + $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); + + // 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'; } - $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'; + $rankings[] = $ranking; } - return $element; + return [ + 0 => [ + '#type' => 'component', + '#component' => 'az_quickstart:ranking-deck', + '#props' => $deck_props, + '#slots' => ['rankings' => $rankings], + ], + ]; } } 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..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); } /** @@ -138,9 +145,7 @@ public static function schema(FieldStorageDefinitionInterface $field_definition) /** * {@inheritdoc} - */ - - /** + * * @todo samplePreview */ public static function generateSampleValue(FieldDefinitionInterface $field_definition) { 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..886da12b6d 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; } @@ -73,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']; @@ -83,13 +75,20 @@ 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'] = []; - 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); @@ -112,83 +111,68 @@ 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; - - // 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; - } + // 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(); - // 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'] = [ '#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, show a preview of the ranking. + // A closed row shows a rendered card in place of its fields. if (!$status) { $element['preview_wrapper'] = [ '#type' => 'container', @@ -196,12 +180,20 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen '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, ]; - // Build the preview using the helper method. - $element['preview_wrapper']['preview'] = $this->buildRankingPreview($item, $ranking_classes); + // 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', + '#props' => [], + ]; } // Create a globally unique ID that includes @@ -214,7 +206,10 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen $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) { @@ -446,12 +441,96 @@ 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; + } + // 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; + } + $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; } @@ -471,7 +550,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: @@ -483,20 +561,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'), @@ -515,43 +604,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. * @@ -756,201 +808,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 %} -