From 3d639f712694194cfcdc355a10576f814161cdaf Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 5 Mar 2026 11:29:57 +0100 Subject: [PATCH 01/12] Add split section appearance variant Introduce a new "split" appearance option for sections that renders a translucent overlay covering part of the section background, aligned with content. Includes SplitShadow (overlay) and SplitBox (foreground) components with support for configurable width modes (content/half), width sizes (md/lg/xl), custom overlay color, and stacked mode for narrow viewports. Make card and split surface color inputs available without requiring the custom_palette_colors feature flag. --- entry_types/scrolled/config/locales/de.yml | 4 + entry_types/scrolled/config/locales/en.yml | 4 + .../sectionAppearanceScopeClass-spec.js | 11 ++ .../frontend/foregroundBoxes/SplitBox-spec.js | 99 +++++++++++++++ .../spec/frontend/shadows/SplitShadow-spec.js | 119 ++++++++++++++++++ .../src/editor/views/EditSectionView.js | 30 +++-- .../scrolled/package/src/frontend/Section.js | 6 +- .../__stories__/appearance-stories.js | 2 +- .../package/src/frontend/appearance.js | 10 +- .../src/frontend/foregroundBoxes/SplitBox.js | 23 ++++ .../foregroundBoxes/SplitBox.module.css | 39 ++++++ .../src/frontend/shadows/SplitShadow.js | 29 +++++ .../frontend/shadows/SplitShadow.module.css | 42 +++++++ 13 files changed, 403 insertions(+), 15 deletions(-) create mode 100644 entry_types/scrolled/package/spec/frontend/foregroundBoxes/SplitBox-spec.js create mode 100644 entry_types/scrolled/package/spec/frontend/shadows/SplitShadow-spec.js create mode 100644 entry_types/scrolled/package/src/frontend/foregroundBoxes/SplitBox.js create mode 100644 entry_types/scrolled/package/src/frontend/foregroundBoxes/SplitBox.module.css create mode 100644 entry_types/scrolled/package/src/frontend/shadows/SplitShadow.js create mode 100644 entry_types/scrolled/package/src/frontend/shadows/SplitShadow.module.css diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 6b2e784a28..22034d9fba 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1214,6 +1214,7 @@ de: cards: Karte shadow: Schatten transparent: Keine + split: Split atmoAudioFileId: inline_help: Wähle eine Audio-Datei, die im Hintergrund abgespielt werden soll. Wenn diese Atmo auch in nachfolgenden Abschnitten nahtlos weiterspielen soll, wähle für diese Abschnitte die selbe Atmo aus. label: Atmo-Audio @@ -1274,6 +1275,9 @@ de: cardSurfaceColor: label: Kartenhintergrundfarbe auto: "(Automatisch)" + splitSurfaceColor: + label: Overlay-Farbe + auto: "(Automatisch)" sectionPaddings: label: Innenabstände oben/unten tabs: diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index 2473ea8d90..2dbd03c1c1 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1198,6 +1198,7 @@ en: cards: Card shadow: Shadow transparent: Transparent + split: Split atmoAudioFileId: inline_help: Choose an audio file, that shall be played in the background. If you want this audio to continue playing on following sections, just choose the same file again there. label: Atmo Audio @@ -1258,6 +1259,9 @@ en: cardSurfaceColor: label: Cards background color auto: "(Auto)" + splitSurfaceColor: + label: Overlay color + auto: "(Auto)" sectionPaddings: label: "Top/bottom padding" tabs: diff --git a/entry_types/scrolled/package/spec/frontend/features/sectionAppearanceScopeClass-spec.js b/entry_types/scrolled/package/spec/frontend/features/sectionAppearanceScopeClass-spec.js index b96917d3b1..fe7d2bfb94 100644 --- a/entry_types/scrolled/package/spec/frontend/features/sectionAppearanceScopeClass-spec.js +++ b/entry_types/scrolled/package/spec/frontend/features/sectionAppearanceScopeClass-spec.js @@ -34,4 +34,15 @@ describe('section appearance scope class', () => { expect(getSectionByPermaId(6).el).toHaveClass('scope-transparentAppearanceSection'); }); + + it('applies scope class for split appearance', () => { + const {getSectionByPermaId} = renderEntry({ + seed: { + sections: [{id: 5, permaId: 6, configuration: {appearance: 'split'}}], + contentElements: [{sectionId: 5}] + } + }); + + expect(getSectionByPermaId(6).el).toHaveClass('scope-splitAppearanceSection'); + }); }); diff --git a/entry_types/scrolled/package/spec/frontend/foregroundBoxes/SplitBox-spec.js b/entry_types/scrolled/package/spec/frontend/foregroundBoxes/SplitBox-spec.js new file mode 100644 index 0000000000..e91bd7efd1 --- /dev/null +++ b/entry_types/scrolled/package/spec/frontend/foregroundBoxes/SplitBox-spec.js @@ -0,0 +1,99 @@ +import React from 'react'; +import {render} from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; + +import SplitBox from 'frontend/foregroundBoxes/SplitBox'; +import styles from 'frontend/foregroundBoxes/SplitBox.module.css'; + +describe('SplitBox', () => { + it('renders children', () => { + const {getByTestId} = render( + +
+ + ); + + expect(getByTestId('child')).not.toBeNull(); + }); + + it('applies paddingTop from motifAreaState', () => { + const {container} = render( + +
+ + ); + + expect(container.firstChild).toHaveStyle({paddingTop: '100px'}); + }); + + it('renders overlay when isContentPadded is true', () => { + const {container} = render( + +
+ + ); + + expect(container.querySelector(`.${styles.overlay}`)).not.toBeNull(); + }); + + it('does not render overlay when isContentPadded is false', () => { + const {container} = render( + +
+ + ); + + expect(container.querySelector(`.${styles.overlay}`)).toBeNull(); + }); + + it('sets overlay top from paddingTop', () => { + const {container} = render( + +
+ + ); + + expect(container.querySelector(`.${styles.overlay}`)) + .toHaveStyle({top: '200px'}); + }); + + it('applies dark overlay class when not inverted', () => { + const {container} = render( + +
+ + ); + + expect(container.querySelector(`.${styles.overlay}`)) + .toHaveClass(styles.overlayDark); + }); + + it('applies light overlay class when inverted', () => { + const {container} = render( + +
+ + ); + + expect(container.querySelector(`.${styles.overlay}`)) + .toHaveClass(styles.overlayLight); + }); + + it('sets overlay background color from splitSurfaceColor', () => { + const {container} = render( + +
+ + ); + + expect(container.querySelector(`.${styles.overlay}`)) + .toHaveStyle({backgroundColor: '#ff000080'}); + }); +}); diff --git a/entry_types/scrolled/package/spec/frontend/shadows/SplitShadow-spec.js b/entry_types/scrolled/package/spec/frontend/shadows/SplitShadow-spec.js new file mode 100644 index 0000000000..c9407b4a98 --- /dev/null +++ b/entry_types/scrolled/package/spec/frontend/shadows/SplitShadow-spec.js @@ -0,0 +1,119 @@ +import React from 'react'; +import {render} from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; + +import SplitShadow from 'frontend/shadows/SplitShadow'; +import styles from 'frontend/shadows/SplitShadow.module.css'; + +describe('SplitShadow', () => { + const defaultProps = { + align: 'left', + inverted: false, + motifAreaState: {isContentPadded: false} + }; + + it('renders overlay and children', () => { + const {container} = render( + +
+ + ); + + expect(container.querySelector(`.${styles.overlay}`)).not.toBeNull(); + expect(container.querySelector('[data-testid="child"]')).not.toBeNull(); + }); + + it('applies left alignment class', () => { + const {container} = render( + +
+ + ); + + expect(container.firstChild).toHaveClass(styles['align-left']); + }); + + it('applies right alignment class', () => { + const {container} = render( + +
+ + ); + + expect(container.firstChild).toHaveClass(styles['align-right']); + }); + + it('applies center alignment class', () => { + const {container} = render( + +
+ + ); + + expect(container.firstChild).toHaveClass(styles['align-center']); + }); + + it('applies dark class when not inverted', () => { + const {container} = render( + +
+ + ); + + expect(container.firstChild).toHaveClass(styles.dark); + }); + + it('applies light class when inverted', () => { + const {container} = render( + +
+ + ); + + expect(container.firstChild).toHaveClass(styles.light); + }); + + it('sets background color from splitSurfaceColor prop', () => { + const {container} = render( + +
+ + ); + + expect(container.querySelector(`.${styles.overlay}`)) + .toHaveStyle({backgroundColor: '#ff000080'}); + }); + + it('does not set inline background color when no splitSurfaceColor', () => { + const {container} = render( + +
+ + ); + + expect(container.querySelector(`.${styles.overlay}`).style.backgroundColor) + .toBe(''); + }); + + it('does not render overlay when isContentPadded is true', () => { + const {container} = render( + +
+ + ); + + expect(container.querySelector(`.${styles.overlay}`)).toBeNull(); + }); + + it('renders children when isContentPadded is true', () => { + const {container} = render( + +
+ + ); + + expect(container.querySelector('[data-testid="child"]')).not.toBeNull(); + }); +}); diff --git a/entry_types/scrolled/package/src/editor/views/EditSectionView.js b/entry_types/scrolled/package/src/editor/views/EditSectionView.js index 2e6ddd83a0..87958321d3 100644 --- a/entry_types/scrolled/package/src/editor/views/EditSectionView.js +++ b/entry_types/scrolled/package/src/editor/views/EditSectionView.js @@ -169,7 +169,7 @@ export const EditSectionView = EditConfigurationView.extend({ }); } this.input('appearance', SelectInputView, { - values: ['shadow', 'cards', 'transparent'] + values: ['shadow', 'cards', 'transparent', 'split'] }); this.input('invert', CheckBoxInputView); @@ -190,16 +190,24 @@ export const EditSectionView = EditConfigurationView.extend({ (!exposeMotifArea || motifAreaDisabled(motifAreaDisabledBindingValues)) && backdropType !== 'contentElement' }); - if (features.isEnabled('custom_palette_colors')) { - this.input('cardSurfaceColor', ColorInputView, { - visibleBinding: 'appearance', - visibleBindingValue: 'cards', - placeholder: I18n.t('pageflow_scrolled.editor.edit_section.attributes.cardSurfaceColor.auto'), - placeholderColorBinding: 'invert', - placeholderColor: invert => invert ? '#101010' : '#ffffff', - swatches: entry.getUsedSectionBackgroundColors() - }); - } + this.input('cardSurfaceColor', ColorInputView, { + visibleBinding: 'appearance', + visibleBindingValue: 'cards', + placeholder: I18n.t('pageflow_scrolled.editor.edit_section.attributes.cardSurfaceColor.auto'), + placeholderColorBinding: 'invert', + placeholderColor: invert => invert ? '#101010' : '#ffffff', + swatches: entry.getUsedSectionBackgroundColors() + }); + + this.input('splitSurfaceColor', ColorInputView, { + visibleBinding: 'appearance', + visibleBindingValue: 'split', + alpha: true, + placeholder: I18n.t('pageflow_scrolled.editor.edit_section.attributes.splitSurfaceColor.auto'), + placeholderColorBinding: 'invert', + placeholderColor: invert => invert ? '#ffffffb3' : '#000000b3', + swatches: entry.getUsedSectionBackgroundColors() + }); this.view(SeparatorView); diff --git a/entry_types/scrolled/package/src/frontend/Section.js b/entry_types/scrolled/package/src/frontend/Section.js index 81d48d81da..f7d5a62c10 100644 --- a/entry_types/scrolled/package/src/frontend/Section.js +++ b/entry_types/scrolled/package/src/frontend/Section.js @@ -152,7 +152,8 @@ function SectionContents({ inverted={section.invert} motifAreaState={motifAreaState} staticShadowOpacity={staticShadowOpacity} - dynamicShadowOpacity={dynamicShadowOpacity}> + dynamicShadowOpacity={dynamicShadowOpacity} + splitSurfaceColor={section.splitSurfaceColor}> {children} } @@ -170,7 +171,8 @@ function SectionContents({ transitionStyles={transitionStyles} state={state} motifAreaState={motifAreaState} - staticShadowOpacity={staticShadowOpacity}> + staticShadowOpacity={staticShadowOpacity} + splitSurfaceColor={section.splitSurfaceColor}> + {props.motifAreaState.isContentPadded && +
} +
+ {props.children} +
+
+ ); +} diff --git a/entry_types/scrolled/package/src/frontend/foregroundBoxes/SplitBox.module.css b/entry_types/scrolled/package/src/frontend/foregroundBoxes/SplitBox.module.css new file mode 100644 index 0000000000..9a5e498688 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/foregroundBoxes/SplitBox.module.css @@ -0,0 +1,39 @@ +.wrapper {} + +.content { + position: relative; + pointer-events: auto; +} + +.overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; +} + +.overlayDark { + composes: overlay; + background-color: rgba(0, 0, 0, 0.7); +} + +.overlayLight { + composes: overlay; + background-color: rgba(255, 255, 255, 0.7); +} + +.long { + bottom: -100vh; +} + +@media print { + .wrapper { + padding-top: 0 !important; + } + + .overlay { + display: none; + } +} diff --git a/entry_types/scrolled/package/src/frontend/shadows/SplitShadow.js b/entry_types/scrolled/package/src/frontend/shadows/SplitShadow.js new file mode 100644 index 0000000000..9663f4ff4a --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/shadows/SplitShadow.js @@ -0,0 +1,29 @@ +import React from 'react'; +import classNames from 'classnames'; + +import Fullscreen from '../Fullscreen'; +import styles from './SplitShadow.module.css'; + +export default function SplitShadow(props) { + if (props.motifAreaState.isContentPadded) { + return ( +
+ {props.children} +
+ ); + } + + return ( +
+
+ +
+ {props.children} +
+ ); +} diff --git a/entry_types/scrolled/package/src/frontend/shadows/SplitShadow.module.css b/entry_types/scrolled/package/src/frontend/shadows/SplitShadow.module.css new file mode 100644 index 0000000000..e7444b1142 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/shadows/SplitShadow.module.css @@ -0,0 +1,42 @@ +.wrapper { + position: relative; +} + +.overlay { + position: absolute; + top: 0; + left: 0; + width: 50%; + height: 100%; + z-index: 1; + pointer-events: none; +} + +.align-left .overlay { + left: 0; +} + +.align-right .overlay { + left: auto; + right: 0; +} + +.align-center .overlay, +.align-centerRagged .overlay { + left: 50%; + transform: translateX(-50%); +} + +.dark .overlay { + background-color: rgba(0, 0, 0, 0.7); +} + +.light .overlay { + background-color: rgba(255, 255, 255, 0.7); +} + +@media print { + .overlay { + display: none; + } +} From 0766251fba7205f6bc1cc171810ab6b67c65d471 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 9 Mar 2026 11:18:13 +0100 Subject: [PATCH 02/12] Constrain layout content width in split sections Keep content elements within the 50% split overlay by constraining inline content max-width and margins in both TwoColumn and Center layouts. Below 950px the overlay is full width, so constraints only apply above that breakpoint. REDMINE-21203 --- .../features/constrainContentWidth-spec.js | 64 +++++++++++++++++++ .../package/spec/support/pageObjects.js | 5 ++ .../scrolled/package/src/frontend/Section.js | 2 + .../package/src/frontend/layouts/Center.js | 3 +- .../src/frontend/layouts/Center.module.css | 22 ++++++- .../package/src/frontend/layouts/TwoColumn.js | 5 +- .../src/frontend/layouts/TwoColumn.module.css | 40 +++++++++++- .../package/src/frontend/layouts/index.js | 3 +- .../frontend/shadows/SplitShadow.module.css | 8 ++- 9 files changed, 142 insertions(+), 10 deletions(-) create mode 100644 entry_types/scrolled/package/spec/frontend/features/constrainContentWidth-spec.js diff --git a/entry_types/scrolled/package/spec/frontend/features/constrainContentWidth-spec.js b/entry_types/scrolled/package/spec/frontend/features/constrainContentWidth-spec.js new file mode 100644 index 0000000000..1a74c3aad8 --- /dev/null +++ b/entry_types/scrolled/package/spec/frontend/features/constrainContentWidth-spec.js @@ -0,0 +1,64 @@ +import {renderEntry, usePageObjects} from 'support/pageObjects'; +import '@testing-library/jest-dom/extend-expect'; + +import {useMotifAreaState} from 'frontend/v1/useMotifAreaState'; +jest.mock('frontend/v1/useMotifAreaState'); + +describe('constrainContentWidth', () => { + usePageObjects(); + it('applies class for split appearance', () => { + const {getSectionByPermaId} = renderEntry({ + seed: { + sections: [{id: 5, permaId: 6, configuration: { + appearance: 'split' + }}], + contentElements: [{sectionId: 5}] + } + }); + + expect(getSectionByPermaId(6).hasConstrainedContentWidth()).toBe(true); + }); + + it('does not apply class for shadow appearance', () => { + const {getSectionByPermaId} = renderEntry({ + seed: { + sections: [{id: 5, permaId: 6, configuration: { + appearance: 'shadow' + }}], + contentElements: [{sectionId: 5}] + } + }); + + expect(getSectionByPermaId(6).hasConstrainedContentWidth()).toBe(false); + }); + + it('applies class for split appearance with center layout', () => { + const {getSectionByPermaId} = renderEntry({ + seed: { + sections: [{id: 5, permaId: 6, configuration: { + appearance: 'split', + layout: 'center' + }}], + contentElements: [{sectionId: 5, typeName: 'withTestId', + configuration: {testId: 1}}] + } + }); + + expect(getSectionByPermaId(6).hasConstrainedContentWidth()).toBe(true); + }); + + it('does not apply class when content is padded', () => { + useMotifAreaState.mockContentPadded(); + + const {getSectionByPermaId} = renderEntry({ + seed: { + sections: [{id: 5, permaId: 6, configuration: { + appearance: 'split' + }}], + contentElements: [{sectionId: 5}] + } + }); + + expect(getSectionByPermaId(6).hasConstrainedContentWidth()).toBe(false); + }); +}); diff --git a/entry_types/scrolled/package/spec/support/pageObjects.js b/entry_types/scrolled/package/spec/support/pageObjects.js index eda89bbd36..575b740e97 100644 --- a/entry_types/scrolled/package/spec/support/pageObjects.js +++ b/entry_types/scrolled/package/spec/support/pageObjects.js @@ -242,6 +242,11 @@ function createSectionPageObject(el) { hasFirstBoxSuppressedTopMargin() { const firstBox = foreground.querySelector(`.${boxBoundaryMarginStyles.noTopMargin}`); return !!firstBox; + }, + + hasConstrainedContentWidth() { + return !!(el.querySelector(`.${twoColumnLayoutStyles.constrainContentWidth}`) || + el.querySelector(`.${centerLayoutStyles.constrainContentWidth}`)); } } } diff --git a/entry_types/scrolled/package/src/frontend/Section.js b/entry_types/scrolled/package/src/frontend/Section.js index f7d5a62c10..85e8cc4d47 100644 --- a/entry_types/scrolled/package/src/frontend/Section.js +++ b/entry_types/scrolled/package/src/frontend/Section.js @@ -176,6 +176,8 @@ function SectionContents({ diff --git a/entry_types/scrolled/package/src/frontend/layouts/Center.js b/entry_types/scrolled/package/src/frontend/layouts/Center.js index 7a07fae4a0..d346d4caa7 100644 --- a/entry_types/scrolled/package/src/frontend/layouts/Center.js +++ b/entry_types/scrolled/package/src/frontend/layouts/Center.js @@ -14,7 +14,8 @@ export function Center(props) { const groups = groupItems(props.items); return ( -
+
{groups.map((group, groupIndex) => { diff --git a/entry_types/scrolled/package/src/frontend/layouts/Center.module.css b/entry_types/scrolled/package/src/frontend/layouts/Center.module.css index 72a2b8fa97..beca6af811 100644 --- a/entry_types/scrolled/package/src/frontend/layouts/Center.module.css +++ b/entry_types/scrolled/package/src/frontend/layouts/Center.module.css @@ -19,7 +19,8 @@ } .box { - --content-max-width: var(--layout-inline-content-max-width); + --content-max-width: min(var(--layout-inline-content-max-width), + var(--constrained-content-max-width, 100%)); margin-left: auto; margin-right: auto; max-width: var(--content-max-width); @@ -30,7 +31,8 @@ } .box-lg { - --content-max-width: var(--layout-inline-lg-content-max-width); + --content-max-width: min(var(--layout-inline-lg-content-max-width), + var(--constrained-content-max-width, 100%)); } .box-xl { @@ -43,6 +45,22 @@ margin-right: 0; } +@media screen and (min-width: 950px) { + .constrainContentWidth { + container-type: inline-size; + --content-margin-fraction-override: calc(var(--content-margin-fraction) / 2); + } + + .constrainContentWidth > .outer { + --constrained-content-max-width: calc(50cqw - 100cqw * var(--content-margin-fraction) * 2); + } + + .constrainContentWidth > .outer-lg { + --content-margin-fraction: var(--content-margin-fraction-override); + --content-margin: calc(var(--content-margin-fraction) * 100cqw); + } +} + .clear { clear: both !important; } diff --git a/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.js b/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.js index e35750218b..98b8be8242 100644 --- a/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.js +++ b/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.js @@ -13,7 +13,8 @@ export function TwoColumn(props) { const shouldInline = useShouldInlineSticky(); return ( -
+
.group { + --constrained-content-max-width: calc(50cqw - 100cqw * var(--content-margin-fraction) * 2); + } + + .constrainContentWidth > .group-lg { + --content-margin-fraction: var(--content-margin-fraction-override); + --content-margin: calc(var(--content-margin-fraction) * 100cqw); + + max-width: min(calc(var(--section-max-width, 100%) + var(--content-margin) * 2), + calc(50% + var(--layout-inline-lg-content-max-width))); + } +} + .restrict-xxs, .restrict-xs, .restrict-sm { @@ -99,6 +121,18 @@ width: var(--content-width); } +.constrainContentWidth .side { + position: relative; + --side-offset: calc((min(var(--layout-inline-content-max-width), var(--constrained-content-max-width)) - + var(--content-max-width)) / 2); + right: var(--side-offset); +} + +.constrainContentWidth.right .side { + right: auto; + left: var(--side-offset); +} + .sticky { composes: side; position: sticky; diff --git a/entry_types/scrolled/package/src/frontend/layouts/index.js b/entry_types/scrolled/package/src/frontend/layouts/index.js index 2df9f09545..bedae7a117 100644 --- a/entry_types/scrolled/package/src/frontend/layouts/index.js +++ b/entry_types/scrolled/package/src/frontend/layouts/index.js @@ -15,7 +15,8 @@ export const Layout = React.memo( prevProps.appearance === nextProps.appearance && prevProps.contentAreaRef === nextProps.contentAreaRef && prevProps.sectionProps === nextProps.sectionProps && - prevProps.isContentPadded === nextProps.isContentPadded + prevProps.isContentPadded === nextProps.isContentPadded && + prevProps.constrainContentWidth === nextProps.constrainContentWidth ) ); diff --git a/entry_types/scrolled/package/src/frontend/shadows/SplitShadow.module.css b/entry_types/scrolled/package/src/frontend/shadows/SplitShadow.module.css index e7444b1142..1a48e4a674 100644 --- a/entry_types/scrolled/package/src/frontend/shadows/SplitShadow.module.css +++ b/entry_types/scrolled/package/src/frontend/shadows/SplitShadow.module.css @@ -6,12 +6,18 @@ position: absolute; top: 0; left: 0; - width: 50%; + width: 100%; height: 100%; z-index: 1; pointer-events: none; } +@media screen and (min-width: 950px) { + .overlay { + width: 50%; + } +} + .align-left .overlay { left: 0; } From 39344324c6b7692087e300bdc7995f90c1ea9da7 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 9 Mar 2026 15:29:27 +0100 Subject: [PATCH 03/12] Center headings with large width in split layout Let content elements know when they are displayed inside the split overlay so they can adapt their layout. Center heading text at the breakpoint where the overlay becomes 50% wide. REDMINE-21203 --- .../contentElements/heading/Heading-spec.js | 46 +++++++++++++++++++ .../features/constrainContentWidth-spec.js | 41 +++++++++++++++++ .../src/contentElements/heading/Heading.js | 3 ++ .../heading/Heading.module.css | 8 +++- .../scrolled/package/src/frontend/Section.js | 18 ++++---- 5 files changed, 107 insertions(+), 9 deletions(-) create mode 100644 entry_types/scrolled/package/spec/contentElements/heading/Heading-spec.js diff --git a/entry_types/scrolled/package/spec/contentElements/heading/Heading-spec.js b/entry_types/scrolled/package/spec/contentElements/heading/Heading-spec.js new file mode 100644 index 0000000000..2f38bfae49 --- /dev/null +++ b/entry_types/scrolled/package/spec/contentElements/heading/Heading-spec.js @@ -0,0 +1,46 @@ +import React from 'react'; + +import {Heading} from 'contentElements/heading/Heading'; + +import {renderInContentElement} from 'pageflow-scrolled/testHelpers'; +import {contentElementWidths} from 'pageflow-scrolled/frontend'; + +import styles from 'contentElements/heading/Heading.module.css'; + +import '@testing-library/jest-dom/extend-expect'; + +describe('Heading', () => { + function renderHeading({configuration, width, sectionProps} = {}) { + return renderInContentElement( + , { + editorState: {isEditable: false} + } + ); + } + + it('centers heading with width lg when constrainContentWidth is set', () => { + const {container} = renderHeading({ + width: contentElementWidths.lg, + sectionProps: {constrainContentWidth: true} + }); + + expect(container.querySelector('header')).toHaveClass(styles.centerConstrained); + }); + + it('does not center heading with width xl when constrainContentWidth is set', () => { + const {container} = renderHeading({ + width: contentElementWidths.xl, + sectionProps: {constrainContentWidth: true} + }); + + expect(container.querySelector('header')).not.toHaveClass(styles.centerConstrained); + }); +}); diff --git a/entry_types/scrolled/package/spec/frontend/features/constrainContentWidth-spec.js b/entry_types/scrolled/package/spec/frontend/features/constrainContentWidth-spec.js index 1a74c3aad8..5399bed89b 100644 --- a/entry_types/scrolled/package/spec/frontend/features/constrainContentWidth-spec.js +++ b/entry_types/scrolled/package/spec/frontend/features/constrainContentWidth-spec.js @@ -1,11 +1,26 @@ +import React from 'react'; import {renderEntry, usePageObjects} from 'support/pageObjects'; import '@testing-library/jest-dom/extend-expect'; +import {api} from 'frontend/api'; + import {useMotifAreaState} from 'frontend/v1/useMotifAreaState'; jest.mock('frontend/v1/useMotifAreaState'); describe('constrainContentWidth', () => { usePageObjects(); + + beforeEach(() => { + api.contentElementTypes.register('probe', { + component: function Probe({sectionProps}) { + return ( +
+ ); + } + }); + }); + it('applies class for split appearance', () => { const {getSectionByPermaId} = renderEntry({ seed: { @@ -61,4 +76,30 @@ describe('constrainContentWidth', () => { expect(getSectionByPermaId(6).hasConstrainedContentWidth()).toBe(false); }); + + it('passes constrainContentWidth to content elements via sectionProps', () => { + const {getByTestId} = renderEntry({ + seed: { + sections: [{id: 5, permaId: 6, configuration: { + appearance: 'split' + }}], + contentElements: [{sectionId: 5, typeName: 'probe'}] + } + }); + + expect(getByTestId('probe')).toHaveAttribute('data-constrain-content-width', 'true'); + }); + + it('does not pass constrainContentWidth for shadow appearance', () => { + const {getByTestId} = renderEntry({ + seed: { + sections: [{id: 5, permaId: 6, configuration: { + appearance: 'shadow' + }}], + contentElements: [{sectionId: 5, typeName: 'probe'}] + } + }); + + expect(getByTestId('probe')).toHaveAttribute('data-constrain-content-width', 'false'); + }); }); diff --git a/entry_types/scrolled/package/src/contentElements/heading/Heading.js b/entry_types/scrolled/package/src/contentElements/heading/Heading.js index 8d94b6d8ff..03c16343e1 100644 --- a/entry_types/scrolled/package/src/contentElements/heading/Heading.js +++ b/entry_types/scrolled/package/src/contentElements/heading/Heading.js @@ -100,6 +100,9 @@ export function Heading({configuration, sectionProps, contentElementWidth}) { {[styles[sectionProps.layout]]: contentElementWidth > contentElementWidths.md || sectionProps.layout === 'centerRagged'}, + {[styles.centerConstrained]: + sectionProps.constrainContentWidth && + contentElementWidth === contentElementWidths.lg}, {[withShadowClassName]: !sectionProps.invert})}> {renderSubtitle('tagline')} ({ - layout: section.layout, - invert: section.invert, - sectionIndex: section.sectionIndex - }), [section.layout, section.invert, section.sectionIndex]); - const [, exitTransition] = transitions; const [motifAreaState, setMotifAreaRef, setContentAreaRef] = useMotifAreaState({ @@ -131,6 +125,15 @@ function SectionContents({ fullHeight: section.fullHeight }); + const sectionProperties = useMemo(() => ({ + layout: section.layout, + invert: section.invert, + sectionIndex: section.sectionIndex, + constrainContentWidth: section.appearance === 'split' && + !motifAreaState.isContentPadded + }), [section.layout, section.invert, section.sectionIndex, + section.appearance, motifAreaState.isContentPadded]); + const {Shadow, Box, BoxWrapper} = getAppearanceComponents(section.appearance) const staticShadowOpacity = percentToFraction(section.staticShadowOpacity, {defaultValue: 0.7}); @@ -176,8 +179,7 @@ function SectionContents({ From 8187d429ef800650c907763569b1a194b53cbe85 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 9 Mar 2026 16:05:17 +0100 Subject: [PATCH 04/12] Register --constrained-content-max-width type Ensures cqw values resolve to pixels at the layout level before inheriting into nested containers that set their own container-type. REDMINE-21203 --- .../scrolled/package/src/frontend/layouts/Center.module.css | 6 ++++-- .../package/src/frontend/layouts/TwoColumn.module.css | 6 ++++-- entry_types/scrolled/package/values/properties.css | 5 +++++ 3 files changed, 13 insertions(+), 4 deletions(-) create mode 100644 entry_types/scrolled/package/values/properties.css diff --git a/entry_types/scrolled/package/src/frontend/layouts/Center.module.css b/entry_types/scrolled/package/src/frontend/layouts/Center.module.css index beca6af811..9755350fa3 100644 --- a/entry_types/scrolled/package/src/frontend/layouts/Center.module.css +++ b/entry_types/scrolled/package/src/frontend/layouts/Center.module.css @@ -1,3 +1,5 @@ +@import 'pageflow-scrolled/values/properties.css'; + .outer { --layout-inline-content-max-width: var(--centered-inline-content-max-width, 700px); --layout-inline-lg-content-max-width: var(--centered-inline-lg-content-max-width, 950px); @@ -20,7 +22,7 @@ .box { --content-max-width: min(var(--layout-inline-content-max-width), - var(--constrained-content-max-width, 100%)); + var(--constrained-content-max-width)); margin-left: auto; margin-right: auto; max-width: var(--content-max-width); @@ -32,7 +34,7 @@ .box-lg { --content-max-width: min(var(--layout-inline-lg-content-max-width), - var(--constrained-content-max-width, 100%)); + var(--constrained-content-max-width)); } .box-xl { diff --git a/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.module.css b/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.module.css index c27fa84e30..858f2c4fc6 100644 --- a/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.module.css +++ b/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.module.css @@ -1,3 +1,5 @@ +@import 'pageflow-scrolled/values/properties.css'; + .root { --layout-inline-content-max-width: var(--two-column-inline-content-max-width, 500px); --layout-inline-lg-content-max-width: var(--two-column-inline-lg-content-max-width, 700px); @@ -26,12 +28,12 @@ .inline { --content-max-width: min(var(--layout-inline-content-max-width), - var(--constrained-content-max-width, 100%)); + var(--constrained-content-max-width)); } .inline.width-lg { --content-max-width: min(var(--layout-inline-lg-content-max-width), - var(--constrained-content-max-width, 100%)); + var(--constrained-content-max-width)); } .inline.width-xl { diff --git a/entry_types/scrolled/package/values/properties.css b/entry_types/scrolled/package/values/properties.css new file mode 100644 index 0000000000..abe516401e --- /dev/null +++ b/entry_types/scrolled/package/values/properties.css @@ -0,0 +1,5 @@ +@property --constrained-content-max-width { + syntax: ""; + inherits: true; + initial-value: 9999px; +} From d7b48fe3ef3b51a4cd3dd163f730cb63cc848a98 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 9 Mar 2026 16:26:38 +0100 Subject: [PATCH 05/12] Add backdrop blur option for split overlay Allow configuring background blur on the split overlay when the overlay color has transparency. Disabled when the color is fully opaque. REDMINE-21203 --- entry_types/scrolled/config/locales/de.yml | 4 ++ entry_types/scrolled/config/locales/en.yml | 4 ++ .../frontend/foregroundBoxes/SplitBox-spec.js | 28 ++++++++++++ .../spec/frontend/shadows/SplitShadow-spec.js | 38 ++++++++++++++++ .../spec/frontend/splitOverlayStyle-spec.js | 43 +++++++++++++++++++ .../frontend/utils/isTranslucentColor-spec.js | 23 ++++++++++ .../src/editor/views/EditSectionView.js | 11 +++++ .../scrolled/package/src/frontend/Section.js | 6 ++- .../src/frontend/foregroundBoxes/SplitBox.js | 8 ++-- .../src/frontend/shadows/SplitShadow.js | 8 ++-- .../package/src/frontend/splitOverlayStyle.js | 25 +++++++++++ .../package/src/frontend/utils/index.js | 2 + .../src/frontend/utils/isTranslucentColor.js | 3 ++ 13 files changed, 195 insertions(+), 8 deletions(-) create mode 100644 entry_types/scrolled/package/spec/frontend/splitOverlayStyle-spec.js create mode 100644 entry_types/scrolled/package/spec/frontend/utils/isTranslucentColor-spec.js create mode 100644 entry_types/scrolled/package/src/frontend/splitOverlayStyle.js create mode 100644 entry_types/scrolled/package/src/frontend/utils/isTranslucentColor.js diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 22034d9fba..bb3b921667 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1278,6 +1278,10 @@ de: splitSurfaceColor: label: Overlay-Farbe auto: "(Automatisch)" + overlayBackdropBlur: + inline_help: Stärke der Unschärfe, die auf den Hintergrund hinter dem Overlay angewendet wird. + label: Hintergrundunschärfe + inline_help_disabled: Nur verfügbar, wenn die Overlay-Farbe Transparenz hat. sectionPaddings: label: Innenabstände oben/unten tabs: diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index 2dbd03c1c1..5a96fcb07e 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1262,6 +1262,10 @@ en: splitSurfaceColor: label: Overlay color auto: "(Auto)" + overlayBackdropBlur: + inline_help: Amount of blur applied to the backdrop behind the overlay. + label: Background blur + inline_help_disabled: Only available when overlay color has transparency. sectionPaddings: label: "Top/bottom padding" tabs: diff --git a/entry_types/scrolled/package/spec/frontend/foregroundBoxes/SplitBox-spec.js b/entry_types/scrolled/package/spec/frontend/foregroundBoxes/SplitBox-spec.js index e91bd7efd1..fcfc38272f 100644 --- a/entry_types/scrolled/package/spec/frontend/foregroundBoxes/SplitBox-spec.js +++ b/entry_types/scrolled/package/spec/frontend/foregroundBoxes/SplitBox-spec.js @@ -84,6 +84,34 @@ describe('SplitBox', () => { .toHaveClass(styles.overlayLight); }); + it('applies backdrop filter when overlayBackdropBlur is set and color is translucent', () => { + const {container} = render( + +
+ + ); + + expect(container.querySelector(`.${styles.overlay}`).style.backdropFilter) + .toBe('blur(5px)'); + }); + + it('does not apply backdrop filter when color is opaque', () => { + const {container} = render( + +
+ + ); + + expect(container.querySelector(`.${styles.overlay}`).style.backdropFilter) + .toBeFalsy(); + }); + it('sets overlay background color from splitSurfaceColor', () => { const {container} = render( { .toBe(''); }); + it('applies backdrop filter when overlayBackdropBlur is set and color is translucent', () => { + const {container} = render( + +
+ + ); + + expect(container.querySelector(`.${styles.overlay}`).style.backdropFilter) + .toBe('blur(5px)'); + }); + + it('does not apply backdrop filter when color is opaque', () => { + const {container} = render( + +
+ + ); + + expect(container.querySelector(`.${styles.overlay}`).style.backdropFilter) + .toBeFalsy(); + }); + + it('does not apply backdrop filter when no color is set', () => { + const {container} = render( + +
+ + ); + + expect(container.querySelector(`.${styles.overlay}`).style.backdropFilter) + .toBeFalsy(); + }); + it('does not render overlay when isContentPadded is true', () => { const {container} = render( { + it('sets backgroundColor from color', () => { + expect(splitOverlayStyle({color: '#ff0000'})) + .toEqual({backgroundColor: '#ff0000'}); + }); + + it('sets backdropFilter when color is translucent and backdropBlur is set', () => { + expect(splitOverlayStyle({color: '#ff000080', backdropBlur: 50})) + .toEqual({backgroundColor: '#ff000080', backdropFilter: 'blur(5px)'}); + }); + + it('does not set backdropFilter when color is opaque', () => { + expect(splitOverlayStyle({color: '#ff0000', backdropBlur: 50})) + .toEqual({backgroundColor: '#ff0000'}); + }); + + it('does not set backdropFilter when backdropBlur is 0', () => { + expect(splitOverlayStyle({color: '#ff000080', backdropBlur: 0})) + .toEqual({backgroundColor: '#ff000080'}); + }); + + it('scales blur value to max 10px', () => { + expect(splitOverlayStyle({color: '#ff000080', backdropBlur: 100})) + .toEqual({backgroundColor: '#ff000080', backdropFilter: 'blur(10px)'}); + }); + + it('defaults backdropFilter for translucent color', () => { + expect(splitOverlayStyle({color: '#ff000080'})) + .toEqual({backgroundColor: '#ff000080', backdropFilter: 'blur(10px)'}); + }); + + it('defaults backdropFilter when no color is set', () => { + expect(splitOverlayStyle({})) + .toEqual({backdropFilter: 'blur(10px)'}); + }); + + it('does not default backdropFilter for opaque color', () => { + expect(splitOverlayStyle({color: '#ff0000'})) + .toEqual({backgroundColor: '#ff0000'}); + }); +}); diff --git a/entry_types/scrolled/package/spec/frontend/utils/isTranslucentColor-spec.js b/entry_types/scrolled/package/spec/frontend/utils/isTranslucentColor-spec.js new file mode 100644 index 0000000000..8f29138f79 --- /dev/null +++ b/entry_types/scrolled/package/spec/frontend/utils/isTranslucentColor-spec.js @@ -0,0 +1,23 @@ +import {isTranslucentColor} from 'frontend/utils/isTranslucentColor'; + +describe('isTranslucentColor', () => { + it('returns true for color with alpha channel', () => { + expect(isTranslucentColor('#ff000080')).toBe(true); + }); + + it('returns false for color with ff alpha', () => { + expect(isTranslucentColor('#ff0000ff')).toBe(false); + }); + + it('returns false for color without alpha', () => { + expect(isTranslucentColor('#ff0000')).toBe(false); + }); + + it('returns false for undefined', () => { + expect(isTranslucentColor(undefined)).toBe(false); + }); + + it('returns false for null', () => { + expect(isTranslucentColor(null)).toBe(false); + }); +}); diff --git a/entry_types/scrolled/package/src/editor/views/EditSectionView.js b/entry_types/scrolled/package/src/editor/views/EditSectionView.js index 87958321d3..acf7787a88 100644 --- a/entry_types/scrolled/package/src/editor/views/EditSectionView.js +++ b/entry_types/scrolled/package/src/editor/views/EditSectionView.js @@ -13,6 +13,7 @@ import {InlineFileRightsMenuItem} from '../models/InlineFileRightsMenuItem' import {createSectionMenuItems} from '../models/sectionMenuItems'; import I18n from 'i18n-js'; import {features} from 'pageflow/frontend'; +import {utils} from 'pageflow-scrolled/frontend'; import {EditMotifAreaDialogView} from './EditMotifAreaDialogView'; @@ -209,6 +210,16 @@ export const EditSectionView = EditConfigurationView.extend({ swatches: entry.getUsedSectionBackgroundColors() }); + this.input('overlayBackdropBlur', SliderInputView, { + visibleBinding: 'appearance', + visibleBindingValue: 'split', + disabledBinding: 'splitSurfaceColor', + disabled: color => !utils.isTranslucentColor(color), + values: [0, 25, 50, 75, 100], + defaultValue: 100, + saveOnSlide: true + }); + this.view(SeparatorView); this.input('atmoAudioFileId', FileInputView, { diff --git a/entry_types/scrolled/package/src/frontend/Section.js b/entry_types/scrolled/package/src/frontend/Section.js index d716ccf014..547a3ff503 100644 --- a/entry_types/scrolled/package/src/frontend/Section.js +++ b/entry_types/scrolled/package/src/frontend/Section.js @@ -156,7 +156,8 @@ function SectionContents({ motifAreaState={motifAreaState} staticShadowOpacity={staticShadowOpacity} dynamicShadowOpacity={dynamicShadowOpacity} - splitSurfaceColor={section.splitSurfaceColor}> + splitSurfaceColor={section.splitSurfaceColor} + overlayBackdropBlur={section.overlayBackdropBlur}> {children} } @@ -175,7 +176,8 @@ function SectionContents({ state={state} motifAreaState={motifAreaState} staticShadowOpacity={staticShadowOpacity} - splitSurfaceColor={section.splitSurfaceColor}> + splitSurfaceColor={section.splitSurfaceColor} + overlayBackdropBlur={section.overlayBackdropBlur}> }
{props.children} diff --git a/entry_types/scrolled/package/src/frontend/shadows/SplitShadow.js b/entry_types/scrolled/package/src/frontend/shadows/SplitShadow.js index 9663f4ff4a..8ae576a7d1 100644 --- a/entry_types/scrolled/package/src/frontend/shadows/SplitShadow.js +++ b/entry_types/scrolled/package/src/frontend/shadows/SplitShadow.js @@ -2,6 +2,7 @@ import React from 'react'; import classNames from 'classnames'; import Fullscreen from '../Fullscreen'; +import {splitOverlayStyle} from '../splitOverlayStyle'; import styles from './SplitShadow.module.css'; export default function SplitShadow(props) { @@ -18,9 +19,10 @@ export default function SplitShadow(props) { styles[`align-${props.align}`], props.inverted ? styles.light : styles.dark)}>
+ style={splitOverlayStyle({ + color: props.splitSurfaceColor, + backdropBlur: props.overlayBackdropBlur + })}>
{props.children} diff --git a/entry_types/scrolled/package/src/frontend/splitOverlayStyle.js b/entry_types/scrolled/package/src/frontend/splitOverlayStyle.js new file mode 100644 index 0000000000..fbe318b6f2 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/splitOverlayStyle.js @@ -0,0 +1,25 @@ +import {isTranslucentColor} from './utils/isTranslucentColor'; + +export function splitOverlayStyle({color, backdropBlur}) { + const style = {}; + + if (color) { + style.backgroundColor = color; + } + + const blur = resolvedBackdropBlur({color, backdropBlur}); + + if (blur > 0) { + style.backdropFilter = `blur(${blur / 100 * 10}px)`; + } + + return style; +} + +function resolvedBackdropBlur({color, backdropBlur}) { + if (color && !isTranslucentColor(color)) { + return 0; + } + + return backdropBlur ?? 100; +} diff --git a/entry_types/scrolled/package/src/frontend/utils/index.js b/entry_types/scrolled/package/src/frontend/utils/index.js index 9bbd383aaf..9051e502fb 100644 --- a/entry_types/scrolled/package/src/frontend/utils/index.js +++ b/entry_types/scrolled/package/src/frontend/utils/index.js @@ -5,11 +5,13 @@ import { isBlankEditableTextValue, presence, } from './blank'; +import {isTranslucentColor} from './isTranslucentColor'; export const utils = { capitalize, camelize, isBlank, isBlankEditableTextValue, + isTranslucentColor, presence } diff --git a/entry_types/scrolled/package/src/frontend/utils/isTranslucentColor.js b/entry_types/scrolled/package/src/frontend/utils/isTranslucentColor.js new file mode 100644 index 0000000000..46ff97d638 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/utils/isTranslucentColor.js @@ -0,0 +1,3 @@ +export function isTranslucentColor(color) { + return !!color && color.length > 7 && color.slice(-2).toLowerCase() !== 'ff'; +} From c60ccc3d63d8b7f78b10d7766c2a71fc968357fe Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 6 Mar 2026 09:01:58 +0100 Subject: [PATCH 06/12] Visual pickers for section layout and appearance Replace plain select inputs with visual previews that show how each option affects content layout. Previews adapt to current settings. Shared SectionVisualization component renders the miniature section representation. REDMINE-21203 --- .../src/editor/views/EditSectionView.js | 8 +- .../views/inputs/AppearanceSelectInputView.js | 30 +++ .../views/inputs/LayoutSelectInputView.js | 30 +++ .../views/inputs/SectionVisualization.js | 43 ++++ .../inputs/SectionVisualization.module.css | 215 ++++++++++++++++++ 5 files changed, 323 insertions(+), 3 deletions(-) create mode 100644 entry_types/scrolled/package/src/editor/views/inputs/AppearanceSelectInputView.js create mode 100644 entry_types/scrolled/package/src/editor/views/inputs/LayoutSelectInputView.js create mode 100644 entry_types/scrolled/package/src/editor/views/inputs/SectionVisualization.js create mode 100644 entry_types/scrolled/package/src/editor/views/inputs/SectionVisualization.module.css diff --git a/entry_types/scrolled/package/src/editor/views/EditSectionView.js b/entry_types/scrolled/package/src/editor/views/EditSectionView.js index acf7787a88..47c2add931 100644 --- a/entry_types/scrolled/package/src/editor/views/EditSectionView.js +++ b/entry_types/scrolled/package/src/editor/views/EditSectionView.js @@ -5,6 +5,8 @@ import { SeparatorView, SliderInputView } from 'pageflow/ui'; +import {AppearanceSelectInputView} from './inputs/AppearanceSelectInputView'; +import {LayoutSelectInputView} from './inputs/LayoutSelectInputView'; import {BackdropContentElementInputView} from './inputs/BackdropContentElementInputView'; import {EditMotifAreaInputView} from './inputs/EditMotifAreaInputView'; import {EffectListInputView} from './inputs/EffectListInputView'; @@ -156,7 +158,7 @@ export const EditSectionView = EditConfigurationView.extend({ this.view(SeparatorView); - this.input('layout', SelectInputView, { + this.input('layout', LayoutSelectInputView, { values: ['left', 'right', 'center', 'centerRagged'] }); @@ -169,8 +171,8 @@ export const EditSectionView = EditConfigurationView.extend({ values: ['wide', 'narrow'] }); } - this.input('appearance', SelectInputView, { - values: ['shadow', 'cards', 'transparent', 'split'] + this.input('appearance', AppearanceSelectInputView, { + values: ['shadow', 'cards', 'split', 'transparent'] }); this.input('invert', CheckBoxInputView); diff --git a/entry_types/scrolled/package/src/editor/views/inputs/AppearanceSelectInputView.js b/entry_types/scrolled/package/src/editor/views/inputs/AppearanceSelectInputView.js new file mode 100644 index 0000000000..e15ca45e74 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/inputs/AppearanceSelectInputView.js @@ -0,0 +1,30 @@ +import React from 'react'; + +import {ListboxInputView} from './ListboxInputView'; +import {SectionVisualization} from './SectionVisualization'; + +export const AppearanceSelectInputView = ListboxInputView.extend({ + modelEvents() { + return { + ...ListboxInputView.prototype.modelEvents.call(this), + 'change:layout': 'renderDropdown', + 'change:invert': 'renderDropdown', + 'change:exposeMotifArea': 'renderDropdown' + }; + }, + + renderItem(item) { + const layout = this.model.get('layout') || 'left'; + const isCenter = layout === 'center' || layout === 'centerRagged'; + + return ( +
+ + {item.text} +
+ ); + } +}); diff --git a/entry_types/scrolled/package/src/editor/views/inputs/LayoutSelectInputView.js b/entry_types/scrolled/package/src/editor/views/inputs/LayoutSelectInputView.js new file mode 100644 index 0000000000..d8e38be8c2 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/inputs/LayoutSelectInputView.js @@ -0,0 +1,30 @@ +import React from 'react'; + +import {ListboxInputView} from './ListboxInputView'; +import {SectionVisualization} from './SectionVisualization'; + +export const LayoutSelectInputView = ListboxInputView.extend({ + modelEvents() { + return { + ...ListboxInputView.prototype.modelEvents.call(this), + 'change:appearance': 'renderDropdown', + 'change:invert': 'renderDropdown', + 'change:exposeMotifArea': 'renderDropdown' + }; + }, + + renderItem(item) { + const appearance = this.model.get('appearance') || 'shadow'; + const isCenter = item.value === 'center' || item.value === 'centerRagged'; + + return ( +
+ + {item.text} +
+ ); + } +}); diff --git a/entry_types/scrolled/package/src/editor/views/inputs/SectionVisualization.js b/entry_types/scrolled/package/src/editor/views/inputs/SectionVisualization.js new file mode 100644 index 0000000000..734bfc53f6 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/inputs/SectionVisualization.js @@ -0,0 +1,43 @@ +import React from 'react'; +import classNames from 'classnames'; + +import styles from './SectionVisualization.module.css'; + +export function SectionVisualization({layout, appearance, invert, padded}) { + const isCards = appearance === 'cards'; + + return ( +
+ +
+
+
+
+
+
+
+
+
+
+
+
+ ); +} + +function Overlay({appearance, invert, padded}) { + switch (appearance) { + case 'shadow': + return
; + case 'cards': + return
; + case 'split': + return
; + default: + return null; + } +} diff --git a/entry_types/scrolled/package/src/editor/views/inputs/SectionVisualization.module.css b/entry_types/scrolled/package/src/editor/views/inputs/SectionVisualization.module.css new file mode 100644 index 0000000000..2869525030 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/inputs/SectionVisualization.module.css @@ -0,0 +1,215 @@ +.preview { + --content-width: 35%; + aspect-ratio: 16 / 9; + border: solid 1px var(--ui-on-surface-color-lightest); + border-radius: rounded(sm); + margin-bottom: space(1); + background-color: var(--ui-primary-color-light); + color: #ddd; + padding: space(8) 0; + overflow: hidden; + position: relative; + max-width: 260px; + box-sizing: border-box; + margin-left: auto; + margin-right: auto; +} + +.inverted { + color: #222; +} + +.center, +.centerRagged { + --content-width: 45%; +} + +.padded { + padding-top: space(16); +} + +/* Content text area */ + +.content { + position: relative; + z-index: 1; + width: var(--content-width); + margin-left: space(8); +} + +.right .content { + margin-left: auto; + margin-right: space(8); +} + +.center .content, +.centerRagged .content { + margin-left: auto; + margin-right: auto; +} + +.line { + height: 2px; + background: currentColor; + border-radius: 1px; + margin-bottom: space(2.5); +} + +.shortLine { + width: 60%; +} + +.centerRagged .shortLine { + margin-left: auto; + margin-right: auto; +} + +.gap { + height: space(3); +} + +/* Shadow appearance: gradient overlay on content side */ + +.shadowOverlay { + position: absolute; + top: 0; + bottom: 0; + width: 70%; +} + +.left .shadowOverlay { + left: 0; + background: linear-gradient(to right, rgba(0, 0, 0, 0.55), transparent); +} + +.right .shadowOverlay { + right: 0; + background: linear-gradient(to left, rgba(0, 0, 0, 0.55), transparent); +} + +.center .shadowOverlay, +.centerRagged .shadowOverlay { + left: 0; + width: 100%; + background: rgba(0, 0, 0, 0.35); +} + +.center .shadowOverlay.padded, +.centerRagged .shadowOverlay.padded { + background: linear-gradient(to top, rgba(0, 0, 0, 0.55), transparent); +} + +.left .shadowOverlay.light { + background: linear-gradient(to right, rgba(255, 255, 255, 0.55), transparent); +} + +.right .shadowOverlay.light { + background: linear-gradient(to left, rgba(255, 255, 255, 0.55), transparent); +} + +.center .shadowOverlay.light, +.centerRagged .shadowOverlay.light { + background: rgba(255, 255, 255, 0.35); +} + +.center .shadowOverlay.light.padded, +.centerRagged .shadowOverlay.light.padded { + background: linear-gradient(to top, rgba(255, 255, 255, 0.55), transparent); +} + +.cards .content { + width: calc(var(--content-width) - space(4)); + margin-left: calc(space(8) + space(2)); +} + +.cards.right .content { + margin-left: auto; + margin-right: calc(space(8) + space(2)); +} + +.cards.center .content, +.cards.centerRagged .content { + margin-left: auto; + margin-right: auto; +} + +/* Cards appearance: box behind content */ + +.cardBox { + position: absolute; + top: space(4); + bottom: 0; + width: var(--content-width); + background: rgba(255, 255, 255, 0.85); + border-radius: calc(var(--theme-cards-border-radius, 15px) / 3) + calc(var(--theme-cards-border-radius, 15px) / 3) + 0 0; +} + +.cardBox.dark { + background: rgba(0, 0, 0, 0.85); +} + +.left .cardBox { + left: space(8); +} + +.right .cardBox { + right: space(8); +} + +.center .cardBox, +.centerRagged .cardBox { + left: 50%; + transform: translateX(-50%); +} + +.cardBox.padded { + top: calc(space(8) + space(4)); +} + +/* Split appearance: 50% overlay with reduced content width */ + +.split { + --content-width: calc(50% - space(8) * 2); +} + +.split.center, +.split.centerRagged { + --content-width: calc(50% - space(8) * 2); +} + +.splitOverlay { + position: absolute; + top: 0; + bottom: 0; + width: 50%; + background: rgba(0, 0, 0, 0.55); +} + +.splitOverlay.light { + background: rgba(255, 255, 255, 0.55); +} + +.left .splitOverlay { + left: 0; +} + +.right .splitOverlay { + right: 0; +} + +.center .splitOverlay, +.centerRagged .splitOverlay { + left: 50%; + transform: translateX(-50%); +} + +.center .splitOverlay.padded, +.centerRagged .splitOverlay.padded { + left: 0; + right: 0; + width: auto; + transform: none; + top: calc(space(8) + space(4)); +} From ca4f2c2ab8838b3c6e837f44f74f6ec04115b068 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 9 Mar 2026 17:21:31 +0100 Subject: [PATCH 07/12] Support backdrop blur for cards layout Extend the existing backdrop blur option (previously only available for the split overlay) to also work with the cards appearance. Enable alpha channel for the card surface color picker so users can create translucent cards with blur effects. REDMINE-21240 --- .../foregroundBoxes/CardBoxWrapper-spec.js | 78 +++++++++++++++++++ .../src/editor/views/EditSectionView.js | 10 ++- .../scrolled/package/src/frontend/Section.js | 1 + .../foregroundBoxes/CardBoxWrapper.js | 24 +++++- .../foregroundBoxes/CardBoxWrapper.module.css | 4 + 5 files changed, 112 insertions(+), 5 deletions(-) diff --git a/entry_types/scrolled/package/spec/frontend/foregroundBoxes/CardBoxWrapper-spec.js b/entry_types/scrolled/package/spec/frontend/foregroundBoxes/CardBoxWrapper-spec.js index 98a400b64c..5e6ce49118 100644 --- a/entry_types/scrolled/package/spec/frontend/foregroundBoxes/CardBoxWrapper-spec.js +++ b/entry_types/scrolled/package/spec/frontend/foregroundBoxes/CardBoxWrapper-spec.js @@ -50,6 +50,84 @@ describe('CardBoxWrapper', () => { }); }); + describe('backdrop blur', () => { + it('applies blur class when overlayBackdropBlur is set and color is translucent', () => { + const {container} = render( + + Content + + ); + + expect(container.firstChild).toHaveClass(cardBoxStyles.blur); + }); + + it('does not apply blur class when color is opaque', () => { + const {container} = render( + + Content + + ); + + expect(container.firstChild).not.toHaveClass(cardBoxStyles.blur); + }); + + it('does not apply blur when everything is default', () => { + const {container} = render( + + Content + + ); + + expect(container.firstChild).not.toHaveClass(cardBoxStyles.blur); + }); + + it('applies blur class by default for translucent color', () => { + const {container} = render( + + Content + + ); + + expect(container.firstChild).toHaveClass(cardBoxStyles.blur); + }); + + it('sets backdrop blur CSS variable by default for translucent color', () => { + const {container} = render( + + Content + + ); + + expect(container.firstChild.style.getPropertyValue('--card-backdrop-blur')) + .toBe('blur(10px)'); + }); + + it('does not apply blur class when overlayBackdropBlur is 0', () => { + const {container} = render( + + Content + + ); + + expect(container.firstChild).not.toHaveClass(cardBoxStyles.blur); + }); + + it('sets backdrop blur CSS variable when color is translucent', () => { + const {container} = render( + + Content + + ); + + expect(container.firstChild.style.getPropertyValue('--card-backdrop-blur')) + .toBe('blur(5px)'); + }); + }); + describe('cardEnd padding', () => { it('does not have cardEndPadding class when lastMarginBottom is set', () => { const {container} = render( diff --git a/entry_types/scrolled/package/src/editor/views/EditSectionView.js b/entry_types/scrolled/package/src/editor/views/EditSectionView.js index 47c2add931..d6cab852ec 100644 --- a/entry_types/scrolled/package/src/editor/views/EditSectionView.js +++ b/entry_types/scrolled/package/src/editor/views/EditSectionView.js @@ -196,6 +196,7 @@ export const EditSectionView = EditConfigurationView.extend({ this.input('cardSurfaceColor', ColorInputView, { visibleBinding: 'appearance', visibleBindingValue: 'cards', + alpha: true, placeholder: I18n.t('pageflow_scrolled.editor.edit_section.attributes.cardSurfaceColor.auto'), placeholderColorBinding: 'invert', placeholderColor: invert => invert ? '#101010' : '#ffffff', @@ -214,9 +215,12 @@ export const EditSectionView = EditConfigurationView.extend({ this.input('overlayBackdropBlur', SliderInputView, { visibleBinding: 'appearance', - visibleBindingValue: 'split', - disabledBinding: 'splitSurfaceColor', - disabled: color => !utils.isTranslucentColor(color), + visible: appearance => appearance === 'split' || appearance === 'cards', + disabledBinding: ['appearance', 'splitSurfaceColor', 'cardSurfaceColor'], + disabled: ([appearance, splitSurfaceColor, cardSurfaceColor]) => + appearance === 'split' + ? splitSurfaceColor && !utils.isTranslucentColor(splitSurfaceColor) + : !utils.isTranslucentColor(cardSurfaceColor), values: [0, 25, 50, 75, 100], defaultValue: 100, saveOnSlide: true diff --git a/entry_types/scrolled/package/src/frontend/Section.js b/entry_types/scrolled/package/src/frontend/Section.js index 547a3ff503..ac945f0d15 100644 --- a/entry_types/scrolled/package/src/frontend/Section.js +++ b/entry_types/scrolled/package/src/frontend/Section.js @@ -188,6 +188,7 @@ function SectionContents({ {(children, boxProps) => {children} } diff --git a/entry_types/scrolled/package/src/frontend/foregroundBoxes/CardBoxWrapper.js b/entry_types/scrolled/package/src/frontend/foregroundBoxes/CardBoxWrapper.js index 85d5bdfd87..ace4a857cb 100644 --- a/entry_types/scrolled/package/src/frontend/foregroundBoxes/CardBoxWrapper.js +++ b/entry_types/scrolled/package/src/frontend/foregroundBoxes/CardBoxWrapper.js @@ -4,6 +4,7 @@ import classNames from 'classnames'; import {widths} from '../layouts'; import {BackgroundColorProvider} from '../backgroundColor'; import {TrimDefaultMarginTop} from '../TrimDefaultMarginTop'; +import {isTranslucentColor} from '../utils/isTranslucentColor'; import styles from "./CardBoxWrapper.module.css"; import boundaryMarginStyles from "./BoxBoundaryMargin.module.css"; @@ -19,7 +20,7 @@ export default function CardBoxWrapper(props) { return (
+ style={cardStyle(props)}> {props.children} @@ -33,11 +34,30 @@ function outsideBox(props) { props.customMargin } +function cardStyle(props) { + const style = {'--card-surface-color': props.cardSurfaceColor}; + const blur = resolvedBackdropBlur(props); + + if (blur > 0) { + style['--card-backdrop-blur'] = `blur(${blur / 100 * 10}px)`; + } + + return style; +} + +function resolvedBackdropBlur(props) { + if (!isTranslucentColor(props.cardSurfaceColor)) { + return 0; + } + + return props.overlayBackdropBlur ?? 100; +} + function className(props) { return classNames( styles.card, props.inverted ? styles.cardBgBlack : styles.cardBgWhite, - {[styles.blur]: props.cardSurfaceTransparency > 0}, + {[styles.blur]: resolvedBackdropBlur(props) > 0}, {[styles.cardStart]: !props.openStart}, {[styles.cardEnd]: !props.openEnd}, {[styles.cardEndPadding]: !props.openEnd && !props.lastMarginBottom}, diff --git a/entry_types/scrolled/package/src/frontend/foregroundBoxes/CardBoxWrapper.module.css b/entry_types/scrolled/package/src/frontend/foregroundBoxes/CardBoxWrapper.module.css index 2ef8b596b3..bb9060a4dd 100644 --- a/entry_types/scrolled/package/src/frontend/foregroundBoxes/CardBoxWrapper.module.css +++ b/entry_types/scrolled/package/src/frontend/foregroundBoxes/CardBoxWrapper.module.css @@ -54,6 +54,10 @@ composes: scope-lightContent from global; } +.blur::before { + backdrop-filter: var(--card-backdrop-blur); +} + @media screen { .cardBgWhite::before { background-color: var(--card-surface-color, lightContentSurfaceColor); From 36212f4bc7609396329cc0cdb55aedaabda91ada Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 5 Mar 2026 15:01:57 +0100 Subject: [PATCH 08/12] Add fallbackColor to ColorPicker Show the placeholder color when the picker opens with no value instead of defaulting to white. The fallback only affects the display color, not the saved value. Wire up placeholderColor as fallbackColor in ColorInputView. --- .../pageflow/ui/input/color_input.scss | 1 - entry_types/scrolled/config/locales/de.yml | 2 + entry_types/scrolled/config/locales/en.yml | 2 + .../src/editor/views/EditSectionView.js | 2 + package/spec/ui/views/ColorPicker-spec.js | 106 ++++++++++++++++++ .../ui/views/inputs/ColorInputView-spec.js | 56 +++++---- package/src/ui/views/ColorPicker.js | 32 +++++- package/src/ui/views/inputs/ColorInputView.js | 16 +-- 8 files changed, 171 insertions(+), 46 deletions(-) diff --git a/app/assets/stylesheets/pageflow/ui/input/color_input.scss b/app/assets/stylesheets/pageflow/ui/input/color_input.scss index f7849bd7ce..520b51e5b9 100644 --- a/app/assets/stylesheets/pageflow/ui/input/color_input.scss +++ b/app/assets/stylesheets/pageflow/ui/input/color_input.scss @@ -1,6 +1,5 @@ .color_input { .color_picker-field { - color: var(--placeholder-color, transparent); box-sizing: border-box; width: 100%; } diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index bb3b921667..db19c1b9c5 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1275,9 +1275,11 @@ de: cardSurfaceColor: label: Kartenhintergrundfarbe auto: "(Automatisch)" + auto_color: "Automatische Farbe" splitSurfaceColor: label: Overlay-Farbe auto: "(Automatisch)" + auto_color: "Automatische Farbe" overlayBackdropBlur: inline_help: Stärke der Unschärfe, die auf den Hintergrund hinter dem Overlay angewendet wird. label: Hintergrundunschärfe diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index 5a96fcb07e..231e3dcdb0 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1259,9 +1259,11 @@ en: cardSurfaceColor: label: Cards background color auto: "(Auto)" + auto_color: "Auto color" splitSurfaceColor: label: Overlay color auto: "(Auto)" + auto_color: "Auto color" overlayBackdropBlur: inline_help: Amount of blur applied to the backdrop behind the overlay. label: Background blur diff --git a/entry_types/scrolled/package/src/editor/views/EditSectionView.js b/entry_types/scrolled/package/src/editor/views/EditSectionView.js index d6cab852ec..5731f75ca9 100644 --- a/entry_types/scrolled/package/src/editor/views/EditSectionView.js +++ b/entry_types/scrolled/package/src/editor/views/EditSectionView.js @@ -200,6 +200,7 @@ export const EditSectionView = EditConfigurationView.extend({ placeholder: I18n.t('pageflow_scrolled.editor.edit_section.attributes.cardSurfaceColor.auto'), placeholderColorBinding: 'invert', placeholderColor: invert => invert ? '#101010' : '#ffffff', + placeholderColorDescription: I18n.t('pageflow_scrolled.editor.edit_section.attributes.cardSurfaceColor.auto_color'), swatches: entry.getUsedSectionBackgroundColors() }); @@ -210,6 +211,7 @@ export const EditSectionView = EditConfigurationView.extend({ placeholder: I18n.t('pageflow_scrolled.editor.edit_section.attributes.splitSurfaceColor.auto'), placeholderColorBinding: 'invert', placeholderColor: invert => invert ? '#ffffffb3' : '#000000b3', + placeholderColorDescription: I18n.t('pageflow_scrolled.editor.edit_section.attributes.splitSurfaceColor.auto_color'), swatches: entry.getUsedSectionBackgroundColors() }); diff --git a/package/spec/ui/views/ColorPicker-spec.js b/package/spec/ui/views/ColorPicker-spec.js index e4af63d03b..add4d3f183 100644 --- a/package/spec/ui/views/ColorPicker-spec.js +++ b/package/spec/ui/views/ColorPicker-spec.js @@ -478,6 +478,112 @@ describe('ColorPicker', () => { }); }); + describe('fallbackColor option', () => { + it('uses fallbackColor for display when opened with no value', () => { + createColorPicker({fallbackColor: '#ff0000'}); + + open(); + + expect(colorPicker._displayColor).toEqual({r: 255, g: 0, b: 0, a: 1}); + }); + + it('does not set fallbackColor as currentColor', () => { + createColorPicker({fallbackColor: '#ff0000'}); + + open(); + + expect(colorPicker._currentColor).toBeNull(); + }); + + it('does not write fallbackColor to input', () => { + createColorPicker({fallbackColor: '#ff0000'}); + + open(); + + expect(input.value).toBe(''); + }); + + it('sets wrapper color to fallbackColor when input is cleared', () => { + createColorPicker({value: '#00ff00', fallbackColor: '#ff0000'}); + + input.value = ''; + input.dispatchEvent(new Event('input', {bubbles: true})); + + expect(input.parentNode).toHaveStyle('color: #ff0000'); + }); + + it('sets wrapper color to updated fallbackColor', () => { + createColorPicker({fallbackColor: '#ff0000'}); + + colorPicker.update({fallbackColor: '#00ff00'}); + + expect(input.parentNode).toHaveStyle('color: #00ff00'); + }); + + it('uses updated fallbackColor', () => { + createColorPicker({fallbackColor: '#ff0000'}); + + colorPicker.update({fallbackColor: '#00ff00'}); + open(); + + expect(colorPicker._displayColor).toEqual({r: 0, g: 255, b: 0, a: 1}); + }); + + it('sets wrapper color to fallbackColor when no value is set', () => { + createColorPicker({fallbackColor: '#ff0000'}); + + expect(input.parentNode).toHaveStyle('color: #ff0000'); + }); + + it('sets aria-description on input when displaying fallback', () => { + createColorPicker({ + fallbackColor: '#ff0000', + fallbackColorDescription: 'Automatic color' + }); + + expect(input).toHaveAccessibleDescription('Automatic color: #ff0000'); + }); + + it('does not set aria-description without fallbackColorDescription', () => { + createColorPicker({fallbackColor: '#ff0000'}); + + expect(input).not.toHaveAccessibleDescription(); + }); + + it('clears aria-description when color is set', () => { + createColorPicker({ + fallbackColor: '#ff0000', + fallbackColorDescription: 'Automatic color', + swatches: ['#00ff00'] + }); + open(); + + picker().querySelector('.color_picker-swatches button') + .dispatchEvent(new Event('click', {bubbles: true})); + + expect(input).not.toHaveAccessibleDescription(); + }); + + it('updates aria-description when fallbackColor changes', () => { + createColorPicker({ + fallbackColor: '#ff0000', + fallbackColorDescription: 'Automatic color' + }); + + colorPicker.update({fallbackColor: '#00ff00'}); + + expect(input).toHaveAccessibleDescription('Automatic color: #00ff00'); + }); + + it('prefers defaultColor over fallbackColor', () => { + createColorPicker({defaultValue: '#0000ff', fallbackColor: '#ff0000'}); + + open(); + + expect(colorPicker._displayColor).toEqual({r: 0, g: 0, b: 255, a: 1}); + }); + }); + describe('destroy', () => { it('removes picker element from DOM', () => { createColorPicker(); diff --git a/package/spec/ui/views/inputs/ColorInputView-spec.js b/package/spec/ui/views/inputs/ColorInputView-spec.js index a2cac9650a..5123ec81e2 100644 --- a/package/spec/ui/views/inputs/ColorInputView-spec.js +++ b/package/spec/ui/views/inputs/ColorInputView-spec.js @@ -3,6 +3,7 @@ import sinon from 'sinon'; import '@testing-library/jest-dom/extend-expect'; import {ColorInputView} from 'pageflow/ui'; +import {renderBackboneView} from 'testHelpers/renderBackboneView'; import {ColorInput} from '$support/dominos/ui' @@ -495,54 +496,47 @@ describe('pageflow.ColorInputView', () => { }); describe('with placeholderColor option', () => { - it('sets custom property', () => { - var colorInputView = new ColorInputView({ + it('sets fallback color', () => { + const {getByRole} = renderBackboneView(new ColorInputView({ model: new Backbone.Model(), propertyName: 'color', - placeholderColor: '#fff' - }); - - var colorInput = ColorInput.render( - colorInputView - ); + placeholderColor: '#ff0000', + placeholderColorDescription: 'Automatic color' + })); - expect(colorInput.$el[0]).toHaveStyle('--placeholder-color: #fff'); + expect(getByRole('textbox')) + .toHaveAccessibleDescription('Automatic color: #ff0000'); }); }); - describe('with function as placeholderColor and placeholderColorBinding option', () => { - it('sets custom property', () => { - var model = new Backbone.Model(); - var colorInputView = new ColorInputView({ - model, + describe('with placeholderColor and placeholderColorBinding option', () => { + it('sets fallback color', () => { + const {getByRole} = renderBackboneView(new ColorInputView({ + model: new Backbone.Model(), propertyName: 'color', placeholderColorBinding: 'invert', - placeholderColor: invert => invert ? '#000' : '#fff' - }); + placeholderColor: invert => invert ? '#000000' : '#ffffff', + placeholderColorDescription: 'Automatic color' + })); - var colorInput = ColorInput.render( - colorInputView - ); - - expect(colorInput.$el[0]).toHaveStyle('--placeholder-color: #fff'); + expect(getByRole('textbox')) + .toHaveAccessibleDescription('Automatic color: #ffffff'); }); - it('updates custom property', () => { - var model = new Backbone.Model(); - var colorInputView = new ColorInputView({ + it('updates fallback color', () => { + const model = new Backbone.Model(); + const {getByRole} = renderBackboneView(new ColorInputView({ model, propertyName: 'color', placeholderColorBinding: 'invert', - placeholderColor: invert => invert ? '#000' : '#fff' - }); - - var colorInput = ColorInput.render( - colorInputView - ); + placeholderColor: invert => invert ? '#000000' : '#ffffff', + placeholderColorDescription: 'Automatic color' + })); model.set('invert', true); - expect(colorInput.$el[0]).toHaveStyle('--placeholder-color: #000'); + expect(getByRole('textbox')) + .toHaveAccessibleDescription('Automatic color: #000000'); }); }); }); diff --git a/package/src/ui/views/ColorPicker.js b/package/src/ui/views/ColorPicker.js index 7aa0913f19..35655792f3 100644 --- a/package/src/ui/views/ColorPicker.js +++ b/package/src/ui/views/ColorPicker.js @@ -3,7 +3,8 @@ const ctx = typeof OffscreenCanvas !== 'undefined' && new OffscreenCanvas(1, 1).getContext('2d'); -const FALLBACK_COLOR = {r: 255, g: 255, b: 255, a: 1}; +const DEFAULT_DISPLAY_COLOR = {r: 255, g: 255, b: 255, a: 1}; +let nextDescriptionId = 0; const PICKER_HTML = '
' + @@ -27,6 +28,8 @@ export default class ColorPicker { this._alpha = options.alpha || false; this._onChange = options.onChange; this._defaultColor = strToRGBA(options.defaultValue); + this._fallbackColor = strToRGBA(options.fallbackColor); + this._fallbackColorDescription = options.fallbackColorDescription; this._swatches = options.swatches || []; this._wrapInput(); @@ -36,10 +39,19 @@ export default class ColorPicker { this._updateColor(strToRGBA(this._input.value), {silent: true}); } + setValue(str) { + this._input.value = str || ''; + this._updateColor(strToRGBA(str), {silent: true}); + } + update(options) { if ('defaultValue' in options) { this._defaultColor = strToRGBA(options.defaultValue); } + if ('fallbackColor' in options) { + this._fallbackColor = strToRGBA(options.fallbackColor); + this._updateColor(this._currentColor, {silent: true}); + } if (options.swatches) { this._swatches = options.swatches; this._renderSwatches(); @@ -80,6 +92,14 @@ export default class ColorPicker { parent.insertBefore(wrapper, this._input); wrapper.className = 'color_picker-field'; wrapper.appendChild(this._input); + + if (this._fallbackColorDescription) { + this._descriptionElement = document.createElement('span'); + this._descriptionElement.hidden = true; + this._descriptionElement.id = 'color_picker_desc_' + nextDescriptionId++; + wrapper.appendChild(this._descriptionElement); + this._input.setAttribute('aria-describedby', this._descriptionElement.id); + } } } @@ -255,7 +275,7 @@ export default class ColorPicker { if (rgba && !this._alpha) rgba.a = 1; this._currentColor = rgba && {...this._displayColor, ...rgba}; - this._displayColor = this._currentColor || FALLBACK_COLOR; + this._displayColor = this._currentColor || this._fallbackColor || DEFAULT_DISPLAY_COLOR; const hex = rgbaToHex(this._displayColor); const opaqueHex = hex.substring(0, 7); @@ -265,9 +285,15 @@ export default class ColorPicker { this._alphaMarker.style.color = hex; const formatted = this._formatHex(this._currentColor); + const fallbackHex = this._formatHex(this._fallbackColor); const wrapper = this._input.parentNode; if (wrapper && wrapper.classList.contains('color_picker-field')) { - wrapper.style.color = formatted || ''; + wrapper.style.color = formatted || fallbackHex || ''; + } + + if (this._descriptionElement) { + this._descriptionElement.textContent = + !formatted && fallbackHex ? `${this._fallbackColorDescription}: ${fallbackHex}` : ''; } // Force repaint the color and alpha gradients (Chrome workaround) diff --git a/package/src/ui/views/inputs/ColorInputView.js b/package/src/ui/views/inputs/ColorInputView.js index 09ed2f6727..ef7e3b89a4 100644 --- a/package/src/ui/views/inputs/ColorInputView.js +++ b/package/src/ui/views/inputs/ColorInputView.js @@ -65,6 +65,8 @@ export const ColorInputView = Marionette.ItemView.extend({ this._colorPicker = new ColorPicker(this.ui.input[0], { alpha: this.options.alpha, defaultValue: this.defaultValue(), + fallbackColor: this.getAttributeBoundOption('placeholderColor'), + fallbackColorDescription: this.options.placeholderColorDescription, swatches: this.getSwatches(), onChange: _.debounce(_.bind(this._onChange, this), 200) }); @@ -79,11 +81,8 @@ export const ColorInputView = Marionette.ItemView.extend({ }, updatePlaceholderColor(value) { - if (value) { - this.el.style.setProperty('--placeholder-color', value); - } - else { - this.el.style.removeProperty('--placeholder-color'); + if (this._colorPicker) { + this._colorPicker.update({fallbackColor: value}); } }, @@ -100,12 +99,7 @@ export const ColorInputView = Marionette.ItemView.extend({ var color = this.model.get(this.options.propertyName) || this.defaultValue() || ''; if (!this._saving) { - this.ui.input[0].value = color; - - var wrapper = this.ui.input[0].parentNode; - if (wrapper && wrapper.classList.contains('color_picker-field')) { - wrapper.style.color = color; - } + this._colorPicker.setValue(color); } this.$el.toggleClass('is_default', !this.model.has(this.options.propertyName)); From 7879fea7d73b16207b9e02d3d629d8fc09041df7 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 12 Mar 2026 11:13:21 +0100 Subject: [PATCH 09/12] Modernize ColorInputView specs Replace custom ColorInput domino with renderBackboneView and Testing Library queries. Use userEvent for more realistic interaction testing. Add disableChangeDebounce option so tests can run synchronously without fake timers. REDMINE-21203 --- .../ui/views/inputs/ColorInputView-spec.js | 470 ++++++------------ package/src/testHelpers/dominos/ui/index.js | 1 - .../dominos/ui/inputs/ColorInput.js | 28 -- package/src/ui/views/inputs/ColorInputView.js | 7 +- 4 files changed, 158 insertions(+), 348 deletions(-) delete mode 100644 package/src/testHelpers/dominos/ui/inputs/ColorInput.js diff --git a/package/spec/ui/views/inputs/ColorInputView-spec.js b/package/spec/ui/views/inputs/ColorInputView-spec.js index 5123ec81e2..36962b138c 100644 --- a/package/spec/ui/views/inputs/ColorInputView-spec.js +++ b/package/spec/ui/views/inputs/ColorInputView-spec.js @@ -1,377 +1,245 @@ import Backbone from 'backbone'; -import sinon from 'sinon'; import '@testing-library/jest-dom/extend-expect'; +import userEvent from '@testing-library/user-event'; import {ColorInputView} from 'pageflow/ui'; import {renderBackboneView} from 'testHelpers/renderBackboneView'; -import {ColorInput} from '$support/dominos/ui' - describe('pageflow.ColorInputView', () => { - let testContext; + const user = userEvent.setup({delay: null}); - beforeEach(() => { - testContext = {}; - }); + function render(options) { + return renderBackboneView(new ColorInputView({ + disableChangeDebounce: true, + ...options + })); + } - beforeEach(() => { - testContext.clock = sinon.useFakeTimers(); - }); - - afterEach(() => { - testContext.clock.restore(); - }); + async function fillIn(input, value) { + await user.clear(input); + await user.type(input, value); + } it('loads value into input', () => { - var model = new Backbone.Model({ - color: '#ababab' - }); - var colorInputView = new ColorInputView({ - model: model, + const {getByRole} = render({ + model: new Backbone.Model({color: '#ababab'}), propertyName: 'color' }); - var colorInput = ColorInput.render(colorInputView); - - expect(colorInput.value()).toBe('#ababab'); + expect(getByRole('textbox')).toHaveValue('#ababab'); }); it('updates input when model changes', () => { - var model = new Backbone.Model(); - var colorInputView = new ColorInputView({ - model: model, - propertyName: 'color' - }); + const model = new Backbone.Model(); + const {getByRole} = render({model, propertyName: 'color'}); - var colorInput = ColorInput.render(colorInputView); model.set('color', '#ababab'); - expect(colorInput.value()).toBe('#ababab'); + expect(getByRole('textbox')).toHaveValue('#ababab'); }); - it('saves value to model on change', () => { - var model = new Backbone.Model({ - color: '#ababab' - }); - var colorInputView = new ColorInputView({ - model: model, - propertyName: 'color' - }); + it('saves value to model on change', async () => { + const model = new Backbone.Model({color: '#ababab'}); + const {getByRole} = render({model, propertyName: 'color'}); - var colorInput = ColorInput.render( - colorInputView - ); - colorInput.fillIn('#bbb', testContext.clock); + await fillIn(getByRole('textbox'), '#bbb'); expect(model.get('color')).toBe('#bbbbbb'); }); - it('does not update color input with normalized value', () => { - var model = new Backbone.Model({ - color: '#ababab' - }); - var colorInputView = new ColorInputView({ - model: model, - propertyName: 'color' - }); + it('does not update color input with normalized value', async () => { + const model = new Backbone.Model({color: '#ababab'}); + const {getByRole} = render({model, propertyName: 'color'}); - var colorInput = ColorInput.render( - colorInputView - ); - colorInput.fillIn('#bbb', testContext.clock); + await fillIn(getByRole('textbox'), '#bbb'); - expect(colorInput.value()).toBe('#bbb'); + expect(getByRole('textbox')).toHaveValue('#bbb'); }); it('allows passing swatches', () => { - var model = new Backbone.Model(); - var colorInputView = new ColorInputView({ - model: model, + const {getAllByRole} = render({ + model: new Backbone.Model(), propertyName: 'color', swatches: ['#cdcdcd', '#dedede'] }); - var colorInput = ColorInput.render( - colorInputView - ); - - expect(colorInput.swatches()).toEqual(['rgb(205, 205, 205)', 'rgb(222, 222, 222)']); + const buttons = getAllByRole('button'); + expect(buttons).toHaveLength(2); + expect(buttons[0]).toHaveTextContent('#cdcdcd'); + expect(buttons[1]).toHaveTextContent('#dedede'); }); describe('with defaultValue option', () => { it('falls back to default value', () => { - var model = new Backbone.Model(); - var colorInputView = new ColorInputView({ - model: model, + const {getByRole} = render({ + model: new Backbone.Model(), propertyName: 'color', defaultValue: '#cdcdcd' }); - var colorInput = ColorInput.render( - colorInputView - ); - - expect(colorInput.value()).toBe('#cdcdcd'); + expect(getByRole('textbox')).toHaveValue('#cdcdcd'); }); - it('does not store default value in model', () => { - var model = new Backbone.Model(); - var colorInputView = new ColorInputView({ - model: model, - propertyName: 'color', - defaultValue: '#cdcdcd' - }); + it('does not store default value in model', async () => { + const model = new Backbone.Model(); + const {getByRole} = render({model, propertyName: 'color', defaultValue: '#cdcdcd'}); - var colorInput = ColorInput.render( - colorInputView - ); - colorInput.fillIn('#cdcdcd', testContext.clock); + await fillIn(getByRole('textbox'), '#cdcdcd'); expect(model.has('color')).toBe(false); }); - it('stores non default value in model', () => { - var model = new Backbone.Model(); - var colorInputView = new ColorInputView({ - model: model, - propertyName: 'color', - defaultValue: '#cdcdcd' - }); + it('stores non default value in model', async () => { + const model = new Backbone.Model(); + const {getByRole} = render({model, propertyName: 'color', defaultValue: '#cdcdcd'}); - var colorInput = ColorInput.render( - colorInputView - ); - colorInput.fillIn('#ababab', testContext.clock); + await fillIn(getByRole('textbox'), '#ababab'); expect(model.get('color')).toBe('#ababab'); }); - it('unsets attribute in model if choosing default value', () => { - var model = new Backbone.Model({ - color: '#fff' - }); - var colorInputView = new ColorInputView({ - model: model, - propertyName: 'color', - defaultValue: '#cdcdcd' - }); + it('unsets attribute in model if choosing default value', async () => { + const model = new Backbone.Model({color: '#fff'}); + const {getByRole} = render({model, propertyName: 'color', defaultValue: '#cdcdcd'}); - var colorInput = ColorInput.render( - colorInputView - ); - colorInput.fillIn('#cdcdcd', testContext.clock); + await fillIn(getByRole('textbox'), '#cdcdcd'); expect(model.has('color')).toBe(false); }); it('includes swatch for default value', () => { - var model = new Backbone.Model(); - var colorInputView = new ColorInputView({ - model: model, + const {getAllByRole} = render({ + model: new Backbone.Model(), propertyName: 'color', defaultValue: '#cdcdcd', swatches: ['#dedede'] }); - var colorInput = ColorInput.render( - colorInputView - ); - - expect(colorInput.swatches()).toEqual(['rgb(205, 205, 205)', 'rgb(222, 222, 222)']); + const buttons = getAllByRole('button'); + expect(buttons).toHaveLength(2); + expect(buttons[0]).toHaveTextContent('#cdcdcd'); + expect(buttons[1]).toHaveTextContent('#dedede'); }); it('does not duplicate swatch', () => { - var model = new Backbone.Model(); - var colorInputView = new ColorInputView({ - model: model, + const {getAllByRole} = render({ + model: new Backbone.Model(), propertyName: 'color', defaultValue: '#cdcdcd', swatches: ['#dedede', '#cdcdcd'] }); - var colorInput = ColorInput.render( - colorInputView - ); - - expect(colorInput.swatches()).toEqual(['rgb(205, 205, 205)', 'rgb(222, 222, 222)']); + expect(getAllByRole('button')).toHaveLength(2); }); }); describe('with function as defaultValue option', () => { it('falls back to default value', () => { - var model = new Backbone.Model(); - var colorInputView = new ColorInputView({ - model: model, + const {getByRole} = render({ + model: new Backbone.Model(), propertyName: 'color', - defaultValue: function() { return '#cdcdcd'; } + defaultValue: () => '#cdcdcd' }); - var colorInput = ColorInput.render( - colorInputView - ); - - expect(colorInput.value()).toBe('#cdcdcd'); + expect(getByRole('textbox')).toHaveValue('#cdcdcd'); }); - it('does not store default value in model', () => { - var model = new Backbone.Model(); - var colorInputView = new ColorInputView({ - model: model, - propertyName: 'color', - defaultValue: function() { return '#cdcdcd'; } + it('does not store default value in model', async () => { + const model = new Backbone.Model(); + const {getByRole} = render({ + model, propertyName: 'color', + defaultValue: () => '#cdcdcd' }); - var colorInput = ColorInput.render( - colorInputView - ); - colorInput.fillIn('#cdcdcd', testContext.clock); + await fillIn(getByRole('textbox'), '#cdcdcd'); expect(model.has('color')).toBe(false); }); - it('stores non default value in model', () => { - var model = new Backbone.Model(); - var colorInputView = new ColorInputView({ - model: model, - propertyName: 'color', - defaultValue: function() { return '#cdcdcd'; } + it('stores non default value in model', async () => { + const model = new Backbone.Model(); + const {getByRole} = render({ + model, propertyName: 'color', + defaultValue: () => '#cdcdcd' }); - var colorInput = ColorInput.render( - colorInputView - ); - colorInput.fillIn('#ababab', testContext.clock); + await fillIn(getByRole('textbox'), '#ababab'); expect(model.get('color')).toBe('#ababab'); }); - it('unsets attribute in model if choosing default value', () => { - var model = new Backbone.Model({ - color: '#fff' - }); - var colorInputView = new ColorInputView({ - model: model, - propertyName: 'color', - defaultValue: function() { return '#cdcdcd'; } + it('unsets attribute in model if choosing default value', async () => { + const model = new Backbone.Model({color: '#fff'}); + const {getByRole} = render({ + model, propertyName: 'color', + defaultValue: () => '#cdcdcd' }); - var colorInput = ColorInput.render( - colorInputView - ); - colorInput.fillIn('#cdcdcd', testContext.clock); + await fillIn(getByRole('textbox'), '#cdcdcd'); expect(model.has('color')).toBe(false); }); it('includes swatch for default value', () => { - var model = new Backbone.Model(); - var colorInputView = new ColorInputView({ - model: model, + const {getAllByRole} = render({ + model: new Backbone.Model(), propertyName: 'color', - defaultValue: function() { return '#cdcdcd'; }, + defaultValue: () => '#cdcdcd', swatches: ['#dedede'] }); - var colorInput = ColorInput.render( - colorInputView - ); - - expect(colorInput.swatches()).toEqual(['rgb(205, 205, 205)', 'rgb(222, 222, 222)']); + const buttons = getAllByRole('button'); + expect(buttons).toHaveLength(2); + expect(buttons[0]).toHaveTextContent('#cdcdcd'); }); }); describe('with defaultValueBinding option', () => { it('uses value of binding attribute as default value', () => { - var model = new Backbone.Model({ - default_color: '#cdcdcd' - }); - var colorInputView = new ColorInputView({ - model: model, + const {getByRole} = render({ + model: new Backbone.Model({default_color: '#cdcdcd'}), propertyName: 'color', defaultValueBinding: 'default_color' }); - var colorInput = ColorInput.render( - colorInputView - ); + expect(getByRole('textbox')).toHaveValue('#cdcdcd'); + }); + + it('updates displayed default value when binding attribute changes', () => { + const model = new Backbone.Model({default_color: '#aaaaaa'}); + const {getByRole} = render({model, propertyName: 'color', defaultValueBinding: 'default_color'}); + + model.set('default_color', '#cdcdcd'); - expect(colorInput.value()).toBe('#cdcdcd'); + expect(getByRole('textbox')).toHaveValue('#cdcdcd'); }); - it( - 'updates displayed default value when binding attribute changes', - () => { - var model = new Backbone.Model({ - default_color: '#aaaaaa' - }); - var colorInputView = new ColorInputView({ - model: model, - propertyName: 'color', - defaultValueBinding: 'default_color' - }); - - var colorInput = ColorInput.render( - colorInputView - ); - model.set('default_color', '#cdcdcd'); - - expect(colorInput.value()).toBe('#cdcdcd'); - } - ); - - it('does not store default value in model', () => { - var model = new Backbone.Model({ - default_color: '#cdcdcd' - }); - var colorInputView = new ColorInputView({ - model: model, - propertyName: 'color', - defaultValueBinding: 'default_color' - }); + it('does not store default value in model', async () => { + const model = new Backbone.Model({default_color: '#cdcdcd'}); + const {getByRole} = render({model, propertyName: 'color', defaultValueBinding: 'default_color'}); - var colorInput = ColorInput.render( - colorInputView - ); model.set('default_color', '#aaaaaa'); - colorInput.fillIn('#aaaaaa', testContext.clock); + await fillIn(getByRole('textbox'), '#aaaaaa'); expect(model.has('color')).toBe(false); }); - it('stores non default value in model', () => { - var model = new Backbone.Model({ - default_color: '#cdcdcd' - }); - var colorInputView = new ColorInputView({ - model: model, - propertyName: 'color', - defaultValueBinding: 'default_color' - }); + it('stores non default value in model', async () => { + const model = new Backbone.Model({default_color: '#cdcdcd'}); + const {getByRole} = render({model, propertyName: 'color', defaultValueBinding: 'default_color'}); - var colorInput = ColorInput.render( - colorInputView - ); model.set('default_color', '#aaaaaa'); - colorInput.fillIn('#cdcdcd', testContext.clock); + await fillIn(getByRole('textbox'), '#cdcdcd'); expect(model.get('color')).toBe('#cdcdcd'); }); - it('unsets attribute in model if choosing default value', () => { - var model = new Backbone.Model({ - color: '#fff' - }); - var colorInputView = new ColorInputView({ - model: model, - propertyName: 'color', - defaultValueBinding: 'default_color' - }); + it('unsets attribute in model if choosing default value', async () => { + const model = new Backbone.Model({color: '#fff'}); + const {getByRole} = render({model, propertyName: 'color', defaultValueBinding: 'default_color'}); - var colorInput = ColorInput.render( - colorInputView - ); model.set('default_color', '#cdcdcd'); - colorInput.fillIn('#cdcdcd', testContext.clock); + await fillIn(getByRole('textbox'), '#cdcdcd'); expect(model.has('color')).toBe(false); }); @@ -379,130 +247,96 @@ describe('pageflow.ColorInputView', () => { describe('with function as defaultValue and defaultValueBinding option', () => { it('passes binding attribute to default value function', () => { - var model = new Backbone.Model({ - light: true - }); - var colorInputView = new ColorInputView({ - model: model, + const {getByRole} = render({ + model: new Backbone.Model({light: true}), propertyName: 'color', defaultValueBinding: 'light', - defaultValue: function(light) { return light ? '#fefefe' : '#010101'; } + defaultValue: light => light ? '#fefefe' : '#010101' }); - var colorInput = ColorInput.render( - colorInputView - ); - - expect(colorInput.value()).toBe('#fefefe'); + expect(getByRole('textbox')).toHaveValue('#fefefe'); }); - it( - 'updates displayed default value when binding attribute changes', - () => { - var model = new Backbone.Model({ - light: true - }); - var colorInputView = new ColorInputView({ - model: model, - propertyName: 'color', - defaultValueBinding: 'light', - defaultValue: function(light) { return light ? '#fefefe' : '#010101'; } - }); - - var colorInput = ColorInput.render( - colorInputView - ); - model.set('light', false); - - expect(colorInput.value()).toBe('#010101'); - } - ); - - it('does not store default value in model', () => { - var model = new Backbone.Model({ - light: true + it('updates displayed default value when binding attribute changes', () => { + const model = new Backbone.Model({light: true}); + const {getByRole} = render({ + model, propertyName: 'color', + defaultValueBinding: 'light', + defaultValue: light => light ? '#fefefe' : '#010101' }); - var colorInputView = new ColorInputView({ - model: model, - propertyName: 'color', + + model.set('light', false); + + expect(getByRole('textbox')).toHaveValue('#010101'); + }); + + it('does not store default value in model', async () => { + const model = new Backbone.Model({light: true}); + const {getByRole} = render({ + model, propertyName: 'color', defaultValueBinding: 'light', - defaultValue: function(light) { return light ? '#fefefe' : '#010101'; } + defaultValue: light => light ? '#fefefe' : '#010101' }); - var colorInput = ColorInput.render( - colorInputView - ); model.set('light', false); - colorInput.fillIn('#010101', testContext.clock); + await fillIn(getByRole('textbox'), '#010101'); expect(model.has('color')).toBe(false); }); - it('stores non default value in model', () => { - var model = new Backbone.Model({ - light: true - }); - var colorInputView = new ColorInputView({ - model: model, - propertyName: 'color', + it('stores non default value in model', async () => { + const model = new Backbone.Model({light: true}); + const {getByRole} = render({ + model, propertyName: 'color', defaultValueBinding: 'light', - defaultValue: function(light) { return light ? '#fefefe' : '#010101'; } + defaultValue: light => light ? '#fefefe' : '#010101' }); - var colorInput = ColorInput.render( - colorInputView - ); model.set('light', false); - colorInput.fillIn('#fefefe', testContext.clock); + await fillIn(getByRole('textbox'), '#fefefe'); expect(model.get('color')).toBe('#fefefe'); }); - it('unsets attribute in model if choosing default value', () => { - var model = new Backbone.Model({ - light: true - }); - var colorInputView = new ColorInputView({ - model: model, - propertyName: 'color', + it('unsets attribute in model if choosing default value', async () => { + const model = new Backbone.Model({light: true}); + const {getByRole} = render({ + model, propertyName: 'color', defaultValueBinding: 'light', - defaultValue: function(light) { return light ? '#fefefe' : '#010101'; } + defaultValue: light => light ? '#fefefe' : '#010101' }); - var colorInput = ColorInput.render( - colorInputView - ); model.set('light', false); - colorInput.fillIn('#010101', testContext.clock); + await fillIn(getByRole('textbox'), '#010101'); expect(model.has('color')).toBe(false); }); }); it('removes picker element when view is closed', () => { - var pickersBefore = document.querySelectorAll('.color_picker').length; - var colorInputView = new ColorInputView({ + const pickersBefore = document.querySelectorAll('.color_picker').length; + const view = new ColorInputView({ model: new Backbone.Model(), propertyName: 'color' }); - ColorInput.render(colorInputView); + renderBackboneView(view); expect(document.querySelectorAll('.color_picker').length).toBe(pickersBefore + 1); - colorInputView.close(); + view.close(); expect(document.querySelectorAll('.color_picker').length).toBe(pickersBefore); }); describe('with placeholderColor option', () => { it('sets fallback color', () => { - const {getByRole} = renderBackboneView(new ColorInputView({ + const {getByRole} = render({ model: new Backbone.Model(), propertyName: 'color', placeholderColor: '#ff0000', placeholderColorDescription: 'Automatic color' - })); + }); expect(getByRole('textbox')) .toHaveAccessibleDescription('Automatic color: #ff0000'); @@ -511,13 +345,13 @@ describe('pageflow.ColorInputView', () => { describe('with placeholderColor and placeholderColorBinding option', () => { it('sets fallback color', () => { - const {getByRole} = renderBackboneView(new ColorInputView({ + const {getByRole} = render({ model: new Backbone.Model(), propertyName: 'color', placeholderColorBinding: 'invert', placeholderColor: invert => invert ? '#000000' : '#ffffff', placeholderColorDescription: 'Automatic color' - })); + }); expect(getByRole('textbox')) .toHaveAccessibleDescription('Automatic color: #ffffff'); @@ -525,13 +359,13 @@ describe('pageflow.ColorInputView', () => { it('updates fallback color', () => { const model = new Backbone.Model(); - const {getByRole} = renderBackboneView(new ColorInputView({ + const {getByRole} = render({ model, propertyName: 'color', placeholderColorBinding: 'invert', placeholderColor: invert => invert ? '#000000' : '#ffffff', placeholderColorDescription: 'Automatic color' - })); + }); model.set('invert', true); diff --git a/package/src/testHelpers/dominos/ui/index.js b/package/src/testHelpers/dominos/ui/index.js index 7f7a18c933..546d5b1de4 100644 --- a/package/src/testHelpers/dominos/ui/index.js +++ b/package/src/testHelpers/dominos/ui/index.js @@ -2,6 +2,5 @@ export * from './ConfigurationEditor' export * from './ConfigurationEditorTab' export * from './Table' export * from './Tabs'; -export * from './inputs/ColorInput' export * from './inputs/RadioButtonGroupInput' export * from './inputs/SelectInput' diff --git a/package/src/testHelpers/dominos/ui/inputs/ColorInput.js b/package/src/testHelpers/dominos/ui/inputs/ColorInput.js deleted file mode 100644 index 80cd1859e4..0000000000 --- a/package/src/testHelpers/dominos/ui/inputs/ColorInput.js +++ /dev/null @@ -1,28 +0,0 @@ -import {Base} from '../../Base'; - -export const ColorInput = Base.extend({ - value: function() { - return this._input().val(); - }, - - fillIn: function(value, clock) { - var input = this._input()[0]; - input.value = value; - input.dispatchEvent(new Event('input', {bubbles: true})); - - clock.tick(500); - }, - - swatches: function() { - var picker = this._input()[0].parentNode.querySelector('.color_picker'); - var buttons = picker.querySelectorAll('.color_picker-swatches button'); - - return Array.from(buttons).map(function(button) { - return window.getComputedStyle(button).color; - }); - }, - - _input: function() { - return this.$el.find('input'); - } -}); diff --git a/package/src/ui/views/inputs/ColorInputView.js b/package/src/ui/views/inputs/ColorInputView.js index ef7e3b89a4..1f0d54adb8 100644 --- a/package/src/ui/views/inputs/ColorInputView.js +++ b/package/src/ui/views/inputs/ColorInputView.js @@ -68,7 +68,7 @@ export const ColorInputView = Marionette.ItemView.extend({ fallbackColor: this.getAttributeBoundOption('placeholderColor'), fallbackColorDescription: this.options.placeholderColorDescription, swatches: this.getSwatches(), - onChange: _.debounce(_.bind(this._onChange, this), 200) + onChange: this._debouncedOnChange() }); this.listenTo(this.model, 'change:' + this.options.propertyName, this.load); @@ -135,6 +135,11 @@ export const ColorInputView = Marionette.ItemView.extend({ } }, + _debouncedOnChange: function() { + const handler = _.bind(this._onChange, this); + return this.options.disableChangeDebounce ? handler : _.debounce(handler, 200); + }, + _onChange: function(color) { this._saving = true; From f492015786e71e06c37611558e725a00769f7e4a Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 12 Mar 2026 13:13:33 +0100 Subject: [PATCH 10/12] Filter translucent swatches in color picker Hide color swatches with alpha transparency when the color picker does not have alpha editing enabled. This prevents users from picking colors they cannot fully control. Also include splitSurfaceColor in the set of used section background colors so split sections contribute their surface color to the swatch palette. REDMINE-21203 --- .../getUsedSectionBackgroundColors-spec.js | 23 +++++++++++++++ .../src/editor/models/ScrolledEntry/index.js | 4 +++ package/spec/ui/views/ColorPicker-spec.js | 28 +++++++++++++++++++ package/src/ui/views/ColorPicker.js | 14 ++++++++-- 4 files changed, 66 insertions(+), 3 deletions(-) diff --git a/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/getUsedSectionBackgroundColors-spec.js b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/getUsedSectionBackgroundColors-spec.js index 3a73a5b517..2265044df5 100644 --- a/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/getUsedSectionBackgroundColors-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/getUsedSectionBackgroundColors-spec.js @@ -50,6 +50,29 @@ describe('ScrolledEntry', () => { expect(colors).toEqual(['#400', '#500', '#040']); }); + it('includes splitSurfaceColor of split sections', () => { + const entry = factories.entry( + ScrolledEntry, + {}, + { + entryTypeSeed: normalizeSeed({ + sections: [ + { + configuration: { + appearance: 'split', + splitSurfaceColor: '#600' + } + } + ] + }) + } + ); + + const colors = entry.getUsedSectionBackgroundColors(); + + expect(colors).toEqual(['#600']); + }); + it('ignores blank colors', () => { const entry = factories.entry( ScrolledEntry, diff --git a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js index 7632bf8681..6693638e2b 100644 --- a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js +++ b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js @@ -399,6 +399,10 @@ export const ScrolledEntry = Entry.extend({ if (section.configuration.get('appearance') === 'cards') { colors.add(section.configuration.get('cardSurfaceColor')); } + + if (section.configuration.get('appearance') === 'split') { + colors.add(section.configuration.get('splitSurfaceColor')); + } }); return sortColors([...colors].filter(Boolean)); diff --git a/package/spec/ui/views/ColorPicker-spec.js b/package/spec/ui/views/ColorPicker-spec.js index add4d3f183..0c7b867bbc 100644 --- a/package/spec/ui/views/ColorPicker-spec.js +++ b/package/spec/ui/views/ColorPicker-spec.js @@ -478,6 +478,34 @@ describe('ColorPicker', () => { }); }); + describe('swatch filtering', () => { + it('filters out translucent swatches when alpha is not enabled', () => { + createColorPicker({swatches: ['#aabbcc', '#ff000080', '#112233']}); + + var buttons = picker().querySelectorAll('.color_picker-swatches button'); + expect(buttons).toHaveLength(2); + expect(buttons[0].textContent).toBe('#aabbcc'); + expect(buttons[1].textContent).toBe('#112233'); + }); + + it('keeps translucent swatches when alpha is enabled', () => { + createColorPicker({alpha: true, swatches: ['#aabbcc', '#ff000080', '#112233']}); + + var buttons = picker().querySelectorAll('.color_picker-swatches button'); + expect(buttons).toHaveLength(3); + }); + + it('filters translucent swatches on update', () => { + createColorPicker({swatches: ['#aabbcc']}); + + colorPicker.update({swatches: ['#112233', '#ff000080']}); + + var buttons = picker().querySelectorAll('.color_picker-swatches button'); + expect(buttons).toHaveLength(1); + expect(buttons[0].textContent).toBe('#112233'); + }); + }); + describe('fallbackColor option', () => { it('uses fallbackColor for display when opened with no value', () => { createColorPicker({fallbackColor: '#ff0000'}); diff --git a/package/src/ui/views/ColorPicker.js b/package/src/ui/views/ColorPicker.js index 35655792f3..e7563a13f0 100644 --- a/package/src/ui/views/ColorPicker.js +++ b/package/src/ui/views/ColorPicker.js @@ -114,14 +114,18 @@ export default class ColorPicker { } _renderSwatches() { + const swatches = this._alpha + ? this._swatches + : this._swatches.filter(s => !isTranslucentSwatch(s)); + this._swatchesContainer.textContent = ''; - this._swatchesContainer.classList.toggle('color_picker-empty', !this._swatches.length); + this._swatchesContainer.classList.toggle('color_picker-empty', !swatches.length); - if (!this._swatches.length) { + if (!swatches.length) { return; } - this._swatches.forEach(swatch => { + swatches.forEach(swatch => { const button = document.createElement('button'); button.setAttribute('type', 'button'); button.title = swatch; @@ -634,3 +638,7 @@ function getClipRect(element) { return viewport; } + +function isTranslucentSwatch(str) { + return str.length > 7 && str.slice(-2).toLowerCase() !== 'ff'; +} From 57242b5364664ef26730ffe19f4b4b90fcee7257 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 12 Mar 2026 13:43:24 +0100 Subject: [PATCH 11/12] Rename splitSurfaceColor to splitOverlayColor The prop name now matches the visual concept it controls: the color of the split layout's overlay, not its surface. Also adjust the default behavior so that the split overlay applies backdrop blur even without an explicit color. REDMINE-21203 --- entry_types/scrolled/config/locales/de.yml | 2 +- entry_types/scrolled/config/locales/en.yml | 2 +- .../getUsedSectionBackgroundColors-spec.js | 4 ++-- .../frontend/foregroundBoxes/SplitBox-spec.js | 8 ++++---- .../spec/frontend/shadows/SplitShadow-spec.js | 17 ++++++++--------- .../src/editor/models/ScrolledEntry/index.js | 2 +- .../package/src/editor/views/EditSectionView.js | 12 ++++++------ .../scrolled/package/src/frontend/Section.js | 4 ++-- .../src/frontend/foregroundBoxes/SplitBox.js | 2 +- .../package/src/frontend/shadows/SplitShadow.js | 2 +- 10 files changed, 27 insertions(+), 28 deletions(-) diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index db19c1b9c5..5ac50d57a4 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1276,7 +1276,7 @@ de: label: Kartenhintergrundfarbe auto: "(Automatisch)" auto_color: "Automatische Farbe" - splitSurfaceColor: + splitOverlayColor: label: Overlay-Farbe auto: "(Automatisch)" auto_color: "Automatische Farbe" diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index 231e3dcdb0..4a2cd9d470 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1260,7 +1260,7 @@ en: label: Cards background color auto: "(Auto)" auto_color: "Auto color" - splitSurfaceColor: + splitOverlayColor: label: Overlay color auto: "(Auto)" auto_color: "Auto color" diff --git a/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/getUsedSectionBackgroundColors-spec.js b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/getUsedSectionBackgroundColors-spec.js index 2265044df5..57fff4a030 100644 --- a/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/getUsedSectionBackgroundColors-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/getUsedSectionBackgroundColors-spec.js @@ -50,7 +50,7 @@ describe('ScrolledEntry', () => { expect(colors).toEqual(['#400', '#500', '#040']); }); - it('includes splitSurfaceColor of split sections', () => { + it('includes splitOverlayColor of split sections', () => { const entry = factories.entry( ScrolledEntry, {}, @@ -60,7 +60,7 @@ describe('ScrolledEntry', () => { { configuration: { appearance: 'split', - splitSurfaceColor: '#600' + splitOverlayColor: '#600' } } ] diff --git a/entry_types/scrolled/package/spec/frontend/foregroundBoxes/SplitBox-spec.js b/entry_types/scrolled/package/spec/frontend/foregroundBoxes/SplitBox-spec.js index fcfc38272f..4c6b2232f5 100644 --- a/entry_types/scrolled/package/spec/frontend/foregroundBoxes/SplitBox-spec.js +++ b/entry_types/scrolled/package/spec/frontend/foregroundBoxes/SplitBox-spec.js @@ -88,7 +88,7 @@ describe('SplitBox', () => { const {container} = render(
@@ -102,7 +102,7 @@ describe('SplitBox', () => { const {container} = render(
@@ -112,11 +112,11 @@ describe('SplitBox', () => { .toBeFalsy(); }); - it('sets overlay background color from splitSurfaceColor', () => { + it('sets overlay background color from splitOverlayColor', () => { const {container} = render( + splitOverlayColor="#ff000080">
); diff --git a/entry_types/scrolled/package/spec/frontend/shadows/SplitShadow-spec.js b/entry_types/scrolled/package/spec/frontend/shadows/SplitShadow-spec.js index 38532e5d20..c6c3b6717b 100644 --- a/entry_types/scrolled/package/spec/frontend/shadows/SplitShadow-spec.js +++ b/entry_types/scrolled/package/spec/frontend/shadows/SplitShadow-spec.js @@ -73,9 +73,9 @@ describe('SplitShadow', () => { expect(container.firstChild).toHaveClass(styles.light); }); - it('sets background color from splitSurfaceColor prop', () => { + it('sets background color from splitOverlayColor prop', () => { const {container} = render( - +
); @@ -84,7 +84,7 @@ describe('SplitShadow', () => { .toHaveStyle({backgroundColor: '#ff000080'}); }); - it('does not set inline background color when no splitSurfaceColor', () => { + it('does not set inline background color when no splitOverlayColor', () => { const {container} = render(
@@ -98,7 +98,7 @@ describe('SplitShadow', () => { it('applies backdrop filter when overlayBackdropBlur is set and color is translucent', () => { const {container} = render(
@@ -111,7 +111,7 @@ describe('SplitShadow', () => { it('does not apply backdrop filter when color is opaque', () => { const {container} = render(
@@ -121,16 +121,15 @@ describe('SplitShadow', () => { .toBeFalsy(); }); - it('does not apply backdrop filter when no color is set', () => { + it('applies default backdrop filter when no color is set', () => { const {container} = render( - +
); expect(container.querySelector(`.${styles.overlay}`).style.backdropFilter) - .toBeFalsy(); + .toBe('blur(10px)'); }); it('does not render overlay when isContentPadded is true', () => { diff --git a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js index 6693638e2b..f4dbce3229 100644 --- a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js +++ b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js @@ -401,7 +401,7 @@ export const ScrolledEntry = Entry.extend({ } if (section.configuration.get('appearance') === 'split') { - colors.add(section.configuration.get('splitSurfaceColor')); + colors.add(section.configuration.get('splitOverlayColor')); } }); diff --git a/entry_types/scrolled/package/src/editor/views/EditSectionView.js b/entry_types/scrolled/package/src/editor/views/EditSectionView.js index 5731f75ca9..5563d94acc 100644 --- a/entry_types/scrolled/package/src/editor/views/EditSectionView.js +++ b/entry_types/scrolled/package/src/editor/views/EditSectionView.js @@ -204,24 +204,24 @@ export const EditSectionView = EditConfigurationView.extend({ swatches: entry.getUsedSectionBackgroundColors() }); - this.input('splitSurfaceColor', ColorInputView, { + this.input('splitOverlayColor', ColorInputView, { visibleBinding: 'appearance', visibleBindingValue: 'split', alpha: true, - placeholder: I18n.t('pageflow_scrolled.editor.edit_section.attributes.splitSurfaceColor.auto'), + placeholder: I18n.t('pageflow_scrolled.editor.edit_section.attributes.splitOverlayColor.auto'), placeholderColorBinding: 'invert', placeholderColor: invert => invert ? '#ffffffb3' : '#000000b3', - placeholderColorDescription: I18n.t('pageflow_scrolled.editor.edit_section.attributes.splitSurfaceColor.auto_color'), + placeholderColorDescription: I18n.t('pageflow_scrolled.editor.edit_section.attributes.splitOverlayColor.auto_color'), swatches: entry.getUsedSectionBackgroundColors() }); this.input('overlayBackdropBlur', SliderInputView, { visibleBinding: 'appearance', visible: appearance => appearance === 'split' || appearance === 'cards', - disabledBinding: ['appearance', 'splitSurfaceColor', 'cardSurfaceColor'], - disabled: ([appearance, splitSurfaceColor, cardSurfaceColor]) => + disabledBinding: ['appearance', 'splitOverlayColor', 'cardSurfaceColor'], + disabled: ([appearance, splitOverlayColor, cardSurfaceColor]) => appearance === 'split' - ? splitSurfaceColor && !utils.isTranslucentColor(splitSurfaceColor) + ? splitOverlayColor && !utils.isTranslucentColor(splitOverlayColor) : !utils.isTranslucentColor(cardSurfaceColor), values: [0, 25, 50, 75, 100], defaultValue: 100, diff --git a/entry_types/scrolled/package/src/frontend/Section.js b/entry_types/scrolled/package/src/frontend/Section.js index ac945f0d15..6a2caaaa16 100644 --- a/entry_types/scrolled/package/src/frontend/Section.js +++ b/entry_types/scrolled/package/src/frontend/Section.js @@ -156,7 +156,7 @@ function SectionContents({ motifAreaState={motifAreaState} staticShadowOpacity={staticShadowOpacity} dynamicShadowOpacity={dynamicShadowOpacity} - splitSurfaceColor={section.splitSurfaceColor} + splitOverlayColor={section.splitOverlayColor} overlayBackdropBlur={section.overlayBackdropBlur}> {children} } @@ -176,7 +176,7 @@ function SectionContents({ state={state} motifAreaState={motifAreaState} staticShadowOpacity={staticShadowOpacity} - splitSurfaceColor={section.splitSurfaceColor} + splitOverlayColor={section.splitOverlayColor} overlayBackdropBlur={section.overlayBackdropBlur}> } diff --git a/entry_types/scrolled/package/src/frontend/shadows/SplitShadow.js b/entry_types/scrolled/package/src/frontend/shadows/SplitShadow.js index 8ae576a7d1..584e2f5d86 100644 --- a/entry_types/scrolled/package/src/frontend/shadows/SplitShadow.js +++ b/entry_types/scrolled/package/src/frontend/shadows/SplitShadow.js @@ -20,7 +20,7 @@ export default function SplitShadow(props) { props.inverted ? styles.light : styles.dark)}>
From 5579e9a4fe451a8b729658137148f4a4eeabfd19 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 12 Mar 2026 14:06:31 +0100 Subject: [PATCH 12/12] Improve inline help for section appearance options Update the appearance, card surface color and split overlay color inline help texts in English and German. Reword the appearance descriptions to cover the new split option and make them more concise. Add missing inline help for the card and split color pickers. REDMINE-21203 --- entry_types/scrolled/config/locales/de.yml | 4 +++- entry_types/scrolled/config/locales/en.yml | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 5ac50d57a4..ee82ff23b3 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1208,7 +1208,7 @@ de: motif_area_info_text: Markiere den wichtigsten Teil des Hintergrunds, der beim Erreichen des Abschnitts sichtbar und nicht von anderen Elementen überdeckt sein soll. attributes: appearance: - inline_help: Diese Einstellung legt fest wie die scrollenden Vordergrund-Ebene vom Hintergrund abgehoben werden soll. Bei der Einstellung "Karte" wird der Vordergrund mit einer Fläche mit abgerundeten Ecken hinterlegt, bei "Schatten" mit einem hellen oder dunklen Farbverlauf. + inline_help: Legt fest, wie der Vordergrund vom Hintergrund abgehoben wird. "Schatten" fügt einen Farbverlauf hinter dem Text hinzu. "Karte" platziert den Inhalt auf einer abgerundeten Fläche. "Split" bedeckt die halbe Viewport-Breite mit einem Overlay für den Inhalt. label: Abblendung values: cards: Karte @@ -1273,10 +1273,12 @@ de: coverViewport: Viewport überdecken coverSection: Abschnitt überdecken cardSurfaceColor: + inline_help: Hintergrundfarbe der Karte. Verwende eine teiltransparente Farbe, um den Hintergrund durchscheinen zu lassen und Hintergrundunschärfe zu aktivieren. label: Kartenhintergrundfarbe auto: "(Automatisch)" auto_color: "Automatische Farbe" splitOverlayColor: + inline_help: Farbe des Overlays hinter dem Inhalt. label: Overlay-Farbe auto: "(Automatisch)" auto_color: "Automatische Farbe" diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index 4a2cd9d470..08eb63adb2 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1192,7 +1192,7 @@ en: motif_area_info_text: Mark the most important part of the backdrop that should be visible and unobstructed when reaching the section. attributes: appearance: - inline_help: This setting controls how the scrolling foreground should get dimmed to increase contrast from the background. The option "Cards" adds a box with rounded corners to the foreground, with "Shadow" you can add a light or dark shadow to increase readability. + inline_help: Controls how the foreground is visually separated from the background. "Shadow" adds a gradient behind text. "Cards" places content on a rounded box. "Split" covers half the viewport width with an overlay for the content. label: Text-Background values: cards: Card @@ -1257,10 +1257,12 @@ en: coverViewport: Cover viewport coverSection: Cover section cardSurfaceColor: + inline_help: Background color of the card. Use a translucent color to let the backdrop shine through and enable background blur. label: Cards background color auto: "(Auto)" auto_color: "Auto color" splitOverlayColor: + inline_help: Color of the overlay behind the content. label: Overlay color auto: "(Auto)" auto_color: "Auto color"