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/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index ca51467025..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: @@ -1672,6 +1694,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..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: @@ -1502,6 +1524,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/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/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/spec/frontend/Layout-spec.js b/entry_types/scrolled/package/spec/frontend/Layout-spec.js index 831b170bd8..83e51b666d 100644 --- a/entry_types/scrolled/package/spec/frontend/Layout-spec.js +++ b/entry_types/scrolled/package/spec/frontend/Layout-spec.js @@ -814,6 +814,105 @@ 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 ]'); + }); + + 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 ]'); + }); + }); }); describe('floating items in centered variant', () => { 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..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(); @@ -30,4 +32,54 @@ 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); + }); + + 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/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/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/spec/frontend/features/sectionPadding-spec.js b/entry_types/scrolled/package/spec/frontend/features/sectionPadding-spec.js index 1d37490b20..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,14 +1,33 @@ import {renderEntry, useInlineEditingPageObjects} from 'support/pageObjects'; +import {act} from '@testing-library/react'; 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(() => { + features.enable('frontend', ['section_paddings']); + }); + 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}], + contentElements: [{sectionId: 5}] + } + }); + + expect(getSectionByPermaId(6).hasSuppressedTopPadding()).toBe(false); + }); + + it('does not suppress bottom padding by default', () => { const {getSectionByPermaId} = renderEntry({ seed: { sections: [{id: 5, permaId: 6}], @@ -16,10 +35,10 @@ describe('section padding', () => { } }); - expect(getSectionByPermaId(6).hasBottomPadding()).toBe(true); + expect(getSectionByPermaId(6).hasSuppressedBottomPadding()).toBe(false); }); - it('does not add padding to bottom of section if last content element is full width', () => { + it('suppresses top padding if first content element is full width', () => { const {getSectionByPermaId} = renderEntry({ seed: { sections: [{id: 5, permaId: 6}], @@ -27,38 +46,107 @@ describe('section padding', () => { } }); - expect(getSectionByPermaId(6).hasBottomPadding()).toBe(false); + 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('adds padding below full width element if section is selected', () => { + 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, configuration: {position: 'full'}}] + 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: { + sections: [{id: 5, permaId: 6}], + contentElements: [{sectionId: 5, configuration: {width: 3}}] + } + }); + + expect(getSectionByPermaId(6).hasSuppressedBottomPadding()).toBe(true); + }); + + it('forces padding below full width element if section is selected', () => { + const {getSectionByPermaId} = renderEntry({ + seed: { + sections: [{id: 5, permaId: 6}], + 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', () => { + 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', () => { @@ -87,6 +175,7 @@ describe('section padding', () => { const {getSectionByPermaId} = renderEntry({ seed: { + imageFiles: [{id: 100, permaId: 100}], sections: [{ id: 5, permaId: 6, @@ -94,7 +183,8 @@ describe('section padding', () => { paddingTop: 'lg', paddingBottom: 'md', portraitPaddingTop: 'sm', - portraitPaddingBottom: 'xs' + portraitPaddingBottom: 'xs', + backdrop: {image: 100, imageMobile: 100} } }], contentElements: [{sectionId: 5}] @@ -130,6 +220,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/spec/frontend/foregroundBoxes/CardBoxWrapper-spec.js b/entry_types/scrolled/package/spec/frontend/foregroundBoxes/CardBoxWrapper-spec.js new file mode 100644 index 0000000000..8266ef22ea --- /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 boundaryMarginStyles from 'frontend/foregroundBoxes/BoxBoundaryMargin.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(boundaryMarginStyles.noTopMargin); + }); + + it('has noTopMargin class when at section start', () => { + const {container} = render( + + Content + + ); + + expect(container.firstChild).toHaveClass(boundaryMarginStyles.noTopMargin); + }); + + it('does not have noBottomMargin class when not at section end', () => { + const {container} = render( + + Content + + ); + + expect(container.firstChild).not.toHaveClass(boundaryMarginStyles.noBottomMargin); + }); + + it('has noBottomMargin class when at section end', () => { + const {container} = render( + + Content + + ); + + 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 new file mode 100644 index 0000000000..fcadf7fb86 --- /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 boundaryMarginStyles from 'frontend/foregroundBoxes/BoxBoundaryMargin.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(boundaryMarginStyles.noTopMargin); + }); + + it('has noTopMargin class when at section start', () => { + const {container} = render( + + Content + + ); + + expect(container.firstChild).toHaveClass(boundaryMarginStyles.noTopMargin); + }); + + it('does not have noBottomMargin class when not at section end', () => { + const {container} = render( + + Content + + ); + + expect(container.firstChild).not.toHaveClass(boundaryMarginStyles.noBottomMargin); + }); + + it('has noBottomMargin class when at section end', () => { + const {container} = render( + + Content + + ); + + 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 af247e01a9..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'; @@ -79,7 +80,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(); @@ -196,8 +199,16 @@ 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() { + return foreground.classList.contains(foregroundStyles.forcePadding); }, hasRemainingSpaceAbove() { @@ -219,6 +230,16 @@ function createSectionPageObject(el) { bottom: 'Edit bottom padding' }; return getByLabelText(labels[position]); + }, + + selectPadding(position) { + fireEvent.mouseDown(selectionRect); + fireEvent.click(this.getPaddingIndicator(position)); + }, + + hasFirstBoxSuppressedTopMargin() { + const firstBox = foreground.querySelector(`.${boxBoundaryMarginStyles.noTopMargin}`); + return !!firstBox; } } } @@ -250,6 +271,16 @@ function createContentElementPageObject(el) { return !!el.closest(`.${contentElementMarginStyles.wrapper}`); }, + hasTopMargin() { + const wrapper = el.closest(`.${contentElementMarginStyles.wrapper}`); + 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/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; } 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/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; +} diff --git a/entry_types/scrolled/package/src/frontend/ContentElement.js b/entry_types/scrolled/package/src/frontend/ContentElement.js index f078627b16..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 ( @@ -20,6 +21,8 @@ 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/Foreground.js b/entry_types/scrolled/package/src/frontend/Foreground.js index 56b7934128..135b1b1fae 100644 --- a/entry_types/scrolled/package/src/frontend/Foreground.js +++ b/entry_types/scrolled/package/src/frontend/Foreground.js @@ -23,7 +23,9 @@ function className(props, forcePadding) { styles.Foreground, props.transitionStyles.foreground, props.transitionStyles[`foreground-${props.state}`], - {[styles.paddingBottom]: props.paddingBottom || forcePadding}, + {[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 00723d5111..ddf938031c 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 { @@ -29,8 +30,16 @@ justify-content: flex-start; } -.paddingBottom { - padding-bottom: var(--foreground-padding-bottom, 3em); +.suppressedPaddingTop { + --foreground-padding-top: 0px; +} + +.suppressedPaddingBottom { + --foreground-padding-bottom: 0px; +} + +.forcePadding { + padding-bottom: max(var(--foreground-padding-bottom, 0px), 3em); } @media print { diff --git a/entry_types/scrolled/package/src/frontend/Section.js b/entry_types/scrolled/package/src/frontend/Section.js index 6cf64b52e7..26a9c0bb9b 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 @@ -54,7 +54,7 @@ const Section = withInlineEditingDecorator('SectionDecorator', function Section( propertyName: 'atmoAudioFileId' }); - const sectionPadding = useSectionPadding(section); + const sectionPadding = useSectionPadding(section, {portrait: backdrop.portrait}); return (
+ sectionProps={sectionProperties} + isContentPadded={motifAreaState.isContentPadded}> {(children, boxProps) => 0}, {[styles.cardStart]: !props.openStart}, - {[styles.cardEnd]: !props.openEnd} + {[styles.cardEnd]: !props.openEnd}, + {[boundaryMarginStyles.noTopMargin]: props.atSectionStart}, + {[boundaryMarginStyles.noBottomMargin]: props.atSectionEnd} ); } diff --git a/entry_types/scrolled/package/src/frontend/foregroundBoxes/InvisibleBoxWrapper.js b/entry_types/scrolled/package/src/frontend/foregroundBoxes/InvisibleBoxWrapper.js index 230b5a737f..565476bf82 100644 --- a/entry_types/scrolled/package/src/frontend/foregroundBoxes/InvisibleBoxWrapper.js +++ b/entry_types/scrolled/package/src/frontend/foregroundBoxes/InvisibleBoxWrapper.js @@ -2,17 +2,23 @@ import React from 'react'; 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, children}) { +export function InvisibleBoxWrapper({position, width, openStart, openEnd, atSectionStart, atSectionEnd, children}) { const full = (width === widths.full); return ( -
- {children} -
+ +
+ {children} +
+
) } 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'; 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'); } 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.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'), 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; diff --git a/entry_types/scrolled/package/src/frontend/layouts/Center.js b/entry_types/scrolled/package/src/frontend/layouts/Center.js index 22760d7f5c..bbff72caa6 100644 --- a/entry_types/scrolled/package/src/frontend/layouts/Center.js +++ b/entry_types/scrolled/package/src/frontend/layouts/Center.js @@ -34,7 +34,7 @@ export function Center(props) { {[styles[`sideBySide`]]: sideBySideFloat(props.items, index)})}> {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); @@ -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 && !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 b6b72808aa..a9c52ac88d 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 (
@@ -101,10 +106,11 @@ function RestrictWidth({width, alignment, children}) { } } -function groupItemsByPosition(items, shouldInline) { +function groupItemsByPosition(items, shouldInline, isContentPadded) { 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 = !isContentPadded; + firstInlineBox = currentBox; + } + currentGroup.boxes.push(currentBox) } @@ -168,6 +179,10 @@ function groupItemsByPosition(items, shouldInline) { return position; }, null); + if (currentBox) { + currentBox.atSectionEnd = true; + } + return groups; } 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/useSectionPaddingCustomProperties.js b/entry_types/scrolled/package/src/frontend/useSectionPaddingCustomProperties.js index f6e9c6c854..1b0f2d9626 100644 --- a/entry_types/scrolled/package/src/frontend/useSectionPaddingCustomProperties.js +++ b/entry_types/scrolled/package/src/frontend/useSectionPaddingCustomProperties.js @@ -1,17 +1,13 @@ -import {usePortraitOrientation} from './usePortraitOrientation'; - -export function useSectionPadding(section) { - const portrait = usePortraitOrientation({ - active: section.portraitPaddingTop || - section.portraitPaddingBottom - }); +export function useSectionPadding(section, {portrait} = {}) { + const usePortraitPaddings = + section.customPortraitPaddings !== false && portrait; const paddingTop = - portrait && section.portraitPaddingTop ? + usePortraitPaddings && section.portraitPaddingTop ? section.portraitPaddingTop : section.paddingTop; const paddingBottom = - portrait && section.portraitPaddingBottom ? + usePortraitPaddings && section.portraitPaddingBottom ? section.portraitPaddingBottom : section.paddingBottom; const styles = {}; 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; + } }; diff --git a/entry_types/scrolled/package/src/frontend/v1/useBackdrop.js b/entry_types/scrolled/package/src/frontend/v1/useBackdrop.js index 148befb6a9..e92f589fec 100644 --- a/entry_types/scrolled/package/src/frontend/v1/useBackdrop.js +++ b/entry_types/scrolled/package/src/frontend/v1/useBackdrop.js @@ -53,14 +53,16 @@ function useFileBackdrop({section, collectionName, propertyName}) { return { [propertyName]: mobileFile, motifArea: section.backdrop[`${propertyName}MobileMotifArea`], - effects: section.backdropEffectsMobile + effects: section.backdropEffectsMobile, + portrait: true } } else if (file) { return { [propertyName]: file, motifArea: section.backdrop[`${propertyName}MotifArea`], - effects: section.backdropEffects + effects: section.backdropEffects, + portrait: false } } else { 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 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 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