From d9ca61f6a3572364f566d9e862f24df5acba67b1 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 17 Mar 2026 16:02:36 +0100 Subject: [PATCH 01/12] Add aria labels to color picker sliders Label hue and opacity range inputs so screen readers can announce them. ColorPicker now reads translations directly via I18n. REDMINE-21248 --- config/locales/de.yml | 3 +++ config/locales/en.yml | 3 +++ package/src/ui/views/ColorPicker.js | 7 +++++++ 3 files changed, 13 insertions(+) diff --git a/config/locales/de.yml b/config/locales/de.yml index 2a2caebf0c..c4a887c2fa 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -1959,6 +1959,9 @@ de: feature_name: "'Titel und Bild'-Lade-Ansicht" widget_type_name: Titel und Bild ui: + color_picker: + hue: "Farbton" + opacity: "Deckkraft" configuration_editor: tabs: consent_bar: Consent-Leiste diff --git a/config/locales/en.yml b/config/locales/en.yml index 85e3d2d473..3a1b9a05a1 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1946,6 +1946,9 @@ en: feature_name: "'Title and image' loading view" widget_type_name: Title and Image ui: + color_picker: + hue: "Hue" + opacity: "Opacity" configuration_editor: tabs: consent_bar: Consent bar diff --git a/package/src/ui/views/ColorPicker.js b/package/src/ui/views/ColorPicker.js index e7563a13f0..5f0185349e 100644 --- a/package/src/ui/views/ColorPicker.js +++ b/package/src/ui/views/ColorPicker.js @@ -1,4 +1,5 @@ // Inspired by https://github.com/mdbassit/Coloris +import I18n from 'i18n-js'; const ctx = typeof OffscreenCanvas !== 'undefined' && new OffscreenCanvas(1, 1).getContext('2d'); @@ -81,6 +82,12 @@ export default class ColorPicker { this._alphaSlider = this._picker.querySelector('.color_picker-alpha input'); this._alphaMarker = this._picker.querySelector('.color_picker-alpha div'); this._swatchesContainer = this._picker.querySelector('.color_picker-swatches'); + + this._hueSlider.setAttribute('aria-label', I18n.t('pageflow.ui.color_picker.hue')); + + if (this._alpha) { + this._alphaSlider.setAttribute('aria-label', I18n.t('pageflow.ui.color_picker.opacity')); + } } _wrapInput() { From bca80de403487d6c1e985d42ae6bab529ef89d0b Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 17 Mar 2026 16:02:36 +0100 Subject: [PATCH 02/12] Pass input options from style types to color picker Allow style types to configure color picker behavior like alpha support and swatches via inputOptions. REDMINE-21248 --- .../views/inputs/StyleListInputView-spec.js | 51 ++++++++++++++++++- .../package/src/editor/models/Style.js | 4 ++ .../editor/views/inputs/StyleListInputView.js | 1 + 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/entry_types/scrolled/package/spec/editor/views/inputs/StyleListInputView-spec.js b/entry_types/scrolled/package/spec/editor/views/inputs/StyleListInputView-spec.js index e74b9ba784..7f68ab477b 100644 --- a/entry_types/scrolled/package/spec/editor/views/inputs/StyleListInputView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/inputs/StyleListInputView-spec.js @@ -11,7 +11,9 @@ import styles from 'editor/views/inputs/StyleListInputView.module.css'; describe('StyleListInputView', () => { useFakeTranslations({ 'pageflow_scrolled.editor.style_list_input.add': 'Add style', - 'pageflow_scrolled.editor.style_list_input.remove': 'Remove style' + 'pageflow_scrolled.editor.style_list_input.remove': 'Remove style', + 'pageflow.ui.color_picker.hue': 'Hue', + 'pageflow.ui.color_picker.opacity': 'Opacity' }); it('displays styles', () => { @@ -419,6 +421,53 @@ describe('StyleListInputView', () => { expect(view.el).not.toHaveClass(styles.allUsed); }); + it('renders hue and opacity sliders with aria-labels for color input with alpha', () => { + const types = { + outlineColor: { + label: 'Outline', + inputType: 'color', + propertyName: 'outlineColor', + inputOptions: {alpha: true} + } + }; + const model = new Backbone.Model({outlineColor: '#ff000080'}); + + const view = new StyleListInputView({ + model, + propertyName: 'styles', + types, + translationKeyPrefix: 'pageflow_scrolled.editor.style_list_input' + }); + const {getByRole} = render(view); + + expect(getByRole('slider', {name: 'Hue'})).toBeDefined(); + expect(getByRole('slider', {name: 'Opacity'})).toBeDefined(); + }); + + it('renders hue slider with aria-label for color input without alpha', () => { + const types = { + frame: { + label: 'Frame', + inputType: 'color', + defaultValue: '#ffffff' + } + }; + const model = new Backbone.Model({styles: [ + {name: 'frame', value: '#ffffff'} + ]}); + + const view = new StyleListInputView({ + model, + propertyName: 'styles', + types, + translationKeyPrefix: 'pageflow_scrolled.editor.style_list_input' + }); + const {getByRole, queryByRole} = render(view); + + expect(getByRole('slider', {name: 'Hue'})).toBeDefined(); + expect(queryByRole('slider', {name: 'Opacity'})).toBeNull(); + }); + it('allows removing styles', async () => { const types = { blur: { diff --git a/entry_types/scrolled/package/src/editor/models/Style.js b/entry_types/scrolled/package/src/editor/models/Style.js index 2d650210d4..7a6c568517 100644 --- a/entry_types/scrolled/package/src/editor/models/Style.js +++ b/entry_types/scrolled/package/src/editor/models/Style.js @@ -49,6 +49,10 @@ export const Style = Backbone.Model.extend({ inputType() { return this.types[this.get('name')].inputType || 'none'; + }, + + inputOptions() { + return this.types[this.get('name')].inputOptions || {}; } }); diff --git a/entry_types/scrolled/package/src/editor/views/inputs/StyleListInputView.js b/entry_types/scrolled/package/src/editor/views/inputs/StyleListInputView.js index 8ca561d442..374844f156 100644 --- a/entry_types/scrolled/package/src/editor/views/inputs/StyleListInputView.js +++ b/entry_types/scrolled/package/src/editor/views/inputs/StyleListInputView.js @@ -190,6 +190,7 @@ const StyleListItemView = Marionette.ItemView.extend({ this._colorPicker = new ColorPicker(colorInput, { defaultValue: this.model.defaultValue(), + ...this.model.inputOptions(), onChange: (color) => { this.model.set('value', color || ''); } From 77975df4ff85d4e8db8e36459c80e17e8d236e41 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 17 Mar 2026 15:30:44 +0100 Subject: [PATCH 03/12] Add box shadow and outline styles to content elements Allow content elements to opt into box shadow and outline decoration effects via the supportedStyles mechanism. Themes can configure a box shadow scale similar to the existing margin scale. REDMINE-21248 --- entry_types/scrolled/config/locales/de.yml | 7 + entry_types/scrolled/config/locales/en.yml | 7 + .../scrolled/lib/pageflow_scrolled/plugin.rb | 18 +- .../package/spec/editor/models/Style-spec.js | 218 +++++++++++++++++- .../features/contentElementBox-spec.js | 76 ++++++ .../package/spec/support/pageObjects.js | 10 + .../src/editor/models/ScrolledEntry/index.js | 3 +- .../package/src/editor/models/Style.js | 32 +++ .../package/src/frontend/ContentElementBox.js | 28 ++- .../src/frontend/ContentElementBox.module.css | 2 + 10 files changed, 392 insertions(+), 9 deletions(-) diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index ee82ff23b3..34ab828a11 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1427,6 +1427,11 @@ de: lg: L xl: XL xxl: XXL + contentElementBoxShadow: + sm: S + md: M + lg: L + xl: XL content_element_text_inline_file_rights_attributes: showTextInlineFileRightsBackdrop: label: "Abblendung hinter Rechteangabe" @@ -1467,6 +1472,8 @@ de: remove: "Stil entfernen" marginTop: "Abstand oben" marginBottom: "Abstand unten" + boxShadow: "Schatten" + outlineColor: "Umrandung" image_modifier_list_input: add: "Stil hinzufügen..." remove: "Stil entfernen" diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index 08eb63adb2..2af6b4141e 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1411,6 +1411,11 @@ en: lg: L xl: XL xxl: XXL + contentElementBoxShadow: + sm: S + md: M + lg: L + xl: XL content_element_text_inline_file_rights_attributes: showTextInlineFileRightsBackdrop: label: "Backdrop behind inline file rights" @@ -1451,6 +1456,8 @@ en: remove: "Remove style" marginTop: "Margin top" marginBottom: "Margin bottom" + boxShadow: "Box shadow" + outlineColor: "Outline" image_modifier_list_input: add: "Add style..." remove: "Remove style" diff --git a/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb b/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb index b7ab879b37..bf419ed772 100644 --- a/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb +++ b/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb @@ -30,6 +30,13 @@ def configure(config) 'xxl' => '16em' } + box_shadow_scale = { + 'sm' => '0 1px 3px 0 rgb(0 0 0 / 0.2), 0 1px 2px -1px rgb(0 0 0 / 0.15)', + 'md' => '0 4px 6px -1px rgb(0 0 0 / 0.2), 0 2px 4px -2px rgb(0 0 0 / 0.15)', + 'lg' => '0 10px 15px -3px rgb(0 0 0 / 0.2), 0 4px 6px -4px rgb(0 0 0 / 0.12)', + 'xl' => '0 20px 25px -5px rgb(0 0 0 / 0.2), 0 8px 10px -6px rgb(0 0 0 / 0.12)' + } + c.themes.register_options_transform( ThemeOptionsDefaultScale.new( prefix: 'section_padding_top', @@ -51,12 +58,21 @@ def configure(config) ) ) + c.themes.register_options_transform( + ThemeOptionsDefaultScale.new( + prefix: 'content_element_box_shadow', + values: box_shadow_scale + ) + ) + c.themes.register_default_options( properties: { root: { 'section_default_padding_top' => 'max(10em, 20svh)', 'section_default_padding_bottom' => 'max(10em, 20svh)', - 'content_element_margin_style_default' => '2em' + 'content_element_margin_style_default' => '2em', + 'content_element_box_shadow_style_default' => '0 4px 6px -1px rgb(0 0 0 / 0.2), 0 2px 4px -2px rgb(0 0 0 / 0.15)', + 'outline_color' => '#a0a0a080' }, cards_appearance_section: { 'section_default_padding_top' => 'max(10em, 20svh)', diff --git a/entry_types/scrolled/package/spec/editor/models/Style-spec.js b/entry_types/scrolled/package/spec/editor/models/Style-spec.js index f461454007..e0270782dc 100644 --- a/entry_types/scrolled/package/spec/editor/models/Style-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/Style-spec.js @@ -22,7 +22,9 @@ describe('Style', () => { }; useFakeTranslations({ - 'pageflow_scrolled.editor.backdrop_effects.blur.label': 'Blur' + 'pageflow_scrolled.editor.backdrop_effects.blur.label': 'Blur', + 'pageflow_scrolled.editor.content_element_style_list_input.boxShadow': 'Box shadow', + 'pageflow_scrolled.editor.content_element_style_list_input.outlineColor': 'Outline' }); it('has label based on translation', () => { @@ -231,6 +233,220 @@ describe('Style', () => { expect(result.marginTop.defaultValue).toEqual('sm'); expect(result.marginBottom.defaultValue).toEqual('sm'); }); + + it('returns boxShadow type when supportedStyles includes boxShadow and scale is defined', () => { + editor.contentElementTypes.register('inlineImage', { + supportedStyles: ['boxShadow'] + }); + + const entry = factories.entry( + ScrolledEntry, + {}, + { + entryTypeSeed: normalizeSeed({ + contentElements: [ + {id: 1, typeName: 'inlineImage'} + ], + themeOptions: { + properties: { + root: { + 'contentElementBoxShadow-sm': '0 1px 3px rgba(0,0,0,0.12)', + 'contentElementBoxShadow-md': '0 4px 6px rgba(0,0,0,0.1)', + 'contentElementBoxShadow-lg': '0 10px 15px rgba(0,0,0,0.1)' + } + } + }, + themeTranslations: { + scales: { + contentElementBoxShadow: { + sm: 'Small', + md: 'Medium', + lg: 'Large' + } + } + } + }) + } + ); + + const contentElement = entry.contentElements.get(1); + const result = Style.getTypesForContentElement({entry, contentElement}); + + expect(result).toMatchObject({ + boxShadow: { + inputType: 'slider', + propertyName: 'boxShadow', + values: ['sm', 'md', 'lg'], + texts: ['Small', 'Medium', 'Large'] + } + }); + }); + + it('does not return boxShadow when type does not have supportedStyles', () => { + editor.contentElementTypes.register('textBlock', {}); + + const entry = factories.entry( + ScrolledEntry, + {}, + { + entryTypeSeed: normalizeSeed({ + contentElements: [ + {id: 1, typeName: 'textBlock'} + ], + themeOptions: { + properties: { + root: { + 'contentElementBoxShadow-sm': '0 1px 3px rgba(0,0,0,0.12)', + 'contentElementBoxShadow-md': '0 4px 6px rgba(0,0,0,0.1)' + } + } + }, + themeTranslations: { + scales: { + contentElementBoxShadow: { + sm: 'Small', + md: 'Medium' + } + } + } + }) + } + ); + + const contentElement = entry.contentElements.get(1); + const result = Style.getTypesForContentElement({entry, contentElement}); + + expect(result).not.toHaveProperty('boxShadow'); + }); + + it('does not return boxShadow when no scale is defined', () => { + editor.contentElementTypes.register('inlineImage', { + supportedStyles: ['boxShadow'] + }); + + const entry = factories.entry( + ScrolledEntry, + {}, + { + entryTypeSeed: normalizeSeed({ + contentElements: [ + {id: 1, typeName: 'inlineImage'} + ] + }) + } + ); + + const contentElement = entry.contentElements.get(1); + const result = Style.getTypesForContentElement({entry, contentElement}); + + expect(result).not.toHaveProperty('boxShadow'); + }); + + it('returns outlineColor type when supportedStyles includes outline', () => { + editor.contentElementTypes.register('inlineImage', { + supportedStyles: ['outline'] + }); + + const entry = factories.entry( + ScrolledEntry, + {}, + { + entryTypeSeed: normalizeSeed({ + contentElements: [ + {id: 1, typeName: 'inlineImage'} + ] + }) + } + ); + + const contentElement = entry.contentElements.get(1); + const result = Style.getTypesForContentElement({entry, contentElement}); + + expect(result).toMatchObject({ + outlineColor: { + inputType: 'color', + propertyName: 'outlineColor' + } + }); + }); + + it('uses outlineColor theme property as default for outlineColor', () => { + editor.contentElementTypes.register('inlineImage', { + supportedStyles: ['outline'] + }); + + const entry = factories.entry( + ScrolledEntry, + {}, + { + entryTypeSeed: normalizeSeed({ + contentElements: [ + {id: 1, typeName: 'inlineImage'} + ], + themeOptions: { + properties: { + root: { + outlineColor: '#cc0000' + } + } + } + }) + } + ); + + const contentElement = entry.contentElements.get(1); + const result = Style.getTypesForContentElement({entry, contentElement}); + + expect(result.outlineColor.defaultValue).toEqual('#cc0000'); + }); + + it('includes used outline colors as swatches in outlineColor inputOptions', () => { + editor.contentElementTypes.register('inlineImage', { + supportedStyles: ['outline'] + }); + + const entry = factories.entry( + ScrolledEntry, + {}, + { + entryTypeSeed: normalizeSeed({ + contentElements: [ + {id: 1, typeName: 'inlineImage', configuration: {outlineColor: '#ff0000'}}, + {id: 2, typeName: 'inlineImage', configuration: {outlineColor: '#00ff00'}}, + {id: 3, typeName: 'inlineImage'} + ] + }) + } + ); + + const contentElement = entry.contentElements.get(1); + const result = Style.getTypesForContentElement({entry, contentElement}); + + expect(result.outlineColor.inputOptions.swatches).toEqual( + expect.arrayContaining(['#ff0000', '#00ff00']) + ); + }); + + it('does not return outlineColor when type does not have supportedStyles', () => { + editor.contentElementTypes.register('textBlock', {}); + + const entry = factories.entry( + ScrolledEntry, + {}, + { + entryTypeSeed: normalizeSeed({ + contentElements: [ + {id: 1, typeName: 'textBlock'} + ] + }) + } + ); + + const contentElement = entry.contentElements.get(1); + const result = Style.getTypesForContentElement({entry, contentElement}); + + expect(result).not.toHaveProperty('outlineColor'); + }); }); describe('.getImageModifierTypes', () => { diff --git a/entry_types/scrolled/package/spec/frontend/features/contentElementBox-spec.js b/entry_types/scrolled/package/spec/frontend/features/contentElementBox-spec.js index c320d6a252..c124fc7dd4 100644 --- a/entry_types/scrolled/package/spec/frontend/features/contentElementBox-spec.js +++ b/entry_types/scrolled/package/spec/frontend/features/contentElementBox-spec.js @@ -44,6 +44,42 @@ describe('content element box', () => { ) } }); + + frontend.contentElementTypes.register('testBoxShadow', { + component: function Component({configuration}) { + return ( +
+ + Some content with box shadow + +
+ ) + } + }); + + frontend.contentElementTypes.register('testOutline', { + component: function Component({configuration}) { + return ( +
+ + Some content with outline + +
+ ) + } + }); + + frontend.contentElementTypes.register('testBoxShadowNoBorderRadius', { + component: function Component({configuration}) { + return ( +
+ + Box shadow without border radius + +
+ ) + } + }); }); it('renders box', () => { @@ -105,4 +141,44 @@ describe('content element box', () => { expect(getContentElementByTestId('testNone').containsBox()).toEqual(false); }); + + it('applies box shadow CSS custom property from configuration', () => { + const {getContentElementByTestId} = renderEntry({ + seed: { + contentElements: [{ + typeName: 'testBoxShadow', + configuration: {boxShadow: 'md'} + }] + } + }); + + expect(getContentElementByTestId('testBoxShadow').hasBoxShadow('md')).toBe(true); + }); + + it('applies outline color CSS custom property from configuration', () => { + const {getContentElementByTestId} = renderEntry({ + seed: { + contentElements: [{ + typeName: 'testOutline', + configuration: {outlineColor: '#ff0000'} + }] + } + }); + + expect(getContentElementByTestId('testOutline').hasOutlineColor('#ff0000')).toBe(true); + }); + + it('renders box when borderRadius is "none" but configuration has boxShadow', () => { + const {getContentElementByTestId} = renderEntry({ + seed: { + contentElements: [{ + typeName: 'testBoxShadowNoBorderRadius', + configuration: {boxShadow: 'md'} + }] + } + }); + + expect(getContentElementByTestId('testBoxShadowNoBorderRadius').containsBox()).toEqual(true); + expect(getContentElementByTestId('testBoxShadowNoBorderRadius').hasBoxShadow('md')).toBe(true); + }); }); diff --git a/entry_types/scrolled/package/spec/support/pageObjects.js b/entry_types/scrolled/package/spec/support/pageObjects.js index 9cc51aa54d..cd9c909c24 100644 --- a/entry_types/scrolled/package/spec/support/pageObjects.js +++ b/entry_types/scrolled/package/spec/support/pageObjects.js @@ -278,6 +278,16 @@ function createContentElementPageObject(el) { return match ? match[1] : cssValue; }, + hasBoxShadow(value) { + const wrapper = el.querySelector(`.${contentElementBoxStyles.wrapper}`); + return wrapper && wrapper.style.getPropertyValue('--content-element-box-shadow') === `var(--theme-content-element-box-shadow-${value})`; + }, + + hasOutlineColor(value) { + const wrapper = el.querySelector(`.${contentElementBoxStyles.wrapper}`); + return wrapper && wrapper.style.getPropertyValue('--content-element-box-outline-color') === value; + }, + hasMargin() { return !!el.closest(`.${contentElementMarginStyles.wrapper}`); }, 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 f4dbce3229..f608e996e9 100644 --- a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js +++ b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js @@ -28,7 +28,8 @@ const scaleDefaultPropertyNames = { sectionPaddingTop: 'sectionDefaultPaddingTop', sectionPaddingBottom: 'sectionDefaultPaddingBottom', contentElementBoxBorderRadius: 'contentElementBoxBorderRadius', - contentElementMargin: 'contentElementMarginStyleDefault' + contentElementMargin: 'contentElementMarginStyleDefault', + contentElementBoxShadow: 'contentElementBoxShadowStyleDefault' }; const defaultAspectRatios = [{ diff --git a/entry_types/scrolled/package/src/editor/models/Style.js b/entry_types/scrolled/package/src/editor/models/Style.js index 7a6c568517..8bc802a89f 100644 --- a/entry_types/scrolled/package/src/editor/models/Style.js +++ b/entry_types/scrolled/package/src/editor/models/Style.js @@ -134,6 +134,8 @@ Style.getTypesForContentElement = function({entry, contentElement}) { const defaultConfig = contentElement.getType().defaultConfig || {}; const result = {}; + const supportedStyles = contentElement.getType().supportedStyles || []; + if (marginScale.values.length > 0) { result.marginTop = { label: I18n.t('pageflow_scrolled.editor.content_element_style_list_input.marginTop'), @@ -156,6 +158,36 @@ Style.getTypesForContentElement = function({entry, contentElement}) { }; } + if (supportedStyles.includes('boxShadow')) { + const boxShadowScale = entry.getScale('contentElementBoxShadow'); + + if (boxShadowScale.values.length > 0) { + result.boxShadow = { + label: I18n.t('pageflow_scrolled.editor.content_element_style_list_input.boxShadow'), + propertyName: 'boxShadow', + inputType: 'slider', + values: boxShadowScale.values, + texts: boxShadowScale.texts, + defaultValue: boxShadowScale.defaultValue + }; + } + } + + if (supportedStyles.includes('outline')) { + const themeProperties = entry.getThemeProperties(); + + result.outlineColor = { + label: I18n.t('pageflow_scrolled.editor.content_element_style_list_input.outlineColor'), + propertyName: 'outlineColor', + inputType: 'color', + defaultValue: themeProperties.root?.outlineColor, + inputOptions: { + alpha: true, + swatches: entry.getUsedContentElementColors('outlineColor') + } + }; + } + return result; } diff --git a/entry_types/scrolled/package/src/frontend/ContentElementBox.js b/entry_types/scrolled/package/src/frontend/ContentElementBox.js index 287c9a7256..bb2bfe907c 100644 --- a/entry_types/scrolled/package/src/frontend/ContentElementBox.js +++ b/entry_types/scrolled/package/src/frontend/ContentElementBox.js @@ -12,18 +12,34 @@ import styles from './ContentElementBox.module.css'; * * @param {Object} props * @param {string} props.children - Content of box. - * @param {string} props.borderRadius - Border radius value from theme scale, or "none" to render no wrapper. + * @param {Object} [props.configuration] - Content element configuration. Used to read box shadow and outline styles. + * @param {string} [props.borderRadius] - Border radius value from theme scale, or "none" to render no wrapper. */ -export function ContentElementBox({children, borderRadius, positioned}) { +export function ContentElementBox({children, configuration, borderRadius, positioned}) { const {position, width} = useContentElementAttributes(); - if (position === 'backdrop' || borderRadius === 'none') { + const boxShadow = configuration?.boxShadow; + const outlineColor = configuration?.outlineColor; + + if (position === 'backdrop') { + return children; + } + + if (borderRadius === 'none' && !boxShadow && !outlineColor) { return children; } - const style = borderRadius ? { - '--content-element-box-border-radius': `var(--theme-content-element-box-border-radius-${borderRadius})` - } : {}; + const style = { + ...(borderRadius && borderRadius !== 'none' && { + '--content-element-box-border-radius': `var(--theme-content-element-box-border-radius-${borderRadius})` + }), + ...(boxShadow && { + '--content-element-box-shadow': `var(--theme-content-element-box-shadow-${boxShadow})` + }), + ...(outlineColor && { + '--content-element-box-outline-color': outlineColor + }) + }; return (
Date: Wed, 18 Mar 2026 08:42:08 +0100 Subject: [PATCH 04/12] Filter decoration effects by feature flag at type level Move the decoration_effects feature check from StylesCollection into Style.getEffectTypes so that decoration effect types are excluded before they reach the collection. This lets StylesCollection stay agnostic of feature flags. This allows giving content element styles kind property to group styles in list. REDMINE-21248 --- .../collections/StylesCollection-spec.js | 22 +------------ .../package/spec/editor/models/Style-spec.js | 33 +++++++++++++++++++ .../editor/collections/StylesCollection.js | 11 ++----- .../package/src/editor/models/Style.js | 19 ++++++++++- .../views/inputs/EffectListInputView.js | 2 +- 5 files changed, 55 insertions(+), 32 deletions(-) diff --git a/entry_types/scrolled/package/spec/editor/collections/StylesCollection-spec.js b/entry_types/scrolled/package/spec/editor/collections/StylesCollection-spec.js index 7b486970df..5dd0c04349 100644 --- a/entry_types/scrolled/package/spec/editor/collections/StylesCollection-spec.js +++ b/entry_types/scrolled/package/spec/editor/collections/StylesCollection-spec.js @@ -2,7 +2,6 @@ import {StylesCollection} from 'editor/collections/StylesCollection'; import {Style} from 'editor/models/Style'; import {useFakeTranslations} from 'pageflow/testHelpers'; -import {features} from 'pageflow/frontend'; describe('StylesCollection', () => { const exampleTypes = { @@ -37,8 +36,6 @@ describe('StylesCollection', () => { } }; - beforeEach(() => features.enabledFeatureNames = []); - describe('#getUnusedStyles', () => { useFakeTranslations({ 'pageflow_scrolled.editor.backdrop_effects.blur.label': 'Blur', @@ -63,23 +60,6 @@ describe('StylesCollection', () => { expect(unusedStyles.findWhere({name: 'brightness'}).get('hidden')).toEqual(true); }); - it('does not include decoration styles by default', () => { - const styles = new StylesCollection([], {types: exampleTypes}); - - const unusedStyles = styles.getUnusedStyles(); - - expect(unusedStyles.pluck('name')).not.toContain('frame'); - }); - - it('includes decoration styles if feature is enabled', () => { - const styles = new StylesCollection([], {types: exampleTypes}); - features.enable('frontend', ['decoration_effects']); - - const unusedStyles = styles.getUnusedStyles(); - - expect(unusedStyles.pluck('name')).toContain('frame'); - }); - it('selecting an unused style adds it to the collection', () => { const styles = new StylesCollection([], {types: exampleTypes}); @@ -424,7 +404,7 @@ describe('StylesCollection', () => { describe('with effect types', () => { it('creates collection with effect style types', () => { - const styles = new StylesCollection([{name: 'blur', value: 50}], {types: Style.effectTypes}); + const styles = new StylesCollection([{name: 'blur', value: 50}], {types: Style.getEffectTypes()}); expect(styles.pluck('name')).toEqual(['blur']); expect(styles.first().minValue()).toEqual(0); diff --git a/entry_types/scrolled/package/spec/editor/models/Style-spec.js b/entry_types/scrolled/package/spec/editor/models/Style-spec.js index e0270782dc..3e42517e68 100644 --- a/entry_types/scrolled/package/spec/editor/models/Style-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/Style-spec.js @@ -3,6 +3,7 @@ import {Style} from 'editor/models/Style'; import {editor} from 'pageflow-scrolled/editor'; import {ScrolledEntry} from 'editor/models/ScrolledEntry'; import {factories, useFakeTranslations} from 'pageflow/testHelpers'; +import {features} from 'pageflow/frontend'; import {normalizeSeed} from 'support'; describe('Style', () => { @@ -80,6 +81,34 @@ describe('Style', () => { expect(style.inputType()).toEqual('color'); }); + describe('.getEffectTypes', () => { + beforeEach(() => features.enabledFeatureNames = []); + + it('includes filter and animation effects', () => { + const types = Style.getEffectTypes(); + + expect(types).toHaveProperty('blur'); + expect(types).toHaveProperty('autoZoom'); + expect(types.blur.kind).toEqual('filter'); + expect(types.autoZoom.kind).toEqual('animation'); + }); + + it('excludes decoration effects by default', () => { + const types = Style.getEffectTypes(); + + expect(types).not.toHaveProperty('frame'); + }); + + it('includes decoration effects when feature is enabled', () => { + features.enable('frontend', ['decoration_effects']); + + const types = Style.getEffectTypes(); + + expect(types).toHaveProperty('frame'); + expect(types.frame.kind).toEqual('decoration'); + }); + }); + describe('.getTypesForContentElement', () => { it('returns empty object when no margin scale is defined', () => { editor.contentElementTypes.register('textBlock', {}); @@ -140,11 +169,13 @@ describe('Style', () => { expect(result).toMatchObject({ marginTop: { + kind: 'spacing', inputType: 'slider', values: ['sm', 'md', 'lg'], texts: ['Small', 'Medium', 'Large'] }, marginBottom: { + kind: 'spacing', inputType: 'slider', values: ['sm', 'md', 'lg'], texts: ['Small', 'Medium', 'Large'] @@ -274,6 +305,7 @@ describe('Style', () => { expect(result).toMatchObject({ boxShadow: { + kind: 'decoration', inputType: 'slider', propertyName: 'boxShadow', values: ['sm', 'md', 'lg'], @@ -364,6 +396,7 @@ describe('Style', () => { expect(result).toMatchObject({ outlineColor: { + kind: 'decoration', inputType: 'color', propertyName: 'outlineColor' } diff --git a/entry_types/scrolled/package/src/editor/collections/StylesCollection.js b/entry_types/scrolled/package/src/editor/collections/StylesCollection.js index d86f89e2ae..f1ae54ea23 100644 --- a/entry_types/scrolled/package/src/editor/collections/StylesCollection.js +++ b/entry_types/scrolled/package/src/editor/collections/StylesCollection.js @@ -1,6 +1,5 @@ import Backbone from 'backbone'; import I18n from 'i18n-js'; -import {features} from 'pageflow/frontend'; import {Style} from '../models/Style'; export const StylesCollection = Backbone.Collection.extend({ @@ -13,14 +12,8 @@ export const StylesCollection = Backbone.Collection.extend({ getUnusedStyles() { const unusedStyles = new Backbone.Collection( Object - .entries(this.types) - .filter( - ([name, styleType]) => ( - features.isEnabled('decoration_effects') || - Style.getKind(name, this.types) !== 'decoration' - ) - ) - .map(([name]) => ({name})), + .keys(this.types) + .map(name => ({name})), { comparator: style => Object.keys(this.types).indexOf(style.get('name')), styles: this, diff --git a/entry_types/scrolled/package/src/editor/models/Style.js b/entry_types/scrolled/package/src/editor/models/Style.js index 8bc802a89f..02bec10358 100644 --- a/entry_types/scrolled/package/src/editor/models/Style.js +++ b/entry_types/scrolled/package/src/editor/models/Style.js @@ -1,5 +1,6 @@ import Backbone from 'backbone'; import I18n from 'i18n-js'; +import {features} from 'pageflow/frontend'; export const Style = Backbone.Model.extend({ initialize({name}, {types}) { @@ -65,7 +66,7 @@ Style.getKind = function(name, types) { return types[name].kind; }; -Style.effectTypes = { +const allEffectTypes = { blur: { inputType: 'slider', minValue: 0, @@ -129,6 +130,18 @@ Style.effectTypes = { } }; +Style.getEffectTypes = function() { + if (features.isEnabled('decoration_effects')) { + return allEffectTypes; + } + + return Object.fromEntries( + Object.entries(allEffectTypes).filter( + ([, type]) => type.kind !== 'decoration' + ) + ); +}; + Style.getTypesForContentElement = function({entry, contentElement}) { const marginScale = entry.getScale('contentElementMargin'); const defaultConfig = contentElement.getType().defaultConfig || {}; @@ -138,6 +151,7 @@ Style.getTypesForContentElement = function({entry, contentElement}) { if (marginScale.values.length > 0) { result.marginTop = { + kind: 'spacing', label: I18n.t('pageflow_scrolled.editor.content_element_style_list_input.marginTop'), propertyName: 'marginTop', inputType: 'slider', @@ -148,6 +162,7 @@ Style.getTypesForContentElement = function({entry, contentElement}) { }; result.marginBottom = { + kind: 'spacing', label: I18n.t('pageflow_scrolled.editor.content_element_style_list_input.marginBottom'), propertyName: 'marginBottom', inputType: 'slider', @@ -163,6 +178,7 @@ Style.getTypesForContentElement = function({entry, contentElement}) { if (boxShadowScale.values.length > 0) { result.boxShadow = { + kind: 'decoration', label: I18n.t('pageflow_scrolled.editor.content_element_style_list_input.boxShadow'), propertyName: 'boxShadow', inputType: 'slider', @@ -177,6 +193,7 @@ Style.getTypesForContentElement = function({entry, contentElement}) { const themeProperties = entry.getThemeProperties(); result.outlineColor = { + kind: 'decoration', label: I18n.t('pageflow_scrolled.editor.content_element_style_list_input.outlineColor'), propertyName: 'outlineColor', inputType: 'color', diff --git a/entry_types/scrolled/package/src/editor/views/inputs/EffectListInputView.js b/entry_types/scrolled/package/src/editor/views/inputs/EffectListInputView.js index e42d67e2ad..5e9c2d8430 100644 --- a/entry_types/scrolled/package/src/editor/views/inputs/EffectListInputView.js +++ b/entry_types/scrolled/package/src/editor/views/inputs/EffectListInputView.js @@ -5,7 +5,7 @@ export const EffectListInputView = function(options) { return new StyleListInputView({ ...options, hideLabel: true, - types: Style.effectTypes, + types: Style.getEffectTypes(), translationKeyPrefix: 'pageflow_scrolled.editor.effect_list_input' }); }; From 1fd5f77a161724a9b170727df202f2371b31c20b Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Wed, 18 Mar 2026 12:56:30 +0100 Subject: [PATCH 05/12] Conditionally show style controls based on bindings Extract shared attribute binding logic into reusable utils so that style types can declare a binding condition. When a bound attribute (e.g. a poster image) is not set, the corresponding style option is hidden in the unused styles list and displayed as unavailable with inert controls when already applied. REDMINE-21248 --- .../collections/StylesCollection-spec.js | 63 ++++++++++++++++ .../package/spec/editor/models/Style-spec.js | 44 ++++++++++++ .../views/inputs/StyleListInputView-spec.js | 71 +++++++++++++++++++ .../editor/collections/StylesCollection.js | 40 ++++++++--- .../package/src/editor/models/Style.js | 38 ++++++++-- .../editor/views/inputs/StyleListInputView.js | 21 ++++-- .../inputs/StyleListInputView.module.css | 11 +++ package/documentation.yml | 3 +- package/src/ui/index.js | 9 ++- package/src/ui/utils/attributeBindingUtils.js | 42 +++++++++++ .../src/ui/views/mixins/attributeBinding.js | 49 +++++-------- 11 files changed, 339 insertions(+), 52 deletions(-) create mode 100644 package/src/ui/utils/attributeBindingUtils.js diff --git a/entry_types/scrolled/package/spec/editor/collections/StylesCollection-spec.js b/entry_types/scrolled/package/spec/editor/collections/StylesCollection-spec.js index 5dd0c04349..9d82271ad6 100644 --- a/entry_types/scrolled/package/spec/editor/collections/StylesCollection-spec.js +++ b/entry_types/scrolled/package/spec/editor/collections/StylesCollection-spec.js @@ -1,6 +1,7 @@ import {StylesCollection} from 'editor/collections/StylesCollection'; import {Style} from 'editor/models/Style'; +import Backbone from 'backbone'; import {useFakeTranslations} from 'pageflow/testHelpers'; describe('StylesCollection', () => { @@ -402,6 +403,68 @@ describe('StylesCollection', () => { }); }); + describe('conditional styles', () => { + it('hides unused style when condition is not met', () => { + const bindingModel = new Backbone.Model({posterId: null}); + const types = { + boxShadow: { + label: 'Box shadow', + kind: 'decoration', + inputType: 'slider', + values: ['sm', 'md', 'lg'], + texts: ['S', 'M', 'L'], + defaultValue: 'md', + binding: 'posterId', + when: posterId => !!posterId + } + }; + + const styles = new StylesCollection([], {types, bindingModel}); + const unusedStyles = styles.getUnusedStyles(); + + expect(unusedStyles.findWhere({name: 'boxShadow'}).get('hidden')).toEqual(true); + }); + + it('shows unused style when condition becomes met', () => { + const bindingModel = new Backbone.Model({posterId: null}); + const types = { + boxShadow: { + label: 'Box shadow', + kind: 'decoration', + inputType: 'slider', + values: ['sm', 'md', 'lg'], + texts: ['S', 'M', 'L'], + defaultValue: 'md', + binding: 'posterId', + when: posterId => !!posterId + } + }; + + const styles = new StylesCollection([], {types, bindingModel}); + const unusedStyles = styles.getUnusedStyles(); + + bindingModel.set('posterId', 5); + + expect(unusedStyles.findWhere({name: 'boxShadow'}).get('hidden')).toEqual(false); + }); + + it('does not hide unused style without condition', () => { + const bindingModel = new Backbone.Model(); + const types = { + outlineColor: { + label: 'Outline', + kind: 'decoration', + inputType: 'color' + } + }; + + const styles = new StylesCollection([], {types, bindingModel}); + const unusedStyles = styles.getUnusedStyles(); + + expect(unusedStyles.findWhere({name: 'outlineColor'}).get('hidden')).toEqual(false); + }); + }); + describe('with effect types', () => { it('creates collection with effect style types', () => { const styles = new StylesCollection([{name: 'blur', value: 50}], {types: Style.getEffectTypes()}); diff --git a/entry_types/scrolled/package/spec/editor/models/Style-spec.js b/entry_types/scrolled/package/spec/editor/models/Style-spec.js index 3e42517e68..320a3b165c 100644 --- a/entry_types/scrolled/package/spec/editor/models/Style-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/Style-spec.js @@ -460,6 +460,50 @@ describe('Style', () => { ); }); + it('passes binding condition from supportedStyles object to type definition', () => { + const whenFn = posterId => !!posterId; + + editor.contentElementTypes.register('inlineAudio', { + supportedStyles: [ + {name: 'boxShadow', binding: 'posterId', when: whenFn} + ] + }); + + const entry = factories.entry( + ScrolledEntry, + {}, + { + entryTypeSeed: normalizeSeed({ + contentElements: [ + {id: 1, typeName: 'inlineAudio'} + ], + themeOptions: { + properties: { + root: { + 'contentElementBoxShadow-sm': '0 1px 3px rgba(0,0,0,0.12)', + 'contentElementBoxShadow-md': '0 4px 6px rgba(0,0,0,0.1)' + } + } + }, + themeTranslations: { + scales: { + contentElementBoxShadow: { + sm: 'Small', + md: 'Medium' + } + } + } + }) + } + ); + + const contentElement = entry.contentElements.get(1); + const result = Style.getTypesForContentElement({entry, contentElement}); + + expect(result.boxShadow.binding).toEqual('posterId'); + expect(result.boxShadow.when).toBe(whenFn); + }); + it('does not return outlineColor when type does not have supportedStyles', () => { editor.contentElementTypes.register('textBlock', {}); diff --git a/entry_types/scrolled/package/spec/editor/views/inputs/StyleListInputView-spec.js b/entry_types/scrolled/package/spec/editor/views/inputs/StyleListInputView-spec.js index 7f68ab477b..ca8d5360f3 100644 --- a/entry_types/scrolled/package/spec/editor/views/inputs/StyleListInputView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/inputs/StyleListInputView-spec.js @@ -468,6 +468,77 @@ describe('StyleListInputView', () => { expect(queryByRole('slider', {name: 'Opacity'})).toBeNull(); }); + it('makes controls inert when binding condition becomes false', () => { + const model = new Backbone.Model({ + posterId: 5, + boxShadow: 'md' + }); + const types = { + boxShadow: { + label: 'Box shadow', + kind: 'decoration', + propertyName: 'boxShadow', + inputType: 'slider', + values: ['sm', 'md', 'lg'], + texts: ['Small', 'Medium', 'Large'], + defaultValue: 'md', + binding: 'posterId', + when: posterId => !!posterId + } + }; + + const view = new StyleListInputView({ + model, + propertyName: 'styles', + types, + translationKeyPrefix: 'pageflow_scrolled.editor.style_list_input' + }); + render(view); + + const controls = view.el.querySelector(`.${styles.controls}`); + expect(controls).not.toHaveAttribute('inert'); + + model.unset('posterId'); + + expect(controls).toHaveAttribute('inert'); + expect(view.el.querySelector(`.${styles.item}`)).toHaveClass(styles.unavailable); + }); + + it('removes inert from controls when binding condition becomes true', () => { + const model = new Backbone.Model({ + boxShadow: 'md' + }); + const types = { + boxShadow: { + label: 'Box shadow', + kind: 'decoration', + propertyName: 'boxShadow', + inputType: 'slider', + values: ['sm', 'md', 'lg'], + texts: ['Small', 'Medium', 'Large'], + defaultValue: 'md', + binding: 'posterId', + when: posterId => !!posterId + } + }; + + const view = new StyleListInputView({ + model, + propertyName: 'styles', + types, + translationKeyPrefix: 'pageflow_scrolled.editor.style_list_input' + }); + render(view); + + const controls = view.el.querySelector(`.${styles.controls}`); + expect(controls).toHaveAttribute('inert'); + + model.set('posterId', 5); + + expect(controls).not.toHaveAttribute('inert'); + expect(view.el.querySelector(`.${styles.item}`)).not.toHaveClass(styles.unavailable); + }); + it('allows removing styles', async () => { const types = { blur: { diff --git a/entry_types/scrolled/package/src/editor/collections/StylesCollection.js b/entry_types/scrolled/package/src/editor/collections/StylesCollection.js index f1ae54ea23..aee40bb619 100644 --- a/entry_types/scrolled/package/src/editor/collections/StylesCollection.js +++ b/entry_types/scrolled/package/src/editor/collections/StylesCollection.js @@ -1,5 +1,6 @@ import Backbone from 'backbone'; import I18n from 'i18n-js'; +import {attributeBindingUtils} from 'pageflow/ui'; import {Style} from '../models/Style'; export const StylesCollection = Backbone.Collection.extend({ @@ -7,6 +8,7 @@ export const StylesCollection = Backbone.Collection.extend({ initialize(models, options = {}) { this.types = options.types || {}; + this.bindingModel = options.bindingModel; }, getUnusedStyles() { @@ -17,6 +19,7 @@ export const StylesCollection = Backbone.Collection.extend({ { comparator: style => Object.keys(this.types).indexOf(style.get('name')), styles: this, + bindingModel: this.bindingModel, model: UnusedStyle } ); @@ -41,8 +44,9 @@ function updateSeparation(styles, types) { } const UnusedStyle = Backbone.Model.extend({ - initialize({name}, {styles}) { - const {items} = styles.types[name]; + initialize({name}, {styles, bindingModel}) { + const type = styles.types[name]; + const {items} = type; this.set('label', Style.getLabel(name, styles.types)); @@ -55,18 +59,34 @@ const UnusedStyle = Backbone.Model.extend({ } else { this.selected = () => { - styles.add({name: this.get('name')}, {types: styles.types}); + styles.add({name: this.get('name')}, {types: styles.types, bindingModel: styles.bindingModel}); } } - const update = () => { - this.set({ - hidden: !!styles.findWhere({name: this.get('name')}) && !items - }); + const updateHidden = () => { + const inUse = !!styles.findWhere({name: this.get('name')}) && !items; + this.set({hidden: inUse || !this.get('available')}); }; - this.listenTo(styles, 'add remove', update); - update(); + this.listenTo(styles, 'add remove', updateHidden); + + if (type.binding && bindingModel) { + attributeBindingUtils.setup({ + binding: type.binding, + model: bindingModel, + listener: this, + option: type.when, + callback: available => { + this.set({available}); + updateHidden(); + } + }); + } + else { + this.set({available: true}); + } + + updateHidden(); } }); @@ -131,7 +151,7 @@ const UnusedStyleItem = Backbone.Model.extend({ _applyStyle(currentStyle) { this.styles.remove(currentStyle); this._removeIncompatibleStyles(); - this.styles.add({name: this.styleName, value: this.get(('value'))}, {types: this.styles.types}); + this.styles.add({name: this.styleName, value: this.get(('value'))}, {types: this.styles.types, bindingModel: this.styles.bindingModel}); }, _removeIncompatibleStyles() { diff --git a/entry_types/scrolled/package/src/editor/models/Style.js b/entry_types/scrolled/package/src/editor/models/Style.js index 02bec10358..e506269062 100644 --- a/entry_types/scrolled/package/src/editor/models/Style.js +++ b/entry_types/scrolled/package/src/editor/models/Style.js @@ -1,14 +1,27 @@ import Backbone from 'backbone'; import I18n from 'i18n-js'; import {features} from 'pageflow/frontend'; +import {attributeBindingUtils} from 'pageflow/ui'; export const Style = Backbone.Model.extend({ - initialize({name}, {types}) { + initialize({name}, {types, bindingModel}) { this.types = types; if (!this.has('value')) { this.set('value', this.defaultValue()); } + + const type = types[name]; + + if (type?.binding && bindingModel) { + attributeBindingUtils.setup({ + binding: type.binding, + model: bindingModel, + listener: this, + option: type.when, + callback: available => this.set({available}) + }); + } }, label() { @@ -149,6 +162,19 @@ Style.getTypesForContentElement = function({entry, contentElement}) { const supportedStyles = contentElement.getType().supportedStyles || []; + function findSupportedStyle(name) { + return supportedStyles.find(s => s === name || s.name === name); + } + + function bindingOptions(name) { + const style = findSupportedStyle(name); + if (!style || typeof style === 'string') return {}; + const {binding, when} = style; + return { + ...(binding && {binding, when}) + }; + } + if (marginScale.values.length > 0) { result.marginTop = { kind: 'spacing', @@ -173,7 +199,7 @@ Style.getTypesForContentElement = function({entry, contentElement}) { }; } - if (supportedStyles.includes('boxShadow')) { + if (findSupportedStyle('boxShadow')) { const boxShadowScale = entry.getScale('contentElementBoxShadow'); if (boxShadowScale.values.length > 0) { @@ -184,12 +210,13 @@ Style.getTypesForContentElement = function({entry, contentElement}) { inputType: 'slider', values: boxShadowScale.values, texts: boxShadowScale.texts, - defaultValue: boxShadowScale.defaultValue + defaultValue: boxShadowScale.defaultValue, + ...bindingOptions('boxShadow') }; } } - if (supportedStyles.includes('outline')) { + if (findSupportedStyle('outline')) { const themeProperties = entry.getThemeProperties(); result.outlineColor = { @@ -201,7 +228,8 @@ Style.getTypesForContentElement = function({entry, contentElement}) { inputOptions: { alpha: true, swatches: entry.getUsedContentElementColors('outlineColor') - } + }, + ...bindingOptions('outline') }; } diff --git a/entry_types/scrolled/package/src/editor/views/inputs/StyleListInputView.js b/entry_types/scrolled/package/src/editor/views/inputs/StyleListInputView.js index 374844f156..f7c458df6c 100644 --- a/entry_types/scrolled/package/src/editor/views/inputs/StyleListInputView.js +++ b/entry_types/scrolled/package/src/editor/views/inputs/StyleListInputView.js @@ -22,7 +22,7 @@ export const StyleListInputView = Marionette.ItemView.extend({ initialize() { this.styles = new StylesCollection( this.readFromModel(), - {types: this.options.types} + {types: this.options.types, bindingModel: this.model} ); this.listenTo(this.styles, 'add remove change', () => { @@ -112,13 +112,19 @@ const StyleListItemView = Marionette.ItemView.extend({ className: styles.item, template: (data) => ` -
${data.label}
- ${renderInput(data.inputType)} +
+
${data.label}
+ ${renderInput(data.inputType)} +
`, + modelEvents: { + 'change:available': 'updateAvailability' + }, + serializeData() { return { label: this.model.label(), @@ -127,7 +133,7 @@ const StyleListItemView = Marionette.ItemView.extend({ }; }, - ui: cssModulesUtils.ui(styles, 'widget', 'value', 'colorInput'), + ui: cssModulesUtils.ui(styles, 'controls', 'widget', 'value', 'colorInput'), events: cssModulesUtils.events(styles, { 'click remove': function() { @@ -153,7 +159,14 @@ const StyleListItemView = Marionette.ItemView.extend({ } }), + updateAvailability() { + const unavailable = this.model.get('available') === false; + this.$el.toggleClass(styles.unavailable, unavailable); + this.ui.controls.attr('inert', unavailable ? '' : null); + }, + onRender() { + this.updateAvailability(); this.$el.addClass(styles[`input-${this.model.inputType()}`]); const values = this.model.values(); diff --git a/entry_types/scrolled/package/src/editor/views/inputs/StyleListInputView.module.css b/entry_types/scrolled/package/src/editor/views/inputs/StyleListInputView.module.css index c617336e05..1b5e211ba4 100644 --- a/entry_types/scrolled/package/src/editor/views/inputs/StyleListInputView.module.css +++ b/entry_types/scrolled/package/src/editor/views/inputs/StyleListInputView.module.css @@ -119,6 +119,17 @@ height: size(4); } +.controls { + display: flex; + align-items: center; + flex: 1; + min-width: 0; +} + +.unavailable .controls { + opacity: 0.4; +} + .remove { composes: cancel from '../icons.module.css'; padding: 0 space(2); diff --git a/package/documentation.yml b/package/documentation.yml index 921e695e2a..046c35915f 100644 --- a/package/documentation.yml +++ b/package/documentation.yml @@ -122,8 +122,9 @@ toc: description: | Utility functions exported by `pageflow/ui`. children: - - i18nUtils + - attributeBindingUtils - cssModulesUtils + - i18nUtils - name: Test Helpers description: | diff --git a/package/src/ui/index.js b/package/src/ui/index.js index cb902cce8d..1bd379ef48 100644 --- a/package/src/ui/index.js +++ b/package/src/ui/index.js @@ -14,7 +14,14 @@ import * as i18nUtils from './utils/i18nUtils'; */ import * as cssModulesUtils from './utils/cssModulesUtils'; -export { i18nUtils, cssModulesUtils }; +/** + * Helpers for setting up attribute bindings. + * + * @alias attributeBindingUtils + */ +import * as attributeBindingUtils from './utils/attributeBindingUtils'; + +export { i18nUtils, cssModulesUtils, attributeBindingUtils }; export {default as Object} from '../Object'; export * from './models/mixins/serverSideValidation'; diff --git a/package/src/ui/utils/attributeBindingUtils.js b/package/src/ui/utils/attributeBindingUtils.js new file mode 100644 index 0000000000..2433e01561 --- /dev/null +++ b/package/src/ui/utils/attributeBindingUtils.js @@ -0,0 +1,42 @@ +import _ from 'underscore'; + +export function setup({ + binding, model, listener, callback, normalize = value => value, + option, bindingValue +}) { + if (binding) { + _.flatten([binding]).forEach(attribute => { + listener.listenTo(model, 'change:' + attribute, update); + }); + } + + update(); + + function update() { + callback(resolve({ + binding, model, normalize, option, bindingValue + })); + } +} + +export function resolve({ + binding, model, normalize = value => value, + option, bindingValue +}) { + const boundValue = Array.isArray(binding) ? + binding.map(attribute => model.get(attribute)) : + model.get(binding); + + if (bindingValue !== undefined) { + return boundValue === bindingValue; + } + else if (typeof option === 'function') { + return normalize(option(boundValue)); + } + else if (option !== undefined) { + return normalize(option); + } + else if (binding) { + return normalize(boundValue); + } +} diff --git a/package/src/ui/views/mixins/attributeBinding.js b/package/src/ui/views/mixins/attributeBinding.js index 6fd377b7bd..cbb920dfcf 100644 --- a/package/src/ui/views/mixins/attributeBinding.js +++ b/package/src/ui/views/mixins/attributeBinding.js @@ -1,4 +1,4 @@ -import _ from 'underscore'; +import {setup, resolve} from '../../utils/attributeBindingUtils'; export const attributeBinding = { setupBooleanAttributeBinding(optionName, updateMethod) { @@ -9,44 +9,31 @@ export const attributeBinding = { return this.getAttributeBoundOption(optionName, Boolean); }, - setupAttributeBinding: function(optionName, updateMethod, normalize = value => value) { + setupAttributeBinding(optionName, updateMethod, normalize = value => value) { const binding = this.options[`${optionName}Binding`]; const model = this.options[`${optionName}BindingModel`] || this.model; - const view = this; - if (binding) { - _.flatten([binding]).forEach(attribute => { - this.listenTo(model, 'change:' + attribute, update); - }); - } - - update(); - - function update() { - updateMethod.call(view, view.getAttributeBoundOption(optionName, normalize)); - } + setup({ + binding, + model, + listener: this, + normalize, + option: this.options[optionName], + bindingValue: this.options[`${optionName}BindingValue`], + callback: value => updateMethod.call(this, value) + }); }, getAttributeBoundOption(optionName, normalize = value => value) { const binding = this.options[`${optionName}Binding`]; const model = this.options[`${optionName}BindingModel`] || this.model; - const bindingValueOptionName = `${optionName}BindingValue`; - - const value = Array.isArray(binding) ? - binding.map(attribute => model.get(attribute)) : - model.get(binding); - if (bindingValueOptionName in this.options) { - return value === this.options[bindingValueOptionName]; - } - else if (typeof this.options[optionName] === 'function') { - return normalize(this.options[optionName](value)); - } - else if (optionName in this.options) { - return normalize(this.options[optionName]); - } - else if (binding) { - return normalize(value); - } + return resolve({ + binding, + model, + normalize, + option: this.options[optionName], + bindingValue: this.options[`${optionName}BindingValue`] + }); } }; From 3f448583ddbe812f902b39b1c46ebc48616d9b0d Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 17 Mar 2026 15:30:44 +0100 Subject: [PATCH 06/12] Support decoration styles for block-like content elements REDMINE-21248 --- .../src/contentElements/dataWrapperChart/DataWrapperChart.js | 2 +- .../package/src/contentElements/dataWrapperChart/editor.js | 1 + .../scrolled/package/src/contentElements/hotspots/Hotspots.js | 2 +- .../package/src/contentElements/hotspots/editor/index.js | 1 + .../package/src/contentElements/iframeEmbed/IframeEmbed.js | 2 +- .../scrolled/package/src/contentElements/iframeEmbed/editor.js | 1 + .../src/contentElements/inlineBeforeAfter/BeforeAfter.js | 2 +- .../package/src/contentElements/inlineBeforeAfter/editor.js | 1 + .../package/src/contentElements/inlineVideo/InlineVideo.js | 2 +- .../scrolled/package/src/contentElements/inlineVideo/editor.js | 1 + .../package/src/contentElements/videoEmbed/VideoEmbed.js | 2 +- .../scrolled/package/src/contentElements/videoEmbed/editor.js | 1 + .../scrolled/package/src/contentElements/vrImage/VrImage.js | 2 +- .../scrolled/package/src/contentElements/vrImage/editor.js | 1 + 14 files changed, 14 insertions(+), 7 deletions(-) diff --git a/entry_types/scrolled/package/src/contentElements/dataWrapperChart/DataWrapperChart.js b/entry_types/scrolled/package/src/contentElements/dataWrapperChart/DataWrapperChart.js index bdd8c06683..8cede05946 100644 --- a/entry_types/scrolled/package/src/contentElements/dataWrapperChart/DataWrapperChart.js +++ b/entry_types/scrolled/package/src/contentElements/dataWrapperChart/DataWrapperChart.js @@ -30,7 +30,7 @@ export function DataWrapperChart({configuration}) { const backgroundColor = configuration.backgroundColor || '#323d4d'; return ( - +
{children => - +
{children} diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/editor/index.js b/entry_types/scrolled/package/src/contentElements/hotspots/editor/index.js index 617aed1d29..9226ae913b 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/editor/index.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/index.js @@ -20,6 +20,7 @@ editor.contentElementTypes.register('hotspots', { category: 'interactive', supportedPositions: ['inline', 'side', 'sticky', 'standAlone', 'left', 'right', 'backdrop'], supportedWidthRange: ['xxs', 'full'], + supportedStyles: ['boxShadow', 'outline'], editorPath(contentElement) { const activeAreaId = contentElement.transientState.get('activeAreaId'); diff --git a/entry_types/scrolled/package/src/contentElements/iframeEmbed/IframeEmbed.js b/entry_types/scrolled/package/src/contentElements/iframeEmbed/IframeEmbed.js index 5135f7bf80..f27f893361 100644 --- a/entry_types/scrolled/package/src/contentElements/iframeEmbed/IframeEmbed.js +++ b/entry_types/scrolled/package/src/contentElements/iframeEmbed/IframeEmbed.js @@ -57,7 +57,7 @@ export function IframeEmbed({configuration}) { style={{pointerEvents: isEditable && !isSelected ? 'none' : undefined}}> - + {renderSpanningWrapper( diff --git a/entry_types/scrolled/package/src/contentElements/iframeEmbed/editor.js b/entry_types/scrolled/package/src/contentElements/iframeEmbed/editor.js index 6caf1cdc94..b53c0a6bbb 100644 --- a/entry_types/scrolled/package/src/contentElements/iframeEmbed/editor.js +++ b/entry_types/scrolled/package/src/contentElements/iframeEmbed/editor.js @@ -13,6 +13,7 @@ editor.contentElementTypes.register('iframeEmbed', { featureName: 'iframe_embed_content_element', supportedPositions: ['inline', 'side', 'sticky', 'standAlone', 'left', 'right'], supportedWidthRange: ['xxs', 'full'], + supportedStyles: ['boxShadow', 'outline'], configurationEditor({entry}) { this.tab('general', function() { diff --git a/entry_types/scrolled/package/src/contentElements/inlineBeforeAfter/BeforeAfter.js b/entry_types/scrolled/package/src/contentElements/inlineBeforeAfter/BeforeAfter.js index 39e8683200..369b105cc2 100644 --- a/entry_types/scrolled/package/src/contentElements/inlineBeforeAfter/BeforeAfter.js +++ b/entry_types/scrolled/package/src/contentElements/inlineBeforeAfter/BeforeAfter.js @@ -66,7 +66,7 @@ export function BeforeAfter(configuration) { return ( - + diff --git a/entry_types/scrolled/package/src/contentElements/inlineBeforeAfter/editor.js b/entry_types/scrolled/package/src/contentElements/inlineBeforeAfter/editor.js index f928db6f5a..cdd38c2be0 100644 --- a/entry_types/scrolled/package/src/contentElements/inlineBeforeAfter/editor.js +++ b/entry_types/scrolled/package/src/contentElements/inlineBeforeAfter/editor.js @@ -9,6 +9,7 @@ editor.contentElementTypes.register('inlineBeforeAfter', { category: 'interactive', supportedPositions: ['inline', 'side', 'sticky', 'standAlone', 'left', 'right', 'backdrop'], supportedWidthRange: ['xxs', 'full'], + supportedStyles: ['boxShadow', 'outline'], configurationEditor({entry}) { this.tab('general', function() { diff --git a/entry_types/scrolled/package/src/contentElements/inlineVideo/InlineVideo.js b/entry_types/scrolled/package/src/contentElements/inlineVideo/InlineVideo.js index 738fafe9c6..0a3a436f00 100644 --- a/entry_types/scrolled/package/src/contentElements/inlineVideo/InlineVideo.js +++ b/entry_types/scrolled/package/src/contentElements/inlineVideo/InlineVideo.js @@ -127,7 +127,7 @@ function OrientationUnawareInlineVideo({ undefined : fallbackAspectRatio} fill={configuration.position === 'backdrop'} opaque={!videoFile}> - + - + {shouldLoad && - + {renderLazyPanorama(configuration, imageFile, shouldLoad, aspectRatio)} diff --git a/entry_types/scrolled/package/src/contentElements/vrImage/editor.js b/entry_types/scrolled/package/src/contentElements/vrImage/editor.js index c6f7b3f490..c4e6cae051 100644 --- a/entry_types/scrolled/package/src/contentElements/vrImage/editor.js +++ b/entry_types/scrolled/package/src/contentElements/vrImage/editor.js @@ -10,6 +10,7 @@ editor.contentElementTypes.register('vrImage', { category: 'interactive', supportedPositions: ['inline', 'side', 'sticky', 'standAlone', 'left', 'right', 'backdrop'], supportedWidthRange: ['xxs', 'full'], + supportedStyles: ['boxShadow', 'outline'], configurationEditor({entry}) { this.tab('general', function() { From 884d27aeb9ab36a0e80e65d16a4865045d0f9838 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 17 Mar 2026 15:30:44 +0100 Subject: [PATCH 07/12] Support decoration styles for inline images REDMINE-21248 --- .../inlineImage/InlineImage-spec.js | 44 +++++++++++++++++++ .../inlineImage/InlineImage.js | 4 +- .../src/contentElements/inlineImage/editor.js | 1 + 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/entry_types/scrolled/package/spec/contentElements/inlineImage/InlineImage-spec.js b/entry_types/scrolled/package/spec/contentElements/inlineImage/InlineImage-spec.js index 2c563fae9c..7c47b25a4e 100644 --- a/entry_types/scrolled/package/spec/contentElements/inlineImage/InlineImage-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/inlineImage/InlineImage-spec.js @@ -57,6 +57,28 @@ describe('InlineImage', () => { expect(contentElement.getBoxBorderRadius()).toEqual('circle'); }); + it('applies box shadow on circle box', () => { + const {getContentElement} = renderContentElement({ + typeName: 'inlineImage', + configuration: { + id: 100, + boxShadow: 'md', + imageModifiers: [ + {name: 'crop', value: 'circle'} + ] + }, + imageFiles: [{ + permaId: 100, + width: 200, + height: 100 + }] + }); + + const contentElement = getContentElement(); + expect(contentElement.getBoxBorderRadius()).toEqual('circle'); + expect(contentElement.hasBoxShadow('md')).toBe(true); + }); + it('overrides rounded styles', () => { const {getContentElement} = renderContentElement({ typeName: 'inlineImage', @@ -100,6 +122,28 @@ describe('InlineImage', () => { expect(contentElement.getFitViewportAspectRatio()).toEqual('square'); }); + it('applies box shadow on outer box with rounded styles', () => { + const {getContentElement} = renderContentElement({ + typeName: 'inlineImage', + configuration: { + id: 100, + boxShadow: 'lg', + imageModifiers: [ + {name: 'rounded', value: 'md'} + ] + }, + imageFiles: [{ + permaId: 100, + width: 200, + height: 100 + }] + }); + + const contentElement = getContentElement(); + expect(contentElement.getBoxBorderRadius()).toEqual('md'); + expect(contentElement.hasBoxShadow('lg')).toBe(true); + }); + it('applies rounded styles independently', () => { const {getContentElement} = renderContentElement({ typeName: 'inlineImage', diff --git a/entry_types/scrolled/package/src/contentElements/inlineImage/InlineImage.js b/entry_types/scrolled/package/src/contentElements/inlineImage/InlineImage.js index fd13dd2b3e..09ee0d10cd 100644 --- a/entry_types/scrolled/package/src/contentElements/inlineImage/InlineImage.js +++ b/entry_types/scrolled/package/src/contentElements/inlineImage/InlineImage.js @@ -92,10 +92,12 @@ function ImageWithCaption({ - + Date: Tue, 17 Mar 2026 15:30:44 +0100 Subject: [PATCH 08/12] Support decoration styles for image galleries REDMINE-21248 --- .../scrolled/lib/pageflow_scrolled/plugin.rb | 1 + .../imageGallery/ImageGallery.js | 4 +-- .../imageGallery/ImageGallery.module.css | 26 +++++++++++++------ .../imageGallery/editor/index.js | 1 + 4 files changed, 22 insertions(+), 10 deletions(-) diff --git a/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb b/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb index bf419ed772..87964c9ba1 100644 --- a/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb +++ b/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb @@ -72,6 +72,7 @@ def configure(config) 'section_default_padding_bottom' => 'max(10em, 20svh)', 'content_element_margin_style_default' => '2em', 'content_element_box_shadow_style_default' => '0 4px 6px -1px rgb(0 0 0 / 0.2), 0 2px 4px -2px rgb(0 0 0 / 0.15)', + 'box_shadow_clip_margin_bottom' => '3rem', 'outline_color' => '#a0a0a080' }, cards_appearance_section: { diff --git a/entry_types/scrolled/package/src/contentElements/imageGallery/ImageGallery.js b/entry_types/scrolled/package/src/contentElements/imageGallery/ImageGallery.js index e9ddad542d..d5524618d6 100644 --- a/entry_types/scrolled/package/src/contentElements/imageGallery/ImageGallery.js +++ b/entry_types/scrolled/package/src/contentElements/imageGallery/ImageGallery.js @@ -148,7 +148,7 @@ function Scroller({ contentElementWidth === contentElementWidths.xl}, {[styles.full]: contentElementWidth === contentElementWidths.full}, - {[styles.clip]: configuration.hidePeeks}, + {[styles.hidePeeks]: configuration.hidePeeks}, {[styles.customMargin]: customMargin})}>
- +
Date: Tue, 17 Mar 2026 15:30:44 +0100 Subject: [PATCH 09/12] Support decoration styles for teaser lists REDMINE-21248 --- .../ExternalLinkList/boxStyles-spec.js | 41 +++++++++++++++++++ .../externalLinkList/editor/index.js | 1 + .../frontend/ExternalLink.module.css | 2 + .../frontend/ExternalLinkList.js | 8 +++- .../frontend/ExternalLinkList.module.css | 3 +- 5 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 entry_types/scrolled/package/spec/contentElements/externalLinkList/frontend/ExternalLinkList/boxStyles-spec.js diff --git a/entry_types/scrolled/package/spec/contentElements/externalLinkList/frontend/ExternalLinkList/boxStyles-spec.js b/entry_types/scrolled/package/spec/contentElements/externalLinkList/frontend/ExternalLinkList/boxStyles-spec.js new file mode 100644 index 0000000000..037b5873a5 --- /dev/null +++ b/entry_types/scrolled/package/spec/contentElements/externalLinkList/frontend/ExternalLinkList/boxStyles-spec.js @@ -0,0 +1,41 @@ +import React from 'react'; + +import {ExternalLinkList} from 'contentElements/externalLinkList/frontend/ExternalLinkList'; + +import {renderInContentElement} from 'pageflow-scrolled/testHelpers'; +import '@testing-library/jest-dom/extend-expect' + +describe('ExternalLinkList box styles', () => { + it('sets box shadow custom property from configuration', () => { + const {container} = renderInContentElement( + + ); + + const list = container.querySelector('ul'); + expect(list.style.getPropertyValue('--content-element-box-shadow')) + .toEqual('var(--theme-content-element-box-shadow-md)'); + }); + + it('sets outline color custom property from configuration', () => { + const {container} = renderInContentElement( + + ); + + const list = container.querySelector('ul'); + expect(list.style.getPropertyValue('--content-element-box-outline-color')) + .toEqual('#ff0000'); + }); + + it('does not set custom properties when not configured', () => { + const {container} = renderInContentElement( + + ); + + const list = container.querySelector('ul'); + expect(list.style.getPropertyValue('--content-element-box-shadow')).toEqual(''); + expect(list.style.getPropertyValue('--content-element-box-outline-color')).toEqual(''); + }); +}); diff --git a/entry_types/scrolled/package/src/contentElements/externalLinkList/editor/index.js b/entry_types/scrolled/package/src/contentElements/externalLinkList/editor/index.js index b237dc185d..89f06f191b 100644 --- a/entry_types/scrolled/package/src/contentElements/externalLinkList/editor/index.js +++ b/entry_types/scrolled/package/src/contentElements/externalLinkList/editor/index.js @@ -22,6 +22,7 @@ editor.contentElementTypes.register('externalLinkList', { category: 'tilesAndLinks', supportedPositions: ['inline', 'standAlone'], supportedWidthRange: ['m', 'full'], + supportedStyles: ['boxShadow', 'outline'], defaultConfig: { thumbnailAspectRatio: 'square' diff --git a/entry_types/scrolled/package/src/contentElements/externalLinkList/frontend/ExternalLink.module.css b/entry_types/scrolled/package/src/contentElements/externalLinkList/frontend/ExternalLink.module.css index 228b17529c..a75eeb0748 100644 --- a/entry_types/scrolled/package/src/contentElements/externalLinkList/frontend/ExternalLink.module.css +++ b/entry_types/scrolled/package/src/contentElements/externalLinkList/frontend/ExternalLink.module.css @@ -44,6 +44,8 @@ .card { display: flex; border-radius: var(--theme-content-element-box-border-radius); + box-shadow: var(--content-element-box-shadow, 0 0 #000), + 0 0 0 1px var(--content-element-box-outline-color, transparent); overflow: hidden; will-change: transform; flex: 1; diff --git a/entry_types/scrolled/package/src/contentElements/externalLinkList/frontend/ExternalLinkList.js b/entry_types/scrolled/package/src/contentElements/externalLinkList/frontend/ExternalLinkList.js index 90db6f539d..1c3ab8d2b0 100644 --- a/entry_types/scrolled/package/src/contentElements/externalLinkList/frontend/ExternalLinkList.js +++ b/entry_types/scrolled/package/src/contentElements/externalLinkList/frontend/ExternalLinkList.js @@ -117,7 +117,13 @@ export function ExternalLinkList(props) { {[textPositionStyles.scroller]: scrollerEnabled} )} style={{'--overlay-opacity': overlayOpacity, - '--thumbnail-aspect-ratio': `var(--theme-aspect-ratio-${props.configuration.thumbnailAspectRatio || 'wide'})`}} + '--thumbnail-aspect-ratio': `var(--theme-aspect-ratio-${props.configuration.thumbnailAspectRatio || 'wide'})`, + ...(props.configuration.boxShadow && { + '--content-element-box-shadow': `var(--theme-content-element-box-shadow-${props.configuration.boxShadow})` + }), + ...(props.configuration.outlineColor && { + '--content-element-box-outline-color': props.configuration.outlineColor + })}} onScroll={handleScroll}> {linkList.map((link, index) => Date: Tue, 17 Mar 2026 15:30:44 +0100 Subject: [PATCH 10/12] Support decoration styles for inline audios REDMINE-21248 --- .../src/contentElements/inlineAudio/InlineAudio.js | 2 +- .../package/src/contentElements/inlineAudio/editor.js | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/entry_types/scrolled/package/src/contentElements/inlineAudio/InlineAudio.js b/entry_types/scrolled/package/src/contentElements/inlineAudio/InlineAudio.js index af81698642..f5ad01de0c 100644 --- a/entry_types/scrolled/package/src/contentElements/inlineAudio/InlineAudio.js +++ b/entry_types/scrolled/package/src/contentElements/inlineAudio/InlineAudio.js @@ -73,7 +73,7 @@ export function InlineAudio({contentElementId, configuration}) { return ( - + !!posterId, + binding: 'posterId' + }, + 'outline' + ], defaultConfig: {playerControlVariant: 'waveformBars'}, From 542b8e6fb76e60182431e3cfce2b39422cdb3c0e Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 17 Mar 2026 17:03:16 +0100 Subject: [PATCH 11/12] Support image modifiers for inline videos Allow applying crop and rounded corner effects to inline video elements, including orientation-specific variants for portrait video files. REDMINE-21248 --- entry_types/scrolled/config/locales/de.yml | 2 +- entry_types/scrolled/config/locales/en.yml | 2 +- .../inlineImage/InlineImage-spec.js | 4 +- .../package/spec/frontend/MediaPlayer-spec.js | 86 +++++++++++++ .../frontend/ExternalLink.module.css | 3 +- .../frontend/ExternalLinkList.js | 14 +-- .../inlineImage/InlineImage.js | 21 +--- .../src/contentElements/inlineImage/editor.js | 16 +-- .../inlineVideo/InlineVideo.js | 117 ++++++++++++------ .../src/contentElements/inlineVideo/editor.js | 37 +++++- .../package/src/frontend/ContentElementBox.js | 19 +-- .../src/frontend/ContentElementBox.module.css | 9 +- .../src/frontend/MediaPlayer.module.css | 18 ++- .../package/src/frontend/MediaPlayer/index.js | 19 +-- .../src/frontend/contentElementBoxStyle.js | 18 +++ .../package/src/frontend/imageModifiers.js | 13 ++ .../scrolled/package/src/frontend/index.js | 3 + .../src/frontend/useFileWithCropPosition.js | 3 + 18 files changed, 292 insertions(+), 112 deletions(-) create mode 100644 entry_types/scrolled/package/src/frontend/contentElementBoxStyle.js create mode 100644 entry_types/scrolled/package/src/frontend/imageModifiers.js create mode 100644 entry_types/scrolled/package/src/frontend/useFileWithCropPosition.js diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 34ab828a11..0afa3c57b3 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -720,7 +720,7 @@ de: turnDown: Leiser weiterspielen hideControlBar: inline_help: Für kurze Videos, bei denen der Benutzer nicht an bestimmte Stellen springen will. - inline_help_disabled: Für Videos mit Wiedergabe-Modus "Loop" sind die Controls immer ausgeblendet. + inline_help_disabled: Für Videos mit Wiedergabe-Modus "Loop" oder kreisförmigem Zuschnitt sind die Controls immer ausgeblendet. label: Controls ausblenden id: label: Video diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index 2af6b4141e..d67631e39b 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -705,7 +705,7 @@ en: turnDown: Keep playing at lower volume hideControlBar: inline_help: For short videos where there is no need to seek. - inline_help_disabled: Controls are always hidden for videos with playback mode "Loop". + inline_help_disabled: Controls are always hidden for videos with playback mode "Loop" or circle crop. label: Hide controls id: label: Video diff --git a/entry_types/scrolled/package/spec/contentElements/inlineImage/InlineImage-spec.js b/entry_types/scrolled/package/spec/contentElements/inlineImage/InlineImage-spec.js index 7c47b25a4e..8c5d8b7bbb 100644 --- a/entry_types/scrolled/package/spec/contentElements/inlineImage/InlineImage-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/inlineImage/InlineImage-spec.js @@ -34,7 +34,7 @@ describe('InlineImage', () => { }); const contentElement = getContentElement(); - expect(contentElement.getFitViewportAspectRatio()).toEqual('1'); + expect(contentElement.getFitViewportAspectRatio()).toEqual('square'); }); it('applies circle border radius', () => { @@ -198,7 +198,7 @@ describe('InlineImage', () => { }); const contentElement = getContentElement(); - expect(contentElement.getFitViewportAspectRatio()).toEqual('1'); + expect(contentElement.getFitViewportAspectRatio()).toEqual('square'); expect(contentElement.getBoxBorderRadius()).toEqual('circle'); }); diff --git a/entry_types/scrolled/package/spec/frontend/MediaPlayer-spec.js b/entry_types/scrolled/package/spec/frontend/MediaPlayer-spec.js index ddc2ded947..20c02f91ad 100644 --- a/entry_types/scrolled/package/spec/frontend/MediaPlayer-spec.js +++ b/entry_types/scrolled/package/spec/frontend/MediaPlayer-spec.js @@ -9,6 +9,7 @@ import {media} from 'pageflow/frontend'; import {MediaPlayer} from 'frontend/MediaPlayer'; import {EventContext} from 'frontend/useEventContextData'; import {StaticPreview} from 'frontend/useScrollPositionLifecycle'; +import styles from 'frontend/MediaPlayer.module.css'; describe('MediaPlayer', () => { useFakeMedia(); @@ -116,6 +117,91 @@ describe('MediaPlayer', () => { expect(getByRole('img')).toHaveAttribute('src', 'poster.jpg'); }); + it('has posterVisible class when video has poster and has not started playing', () => { + const {container} = render(); + + expect(container.firstChild).toHaveClass(styles.posterVisible); + }); + + it('does not have posterVisible class when video has no poster', () => { + const {container} = render(); + + expect(container.firstChild).not.toHaveClass(styles.posterVisible); + }); + + it('does not have posterVisible class when video data loaded and played', () => { + const playerState = { + ...getInitialPlayerState(), + dataLoaded: true, + unplayed: false + }; + + const {container} = render(); + + expect(container.firstChild).not.toHaveClass(styles.posterVisible); + }); + + it('has posterVisible class when video data loaded but still unplayed', () => { + const playerState = { + ...getInitialPlayerState(), + dataLoaded: true, + unplayed: true + }; + + const {container} = render(); + + expect(container.firstChild).toHaveClass(styles.posterVisible); + }); + + it('has posterVisible class for audio type with poster', () => { + const {container} = render(); + + expect(container.firstChild).toHaveClass(styles.posterVisible); + }); + + it('keeps posterVisible class for audio type during playback', () => { + const playerState = { + ...getInitialPlayerState(), + dataLoaded: true, + unplayed: false + }; + + const {container} = render(); + + expect(container.firstChild).toHaveClass(styles.posterVisible); + }); + + it('applies contentElementBox class when applyContentElementBoxStyles is set', () => { + const {container} = render(); + + expect(container.firstChild).toHaveClass(styles.contentElementBox); + }); + + it('does not apply contentElementBox class by default', () => { + const {container} = render(); + + expect(container.firstChild).not.toHaveClass(styles.contentElementBox); + }); + it('renders audio player when sources are present', () => { const {queryPlayer} = render( {linkList.map((link, index) => imageModifier.name === name)?.value; -} - -function useFileWithCropPosition(file, cropPosition) { - return file && {...file, cropPosition}; -} diff --git a/entry_types/scrolled/package/src/contentElements/inlineImage/editor.js b/entry_types/scrolled/package/src/contentElements/inlineImage/editor.js index df9592edaa..3bf23028a2 100644 --- a/entry_types/scrolled/package/src/contentElements/inlineImage/editor.js +++ b/entry_types/scrolled/package/src/contentElements/inlineImage/editor.js @@ -1,5 +1,5 @@ import {editor, InlineFileRightsMenuItem, ImageModifierListInputView} from 'pageflow-scrolled/editor'; -import {contentElementWidths} from 'pageflow-scrolled/frontend'; +import {contentElementWidths, processImageModifiers} from 'pageflow-scrolled/frontend'; import {FileInputView} from 'pageflow/editor'; import {SeparatorView, CheckBoxInputView} from 'pageflow/ui'; @@ -21,12 +21,12 @@ editor.contentElementTypes.register('inlineImage', { this.input('id', FileInputView, { collection: 'image_files', fileSelectionHandler: 'contentElementConfiguration', - positioning: imageModifiers => imageModifiers?.length, + positioning: imageModifiers => !!processImageModifiers(imageModifiers).aspectRatio, positioningBinding: 'imageModifiers', positioningOptions: () => { - const aspectRatio = entry.getAspectRatio(this.model.get('imageModifiers')?.[0]?.value); + const {aspectRatio} = processImageModifiers(this.model.get('imageModifiers')); return { - preview: aspectRatio && (1 / aspectRatio) + preview: aspectRatio && (1 / entry.getAspectRatio(aspectRatio)) }; }, dropDownMenuItems: [InlineFileRightsMenuItem] @@ -39,13 +39,13 @@ editor.contentElementTypes.register('inlineImage', { this.input('portraitId', FileInputView, { collection: 'image_files', fileSelectionHandler: 'contentElementConfiguration', - positioning: imageModifiers => imageModifiers?.length, + positioning: imageModifiers => !!processImageModifiers(imageModifiers).aspectRatio, positioningBinding: 'portraitImageModifiers', positioningOptions: () => { - const aspectRatio = entry.getAspectRatio(this.model.get('portraitImageModifiers')?.[0]?.value); + const {aspectRatio} = processImageModifiers(this.model.get('portraitImageModifiers')); return { - preview: aspectRatio && (1 / aspectRatio) - } + preview: aspectRatio && (1 / entry.getAspectRatio(aspectRatio)) + }; } }); this.input('portraitImageModifiers', ImageModifierListInputView, { diff --git a/entry_types/scrolled/package/src/contentElements/inlineVideo/InlineVideo.js b/entry_types/scrolled/package/src/contentElements/inlineVideo/InlineVideo.js index 0a3a436f00..fc1439a127 100644 --- a/entry_types/scrolled/package/src/contentElements/inlineVideo/InlineVideo.js +++ b/entry_types/scrolled/package/src/contentElements/inlineVideo/InlineVideo.js @@ -10,6 +10,9 @@ import { InlineFileRights, FitViewport, PlayerEventContextDataProvider, + contentElementBoxProps, + processImageModifiers, + useFileWithCropPosition, useBackgroundFile, useContentElementEditorState, useFileWithInlineRights, @@ -27,38 +30,38 @@ import { } from './handlers'; export function InlineVideo({contentElementId, configuration, sectionProps}) { - const videoFile = useFileWithInlineRights({ - configuration, - collectionName: 'videoFiles', - propertyName: 'id' - }); + const videoFile = useFileWithCropPosition( + useFileWithInlineRights({ + configuration, collectionName: 'videoFiles', propertyName: 'id' + }), + configuration.cropPosition + ); const posterImageFile = useFileWithInlineRights({ configuration, collectionName: 'imageFiles', propertyName: 'posterId' }); - const portraitVideoFile = useFileWithInlineRights({ - configuration, - collectionName: 'videoFiles', - propertyName: 'portraitId' - }); + const portraitVideoFile = useFileWithCropPosition( + useFileWithInlineRights({ + configuration, collectionName: 'videoFiles', propertyName: 'portraitId' + }), + configuration.portraitCropPosition + ); const portraitPosterImageFile = useFileWithInlineRights({ configuration, collectionName: 'imageFiles', propertyName: 'portraitPosterId' }); - // Only render OrientationAwareInlineImage if a portrait image has - // been selected. This prevents having the component rerender on - // orientation changes even if it does not depend on orientation at - // all. if (portraitVideoFile) { return ( - - - - - - - - + {renderContentElementBox({rounded, configuration}, + + + + + + + )} @@ -154,6 +169,25 @@ function OrientationUnawareInlineVideo({ ) } +function renderContentElementBox({rounded, configuration}, children) { + if (rounded === 'circle') { + const {style, className} = contentElementBoxProps(configuration, {borderRadius: 'circle'}); + + return ( +
+ {children} +
+ ); + } + + return ( + + {children} + + ); +} + function CropPositionComputingPlayer({videoFile, motifArea, ...props}) { const videoFileWithMotifArea = useBackgroundFile({ file: videoFile, @@ -172,7 +206,8 @@ function PlayerWithControlBar({ inlineFileRightsItems, playerState, playerActions, contentElementId, configuration, - sectionProps + sectionProps, fit, hideControlBar, + applyContentElementBoxStyles }) { const {isEditable, isSelected} = useContentElementEditorState(); @@ -225,8 +260,7 @@ function PlayerWithControlBar({ defaultTextTrackFilePermaId={configuration.defaultTextTrackFileId} playerState={playerState} playerActions={playerActions} - hideControlBar={configuration.hideControlBar || - configuration.playbackMode === 'loop'} + hideControlBar={hideControlBar} hideBigPlayButton={configuration.playbackMode === 'loop'} inlineFileRightsItems={inlineFileRightsItems} configuration={configuration} @@ -238,7 +272,7 @@ function PlayerWithControlBar({ shouldLoad ? 'poster' : 'none'} loop={configuration.playbackMode === 'loop'} - fit={configuration.position === 'backdrop' ? 'cover' : 'contain'} + fit={fit} playerState={playerState} playerActions={playerActions} videoFile={videoFile} @@ -246,7 +280,8 @@ function PlayerWithControlBar({ defaultTextTrackFilePermaId={configuration.defaultTextTrackFileId} quality={'high'} playsInline={true} - atmoDuringPlayback={configuration.atmoDuringPlayback} /> + atmoDuringPlayback={configuration.atmoDuringPlayback} + applyContentElementBoxStyles={applyContentElementBoxStyles} /> ); diff --git a/entry_types/scrolled/package/src/contentElements/inlineVideo/editor.js b/entry_types/scrolled/package/src/contentElements/inlineVideo/editor.js index 46ba29f24e..034889b8db 100644 --- a/entry_types/scrolled/package/src/contentElements/inlineVideo/editor.js +++ b/entry_types/scrolled/package/src/contentElements/inlineVideo/editor.js @@ -1,7 +1,8 @@ import Backbone from 'backbone'; import I18n from 'i18n-js'; -import {editor, InlineFileRightsMenuItem, EditMotifAreaDialogView} from 'pageflow-scrolled/editor'; +import {editor, InlineFileRightsMenuItem, EditMotifAreaDialogView, ImageModifierListInputView} from 'pageflow-scrolled/editor'; +import {processImageModifiers} from 'pageflow-scrolled/frontend'; import {FileInputView, CheckBoxInputView} from 'pageflow/editor'; import {SelectInputView, SeparatorView, LabelOnlyView} from 'pageflow/ui'; @@ -52,7 +53,14 @@ editor.contentElementTypes.register('inlineVideo', { this.input('id', FileInputView, { collection: 'video_files', fileSelectionHandler: 'contentElementConfiguration', - positioning: false, + positioning: imageModifiers => !!processImageModifiers(imageModifiers).aspectRatio, + positioningBinding: 'imageModifiers', + positioningOptions: () => { + const {aspectRatio} = processImageModifiers(this.model.get('imageModifiers')); + return { + preview: aspectRatio && (1 / entry.getAspectRatio(aspectRatio)) + }; + }, defaultTextTrackFilePropertyName: 'defaultTextTrackFileId', dropDownMenuItems: [EditMotifAreaMenuItem, InlineFileRightsMenuItem] }); @@ -62,11 +70,23 @@ editor.contentElementTypes.register('inlineVideo', { positioning: false, dropDownMenuItems: [InlineFileRightsMenuItem] }); + this.input('imageModifiers', ImageModifierListInputView, { + entry, + visibleBinding: 'id', + visible: () => this.model.getReference('id', 'video_files') + }); this.input('portraitId', FileInputView, { collection: 'video_files', fileSelectionHandler: 'contentElementConfiguration', - positioning: false, + positioning: imageModifiers => !!processImageModifiers(imageModifiers).aspectRatio, + positioningBinding: 'portraitImageModifiers', + positioningOptions: () => { + const {aspectRatio} = processImageModifiers(this.model.get('portraitImageModifiers')); + return { + preview: aspectRatio && (1 / entry.getAspectRatio(aspectRatio)) + }; + }, defaultTextTrackFilePropertyName: 'defaultTextTrackFileId', dropDownMenuItems: [EditMotifAreaMenuItem, InlineFileRightsMenuItem] }); @@ -78,6 +98,11 @@ editor.contentElementTypes.register('inlineVideo', { visible: () => this.model.getReference('portraitId', 'video_files'), dropDownMenuItems: [InlineFileRightsMenuItem] }); + this.input('portraitImageModifiers', ImageModifierListInputView, { + entry, + visibleBinding: 'portraitId', + visible: () => this.model.getReference('portraitId', 'video_files') + }); this.view(SeparatorView); @@ -86,8 +111,10 @@ editor.contentElementTypes.register('inlineVideo', { }); this.input('hideControlBar', CheckBoxInputView, { - disabledBinding: 'playbackMode', - disabled: playbackMode => playbackMode === 'loop', + disabledBinding: ['playbackMode', 'imageModifiers'], + disabled: ([playbackMode, imageModifiers]) => + playbackMode === 'loop' || + processImageModifiers(imageModifiers).rounded === 'circle', displayCheckedIfDisabled: true }); diff --git a/entry_types/scrolled/package/src/frontend/ContentElementBox.js b/entry_types/scrolled/package/src/frontend/ContentElementBox.js index bb2bfe907c..67b17a0f96 100644 --- a/entry_types/scrolled/package/src/frontend/ContentElementBox.js +++ b/entry_types/scrolled/package/src/frontend/ContentElementBox.js @@ -2,6 +2,7 @@ import React from 'react'; import classNames from 'classnames'; import {useContentElementAttributes} from './useContentElementAttributes'; +import {contentElementBoxProps} from './contentElementBoxStyle'; import {widths} from './layouts/widths'; import styles from './ContentElementBox.module.css'; @@ -18,31 +19,19 @@ import styles from './ContentElementBox.module.css'; export function ContentElementBox({children, configuration, borderRadius, positioned}) { const {position, width} = useContentElementAttributes(); - const boxShadow = configuration?.boxShadow; - const outlineColor = configuration?.outlineColor; + const {style} = contentElementBoxProps(configuration, {borderRadius}); if (position === 'backdrop') { return children; } - if (borderRadius === 'none' && !boxShadow && !outlineColor) { + if (borderRadius === 'none' && !Object.keys(style).length) { return children; } - const style = { - ...(borderRadius && borderRadius !== 'none' && { - '--content-element-box-border-radius': `var(--theme-content-element-box-border-radius-${borderRadius})` - }), - ...(boxShadow && { - '--content-element-box-shadow': `var(--theme-content-element-box-shadow-${boxShadow})` - }), - ...(outlineColor && { - '--content-element-box-outline-color': outlineColor - }) - }; - return (
+ {[styles.contentElementBox]: props.applyContentElementBoxStyles, + [styles.posterVisible]: posterVisible, + [textTrackStyles.inset]: props.textTracksInset})}> {load === 'auto' && } {load !== 'none' && } + objectPosition={props.objectPosition} />}
); } @@ -40,7 +44,7 @@ MediaPlayer.defaultProps = { load: 'auto' }; -function Poster({imageUrl, objectPosition, hide}) { +function Poster({imageUrl, objectPosition}) { if (!imageUrl) { return null; } @@ -49,7 +53,6 @@ function Poster({imageUrl, objectPosition, hide}) { ); diff --git a/entry_types/scrolled/package/src/frontend/contentElementBoxStyle.js b/entry_types/scrolled/package/src/frontend/contentElementBoxStyle.js new file mode 100644 index 0000000000..715ce72da7 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/contentElementBoxStyle.js @@ -0,0 +1,18 @@ +import styles from './ContentElementBox.module.css'; + +export function contentElementBoxProps(configuration, {borderRadius} = {}) { + return { + className: styles.properties, + style: { + ...(borderRadius && borderRadius !== 'none' && { + '--content-element-box-border-radius': `var(--theme-content-element-box-border-radius-${borderRadius})` + }), + ...(configuration?.boxShadow && { + '--content-element-box-shadow': `var(--theme-content-element-box-shadow-${configuration.boxShadow})` + }), + ...(configuration?.outlineColor && { + '--content-element-box-outline-color': configuration.outlineColor + }) + } + }; +} diff --git a/entry_types/scrolled/package/src/frontend/imageModifiers.js b/entry_types/scrolled/package/src/frontend/imageModifiers.js new file mode 100644 index 0000000000..49e3e4fd33 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/imageModifiers.js @@ -0,0 +1,13 @@ +export function processImageModifiers(imageModifiers) { + const cropValue = getModifierValue(imageModifiers, 'crop'); + const isCircleCrop = cropValue === 'circle'; + + return { + aspectRatio: isCircleCrop ? 'square' : cropValue, + rounded: isCircleCrop ? 'circle' : getModifierValue(imageModifiers, 'rounded') + }; +} + +function getModifierValue(imageModifiers, name) { + return (imageModifiers || []).find(imageModifier => imageModifier.name === name)?.value; +} diff --git a/entry_types/scrolled/package/src/frontend/index.js b/entry_types/scrolled/package/src/frontend/index.js index 9dbb16ce51..f474d0599b 100644 --- a/entry_types/scrolled/package/src/frontend/index.js +++ b/entry_types/scrolled/package/src/frontend/index.js @@ -38,7 +38,10 @@ export * from './Atmo'; export * from './useAtmo'; export {ContentElementBox} from './ContentElementBox'; +export {contentElementBoxProps} from './contentElementBoxStyle'; export {ContentElementFigure} from './ContentElementFigure'; +export {processImageModifiers} from './imageModifiers'; +export {useFileWithCropPosition} from './useFileWithCropPosition'; export {MediaInteractionTracking} from './MediaInteractionTracking'; export {MediaPlayerControls} from './MediaPlayerControls'; export {VideoPlayerControls} from './VideoPlayerControls'; diff --git a/entry_types/scrolled/package/src/frontend/useFileWithCropPosition.js b/entry_types/scrolled/package/src/frontend/useFileWithCropPosition.js new file mode 100644 index 0000000000..5503294386 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/useFileWithCropPosition.js @@ -0,0 +1,3 @@ +export function useFileWithCropPosition(file, cropPosition) { + return file && {...file, cropPosition}; +} From 25e835f95ba03f4387b9b9eca8ed4a8a9cc40133 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Wed, 18 Mar 2026 09:14:07 +0100 Subject: [PATCH 12/12] Remove separator below last dropdown button group The bottom border on the last group creates a visual artifact since there is no following group to separate from. REDMINE-21248 --- app/assets/stylesheets/pageflow/editor/drop_down_button.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/stylesheets/pageflow/editor/drop_down_button.scss b/app/assets/stylesheets/pageflow/editor/drop_down_button.scss index f5f5e7e77a..9ba5dc2801 100644 --- a/app/assets/stylesheets/pageflow/editor/drop_down_button.scss +++ b/app/assets/stylesheets/pageflow/editor/drop_down_button.scss @@ -160,7 +160,7 @@ } } - ul { + &:not(:last-child) ul { border-bottom: solid 1px var(--ui-on-surface-color-lighter); padding-bottom: 1px; margin-bottom: 1px;