From 93b6a3d914e04bdbb782e4d8f5912d7d7ffa5435 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 18 Dec 2025 08:54:29 +0100 Subject: [PATCH 01/17] Allow registering default options for all themes A callable can be passed to base defaults on options set by theme. REDMINE-21191 --- app/models/pageflow/customized_theme.rb | 5 +- lib/pageflow/themes.rb | 32 ++++++ spec/pageflow/theme_customizations_spec.rb | 117 +++++++++++++++++++++ 3 files changed, 152 insertions(+), 2 deletions(-) diff --git a/app/models/pageflow/customized_theme.rb b/app/models/pageflow/customized_theme.rb index 5f081da23f..afc6ddf427 100644 --- a/app/models/pageflow/customized_theme.rb +++ b/app/models/pageflow/customized_theme.rb @@ -1,9 +1,9 @@ module Pageflow # @api private class CustomizedTheme < SimpleDelegator - def initialize(theme, overrides, files) + def initialize(theme, transformed_options, overrides, files) super(theme) - @options = __getobj__.options.deep_merge(overrides || {}) + @options = transformed_options.deep_merge(overrides || {}) @files = files end @@ -24,6 +24,7 @@ def self.build(entry:, theme:, theme_customization:) config = Pageflow.config_for(entry) new(theme, + config.themes.apply_default_options(theme.options), config.transform_theme_customization_overrides.call( theme_customization.overrides, entry:, diff --git a/lib/pageflow/themes.rb b/lib/pageflow/themes.rb index dc1386d782..b42182370c 100644 --- a/lib/pageflow/themes.rb +++ b/lib/pageflow/themes.rb @@ -4,6 +4,38 @@ class Themes # rubocop:todo Style/Documentation def initialize @themes = HashWithIndifferentAccess.new + @options_transforms = [] + end + + # Register default options that apply to all themes. Can be called + # multiple times to accumulate defaults from different sources + # (gem, plugins, host app). + # + # @overload register_default_options(options) + # @param options [Hash] + # Default options to deep merge into theme options. + # + # @overload register_default_options(callable) + # @param callable [#call] + # Receives options hash, returns transformed options. + # Use for conditional defaults based on what theme defines. + # + # @since edge + def register_default_options(options_or_callable) + @options_transforms << if options_or_callable.respond_to?(:call) + options_or_callable + else + ->(options) { options_or_callable.deep_merge(options) } + end + end + + # Apply all registered defaults to theme options. + # + # @api private + def apply_default_options(options) + @options_transforms.reduce(options.deep_dup) do |opts, transform| + transform.call(opts) + end end # Register a theme and supply theme options. diff --git a/spec/pageflow/theme_customizations_spec.rb b/spec/pageflow/theme_customizations_spec.rb index 2e4a39e042..c4ccd60d1e 100644 --- a/spec/pageflow/theme_customizations_spec.rb +++ b/spec/pageflow/theme_customizations_spec.rb @@ -471,5 +471,122 @@ module Pageflow expect(customization.selected_files[:inverted_logo].urls[:small]) .to match(%r{small/image.png}) end + + it 'deep merges hash default options into theme options' do + pageflow_configure do |config| + rainbow_entry_type = TestEntryType.register(config, name: 'rainbow') + + config.for_entry_type(rainbow_entry_type) do |c| + c.themes.register_default_options(colors: {accent: '#default', surface: '#fff'}) + c.themes.register('dark') + end + end + entry = create(:published_entry, + type_name: 'rainbow', + revision_attributes: {theme_name: 'dark'}) + + expect(entry.theme.options).to match(colors: {accent: '#default', surface: '#fff'}) + end + + it 'lets theme options override hash defaults' do + pageflow_configure do |config| + rainbow_entry_type = TestEntryType.register(config, name: 'rainbow') + + config.for_entry_type(rainbow_entry_type) do |c| + c.themes.register_default_options(colors: {accent: '#default', surface: '#fff'}) + c.themes.register('dark', colors: {accent: '#f00'}) + end + end + entry = create(:published_entry, + type_name: 'rainbow', + revision_attributes: {theme_name: 'dark'}) + + expect(entry.theme.options).to match(colors: {accent: '#f00', surface: '#fff'}) + end + + it 'allows site customizations to override default options' do + pageflow_configure do |config| + rainbow_entry_type = TestEntryType.register(config, name: 'rainbow') + + config.for_entry_type(rainbow_entry_type) do |c| + c.themes.register_default_options(colors: {surface: '#fff'}) + c.themes.register('dark', colors: {accent: '#f00'}) + end + end + site = create(:site) + entry = create(:published_entry, + site:, + type_name: 'rainbow', + revision_attributes: {theme_name: 'dark'}) + + Pageflow.theme_customizations.update(site:, + entry_type_name: 'rainbow', + overrides: {colors: {accent: '#0f0'}}) + + expect(entry.theme.options).to match(colors: {accent: '#0f0', surface: '#fff'}) + end + + it 'accumulates multiple register_default_options calls' do + pageflow_configure do |config| + rainbow_entry_type = TestEntryType.register(config, name: 'rainbow') + + config.for_entry_type(rainbow_entry_type) do |c| + c.themes.register_default_options(colors: {accent: '#default'}) + c.themes.register_default_options(typography: {base: {fontSize: '16px'}}) + c.themes.register('dark') + end + end + entry = create(:published_entry, + type_name: 'rainbow', + revision_attributes: {theme_name: 'dark'}) + + expect(entry.theme.options).to match( + colors: {accent: '#default'}, + typography: {base: {fontSize: '16px'}} + ) + end + + it 'supports callable for conditional defaults' do + pageflow_configure do |config| + rainbow_entry_type = TestEntryType.register(config, name: 'rainbow') + + config.for_entry_type(rainbow_entry_type) do |c| + c.themes.register_default_options(->(options) { + if options[:colors].blank? + options.deep_merge(colors: {accent: '#default'}) + else + options + end + }) + c.themes.register('dark', colors: {accent: '#f00'}) + end + end + entry = create(:published_entry, + type_name: 'rainbow', + revision_attributes: {theme_name: 'dark'}) + + expect(entry.theme.options).to match(colors: {accent: '#f00'}) + end + + it 'scopes default options by entry type' do + pageflow_configure do |config| + rainbow_entry_type = TestEntryType.register(config, name: 'rainbow') + other_entry_type = TestEntryType.register(config, name: 'other') + + config.for_entry_type(rainbow_entry_type) do |c| + c.themes.register('dark', colors: {accent: '#f00'}) + end + + config.for_entry_type(other_entry_type) do |c| + c.themes.register_default_options(colors: {accent: '#other'}) + c.themes.register('dark') + end + end + entry = create(:published_entry, + type_name: 'rainbow', + revision_attributes: {theme_name: 'dark'}) + + expect(entry.theme.options).to match(colors: {accent: '#f00'}) + end end end From f6fea233fd8ba1bb7bfcae2a6ce0734c68372f9e Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 18 Dec 2025 09:40:58 +0100 Subject: [PATCH 02/17] Apply appearance scope class to section Can be used to use different default section padding per appearance. REDMINE-21191 --- .../sectionAppearanceScopeClass-spec.js | 37 +++++++++++++++++++ .../scrolled/package/src/frontend/Section.js | 3 +- .../package/src/frontend/appearance.js | 10 +++++ .../scrolled/package/src/frontend/index.js | 1 + 4 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 entry_types/scrolled/package/spec/frontend/features/sectionAppearanceScopeClass-spec.js diff --git a/entry_types/scrolled/package/spec/frontend/features/sectionAppearanceScopeClass-spec.js b/entry_types/scrolled/package/spec/frontend/features/sectionAppearanceScopeClass-spec.js new file mode 100644 index 0000000000..b96917d3b1 --- /dev/null +++ b/entry_types/scrolled/package/spec/frontend/features/sectionAppearanceScopeClass-spec.js @@ -0,0 +1,37 @@ +import {renderEntry} from 'support/pageObjects'; +import '@testing-library/jest-dom/extend-expect'; + +describe('section appearance scope class', () => { + it('applies scope class for default shadow appearance', () => { + const {getSectionByPermaId} = renderEntry({ + seed: { + sections: [{id: 5, permaId: 6}], + contentElements: [{sectionId: 5}] + } + }); + + expect(getSectionByPermaId(6).el).toHaveClass('scope-shadowAppearanceSection'); + }); + + it('applies scope class for cards appearance', () => { + const {getSectionByPermaId} = renderEntry({ + seed: { + sections: [{id: 5, permaId: 6, configuration: {appearance: 'cards'}}], + contentElements: [{sectionId: 5}] + } + }); + + expect(getSectionByPermaId(6).el).toHaveClass('scope-cardsAppearanceSection'); + }); + + it('applies scope class for transparent appearance', () => { + const {getSectionByPermaId} = renderEntry({ + seed: { + sections: [{id: 5, permaId: 6, configuration: {appearance: 'transparent'}}], + contentElements: [{sectionId: 5}] + } + }); + + expect(getSectionByPermaId(6).el).toHaveClass('scope-transparentAppearanceSection'); + }); +}); diff --git a/entry_types/scrolled/package/src/frontend/Section.js b/entry_types/scrolled/package/src/frontend/Section.js index 6cf64b52e7..806668947c 100644 --- a/entry_types/scrolled/package/src/frontend/Section.js +++ b/entry_types/scrolled/package/src/frontend/Section.js @@ -22,13 +22,13 @@ import {BackgroundColorProvider} from './backgroundColor'; import {SelectableWidget} from './SelectableWidget'; import {useSectionPadding} from './useSectionPaddingCustomProperties'; import {SectionIntersectionProbe} from './SectionIntersectionObserver'; +import {getAppearanceComponents, getAppearanceSectionScopeName} from './appearance'; import * as v1 from './v1'; import * as v2 from './v2'; import styles from './Section.module.css'; import {getTransitionStyles, getEnterAndExitTransitions} from './transitions' -import {getAppearanceComponents} from './appearance'; const Section = withInlineEditingDecorator('SectionDecorator', function Section({ section, transitions, backdrop, contentElements, state, onActivate, domIdPrefix @@ -64,6 +64,7 @@ const Section = withInlineEditingDecorator('SectionDecorator', function Section( backdropSectionClassNames, {[styles.first]: section.sectionIndex === 0}, {[styles.narrow]: section.width === 'narrow'}, + `scope-${getAppearanceSectionScopeName(section.appearance)}`, section.invert ? styles.darkContent : styles.lightContent)} style={{ ...useBackdropSectionCustomProperties(backdrop), diff --git a/entry_types/scrolled/package/src/frontend/appearance.js b/entry_types/scrolled/package/src/frontend/appearance.js index 1d2d01aea2..07de98e68c 100644 --- a/entry_types/scrolled/package/src/frontend/appearance.js +++ b/entry_types/scrolled/package/src/frontend/appearance.js @@ -24,6 +24,16 @@ const components = { } }; +const sectionScopeNames = { + shadow: 'shadowAppearanceSection', + transparent: 'transparentAppearanceSection', + cards: 'cardsAppearanceSection' +}; + export function getAppearanceComponents(appearance) { return components[appearance || 'shadow'] } + +export function getAppearanceSectionScopeName(appearance) { + return sectionScopeNames[appearance || 'shadow']; +} diff --git a/entry_types/scrolled/package/src/frontend/index.js b/entry_types/scrolled/package/src/frontend/index.js index e6edfa0dc9..52f737b40c 100644 --- a/entry_types/scrolled/package/src/frontend/index.js +++ b/entry_types/scrolled/package/src/frontend/index.js @@ -126,6 +126,7 @@ export {ScrollButton} from './ScrollButton'; export {textColorForBackgroundColor} from './textColorForBackgroundColor'; export {getTransitionNames, getAvailableTransitionNames} from './transitions'; +export {getAppearanceSectionScopeName} from './appearance'; export {RootProviders, registerConsentVendors}; export {default as registerTemplateWidgetType} from './registerTemplateWidgetType'; From b8737d6298c92e3d2731608c248f571f68610f1a Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 18 Dec 2025 09:44:35 +0100 Subject: [PATCH 03/17] Set sliders to default padding when no padding has been defined REDMINE-21191 --- .../views/EditSectionPaddingsView-spec.js | 99 +++++++++++++++++++ .../editor/views/EditSectionPaddingsView.js | 28 +++++- 2 files changed, 124 insertions(+), 3 deletions(-) diff --git a/entry_types/scrolled/package/spec/editor/views/EditSectionPaddingsView-spec.js b/entry_types/scrolled/package/spec/editor/views/EditSectionPaddingsView-spec.js index 764b721488..99fcf4d4e0 100644 --- a/entry_types/scrolled/package/spec/editor/views/EditSectionPaddingsView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/EditSectionPaddingsView-spec.js @@ -760,4 +760,103 @@ describe('EditSectionPaddingsView', () => { expect(listener).toHaveBeenCalledWith(entry.sections.get(1), {align: 'nearEnd', ifNeeded: true}); }); }); + + describe('slider default value', () => { + useFakeTranslations({ + 'pageflow_scrolled.editor.edit_section_paddings.tabs.sectionPaddings': 'Landscape', + 'pageflow_scrolled.editor.section_padding_visualization.top_padding': 'TopPadding', + 'pageflow_scrolled.editor.section_padding_visualization.bottom_padding': 'Bottom' + }); + + it('displays default value text when section has no paddingTop set', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {backdropType: 'color'}}], + themeOptions: { + properties: { + root: { + sectionDefaultPaddingTop: '3em', + 'sectionPaddingTop-none': '0', + 'sectionPaddingTop-sm': '1.375em', + 'sectionPaddingTop-lg': '3em', + 'sectionPaddingBottom-none': '0', + 'sectionPaddingBottom-sm': '1.375em', + 'sectionPaddingBottom-lg': '3em' + } + } + }, + themeTranslations: { + scales: { + sectionPaddingTop: { + none: 'None', + sm: 'Small', + lg: 'Large' + }, + sectionPaddingBottom: { + none: 'None', + sm: 'Small', + lg: 'Large' + } + } + } + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + renderBackboneView(view); + + // Find the first slider's value display element + const sliderValueText = view.$el.find('.slider_input .value').eq(0).text(); + expect(sliderValueText).toEqual('Large'); + }); + + it('prefers appearance-specific default over root default', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {backdropType: 'color', appearance: 'cards'}}], + themeOptions: { + properties: { + root: { + sectionDefaultPaddingTop: '1.375em', + 'sectionPaddingTop-none': '0', + 'sectionPaddingTop-sm': '1.375em', + 'sectionPaddingTop-lg': '3em', + 'sectionPaddingBottom-none': '0', + 'sectionPaddingBottom-sm': '1.375em', + 'sectionPaddingBottom-lg': '3em' + }, + cardsAppearanceSection: { + sectionDefaultPaddingTop: '3em' + } + } + }, + themeTranslations: { + scales: { + sectionPaddingTop: { + none: 'None', + sm: 'Small', + lg: 'Large' + }, + sectionPaddingBottom: { + none: 'None', + sm: 'Small', + lg: 'Large' + } + } + } + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + renderBackboneView(view); + + // Should display 'Large' (3em from cardsAppearanceSection) not 'Small' (1.375em from root) + const sliderValueText = view.$el.find('.slider_input .value').eq(0).text(); + expect(sliderValueText).toEqual('Large'); + }); + }); }); diff --git a/entry_types/scrolled/package/src/editor/views/EditSectionPaddingsView.js b/entry_types/scrolled/package/src/editor/views/EditSectionPaddingsView.js index 560e114bca..0d96e8cf56 100644 --- a/entry_types/scrolled/package/src/editor/views/EditSectionPaddingsView.js +++ b/entry_types/scrolled/package/src/editor/views/EditSectionPaddingsView.js @@ -3,6 +3,7 @@ import I18n from 'i18n-js'; import {EditConfigurationView, SeparatorView} from 'pageflow/editor'; import {SliderInputView, RadioButtonGroupInputView, CheckBoxInputView, SelectInputView} from 'pageflow/ui'; +import {getAppearanceSectionScopeName} from 'pageflow-scrolled/frontend'; import {SectionPaddingVisualizationView} from './inputs/SectionPaddingVisualizationView'; import {EditMotifAreaInputView} from './inputs/EditMotifAreaInputView'; import paddingTopIcon from './images/paddingTop.svg'; @@ -32,8 +33,12 @@ export const EditSectionPaddingsView = EditConfigurationView.extend({ const section = this.model; const configuration = section.configuration; - const [paddingTopValues, paddingTopTexts] = entry.getScale('sectionPaddingTop'); - const [paddingBottomValues, paddingBottomTexts] = entry.getScale('sectionPaddingBottom'); + const [paddingTopValues, paddingTopTexts, paddingTopCssValues] = entry.getScale('sectionPaddingTop'); + const [paddingBottomValues, paddingBottomTexts, paddingBottomCssValues] = entry.getScale('sectionPaddingBottom'); + + const appearance = configuration.get('appearance'); + const defaultPaddingTop = getDefaultPaddingValue(entry, 'sectionDefaultPaddingTop', paddingTopValues, paddingTopCssValues, appearance); + const defaultPaddingBottom = getDefaultPaddingValue(entry, 'sectionDefaultPaddingBottom', paddingBottomValues, paddingBottomCssValues, appearance); const hasPortrait = hasPortraitBackdrop(configuration); @@ -42,7 +47,7 @@ export const EditSectionPaddingsView = EditConfigurationView.extend({ entry.unset('emulation_mode'); } - paddingInputs(this, {entry, section, paddingTopValues, paddingTopTexts, paddingBottomValues, paddingBottomTexts}); + paddingInputs(this, {entry, section, paddingTopValues, paddingTopTexts, paddingBottomValues, paddingBottomTexts, defaultPaddingTop, defaultPaddingBottom}); }); if (!hasPortrait) { @@ -70,6 +75,7 @@ export const EditSectionPaddingsView = EditConfigurationView.extend({ prefix: usePortraitProperties ? 'portrait' : '', portrait: true, paddingTopValues, paddingTopTexts, paddingBottomValues, paddingBottomTexts, + defaultPaddingTop, defaultPaddingBottom, disabledOptions: usePortraitProperties ? {} : {disabled: true} }); }); @@ -83,6 +89,7 @@ function paddingInputs(tab, options) { prefix = '', portrait = false, paddingTopValues, paddingTopTexts, paddingBottomValues, paddingBottomTexts, + defaultPaddingTop, defaultPaddingBottom, disabledOptions } = options; @@ -166,6 +173,7 @@ function paddingInputs(tab, options) { icon: paddingTopIcon, texts: paddingTopTexts, values: paddingTopValues, + defaultValue: defaultPaddingTop, saveOnSlide: true, onInteractionStart: scrollToSectionStart, disabledBinding: motifAreaNotDefinedBinding, @@ -185,6 +193,7 @@ function paddingInputs(tab, options) { icon: paddingBottomIcon, texts: paddingBottomTexts, values: paddingBottomValues, + defaultValue: defaultPaddingBottom, saveOnSlide: true, onInteractionStart: scrollToSectionEnd, ...disabledOptions @@ -228,3 +237,16 @@ function hasPortraitBackdrop(configuration) { return !!configuration.getReference(propertyName, collection); } + +function getDefaultPaddingValue(entry, propertyName, scaleValues, scaleCssValues, appearance) { + const properties = entry.getThemeProperties(); + const scopeName = getAppearanceSectionScopeName(appearance); + const defaultCssValue = properties[scopeName]?.[propertyName] ?? properties.root?.[propertyName]; + + if (!defaultCssValue) { + return undefined; + } + + const index = scaleCssValues.indexOf(defaultCssValue); + return index >= 0 ? scaleValues[index] : undefined; +} From 4fff227db547975a2dbc91d1523bad210e69effa Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 18 Dec 2025 09:46:02 +0100 Subject: [PATCH 04/17] Remove box margin at start and end of sections REDMINE-21191 --- .../package/spec/frontend/Layout-spec.js | 71 +++++++++++++++++++ .../foregroundBoxes/CardBoxWrapper-spec.js | 51 +++++++++++++ .../InvisibleBoxWrapper-spec.js | 51 +++++++++++++ .../foregroundBoxes/CardBoxWrapper.js | 4 +- .../foregroundBoxes/CardBoxWrapper.module.css | 8 +++ .../foregroundBoxes/InvisibleBoxWrapper.js | 6 +- .../InvisibleBoxWrapper.module.css | 8 +++ .../package/src/frontend/layouts/Center.js | 4 +- .../package/src/frontend/layouts/TwoColumn.js | 25 +++++-- 9 files changed, 219 insertions(+), 9 deletions(-) create mode 100644 entry_types/scrolled/package/spec/frontend/foregroundBoxes/CardBoxWrapper-spec.js create mode 100644 entry_types/scrolled/package/spec/frontend/foregroundBoxes/InvisibleBoxWrapper-spec.js diff --git a/entry_types/scrolled/package/spec/frontend/Layout-spec.js b/entry_types/scrolled/package/spec/frontend/Layout-spec.js index 831b170bd8..bcac17b46e 100644 --- a/entry_types/scrolled/package/spec/frontend/Layout-spec.js +++ b/entry_types/scrolled/package/spec/frontend/Layout-spec.js @@ -814,6 +814,77 @@ describe('Layout', () => { expect(container.textContent).toEqual('( 1 || 2 || 3 )'); }); }); + + describe('atSectionStart and atSectionEnd props', () => { + beforeAll(() => { + TwoColumn.GroupComponent = 'div'; + }); + + const SectionBoundaryBox = function SectionBoundaryBox({atSectionStart, atSectionEnd, children}) { + return ( +
{atSectionStart ? '[' : ''}{children}{atSectionEnd ? ']' : ''}
+ ); + }; + + it('marks first box with atSectionStart in two column variant', () => { + const items = [ + {id: 1, type: 'probe', position: 'inline'}, + {id: 2, type: 'probe', position: 'inline'}, + ]; + const {container} = renderInEntry( + + {(children, boxProps) => {children}} + + ); + + expect(container.textContent).toEqual('[1 2 ]'); + }); + + it('marks first and last boxes in multiple groups', () => { + const items = [ + {id: 1, type: 'probe', position: 'inline'}, + {id: 2, type: 'probe', position: 'sticky'}, + {id: 3, type: 'probe', position: 'inline'}, + ]; + const {container} = renderInEntry( + + {(children, boxProps) => {children}} + + ); + + expect(container.textContent).toEqual('[1 2 3 ]'); + }); + + it('marks first inline box with atSectionStart when first element is sticky', () => { + const items = [ + {id: 1, type: 'probe', position: 'sticky'}, + {id: 2, type: 'probe', position: 'inline'}, + {id: 3, type: 'probe', position: 'inline'}, + ]; + const {container} = renderInEntry( + + {(children, boxProps) => {children}} + + ); + + expect(container.textContent).toEqual('1 [2 3 ]'); + }); + + it('marks first and last boxes in center variant', () => { + const items = [ + {id: 1, type: 'probe', position: 'inline'}, + {id: 2, type: 'probe', position: 'inline'}, + {id: 3, type: 'probe', position: 'inline'}, + ]; + const {container} = renderInEntry( + + {(children, boxProps) => {children}} + + ); + + expect(container.textContent).toEqual('[1 2 3 ]'); + }); + }); }); describe('floating items in centered variant', () => { diff --git a/entry_types/scrolled/package/spec/frontend/foregroundBoxes/CardBoxWrapper-spec.js b/entry_types/scrolled/package/spec/frontend/foregroundBoxes/CardBoxWrapper-spec.js new file mode 100644 index 0000000000..197ec48385 --- /dev/null +++ b/entry_types/scrolled/package/spec/frontend/foregroundBoxes/CardBoxWrapper-spec.js @@ -0,0 +1,51 @@ +import React from 'react'; +import {render} from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; + +import CardBoxWrapper from 'frontend/foregroundBoxes/CardBoxWrapper'; + +import styles from 'frontend/foregroundBoxes/CardBoxWrapper.module.css'; + +describe('CardBoxWrapper', () => { + describe('at section boundaries', () => { + it('does not have noTopMargin class when not at section start', () => { + const {container} = render( + + Content + + ); + + expect(container.firstChild).not.toHaveClass(styles.noTopMargin); + }); + + it('has noTopMargin class when at section start', () => { + const {container} = render( + + Content + + ); + + expect(container.firstChild).toHaveClass(styles.noTopMargin); + }); + + it('does not have noBottomMargin class when not at section end', () => { + const {container} = render( + + Content + + ); + + expect(container.firstChild).not.toHaveClass(styles.noBottomMargin); + }); + + it('has noBottomMargin class when at section end', () => { + const {container} = render( + + Content + + ); + + expect(container.firstChild).toHaveClass(styles.noBottomMargin); + }); + }); +}); diff --git a/entry_types/scrolled/package/spec/frontend/foregroundBoxes/InvisibleBoxWrapper-spec.js b/entry_types/scrolled/package/spec/frontend/foregroundBoxes/InvisibleBoxWrapper-spec.js new file mode 100644 index 0000000000..7092799d5e --- /dev/null +++ b/entry_types/scrolled/package/spec/frontend/foregroundBoxes/InvisibleBoxWrapper-spec.js @@ -0,0 +1,51 @@ +import React from 'react'; +import {render} from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; + +import {InvisibleBoxWrapper} from 'frontend/foregroundBoxes/InvisibleBoxWrapper'; + +import styles from 'frontend/foregroundBoxes/InvisibleBoxWrapper.module.css'; + +describe('InvisibleBoxWrapper', () => { + describe('at section boundaries', () => { + it('does not have noTopMargin class when not at section start', () => { + const {container} = render( + + Content + + ); + + expect(container.firstChild).not.toHaveClass(styles.noTopMargin); + }); + + it('has noTopMargin class when at section start', () => { + const {container} = render( + + Content + + ); + + expect(container.firstChild).toHaveClass(styles.noTopMargin); + }); + + it('does not have noBottomMargin class when not at section end', () => { + const {container} = render( + + Content + + ); + + expect(container.firstChild).not.toHaveClass(styles.noBottomMargin); + }); + + it('has noBottomMargin class when at section end', () => { + const {container} = render( + + Content + + ); + + expect(container.firstChild).toHaveClass(styles.noBottomMargin); + }); + }); +}); diff --git a/entry_types/scrolled/package/src/frontend/foregroundBoxes/CardBoxWrapper.js b/entry_types/scrolled/package/src/frontend/foregroundBoxes/CardBoxWrapper.js index 176fbdc2ce..0028dd5893 100644 --- a/entry_types/scrolled/package/src/frontend/foregroundBoxes/CardBoxWrapper.js +++ b/entry_types/scrolled/package/src/frontend/foregroundBoxes/CardBoxWrapper.js @@ -34,6 +34,8 @@ function className(props) { styles[`selfClear-${props.selfClear}`], {[styles.blur]: props.cardSurfaceTransparency > 0}, {[styles.cardStart]: !props.openStart}, - {[styles.cardEnd]: !props.openEnd} + {[styles.cardEnd]: !props.openEnd}, + {[styles.noTopMargin]: props.atSectionStart}, + {[styles.noBottomMargin]: props.atSectionEnd} ); } 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 ee1d90c3d2..a375989a90 100644 --- a/entry_types/scrolled/package/src/frontend/foregroundBoxes/CardBoxWrapper.module.css +++ b/entry_types/scrolled/package/src/frontend/foregroundBoxes/CardBoxWrapper.module.css @@ -47,11 +47,19 @@ margin-top: 3em; } +.cardStart.noTopMargin { + margin-top: 0; +} + .cardEnd { margin-bottom: 3em; padding-bottom: 1.5em; } +.cardEnd.noBottomMargin { + margin-bottom: 0; +} + .cardStart::before { border-top-left-radius: var(--theme-cards-border-radius, 15px); border-top-right-radius: var(--theme-cards-border-radius, 15px); diff --git a/entry_types/scrolled/package/src/frontend/foregroundBoxes/InvisibleBoxWrapper.js b/entry_types/scrolled/package/src/frontend/foregroundBoxes/InvisibleBoxWrapper.js index 230b5a737f..e3c4ee7513 100644 --- a/entry_types/scrolled/package/src/frontend/foregroundBoxes/InvisibleBoxWrapper.js +++ b/entry_types/scrolled/package/src/frontend/foregroundBoxes/InvisibleBoxWrapper.js @@ -4,13 +4,15 @@ import classNames from 'classnames'; import {widths} from '../layouts'; import styles from './InvisibleBoxWrapper.module.css'; -export function InvisibleBoxWrapper({position, width, openStart, openEnd, children}) { +export function InvisibleBoxWrapper({position, width, openStart, openEnd, atSectionStart, atSectionEnd, children}) { const full = (width === widths.full); return (
{children}
diff --git a/entry_types/scrolled/package/src/frontend/foregroundBoxes/InvisibleBoxWrapper.module.css b/entry_types/scrolled/package/src/frontend/foregroundBoxes/InvisibleBoxWrapper.module.css index 5685ab15e3..7382268a21 100644 --- a/entry_types/scrolled/package/src/frontend/foregroundBoxes/InvisibleBoxWrapper.module.css +++ b/entry_types/scrolled/package/src/frontend/foregroundBoxes/InvisibleBoxWrapper.module.css @@ -2,6 +2,14 @@ margin-top: 1.375em; } +.start.noTopMargin { + margin-top: 0; +} + .end { margin-bottom: 1.375em; } + +.end.noBottomMargin { + margin-bottom: 0; +} diff --git a/entry_types/scrolled/package/src/frontend/layouts/Center.js b/entry_types/scrolled/package/src/frontend/layouts/Center.js index 22760d7f5c..479f791234 100644 --- a/entry_types/scrolled/package/src/frontend/layouts/Center.js +++ b/entry_types/scrolled/package/src/frontend/layouts/Center.js @@ -76,7 +76,9 @@ function boxProps(items, item, index) { openEnd: next && !customMargin && !hasCustomMargin(next) && - !isWideOrFull(item) && !isWideOrFull(next) + !isWideOrFull(item) && !isWideOrFull(next), + atSectionStart: index === 0, + atSectionEnd: index === items.length - 1 } } diff --git a/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.js b/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.js index b6b72808aa..038404f1fc 100644 --- a/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.js +++ b/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.js @@ -51,18 +51,21 @@ TwoColumn.GroupComponent = 'div'; TwoColumn.contentAreaProbeProps = {}; function renderItems(props, shouldInline) { - return groupItemsByPosition(props.items, shouldInline).map((group, index) => - + - {group.boxes.map((box, index) => renderItemGroup(props, box, index))} + {group.boxes.map((box, boxIndex) => + renderItemGroup(props, box, boxIndex) + )} ); } function renderItemGroup(props, box, key) { if (box.items.length) { - return (
@@ -105,6 +110,7 @@ function groupItemsByPosition(items, shouldInline) { const groups = []; let lastInlineBox = null; + let firstInlineBox = null; let currentGroup, currentBox; items.reduce((previousPosition, item, index) => { @@ -161,6 +167,11 @@ function groupItemsByPosition(items, shouldInline) { lastInlineBox = null; } + if (position === 'inline' && !firstInlineBox) { + currentBox.atSectionStart = true; + firstInlineBox = currentBox; + } + currentGroup.boxes.push(currentBox) } @@ -168,6 +179,10 @@ function groupItemsByPosition(items, shouldInline) { return position; }, null); + if (currentBox) { + currentBox.atSectionEnd = true; + } + return groups; } From 2cf3576d383e2f64996a507bf8775c3e65af515a Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 18 Dec 2025 10:20:09 +0100 Subject: [PATCH 05/17] Apply default section padding Apply theme default padding to foreground to replace removed box margins. Also move indicators to prevent padding indcator from being hidden behind navigation. REDMINE-21191 --- .../frontend/features/sectionPadding-spec.js | 16 ++++++++++++++++ .../package/src/frontend/Foreground.module.css | 3 ++- .../package/src/frontend/Section.module.css | 6 ++++-- .../inlineEditing/PaddingIndicator.module.css | 2 +- .../inlineEditing/SectionDecorator.module.css | 2 +- 5 files changed, 24 insertions(+), 5 deletions(-) diff --git a/entry_types/scrolled/package/spec/frontend/features/sectionPadding-spec.js b/entry_types/scrolled/package/spec/frontend/features/sectionPadding-spec.js index 1d37490b20..067ab71649 100644 --- a/entry_types/scrolled/package/spec/frontend/features/sectionPadding-spec.js +++ b/entry_types/scrolled/package/spec/frontend/features/sectionPadding-spec.js @@ -61,6 +61,22 @@ describe('section padding', () => { expect(getSectionByPermaId(6).hasBottomPadding()).toBe(true); }); + it('does not set inline padding styles when no paddingTop/paddingBottom set', () => { + const {getSectionByPermaId} = renderEntry({ + seed: { + sections: [{id: 5, permaId: 6}], + contentElements: [{sectionId: 5}] + } + }); + + expect(getSectionByPermaId(6).el).not.toHaveStyle({ + '--foreground-padding-top': expect.anything(), + }); + expect(getSectionByPermaId(6).el).not.toHaveStyle({ + '--foreground-padding-bottom': expect.anything(), + }); + }); + it('supports setting custom foreground padding', () => { const {getSectionByPermaId} = renderEntry({ seed: { diff --git a/entry_types/scrolled/package/src/frontend/Foreground.module.css b/entry_types/scrolled/package/src/frontend/Foreground.module.css index 00723d5111..e27920e1be 100644 --- a/entry_types/scrolled/package/src/frontend/Foreground.module.css +++ b/entry_types/scrolled/package/src/frontend/Foreground.module.css @@ -10,7 +10,8 @@ flex-direction: column; justify-content: center; - padding-top: var(--foreground-padding-top); + padding-top: calc(var(--foreground-widget-padding-top) + var(--foreground-padding-top)); + padding-bottom: var(--foreground-padding-bottom); } .fullFadeHeight { diff --git a/entry_types/scrolled/package/src/frontend/Section.module.css b/entry_types/scrolled/package/src/frontend/Section.module.css index 37246815e7..5968bf27c3 100644 --- a/entry_types/scrolled/package/src/frontend/Section.module.css +++ b/entry_types/scrolled/package/src/frontend/Section.module.css @@ -35,13 +35,15 @@ --centered-inline-xl-content-max-width: var(--theme-centered-inline-xl-content-max-width); - --foreground-padding-top: 0px; + --foreground-widget-padding-top: 0px; + --foreground-padding-top: var(--theme-section-default-padding-top); + --foreground-padding-bottom: var(--theme-section-default-padding-bottom); } .first { /* Let content begin below navigation bar. Navigation bar has zero height to let first chapter start at the very top. */ - --foreground-padding-top: var(--theme-widget-margin-top, 58px); + --foreground-widget-padding-top: var(--theme-widget-margin-top, 58px); } .narrow { diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/PaddingIndicator.module.css b/entry_types/scrolled/package/src/frontend/inlineEditing/PaddingIndicator.module.css index 82a21fb34c..c1410d8961 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/PaddingIndicator.module.css +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/PaddingIndicator.module.css @@ -36,7 +36,7 @@ .indicator-top { composes: indicator; - top: 0; + top: var(--foreground-widget-padding-top); height: var(--foreground-padding-top, space(12)); } diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/SectionDecorator.module.css b/entry_types/scrolled/package/src/frontend/inlineEditing/SectionDecorator.module.css index a110c5f8ea..d1df364857 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/SectionDecorator.module.css +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/SectionDecorator.module.css @@ -6,7 +6,7 @@ section::before { content: ""; display: block; position: absolute; - top: 6px; + top: calc(6px + var(--foreground-widget-padding-top)); left: 6px; right: 6px; bottom: 6px; From d6509cb44a62d7bf55a016295f637960f35eb9d9 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 18 Dec 2025 11:05:13 +0100 Subject: [PATCH 06/17] Separate forced padding from bottom padding class Ensure padding can be forced to make room for the insert element button even when custom padding is set. REDMINE-21191 --- .../frontend/features/sectionPadding-spec.js | 31 +++++++++++++++---- .../package/spec/support/pageObjects.js | 9 ++++++ .../package/src/frontend/Foreground.js | 3 +- .../src/frontend/Foreground.module.css | 6 +++- .../inlineEditing/SectionDecorator.js | 6 ++-- 5 files changed, 44 insertions(+), 11 deletions(-) diff --git a/entry_types/scrolled/package/spec/frontend/features/sectionPadding-spec.js b/entry_types/scrolled/package/spec/frontend/features/sectionPadding-spec.js index 067ab71649..557c635a34 100644 --- a/entry_types/scrolled/package/spec/frontend/features/sectionPadding-spec.js +++ b/entry_types/scrolled/package/spec/frontend/features/sectionPadding-spec.js @@ -2,10 +2,15 @@ import {renderEntry, useInlineEditingPageObjects} from 'support/pageObjects'; import '@testing-library/jest-dom/extend-expect'; +import {features} from 'pageflow/frontend'; import {usePortraitOrientation} from 'frontend/usePortraitOrientation'; jest.mock('frontend/usePortraitOrientation'); describe('section padding', () => { + beforeEach(() => { + features.enable('frontend', ['section_paddings']); + }); + useInlineEditingPageObjects(); it('adds padding to bottom of section by default', () => { @@ -30,35 +35,49 @@ describe('section padding', () => { expect(getSectionByPermaId(6).hasBottomPadding()).toBe(false); }); - it('adds padding below full width element if section is selected', () => { + it('forces padding below full width element if section is selected', () => { const {getSectionByPermaId} = renderEntry({ seed: { sections: [{id: 5, permaId: 6}], - contentElements: [{sectionId: 5, configuration: {position: 'full'}}] + contentElements: [{sectionId: 5, configuration: {width: 3}}] } }); const section = getSectionByPermaId(6); section.select(); - expect(section.hasBottomPadding()).toBe(true); + expect(section.hasForcedPadding()).toBe(true); }); - it('adds padding below full width element if element is selected', () => { + it('forces padding below full width element if element is selected', () => { const {getSectionByPermaId, getContentElementByTestId} = renderEntry({ seed: { sections: [{id: 5, permaId: 6}], contentElements: [{ sectionId: 5, typeName: 'withTestId', - configuration: {testId: 10, position: 'full'} + configuration: {testId: 10, width: 3} }] } }); getContentElementByTestId(10).select(); - expect(getSectionByPermaId(6).hasBottomPadding()).toBe(true); + expect(getSectionByPermaId(6).hasForcedPadding()).toBe(true); + }); + + it('does not force padding if padding is selected', () => { + const {getSectionByPermaId} = renderEntry({ + seed: { + sections: [{id: 5, permaId: 6}], + contentElements: [{sectionId: 5, configuration: {width: 3}}] + } + }); + + const section = getSectionByPermaId(6); + section.selectPadding('bottom'); + + expect(section.hasForcedPadding()).toBe(false); }); it('does not set inline padding styles when no paddingTop/paddingBottom set', () => { diff --git a/entry_types/scrolled/package/spec/support/pageObjects.js b/entry_types/scrolled/package/spec/support/pageObjects.js index af247e01a9..0274702e29 100644 --- a/entry_types/scrolled/package/spec/support/pageObjects.js +++ b/entry_types/scrolled/package/spec/support/pageObjects.js @@ -200,6 +200,10 @@ function createSectionPageObject(el) { return foreground.classList.contains(foregroundStyles.paddingBottom); }, + hasForcedPadding() { + return foreground.classList.contains(foregroundStyles.forcePadding); + }, + hasRemainingSpaceAbove() { return foreground.classList.contains(foregroundStyles.spaceAbove); }, @@ -219,6 +223,11 @@ function createSectionPageObject(el) { bottom: 'Edit bottom padding' }; return getByLabelText(labels[position]); + }, + + selectPadding(position) { + fireEvent.mouseDown(selectionRect); + fireEvent.click(this.getPaddingIndicator(position)); } } } diff --git a/entry_types/scrolled/package/src/frontend/Foreground.js b/entry_types/scrolled/package/src/frontend/Foreground.js index 56b7934128..22a8298b9a 100644 --- a/entry_types/scrolled/package/src/frontend/Foreground.js +++ b/entry_types/scrolled/package/src/frontend/Foreground.js @@ -23,7 +23,8 @@ function className(props, forcePadding) { styles.Foreground, props.transitionStyles.foreground, props.transitionStyles[`foreground-${props.state}`], - {[styles.paddingBottom]: props.paddingBottom || forcePadding}, + {[styles.paddingBottom]: props.paddingBottom}, + {[styles.forcePadding]: forcePadding}, styles[`${props.heightMode}Height`], styles[spaceClassName(props.section?.remainingVerticalSpace)] ) diff --git a/entry_types/scrolled/package/src/frontend/Foreground.module.css b/entry_types/scrolled/package/src/frontend/Foreground.module.css index e27920e1be..25a47721c7 100644 --- a/entry_types/scrolled/package/src/frontend/Foreground.module.css +++ b/entry_types/scrolled/package/src/frontend/Foreground.module.css @@ -31,7 +31,11 @@ } .paddingBottom { - padding-bottom: var(--foreground-padding-bottom, 3em); + padding-bottom: var(--foreground-padding-bottom); +} + +.forcePadding { + padding-bottom: max(var(--foreground-padding-bottom, 0px), 3em); } @media print { diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/SectionDecorator.js b/entry_types/scrolled/package/src/frontend/inlineEditing/SectionDecorator.js index 9f96f3d6b0..6516691534 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/SectionDecorator.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/SectionDecorator.js @@ -80,7 +80,7 @@ export function SectionDecorator({backdrop, section, contentElements, transition position: 'after'})}
- + {children} @@ -88,9 +88,9 @@ export function SectionDecorator({backdrop, section, contentElements, transition ); } -function className(isSectionSelected, transitionSelection, isHighlighted, isBackdropElementSelected, transitions) { +function className(isSelected, transitionSelection, isHighlighted, isBackdropElementSelected, transitions) { return classNames(styles.wrapper, { - [styles.selected]: isSectionSelected, + [styles.selected]: isSelected, [styles.highlighted]: isHighlighted, [styles.lineAbove]: isBackdropElementSelected && transitions[0].startsWith('fade'), [styles.lineBelow]: isBackdropElementSelected && transitions[1].startsWith('fade'), From da542db0f61ffa7292a7661d7cad1528c6872dc0 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 18 Dec 2025 12:48:08 +0100 Subject: [PATCH 07/17] Suppress paddings above full width elements REDMINE-21191 --- .../frontend/features/sectionPadding-spec.js | 30 ++++++++++++++++--- .../package/spec/support/pageObjects.js | 8 +++-- .../package/src/frontend/Foreground.js | 3 +- .../src/frontend/Foreground.module.css | 8 +++-- .../scrolled/package/src/frontend/Section.js | 18 +++++++---- 5 files changed, 52 insertions(+), 15 deletions(-) diff --git a/entry_types/scrolled/package/spec/frontend/features/sectionPadding-spec.js b/entry_types/scrolled/package/spec/frontend/features/sectionPadding-spec.js index 557c635a34..d0d271f039 100644 --- a/entry_types/scrolled/package/spec/frontend/features/sectionPadding-spec.js +++ b/entry_types/scrolled/package/spec/frontend/features/sectionPadding-spec.js @@ -13,7 +13,7 @@ describe('section padding', () => { useInlineEditingPageObjects(); - it('adds padding to bottom of section by default', () => { + it('does not suppress top padding by default', () => { const {getSectionByPermaId} = renderEntry({ seed: { sections: [{id: 5, permaId: 6}], @@ -21,10 +21,32 @@ describe('section padding', () => { } }); - expect(getSectionByPermaId(6).hasBottomPadding()).toBe(true); + expect(getSectionByPermaId(6).hasSuppressedTopPadding()).toBe(false); }); - it('does not add padding to bottom of section if last content element is full width', () => { + it('does not suppress bottom padding by default', () => { + const {getSectionByPermaId} = renderEntry({ + seed: { + sections: [{id: 5, permaId: 6}], + contentElements: [{sectionId: 5}] + } + }); + + expect(getSectionByPermaId(6).hasSuppressedBottomPadding()).toBe(false); + }); + + it('suppresses top padding if first content element is full width', () => { + const {getSectionByPermaId} = renderEntry({ + seed: { + sections: [{id: 5, permaId: 6}], + contentElements: [{sectionId: 5, configuration: {width: 3}}] + } + }); + + expect(getSectionByPermaId(6).hasSuppressedTopPadding()).toBe(true); + }); + + it('suppresses bottom padding if last content element is full width', () => { const {getSectionByPermaId} = renderEntry({ seed: { sections: [{id: 5, permaId: 6}], @@ -32,7 +54,7 @@ describe('section padding', () => { } }); - expect(getSectionByPermaId(6).hasBottomPadding()).toBe(false); + expect(getSectionByPermaId(6).hasSuppressedBottomPadding()).toBe(true); }); it('forces padding below full width element if section is selected', () => { diff --git a/entry_types/scrolled/package/spec/support/pageObjects.js b/entry_types/scrolled/package/spec/support/pageObjects.js index 0274702e29..839800ddd6 100644 --- a/entry_types/scrolled/package/spec/support/pageObjects.js +++ b/entry_types/scrolled/package/spec/support/pageObjects.js @@ -196,8 +196,12 @@ function createSectionPageObject(el) { fireEvent.mouseDown(getByTitle('Edit section transition after')); }, - hasBottomPadding() { - return foreground.classList.contains(foregroundStyles.paddingBottom); + hasSuppressedTopPadding() { + return foreground.classList.contains(foregroundStyles.suppressedPaddingTop); + }, + + hasSuppressedBottomPadding() { + return foreground.classList.contains(foregroundStyles.suppressedPaddingBottom); }, hasForcedPadding() { diff --git a/entry_types/scrolled/package/src/frontend/Foreground.js b/entry_types/scrolled/package/src/frontend/Foreground.js index 22a8298b9a..135b1b1fae 100644 --- a/entry_types/scrolled/package/src/frontend/Foreground.js +++ b/entry_types/scrolled/package/src/frontend/Foreground.js @@ -23,7 +23,8 @@ function className(props, forcePadding) { styles.Foreground, props.transitionStyles.foreground, props.transitionStyles[`foreground-${props.state}`], - {[styles.paddingBottom]: props.paddingBottom}, + {[styles.suppressedPaddingTop]: props.suppressedPaddings?.top}, + {[styles.suppressedPaddingBottom]: props.suppressedPaddings?.bottom}, {[styles.forcePadding]: forcePadding}, styles[`${props.heightMode}Height`], styles[spaceClassName(props.section?.remainingVerticalSpace)] diff --git a/entry_types/scrolled/package/src/frontend/Foreground.module.css b/entry_types/scrolled/package/src/frontend/Foreground.module.css index 25a47721c7..ddf938031c 100644 --- a/entry_types/scrolled/package/src/frontend/Foreground.module.css +++ b/entry_types/scrolled/package/src/frontend/Foreground.module.css @@ -30,8 +30,12 @@ justify-content: flex-start; } -.paddingBottom { - padding-bottom: var(--foreground-padding-bottom); +.suppressedPaddingTop { + --foreground-padding-top: 0px; +} + +.suppressedPaddingBottom { + --foreground-padding-bottom: 0px; } .forcePadding { diff --git a/entry_types/scrolled/package/src/frontend/Section.js b/entry_types/scrolled/package/src/frontend/Section.js index 806668947c..6ee5e8e052 100644 --- a/entry_types/scrolled/package/src/frontend/Section.js +++ b/entry_types/scrolled/package/src/frontend/Section.js @@ -163,7 +163,7 @@ function SectionContents({ motifAreaState={motifAreaState} sectionPadding={sectionPadding} minHeight={motifAreaState.minHeight} - paddingBottom={!endsWithFullWidthElement(contentElements)} + suppressedPaddings={getSuppressedPaddings(contentElements)} heightMode={heightMode(section)}> Date: Wed, 7 Jan 2026 14:59:09 +0100 Subject: [PATCH 08/17] Suppress top padding when motif area pads content When the motif area is exposed, the Foreground component now suppresses its top padding to avoid double spacing. This ensures consistent visual appearance when the backdrop motif area is configured to push content down. REDMINE-21191 --- .../spec/frontend/features/sectionPadding-spec.js | 15 +++++++++++++++ .../scrolled/package/src/frontend/Section.js | 6 +++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/entry_types/scrolled/package/spec/frontend/features/sectionPadding-spec.js b/entry_types/scrolled/package/spec/frontend/features/sectionPadding-spec.js index d0d271f039..b3cc5cda0f 100644 --- a/entry_types/scrolled/package/spec/frontend/features/sectionPadding-spec.js +++ b/entry_types/scrolled/package/spec/frontend/features/sectionPadding-spec.js @@ -4,7 +4,9 @@ import '@testing-library/jest-dom/extend-expect'; import {features} from 'pageflow/frontend'; import {usePortraitOrientation} from 'frontend/usePortraitOrientation'; +import {useMotifAreaState} from 'frontend/v1/useMotifAreaState'; jest.mock('frontend/usePortraitOrientation'); +jest.mock('frontend/v1/useMotifAreaState'); describe('section padding', () => { beforeEach(() => { @@ -46,6 +48,19 @@ describe('section padding', () => { expect(getSectionByPermaId(6).hasSuppressedTopPadding()).toBe(true); }); + it('suppresses top padding if motif area is content padded', () => { + useMotifAreaState.mockContentPadded(); + + const {getSectionByPermaId} = renderEntry({ + seed: { + sections: [{id: 5, permaId: 6}], + contentElements: [{sectionId: 5}] + } + }); + + expect(getSectionByPermaId(6).hasSuppressedTopPadding()).toBe(true); + }); + it('suppresses bottom padding if last content element is full width', () => { const {getSectionByPermaId} = renderEntry({ seed: { diff --git a/entry_types/scrolled/package/src/frontend/Section.js b/entry_types/scrolled/package/src/frontend/Section.js index 6ee5e8e052..a1e4da0673 100644 --- a/entry_types/scrolled/package/src/frontend/Section.js +++ b/entry_types/scrolled/package/src/frontend/Section.js @@ -163,7 +163,7 @@ function SectionContents({ motifAreaState={motifAreaState} sectionPadding={sectionPadding} minHeight={motifAreaState.minHeight} - suppressedPaddings={getSuppressedPaddings(contentElements)} + suppressedPaddings={getSuppressedPaddings(contentElements, motifAreaState)} heightMode={heightMode(section)}> Date: Thu, 18 Dec 2025 12:48:51 +0100 Subject: [PATCH 09/17] Display suppressed padding hint in padding indicator REDMINE-21191 --- entry_types/scrolled/config/locales/de.yml | 2 + entry_types/scrolled/config/locales/en.yml | 2 + .../features/paddingIndicator-spec.js | 71 +++++++++++++++++++ .../package/spec/support/pageObjects.js | 4 +- .../inlineEditing/ForegroundDecorator.js | 4 +- .../inlineEditing/PaddingIndicator.js | 15 ++-- 6 files changed, 92 insertions(+), 6 deletions(-) diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index ca51467025..7022d35013 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1672,6 +1672,8 @@ de: cancel: Abbrechen default_padding: Standard drag_content_element: Ziehen, um Element zu verschieben + padding_suppressed_before_full_width: Abstand unterdrückt vor vollbreitem Element + padding_suppressed_after_full_width: Abstand unterdrückt nach vollbreitem Element edit_section_transition_after: Übergangseffekt bearbeiten edit_section_transition_before: Übergangseffekt bearbeiten flip_card: Karte wenden diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index 34d70add8a..20564cf13b 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1502,6 +1502,8 @@ en: cancel: Cancel default_padding: Default drag_content_element: Drag to move element + padding_suppressed_before_full_width: Padding suppressed before full width element + padding_suppressed_after_full_width: Padding suppressed after full width element edit_section_transition_after: Edit section transition edit_section_transition_before: Edit section transition flip_card: Flip card diff --git a/entry_types/scrolled/package/spec/frontend/features/paddingIndicator-spec.js b/entry_types/scrolled/package/spec/frontend/features/paddingIndicator-spec.js index 3a183fbc1a..312bf90929 100644 --- a/entry_types/scrolled/package/spec/frontend/features/paddingIndicator-spec.js +++ b/entry_types/scrolled/package/spec/frontend/features/paddingIndicator-spec.js @@ -103,4 +103,75 @@ describe('PaddingIndicator', () => { expect(section.getPaddingIndicator('top')).toHaveTextContent('Expose motif area'); }); + + it('displays suppressed text for top padding when section starts with full width element', () => { + const {getSectionByPermaId} = renderEntry({ + seed: { + sections: [{ + id: 1, + permaId: 10 + }], + contentElements: [{sectionId: 1, configuration: {width: 3}}] + } + }); + + const section = getSectionByPermaId(10); + section.select(); + + expect(section.getPaddingIndicator('top')).toHaveTextContent('Padding suppressed before full width element'); + }); + + it('displays suppressed text for bottom padding when section ends with full width element', () => { + const {getSectionByPermaId} = renderEntry({ + seed: { + sections: [{ + id: 1, + permaId: 10 + }], + contentElements: [{sectionId: 1, configuration: {width: 3}}] + } + }); + + const section = getSectionByPermaId(10); + section.select(); + + expect(section.getPaddingIndicator('bottom')).toHaveTextContent('Padding suppressed after full width element'); + }); + + it('applies none class to padding indicator when padding is suppressed', () => { + const {getSectionByPermaId} = renderEntry({ + seed: { + sections: [{ + id: 1, + permaId: 10 + }], + contentElements: [{sectionId: 1, configuration: {width: 3}}] + } + }); + + const section = getSectionByPermaId(10); + section.select(); + + expect(section.getPaddingIndicator('top').className).toContain('none'); + }); + + it('applies none class to padding indicator when padding value is none', () => { + const {getSectionByPermaId} = renderEntry({ + seed: { + sections: [{ + id: 1, + permaId: 10, + configuration: { + paddingTop: 'none' + } + }], + contentElements: [{sectionId: 1}] + } + }); + + const section = getSectionByPermaId(10); + section.select(); + + expect(section.getPaddingIndicator('top').className).toContain('none'); + }); }); diff --git a/entry_types/scrolled/package/spec/support/pageObjects.js b/entry_types/scrolled/package/spec/support/pageObjects.js index 839800ddd6..db0bbc96b3 100644 --- a/entry_types/scrolled/package/spec/support/pageObjects.js +++ b/entry_types/scrolled/package/spec/support/pageObjects.js @@ -79,7 +79,9 @@ export function useInlineEditingPageObjects() { 'pageflow_scrolled.inline_editing.edit_section_padding_top': 'Edit top padding', 'pageflow_scrolled.inline_editing.edit_section_padding_bottom': 'Edit bottom padding', 'pageflow_scrolled.inline_editing.expose_motif_area': 'Expose motif area', - 'pageflow_scrolled.inline_editing.default_padding': 'Default' + 'pageflow_scrolled.inline_editing.default_padding': 'Default', + 'pageflow_scrolled.inline_editing.padding_suppressed_before_full_width': 'Padding suppressed before full width element', + 'pageflow_scrolled.inline_editing.padding_suppressed_after_full_width': 'Padding suppressed after full width element' }); usePageObjects(); diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/ForegroundDecorator.js b/entry_types/scrolled/package/src/frontend/inlineEditing/ForegroundDecorator.js index b1cdc989eb..a466ac26de 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/ForegroundDecorator.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/ForegroundDecorator.js @@ -4,7 +4,7 @@ import {features} from 'pageflow/frontend'; import {PaddingIndicator} from './PaddingIndicator'; -export function ForegroundDecorator({section, motifAreaState, sectionPadding, children}) { +export function ForegroundDecorator({section, motifAreaState, sectionPadding, suppressedPaddings, children}) { if (!features.isEnabled('section_paddings')) { return children; } @@ -14,10 +14,12 @@ export function ForegroundDecorator({section, motifAreaState, sectionPadding, ch {children} ); diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/PaddingIndicator.js b/entry_types/scrolled/package/src/frontend/inlineEditing/PaddingIndicator.js index 4b577662de..357f5e8760 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/PaddingIndicator.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/PaddingIndicator.js @@ -20,7 +20,7 @@ const scaleNames = { bottom: 'sectionPaddingBottom' }; -export function PaddingIndicator({section, motifAreaState, paddingValue, position}) { +export function PaddingIndicator({section, motifAreaState, paddingValue, position, suppressed}) { const Icon = paddingIcons[position]; const theme = useTheme(); const {t} = useI18n({locale: 'ui'}); @@ -39,7 +39,7 @@ export function PaddingIndicator({section, motifAreaState, paddingValue, positio const motifPadding = motifAreaState?.paddingTop > 0; const paddingText = motifPadding ? t('pageflow_scrolled.inline_editing.expose_motif_area') : - getPaddingText({theme, paddingValue, position, t}); + getPaddingText({theme, paddingValue, position, t, suppressed}); if (isSectionSelected || isPaddingSelected) { return ( @@ -47,7 +47,7 @@ export function PaddingIndicator({section, motifAreaState, paddingValue, positio className={classNames(styles[`indicator-${position}`], {[styles.selected]: isPaddingSelected, [styles.motif]: motifPadding, - [styles.none]: !motifPadding && paddingValue === 'none'})} + [styles.none]: !motifPadding && (paddingValue === 'none' || suppressed)})} onClick={() => select()}>
@@ -61,7 +61,14 @@ export function PaddingIndicator({section, motifAreaState, paddingValue, positio } } -function getPaddingText({theme, paddingValue, position, t}) { +function getPaddingText({theme, paddingValue, position, t, suppressed}) { + if (suppressed) { + const key = position === 'top' ? + 'pageflow_scrolled.inline_editing.padding_suppressed_before_full_width' : + 'pageflow_scrolled.inline_editing.padding_suppressed_after_full_width'; + return t(key); + } + if (!paddingValue) { return t('pageflow_scrolled.inline_editing.default_padding'); } From cdaa57b977e45c64728e28e36ea9a4ecb0e62ed7 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 18 Dec 2025 16:17:43 +0100 Subject: [PATCH 10/17] Apply portrait paddings conditionally Only if portrait asset is present and opt-out has not been set. REDMINE-21191 --- .../frontend/features/sectionPadding-spec.js | 59 ++++++++++++++++++- .../scrolled/package/src/frontend/Section.js | 2 +- .../useSectionPaddingCustomProperties.js | 14 ++--- .../package/src/frontend/v1/useBackdrop.js | 6 +- 4 files changed, 68 insertions(+), 13 deletions(-) diff --git a/entry_types/scrolled/package/spec/frontend/features/sectionPadding-spec.js b/entry_types/scrolled/package/spec/frontend/features/sectionPadding-spec.js index b3cc5cda0f..b56a71f5a2 100644 --- a/entry_types/scrolled/package/spec/frontend/features/sectionPadding-spec.js +++ b/entry_types/scrolled/package/spec/frontend/features/sectionPadding-spec.js @@ -159,6 +159,7 @@ describe('section padding', () => { const {getSectionByPermaId} = renderEntry({ seed: { + imageFiles: [{id: 100, permaId: 100}], sections: [{ id: 5, permaId: 6, @@ -166,7 +167,8 @@ describe('section padding', () => { paddingTop: 'lg', paddingBottom: 'md', portraitPaddingTop: 'sm', - portraitPaddingBottom: 'xs' + portraitPaddingBottom: 'xs', + backdrop: {image: 100, imageMobile: 100} } }], contentElements: [{sectionId: 5}] @@ -202,6 +204,61 @@ describe('section padding', () => { }); }); + it('ignores portrait paddings if customPortraitPaddings is false', () => { + usePortraitOrientation.mockReturnValue(true); + + const {getSectionByPermaId} = renderEntry({ + seed: { + imageFiles: [{id: 100, permaId: 100}], + sections: [{ + id: 5, + permaId: 6, + configuration: { + paddingTop: 'lg', + paddingBottom: 'md', + portraitPaddingTop: 'sm', + portraitPaddingBottom: 'xs', + customPortraitPaddings: false, + backdrop: {image: 100, imageMobile: 100} + } + }], + contentElements: [{sectionId: 5}] + } + }); + + expect(getSectionByPermaId(6).el).toHaveStyle({ + '--foreground-padding-top': 'var(--theme-section-padding-top-lg)', + '--foreground-padding-bottom': 'var(--theme-section-padding-bottom-md)', + }); + }); + + it('ignores portrait paddings if no mobile backdrop is assigned', () => { + usePortraitOrientation.mockReturnValue(true); + + const {getSectionByPermaId} = renderEntry({ + seed: { + imageFiles: [{id: 100, permaId: 100}], + sections: [{ + id: 5, + permaId: 6, + configuration: { + paddingTop: 'lg', + paddingBottom: 'md', + portraitPaddingTop: 'sm', + portraitPaddingBottom: 'xs', + backdrop: {image: 100} + } + }], + contentElements: [{sectionId: 5}] + } + }); + + expect(getSectionByPermaId(6).el).toHaveStyle({ + '--foreground-padding-top': 'var(--theme-section-padding-top-lg)', + '--foreground-padding-bottom': 'var(--theme-section-padding-bottom-md)', + }); + }); + it('centers content vertically by default', () => { const {getSectionByPermaId} = renderEntry({ seed: { diff --git a/entry_types/scrolled/package/src/frontend/Section.js b/entry_types/scrolled/package/src/frontend/Section.js index a1e4da0673..2207660b47 100644 --- a/entry_types/scrolled/package/src/frontend/Section.js +++ b/entry_types/scrolled/package/src/frontend/Section.js @@ -54,7 +54,7 @@ const Section = withInlineEditingDecorator('SectionDecorator', function Section( propertyName: 'atmoAudioFileId' }); - const sectionPadding = useSectionPadding(section); + const sectionPadding = useSectionPadding(section, {portrait: backdrop.portrait}); return (
Date: Thu, 8 Jan 2026 09:09:57 +0100 Subject: [PATCH 11/17] Let fake translations override loaded defaults Force initialization of the I18n backend before tests run so that translations from locale files are loaded first. Fake translations set via the translation helper then override those defaults instead of being overwritten when I18n.t triggers lazy loading. REDMINE-21191 --- spec/support/pageflow/shared_contexts/fake_translations.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/spec/support/pageflow/shared_contexts/fake_translations.rb b/spec/support/pageflow/shared_contexts/fake_translations.rb index 03771bba45..0f56fe57fe 100644 --- a/spec/support/pageflow/shared_contexts/fake_translations.rb +++ b/spec/support/pageflow/shared_contexts/fake_translations.rb @@ -4,6 +4,7 @@ @fake_i18n_backend = I18n::Backend::Simple.new I18n.backend = @fake_i18n_backend + @fake_i18n_backend.send(:init_translations) example.run I18n.backend = i18n_backend_backup end From d608c0ad430d19237d578ca813f329cd516fc99f Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 18 Dec 2025 09:09:06 +0100 Subject: [PATCH 12/17] Define padding scales and default paddings Make defaults match previously hard-coded paddings for different appearance options. REDMINE-21191 --- entry_types/scrolled/config/locales/de.yml | 22 +++++ entry_types/scrolled/config/locales/en.yml | 22 +++++ .../scrolled/lib/pageflow_scrolled/plugin.rb | 40 +++++++++ .../theme_options_default_scale.rb | 35 ++++++++ .../theme_options_default_scale_spec.rb | 90 +++++++++++++++++++ 5 files changed, 209 insertions(+) create mode 100644 entry_types/scrolled/lib/pageflow_scrolled/theme_options_default_scale.rb create mode 100644 entry_types/scrolled/spec/pageflow_scrolled/theme_options_default_scale_spec.rb diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 7022d35013..7550431416 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1309,6 +1309,28 @@ de: add: Neues Element unset: Nicht mehr als Hintergrund verwenden scales: + sectionPaddingTop: + none: "-" + xxxs: XXXS + xxs: XXS + xs: XS + sm: S + md: M + lg: L + xl: XL + xxl: XXL + xxxl: XXXL + sectionPaddingBottom: + none: "-" + xxxs: XXXS + xxs: XXS + xs: XS + sm: S + md: M + lg: L + xl: XL + xxl: XXL + xxxl: XXXL contentElementBoxBorderRadius: none: Keine content_element_text_inline_file_rights_attributes: diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index 20564cf13b..f3c219265c 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1293,6 +1293,28 @@ en: add: New element unset: No longer use as backdrop scales: + sectionPaddingTop: + none: "-" + xxxs: XXXS + xxs: XXS + xs: XS + sm: S + md: M + lg: L + xl: XL + xxl: XXL + xxxl: XXXL + sectionPaddingBottom: + none: "-" + xxxs: XXXS + xxs: XXS + xs: XS + sm: S + md: M + lg: L + xl: XL + xxl: XXL + xxxl: XXXL contentElementBoxBorderRadius: none: None content_element_text_inline_file_rights_attributes: diff --git a/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb b/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb index 4a7b89562e..8ab10f3406 100644 --- a/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb +++ b/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb @@ -7,6 +7,46 @@ def configure(config) end config.for_entry_type(PageflowScrolled.entry_type) do |c| + padding_scale = { + 'none' => '0px', + 'xxxs' => '1.375em', + 'xxs' => '3em', + 'xs' => '4.375em', + 'sm' => '6em', + 'md' => 'max(7em, 10svh)', + 'lg' => 'max(8em, 10svh)', + 'xl' => 'max(9em, 15svh)', + 'xxl' => 'max(10em, 20svh)', + 'xxxl' => 'max(11em, 30svh)' + } + + c.themes.register_default_options( + ThemeOptionsDefaultScale.new( + prefix: 'section_padding_top', + values: padding_scale + ) + ) + + c.themes.register_default_options( + ThemeOptionsDefaultScale.new( + prefix: 'section_padding_bottom', + values: padding_scale + ) + ) + + c.themes.register_default_options( + properties: { + root: { + 'section_default_padding_top' => '1.375em', + 'section_default_padding_bottom' => '4.375em' + }, + cards_appearance_section: { + 'section_default_padding_top' => '3em', + 'section_default_padding_bottom' => '6em' + } + } + ) + c.file_types.register(Pageflow::BuiltInFileType.image) c.file_types.register(Pageflow::BuiltInFileType.video) c.file_types.register(Pageflow::BuiltInFileType.audio) diff --git a/entry_types/scrolled/lib/pageflow_scrolled/theme_options_default_scale.rb b/entry_types/scrolled/lib/pageflow_scrolled/theme_options_default_scale.rb new file mode 100644 index 0000000000..0b6de884d7 --- /dev/null +++ b/entry_types/scrolled/lib/pageflow_scrolled/theme_options_default_scale.rb @@ -0,0 +1,35 @@ +module PageflowScrolled + # Callable that ensures a scale with the given prefix is defined + # in theme options. If the theme already defines any properties + # with the prefix, the defaults are not added. + # + # @api private + class ThemeOptionsDefaultScale + def initialize(prefix:, values:) + @prefix = prefix + @values = values + end + + def call(options) + return options if scale_defined?(options) + + options.deep_merge( + properties: { + root: prefixed_values + } + ) + end + + private + + def scale_defined?(options) + options.dig(:properties, :root)&.keys&.any? do |key| + key.to_s.start_with?("#{@prefix}-") + end + end + + def prefixed_values + @values.transform_keys { |key| "#{@prefix}-#{key}" } + end + end +end diff --git a/entry_types/scrolled/spec/pageflow_scrolled/theme_options_default_scale_spec.rb b/entry_types/scrolled/spec/pageflow_scrolled/theme_options_default_scale_spec.rb new file mode 100644 index 0000000000..e3ca15c702 --- /dev/null +++ b/entry_types/scrolled/spec/pageflow_scrolled/theme_options_default_scale_spec.rb @@ -0,0 +1,90 @@ +require 'spec_helper' + +module PageflowScrolled + RSpec.describe ThemeOptionsDefaultScale do + it 'adds scale defaults if no scale properties defined' do + transform = ThemeOptionsDefaultScale.new( + prefix: 'section_padding_top', + values: {'none' => '0', 'sm' => '1em'} + ) + + result = transform.call({}) + + expect(result).to eq( + properties: { + root: { + 'section_padding_top-none' => '0', + 'section_padding_top-sm' => '1em' + } + } + ) + end + + it 'preserves existing theme options' do + transform = ThemeOptionsDefaultScale.new( + prefix: 'section_padding_top', + values: {'none' => '0', 'sm' => '1em'} + ) + + result = transform.call(colors: {accent: '#f00'}) + + expect(result).to eq( + colors: {accent: '#f00'}, + properties: { + root: { + 'section_padding_top-none' => '0', + 'section_padding_top-sm' => '1em' + } + } + ) + end + + it 'does not add defaults if theme defines any scale property' do + transform = ThemeOptionsDefaultScale.new( + prefix: 'section_padding_top', + values: {'none' => '0', 'sm' => '1em', 'md' => '2em'} + ) + + result = transform.call( + properties: { + root: { + 'section_padding_top-lg' => '5em' + } + } + ) + + expect(result).to eq( + properties: { + root: { + 'section_padding_top-lg' => '5em' + } + } + ) + end + + it 'adds defaults for one scale even if other scale is defined' do + transform = ThemeOptionsDefaultScale.new( + prefix: 'section_padding_top', + values: {'none' => '0', 'sm' => '1em'} + ) + + result = transform.call( + properties: { + root: { + 'section_padding_bottom-lg' => '5em' + } + } + ) + + expect(result).to eq( + properties: { + root: { + 'section_padding_bottom-lg' => '5em', + 'section_padding_top-none' => '0', + 'section_padding_top-sm' => '1em' + } + } + ) + end + end +end From c21a2791688e3b81ffe01e49ac01e073fa4c4b8c Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 8 Jan 2026 17:15:49 +0100 Subject: [PATCH 13/17] Trim first content element top margin at section start Section padding should exclusively control spacing above the first content element. This removes the content element's own top margin when it's at the start of a section (shadow/transparent appearance). Cards appearance keeps the margin since elements are inside the card and section padding is outside. REDMINE-21191 --- .../features/contentElementMargin-spec.js | 30 +++++++++++++++++++ .../package/spec/support/pageObjects.js | 5 ++++ .../package/src/frontend/ContentElement.js | 2 ++ .../src/frontend/ContentElementMargin.js | 9 ++++-- .../frontend/ContentElementMargin.module.css | 4 +++ .../package/src/frontend/TrimMarginTop.js | 9 ++++++ .../foregroundBoxes/InvisibleBoxWrapper.js | 19 +++++++----- 7 files changed, 68 insertions(+), 10 deletions(-) create mode 100644 entry_types/scrolled/package/src/frontend/TrimMarginTop.js diff --git a/entry_types/scrolled/package/spec/frontend/features/contentElementMargin-spec.js b/entry_types/scrolled/package/spec/frontend/features/contentElementMargin-spec.js index d973b51838..801cd1c430 100644 --- a/entry_types/scrolled/package/spec/frontend/features/contentElementMargin-spec.js +++ b/entry_types/scrolled/package/spec/frontend/features/contentElementMargin-spec.js @@ -30,4 +30,34 @@ describe('content element margin', () => { expect(getContentElementByTestId(1).hasMargin()).toBe(false); }); + + it('does not apply top margin to first content element in section', () => { + const {getContentElementByTestId} = renderEntry({ + seed: { + sections: [{id: 5}], + contentElements: [ + {sectionId: 5, typeName: 'withTestId', configuration: {testId: 1}}, + {sectionId: 5, typeName: 'withTestId', configuration: {testId: 2}} + ] + } + }); + + expect(getContentElementByTestId(1).hasTopMargin()).toBe(false); + expect(getContentElementByTestId(2).hasTopMargin()).toBe(true); + }); + + it('still applies top margin to first content element in cards appearance', () => { + const {getContentElementByTestId} = renderEntry({ + seed: { + sections: [{id: 5, configuration: {appearance: 'cards'}}], + contentElements: [ + {sectionId: 5, typeName: 'withTestId', configuration: {testId: 1}}, + {sectionId: 5, typeName: 'withTestId', configuration: {testId: 2}} + ] + } + }); + + expect(getContentElementByTestId(1).hasTopMargin()).toBe(true); + expect(getContentElementByTestId(2).hasTopMargin()).toBe(true); + }); }); diff --git a/entry_types/scrolled/package/spec/support/pageObjects.js b/entry_types/scrolled/package/spec/support/pageObjects.js index db0bbc96b3..84e71f9037 100644 --- a/entry_types/scrolled/package/spec/support/pageObjects.js +++ b/entry_types/scrolled/package/spec/support/pageObjects.js @@ -265,6 +265,11 @@ function createContentElementPageObject(el) { return !!el.closest(`.${contentElementMarginStyles.wrapper}`); }, + hasTopMargin() { + const wrapper = el.closest(`.${contentElementMarginStyles.wrapper}`); + return wrapper && !wrapper.classList.contains(contentElementMarginStyles.noTopMargin); + }, + hasScrollSpace() { return !!el.closest(`.${contentElementScrollSpaceStyles.wrapper}`); }, diff --git a/entry_types/scrolled/package/src/frontend/ContentElement.js b/entry_types/scrolled/package/src/frontend/ContentElement.js index f078627b16..3d37dc66b3 100644 --- a/entry_types/scrolled/package/src/frontend/ContentElement.js +++ b/entry_types/scrolled/package/src/frontend/ContentElement.js @@ -20,6 +20,7 @@ export const ContentElement = React.memo(withInlineEditingDecorator( {children} diff --git a/entry_types/scrolled/package/src/frontend/ContentElementMargin.module.css b/entry_types/scrolled/package/src/frontend/ContentElementMargin.module.css index 4b0a8968f9..9db2364c93 100644 --- a/entry_types/scrolled/package/src/frontend/ContentElementMargin.module.css +++ b/entry_types/scrolled/package/src/frontend/ContentElementMargin.module.css @@ -1,3 +1,7 @@ .wrapper { margin: 1em 0 0 0; } + +.noTopMargin { + margin-top: 0 !important; +} diff --git a/entry_types/scrolled/package/src/frontend/TrimMarginTop.js b/entry_types/scrolled/package/src/frontend/TrimMarginTop.js new file mode 100644 index 0000000000..86fb10b7cd --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/TrimMarginTop.js @@ -0,0 +1,9 @@ +import {createContext, useContext} from 'react'; + +const TrimMarginTopContext = createContext(false); + +export const TrimMarginTopProvider = TrimMarginTopContext.Provider; + +export function useTrimMarginTop() { + return useContext(TrimMarginTopContext); +} diff --git a/entry_types/scrolled/package/src/frontend/foregroundBoxes/InvisibleBoxWrapper.js b/entry_types/scrolled/package/src/frontend/foregroundBoxes/InvisibleBoxWrapper.js index e3c4ee7513..5c033de982 100644 --- a/entry_types/scrolled/package/src/frontend/foregroundBoxes/InvisibleBoxWrapper.js +++ b/entry_types/scrolled/package/src/frontend/foregroundBoxes/InvisibleBoxWrapper.js @@ -2,19 +2,22 @@ import React from 'react'; import classNames from 'classnames'; import {widths} from '../layouts'; +import {TrimMarginTopProvider} from '../TrimMarginTop'; import styles from './InvisibleBoxWrapper.module.css'; export function InvisibleBoxWrapper({position, width, openStart, openEnd, atSectionStart, atSectionEnd, children}) { const full = (width === widths.full); return ( -
- {children} -
+ +
+ {children} +
+
) } From 5b2ab75b3a3792ec9579e85156fc08018bdf7195 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 8 Jan 2026 17:30:40 +0100 Subject: [PATCH 14/17] Support defaultMarginTop in content element registration Content elements like textBlock and question have internal top margins that extend beyond the default 1em content element margin. This allows content elements to specify their preferred top margin via the new defaultMarginTop option, enabling these custom margins to also be trimmed at the beginning of sections where section padding should control spacing exclusively. REDMINE-21191 --- .../features/contentElementMargin-spec.js | 24 ++++++++++++++++++- .../package/spec/support/pageObjects.js | 5 ++++ .../question/Question.module.css | 1 - .../src/contentElements/question/frontend.js | 3 ++- .../textBlock/TextBlock.module.css | 13 ++++++++++ .../src/contentElements/textBlock/frontend.js | 3 ++- .../package/src/frontend/ContentElement.js | 2 ++ .../src/frontend/ContentElementMargin.js | 4 ++-- 8 files changed, 49 insertions(+), 6 deletions(-) diff --git a/entry_types/scrolled/package/spec/frontend/features/contentElementMargin-spec.js b/entry_types/scrolled/package/spec/frontend/features/contentElementMargin-spec.js index 801cd1c430..2254fa316e 100644 --- a/entry_types/scrolled/package/spec/frontend/features/contentElementMargin-spec.js +++ b/entry_types/scrolled/package/spec/frontend/features/contentElementMargin-spec.js @@ -1,6 +1,8 @@ +import React from 'react'; + import {renderEntry, usePageObjects} from 'support/pageObjects'; -import {contentElementWidths as widths} from 'pageflow-scrolled/frontend'; +import {contentElementWidths as widths, frontend} from 'pageflow-scrolled/frontend'; describe('content element margin', () => { usePageObjects(); @@ -60,4 +62,24 @@ describe('content element margin', () => { expect(getContentElementByTestId(1).hasTopMargin()).toBe(true); expect(getContentElementByTestId(2).hasTopMargin()).toBe(true); }); + + it('supports defaultMarginTop option in content element registration', () => { + frontend.contentElementTypes.register('withDefaultMargin', { + component: function WithDefaultMargin({configuration}) { + return
; + }, + defaultMarginTop: '1.375rem' + }); + + const {getContentElementByTestId} = renderEntry({ + seed: { + sections: [{id: 5, configuration: {appearance: 'cards'}}], + contentElements: [ + {sectionId: 5, typeName: 'withDefaultMargin', configuration: {testId: 1}} + ] + } + }); + + expect(getContentElementByTestId(1).getMarginTop()).toBe('1.375rem'); + }); }); diff --git a/entry_types/scrolled/package/spec/support/pageObjects.js b/entry_types/scrolled/package/spec/support/pageObjects.js index 84e71f9037..9e032c7423 100644 --- a/entry_types/scrolled/package/spec/support/pageObjects.js +++ b/entry_types/scrolled/package/spec/support/pageObjects.js @@ -270,6 +270,11 @@ function createContentElementPageObject(el) { return wrapper && !wrapper.classList.contains(contentElementMarginStyles.noTopMargin); }, + getMarginTop() { + const wrapper = el.closest(`.${contentElementMarginStyles.wrapper}`); + return wrapper && wrapper.style.marginTop; + }, + hasScrollSpace() { return !!el.closest(`.${contentElementScrollSpaceStyles.wrapper}`); }, diff --git a/entry_types/scrolled/package/src/contentElements/question/Question.module.css b/entry_types/scrolled/package/src/contentElements/question/Question.module.css index 4095decb1a..57fbcca0e7 100644 --- a/entry_types/scrolled/package/src/contentElements/question/Question.module.css +++ b/entry_types/scrolled/package/src/contentElements/question/Question.module.css @@ -1,7 +1,6 @@ .details { position: relative; padding-left: 22px; - margin: 1.375rem 0 0 0; } .details summary { diff --git a/entry_types/scrolled/package/src/contentElements/question/frontend.js b/entry_types/scrolled/package/src/contentElements/question/frontend.js index 69e1ca7069..b1feced1fd 100644 --- a/entry_types/scrolled/package/src/contentElements/question/frontend.js +++ b/entry_types/scrolled/package/src/contentElements/question/frontend.js @@ -2,5 +2,6 @@ import {frontend} from 'pageflow-scrolled/frontend'; import {Question} from './Question'; frontend.contentElementTypes.register('question', { - component: Question + component: Question, + defaultMarginTop: '1.375rem' }); diff --git a/entry_types/scrolled/package/src/contentElements/textBlock/TextBlock.module.css b/entry_types/scrolled/package/src/contentElements/textBlock/TextBlock.module.css index 982b5dd7e3..44887fbd00 100644 --- a/entry_types/scrolled/package/src/contentElements/textBlock/TextBlock.module.css +++ b/entry_types/scrolled/package/src/contentElements/textBlock/TextBlock.module.css @@ -16,10 +16,19 @@ margin: 1.375rem 0 0 0; } +.text > p:first-child { + margin-top: 0; +} + .text li { margin: var(--theme-text-block-first-list-item-margin-top, 1.375rem) 0 0 0; } +.text > ol:first-child > li:first-child, +.text > ul:first-child > li:first-child { + margin-top: 0; +} + .text li + li { margin-top: var(--theme-text-block-list-item-margin-top, 0.6875rem); } @@ -59,6 +68,10 @@ overflow: hidden; } +.text > blockquote:first-child { + margin-top: 0; +} + .text blockquote::before, .text blockquote::after { font-family: var(--theme-quote-mark-font-family); diff --git a/entry_types/scrolled/package/src/contentElements/textBlock/frontend.js b/entry_types/scrolled/package/src/contentElements/textBlock/frontend.js index c7d7b9dd57..5d6c64e3f1 100644 --- a/entry_types/scrolled/package/src/contentElements/textBlock/frontend.js +++ b/entry_types/scrolled/package/src/contentElements/textBlock/frontend.js @@ -4,5 +4,6 @@ import {TextBlock} from './TextBlock'; frontend.contentElementTypes.register('textBlock', { component: TextBlock, customSelectionRect: true, - supportsWrappingAroundFloats: true + supportsWrappingAroundFloats: true, + defaultMarginTop: '1.375rem' }); diff --git a/entry_types/scrolled/package/src/frontend/ContentElement.js b/entry_types/scrolled/package/src/frontend/ContentElement.js index 3d37dc66b3..cc69c6aa49 100644 --- a/entry_types/scrolled/package/src/frontend/ContentElement.js +++ b/entry_types/scrolled/package/src/frontend/ContentElement.js @@ -13,6 +13,7 @@ export const ContentElement = React.memo(withInlineEditingDecorator( 'ContentElementDecorator', function ContentElement(props) { const Component = api.contentElementTypes.getComponent(props.type); + const {defaultMarginTop} = api.contentElementTypes.getOptions(props.type) || {}; if (Component) { return ( @@ -21,6 +22,7 @@ export const ContentElement = React.memo(withInlineEditingDecorator( override={props.lifecycleOverride}> {children}
From cc8bada8c65232b34a8f0540cd8c107f13a2e2ff Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 8 Jan 2026 10:19:16 +0100 Subject: [PATCH 15/17] Keep box top margin when motif area is padded When the motif area is exposed and content is padded down, the first content element box should keep its top margin rather than suppressing it. The space above comes from the motif area padding, not a manually configured section padding. Keep existing spacing between motif and box. REDMINE-21191 --- .../package/spec/frontend/Layout-spec.js | 28 +++++++++++++++++++ .../scrolled/package/src/frontend/Section.js | 3 +- .../package/src/frontend/layouts/Center.js | 6 ++-- .../package/src/frontend/layouts/TwoColumn.js | 6 ++-- 4 files changed, 36 insertions(+), 7 deletions(-) diff --git a/entry_types/scrolled/package/spec/frontend/Layout-spec.js b/entry_types/scrolled/package/spec/frontend/Layout-spec.js index bcac17b46e..83e51b666d 100644 --- a/entry_types/scrolled/package/spec/frontend/Layout-spec.js +++ b/entry_types/scrolled/package/spec/frontend/Layout-spec.js @@ -884,6 +884,34 @@ describe('Layout', () => { expect(container.textContent).toEqual('[1 2 3 ]'); }); + + it('does not mark first box with atSectionStart when isContentPadded in two column variant', () => { + const items = [ + {id: 1, type: 'probe', position: 'inline'}, + {id: 2, type: 'probe', position: 'inline'}, + ]; + const {container} = renderInEntry( + + {(children, boxProps) => {children}} + + ); + + expect(container.textContent).toEqual('1 2 ]'); + }); + + it('does not mark first box with atSectionStart when isContentPadded in center variant', () => { + const items = [ + {id: 1, type: 'probe', position: 'inline'}, + {id: 2, type: 'probe', position: 'inline'}, + ]; + const {container} = renderInEntry( + + {(children, boxProps) => {children}} + + ); + + expect(container.textContent).toEqual('1 2 ]'); + }); }); }); diff --git a/entry_types/scrolled/package/src/frontend/Section.js b/entry_types/scrolled/package/src/frontend/Section.js index 2207660b47..26a9c0bb9b 100644 --- a/entry_types/scrolled/package/src/frontend/Section.js +++ b/entry_types/scrolled/package/src/frontend/Section.js @@ -175,7 +175,8 @@ function SectionContents({ items={contentElements} appearance={section.appearance} contentAreaRef={setContentAreaRef} - sectionProps={sectionProperties}> + sectionProps={sectionProperties} + isContentPadded={motifAreaState.isContentPadded}> {(children, boxProps) => {child}
, - boxProps(props.items, item, index) + boxProps(props.items, item, index, props.isContentPadded) )} @@ -58,7 +58,7 @@ function outerClassName(items, index) { ); } -function boxProps(items, item, index) { +function boxProps(items, item, index, isContentPadded) { const previous = items[index - 1]; const next = items[index + 1]; const customMargin = hasCustomMargin(item); @@ -77,7 +77,7 @@ function boxProps(items, item, index) { !customMargin && !hasCustomMargin(next) && !isWideOrFull(item) && !isWideOrFull(next), - atSectionStart: index === 0, + atSectionStart: index === 0 && !isContentPadded, atSectionEnd: index === items.length - 1 } } diff --git a/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.js b/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.js index 038404f1fc..a9c52ac88d 100644 --- a/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.js +++ b/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.js @@ -51,7 +51,7 @@ TwoColumn.GroupComponent = 'div'; TwoColumn.contentAreaProbeProps = {}; function renderItems(props, shouldInline) { - const groups = groupItemsByPosition(props.items, shouldInline); + const groups = groupItemsByPosition(props.items, shouldInline, props.isContentPadded); return groups.map((group, groupIndex) => Date: Fri, 9 Jan 2026 12:18:10 +0100 Subject: [PATCH 16/17] Add integration test for box margin with motif area Verifies that box wrappers update their margin when isContentPadded changes dynamically. This required fixing the Layout memo comparison to include isContentPadded, and enhancing the useMotifAreaState mock to support triggering re-renders. Extracts margin suppression styles to a shared CSS module for easier testing across different box wrapper implementations. REDMINE-21191 --- .../frontend/features/sectionPadding-spec.js | 16 ++++++++++++ .../foregroundBoxes/CardBoxWrapper-spec.js | 10 ++++---- .../InvisibleBoxWrapper-spec.js | 10 ++++---- .../package/spec/support/pageObjects.js | 6 +++++ .../BoxBoundaryMargin.module.css | 7 ++++++ .../foregroundBoxes/CardBoxWrapper.js | 5 ++-- .../foregroundBoxes/CardBoxWrapper.module.css | 8 ------ .../foregroundBoxes/InvisibleBoxWrapper.js | 5 ++-- .../InvisibleBoxWrapper.module.css | 8 ------ .../package/src/frontend/layouts/index.js | 3 ++- .../v1/__mocks__/useMotifAreaState.js | 25 ++++++++++++++----- 11 files changed, 66 insertions(+), 37 deletions(-) create mode 100644 entry_types/scrolled/package/src/frontend/foregroundBoxes/BoxBoundaryMargin.module.css diff --git a/entry_types/scrolled/package/spec/frontend/features/sectionPadding-spec.js b/entry_types/scrolled/package/spec/frontend/features/sectionPadding-spec.js index b56a71f5a2..8acf9c8102 100644 --- a/entry_types/scrolled/package/spec/frontend/features/sectionPadding-spec.js +++ b/entry_types/scrolled/package/spec/frontend/features/sectionPadding-spec.js @@ -1,4 +1,5 @@ import {renderEntry, useInlineEditingPageObjects} from 'support/pageObjects'; +import {act} from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; @@ -61,6 +62,21 @@ describe('section padding', () => { expect(getSectionByPermaId(6).hasSuppressedTopPadding()).toBe(true); }); + it('does not suppress first box top margin if motif area becomes content padded', () => { + const {getSectionByPermaId} = renderEntry({ + seed: { + sections: [{id: 5, permaId: 6}], + contentElements: [{sectionId: 5}] + } + }); + + expect(getSectionByPermaId(6).hasFirstBoxSuppressedTopMargin()).toBe(true); + + act(() => useMotifAreaState.mockContentPadded()); + + expect(getSectionByPermaId(6).hasFirstBoxSuppressedTopMargin()).toBe(false); + }); + it('suppresses bottom padding if last content element is full width', () => { const {getSectionByPermaId} = renderEntry({ seed: { 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 197ec48385..8266ef22ea 100644 --- a/entry_types/scrolled/package/spec/frontend/foregroundBoxes/CardBoxWrapper-spec.js +++ b/entry_types/scrolled/package/spec/frontend/foregroundBoxes/CardBoxWrapper-spec.js @@ -4,7 +4,7 @@ import '@testing-library/jest-dom/extend-expect'; import CardBoxWrapper from 'frontend/foregroundBoxes/CardBoxWrapper'; -import styles from 'frontend/foregroundBoxes/CardBoxWrapper.module.css'; +import boundaryMarginStyles from 'frontend/foregroundBoxes/BoxBoundaryMargin.module.css'; describe('CardBoxWrapper', () => { describe('at section boundaries', () => { @@ -15,7 +15,7 @@ describe('CardBoxWrapper', () => { ); - expect(container.firstChild).not.toHaveClass(styles.noTopMargin); + expect(container.firstChild).not.toHaveClass(boundaryMarginStyles.noTopMargin); }); it('has noTopMargin class when at section start', () => { @@ -25,7 +25,7 @@ describe('CardBoxWrapper', () => { ); - expect(container.firstChild).toHaveClass(styles.noTopMargin); + expect(container.firstChild).toHaveClass(boundaryMarginStyles.noTopMargin); }); it('does not have noBottomMargin class when not at section end', () => { @@ -35,7 +35,7 @@ describe('CardBoxWrapper', () => { ); - expect(container.firstChild).not.toHaveClass(styles.noBottomMargin); + expect(container.firstChild).not.toHaveClass(boundaryMarginStyles.noBottomMargin); }); it('has noBottomMargin class when at section end', () => { @@ -45,7 +45,7 @@ describe('CardBoxWrapper', () => { ); - expect(container.firstChild).toHaveClass(styles.noBottomMargin); + expect(container.firstChild).toHaveClass(boundaryMarginStyles.noBottomMargin); }); }); }); diff --git a/entry_types/scrolled/package/spec/frontend/foregroundBoxes/InvisibleBoxWrapper-spec.js b/entry_types/scrolled/package/spec/frontend/foregroundBoxes/InvisibleBoxWrapper-spec.js index 7092799d5e..fcadf7fb86 100644 --- a/entry_types/scrolled/package/spec/frontend/foregroundBoxes/InvisibleBoxWrapper-spec.js +++ b/entry_types/scrolled/package/spec/frontend/foregroundBoxes/InvisibleBoxWrapper-spec.js @@ -4,7 +4,7 @@ import '@testing-library/jest-dom/extend-expect'; import {InvisibleBoxWrapper} from 'frontend/foregroundBoxes/InvisibleBoxWrapper'; -import styles from 'frontend/foregroundBoxes/InvisibleBoxWrapper.module.css'; +import boundaryMarginStyles from 'frontend/foregroundBoxes/BoxBoundaryMargin.module.css'; describe('InvisibleBoxWrapper', () => { describe('at section boundaries', () => { @@ -15,7 +15,7 @@ describe('InvisibleBoxWrapper', () => { ); - expect(container.firstChild).not.toHaveClass(styles.noTopMargin); + expect(container.firstChild).not.toHaveClass(boundaryMarginStyles.noTopMargin); }); it('has noTopMargin class when at section start', () => { @@ -25,7 +25,7 @@ describe('InvisibleBoxWrapper', () => { ); - expect(container.firstChild).toHaveClass(styles.noTopMargin); + expect(container.firstChild).toHaveClass(boundaryMarginStyles.noTopMargin); }); it('does not have noBottomMargin class when not at section end', () => { @@ -35,7 +35,7 @@ describe('InvisibleBoxWrapper', () => { ); - expect(container.firstChild).not.toHaveClass(styles.noBottomMargin); + expect(container.firstChild).not.toHaveClass(boundaryMarginStyles.noBottomMargin); }); it('has noBottomMargin class when at section end', () => { @@ -45,7 +45,7 @@ describe('InvisibleBoxWrapper', () => { ); - expect(container.firstChild).toHaveClass(styles.noBottomMargin); + expect(container.firstChild).toHaveClass(boundaryMarginStyles.noBottomMargin); }); }); }); diff --git a/entry_types/scrolled/package/spec/support/pageObjects.js b/entry_types/scrolled/package/spec/support/pageObjects.js index 9e032c7423..c228d9ef6b 100644 --- a/entry_types/scrolled/package/spec/support/pageObjects.js +++ b/entry_types/scrolled/package/spec/support/pageObjects.js @@ -10,6 +10,7 @@ import contentElementScrollSpaceStyles from 'frontend/ContentElementScrollSpace. import fitViewportStyles from 'frontend/FitViewport.module.css'; import centerLayoutStyles from 'frontend/layouts/Center.module.css'; import twoColumnLayoutStyles from 'frontend/layouts/TwoColumn.module.css'; +import boxBoundaryMarginStyles from 'frontend/foregroundBoxes/BoxBoundaryMargin.module.css'; import {StaticPreview} from 'frontend/useScrollPositionLifecycle'; import {loadInlineEditingComponents} from 'frontend/inlineEditing'; import {api} from 'frontend/api'; @@ -234,6 +235,11 @@ function createSectionPageObject(el) { selectPadding(position) { fireEvent.mouseDown(selectionRect); fireEvent.click(this.getPaddingIndicator(position)); + }, + + hasFirstBoxSuppressedTopMargin() { + const firstBox = foreground.querySelector(`.${boxBoundaryMarginStyles.noTopMargin}`); + return !!firstBox; } } } diff --git a/entry_types/scrolled/package/src/frontend/foregroundBoxes/BoxBoundaryMargin.module.css b/entry_types/scrolled/package/src/frontend/foregroundBoxes/BoxBoundaryMargin.module.css new file mode 100644 index 0000000000..138e48e2b7 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/foregroundBoxes/BoxBoundaryMargin.module.css @@ -0,0 +1,7 @@ +.noTopMargin { + margin-top: 0 !important; +} + +.noBottomMargin { + margin-bottom: 0 !important; +} diff --git a/entry_types/scrolled/package/src/frontend/foregroundBoxes/CardBoxWrapper.js b/entry_types/scrolled/package/src/frontend/foregroundBoxes/CardBoxWrapper.js index 0028dd5893..3ee3c463d6 100644 --- a/entry_types/scrolled/package/src/frontend/foregroundBoxes/CardBoxWrapper.js +++ b/entry_types/scrolled/package/src/frontend/foregroundBoxes/CardBoxWrapper.js @@ -5,6 +5,7 @@ import {widths} from '../layouts'; import {BackgroundColorProvider} from '../backgroundColor'; import styles from "./CardBoxWrapper.module.css"; +import boundaryMarginStyles from "./BoxBoundaryMargin.module.css"; export default function CardBoxWrapper(props) { if (outsideBox(props)) { @@ -35,7 +36,7 @@ function className(props) { {[styles.blur]: props.cardSurfaceTransparency > 0}, {[styles.cardStart]: !props.openStart}, {[styles.cardEnd]: !props.openEnd}, - {[styles.noTopMargin]: props.atSectionStart}, - {[styles.noBottomMargin]: props.atSectionEnd} + {[boundaryMarginStyles.noTopMargin]: props.atSectionStart}, + {[boundaryMarginStyles.noBottomMargin]: props.atSectionEnd} ); } 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 a375989a90..ee1d90c3d2 100644 --- a/entry_types/scrolled/package/src/frontend/foregroundBoxes/CardBoxWrapper.module.css +++ b/entry_types/scrolled/package/src/frontend/foregroundBoxes/CardBoxWrapper.module.css @@ -47,19 +47,11 @@ margin-top: 3em; } -.cardStart.noTopMargin { - margin-top: 0; -} - .cardEnd { margin-bottom: 3em; padding-bottom: 1.5em; } -.cardEnd.noBottomMargin { - margin-bottom: 0; -} - .cardStart::before { border-top-left-radius: var(--theme-cards-border-radius, 15px); border-top-right-radius: var(--theme-cards-border-radius, 15px); diff --git a/entry_types/scrolled/package/src/frontend/foregroundBoxes/InvisibleBoxWrapper.js b/entry_types/scrolled/package/src/frontend/foregroundBoxes/InvisibleBoxWrapper.js index 5c033de982..565476bf82 100644 --- a/entry_types/scrolled/package/src/frontend/foregroundBoxes/InvisibleBoxWrapper.js +++ b/entry_types/scrolled/package/src/frontend/foregroundBoxes/InvisibleBoxWrapper.js @@ -4,6 +4,7 @@ import classNames from 'classnames'; import {widths} from '../layouts'; import {TrimMarginTopProvider} from '../TrimMarginTop'; import styles from './InvisibleBoxWrapper.module.css'; +import boundaryMarginStyles from './BoxBoundaryMargin.module.css'; export function InvisibleBoxWrapper({position, width, openStart, openEnd, atSectionStart, atSectionEnd, children}) { const full = (width === widths.full); @@ -13,8 +14,8 @@ export function InvisibleBoxWrapper({position, width, openStart, openEnd, atSect
{children}
diff --git a/entry_types/scrolled/package/src/frontend/foregroundBoxes/InvisibleBoxWrapper.module.css b/entry_types/scrolled/package/src/frontend/foregroundBoxes/InvisibleBoxWrapper.module.css index 7382268a21..5685ab15e3 100644 --- a/entry_types/scrolled/package/src/frontend/foregroundBoxes/InvisibleBoxWrapper.module.css +++ b/entry_types/scrolled/package/src/frontend/foregroundBoxes/InvisibleBoxWrapper.module.css @@ -2,14 +2,6 @@ margin-top: 1.375em; } -.start.noTopMargin { - margin-top: 0; -} - .end { margin-bottom: 1.375em; } - -.end.noBottomMargin { - margin-bottom: 0; -} diff --git a/entry_types/scrolled/package/src/frontend/layouts/index.js b/entry_types/scrolled/package/src/frontend/layouts/index.js index 29ca1f5078..2df9f09545 100644 --- a/entry_types/scrolled/package/src/frontend/layouts/index.js +++ b/entry_types/scrolled/package/src/frontend/layouts/index.js @@ -14,7 +14,8 @@ export const Layout = React.memo( prevProps.items === nextProps.items && prevProps.appearance === nextProps.appearance && prevProps.contentAreaRef === nextProps.contentAreaRef && - prevProps.sectionProps === nextProps.sectionProps + prevProps.sectionProps === nextProps.sectionProps && + prevProps.isContentPadded === nextProps.isContentPadded ) ); diff --git a/entry_types/scrolled/package/src/frontend/v1/__mocks__/useMotifAreaState.js b/entry_types/scrolled/package/src/frontend/v1/__mocks__/useMotifAreaState.js index fceb46aac9..cb801d4802 100644 --- a/entry_types/scrolled/package/src/frontend/v1/__mocks__/useMotifAreaState.js +++ b/entry_types/scrolled/package/src/frontend/v1/__mocks__/useMotifAreaState.js @@ -1,28 +1,41 @@ -let currentState = {}; +import {useState, useCallback} from 'react'; + +let initialState = {}; +let setStateRef = null; export function useMotifAreaState() { + const [overrides, setOverrides] = useState(initialState); + setStateRef = setOverrides; + const state = { paddingTop: 0, isContentPadded: false, minHeight: undefined, intersectionRatioY: 0, isMotifIntersected: false, - ...currentState + ...overrides }; - const setMotifAreaRef = () => {}; - const setContentAreaRef = () => {}; + const setMotifAreaRef = useCallback(() => {}, []); + const setContentAreaRef = useCallback(() => {}, []); return [state, setMotifAreaRef, setContentAreaRef]; } beforeEach(() => { - currentState = {}; + initialState = {}; + setStateRef = null; }); useMotifAreaState.mockContentPadded = function() { - currentState = { + const state = { paddingTop: 100, isContentPadded: true }; + + if (setStateRef) { + setStateRef(state); + } else { + initialState = state; + } }; From 5f0a55406a9ce83499ae5b66083eb38b058580a8 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 8 Jan 2026 18:09:25 +0100 Subject: [PATCH 17/17] Remove custom margin top in heading Used to collapse with box margin. REDMINE-21191 --- .../package/src/contentElements/heading/Heading.module.css | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/entry_types/scrolled/package/src/contentElements/heading/Heading.module.css b/entry_types/scrolled/package/src/contentElements/heading/Heading.module.css index 6fc1996cb9..fc10654951 100644 --- a/entry_types/scrolled/package/src/contentElements/heading/Heading.module.css +++ b/entry_types/scrolled/package/src/contentElements/heading/Heading.module.css @@ -4,8 +4,7 @@ ) from "pageflow-scrolled/values/colors.module.css"; .root { - margin-top: 0.3em; - margin-bottom: 0; + margin: 0; padding-top: 0.45em; }