@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.
Files changed (143) hide show
  1. package/components/componentLibrary/Accordion/AccordionContent/ContentFields.tsx +5 -3
  2. package/components/componentLibrary/Accordion/AccordionItem/StyleFields.tsx +5 -3
  3. package/components/componentLibrary/Accordion/AccordionItem/index.module.scss +2 -2
  4. package/components/componentLibrary/Accordion/AccordionItem/index.tsx +3 -3
  5. package/components/componentLibrary/Accordion/AccordionTitle/ContentFields.tsx +5 -3
  6. package/components/componentLibrary/Accordion/AccordionTitle/index.module.scss +2 -2
  7. package/components/componentLibrary/Accordion/stories/Accordion.stories.tsx +80 -1
  8. package/components/componentLibrary/Accordion/stories/AccordionDecorator.tsx +14 -14
  9. package/components/componentLibrary/Button/ContentFields.tsx +5 -3
  10. package/components/componentLibrary/Button/StyleFields.tsx +5 -3
  11. package/components/componentLibrary/Button/index.module.scss +22 -14
  12. package/components/componentLibrary/Button/index.tsx +6 -6
  13. package/components/componentLibrary/Button/stories/Button.AsButton.stories.tsx +30 -1
  14. package/components/componentLibrary/Button/stories/Button.AsLink.stories.tsx +38 -1
  15. package/components/componentLibrary/Button/stories/ButtonDecorator.tsx +1 -1
  16. package/components/componentLibrary/Card/StyleFields.tsx +5 -3
  17. package/components/componentLibrary/Card/stories/Card.stories.tsx +46 -1
  18. package/components/componentLibrary/Card/stories/CardDecorator.tsx +1 -1
  19. package/components/componentLibrary/Divider/ContentFields.tsx +5 -3
  20. package/components/componentLibrary/Divider/StyleFields.tsx +5 -3
  21. package/components/componentLibrary/Divider/index.module.scss +6 -6
  22. package/components/componentLibrary/Divider/index.tsx +7 -3
  23. package/components/componentLibrary/Divider/stories/Divider.stories.tsx +44 -50
  24. package/components/componentLibrary/Divider/stories/{DividerDecorator.module.css → DividerDecorator.module.scss} +5 -4
  25. package/components/componentLibrary/Divider/stories/DividerDecorator.tsx +1 -1
  26. package/components/componentLibrary/Divider/types.ts +3 -1
  27. package/components/componentLibrary/Drawer/hooks/index.tsx +13 -0
  28. package/components/componentLibrary/Drawer/index.module.scss +94 -0
  29. package/components/componentLibrary/Drawer/index.tsx +131 -0
  30. package/components/componentLibrary/Drawer/llm.txt +416 -0
  31. package/components/componentLibrary/Drawer/stories/Drawer.stories.tsx +512 -0
  32. package/components/componentLibrary/Drawer/stories/DrawerDecorator.module.scss +8 -0
  33. package/components/componentLibrary/Drawer/stories/DrawerDecorator.tsx +18 -0
  34. package/components/componentLibrary/Drawer/types.ts +25 -0
  35. package/components/componentLibrary/Flex/stories/FlexDecorator.tsx +1 -1
  36. package/components/componentLibrary/Flex/types.ts +3 -1
  37. package/components/componentLibrary/Grid/stories/Grid.stories.tsx +454 -152
  38. package/components/componentLibrary/Grid/stories/GridDecorator.tsx +2 -2
  39. package/components/componentLibrary/Heading/ContentFields.tsx +5 -3
  40. package/components/componentLibrary/Heading/StyleFields.tsx +11 -9
  41. package/components/componentLibrary/Heading/index.tsx +3 -3
  42. package/components/componentLibrary/Heading/llm.txt +8 -8
  43. package/components/componentLibrary/Heading/stories/Heading.stories.tsx +3 -3
  44. package/components/componentLibrary/Heading/stories/HeadingDecorator.tsx +1 -1
  45. package/components/componentLibrary/Heading/types.ts +4 -4
  46. package/components/componentLibrary/Icon/ContentFields.tsx +5 -3
  47. package/components/componentLibrary/Icon/stories/Icon.stories.tsx +1 -1
  48. package/components/componentLibrary/Icon/stories/IconDecorator.tsx +1 -1
  49. package/components/componentLibrary/Image/ContentFields.tsx +5 -3
  50. package/components/componentLibrary/Image/index.tsx +4 -4
  51. package/components/componentLibrary/Image/llm.txt +17 -17
  52. package/components/componentLibrary/Image/stories/Image.stories.tsx +61 -18
  53. package/components/componentLibrary/Image/stories/ImageDecorator.tsx +1 -1
  54. package/components/componentLibrary/Image/types.ts +2 -2
  55. package/components/componentLibrary/LanguageSwitcher/ContentFields.tsx +18 -0
  56. package/components/componentLibrary/LanguageSwitcher/LanguageOptions.module.scss +37 -0
  57. package/components/componentLibrary/LanguageSwitcher/LanguageOptions.tsx +65 -0
  58. package/components/componentLibrary/LanguageSwitcher/StyleFields.tsx +48 -0
  59. package/components/componentLibrary/LanguageSwitcher/_dummyData.tsx +247 -0
  60. package/components/componentLibrary/LanguageSwitcher/assets/Globe.tsx +16 -0
  61. package/components/componentLibrary/LanguageSwitcher/index.module.scss +58 -0
  62. package/components/componentLibrary/LanguageSwitcher/index.tsx +125 -0
  63. package/components/componentLibrary/LanguageSwitcher/llm.txt +380 -0
  64. package/components/componentLibrary/LanguageSwitcher/stories/LanguageSwitcher.stories.tsx +349 -0
  65. package/components/componentLibrary/LanguageSwitcher/stories/LanguageSwitcherDecorator.module.scss +5 -0
  66. package/components/componentLibrary/LanguageSwitcher/stories/LanguageSwitcherDecorator.tsx +8 -0
  67. package/components/componentLibrary/LanguageSwitcher/types.ts +48 -0
  68. package/components/componentLibrary/LanguageSwitcher/utils.tsx +38 -0
  69. package/components/componentLibrary/Link/ContentFields.tsx +5 -3
  70. package/components/componentLibrary/Link/StyleFields.tsx +5 -3
  71. package/components/componentLibrary/Link/index.module.scss +10 -0
  72. package/components/componentLibrary/Link/index.tsx +24 -14
  73. package/components/componentLibrary/Link/stories/Link.stories.tsx +35 -5
  74. package/components/componentLibrary/Link/stories/LinkDecorator.tsx +11 -1
  75. package/components/componentLibrary/Link/types.ts +22 -13
  76. package/components/componentLibrary/List/ContentFields.tsx +5 -3
  77. package/components/componentLibrary/List/ListItem/ContentFields.tsx +6 -17
  78. package/components/componentLibrary/List/ListItem/index.module.scss +1 -13
  79. package/components/componentLibrary/List/ListItem/index.tsx +3 -30
  80. package/components/componentLibrary/List/ListItem/types.ts +1 -16
  81. package/components/componentLibrary/List/StyleFields.tsx +15 -18
  82. package/components/componentLibrary/List/index.module.scss +3 -0
  83. package/components/componentLibrary/List/index.tsx +5 -2
  84. package/components/componentLibrary/List/llm.txt +73 -103
  85. package/components/componentLibrary/List/stories/List.stories.tsx +56 -80
  86. package/components/componentLibrary/List/stories/ListDecorator.tsx +3 -6
  87. package/components/componentLibrary/List/types.ts +1 -3
  88. package/components/componentLibrary/Logo/_dummyLogoData.ts +12 -0
  89. package/components/componentLibrary/Logo/assets/hubspot-logo.png +0 -0
  90. package/components/componentLibrary/Logo/index.module.scss +22 -0
  91. package/components/componentLibrary/Logo/index.tsx +73 -0
  92. package/components/componentLibrary/Logo/llm.txt +262 -0
  93. package/components/componentLibrary/Logo/stories/Logo.stories.tsx +88 -0
  94. package/components/componentLibrary/Logo/stories/LogoDecorator.module.scss +10 -0
  95. package/components/componentLibrary/Logo/stories/LogoDecorator.tsx +8 -0
  96. package/components/componentLibrary/Logo/types.tsx +16 -0
  97. package/components/componentLibrary/Menu/ContentFields.tsx +16 -0
  98. package/components/componentLibrary/Menu/MenuItem/Chevron/index.module.scss +6 -0
  99. package/components/componentLibrary/Menu/MenuItem/Chevron/index.tsx +17 -0
  100. package/components/componentLibrary/Menu/MenuItem/index.module.scss +7 -0
  101. package/components/componentLibrary/Menu/MenuItem/index.tsx +266 -0
  102. package/components/componentLibrary/Menu/MenuItem/types.ts +17 -0
  103. package/components/componentLibrary/Menu/NavigationMenu/ContentFields.tsx +20 -0
  104. package/components/componentLibrary/Menu/NavigationMenu/index.tsx +18 -0
  105. package/components/componentLibrary/Menu/NavigationMenu/islands/NavigationMenuIsland.tsx +95 -0
  106. package/components/componentLibrary/Menu/NavigationMenu/islands/index.module.scss +100 -0
  107. package/components/componentLibrary/Menu/NavigationMenu/islands/types.ts +19 -0
  108. package/components/componentLibrary/Menu/NavigationMenu/llm.txt +197 -0
  109. package/components/componentLibrary/Menu/NavigationMenu/stories/NavigationMenu.stories.tsx +286 -0
  110. package/components/componentLibrary/Menu/NavigationMenu/stories/NavigationMenuDecorator.module.scss +15 -0
  111. package/components/componentLibrary/Menu/NavigationMenu/stories/NavigationMenuDecorator.tsx +12 -0
  112. package/components/componentLibrary/Menu/NavigationMenu/types.ts +3 -0
  113. package/components/componentLibrary/Menu/VerticalMenu/ContentFields.tsx +20 -0
  114. package/components/componentLibrary/Menu/VerticalMenu/index.tsx +18 -0
  115. package/components/componentLibrary/Menu/VerticalMenu/islands/index.module.scss +53 -0
  116. package/components/componentLibrary/Menu/VerticalMenu/islands/verticalMenuIsland.tsx +78 -0
  117. package/components/componentLibrary/Menu/VerticalMenu/llm.txt +177 -0
  118. package/components/componentLibrary/Menu/VerticalMenu/stories/VerticalMenu.stories.tsx +242 -0
  119. package/components/componentLibrary/Menu/VerticalMenu/stories/VerticalMenuDecorator.module.scss +19 -0
  120. package/components/componentLibrary/Menu/VerticalMenu/stories/VerticalMenuDecorator.tsx +12 -0
  121. package/components/componentLibrary/Menu/VerticalMenu/types.ts +21 -0
  122. package/components/componentLibrary/Menu/_dummyMenuData.js +1346 -0
  123. package/components/componentLibrary/Menu/types.ts +56 -0
  124. package/components/componentLibrary/Menu/utils/transformMenuData.ts +11 -0
  125. package/components/componentLibrary/_patterns/README.md +15 -17
  126. package/components/componentLibrary/_patterns/checklist-and-examples.md +17 -17
  127. package/components/componentLibrary/_patterns/component-structure.md +21 -23
  128. package/components/componentLibrary/_patterns/css-patterns.md +170 -18
  129. package/components/componentLibrary/_patterns/field-patterns.md +97 -27
  130. package/components/componentLibrary/_patterns/function-declaration-patterns.md +281 -0
  131. package/components/componentLibrary/_patterns/llm-txt.template.md +4 -2
  132. package/components/componentLibrary/_patterns/prop-naming-patterns.md +208 -0
  133. package/components/componentLibrary/_patterns/storybook-patterns.md +25 -8
  134. package/components/componentLibrary/_patterns/typescript-patterns.md +6 -3
  135. package/package.json +4 -2
  136. /package/components/componentLibrary/Button/stories/{ButtonDecorator.module.css → ButtonDecorator.module.scss} +0 -0
  137. /package/components/componentLibrary/Card/stories/{CardDecorator.module.css → CardDecorator.module.scss} +0 -0
  138. /package/components/componentLibrary/Flex/stories/{FlexDecorator.module.css → FlexDecorator.module.scss} +0 -0
  139. /package/components/componentLibrary/Grid/stories/{GridDecorator.module.css → GridDecorator.module.scss} +0 -0
  140. /package/components/componentLibrary/Heading/stories/{HeadingDecorator.module.css → HeadingDecorator.module.scss} +0 -0
  141. /package/components/componentLibrary/Icon/stories/{IconDecorator.module.css → IconDecorator.module.scss} +0 -0
  142. /package/components/componentLibrary/Image/stories/{ImageDecorator.module.css → ImageDecorator.module.scss} +0 -0
  143. /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,8 @@
1
+ import type { Decorator } from '@storybook/react';
2
+ import styles from './LogoDecorator.module.scss';
3
+
4
+ export const withLogoStyles: Decorator = Story => (
5
+ <div className={styles.decoratorContainer}>
6
+ <Story />
7
+ </div>
8
+ );
@@ -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,6 @@
1
+ .menuItemChevron {
2
+ width: 9px;
3
+ height: auto;
4
+ fill: var(--hscl-menuItem-chevron-fill, currentColor);
5
+ padding: 0;
6
+ }
@@ -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,7 @@
1
+ .link {
2
+ display: inline-flex;
3
+ flex-direction: row;
4
+ align-items: center;
5
+ gap: var(--hscl-menuItem-link-gap, 8px);
6
+ justify-content: flex-start;
7
+ }
@@ -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
+