@hubspot/cms-component-library 0.1.0 → 0.2.0
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/components/componentLibrary/Accordion/AccordionContent/ContentFields.tsx +5 -3
- package/components/componentLibrary/Accordion/AccordionItem/StyleFields.tsx +5 -3
- package/components/componentLibrary/Accordion/AccordionItem/index.module.scss +2 -2
- package/components/componentLibrary/Accordion/AccordionItem/index.tsx +3 -3
- package/components/componentLibrary/Accordion/AccordionTitle/ContentFields.tsx +5 -3
- package/components/componentLibrary/Accordion/AccordionTitle/index.module.scss +2 -2
- package/components/componentLibrary/Accordion/stories/Accordion.stories.tsx +80 -1
- package/components/componentLibrary/Accordion/stories/AccordionDecorator.tsx +14 -14
- package/components/componentLibrary/Button/ContentFields.tsx +5 -3
- package/components/componentLibrary/Button/StyleFields.tsx +5 -3
- package/components/componentLibrary/Button/index.module.scss +22 -14
- package/components/componentLibrary/Button/index.tsx +6 -6
- package/components/componentLibrary/Button/stories/Button.AsButton.stories.tsx +30 -1
- package/components/componentLibrary/Button/stories/Button.AsLink.stories.tsx +38 -1
- package/components/componentLibrary/Button/stories/ButtonDecorator.tsx +1 -1
- package/components/componentLibrary/Card/StyleFields.tsx +5 -3
- package/components/componentLibrary/Card/stories/Card.stories.tsx +46 -1
- package/components/componentLibrary/Card/stories/CardDecorator.tsx +1 -1
- package/components/componentLibrary/Divider/ContentFields.tsx +5 -3
- package/components/componentLibrary/Divider/StyleFields.tsx +5 -3
- package/components/componentLibrary/Divider/index.module.scss +6 -6
- package/components/componentLibrary/Divider/index.tsx +7 -3
- package/components/componentLibrary/Divider/stories/Divider.stories.tsx +44 -50
- package/components/componentLibrary/Divider/stories/{DividerDecorator.module.css → DividerDecorator.module.scss} +5 -4
- package/components/componentLibrary/Divider/stories/DividerDecorator.tsx +1 -1
- package/components/componentLibrary/Divider/types.ts +3 -1
- package/components/componentLibrary/Drawer/hooks/index.tsx +13 -0
- package/components/componentLibrary/Drawer/index.module.scss +94 -0
- package/components/componentLibrary/Drawer/index.tsx +131 -0
- package/components/componentLibrary/Drawer/llm.txt +416 -0
- package/components/componentLibrary/Drawer/stories/Drawer.stories.tsx +512 -0
- package/components/componentLibrary/Drawer/stories/DrawerDecorator.module.scss +8 -0
- package/components/componentLibrary/Drawer/stories/DrawerDecorator.tsx +18 -0
- package/components/componentLibrary/Drawer/types.ts +25 -0
- package/components/componentLibrary/Flex/stories/FlexDecorator.tsx +1 -1
- package/components/componentLibrary/Flex/types.ts +3 -1
- package/components/componentLibrary/Grid/stories/Grid.stories.tsx +454 -152
- package/components/componentLibrary/Grid/stories/GridDecorator.tsx +2 -2
- package/components/componentLibrary/Heading/ContentFields.tsx +5 -3
- package/components/componentLibrary/Heading/StyleFields.tsx +11 -9
- package/components/componentLibrary/Heading/index.tsx +3 -3
- package/components/componentLibrary/Heading/llm.txt +8 -8
- package/components/componentLibrary/Heading/stories/Heading.stories.tsx +3 -3
- package/components/componentLibrary/Heading/stories/HeadingDecorator.tsx +1 -1
- package/components/componentLibrary/Heading/types.ts +4 -4
- package/components/componentLibrary/Icon/ContentFields.tsx +5 -3
- package/components/componentLibrary/Icon/stories/Icon.stories.tsx +1 -1
- package/components/componentLibrary/Icon/stories/IconDecorator.tsx +1 -1
- package/components/componentLibrary/Image/ContentFields.tsx +5 -3
- package/components/componentLibrary/Image/index.tsx +4 -4
- package/components/componentLibrary/Image/llm.txt +17 -17
- package/components/componentLibrary/Image/stories/Image.stories.tsx +61 -18
- package/components/componentLibrary/Image/stories/ImageDecorator.tsx +1 -1
- package/components/componentLibrary/Image/types.ts +2 -2
- package/components/componentLibrary/LanguageSwitcher/ContentFields.tsx +18 -0
- package/components/componentLibrary/LanguageSwitcher/LanguageOptions.module.scss +37 -0
- package/components/componentLibrary/LanguageSwitcher/LanguageOptions.tsx +65 -0
- package/components/componentLibrary/LanguageSwitcher/StyleFields.tsx +48 -0
- package/components/componentLibrary/LanguageSwitcher/_dummyData.tsx +247 -0
- package/components/componentLibrary/LanguageSwitcher/assets/Globe.tsx +16 -0
- package/components/componentLibrary/LanguageSwitcher/index.module.scss +58 -0
- package/components/componentLibrary/LanguageSwitcher/index.tsx +125 -0
- package/components/componentLibrary/LanguageSwitcher/llm.txt +380 -0
- package/components/componentLibrary/LanguageSwitcher/stories/LanguageSwitcher.stories.tsx +349 -0
- package/components/componentLibrary/LanguageSwitcher/stories/LanguageSwitcherDecorator.module.scss +5 -0
- package/components/componentLibrary/LanguageSwitcher/stories/LanguageSwitcherDecorator.tsx +8 -0
- package/components/componentLibrary/LanguageSwitcher/types.ts +48 -0
- package/components/componentLibrary/LanguageSwitcher/utils.tsx +38 -0
- package/components/componentLibrary/Link/ContentFields.tsx +5 -3
- package/components/componentLibrary/Link/StyleFields.tsx +5 -3
- package/components/componentLibrary/Link/index.module.scss +10 -0
- package/components/componentLibrary/Link/index.tsx +24 -14
- package/components/componentLibrary/Link/stories/Link.stories.tsx +35 -5
- package/components/componentLibrary/Link/stories/LinkDecorator.tsx +11 -1
- package/components/componentLibrary/Link/types.ts +22 -13
- package/components/componentLibrary/List/ContentFields.tsx +5 -3
- package/components/componentLibrary/List/ListItem/ContentFields.tsx +6 -17
- package/components/componentLibrary/List/ListItem/index.module.scss +1 -13
- package/components/componentLibrary/List/ListItem/index.tsx +3 -30
- package/components/componentLibrary/List/ListItem/types.ts +1 -16
- package/components/componentLibrary/List/StyleFields.tsx +15 -18
- package/components/componentLibrary/List/index.module.scss +3 -0
- package/components/componentLibrary/List/index.tsx +5 -2
- package/components/componentLibrary/List/llm.txt +73 -103
- package/components/componentLibrary/List/stories/List.stories.tsx +56 -80
- package/components/componentLibrary/List/stories/ListDecorator.tsx +3 -6
- package/components/componentLibrary/List/types.ts +1 -3
- package/components/componentLibrary/Logo/_dummyLogoData.ts +12 -0
- package/components/componentLibrary/Logo/assets/hubspot-logo.png +0 -0
- package/components/componentLibrary/Logo/index.module.scss +22 -0
- package/components/componentLibrary/Logo/index.tsx +73 -0
- package/components/componentLibrary/Logo/llm.txt +262 -0
- package/components/componentLibrary/Logo/stories/Logo.stories.tsx +88 -0
- package/components/componentLibrary/Logo/stories/LogoDecorator.module.scss +10 -0
- package/components/componentLibrary/Logo/stories/LogoDecorator.tsx +8 -0
- package/components/componentLibrary/Logo/types.tsx +16 -0
- package/components/componentLibrary/Menu/ContentFields.tsx +16 -0
- package/components/componentLibrary/Menu/MenuItem/Chevron/index.module.scss +6 -0
- package/components/componentLibrary/Menu/MenuItem/Chevron/index.tsx +17 -0
- package/components/componentLibrary/Menu/MenuItem/index.module.scss +7 -0
- package/components/componentLibrary/Menu/MenuItem/index.tsx +266 -0
- package/components/componentLibrary/Menu/MenuItem/types.ts +17 -0
- package/components/componentLibrary/Menu/NavigationMenu/ContentFields.tsx +20 -0
- package/components/componentLibrary/Menu/NavigationMenu/index.tsx +18 -0
- package/components/componentLibrary/Menu/NavigationMenu/islands/NavigationMenuIsland.tsx +95 -0
- package/components/componentLibrary/Menu/NavigationMenu/islands/index.module.scss +100 -0
- package/components/componentLibrary/Menu/NavigationMenu/islands/types.ts +19 -0
- package/components/componentLibrary/Menu/NavigationMenu/llm.txt +197 -0
- package/components/componentLibrary/Menu/NavigationMenu/stories/NavigationMenu.stories.tsx +286 -0
- package/components/componentLibrary/Menu/NavigationMenu/stories/NavigationMenuDecorator.module.scss +15 -0
- package/components/componentLibrary/Menu/NavigationMenu/stories/NavigationMenuDecorator.tsx +12 -0
- package/components/componentLibrary/Menu/NavigationMenu/types.ts +3 -0
- package/components/componentLibrary/Menu/VerticalMenu/ContentFields.tsx +20 -0
- package/components/componentLibrary/Menu/VerticalMenu/index.tsx +18 -0
- package/components/componentLibrary/Menu/VerticalMenu/islands/index.module.scss +53 -0
- package/components/componentLibrary/Menu/VerticalMenu/islands/verticalMenuIsland.tsx +78 -0
- package/components/componentLibrary/Menu/VerticalMenu/llm.txt +177 -0
- package/components/componentLibrary/Menu/VerticalMenu/stories/VerticalMenu.stories.tsx +242 -0
- package/components/componentLibrary/Menu/VerticalMenu/stories/VerticalMenuDecorator.module.scss +19 -0
- package/components/componentLibrary/Menu/VerticalMenu/stories/VerticalMenuDecorator.tsx +12 -0
- package/components/componentLibrary/Menu/VerticalMenu/types.ts +21 -0
- package/components/componentLibrary/Menu/_dummyMenuData.js +1346 -0
- package/components/componentLibrary/Menu/types.ts +56 -0
- package/components/componentLibrary/Menu/utils/transformMenuData.ts +11 -0
- package/components/componentLibrary/_patterns/README.md +15 -17
- package/components/componentLibrary/_patterns/checklist-and-examples.md +17 -17
- package/components/componentLibrary/_patterns/component-structure.md +21 -23
- package/components/componentLibrary/_patterns/css-patterns.md +170 -18
- package/components/componentLibrary/_patterns/field-patterns.md +97 -27
- package/components/componentLibrary/_patterns/function-declaration-patterns.md +281 -0
- package/components/componentLibrary/_patterns/llm-txt.template.md +4 -2
- package/components/componentLibrary/_patterns/prop-naming-patterns.md +208 -0
- package/components/componentLibrary/_patterns/storybook-patterns.md +25 -8
- package/components/componentLibrary/_patterns/typescript-patterns.md +6 -3
- package/package.json +4 -2
- /package/components/componentLibrary/Button/stories/{ButtonDecorator.module.css → ButtonDecorator.module.scss} +0 -0
- /package/components/componentLibrary/Card/stories/{CardDecorator.module.css → CardDecorator.module.scss} +0 -0
- /package/components/componentLibrary/Flex/stories/{FlexDecorator.module.css → FlexDecorator.module.scss} +0 -0
- /package/components/componentLibrary/Grid/stories/{GridDecorator.module.css → GridDecorator.module.scss} +0 -0
- /package/components/componentLibrary/Heading/stories/{HeadingDecorator.module.css → HeadingDecorator.module.scss} +0 -0
- /package/components/componentLibrary/Icon/stories/{IconDecorator.module.css → IconDecorator.module.scss} +0 -0
- /package/components/componentLibrary/Image/stories/{ImageDecorator.module.css → ImageDecorator.module.scss} +0 -0
- /package/components/componentLibrary/Image/stories/assets/{catSmile.jpg → cat-smile.jpg} +0 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import Logo from '../index.js';
|
|
3
|
+
import { LogoProps } from '../types.js';
|
|
4
|
+
import { withLogoStyles } from './LogoDecorator.js';
|
|
5
|
+
import { SBContainer, SBFocusWrapper } from '@sb-utils';
|
|
6
|
+
|
|
7
|
+
const meta: Meta<LogoProps> = {
|
|
8
|
+
title: 'Component Library/Logo',
|
|
9
|
+
component: Logo,
|
|
10
|
+
parameters: {
|
|
11
|
+
layout: 'centered',
|
|
12
|
+
docs: {
|
|
13
|
+
description: {
|
|
14
|
+
component: `The Logo component displays a brand logo with configurable sizing. It automatically wraps the logo in a link if provided in the brand settings, and supports both real brand logos and dummy logo data for development.`,
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
decorators: [withLogoStyles],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export default meta;
|
|
22
|
+
type Story = StoryObj<typeof meta>;
|
|
23
|
+
|
|
24
|
+
export const Default: Story = {
|
|
25
|
+
args: {
|
|
26
|
+
useDummyLogo: true,
|
|
27
|
+
loading: 'eager',
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const SizeVariations: Story = {
|
|
32
|
+
name: 'Size Variations',
|
|
33
|
+
render: () => (
|
|
34
|
+
<SBContainer flex direction="column" gap="large">
|
|
35
|
+
<SBContainer addBackground>
|
|
36
|
+
<h4>No Max Constraints</h4>
|
|
37
|
+
<p>Logo uses its natural dimensions</p>
|
|
38
|
+
<Logo useDummyLogo />
|
|
39
|
+
</SBContainer>
|
|
40
|
+
|
|
41
|
+
<SBContainer addBackground>
|
|
42
|
+
<h4>Max Width Only (120px)</h4>
|
|
43
|
+
<p>Width is constrained, height scales proportionally</p>
|
|
44
|
+
<Logo useDummyLogo maxWidth="120px" />
|
|
45
|
+
</SBContainer>
|
|
46
|
+
|
|
47
|
+
<SBContainer addBackground>
|
|
48
|
+
<h4>Max Height Only (60px)</h4>
|
|
49
|
+
<p>Height is constrained, width scales proportionally</p>
|
|
50
|
+
<Logo useDummyLogo maxHeight="60px" />
|
|
51
|
+
</SBContainer>
|
|
52
|
+
|
|
53
|
+
<SBContainer addBackground>
|
|
54
|
+
<h4>Max Width (150px) and Max Height (50px)</h4>
|
|
55
|
+
<p>Both dimensions are constrained, logo fits within bounds</p>
|
|
56
|
+
<Logo useDummyLogo maxWidth="150px" maxHeight="50px" />
|
|
57
|
+
</SBContainer>
|
|
58
|
+
</SBContainer>
|
|
59
|
+
),
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const InteractionStates: Story = {
|
|
63
|
+
name: 'Interaction States',
|
|
64
|
+
render: () => (
|
|
65
|
+
<SBContainer flex direction="column" gap="large">
|
|
66
|
+
<SBContainer addBackground>
|
|
67
|
+
<h4>With Link (Clickable)</h4>
|
|
68
|
+
<p>The logo is wrapped in a link and shows hover/focus states</p>
|
|
69
|
+
<p>
|
|
70
|
+
<strong>Try:</strong> Hover over and tab to focus
|
|
71
|
+
</p>
|
|
72
|
+
<Logo useDummyLogo />
|
|
73
|
+
</SBContainer>
|
|
74
|
+
|
|
75
|
+
<SBContainer addBackground>
|
|
76
|
+
<h4>Focus State</h4>
|
|
77
|
+
<p>
|
|
78
|
+
In production, this focus outline appears when navigating with the
|
|
79
|
+
keyboard (Tab key). This Storybook demo auto-applies the focus state
|
|
80
|
+
to show how it looks.
|
|
81
|
+
</p>
|
|
82
|
+
<SBFocusWrapper>
|
|
83
|
+
<Logo useDummyLogo />
|
|
84
|
+
</SBFocusWrapper>
|
|
85
|
+
</SBContainer>
|
|
86
|
+
</SBContainer>
|
|
87
|
+
),
|
|
88
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
.decoratorContainer {
|
|
2
|
+
padding: 20px;
|
|
3
|
+
--hscl-logo-display: inline-block;
|
|
4
|
+
--hscl-logo-maxWidth: 100%;
|
|
5
|
+
--hscl-logo-maxHeight: none;
|
|
6
|
+
--hscl-logo-cursor-hover: pointer;
|
|
7
|
+
--hscl-logo-outlineWidth-focus: 2px;
|
|
8
|
+
--hscl-logo-outlineColor-focus: #007aff;
|
|
9
|
+
--hscl-logo-outlineOffset-focus: 2px;
|
|
10
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type LogoData = {
|
|
2
|
+
src: string;
|
|
3
|
+
alt?: string;
|
|
4
|
+
width?: number;
|
|
5
|
+
height?: number;
|
|
6
|
+
link?: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type LogoProps = {
|
|
10
|
+
useDummyLogo?: boolean;
|
|
11
|
+
className?: string;
|
|
12
|
+
style?: React.CSSProperties;
|
|
13
|
+
maxWidth?: string;
|
|
14
|
+
maxHeight?: string;
|
|
15
|
+
loading?: 'lazy' | 'eager' | 'disabled';
|
|
16
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { MenuField } from '@hubspot/cms-components/fields';
|
|
2
|
+
import { ContentFieldsProps } from './types.js';
|
|
3
|
+
|
|
4
|
+
const ContentFields = ({
|
|
5
|
+
menuLabel = 'Menu',
|
|
6
|
+
menuName = 'menu',
|
|
7
|
+
menuDefault = 'default',
|
|
8
|
+
}: ContentFieldsProps) => {
|
|
9
|
+
return (
|
|
10
|
+
<>
|
|
11
|
+
<MenuField label={menuLabel} name={menuName} default={menuDefault} />
|
|
12
|
+
</>
|
|
13
|
+
);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export default ContentFields;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import cx from '../../../utils/classname.js';
|
|
2
|
+
import styles from './index.module.scss';
|
|
3
|
+
|
|
4
|
+
export const ChevronIcon = () => {
|
|
5
|
+
return (
|
|
6
|
+
<svg
|
|
7
|
+
className={cx(styles.menuItemChevron, 'menuItemChevron')}
|
|
8
|
+
viewBox="0 0 28 17"
|
|
9
|
+
fill="currentColor"
|
|
10
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
11
|
+
aria-hidden="true"
|
|
12
|
+
role="img"
|
|
13
|
+
>
|
|
14
|
+
<path d="M12.5875 15.8886C13.3687 16.6699 14.6375 16.6699 15.4187 15.8886L27.4187 3.88862C28.2 3.10737 28.2 1.83862 27.4187 1.05737C26.6375 0.276123 25.3687 0.276123 24.5875 1.05737L14 11.6449L3.41249 1.06362C2.63124 0.282372 1.36249 0.282372 0.581238 1.06362C-0.200012 1.84487 -0.200012 3.11362 0.581238 3.89487L12.5812 15.8949L12.5875 15.8886Z" />
|
|
15
|
+
</svg>
|
|
16
|
+
);
|
|
17
|
+
};
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { useRef, useState } from 'react';
|
|
2
|
+
import { EnrichedMenuDataItem } from '../types.js';
|
|
3
|
+
import { ChevronIcon } from './Chevron/index.js';
|
|
4
|
+
import { HandleKeyDownProps, MenuItemProps } from './types.js';
|
|
5
|
+
import styles from './index.module.scss';
|
|
6
|
+
|
|
7
|
+
const MenuItem = ({
|
|
8
|
+
item,
|
|
9
|
+
level,
|
|
10
|
+
maxDepth,
|
|
11
|
+
addArrows = false,
|
|
12
|
+
className = '',
|
|
13
|
+
useNavigationControls = false,
|
|
14
|
+
style = {},
|
|
15
|
+
}: MenuItemProps) => {
|
|
16
|
+
const itemRef = useRef<HTMLLIElement>(null);
|
|
17
|
+
const linkRef = useRef<HTMLAnchorElement>(null);
|
|
18
|
+
const [isSubmenuOpen, setIsSubmenuOpen] = useState(false);
|
|
19
|
+
const hasUrl = Boolean(item?.url && item.url.trim() !== '');
|
|
20
|
+
const hasChildren = Boolean(item?.children?.length);
|
|
21
|
+
const shouldShowChildren = maxDepth
|
|
22
|
+
? hasChildren && level < maxDepth
|
|
23
|
+
: hasChildren;
|
|
24
|
+
|
|
25
|
+
const focusElement = (element: Element | null | undefined) => {
|
|
26
|
+
if (element instanceof HTMLElement) {
|
|
27
|
+
element.focus();
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const focusNextSibling = () => {
|
|
32
|
+
const nextSibling = itemRef.current?.nextElementSibling;
|
|
33
|
+
if (nextSibling) {
|
|
34
|
+
focusElement(nextSibling);
|
|
35
|
+
} else {
|
|
36
|
+
focusFirstSibling();
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const focusPreviousSibling = () => {
|
|
41
|
+
const previousSibling = itemRef.current?.previousElementSibling;
|
|
42
|
+
if (previousSibling) {
|
|
43
|
+
focusElement(previousSibling);
|
|
44
|
+
} else {
|
|
45
|
+
focusLastSibling();
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const focusParentItem = () => {
|
|
50
|
+
const parentItem = itemRef.current?.parentElement?.closest('li');
|
|
51
|
+
focusElement(parentItem);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const focusParentNav = () => {
|
|
55
|
+
const parentNav = itemRef.current?.closest('nav');
|
|
56
|
+
focusElement(parentNav);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const focusFirstSibling = () => {
|
|
60
|
+
const parent = itemRef.current?.parentElement;
|
|
61
|
+
focusElement(parent?.firstElementChild);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const focusLastSibling = () => {
|
|
65
|
+
const parent = itemRef.current?.parentElement;
|
|
66
|
+
focusElement(parent?.lastElementChild);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const openSubmenu = () => {
|
|
70
|
+
setIsSubmenuOpen(true);
|
|
71
|
+
|
|
72
|
+
requestAnimationFrame(() => {
|
|
73
|
+
const firstChild = itemRef.current?.querySelector('ul > li');
|
|
74
|
+
focusElement(firstChild);
|
|
75
|
+
});
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const closeSubmenu = () => {
|
|
79
|
+
setIsSubmenuOpen(false);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const activateLink = () => {
|
|
83
|
+
linkRef.current?.click();
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const navigateToSiblingAndCloseSubmenu = (direction: 'next' | 'previous') => {
|
|
87
|
+
closeSubmenu();
|
|
88
|
+
if (direction === 'next') {
|
|
89
|
+
focusNextSibling();
|
|
90
|
+
} else {
|
|
91
|
+
focusPreviousSibling();
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const returnToParentAndCloseSubmenu = () => {
|
|
96
|
+
focusParentItem();
|
|
97
|
+
closeSubmenu();
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const exitMenuAndCloseSubmenu = () => {
|
|
101
|
+
closeSubmenu();
|
|
102
|
+
itemRef.current?.blur();
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const handleKeyDown = ({ event, menuLevel }: HandleKeyDownProps) => {
|
|
106
|
+
const isTopLevel = menuLevel === 1;
|
|
107
|
+
|
|
108
|
+
event.stopPropagation();
|
|
109
|
+
|
|
110
|
+
switch (event.key) {
|
|
111
|
+
case 'ArrowRight':
|
|
112
|
+
event.preventDefault();
|
|
113
|
+
if (isTopLevel) {
|
|
114
|
+
navigateToSiblingAndCloseSubmenu('next');
|
|
115
|
+
} else if (shouldShowChildren) {
|
|
116
|
+
openSubmenu();
|
|
117
|
+
}
|
|
118
|
+
break;
|
|
119
|
+
|
|
120
|
+
case 'ArrowLeft':
|
|
121
|
+
event.preventDefault();
|
|
122
|
+
if (isTopLevel) {
|
|
123
|
+
navigateToSiblingAndCloseSubmenu('previous');
|
|
124
|
+
} else {
|
|
125
|
+
returnToParentAndCloseSubmenu();
|
|
126
|
+
}
|
|
127
|
+
break;
|
|
128
|
+
|
|
129
|
+
case 'ArrowDown':
|
|
130
|
+
event.preventDefault();
|
|
131
|
+
if (isTopLevel && shouldShowChildren) {
|
|
132
|
+
openSubmenu();
|
|
133
|
+
} else {
|
|
134
|
+
focusNextSibling();
|
|
135
|
+
}
|
|
136
|
+
break;
|
|
137
|
+
|
|
138
|
+
case 'ArrowUp':
|
|
139
|
+
event.preventDefault();
|
|
140
|
+
if (isTopLevel) {
|
|
141
|
+
focusParentNav();
|
|
142
|
+
} else {
|
|
143
|
+
focusPreviousSibling();
|
|
144
|
+
}
|
|
145
|
+
break;
|
|
146
|
+
|
|
147
|
+
case 'Enter':
|
|
148
|
+
case ' ':
|
|
149
|
+
event.preventDefault();
|
|
150
|
+
activateLink();
|
|
151
|
+
break;
|
|
152
|
+
|
|
153
|
+
case 'Escape':
|
|
154
|
+
event.preventDefault();
|
|
155
|
+
if (isTopLevel) {
|
|
156
|
+
exitMenuAndCloseSubmenu();
|
|
157
|
+
} else {
|
|
158
|
+
returnToParentAndCloseSubmenu();
|
|
159
|
+
}
|
|
160
|
+
break;
|
|
161
|
+
|
|
162
|
+
case 'Home':
|
|
163
|
+
event.preventDefault();
|
|
164
|
+
focusFirstSibling();
|
|
165
|
+
break;
|
|
166
|
+
|
|
167
|
+
case 'End':
|
|
168
|
+
event.preventDefault();
|
|
169
|
+
focusLastSibling();
|
|
170
|
+
break;
|
|
171
|
+
|
|
172
|
+
default:
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const renderContent = () => {
|
|
178
|
+
if (hasUrl) {
|
|
179
|
+
const linkAttributes =
|
|
180
|
+
item.linkTarget === '_blank'
|
|
181
|
+
? { target: '_blank', rel: 'noopener noreferrer' }
|
|
182
|
+
: {};
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<a
|
|
186
|
+
ref={linkRef}
|
|
187
|
+
href={item.url!}
|
|
188
|
+
className={styles.link}
|
|
189
|
+
tabIndex={-1}
|
|
190
|
+
{...linkAttributes}
|
|
191
|
+
>
|
|
192
|
+
{item.label}
|
|
193
|
+
{shouldShowChildren && addArrows && <ChevronIcon />}
|
|
194
|
+
</a>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return (
|
|
199
|
+
<span className={styles.link}>
|
|
200
|
+
{item.label}
|
|
201
|
+
{shouldShowChildren && addArrows && <ChevronIcon />}
|
|
202
|
+
</span>
|
|
203
|
+
);
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const handleBlur = (event: React.FocusEvent) => {
|
|
207
|
+
const relatedTarget = event.relatedTarget as Node | null;
|
|
208
|
+
|
|
209
|
+
// Keep submenu open if focus is moving to a child (drilling down)
|
|
210
|
+
if (
|
|
211
|
+
relatedTarget &&
|
|
212
|
+
itemRef.current?.contains(relatedTarget) &&
|
|
213
|
+
relatedTarget !== itemRef.current
|
|
214
|
+
) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Close submenu when moving away (up to parent, sideways to sibling, or outside)
|
|
219
|
+
setIsSubmenuOpen(false);
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const itemStyle = {
|
|
223
|
+
...style,
|
|
224
|
+
'--hscl-menuItem-level': level,
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
return (
|
|
228
|
+
<li
|
|
229
|
+
ref={itemRef}
|
|
230
|
+
className={className}
|
|
231
|
+
style={itemStyle}
|
|
232
|
+
data-level={level}
|
|
233
|
+
data-submenu-open={
|
|
234
|
+
useNavigationControls ? (isSubmenuOpen ? 'true' : 'false') : undefined
|
|
235
|
+
}
|
|
236
|
+
tabIndex={useNavigationControls ? -1 : 0}
|
|
237
|
+
role="menuitem"
|
|
238
|
+
aria-expanded={
|
|
239
|
+
useNavigationControls && shouldShowChildren ? isSubmenuOpen : undefined
|
|
240
|
+
}
|
|
241
|
+
{...(useNavigationControls && {
|
|
242
|
+
onKeyDown: event => handleKeyDown({ event, menuLevel: level }),
|
|
243
|
+
onBlur: handleBlur,
|
|
244
|
+
})}
|
|
245
|
+
>
|
|
246
|
+
{renderContent()}
|
|
247
|
+
|
|
248
|
+
{shouldShowChildren && (
|
|
249
|
+
<ul>
|
|
250
|
+
{item.children.map((child: EnrichedMenuDataItem) => (
|
|
251
|
+
<MenuItem
|
|
252
|
+
key={child._id}
|
|
253
|
+
item={child}
|
|
254
|
+
level={level + 1}
|
|
255
|
+
maxDepth={maxDepth}
|
|
256
|
+
addArrows={addArrows}
|
|
257
|
+
useNavigationControls={useNavigationControls}
|
|
258
|
+
/>
|
|
259
|
+
))}
|
|
260
|
+
</ul>
|
|
261
|
+
)}
|
|
262
|
+
</li>
|
|
263
|
+
);
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
export default MenuItem;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { CSSProperties, KeyboardEvent } from 'react';
|
|
2
|
+
import { EnrichedMenuDataItem } from '../types.js';
|
|
3
|
+
|
|
4
|
+
export type MenuItemProps = {
|
|
5
|
+
item: EnrichedMenuDataItem;
|
|
6
|
+
level: number;
|
|
7
|
+
maxDepth?: number;
|
|
8
|
+
addArrows?: boolean;
|
|
9
|
+
className?: string;
|
|
10
|
+
useNavigationControls?: boolean;
|
|
11
|
+
style?: CSSProperties;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type HandleKeyDownProps = {
|
|
15
|
+
event: KeyboardEvent;
|
|
16
|
+
menuLevel: number;
|
|
17
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import BaseContentFields from '../ContentFields.js';
|
|
2
|
+
import { NavigationMenuContentFieldProps } from './types.js';
|
|
3
|
+
|
|
4
|
+
const ContentFields = ({
|
|
5
|
+
menuLabel,
|
|
6
|
+
menuName,
|
|
7
|
+
menuDefault,
|
|
8
|
+
}: NavigationMenuContentFieldProps) => {
|
|
9
|
+
return (
|
|
10
|
+
<>
|
|
11
|
+
<BaseContentFields
|
|
12
|
+
menuLabel={menuLabel}
|
|
13
|
+
menuName={menuName}
|
|
14
|
+
menuDefault={menuDefault}
|
|
15
|
+
/>
|
|
16
|
+
</>
|
|
17
|
+
);
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export default ContentFields;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// @ts-expect-error -- ?island not typed
|
|
2
|
+
import NavigationMenuIsland from './islands/NavigationMenuIsland.js?island';
|
|
3
|
+
import { NavigationMenuProps } from './islands/types.js';
|
|
4
|
+
import { Island } from '@hubspot/cms-components';
|
|
5
|
+
import ContentFields from './ContentFields.js';
|
|
6
|
+
|
|
7
|
+
const NavigationMenuComponent = (props: NavigationMenuProps) => {
|
|
8
|
+
return <Island module={NavigationMenuIsland} {...props} />;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type NavigationComponentType = typeof NavigationMenuComponent & {
|
|
12
|
+
ContentFields: typeof ContentFields;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const NavigationMenu = NavigationMenuComponent as NavigationComponentType;
|
|
16
|
+
NavigationMenu.ContentFields = ContentFields;
|
|
17
|
+
|
|
18
|
+
export default NavigationMenu;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import MenuItem from '../../MenuItem/index.js';
|
|
2
|
+
import Flex from '../../../Flex/index.js';
|
|
3
|
+
import styles from './index.module.scss';
|
|
4
|
+
import { NavigationMenuProps } from './types.js';
|
|
5
|
+
import cx from '../../../utils/classname.js';
|
|
6
|
+
import { addIdsToMenuItems } from '../../utils/transformMenuData.js';
|
|
7
|
+
import { useMemo, useRef } from 'react';
|
|
8
|
+
|
|
9
|
+
const NavigationMenuIsland = ({
|
|
10
|
+
justifyMenu = 'flex-start',
|
|
11
|
+
className = '',
|
|
12
|
+
navAriaLabel = 'Navigation',
|
|
13
|
+
style = {},
|
|
14
|
+
linkColor,
|
|
15
|
+
linkColorHover,
|
|
16
|
+
backgroundColor,
|
|
17
|
+
backgroundColorHover,
|
|
18
|
+
submenuLinkColor,
|
|
19
|
+
submenuLinkColorHover,
|
|
20
|
+
submenuBackgroundColor,
|
|
21
|
+
submenuBackgroundColorHover,
|
|
22
|
+
menuData,
|
|
23
|
+
}: NavigationMenuProps) => {
|
|
24
|
+
if (!menuData?.pagesTree?.children) return null;
|
|
25
|
+
|
|
26
|
+
const menuItems = useMemo(
|
|
27
|
+
() => addIdsToMenuItems(menuData.pagesTree.children),
|
|
28
|
+
[menuData]
|
|
29
|
+
);
|
|
30
|
+
const level = 1;
|
|
31
|
+
const navRef = useRef<HTMLElement>(null);
|
|
32
|
+
|
|
33
|
+
const handleKeyDown = (event: React.KeyboardEvent) => {
|
|
34
|
+
if (event.key === 'ArrowDown') {
|
|
35
|
+
event.preventDefault();
|
|
36
|
+
navRef.current?.querySelector('li')?.focus();
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const cssVariables = {
|
|
41
|
+
...(linkColor && { '--hscl-navigationMenu-color': linkColor }),
|
|
42
|
+
...(linkColorHover && {
|
|
43
|
+
'--hscl-navigationMenu-color-hover': linkColorHover,
|
|
44
|
+
}),
|
|
45
|
+
...(backgroundColor && {
|
|
46
|
+
'--hscl-navigationMenu-backgroundColor': backgroundColor,
|
|
47
|
+
}),
|
|
48
|
+
...(backgroundColorHover && {
|
|
49
|
+
'--hscl-navigationMenu-backgroundColor-hover': backgroundColorHover,
|
|
50
|
+
}),
|
|
51
|
+
...(submenuLinkColor && {
|
|
52
|
+
'--hscl-navigationMenu-submenu-color': submenuLinkColor,
|
|
53
|
+
}),
|
|
54
|
+
...(submenuLinkColorHover && {
|
|
55
|
+
'--hscl-navigationMenu-submenu-color-hover': submenuLinkColorHover,
|
|
56
|
+
}),
|
|
57
|
+
...(submenuBackgroundColor && {
|
|
58
|
+
'--hscl-navigationMenu-submenu-backgroundColor': submenuBackgroundColor,
|
|
59
|
+
}),
|
|
60
|
+
...(submenuBackgroundColorHover && {
|
|
61
|
+
'--hscl-navigationMenu-submenu-backgroundColor-hover':
|
|
62
|
+
submenuBackgroundColorHover,
|
|
63
|
+
}),
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<nav
|
|
68
|
+
ref={navRef}
|
|
69
|
+
className={cx(styles.navigationMenu, className)}
|
|
70
|
+
style={{ ...cssVariables, ...style }}
|
|
71
|
+
tabIndex={0}
|
|
72
|
+
onKeyDown={handleKeyDown}
|
|
73
|
+
aria-label={navAriaLabel}
|
|
74
|
+
>
|
|
75
|
+
<Flex
|
|
76
|
+
as="ul"
|
|
77
|
+
direction="row"
|
|
78
|
+
className={styles.navList}
|
|
79
|
+
justifyContent={justifyMenu}
|
|
80
|
+
>
|
|
81
|
+
{menuItems.map(item => (
|
|
82
|
+
<MenuItem
|
|
83
|
+
key={item._id}
|
|
84
|
+
item={item}
|
|
85
|
+
level={level}
|
|
86
|
+
addArrows
|
|
87
|
+
useNavigationControls
|
|
88
|
+
/>
|
|
89
|
+
))}
|
|
90
|
+
</Flex>
|
|
91
|
+
</nav>
|
|
92
|
+
);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export default NavigationMenuIsland;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
.navigationMenu {
|
|
2
|
+
width: 100%;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
.navList {
|
|
6
|
+
list-style: none;
|
|
7
|
+
gap: var(--hscl-navigationMenu-gap);
|
|
8
|
+
width: 100%;
|
|
9
|
+
|
|
10
|
+
// base styles for all list items
|
|
11
|
+
li {
|
|
12
|
+
position: relative;
|
|
13
|
+
max-width: 250px;
|
|
14
|
+
|
|
15
|
+
// base styles for all links and spans
|
|
16
|
+
a, span {
|
|
17
|
+
text-decoration: none;
|
|
18
|
+
padding-inline: 20px;
|
|
19
|
+
padding-block: 10px;
|
|
20
|
+
color: var(--hscl-navigationMenu-color, currentColor);
|
|
21
|
+
background-color: var(--hscl-navigationMenu-backgroundColor, transparent);
|
|
22
|
+
|
|
23
|
+
&:hover {
|
|
24
|
+
color: var(--hscl-navigationMenu-color-hover, var(--hscl-navigationMenu-color, currentColor));
|
|
25
|
+
background-color: var(--hscl-navigationMenu-backgroundColor-hover, var(--hscl-navigationMenu-backgroundColor, transparent));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// submenu-specific styles
|
|
31
|
+
ul {
|
|
32
|
+
li {
|
|
33
|
+
background-color: var(--hscl-navigationMenu-submenu-backgroundColor, var(--hscl-navigationMenu-backgroundColor, transparent));
|
|
34
|
+
|
|
35
|
+
a, span {
|
|
36
|
+
color: var(--hscl-navigationMenu-submenu-color, var(--hscl-navigationMenu-color, currentColor));
|
|
37
|
+
background-color: var(--hscl-navigationMenu-submenu-backgroundColor, var(--hscl-navigationMenu-backgroundColor, transparent));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
&:hover {
|
|
41
|
+
color: var(--hscl-navigationMenu-submenu-color-hover, var(--hscl-navigationMenu-submenu-color, var(--hscl-navigationMenu-color-hover, var(--hscl-navigationMenu-color, currentColor))));
|
|
42
|
+
background-color: var(--hscl-navigationMenu-submenu-backgroundColor-hover, var(--hscl-navigationMenu-submenu-backgroundColor, var(--hscl-navigationMenu-backgroundColor-hover, var(--hscl-navigationMenu-backgroundColor, transparent))));
|
|
43
|
+
|
|
44
|
+
> a, > span {
|
|
45
|
+
color: var(--hscl-navigationMenu-submenu-color-hover, var(--hscl-navigationMenu-submenu-color, var(--hscl-navigationMenu-color-hover, var(--hscl-navigationMenu-color, currentColor))));
|
|
46
|
+
background-color: var(--hscl-navigationMenu-submenu-backgroundColor-hover, var(--hscl-navigationMenu-submenu-backgroundColor, var(--hscl-navigationMenu-backgroundColor-hover, var(--hscl-navigationMenu-backgroundColor, transparent))));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Override padding for top level items
|
|
53
|
+
> li {
|
|
54
|
+
> a, > span {
|
|
55
|
+
padding-inline: 10px;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// base styles for all submenu lists
|
|
60
|
+
ul {
|
|
61
|
+
list-style: none;
|
|
62
|
+
display: none;
|
|
63
|
+
margin-inline: 0;
|
|
64
|
+
padding-inline: 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Shared styles for all visible submenus
|
|
68
|
+
li:hover > ul,
|
|
69
|
+
li:not([data-submenu-open]):focus-within > ul,
|
|
70
|
+
li[data-submenu-open="true"] > ul,
|
|
71
|
+
ul:has(> li:focus) {
|
|
72
|
+
display: block;
|
|
73
|
+
position: absolute;
|
|
74
|
+
min-width: max-content;
|
|
75
|
+
background-color: #fff;
|
|
76
|
+
padding: 0;
|
|
77
|
+
|
|
78
|
+
svg {
|
|
79
|
+
transform: rotate(-90deg);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Controlled mode: explicitly hide submenu when state is closed (but allow hover to override)
|
|
84
|
+
li[data-submenu-open="false"]:not(:hover) > ul {
|
|
85
|
+
display: none;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Position top-level submenus below parent
|
|
89
|
+
> li > ul {
|
|
90
|
+
top: 100%;
|
|
91
|
+
left: 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Position nested submenus (level 2+) to the right
|
|
95
|
+
ul ul {
|
|
96
|
+
top: 0;
|
|
97
|
+
left: 100%;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|