diff --git a/config/default/core.entity_form_display.paragraph.image_list.default.yml b/config/default/core.entity_form_display.paragraph.image_list.default.yml new file mode 100644 index 00000000..e221477d --- /dev/null +++ b/config/default/core.entity_form_display.paragraph.image_list.default.yml @@ -0,0 +1,65 @@ +uuid: c791cf92-99ab-4b0a-b236-3da575e11796 +langcode: en +status: true +dependencies: + config: + - field.field.paragraph.image_list.field_c_p_background + - field.field.paragraph.image_list.field_c_p_content + - field.field.paragraph.image_list.field_c_p_image + - field.field.paragraph.image_list.field_c_p_theme + - field.field.paragraph.image_list.field_c_p_title + - field.field.paragraph.image_list.field_c_p_vertical_spacing + - paragraphs.paragraphs_type.image_list + module: + - media_library + - text +id: paragraph.image_list.default +targetEntityType: paragraph +bundle: image_list +mode: default +content: + field_c_p_background: + type: boolean_checkbox + weight: 5 + region: content + settings: + display_label: true + third_party_settings: { } + field_c_p_content: + type: text_textarea + weight: 1 + region: content + settings: + rows: 5 + placeholder: '' + third_party_settings: { } + field_c_p_image: + type: media_library_widget + weight: 2 + region: content + settings: + media_types: { } + third_party_settings: { } + field_c_p_theme: + type: options_buttons + weight: 3 + region: content + settings: { } + third_party_settings: { } + field_c_p_title: + type: string_textfield + weight: 0 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + field_c_p_vertical_spacing: + type: options_buttons + weight: 4 + region: content + settings: { } + third_party_settings: { } +hidden: + created: true + status: true diff --git a/config/default/core.entity_view_display.paragraph.image_list.default.yml b/config/default/core.entity_view_display.paragraph.image_list.default.yml new file mode 100644 index 00000000..1ec40831 --- /dev/null +++ b/config/default/core.entity_view_display.paragraph.image_list.default.yml @@ -0,0 +1,33 @@ +uuid: 205a19a3-0247-4316-a0da-14a78382c5b7 +langcode: en +status: true +dependencies: + config: + - field.field.paragraph.image_list.field_c_p_background + - field.field.paragraph.image_list.field_c_p_content + - field.field.paragraph.image_list.field_c_p_image + - field.field.paragraph.image_list.field_c_p_theme + - field.field.paragraph.image_list.field_c_p_title + - field.field.paragraph.image_list.field_c_p_vertical_spacing + - paragraphs.paragraphs_type.image_list + module: + - text +id: paragraph.image_list.default +targetEntityType: paragraph +bundle: image_list +mode: default +content: + field_c_p_content: + type: text_default + label: hidden + settings: { } + third_party_settings: { } + weight: 0 + region: content +hidden: + field_c_p_background: true + field_c_p_image: true + field_c_p_theme: true + field_c_p_title: true + field_c_p_vertical_spacing: true + search_api_excerpt: true diff --git a/config/default/field.field.node.blog.field_c_n_components.yml b/config/default/field.field.node.blog.field_c_n_components.yml index 569b09a2..63e153af 100644 --- a/config/default/field.field.node.blog.field_c_n_components.yml +++ b/config/default/field.field.node.blog.field_c_n_components.yml @@ -21,6 +21,7 @@ dependencies: - paragraphs.paragraphs_type.civictheme_webform - paragraphs.paragraphs_type.divider - paragraphs.paragraphs_type.from_library + - paragraphs.paragraphs_type.image_list - paragraphs.paragraphs_type.steps module: - entity_reference_revisions @@ -55,6 +56,7 @@ settings: from_library: from_library civictheme_message: civictheme_message steps: steps + image_list: image_list negate: 0 target_bundles_drag_drop: civictheme_accordion: @@ -153,6 +155,9 @@ settings: from_library: weight: 62 enabled: true + image_list: + weight: 63 + enabled: true steps: weight: 61 enabled: true diff --git a/config/default/field.field.node.civictheme_page.field_c_n_components.yml b/config/default/field.field.node.civictheme_page.field_c_n_components.yml index 6ec9966e..0c1beecb 100644 --- a/config/default/field.field.node.civictheme_page.field_c_n_components.yml +++ b/config/default/field.field.node.civictheme_page.field_c_n_components.yml @@ -14,12 +14,14 @@ dependencies: - paragraphs.paragraphs_type.civictheme_iframe - paragraphs.paragraphs_type.civictheme_manual_list - paragraphs.paragraphs_type.civictheme_map + - paragraphs.paragraphs_type.civictheme_message - paragraphs.paragraphs_type.civictheme_next_step - paragraphs.paragraphs_type.civictheme_promo - paragraphs.paragraphs_type.civictheme_slider - paragraphs.paragraphs_type.civictheme_webform - paragraphs.paragraphs_type.divider - paragraphs.paragraphs_type.from_library + - paragraphs.paragraphs_type.image_list - paragraphs.paragraphs_type.steps module: - entity_reference_revisions @@ -56,6 +58,7 @@ settings: from_library: from_library civictheme_message: civictheme_message steps: steps + image_list: image_list negate: 0 target_bundles_drag_drop: civictheme_accordion: @@ -154,6 +157,9 @@ settings: from_library: weight: 62 enabled: true + image_list: + weight: 63 + enabled: true steps: weight: 61 enabled: true diff --git a/config/default/field.field.node.project.field_c_n_components.yml b/config/default/field.field.node.project.field_c_n_components.yml index e2637670..e3f42723 100644 --- a/config/default/field.field.node.project.field_c_n_components.yml +++ b/config/default/field.field.node.project.field_c_n_components.yml @@ -21,6 +21,7 @@ dependencies: - paragraphs.paragraphs_type.civictheme_webform - paragraphs.paragraphs_type.divider - paragraphs.paragraphs_type.from_library + - paragraphs.paragraphs_type.image_list - paragraphs.paragraphs_type.steps module: - entity_reference_revisions @@ -55,6 +56,7 @@ settings: from_library: from_library civictheme_message: civictheme_message steps: steps + image_list: image_list negate: 0 target_bundles_drag_drop: civictheme_accordion: @@ -153,6 +155,9 @@ settings: from_library: weight: 62 enabled: true + image_list: + weight: 63 + enabled: true steps: weight: 61 enabled: true diff --git a/config/default/field.field.paragraph.image_list.field_c_p_background.yml b/config/default/field.field.paragraph.image_list.field_c_p_background.yml new file mode 100644 index 00000000..e6b5eb63 --- /dev/null +++ b/config/default/field.field.paragraph.image_list.field_c_p_background.yml @@ -0,0 +1,23 @@ +uuid: a84abbf9-f0dc-4c62-9e63-4bd9d93c2403 +langcode: en +status: true +dependencies: + config: + - field.storage.paragraph.field_c_p_background + - paragraphs.paragraphs_type.image_list +id: paragraph.image_list.field_c_p_background +field_name: field_c_p_background +entity_type: paragraph +bundle: image_list +label: Background +description: 'Apply a themed background colour and provide horizontal spacing to the component.' +required: false +translatable: true +default_value: + - + value: 0 +default_value_callback: '' +settings: + on_label: 'On' + off_label: 'Off' +field_type: boolean diff --git a/config/default/field.field.paragraph.image_list.field_c_p_content.yml b/config/default/field.field.paragraph.image_list.field_c_p_content.yml new file mode 100644 index 00000000..ca486f33 --- /dev/null +++ b/config/default/field.field.paragraph.image_list.field_c_p_content.yml @@ -0,0 +1,22 @@ +uuid: 01f22e86-054d-4f3d-b771-ce291de272a4 +langcode: en +status: true +dependencies: + config: + - field.storage.paragraph.field_c_p_content + - paragraphs.paragraphs_type.image_list + module: + - text +id: paragraph.image_list.field_c_p_content +field_name: field_c_p_content +entity_type: paragraph +bundle: image_list +label: Content +description: '' +required: false +translatable: true +default_value: { } +default_value_callback: '' +settings: + allowed_formats: { } +field_type: text_long diff --git a/config/default/field.field.paragraph.image_list.field_c_p_image.yml b/config/default/field.field.paragraph.image_list.field_c_p_image.yml new file mode 100644 index 00000000..97b04dc6 --- /dev/null +++ b/config/default/field.field.paragraph.image_list.field_c_p_image.yml @@ -0,0 +1,29 @@ +uuid: db8e25b6-c863-4aff-a287-c3b794427eb2 +langcode: en +status: true +dependencies: + config: + - field.storage.paragraph.field_c_p_image + - media.type.civictheme_image + - paragraphs.paragraphs_type.image_list +id: paragraph.image_list.field_c_p_image +field_name: field_c_p_image +entity_type: paragraph +bundle: image_list +label: Images +description: 'Images shown in the list, in the order they are listed.' +required: false +translatable: true +default_value: { } +default_value_callback: '' +settings: + handler: 'default:media' + handler_settings: + target_bundles: + civictheme_image: civictheme_image + sort: + field: _none + direction: ASC + auto_create: false + auto_create_bundle: '' +field_type: entity_reference diff --git a/config/default/field.field.paragraph.image_list.field_c_p_theme.yml b/config/default/field.field.paragraph.image_list.field_c_p_theme.yml new file mode 100644 index 00000000..07665430 --- /dev/null +++ b/config/default/field.field.paragraph.image_list.field_c_p_theme.yml @@ -0,0 +1,23 @@ +uuid: dd314639-8cb9-4467-8968-664816f44a6c +langcode: en +status: true +dependencies: + config: + - field.storage.paragraph.field_c_p_theme + - paragraphs.paragraphs_type.image_list + module: + - options +id: paragraph.image_list.field_c_p_theme +field_name: field_c_p_theme +entity_type: paragraph +bundle: image_list +label: Theme +description: '' +required: true +translatable: true +default_value: + - + value: light +default_value_callback: '' +settings: { } +field_type: list_string diff --git a/config/default/field.field.paragraph.image_list.field_c_p_title.yml b/config/default/field.field.paragraph.image_list.field_c_p_title.yml new file mode 100644 index 00000000..eb280607 --- /dev/null +++ b/config/default/field.field.paragraph.image_list.field_c_p_title.yml @@ -0,0 +1,19 @@ +uuid: adf44d12-4cf2-446d-beae-4ba9eae21ba6 +langcode: en +status: true +dependencies: + config: + - field.storage.paragraph.field_c_p_title + - paragraphs.paragraphs_type.image_list +id: paragraph.image_list.field_c_p_title +field_name: field_c_p_title +entity_type: paragraph +bundle: image_list +label: Title +description: '' +required: false +translatable: true +default_value: { } +default_value_callback: '' +settings: { } +field_type: string diff --git a/config/default/field.field.paragraph.image_list.field_c_p_vertical_spacing.yml b/config/default/field.field.paragraph.image_list.field_c_p_vertical_spacing.yml new file mode 100644 index 00000000..5c90b4a2 --- /dev/null +++ b/config/default/field.field.paragraph.image_list.field_c_p_vertical_spacing.yml @@ -0,0 +1,23 @@ +uuid: b744aff8-dc86-4e8c-a177-6bb0456059e5 +langcode: en +status: true +dependencies: + config: + - field.storage.paragraph.field_c_p_vertical_spacing + - paragraphs.paragraphs_type.image_list + module: + - options +id: paragraph.image_list.field_c_p_vertical_spacing +field_name: field_c_p_vertical_spacing +entity_type: paragraph +bundle: image_list +label: 'Vertical spacing' +description: 'Adds additional vertical spacing to a component' +required: true +translatable: true +default_value: + - + value: both +default_value_callback: '' +settings: { } +field_type: list_string diff --git a/config/default/field.storage.paragraph.field_c_p_image.yml b/config/default/field.storage.paragraph.field_c_p_image.yml index e04ddda6..5ff8285d 100644 --- a/config/default/field.storage.paragraph.field_c_p_image.yml +++ b/config/default/field.storage.paragraph.field_c_p_image.yml @@ -15,7 +15,7 @@ settings: target_type: media module: core locked: false -cardinality: 1 +cardinality: -1 translatable: true indexes: { } persist_with_no_fields: false diff --git a/config/default/image.style.image_list.yml b/config/default/image.style.image_list.yml new file mode 100644 index 00000000..415dd7e3 --- /dev/null +++ b/config/default/image.style.image_list.yml @@ -0,0 +1,21 @@ +uuid: be65753e-cf04-43e9-9738-285f13c4ffb1 +langcode: en +status: true +dependencies: { } +name: image_list +label: 'Image list (320x160, WebP)' +effects: + 4da4bcd9-a025-447c-8269-ba93b559b159: + uuid: 4da4bcd9-a025-447c-8269-ba93b559b159 + id: image_scale + weight: 1 + data: + width: 320 + height: 160 + upscale: false + 5dcdbfad-d5e1-44ba-9156-69cc79b29cd3: + uuid: 5dcdbfad-d5e1-44ba-9156-69cc79b29cd3 + id: image_convert + weight: 2 + data: + extension: webp diff --git a/config/default/paragraphs.paragraphs_type.image_list.yml b/config/default/paragraphs.paragraphs_type.image_list.yml new file mode 100644 index 00000000..f3392b71 --- /dev/null +++ b/config/default/paragraphs.paragraphs_type.image_list.yml @@ -0,0 +1,10 @@ +uuid: ad60296d-77b5-44fa-afb6-7deec8564131 +langcode: en +status: true +dependencies: { } +id: image_list +label: 'Image list' +icon_uuid: null +icon_default: null +description: 'A row of images rendered as a single band, wrapping to fit the screen.' +behavior_plugins: { } diff --git a/docs/performance.md b/docs/performance.md index ad03b341..9c341774 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -12,10 +12,11 @@ An image style does two things here: it caps the pixel dimensions, and it conver | `banner_featured` | The banner featured image: cropped to 1090x818 around the focal point, converted to WebP | | `civictheme_*` | Cards, campaigns and slides: cropped by the CivicTheme sizes, converted to WebP | | `divider` | The divider graphic: scaled to fit within 1090x480, converted to WebP | +| `image_list` | An image in the image list: scaled to fit within 320x160, converted to WebP | | `wide` | An image placed in content and rendered as a figure | | `social_share` | Share cards, deliberately **not** converted, because some social crawlers still handle WebP badly | -CivicTheme resolves several of these with no image style at all, which yields the URL of the original upload. Three preprocessors here re-resolve them: `_drevops_banner_apply_image_styles()` for the two banner images, `drevops_preprocess_paragraph__divider()` for the divider, and `drevops_preprocess_media__civictheme_image()` for the figure. The banner one covers the block field and the node field, because the node's value wins when both are set. +CivicTheme resolves several of these with no image style at all, which yields the URL of the original upload. Four preprocessors here re-resolve them: `_drevops_banner_apply_image_styles()` for the two banner images, `drevops_preprocess_paragraph__divider()` for the divider, `_drevops_preprocess_paragraph__paragraph_field__images()` for the image list, and `drevops_preprocess_media__civictheme_image()` for the figure. The banner one covers the block field and the node field, because the node's value wins when both are set. The featured image sits in a box 40% of the viewport wide and no more than 600px tall, filled with `object-fit: cover`. Its ratio moves with the viewport and with how tall the banner's own content makes it, so the browser trims a different part of the image on every screen. A focal point crop puts the subject at the centre of the derivative, which is the part `cover` keeps whichever way it trims. diff --git a/tests/behat/features/paragraph_image_list_render.feature b/tests/behat/features/paragraph_image_list_render.feature new file mode 100644 index 00000000..ab6eb26b --- /dev/null +++ b/tests/behat/features/paragraph_image_list_render.feature @@ -0,0 +1,78 @@ +@p1 @civictheme @drevops @imagelist +Feature: Image list render + + As a site visitor + I want to see a band of images rendered on a page + So that I can recognise the organisations and projects behind the site + + Background: + Given the following managed files: + | path | uri | status | + | image.jpg | public://do_test/image.jpg | 1 | + + And the following media "civictheme_image" exist: + | name | field_c_m_image | + | [TEST] DO Image one | image.jpg | + | [TEST] DO Image two | image.jpg | + | [TEST] DO Image three | image.jpg | + + And the following "civictheme_page" content: + | title | status | + | [TEST] Page Image list test 1 | 1 | + | [TEST] Page Image list test 2 | 1 | + + @api + Scenario: CivicTheme page renders a light Image list with a heading and every image + Given I am an anonymous user + And the following fields for the paragraph "image_list" exist in the field "field_c_n_components" within the "civictheme_page" "node" identified by the field "title" and the value "[TEST] Page Image list test 1": + | field_c_p_title | [TEST] Image list title | + | field_c_p_theme | light | + | field_c_p_vertical_spacing | both | + | field_c_p_background | 0 | + | field_c_p_image | [TEST] DO Image one, [TEST] DO Image two, [TEST] DO Image three | + + When I visit the "civictheme_page" content page with the title "[TEST] Page Image list test 1" + Then I should see an "article .ct-image-list" element + And I should see an "article .ct-image-list.ct-theme-light" element + And I should not see an "article .ct-image-list.ct-theme-dark" element + And I should see an "article .ct-image-list.ct-vertical-spacing-inset--both" element + And I should not see an "article .ct-image-list.ct-image-list--with-background" element + And I should see an "article .ct-image-list__items.ct-item-list--horizontal.ct-item-list--large" element + And I should see 3 ".ct-image-list__items .ct-item-list__item" elements + And I should see 3 ".ct-image-list__items .ct-item-list__item img" elements + And I should see the text "[TEST] Image list title" + And save screenshot + + @api + Scenario: CivicTheme page renders a dark Image list with a background + Given I am an anonymous user + And the following fields for the paragraph "image_list" exist in the field "field_c_n_components" within the "civictheme_page" "node" identified by the field "title" and the value "[TEST] Page Image list test 2": + | field_c_p_title | [TEST] Image list dark title | + | field_c_p_theme | dark | + | field_c_p_vertical_spacing | top | + | field_c_p_background | 1 | + | field_c_p_image | [TEST] DO Image one | + + When I visit the "civictheme_page" content page with the title "[TEST] Page Image list test 2" + Then I should see an "article .ct-image-list" element + And I should see an "article .ct-image-list.ct-theme-dark" element + And I should not see an "article .ct-image-list.ct-theme-light" element + And I should see an "article .ct-image-list.ct-image-list--with-background" element + And I should see an "article .ct-image-list.ct-vertical-spacing-inset--top" element + And I should see 1 ".ct-image-list__items .ct-item-list__item" elements + And I should see the text "[TEST] Image list dark title" + And save screenshot + + @api + Scenario: CivicTheme page renders nothing for an Image list that has no images + Given I am an anonymous user + And the following fields for the paragraph "image_list" exist in the field "field_c_n_components" within the "civictheme_page" "node" identified by the field "title" and the value "[TEST] Page Image list test 1": + | field_c_p_title | [TEST] Image list empty title | + | field_c_p_theme | light | + | field_c_p_vertical_spacing | both | + | field_c_p_background | 0 | + + When I visit the "civictheme_page" content page with the title "[TEST] Page Image list test 1" + Then I should not see an "article .ct-image-list" element + And I should not see the text "[TEST] Image list empty title" + And save screenshot diff --git a/web/modules/custom/do_base/do_base.deploy.php b/web/modules/custom/do_base/do_base.deploy.php index 4ffcd9e9..4e80f171 100644 --- a/web/modules/custom/do_base/do_base.deploy.php +++ b/web/modules/custom/do_base/do_base.deploy.php @@ -1318,6 +1318,66 @@ function do_base_deploy_add_related_posts_block(): string { return Helper::report(); } +/** + * Puts an empty Image list on the homepage above the blog list. + * + * The component is placed without images so an author can fill it from the + * media library. It renders nothing until they do, so an empty band never + * reaches a visitor. + */ +function do_base_deploy_add_homepage_image_list(): string { + $node = _do_base_front_page_node(); + + if (!$node instanceof NodeInterface) { + Helper::reporter()->skipped('The front page is not a node, so there is nothing to place the image list on.'); + + return Helper::report(); + } + + // Only three of the site's bundles carry components, so a front page set to + // an event or an alert has nowhere to put this. Reading the field regardless + // would throw and take the whole deployment down with it. + if (!$node->hasField('field_c_n_components')) { + Helper::reporter()->skipped(sprintf('The front page "%s" has no components field, so there is nowhere to place the image list.', $node->getTitle())); + + return Helper::report(); + } + + // The guard is that the component is on the page, not merely that the + // paragraph exists. A run interrupted between saving the paragraph and + // saving the node leaves one behind that no page references, and that is + // exactly the state the next run has to finish rather than skip. + $paragraph = \Drupal::service('entity.repository')->loadEntityByUuid('paragraph', _do_base_image_list_uuid()); + + if ($paragraph instanceof ParagraphInterface && _do_base_node_references_paragraph($node, $paragraph)) { + Helper::reporter()->skipped('The homepage image list already exists.'); + + return Helper::report(); + } + + if (!$paragraph instanceof ParagraphInterface) { + $paragraph = _do_base_image_list_paragraph(); + } + + $items = $node->get('field_c_n_components')->getValue(); + $delta = _do_base_blog_list_delta($node); + + $reference = [ + 'target_id' => $paragraph->id(), + 'target_revision_id' => $paragraph->getRevisionId(), + ]; + + array_splice($items, $delta, 0, [$reference]); + + $node->set('field_c_n_components', $items); + $node->setNewRevision(FALSE); + $node->save(); + + Helper::reporter()->created(sprintf('Placed the image list at position %d on "%s".', $delta, $node->getTitle())); + + return Helper::report(); +} + /** * Strips the related-content list from the foot of every blog post. * @@ -1369,6 +1429,108 @@ function _do_base_remove_related_list(NodeInterface $node): void { } } +/** + * Loads the node the site serves as its front page. + */ +function _do_base_front_page_node(): ?NodeInterface { + $front = (string) \Drupal::config('system.site')->get('page.front'); + + if (!preg_match('#^/node/(\d+)$#', $front, $matches)) { + return NULL; + } + + $node = \Drupal::entityTypeManager()->getStorage('node')->load($matches[1]); + + return $node instanceof NodeInterface ? $node : NULL; +} + +/** + * Finds the position of the blog list among a node's components. + * + * Resolved by looking for the list rather than by a fixed delta, so an editor + * reordering the page does not silently move the component placed above it. A + * page with no blog list puts that component last, which is still a sensible + * place for it. + */ +function _do_base_blog_list_delta(NodeInterface $node): int { + $field = $node->get('field_c_n_components'); + $values = $field->getValue(); + + // The position is read from the raw values rather than taken from the loop + // over the referenced entities: the field is spliced by delta, and + // referencedEntities() renumbers from zero once a reference stops resolving. + // The earliest delta wins, so a paragraph placed on the page twice puts the + // component above the first of them rather than between the two. + $deltas = []; + + foreach ($values as $delta => $value) { + $target_id = (int) ($value['target_id'] ?? 0); + + if ($target_id !== 0 && !isset($deltas[$target_id])) { + $deltas[$target_id] = $delta; + } + } + + foreach ($field->referencedEntities() as $paragraph) { + if (!$paragraph instanceof ParagraphInterface || $paragraph->bundle() !== 'civictheme_automated_list') { + continue; + } + + if (!$paragraph->hasField('field_c_p_list_content_type')) { + continue; + } + + if ($paragraph->get('field_c_p_list_content_type')->getString() === 'blog') { + return (int) ($deltas[$paragraph->id()] ?? count($values)); + } + } + + return count($values); +} + +/** + * The fixed identity of the homepage image list, across every environment. + */ +function _do_base_image_list_uuid(): string { + return 'd4b0f9a2-3c17-4e6d-9f52-8a1c6b0e7d34'; +} + +/** + * Tells whether a node's components already include a paragraph. + */ +function _do_base_node_references_paragraph(NodeInterface $node, ParagraphInterface $paragraph): bool { + foreach ($node->get('field_c_n_components')->getValue() as $item) { + if ((int) ($item['target_id'] ?? 0) === (int) $paragraph->id()) { + return TRUE; + } + } + + return FALSE; +} + +/** + * Builds the homepage image list. + * + * No images are attached. The site ships none of its own, and inventing + * artwork for real organisations would misrepresent them, so the component is + * placed as an empty shell for an author to fill from the media library. + */ +function _do_base_image_list_paragraph(): ParagraphInterface { + $paragraph = Paragraph::create([ + 'type' => 'image_list', + 'uuid' => _do_base_image_list_uuid(), + 'field_c_p_theme' => 'light', + // The blog list below already carries a tinted background, so this band is + // left plain to keep the homepage alternating rather than running two + // tinted sections together. + 'field_c_p_background' => FALSE, + 'field_c_p_vertical_spacing' => 'both', + ]); + $paragraph->save(); + + return $paragraph; +} + /** * Tells whether a paragraph is an Automated list following the page's topics. */ diff --git a/web/modules/custom/do_generated_content/src/Generator/ComponentGenerator.php b/web/modules/custom/do_generated_content/src/Generator/ComponentGenerator.php index 38fae103..8e7b8e0c 100644 --- a/web/modules/custom/do_generated_content/src/Generator/ComponentGenerator.php +++ b/web/modules/custom/do_generated_content/src/Generator/ComponentGenerator.php @@ -91,6 +91,7 @@ public static function supportedBundles(): array { 'civictheme_webform', 'divider', 'from_library', + 'image_list', 'steps', 'steps_item', ]; @@ -145,6 +146,7 @@ public function create(string $bundle, int $index, int $position = 0): ?Paragrap 'civictheme_webform' => $this->webform($index), 'divider' => $this->divider($index), 'from_library' => $this->fromLibrary(), + 'image_list' => $this->imageList($index), 'steps' => $this->steps($index), 'steps_item' => $this->stepsItem($position), default => throw new \InvalidArgumentException(sprintf('Unsupported component bundle %s.', $bundle)), @@ -698,6 +700,28 @@ protected function fromLibrary(): array { return ['field_reusable_paragraph' => ['target_id' => $this->libraryItem()->id()]]; } + /** + * Build an image list. + * + * @return array|null + * Field values, or NULL when no image has been generated yet: a list with + * no images renders nothing, so there would be nothing to look at. + */ + protected function imageList(int $index): ?array { + $images = $this->generatedContentHelper::randomMediaItems('civictheme_image', 8); + + if ($images === []) { + return NULL; + } + + return $this->chrome('image_list', $index) + [ + 'field_c_p_title' => $this->generatedContentHelper::staticSentence(4), + 'field_c_p_content' => $this->richText(1), + 'field_c_p_background' => CaseMatrix::bit($index, 0), + 'field_c_p_image' => array_map(static fn(MediaInterface $media): array => ['target_id' => $media->id()], $images), + ]; + } + /** * Build a steps component with items. */ diff --git a/web/themes/custom/drevops/components/03-organisms/image-list/image-list.component.yml b/web/themes/custom/drevops/components/03-organisms/image-list/image-list.component.yml new file mode 100644 index 00000000..0714eff5 --- /dev/null +++ b/web/themes/custom/drevops/components/03-organisms/image-list/image-list.component.yml @@ -0,0 +1,57 @@ +$schema: https://git.drupalcode.org/project/drupal/-/raw/11.x/core/modules/sdc/src/metadata.schema.json +name: Image list +status: stable +description: A row of images rendered as a single band, wrapping to fit the screen. +props: + type: object + properties: + theme: + type: string + title: Theme + description: Theme variation (light or dark). + enum: + - light + - dark + default: light + images: + type: array + title: Images + description: Images rendered in order. + items: + type: object + properties: + url: + type: string + title: URL + description: Image URL. + alt: + type: string + title: Alt text + description: Image alt text. + vertical_spacing: + type: string + title: Vertical spacing + description: Controls vertical spacing (top, bottom, or both). + enum: + - top + - bottom + - both + with_background: + type: boolean + title: With background + description: Whether to display with a themed background. + attributes: + type: Drupal\Core\Template\Attribute + title: Additional HTML attributes + description: Additional HTML attributes to add to the component. + modifier_class: + type: string + title: Additional CSS classes + description: Additional CSS classes to add to the component. +slots: + title: + title: Title + description: Optional heading shown above the images. + content: + title: Content + description: Optional intro content shown above the images. diff --git a/web/themes/custom/drevops/components/03-organisms/image-list/image-list.scss b/web/themes/custom/drevops/components/03-organisms/image-list/image-list.scss new file mode 100644 index 00000000..49b7741f --- /dev/null +++ b/web/themes/custom/drevops/components/03-organisms/image-list/image-list.scss @@ -0,0 +1,93 @@ +// +// Image list component. +// + +.ct-image-list { + $root: &; + + #{$root}__header { + text-align: center; + margin-bottom: ct-spacing(5); + max-width: ct-particle(97.5); + margin-left: auto; + margin-right: auto; + } + + // The rich text arrives as bare markup, so it carries no typography or + // theme colours of its own. + #{$root}__content { + @include ct-content(); + } + + // Flex layout and responsive gaps come from ct-item-list--horizontal--large; + // only the centring and the measure are set here. + #{$root}__items { + justify-content: center; + align-items: center; + + // Capped rather than left to fill the container: on the widest screens the + // first row would otherwise absorb almost every image and strand the + // remainder on a row of their own. + max-width: ct-particle(137.5); + margin-left: auto; + margin-right: auto; + + .ct-item-list__item { + display: flex; + align-items: center; + justify-content: center; + + // A set of logos or marks mixes wide artwork with square artwork, so the + // box is fixed and the image is fitted inside it. Sizing on width alone + // would let a square image stand several times taller than the wide one + // beside it. + width: ct-particle(15); + height: ct-particle(5.5); + + @include ct-breakpoint(s) { + width: ct-particle(16); + height: ct-particle(6); + } + + @include ct-breakpoint(m) { + width: ct-particle(17); + height: ct-particle(6.25); + } + + @include ct-breakpoint(l) { + width: ct-particle(18); + height: ct-particle(6.5); + } + } + } + + #{$root}__image { + max-width: 100%; + max-height: 100%; + width: auto; + height: auto; + object-fit: contain; + } + + @include ct-component-theme($root) using($root, $theme) { + &#{$root}--with-background { + background-color: if($theme == 'light', $ct-image-list-light-background-color, $ct-image-list-dark-background-color); + } + + #{$root}__content { + @include ct-content-theme($theme); + } + + // Artwork carries its own colours, which cannot be recoloured without + // misrepresenting it, so the dark theme puts a light plate behind each + // image rather than filtering it. + @if $theme == 'dark' { + #{$root}__items .ct-item-list__item { + background-color: $ct-image-list-dark-plate-background-color; + border-radius: ct-particle(0.5); + padding: ct-spacing(1); + box-sizing: content-box; + } + } + } +} diff --git a/web/themes/custom/drevops/components/03-organisms/image-list/image-list.stories.js b/web/themes/custom/drevops/components/03-organisms/image-list/image-list.stories.js new file mode 100644 index 00000000..1afa210b --- /dev/null +++ b/web/themes/custom/drevops/components/03-organisms/image-list/image-list.stories.js @@ -0,0 +1,67 @@ +// phpcs:ignoreFile +import Component from './image-list.twig'; + +const meta = { + title: 'Organisms/Image list', + component: Component, + argTypes: { + theme: { + control: { type: 'radio' }, + options: ['light', 'dark'], + }, + title: { + control: { type: 'text' }, + }, + content: { + control: { type: 'text' }, + }, + images: { + control: { type: 'object' }, + }, + vertical_spacing: { + control: { type: 'radio' }, + options: ['top', 'bottom', 'both'], + }, + with_background: { + control: { type: 'boolean' }, + }, + modifier_class: { + control: { type: 'text' }, + }, + attributes: { + control: { type: 'text' }, + }, + }, +}; + +export default meta; + +const placeholder = (label) => ({ + url: `https://placehold.co/240x80/eeeeee/333333/png?text=${encodeURIComponent(label)}`, + alt: label, +}); + +export const ImageList = { + parameters: { + layout: 'fullscreen', + }, + args: { + theme: 'light', + title: 'Who is in the network', + content: '

