From 1d846008e610bacc244afa03fbf4d5a5d2915b74 Mon Sep 17 00:00:00 2001 From: Robbe Bierebeeck Date: Wed, 12 Aug 2026 23:39:07 +0200 Subject: [PATCH 1/2] feat(ARC-3833): add double banner content block Two mirrored halves, each a clickable path with a label, up to three icons, an image and its own text/background colour. https://meemoo.atlassian.net/browse/ARC-3833 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KfHyE7TNyMnk3QALg89RK9 --- ui/src/client.ts | 1 + .../ContentBlockRenderer.const.tsx | 2 + .../BlockDoubleBanner.editorconfig.ts | 189 ++++++++++++++++++ .../BlockDoubleBanner/BlockDoubleBanner.scss | 114 +++++++++++ .../BlockDoubleBanner/BlockDoubleBanner.tsx | 90 +++++++++ .../BlockDoubleBanner.types.ts | 22 ++ .../blocks/BlockDoubleBanner/index.ts | 6 + .../const/content-block-config-map.ts | 2 + .../const/content-block-initial-state-map.ts | 2 + .../const/get-content-block-type-options.ts | 8 + .../content-page/types/content-block.types.ts | 1 + ui/src/shared/helpers/admin-core-config.tsx | 1 + 12 files changed, 438 insertions(+) create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.editorconfig.ts create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.scss create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.tsx create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.types.ts create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/index.ts diff --git a/ui/src/client.ts b/ui/src/client.ts index f3eb7810..762b7852 100644 --- a/ui/src/client.ts +++ b/ui/src/client.ts @@ -5,6 +5,7 @@ export { BlockButtonsWrapper } from '~content-blocks/BlockButtons/BlockButtons.w export { BlockCardsWithoutDescription } from '~content-blocks/BlockCardsWithoutDescription'; export { BlockContentPageMeta } from '~content-blocks/BlockContentPageMeta'; export { BlockCTAsWrapper } from '~content-blocks/BlockCTAs/BlockCTAs.wrapper'; +export { BlockDoubleBanner } from '~content-blocks/BlockDoubleBanner'; export { BlockEventbrite } from '~content-blocks/BlockEventbrite'; export { BlockHeading } from '~content-blocks/BlockHeading/BlockHeading'; export { BlockHetArchiefHeaderSearch } from '~content-blocks/BlockHetArchiefHeaderSearch'; diff --git a/ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.const.tsx b/ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.const.tsx index 1dca6479..78abd764 100644 --- a/ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.const.tsx +++ b/ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.const.tsx @@ -6,6 +6,7 @@ import { BlockButtonsWrapper } from '~content-blocks/BlockButtons'; import { BlockCardsWithoutDescription } from '~content-blocks/BlockCardsWithoutDescription'; import { BlockContentPageMeta } from '~content-blocks/BlockContentPageMeta'; import { BlockCTAsWrapper } from '~content-blocks/BlockCTAs'; +import { BlockDoubleBanner } from '~content-blocks/BlockDoubleBanner'; import { BlockEventbrite } from '~content-blocks/BlockEventbrite'; import { BlockHeading } from '~content-blocks/BlockHeading'; import { BlockHetArchiefHeaderSearch } from '~content-blocks/BlockHetArchiefHeaderSearch/BlockHetArchiefHeaderSearch'; @@ -95,6 +96,7 @@ export function GET_BLOCK_COMPONENT( [ContentBlockType.HighlightText]: BlockHighlightText, [ContentBlockType.ThemeReels]: BlockThemeReels, [ContentBlockType.OverviewThemes]: BlockOverviewThemes, + [ContentBlockType.DoubleBanner]: BlockDoubleBanner, // Avo specific blocks [ContentBlockType.MediaGrid]: loadComponentFromConfig(ContentBlockType.MediaGrid), diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.editorconfig.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.editorconfig.ts new file mode 100644 index 00000000..9d4291b9 --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.editorconfig.ts @@ -0,0 +1,189 @@ +import { AvoCoreContentPickerType } from '@viaa/avo2-types'; +import { + GET_BACKGROUND_COLOR_OPTIONS_ARCHIEF, + GET_FOREGROUND_COLOR_OPTIONS_ARCHIEF, +} from '~modules/content-page/const/get-color-options'; +import { + Color, + type ContentBlockConfig, + ContentBlockEditor, + ContentBlockType, + type DefaultContentBlockState, +} from '~modules/content-page/types/content-block.types'; +import { GET_ADMIN_ICON_OPTIONS } from '~shared/consts/icons.consts'; +import { tText } from '~shared/helpers/translation-functions'; +import { validateRequiredValue } from '~shared/helpers/validation'; +import { HET_ARCHIEF } from '~shared/types'; +import { BLOCK_FIELD_DEFAULTS, BLOCK_STATE_DEFAULTS, FILE_FIELD, TEXT_FIELD } from '../defaults'; + +const INITIAL_DOUBLE_BANNER_HALF_STATE = () => ({ + label: '', + icon1: '', + icon2: '', + icon3: '', + link: undefined, + image: '', + textColor: Color.White, + backgroundColor: Color.Black, +}); + +/** + * The block always shows exactly two halves, so `halves` starts with two entries and the group is + * pinned to min = max = 2: `FieldGenerator` then hides both the add and the delete button, which is + * how the FA requirement "beide helften moeten volledig ingevuld worden" is enforced in the editor. + * https://meemoo.atlassian.net/browse/ARC-3833 + */ +export const INITIAL_DOUBLE_BANNER_COMPONENTS_STATE = () => ({ + halves: [INITIAL_DOUBLE_BANNER_HALF_STATE(), INITIAL_DOUBLE_BANNER_HALF_STATE()], +}); + +export const INITIAL_DOUBLE_BANNER_BLOCK_STATE = (): DefaultContentBlockState => + BLOCK_STATE_DEFAULTS(); + +const ICON_FIELD = (label: string) => ({ + label, + editorType: ContentBlockEditor.IconPicker, + editorProps: { + options: GET_ADMIN_ICON_OPTIONS(), + }, +}); + +export const DOUBLE_BANNER_BLOCK_CONFIG = (position = 0): ContentBlockConfig => ({ + position, + name: tText( + 'modules/content-page/components/blocks/block-double-banner/block-double-banner___dubbele-banner', + {}, + [HET_ARCHIEF] + ), + type: ContentBlockType.DoubleBanner, + components: { + state: INITIAL_DOUBLE_BANNER_COMPONENTS_STATE(), + fields: { + halves: { + label: tText( + 'modules/content-page/components/blocks/block-double-banner/block-double-banner___helft', + {}, + [HET_ARCHIEF] + ), + type: 'fieldGroup', + min: 2, + max: 2, + repeat: { + defaultState: INITIAL_DOUBLE_BANNER_HALF_STATE(), + }, + fields: { + label: TEXT_FIELD( + { + label: tText( + 'modules/content-page/components/blocks/block-double-banner/block-double-banner___label', + {}, + [HET_ARCHIEF] + ), + }, + tText( + 'modules/content-page/components/blocks/block-double-banner/block-double-banner___label-is-verplicht', + {}, + [HET_ARCHIEF] + ) + ), + icon1: ICON_FIELD( + tText( + 'modules/content-page/components/blocks/block-double-banner/block-double-banner___icoon-1', + {}, + [HET_ARCHIEF] + ) + ), + icon2: ICON_FIELD( + tText( + 'modules/content-page/components/blocks/block-double-banner/block-double-banner___icoon-2', + {}, + [HET_ARCHIEF] + ) + ), + icon3: ICON_FIELD( + tText( + 'modules/content-page/components/blocks/block-double-banner/block-double-banner___icoon-3', + {}, + [HET_ARCHIEF] + ) + ), + link: { + label: tText( + 'modules/content-page/components/blocks/block-double-banner/block-double-banner___bestemming', + {}, + [HET_ARCHIEF] + ), + editorType: ContentBlockEditor.ContentPicker, + editorProps: { + allowedTypes: [ + AvoCoreContentPickerType.CONTENT_PAGE, + AvoCoreContentPickerType.INTERNAL_LINK, + AvoCoreContentPickerType.EXTERNAL_LINK, + AvoCoreContentPickerType.ANCHOR_LINK, + ], + // The FA requires the destination to always open in the same tab + hideTargetSwitch: true, + }, + validator: (value: string) => + validateRequiredValue( + value, + tText( + 'modules/content-page/components/blocks/block-double-banner/block-double-banner___bestemming-is-verplicht', + {}, + [HET_ARCHIEF] + ) + ), + }, + image: FILE_FIELD( + tText( + 'modules/content-page/components/blocks/block-double-banner/block-double-banner___afbeelding-is-verplicht', + {}, + [HET_ARCHIEF] + ), + { + label: tText( + 'modules/content-page/components/blocks/block-double-banner/block-double-banner___afbeelding', + {}, + [HET_ARCHIEF] + ), + } + ), + textColor: { + label: tText( + 'modules/content-page/components/blocks/block-double-banner/block-double-banner___tekstkleur', + {}, + [HET_ARCHIEF] + ), + editorType: ContentBlockEditor.ColorSelect, + editorProps: { + options: GET_FOREGROUND_COLOR_OPTIONS_ARCHIEF(), + defaultValue: GET_FOREGROUND_COLOR_OPTIONS_ARCHIEF().find( + (option) => option.value === Color.White + ), + }, + }, + backgroundColor: { + label: tText( + 'modules/content-page/components/blocks/block-double-banner/block-double-banner___achtergrondkleur-tekstvak', + {}, + [HET_ARCHIEF] + ), + editorType: ContentBlockEditor.ColorSelect, + editorProps: { + options: GET_BACKGROUND_COLOR_OPTIONS_ARCHIEF(), + defaultValue: GET_BACKGROUND_COLOR_OPTIONS_ARCHIEF().find( + (option) => option.value === Color.Black + ), + }, + }, + }, + }, + }, + }, + block: { + state: INITIAL_DOUBLE_BANNER_BLOCK_STATE(), + fields: { + ...BLOCK_FIELD_DEFAULTS(), + }, + }, +}); diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.scss b/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.scss new file mode 100644 index 00000000..e87c4f2f --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.scss @@ -0,0 +1,114 @@ +@use "../../../../shared/styles/settings/variables" as variables; +@use "../../../../shared/styles/mixins/typography" as typography; +@use "../../../../shared/styles/mixins/animations" as animations; +@use "../../../../shared/styles/mixins/focus" as focus; + +$panel-padding: variables.$g-spacer-unit * 2; +$half-gap: variables.$g-spacer-unit * 2; + +.c-block-double-banner { + display: grid; + grid-template-columns: 1fr; + gap: $half-gap; + + @media (min-width: variables.$g-bp3) { + grid-template-columns: 1fr 1fr; + } + + &__half { + display: flex; + min-height: 12rem; + overflow: hidden; + text-decoration: none; + + @media (min-width: variables.$g-bp3) { + min-height: 14rem; + } + + &:focus-visible { + @include focus.focus; + } + + &:hover .c-block-double-banner__image img { + @include animations.zoom-in-animation; + } + + // Mirror image: the image moves to the other side and its rounded end flips with it. + &--mirrored { + flex-direction: row-reverse; + + .c-block-double-banner__image { + border-radius: 999px 0 0 999px; + } + } + } + + &__panel { + // The panel needs more room than the image while the two halves sit under each other. + flex: 3 1 0; + display: flex; + flex-direction: column; + justify-content: space-between; + gap: variables.$g-spacer-unit; + padding: $panel-padding; + + @media (min-width: variables.$g-bp3) { + flex: 1 1 0; + padding: $panel-padding * 1.5; + } + } + + &__label { + // Body copy at heading weight: none of the presets cover that combo, which is what the + // sofia-pro mixin is there for. + @include typography.sofia-pro(1.6rem, 800, 2.4rem); + + @media (min-width: variables.$g-bp3) { + @include typography.sofia-pro(1.8rem, 800, 2.8rem); + } + } + + // The design groups the icons and the arrow together at the start of the row, rather than + // pushing the arrow out to the far edge of the panel. + &__actions { + display: flex; + align-items: center; + gap: variables.$g-spacer-unit * 2; + } + + &__icons { + display: flex; + align-items: center; + gap: variables.$g-spacer-unit; + } + + &__icon, + &__arrow { + font-size: 2rem; + line-height: 1; + } + + &__image { + position: relative; + flex: 2 1 0; + overflow: hidden; + + @media (min-width: variables.$g-bp3) { + flex: 1 1 0; + } + // A pill end on the side facing the middle of the block; the radius is deliberately larger + // than the half is tall so it always resolves to a semicircle. + border-radius: 0 999px 999px 0; + + img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + // Cover-scaling, centered both ways, as required by the FA. + object-fit: cover; + object-position: center; + @include animations.zoom-in-transition; + } + } +} diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.tsx new file mode 100644 index 00000000..fff71890 --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.tsx @@ -0,0 +1,90 @@ +import { type IconName, Image, LinkTarget } from '@viaa/avo2-components'; +import clsx from 'clsx'; +import { compact } from 'es-toolkit'; +import type { CSSProperties, FunctionComponent, ReactElement } from 'react'; +import React from 'react'; +import type { + BlockDoubleBannerProps, + DoubleBannerHalf, +} from '~content-blocks/BlockDoubleBanner/BlockDoubleBanner.types'; +import { Icon } from '~shared/components/Icon/Icon'; +import { SmartLink } from '~shared/components/SmartLink/SmartLink'; +import './BlockDoubleBanner.scss'; + +/** + * Two mirrored halves, each one clickable path to a search page or content page. + * The first half puts its text panel on the left and its image on the right; the second is the + * mirror image of that. https://meemoo.atlassian.net/browse/ARC-3833 + */ +export const BlockDoubleBanner: FunctionComponent = ({ + className, + halves, +}): ReactElement => { + const renderHalf = (half: DoubleBannerHalf, index: number) => { + const icons = compact([half.icon1, half.icon2, half.icon3]); + + return ( + <> +
+ {half.label} + + + {icons.map((icon) => ( + + ))} + + + +
+
+ {half.label} +
+ + ); + }; + + return ( +
+ {(halves || []).map((half: DoubleBannerHalf, index: number) => { + const content = renderHalf(half, index); + // Every half after the first mirrors the previous one: image towards the middle. + const halfClassName = clsx('c-block-double-banner__half', { + 'c-block-double-banner__half--mirrored': index % 2 === 1, + }); + + if (!half.link) { + return ( + // biome-ignore lint/suspicious/noArrayIndexKey: the halves have no id of their own +
+ {content} +
+ ); + } + + return ( + + {content} + + ); + })} +
+ ); +}; diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.types.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.types.ts new file mode 100644 index 00000000..0900ce97 --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.types.ts @@ -0,0 +1,22 @@ +import type { ButtonAction } from '@viaa/avo2-components'; +import type { DefaultComponentProps } from '~shared/types'; + +/** + * One of the two mirrored halves of the double banner. + * The FA requires every field except the icons to be filled in. + * https://meemoo.atlassian.net/browse/ARC-3833 + */ +export interface DoubleBannerHalf { + label: string; + icon1?: string; + icon2?: string; + icon3?: string; + link?: ButtonAction; + image: string; + textColor: string; + backgroundColor: string; +} + +export interface BlockDoubleBannerProps extends DefaultComponentProps { + halves: DoubleBannerHalf[]; +} diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/index.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/index.ts new file mode 100644 index 00000000..cba2f2dc --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/index.ts @@ -0,0 +1,6 @@ +export { BlockDoubleBanner } from './BlockDoubleBanner'; +export { + DOUBLE_BANNER_BLOCK_CONFIG, + INITIAL_DOUBLE_BANNER_BLOCK_STATE, + INITIAL_DOUBLE_BANNER_COMPONENTS_STATE, +} from './BlockDoubleBanner.editorconfig'; diff --git a/ui/src/react-admin/modules/content-page/const/content-block-config-map.ts b/ui/src/react-admin/modules/content-page/const/content-block-config-map.ts index 9406f588..0cf08327 100644 --- a/ui/src/react-admin/modules/content-page/const/content-block-config-map.ts +++ b/ui/src/react-admin/modules/content-page/const/content-block-config-map.ts @@ -7,6 +7,7 @@ import { CARDS_WITHOUT_DESCRIPTION_BLOCK_CONFIG } from '~content-blocks/BlockCar import { CONTENT_ENCLOSE_BLOCK_CONFIG } from '~content-blocks/BlockContentEnclose'; import { CONTENT_PAGE_META_BLOCK_CONFIG } from '~content-blocks/BlockContentPageMeta'; import { CTAS_BLOCK_CONFIG } from '~content-blocks/BlockCTAs'; +import { DOUBLE_BANNER_BLOCK_CONFIG } from '~content-blocks/BlockDoubleBanner'; import { EVENTBRITE_BLOCK_CONFIG } from '~content-blocks/BlockEventbrite'; import { HEADING_BLOCK_CONFIG } from '~content-blocks/BlockHeading'; import { HET_ARCHIEF_HEADER_SEARCH_BLOCK_CONFIG } from '~content-blocks/BlockHetArchiefHeaderSearch/BlockHetArchiefHeaderSearch.editorconfig'; @@ -95,4 +96,5 @@ export const CONTENT_BLOCK_CONFIG_MAP: Record< [ContentBlockType.HighlightText]: CONTENT_HIGHLIGHT_TEXT_CONFIG, [ContentBlockType.ThemeReels]: THEME_REELS_BLOCK_CONFIG, [ContentBlockType.OverviewThemes]: OVERVIEW_THEMES_BLOCK_CONFIG, + [ContentBlockType.DoubleBanner]: DOUBLE_BANNER_BLOCK_CONFIG, }; diff --git a/ui/src/react-admin/modules/content-page/const/content-block-initial-state-map.ts b/ui/src/react-admin/modules/content-page/const/content-block-initial-state-map.ts index 61b21ca0..55a81bde 100644 --- a/ui/src/react-admin/modules/content-page/const/content-block-initial-state-map.ts +++ b/ui/src/react-admin/modules/content-page/const/content-block-initial-state-map.ts @@ -7,6 +7,7 @@ import { INITIAL_CARDS_WITHOUT_DESCRIPTION_COMPONENTS_STATE } from '~content-blo import { INITIAL_CONTENT_ENCLOSE_BLOCK_STATE } from '~content-blocks/BlockContentEnclose/BlockContentEnclose.editorconfig'; import { INITIAL_CONTENT_PAGE_META_COMPONENTS_STATE } from '~content-blocks/BlockContentPageMeta'; import { INITIAL_CTAS_COMPONENTS_STATE } from '~content-blocks/BlockCTAs'; +import { INITIAL_DOUBLE_BANNER_COMPONENTS_STATE } from '~content-blocks/BlockDoubleBanner/BlockDoubleBanner.editorconfig'; import { INITIAL_EVENTBRITE_COMPONENTS_STATE } from '~content-blocks/BlockEventbrite'; import { INITIAL_HEADING_COMPONENTS_STATE } from '~content-blocks/BlockHeading'; import { INITIAL_HET_ARCHIEF_HEADER_SEARCH_BLOCK_STATE } from '~content-blocks/BlockHetArchiefHeaderSearch/BlockHetArchiefHeaderSearch.editorconfig'; @@ -98,4 +99,5 @@ export const CONTENT_BLOCK_INITIAL_STATE_MAP: { [ContentBlockType.HighlightText]: INITIAL_CONTENT_HIGHLIGHT_TEXT_BLOCK_STATE, [ContentBlockType.ThemeReels]: INITIAL_THEME_REELS_COMPONENTS_STATE, [ContentBlockType.OverviewThemes]: INITIAL_OVERVIEW_THEMES_COMPONENTS_STATE, + [ContentBlockType.DoubleBanner]: INITIAL_DOUBLE_BANNER_COMPONENTS_STATE, }; diff --git a/ui/src/react-admin/modules/content-page/const/get-content-block-type-options.ts b/ui/src/react-admin/modules/content-page/const/get-content-block-type-options.ts index 18f589ed..8e955f28 100644 --- a/ui/src/react-admin/modules/content-page/const/get-content-block-type-options.ts +++ b/ui/src/react-admin/modules/content-page/const/get-content-block-type-options.ts @@ -214,6 +214,14 @@ export const GET_CONTENT_BLOCK_TYPE_OPTIONS: () => SelectOption[] = () = ), value: ContentBlockType.OverviewThemes, }, + { + label: tText( + 'modules/content-page/const/get-content-block-type-options___dubbele-banner', + {}, + [HET_ARCHIEF] + ), + value: ContentBlockType.DoubleBanner, + }, ]; // Only show the content blocks that the client enabled through the config object diff --git a/ui/src/react-admin/modules/content-page/types/content-block.types.ts b/ui/src/react-admin/modules/content-page/types/content-block.types.ts index f276f3ce..0d238987 100644 --- a/ui/src/react-admin/modules/content-page/types/content-block.types.ts +++ b/ui/src/react-admin/modules/content-page/types/content-block.types.ts @@ -191,6 +191,7 @@ export enum ContentBlockType { HighlightText = 'HIGHLIGHT_TEXT', ThemeReels = 'THEME_REELS', OverviewThemes = 'OVERVIEW_THEMES', + DoubleBanner = 'DOUBLE_BANNER', } export enum ContentBlockEditor { diff --git a/ui/src/shared/helpers/admin-core-config.tsx b/ui/src/shared/helpers/admin-core-config.tsx index d8082acf..31693c5d 100644 --- a/ui/src/shared/helpers/admin-core-config.tsx +++ b/ui/src/shared/helpers/admin-core-config.tsx @@ -98,6 +98,7 @@ export function getAdminCoreConfigForLocalTestApp(navigateFunc: NavigateFunction // ContentBlockType.Breadcrumbs, ContentBlockType.OverviewWithCarousel, ContentBlockType.OverviewThemes, + ContentBlockType.DoubleBanner, ], defaultPageWidth: ContentPageWidth.LARGE, onSaveContentPage: async (contentPageInfo: ContentPageInfo) => { From e091a2de5720b79fd9d052e406c013ab26cd19a1 Mon Sep 17 00:00:00 2001 From: Robbe Bierebeeck Date: Thu, 13 Aug 2026 14:15:46 +0200 Subject: [PATCH 2/2] fix(ARC-3833): improve double banner responsive layout, validation and a11y --- ui/all-translations-het-archief.json | 322 +++++++++++++++++- .../BlockDoubleBanner.editorconfig.test.ts | 89 +++++ .../BlockDoubleBanner.editorconfig.ts | 29 +- .../BlockDoubleBanner/BlockDoubleBanner.scss | 77 +++-- .../BlockDoubleBanner.test.tsx | 73 ++++ .../BlockDoubleBanner/BlockDoubleBanner.tsx | 13 +- .../shared/components/Icon/Icon.test.tsx | 43 +++ .../modules/shared/components/Icon/Icon.tsx | 12 +- ui/src/shared/translations/hetArchief/nl.json | 16 + 9 files changed, 636 insertions(+), 38 deletions(-) create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.editorconfig.test.ts create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.test.tsx create mode 100644 ui/src/react-admin/modules/shared/components/Icon/Icon.test.tsx diff --git a/ui/all-translations-het-archief.json b/ui/all-translations-het-archief.json index a5b3ba30..997f3a22 100644 --- a/ui/all-translations-het-archief.json +++ b/ui/all-translations-het-archief.json @@ -58538,5 +58538,325 @@ "language": "en", "value": "You do not have the right permissions to call this route", "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-double-banner/block-double-banner", + "key": "achtergrondkleur-tekstvak", + "language": "nl", + "value": "Achtergrondkleur tekstvak", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-double-banner/block-double-banner", + "key": "achtergrondkleur-tekstvak", + "language": "en", + "value": "Text panel background colour", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-double-banner/block-double-banner", + "key": "afbeelding", + "language": "nl", + "value": "Afbeelding", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-double-banner/block-double-banner", + "key": "afbeelding", + "language": "en", + "value": "Image", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-double-banner/block-double-banner", + "key": "afbeelding-is-verplicht", + "language": "nl", + "value": "Afbeelding is verplicht", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-double-banner/block-double-banner", + "key": "afbeelding-is-verplicht", + "language": "en", + "value": "Image is required", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-double-banner/block-double-banner", + "key": "bestemming", + "language": "nl", + "value": "Bestemming", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-double-banner/block-double-banner", + "key": "bestemming", + "language": "en", + "value": "Destination", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-double-banner/block-double-banner", + "key": "bestemming-is-verplicht", + "language": "nl", + "value": "Bestemming is verplicht", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-double-banner/block-double-banner", + "key": "bestemming-is-verplicht", + "language": "en", + "value": "Destination is required", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-double-banner/block-double-banner", + "key": "dubbele-banner", + "language": "nl", + "value": "Dubbele banner", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-double-banner/block-double-banner", + "key": "dubbele-banner", + "language": "en", + "value": "Double banner", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-double-banner/block-double-banner", + "key": "helft", + "language": "nl", + "value": "Helft", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-double-banner/block-double-banner", + "key": "helft", + "language": "en", + "value": "Half", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-double-banner/block-double-banner", + "key": "icoon-1", + "language": "nl", + "value": "Icoon 1", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-double-banner/block-double-banner", + "key": "icoon-1", + "language": "en", + "value": "Icon 1", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-double-banner/block-double-banner", + "key": "icoon-2", + "language": "nl", + "value": "Icoon 2", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-double-banner/block-double-banner", + "key": "icoon-2", + "language": "en", + "value": "Icon 2", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-double-banner/block-double-banner", + "key": "icoon-3", + "language": "nl", + "value": "Icoon 3", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-double-banner/block-double-banner", + "key": "icoon-3", + "language": "en", + "value": "Icon 3", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-double-banner/block-double-banner", + "key": "label", + "language": "nl", + "value": "Label", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-double-banner/block-double-banner", + "key": "label", + "language": "en", + "value": "Label", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-double-banner/block-double-banner", + "key": "label-is-verplicht", + "language": "nl", + "value": "Label is verplicht", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-double-banner/block-double-banner", + "key": "label-is-verplicht", + "language": "en", + "value": "Label is required", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-double-banner/block-double-banner", + "key": "tekstkleur", + "language": "nl", + "value": "Tekstkleur", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-double-banner/block-double-banner", + "key": "tekstkleur", + "language": "en", + "value": "Text colour", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/const/get-content-block-type-options", + "key": "dubbele-banner", + "language": "nl", + "value": "Dubbele banner", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/const/get-content-block-type-options", + "key": "dubbele-banner", + "language": "en", + "value": "Double banner", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-double-banner/block-double-banner", + "key": "achtergrondkleur-tekstvak-is-verplicht", + "language": "nl", + "value": "Achtergrondkleur tekstvak is verplicht", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-double-banner/block-double-banner", + "key": "achtergrondkleur-tekstvak-is-verplicht", + "language": "en", + "value": "Text panel background colour is required", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-double-banner/block-double-banner", + "key": "tekstkleur-is-verplicht", + "language": "nl", + "value": "Tekstkleur is verplicht", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-double-banner/block-double-banner", + "key": "tekstkleur-is-verplicht", + "language": "en", + "value": "Text colour is required", + "value_type": "TEXT" } -] \ No newline at end of file +] diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.editorconfig.test.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.editorconfig.test.ts new file mode 100644 index 00000000..a85538ae --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.editorconfig.test.ts @@ -0,0 +1,89 @@ +import { AvoCoreContentPickerType } from '@viaa/avo2-types'; +import { describe, expect, it, vi } from 'vitest'; +import type { + ContentBlockField, + ContentBlockFieldGroup, +} from '~modules/content-page/types/content-block.types'; +import { Color } from '~modules/content-page/types/content-block.types'; +import { + DOUBLE_BANNER_BLOCK_CONFIG, + INITIAL_DOUBLE_BANNER_COMPONENTS_STATE, +} from './BlockDoubleBanner.editorconfig'; + +vi.mock('~shared/helpers/translation-functions', () => ({ + tText: (key: string) => key, +})); + +vi.mock('~shared/helpers/is-avo', () => ({ + isAvo: () => false, +})); + +vi.mock('~shared/consts/icons.consts', () => ({ + GET_ADMIN_ICON_OPTIONS: () => [{ label: 'Newspaper', value: 'newspaper' }], +})); + +const config = DOUBLE_BANNER_BLOCK_CONFIG(); +const halvesField = config.components.fields.halves as ContentBlockFieldGroup; +const fields = halvesField.fields; +const field = (key: string): ContentBlockField => fields[key]; + +describe('DOUBLE_BANNER_BLOCK_CONFIG', () => { + it('always starts with exactly two halves', () => { + const state = INITIAL_DOUBLE_BANNER_COMPONENTS_STATE(); + + expect(state.halves).toHaveLength(2); + expect(halvesField.min).toBe(2); + expect(halvesField.max).toBe(2); + }); + + it('keeps only the three icon fields optional', () => { + for (const key of ['label', 'link', 'image', 'textColor', 'backgroundColor']) { + expect(field(key).validator?.('')).not.toEqual([]); + expect(field(key).validator?.('#000')).toEqual([]); + } + + for (const key of ['icon1', 'icon2', 'icon3']) { + expect(field(key).validator).toBeUndefined(); + expect(field(key).editorProps.options).toEqual([{ label: 'Newspaper', value: 'newspaper' }]); + } + }); + + it('does not impose a maximum label length', () => { + expect(field('label').editorProps?.maxLength).toBeUndefined(); + }); + + it('accepts one image per half', () => { + expect(field('image').editorProps).toMatchObject({ + assetType: 'CONTENT_BLOCK_IMAGE', + allowMulti: false, + }); + }); + + it('accepts URLs and internal destinations in the same tab', () => { + expect(field('link').editorProps).toMatchObject({ + hideTargetSwitch: true, + allowedTypes: [ + AvoCoreContentPickerType.CONTENT_PAGE, + AvoCoreContentPickerType.INTERNAL_LINK, + AvoCoreContentPickerType.EXTERNAL_LINK, + AvoCoreContentPickerType.ANCHOR_LINK, + ], + }); + }); + + it('defaults to white text on a black flat-color background', () => { + const state = INITIAL_DOUBLE_BANNER_COMPONENTS_STATE(); + + expect(state.halves[0]).toMatchObject({ + textColor: Color.White, + backgroundColor: Color.Black, + }); + expect(field('textColor').editorProps.defaultValue?.value).toBe(Color.White); + expect(field('backgroundColor').editorProps.defaultValue?.value).toBe(Color.Black); + expect( + field('backgroundColor').editorProps.options.every(({ value }: { value: string }) => + value.startsWith('#') + ) + ).toBe(true); + }); +}); diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.editorconfig.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.editorconfig.ts index 9d4291b9..82e16476 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.editorconfig.ts +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.editorconfig.ts @@ -48,6 +48,9 @@ const ICON_FIELD = (label: string) => ({ }, }); +const GET_DOUBLE_BANNER_BACKGROUND_COLOR_OPTIONS = () => + GET_BACKGROUND_COLOR_OPTIONS_ARCHIEF().filter(({ value }) => value.startsWith('#')); + export const DOUBLE_BANNER_BLOCK_CONFIG = (position = 0): ContentBlockConfig => ({ position, name: tText( @@ -146,6 +149,10 @@ export const DOUBLE_BANNER_BLOCK_CONFIG = (position = 0): ContentBlockConfig => {}, [HET_ARCHIEF] ), + editorProps: { + assetType: 'CONTENT_BLOCK_IMAGE', + allowMulti: false, + }, } ), textColor: { @@ -161,6 +168,15 @@ export const DOUBLE_BANNER_BLOCK_CONFIG = (position = 0): ContentBlockConfig => (option) => option.value === Color.White ), }, + validator: (value: string) => + validateRequiredValue( + value, + tText( + 'modules/content-page/components/blocks/block-double-banner/block-double-banner___tekstkleur-is-verplicht', + {}, + [HET_ARCHIEF] + ) + ), }, backgroundColor: { label: tText( @@ -170,11 +186,20 @@ export const DOUBLE_BANNER_BLOCK_CONFIG = (position = 0): ContentBlockConfig => ), editorType: ContentBlockEditor.ColorSelect, editorProps: { - options: GET_BACKGROUND_COLOR_OPTIONS_ARCHIEF(), - defaultValue: GET_BACKGROUND_COLOR_OPTIONS_ARCHIEF().find( + options: GET_DOUBLE_BANNER_BACKGROUND_COLOR_OPTIONS(), + defaultValue: GET_DOUBLE_BANNER_BACKGROUND_COLOR_OPTIONS().find( (option) => option.value === Color.Black ), }, + validator: (value: string) => + validateRequiredValue( + value, + tText( + 'modules/content-page/components/blocks/block-double-banner/block-double-banner___achtergrondkleur-tekstvak-is-verplicht', + {}, + [HET_ARCHIEF] + ) + ), }, }, }, diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.scss b/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.scss index e87c4f2f..142a0c16 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.scss +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.scss @@ -17,12 +17,13 @@ $half-gap: variables.$g-spacer-unit * 2; &__half { display: flex; - min-height: 12rem; + flex-direction: column-reverse; overflow: hidden; text-decoration: none; @media (min-width: variables.$g-bp3) { - min-height: 14rem; + flex-direction: row; + min-height: 16rem; } &:focus-visible { @@ -35,25 +36,31 @@ $half-gap: variables.$g-spacer-unit * 2; // Mirror image: the image moves to the other side and its rounded end flips with it. &--mirrored { - flex-direction: row-reverse; - .c-block-double-banner__image { border-radius: 999px 0 0 999px; } + + @media (min-width: variables.$g-bp3) { + flex-direction: row-reverse; + } } } &__panel { - // The panel needs more room than the image while the two halves sit under each other. - flex: 3 1 0; - display: flex; - flex-direction: column; - justify-content: space-between; + box-sizing: border-box; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; gap: variables.$g-spacer-unit; - padding: $panel-padding; + padding: $panel-padding $panel-padding * 1.5; @media (min-width: variables.$g-bp3) { - flex: 1 1 0; + flex: 0 0 50%; + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: stretch; + min-width: 0; padding: $panel-padding * 1.5; } } @@ -61,53 +68,71 @@ $half-gap: variables.$g-spacer-unit * 2; &__label { // Body copy at heading weight: none of the presets cover that combo, which is what the // sofia-pro mixin is there for. - @include typography.sofia-pro(1.6rem, 800, 2.4rem); - - @media (min-width: variables.$g-bp3) { - @include typography.sofia-pro(1.8rem, 800, 2.8rem); - } + @include typography.sofia-pro(1.8rem, 800, 2.8rem); } - // The design groups the icons and the arrow together at the start of the row, rather than - // pushing the arrow out to the far edge of the panel. + // Mobile keeps only the arrow at the far edge; desktop groups the selected icons and arrow at + // the start of the row. &__actions { display: flex; align-items: center; - gap: variables.$g-spacer-unit * 2; + gap: variables.$g-spacer-unit * 0.5; + justify-self: end; + + @media (min-width: variables.$g-bp3) { + justify-self: auto; + } } &__icons { - display: flex; + display: none; align-items: center; - gap: variables.$g-spacer-unit; + gap: variables.$g-spacer-unit * 0.5; + + @media (min-width: variables.$g-bp3) { + display: flex; + } } &__icon, &__arrow { - font-size: 2rem; + font-size: 2.4rem; line-height: 1; + + @media (min-width: variables.$g-bp3) { + font-size: 3.2rem; + } } &__image { position: relative; - flex: 2 1 0; + flex: 0 0 auto; + width: 100%; + height: 9.6rem; overflow: hidden; - @media (min-width: variables.$g-bp3) { - flex: 1 1 0; - } // A pill end on the side facing the middle of the block; the radius is deliberately larger // than the half is tall so it always resolves to a semicircle. border-radius: 0 999px 999px 0; + @media (min-width: variables.$g-bp3) { + box-sizing: border-box; + flex: 0 0 50%; + width: auto; + height: auto; + min-width: 0; + } + img { position: absolute; inset: 0; width: 100%; height: 100%; + // Cover-scaling, centered both ways, as required by the FA. object-fit: cover; object-position: center; + @include animations.zoom-in-transition; } } diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.test.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.test.tsx new file mode 100644 index 00000000..95f1b459 --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.test.tsx @@ -0,0 +1,73 @@ +import { render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { BlockDoubleBanner } from './BlockDoubleBanner'; + +vi.mock('@viaa/avo2-components', () => ({ + LinkTarget: { Self: '_self' }, + Image: ({ alt, src }: { alt: string; src: string }) => {alt}, +})); + +vi.mock('~shared/components/Icon/Icon', () => ({ + Icon: ({ name, ...props }: { name: string; 'aria-hidden'?: boolean }) => ( + + ), +})); + +vi.mock('~shared/components/SmartLink/SmartLink', () => ({ + SmartLink: ({ + action, + children, + className, + }: { + action: { target: string; value: string }; + children: ReactNode; + className: string; + }) => ( + + {children} + + ), +})); + +describe('BlockDoubleBanner', () => { + it('wraps each complete half in a same-tab link and keeps its media decorative', () => { + render( + + ); + + const links = screen.getAllByRole('link'); + expect(links).toHaveLength(2); + expect(links[0]).toHaveAttribute('href', '/newspapers'); + expect(links[0]).toHaveAttribute('target', '_self'); + expect(links[1]).toHaveAttribute('href', '/av'); + expect(links[1]).toHaveAttribute('target', '_self'); + + for (const link of links) { + expect(link.querySelector('img')).toHaveAttribute('alt', ''); + for (const icon of link.querySelectorAll('[data-icon]')) { + expect(icon).toHaveAttribute('aria-hidden', 'true'); + } + } + }); +}); diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.tsx index fff71890..93e0434a 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.tsx +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.tsx @@ -37,19 +37,24 @@ export const BlockDoubleBanner: FunctionComponent = ({ {half.label} - {icons.map((icon) => ( + {icons.map((icon, iconIndex) => ( ))} - +
- {half.label} +
); diff --git a/ui/src/react-admin/modules/shared/components/Icon/Icon.test.tsx b/ui/src/react-admin/modules/shared/components/Icon/Icon.test.tsx new file mode 100644 index 00000000..ef8129eb --- /dev/null +++ b/ui/src/react-admin/modules/shared/components/Icon/Icon.test.tsx @@ -0,0 +1,43 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { AdminConfigManager } from '~core/config/config.class'; +import { Icon } from './Icon'; + +vi.mock('~shared/helpers/is-hetarchief.ts', () => ({ + isHetArchief: () => true, +})); + +describe('Icon', () => { + beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); + AdminConfigManager.setConfig({ + icon: { + component: ({ name, className }: { name: string; className?: string }) => ( + + ), + componentProps: { + video: { name: 'video--light' }, + }, + }, + } as never); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('uses a configured icon mapping', () => { + render(); + + expect(screen.getByTestId('video--light-icon')).toHaveAttribute('data-icon', 'video--light'); + }); + + it('passes an icon-picker glyph through when it is not a config key', () => { + render(); + + const icon = screen.getByTestId('newspaper--light-icon'); + expect(icon).toHaveAttribute('data-icon', 'newspaper--light'); + expect(icon).toHaveClass('banner-icon'); + }); +}); diff --git a/ui/src/react-admin/modules/shared/components/Icon/Icon.tsx b/ui/src/react-admin/modules/shared/components/Icon/Icon.tsx index de91e5c4..b27c31c4 100644 --- a/ui/src/react-admin/modules/shared/components/Icon/Icon.tsx +++ b/ui/src/react-admin/modules/shared/components/Icon/Icon.tsx @@ -9,9 +9,10 @@ import { isHetArchief } from '~shared/helpers/is-hetarchief.ts'; interface IconProps { name: keyof IconConfig['componentProps'] | IconName; className?: string; + 'aria-hidden'?: boolean; } -export const Icon: FC = ({ name, className }) => { +export const Icon: FC = ({ name, className, ...accessibilityProps }) => { const iconConfig = AdminConfigManager.getConfig().icon; // biome-ignore lint/suspicious/noExplicitAny: todo let iconProps = (iconConfig?.componentProps as any)?.[name] as { @@ -25,12 +26,13 @@ export const Icon: FC = ({ name, className }) => { config: iconConfig?.componentProps, }) ); - } else { - // Default to avo2 icons - iconProps = { name }; } + + // Icon picker values are concrete client glyph names (for example `video--light`) rather + // than admin-core config keys. Pass those values through to the configured icon component. + iconProps = { name }; } const IconComponent = iconConfig?.component ?? (() => null); - return ; + return ; }; diff --git a/ui/src/shared/translations/hetArchief/nl.json b/ui/src/shared/translations/hetArchief/nl.json index 88621ca2..c0215523 100644 --- a/ui/src/shared/translations/hetArchief/nl.json +++ b/ui/src/shared/translations/hetArchief/nl.json @@ -674,6 +674,21 @@ "modules/content-page/components/blocks/block-content-enclose/block-content-enclose___titletype": "titeltype", "modules/content-page/components/blocks/block-content-enclose/block-content-enclose___verwijder-object": "verwijder object", "modules/content-page/components/blocks/block-content-enclose/block-content-enclose___voeg-object-toe": "voeg object toe", + "modules/content-page/components/blocks/block-double-banner/block-double-banner___achtergrondkleur-tekstvak": "Achtergrondkleur tekstvak", + "modules/content-page/components/blocks/block-double-banner/block-double-banner___achtergrondkleur-tekstvak-is-verplicht": "Achtergrondkleur tekstvak is verplicht", + "modules/content-page/components/blocks/block-double-banner/block-double-banner___afbeelding": "Afbeelding", + "modules/content-page/components/blocks/block-double-banner/block-double-banner___afbeelding-is-verplicht": "Afbeelding is verplicht", + "modules/content-page/components/blocks/block-double-banner/block-double-banner___bestemming": "Bestemming", + "modules/content-page/components/blocks/block-double-banner/block-double-banner___bestemming-is-verplicht": "Bestemming is verplicht", + "modules/content-page/components/blocks/block-double-banner/block-double-banner___dubbele-banner": "Dubbele banner", + "modules/content-page/components/blocks/block-double-banner/block-double-banner___helft": "Helft", + "modules/content-page/components/blocks/block-double-banner/block-double-banner___icoon-1": "Icoon 1", + "modules/content-page/components/blocks/block-double-banner/block-double-banner___icoon-2": "Icoon 2", + "modules/content-page/components/blocks/block-double-banner/block-double-banner___icoon-3": "Icoon 3", + "modules/content-page/components/blocks/block-double-banner/block-double-banner___label": "Label", + "modules/content-page/components/blocks/block-double-banner/block-double-banner___label-is-verplicht": "Label is verplicht", + "modules/content-page/components/blocks/block-double-banner/block-double-banner___tekstkleur": "Tekstkleur", + "modules/content-page/components/blocks/block-double-banner/block-double-banner___tekstkleur-is-verplicht": "Tekstkleur is verplicht", "modules/content-page/components/blocks/block-het-archief-header-search/block-het-archief-header-search___aria-label-verplicht": "aria label verplicht", "modules/content-page/components/blocks/block-het-archief-header-search/block-het-archief-header-search___aria-label-voor-zoekveld": "Aria label voor zoekveld", "modules/content-page/components/blocks/block-het-archief-header-search/block-het-archief-header-search___zoek-in-de-publieke-catalogus-input-aria-label": "Hiermee kan je zoeken in de publieke catalogus ", @@ -804,6 +819,7 @@ "modules/content-page/const/content-page___vertalingen": "Vertalingen", "modules/content-page/const/get-content-block-type-options___breadcrumbs": "Breadcrumbs", "modules/content-page/const/get-content-block-type-options___content-enclose-grid": "Content insluiten grid", + "modules/content-page/const/get-content-block-type-options___dubbele-banner": "Dubbele banner", "modules/content-page/const/get-content-block-type-options___highlight-text": "Highlight text", "modules/content-page/const/get-content-block-type-options___homepage-banner": "Homepage banner", "modules/content-page/const/get-content-block-type-options___overzicht-krantentitels": "Overzicht krantentitels",