@cronos-labs/ui 0.8.0 → 0.9.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 (40) hide show
  1. package/README.md +60 -16
  2. package/dist/Footer/index.d.ts +7 -38
  3. package/dist/Footer/index.js +9 -63
  4. package/dist/Footer/static.d.ts +16 -0
  5. package/dist/Footer/static.js +11 -0
  6. package/dist/Footer/styles.d.ts +2 -2
  7. package/dist/Footer/styles.js +29 -27
  8. package/dist/Footer/types.d.ts +41 -0
  9. package/dist/Footer/types.js +1 -0
  10. package/dist/Footer/view.d.ts +2 -0
  11. package/dist/Footer/view.js +55 -0
  12. package/dist/Header/NavDropdown.d.ts +1 -1
  13. package/dist/Header/NavDropdown.js +3 -3
  14. package/dist/Header/index.d.ts +7 -56
  15. package/dist/Header/index.js +9 -186
  16. package/dist/Header/static.d.ts +15 -0
  17. package/dist/Header/static.js +11 -0
  18. package/dist/Header/styles.d.ts +4 -1
  19. package/dist/Header/styles.js +27 -4
  20. package/dist/Header/types.d.ts +61 -0
  21. package/dist/Header/types.js +1 -0
  22. package/dist/Header/view.d.ts +2 -0
  23. package/dist/Header/view.js +184 -0
  24. package/dist/LocaleSelector/index.d.ts +8 -9
  25. package/dist/LocaleSelector/index.js +10 -61
  26. package/dist/LocaleSelector/view.d.ts +8 -0
  27. package/dist/LocaleSelector/view.js +59 -0
  28. package/dist/config/eslintConfig.d.ts +17 -0
  29. package/dist/config/eslintConfig.js +52 -23
  30. package/dist/index.d.ts +4 -0
  31. package/dist/index.js +4 -0
  32. package/dist/navigation/context.d.ts +26 -0
  33. package/dist/navigation/context.js +18 -0
  34. package/dist/navigation/router.d.ts +11 -0
  35. package/dist/navigation/router.js +25 -0
  36. package/dist/navigation/static.d.ts +15 -0
  37. package/dist/navigation/static.js +19 -0
  38. package/dist/navigationContent.d.ts +6 -0
  39. package/dist/navigationContent.js +27 -0
  40. package/package.json +2 -1
