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 d62f1ea1a..82a2eb426 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 @@ -10,6 +10,7 @@ import { BlockEventbrite } from '~content-blocks/BlockEventbrite'; import { BlockHeading } from '~content-blocks/BlockHeading'; import { BlockHetArchiefHeaderSearch } from '~content-blocks/BlockHetArchiefHeaderSearch/BlockHetArchiefHeaderSearch'; import { BlockHetArchiefImageTextBackground } from '~content-blocks/BlockHetArchiefImageTextBackground'; +import { BlockHighlightText } from '~content-blocks/BlockHighlightText'; import { BlockHomepageBanner } from '~content-blocks/BlockHomepageBanner'; import { BlockIFrame } from '~content-blocks/BlockIFrame'; import { BlockImage } from '~content-blocks/BlockImage'; @@ -89,6 +90,7 @@ export function GET_BLOCK_COMPONENT( [ContentBlockType.HetArchiefImageTextBackground]: BlockHetArchiefImageTextBackground, [ContentBlockType.OverviewWithCarousel]: BlockOverviewWithCarousel, [ContentBlockType.HomepageBanner]: BlockHomepageBanner, + [ContentBlockType.HighlightText]: BlockHighlightText, // Avo specific blocks [ContentBlockType.MediaGrid]: loadComponentFromConfig(ContentBlockType.MediaGrid), diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockHighlightText/BlockHighlightText.editorconfig.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockHighlightText/BlockHighlightText.editorconfig.ts new file mode 100644 index 000000000..d2fea1431 --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockHighlightText/BlockHighlightText.editorconfig.ts @@ -0,0 +1,57 @@ +import { BLOCK_FIELD_DEFAULTS, BLOCK_STATE_DEFAULTS, TEXT_FIELD } from '~content-blocks/defaults'; +import { GET_BACKGROUND_COLOR_OPTIONS_ARCHIEF } from '~modules/content-page/const/get-color-options.ts'; +import { + Color, + type ContentBlockConfig, + ContentBlockEditor, + ContentBlockType, + type DefaultContentBlockState, +} from '~modules/content-page/types/content-block.types'; +import { RICH_TEXT_EDITOR_OPTIONS_FULL } from '~shared/consts/rich-text-editor.consts.ts'; +import { tText } from '~shared/helpers/translation-functions'; +import { validateRequiredValue } from '~shared/helpers/validation.ts'; +import { HET_ARCHIEF } from '~shared/types'; + +const INITIAL_CONTENT_HIGHLIGHT_TEXT_COMPONENTS_STATE = () => ({ + content: '', + backgroundColor: Color.Transparent, + highlightColor: Color.OceanGreen, +}); + +export const INITIAL_CONTENT_HIGHLIGHT_TEXT_BLOCK_STATE = (): DefaultContentBlockState => ({ + ...BLOCK_STATE_DEFAULTS(), + fullWidth: true, +}); + +export const CONTENT_HIGHLIGHT_TEXT_CONFIG = (position = 0): ContentBlockConfig => ({ + position, + name: tText('Highlight text'), + type: ContentBlockType.HighlightText, + components: { + state: INITIAL_CONTENT_HIGHLIGHT_TEXT_COMPONENTS_STATE(), + fields: { + content: TEXT_FIELD({ + editorType: ContentBlockEditor.RICH_TEXT_EDITOR, + editorProps: { + controls: RICH_TEXT_EDITOR_OPTIONS_FULL, + }, + validator: (value: string) => + validateRequiredValue(value, tText('Tekst is verplicht', undefined, [HET_ARCHIEF])), + }), + highlightColor: { + label: tText('Highlightkleur', undefined, [HET_ARCHIEF]), + editorType: ContentBlockEditor.ColorSelect, + editorProps: { + options: GET_BACKGROUND_COLOR_OPTIONS_ARCHIEF(), + defaultValue: GET_BACKGROUND_COLOR_OPTIONS_ARCHIEF()[2], + }, + }, + }, + }, + block: { + state: INITIAL_CONTENT_HIGHLIGHT_TEXT_BLOCK_STATE(), + fields: { + ...BLOCK_FIELD_DEFAULTS(), + }, + }, +}); diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockHighlightText/BlockHighlightText.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockHighlightText/BlockHighlightText.tsx new file mode 100644 index 000000000..13bfbb27f --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockHighlightText/BlockHighlightText.tsx @@ -0,0 +1,57 @@ +import React, { type CSSProperties, type FunctionComponent, type ReactElement } from 'react'; +import type { DefaultComponentProps } from '~modules/shared/types/components'; +import './BlockHighligtText.scss'; +import { Container } from '@viaa/avo2-components'; +import clsx from 'clsx'; +import { ContentPageWidth } from '~modules/content-page/types/content-pages.types.ts'; +import Html from '~shared/components/Html/Html.tsx'; + +export interface BlockHighlightTextProps extends DefaultComponentProps { + content: string; + highlightColor: string; + backgroundColor: string; + pageWidth?: string; +} + +export const BlockHighlightText: FunctionComponent = ({ + content, + highlightColor, + backgroundColor, + pageWidth, +}): ReactElement => { + return ( +
+ +
+ + +
+ + +
+ ); +}; diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockHighlightText/BlockHighligtText.scss b/ui/src/react-admin/modules/content-page/components/blocks/BlockHighlightText/BlockHighligtText.scss new file mode 100644 index 000000000..3ce927be8 --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockHighlightText/BlockHighligtText.scss @@ -0,0 +1,120 @@ +@use "../../../../shared/styles/settings/variables" as variables; +@use "../../../../shared/styles/mixins/pattern" as pattern; +@use "../../../../shared/styles/mixins/typography" as typography; + +$pattern-height: 12rem; +$pattern-height-mobile: 8rem; +$pattern-repeat-count: 20; +// How far the mobile pattern slots overshoot &__content's edge - see &__pattern-slot. +$pattern-mobile-overshoot: 15rem; + +.c-block-highlight-text { + // clip-path (not overflow-x: hidden) avoids the overflow-x/y coupling rule that would + // otherwise turn overflow-y into "auto" and risk a vertical scrollbar. + clip-path: inset(0); + width: 100% !important; + max-width: none !important; + + @media (max-width: variables.$g-bp4) { + padding: 0 calc(#{variables.$g-spacer-unit} * 2.5) !important; + } + + // Reservation box only - carries no background of its own, so the space it reserves for + // the patterns (mobile only, see &-text and &__pattern-slot) reads as "outside" &-text, + // which is what the patterns need to touch. + &__content { + position: relative; + width: 100%; + display: flex; + flex-direction: column; + padding: 0; + + @media (max-width: variables.$g-bp4) { + // Sized to match &__pattern-slot's height exactly so the patterns touch &-text with + // no gap - without this, .c-block-highlight-text's clip-path would cut the slots away. + padding: $pattern-height-mobile 0; + } + + // The actual highlighted box - its own padding gives the text breathing room from the + // box's edges, independent of &__content's (pattern-reservation-only) padding above. + &-text { + position: relative; + min-height: $pattern-height; + background-color: var(--pattern-color, transparent); + padding: variables.$g-spacer-unit * 6; + @include typography.sofia-pro-body(); + + @media (max-width: variables.$g-bp4) { + padding: calc(#{variables.$g-spacer-unit} * 3) calc(#{variables.$g-spacer-unit} * 2.5) !important; + } + } + } + + // The strip is always the same 5-shape cycle - rect, circle, circle, rect, circle - just + // entered at a different point per side (see pattern.pattern-side): + // - left: enters at shape 5, nearest text -> circle, rect, circle, circle, rect + // - right: enters at shape 2, nearest text -> circle, circle, rect, circle, rect + &__pattern { + @include pattern.pattern-strip($pattern-height, $pattern-repeat-count); + height: $pattern-height; + + // Aligned to &-text's top edge, not stretched to &__content's full (padded) height. + &--left { + top: 0; + right: 100%; + background-position-x: right; + @include pattern.pattern-side(left, 5, $pattern-height); + } + + // Aligned to &-text's bottom edge, not stretched from &__content's full (padded) height. + &--right { + bottom: 0; + left: 100%; + background-position-x: left; + @include pattern.pattern-side(right, 2, $pattern-height); + } + + // Below $g-bp4 the pattern moves from beside the text to above/below it (see &__pattern-slot). + @media (max-width: variables.$g-bp4) { + @include pattern.pattern-strip($pattern-height-mobile, $pattern-repeat-count); + height: $pattern-height-mobile; + + &--left { + right: 0; + left: auto; + @include pattern.pattern-side(left, 5, $pattern-height-mobile); + } + + &--right { + left: 0; + right: auto; + @include pattern.pattern-side(right, 2, $pattern-height-mobile); + } + } + } + + // No-op on desktop. Below $g-bp4 it's the positioned, overflow-clipping box that caps + // &__pattern at 40% (top) / 30% (bottom) of &__content's width - the cap can't live on + // &__pattern itself without breaking the Chrome tiling-bug workaround above. + &__pattern-slot { + @media (max-width: variables.$g-bp4) { + position: absolute; + overflow: hidden; + height: $pattern-height-mobile; + + // left/right overshoot to reach past avo2 Container's gutter to the true screen + // edge; width compensates so the inner (anchor) edge stays exactly at 40% / 30%. + &--top { + top: 0; + left: -$pattern-mobile-overshoot; + width: calc(40% + #{$pattern-mobile-overshoot}); + } + + &--bottom { + bottom: 0; + right: -$pattern-mobile-overshoot; + width: calc(30% + #{$pattern-mobile-overshoot}); + } + } + } +} diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockHighlightText/index.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockHighlightText/index.ts new file mode 100644 index 000000000..e05950cea --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockHighlightText/index.ts @@ -0,0 +1,2 @@ +export * from './BlockHighlightText.editorconfig.ts'; +export { BlockHighlightText } from './BlockHighlightText.tsx'; diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockHomepageBanner/BlockHomepageBanner.scss b/ui/src/react-admin/modules/content-page/components/blocks/BlockHomepageBanner/BlockHomepageBanner.scss index ab1de8900..32e4062db 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockHomepageBanner/BlockHomepageBanner.scss +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockHomepageBanner/BlockHomepageBanner.scss @@ -1,58 +1,19 @@ -@use "sass:math"; -@use "sass:list"; @use "../../../../shared/styles/settings/variables" as variables; @use "../../../../shared/styles/settings/colors" as colors; +@use "../../../../shared/styles/mixins/pattern" as pattern; +@use "../../../../shared/styles/mixins/typography" as typography; -// Shared shape sizing for the decorative pattern strips (used by --left and --right). $pattern-height: 8rem; $pattern-distance-vertical: variables.$g-spacer-unit * 3; $pattern-distance-horizontal: variables.$g-spacer-unit * 5; - -$pattern-rect-width: 4rem; -$pattern-circle-diameter: $pattern-height; -$pattern-circle-radius: math.div($pattern-circle-diameter, 2); -$pattern-circle-antialias-edge: $pattern-circle-radius + 0.02rem; -$pattern-cycle-width: $pattern-rect-width * 2 + $pattern-circle-diameter * 3; -// Must stay a multiple of $pattern-cycle-width (see &__pattern) - generous safety margin -// so the strip always reaches this component's own edge on any realistic viewport. +// Generous enough that the strip always reaches this component's own edge on any viewport. $pattern-repeat-count: 20; -// How far the mobile pattern slots overshoot &__content's edge, so they can still bleed -// past avo2 Container's (smaller, but non-zero) gutter to the true screen edge. +// How far the mobile pattern slots overshoot &__content's edge - see &__pattern-slot. $pattern-mobile-overshoot: 10rem; -// Renders the repeating tile (3 circles + 2 rects) for one pattern strip. $circle-centers -// is a 3-item list of x-offsets; $rect-ranges is a 2-item list of [start, end] pairs. Both -// sides share this shape/size - only the ORDER (which offsets are passed in) differs. -@mixin pattern-tile($circle-centers, $rect-ranges) { - background-image: - radial-gradient( - circle at list.nth($circle-centers, 1) 50%, - var(--pattern-color, transparent) $pattern-circle-radius, - transparent $pattern-circle-antialias-edge - ), - radial-gradient( - circle at list.nth($circle-centers, 2) 50%, - var(--pattern-color, transparent) $pattern-circle-radius, - transparent $pattern-circle-antialias-edge - ), - radial-gradient( - circle at list.nth($circle-centers, 3) 50%, - var(--pattern-color, transparent) $pattern-circle-radius, - transparent $pattern-circle-antialias-edge - ), - linear-gradient( - to right, - transparent 0 list.nth(list.nth($rect-ranges, 1), 1), - var(--pattern-color, transparent) list.nth(list.nth($rect-ranges, 1), 1) list.nth(list.nth($rect-ranges, 1), 2), - transparent list.nth(list.nth($rect-ranges, 1), 2) list.nth(list.nth($rect-ranges, 2), 1), - var(--pattern-color, transparent) list.nth(list.nth($rect-ranges, 2), 1) list.nth(list.nth($rect-ranges, 2), 2) - ); -} - .c-block-homepage-banner { - // The pattern strips escape all the way to this element's edges (and beyond) - clip - // them here. clip-path (not overflow-x: hidden) avoids the overflow-x/y coupling rule - // that would otherwise turn overflow-y into "auto" and risk a vertical scrollbar. + // clip-path (not overflow-x: hidden) avoids the overflow-x/y coupling rule that would + // otherwise turn overflow-y into "auto" and risk a vertical scrollbar. clip-path: inset(0); width: 100% !important; max-width: none !important; @@ -65,13 +26,11 @@ $pattern-mobile-overshoot: 10rem; flex-direction: column; @media (max-width: variables.$g-bp4) { - // Mobile pattern slots size themselves as a % of this element, so it needs to - // span the full screen width here, not the desktop 75% cap. + // Mobile pattern slots size themselves as a % of this element, so it needs to span + // the full screen width here, not the desktop 75% cap. max-width: 100%; - // Reserves normal-flow space for the pattern slots above/below the text. - // &__pattern-slot is position: absolute so its height doesn't count towards - // this element's own height - without this padding, .c-block-homepage-banner's - // clip-path would end right at the text and clip the slots away entirely. + // &__pattern-slot is position: absolute so this is what reserves its space in + // normal flow - without it the clip-path above would cut the slots away entirely. padding: calc($pattern-height + $pattern-distance-vertical) 0; } @@ -90,7 +49,7 @@ $pattern-mobile-overshoot: 10rem; &--right { margin-left: auto; - .c-block-homepage-banner__pattern--left{ + .c-block-homepage-banner__pattern--left { right: calc(100% + $pattern-distance-horizontal + $pattern-distance-horizontal); } } @@ -98,118 +57,63 @@ $pattern-mobile-overshoot: 10rem; &-title { padding-bottom: variables.$g-spacer-unit * 4; color: colors.$color-gray-1000; - - /* H4 */ - font-family: "SofiaPro" !important; - font-size: 3.2rem !important; - font-style: normal !important; - font-weight: 800 !important; - line-height: 4rem !important; width: 100%; white-space: wrap; + @include typography.sofia-pro-heading-xl(true); } &-text { color: colors.$neutral; - - /* H4 */ - font-family: "SofiaPro" !important; - font-size: 1.8rem !important; - font-style: normal !important; - font-weight: 500 !important; - line-height: 2.8rem !important; + @include typography.sofia-pro-body(true); } } - // Anchored off the text box's own edge (not Container's), so the $pattern-distance-horizontal gap holds - // regardless of alignment or actual (up to 75%) text width. - // - // width MUST be a multiple of $pattern-cycle-width: Chrome has a rendering bug where a - // repeating multi-layer gradient tears in the middle of the element when its width - // isn't an exact multiple of background-size (100vw reproduces this reliably). - // - // --left/--right are two DIFFERENT tiles (not one tile phase-shifted via - // background-position - that proved unreliable), each built so the desired near-text - // shape sits naturally at the tile's own edge, anchored via a plain - // background-position-x: right / left (no offset): - // - left: rect, circle, rect, [circle, circle, rect, circle, ...] - // - right: circle, rect, circle, circle, [rect, circle, circle, rect, circle, ...] + // The strip is always the same 5-shape cycle - rect, circle, circle, rect, circle - just + // entered at a different point per side (see pattern.pattern-side): + // - left: enters at shape 1, nearest text -> rect, circle, rect, circle, circle + // - right: enters at shape 5, nearest text -> circle, rect, circle, circle, rect &__pattern { - position: absolute; + @include pattern.pattern-strip($pattern-height, $pattern-repeat-count); top: 0; bottom: 0; - width: $pattern-cycle-width * $pattern-repeat-count; - background-repeat: repeat-x; - background-position-y: top; - background-size: $pattern-cycle-width $pattern-height; &--left { right: calc(100% + $pattern-distance-horizontal); background-position-x: right; - @include pattern-tile( - ( - $pattern-circle-radius, - $pattern-circle-diameter + $pattern-circle-radius, - $pattern-circle-diameter * 2 + $pattern-rect-width + $pattern-circle-radius, - ), - ( - ($pattern-circle-diameter * 2, $pattern-circle-diameter * 2 + $pattern-rect-width), - ($pattern-circle-diameter * 3 + $pattern-rect-width, $pattern-cycle-width), - ) - ); + @include pattern.pattern-side(left, 1, $pattern-height); } &--right { left: calc(100% + $pattern-distance-horizontal); background-position-x: left; - @include pattern-tile( - ( - $pattern-circle-radius, - $pattern-circle-diameter + $pattern-rect-width + $pattern-circle-radius, - $pattern-circle-diameter * 2 + $pattern-rect-width + $pattern-circle-radius, - ), - ( - ($pattern-circle-diameter, $pattern-circle-diameter + $pattern-rect-width), - ($pattern-circle-diameter * 3 + $pattern-rect-width, $pattern-cycle-width), - ) - ); + @include pattern.pattern-side(right, 5, $pattern-height); } - // Below $g-bp4 the patterns move from beside the text to above/below it (see - // &__pattern-slot). Same tile/anchor as desktop - only the anchor's landmark moves - // from "near text" to the slot's inner (1/3 or 2/3) edge; top/bottom: 0 is already - // set above. + // Below $g-bp4 the patterns move from beside the text to above/below it (see &__pattern-slot). @media (max-width: variables.$g-bp4) { &--left { - right: 0; + right: 0 !important; left: auto; } &--right { - left: 0; + left: 0 !important; right: auto; } } } - // No-op on desktop (unpositioned - &__pattern's position: absolute reaches past it to - // &__content). Below $g-bp4 it's the positioned, overflow-clipping box that caps - // &__pattern at exactly 1/3 (top) / 2/3 (bottom) of &__content's width. This cap can't - // live on &__pattern itself: capping its width there would make it a non-multiple of - // $pattern-cycle-width again (see the Chrome bug above). + // No-op on desktop. Below $g-bp4 it's the positioned, overflow-clipping box that caps + // &__pattern at exactly 1/3 (top) / 2/3 (bottom) of &__content's width - the cap can't live + // on &__pattern itself without breaking the Chrome tiling-bug workaround above. &__pattern-slot { @media (max-width: variables.$g-bp4) { position: absolute; overflow: hidden; height: $pattern-height; - // top/bottom: 0 land inside &__content's reserved padding, not against its - // content-box - literal zero offsets, so (unlike the desktop calc(100% + $pattern-distance-horizontal) - // trick) they don't need &__content to have a definite height to resolve. - // - // left/right overshoot by $pattern-mobile-overshoot to reach past avo2 - // Container's gutter to the true screen edge; width compensates so the INNER - // edge (the anchor landmark) stays exactly at 1/3 / 2/3. + // left/right overshoot to reach past avo2 Container's gutter to the true screen + // edge; width compensates so the inner (anchor) edge stays exactly at 1/3 / 2/3. &--top { top: 0; left: -$pattern-mobile-overshoot; diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewWithCarousel/BlockOverviewWithCarousel.scss b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewWithCarousel/BlockOverviewWithCarousel.scss index 2277674d2..a3f876bbf 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewWithCarousel/BlockOverviewWithCarousel.scss +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewWithCarousel/BlockOverviewWithCarousel.scss @@ -1,6 +1,7 @@ @use "sass:math"; @use "../../../../shared/styles/settings/variables" as variables; @use "../../../../shared/styles/settings/colors" as colors; +@use "../../../../shared/styles/mixins/typography" as typography; $slide-width: 28rem; @@ -22,13 +23,8 @@ $slide-width: 28rem; &-title { color: colors.$color-gray-1000; - font-family: "SofiaPro"; - font-size: 2.4rem; - font-style: normal; - font-weight: 800; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + @include typography.sofia-pro-heading-lg(); + @include typography.truncate(); width: fit-content; } @@ -59,9 +55,7 @@ $slide-width: 28rem; .c-button__label { font-weight: 700; // since sm buttons loose this - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + @include typography.truncate(); } } } @@ -152,10 +146,7 @@ $slide-width: 28rem; } .c-block-overview-with-carousel__slide-text { - font-size: 3.2rem; - font-style: normal; - font-weight: 800; - line-height: 4.0rem; + @include typography.sofia-pro-heading-xl(); } } @@ -178,7 +169,7 @@ $slide-width: 28rem; min-height: 0; display: flex; align-items: end; - padding: calc(variables.$g-spacer-unit * 2) calc(variables.$g-spacer-unit * 3); + padding: calc(#{variables.$g-spacer-unit} * 2) calc(#{variables.$g-spacer-unit} * 3); } &-text { @@ -191,11 +182,7 @@ $slide-width: 28rem; width: 100%; color: colors.$color-gray-1000; - font-family: "SofiaPro"; - font-size: 1.8rem; - font-style: normal; - font-weight: 800; - line-height: 2.8rem; + @include typography.sofia-pro-heading-md(); } } } 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 b65c12144..468e50f54 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 @@ -11,6 +11,7 @@ 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'; import { HET_ARCHIEF_IMAGE_TEXT_BACKGROUND_BLOCK_CONFIG } from '~content-blocks/BlockHetArchiefImageTextBackground'; +import { CONTENT_HIGHLIGHT_TEXT_CONFIG } from '~content-blocks/BlockHighlightText'; import { CONTENT_HOMEPAGE_BANNER_CONFIG } from '~content-blocks/BlockHomepageBanner'; import { IFRAME_BLOCK_CONFIG } from '~content-blocks/BlockIFrame'; import { IMAGE_BLOCK_CONFIG } from '~content-blocks/BlockImage'; @@ -89,4 +90,5 @@ export const CONTENT_BLOCK_CONFIG_MAP: Record< [ContentBlockType.ScrollDownNudge]: CONTENT_SCROLL_DOWN_NUDGE_CONFIG, [ContentBlockType.OverviewWithCarousel]: CONTENT_OVERVIEW_WITH_CAROUSEL_CONFIG, [ContentBlockType.HomepageBanner]: CONTENT_HOMEPAGE_BANNER_CONFIG, + [ContentBlockType.HighlightText]: CONTENT_HIGHLIGHT_TEXT_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 2f689b91c..416612346 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 @@ -11,6 +11,7 @@ import { INITIAL_EVENTBRITE_COMPONENTS_STATE } from '~content-blocks/BlockEventb import { INITIAL_HEADING_COMPONENTS_STATE } from '~content-blocks/BlockHeading'; import { INITIAL_HET_ARCHIEF_HEADER_SEARCH_BLOCK_STATE } from '~content-blocks/BlockHetArchiefHeaderSearch/BlockHetArchiefHeaderSearch.editorconfig'; import { INITIAL_HET_ARCHIEF_IMAGE_TEXT_BACKGROUND_BLOCK_STATE } from '~content-blocks/BlockHetArchiefImageTextBackground'; +import { INITIAL_CONTENT_HIGHLIGHT_TEXT_BLOCK_STATE } from '~content-blocks/BlockHighlightText'; import { INITIAL_CONTENT_HOMEPAGE_BANNER_BLOCK_STATE } from '~content-blocks/BlockHomepageBanner'; import { INITIAL_IFRAME_COMPONENTS_STATE } from '~content-blocks/BlockIFrame'; import { INITIAL_IMAGE_COMPONENTS_STATE } from '~content-blocks/BlockImage'; @@ -92,4 +93,5 @@ export const CONTENT_BLOCK_INITIAL_STATE_MAP: { [ContentBlockType.ScrollDownNudge]: INITIAL_SCROLL_DOWN_NUDGE_BLOCK_STATE, [ContentBlockType.OverviewWithCarousel]: INITIAL_CONTENT_OVERVIEW_WITH_CAROUSEL_BLOCK_STATE, [ContentBlockType.HomepageBanner]: INITIAL_CONTENT_HOMEPAGE_BANNER_BLOCK_STATE, + [ContentBlockType.HighlightText]: INITIAL_CONTENT_HIGHLIGHT_TEXT_BLOCK_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 e4c81af77..e06d5e6fa 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 @@ -186,12 +186,20 @@ export const GET_CONTENT_BLOCK_TYPE_OPTIONS: () => SelectOption[] = () = }, { label: tText( - 'modules/content-page/const/get-content-block-type-options___homepage=-banner', + 'modules/content-page/const/get-content-block-type-options___homepage-banner', {}, [HET_ARCHIEF] ), value: ContentBlockType.HomepageBanner, }, + { + label: tText( + 'modules/content-page/const/get-content-block-type-options___highlight-text', + {}, + [HET_ARCHIEF] + ), + value: ContentBlockType.HighlightText, + }, ]; // 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 28ec3b58b..f190659cf 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 @@ -188,6 +188,7 @@ export enum ContentBlockType { ScrollDownNudge = 'SCROLL_DOWN_NUDGE', OverviewWithCarousel = 'OVERVIEW_WITH_CAROUSEL', HomepageBanner = 'HOMEPAGE_BANNER', + HighlightText = 'HIGHLIGHT_TEXT', } export enum ContentBlockEditor { diff --git a/ui/src/react-admin/modules/shared/styles/mixins/_pattern.scss b/ui/src/react-admin/modules/shared/styles/mixins/_pattern.scss new file mode 100644 index 000000000..fce915164 --- /dev/null +++ b/ui/src/react-admin/modules/shared/styles/mixins/_pattern.scss @@ -0,0 +1,129 @@ +@use "sass:math"; +@use "sass:list"; + +// Shared geometry + drawing for the repeating decorative pattern strip used by content blocks +// like BlockHomepageBanner and BlockHighlightText. The strip is always the same 5-shape cycle - +// rect, circle, circle, rect, circle - repeated; blocks only ever vary $height, which side of +// the content the strip is on, and which shape in the cycle sits nearest that content. +$_pattern-canon: rect, circle, circle, rect, circle; + +@function _pattern-rect-width($height) { + @return math.div($height, 2); +} + +@function _pattern-circle-radius($height) { + @return math.div($height, 2); +} + +@function _pattern-antialias-edge($height) { + @return _pattern-circle-radius($height) + 0.02rem; +} + +// Must stay a multiple of this cycle width wherever it's used as a background-size: Chrome +// tears a repeating multi-layer gradient mid-strip when the element's width isn't an exact +// multiple of its background-size. +@function _pattern-cycle-width($height) { + @return _pattern-rect-width($height) * 2 + $height * 3; +} + +// Floor modulo (Sass % is unreliable with negative operands) - keeps cycle indices in 1..$m. +@function _pattern-mod1($n, $m) { + @return $n - math.floor(math.div($n, $m)) * $m; +} + +// The 5 shapes in ascending (left-to-right) x order for a strip anchored on $side, starting +// $start (1-5) shapes into $_pattern-canon at the edge touching the content. "left" tiles touch +// at their RIGHT edge, so we walk the cycle backwards moving away from it and then flip that +// into left-to-right order; "right" tiles touch at their LEFT edge, so walking forwards away +// from it already IS left-to-right order. +@function _pattern-ascending-shapes($side, $start) { + $away-from-content: (); + @for $i from 0 through 4 { + $step: if($side == right, $i, -$i); + $idx: _pattern-mod1($start - 1 + $step, 5) + 1; + $away-from-content: list.append($away-from-content, list.nth($_pattern-canon, $idx)); + } + @if $side == left { + // list.append can't prepend, so build the flipped list back-to-front + $ascending: (); + @for $i from 5 through 1 { + $ascending: list.append($ascending, list.nth($away-from-content, $i)); + } + @return $ascending; + } + @return $away-from-content; +} + +// Lays the 5 shapes from _pattern-ascending-shapes out left-to-right, returning the +// (circle-centers, rect-ranges) pair that pattern-side draws from. +@function _pattern-positions($side, $start, $height) { + $rect-width: _pattern-rect-width($height); + $circle-centers: (); + $rect-ranges: (); + $cursor: 0; + + @each $shape in _pattern-ascending-shapes($side, $start) { + @if $shape == rect { + $rect-ranges: list.append($rect-ranges, ($cursor, $cursor + $rect-width), $separator: comma); + $cursor: $cursor + $rect-width; + } @else { + $circle-centers: list.append($circle-centers, $cursor + _pattern-circle-radius($height)); + $cursor: $cursor + $height; + } + } + + @return $circle-centers, $rect-ranges; +} + +// Common base for a pattern strip element - position/tiling only, no shape or side concerns. +// $repeat-count just needs to be generous enough that the strip always reaches the block's own +// edge on any realistic viewport. +@mixin pattern-strip($height, $repeat-count: 20) { + $cycle-width: _pattern-cycle-width($height); + + position: absolute; + width: $cycle-width * $repeat-count; + background-repeat: repeat-x; + background-position-y: top; + background-size: $cycle-width $height; +} + +// Draws the repeating tile (3 circles + 2 rects) for one side of a pattern strip. $side is +// `left` or `right` (which edge of the strip touches the content); $start (1-5) picks which +// shape of the fixed rect/circle/circle/rect/circle cycle sits nearest that content. +@mixin pattern-side($side, $start, $height) { + $circle-radius: _pattern-circle-radius($height); + $antialias-edge: _pattern-antialias-edge($height); + $cycle-width: _pattern-cycle-width($height); + $positions: _pattern-positions($side, $start, $height); + $circle-centers: list.nth($positions, 1); + $rect-ranges: list.nth($positions, 2); + + background-image: + radial-gradient( + circle at list.nth($circle-centers, 1) 50%, + var(--pattern-color, transparent) $circle-radius, + transparent $antialias-edge + ), + radial-gradient( + circle at list.nth($circle-centers, 2) 50%, + var(--pattern-color, transparent) $circle-radius, + transparent $antialias-edge + ), + radial-gradient( + circle at list.nth($circle-centers, 3) 50%, + var(--pattern-color, transparent) $circle-radius, + transparent $antialias-edge + ), + linear-gradient( + to right, + transparent 0 list.nth(list.nth($rect-ranges, 1), 1), + var(--pattern-color, transparent) list.nth(list.nth($rect-ranges, 1), 1) list.nth(list.nth($rect-ranges, 1), 2), + transparent list.nth(list.nth($rect-ranges, 1), 2) list.nth(list.nth($rect-ranges, 2), 1), + var(--pattern-color, transparent) list.nth(list.nth($rect-ranges, 2), 1) list.nth(list.nth($rect-ranges, 2), 2), + // Without this trailing stop, a plain linear-gradient holds its LAST color for the rest + // of its length - the 2nd rect would bleed solid color all the way to the tile's edge + // instead of stopping, whenever that edge doesn't land exactly on $cycle-width. + transparent list.nth(list.nth($rect-ranges, 2), 2) $cycle-width + ); +} diff --git a/ui/src/react-admin/modules/shared/styles/mixins/_typography.scss b/ui/src/react-admin/modules/shared/styles/mixins/_typography.scss new file mode 100644 index 000000000..c1f3782d9 --- /dev/null +++ b/ui/src/react-admin/modules/shared/styles/mixins/_typography.scss @@ -0,0 +1,49 @@ +// Shared text stylings for the "SofiaPro" typeface used across content blocks. Centralised so +// the same size/weight combo (e.g. body copy) reads identically wherever it's used, instead of +// each block re-declaring its own font-family/size/weight/line-height by hand. +$font-family: "SofiaPro", sans-serif; + +@mixin sofia-pro($size, $weight: 500, $line-height: null, $important: false) { + @if $important { + font-family: $font-family !important; + font-size: $size !important; + font-style: normal !important; + font-weight: $weight !important; + @if $line-height { + line-height: $line-height !important; + } + } @else { + font-family: $font-family; + font-size: $size; + font-style: normal; + font-weight: $weight; + @if $line-height { + line-height: $line-height; + } + } +} + +// Presets for the combos already repeated across content blocks - reach for `sofia-pro(...)` +// directly instead if a new block needs a size/weight that doesn't match one of these. +@mixin sofia-pro-heading-xl($important: false) { + @include sofia-pro(3.2rem, 800, 4rem, $important); +} + +@mixin sofia-pro-heading-lg($important: false) { + @include sofia-pro(2.4rem, 800, null, $important); +} + +@mixin sofia-pro-heading-md($important: false) { + @include sofia-pro(1.8rem, 800, 2.8rem, $important); +} + +@mixin sofia-pro-body($important: false) { + @include sofia-pro(1.8rem, 500, 2.8rem, $important); +} + +// Single-line truncation with an ellipsis - shared with .u-truncate (utilities/_text.scss). +@mixin truncate() { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/ui/src/react-admin/modules/shared/styles/utilities/_text.scss b/ui/src/react-admin/modules/shared/styles/utilities/_text.scss index 42ad07824..f7e970bf1 100644 --- a/ui/src/react-admin/modules/shared/styles/utilities/_text.scss +++ b/ui/src/react-admin/modules/shared/styles/utilities/_text.scss @@ -1,5 +1,6 @@ @use '../../styles/settings/colors' as colors; @use '../../styles/settings/variables' as variables; +@use '../mixins/typography' as typography; /* ========================================================================== Utility: Text @@ -59,7 +60,5 @@ $u-text-danger-color: colors.$color-error !default; } .u-truncate { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + @include typography.truncate(); }