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/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.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 new file mode 100644 index 00000000..82e16476 --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.editorconfig.ts @@ -0,0 +1,214 @@ +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(), + }, +}); + +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( + '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] + ), + editorProps: { + assetType: 'CONTENT_BLOCK_IMAGE', + allowMulti: false, + }, + } + ), + 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 + ), + }, + 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( + 'modules/content-page/components/blocks/block-double-banner/block-double-banner___achtergrondkleur-tekstvak', + {}, + [HET_ARCHIEF] + ), + editorType: ContentBlockEditor.ColorSelect, + editorProps: { + 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] + ) + ), + }, + }, + }, + }, + }, + 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..142a0c16 --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.scss @@ -0,0 +1,139 @@ +@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; + flex-direction: column-reverse; + overflow: hidden; + text-decoration: none; + + @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; + } + + // 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; + } + + @media (min-width: variables.$g-bp3) { + flex-direction: row-reverse; + } + } + } + + &__panel { + box-sizing: border-box; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: variables.$g-spacer-unit; + padding: $panel-padding $panel-padding * 1.5; + + @media (min-width: variables.$g-bp3) { + flex: 0 0 50%; + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: stretch; + min-width: 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.8rem, 800, 2.8rem); + } + + // 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 * 0.5; + justify-self: end; + + @media (min-width: variables.$g-bp3) { + justify-self: auto; + } + } + + &__icons { + display: none; + align-items: center; + gap: variables.$g-spacer-unit * 0.5; + + @media (min-width: variables.$g-bp3) { + display: flex; + } + } + + &__icon, + &__arrow { + font-size: 2.4rem; + line-height: 1; + + @media (min-width: variables.$g-bp3) { + font-size: 3.2rem; + } + } + + &__image { + position: relative; + flex: 0 0 auto; + width: 100%; + 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; + + @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 new file mode 100644 index 00000000..93e0434a --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.tsx @@ -0,0 +1,95 @@ +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, iconIndex) => ( + + ))} + + + +
+
+ +
+ + ); + }; + + 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/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/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) => { 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",