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..f26a717b --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.editorconfig.test.ts @@ -0,0 +1,84 @@ +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 background', () => { + const state = INITIAL_DOUBLE_BANNER_COMPONENTS_STATE(); + + expect(state.halves[0]).toMatchObject({ + textColor: Color.White, + backgroundColor: 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 new file mode 100644 index 00000000..cc108ffa --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.editorconfig.ts @@ -0,0 +1,207 @@ +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] + ), + 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: 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_BACKGROUND_COLOR_OPTIONS_ARCHIEF(), + defaultValue: 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..c2f9f24b --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.scss @@ -0,0 +1,138 @@ +@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; +$pill-radius: 999px; + +.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: $pill-radius 0 0 $pill-radius; + } + + @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 { + @include typography.sofia-pro-heading-md; + } + + // 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; + + // 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; + 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..74375ab1 --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.test.tsx @@ -0,0 +1,87 @@ +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 { 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', () => ({ + generateSmartLink: ( + action: { target: string; value: string }, + children: ReactNode, + _title?: string, + className?: string + ) => ( + + {children} + + ), +})); + +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( + + ); + + 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', ''); + 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 new file mode 100644 index 00000000..744ebbbc --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockDoubleBanner/BlockDoubleBanner.tsx @@ -0,0 +1,83 @@ +import { 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 { AdminConfigManager } from '~core/config/config.class'; +import { Icon } from '~shared/components/Icon/Icon'; +import { generateSmartLink } 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 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]); + + 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, + }); + + 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 new file mode 100644 index 00000000..459d3669 --- /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, 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/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] || ''; 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) => { 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",