diff --git a/app/assets/stylesheets/pageflow/ui/color_picker.scss b/app/assets/stylesheets/pageflow/ui/color_picker.scss index c4caf48ad8..3d91913f60 100644 --- a/app/assets/stylesheets/pageflow/ui/color_picker.scss +++ b/app/assets/stylesheets/pageflow/ui/color_picker.scss @@ -126,6 +126,13 @@ display: none; } + .color_picker-swatch_divider { + flex-basis: 100%; + height: 0; + margin: size(1) 0; + border-top: 1px solid var(--ui-on-surface-color-lightest); + } + button { position: relative; width: size(5); diff --git a/entry_types/scrolled/doc/creating_themes/custom_colors_and_dimensions.md b/entry_types/scrolled/doc/creating_themes/custom_colors_and_dimensions.md index 9c5b7b0558..37ae66b118 100644 --- a/entry_types/scrolled/doc/creating_themes/custom_colors_and_dimensions.md +++ b/entry_types/scrolled/doc/creating_themes/custom_colors_and_dimensions.md @@ -377,3 +377,44 @@ en: Users will now be able to select "Brand Blue" as a color for quotes, counters and text paragraphs. + +## Background Color Presets + +Themes can supply a set of background color presets that editors can +pick from when choosing the background color of a section, the surface +color of cards or the overlay color of split sections. They appear as +swatches in the color picker, separated by a divider from the colors +already used elsewhere in the story. + +Unlike palette colors, presets are **not** CSS custom properties and are +**not** referenced by name. When an editor picks a preset, its color +value is copied into the section. Editing or removing a preset later +therefore does not change sections that already use that color – think +of a preset as a suggested starting point rather than a managed color +that stays in sync. This makes presets a good fit for background colors, +which are usually chosen for a specific section while looking at its +content and contrast, and rarely meant to change story-wide. + +Presets are configured via the `presets` theme option as a list of +objects with a `value` and an optional `name`: + +``` ruby +entry_type_config.themes.register(:my_custom_theme, + # ... + presets: { + background_colors: [ + {value: '#c9e9fb', name: 'Extra Light Blue'}, + {value: '#b9d3c6', name: 'BG Green'}, + {value: '#f6e8d7', name: 'BG Sand'} + ] + }) +``` + +The `name` is shown as the swatch's tooltip. Since presets are data, not +managed colors, the name is stored inline with the value instead of in a +translation file. This keeps presets editable from a host application +(for example via theme customization overrides) without having to deploy +translations. + +If a theme defines no `presets`, the color picker only offers the colors +already used in other sections, as before. diff --git a/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/getBackgroundColorPresets-spec.js b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/getBackgroundColorPresets-spec.js new file mode 100644 index 0000000000..4fcf5a17f8 --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/getBackgroundColorPresets-spec.js @@ -0,0 +1,61 @@ +import {ScrolledEntry} from 'editor/models/ScrolledEntry'; +import {factories} from 'pageflow/testHelpers'; +import {normalizeSeed} from 'support'; + +describe('ScrolledEntry', () => { + describe('#getBackgroundColorPresets', () => { + it('returns empty array by default', () => { + const entry = factories.entry( + ScrolledEntry, + {}, + {entryTypeSeed: normalizeSeed()} + ); + + expect(entry.getBackgroundColorPresets()).toEqual([]); + }); + + it('returns value and text for each preset', () => { + const entry = factories.entry( + ScrolledEntry, + {}, + { + entryTypeSeed: normalizeSeed({ + themeOptions: { + presets: { + backgroundColors: [ + {value: '#c9e9fb', name: 'Extra Light Blue'}, + {value: '#fbe6b8', name: 'BG Yellow'} + ] + } + } + }) + } + ); + + expect(entry.getBackgroundColorPresets()).toEqual([ + {value: '#c9e9fb', text: 'Extra Light Blue'}, + {value: '#fbe6b8', text: 'BG Yellow'} + ]); + }); + + it('falls back to value as text when name is missing', () => { + const entry = factories.entry( + ScrolledEntry, + {}, + { + entryTypeSeed: normalizeSeed({ + themeOptions: { + presets: { + backgroundColors: [{value: '#c9e9fb'}] + } + } + }) + } + ); + + expect(entry.getBackgroundColorPresets()).toEqual([ + {value: '#c9e9fb', text: '#c9e9fb'} + ]); + }); + }); +}); diff --git a/entry_types/scrolled/package/spec/editor/views/EditSectionView-spec.js b/entry_types/scrolled/package/spec/editor/views/EditSectionView-spec.js index df9832b0ae..f4a40184f6 100644 --- a/entry_types/scrolled/package/spec/editor/views/EditSectionView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/EditSectionView-spec.js @@ -3,6 +3,12 @@ import {EditSectionView} from 'editor/views/EditSectionView'; import {ConfigurationEditor, DropDownButton, useFakeTranslations} from 'pageflow/testHelpers'; import {useEditorGlobals} from 'support'; +function backdropColorSwatches(view) { + return view.$el.find('.color_input').filter((_, el) => + el.querySelector('.color_picker-swatches button[title="Extra Light Blue"]') + ).first(); +} + describe('EditSectionView', () => { const {createEntry} = useEditorGlobals(); @@ -271,6 +277,100 @@ describe('EditSectionView', () => { .not.toContain('backdropEffectsMobile'); }); + describe('background color presets', () => { + it('offers theme background color presets as swatches', () => { + const entry = createEntry({ + themeOptions: { + presets: { + backgroundColors: [{value: '#c9e9fb', name: 'Extra Light Blue'}] + } + }, + sections: [{id: 1, configuration: {backdropType: 'color'}}] + }); + + const view = new EditSectionView({ + model: entry.sections.get(1), + entry + }); + + view.render(); + + const swatch = + view.$el.find('.color_picker-swatches button[title="Extra Light Blue"]')[0]; + + expect(swatch).toBeDefined(); + expect(swatch.textContent).toBe('#c9e9fb'); + }); + + it('offers colors already used in other sections as swatches', () => { + const entry = createEntry({ + sections: [ + {id: 1, configuration: {backdropType: 'color'}}, + {id: 2, configuration: {backdropType: 'color', backdropColor: '#040404'}} + ] + }); + + const view = new EditSectionView({ + model: entry.sections.get(1), + entry + }); + + view.render(); + + const swatch = + view.$el.find('.color_picker-swatches button[title="#040404"]')[0]; + + expect(swatch).toBeDefined(); + }); + + describe('swatch group divider', () => { + it('separates theme presets from used colors with a divider', () => { + const entry = createEntry({ + themeOptions: { + presets: { + backgroundColors: [{value: '#c9e9fb', name: 'Extra Light Blue'}] + } + }, + sections: [ + {id: 1, configuration: {backdropType: 'color'}}, + {id: 2, configuration: {backdropType: 'color', backdropColor: '#040404'}} + ] + }); + + const view = new EditSectionView({ + model: entry.sections.get(1), + entry + }); + + view.render(); + + expect(backdropColorSwatches(view).find('.color_picker-swatch_divider')) + .toHaveLength(1); + }); + + it('shows no divider when no colors are used yet', () => { + const entry = createEntry({ + themeOptions: { + presets: { + backgroundColors: [{value: '#c9e9fb', name: 'Extra Light Blue'}] + } + }, + sections: [{id: 1, configuration: {backdropType: 'color'}}] + }); + + const view = new EditSectionView({ + model: entry.sections.get(1), + entry + }); + + view.render(); + + expect(backdropColorSwatches(view).find('.color_picker-swatch_divider')) + .toHaveLength(0); + }); + }); + }); + describe('actions dropdown', () => { useFakeTranslations({ 'pageflow_scrolled.editor.section_menu_items.duplicate': 'Duplicate', diff --git a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js index 482cfdcd58..c33d57921e 100644 --- a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js +++ b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js @@ -420,6 +420,15 @@ export const ScrolledEntry = Entry.extend({ return sortColors([...colors].filter(Boolean)); }, + getBackgroundColorPresets() { + const presets = this.scrolledSeed.config.theme.options.presets || {}; + + return (presets.backgroundColors || []).map(({value, name}) => ({ + value, + text: name || value + })); + }, + getThemeProperties() { return this.scrolledSeed.config.theme.options.properties || {}; }, diff --git a/entry_types/scrolled/package/src/editor/views/EditSectionView.js b/entry_types/scrolled/package/src/editor/views/EditSectionView.js index b4e7d7ae97..aab8a9f65f 100644 --- a/entry_types/scrolled/package/src/editor/views/EditSectionView.js +++ b/entry_types/scrolled/package/src/editor/views/EditSectionView.js @@ -33,6 +33,11 @@ export const EditSectionView = EditConfigurationView.extend({ const entry = this.options.entry; const editor = this.options.editor; + const backgroundColorSwatches = [ + ...entry.getBackgroundColorPresets().map(swatch => ({...swatch, group: 'presets'})), + ...entry.getUsedSectionBackgroundColors().map(value => ({value, group: 'used'})) + ]; + const editMotifAreaMenuItem = { name: 'editMotifArea', label: I18n.t('pageflow_scrolled.editor.edit_motif_area_menu_item'), @@ -149,7 +154,7 @@ export const EditSectionView = EditConfigurationView.extend({ this.input('backdropColor', ColorInputView, { visibleBinding: 'backdropType', visibleBindingValue: 'color', - swatches: entry.getUsedSectionBackgroundColors() + swatches: backgroundColorSwatches }); if (hasDecorationEffects(entry)) { this.input('backdropEffects', EffectListInputView, { @@ -212,7 +217,7 @@ export const EditSectionView = EditConfigurationView.extend({ placeholderColorBinding: 'invert', placeholderColor: invert => invert ? '#101010' : '#ffffff', placeholderColorDescription: I18n.t('pageflow_scrolled.editor.edit_section.attributes.cardSurfaceColor.auto_color'), - swatches: entry.getUsedSectionBackgroundColors() + swatches: backgroundColorSwatches }); this.input('splitOverlayColor', ColorInputView, { @@ -223,7 +228,7 @@ export const EditSectionView = EditConfigurationView.extend({ placeholderColorBinding: 'invert', placeholderColor: invert => invert ? '#ffffffb3' : '#000000b3', placeholderColorDescription: I18n.t('pageflow_scrolled.editor.edit_section.attributes.splitOverlayColor.auto_color'), - swatches: entry.getUsedSectionBackgroundColors() + swatches: backgroundColorSwatches }); this.input('overlayBackdropBlur', SliderInputView, { diff --git a/package/spec/ui/views/ColorPicker-spec.js b/package/spec/ui/views/ColorPicker-spec.js index 0c7b867bbc..4e9f2b276a 100644 --- a/package/spec/ui/views/ColorPicker-spec.js +++ b/package/spec/ui/views/ColorPicker-spec.js @@ -88,6 +88,91 @@ describe('ColorPicker', () => { }); }); + describe('labelled swatches', () => { + it('uses object value as swatch color and text as title', () => { + createColorPicker({swatches: [{value: '#aabbcc', text: 'Sky'}]}); + + var button = picker().querySelector('.color_picker-swatches button'); + + expect(button.title).toBe('Sky'); + expect(button).toHaveStyle('color: #aabbcc'); + }); + + it('falls back to value as title when text is missing', () => { + createColorPicker({swatches: [{value: '#aabbcc'}]}); + + var button = picker().querySelector('.color_picker-swatches button'); + + expect(button.title).toBe('#aabbcc'); + }); + + it('applies object swatch value when clicked', () => { + createColorPicker({swatches: [{value: '#aabbcc', text: 'Sky'}]}); + open(); + + picker().querySelector('.color_picker-swatches button') + .dispatchEvent(new Event('click', {bubbles: true})); + + expect(input.value).toBe('#aabbcc'); + }); + + it('filters translucent object swatches when alpha is not enabled', () => { + createColorPicker({ + swatches: [{value: '#aabbcc', text: 'A'}, {value: '#ff000080', text: 'B'}] + }); + + expect(picker().querySelectorAll('.color_picker-swatches button')).toHaveLength(1); + }); + }); + + describe('swatch groups', () => { + function dividerCount() { + return picker().querySelectorAll('.color_picker-swatch_divider').length; + } + + it('renders a divider between groups of swatches', () => { + createColorPicker({ + swatches: [ + {value: '#aabbcc', group: 'presets'}, + {value: '#112233', group: 'used'} + ] + }); + + expect(dividerCount()).toBe(1); + expect(picker().querySelectorAll('.color_picker-swatches button')).toHaveLength(2); + }); + + it('keeps swatches that share a group together', () => { + createColorPicker({ + swatches: [ + {value: '#aabbcc', group: 'presets'}, + {value: '#445566', group: 'presets'}, + {value: '#112233', group: 'used'} + ] + }); + + expect(dividerCount()).toBe(1); + expect(picker().querySelectorAll('.color_picker-swatches button')).toHaveLength(3); + }); + + it('renders no divider for plain swatches', () => { + createColorPicker({swatches: ['#aabbcc', '#112233']}); + + expect(dividerCount()).toBe(0); + expect(picker().querySelectorAll('.color_picker-swatches button')).toHaveLength(2); + }); + + it('still applies the value of a grouped swatch when clicked', () => { + createColorPicker({swatches: [{value: '#aabbcc', group: 'presets'}]}); + open(); + + picker().querySelector('.color_picker-swatches button') + .dispatchEvent(new Event('click', {bubbles: true})); + + expect(input.value).toBe('#aabbcc'); + }); + }); + describe('open and close', () => { it('opens picker on input click', () => { createColorPicker(); diff --git a/package/spec/ui/views/inputs/ColorInputView-spec.js b/package/spec/ui/views/inputs/ColorInputView-spec.js index 36962b138c..ddffa622a3 100644 --- a/package/spec/ui/views/inputs/ColorInputView-spec.js +++ b/package/spec/ui/views/inputs/ColorInputView-spec.js @@ -69,6 +69,41 @@ describe('pageflow.ColorInputView', () => { expect(buttons[1]).toHaveTextContent('#dedede'); }); + it('allows passing swatches with labels', () => { + const {getAllByRole} = render({ + model: new Backbone.Model(), + propertyName: 'color', + swatches: [{value: '#cdcdcd', text: 'Concrete'}] + }); + + expect(getAllByRole('button')[0]).toHaveAttribute('title', 'Concrete'); + }); + + it('deduplicates swatches by value across labelled and plain entries', () => { + const {getAllByRole} = render({ + model: new Backbone.Model(), + propertyName: 'color', + swatches: [{value: '#cdcdcd', text: 'Concrete'}, '#cdcdcd'] + }); + + const buttons = getAllByRole('button'); + expect(buttons).toHaveLength(1); + expect(buttons[0]).toHaveAttribute('title', 'Concrete'); + }); + + it('separates swatch groups with a divider in the picker', () => { + render({ + model: new Backbone.Model(), + propertyName: 'color', + swatches: [ + {value: '#cdcdcd', text: 'Concrete', group: 'presets'}, + {value: '#dedede', text: 'Smoke', group: 'used'} + ] + }); + + expect(document.querySelectorAll('.color_picker-swatch_divider')).toHaveLength(1); + }); + describe('with defaultValue option', () => { it('falls back to default value', () => { const {getByRole} = render({ diff --git a/package/src/ui/views/ColorPicker.js b/package/src/ui/views/ColorPicker.js index 5f0185349e..d22f8c977b 100644 --- a/package/src/ui/views/ColorPicker.js +++ b/package/src/ui/views/ColorPicker.js @@ -121,9 +121,9 @@ export default class ColorPicker { } _renderSwatches() { - const swatches = this._alpha - ? this._swatches - : this._swatches.filter(s => !isTranslucentSwatch(s)); + const swatches = this._swatches + .map(normalizeSwatch) + .filter(swatch => this._alpha || !isTranslucentSwatch(swatch.value)); this._swatchesContainer.textContent = ''; this._swatchesContainer.classList.toggle('color_picker-empty', !swatches.length); @@ -132,13 +132,21 @@ export default class ColorPicker { return; } - swatches.forEach(swatch => { - const button = document.createElement('button'); - button.setAttribute('type', 'button'); - button.title = swatch; - button.style.color = swatch; - button.textContent = swatch; - this._swatchesContainer.appendChild(button); + groupSwatches(swatches).forEach((group, index) => { + if (index > 0) { + const divider = document.createElement('span'); + divider.className = 'color_picker-swatch_divider'; + this._swatchesContainer.appendChild(divider); + } + + group.swatches.forEach(({value, text}) => { + const button = document.createElement('button'); + button.setAttribute('type', 'button'); + button.title = text; + button.style.color = value; + button.textContent = value; + this._swatchesContainer.appendChild(button); + }); }); } @@ -649,3 +657,29 @@ function getClipRect(element) { function isTranslucentSwatch(str) { return str.length > 7 && str.slice(-2).toLowerCase() !== 'ff'; } + +function normalizeSwatch(swatch) { + return typeof swatch === 'string' ? + {value: swatch, text: swatch} : + {value: swatch.value, text: swatch.text || swatch.value, group: swatch.group}; +} + +function groupSwatches(swatches) { + const groups = []; + const groupsByKey = new Map(); + + swatches.forEach(swatch => { + const key = swatch.group || ''; + let group = groupsByKey.get(key); + + if (!group) { + group = {key, swatches: []}; + groupsByKey.set(key, group); + groups.push(group); + } + + group.swatches.push(swatch); + }); + + return groups; +} diff --git a/package/src/ui/views/inputs/ColorInputView.js b/package/src/ui/views/inputs/ColorInputView.js index 1f0d54adb8..db63c97265 100644 --- a/package/src/ui/views/inputs/ColorInputView.js +++ b/package/src/ui/views/inputs/ColorInputView.js @@ -42,10 +42,14 @@ import template from '../../templates/inputs/colorInput.jst'; * colors are stored in `#rrggbbaa` format. Fully opaque colors still * use `#rrggbb`. * - * @param {string[]} [options.swatches] - * Preset color values to be displayed inside the picker drop - * down. The default value, if present, is always used as the - * first swatch automatically. + * @param {Array} [options.swatches] + * Preset colors to be displayed inside the picker drop down. Each + * entry is either a color string or an object with a `value` color, + * a `text` label shown as the swatch's tooltip and an optional + * `group` key. Consecutive swatches with a different `group` are + * separated by a divider in the picker. The default value, if + * present, is always used as the first swatch automatically. Swatches + * are deduplicated by value. * * @class */ @@ -112,8 +116,9 @@ export const ColorInputView = Marionette.ItemView.extend({ getSwatches: function() { return _.chain([this.defaultValue(), this.options.swatches]) .flatten() - .uniq() .compact() + .map(swatch => typeof swatch === 'string' ? {value: swatch} : swatch) + .uniq(swatch => swatch.value) .value(); },