@@ -1,56 +1,7 @@
1
- export type HeaderPresentationVariant = 'default' | 'inverse';
2
- export interface HeaderPresentationState {
3
- backgroundColor: string;
4
- contrast?: boolean;
5
- textColor: string;
6
- variant: HeaderPresentationVariant;
7
- }
8
- export interface HeaderNavChild {
9
- key: string;
10
- label: string;
11
- route?: string;
12
- external?: boolean;
13
- /**
14
- * Renders the child greyed and non-interactive, with `badge` beside it if
15
- * given — for a destination that is not open yet. A disabled child needs
16
- * no `route`.
17
- */
18
- disabled?: boolean;
19
- badge?: string;
20
- }
21
- export interface HeaderNavItem {
22
- label: string;
23
- route?: string;
24
- enabled: boolean;
25
- order: number;
26
- external?: boolean;
27
- placeholder?: boolean;
28
- badge?: string;
29
- children?: HeaderNavChild[];
30
- }
31
- export interface HeaderContent {
32
- joinWaitlist: string;
33
- waitlistEmailCta: string;
34
- localeSelectorAriaLabel: string;
35
- }
36
- export interface HeaderWaitlistCta {
37
- href: string;
38
- onClick?: (event: React.MouseEvent<HTMLAnchorElement>, ctaLocation: 'nav_desktop' | 'nav_mobile') => void;
39
- /** Renders the CTA greyed and non-interactive, keeping its place and label. */
40
- disabled?: boolean;
41
- }
42
- interface HeaderProps {
43
- /** Overrides the wordmark from `<BrandProvider>`. Optional when one is present. */
44
- brandWordmarkSrc?: string;
45
- content: HeaderContent;
46
- forceDocumentNavigation?: boolean;
47
- homeHref: string;
48
- isExternalHref: (href: string) => boolean;
49
- mobilePresentation?: HeaderPresentationState | undefined;
50
- navItems: HeaderNavItem[];
51
- presentation: HeaderPresentationState;
52
- shouldAnimatePresentation: boolean;
53
- waitlistCta?: HeaderWaitlistCta | undefined;
54
- }
55
- export declare function Header({ brandWordmarkSrc, content, forceDocumentNavigation, homeHref, isExternalHref, mobilePresentation, navItems, presentation, shouldAnimatePresentation, waitlistCta, }: HeaderProps): React.JSX.Element;
56
- export {};
1
+ import type { HeaderProps } from './types.js';
2
+ export type { HeaderContent, HeaderNavChild, HeaderNavItem, HeaderPresentationState, HeaderPresentationVariant, HeaderProps, HeaderWaitlistCta, } from './types.js';
3
+ /**
4
+ * The site header for apps with a react-router `<Router>` above it. Static
5
+ * sites without a router render `HeaderStatic` instead.
6
+ */
7
+ export declare function Header(props: HeaderProps): React.JSX.Element;
@@ -1,187 +1,10 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { useEffect, useState } from 'react';
3
- import { useLocation, useNavigate } from 'react-router-dom';
4
- import { LocaleSelector } from '../LocaleSelector/index.js';
5
- import { buildLocalizedPath, stripLocalePrefix } from '../locale/routes.js';
6
- import { useCurrentLocale } from '../locale/hooks.js';
7
- import { desktopViewportQuery } from '../tokens.js';
8
- import { useOptionalBrand } from '../brand/index.js';
9
- import { normalizePathname } from './helper.js';
10
- import { NavDropdown } from './NavDropdown.js';
11
- import { BrandWordmarkMask, HeaderAction, HeaderActionArrow, HeaderBrandLink, HeaderFrame, HeaderMobileNav, HeaderMobileNavDisabledItem, HeaderMobileNavGroup, HeaderMobileNavGroupChevron, HeaderMobileNavGroupPanel, HeaderMobileNavGroupPanelInner, HeaderMobileNavGroupToggle, HeaderMobileNavSubDisabledItem, HeaderMobileNavSubLink, HeaderMobileNavSubList, HeaderMobileNavLink, HeaderMobileOverlay, HeaderMobileLocaleSlot, HeaderMobileSheet, HeaderMobileSoonBadge, HeaderMobileToggle, HeaderMobileToggleIcon, HeaderMobileWaitlistArrow, HeaderMobileWaitlistLabel, HeaderMobileWaitlistLink, HeaderShell, Nav, NavBadge, NavBadgeLabel, Wordmark, } from './styles.js';
12
- function MobileNavGroup({ label, items }) {
13
- const [isExpanded, setIsExpanded] = useState(false);
14
- const panelId = `mobile-nav-group-${label.toLowerCase().replace(/\s+/gu, '-')}`;
15
- return (_jsxs(HeaderMobileNavGroup, { children: [_jsxs(HeaderMobileNavGroupToggle, { type: "button", "aria-expanded": isExpanded, "aria-controls": panelId, onClick: () => setIsExpanded((currentValue) => !currentValue), children: [label, _jsx(HeaderMobileNavGroupChevron, { "$open": isExpanded, "aria-hidden": "true" })] }), _jsx(HeaderMobileNavGroupPanel, { "$open": isExpanded, id: panelId, children: _jsx(HeaderMobileNavGroupPanelInner, { "$open": isExpanded, children: _jsx(HeaderMobileNavSubList, { "aria-hidden": !isExpanded, children: items.map((item) => item.disabled ? (_jsxs(HeaderMobileNavSubDisabledItem, { "aria-disabled": "true", "aria-label": `${item.label} ${item.badge ?? ''}`.trim(), children: [item.label, item.badge ? _jsx(HeaderMobileSoonBadge, { children: item.badge }) : null] }, item.key)) : (_jsx(HeaderMobileNavSubLink, { href: item.href, "aria-label": item.ariaLabel, tabIndex: isExpanded ? undefined : -1, onClick: item.onClick, ...(item.external ? { target: '_blank', rel: 'noreferrer' } : {}), children: item.label }, item.key))) }) }) })] }));
16
- }
17
- export function Header({ brandWordmarkSrc, content, forceDocumentNavigation = false, homeHref, isExternalHref, mobilePresentation, navItems, presentation, shouldAnimatePresentation, waitlistCta, }) {
18
- const brand = useOptionalBrand();
19
- const wordmarkSrc = brandWordmarkSrc ?? brand?.wordmarkSrc;
20
- const brandName = brand?.name ?? '';
21
- // Rendering a header with no wordmark is never what the caller meant, and a
22
- // broken image is harder to diagnose than a named error.
23
- if (wordmarkSrc === undefined) {
24
- throw new Error('<Header> needs a brandWordmarkSrc prop or a <BrandProvider>.');
25
- }
26
- const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
27
- const locale = useCurrentLocale();
28
- const location = useLocation();
29
- const navigate = useNavigate();
30
- const localizedHomePath = normalizePathname(homeHref);
31
- const isHomeRoute = normalizePathname(location.pathname) === localizedHomePath;
32
- const enabledNavItems = navItems.filter((item) => item.enabled).sort((a, b) => a.order - b.order);
33
- const canonicalizeDocumentHref = (href) => {
34
- const hashStart = href.indexOf('#');
35
- const path = hashStart === -1 ? href : href.slice(0, hashStart);
36
- const hash = hashStart === -1 ? '' : href.slice(hashStart);
37
- if (path === '/' || path.endsWith('/')) {
38
- return href;
39
- }
40
- return `${path}/${hash}`;
41
- };
42
- useEffect(() => {
43
- setIsMobileMenuOpen(false);
44
- }, [location.pathname, location.hash]);
45
- useEffect(() => {
46
- const desktopMediaQuery = window.matchMedia(desktopViewportQuery);
47
- const handleChange = (event) => {
48
- if (event.matches) {
49
- setIsMobileMenuOpen(false);
50
- }
51
- };
52
- desktopMediaQuery.addEventListener('change', handleChange);
53
- return () => {
54
- desktopMediaQuery.removeEventListener('change', handleChange);
55
- };
56
- }, []);
57
- const resolveHref = (route) => {
58
- const href = route.startsWith('#')
59
- ? isHomeRoute
60
- ? route
61
- : `${localizedHomePath}${route}`
62
- : route.startsWith('/')
63
- ? buildLocalizedPath(route, locale)
64
- : route;
65
- return forceDocumentNavigation || isExternalHref(stripLocalePrefix(normalizePathname(href)))
66
- ? canonicalizeDocumentHref(href)
67
- : href;
68
- };
69
- const handleInternalNavigation = (event, href) => {
70
- if (event.defaultPrevented ||
71
- event.button !== 0 ||
72
- event.metaKey ||
73
- event.altKey ||
74
- event.ctrlKey ||
75
- event.shiftKey) {
76
- return;
77
- }
78
- const isSameRouteNavigation = normalizePathname(href) === normalizePathname(location.pathname);
79
- if (isSameRouteNavigation) {
80
- event.preventDefault();
81
- window.scrollTo(0, 0);
82
- document
83
- .querySelector('[data-home-shell="true"]')
84
- ?.scrollTo({ top: 0, left: 0, behavior: 'auto' });
85
- return;
86
- }
87
- if (isExternalHref(stripLocalePrefix(normalizePathname(href)))) {
88
- return;
89
- }
90
- event.preventDefault();
91
- void navigate(href);
92
- };
93
- const handleWaitlistCtaClick = (event, ctaLocation) => {
94
- setIsMobileMenuOpen(false);
95
- waitlistCta?.onClick?.(event, ctaLocation);
96
- };
97
- return (_jsxs(HeaderFrame, { "data-header-frame": "true", "$shouldAnimatePresentation": shouldAnimatePresentation, "$backgroundColor": presentation.backgroundColor, "$mobileBackgroundColor": mobilePresentation?.backgroundColor, "$contrast": presentation.contrast, children: [_jsxs(HeaderShell, { "$shouldAnimatePresentation": shouldAnimatePresentation, "$textColor": presentation.textColor, "$mobileTextColor": mobilePresentation?.textColor, children: [_jsx(HeaderBrandLink, { href: forceDocumentNavigation ? canonicalizeDocumentHref(homeHref) : homeHref, "aria-label": brandName ? `${brandName} home` : 'Home', onClick: forceDocumentNavigation
98
- ? undefined
99
- : (event) => handleInternalNavigation(event, homeHref), children: presentation.contrast ? (_jsx(BrandWordmarkMask, { "$url": wordmarkSrc, role: "img", "aria-label": brandName })) : (_jsx(Wordmark, { "$shouldAnimatePresentation": shouldAnimatePresentation, "$variant": presentation.variant, "$mobileVariant": mobilePresentation?.variant, src: wordmarkSrc, alt: brandName })) }), _jsx(Nav, { "$variant": presentation.variant, "$contrast": presentation.contrast, "aria-label": "Global navigation", children: enabledNavItems.map((item) => {
100
- const label = item.label;
101
- if (item.children && item.children.length > 0) {
102
- const dropdownItems = item.children
103
- .filter((child) => Boolean(child.route) || child.disabled)
104
- .map((child) => {
105
- if (child.disabled || !child.route) {
106
- return {
107
- key: child.key,
108
- label: child.label,
109
- disabled: true,
110
- badge: child.badge,
111
- };
112
- }
113
- const childHref = resolveHref(child.route);
114
- return {
115
- key: child.key,
116
- label: child.label,
117
- href: childHref,
118
- external: child.external,
119
- onClick: child.external || forceDocumentNavigation
120
- ? undefined
121
- : (event) => {
122
- handleInternalNavigation(event, childHref);
123
- },
124
- };
125
- });
126
- return (_jsx(NavDropdown, { label: label, items: dropdownItems, variant: presentation.variant, contrast: presentation.contrast }, item.label));
127
- }
128
- const content_ = (_jsxs(_Fragment, { children: [label, item.badge ? (_jsx(NavBadge, { children: _jsx(NavBadgeLabel, { children: item.badge }) })) : null] }));
129
- if (item.placeholder || !item.route) {
130
- return (_jsx("span", { "aria-disabled": "true", "aria-label": `${label} ${item.badge ?? ''}`.trim(), "data-has-badge": item.badge ? 'true' : undefined, children: content_ }, item.label));
131
- }
132
- const href = resolveHref(item.route);
133
- return (_jsx("a", { href: href, "data-has-badge": item.badge ? 'true' : undefined, onClick: item.external
134
- ? undefined
135
- : forceDocumentNavigation
136
- ? undefined
137
- : (event) => {
138
- handleInternalNavigation(event, href);
139
- }, ...(item.external ? { target: '_blank', rel: 'noreferrer' } : {}), children: content_ }, item.label));
140
- }) }), _jsx(HeaderMobileToggle, { type: "button", "aria-label": isMobileMenuOpen ? 'Close navigation menu' : 'Open navigation menu', "aria-expanded": isMobileMenuOpen, "aria-controls": "mobile-navigation", onClick: () => setIsMobileMenuOpen((currentValue) => !currentValue), children: _jsx(HeaderMobileToggleIcon, { "$open": isMobileMenuOpen, children: _jsx("span", {}) }) }), waitlistCta?.disabled ? (_jsxs(HeaderAction, { as: "span", "$variant": presentation.variant, "$disabled": true, "aria-disabled": "true", children: [content.joinWaitlist, _jsx(HeaderActionArrow, { "aria-hidden": "true", children: "\u2192" })] })) : waitlistCta ? (_jsxs(HeaderAction, { "$variant": presentation.variant, href: resolveHref(waitlistCta.href), onClick: forceDocumentNavigation
141
- ? undefined
142
- : (event) => handleWaitlistCtaClick(event, 'nav_desktop'), children: [content.joinWaitlist, _jsx(HeaderActionArrow, { "aria-hidden": "true", children: "\u2192" })] })) : null] }), isMobileMenuOpen ? (_jsx(HeaderMobileOverlay, { onClick: () => setIsMobileMenuOpen(false), children: _jsxs(HeaderMobileSheet, { id: "mobile-navigation", role: "dialog", "aria-modal": "true", "aria-label": "Navigation menu", onClick: (event) => event.stopPropagation(), children: [_jsx(HeaderMobileNav, { "aria-label": "Mobile navigation", children: enabledNavItems.map((item) => {
143
- const label = item.label;
144
- if (item.children && item.children.length > 0) {
145
- const groupItems = item.children
146
- .filter((child) => Boolean(child.route) || child.disabled)
147
- .map((child) => {
148
- if (child.disabled || !child.route) {
149
- return {
150
- key: child.key,
151
- label: child.label,
152
- disabled: true,
153
- badge: child.badge,
154
- };
155
- }
156
- const childHref = resolveHref(child.route);
157
- return {
158
- key: child.key,
159
- label: child.label,
160
- href: childHref,
161
- external: child.external,
162
- onClick: child.external || forceDocumentNavigation
163
- ? () => setIsMobileMenuOpen(false)
164
- : (event) => {
165
- setIsMobileMenuOpen(false);
166
- handleInternalNavigation(event, childHref);
167
- },
168
- };
169
- });
170
- return _jsx(MobileNavGroup, { label: label, items: groupItems }, item.label);
171
- }
172
- if (item.placeholder || !item.route) {
173
- return (_jsxs(HeaderMobileNavDisabledItem, { "aria-disabled": "true", "aria-label": `${label} ${item.badge ?? ''}`.trim(), children: [label, item.badge ? (_jsx(HeaderMobileSoonBadge, { children: item.badge })) : null] }, item.label));
174
- }
175
- const href = resolveHref(item.route);
176
- return (_jsxs(HeaderMobileNavLink, { href: href, onClick: item.external
177
- ? () => setIsMobileMenuOpen(false)
178
- : forceDocumentNavigation
179
- ? () => setIsMobileMenuOpen(false)
180
- : (event) => {
181
- setIsMobileMenuOpen(false);
182
- handleInternalNavigation(event, href);
183
- }, ...(item.external ? { target: '_blank', rel: 'noreferrer' } : {}), children: [label, item.badge ? (_jsx(HeaderMobileSoonBadge, { children: item.badge })) : null] }, item.label));
184
- }) }), _jsx(HeaderMobileLocaleSlot, { children: _jsx(LocaleSelector, { ariaLabel: content.localeSelectorAriaLabel, forceDocumentNavigation: forceDocumentNavigation, inlineMenu: true, menuPlacement: "down", onLocaleSelect: () => setIsMobileMenuOpen(false) }) }), waitlistCta?.disabled ? (_jsxs(HeaderMobileWaitlistLink, { as: "span", "$disabled": true, "aria-disabled": "true", children: [_jsx(HeaderMobileWaitlistLabel, { children: content.waitlistEmailCta }), _jsx(HeaderMobileWaitlistArrow, { "aria-hidden": "true", children: "\u2192" })] })) : waitlistCta ? (_jsxs(HeaderMobileWaitlistLink, { href: resolveHref(waitlistCta.href), onClick: forceDocumentNavigation
185
- ? () => setIsMobileMenuOpen(false)
186
- : (event) => handleWaitlistCtaClick(event, 'nav_mobile'), children: [_jsx(HeaderMobileWaitlistLabel, { children: content.waitlistEmailCta }), _jsx(HeaderMobileWaitlistArrow, { "aria-hidden": "true", children: "\u2192" })] })) : null] }) })) : null] }));
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { RouterNavigationProvider } from '../navigation/router.js';
3
+ import { HeaderView } from './view.js';
4
+ /**
5
+ * The site header for apps with a react-router `<Router>` above it. Static
6
+ * sites without a router render `HeaderStatic` instead.
7
+ */
8
+ export function Header(props) {
9
+ return (_jsx(RouterNavigationProvider, { children: _jsx(HeaderView, { ...props }) }));
187
10
  }
@@ -0,0 +1,15 @@
1
+ import type { AppLocale } from '../locale/types.js';
2
+ import type { HeaderProps } from './types.js';
3
+ export interface HeaderStaticProps extends Omit<HeaderProps, 'forceDocumentNavigation'> {
4
+ /** The page's locale, resolved by the host (Astro) rather than a router param. */
5
+ locale: AppLocale;
6
+ /** The page's pathname; drives the home-route check and the mobile menu's close-on-navigate. */
7
+ pathname: string;
8
+ hash?: string | undefined;
9
+ }
10
+ /**
11
+ * The site header for statically rendered pages with no client router. Every
12
+ * link is a document navigation, so this variant never imports react-router
13
+ * and needs no `<MemoryRouter>` around the island.
14
+ */
15
+ export declare function HeaderStatic({ locale, pathname, hash, ...props }: HeaderStaticProps): React.JSX.Element;
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { StaticNavigationProvider } from '../navigation/static.js';
3
+ import { HeaderView } from './view.js';
4
+ /**
5
+ * The site header for statically rendered pages with no client router. Every
6
+ * link is a document navigation, so this variant never imports react-router
7
+ * and needs no `<MemoryRouter>` around the island.
8
+ */
9
+ export function HeaderStatic({ locale, pathname, hash, ...props }) {
10
+ return (_jsx(StaticNavigationProvider, { locale: locale, pathname: pathname, hash: hash, children: _jsx(HeaderView, { ...props, forceDocumentNavigation: true }) }));
11
+ }
@@ -1,4 +1,4 @@
1
- import type { HeaderPresentationVariant } from './index.js';
1
+ import type { HeaderPresentationVariant } from './types.js';
2
2
  export declare const HeaderFrame: import("styled-components/dist/types").IStyledComponentBase<"web", import("styled-components/dist/types").Merged<Omit<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "style"> & {
3
3
  style?: import("react").CSSProperties | import("styled-components/dist/types").CSSPropertiesWithVars | undefined;
4
4
  }, {
@@ -7,6 +7,9 @@ export declare const HeaderFrame: import("styled-components/dist/types").IStyled
7
7
  $mobileBackgroundColor?: string | undefined;
8
8
  $contrast?: boolean | undefined;
9
9
  }>> & string;
10
+ export declare const HeaderSkipLink: import("styled-components/dist/types").IStyledComponentBase<"web", Omit<import("react").DetailedHTMLProps<import("react").AnchorHTMLAttributes<HTMLAnchorElement>, HTMLAnchorElement>, "style"> & {
11
+ style?: import("react").CSSProperties | import("styled-components/dist/types").CSSPropertiesWithVars | undefined;
12
+ }> & string;
10
13
  export declare const HeaderShell: import("styled-components/dist/types").IStyledComponentBase<"web", import("styled-components/dist/types").Merged<Omit<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLElement>, HTMLElement>, "style"> & {
11
14
  style?: import("react").CSSProperties | import("styled-components/dist/types").CSSPropertiesWithVars | undefined;
12
15
  }, {
@@ -88,6 +88,29 @@ const verticalSwapText = (top, bottom) => `
88
88
  -webkit-background-clip: text;
89
89
  background-clip: text;
90
90
  `;
91
+ // Off-canvas until focused, then a solid pill over the header so a keyboard
92
+ // user's first Tab lands on a visible control rather than the wordmark.
93
+ export const HeaderSkipLink = styled.a `
94
+ position: absolute;
95
+ top: ${({ theme }) => theme.sizes.xs};
96
+ left: ${({ theme }) => theme.sizes.xs};
97
+ z-index: 1;
98
+ padding: 10px 16px;
99
+ border-radius: ${({ theme }) => theme.borderRadius.pill};
100
+ background: ${({ theme }) => theme.colors.ink};
101
+ color: ${({ theme }) => theme.colors.light};
102
+ font-size: 0.875rem;
103
+ font-weight: ${({ theme }) => theme.fontWeight.semibold};
104
+ line-height: 1.25;
105
+ text-decoration: none;
106
+ transform: translateY(calc(-100% - ${({ theme }) => theme.sizes.md}));
107
+
108
+ &:focus-visible {
109
+ outline: 2px solid ${({ theme }) => theme.colors.light};
110
+ outline-offset: 2px;
111
+ transform: none;
112
+ }
113
+ `;
91
114
  export const HeaderShell = styled.header `
92
115
  width: min(
93
116
  calc(100% - (2 * ${({ theme }) => theme.layout.pagePaddingInline})),
@@ -428,7 +451,7 @@ export const NavDropdownOptionBadge = styled.span `
428
451
  border-radius: ${({ theme }) => theme.borderRadius.pill};
429
452
  background: rgb(0 0 0 / 0.08);
430
453
  color: currentColor;
431
- font-size: 0.5625rem;
454
+ font-size: 0.6875rem;
432
455
  font-weight: ${({ theme }) => theme.fontWeight.black};
433
456
  letter-spacing: 0.1em;
434
457
  line-height: ${({ theme }) => theme.typography.lineHeight.tight};
@@ -444,8 +467,8 @@ export const NavBadge = styled.span `
444
467
  padding: 0 8px;
445
468
  border-radius: ${({ theme }) => theme.borderRadius.pill};
446
469
  background: ${({ theme }) => theme.colors.accentSky};
447
- color: ${({ theme }) => theme.colors.light};
448
- font-size: 0.5rem;
470
+ color: ${({ theme }) => theme.colors.ink};
471
+ font-size: 0.6875rem;
449
472
  font-weight: ${({ theme }) => theme.fontWeight.black};
450
473
  letter-spacing: 0.1em;
451
474
  line-height: ${({ theme }) => theme.typography.lineHeight.tight};
@@ -791,7 +814,7 @@ export const HeaderMobileSoonBadge = styled.span `
791
814
  background: rgb(255 255 255 / 0.42);
792
815
  color: currentColor;
793
816
  box-shadow: inset 0 0 0 1px rgb(0 0 0 / 0.08);
794
- font-size: 0.5rem;
817
+ font-size: 0.6875rem;
795
818
  font-weight: ${({ theme }) => theme.fontWeight.black};
796
819
  letter-spacing: 0.1em;
797
820
  line-height: ${({ theme }) => theme.typography.lineHeight.tight};
@@ -0,0 +1,61 @@
1
+ export type HeaderPresentationVariant = 'default' | 'inverse';
2
+ export interface HeaderPresentationState {
3
+ backgroundColor: string;
4
+ contrast?: boolean;
5
+ textColor: string;
6
+ variant: HeaderPresentationVariant;
7
+ }
8
+ export interface HeaderNavChild {
9
+ key: string;
10
+ label: string;
11
+ route?: string;
12
+ external?: boolean;
13
+ /**
14
+ * Renders the child greyed and non-interactive, with `badge` beside it if
15
+ * given — for a destination that is not open yet. A disabled child needs
16
+ * no `route`.
17
+ */
18
+ disabled?: boolean;
19
+ badge?: string;
20
+ }
21
+ export interface HeaderNavItem {
22
+ label: string;
23
+ route?: string;
24
+ enabled: boolean;
25
+ order: number;
26
+ external?: boolean;
27
+ placeholder?: boolean;
28
+ badge?: string;
29
+ children?: HeaderNavChild[];
30
+ }
31
+ export interface HeaderContent {
32
+ joinWaitlist: string;
33
+ waitlistEmailCta: string;
34
+ localeSelectorAriaLabel: string;
35
+ /**
36
+ * Label of the skip-to-content link rendered as the header's first focusable
37
+ * element. Omit to render no link (existing consumers keep their markup).
38
+ */
39
+ skipToContent?: string | undefined;
40
+ }
41
+ export interface HeaderWaitlistCta {
42
+ href: string;
43
+ onClick?: (event: React.MouseEvent<HTMLAnchorElement>, ctaLocation: 'nav_desktop' | 'nav_mobile') => void;
44
+ /** Renders the CTA greyed and non-interactive, keeping its place and label. */
45
+ disabled?: boolean;
46
+ }
47
+ export interface HeaderProps {
48
+ /** Overrides the wordmark from `<BrandProvider>`. Optional when one is present. */
49
+ brandWordmarkSrc?: string;
50
+ content: HeaderContent;
51
+ forceDocumentNavigation?: boolean;
52
+ homeHref: string;
53
+ isExternalHref: (href: string) => boolean;
54
+ /** `id` of the page's main landmark the skip link targets. Defaults to `main`. */
55
+ mainContentId?: string | undefined;
56
+ mobilePresentation?: HeaderPresentationState | undefined;
57
+ navItems: HeaderNavItem[];
58
+ presentation: HeaderPresentationState;
59
+ shouldAnimatePresentation: boolean;
60
+ waitlistCta?: HeaderWaitlistCta | undefined;
61
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ import type { HeaderProps } from './types.js';
2
+ export declare function HeaderView({ brandWordmarkSrc, content, forceDocumentNavigation, homeHref, isExternalHref, mainContentId, mobilePresentation, navItems, presentation, shouldAnimatePresentation, waitlistCta, }: HeaderProps): React.JSX.Element;
@@ -0,0 +1,184 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import { LocaleSelectorView } from '../LocaleSelector/view.js';
4
+ import { buildLocalizedPath, stripLocalePrefix } from '../locale/routes.js';
5
+ import { useNavigationLocation } from '../navigation/context.js';
6
+ import { desktopViewportQuery } from '../tokens.js';
7
+ import { useOptionalBrand } from '../brand/index.js';
8
+ import { normalizePathname } from './helper.js';
9
+ import { NavDropdown } from './NavDropdown.js';
10
+ import { BrandWordmarkMask, HeaderAction, HeaderActionArrow, HeaderBrandLink, HeaderFrame, HeaderMobileNav, HeaderMobileNavDisabledItem, HeaderMobileNavGroup, HeaderMobileNavGroupChevron, HeaderMobileNavGroupPanel, HeaderMobileNavGroupPanelInner, HeaderMobileNavGroupToggle, HeaderMobileNavSubDisabledItem, HeaderMobileNavSubLink, HeaderMobileNavSubList, HeaderMobileNavLink, HeaderMobileOverlay, HeaderMobileLocaleSlot, HeaderMobileSheet, HeaderMobileSoonBadge, HeaderMobileToggle, HeaderMobileToggleIcon, HeaderMobileWaitlistArrow, HeaderMobileWaitlistLabel, HeaderMobileWaitlistLink, HeaderShell, HeaderSkipLink, Nav, NavBadge, NavBadgeLabel, Wordmark, } from './styles.js';
11
+ function MobileNavGroup({ label, items }) {
12
+ const [isExpanded, setIsExpanded] = useState(false);
13
+ const panelId = `mobile-nav-group-${label.toLowerCase().replace(/\s+/gu, '-')}`;
14
+ return (_jsxs(HeaderMobileNavGroup, { children: [_jsxs(HeaderMobileNavGroupToggle, { type: "button", "aria-expanded": isExpanded, "aria-controls": panelId, onClick: () => setIsExpanded((currentValue) => !currentValue), children: [label, _jsx(HeaderMobileNavGroupChevron, { "$open": isExpanded, "aria-hidden": "true" })] }), _jsx(HeaderMobileNavGroupPanel, { "$open": isExpanded, id: panelId, children: _jsx(HeaderMobileNavGroupPanelInner, { "$open": isExpanded, children: _jsx(HeaderMobileNavSubList, { "aria-hidden": !isExpanded, children: items.map((item) => item.disabled ? (_jsxs(HeaderMobileNavSubDisabledItem, { "aria-disabled": "true", "aria-label": `${item.label} ${item.badge ?? ''}`.trim(), children: [item.label, item.badge ? _jsx(HeaderMobileSoonBadge, { children: item.badge }) : null] }, item.key)) : (_jsx(HeaderMobileNavSubLink, { href: item.href, "aria-label": item.ariaLabel, tabIndex: isExpanded ? undefined : -1, onClick: item.onClick, ...(item.external ? { target: '_blank', rel: 'noreferrer' } : {}), children: item.label }, item.key))) }) }) })] }));
15
+ }
16
+ export function HeaderView({ brandWordmarkSrc, content, forceDocumentNavigation = false, homeHref, isExternalHref, mainContentId = 'main', mobilePresentation, navItems, presentation, shouldAnimatePresentation, waitlistCta, }) {
17
+ const brand = useOptionalBrand();
18
+ const wordmarkSrc = brandWordmarkSrc ?? brand?.wordmarkSrc;
19
+ const brandName = brand?.name ?? '';
20
+ // Rendering a header with no wordmark is never what the caller meant, and a
21
+ // broken image is harder to diagnose than a named error.
22
+ if (wordmarkSrc === undefined) {
23
+ throw new Error('<Header> needs a brandWordmarkSrc prop or a <BrandProvider>.');
24
+ }
25
+ const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
26
+ const { locale, pathname, hash, navigate } = useNavigationLocation();
27
+ const localizedHomePath = normalizePathname(homeHref);
28
+ const isHomeRoute = normalizePathname(pathname) === localizedHomePath;
29
+ const enabledNavItems = navItems.filter((item) => item.enabled).sort((a, b) => a.order - b.order);
30
+ const canonicalizeDocumentHref = (href) => {
31
+ const hashStart = href.indexOf('#');
32
+ const path = hashStart === -1 ? href : href.slice(0, hashStart);
33
+ const hash = hashStart === -1 ? '' : href.slice(hashStart);
34
+ if (path === '/' || path.endsWith('/')) {
35
+ return href;
36
+ }
37
+ return `${path}/${hash}`;
38
+ };
39
+ useEffect(() => {
40
+ setIsMobileMenuOpen(false);
41
+ }, [pathname, hash]);
42
+ useEffect(() => {
43
+ const desktopMediaQuery = window.matchMedia(desktopViewportQuery);
44
+ const handleChange = (event) => {
45
+ if (event.matches) {
46
+ setIsMobileMenuOpen(false);
47
+ }
48
+ };
49
+ desktopMediaQuery.addEventListener('change', handleChange);
50
+ return () => {
51
+ desktopMediaQuery.removeEventListener('change', handleChange);
52
+ };
53
+ }, []);
54
+ const resolveHref = (route) => {
55
+ const href = route.startsWith('#')
56
+ ? isHomeRoute
57
+ ? route
58
+ : `${localizedHomePath}${route}`
59
+ : route.startsWith('/')
60
+ ? buildLocalizedPath(route, locale)
61
+ : route;
62
+ return forceDocumentNavigation || isExternalHref(stripLocalePrefix(normalizePathname(href)))
63
+ ? canonicalizeDocumentHref(href)
64
+ : href;
65
+ };
66
+ const handleInternalNavigation = (event, href) => {
67
+ if (event.defaultPrevented ||
68
+ event.button !== 0 ||
69
+ event.metaKey ||
70
+ event.altKey ||
71
+ event.ctrlKey ||
72
+ event.shiftKey) {
73
+ return;
74
+ }
75
+ const isSameRouteNavigation = normalizePathname(href) === normalizePathname(pathname);
76
+ if (isSameRouteNavigation) {
77
+ event.preventDefault();
78
+ window.scrollTo(0, 0);
79
+ document
80
+ .querySelector('[data-home-shell="true"]')
81
+ ?.scrollTo({ top: 0, left: 0, behavior: 'auto' });
82
+ return;
83
+ }
84
+ if (isExternalHref(stripLocalePrefix(normalizePathname(href)))) {
85
+ return;
86
+ }
87
+ event.preventDefault();
88
+ navigate(href);
89
+ };
90
+ const handleWaitlistCtaClick = (event, ctaLocation) => {
91
+ setIsMobileMenuOpen(false);
92
+ waitlistCta?.onClick?.(event, ctaLocation);
93
+ };
94
+ return (_jsxs(HeaderFrame, { "data-header-frame": "true", "$shouldAnimatePresentation": shouldAnimatePresentation, "$backgroundColor": presentation.backgroundColor, "$mobileBackgroundColor": mobilePresentation?.backgroundColor, "$contrast": presentation.contrast, children: [content.skipToContent !== undefined ? (_jsx(HeaderSkipLink, { href: `#${mainContentId}`, children: content.skipToContent })) : null, _jsxs(HeaderShell, { "$shouldAnimatePresentation": shouldAnimatePresentation, "$textColor": presentation.textColor, "$mobileTextColor": mobilePresentation?.textColor, children: [_jsx(HeaderBrandLink, { href: forceDocumentNavigation ? canonicalizeDocumentHref(homeHref) : homeHref, "aria-label": brandName ? `${brandName} home` : 'Home', onClick: forceDocumentNavigation
95
+ ? undefined
96
+ : (event) => handleInternalNavigation(event, homeHref), children: presentation.contrast ? (_jsx(BrandWordmarkMask, { "$url": wordmarkSrc, role: "img", "aria-label": brandName })) : (_jsx(Wordmark, { "$shouldAnimatePresentation": shouldAnimatePresentation, "$variant": presentation.variant, "$mobileVariant": mobilePresentation?.variant, src: wordmarkSrc, alt: brandName })) }), _jsx(Nav, { "$variant": presentation.variant, "$contrast": presentation.contrast, "aria-label": "Global navigation", children: enabledNavItems.map((item) => {
97
+ const label = item.label;
98
+ if (item.children && item.children.length > 0) {
99
+ const dropdownItems = item.children
100
+ .filter((child) => Boolean(child.route) || child.disabled)
101
+ .map((child) => {
102
+ if (child.disabled || !child.route) {
103
+ return {
104
+ key: child.key,
105
+ label: child.label,
106
+ disabled: true,
107
+ badge: child.badge,
108
+ };
109
+ }
110
+ const childHref = resolveHref(child.route);
111
+ return {
112
+ key: child.key,
113
+ label: child.label,
114
+ href: childHref,
115
+ external: child.external,
116
+ onClick: child.external || forceDocumentNavigation
117
+ ? undefined
118
+ : (event) => {
119
+ handleInternalNavigation(event, childHref);
120
+ },
121
+ };
122
+ });
123
+ return (_jsx(NavDropdown, { label: label, items: dropdownItems, variant: presentation.variant, contrast: presentation.contrast }, item.label));
124
+ }
125
+ const content_ = (_jsxs(_Fragment, { children: [label, item.badge ? (_jsx(NavBadge, { children: _jsx(NavBadgeLabel, { children: item.badge }) })) : null] }));
126
+ if (item.placeholder || !item.route) {
127
+ return (_jsx("span", { "aria-disabled": "true", "aria-label": `${label} ${item.badge ?? ''}`.trim(), "data-has-badge": item.badge ? 'true' : undefined, children: content_ }, item.label));
128
+ }
129
+ const href = resolveHref(item.route);
130
+ return (_jsx("a", { href: href, "data-has-badge": item.badge ? 'true' : undefined, onClick: item.external
131
+ ? undefined
132
+ : forceDocumentNavigation
133
+ ? undefined
134
+ : (event) => {
135
+ handleInternalNavigation(event, href);
136
+ }, ...(item.external ? { target: '_blank', rel: 'noreferrer' } : {}), children: content_ }, item.label));
137
+ }) }), _jsx(HeaderMobileToggle, { type: "button", "aria-label": isMobileMenuOpen ? 'Close navigation menu' : 'Open navigation menu', "aria-expanded": isMobileMenuOpen, "aria-controls": "mobile-navigation", onClick: () => setIsMobileMenuOpen((currentValue) => !currentValue), children: _jsx(HeaderMobileToggleIcon, { "$open": isMobileMenuOpen, children: _jsx("span", {}) }) }), waitlistCta?.disabled ? (_jsxs(HeaderAction, { as: "span", "$variant": presentation.variant, "$disabled": true, "aria-disabled": "true", children: [content.joinWaitlist, _jsx(HeaderActionArrow, { "aria-hidden": "true", children: "\u2192" })] })) : waitlistCta ? (_jsxs(HeaderAction, { "$variant": presentation.variant, href: resolveHref(waitlistCta.href), onClick: forceDocumentNavigation
138
+ ? undefined
139
+ : (event) => handleWaitlistCtaClick(event, 'nav_desktop'), children: [content.joinWaitlist, _jsx(HeaderActionArrow, { "aria-hidden": "true", children: "\u2192" })] })) : null] }), isMobileMenuOpen ? (_jsx(HeaderMobileOverlay, { onClick: () => setIsMobileMenuOpen(false), children: _jsxs(HeaderMobileSheet, { id: "mobile-navigation", role: "dialog", "aria-modal": "true", "aria-label": "Navigation menu", onClick: (event) => event.stopPropagation(), children: [_jsx(HeaderMobileNav, { "aria-label": "Mobile navigation", children: enabledNavItems.map((item) => {
140
+ const label = item.label;
141
+ if (item.children && item.children.length > 0) {
142
+ const groupItems = item.children
143
+ .filter((child) => Boolean(child.route) || child.disabled)
144
+ .map((child) => {
145
+ if (child.disabled || !child.route) {
146
+ return {
147
+ key: child.key,
148
+ label: child.label,
149
+ disabled: true,
150
+ badge: child.badge,
151
+ };
152
+ }
153
+ const childHref = resolveHref(child.route);
154
+ return {
155
+ key: child.key,
156
+ label: child.label,
157
+ href: childHref,
158
+ external: child.external,
159
+ onClick: child.external || forceDocumentNavigation
160
+ ? () => setIsMobileMenuOpen(false)
161
+ : (event) => {
162
+ setIsMobileMenuOpen(false);
163
+ handleInternalNavigation(event, childHref);
164
+ },
165
+ };
166
+ });
167
+ return _jsx(MobileNavGroup, { label: label, items: groupItems }, item.label);
168
+ }
169
+ if (item.placeholder || !item.route) {
170
+ return (_jsxs(HeaderMobileNavDisabledItem, { "aria-disabled": "true", "aria-label": `${label} ${item.badge ?? ''}`.trim(), children: [label, item.badge ? (_jsx(HeaderMobileSoonBadge, { children: item.badge })) : null] }, item.label));
171
+ }
172
+ const href = resolveHref(item.route);
173
+ return (_jsxs(HeaderMobileNavLink, { href: href, onClick: item.external
174
+ ? () => setIsMobileMenuOpen(false)
175
+ : forceDocumentNavigation
176
+ ? () => setIsMobileMenuOpen(false)
177
+ : (event) => {
178
+ setIsMobileMenuOpen(false);
179
+ handleInternalNavigation(event, href);
180
+ }, ...(item.external ? { target: '_blank', rel: 'noreferrer' } : {}), children: [label, item.badge ? (_jsx(HeaderMobileSoonBadge, { children: item.badge })) : null] }, item.label));
181
+ }) }), _jsx(HeaderMobileLocaleSlot, { children: _jsx(LocaleSelectorView, { ariaLabel: content.localeSelectorAriaLabel, forceDocumentNavigation: forceDocumentNavigation, inlineMenu: true, menuPlacement: "down", onLocaleSelect: () => setIsMobileMenuOpen(false) }) }), waitlistCta?.disabled ? (_jsxs(HeaderMobileWaitlistLink, { as: "span", "$disabled": true, "aria-disabled": "true", children: [_jsx(HeaderMobileWaitlistLabel, { children: content.waitlistEmailCta }), _jsx(HeaderMobileWaitlistArrow, { "aria-hidden": "true", children: "\u2192" })] })) : waitlistCta ? (_jsxs(HeaderMobileWaitlistLink, { href: resolveHref(waitlistCta.href), onClick: forceDocumentNavigation
182
+ ? () => setIsMobileMenuOpen(false)
183
+ : (event) => handleWaitlistCtaClick(event, 'nav_mobile'), children: [_jsx(HeaderMobileWaitlistLabel, { children: content.waitlistEmailCta }), _jsx(HeaderMobileWaitlistArrow, { "aria-hidden": "true", children: "\u2192" })] })) : null] }) })) : null] }));
184
+ }