From 1d846008e610bacc244afa03fbf4d5a5d2915b74 Mon Sep 17 00:00:00 2001 From: Robbe Bierebeeck Date: Wed, 12 Aug 2026 23:39:07 +0200 Subject: [PATCH 1/8] 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/8] 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", From bbc401b61448370dc11da51ead9e084385763df6 Mon Sep 17 00:00:00 2001 From: Robbe Bierebeeck Date: Fri, 14 Aug 2026 15:58:37 +0200 Subject: [PATCH 3/8] fix(ARC-3833): address double banner review feedback --- .../BlockDoubleBanner.editorconfig.test.ts | 7 +-- .../BlockDoubleBanner.editorconfig.ts | 13 ++--- .../BlockDoubleBanner/BlockDoubleBanner.scss | 13 +++-- .../BlockDoubleBanner.test.tsx | 42 ++++++++++------ .../BlockDoubleBanner/BlockDoubleBanner.tsx | 50 +++++++------------ .../BlockDoubleBanner.types.ts | 4 +- .../shared/components/Icon/Icon.test.tsx | 43 ---------------- .../modules/shared/components/Icon/Icon.tsx | 12 ++--- 8 files changed, 64 insertions(+), 120 deletions(-) delete mode 100644 ui/src/react-admin/modules/shared/components/Icon/Icon.test.tsx 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 index a85538ae..a07e9511 100644 --- 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 @@ -71,7 +71,7 @@ describe('DOUBLE_BANNER_BLOCK_CONFIG', () => { }); }); - it('defaults to white text on a black flat-color background', () => { + it('defaults to white text on a black background', () => { const state = INITIAL_DOUBLE_BANNER_COMPONENTS_STATE(); expect(state.halves[0]).toMatchObject({ @@ -80,10 +80,5 @@ describe('DOUBLE_BANNER_BLOCK_CONFIG', () => { }); 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 82e16476..73ee2430 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,9 +48,6 @@ 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( @@ -164,9 +161,7 @@ export const DOUBLE_BANNER_BLOCK_CONFIG = (position = 0): ContentBlockConfig => editorType: ContentBlockEditor.ColorSelect, editorProps: { options: GET_FOREGROUND_COLOR_OPTIONS_ARCHIEF(), - defaultValue: GET_FOREGROUND_COLOR_OPTIONS_ARCHIEF().find( - (option) => option.value === Color.White - ), + defaultValue: GET_FOREGROUND_COLOR_OPTIONS_ARCHIEF()[1], }, validator: (value: string) => validateRequiredValue( @@ -186,10 +181,8 @@ export const DOUBLE_BANNER_BLOCK_CONFIG = (position = 0): ContentBlockConfig => ), editorType: ContentBlockEditor.ColorSelect, editorProps: { - options: GET_DOUBLE_BANNER_BACKGROUND_COLOR_OPTIONS(), - defaultValue: GET_DOUBLE_BANNER_BACKGROUND_COLOR_OPTIONS().find( - (option) => option.value === Color.Black - ), + options: GET_BACKGROUND_COLOR_OPTIONS_ARCHIEF(), + defaultValue: GET_BACKGROUND_COLOR_OPTIONS_ARCHIEF()[5], }, validator: (value: string) => validateRequiredValue( 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 142a0c16..c2f9f24b 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 @@ -5,6 +5,7 @@ $panel-padding: variables.$g-spacer-unit * 2; $half-gap: variables.$g-spacer-unit * 2; +$pill-radius: 999px; .c-block-double-banner { display: grid; @@ -37,7 +38,7 @@ $half-gap: variables.$g-spacer-unit * 2; // Mirror image: the image moves to the other side and its rounded end flips with it. &--mirrored { .c-block-double-banner__image { - border-radius: 999px 0 0 999px; + border-radius: $pill-radius 0 0 $pill-radius; } @media (min-width: variables.$g-bp3) { @@ -66,9 +67,7 @@ $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.8rem, 800, 2.8rem); + @include typography.sofia-pro-heading-md; } // Mobile keeps only the arrow at the far edge; desktop groups the selected icons and arrow at @@ -111,9 +110,9 @@ $half-gap: variables.$g-spacer-unit * 2; height: 9.6rem; overflow: hidden; - // 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; + // Percentage radii would create an ellipse on this rectangular image. CSS clamps this + // oversized radius to a circular pill cap, regardless of the rendered image height. + border-radius: 0 $pill-radius $pill-radius 0; @media (min-width: variables.$g-bp3) { box-sizing: border-box; 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 index 95f1b459..74375ab1 100644 --- 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 @@ -1,7 +1,8 @@ import { render, screen } from '@testing-library/react'; import type { ReactNode } from 'react'; import React from 'react'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { AdminConfigManager } from '~core/config/config.class'; import { BlockDoubleBanner } from './BlockDoubleBanner'; vi.mock('@viaa/avo2-components', () => ({ @@ -16,15 +17,12 @@ vi.mock('~shared/components/Icon/Icon', () => ({ })); vi.mock('~shared/components/SmartLink/SmartLink', () => ({ - SmartLink: ({ - action, - children, - className, - }: { - action: { target: string; value: string }; - children: ReactNode; - className: string; - }) => ( + generateSmartLink: ( + action: { target: string; value: string }, + children: ReactNode, + _title?: string, + className?: string + ) => ( {children} @@ -32,6 +30,19 @@ vi.mock('~shared/components/SmartLink/SmartLink', () => ({ })); describe('BlockDoubleBanner', () => { + beforeEach(() => { + AdminConfigManager.setConfig({ + icon: { + component: ({ name, className }: { name: string; className?: string }) => ( + + ), + componentProps: { + newspaper: { name: 'newspaper--light' }, + }, + }, + } as never); + }); + it('wraps each complete half in a same-tab link and keeps its media decorative', () => { render( { { label: 'Newspapers', icon1: 'newspaper', - icon2: 'newspaper', + icon2: 'video--light', link: { value: '/newspapers' } as never, image: '/newspapers.jpg', textColor: '#FFF', @@ -65,9 +76,12 @@ describe('BlockDoubleBanner', () => { 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'); - } + expect(link.querySelector('.c-block-double-banner__actions')).toHaveAttribute( + 'aria-hidden', + 'true' + ); } + expect(links[0].querySelector('[data-icon="newspaper--light"]')).toBeInTheDocument(); + expect(links[0].querySelector('[data-icon="video--light"]')).toBeInTheDocument(); }); }); 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 93e0434a..744ebbbc 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 @@ -1,4 +1,4 @@ -import { type IconName, Image, LinkTarget } from '@viaa/avo2-components'; +import { Image, LinkTarget } from '@viaa/avo2-components'; import clsx from 'clsx'; import { compact } from 'es-toolkit'; import type { CSSProperties, FunctionComponent, ReactElement } from 'react'; @@ -7,8 +7,9 @@ import type { BlockDoubleBannerProps, DoubleBannerHalf, } from '~content-blocks/BlockDoubleBanner/BlockDoubleBanner.types'; +import { AdminConfigManager } from '~core/config/config.class'; import { Icon } from '~shared/components/Icon/Icon'; -import { SmartLink } from '~shared/components/SmartLink/SmartLink'; +import { generateSmartLink } from '~shared/components/SmartLink/SmartLink'; import './BlockDoubleBanner.scss'; /** @@ -20,6 +21,12 @@ export const BlockDoubleBanner: FunctionComponent = ({ className, halves, }): ReactElement => { + const iconConfig = AdminConfigManager.getConfig().icon; + const IconComponent = iconConfig?.component ?? (() => null); + const resolveIconName = (icon: string): string => + (iconConfig?.componentProps as Record | undefined)?.[icon]?.name ?? + icon; + const renderHalf = (half: DoubleBannerHalf, index: number) => { const icons = compact([half.icon1, half.icon2, half.icon3]); @@ -35,22 +42,17 @@ export const BlockDoubleBanner: FunctionComponent = ({ } > {half.label} - + {icons.map((icon, iconIndex) => ( - ))} - +
@@ -62,32 +64,18 @@ export const BlockDoubleBanner: FunctionComponent = ({ return (
- {(halves || []).map((half: DoubleBannerHalf, index: number) => { + {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} - + return generateSmartLink( + { ...half.link, target: LinkTarget.Self }, + content, + undefined, + halfClassName ); })}
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 index 0900ce97..459d3669 100644 --- 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 @@ -11,12 +11,12 @@ export interface DoubleBannerHalf { icon1?: string; icon2?: string; icon3?: string; - link?: ButtonAction; + link: ButtonAction; image: string; textColor: string; backgroundColor: string; } export interface BlockDoubleBannerProps extends DefaultComponentProps { - halves: DoubleBannerHalf[]; + halves: [DoubleBannerHalf, DoubleBannerHalf]; } 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 deleted file mode 100644 index ef8129eb..00000000 --- a/ui/src/react-admin/modules/shared/components/Icon/Icon.test.tsx +++ /dev/null @@ -1,43 +0,0 @@ -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 b27c31c4..de91e5c4 100644 --- a/ui/src/react-admin/modules/shared/components/Icon/Icon.tsx +++ b/ui/src/react-admin/modules/shared/components/Icon/Icon.tsx @@ -9,10 +9,9 @@ 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, ...accessibilityProps }) => { +export const Icon: FC = ({ name, className }) => { const iconConfig = AdminConfigManager.getConfig().icon; // biome-ignore lint/suspicious/noExplicitAny: todo let iconProps = (iconConfig?.componentProps as any)?.[name] as { @@ -26,13 +25,12 @@ export const Icon: FC = ({ name, className, ...accessibilityProps }) 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 ; }; From 57571a54c472a6df4d89da60f77f48a36a19c628 Mon Sep 17 00:00:00 2001 From: Robbe Bierebeeck Date: Fri, 14 Aug 2026 16:26:56 +0200 Subject: [PATCH 4/8] fix(ARC-3833): use enum values for color defaults --- .../BlockDoubleBanner.editorconfig.test.ts | 4 +- .../BlockDoubleBanner.editorconfig.ts | 4 +- .../helpers/field-attributes.test.ts | 60 +++++++++++++++++++ .../content-page/helpers/field-attributes.ts | 22 ++++++- 4 files changed, 83 insertions(+), 7 deletions(-) create mode 100644 ui/src/react-admin/modules/content-page/helpers/field-attributes.test.ts 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 index a07e9511..f26a717b 100644 --- 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 @@ -78,7 +78,7 @@ describe('DOUBLE_BANNER_BLOCK_CONFIG', () => { 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('textColor').editorProps.defaultValue).toBe(Color.White); + expect(field('backgroundColor').editorProps.defaultValue).toBe(Color.Black); }); }); 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 73ee2430..cc108ffa 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 @@ -161,7 +161,7 @@ export const DOUBLE_BANNER_BLOCK_CONFIG = (position = 0): ContentBlockConfig => editorType: ContentBlockEditor.ColorSelect, editorProps: { options: GET_FOREGROUND_COLOR_OPTIONS_ARCHIEF(), - defaultValue: GET_FOREGROUND_COLOR_OPTIONS_ARCHIEF()[1], + defaultValue: Color.White, }, validator: (value: string) => validateRequiredValue( @@ -182,7 +182,7 @@ 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()[5], + defaultValue: Color.Black, }, validator: (value: string) => validateRequiredValue( diff --git a/ui/src/react-admin/modules/content-page/helpers/field-attributes.test.ts b/ui/src/react-admin/modules/content-page/helpers/field-attributes.test.ts new file mode 100644 index 00000000..d03bddcb --- /dev/null +++ b/ui/src/react-admin/modules/content-page/helpers/field-attributes.test.ts @@ -0,0 +1,60 @@ +import type { SelectOption } from '@viaa/avo2-components'; +import { describe, expect, it, vi } from 'vitest'; +import { Color, ContentBlockEditor } from '~modules/content-page/types/content-block.types'; +import { generateFieldAttributes } from './field-attributes'; + +interface ColorSelectAttributes { + defaultValue?: SelectOption; + onChange?: (option: SelectOption) => void; +} + +describe('generateFieldAttributes', () => { + it('maps a Color enum default to the option expected by ColorSelect', () => { + const whiteOption: SelectOption = { + label: 'White', + value: Color.White, + }; + const onChange = vi.fn(); + const attributes = generateFieldAttributes( + { + editorType: ContentBlockEditor.ColorSelect, + editorProps: { + options: [whiteOption], + defaultValue: Color.White, + }, + }, + onChange, + undefined, + 'color', + 'color', + {} + ) as ColorSelectAttributes; + + expect(attributes.defaultValue).toBe(whiteOption); + attributes.onChange?.(whiteOption); + expect(onChange).toHaveBeenCalledWith(Color.White); + }); + + it('keeps existing option-object defaults compatible', () => { + const blackOption: SelectOption = { + label: 'Black', + value: Color.Black, + }; + const attributes = generateFieldAttributes( + { + editorType: ContentBlockEditor.ColorSelect, + editorProps: { + options: [blackOption], + defaultValue: blackOption, + }, + }, + vi.fn(), + undefined, + 'color', + 'color', + {} + ) as ColorSelectAttributes; + + expect(attributes.defaultValue).toBe(blackOption); + }); +}); diff --git a/ui/src/react-admin/modules/content-page/helpers/field-attributes.ts b/ui/src/react-admin/modules/content-page/helpers/field-attributes.ts index bd688818..76a51df7 100644 --- a/ui/src/react-admin/modules/content-page/helpers/field-attributes.ts +++ b/ui/src/react-admin/modules/content-page/helpers/field-attributes.ts @@ -91,15 +91,31 @@ export const generateFieldAttributes = ( } as DatePickerProps; case ContentBlockEditor.IconPicker: - case ContentBlockEditor.ColorSelect: - return { + case ContentBlockEditor.ColorSelect: { + const options = field.editorProps.options as SelectOption[]; + const selectAttributes = { onChange: ((option: SelectOption) => { onChange(option?.value || ''); // biome-ignore lint/suspicious/noExplicitAny: todo investigate why this cast is needed }) as any, - value: field.editorProps.options.find((opt: SelectOption) => opt.value === value), + value: options.find((option) => option.value === value), }; + if (field.editorType === ContentBlockEditor.ColorSelect) { + const configuredDefaultValue = field.editorProps.defaultValue; + + return { + ...selectAttributes, + defaultValue: + typeof configuredDefaultValue === 'string' + ? options.find((option) => option.value === configuredDefaultValue) + : configuredDefaultValue, + }; + } + + return selectAttributes; + } + case ContentBlockEditor.RICH_TEXT_EDITOR: { // biome-ignore lint/suspicious/noExplicitAny: todo const html = (state as any)[key] || ''; From e816dabb1f38f07cdb8c36c9787d0de6c2bb009b Mon Sep 17 00:00:00 2001 From: Robbe Bierebeeck Date: Mon, 17 Aug 2026 17:18:17 +0200 Subject: [PATCH 5/8] fix(ARC-3833): keep double banner half wrapper without a destination --- .../BlockDoubleBanner/BlockDoubleBanner.tsx | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) 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 744ebbbc..87f1cdb4 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 @@ -71,11 +71,28 @@ export const BlockDoubleBanner: FunctionComponent = ({ 'c-block-double-banner__half--mirrored': index % 2 === 1, }); - return generateSmartLink( - { ...half.link, target: LinkTarget.Self }, - content, - undefined, - halfClassName + // generateSmartLink returns a bare fragment for an empty action, which drops the + // halfClassName and with it the whole layout. Keep the wrapper in that case, so the + // editor sees the real layout before the destination is filled in. + if (!half.link?.value) { + return ( + // biome-ignore lint/suspicious/noArrayIndexKey: the halves have no id of their own +
+ {content} +
+ ); + } + + return ( + // biome-ignore lint/suspicious/noArrayIndexKey: the halves have no id of their own + + {generateSmartLink( + { ...half.link, target: LinkTarget.Self }, + content, + undefined, + halfClassName + )} + ); })}
From 04b9ea7a593344dd8d863d55cf45c1aaa9b60932 Mon Sep 17 00:00:00 2001 From: Robbe Bierebeeck Date: Tue, 18 Aug 2026 13:55:06 +0200 Subject: [PATCH 6/8] refactor(ARC-3833): use the shared focus-outline mixin --- .../blocks/BlockDoubleBanner/BlockDoubleBanner.scss | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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 c2f9f24b..21afcc77 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 @@ -22,15 +22,13 @@ $pill-radius: 999px; overflow: hidden; text-decoration: none; + @include focus.focus-outline; + @media (min-width: variables.$g-bp3) { flex-direction: row; min-height: 16rem; } - &:focus-visible { - @include focus.focus; - } - &:hover .c-block-double-banner__image img { @include animations.zoom-in-animation; } From b2a95ca141370df3b290a2c369176382286ec488 Mon Sep 17 00:00:00 2001 From: Robbe Bierebeeck Date: Tue, 18 Aug 2026 13:55:37 +0200 Subject: [PATCH 7/8] refactor(ARC-3833): render the double banner icons with the shared Icon The shared Icon now falls back to the raw icon name that the editor stores, so the block no longer needs its own config lookup. --- .../BlockDoubleBanner.icons.test.tsx | 43 +++++++++++++++++++ .../BlockDoubleBanner.test.tsx | 19 ++------ .../BlockDoubleBanner/BlockDoubleBanner.tsx | 13 ++---- 3 files changed, 49 insertions(+), 26 deletions(-) create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.icons.test.tsx diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.icons.test.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.icons.test.tsx new file mode 100644 index 00000000..eba8fde5 --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.icons.test.tsx @@ -0,0 +1,43 @@ +import { render, screen } from '@testing-library/react'; +import { AvoCoreDatabaseType } from '@viaa/avo2-types'; +import type { ReactNode } from 'react'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { AdminConfigManager } from '~core/config/config.class'; +import { BlockDoubleBanner } from './BlockDoubleBanner'; + +// The real Icon is used on purpose here: the editor stores icons by their raw name +// (eg: newspaper--light), not by their admin-core config key (eg: newspaper). +vi.mock('~shared/components/SmartLink/SmartLink', () => ({ + generateSmartLink: (_action: unknown, children: ReactNode) =>
{children}
, +})); + +const half = (label: string, icon?: string) => ({ + label, + icon1: icon, + link: { type: 'EXTERNAL_LINK', value: `/${label}` }, + image: `/${label}.jpg`, + textColor: '#FFFFFF', + backgroundColor: '#000000', +}); + +describe('BlockDoubleBanner icons', () => { + it('renders an icon that the editor picked by its raw name', () => { + AdminConfigManager.setConfig({ + icon: { + component: ({ name, className }: { name: string; className?: string }) => ( + + ), + componentProps: { arrowDownRight: { name: 'arrow-down-right--light' } }, + list: () => [{ label: 'Newspaper light', value: 'newspaper--light' }], + }, + env: { DATABASE_APPLICATION_TYPE: AvoCoreDatabaseType.hetArchief }, + } as never); + + render(); + + expect(screen.getByTestId('icon-newspaper--light')).toBeInTheDocument(); + // Both halves keep their CTA arrow, which resolves through the config key. + expect(screen.getAllByTestId('icon-arrow-down-right--light')).toHaveLength(2); + }); +}); 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 index 74375ab1..88ecc942 100644 --- 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 @@ -1,8 +1,7 @@ import { render, screen } from '@testing-library/react'; import type { ReactNode } from 'react'; import React from 'react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { AdminConfigManager } from '~core/config/config.class'; +import { describe, expect, it, vi } from 'vitest'; import { BlockDoubleBanner } from './BlockDoubleBanner'; vi.mock('@viaa/avo2-components', () => ({ @@ -30,26 +29,13 @@ vi.mock('~shared/components/SmartLink/SmartLink', () => ({ })); describe('BlockDoubleBanner', () => { - beforeEach(() => { - AdminConfigManager.setConfig({ - icon: { - component: ({ name, className }: { name: string; className?: string }) => ( - - ), - componentProps: { - newspaper: { name: 'newspaper--light' }, - }, - }, - } as never); - }); - it('wraps each complete half in a same-tab link and keeps its media decorative', () => { render( { 'true' ); } + // The shared Icon resolves the config name itself, so the block passes the picked name on. expect(links[0].querySelector('[data-icon="newspaper--light"]')).toBeInTheDocument(); expect(links[0].querySelector('[data-icon="video--light"]')).toBeInTheDocument(); }); 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 87f1cdb4..8cd80947 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 @@ -1,4 +1,4 @@ -import { Image, LinkTarget } from '@viaa/avo2-components'; +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'; @@ -7,7 +7,6 @@ import type { BlockDoubleBannerProps, DoubleBannerHalf, } from '~content-blocks/BlockDoubleBanner/BlockDoubleBanner.types'; -import { AdminConfigManager } from '~core/config/config.class'; import { Icon } from '~shared/components/Icon/Icon'; import { generateSmartLink } from '~shared/components/SmartLink/SmartLink'; import './BlockDoubleBanner.scss'; @@ -21,12 +20,6 @@ export const BlockDoubleBanner: FunctionComponent = ({ className, halves, }): ReactElement => { - const iconConfig = AdminConfigManager.getConfig().icon; - const IconComponent = iconConfig?.component ?? (() => null); - const resolveIconName = (icon: string): string => - (iconConfig?.componentProps as Record | undefined)?.[icon]?.name ?? - icon; - const renderHalf = (half: DoubleBannerHalf, index: number) => { const icons = compact([half.icon1, half.icon2, half.icon3]); @@ -45,10 +38,10 @@ export const BlockDoubleBanner: FunctionComponent = ({ {icons.map((icon, iconIndex) => ( - ))} From ed48863f62c29446c44bd21486ab8d5236b3f657 Mon Sep 17 00:00:00 2001 From: Robbe Bierebeeck Date: Tue, 18 Aug 2026 13:55:50 +0200 Subject: [PATCH 8/8] fix(ARC-3833): skip the banner image until one is uploaded An empty src makes the browser fetch the page again. --- .../components/blocks/BlockDoubleBanner/BlockDoubleBanner.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 8cd80947..6ca3e0ec 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 @@ -49,7 +49,9 @@ export const BlockDoubleBanner: FunctionComponent = ({
- + {/* Before an image is uploaded the value is empty, and an empty src makes the + browser refetch the page. */} + {half.image ? : null}
);