+ *
+ *
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 %}
+
+
+
+
+
+ {% if heading|default('') %}
+ <{{ heading_level }} class="m-0 az-ranking-sdc__heading{{ header_style != 'thin' ? ' az-ranking-sdc__heading--' ~ header_style : '' }}{{ heading_font == 'serif' ? ' az-ranking-sdc__heading--serif' : '' }}{{ clickable ? ' hover-text-underline' : '' }}{{ text_override ? ' ' ~ text_override : '' }}">{{ heading }}{{ heading_level }}>
+ {% endif %}
+ {#
+ When transparent, we want description and source to have less margin
+ and be more 'snug' since a large gap without any colored background
+ looks weird.
+ #}
+ {% if description|default('') %}
+
{{ description }}
+ {% endif %}
+
+ {% if source|default('') %}
+
+ {{ source|nl2br }}
+
+ {% endif %}
+
+ {% if link_url|default('') %}
+ {% if hover_effect %}
+
+ {% else %}
+
{{ link_title|default(source|default('')) }}
+ {% endif %}
+ {% endif %}
+
+
diff --git a/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 %}
-