diff --git a/app/assets/stylesheets/pageflow/ui/input/color_input.scss b/app/assets/stylesheets/pageflow/ui/input/color_input.scss index f7849bd7ce..520b51e5b9 100644 --- a/app/assets/stylesheets/pageflow/ui/input/color_input.scss +++ b/app/assets/stylesheets/pageflow/ui/input/color_input.scss @@ -1,6 +1,5 @@ .color_input { .color_picker-field { - color: var(--placeholder-color, transparent); box-sizing: border-box; width: 100%; } diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 6b2e784a28..ee82ff23b3 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1208,12 +1208,13 @@ de: motif_area_info_text: Markiere den wichtigsten Teil des Hintergrunds, der beim Erreichen des Abschnitts sichtbar und nicht von anderen Elementen überdeckt sein soll. attributes: appearance: - inline_help: Diese Einstellung legt fest wie die scrollenden Vordergrund-Ebene vom Hintergrund abgehoben werden soll. Bei der Einstellung "Karte" wird der Vordergrund mit einer Fläche mit abgerundeten Ecken hinterlegt, bei "Schatten" mit einem hellen oder dunklen Farbverlauf. + inline_help: Legt fest, wie der Vordergrund vom Hintergrund abgehoben wird. "Schatten" fügt einen Farbverlauf hinter dem Text hinzu. "Karte" platziert den Inhalt auf einer abgerundeten Fläche. "Split" bedeckt die halbe Viewport-Breite mit einem Overlay für den Inhalt. label: Abblendung values: cards: Karte shadow: Schatten transparent: Keine + split: Split atmoAudioFileId: inline_help: Wähle eine Audio-Datei, die im Hintergrund abgespielt werden soll. Wenn diese Atmo auch in nachfolgenden Abschnitten nahtlos weiterspielen soll, wähle für diese Abschnitte die selbe Atmo aus. label: Atmo-Audio @@ -1272,8 +1273,19 @@ de: coverViewport: Viewport überdecken coverSection: Abschnitt überdecken cardSurfaceColor: + inline_help: Hintergrundfarbe der Karte. Verwende eine teiltransparente Farbe, um den Hintergrund durchscheinen zu lassen und Hintergrundunschärfe zu aktivieren. label: Kartenhintergrundfarbe auto: "(Automatisch)" + auto_color: "Automatische Farbe" + splitOverlayColor: + inline_help: Farbe des Overlays hinter dem Inhalt. + label: Overlay-Farbe + auto: "(Automatisch)" + auto_color: "Automatische Farbe" + overlayBackdropBlur: + inline_help: Stärke der Unschärfe, die auf den Hintergrund hinter dem Overlay angewendet wird. + label: Hintergrundunschärfe + inline_help_disabled: Nur verfügbar, wenn die Overlay-Farbe Transparenz hat. sectionPaddings: label: Innenabstände oben/unten tabs: diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index 2473ea8d90..08eb63adb2 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1192,12 +1192,13 @@ en: motif_area_info_text: Mark the most important part of the backdrop that should be visible and unobstructed when reaching the section. attributes: appearance: - inline_help: This setting controls how the scrolling foreground should get dimmed to increase contrast from the background. The option "Cards" adds a box with rounded corners to the foreground, with "Shadow" you can add a light or dark shadow to increase readability. + inline_help: Controls how the foreground is visually separated from the background. "Shadow" adds a gradient behind text. "Cards" places content on a rounded box. "Split" covers half the viewport width with an overlay for the content. label: Text-Background values: cards: Card shadow: Shadow transparent: Transparent + split: Split atmoAudioFileId: inline_help: Choose an audio file, that shall be played in the background. If you want this audio to continue playing on following sections, just choose the same file again there. label: Atmo Audio @@ -1256,8 +1257,19 @@ en: coverViewport: Cover viewport coverSection: Cover section cardSurfaceColor: + inline_help: Background color of the card. Use a translucent color to let the backdrop shine through and enable background blur. label: Cards background color auto: "(Auto)" + auto_color: "Auto color" + splitOverlayColor: + inline_help: Color of the overlay behind the content. + label: Overlay color + auto: "(Auto)" + auto_color: "Auto color" + overlayBackdropBlur: + inline_help: Amount of blur applied to the backdrop behind the overlay. + label: Background blur + inline_help_disabled: Only available when overlay color has transparency. sectionPaddings: label: "Top/bottom padding" tabs: diff --git a/entry_types/scrolled/package/spec/contentElements/heading/Heading-spec.js b/entry_types/scrolled/package/spec/contentElements/heading/Heading-spec.js new file mode 100644 index 0000000000..2f38bfae49 --- /dev/null +++ b/entry_types/scrolled/package/spec/contentElements/heading/Heading-spec.js @@ -0,0 +1,46 @@ +import React from 'react'; + +import {Heading} from 'contentElements/heading/Heading'; + +import {renderInContentElement} from 'pageflow-scrolled/testHelpers'; +import {contentElementWidths} from 'pageflow-scrolled/frontend'; + +import styles from 'contentElements/heading/Heading.module.css'; + +import '@testing-library/jest-dom/extend-expect'; + +describe('Heading', () => { + function renderHeading({configuration, width, sectionProps} = {}) { + return renderInContentElement( + , { + editorState: {isEditable: false} + } + ); + } + + it('centers heading with width lg when constrainContentWidth is set', () => { + const {container} = renderHeading({ + width: contentElementWidths.lg, + sectionProps: {constrainContentWidth: true} + }); + + expect(container.querySelector('header')).toHaveClass(styles.centerConstrained); + }); + + it('does not center heading with width xl when constrainContentWidth is set', () => { + const {container} = renderHeading({ + width: contentElementWidths.xl, + sectionProps: {constrainContentWidth: true} + }); + + expect(container.querySelector('header')).not.toHaveClass(styles.centerConstrained); + }); +}); diff --git a/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/getUsedSectionBackgroundColors-spec.js b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/getUsedSectionBackgroundColors-spec.js index 3a73a5b517..57fff4a030 100644 --- a/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/getUsedSectionBackgroundColors-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/getUsedSectionBackgroundColors-spec.js @@ -50,6 +50,29 @@ describe('ScrolledEntry', () => { expect(colors).toEqual(['#400', '#500', '#040']); }); + it('includes splitOverlayColor of split sections', () => { + const entry = factories.entry( + ScrolledEntry, + {}, + { + entryTypeSeed: normalizeSeed({ + sections: [ + { + configuration: { + appearance: 'split', + splitOverlayColor: '#600' + } + } + ] + }) + } + ); + + const colors = entry.getUsedSectionBackgroundColors(); + + expect(colors).toEqual(['#600']); + }); + it('ignores blank colors', () => { const entry = factories.entry( ScrolledEntry, diff --git a/entry_types/scrolled/package/spec/frontend/features/constrainContentWidth-spec.js b/entry_types/scrolled/package/spec/frontend/features/constrainContentWidth-spec.js new file mode 100644 index 0000000000..5399bed89b --- /dev/null +++ b/entry_types/scrolled/package/spec/frontend/features/constrainContentWidth-spec.js @@ -0,0 +1,105 @@ +import React from 'react'; +import {renderEntry, usePageObjects} from 'support/pageObjects'; +import '@testing-library/jest-dom/extend-expect'; + +import {api} from 'frontend/api'; + +import {useMotifAreaState} from 'frontend/v1/useMotifAreaState'; +jest.mock('frontend/v1/useMotifAreaState'); + +describe('constrainContentWidth', () => { + usePageObjects(); + + beforeEach(() => { + api.contentElementTypes.register('probe', { + component: function Probe({sectionProps}) { + return ( +
+ ); + } + }); + }); + + it('applies class for split appearance', () => { + const {getSectionByPermaId} = renderEntry({ + seed: { + sections: [{id: 5, permaId: 6, configuration: { + appearance: 'split' + }}], + contentElements: [{sectionId: 5}] + } + }); + + expect(getSectionByPermaId(6).hasConstrainedContentWidth()).toBe(true); + }); + + it('does not apply class for shadow appearance', () => { + const {getSectionByPermaId} = renderEntry({ + seed: { + sections: [{id: 5, permaId: 6, configuration: { + appearance: 'shadow' + }}], + contentElements: [{sectionId: 5}] + } + }); + + expect(getSectionByPermaId(6).hasConstrainedContentWidth()).toBe(false); + }); + + it('applies class for split appearance with center layout', () => { + const {getSectionByPermaId} = renderEntry({ + seed: { + sections: [{id: 5, permaId: 6, configuration: { + appearance: 'split', + layout: 'center' + }}], + contentElements: [{sectionId: 5, typeName: 'withTestId', + configuration: {testId: 1}}] + } + }); + + expect(getSectionByPermaId(6).hasConstrainedContentWidth()).toBe(true); + }); + + it('does not apply class when content is padded', () => { + useMotifAreaState.mockContentPadded(); + + const {getSectionByPermaId} = renderEntry({ + seed: { + sections: [{id: 5, permaId: 6, configuration: { + appearance: 'split' + }}], + contentElements: [{sectionId: 5}] + } + }); + + expect(getSectionByPermaId(6).hasConstrainedContentWidth()).toBe(false); + }); + + it('passes constrainContentWidth to content elements via sectionProps', () => { + const {getByTestId} = renderEntry({ + seed: { + sections: [{id: 5, permaId: 6, configuration: { + appearance: 'split' + }}], + contentElements: [{sectionId: 5, typeName: 'probe'}] + } + }); + + expect(getByTestId('probe')).toHaveAttribute('data-constrain-content-width', 'true'); + }); + + it('does not pass constrainContentWidth for shadow appearance', () => { + const {getByTestId} = renderEntry({ + seed: { + sections: [{id: 5, permaId: 6, configuration: { + appearance: 'shadow' + }}], + contentElements: [{sectionId: 5, typeName: 'probe'}] + } + }); + + expect(getByTestId('probe')).toHaveAttribute('data-constrain-content-width', 'false'); + }); +}); diff --git a/entry_types/scrolled/package/spec/frontend/features/sectionAppearanceScopeClass-spec.js b/entry_types/scrolled/package/spec/frontend/features/sectionAppearanceScopeClass-spec.js index b96917d3b1..fe7d2bfb94 100644 --- a/entry_types/scrolled/package/spec/frontend/features/sectionAppearanceScopeClass-spec.js +++ b/entry_types/scrolled/package/spec/frontend/features/sectionAppearanceScopeClass-spec.js @@ -34,4 +34,15 @@ describe('section appearance scope class', () => { expect(getSectionByPermaId(6).el).toHaveClass('scope-transparentAppearanceSection'); }); + + it('applies scope class for split appearance', () => { + const {getSectionByPermaId} = renderEntry({ + seed: { + sections: [{id: 5, permaId: 6, configuration: {appearance: 'split'}}], + contentElements: [{sectionId: 5}] + } + }); + + expect(getSectionByPermaId(6).el).toHaveClass('scope-splitAppearanceSection'); + }); }); diff --git a/entry_types/scrolled/package/spec/frontend/foregroundBoxes/CardBoxWrapper-spec.js b/entry_types/scrolled/package/spec/frontend/foregroundBoxes/CardBoxWrapper-spec.js index 98a400b64c..5e6ce49118 100644 --- a/entry_types/scrolled/package/spec/frontend/foregroundBoxes/CardBoxWrapper-spec.js +++ b/entry_types/scrolled/package/spec/frontend/foregroundBoxes/CardBoxWrapper-spec.js @@ -50,6 +50,84 @@ describe('CardBoxWrapper', () => { }); }); + describe('backdrop blur', () => { + it('applies blur class when overlayBackdropBlur is set and color is translucent', () => { + const {container} = render( + + Content + + ); + + expect(container.firstChild).toHaveClass(cardBoxStyles.blur); + }); + + it('does not apply blur class when color is opaque', () => { + const {container} = render( + + Content + + ); + + expect(container.firstChild).not.toHaveClass(cardBoxStyles.blur); + }); + + it('does not apply blur when everything is default', () => { + const {container} = render( + + Content + + ); + + expect(container.firstChild).not.toHaveClass(cardBoxStyles.blur); + }); + + it('applies blur class by default for translucent color', () => { + const {container} = render( + + Content + + ); + + expect(container.firstChild).toHaveClass(cardBoxStyles.blur); + }); + + it('sets backdrop blur CSS variable by default for translucent color', () => { + const {container} = render( + + Content + + ); + + expect(container.firstChild.style.getPropertyValue('--card-backdrop-blur')) + .toBe('blur(10px)'); + }); + + it('does not apply blur class when overlayBackdropBlur is 0', () => { + const {container} = render( + + Content + + ); + + expect(container.firstChild).not.toHaveClass(cardBoxStyles.blur); + }); + + it('sets backdrop blur CSS variable when color is translucent', () => { + const {container} = render( + + Content + + ); + + expect(container.firstChild.style.getPropertyValue('--card-backdrop-blur')) + .toBe('blur(5px)'); + }); + }); + describe('cardEnd padding', () => { it('does not have cardEndPadding class when lastMarginBottom is set', () => { const {container} = render( diff --git a/entry_types/scrolled/package/spec/frontend/foregroundBoxes/SplitBox-spec.js b/entry_types/scrolled/package/spec/frontend/foregroundBoxes/SplitBox-spec.js new file mode 100644 index 0000000000..4c6b2232f5 --- /dev/null +++ b/entry_types/scrolled/package/spec/frontend/foregroundBoxes/SplitBox-spec.js @@ -0,0 +1,127 @@ +import React from 'react'; +import {render} from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; + +import SplitBox from 'frontend/foregroundBoxes/SplitBox'; +import styles from 'frontend/foregroundBoxes/SplitBox.module.css'; + +describe('SplitBox', () => { + it('renders children', () => { + const {getByTestId} = render( + +
+ + ); + + expect(getByTestId('child')).not.toBeNull(); + }); + + it('applies paddingTop from motifAreaState', () => { + const {container} = render( + +
+ + ); + + expect(container.firstChild).toHaveStyle({paddingTop: '100px'}); + }); + + it('renders overlay when isContentPadded is true', () => { + const {container} = render( + +
+ + ); + + expect(container.querySelector(`.${styles.overlay}`)).not.toBeNull(); + }); + + it('does not render overlay when isContentPadded is false', () => { + const {container} = render( + +
+ + ); + + expect(container.querySelector(`.${styles.overlay}`)).toBeNull(); + }); + + it('sets overlay top from paddingTop', () => { + const {container} = render( + +
+ + ); + + expect(container.querySelector(`.${styles.overlay}`)) + .toHaveStyle({top: '200px'}); + }); + + it('applies dark overlay class when not inverted', () => { + const {container} = render( + +
+ + ); + + expect(container.querySelector(`.${styles.overlay}`)) + .toHaveClass(styles.overlayDark); + }); + + it('applies light overlay class when inverted', () => { + const {container} = render( + +
+ + ); + + expect(container.querySelector(`.${styles.overlay}`)) + .toHaveClass(styles.overlayLight); + }); + + it('applies backdrop filter when overlayBackdropBlur is set and color is translucent', () => { + const {container} = render( + +
+ + ); + + expect(container.querySelector(`.${styles.overlay}`).style.backdropFilter) + .toBe('blur(5px)'); + }); + + it('does not apply backdrop filter when color is opaque', () => { + const {container} = render( + +
+ + ); + + expect(container.querySelector(`.${styles.overlay}`).style.backdropFilter) + .toBeFalsy(); + }); + + it('sets overlay background color from splitOverlayColor', () => { + const {container} = render( + +
+ + ); + + expect(container.querySelector(`.${styles.overlay}`)) + .toHaveStyle({backgroundColor: '#ff000080'}); + }); +}); diff --git a/entry_types/scrolled/package/spec/frontend/shadows/SplitShadow-spec.js b/entry_types/scrolled/package/spec/frontend/shadows/SplitShadow-spec.js new file mode 100644 index 0000000000..c6c3b6717b --- /dev/null +++ b/entry_types/scrolled/package/spec/frontend/shadows/SplitShadow-spec.js @@ -0,0 +1,156 @@ +import React from 'react'; +import {render} from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; + +import SplitShadow from 'frontend/shadows/SplitShadow'; +import styles from 'frontend/shadows/SplitShadow.module.css'; + +describe('SplitShadow', () => { + const defaultProps = { + align: 'left', + inverted: false, + motifAreaState: {isContentPadded: false} + }; + + it('renders overlay and children', () => { + const {container} = render( + +
+ + ); + + expect(container.querySelector(`.${styles.overlay}`)).not.toBeNull(); + expect(container.querySelector('[data-testid="child"]')).not.toBeNull(); + }); + + it('applies left alignment class', () => { + const {container} = render( + +
+ + ); + + expect(container.firstChild).toHaveClass(styles['align-left']); + }); + + it('applies right alignment class', () => { + const {container} = render( + +
+ + ); + + expect(container.firstChild).toHaveClass(styles['align-right']); + }); + + it('applies center alignment class', () => { + const {container} = render( + +
+ + ); + + expect(container.firstChild).toHaveClass(styles['align-center']); + }); + + it('applies dark class when not inverted', () => { + const {container} = render( + +
+ + ); + + expect(container.firstChild).toHaveClass(styles.dark); + }); + + it('applies light class when inverted', () => { + const {container} = render( + +
+ + ); + + expect(container.firstChild).toHaveClass(styles.light); + }); + + it('sets background color from splitOverlayColor prop', () => { + const {container} = render( + +
+ + ); + + expect(container.querySelector(`.${styles.overlay}`)) + .toHaveStyle({backgroundColor: '#ff000080'}); + }); + + it('does not set inline background color when no splitOverlayColor', () => { + const {container} = render( + +
+ + ); + + expect(container.querySelector(`.${styles.overlay}`).style.backgroundColor) + .toBe(''); + }); + + it('applies backdrop filter when overlayBackdropBlur is set and color is translucent', () => { + const {container} = render( + +
+ + ); + + expect(container.querySelector(`.${styles.overlay}`).style.backdropFilter) + .toBe('blur(5px)'); + }); + + it('does not apply backdrop filter when color is opaque', () => { + const {container} = render( + +
+ + ); + + expect(container.querySelector(`.${styles.overlay}`).style.backdropFilter) + .toBeFalsy(); + }); + + it('applies default backdrop filter when no color is set', () => { + const {container} = render( + +
+ + ); + + expect(container.querySelector(`.${styles.overlay}`).style.backdropFilter) + .toBe('blur(10px)'); + }); + + it('does not render overlay when isContentPadded is true', () => { + const {container} = render( + +
+ + ); + + expect(container.querySelector(`.${styles.overlay}`)).toBeNull(); + }); + + it('renders children when isContentPadded is true', () => { + const {container} = render( + +
+ + ); + + expect(container.querySelector('[data-testid="child"]')).not.toBeNull(); + }); +}); diff --git a/entry_types/scrolled/package/spec/frontend/splitOverlayStyle-spec.js b/entry_types/scrolled/package/spec/frontend/splitOverlayStyle-spec.js new file mode 100644 index 0000000000..a2489e9a07 --- /dev/null +++ b/entry_types/scrolled/package/spec/frontend/splitOverlayStyle-spec.js @@ -0,0 +1,43 @@ +import {splitOverlayStyle} from 'frontend/splitOverlayStyle'; + +describe('splitOverlayStyle', () => { + it('sets backgroundColor from color', () => { + expect(splitOverlayStyle({color: '#ff0000'})) + .toEqual({backgroundColor: '#ff0000'}); + }); + + it('sets backdropFilter when color is translucent and backdropBlur is set', () => { + expect(splitOverlayStyle({color: '#ff000080', backdropBlur: 50})) + .toEqual({backgroundColor: '#ff000080', backdropFilter: 'blur(5px)'}); + }); + + it('does not set backdropFilter when color is opaque', () => { + expect(splitOverlayStyle({color: '#ff0000', backdropBlur: 50})) + .toEqual({backgroundColor: '#ff0000'}); + }); + + it('does not set backdropFilter when backdropBlur is 0', () => { + expect(splitOverlayStyle({color: '#ff000080', backdropBlur: 0})) + .toEqual({backgroundColor: '#ff000080'}); + }); + + it('scales blur value to max 10px', () => { + expect(splitOverlayStyle({color: '#ff000080', backdropBlur: 100})) + .toEqual({backgroundColor: '#ff000080', backdropFilter: 'blur(10px)'}); + }); + + it('defaults backdropFilter for translucent color', () => { + expect(splitOverlayStyle({color: '#ff000080'})) + .toEqual({backgroundColor: '#ff000080', backdropFilter: 'blur(10px)'}); + }); + + it('defaults backdropFilter when no color is set', () => { + expect(splitOverlayStyle({})) + .toEqual({backdropFilter: 'blur(10px)'}); + }); + + it('does not default backdropFilter for opaque color', () => { + expect(splitOverlayStyle({color: '#ff0000'})) + .toEqual({backgroundColor: '#ff0000'}); + }); +}); diff --git a/entry_types/scrolled/package/spec/frontend/utils/isTranslucentColor-spec.js b/entry_types/scrolled/package/spec/frontend/utils/isTranslucentColor-spec.js new file mode 100644 index 0000000000..8f29138f79 --- /dev/null +++ b/entry_types/scrolled/package/spec/frontend/utils/isTranslucentColor-spec.js @@ -0,0 +1,23 @@ +import {isTranslucentColor} from 'frontend/utils/isTranslucentColor'; + +describe('isTranslucentColor', () => { + it('returns true for color with alpha channel', () => { + expect(isTranslucentColor('#ff000080')).toBe(true); + }); + + it('returns false for color with ff alpha', () => { + expect(isTranslucentColor('#ff0000ff')).toBe(false); + }); + + it('returns false for color without alpha', () => { + expect(isTranslucentColor('#ff0000')).toBe(false); + }); + + it('returns false for undefined', () => { + expect(isTranslucentColor(undefined)).toBe(false); + }); + + it('returns false for null', () => { + expect(isTranslucentColor(null)).toBe(false); + }); +}); diff --git a/entry_types/scrolled/package/spec/support/pageObjects.js b/entry_types/scrolled/package/spec/support/pageObjects.js index eda89bbd36..575b740e97 100644 --- a/entry_types/scrolled/package/spec/support/pageObjects.js +++ b/entry_types/scrolled/package/spec/support/pageObjects.js @@ -242,6 +242,11 @@ function createSectionPageObject(el) { hasFirstBoxSuppressedTopMargin() { const firstBox = foreground.querySelector(`.${boxBoundaryMarginStyles.noTopMargin}`); return !!firstBox; + }, + + hasConstrainedContentWidth() { + return !!(el.querySelector(`.${twoColumnLayoutStyles.constrainContentWidth}`) || + el.querySelector(`.${centerLayoutStyles.constrainContentWidth}`)); } } } diff --git a/entry_types/scrolled/package/src/contentElements/heading/Heading.js b/entry_types/scrolled/package/src/contentElements/heading/Heading.js index 8d94b6d8ff..03c16343e1 100644 --- a/entry_types/scrolled/package/src/contentElements/heading/Heading.js +++ b/entry_types/scrolled/package/src/contentElements/heading/Heading.js @@ -100,6 +100,9 @@ export function Heading({configuration, sectionProps, contentElementWidth}) { {[styles[sectionProps.layout]]: contentElementWidth > contentElementWidths.md || sectionProps.layout === 'centerRagged'}, + {[styles.centerConstrained]: + sectionProps.constrainContentWidth && + contentElementWidth === contentElementWidths.lg}, {[withShadowClassName]: !sectionProps.invert})}> {renderSubtitle('tagline')} invert ? '#101010' : '#ffffff', - swatches: entry.getUsedSectionBackgroundColors() - }); - } + this.input('cardSurfaceColor', ColorInputView, { + visibleBinding: 'appearance', + visibleBindingValue: 'cards', + alpha: true, + placeholder: I18n.t('pageflow_scrolled.editor.edit_section.attributes.cardSurfaceColor.auto'), + placeholderColorBinding: 'invert', + placeholderColor: invert => invert ? '#101010' : '#ffffff', + placeholderColorDescription: I18n.t('pageflow_scrolled.editor.edit_section.attributes.cardSurfaceColor.auto_color'), + swatches: entry.getUsedSectionBackgroundColors() + }); + + this.input('splitOverlayColor', ColorInputView, { + visibleBinding: 'appearance', + visibleBindingValue: 'split', + alpha: true, + placeholder: I18n.t('pageflow_scrolled.editor.edit_section.attributes.splitOverlayColor.auto'), + placeholderColorBinding: 'invert', + placeholderColor: invert => invert ? '#ffffffb3' : '#000000b3', + placeholderColorDescription: I18n.t('pageflow_scrolled.editor.edit_section.attributes.splitOverlayColor.auto_color'), + swatches: entry.getUsedSectionBackgroundColors() + }); + + this.input('overlayBackdropBlur', SliderInputView, { + visibleBinding: 'appearance', + visible: appearance => appearance === 'split' || appearance === 'cards', + disabledBinding: ['appearance', 'splitOverlayColor', 'cardSurfaceColor'], + disabled: ([appearance, splitOverlayColor, cardSurfaceColor]) => + appearance === 'split' + ? splitOverlayColor && !utils.isTranslucentColor(splitOverlayColor) + : !utils.isTranslucentColor(cardSurfaceColor), + values: [0, 25, 50, 75, 100], + defaultValue: 100, + saveOnSlide: true + }); this.view(SeparatorView); diff --git a/entry_types/scrolled/package/src/editor/views/inputs/AppearanceSelectInputView.js b/entry_types/scrolled/package/src/editor/views/inputs/AppearanceSelectInputView.js new file mode 100644 index 0000000000..e15ca45e74 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/inputs/AppearanceSelectInputView.js @@ -0,0 +1,30 @@ +import React from 'react'; + +import {ListboxInputView} from './ListboxInputView'; +import {SectionVisualization} from './SectionVisualization'; + +export const AppearanceSelectInputView = ListboxInputView.extend({ + modelEvents() { + return { + ...ListboxInputView.prototype.modelEvents.call(this), + 'change:layout': 'renderDropdown', + 'change:invert': 'renderDropdown', + 'change:exposeMotifArea': 'renderDropdown' + }; + }, + + renderItem(item) { + const layout = this.model.get('layout') || 'left'; + const isCenter = layout === 'center' || layout === 'centerRagged'; + + return ( +
+ + {item.text} +
+ ); + } +}); diff --git a/entry_types/scrolled/package/src/editor/views/inputs/LayoutSelectInputView.js b/entry_types/scrolled/package/src/editor/views/inputs/LayoutSelectInputView.js new file mode 100644 index 0000000000..d8e38be8c2 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/inputs/LayoutSelectInputView.js @@ -0,0 +1,30 @@ +import React from 'react'; + +import {ListboxInputView} from './ListboxInputView'; +import {SectionVisualization} from './SectionVisualization'; + +export const LayoutSelectInputView = ListboxInputView.extend({ + modelEvents() { + return { + ...ListboxInputView.prototype.modelEvents.call(this), + 'change:appearance': 'renderDropdown', + 'change:invert': 'renderDropdown', + 'change:exposeMotifArea': 'renderDropdown' + }; + }, + + renderItem(item) { + const appearance = this.model.get('appearance') || 'shadow'; + const isCenter = item.value === 'center' || item.value === 'centerRagged'; + + return ( +
+ + {item.text} +
+ ); + } +}); diff --git a/entry_types/scrolled/package/src/editor/views/inputs/SectionVisualization.js b/entry_types/scrolled/package/src/editor/views/inputs/SectionVisualization.js new file mode 100644 index 0000000000..734bfc53f6 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/inputs/SectionVisualization.js @@ -0,0 +1,43 @@ +import React from 'react'; +import classNames from 'classnames'; + +import styles from './SectionVisualization.module.css'; + +export function SectionVisualization({layout, appearance, invert, padded}) { + const isCards = appearance === 'cards'; + + return ( +
+ +
+
+
+
+
+
+
+
+
+
+
+
+ ); +} + +function Overlay({appearance, invert, padded}) { + switch (appearance) { + case 'shadow': + return
; + case 'cards': + return
; + case 'split': + return
; + default: + return null; + } +} diff --git a/entry_types/scrolled/package/src/editor/views/inputs/SectionVisualization.module.css b/entry_types/scrolled/package/src/editor/views/inputs/SectionVisualization.module.css new file mode 100644 index 0000000000..2869525030 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/inputs/SectionVisualization.module.css @@ -0,0 +1,215 @@ +.preview { + --content-width: 35%; + aspect-ratio: 16 / 9; + border: solid 1px var(--ui-on-surface-color-lightest); + border-radius: rounded(sm); + margin-bottom: space(1); + background-color: var(--ui-primary-color-light); + color: #ddd; + padding: space(8) 0; + overflow: hidden; + position: relative; + max-width: 260px; + box-sizing: border-box; + margin-left: auto; + margin-right: auto; +} + +.inverted { + color: #222; +} + +.center, +.centerRagged { + --content-width: 45%; +} + +.padded { + padding-top: space(16); +} + +/* Content text area */ + +.content { + position: relative; + z-index: 1; + width: var(--content-width); + margin-left: space(8); +} + +.right .content { + margin-left: auto; + margin-right: space(8); +} + +.center .content, +.centerRagged .content { + margin-left: auto; + margin-right: auto; +} + +.line { + height: 2px; + background: currentColor; + border-radius: 1px; + margin-bottom: space(2.5); +} + +.shortLine { + width: 60%; +} + +.centerRagged .shortLine { + margin-left: auto; + margin-right: auto; +} + +.gap { + height: space(3); +} + +/* Shadow appearance: gradient overlay on content side */ + +.shadowOverlay { + position: absolute; + top: 0; + bottom: 0; + width: 70%; +} + +.left .shadowOverlay { + left: 0; + background: linear-gradient(to right, rgba(0, 0, 0, 0.55), transparent); +} + +.right .shadowOverlay { + right: 0; + background: linear-gradient(to left, rgba(0, 0, 0, 0.55), transparent); +} + +.center .shadowOverlay, +.centerRagged .shadowOverlay { + left: 0; + width: 100%; + background: rgba(0, 0, 0, 0.35); +} + +.center .shadowOverlay.padded, +.centerRagged .shadowOverlay.padded { + background: linear-gradient(to top, rgba(0, 0, 0, 0.55), transparent); +} + +.left .shadowOverlay.light { + background: linear-gradient(to right, rgba(255, 255, 255, 0.55), transparent); +} + +.right .shadowOverlay.light { + background: linear-gradient(to left, rgba(255, 255, 255, 0.55), transparent); +} + +.center .shadowOverlay.light, +.centerRagged .shadowOverlay.light { + background: rgba(255, 255, 255, 0.35); +} + +.center .shadowOverlay.light.padded, +.centerRagged .shadowOverlay.light.padded { + background: linear-gradient(to top, rgba(255, 255, 255, 0.55), transparent); +} + +.cards .content { + width: calc(var(--content-width) - space(4)); + margin-left: calc(space(8) + space(2)); +} + +.cards.right .content { + margin-left: auto; + margin-right: calc(space(8) + space(2)); +} + +.cards.center .content, +.cards.centerRagged .content { + margin-left: auto; + margin-right: auto; +} + +/* Cards appearance: box behind content */ + +.cardBox { + position: absolute; + top: space(4); + bottom: 0; + width: var(--content-width); + background: rgba(255, 255, 255, 0.85); + border-radius: calc(var(--theme-cards-border-radius, 15px) / 3) + calc(var(--theme-cards-border-radius, 15px) / 3) + 0 0; +} + +.cardBox.dark { + background: rgba(0, 0, 0, 0.85); +} + +.left .cardBox { + left: space(8); +} + +.right .cardBox { + right: space(8); +} + +.center .cardBox, +.centerRagged .cardBox { + left: 50%; + transform: translateX(-50%); +} + +.cardBox.padded { + top: calc(space(8) + space(4)); +} + +/* Split appearance: 50% overlay with reduced content width */ + +.split { + --content-width: calc(50% - space(8) * 2); +} + +.split.center, +.split.centerRagged { + --content-width: calc(50% - space(8) * 2); +} + +.splitOverlay { + position: absolute; + top: 0; + bottom: 0; + width: 50%; + background: rgba(0, 0, 0, 0.55); +} + +.splitOverlay.light { + background: rgba(255, 255, 255, 0.55); +} + +.left .splitOverlay { + left: 0; +} + +.right .splitOverlay { + right: 0; +} + +.center .splitOverlay, +.centerRagged .splitOverlay { + left: 50%; + transform: translateX(-50%); +} + +.center .splitOverlay.padded, +.centerRagged .splitOverlay.padded { + left: 0; + right: 0; + width: auto; + transform: none; + top: calc(space(8) + space(4)); +} diff --git a/entry_types/scrolled/package/src/frontend/Section.js b/entry_types/scrolled/package/src/frontend/Section.js index 81d48d81da..6a2caaaa16 100644 --- a/entry_types/scrolled/package/src/frontend/Section.js +++ b/entry_types/scrolled/package/src/frontend/Section.js @@ -114,12 +114,6 @@ function SectionContents({ const {shouldPrepare} = useSectionLifecycle(); - const sectionProperties = useMemo(() => ({ - layout: section.layout, - invert: section.invert, - sectionIndex: section.sectionIndex - }), [section.layout, section.invert, section.sectionIndex]); - const [, exitTransition] = transitions; const [motifAreaState, setMotifAreaRef, setContentAreaRef] = useMotifAreaState({ @@ -131,6 +125,15 @@ function SectionContents({ fullHeight: section.fullHeight }); + const sectionProperties = useMemo(() => ({ + layout: section.layout, + invert: section.invert, + sectionIndex: section.sectionIndex, + constrainContentWidth: section.appearance === 'split' && + !motifAreaState.isContentPadded + }), [section.layout, section.invert, section.sectionIndex, + section.appearance, motifAreaState.isContentPadded]); + const {Shadow, Box, BoxWrapper} = getAppearanceComponents(section.appearance) const staticShadowOpacity = percentToFraction(section.staticShadowOpacity, {defaultValue: 0.7}); @@ -152,7 +155,9 @@ function SectionContents({ inverted={section.invert} motifAreaState={motifAreaState} staticShadowOpacity={staticShadowOpacity} - dynamicShadowOpacity={dynamicShadowOpacity}> + dynamicShadowOpacity={dynamicShadowOpacity} + splitOverlayColor={section.splitOverlayColor} + overlayBackdropBlur={section.overlayBackdropBlur}> {children} } @@ -170,16 +175,20 @@ function SectionContents({ transitionStyles={transitionStyles} state={state} motifAreaState={motifAreaState} - staticShadowOpacity={staticShadowOpacity}> + staticShadowOpacity={staticShadowOpacity} + splitOverlayColor={section.splitOverlayColor} + overlayBackdropBlur={section.overlayBackdropBlur}> {(children, boxProps) => {children} } diff --git a/entry_types/scrolled/package/src/frontend/__stories__/appearance-stories.js b/entry_types/scrolled/package/src/frontend/__stories__/appearance-stories.js index 6958b876ba..7b70d38427 100644 --- a/entry_types/scrolled/package/src/frontend/__stories__/appearance-stories.js +++ b/entry_types/scrolled/package/src/frontend/__stories__/appearance-stories.js @@ -15,7 +15,7 @@ import { import {storiesOf} from '@storybook/react'; -const appearanceOptions = ['shadow', 'transparent', 'cards']; +const appearanceOptions = ['shadow', 'transparent', 'cards', 'split']; function positionOptions({layout, includeWidths}) { const result = [{position: 'inline'}]; diff --git a/entry_types/scrolled/package/src/frontend/appearance.js b/entry_types/scrolled/package/src/frontend/appearance.js index 07de98e68c..7a75d4625f 100644 --- a/entry_types/scrolled/package/src/frontend/appearance.js +++ b/entry_types/scrolled/package/src/frontend/appearance.js @@ -1,10 +1,12 @@ import NoOpShadow from './shadows/NoOpShadow'; import GradientShadow from './shadows/GradientShadow'; +import SplitShadow from './shadows/SplitShadow'; import {InvisibleBoxWrapper} from './foregroundBoxes/InvisibleBoxWrapper'; import GradientBox from './foregroundBoxes/GradientBox'; import CardBox from "./foregroundBoxes/CardBox"; import CardBoxWrapper from "./foregroundBoxes/CardBoxWrapper"; +import SplitBox from "./foregroundBoxes/SplitBox"; const components = { shadow: { @@ -21,13 +23,19 @@ const components = { Shadow: NoOpShadow, Box: CardBox, BoxWrapper: CardBoxWrapper + }, + split: { + Shadow: SplitShadow, + Box: SplitBox, + BoxWrapper: InvisibleBoxWrapper } }; const sectionScopeNames = { shadow: 'shadowAppearanceSection', transparent: 'transparentAppearanceSection', - cards: 'cardsAppearanceSection' + cards: 'cardsAppearanceSection', + split: 'splitAppearanceSection' }; export function getAppearanceComponents(appearance) { diff --git a/entry_types/scrolled/package/src/frontend/foregroundBoxes/CardBoxWrapper.js b/entry_types/scrolled/package/src/frontend/foregroundBoxes/CardBoxWrapper.js index 85d5bdfd87..ace4a857cb 100644 --- a/entry_types/scrolled/package/src/frontend/foregroundBoxes/CardBoxWrapper.js +++ b/entry_types/scrolled/package/src/frontend/foregroundBoxes/CardBoxWrapper.js @@ -4,6 +4,7 @@ import classNames from 'classnames'; import {widths} from '../layouts'; import {BackgroundColorProvider} from '../backgroundColor'; import {TrimDefaultMarginTop} from '../TrimDefaultMarginTop'; +import {isTranslucentColor} from '../utils/isTranslucentColor'; import styles from "./CardBoxWrapper.module.css"; import boundaryMarginStyles from "./BoxBoundaryMargin.module.css"; @@ -19,7 +20,7 @@ export default function CardBoxWrapper(props) { return (
+ style={cardStyle(props)}> {props.children} @@ -33,11 +34,30 @@ function outsideBox(props) { props.customMargin } +function cardStyle(props) { + const style = {'--card-surface-color': props.cardSurfaceColor}; + const blur = resolvedBackdropBlur(props); + + if (blur > 0) { + style['--card-backdrop-blur'] = `blur(${blur / 100 * 10}px)`; + } + + return style; +} + +function resolvedBackdropBlur(props) { + if (!isTranslucentColor(props.cardSurfaceColor)) { + return 0; + } + + return props.overlayBackdropBlur ?? 100; +} + function className(props) { return classNames( styles.card, props.inverted ? styles.cardBgBlack : styles.cardBgWhite, - {[styles.blur]: props.cardSurfaceTransparency > 0}, + {[styles.blur]: resolvedBackdropBlur(props) > 0}, {[styles.cardStart]: !props.openStart}, {[styles.cardEnd]: !props.openEnd}, {[styles.cardEndPadding]: !props.openEnd && !props.lastMarginBottom}, diff --git a/entry_types/scrolled/package/src/frontend/foregroundBoxes/CardBoxWrapper.module.css b/entry_types/scrolled/package/src/frontend/foregroundBoxes/CardBoxWrapper.module.css index 2ef8b596b3..bb9060a4dd 100644 --- a/entry_types/scrolled/package/src/frontend/foregroundBoxes/CardBoxWrapper.module.css +++ b/entry_types/scrolled/package/src/frontend/foregroundBoxes/CardBoxWrapper.module.css @@ -54,6 +54,10 @@ composes: scope-lightContent from global; } +.blur::before { + backdrop-filter: var(--card-backdrop-blur); +} + @media screen { .cardBgWhite::before { background-color: var(--card-surface-color, lightContentSurfaceColor); diff --git a/entry_types/scrolled/package/src/frontend/foregroundBoxes/SplitBox.js b/entry_types/scrolled/package/src/frontend/foregroundBoxes/SplitBox.js new file mode 100644 index 0000000000..9d28c6b9bf --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/foregroundBoxes/SplitBox.js @@ -0,0 +1,25 @@ +import React from 'react'; +import classNames from 'classnames'; + +import {splitOverlayStyle} from '../splitOverlayStyle'; +import styles from './SplitBox.module.css'; + +export default function SplitBox(props) { + return ( +
+ {props.motifAreaState.isContentPadded && +
} +
+ {props.children} +
+
+ ); +} diff --git a/entry_types/scrolled/package/src/frontend/foregroundBoxes/SplitBox.module.css b/entry_types/scrolled/package/src/frontend/foregroundBoxes/SplitBox.module.css new file mode 100644 index 0000000000..9a5e498688 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/foregroundBoxes/SplitBox.module.css @@ -0,0 +1,39 @@ +.wrapper {} + +.content { + position: relative; + pointer-events: auto; +} + +.overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; +} + +.overlayDark { + composes: overlay; + background-color: rgba(0, 0, 0, 0.7); +} + +.overlayLight { + composes: overlay; + background-color: rgba(255, 255, 255, 0.7); +} + +.long { + bottom: -100vh; +} + +@media print { + .wrapper { + padding-top: 0 !important; + } + + .overlay { + display: none; + } +} diff --git a/entry_types/scrolled/package/src/frontend/layouts/Center.js b/entry_types/scrolled/package/src/frontend/layouts/Center.js index 7a07fae4a0..d346d4caa7 100644 --- a/entry_types/scrolled/package/src/frontend/layouts/Center.js +++ b/entry_types/scrolled/package/src/frontend/layouts/Center.js @@ -14,7 +14,8 @@ export function Center(props) { const groups = groupItems(props.items); return ( -
+
{groups.map((group, groupIndex) => { diff --git a/entry_types/scrolled/package/src/frontend/layouts/Center.module.css b/entry_types/scrolled/package/src/frontend/layouts/Center.module.css index 72a2b8fa97..9755350fa3 100644 --- a/entry_types/scrolled/package/src/frontend/layouts/Center.module.css +++ b/entry_types/scrolled/package/src/frontend/layouts/Center.module.css @@ -1,3 +1,5 @@ +@import 'pageflow-scrolled/values/properties.css'; + .outer { --layout-inline-content-max-width: var(--centered-inline-content-max-width, 700px); --layout-inline-lg-content-max-width: var(--centered-inline-lg-content-max-width, 950px); @@ -19,7 +21,8 @@ } .box { - --content-max-width: var(--layout-inline-content-max-width); + --content-max-width: min(var(--layout-inline-content-max-width), + var(--constrained-content-max-width)); margin-left: auto; margin-right: auto; max-width: var(--content-max-width); @@ -30,7 +33,8 @@ } .box-lg { - --content-max-width: var(--layout-inline-lg-content-max-width); + --content-max-width: min(var(--layout-inline-lg-content-max-width), + var(--constrained-content-max-width)); } .box-xl { @@ -43,6 +47,22 @@ margin-right: 0; } +@media screen and (min-width: 950px) { + .constrainContentWidth { + container-type: inline-size; + --content-margin-fraction-override: calc(var(--content-margin-fraction) / 2); + } + + .constrainContentWidth > .outer { + --constrained-content-max-width: calc(50cqw - 100cqw * var(--content-margin-fraction) * 2); + } + + .constrainContentWidth > .outer-lg { + --content-margin-fraction: var(--content-margin-fraction-override); + --content-margin: calc(var(--content-margin-fraction) * 100cqw); + } +} + .clear { clear: both !important; } diff --git a/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.js b/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.js index e35750218b..98b8be8242 100644 --- a/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.js +++ b/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.js @@ -13,7 +13,8 @@ export function TwoColumn(props) { const shouldInline = useShouldInlineSticky(); return ( -
+
.group { + --constrained-content-max-width: calc(50cqw - 100cqw * var(--content-margin-fraction) * 2); + } + + .constrainContentWidth > .group-lg { + --content-margin-fraction: var(--content-margin-fraction-override); + --content-margin: calc(var(--content-margin-fraction) * 100cqw); + + max-width: min(calc(var(--section-max-width, 100%) + var(--content-margin) * 2), + calc(50% + var(--layout-inline-lg-content-max-width))); + } +} + .restrict-xxs, .restrict-xs, .restrict-sm { @@ -99,6 +123,18 @@ width: var(--content-width); } +.constrainContentWidth .side { + position: relative; + --side-offset: calc((min(var(--layout-inline-content-max-width), var(--constrained-content-max-width)) - + var(--content-max-width)) / 2); + right: var(--side-offset); +} + +.constrainContentWidth.right .side { + right: auto; + left: var(--side-offset); +} + .sticky { composes: side; position: sticky; diff --git a/entry_types/scrolled/package/src/frontend/layouts/index.js b/entry_types/scrolled/package/src/frontend/layouts/index.js index 2df9f09545..bedae7a117 100644 --- a/entry_types/scrolled/package/src/frontend/layouts/index.js +++ b/entry_types/scrolled/package/src/frontend/layouts/index.js @@ -15,7 +15,8 @@ export const Layout = React.memo( prevProps.appearance === nextProps.appearance && prevProps.contentAreaRef === nextProps.contentAreaRef && prevProps.sectionProps === nextProps.sectionProps && - prevProps.isContentPadded === nextProps.isContentPadded + prevProps.isContentPadded === nextProps.isContentPadded && + prevProps.constrainContentWidth === nextProps.constrainContentWidth ) ); diff --git a/entry_types/scrolled/package/src/frontend/shadows/SplitShadow.js b/entry_types/scrolled/package/src/frontend/shadows/SplitShadow.js new file mode 100644 index 0000000000..584e2f5d86 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/shadows/SplitShadow.js @@ -0,0 +1,31 @@ +import React from 'react'; +import classNames from 'classnames'; + +import Fullscreen from '../Fullscreen'; +import {splitOverlayStyle} from '../splitOverlayStyle'; +import styles from './SplitShadow.module.css'; + +export default function SplitShadow(props) { + if (props.motifAreaState.isContentPadded) { + return ( +
+ {props.children} +
+ ); + } + + return ( +
+
+ +
+ {props.children} +
+ ); +} diff --git a/entry_types/scrolled/package/src/frontend/shadows/SplitShadow.module.css b/entry_types/scrolled/package/src/frontend/shadows/SplitShadow.module.css new file mode 100644 index 0000000000..1a48e4a674 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/shadows/SplitShadow.module.css @@ -0,0 +1,48 @@ +.wrapper { + position: relative; +} + +.overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 1; + pointer-events: none; +} + +@media screen and (min-width: 950px) { + .overlay { + width: 50%; + } +} + +.align-left .overlay { + left: 0; +} + +.align-right .overlay { + left: auto; + right: 0; +} + +.align-center .overlay, +.align-centerRagged .overlay { + left: 50%; + transform: translateX(-50%); +} + +.dark .overlay { + background-color: rgba(0, 0, 0, 0.7); +} + +.light .overlay { + background-color: rgba(255, 255, 255, 0.7); +} + +@media print { + .overlay { + display: none; + } +} diff --git a/entry_types/scrolled/package/src/frontend/splitOverlayStyle.js b/entry_types/scrolled/package/src/frontend/splitOverlayStyle.js new file mode 100644 index 0000000000..fbe318b6f2 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/splitOverlayStyle.js @@ -0,0 +1,25 @@ +import {isTranslucentColor} from './utils/isTranslucentColor'; + +export function splitOverlayStyle({color, backdropBlur}) { + const style = {}; + + if (color) { + style.backgroundColor = color; + } + + const blur = resolvedBackdropBlur({color, backdropBlur}); + + if (blur > 0) { + style.backdropFilter = `blur(${blur / 100 * 10}px)`; + } + + return style; +} + +function resolvedBackdropBlur({color, backdropBlur}) { + if (color && !isTranslucentColor(color)) { + return 0; + } + + return backdropBlur ?? 100; +} diff --git a/entry_types/scrolled/package/src/frontend/utils/index.js b/entry_types/scrolled/package/src/frontend/utils/index.js index 9bbd383aaf..9051e502fb 100644 --- a/entry_types/scrolled/package/src/frontend/utils/index.js +++ b/entry_types/scrolled/package/src/frontend/utils/index.js @@ -5,11 +5,13 @@ import { isBlankEditableTextValue, presence, } from './blank'; +import {isTranslucentColor} from './isTranslucentColor'; export const utils = { capitalize, camelize, isBlank, isBlankEditableTextValue, + isTranslucentColor, presence } diff --git a/entry_types/scrolled/package/src/frontend/utils/isTranslucentColor.js b/entry_types/scrolled/package/src/frontend/utils/isTranslucentColor.js new file mode 100644 index 0000000000..46ff97d638 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/utils/isTranslucentColor.js @@ -0,0 +1,3 @@ +export function isTranslucentColor(color) { + return !!color && color.length > 7 && color.slice(-2).toLowerCase() !== 'ff'; +} diff --git a/entry_types/scrolled/package/values/properties.css b/entry_types/scrolled/package/values/properties.css new file mode 100644 index 0000000000..abe516401e --- /dev/null +++ b/entry_types/scrolled/package/values/properties.css @@ -0,0 +1,5 @@ +@property --constrained-content-max-width { + syntax: ""; + inherits: true; + initial-value: 9999px; +} diff --git a/package/spec/ui/views/ColorPicker-spec.js b/package/spec/ui/views/ColorPicker-spec.js index e4af63d03b..0c7b867bbc 100644 --- a/package/spec/ui/views/ColorPicker-spec.js +++ b/package/spec/ui/views/ColorPicker-spec.js @@ -478,6 +478,140 @@ describe('ColorPicker', () => { }); }); + describe('swatch filtering', () => { + it('filters out translucent swatches when alpha is not enabled', () => { + createColorPicker({swatches: ['#aabbcc', '#ff000080', '#112233']}); + + var buttons = picker().querySelectorAll('.color_picker-swatches button'); + expect(buttons).toHaveLength(2); + expect(buttons[0].textContent).toBe('#aabbcc'); + expect(buttons[1].textContent).toBe('#112233'); + }); + + it('keeps translucent swatches when alpha is enabled', () => { + createColorPicker({alpha: true, swatches: ['#aabbcc', '#ff000080', '#112233']}); + + var buttons = picker().querySelectorAll('.color_picker-swatches button'); + expect(buttons).toHaveLength(3); + }); + + it('filters translucent swatches on update', () => { + createColorPicker({swatches: ['#aabbcc']}); + + colorPicker.update({swatches: ['#112233', '#ff000080']}); + + var buttons = picker().querySelectorAll('.color_picker-swatches button'); + expect(buttons).toHaveLength(1); + expect(buttons[0].textContent).toBe('#112233'); + }); + }); + + describe('fallbackColor option', () => { + it('uses fallbackColor for display when opened with no value', () => { + createColorPicker({fallbackColor: '#ff0000'}); + + open(); + + expect(colorPicker._displayColor).toEqual({r: 255, g: 0, b: 0, a: 1}); + }); + + it('does not set fallbackColor as currentColor', () => { + createColorPicker({fallbackColor: '#ff0000'}); + + open(); + + expect(colorPicker._currentColor).toBeNull(); + }); + + it('does not write fallbackColor to input', () => { + createColorPicker({fallbackColor: '#ff0000'}); + + open(); + + expect(input.value).toBe(''); + }); + + it('sets wrapper color to fallbackColor when input is cleared', () => { + createColorPicker({value: '#00ff00', fallbackColor: '#ff0000'}); + + input.value = ''; + input.dispatchEvent(new Event('input', {bubbles: true})); + + expect(input.parentNode).toHaveStyle('color: #ff0000'); + }); + + it('sets wrapper color to updated fallbackColor', () => { + createColorPicker({fallbackColor: '#ff0000'}); + + colorPicker.update({fallbackColor: '#00ff00'}); + + expect(input.parentNode).toHaveStyle('color: #00ff00'); + }); + + it('uses updated fallbackColor', () => { + createColorPicker({fallbackColor: '#ff0000'}); + + colorPicker.update({fallbackColor: '#00ff00'}); + open(); + + expect(colorPicker._displayColor).toEqual({r: 0, g: 255, b: 0, a: 1}); + }); + + it('sets wrapper color to fallbackColor when no value is set', () => { + createColorPicker({fallbackColor: '#ff0000'}); + + expect(input.parentNode).toHaveStyle('color: #ff0000'); + }); + + it('sets aria-description on input when displaying fallback', () => { + createColorPicker({ + fallbackColor: '#ff0000', + fallbackColorDescription: 'Automatic color' + }); + + expect(input).toHaveAccessibleDescription('Automatic color: #ff0000'); + }); + + it('does not set aria-description without fallbackColorDescription', () => { + createColorPicker({fallbackColor: '#ff0000'}); + + expect(input).not.toHaveAccessibleDescription(); + }); + + it('clears aria-description when color is set', () => { + createColorPicker({ + fallbackColor: '#ff0000', + fallbackColorDescription: 'Automatic color', + swatches: ['#00ff00'] + }); + open(); + + picker().querySelector('.color_picker-swatches button') + .dispatchEvent(new Event('click', {bubbles: true})); + + expect(input).not.toHaveAccessibleDescription(); + }); + + it('updates aria-description when fallbackColor changes', () => { + createColorPicker({ + fallbackColor: '#ff0000', + fallbackColorDescription: 'Automatic color' + }); + + colorPicker.update({fallbackColor: '#00ff00'}); + + expect(input).toHaveAccessibleDescription('Automatic color: #00ff00'); + }); + + it('prefers defaultColor over fallbackColor', () => { + createColorPicker({defaultValue: '#0000ff', fallbackColor: '#ff0000'}); + + open(); + + expect(colorPicker._displayColor).toEqual({r: 0, g: 0, b: 255, a: 1}); + }); + }); + describe('destroy', () => { it('removes picker element from DOM', () => { createColorPicker(); diff --git a/package/spec/ui/views/inputs/ColorInputView-spec.js b/package/spec/ui/views/inputs/ColorInputView-spec.js index a2cac9650a..36962b138c 100644 --- a/package/spec/ui/views/inputs/ColorInputView-spec.js +++ b/package/spec/ui/views/inputs/ColorInputView-spec.js @@ -1,376 +1,245 @@ import Backbone from 'backbone'; -import sinon from 'sinon'; import '@testing-library/jest-dom/extend-expect'; +import userEvent from '@testing-library/user-event'; import {ColorInputView} from 'pageflow/ui'; - -import {ColorInput} from '$support/dominos/ui' +import {renderBackboneView} from 'testHelpers/renderBackboneView'; describe('pageflow.ColorInputView', () => { - let testContext; + const user = userEvent.setup({delay: null}); - beforeEach(() => { - testContext = {}; - }); + function render(options) { + return renderBackboneView(new ColorInputView({ + disableChangeDebounce: true, + ...options + })); + } - beforeEach(() => { - testContext.clock = sinon.useFakeTimers(); - }); - - afterEach(() => { - testContext.clock.restore(); - }); + async function fillIn(input, value) { + await user.clear(input); + await user.type(input, value); + } it('loads value into input', () => { - var model = new Backbone.Model({ - color: '#ababab' - }); - var colorInputView = new ColorInputView({ - model: model, + const {getByRole} = render({ + model: new Backbone.Model({color: '#ababab'}), propertyName: 'color' }); - var colorInput = ColorInput.render(colorInputView); - - expect(colorInput.value()).toBe('#ababab'); + expect(getByRole('textbox')).toHaveValue('#ababab'); }); it('updates input when model changes', () => { - var model = new Backbone.Model(); - var colorInputView = new ColorInputView({ - model: model, - propertyName: 'color' - }); + const model = new Backbone.Model(); + const {getByRole} = render({model, propertyName: 'color'}); - var colorInput = ColorInput.render(colorInputView); model.set('color', '#ababab'); - expect(colorInput.value()).toBe('#ababab'); + expect(getByRole('textbox')).toHaveValue('#ababab'); }); - it('saves value to model on change', () => { - var model = new Backbone.Model({ - color: '#ababab' - }); - var colorInputView = new ColorInputView({ - model: model, - propertyName: 'color' - }); + it('saves value to model on change', async () => { + const model = new Backbone.Model({color: '#ababab'}); + const {getByRole} = render({model, propertyName: 'color'}); - var colorInput = ColorInput.render( - colorInputView - ); - colorInput.fillIn('#bbb', testContext.clock); + await fillIn(getByRole('textbox'), '#bbb'); expect(model.get('color')).toBe('#bbbbbb'); }); - it('does not update color input with normalized value', () => { - var model = new Backbone.Model({ - color: '#ababab' - }); - var colorInputView = new ColorInputView({ - model: model, - propertyName: 'color' - }); + it('does not update color input with normalized value', async () => { + const model = new Backbone.Model({color: '#ababab'}); + const {getByRole} = render({model, propertyName: 'color'}); - var colorInput = ColorInput.render( - colorInputView - ); - colorInput.fillIn('#bbb', testContext.clock); + await fillIn(getByRole('textbox'), '#bbb'); - expect(colorInput.value()).toBe('#bbb'); + expect(getByRole('textbox')).toHaveValue('#bbb'); }); it('allows passing swatches', () => { - var model = new Backbone.Model(); - var colorInputView = new ColorInputView({ - model: model, + const {getAllByRole} = render({ + model: new Backbone.Model(), propertyName: 'color', swatches: ['#cdcdcd', '#dedede'] }); - var colorInput = ColorInput.render( - colorInputView - ); - - expect(colorInput.swatches()).toEqual(['rgb(205, 205, 205)', 'rgb(222, 222, 222)']); + const buttons = getAllByRole('button'); + expect(buttons).toHaveLength(2); + expect(buttons[0]).toHaveTextContent('#cdcdcd'); + expect(buttons[1]).toHaveTextContent('#dedede'); }); describe('with defaultValue option', () => { it('falls back to default value', () => { - var model = new Backbone.Model(); - var colorInputView = new ColorInputView({ - model: model, + const {getByRole} = render({ + model: new Backbone.Model(), propertyName: 'color', defaultValue: '#cdcdcd' }); - var colorInput = ColorInput.render( - colorInputView - ); - - expect(colorInput.value()).toBe('#cdcdcd'); + expect(getByRole('textbox')).toHaveValue('#cdcdcd'); }); - it('does not store default value in model', () => { - var model = new Backbone.Model(); - var colorInputView = new ColorInputView({ - model: model, - propertyName: 'color', - defaultValue: '#cdcdcd' - }); + it('does not store default value in model', async () => { + const model = new Backbone.Model(); + const {getByRole} = render({model, propertyName: 'color', defaultValue: '#cdcdcd'}); - var colorInput = ColorInput.render( - colorInputView - ); - colorInput.fillIn('#cdcdcd', testContext.clock); + await fillIn(getByRole('textbox'), '#cdcdcd'); expect(model.has('color')).toBe(false); }); - it('stores non default value in model', () => { - var model = new Backbone.Model(); - var colorInputView = new ColorInputView({ - model: model, - propertyName: 'color', - defaultValue: '#cdcdcd' - }); + it('stores non default value in model', async () => { + const model = new Backbone.Model(); + const {getByRole} = render({model, propertyName: 'color', defaultValue: '#cdcdcd'}); - var colorInput = ColorInput.render( - colorInputView - ); - colorInput.fillIn('#ababab', testContext.clock); + await fillIn(getByRole('textbox'), '#ababab'); expect(model.get('color')).toBe('#ababab'); }); - it('unsets attribute in model if choosing default value', () => { - var model = new Backbone.Model({ - color: '#fff' - }); - var colorInputView = new ColorInputView({ - model: model, - propertyName: 'color', - defaultValue: '#cdcdcd' - }); + it('unsets attribute in model if choosing default value', async () => { + const model = new Backbone.Model({color: '#fff'}); + const {getByRole} = render({model, propertyName: 'color', defaultValue: '#cdcdcd'}); - var colorInput = ColorInput.render( - colorInputView - ); - colorInput.fillIn('#cdcdcd', testContext.clock); + await fillIn(getByRole('textbox'), '#cdcdcd'); expect(model.has('color')).toBe(false); }); it('includes swatch for default value', () => { - var model = new Backbone.Model(); - var colorInputView = new ColorInputView({ - model: model, + const {getAllByRole} = render({ + model: new Backbone.Model(), propertyName: 'color', defaultValue: '#cdcdcd', swatches: ['#dedede'] }); - var colorInput = ColorInput.render( - colorInputView - ); - - expect(colorInput.swatches()).toEqual(['rgb(205, 205, 205)', 'rgb(222, 222, 222)']); + const buttons = getAllByRole('button'); + expect(buttons).toHaveLength(2); + expect(buttons[0]).toHaveTextContent('#cdcdcd'); + expect(buttons[1]).toHaveTextContent('#dedede'); }); it('does not duplicate swatch', () => { - var model = new Backbone.Model(); - var colorInputView = new ColorInputView({ - model: model, + const {getAllByRole} = render({ + model: new Backbone.Model(), propertyName: 'color', defaultValue: '#cdcdcd', swatches: ['#dedede', '#cdcdcd'] }); - var colorInput = ColorInput.render( - colorInputView - ); - - expect(colorInput.swatches()).toEqual(['rgb(205, 205, 205)', 'rgb(222, 222, 222)']); + expect(getAllByRole('button')).toHaveLength(2); }); }); describe('with function as defaultValue option', () => { it('falls back to default value', () => { - var model = new Backbone.Model(); - var colorInputView = new ColorInputView({ - model: model, + const {getByRole} = render({ + model: new Backbone.Model(), propertyName: 'color', - defaultValue: function() { return '#cdcdcd'; } + defaultValue: () => '#cdcdcd' }); - var colorInput = ColorInput.render( - colorInputView - ); - - expect(colorInput.value()).toBe('#cdcdcd'); + expect(getByRole('textbox')).toHaveValue('#cdcdcd'); }); - it('does not store default value in model', () => { - var model = new Backbone.Model(); - var colorInputView = new ColorInputView({ - model: model, - propertyName: 'color', - defaultValue: function() { return '#cdcdcd'; } + it('does not store default value in model', async () => { + const model = new Backbone.Model(); + const {getByRole} = render({ + model, propertyName: 'color', + defaultValue: () => '#cdcdcd' }); - var colorInput = ColorInput.render( - colorInputView - ); - colorInput.fillIn('#cdcdcd', testContext.clock); + await fillIn(getByRole('textbox'), '#cdcdcd'); expect(model.has('color')).toBe(false); }); - it('stores non default value in model', () => { - var model = new Backbone.Model(); - var colorInputView = new ColorInputView({ - model: model, - propertyName: 'color', - defaultValue: function() { return '#cdcdcd'; } + it('stores non default value in model', async () => { + const model = new Backbone.Model(); + const {getByRole} = render({ + model, propertyName: 'color', + defaultValue: () => '#cdcdcd' }); - var colorInput = ColorInput.render( - colorInputView - ); - colorInput.fillIn('#ababab', testContext.clock); + await fillIn(getByRole('textbox'), '#ababab'); expect(model.get('color')).toBe('#ababab'); }); - it('unsets attribute in model if choosing default value', () => { - var model = new Backbone.Model({ - color: '#fff' - }); - var colorInputView = new ColorInputView({ - model: model, - propertyName: 'color', - defaultValue: function() { return '#cdcdcd'; } + it('unsets attribute in model if choosing default value', async () => { + const model = new Backbone.Model({color: '#fff'}); + const {getByRole} = render({ + model, propertyName: 'color', + defaultValue: () => '#cdcdcd' }); - var colorInput = ColorInput.render( - colorInputView - ); - colorInput.fillIn('#cdcdcd', testContext.clock); + await fillIn(getByRole('textbox'), '#cdcdcd'); expect(model.has('color')).toBe(false); }); it('includes swatch for default value', () => { - var model = new Backbone.Model(); - var colorInputView = new ColorInputView({ - model: model, + const {getAllByRole} = render({ + model: new Backbone.Model(), propertyName: 'color', - defaultValue: function() { return '#cdcdcd'; }, + defaultValue: () => '#cdcdcd', swatches: ['#dedede'] }); - var colorInput = ColorInput.render( - colorInputView - ); - - expect(colorInput.swatches()).toEqual(['rgb(205, 205, 205)', 'rgb(222, 222, 222)']); + const buttons = getAllByRole('button'); + expect(buttons).toHaveLength(2); + expect(buttons[0]).toHaveTextContent('#cdcdcd'); }); }); describe('with defaultValueBinding option', () => { it('uses value of binding attribute as default value', () => { - var model = new Backbone.Model({ - default_color: '#cdcdcd' - }); - var colorInputView = new ColorInputView({ - model: model, + const {getByRole} = render({ + model: new Backbone.Model({default_color: '#cdcdcd'}), propertyName: 'color', defaultValueBinding: 'default_color' }); - var colorInput = ColorInput.render( - colorInputView - ); + expect(getByRole('textbox')).toHaveValue('#cdcdcd'); + }); + + it('updates displayed default value when binding attribute changes', () => { + const model = new Backbone.Model({default_color: '#aaaaaa'}); + const {getByRole} = render({model, propertyName: 'color', defaultValueBinding: 'default_color'}); + + model.set('default_color', '#cdcdcd'); - expect(colorInput.value()).toBe('#cdcdcd'); + expect(getByRole('textbox')).toHaveValue('#cdcdcd'); }); - it( - 'updates displayed default value when binding attribute changes', - () => { - var model = new Backbone.Model({ - default_color: '#aaaaaa' - }); - var colorInputView = new ColorInputView({ - model: model, - propertyName: 'color', - defaultValueBinding: 'default_color' - }); - - var colorInput = ColorInput.render( - colorInputView - ); - model.set('default_color', '#cdcdcd'); - - expect(colorInput.value()).toBe('#cdcdcd'); - } - ); - - it('does not store default value in model', () => { - var model = new Backbone.Model({ - default_color: '#cdcdcd' - }); - var colorInputView = new ColorInputView({ - model: model, - propertyName: 'color', - defaultValueBinding: 'default_color' - }); + it('does not store default value in model', async () => { + const model = new Backbone.Model({default_color: '#cdcdcd'}); + const {getByRole} = render({model, propertyName: 'color', defaultValueBinding: 'default_color'}); - var colorInput = ColorInput.render( - colorInputView - ); model.set('default_color', '#aaaaaa'); - colorInput.fillIn('#aaaaaa', testContext.clock); + await fillIn(getByRole('textbox'), '#aaaaaa'); expect(model.has('color')).toBe(false); }); - it('stores non default value in model', () => { - var model = new Backbone.Model({ - default_color: '#cdcdcd' - }); - var colorInputView = new ColorInputView({ - model: model, - propertyName: 'color', - defaultValueBinding: 'default_color' - }); + it('stores non default value in model', async () => { + const model = new Backbone.Model({default_color: '#cdcdcd'}); + const {getByRole} = render({model, propertyName: 'color', defaultValueBinding: 'default_color'}); - var colorInput = ColorInput.render( - colorInputView - ); model.set('default_color', '#aaaaaa'); - colorInput.fillIn('#cdcdcd', testContext.clock); + await fillIn(getByRole('textbox'), '#cdcdcd'); expect(model.get('color')).toBe('#cdcdcd'); }); - it('unsets attribute in model if choosing default value', () => { - var model = new Backbone.Model({ - color: '#fff' - }); - var colorInputView = new ColorInputView({ - model: model, - propertyName: 'color', - defaultValueBinding: 'default_color' - }); + it('unsets attribute in model if choosing default value', async () => { + const model = new Backbone.Model({color: '#fff'}); + const {getByRole} = render({model, propertyName: 'color', defaultValueBinding: 'default_color'}); - var colorInput = ColorInput.render( - colorInputView - ); model.set('default_color', '#cdcdcd'); - colorInput.fillIn('#cdcdcd', testContext.clock); + await fillIn(getByRole('textbox'), '#cdcdcd'); expect(model.has('color')).toBe(false); }); @@ -378,171 +247,130 @@ describe('pageflow.ColorInputView', () => { describe('with function as defaultValue and defaultValueBinding option', () => { it('passes binding attribute to default value function', () => { - var model = new Backbone.Model({ - light: true - }); - var colorInputView = new ColorInputView({ - model: model, + const {getByRole} = render({ + model: new Backbone.Model({light: true}), propertyName: 'color', defaultValueBinding: 'light', - defaultValue: function(light) { return light ? '#fefefe' : '#010101'; } + defaultValue: light => light ? '#fefefe' : '#010101' }); - var colorInput = ColorInput.render( - colorInputView - ); - - expect(colorInput.value()).toBe('#fefefe'); + expect(getByRole('textbox')).toHaveValue('#fefefe'); }); - it( - 'updates displayed default value when binding attribute changes', - () => { - var model = new Backbone.Model({ - light: true - }); - var colorInputView = new ColorInputView({ - model: model, - propertyName: 'color', - defaultValueBinding: 'light', - defaultValue: function(light) { return light ? '#fefefe' : '#010101'; } - }); - - var colorInput = ColorInput.render( - colorInputView - ); - model.set('light', false); - - expect(colorInput.value()).toBe('#010101'); - } - ); - - it('does not store default value in model', () => { - var model = new Backbone.Model({ - light: true + it('updates displayed default value when binding attribute changes', () => { + const model = new Backbone.Model({light: true}); + const {getByRole} = render({ + model, propertyName: 'color', + defaultValueBinding: 'light', + defaultValue: light => light ? '#fefefe' : '#010101' }); - var colorInputView = new ColorInputView({ - model: model, - propertyName: 'color', + + model.set('light', false); + + expect(getByRole('textbox')).toHaveValue('#010101'); + }); + + it('does not store default value in model', async () => { + const model = new Backbone.Model({light: true}); + const {getByRole} = render({ + model, propertyName: 'color', defaultValueBinding: 'light', - defaultValue: function(light) { return light ? '#fefefe' : '#010101'; } + defaultValue: light => light ? '#fefefe' : '#010101' }); - var colorInput = ColorInput.render( - colorInputView - ); model.set('light', false); - colorInput.fillIn('#010101', testContext.clock); + await fillIn(getByRole('textbox'), '#010101'); expect(model.has('color')).toBe(false); }); - it('stores non default value in model', () => { - var model = new Backbone.Model({ - light: true - }); - var colorInputView = new ColorInputView({ - model: model, - propertyName: 'color', + it('stores non default value in model', async () => { + const model = new Backbone.Model({light: true}); + const {getByRole} = render({ + model, propertyName: 'color', defaultValueBinding: 'light', - defaultValue: function(light) { return light ? '#fefefe' : '#010101'; } + defaultValue: light => light ? '#fefefe' : '#010101' }); - var colorInput = ColorInput.render( - colorInputView - ); model.set('light', false); - colorInput.fillIn('#fefefe', testContext.clock); + await fillIn(getByRole('textbox'), '#fefefe'); expect(model.get('color')).toBe('#fefefe'); }); - it('unsets attribute in model if choosing default value', () => { - var model = new Backbone.Model({ - light: true - }); - var colorInputView = new ColorInputView({ - model: model, - propertyName: 'color', + it('unsets attribute in model if choosing default value', async () => { + const model = new Backbone.Model({light: true}); + const {getByRole} = render({ + model, propertyName: 'color', defaultValueBinding: 'light', - defaultValue: function(light) { return light ? '#fefefe' : '#010101'; } + defaultValue: light => light ? '#fefefe' : '#010101' }); - var colorInput = ColorInput.render( - colorInputView - ); model.set('light', false); - colorInput.fillIn('#010101', testContext.clock); + await fillIn(getByRole('textbox'), '#010101'); expect(model.has('color')).toBe(false); }); }); it('removes picker element when view is closed', () => { - var pickersBefore = document.querySelectorAll('.color_picker').length; - var colorInputView = new ColorInputView({ + const pickersBefore = document.querySelectorAll('.color_picker').length; + const view = new ColorInputView({ model: new Backbone.Model(), propertyName: 'color' }); - ColorInput.render(colorInputView); + renderBackboneView(view); expect(document.querySelectorAll('.color_picker').length).toBe(pickersBefore + 1); - colorInputView.close(); + view.close(); expect(document.querySelectorAll('.color_picker').length).toBe(pickersBefore); }); describe('with placeholderColor option', () => { - it('sets custom property', () => { - var colorInputView = new ColorInputView({ + it('sets fallback color', () => { + const {getByRole} = render({ model: new Backbone.Model(), propertyName: 'color', - placeholderColor: '#fff' + placeholderColor: '#ff0000', + placeholderColorDescription: 'Automatic color' }); - var colorInput = ColorInput.render( - colorInputView - ); - - expect(colorInput.$el[0]).toHaveStyle('--placeholder-color: #fff'); + expect(getByRole('textbox')) + .toHaveAccessibleDescription('Automatic color: #ff0000'); }); }); - describe('with function as placeholderColor and placeholderColorBinding option', () => { - it('sets custom property', () => { - var model = new Backbone.Model(); - var colorInputView = new ColorInputView({ - model, + describe('with placeholderColor and placeholderColorBinding option', () => { + it('sets fallback color', () => { + const {getByRole} = render({ + model: new Backbone.Model(), propertyName: 'color', placeholderColorBinding: 'invert', - placeholderColor: invert => invert ? '#000' : '#fff' + placeholderColor: invert => invert ? '#000000' : '#ffffff', + placeholderColorDescription: 'Automatic color' }); - var colorInput = ColorInput.render( - colorInputView - ); - - expect(colorInput.$el[0]).toHaveStyle('--placeholder-color: #fff'); + expect(getByRole('textbox')) + .toHaveAccessibleDescription('Automatic color: #ffffff'); }); - it('updates custom property', () => { - var model = new Backbone.Model(); - var colorInputView = new ColorInputView({ + it('updates fallback color', () => { + const model = new Backbone.Model(); + const {getByRole} = render({ model, propertyName: 'color', placeholderColorBinding: 'invert', - placeholderColor: invert => invert ? '#000' : '#fff' + placeholderColor: invert => invert ? '#000000' : '#ffffff', + placeholderColorDescription: 'Automatic color' }); - var colorInput = ColorInput.render( - colorInputView - ); - model.set('invert', true); - expect(colorInput.$el[0]).toHaveStyle('--placeholder-color: #000'); + expect(getByRole('textbox')) + .toHaveAccessibleDescription('Automatic color: #000000'); }); }); }); diff --git a/package/src/testHelpers/dominos/ui/index.js b/package/src/testHelpers/dominos/ui/index.js index 7f7a18c933..546d5b1de4 100644 --- a/package/src/testHelpers/dominos/ui/index.js +++ b/package/src/testHelpers/dominos/ui/index.js @@ -2,6 +2,5 @@ export * from './ConfigurationEditor' export * from './ConfigurationEditorTab' export * from './Table' export * from './Tabs'; -export * from './inputs/ColorInput' export * from './inputs/RadioButtonGroupInput' export * from './inputs/SelectInput' diff --git a/package/src/testHelpers/dominos/ui/inputs/ColorInput.js b/package/src/testHelpers/dominos/ui/inputs/ColorInput.js deleted file mode 100644 index 80cd1859e4..0000000000 --- a/package/src/testHelpers/dominos/ui/inputs/ColorInput.js +++ /dev/null @@ -1,28 +0,0 @@ -import {Base} from '../../Base'; - -export const ColorInput = Base.extend({ - value: function() { - return this._input().val(); - }, - - fillIn: function(value, clock) { - var input = this._input()[0]; - input.value = value; - input.dispatchEvent(new Event('input', {bubbles: true})); - - clock.tick(500); - }, - - swatches: function() { - var picker = this._input()[0].parentNode.querySelector('.color_picker'); - var buttons = picker.querySelectorAll('.color_picker-swatches button'); - - return Array.from(buttons).map(function(button) { - return window.getComputedStyle(button).color; - }); - }, - - _input: function() { - return this.$el.find('input'); - } -}); diff --git a/package/src/ui/views/ColorPicker.js b/package/src/ui/views/ColorPicker.js index 7aa0913f19..e7563a13f0 100644 --- a/package/src/ui/views/ColorPicker.js +++ b/package/src/ui/views/ColorPicker.js @@ -3,7 +3,8 @@ const ctx = typeof OffscreenCanvas !== 'undefined' && new OffscreenCanvas(1, 1).getContext('2d'); -const FALLBACK_COLOR = {r: 255, g: 255, b: 255, a: 1}; +const DEFAULT_DISPLAY_COLOR = {r: 255, g: 255, b: 255, a: 1}; +let nextDescriptionId = 0; const PICKER_HTML = '
' + @@ -27,6 +28,8 @@ export default class ColorPicker { this._alpha = options.alpha || false; this._onChange = options.onChange; this._defaultColor = strToRGBA(options.defaultValue); + this._fallbackColor = strToRGBA(options.fallbackColor); + this._fallbackColorDescription = options.fallbackColorDescription; this._swatches = options.swatches || []; this._wrapInput(); @@ -36,10 +39,19 @@ export default class ColorPicker { this._updateColor(strToRGBA(this._input.value), {silent: true}); } + setValue(str) { + this._input.value = str || ''; + this._updateColor(strToRGBA(str), {silent: true}); + } + update(options) { if ('defaultValue' in options) { this._defaultColor = strToRGBA(options.defaultValue); } + if ('fallbackColor' in options) { + this._fallbackColor = strToRGBA(options.fallbackColor); + this._updateColor(this._currentColor, {silent: true}); + } if (options.swatches) { this._swatches = options.swatches; this._renderSwatches(); @@ -80,6 +92,14 @@ export default class ColorPicker { parent.insertBefore(wrapper, this._input); wrapper.className = 'color_picker-field'; wrapper.appendChild(this._input); + + if (this._fallbackColorDescription) { + this._descriptionElement = document.createElement('span'); + this._descriptionElement.hidden = true; + this._descriptionElement.id = 'color_picker_desc_' + nextDescriptionId++; + wrapper.appendChild(this._descriptionElement); + this._input.setAttribute('aria-describedby', this._descriptionElement.id); + } } } @@ -94,14 +114,18 @@ export default class ColorPicker { } _renderSwatches() { + const swatches = this._alpha + ? this._swatches + : this._swatches.filter(s => !isTranslucentSwatch(s)); + this._swatchesContainer.textContent = ''; - this._swatchesContainer.classList.toggle('color_picker-empty', !this._swatches.length); + this._swatchesContainer.classList.toggle('color_picker-empty', !swatches.length); - if (!this._swatches.length) { + if (!swatches.length) { return; } - this._swatches.forEach(swatch => { + swatches.forEach(swatch => { const button = document.createElement('button'); button.setAttribute('type', 'button'); button.title = swatch; @@ -255,7 +279,7 @@ export default class ColorPicker { if (rgba && !this._alpha) rgba.a = 1; this._currentColor = rgba && {...this._displayColor, ...rgba}; - this._displayColor = this._currentColor || FALLBACK_COLOR; + this._displayColor = this._currentColor || this._fallbackColor || DEFAULT_DISPLAY_COLOR; const hex = rgbaToHex(this._displayColor); const opaqueHex = hex.substring(0, 7); @@ -265,9 +289,15 @@ export default class ColorPicker { this._alphaMarker.style.color = hex; const formatted = this._formatHex(this._currentColor); + const fallbackHex = this._formatHex(this._fallbackColor); const wrapper = this._input.parentNode; if (wrapper && wrapper.classList.contains('color_picker-field')) { - wrapper.style.color = formatted || ''; + wrapper.style.color = formatted || fallbackHex || ''; + } + + if (this._descriptionElement) { + this._descriptionElement.textContent = + !formatted && fallbackHex ? `${this._fallbackColorDescription}: ${fallbackHex}` : ''; } // Force repaint the color and alpha gradients (Chrome workaround) @@ -608,3 +638,7 @@ function getClipRect(element) { return viewport; } + +function isTranslucentSwatch(str) { + return str.length > 7 && str.slice(-2).toLowerCase() !== 'ff'; +} diff --git a/package/src/ui/views/inputs/ColorInputView.js b/package/src/ui/views/inputs/ColorInputView.js index 09ed2f6727..1f0d54adb8 100644 --- a/package/src/ui/views/inputs/ColorInputView.js +++ b/package/src/ui/views/inputs/ColorInputView.js @@ -65,8 +65,10 @@ export const ColorInputView = Marionette.ItemView.extend({ this._colorPicker = new ColorPicker(this.ui.input[0], { alpha: this.options.alpha, defaultValue: this.defaultValue(), + fallbackColor: this.getAttributeBoundOption('placeholderColor'), + fallbackColorDescription: this.options.placeholderColorDescription, swatches: this.getSwatches(), - onChange: _.debounce(_.bind(this._onChange, this), 200) + onChange: this._debouncedOnChange() }); this.listenTo(this.model, 'change:' + this.options.propertyName, this.load); @@ -79,11 +81,8 @@ export const ColorInputView = Marionette.ItemView.extend({ }, updatePlaceholderColor(value) { - if (value) { - this.el.style.setProperty('--placeholder-color', value); - } - else { - this.el.style.removeProperty('--placeholder-color'); + if (this._colorPicker) { + this._colorPicker.update({fallbackColor: value}); } }, @@ -100,12 +99,7 @@ export const ColorInputView = Marionette.ItemView.extend({ var color = this.model.get(this.options.propertyName) || this.defaultValue() || ''; if (!this._saving) { - this.ui.input[0].value = color; - - var wrapper = this.ui.input[0].parentNode; - if (wrapper && wrapper.classList.contains('color_picker-field')) { - wrapper.style.color = color; - } + this._colorPicker.setValue(color); } this.$el.toggleClass('is_default', !this.model.has(this.options.propertyName)); @@ -141,6 +135,11 @@ export const ColorInputView = Marionette.ItemView.extend({ } }, + _debouncedOnChange: function() { + const handler = _.bind(this._onChange, this); + return this.options.disableChangeDebounce ? handler : _.debounce(handler, 200); + }, + _onChange: function(color) { this._saving = true;