@kitconcept/volto-light-theme 8.0.0-alpha.4 → 8.0.0-alpha.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.changelog.draft CHANGED
@@ -1,11 +1,16 @@
1
- ## 8.0.0-alpha.4 (2025-11-05)
1
+ ## 8.0.0-alpha.5 (2025-11-11)
2
+
3
+ ### Feature
4
+
5
+ - Registry color definitions support for ColorSwatch widget. @sneridagh [#723](https://github.com/kitconcept/volto-light-theme/pull/723)
2
6
 
3
7
  ### Bugfix
4
8
 
5
- - Fixed slider config, in case slider is not present. @sneridagh
9
+ - Fixes text in the sticky menu if the text was long enough to wrap. @jnptk [#691](https://github.com/kitconcept/volto-light-theme/pull/691)
10
+ - Identify intranet header with a className in `header header-intranet` div. @sneridagh
6
11
 
7
12
  ### Internal
8
13
 
9
- - Updated volto-dsgvo-banner baseline to latest (3.x series). @sneridagh
14
+ - Normalize all remaining add-ons to be "alpha" versions exclusively for vlt8. @sneridagh
10
15
 
11
16
 
package/CHANGELOG.md CHANGED
@@ -8,6 +8,21 @@
8
8
 
9
9
  <!-- towncrier release notes start -->
10
10
 
11
+ ## 8.0.0-alpha.5 (2025-11-11)
12
+
13
+ ### Feature
14
+
15
+ - Registry color definitions support for ColorSwatch widget. @sneridagh [#723](https://github.com/kitconcept/volto-light-theme/pull/723)
16
+
17
+ ### Bugfix
18
+
19
+ - Fixes text in the sticky menu if the text was long enough to wrap. @jnptk [#691](https://github.com/kitconcept/volto-light-theme/pull/691)
20
+ - Identify intranet header with a className in `header header-intranet` div. @sneridagh
21
+
22
+ ### Internal
23
+
24
+ - Normalize all remaining add-ons to be "alpha" versions exclusively for vlt8. @sneridagh
25
+
11
26
  ## 8.0.0-alpha.4 (2025-11-05)
12
27
 
13
28
  ### Bugfix
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kitconcept/volto-light-theme",
3
- "version": "8.0.0-alpha.4",
3
+ "version": "8.0.0-alpha.5",
4
4
  "description": "Volto Light Theme by kitconcept",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -45,7 +45,7 @@
45
45
  "release-it": "^19.0.3",
46
46
  "typescript": "^5.7.3",
47
47
  "vitest": "^3.1.2",
48
- "@plone/types": "2.0.0-alpha.8"
48
+ "@plone/types": "2.0.0-alpha.10"
49
49
  },
50
50
  "dependencies": {
51
51
  "@dnd-kit/core": "6.0.8",
@@ -162,7 +162,7 @@ const IntranetHeader = ({ pathname, content }) => {
162
162
 
163
163
  return (
164
164
  <>
165
- <div className="header">
165
+ <div className="header header-intranet">
166
166
  <div className="tools-wrapper">
167
167
  <LanguageSelector />
168
168
 
@@ -0,0 +1,147 @@
1
+ import React from 'react';
2
+ import { fn } from '@storybook/test';
3
+ import type { Meta, StoryObj } from '@storybook/react';
4
+ import Wrapper from '@plone/volto/storybook';
5
+ import type { StyleDefinition } from '@plone/types';
6
+
7
+ import ColorSwatch, { type ColorSwatchProps } from './ColorSwatch';
8
+
9
+ const palettes: StyleDefinition[] = [
10
+ {
11
+ name: 'default',
12
+ label: 'Default',
13
+ style: {
14
+ '--theme-color': '#ffffff',
15
+ '--theme-foreground-color': '#1b1c1d',
16
+ '--theme-low-contrast-foreground-color': '#585858',
17
+ },
18
+ },
19
+ {
20
+ name: 'warm',
21
+ label: 'Warm',
22
+ style: {
23
+ '--theme-color': '#fff5ed',
24
+ '--theme-foreground-color': '#6b2c1f',
25
+ '--theme-low-contrast-foreground-color': '#b06a54',
26
+ },
27
+ },
28
+ {
29
+ name: 'unstyled',
30
+ label: 'No Inline Style',
31
+ style: undefined,
32
+ },
33
+ ];
34
+
35
+ const themePalettes: StyleDefinition[] = [
36
+ {
37
+ name: 'ocean',
38
+ label: 'Ocean',
39
+ style: {
40
+ '--theme-color': '#dff5ff',
41
+ '--theme-foreground-color': '#003d5b',
42
+ '--theme-low-contrast-foreground-color': '#5c6b73',
43
+ },
44
+ },
45
+ {
46
+ name: 'forest',
47
+ label: 'Forest',
48
+ style: {
49
+ '--theme-color': '#e6f4ea',
50
+ '--theme-foreground-color': '#1e4d2b',
51
+ '--theme-low-contrast-foreground-color': '#4b7754',
52
+ },
53
+ },
54
+ ];
55
+
56
+ const meta = {
57
+ title: 'Widgets/ColorSwatch',
58
+ component: ColorSwatch,
59
+ parameters: {
60
+ layout: 'centered',
61
+ },
62
+ decorators: [
63
+ (Story) => (
64
+ <Wrapper>
65
+ <div style={{ width: 320 }}>
66
+ <Story />
67
+ </div>
68
+ </Wrapper>
69
+ ),
70
+ ],
71
+ tags: ['autodocs'],
72
+ } satisfies Meta<typeof ColorSwatch>;
73
+
74
+ export default meta;
75
+ type Story = StoryObj<typeof meta>;
76
+
77
+ const InteractiveColorSwatch = (args: ColorSwatchProps) => {
78
+ const [value, setValue] = React.useState(args.value || args.default);
79
+
80
+ return (
81
+ <>
82
+ <ColorSwatch
83
+ {...args}
84
+ onChange={(id, selectedValue) => {
85
+ setValue(selectedValue);
86
+ args.onChange?.(id, selectedValue);
87
+ }}
88
+ />
89
+ <div style={{ marginTop: '10px' }}>
90
+ The selected value is: <strong>{value}</strong>
91
+ </div>
92
+ </>
93
+ );
94
+ };
95
+
96
+ export const DefaultPalette: Story = {
97
+ render: (args) => <InteractiveColorSwatch {...args} />,
98
+ args: {
99
+ id: 'color',
100
+ title: 'Color palette',
101
+ value: 'default',
102
+ colors: palettes,
103
+ onChange: fn(),
104
+ },
105
+ };
106
+
107
+ export const ThemePalette: Story = {
108
+ render: (args) => <InteractiveColorSwatch {...args} />,
109
+ args: {
110
+ id: 'theme',
111
+ title: 'Theme palette',
112
+ value: 'ocean',
113
+ themes: themePalettes,
114
+ onChange: fn(),
115
+ },
116
+ };
117
+
118
+ export const ColorPaletteWithDefault: Story = {
119
+ render: (args) => <InteractiveColorSwatch {...args} />,
120
+ args: {
121
+ id: 'color',
122
+ title: 'Color palette',
123
+ colors: palettes,
124
+ default: 'warm',
125
+ onChange: fn(),
126
+ },
127
+ };
128
+
129
+ export const ColorPaletteNoValueNoDefault: Story = {
130
+ render: (args) => <InteractiveColorSwatch {...args} />,
131
+ args: {
132
+ id: 'color',
133
+ title: 'Color palette',
134
+ colors: palettes,
135
+ onChange: fn(),
136
+ },
137
+ };
138
+
139
+ export const ColorPaletteNoValueNoDefaultWithFallbackToFirst: Story = {
140
+ render: (args) => <InteractiveColorSwatch {...args} />,
141
+ args: {
142
+ id: 'color',
143
+ title: 'Color palette',
144
+ themes: themePalettes,
145
+ onChange: fn(),
146
+ },
147
+ };
@@ -0,0 +1,188 @@
1
+ import React, { createContext, useContext } from 'react';
2
+ import { describe, it, expect, vi } from 'vitest';
3
+ import { render, fireEvent } from '@testing-library/react';
4
+ import ColorSwatch from './ColorSwatch';
5
+ import type { StyleDefinition } from '@plone/types';
6
+
7
+ vi.mock('@plone/volto/components/manage/Widgets/FormFieldWrapper', () => ({
8
+ __esModule: true,
9
+ default: ({ children }: { children: React.ReactNode }) => (
10
+ <div data-testid="form-field-wrapper">{children}</div>
11
+ ),
12
+ }));
13
+
14
+ const MockRadioGroupContext = createContext<
15
+ ((value: string) => void) | undefined
16
+ >(undefined);
17
+
18
+ vi.mock('@plone/components', () => ({
19
+ RadioGroup: ({
20
+ children,
21
+ onChange,
22
+ ...rest
23
+ }: {
24
+ children: React.ReactNode;
25
+ onChange?: (value: string) => void;
26
+ }) => {
27
+ const { isDisabled: _isDisabled, ...passThrough } = rest as Record<
28
+ string,
29
+ unknown
30
+ >;
31
+
32
+ return (
33
+ <MockRadioGroupContext.Provider value={onChange}>
34
+ <div role="radiogroup" {...passThrough}>
35
+ {React.Children.map(children, (child) =>
36
+ React.isValidElement(child)
37
+ ? React.cloneElement(child, {
38
+ onSelect: onChange,
39
+ })
40
+ : child,
41
+ )}
42
+ </div>
43
+ </MockRadioGroupContext.Provider>
44
+ );
45
+ },
46
+ Radio: ({
47
+ value,
48
+ onSelect,
49
+ className,
50
+ children,
51
+ ...rest
52
+ }: {
53
+ value: string;
54
+ onSelect?: (value: string) => void;
55
+ className?: string;
56
+ children?: React.ReactNode;
57
+ }) => {
58
+ const groupOnChange = useContext(MockRadioGroupContext);
59
+ return (
60
+ <button
61
+ type="button"
62
+ className={className}
63
+ data-value={value}
64
+ onClick={() => {
65
+ groupOnChange?.(value);
66
+ onSelect?.(value);
67
+ }}
68
+ {...rest}
69
+ >
70
+ {children}
71
+ </button>
72
+ );
73
+ },
74
+ Tooltip: ({ children }: { children: React.ReactNode }) => (
75
+ <span data-testid="tooltip">{children}</span>
76
+ ),
77
+ }));
78
+
79
+ vi.mock('react-aria-components', () => ({
80
+ Focusable: ({ children }: { children: React.ReactNode }) => <>{children}</>,
81
+ TooltipTrigger: ({ children }: { children: React.ReactNode }) => (
82
+ <>{children}</>
83
+ ),
84
+ }));
85
+
86
+ const palette: StyleDefinition[] = [
87
+ {
88
+ name: 'default',
89
+ label: 'Default',
90
+ style: { '--theme-color': '#fff' },
91
+ },
92
+ {
93
+ name: 'ocean',
94
+ label: 'Ocean',
95
+ style: { '--theme-color': '#00a0e6' },
96
+ },
97
+ {
98
+ name: 'sunset',
99
+ label: 'Sunset',
100
+ style: { '--theme-color': '#ff6b00' },
101
+ },
102
+ ];
103
+
104
+ const renderColorSwatch = (
105
+ props: Partial<React.ComponentProps<typeof ColorSwatch>> = {},
106
+ ) => {
107
+ const { colors = palette, themes, onChange = vi.fn(), ...rest } = props;
108
+
109
+ const componentProps: React.ComponentProps<typeof ColorSwatch> = {
110
+ id: 'theme',
111
+ label: 'Theme',
112
+ title: 'Theme',
113
+ colors,
114
+ onChange,
115
+ ...rest,
116
+ } as React.ComponentProps<typeof ColorSwatch>;
117
+
118
+ if (themes !== undefined) {
119
+ componentProps.themes = themes;
120
+ }
121
+
122
+ return render(<ColorSwatch {...componentProps} />);
123
+ };
124
+
125
+ const getActiveClassName = (container: HTMLElement | Document) =>
126
+ container.querySelector('.color-swatch-option-handler.active')?.className ??
127
+ '';
128
+
129
+ describe('ColorSwatch', () => {
130
+ it('renders nothing when no palettes are provided', () => {
131
+ const { container } = renderColorSwatch({ themes: [] });
132
+ expect(container.firstChild).toBeNull();
133
+ });
134
+
135
+ it('highlights the provided value', () => {
136
+ const { container } = renderColorSwatch({ value: 'ocean' });
137
+ expect(getActiveClassName(container)).toContain('ocean');
138
+ });
139
+
140
+ it('highlights the default value when no current value exists', () => {
141
+ const { container } = renderColorSwatch({
142
+ value: undefined,
143
+ default: 'sunset',
144
+ });
145
+ expect(getActiveClassName(container)).toContain('sunset');
146
+ });
147
+
148
+ it('falls back to the "default" palette name when no value or default is provided', () => {
149
+ const { container } = renderColorSwatch({
150
+ value: undefined,
151
+ default: undefined,
152
+ });
153
+ expect(getActiveClassName(container)).toContain('default');
154
+ });
155
+
156
+ it('falls back to the first color when no "default" palette is present', () => {
157
+ const colorsWithoutDefault: StyleDefinition[] = [
158
+ {
159
+ name: 'primary',
160
+ label: 'Primary',
161
+ style: { '--theme-color': '#333' },
162
+ },
163
+ {
164
+ name: 'secondary',
165
+ label: 'Secondary',
166
+ style: { '--theme-color': '#666' },
167
+ },
168
+ ];
169
+
170
+ const { container } = renderColorSwatch({
171
+ colors: colorsWithoutDefault,
172
+ value: undefined,
173
+ default: undefined,
174
+ });
175
+
176
+ expect(getActiveClassName(container)).toContain('primary');
177
+ });
178
+
179
+ it('notifies when a different swatch is chosen', () => {
180
+ const handleChange = vi.fn();
181
+ const { container } = renderColorSwatch({ onChange: handleChange });
182
+ const buttons = container.querySelectorAll('.color-swatch-option-wrapper');
183
+
184
+ fireEvent.click(buttons[2]);
185
+
186
+ expect(handleChange).toHaveBeenCalledWith('theme', 'sunset');
187
+ });
188
+ });
@@ -1,58 +1,96 @@
1
1
  import FormFieldWrapper from '@plone/volto/components/manage/Widgets/FormFieldWrapper';
2
- import { Button } from '@plone/components';
2
+ import { Radio, RadioGroup, Tooltip } from '@plone/components';
3
3
  import cx from 'classnames';
4
+ import { Focusable, TooltipTrigger } from 'react-aria-components';
5
+ import type { StyleDefinition } from '@plone/types';
4
6
 
5
- export type Color =
6
- | {
7
- name: string;
8
- label: string;
9
- style: Record<`--${string}`, string>;
10
- }
11
- | {
12
- name: string;
13
- label: string;
14
- style: undefined;
15
- };
16
-
17
- export type ColorSwatchProps = {
7
+ type BaseColorSwatchProps = {
18
8
  id: string;
19
9
  title: string;
10
+ label: string;
20
11
  value?: string;
21
- default?: string | object;
12
+ default?: string;
22
13
  required?: boolean;
23
- missing_value?: unknown;
24
14
  className?: string;
25
15
  onChange: (id: string, value: any) => void;
26
- colors: Color[];
27
- themes: Color[];
16
+ disabled?: boolean;
17
+ isDisabled?: boolean;
28
18
  };
29
19
 
20
+ type ColorsOnly = { colors: StyleDefinition[]; themes?: undefined };
21
+ type ThemesOnly = { themes: StyleDefinition[]; colors?: undefined };
22
+
23
+ export type ColorSwatchProps = BaseColorSwatchProps & (ColorsOnly | ThemesOnly);
24
+
30
25
  const ColorSwatch = (props: ColorSwatchProps) => {
31
- const { id, value, onChange } = props;
26
+ const {
27
+ id,
28
+ label,
29
+ title,
30
+ value,
31
+ onChange,
32
+ disabled,
33
+ isDisabled,
34
+ default: defaultValue,
35
+ } = props;
32
36
  const colors = props.themes || props.colors || [];
33
37
 
38
+ const selectedColorName = colors.find(({ name }) => name === value)?.name;
39
+ const defaultSelectedColorName =
40
+ !selectedColorName && typeof defaultValue === 'string'
41
+ ? colors.find(({ name }) => name === defaultValue)?.name
42
+ : undefined;
43
+
44
+ const fallbackColorName =
45
+ !selectedColorName && !defaultSelectedColorName
46
+ ? colors.find(({ name }) => name === 'default')?.name || colors[0]?.name
47
+ : undefined;
48
+
49
+ const radioGroupValueProps: {
50
+ value?: string;
51
+ defaultValue?: string;
52
+ } = selectedColorName
53
+ ? { value: selectedColorName }
54
+ : defaultSelectedColorName
55
+ ? { defaultValue: defaultSelectedColorName }
56
+ : fallbackColorName
57
+ ? { defaultValue: fallbackColorName }
58
+ : {};
59
+
60
+ const currentColorName =
61
+ selectedColorName ?? defaultSelectedColorName ?? fallbackColorName;
62
+
34
63
  return colors.length > 0 ? (
35
64
  <FormFieldWrapper {...props} className="color-swatch-widget">
36
- <div className="buttons">
37
- {colors.map((color) => {
38
- const colorName = color.name;
39
- return (
40
- <Button
41
- key={id + colorName}
42
- className={cx(colorName, { active: value === colorName })}
43
- onPress={(e) => {
44
- onChange(
45
- id,
46
- colorName === value ? props.missing_value : colorName,
47
- );
48
- }}
49
- value={value}
50
- style={color.style}
51
- aria-label={color.label}
52
- />
53
- );
54
- })}
55
- </div>
65
+ <RadioGroup
66
+ aria-label={title || label || id}
67
+ orientation="horizontal"
68
+ {...radioGroupValueProps}
69
+ onChange={(value) => onChange(id, value)}
70
+ isDisabled={disabled || isDisabled}
71
+ >
72
+ {colors.map((color) => (
73
+ <Radio
74
+ aria-label={color.label}
75
+ value={color.name}
76
+ className="color-swatch-option-wrapper"
77
+ key={color.name}
78
+ >
79
+ <TooltipTrigger delay={120} closeDelay={80} trigger="hover">
80
+ <Focusable>
81
+ <div
82
+ role="img"
83
+ className={cx('color-swatch-option-handler', color.name, {
84
+ active: currentColorName === color.name,
85
+ })}
86
+ style={color.style}
87
+ ></div>
88
+ </Focusable>
89
+ <Tooltip>{color.label}</Tooltip>
90
+ </TooltipTrigger>
91
+ </Radio>
92
+ ))}
93
+ </RadioGroup>
56
94
  </FormFieldWrapper>
57
95
  ) : null;
58
96
  };
@@ -2,16 +2,12 @@ import ColorSwatchWidget from './ColorSwatch';
2
2
  import config from '@plone/volto/registry';
3
3
  import type { ColorSwatchProps } from './ColorSwatch';
4
4
 
5
- const ThemeColorSwatch = (props: ColorSwatchProps) => {
6
- const colors: ColorSwatchProps['colors'] = config.blocks.themes;
5
+ const ThemeColorSwatch = (
6
+ props: Omit<ColorSwatchProps, 'themes' | 'colors'>,
7
+ ) => {
8
+ const themes: ColorSwatchProps['themes'] = config.blocks.themes;
7
9
 
8
- const defaultValue = colors.find(
9
- (color) => color.name === config.settings.defaultBackgroundColor,
10
- )?.style;
11
-
12
- return (
13
- <ColorSwatchWidget {...props} default={defaultValue} colors={colors} />
14
- );
10
+ return <ColorSwatchWidget {...props} themes={themes} />;
15
11
  };
16
12
 
17
13
  export default ThemeColorSwatch;
@@ -4,8 +4,12 @@ import type { StyleDefinition } from '@plone/types';
4
4
  import cloneDeep from 'lodash/cloneDeep';
5
5
 
6
6
  import { composeSchema } from '@plone/volto/helpers/Extensions';
7
- import { findStyleByName } from '@plone/volto/helpers/Blocks/Blocks';
8
7
  import { defaultStylingSchema } from '../components/Blocks/schema';
8
+ import {
9
+ blockThemesEnhancer,
10
+ styleDefinitionsEnhancer,
11
+ } from '../helpers/styleDefinitions';
12
+
9
13
  import {
10
14
  gridTeaserDisableStylingSchema,
11
15
  teaserSchemaEnhancer,
@@ -40,7 +44,6 @@ import { tocBlockSchemaEnhancer } from '../components/Blocks/Toc/schema';
40
44
  import { mapsBlockSchemaEnhancer } from '../components/Blocks/Maps/schema';
41
45
  import { sliderBlockSchemaEnhancer } from '../components/Blocks/Slider/schema';
42
46
  import EventMetadataView from '../components/Blocks/EventMetadata/View';
43
- import isEmpty from 'lodash/isEmpty';
44
47
 
45
48
  import SearchBlockViewEvent from '../components/Blocks/EventCalendar/Search/SearchBlockView';
46
49
  import SearchBlockEditEvent from '../components/Blocks/EventCalendar/Search/SearchBlockEdit';
@@ -142,38 +145,18 @@ export default function install(config: ConfigType) {
142
145
  },
143
146
  ];
144
147
 
145
- function blockThemesEnhancer({ data, container }) {
146
- if (!data['@type']) return {};
147
- const blockConfig = config.blocks.blocksConfig[data['@type']];
148
- if (!blockConfig) return {};
149
- const blockStyleDefinitions =
150
- // We look up for the blockThemes in the block's config, then in the global config
151
- // We keep `colors` for BBB, but `themes` should be used
152
- blockConfig.themes || blockConfig.colors || config.blocks.themes || [];
153
-
154
- if (
155
- !isEmpty(container) &&
156
- container.theme &&
157
- (!data.theme || data.theme === 'default')
158
- ) {
159
- return findStyleByName(blockStyleDefinitions, container.theme);
160
- }
161
- if (data.theme) {
162
- return data.theme
163
- ? findStyleByName(blockStyleDefinitions, data.theme)
164
- : {};
165
- } else {
166
- // No theme, return default color
167
- return findStyleByName(config.blocks.themes, 'default');
168
- }
169
- }
170
-
171
148
  config.registerUtility({
172
149
  name: 'blockThemesEnhancer',
173
150
  type: 'styleWrapperStyleObjectEnhancer',
174
151
  method: blockThemesEnhancer,
175
152
  });
176
153
 
154
+ config.registerUtility({
155
+ name: 'styleDefinitionsEnhancer',
156
+ type: 'styleWrapperStyleObjectEnhancer',
157
+ method: styleDefinitionsEnhancer,
158
+ });
159
+
177
160
  // No required blocks except eventMetadata
178
161
  config.blocks.requiredBlocks = [
179
162
  ...config.blocks.requiredBlocks,
@@ -1,8 +1,5 @@
1
1
  import type { ConfigType } from '@plone/registry';
2
- import BlockWidth from '../components/Widgets/BlockWidth';
3
- import BlockAlignment from '../components/Widgets/BlockAlignment';
4
2
  import ColorSwatch from '../components/Widgets/ColorSwatch';
5
- import Size from '../components/Widgets/Size';
6
3
  import ColorPicker from '../components/Widgets/ColorPicker';
7
4
  import ThemeColorSwatch from '../components/Widgets/ThemeColorSwatch';
8
5
  // import BlocksObject from '../components/Widgets/BlocksObject';
@@ -12,7 +9,6 @@ import { footerLogosSchema } from '../components/Widgets/schema/footerLogosSchem
12
9
  import { footerLinksSchema } from '../components/Widgets/schema/footerLinksSchema';
13
10
  import { iconLinkListSchema } from '../components/Widgets/schema/iconLinkListSchema';
14
11
  import ModalJSONEditor from '../components/Widgets/ModalJSONEditor';
15
- import AlignWidget from '../components/Widgets/AlignWidget';
16
12
 
17
13
  export default function install(config: ConfigType) {
18
14
  // Color picker widget override - use our own non-semanticUI widget
@@ -20,20 +16,16 @@ export default function install(config: ConfigType) {
20
16
  // `color_picker` is a terrible name for this widget, it should be `colorSwatch`
21
17
  // ToDo: Rename it in Volto 19
22
18
  config.widgets.widget.color_picker = ColorSwatch;
19
+ config.widgets.widget.colorSwatch = ColorSwatch;
23
20
 
24
21
  // ObjectList widget override - use our own non-semanticUI widget
25
22
  // it uses also dnd-kit for drag and drop
26
23
  config.widgets.widget.object_list = ObjectList;
27
24
 
28
- config.widgets.widget.align = AlignWidget;
29
-
30
25
  // Force Preview image link widget to the image widget
31
26
  config.widgets.id.preview_image_link = config.widgets.widget.image;
32
27
 
33
- config.widgets.widget.blockWidth = BlockWidth;
34
- config.widgets.widget.blockAlignment = BlockAlignment;
35
28
  config.widgets.widget.colorPicker = ColorPicker;
36
- config.widgets.widget.size = Size;
37
29
  config.widgets.widget.themeColorSwatch = ThemeColorSwatch;
38
30
 
39
31
  config.widgets.widget.modalJSONEditor = ModalJSONEditor;
@@ -0,0 +1,30 @@
1
+ import { styleDefinitionsEnhancer } from './styleDefinitions';
2
+ import config from '@plone/volto/registry';
3
+
4
+ describe('styleDefinitionsEnhancer', () => {
5
+ it('should enhance style definitions correctly', () => {
6
+ const data = {
7
+ styles: {
8
+ backgroundColor: 'red',
9
+ textColor: 'blue',
10
+ },
11
+ };
12
+
13
+ config.registerUtility({
14
+ type: 'styleFieldDefinition',
15
+ name: 'backgroundColor',
16
+ method: () => {
17
+ return [
18
+ { name: 'red', label: 'Red', style: { '--bg-color': 'red' } },
19
+ { name: 'green', label: 'Green', style: { '--bg-color': 'green' } },
20
+ ];
21
+ },
22
+ });
23
+
24
+ const container = {};
25
+
26
+ const result = styleDefinitionsEnhancer({ data, container });
27
+
28
+ expect(result).toEqual({ '--bg-color': 'red' });
29
+ });
30
+ });
@@ -0,0 +1,49 @@
1
+ import config from '@plone/volto/registry';
2
+ import { findStyleByName } from '@plone/volto/helpers/Blocks/Blocks';
3
+ import isEmpty from 'lodash/isEmpty';
4
+
5
+ export function blockThemesEnhancer({ data, container }) {
6
+ if (!data['@type']) return {};
7
+ const blockConfig = config.blocks.blocksConfig[data['@type']];
8
+ if (!blockConfig) return {};
9
+ const blockStyleDefinitions =
10
+ // We look up for the blockThemes in the block's config, then in the global config
11
+ // We keep `colors` for BBB, but `themes` should be used
12
+ blockConfig.themes || blockConfig.colors || config.blocks.themes || [];
13
+
14
+ if (
15
+ !isEmpty(container) &&
16
+ container.theme &&
17
+ (!data.theme || data.theme === 'default')
18
+ ) {
19
+ return findStyleByName(blockStyleDefinitions, container.theme);
20
+ }
21
+ if (data.theme) {
22
+ return data.theme ? findStyleByName(blockStyleDefinitions, data.theme) : {};
23
+ } else {
24
+ // No theme, return default color
25
+ return findStyleByName(config.blocks.themes, 'default');
26
+ }
27
+ }
28
+
29
+ export function styleDefinitionsEnhancer({ data, container }) {
30
+ let resultantStyles = {};
31
+ Object.keys(data.styles || {}).forEach((fieldName) => {
32
+ const styleFieldEnhancer = config.getUtility({
33
+ type: 'styleFieldDefinition',
34
+ name: fieldName,
35
+ });
36
+
37
+ if (styleFieldEnhancer.method) {
38
+ resultantStyles = {
39
+ ...resultantStyles,
40
+ ...findStyleByName(
41
+ styleFieldEnhancer.method({ data, container }),
42
+ data.styles[fieldName],
43
+ ),
44
+ };
45
+ }
46
+ });
47
+
48
+ return resultantStyles;
49
+ }
@@ -89,20 +89,18 @@ span.color-contrast-label {
89
89
  }
90
90
 
91
91
  .color-swatch-widget {
92
- .buttons {
93
- flex: 1 1 100%;
94
- margin: 1rem 0 0.5rem;
95
- text-align: center;
92
+ .react-aria-RadioGroup {
93
+ gap: 5px;
96
94
  }
97
95
 
98
- button {
96
+ .color-swatch-option-handler {
99
97
  position: relative;
100
98
  width: 32px;
101
99
  height: 32px;
102
100
  padding: 1rem;
103
101
  border: 2px solid #ccc;
104
102
  border-radius: 100%;
105
- margin-right: 0.25rem;
103
+ cursor: pointer;
106
104
 
107
105
  &.active {
108
106
  border: 2px solid red;
@@ -121,18 +119,9 @@ span.color-contrast-label {
121
119
  }
122
120
  }
123
121
 
124
- .color-swatch-widget button {
125
- background-color: var(--theme-color);
126
- }
127
-
128
- .color-swatch-widget .button.grey,
129
- .color-swatch-widget .button.grey.active,
130
- .color-swatch-widget .ui.grey.button:hover {
131
- background-color: var(--theme-color);
132
- }
133
- .color-swatch-widget .button.active,
134
- .color-swatch-widget .button.active:hover {
122
+ .color-swatch-widget .color-swatch-option-handler {
135
123
  background-color: var(--theme-color);
124
+ color: var(--theme-foreground-color);
136
125
  }
137
126
 
138
127
  .buttons-widget-option {
@@ -21,7 +21,9 @@ body.cms-ui .sticky-menu {
21
21
  flex-direction: column;
22
22
  padding: 0;
23
23
  margin: 0;
24
+ gap: 1px;
24
25
  list-style: none;
26
+ text-align: center;
25
27
 
26
28
  li a {
27
29
  display: flex;
@@ -34,24 +36,24 @@ body.cms-ui .sticky-menu {
34
36
  font-size: 10px;
35
37
  font-style: normal;
36
38
  font-weight: 700;
37
- line-height: 18px; /* 180% */
39
+ line-height: 12px;
38
40
  text-transform: capitalize;
39
41
  }
40
42
 
41
43
  li {
42
44
  display: flex;
43
- height: 93px;
45
+ min-height: 97px;
44
46
  flex-direction: column;
45
47
  align-items: center;
46
48
  justify-content: center;
47
- margin-bottom: 2px;
49
+ padding: 5px 10px;
48
50
  background: var(--sticky-menu-color, #555);
49
51
  color: var(--sticky-menu-foreground-color, #fff);
50
52
  font-family: Inter;
51
53
  font-size: 10px;
52
54
  font-style: normal;
53
55
  font-weight: 700;
54
- line-height: 18px; /* 180% */
56
+ line-height: 12px;
55
57
  text-transform: capitalize;
56
58
  }
57
59
 
@@ -1,84 +0,0 @@
1
- import React from 'react';
2
- import { defineMessages, useIntl } from 'react-intl';
3
- import ButtonsWidget, {
4
- type ActionInfo,
5
- type ButtonsWidgetProps,
6
- } from './Buttons';
7
- import imageLeftSVG from '@plone/volto/icons/image-left.svg';
8
- import imageRightSVG from '@plone/volto/icons/image-right.svg';
9
- import imageFitSVG from '@plone/volto/icons/image-fit.svg';
10
- import imageNarrowSVG from '@plone/volto/icons/image-narrow.svg';
11
- import imageWideSVG from '@plone/volto/icons/image-wide.svg';
12
- import imageFullSVG from '@plone/volto/icons/image-full.svg';
13
- import type { IntlShape } from 'react-intl';
14
-
15
- const messages = defineMessages({
16
- left: {
17
- id: 'Left',
18
- defaultMessage: 'Left',
19
- },
20
- right: {
21
- id: 'Right',
22
- defaultMessage: 'Right',
23
- },
24
- center: {
25
- id: 'Center',
26
- defaultMessage: 'Center',
27
- },
28
- narrow: {
29
- id: 'Narrow',
30
- defaultMessage: 'Narrow',
31
- },
32
- wide: {
33
- id: 'Wide',
34
- defaultMessage: 'Wide',
35
- },
36
- full: {
37
- id: 'Full',
38
- defaultMessage: 'Full',
39
- },
40
- });
41
-
42
- export const defaultActionsInfo = ({
43
- intl,
44
- }: {
45
- intl: IntlShape;
46
- }): Record<string, ActionInfo> => ({
47
- left: [imageLeftSVG, intl.formatMessage(messages.left)],
48
- center: [imageFitSVG, intl.formatMessage(messages.center)],
49
- right: [imageRightSVG, intl.formatMessage(messages.right)],
50
- narrow: [imageNarrowSVG, intl.formatMessage(messages.narrow)],
51
- wide: [imageWideSVG, intl.formatMessage(messages.wide)],
52
- full: [imageFullSVG, intl.formatMessage(messages.full)],
53
- });
54
-
55
- type AlignWidgetProps = ButtonsWidgetProps & {
56
- defaultAction?: string;
57
- };
58
-
59
- const AlignWidget = (props: AlignWidgetProps) => {
60
- const intl = useIntl();
61
-
62
- const {
63
- actions = ['left', 'center', 'right', 'full'],
64
- actionsInfoMap,
65
- default: defaultValue,
66
- defaultAction,
67
- } = props;
68
-
69
- const actionsInfo = {
70
- ...defaultActionsInfo({ intl }),
71
- ...actionsInfoMap,
72
- };
73
-
74
- return (
75
- <ButtonsWidget
76
- {...props}
77
- actions={actions}
78
- actionsInfoMap={actionsInfo}
79
- default={defaultValue ?? defaultAction ?? 'center'}
80
- />
81
- );
82
- };
83
-
84
- export default AlignWidget;
@@ -1,88 +0,0 @@
1
- import React from 'react';
2
- import { defineMessages, useIntl } from 'react-intl';
3
- import ButtonsWidget, {
4
- type ActionInfo,
5
- type ButtonsWidgetProps,
6
- } from './Buttons';
7
- import imageFitSVG from '@plone/volto/icons/image-fit.svg';
8
- import imageLeftSVG from '@plone/volto/icons/image-left.svg';
9
- import imageRightSVG from '@plone/volto/icons/image-right.svg';
10
- import type { IntlShape } from 'react-intl';
11
- import type { StyleDefinition } from '@plone/types';
12
-
13
- const messages = defineMessages({
14
- left: {
15
- id: 'Left',
16
- defaultMessage: 'Left',
17
- },
18
- right: {
19
- id: 'Right',
20
- defaultMessage: 'Right',
21
- },
22
- center: {
23
- id: 'Center',
24
- defaultMessage: 'Center',
25
- },
26
- });
27
-
28
- export const defaultActionsInfo = ({
29
- intl,
30
- }: {
31
- intl: IntlShape;
32
- }): Record<string, ActionInfo> => ({
33
- left: [imageLeftSVG, intl.formatMessage(messages.left)],
34
- right: [imageRightSVG, intl.formatMessage(messages.right)],
35
- center: [imageFitSVG, intl.formatMessage(messages.center)],
36
- });
37
-
38
- const DEFAULT_ACTIONS: StyleDefinition[] = [
39
- {
40
- style: {
41
- '--block-alignment': 'var(--align-left)',
42
- },
43
- name: 'left',
44
- label: 'Left',
45
- },
46
- {
47
- style: {
48
- '--block-alignment': 'var(--align-center)',
49
- },
50
- name: 'center',
51
- label: 'Center',
52
- },
53
- {
54
- style: {
55
- '--block-alignment': 'var(--align-right)',
56
- },
57
- name: 'right',
58
- label: 'Right',
59
- },
60
- ];
61
-
62
- const BlockAlignmentWidget = (props: ButtonsWidgetProps) => {
63
- const intl = useIntl();
64
-
65
- const { actions = DEFAULT_ACTIONS, actionsInfoMap, filterActions } = props;
66
- const filteredActions =
67
- filterActions && filterActions.length > 0
68
- ? actions.filter((action) => {
69
- const actionName = typeof action === 'string' ? action : action.name;
70
- return filterActions.includes(actionName);
71
- })
72
- : actions;
73
-
74
- const actionsInfo = {
75
- ...defaultActionsInfo({ intl }),
76
- ...actionsInfoMap,
77
- };
78
-
79
- return (
80
- <ButtonsWidget
81
- {...props}
82
- actions={filteredActions}
83
- actionsInfoMap={actionsInfo}
84
- />
85
- );
86
- };
87
-
88
- export default BlockAlignmentWidget;
@@ -1,101 +0,0 @@
1
- import React from 'react';
2
- import { defineMessages, useIntl } from 'react-intl';
3
- import type { IntlShape } from 'react-intl';
4
- import ButtonsWidget, {
5
- type ActionInfo,
6
- type ButtonsWidgetProps,
7
- } from './Buttons';
8
- import imageFitSVG from '@plone/volto/icons/image-fit.svg';
9
- import imageNarrowSVG from '@plone/volto/icons/image-narrow.svg';
10
- import imageWideSVG from '@plone/volto/icons/image-wide.svg';
11
- import imageFullSVG from '@plone/volto/icons/image-full.svg';
12
- import type { StyleDefinition } from '@plone/types';
13
-
14
- const messages = defineMessages({
15
- narrow: {
16
- id: 'Narrow',
17
- defaultMessage: 'Narrow',
18
- },
19
- default: {
20
- id: 'Default',
21
- defaultMessage: 'Default',
22
- },
23
- layout: {
24
- id: 'Layout',
25
- defaultMessage: 'Layout',
26
- },
27
- full: {
28
- id: 'Full',
29
- defaultMessage: 'Full',
30
- },
31
- });
32
-
33
- export const defaultActionsInfo = ({
34
- intl,
35
- }: {
36
- intl: IntlShape;
37
- }): Record<string, ActionInfo> => ({
38
- narrow: [imageNarrowSVG, intl.formatMessage(messages.narrow)],
39
- default: [imageFitSVG, intl.formatMessage(messages.default)],
40
- layout: [imageWideSVG, intl.formatMessage(messages.layout)],
41
- full: [imageFullSVG, intl.formatMessage(messages.full)],
42
- });
43
-
44
- const DEFAULT_ACTIONS: StyleDefinition[] = [
45
- {
46
- style: {
47
- '--block-width': 'var(--narrow-container-width)',
48
- },
49
- name: 'narrow',
50
- label: 'Narrow',
51
- },
52
- {
53
- style: {
54
- '--block-width': 'var(--default-container-width)',
55
- },
56
- name: 'default',
57
- label: 'Default',
58
- },
59
- {
60
- style: {
61
- '--block-width': 'var(--layout-container-width)',
62
- },
63
- name: 'layout',
64
- label: 'Layout',
65
- },
66
- {
67
- style: {
68
- '--block-width': 'unset',
69
- },
70
- name: 'full',
71
- label: 'Full',
72
- },
73
- ];
74
-
75
- const BlockWidthWidget = (props: ButtonsWidgetProps) => {
76
- const intl = useIntl();
77
-
78
- const { actions = DEFAULT_ACTIONS, actionsInfoMap, filterActions } = props;
79
- const filteredActions =
80
- filterActions && filterActions.length > 0
81
- ? actions.filter((action) => {
82
- const actionName = typeof action === 'string' ? action : action.name;
83
- return filterActions.includes(actionName);
84
- })
85
- : actions;
86
-
87
- const actionsInfo = {
88
- ...defaultActionsInfo({ intl }),
89
- ...actionsInfoMap,
90
- };
91
-
92
- return (
93
- <ButtonsWidget
94
- {...props}
95
- actions={filteredActions}
96
- actionsInfoMap={actionsInfo}
97
- />
98
- );
99
- };
100
-
101
- export default BlockWidthWidget;
@@ -1,167 +0,0 @@
1
- import React from 'react';
2
- import FormFieldWrapper from '@plone/volto/components/manage/Widgets/FormFieldWrapper';
3
- import Icon from '@plone/volto/components/theme/Icon/Icon';
4
- import { RadioGroup } from '@plone/components';
5
- import { Radio } from 'react-aria-components';
6
- import isEqual from 'lodash/isEqual';
7
- import type { StyleDefinition } from '@plone/types';
8
-
9
- /**
10
- * A tuple that has an icon in the first element and a i18n string in the second.
11
- */
12
- export type ActionInfo = [React.ReactElement<any>, string] | [string, string];
13
-
14
- type ActionValue = string | Record<`--${string}`, string>;
15
-
16
- export type ButtonsWidgetProps = {
17
- /**
18
- * Unique identifier for the widget.
19
- */
20
- id: string;
21
-
22
- /**
23
- * Callback function to handle changes.
24
- */
25
- onChange: (id: string, value: ActionValue) => void;
26
-
27
- /**
28
- * List of actions available for the widget.
29
- */
30
- actions?: Array<StyleDefinition | string>;
31
-
32
- /**
33
- * Map containing additional the information (icon and i18n string) for each action.
34
- */
35
- actionsInfoMap?: Record<string, ActionInfo>;
36
-
37
- /**
38
- * List of actions to be filtered out. In case that we don't want the default ones
39
- * we can filter them out.
40
- */
41
- filterActions?: string[];
42
-
43
- /**
44
- * Current value of the widget.
45
- */
46
- value?: ActionValue;
47
-
48
- /**
49
- * Default value of the widget.
50
- */
51
- default?: ActionValue;
52
-
53
- /**
54
- * Indicates if the widget is disabled.
55
- */
56
- disabled?: boolean;
57
-
58
- /**
59
- * Indicates if the widget is disabled (alternative flag for compatibility reasons).
60
- */
61
- isDisabled?: boolean;
62
- [key: string]: any;
63
- };
64
-
65
- type NormalizedAction = {
66
- name: string;
67
- value: ActionValue;
68
- };
69
-
70
- const ButtonsWidget = (props: ButtonsWidgetProps) => {
71
- const {
72
- disabled,
73
- id,
74
- onChange,
75
- actions = [],
76
- actionsInfoMap,
77
- value,
78
- isDisabled,
79
- default: defaultValue,
80
- } = props;
81
- const normalizedActions = React.useMemo<NormalizedAction[]>(
82
- () =>
83
- actions.map((action) =>
84
- typeof action === 'string'
85
- ? { name: action, value: action }
86
- : {
87
- name: action.name,
88
- value: action.style ?? action.name,
89
- },
90
- ),
91
- [actions],
92
- );
93
-
94
- const selectedActionName = React.useMemo(
95
- () =>
96
- normalizedActions.find((action) => isEqual(value, action.value))?.name,
97
- [normalizedActions, value],
98
- );
99
-
100
- const handleChange = React.useCallback(
101
- (selectedName: string) => {
102
- const selectedAction = normalizedActions.find(
103
- ({ name }) => name === selectedName,
104
- );
105
-
106
- if (selectedAction) {
107
- onChange(id, selectedAction.value);
108
- }
109
- },
110
- [id, normalizedActions, onChange],
111
- );
112
-
113
- React.useEffect(() => {
114
- if (!value && defaultValue) {
115
- const nextValue =
116
- typeof defaultValue === 'string'
117
- ? normalizedActions.find(({ name }) => name === defaultValue)
118
- ?.value ?? defaultValue
119
- : defaultValue;
120
-
121
- onChange(id, nextValue);
122
- }
123
- }, [defaultValue, id, normalizedActions, onChange, value]);
124
-
125
- return (
126
- <FormFieldWrapper {...props} className="widget">
127
- <RadioGroup
128
- aria-label={props.title || props.label || id}
129
- orientation="horizontal"
130
- value={selectedActionName}
131
- onChange={handleChange}
132
- isDisabled={disabled || isDisabled}
133
- className="buttons buttons-widget"
134
- >
135
- {normalizedActions.map((action) => {
136
- const actionInfo = actionsInfoMap?.[action.name];
137
- const [iconOrText, ariaLabel] = actionInfo ?? [
138
- action.name,
139
- action.name,
140
- ];
141
-
142
- return (
143
- <Radio
144
- key={action.name}
145
- aria-label={ariaLabel}
146
- value={action.name}
147
- className="buttons-widget-option"
148
- >
149
- {typeof iconOrText === 'string' ? (
150
- <div className="image-sizes-text">{iconOrText}</div>
151
- ) : (
152
- <Icon
153
- name={iconOrText}
154
- title={ariaLabel || action.name}
155
- size="24px"
156
- ariaHidden={true}
157
- />
158
- )}
159
- </Radio>
160
- );
161
- })}
162
- </RadioGroup>
163
- </FormFieldWrapper>
164
- );
165
- };
166
-
167
- export default ButtonsWidget;
@@ -1,78 +0,0 @@
1
- import { defineMessages, useIntl } from 'react-intl';
2
- import ButtonsWidget, {
3
- type ActionInfo,
4
- type ButtonsWidgetProps,
5
- } from './Buttons';
6
- import type { IntlShape } from 'react-intl';
7
- import type { StyleDefinition } from '@plone/types';
8
-
9
- const messages = defineMessages({
10
- s: {
11
- id: 'Small',
12
- defaultMessage: 'Small',
13
- },
14
- m: {
15
- id: 'Medium',
16
- defaultMessage: 'Medium',
17
- },
18
- l: {
19
- id: 'Large',
20
- defaultMessage: 'Large',
21
- },
22
- });
23
-
24
- export const defaultActionsInfo = ({
25
- intl,
26
- }: {
27
- intl: IntlShape;
28
- }): Record<string, ActionInfo> => ({
29
- s: ['S', intl.formatMessage(messages.s)],
30
- m: ['M', intl.formatMessage(messages.m)],
31
- l: ['L', intl.formatMessage(messages.l)],
32
- });
33
-
34
- const DEFAULT_ACTIONS: StyleDefinition[] = [
35
- {
36
- name: 's',
37
- label: 'Small',
38
- style: undefined,
39
- },
40
- {
41
- name: 'm',
42
- label: 'Medium',
43
- style: undefined,
44
- },
45
- {
46
- name: 'l',
47
- label: 'Large',
48
- style: undefined,
49
- },
50
- ];
51
-
52
- const SizeWidget = (props: ButtonsWidgetProps) => {
53
- const intl = useIntl();
54
-
55
- const { actions = DEFAULT_ACTIONS, actionsInfoMap, filterActions } = props;
56
- const filteredActions =
57
- filterActions && filterActions.length > 0
58
- ? actions.filter((action) => {
59
- const actionName = typeof action === 'string' ? action : action.name;
60
- return filterActions.includes(actionName);
61
- })
62
- : actions;
63
-
64
- const actionsInfo = {
65
- ...defaultActionsInfo({ intl }),
66
- ...actionsInfoMap,
67
- };
68
-
69
- return (
70
- <ButtonsWidget
71
- {...props}
72
- actions={filteredActions}
73
- actionsInfoMap={actionsInfo}
74
- />
75
- );
76
- };
77
-
78
- export default SizeWidget;