Organisations taking part, shown in the order they joined.

', + images: [ + placeholder('One'), + placeholder('Two'), + placeholder('Three'), + placeholder('Four'), + placeholder('Five'), + placeholder('Six'), + placeholder('Seven'), + placeholder('Eight'), + ], + vertical_spacing: 'both', + with_background: true, + modifier_class: '', + attributes: '', + }, +}; diff --git a/web/themes/custom/drevops/components/03-organisms/image-list/image-list.twig b/web/themes/custom/drevops/components/03-organisms/image-list/image-list.twig new file mode 100644 index 00000000..4e09faf7 --- /dev/null +++ b/web/themes/custom/drevops/components/03-organisms/image-list/image-list.twig @@ -0,0 +1,80 @@ +{# +/** + * @file + * Image list component. + * + * Variables: + * - theme: [string] Theme: light, dark. + * - title: [string] Optional heading shown above the images. + * - content: [string] Optional intro content shown above the images. + * - images: [array] Images: + * - url: [string] Image URL. + * - alt: [string] Image alt text. + * - vertical_spacing: [string] With top, bottom or both vertical spaces. + * - with_background: [boolean] Whether to display with a themed background. + * - attributes: [Drupal\Core\Template\Attribute] Additional attributes. + * - modifier_class: [string] Additional classes. + */ +#} + +{% set with_background_class = with_background ? 'ct-image-list--with-background' : '' %} +{% set vertical_spacing_class = vertical_spacing in ['top', 'bottom', 'both'] ? 'ct-vertical-spacing-inset--%s'|format(vertical_spacing) : '' %} +{% set theme_class = 'ct-theme-%s'|format(theme|default('light')) %} +{% set modifier_class = '%s %s %s %s'|format(theme_class, with_background_class, vertical_spacing_class, modifier_class|default('')) %} + +{% if images %} +
+
+ {% if title or content %} +
+
+
+ {% if title %} + {{ include('civictheme:heading', { + content: title, + level: 2, + theme: theme, + modifier_class: 'ct-image-list__title', + }, with_context: false) }} + {% endif %} + {% if content %} +
{{ content }}
+ {% endif %} +
+
+
+ {% endif %} + +
+
+ {% set image_items = [] %} + + {% for image in images %} + {% if image.url is not empty %} + {% set image_item %} + {{ include('civictheme:image', { + theme: theme|default('light'), + url: image.url, + alt: image.alt|default(''), + modifier_class: 'ct-image-list__image', + }, with_context: false) }} + {% endset %} + {% set image_items = image_items|merge([image_item]) %} + {% endif %} + {% endfor %} + + {# The list markup, its flex layout and its responsive gaps all come + from the base component. The role is restated because removing the + bullets also removes the list semantics in Safari. #} + {{ include('civictheme:item-list', { + items: image_items, + direction: 'horizontal', + size: 'large', + modifier_class: 'ct-image-list__items', + attributes: create_attribute({role: 'list'}), + }, with_context: false) }} +
+
+
+
+{% endif %} diff --git a/web/themes/custom/drevops/components/variables.components.scss b/web/themes/custom/drevops/components/variables.components.scss index 3488d652..cc0c25f7 100644 --- a/web/themes/custom/drevops/components/variables.components.scss +++ b/web/themes/custom/drevops/components/variables.components.scss @@ -137,6 +137,13 @@ $ct-steps-dark-receive-background-color: ct-color-dark('background-light') !defa $ct-steps-light-receive-border-color: ct-color-light('highlight') !default; $ct-steps-dark-receive-border-color: ct-color-dark('highlight') !default; +// +// Image list. +// +$ct-image-list-light-background-color: ct-color-light('background') !default; +$ct-image-list-dark-background-color: ct-color-dark('background') !default; +$ct-image-list-dark-plate-background-color: ct-color-light('background-light') !default; + // // At a glance. // diff --git a/web/themes/custom/drevops/drevops.theme b/web/themes/custom/drevops/drevops.theme index 253f8634..421951aa 100644 --- a/web/themes/custom/drevops/drevops.theme +++ b/web/themes/custom/drevops/drevops.theme @@ -17,6 +17,7 @@ require_once __DIR__ . '/includes/media.inc'; require_once __DIR__ . '/includes/manual_list.inc'; require_once __DIR__ . '/includes/automated_list.inc'; require_once __DIR__ . '/includes/steps.inc'; +require_once __DIR__ . '/includes/image_list.inc'; require_once __DIR__ . '/includes/page.inc'; require_once __DIR__ . '/includes/node.inc'; require_once __DIR__ . '/includes/blog.inc'; diff --git a/web/themes/custom/drevops/includes/image_list.inc b/web/themes/custom/drevops/includes/image_list.inc new file mode 100644 index 00000000..c7bd5d5d --- /dev/null +++ b/web/themes/custom/drevops/includes/image_list.inc @@ -0,0 +1,53 @@ + $image['url'], + // CivicTheme image media carry the alt text on the file field, and fall + // back to the media name, which is what the library shows. + 'alt' => $image['alt'] !== '' ? $image['alt'] : $entity->label(), + ]; + } +} diff --git a/web/themes/custom/drevops/templates/paragraphs/paragraph--image-list.html.twig b/web/themes/custom/drevops/templates/paragraphs/paragraph--image-list.html.twig new file mode 100644 index 00000000..d8635753 --- /dev/null +++ b/web/themes/custom/drevops/templates/paragraphs/paragraph--image-list.html.twig @@ -0,0 +1,9 @@ +{# +/** + * @file + * Theme implementation to display the Image list component. + */ +#} +{{ include('drevops:image-list', { + attributes: component_attributes, +}) }}