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

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,16 +1,14 @@
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)
1
+ ## 8.0.0-alpha.7 (2025-11-24)
6
2
 
7
3
  ### Bugfix
8
4
 
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
5
+ - Fix css of unauthorized Page @iRohitSingh [#unauthorizedpageCss](https://github.com/kitconcept/volto-light-theme/pull/unauthorizedpageCss)
6
+ - Fixed Accordion arrow/buttons no longer shrink when the title text becomes too long. @jnptk
11
7
 
12
8
  ### Internal
13
9
 
14
- - Normalize all remaining add-ons to be "alpha" versions exclusively for vlt8. @sneridagh
10
+ - Refactoring to TS some components. @sneridagh [#731](https://github.com/kitconcept/volto-light-theme/pull/731)
11
+ - Update to latest RAC to match the @plone/components one. @sneridagh
12
+ - Use forked Razzle. Update to use Volto 19a14. @sneridagh
15
13
 
16
14
 
package/CHANGELOG.md CHANGED
@@ -8,6 +8,25 @@
8
8
 
9
9
  <!-- towncrier release notes start -->
10
10
 
11
+ ## 8.0.0-alpha.7 (2025-11-24)
12
+
13
+ ### Bugfix
14
+
15
+ - Fix css of unauthorized Page @iRohitSingh [#unauthorizedpageCss](https://github.com/kitconcept/volto-light-theme/pull/unauthorizedpageCss)
16
+ - Fixed Accordion arrow/buttons no longer shrink when the title text becomes too long. @jnptk
17
+
18
+ ### Internal
19
+
20
+ - Refactoring to TS some components. @sneridagh [#731](https://github.com/kitconcept/volto-light-theme/pull/731)
21
+ - Update to latest RAC to match the @plone/components one. @sneridagh
22
+ - Use forked Razzle. Update to use Volto 19a14. @sneridagh
23
+
24
+ ## 8.0.0-alpha.6 (2025-11-13)
25
+
26
+ ### Bugfix
27
+
28
+ - Missing @kitconcept/volto-bm3-compat as dep. @sneridagh [#726](https://github.com/kitconcept/volto-light-theme/pull/726)
29
+
11
30
  ## 8.0.0-alpha.5 (2025-11-11)
12
31
 
13
32
  ### Feature
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kitconcept/volto-light-theme",
3
- "version": "8.0.0-alpha.5",
3
+ "version": "8.0.0-alpha.7",
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.10"
48
+ "@plone/types": "2.0.0-alpha.11"
49
49
  },
50
50
  "dependencies": {
51
51
  "@dnd-kit/core": "6.0.8",
@@ -54,10 +54,11 @@
54
54
  "embla-carousel-autoplay": "^8.0.0",
55
55
  "embla-carousel-react": "^8.0.0",
56
56
  "react-animate-height": "^3.2.3",
57
- "react-aria-components": "^1.12.1",
57
+ "react-aria-components": "^1.13.0",
58
58
  "react-colorful": "^5.6.1",
59
59
  "uuid": "^11.0.0",
60
- "@plone/components": "^4.0.0-alpha.1"
60
+ "@plone/components": "^4.0.0-alpha.4",
61
+ "@kitconcept/volto-bm3-compat": "^1.0.0-alpha.1"
61
62
  },
62
63
  "peerDependencies": {
63
64
  "classnames": "^2.5.1",
@@ -0,0 +1,31 @@
1
+ import React from 'react';
2
+
3
+ type GenericProps = {
4
+ children?: React.ReactNode;
5
+ } & Record<string, unknown>;
6
+
7
+ const createComponent =
8
+ (element: keyof JSX.IntrinsicElements = 'div') =>
9
+ ({ children, ...rest }: GenericProps) =>
10
+ React.createElement(element, rest, children);
11
+
12
+ export const Container = createComponent('div');
13
+ export const Segment = createComponent('section');
14
+ export const Button = createComponent('button');
15
+ export const Form = createComponent('form');
16
+ export const Input = createComponent('input');
17
+ export const Pagination = createComponent('div');
18
+ export const Dimmer = createComponent('div');
19
+ export const Loader = createComponent('div');
20
+
21
+ // eslint-disable-next-line import/no-anonymous-default-export
22
+ export default {
23
+ Container,
24
+ Segment,
25
+ Button,
26
+ Form,
27
+ Input,
28
+ Pagination,
29
+ Dimmer,
30
+ Loader,
31
+ };
@@ -0,0 +1,69 @@
1
+ import { addStyling } from '@plone/volto/helpers/Extensions/withBlockSchemaEnhancer';
2
+ import { defineMessages } from 'react-intl';
3
+ import config from '@plone/volto/registry';
4
+ import type {
5
+ BlocksFormData,
6
+ JSONSchema,
7
+ SchemaEnhancerArgs,
8
+ StyleDefinition,
9
+ } from '@plone/types';
10
+
11
+ const messages = defineMessages({
12
+ backgroundColor: {
13
+ id: 'Background color',
14
+ defaultMessage: 'Background color',
15
+ },
16
+ });
17
+
18
+ const getThemes = (formData: BlocksFormData): StyleDefinition[] | undefined => {
19
+ const blockConfig = config.blocks?.blocksConfig?.[formData['@type']];
20
+
21
+ return blockConfig?.themes || config.blocks.themes;
22
+ };
23
+
24
+ const getDefaultTheme = (formData: BlocksFormData): string | undefined => {
25
+ const blockConfig = config.blocks?.blocksConfig?.[formData['@type']];
26
+
27
+ return (
28
+ blockConfig?.defaultTheme || config.blocks.themes?.[0]?.name || undefined
29
+ );
30
+ };
31
+
32
+ export const defaultStylingSchema = ({
33
+ schema,
34
+ formData,
35
+ intl,
36
+ }: SchemaEnhancerArgs): JSONSchema => {
37
+ const themes = getThemes(formData);
38
+ const defaultTheme = getDefaultTheme(formData);
39
+
40
+ addStyling({ schema, intl });
41
+
42
+ const stylingIndex = schema.fieldsets.findIndex(
43
+ (item) => item.id === 'styling',
44
+ );
45
+
46
+ if (stylingIndex === -1 || !schema.fieldsets[stylingIndex]) {
47
+ return schema;
48
+ }
49
+
50
+ const stylingFieldset = schema.fieldsets[stylingIndex];
51
+
52
+ stylingFieldset.fields = [...stylingFieldset.fields, 'theme'];
53
+
54
+ schema.properties.theme = {
55
+ widget: 'color_picker',
56
+ title: intl.formatMessage(messages.backgroundColor),
57
+ themes,
58
+ default: defaultTheme,
59
+ };
60
+
61
+ return schema;
62
+ };
63
+
64
+ export const removeStylingSchema = ({
65
+ schema,
66
+ }: Pick<SchemaEnhancerArgs, 'schema'>): JSONSchema => {
67
+ schema.fieldsets = schema.fieldsets.filter((item) => item.id !== 'styling');
68
+ return schema;
69
+ };
@@ -0,0 +1,128 @@
1
+ import React from 'react';
2
+ import { describe, expect, it, beforeEach, vi } from 'vitest';
3
+ import { render, screen } from '@testing-library/react';
4
+ import configureStore from 'redux-mock-store';
5
+ import { Provider } from 'react-intl-redux';
6
+ import { MemoryRouter } from 'react-router-dom';
7
+
8
+ import Breadcrumbs from './Breadcrumbs';
9
+ import { getBreadcrumbs } from '@plone/volto/actions/breadcrumbs/breadcrumbs';
10
+ import { getBaseUrl } from '@plone/volto/helpers/Url/Url';
11
+ import { hasApiExpander } from '@plone/volto/helpers/Utils/Utils';
12
+
13
+ vi.mock('@plone/components', () => ({
14
+ Container: ({ children }: { children: React.ReactNode }) => (
15
+ <div data-testid="container">{children}</div>
16
+ ),
17
+ }));
18
+
19
+ vi.mock('@plone/volto/components/theme/Icon/Icon', () => ({
20
+ __esModule: true,
21
+ default: () => <span data-testid="home-icon" />,
22
+ }));
23
+
24
+ vi.mock('@plone/volto/actions/breadcrumbs/breadcrumbs', () => ({
25
+ getBreadcrumbs: vi.fn(() => ({ type: 'GET_BREADCRUMBS' })),
26
+ }));
27
+
28
+ vi.mock('@plone/volto/helpers/Url/Url', () => ({
29
+ getBaseUrl: vi.fn(() => '/base'),
30
+ }));
31
+
32
+ vi.mock('@plone/volto/helpers/Utils/Utils', () => ({
33
+ hasApiExpander: vi.fn(() => false),
34
+ }));
35
+
36
+ const mockStore = configureStore<{
37
+ breadcrumbs: {
38
+ items: { title: string; url: string }[];
39
+ root?: string;
40
+ };
41
+ intl: {
42
+ locale: string;
43
+ messages: Record<string, string>;
44
+ };
45
+ }>();
46
+
47
+ const mockedGetBreadcrumbs = vi.mocked(getBreadcrumbs);
48
+ const mockedGetBaseUrl = vi.mocked(getBaseUrl);
49
+ const mockedHasApiExpander = vi.mocked(hasApiExpander);
50
+
51
+ const renderBreadcrumbs = (
52
+ options: {
53
+ pathname?: string;
54
+ items?: { title: string; url: string }[];
55
+ root?: string;
56
+ } = {},
57
+ ) => {
58
+ const {
59
+ pathname = '/current-path',
60
+ items = [
61
+ { title: 'Section one', url: '/section-one' },
62
+ { title: 'Current page', url: '/section-one/current' },
63
+ ],
64
+ root = '/home',
65
+ } = options;
66
+
67
+ const store = mockStore({
68
+ breadcrumbs: {
69
+ items,
70
+ root,
71
+ },
72
+ intl: {
73
+ locale: 'en',
74
+ messages: {},
75
+ },
76
+ });
77
+
78
+ const renderResult = render(
79
+ <Provider store={store}>
80
+ <MemoryRouter>
81
+ <Breadcrumbs pathname={pathname} />
82
+ </MemoryRouter>
83
+ </Provider>,
84
+ );
85
+
86
+ return { store, ...renderResult };
87
+ };
88
+
89
+ describe('Breadcrumbs', () => {
90
+ beforeEach(() => {
91
+ vi.clearAllMocks();
92
+ mockedGetBaseUrl.mockReturnValue('/base');
93
+ mockedGetBreadcrumbs.mockReturnValue({ type: 'GET_BREADCRUMBS' });
94
+ });
95
+
96
+ it('renders the home link and breadcrumb items', () => {
97
+ mockedHasApiExpander.mockReturnValue(true);
98
+ const { container } = renderBreadcrumbs();
99
+
100
+ const homeLink = screen.getByTitle('Home') as HTMLAnchorElement;
101
+ expect(homeLink).toHaveAttribute('href', '/home');
102
+ expect(screen.getByText('Section one')).toBeInTheDocument();
103
+ expect(screen.getByText('Current page')).toHaveClass('section', {
104
+ exact: false,
105
+ });
106
+ expect(container.querySelector('.section.active')?.textContent).toBe(
107
+ 'Current page',
108
+ );
109
+ });
110
+
111
+ it('dispatches breadcrumb fetching when no expander is registered', () => {
112
+ mockedHasApiExpander.mockReturnValue(false);
113
+ mockedGetBaseUrl.mockReturnValue('/base-url');
114
+ const { store } = renderBreadcrumbs({ pathname: '/another-path' });
115
+
116
+ expect(mockedGetBaseUrl).toHaveBeenCalledWith('/another-path');
117
+ expect(mockedGetBreadcrumbs).toHaveBeenCalledWith('/base-url');
118
+ expect(store.getActions()).toEqual([{ type: 'GET_BREADCRUMBS' }]);
119
+ });
120
+
121
+ it('skips fetching when breadcrumbs expander already exists', () => {
122
+ mockedHasApiExpander.mockReturnValue(true);
123
+ const { store } = renderBreadcrumbs();
124
+
125
+ expect(mockedGetBreadcrumbs).not.toHaveBeenCalled();
126
+ expect(store.getActions()).toHaveLength(0);
127
+ });
128
+ });
@@ -0,0 +1,117 @@
1
+ import React, { useEffect, useMemo } from 'react';
2
+ import {
3
+ type TypedUseSelectorHook,
4
+ useDispatch,
5
+ useSelector,
6
+ } from 'react-redux';
7
+ import { Link, useLocation } from 'react-router-dom';
8
+ import { defineMessages, useIntl } from 'react-intl';
9
+ import { Container } from '@plone/components';
10
+
11
+ import Icon from '@plone/volto/components/theme/Icon/Icon';
12
+ import { getBreadcrumbs } from '@plone/volto/actions/breadcrumbs/breadcrumbs';
13
+ import { getBaseUrl } from '@plone/volto/helpers/Url/Url';
14
+ import { hasApiExpander } from '@plone/volto/helpers/Utils/Utils';
15
+
16
+ import homeSVG from '@plone/volto/icons/home.svg';
17
+
18
+ const messages = defineMessages({
19
+ home: {
20
+ id: 'Home',
21
+ defaultMessage: 'Home',
22
+ },
23
+ breadcrumbs: {
24
+ id: 'Breadcrumbs',
25
+ defaultMessage: 'Breadcrumbs',
26
+ },
27
+ controlpanel: {
28
+ id: 'Site Setup',
29
+ defaultMessage: 'Site Setup',
30
+ },
31
+ });
32
+
33
+ type BreadcrumbItem = {
34
+ title: string;
35
+ url: string;
36
+ };
37
+
38
+ type BreadcrumbsProps = {
39
+ pathname: string;
40
+ };
41
+
42
+ type BreadcrumbState = {
43
+ items: BreadcrumbItem[];
44
+ root?: string | null;
45
+ };
46
+
47
+ type RootState = {
48
+ breadcrumbs: BreadcrumbState;
49
+ };
50
+
51
+ const useRootSelector: TypedUseSelectorHook<RootState> = useSelector;
52
+
53
+ export const BreadcrumbsComponent = ({ pathname }: BreadcrumbsProps) => {
54
+ const intl = useIntl();
55
+ const dispatch = useDispatch();
56
+ const { pathname: realPath } = useLocation();
57
+ const controlpanelItems = useMemo(
58
+ () => [
59
+ {
60
+ url: '/controlpanel',
61
+ title: intl.formatMessage(messages.controlpanel),
62
+ },
63
+ ],
64
+ [intl],
65
+ );
66
+ const storeItems = useRootSelector((state) => state.breadcrumbs.items);
67
+ const root = useRootSelector((state) => state.breadcrumbs.root);
68
+ const items = realPath.startsWith('/controlpanel')
69
+ ? controlpanelItems
70
+ : storeItems;
71
+
72
+ useEffect(() => {
73
+ const baseUrl = getBaseUrl(pathname);
74
+
75
+ if (!hasApiExpander('breadcrumbs', baseUrl)) {
76
+ dispatch(getBreadcrumbs(baseUrl));
77
+ }
78
+ }, [dispatch, pathname]);
79
+
80
+ return (
81
+ <div
82
+ role="navigation"
83
+ aria-label={intl.formatMessage(messages.breadcrumbs)}
84
+ className="breadcrumbs"
85
+ >
86
+ <Container layout>
87
+ <div className="breadcrumb">
88
+ <Link
89
+ to={root || '/'}
90
+ className="home"
91
+ title={intl.formatMessage(messages.home)}
92
+ >
93
+ <Icon name={homeSVG} size="25px" />
94
+ </Link>
95
+ {items.map((item, index) => {
96
+ const isLast = index === items.length - 1;
97
+
98
+ return (
99
+ <React.Fragment key={item.url}>
100
+ <div className="divider" />
101
+ {isLast ? (
102
+ <div className="section active">{item.title}</div>
103
+ ) : (
104
+ <Link to={item.url} className="section">
105
+ {item.title}
106
+ </Link>
107
+ )}
108
+ </React.Fragment>
109
+ );
110
+ })}
111
+ </div>
112
+ </Container>
113
+ </div>
114
+ );
115
+ };
116
+
117
+ export default BreadcrumbsComponent;
@@ -0,0 +1,31 @@
1
+ import React from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+ import { describe, it, expect } from 'vitest';
4
+
5
+ import Caption from './Caption';
6
+
7
+ describe('Caption', () => {
8
+ it('renders title, description lines, and credits', () => {
9
+ const { container } = render(
10
+ <Caption
11
+ title="My image"
12
+ description={'First line\n\nThird line'}
13
+ credit="Photo: Someone"
14
+ />,
15
+ );
16
+
17
+ expect(screen.getByText('My image')).toBeInTheDocument();
18
+ const paragraphs = Array.from(
19
+ container.querySelectorAll('.description p'),
20
+ ).map((node) => node.textContent);
21
+ expect(paragraphs).toEqual(['First line', '\u00A0', 'Third line']);
22
+ expect(screen.getByText('Photo: Someone')).toHaveClass('credits');
23
+ });
24
+
25
+ it('allows configuring the wrapper tag', () => {
26
+ const { container } = render(<Caption as="div" title="Custom tag" />);
27
+ const wrapper = container.querySelector('div');
28
+ expect(wrapper).not.toBeNull();
29
+ expect(wrapper?.tagName).toBe('DIV');
30
+ });
31
+ });
@@ -1,9 +1,9 @@
1
- /**
2
- * Image/video caption component.
3
- * @module components/Image/Caption
4
- */
5
- import React from 'react';
6
- import PropTypes from 'prop-types';
1
+ type CaptionProps = {
2
+ as?: keyof JSX.IntrinsicElements;
3
+ title?: string;
4
+ description?: string;
5
+ credit?: string;
6
+ };
7
7
 
8
8
  /**
9
9
  * Image/video caption component class.
@@ -14,10 +14,14 @@ import PropTypes from 'prop-types';
14
14
  * @params {object} credit Credit text.
15
15
  * @returns {string} Markup of the component.
16
16
  */
17
- const Caption = ({ as = 'figcaption', title, description, credit }) => {
18
- const As = as;
17
+ const Caption = ({
18
+ as: Tag = 'figcaption',
19
+ title,
20
+ description,
21
+ credit,
22
+ }: CaptionProps) => {
19
23
  return (
20
- <As>
24
+ <Tag>
21
25
  {title && <strong className="title">{title}</strong>}
22
26
  {description && (
23
27
  <div className="description">
@@ -27,19 +31,8 @@ const Caption = ({ as = 'figcaption', title, description, credit }) => {
27
31
  </div>
28
32
  )}
29
33
  {credit && <p className="credits">{credit}</p>}
30
- </As>
34
+ </Tag>
31
35
  );
32
36
  };
33
37
 
34
- /**
35
- * Property types.
36
- * @property {Object} propTypes Property types.
37
- * @static
38
- */
39
- Caption.propTypes = {
40
- credit: PropTypes.string,
41
- title: PropTypes.string,
42
- description: PropTypes.string,
43
- };
44
-
45
38
  export default Caption;
@@ -0,0 +1,176 @@
1
+ import React from 'react';
2
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
3
+ import { render, screen, fireEvent } from '@testing-library/react';
4
+ import configureStore from 'redux-mock-store';
5
+ import { Provider } from 'react-intl-redux';
6
+ import { MemoryRouter } from 'react-router-dom';
7
+
8
+ import Navigation from './Navigation';
9
+ import { getNavigation } from '@plone/volto/actions/navigation/navigation';
10
+ import { hasApiExpander } from '@plone/volto/helpers/Utils/Utils';
11
+ import { getBaseUrl } from '@plone/volto/helpers/Url/Url';
12
+
13
+ vi.mock('../../helpers/doesNodeContainClick', () => ({
14
+ __esModule: true,
15
+ default: () => false,
16
+ }));
17
+
18
+ vi.mock('@plone/volto/components/theme/Icon/Icon', () => ({
19
+ __esModule: true,
20
+ default: () => <span data-testid="icon" />,
21
+ }));
22
+
23
+ vi.mock('@plone/volto/components/theme/Navigation/NavItem', () => ({
24
+ __esModule: true,
25
+ default: ({ item }: { item: { title: string } }) => (
26
+ <span data-testid="nav-item">{item.title}</span>
27
+ ),
28
+ }));
29
+
30
+ vi.mock('@plone/volto/actions/navigation/navigation', () => ({
31
+ getNavigation: vi.fn(() => ({ type: 'GET_NAVIGATION' })),
32
+ }));
33
+
34
+ vi.mock('@plone/volto/helpers/Url/Url', () => ({
35
+ getBaseUrl: vi.fn((path: string) => path),
36
+ }));
37
+
38
+ vi.mock('@plone/volto/helpers/Utils/Utils', () => ({
39
+ hasApiExpander: vi.fn(() => false),
40
+ }));
41
+
42
+ vi.mock('@plone/volto/registry', () => ({
43
+ __esModule: true,
44
+ default: {
45
+ settings: {
46
+ navDepth: 3,
47
+ },
48
+ },
49
+ }));
50
+
51
+ vi.mock('@plone/volto/icons/clear.svg', () => ({
52
+ __esModule: true,
53
+ default: 'clear.svg',
54
+ }));
55
+
56
+ const mockStore = configureStore();
57
+
58
+ const mockedHasApiExpander = vi.mocked(hasApiExpander);
59
+ const mockedGetNavigation = vi.mocked(getNavigation);
60
+ const mockedGetBaseUrl = vi.mocked(getBaseUrl);
61
+
62
+ type TestNavigationItem = {
63
+ title: string;
64
+ url: string;
65
+ nav_title?: string;
66
+ items?: TestNavigationItem[];
67
+ };
68
+
69
+ const baseItems: TestNavigationItem[] = [
70
+ { title: 'About', url: '/about' },
71
+ { title: 'News', url: '/news' },
72
+ ];
73
+
74
+ const renderNavigation = ({
75
+ items = baseItems,
76
+ hasFatMenuHeader = false,
77
+ formFatMenu,
78
+ pathname = '/news',
79
+ }: {
80
+ items?: TestNavigationItem[];
81
+ hasFatMenuHeader?: boolean;
82
+ formFatMenu?: boolean;
83
+ pathname?: string;
84
+ } = {}) => {
85
+ const store = mockStore({
86
+ content: {
87
+ data: {
88
+ '@components': {
89
+ inherit: {
90
+ 'voltolighttheme.header': {
91
+ data: { has_fat_menu: hasFatMenuHeader },
92
+ },
93
+ },
94
+ },
95
+ },
96
+ },
97
+ form: {
98
+ global:
99
+ formFatMenu === undefined ? undefined : { has_fat_menu: formFatMenu },
100
+ },
101
+ intl: {
102
+ locale: 'en',
103
+ },
104
+ userSession: {
105
+ token: null,
106
+ },
107
+ navigation: {
108
+ items,
109
+ },
110
+ });
111
+
112
+ const renderResult = render(
113
+ <Provider store={store}>
114
+ <MemoryRouter>
115
+ <Navigation pathname={pathname} />
116
+ </MemoryRouter>
117
+ </Provider>,
118
+ );
119
+
120
+ return { store, ...renderResult };
121
+ };
122
+
123
+ describe('Navigation', () => {
124
+ beforeEach(() => {
125
+ vi.clearAllMocks();
126
+ mockedHasApiExpander.mockReturnValue(false);
127
+ mockedGetNavigation.mockReturnValue({ type: 'GET_NAVIGATION' });
128
+ mockedGetBaseUrl.mockImplementation((path: string) => path);
129
+ });
130
+
131
+ it('renders simple nav items when fat menu is disabled', () => {
132
+ renderNavigation({ hasFatMenuHeader: false });
133
+
134
+ const navItems = screen.getAllByTestId('nav-item');
135
+ expect(navItems).toHaveLength(baseItems.length);
136
+ expect(navItems[0]).toHaveTextContent('About');
137
+ });
138
+
139
+ it('expands and collapses the fat menu', () => {
140
+ const { container } = renderNavigation({
141
+ hasFatMenuHeader: true,
142
+ items: [
143
+ {
144
+ title: 'Sections',
145
+ url: '/sections',
146
+ items: [
147
+ {
148
+ title: 'First',
149
+ url: '/sections/first',
150
+ items: [],
151
+ },
152
+ ],
153
+ },
154
+ ],
155
+ });
156
+
157
+ const openButtons = screen.getAllByRole('button', {
158
+ name: 'Open menu',
159
+ });
160
+ fireEvent.click(openButtons[0]);
161
+ expect(container.querySelector('.submenu.active')).not.toBeNull();
162
+
163
+ const closeButton = screen.getByLabelText('Close menu');
164
+ fireEvent.click(closeButton);
165
+ expect(container.querySelector('.submenu.active')).toBeNull();
166
+ });
167
+
168
+ it('fetches navigation data when no expander is present', () => {
169
+ mockedGetBaseUrl.mockReturnValue('/base');
170
+ const { store } = renderNavigation({ pathname: '/blog' });
171
+
172
+ expect(mockedGetBaseUrl).toHaveBeenCalledWith('/blog');
173
+ expect(mockedGetNavigation).toHaveBeenCalledWith('/base', 3);
174
+ expect(store.getActions()).toEqual([{ type: 'GET_NAVIGATION' }]);
175
+ });
176
+ });
@@ -1,12 +1,9 @@
1
- // SemanticUI-free pre-@plone/components
2
-
3
1
  import React, { useState, useEffect, useRef } from 'react';
4
- import PropTypes from 'prop-types';
5
2
  import isEmpty from 'lodash/isEmpty';
6
3
  import { useDispatch, useSelector, shallowEqual } from 'react-redux';
7
4
  import { NavLink } from 'react-router-dom';
8
5
  import doesNodeContainClick from '../../helpers/doesNodeContainClick';
9
- import { useIntl, defineMessages, injectIntl } from 'react-intl';
6
+ import { useIntl, defineMessages } from 'react-intl';
10
7
  import cx from 'classnames';
11
8
  import { getBaseUrl } from '@plone/volto/helpers/Url/Url';
12
9
  import { hasApiExpander } from '@plone/volto/helpers/Utils/Utils';
@@ -28,31 +25,87 @@ const messages = defineMessages({
28
25
  },
29
26
  });
30
27
 
31
- const Navigation = ({ pathname }) => {
32
- const [desktopMenuOpen, setDesktopMenuOpen] = useState(null);
33
- const [currentOpenIndex, setCurrentOpenIndex] = useState(null);
34
- const navigation = useRef(null);
28
+ type NavigationItem = {
29
+ title: string;
30
+ nav_title?: string;
31
+ url: string;
32
+ items?: NavigationItem[];
33
+ };
34
+
35
+ type NavigationProps = {
36
+ pathname: string;
37
+ };
38
+
39
+ type HeaderSettings = {
40
+ has_fat_menu?: boolean;
41
+ };
42
+
43
+ type RootState = {
44
+ content: {
45
+ data?: {
46
+ '@components'?: {
47
+ inherit?: {
48
+ 'voltolighttheme.header'?: {
49
+ data?: HeaderSettings;
50
+ };
51
+ };
52
+ };
53
+ };
54
+ };
55
+ form: {
56
+ global?: {
57
+ has_fat_menu?: boolean;
58
+ [key: string]: unknown;
59
+ };
60
+ };
61
+ intl: {
62
+ locale: string;
63
+ };
64
+ userSession: {
65
+ token?: string | null;
66
+ };
67
+ navigation: {
68
+ items: NavigationItem[];
69
+ };
70
+ };
71
+
72
+ const Navigation = ({ pathname }: NavigationProps) => {
73
+ const [desktopMenuOpen, setDesktopMenuOpen] = useState<number | null>(null);
74
+ const [currentOpenIndex, setCurrentOpenIndex] = useState<number | null>(null);
75
+ const navigation = useRef<HTMLElement | null>(null);
35
76
  const dispatch = useDispatch();
36
77
  const intl = useIntl();
37
78
  const headerSettings = useSelector(
38
- (state) =>
79
+ (state: RootState) =>
39
80
  state.content.data?.['@components']?.inherit?.['voltolighttheme.header']
40
81
  ?.data,
41
82
  );
42
- const formData = useSelector((state) => state.form.global);
83
+ const formData = useSelector((state: RootState) => state.form.global);
43
84
 
44
- const has_fat_menu =
45
- !isEmpty(formData) && formData?.has_fat_menu
85
+ const hasFatMenuSetting =
86
+ !isEmpty(formData) && formData?.has_fat_menu !== undefined
46
87
  ? formData.has_fat_menu
47
88
  : headerSettings?.has_fat_menu;
89
+ const hasFatMenu = hasFatMenuSetting ?? false;
90
+
91
+ const lang = useSelector((state: RootState) => state.intl.locale);
92
+ const token = useSelector(
93
+ (state: RootState) => state.userSession.token,
94
+ shallowEqual,
95
+ );
96
+ const items = useSelector(
97
+ (state: RootState) => state.navigation.items,
98
+ shallowEqual,
99
+ );
48
100
 
49
- const lang = useSelector((state) => state.intl.locale);
50
- const token = useSelector((state) => state.userSession.token, shallowEqual);
51
- const items = useSelector((state) => state.navigation.items, shallowEqual);
101
+ const closeMenu = () => {
102
+ setDesktopMenuOpen(null);
103
+ setCurrentOpenIndex(null);
104
+ };
52
105
 
53
106
  useEffect(() => {
54
- const handleClickOutside = (e) => {
55
- if (navigation.current && doesNodeContainClick(navigation.current, e))
107
+ const handleClickOutside = (event: MouseEvent) => {
108
+ if (navigation.current && doesNodeContainClick(navigation.current, event))
56
109
  return;
57
110
  closeMenu();
58
111
  };
@@ -70,11 +123,11 @@ const Navigation = ({ pathname }) => {
70
123
  }
71
124
  }, [pathname, token, dispatch]);
72
125
 
73
- const isActive = (url) => {
126
+ const isActive = (url: string) => {
74
127
  return (url === '' && pathname === '/') || (url !== '' && pathname === url);
75
128
  };
76
129
 
77
- const openMenu = (index) => {
130
+ const openMenu = (index: number) => {
78
131
  if (index === currentOpenIndex) {
79
132
  setDesktopMenuOpen(null);
80
133
  setCurrentOpenIndex(null);
@@ -84,14 +137,9 @@ const Navigation = ({ pathname }) => {
84
137
  }
85
138
  };
86
139
 
87
- const closeMenu = (index) => {
88
- setDesktopMenuOpen(null);
89
- setCurrentOpenIndex(null);
90
- };
91
-
92
140
  useEffect(() => {
93
- const handleEsc = (event) => {
94
- if (event.keyCode === 27) {
141
+ const handleEsc = (event: KeyboardEvent) => {
142
+ if (event.key === 'Escape' || event.keyCode === 27) {
95
143
  closeMenu();
96
144
  }
97
145
  };
@@ -113,7 +161,7 @@ const Navigation = ({ pathname }) => {
113
161
  <ul className="desktop-menu">
114
162
  {items.map((item, index) => (
115
163
  <li key={item.url}>
116
- {has_fat_menu ? (
164
+ {hasFatMenu ? (
117
165
  <>
118
166
  <button
119
167
  onClick={() => openMenu(index)}
@@ -123,7 +171,7 @@ const Navigation = ({ pathname }) => {
123
171
  (!desktopMenuOpen && pathname === item.url),
124
172
  })}
125
173
  aria-label={intl.formatMessage(messages.openFatMenu)}
126
- aria-expanded={desktopMenuOpen === index ? true : false}
174
+ aria-expanded={desktopMenuOpen === index}
127
175
  >
128
176
  {item.title}
129
177
  </button>
@@ -212,12 +260,4 @@ const Navigation = ({ pathname }) => {
212
260
  );
213
261
  };
214
262
 
215
- Navigation.propTypes = {
216
- pathname: PropTypes.string.isRequired,
217
- };
218
-
219
- Navigation.defaultProps = {
220
- token: null,
221
- };
222
-
223
- export default injectIntl(Navigation);
263
+ export default Navigation;
@@ -0,0 +1,71 @@
1
+ import React from 'react';
2
+ import { describe, it, beforeEach, expect, vi } from 'vitest';
3
+ import { render, screen } from '@testing-library/react';
4
+ import { MemoryRouter } from 'react-router-dom';
5
+
6
+ import Tags from './Tags';
7
+ import type { Content } from '@plone/types';
8
+
9
+ const mockConfig = vi.hoisted(() => ({
10
+ getComponent: vi.fn(() => ({
11
+ component: ({ children }: { children: React.ReactNode }) => (
12
+ <div data-testid="container">{children}</div>
13
+ ),
14
+ })),
15
+ settings: {
16
+ showTags: true,
17
+ },
18
+ }));
19
+
20
+ vi.mock('@plone/registry', () => ({
21
+ __esModule: true,
22
+ default: mockConfig,
23
+ }));
24
+
25
+ const mockUseLiveData = vi.fn();
26
+
27
+ vi.mock('@kitconcept/volto-light-theme/helpers/useLiveData', () => ({
28
+ useLiveData: (content: unknown, behavior: unknown, field: string) =>
29
+ mockUseLiveData(content, behavior, field),
30
+ }));
31
+
32
+ describe('Tags', () => {
33
+ beforeEach(() => {
34
+ vi.clearAllMocks();
35
+ mockConfig.settings.showTags = true;
36
+ });
37
+
38
+ it('renders tag links when enabled', () => {
39
+ mockUseLiveData.mockReturnValue(['React', 'Volto']);
40
+ render(
41
+ <MemoryRouter>
42
+ <Tags content={{ subjects: ['React', 'Volto'] } as Content} />
43
+ </MemoryRouter>,
44
+ );
45
+
46
+ const links = screen.getAllByRole('link');
47
+ expect(links).toHaveLength(2);
48
+ expect(links[0]).toHaveAttribute('href', '/search?Subject=React');
49
+ expect(links[1]).toHaveTextContent('Volto');
50
+ });
51
+
52
+ it('returns null when tags are hidden or empty', () => {
53
+ mockConfig.settings.showTags = false;
54
+ mockUseLiveData.mockReturnValue(['React']);
55
+ const { container: hidden } = render(
56
+ <MemoryRouter>
57
+ <Tags />
58
+ </MemoryRouter>,
59
+ );
60
+ expect(hidden.firstChild).toBeNull();
61
+
62
+ mockConfig.settings.showTags = true;
63
+ mockUseLiveData.mockReturnValue([]);
64
+ const { container: empty } = render(
65
+ <MemoryRouter>
66
+ <Tags />
67
+ </MemoryRouter>,
68
+ );
69
+ expect(empty.firstChild).toBeNull();
70
+ });
71
+ });
@@ -1,11 +1,17 @@
1
+ import React from 'react';
1
2
  import { Link } from 'react-router-dom';
2
- import PropTypes from 'prop-types';
3
3
  import { Container as SemanticContainer } from 'semantic-ui-react';
4
4
  import config from '@plone/registry';
5
5
  import { useLiveData } from '@kitconcept/volto-light-theme/helpers/useLiveData';
6
+ import type { Content } from '@plone/types';
6
7
 
7
- const Tags = ({ content }) => {
8
- const tags = useLiveData(content, undefined, 'subjects') || [];
8
+ type TagsProps = {
9
+ content?: Content;
10
+ };
11
+
12
+ const Tags: React.FC<TagsProps> = ({ content }) => {
13
+ const safeContent = content ?? ({ subjects: [] } as Content);
14
+ const tags = useLiveData<string[]>(safeContent, undefined, 'subjects') || [];
9
15
  const Container =
10
16
  config.getComponent({ name: 'Container' }).component || SemanticContainer;
11
17
 
@@ -22,26 +28,4 @@ const Tags = ({ content }) => {
22
28
  );
23
29
  };
24
30
 
25
- /**
26
- * Property types.
27
- * @property {Object} propTypes Property types.
28
- * @static
29
- */
30
- Tags.propTypes = {
31
- content: PropTypes.shape({
32
- subjects: PropTypes.arrayOf(PropTypes.string),
33
- }),
34
- };
35
-
36
- /**
37
- * Default properties.
38
- * @property {Object} defaultProps Default properties.
39
- * @static
40
- */
41
- Tags.defaultProps = {
42
- content: {
43
- subjects: [],
44
- },
45
- };
46
-
47
31
  export default Tags;
@@ -65,6 +65,7 @@ declare module '@plone/types' {
65
65
  }
66
66
  export interface BlockConfigBase {
67
67
  themes?: StyleDefinition[];
68
+ defaultTheme: string;
68
69
  allowedBlocks?: string[];
69
70
  allowed_headline_tags?: string[][];
70
71
  unwantedButtons?: string[];
@@ -0,0 +1,94 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { describe, expect, it } from 'vitest';
5
+
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = path.dirname(__filename);
8
+
9
+ const recommendedAddonsPath = path.resolve(
10
+ __dirname,
11
+ '../../../..',
12
+ 'recommendedAddons.json',
13
+ );
14
+ const mrsDeveloperPath = path.resolve(
15
+ __dirname,
16
+ '../../..',
17
+ 'mrs.developer.json',
18
+ );
19
+
20
+ const readJSON = <T>(filePath: string): T =>
21
+ JSON.parse(fs.readFileSync(filePath, 'utf-8'));
22
+
23
+ const normalizeVersion = (version: unknown) =>
24
+ typeof version === 'string' ? version.replace(/^[\\^~]/, '') : version;
25
+
26
+ const excludedPackages = new Set(['@eeacms/volto-accordion-block']);
27
+
28
+ describe('internal checks', () => {
29
+ it('keeps recommended add-ons in sync with mrs.developer.json', () => {
30
+ const recommendedAddons = readJSON<Record<string, string>>(
31
+ recommendedAddonsPath,
32
+ );
33
+ const mrsDeveloper = readJSON<Record<string, unknown>>(mrsDeveloperPath);
34
+
35
+ const mrsByPackage = Object.entries(mrsDeveloper).reduce<
36
+ Record<string, Record<string, unknown> & { configKey: string }>
37
+ >((acc, [configKey, configValue]) => {
38
+ if (
39
+ configValue &&
40
+ typeof configValue === 'object' &&
41
+ 'package' in configValue
42
+ ) {
43
+ acc[String((configValue as Record<string, unknown>).package)] = {
44
+ ...(configValue as Record<string, unknown>),
45
+ configKey,
46
+ };
47
+ }
48
+ return acc;
49
+ }, {});
50
+
51
+ const issues: string[] = [];
52
+
53
+ Object.entries(recommendedAddons).forEach(
54
+ ([packageName, recommendedVersion]) => {
55
+ if (excludedPackages.has(packageName)) return;
56
+
57
+ const mrsEntry = mrsByPackage[packageName];
58
+
59
+ if (!mrsEntry) {
60
+ issues.push(
61
+ `${packageName} is listed in recommendedAddons.json but missing from frontend/mrs.developer.json.`,
62
+ );
63
+ return;
64
+ }
65
+
66
+ const mrsVersion =
67
+ (mrsEntry.tag as string | undefined) ||
68
+ (mrsEntry.branch as string | undefined) ||
69
+ (mrsEntry.version as string | undefined);
70
+
71
+ if (!mrsVersion) {
72
+ issues.push(
73
+ `${packageName} (${mrsEntry.configKey}) has no tag, branch, or version specified in frontend/mrs.developer.json.`,
74
+ );
75
+ return;
76
+ }
77
+
78
+ const normalizedRecommended = normalizeVersion(recommendedVersion);
79
+ const normalizedMrsVersion = normalizeVersion(mrsVersion);
80
+
81
+ if (
82
+ recommendedVersion !== mrsVersion &&
83
+ normalizedRecommended !== normalizedMrsVersion
84
+ ) {
85
+ issues.push(
86
+ `${packageName} differs: recommendedAddons.json=${recommendedVersion} vs frontend/mrs.developer.json(${mrsEntry.configKey})=${mrsVersion}.`,
87
+ );
88
+ }
89
+ },
90
+ );
91
+
92
+ expect(issues).toEqual([]);
93
+ });
94
+ });
@@ -26,6 +26,7 @@
26
26
  & > span,
27
27
  .input-accordion-title {
28
28
  @include introduction();
29
+ max-width: calc(100% - 50px); // 100% - svg height
29
30
  color: $darkGrey;
30
31
  }
31
32
 
@@ -1,3 +1,4 @@
1
+ .view-editview.is-anonymous,
1
2
  .page-not-found {
2
3
  .content-area {
3
4
  @include default-container-width();
package/vitest.config.mjs CHANGED
@@ -10,6 +10,10 @@ export default defineConfig({
10
10
  // Alias for absolute imports
11
11
  '@kitconcept/volto-light-theme': path.resolve(__dirname, './src'),
12
12
  '@kitconcept/volto-light-theme/': path.resolve(__dirname, './src/'),
13
+ 'semantic-ui-react': path.resolve(
14
+ __dirname,
15
+ './src/__mocks__/semantic-ui-react.ts',
16
+ ),
13
17
  },
14
18
  },
15
19
  server: {
package/razzle.extend.js DELETED
@@ -1,38 +0,0 @@
1
- const plugins = (defaultPlugins) => {
2
- const newPlugins = defaultPlugins.filter((plugin) => plugin !== 'scss');
3
- newPlugins.push({
4
- name: 'scss',
5
- options: {
6
- sass: {
7
- dev: {
8
- sassOptions: {
9
- includePaths: ['node_modules'],
10
- outputStyle: 'expanded',
11
- sourceMap: true,
12
- silenceDeprecations: ['legacy-js-api', 'mixed-decls', 'import'],
13
- quietDeps: true,
14
- },
15
- },
16
- prod: {
17
- sassOptions: {
18
- includePaths: ['node_modules'],
19
- outputStyle: 'expanded',
20
- sourceMap: true,
21
- silenceDeprecations: ['legacy-js-api', 'mixed-decls', 'import'],
22
- quietDeps: true,
23
- },
24
- },
25
- },
26
- },
27
- });
28
- return newPlugins;
29
- };
30
-
31
- const modify = (config, { target, dev }, webpack) => {
32
- return config;
33
- };
34
-
35
- module.exports = {
36
- plugins,
37
- modify,
38
- };
@@ -1,44 +0,0 @@
1
- import { addStyling } from '@plone/volto/helpers/Extensions/withBlockSchemaEnhancer';
2
- import { defineMessages } from 'react-intl';
3
- import config from '@plone/volto/registry';
4
-
5
- const messages = defineMessages({
6
- backgroundColor: {
7
- id: 'Background color',
8
- defaultMessage: 'Background color',
9
- },
10
- });
11
-
12
- export const defaultStylingSchema = ({ schema, formData, intl }) => {
13
- const themes =
14
- config.blocks?.blocksConfig?.[formData['@type']]?.themes ||
15
- config.blocks.themes;
16
-
17
- const defaultTheme =
18
- config.blocks?.blocksConfig?.[formData['@type']]?.defaultTheme ||
19
- // The default color is the first color in the themes list
20
- config.blocks.themes?.[0].name;
21
-
22
- addStyling({ schema, intl });
23
-
24
- const stylingIndex = schema.fieldsets.findIndex(
25
- (item) => item.id === 'styling',
26
- );
27
- schema.fieldsets[stylingIndex].fields = [
28
- ...schema.fieldsets[stylingIndex].fields,
29
- 'theme',
30
- ];
31
- schema.properties.theme = {
32
- widget: 'color_picker',
33
- title: intl.formatMessage(messages.backgroundColor),
34
- themes,
35
- default: defaultTheme,
36
- };
37
-
38
- return schema;
39
- };
40
-
41
- export const removeStylingSchema = ({ schema, formData, intl }) => {
42
- schema.fieldsets = schema.fieldsets.filter((item) => item.id !== 'styling');
43
- return schema;
44
- };
@@ -1,118 +0,0 @@
1
- // SemanticUI-free pre-@plone/components
2
- import React, { Component } from 'react';
3
- import PropTypes from 'prop-types';
4
- import { connect } from 'react-redux';
5
- import { compose } from 'redux';
6
- import { Link } from 'react-router-dom';
7
- import { defineMessages, injectIntl } from 'react-intl';
8
- import { Container } from '@plone/components';
9
-
10
- import Icon from '@plone/volto/components/theme/Icon/Icon';
11
- import { getBreadcrumbs } from '@plone/volto/actions/breadcrumbs/breadcrumbs';
12
- import { getBaseUrl } from '@plone/volto/helpers/Url/Url';
13
- import { hasApiExpander } from '@plone/volto/helpers/Utils/Utils';
14
-
15
- import homeSVG from '@plone/volto/icons/home.svg';
16
-
17
- const messages = defineMessages({
18
- home: {
19
- id: 'Home',
20
- defaultMessage: 'Home',
21
- },
22
- breadcrumbs: {
23
- id: 'Breadcrumbs',
24
- defaultMessage: 'Breadcrumbs',
25
- },
26
- });
27
-
28
- /**
29
- * Breadcrumbs container class.
30
- */
31
- export class BreadcrumbsComponent extends Component {
32
- /**
33
- * Property types.
34
- * @property {Object} propTypes Property types.
35
- * @static
36
- */
37
- static propTypes = {
38
- getBreadcrumbs: PropTypes.func.isRequired,
39
- pathname: PropTypes.string.isRequired,
40
- root: PropTypes.string,
41
- items: PropTypes.arrayOf(
42
- PropTypes.shape({
43
- title: PropTypes.string,
44
- url: PropTypes.string,
45
- }),
46
- ).isRequired,
47
- };
48
-
49
- componentDidMount() {
50
- if (!hasApiExpander('breadcrumbs', getBaseUrl(this.props.pathname))) {
51
- this.props.getBreadcrumbs(getBaseUrl(this.props.pathname));
52
- }
53
- }
54
-
55
- /**
56
- * Component will receive props
57
- * @method componentWillReceiveProps
58
- * @param {Object} nextProps Next properties
59
- * @returns {undefined}
60
- */
61
- UNSAFE_componentWillReceiveProps(nextProps) {
62
- if (nextProps.pathname !== this.props.pathname) {
63
- if (!hasApiExpander('breadcrumbs', getBaseUrl(this.props.pathname))) {
64
- this.props.getBreadcrumbs(getBaseUrl(nextProps.pathname));
65
- }
66
- }
67
- }
68
-
69
- /**
70
- * Render method.
71
- * @method render
72
- * @returns {string} Markup for the component.
73
- */
74
- render() {
75
- return (
76
- <div
77
- role="navigation"
78
- aria-label={this.props.intl.formatMessage(messages.breadcrumbs)}
79
- className="breadcrumbs"
80
- >
81
- <Container layout>
82
- <div className="breadcrumb">
83
- <Link
84
- to={this.props.root || '/'}
85
- className="home"
86
- title={this.props.intl.formatMessage(messages.home)}
87
- >
88
- <Icon name={homeSVG} size="25px" />
89
- </Link>
90
- {this.props.items.map((item, index, items) => [
91
- <div className="divider" key={`divider-${item.url}`} />,
92
- index < items.length - 1 ? (
93
- <Link key={item.url} to={item.url} className="section">
94
- {item.title}
95
- </Link>
96
- ) : (
97
- <div className="section active" key={item.url}>
98
- {item.title}
99
- </div>
100
- ),
101
- ])}
102
- </div>
103
- </Container>
104
- </div>
105
- );
106
- }
107
- }
108
-
109
- export default compose(
110
- injectIntl,
111
- connect(
112
- (state) => ({
113
- items: state.breadcrumbs.items,
114
- root: state.breadcrumbs.root,
115
- }),
116
- { getBreadcrumbs },
117
- ),
118
- )(BreadcrumbsComponent);