diff --git a/entry_types/scrolled/config/locales/new/full_width_in_phone_layout.de.yml b/entry_types/scrolled/config/locales/new/full_width_in_phone_layout.de.yml new file mode 100644 index 000000000..2f665afa2 --- /dev/null +++ b/entry_types/scrolled/config/locales/new/full_width_in_phone_layout.de.yml @@ -0,0 +1,10 @@ +de: + pageflow_scrolled: + editor: + common_content_element_attributes: + fullWidthInPhoneLayout: + label: "Volle Breite im Phone-Layout" + inline_help: |- + Ermöglicht dem Element, auf kleineren Bildschirmen die + volle Breite des Viewports einzunehmen, um den verfügbaren + Platz optimal zu nutzen. diff --git a/entry_types/scrolled/config/locales/new/full_width_in_phone_layout.en.yml b/entry_types/scrolled/config/locales/new/full_width_in_phone_layout.en.yml new file mode 100644 index 000000000..78b26300a --- /dev/null +++ b/entry_types/scrolled/config/locales/new/full_width_in_phone_layout.en.yml @@ -0,0 +1,9 @@ +en: + pageflow_scrolled: + editor: + common_content_element_attributes: + fullWidthInPhoneLayout: + label: "Full width in phone layout" + inline_help: |- + Allow the element to span the full width of the viewport + on smaller screens, maximizing use of available space. diff --git a/entry_types/scrolled/package/spec/editor/models/ContentElement-spec.js b/entry_types/scrolled/package/spec/editor/models/ContentElement-spec.js index 48a1ebb06..4416c61c0 100644 --- a/entry_types/scrolled/package/spec/editor/models/ContentElement-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/ContentElement-spec.js @@ -385,6 +385,42 @@ describe('ContentElement', () => { }); }); + describe('supportsFullWidthInPhoneLayout', () => { + beforeEach(() => { + editor.contentElementTypes.register('headisng', { + supportedWidthRange: ['md', 'xl'], + customMargin: true + }); + editor.contentElementTypes.register('imageGallery', { + supportedWidthRange: ['md', 'full'], + customMargin: true + }); + editor.contentElementTypes.register('inlineImage', { + supportedWidthRange: ['xxs', 'full'] + }); + }); + + it('returns true for types that support full but do not have custom margin', () => { + const entry = factories.entry( + ScrolledEntry, + {}, + { + entryTypeSeed: normalizeSeed({ + contentElements: [ + {id: 4, typeName: 'heading'}, + {id: 5, typeName: 'imageGallery'}, + {id: 6, typeName: 'inlineImage'} + ] + }) + } + ); + + expect(entry.contentElements.get(4).supportsFullWidthInPhoneLayout()).toEqual(false); + expect(entry.contentElements.get(5).supportsFullWidthInPhoneLayout()).toEqual(false); + expect(entry.contentElements.get(6).supportsFullWidthInPhoneLayout()).toEqual(true); + }); + }); + describe('transientState', () => { it('stays in sync with transientState attribute', () => { editor.contentElementTypes.register('someElement', {}); diff --git a/entry_types/scrolled/package/spec/entryState/structure-spec.js b/entry_types/scrolled/package/spec/entryState/structure-spec.js index 3eb1d3957..5b6f9636e 100644 --- a/entry_types/scrolled/package/spec/entryState/structure-spec.js +++ b/entry_types/scrolled/package/spec/entryState/structure-spec.js @@ -972,6 +972,120 @@ describe('useSectionForegroundContentElements', () => { } ]); }); + + it('does not make fullWidthInPhoneLayout elements full width in desktop layout', () => { + const {result} = renderHookInEntry( + () => useSectionForegroundContentElements({sectionId: 2, layout: 'right', phoneLayout: false}), + { + seed: { + chapters: chaptersSeed, + sections: sectionsSeed, + contentElements: [ + { + id: 1, + permaId: 1001, + sectionId: 2, + typeName: 'image', + configuration: { + width: 2, + fullWidthInPhoneLayout: true + } + }, + { + id: 2, + permaId: 1002, + sectionId: 2, + typeName: 'image', + configuration: { + position: 'sticky', + width: 1, + fullWidthInPhoneLayout: true + } + } + ] + } + } + ); + + const contentElements = result.current; + + expect(contentElements).toMatchObject([ + { + id: 1, + position: 'inline', + width: 2 + }, + { + id: 2, + position: 'sticky', + width: 1 + } + ]); + }); + + it('makes fullWidthInPhoneLayout elements full width in phone layout', () => { + const {result} = renderHookInEntry( + () => useSectionForegroundContentElements({sectionId: 2, layout: 'right', phoneLayout: true}), + { + seed: { + chapters: chaptersSeed, + sections: sectionsSeed, + contentElements: [ + { + id: 1, + permaId: 1001, + sectionId: 2, + typeName: 'image', + configuration: { + width: 2, + fullWidthInPhoneLayout: true + } + }, + { + id: 2, + permaId: 1002, + sectionId: 2, + typeName: 'image', + configuration: { + position: 'sticky', + width: 1, + fullWidthInPhoneLayout: true + } + }, + { + id: 3, + permaId: 1003, + sectionId: 2, + typeName: 'image', + configuration: { + width: 1 + } + } + ] + } + } + ); + + const contentElements = result.current; + + expect(contentElements).toMatchObject([ + { + id: 1, + position: 'inline', + width: 3 + }, + { + id: 2, + position: 'inline', + width: 3 + }, + { + id: 3, + position: 'inline', + width: 1 + } + ]); + }); }); describe('useContentElement', () => { diff --git a/entry_types/scrolled/package/spec/frontend/Layout-spec.js b/entry_types/scrolled/package/spec/frontend/Layout-spec.js index 03f3e093e..7ca43f882 100644 --- a/entry_types/scrolled/package/spec/frontend/Layout-spec.js +++ b/entry_types/scrolled/package/spec/frontend/Layout-spec.js @@ -11,20 +11,7 @@ import {widthName} from 'frontend/layouts/widths'; import {renderInEntry} from 'testHelpers'; -import useMediaQuery from 'frontend/useMediaQuery'; - -jest.mock('frontend/useMediaQuery'); - -let viewportWidth; - -useMediaQuery.mockImplementation(query => { - const match = query.match(/max-width: ([0-9]+)px/); - return viewportWidth <= parseInt(match[1], 10); -}) - describe('Layout', () => { - beforeEach(() => { viewportWidth = 1920; }); - describe('placeholder', () => { it('renders in two column variant', () => { const {getByTestId} = renderInEntry( @@ -465,7 +452,7 @@ describe('Layout', () => { {id: 2, type: 'probe', position: 'side', width: 1}, {id: 3, type: 'probe', position: 'side', width: 2} ]; - viewportWidth = 1000; + window.matchMedia.mockViewportWidth(1000); const {container} = renderInEntry( {(children, {position}) =>
{position} {children}
} @@ -496,7 +483,7 @@ describe('Layout', () => { {id: 2, type: 'probe', position: 'sticky', width: 1}, {id: 3, type: 'probe', position: 'sticky', width: 2} ]; - viewportWidth = 1000; + window.matchMedia.mockViewportWidth(1000); const {container} = renderInEntry( {(children, {position}) =>
{position} {children}
} @@ -529,7 +516,7 @@ describe('Layout', () => { {id: 4, type: 'probe', position: 'side', width: 1}, {id: 5, type: 'probe', position: 'side', width: 2} ]; - viewportWidth = 500; + window.matchMedia.mockViewportWidth(500); const {container} = renderInEntry( {(children, {position, width}) =>
{position} {widthName(width)} {children}
} @@ -562,7 +549,7 @@ describe('Layout', () => { {id: 4, type: 'probe', position: 'sticky', width: 1}, {id: 5, type: 'probe', position: 'sticky', width: 2} ]; - viewportWidth = 500; + window.matchMedia.mockViewportWidth(500); const {container} = renderInEntry( {(children, {position, width}) =>
{position} {widthName(width)} {children}
} diff --git a/entry_types/scrolled/package/spec/frontend/features/contentElementWidth-spec.js b/entry_types/scrolled/package/spec/frontend/features/contentElementWidth-spec.js index eaf3cb30d..5e12e971b 100644 --- a/entry_types/scrolled/package/spec/frontend/features/contentElementWidth-spec.js +++ b/entry_types/scrolled/package/spec/frontend/features/contentElementWidth-spec.js @@ -1,4 +1,4 @@ -import React, {useEffect} from 'react'; +import React from 'react'; import {frontend} from 'frontend'; @@ -8,7 +8,7 @@ import '@testing-library/jest-dom/extend-expect' describe('content element width', () => { usePageObjects(); - it('is passed as resolved value', () => { + beforeAll(() => { frontend.contentElementTypes.register('test', { component: function Component({contentElementWidth}) { return ( @@ -16,7 +16,9 @@ describe('content element width', () => { ) } }); + }); + it('is passed as resolved value', () => { const {getByTestId} = renderEntry({ seed: { contentElements: [{ @@ -31,4 +33,39 @@ describe('content element width', () => { expect(getByTestId('test-component')).toHaveTextContent(2); }); + + it('does not become full in phone layout if fullWidthInPhoneLayout is not set', () => { + window.matchMedia.mockViewportWidth(500); + + const {getByTestId} = renderEntry({ + seed: { + contentElements: [{ + typeName: 'test', + configuration: { + width: 2 + } + }] + } + }); + + expect(getByTestId('test-component')).toHaveTextContent(2); + }); + + it('becomes full in phone layout if fullWidthInPhoneLayout is set', () => { + window.matchMedia.mockViewportWidth(500); + + const {getByTestId} = renderEntry({ + seed: { + contentElements: [{ + typeName: 'test', + configuration: { + width: 2, + fullWidthInPhoneLayout: true + } + }] + } + }); + + expect(getByTestId('test-component')).toHaveTextContent(3); + }); }); diff --git a/entry_types/scrolled/package/spec/support/matchMediaStub.js b/entry_types/scrolled/package/spec/support/matchMediaStub.js index 3e17bd3db..2a48ea857 100644 --- a/entry_types/scrolled/package/spec/support/matchMediaStub.js +++ b/entry_types/scrolled/package/spec/support/matchMediaStub.js @@ -1,9 +1,12 @@ let mockOrientation; let mockPrefersReducedMotion; +let mockViewportWidth; Object.defineProperty(window, 'matchMedia', { writable: true, value: jest.fn().mockImplementation(query => { + let match; + if (query === '(orientation: portrait)') { return { addEventListener: jest.fn(), @@ -25,6 +28,20 @@ Object.defineProperty(window, 'matchMedia', { matches: mockPrefersReducedMotion }; } + else if ((match = query.match(/^\(max-width: ([0-9]+)px\)$/))) { + return { + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + matches: mockViewportWidth <= parseInt(match[1], 10) + }; + } + else if ((match = query.match(/^\(min-width: ([0-9]+)px\)$/))) { + return { + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + matches: mockViewportWidth >= parseInt(match[1], 10) + }; + } else { return { addEventListener: jest.fn(), @@ -38,7 +55,9 @@ Object.defineProperty(window, 'matchMedia', { beforeEach(() => { mockOrientation = 'landscape'; mockPrefersReducedMotion = false; + mockViewportWidth = 1920; }); window.matchMedia.mockPortrait = () => mockOrientation = 'portrait'; window.matchMedia.mockPrefersReducedMotion = () => mockPrefersReducedMotion = true; +window.matchMedia.mockViewportWidth = (width) => mockViewportWidth = width; diff --git a/entry_types/scrolled/package/src/editor/models/ContentElement.js b/entry_types/scrolled/package/src/editor/models/ContentElement.js index 7001ee34e..2c6dcd081 100644 --- a/entry_types/scrolled/package/src/editor/models/ContentElement.js +++ b/entry_types/scrolled/package/src/editor/models/ContentElement.js @@ -141,6 +141,11 @@ export const ContentElement = Backbone.Model.extend({ } }, + supportsFullWidthInPhoneLayout() { + return !this.getType().customMargin && + this.getType().supportedWidthRange?.[1] === 'full'; + }, + getEditorPath() { return this.getType().editorPath?.call(null, this) || `/scrolled/content_elements/${this.id}`; diff --git a/entry_types/scrolled/package/src/editor/views/configurationEditors/groups/CommonContentElementAttributes.js b/entry_types/scrolled/package/src/editor/views/configurationEditors/groups/CommonContentElementAttributes.js index b41c664dc..3bfe81aef 100644 --- a/entry_types/scrolled/package/src/editor/views/configurationEditors/groups/CommonContentElementAttributes.js +++ b/entry_types/scrolled/package/src/editor/views/configurationEditors/groups/CommonContentElementAttributes.js @@ -1,4 +1,4 @@ -import {ConfigurationEditorTabView, SelectInputView, SliderInputView} from 'pageflow/ui'; +import {ConfigurationEditorTabView, CheckBoxInputView, SelectInputView, SliderInputView} from 'pageflow/ui'; import { TypographyVariantSelectInputView @@ -22,6 +22,7 @@ ConfigurationEditorTabView.groups.define('ContentElementPosition', function() { sectionLayout: this.model.parent.section.configuration.get('layout') }); } + this.input('width', SliderInputView, { attributeTranslationKeyPrefixes: ['pageflow_scrolled.editor.common_content_element_attributes'], displayText: value => [ @@ -40,6 +41,15 @@ ConfigurationEditorTabView.groups.define('ContentElementPosition', function() { this.model.get('position') === 'full' ? 3 : 0 }); + + if (contentElement.supportsFullWidthInPhoneLayout()) { + this.input('fullWidthInPhoneLayout', CheckBoxInputView, { + attributeTranslationKeyPrefixes: ['pageflow_scrolled.editor.common_content_element_attributes'], + disabledBinding: 'width', + disabled: () => contentElement.getWidth() === 3, + displayCheckedIfDisabled: true + }); + } }); ConfigurationEditorTabView.groups.define( diff --git a/entry_types/scrolled/package/src/entryState/structure.js b/entry_types/scrolled/package/src/entryState/structure.js index 8ef05fb3a..c9df1a65a 100644 --- a/entry_types/scrolled/package/src/entryState/structure.js +++ b/entry_types/scrolled/package/src/entryState/structure.js @@ -150,7 +150,7 @@ export function normalizeSectionConfigurationData(configuration) { }; } -export function useSectionForegroundContentElements({sectionId, layout}) { +export function useSectionForegroundContentElements({sectionId, layout, phoneLayout}) { const filter = useCallback(contentElement => ( contentElement.sectionId === sectionId && contentElement.configuration.position !== 'backdrop' @@ -158,7 +158,7 @@ export function useSectionForegroundContentElements({sectionId, layout}) { const contentElements = useEntryStateCollectionItems('contentElements', filter); return contentElements.map(contentElement => - contentElementData(contentElement, layout) + contentElementData(contentElement, layout, phoneLayout) ); } @@ -171,8 +171,8 @@ export function useContentElement({permaId, layout}) { ); } -function contentElementData(contentElement, layout) { - const position = getPosition(contentElement, layout); +function contentElementData(contentElement, layout, phoneLayout) { + const position = getPosition(contentElement, layout, phoneLayout); return { id: contentElement.id, @@ -180,7 +180,7 @@ function contentElementData(contentElement, layout) { sectionId: contentElement.sectionId, type: contentElement.typeName, position, - width: getWidth(contentElement, position), + width: getWidth(contentElement, position, phoneLayout), standAlone: contentElement.configuration.position === 'standAlone', props: contentElement.configuration }; @@ -194,9 +194,13 @@ const supportedPositions = { backdrop: ['backdrop'] }; -function getPosition(contentElement, layout) { +function getPosition(contentElement, layout, phoneLayout) { const position = contentElement.configuration.position; + if (contentElement.configuration.fullWidthInPhoneLayout && phoneLayout) { + return 'inline'; + } + return supportedPositions[layout || 'left'].includes(position) ? position : 'inline'; @@ -209,12 +213,15 @@ const legacyPositionWidths = { const clampedWidthPositions = ['sticky', 'left', 'right']; -function getWidth(contentElement, position) { +function getWidth(contentElement, position, phoneLayout) { const width = typeof contentElement.configuration.width === 'number' ? contentElement.configuration.width : legacyPositionWidths[contentElement.configuration.position] || 0; - if (clampedWidthPositions.includes(position)) { + if (contentElement.configuration.fullWidthInPhoneLayout && phoneLayout) { + return 3; + } + else if (clampedWidthPositions.includes(position)) { return Math.min(Math.max(width || 0, -2), 2); } else { diff --git a/entry_types/scrolled/package/src/frontend/RootProviders.js b/entry_types/scrolled/package/src/frontend/RootProviders.js index 716c59c65..eac31d9a0 100644 --- a/entry_types/scrolled/package/src/frontend/RootProviders.js +++ b/entry_types/scrolled/package/src/frontend/RootProviders.js @@ -7,6 +7,7 @@ import {EntryStateProvider} from '../entryState'; import {FocusOutlineProvider} from './focusOutline'; import {LocaleProvider} from './i18n'; import {PhonePlatformProvider} from './PhonePlatformProvider'; +import {PhoneLayoutProvider} from './usePhoneLayout'; import {MediaMutedProvider} from './useMediaMuted'; import {AudioFocusProvider} from './useAudioFocus'; import {ConsentProvider} from './thirdPartyConsent'; @@ -18,21 +19,23 @@ export function RootProviders({seed, consent = consentApi, children}) { - - - - - - - - {children} - - - - - - - + + + + + + + + + {children} + + + + + + + + diff --git a/entry_types/scrolled/package/src/frontend/Section.js b/entry_types/scrolled/package/src/frontend/Section.js index 2ff2b1edf..09a1d3921 100644 --- a/entry_types/scrolled/package/src/frontend/Section.js +++ b/entry_types/scrolled/package/src/frontend/Section.js @@ -12,6 +12,7 @@ import Foreground from './Foreground'; import {SectionInlineFileRights} from './SectionInlineFileRights'; import {Layout, widths as contentElementWidths} from './layouts'; import {useScrollTarget} from './useScrollTarget'; +import {usePhoneLayout} from './usePhoneLayout'; import {SectionLifecycleProvider, useSectionLifecycle} from './useSectionLifecycle' import {SectionViewTimelineProvider} from './SectionViewTimelineProvider'; import {withInlineEditingDecorator} from './inlineEditing'; @@ -164,7 +165,8 @@ function SectionContents({ function ConnectedSection(props) { const contentElements = useSectionForegroundContentElements({ sectionId: props.section.id, - layout: props.section.layout + layout: props.section.layout, + phoneLayout: usePhoneLayout() }); const { diff --git a/entry_types/scrolled/package/src/frontend/usePhoneLayout.js b/entry_types/scrolled/package/src/frontend/usePhoneLayout.js new file mode 100644 index 000000000..624e463ae --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/usePhoneLayout.js @@ -0,0 +1,20 @@ +import React, {createContext, useContext} from 'react'; + +import breakpoints from '../../values/breakpoints.module.css'; +import useMediaQuery from './useMediaQuery'; + +const PhoneLayoutContext = createContext(false); + +export function PhoneLayoutProvider({children}) { + const phoneLayout = useMediaQuery(breakpoints['breakpoint-below-sm']) + + return ( + + {children} + + ); +} + +export function usePhoneLayout() { + return useContext(PhoneLayoutContext); +}