@flamingo-stack/openframe-frontend-core 0.0.493 → 0.0.494

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 (115) hide show
  1. package/dist/{chunk-WPFLV4QJ.js → chunk-4BGDD6CU.js} +4 -4
  2. package/dist/{chunk-LIRXKEOE.cjs → chunk-7M24CJRH.cjs} +15 -15
  3. package/dist/{chunk-LIRXKEOE.cjs.map → chunk-7M24CJRH.cjs.map} +1 -1
  4. package/dist/{chunk-5ICPQAUI.cjs → chunk-CDRNCBLS.cjs} +59 -59
  5. package/dist/{chunk-5ICPQAUI.cjs.map → chunk-CDRNCBLS.cjs.map} +1 -1
  6. package/dist/{chunk-FG5ALORQ.js → chunk-DZU3FCOQ.js} +2 -2
  7. package/dist/{chunk-J2J36PEN.cjs → chunk-E5AIN5SS.cjs} +31 -31
  8. package/dist/{chunk-J2J36PEN.cjs.map → chunk-E5AIN5SS.cjs.map} +1 -1
  9. package/dist/{chunk-O6S6Q52W.cjs → chunk-FFL3D5LX.cjs} +26 -26
  10. package/dist/{chunk-O6S6Q52W.cjs.map → chunk-FFL3D5LX.cjs.map} +1 -1
  11. package/dist/{chunk-VML66FJQ.cjs → chunk-FP62TQ3A.cjs} +9 -9
  12. package/dist/{chunk-VML66FJQ.cjs.map → chunk-FP62TQ3A.cjs.map} +1 -1
  13. package/dist/{chunk-QHRNQTW5.js → chunk-GANUPKHV.js} +2 -2
  14. package/dist/{chunk-LVDI7R7T.cjs → chunk-J6MC4SY7.cjs} +37 -37
  15. package/dist/{chunk-LVDI7R7T.cjs.map → chunk-J6MC4SY7.cjs.map} +1 -1
  16. package/dist/{chunk-U5LU56JE.js → chunk-KDPQGHQ2.js} +2 -2
  17. package/dist/{chunk-56J7RLMM.js → chunk-KXOSLXL2.js} +1667 -1602
  18. package/dist/chunk-KXOSLXL2.js.map +1 -0
  19. package/dist/{chunk-OFFYASUY.cjs → chunk-L5GMHETR.cjs} +26 -26
  20. package/dist/{chunk-OFFYASUY.cjs.map → chunk-L5GMHETR.cjs.map} +1 -1
  21. package/dist/{chunk-P3ITKTUE.js → chunk-OQFXEWW2.js} +2 -2
  22. package/dist/{chunk-2XAAFAQT.cjs → chunk-P77L6MPJ.cjs} +11 -11
  23. package/dist/{chunk-2XAAFAQT.cjs.map → chunk-P77L6MPJ.cjs.map} +1 -1
  24. package/dist/{chunk-4J3CB4KO.js → chunk-PBAFZETL.js} +2 -2
  25. package/dist/{chunk-PI5BMKV3.cjs → chunk-QHGGQZFK.cjs} +62 -62
  26. package/dist/{chunk-PI5BMKV3.cjs.map → chunk-QHGGQZFK.cjs.map} +1 -1
  27. package/dist/{chunk-M4SCYPCR.cjs → chunk-QRHAIJMG.cjs} +7 -7
  28. package/dist/{chunk-M4SCYPCR.cjs.map → chunk-QRHAIJMG.cjs.map} +1 -1
  29. package/dist/{chunk-4MLOMMDX.cjs → chunk-UCZHGV4E.cjs} +635 -570
  30. package/dist/chunk-UCZHGV4E.cjs.map +1 -0
  31. package/dist/{chunk-SZWMJIH3.js → chunk-VCHK2B6W.js} +2 -2
  32. package/dist/{chunk-5RHJVPL7.js → chunk-VUUIYBIH.js} +4 -4
  33. package/dist/{chunk-4AWRPTKE.js → chunk-WUM5BTDY.js} +4 -4
  34. package/dist/{chunk-BTIRYGDT.js → chunk-YQ4B7LCE.js} +4 -4
  35. package/dist/{chunk-WKWZ7LVC.js → chunk-ZP4QMJX4.js} +2 -2
  36. package/dist/{chunk-DY6AJNVK.cjs → chunk-ZVCODNW7.cjs} +5 -5
  37. package/dist/{chunk-DY6AJNVK.cjs.map → chunk-ZVCODNW7.cjs.map} +1 -1
  38. package/dist/components/case-studies/index.cjs +8 -8
  39. package/dist/components/case-studies/index.js +2 -2
  40. package/dist/components/chat/index.cjs +2 -2
  41. package/dist/components/chat/index.js +1 -1
  42. package/dist/components/contact/index.cjs +3 -3
  43. package/dist/components/contact/index.js +2 -2
  44. package/dist/components/docs/index.cjs +5 -5
  45. package/dist/components/docs/index.js +4 -4
  46. package/dist/components/embeds/index.cjs +3 -3
  47. package/dist/components/embeds/index.js +2 -2
  48. package/dist/components/faq/index.cjs +3 -3
  49. package/dist/components/faq/index.js +2 -2
  50. package/dist/components/features/index.cjs +2 -2
  51. package/dist/components/features/index.js +1 -1
  52. package/dist/components/features/time-tracker/time-tracker-header-button.d.ts.map +1 -1
  53. package/dist/components/help-center-pages/index.cjs +20 -20
  54. package/dist/components/help-center-pages/index.js +11 -11
  55. package/dist/components/index.cjs +132 -130
  56. package/dist/components/index.cjs.map +1 -1
  57. package/dist/components/index.js +11 -9
  58. package/dist/components/index.js.map +1 -1
  59. package/dist/components/navigation/app-header.d.ts.map +1 -1
  60. package/dist/components/navigation/header-button.d.ts.map +1 -1
  61. package/dist/components/navigation/header-global-search.d.ts.map +1 -1
  62. package/dist/components/navigation/header-mingo-button.d.ts.map +1 -1
  63. package/dist/components/navigation/header-organization-filter.d.ts.map +1 -1
  64. package/dist/components/navigation/header-skeleton.d.ts +6 -0
  65. package/dist/components/navigation/header-skeleton.d.ts.map +1 -1
  66. package/dist/components/navigation/header.d.ts.map +1 -1
  67. package/dist/components/navigation/index.cjs +4 -2
  68. package/dist/components/navigation/index.cjs.map +1 -1
  69. package/dist/components/navigation/index.d.ts +2 -0
  70. package/dist/components/navigation/index.d.ts.map +1 -1
  71. package/dist/components/navigation/index.js +3 -1
  72. package/dist/components/navigation/mingo-ai-button.d.ts.map +1 -1
  73. package/dist/components/navigation/mobile-nav-panel.d.ts.map +1 -1
  74. package/dist/components/navigation/top-navigation.d.ts +55 -0
  75. package/dist/components/navigation/top-navigation.d.ts.map +1 -0
  76. package/dist/components/onboarding-guides/index.cjs +5 -5
  77. package/dist/components/onboarding-guides/index.js +4 -4
  78. package/dist/components/related-content/index.cjs +3 -3
  79. package/dist/components/related-content/index.js +2 -2
  80. package/dist/components/tickets/index.cjs +5 -5
  81. package/dist/components/tickets/index.js +4 -4
  82. package/dist/components/ui/index.cjs +2 -2
  83. package/dist/components/ui/index.js +1 -1
  84. package/dist/index.cjs +4 -2
  85. package/dist/index.cjs.map +1 -1
  86. package/dist/index.js +3 -1
  87. package/package.json +1 -1
  88. package/src/components/features/time-tracker/time-tracker-header-button.tsx +42 -38
  89. package/src/components/navigation/app-header.tsx +190 -198
  90. package/src/components/navigation/header-button.tsx +3 -1
  91. package/src/components/navigation/header-global-search.tsx +3 -1
  92. package/src/components/navigation/header-mingo-button.tsx +3 -1
  93. package/src/components/navigation/header-organization-filter.tsx +2 -1
  94. package/src/components/navigation/header-skeleton.tsx +57 -54
  95. package/src/components/navigation/header.tsx +231 -208
  96. package/src/components/navigation/index.ts +3 -0
  97. package/src/components/navigation/mingo-ai-button.tsx +24 -19
  98. package/src/components/navigation/mobile-nav-panel.tsx +6 -3
  99. package/src/components/navigation/top-navigation.tsx +128 -0
  100. package/src/stories/AppHeader.stories.tsx +148 -0
  101. package/src/stories/Header.stories.tsx +18 -23
  102. package/src/stories/TopNavigation.stories.tsx +150 -0
  103. package/dist/chunk-4MLOMMDX.cjs.map +0 -1
  104. package/dist/chunk-56J7RLMM.js.map +0 -1
  105. /package/dist/{chunk-WPFLV4QJ.js.map → chunk-4BGDD6CU.js.map} +0 -0
  106. /package/dist/{chunk-FG5ALORQ.js.map → chunk-DZU3FCOQ.js.map} +0 -0
  107. /package/dist/{chunk-QHRNQTW5.js.map → chunk-GANUPKHV.js.map} +0 -0
  108. /package/dist/{chunk-U5LU56JE.js.map → chunk-KDPQGHQ2.js.map} +0 -0
  109. /package/dist/{chunk-P3ITKTUE.js.map → chunk-OQFXEWW2.js.map} +0 -0
  110. /package/dist/{chunk-4J3CB4KO.js.map → chunk-PBAFZETL.js.map} +0 -0
  111. /package/dist/{chunk-SZWMJIH3.js.map → chunk-VCHK2B6W.js.map} +0 -0
  112. /package/dist/{chunk-5RHJVPL7.js.map → chunk-VUUIYBIH.js.map} +0 -0
  113. /package/dist/{chunk-4AWRPTKE.js.map → chunk-WUM5BTDY.js.map} +0 -0
  114. /package/dist/{chunk-BTIRYGDT.js.map → chunk-YQ4B7LCE.js.map} +0 -0
  115. /package/dist/{chunk-WKWZ7LVC.js.map → chunk-ZP4QMJX4.js.map} +0 -0
@@ -1,147 +1,152 @@
1
- "use client"
1
+ 'use client';
2
2
 
3
- import Link from '../../embed-shims/next-link'
4
- import React, { useEffect, useRef, useState } from 'react'
5
- import { HeaderConfig, NavigationItem } from '../../types/navigation'
6
- import { cn } from '../../utils'
7
- import { Button } from '../ui/button'
8
- import { Menu01Icon } from '../icons-v2-generated'
9
- import { MOBILE_NAV_PANEL_ID } from './mobile-nav-panel'
10
- import { MingoAiButton } from './mingo-ai-button'
3
+ import React, { useEffect, useRef, useState } from 'react';
4
+ import Link from '../../embed-shims/next-link';
5
+ import { HeaderConfig, NavigationItem } from '../../types/navigation';
6
+ import { cn } from '../../utils';
7
+ import { Menu01Icon, XmarkIcon } from '../icons-v2-generated';
8
+ import { Button } from '../ui/button';
9
+ import { HeaderButton } from './header-button';
10
+ import { MingoAiButton } from './mingo-ai-button';
11
+ import { MOBILE_NAV_PANEL_ID } from './mobile-nav-panel';
12
+ import { TopNavigation } from './top-navigation';
11
13
 
12
14
  export interface HeaderProps {
13
- config: HeaderConfig
14
- platform?: string
15
+ config: HeaderConfig;
16
+ platform?: string;
15
17
  }
16
18
 
17
19
  // Re-export from types for convenience
18
- export type { HeaderConfig } from '../../types/navigation'
20
+ export type { HeaderConfig } from '../../types/navigation';
19
21
 
20
22
  export function Header({ config, platform }: HeaderProps) {
21
- const [show, setShow] = useState(true)
22
- const [lastScrollY, setLastScrollY] = useState(0)
23
- const [openDropdowns, setOpenDropdowns] = useState<Record<string, boolean>>({})
24
- const dropdownRefs = useRef<Record<string, HTMLDivElement | null>>({})
25
- const triggerRefs = useRef<Record<string, HTMLButtonElement | null>>({})
26
-
23
+ const [show, setShow] = useState(true);
24
+ const [lastScrollY, setLastScrollY] = useState(0);
25
+ const [openDropdowns, setOpenDropdowns] = useState<Record<string, boolean>>({});
26
+ const dropdownRefs = useRef<Record<string, HTMLDivElement | null>>({});
27
+ const triggerRefs = useRef<Record<string, HTMLButtonElement | null>>({});
28
+
27
29
  // Handle click outside and escape key for custom dropdown
28
30
  useEffect(() => {
29
31
  const handleClickOutside = (event: MouseEvent) => {
30
- const target = event.target as HTMLElement
31
- if (!target) return
32
+ const target = event.target as HTMLElement;
33
+ if (!target) return;
32
34
 
33
35
  // Check if click is outside all dropdowns
34
36
  const isOutsideAllDropdowns = Object.keys(openDropdowns).every(id => {
35
- const dropdown = dropdownRefs.current[id]
36
- const trigger = triggerRefs.current[id]
37
-
38
- if (!dropdown || !trigger) return true
39
-
40
- return !dropdown.contains(target) && !trigger.contains(target)
41
- })
37
+ const dropdown = dropdownRefs.current[id];
38
+ const trigger = triggerRefs.current[id];
39
+
40
+ if (!dropdown || !trigger) return true;
41
+
42
+ return !dropdown.contains(target) && !trigger.contains(target);
43
+ });
42
44
 
43
45
  if (isOutsideAllDropdowns) {
44
- setOpenDropdowns({})
46
+ setOpenDropdowns({});
45
47
  }
46
- }
48
+ };
47
49
 
48
50
  const handleEscapeKey = (event: KeyboardEvent) => {
49
51
  if (event.key === 'Escape') {
50
- setOpenDropdowns({})
52
+ setOpenDropdowns({});
51
53
  }
52
- }
54
+ };
53
55
 
54
56
  // Only add listeners if any dropdown is open
55
- const hasOpenDropdowns = Object.values(openDropdowns).some(Boolean)
57
+ const hasOpenDropdowns = Object.values(openDropdowns).some(Boolean);
56
58
  if (hasOpenDropdowns) {
57
- document.addEventListener('mousedown', handleClickOutside)
58
- document.addEventListener('keydown', handleEscapeKey)
59
+ document.addEventListener('mousedown', handleClickOutside);
60
+ document.addEventListener('keydown', handleEscapeKey);
59
61
  }
60
62
 
61
63
  return () => {
62
- document.removeEventListener('mousedown', handleClickOutside)
63
- document.removeEventListener('keydown', handleEscapeKey)
64
- }
65
- }, [openDropdowns])
64
+ document.removeEventListener('mousedown', handleClickOutside);
65
+ document.removeEventListener('keydown', handleEscapeKey);
66
+ };
67
+ }, [openDropdowns]);
66
68
 
67
69
  // Force close all dropdowns and cleanup on unmount
68
70
  useEffect(() => {
69
71
  return () => {
70
72
  // Close all dropdowns before unmounting to prevent focus errors
71
- setOpenDropdowns({})
73
+ setOpenDropdowns({});
72
74
  // Clear any stored refs
73
- dropdownRefs.current = {}
74
- triggerRefs.current = {}
75
- }
76
- }, [])
77
-
75
+ dropdownRefs.current = {};
76
+ triggerRefs.current = {};
77
+ };
78
+ }, []);
79
+
78
80
  useEffect(() => {
79
81
  // Only add scroll listener if autoHide is enabled
80
82
  if (!config.autoHide) {
81
- setShow(true) // Always show header when autoHide is disabled
82
- return
83
+ setShow(true); // Always show header when autoHide is disabled
84
+ return;
83
85
  }
84
-
86
+
85
87
  const handleScroll = () => {
86
- const currentScrollY = window.scrollY
87
-
88
+ const currentScrollY = window.scrollY;
89
+
88
90
  setLastScrollY(prevScrollY => {
89
91
  // Determine if we should show or hide the header
90
- const shouldHide = currentScrollY > prevScrollY && currentScrollY > 50
91
- const shouldShow = currentScrollY < prevScrollY || currentScrollY <= 10
92
-
92
+ const shouldHide = currentScrollY > prevScrollY && currentScrollY > 50;
93
+ const shouldShow = currentScrollY < prevScrollY || currentScrollY <= 10;
94
+
93
95
  if (shouldHide) {
94
- setShow(false)
96
+ setShow(false);
95
97
  } else if (shouldShow) {
96
- setShow(true)
98
+ setShow(true);
97
99
  }
98
-
99
- return currentScrollY
100
- })
101
- }
102
100
 
103
- window.addEventListener('scroll', handleScroll, { passive: true })
104
-
105
- return () => {
106
- window.removeEventListener('scroll', handleScroll)
107
- }
108
- }, [config.autoHide])
101
+ return currentScrollY;
102
+ });
103
+ };
104
+
105
+ window.addEventListener('scroll', handleScroll, { passive: true });
109
106
 
107
+ return () => {
108
+ window.removeEventListener('scroll', handleScroll);
109
+ };
110
+ }, [config.autoHide]);
110
111
 
111
112
  const renderNavigationItem = (item: NavigationItem) => {
112
113
  // If custom element provided, use it
113
114
  if (item.element) {
114
- return <React.Fragment key={item.id}>{item.element}</React.Fragment>
115
+ return <React.Fragment key={item.id}>{item.element}</React.Fragment>;
115
116
  }
116
117
 
117
118
  // If it has children, render as custom dropdown
118
119
  if (item.children && item.children.length > 0) {
119
- const isOpen = openDropdowns[item.id] || false
120
-
120
+ const isOpen = openDropdowns[item.id] || false;
121
+
121
122
  return (
122
123
  <div key={item.id} className="relative">
123
124
  <Button
124
- ref={(el) => { triggerRefs.current[item.id] = el }}
125
+ ref={el => {
126
+ triggerRefs.current[item.id] = el;
127
+ }}
125
128
  variant="transparent"
126
129
  leftIcon={item.icon}
127
130
  rightIcon={item.badge}
128
131
  onClick={() => {
129
- setOpenDropdowns(prev => ({
130
- ...prev,
131
- [item.id]: !prev[item.id]
132
- }))
132
+ setOpenDropdowns(prev => ({
133
+ ...prev,
134
+ [item.id]: !prev[item.id],
135
+ }));
133
136
  }}
134
137
  size="small-legacy"
135
138
  className={cn(
136
- "text-h6 font-bold",
139
+ // Top-level nav links: h3 bold per the ODS top-navigation spec
140
+ // (Figma 2797-5978); dropdown children stay on the h6 step.
141
+ 'text-h3 font-bold tracking-[-0.36px]',
137
142
  item.isActive && 'bg-ods-bg-hover', // Active items get subtle gray background
138
143
  isOpen && 'bg-ods-bg-hover', // Open dropdowns get gray background
139
- item.className
144
+ item.className,
140
145
  )}
141
146
  >
142
147
  {item.label}
143
148
  </Button>
144
-
149
+
145
150
  {/* Always render dropdown in DOM so crawlers see child <a> links;
146
151
  toggle visibility via CSS + `inert`.
147
152
 
@@ -156,18 +161,18 @@ export function Header({ config, platform }: HeaderProps) {
156
161
  removes from a11y tree + blocks focus + prevents click +
157
162
  removes from tab order. Native React 19 support. */}
158
163
  <div
159
- ref={(el) => { dropdownRefs.current[item.id] = el }}
164
+ ref={el => {
165
+ dropdownRefs.current[item.id] = el;
166
+ }}
160
167
  inert={!isOpen}
161
168
  className={cn(
162
- "absolute top-full left-0 mt-1",
163
- item.dropdownClassName ? "" : "bg-ods-card border border-ods-border",
164
- "rounded-lg shadow-xl z-[9999]",
165
- item.id === 'community' ? "min-w-[240px]" : "min-w-[220px]",
166
- "transition-opacity duration-150",
167
- isOpen
168
- ? "opacity-100 visible pointer-events-auto"
169
- : "opacity-0 invisible pointer-events-none",
170
- item.dropdownClassName || ''
169
+ 'absolute top-full left-0 mt-1',
170
+ item.dropdownClassName ? '' : 'bg-ods-card border border-ods-border',
171
+ 'rounded-lg shadow-xl z-[9999]',
172
+ item.id === 'community' ? 'min-w-[240px]' : 'min-w-[220px]',
173
+ 'transition-opacity duration-150',
174
+ isOpen ? 'opacity-100 visible pointer-events-auto' : 'opacity-0 invisible pointer-events-none',
175
+ item.dropdownClassName || '',
171
176
  )}
172
177
  >
173
178
  <div className="p-2">
@@ -181,18 +186,18 @@ export function Header({ config, platform }: HeaderProps) {
181
186
  rightIcon={child.badge}
182
187
  onClick={() => {
183
188
  // Always close dropdown when any item is clicked
184
- setOpenDropdowns(prev => ({ ...prev, [item.id]: false }))
189
+ setOpenDropdowns(prev => ({ ...prev, [item.id]: false }));
185
190
  // If there's a custom onClick, call it too
186
191
  if (child.onClick) {
187
- child.onClick()
192
+ child.onClick();
188
193
  }
189
194
  }}
190
195
  className={cn(
191
- "flex justify-start w-full",
192
- "text-h6 font-bold",
193
- index < (item.children?.length ?? 0) - 1 && "mb-1",
194
- "text-ods-text-primary", // All dropdown items use primary text color
195
- child.isActive && 'bg-ods-bg-hover' // Active dropdown items get gray background
196
+ 'flex justify-start w-full',
197
+ 'text-h6 font-bold',
198
+ index < (item.children?.length ?? 0) - 1 && 'mb-1',
199
+ 'text-ods-text-primary', // All dropdown items use primary text color
200
+ child.isActive && 'bg-ods-bg-hover', // Active dropdown items get gray background
196
201
  )}
197
202
  {...(child.isExternal && { isExternal: true })}
198
203
  >
@@ -203,14 +208,12 @@ export function Header({ config, platform }: HeaderProps) {
203
208
  {item.dropdownContent && (
204
209
  <>
205
210
  {item.showDropdownDivider !== false && <div className="h-px my-2 mx-2 bg-ods-border" />}
206
- <div className="px-2 pb-2">
207
- {item.dropdownContent}
208
- </div>
211
+ <div className="px-2 pb-2">{item.dropdownContent}</div>
209
212
  </>
210
213
  )}
211
214
  </div>
212
215
  </div>
213
- )
216
+ );
214
217
  }
215
218
 
216
219
  // Regular navigation item
@@ -225,18 +228,18 @@ export function Header({ config, platform }: HeaderProps) {
225
228
  rightIcon={item.badge}
226
229
  size="small-legacy"
227
230
  className={cn(
228
- "text-h6 font-bold",
229
- "hover:bg-ods-bg-hover focus:bg-ods-bg-hover",
230
- "whitespace-nowrap",
231
- "text-ods-text-primary", // All items use primary text color
231
+ 'text-h3 font-bold tracking-[-0.36px]',
232
+ 'hover:bg-ods-bg-hover focus:bg-ods-bg-hover',
233
+ 'whitespace-nowrap',
234
+ 'text-ods-text-primary', // All items use primary text color
232
235
  item.isActive && 'bg-ods-bg-hover', // Active items get subtle gray background
233
- item.className
236
+ item.className,
234
237
  )}
235
238
  {...(item.isExternal && { isExternal: true })}
236
239
  >
237
240
  {item.label}
238
241
  </Button>
239
- )
242
+ );
240
243
  }
241
244
 
242
245
  // Button with onClick
@@ -249,121 +252,141 @@ export function Header({ config, platform }: HeaderProps) {
249
252
  rightIcon={item.badge}
250
253
  size="small-legacy"
251
254
  className={cn(
252
- "text-h6 font-bold",
253
- "hover:bg-ods-bg-hover focus:bg-ods-bg-hover",
254
- "whitespace-nowrap",
255
- "text-ods-text-primary", // All items use primary text color
255
+ 'text-h3 font-bold tracking-[-0.36px]',
256
+ 'hover:bg-ods-bg-hover focus:bg-ods-bg-hover',
257
+ 'whitespace-nowrap',
258
+ 'text-ods-text-primary', // All items use primary text color
256
259
  item.isActive && 'bg-ods-bg-hover', // Active items get gray background
257
- item.className
260
+ item.className,
258
261
  )}
259
262
  >
260
263
  {item.label}
261
264
  </Button>
262
- )
263
- }
264
-
265
-
265
+ );
266
+ };
267
+
268
+ const hasNav = !!config.navigation && config.navigation.items.length > 0;
269
+ // Always-visible leading cells (admin-sidebar toggle). The logo zone's
270
+ // desktop `pl-xxl` (80px) exists to compensate for the ABSENCE of a leading
271
+ // cell — with one present it reads as a stray gap, so collapse it to the
272
+ // regular `pl-l` inset. (The marketing burger doesn't count: it is
273
+ // CSS-hidden on lg, exactly where the 80px inset applies.)
274
+ const hasLeftCells = !!config.actions?.left?.length;
275
+ const hasCta =
276
+ !!config.actions?.right?.length || !!(config.actions?.persistent && config.actions.persistent.length > 0);
277
+
266
278
  return (
267
- <div
268
- className={cn(
269
- "sticky top-0 z-[50] w-full transition-transform duration-300 ease-in-out"
270
- )}
279
+ <div
280
+ className={cn('sticky top-0 z-[50] w-full transition-transform duration-300 ease-in-out')}
271
281
  style={{
272
- transform: !show ? 'translateY(-100%)' : 'translateY(0)'
282
+ transform: !show ? 'translateY(-100%)' : 'translateY(0)',
273
283
  }}
274
284
  >
275
- <header
276
- className={cn(
277
- // 72px = unified-header spec height (Figma 4033-90260); the right
278
- // cluster self-stretches so the Mingo launcher can sit flush.
279
- "w-full h-[72px] flex items-center justify-between",
280
- // NOTE: no `backdrop-blur` here. Every platform ships an OPAQUE
281
- // header background (`backgroundColor` below resolves to an opaque
282
- // ODS token bg-ods-bg / bg-ods-card), so a backdrop-filter would
283
- // blur a backdrop that is then fully painted over: zero visual
284
- // effect, but it still forces the browser to keep a backdrop-filter
285
- // surface and re-rasterize the strip behind this sticky bar every
286
- // scroll frame. That surface desyncs a frame against the scrolling
287
- // content and makes content flicker as it passes under the header
288
- // (most visible on large high-contrast headings, e.g. /pricing).
289
- // If a consumer ever wants a translucent "glass" header, add the
290
- // blur together with a translucent `backgroundColor` deliberately.
291
- "border-b border-ods-border",
292
- "pl-6",
293
- !config.mingo?.enabled && "pr-6",
294
- // Background color (configurable via backgroundColor prop)
295
- config.backgroundColor || "bg-ods-card",
296
- config.className
297
- )}
285
+ {/* Unified ODS top-navigation shell (Figma 2797-5978): 48px mobile /
286
+ 56px md+, cell model with per-cell dividers. NOTE: no `backdrop-blur`
287
+ anywhere in this bar. Every platform ships an OPAQUE header
288
+ background (`backgroundColor` resolves to an opaque ODS token), so a
289
+ backdrop-filter would blur a backdrop that is then fully painted
290
+ over: zero visual effect, but the browser still re-rasterizes the
291
+ strip behind this sticky bar every scroll frame and content flickers
292
+ as it passes under the header. If a consumer ever wants a
293
+ translucent "glass" header, add the blur together with a translucent
294
+ `backgroundColor` deliberately. */}
295
+ <TopNavigation
296
+ // `relative` anchors the absolutely-centered nav (position 'center')
297
+ // to the bar itself, so the links sit at the true viewport center
298
+ // regardless of the asymmetric CTA/Mingo cluster on the right.
299
+ className={cn('relative', config.className)}
298
300
  style={config.style}
299
- >
300
- {/* Left: Logo */}
301
- <div className="flex items-center justify-start flex-shrink-0">
302
- {config.actions?.left && (
303
- <div className="flex items-center">
304
- {config.actions.left}
305
- </div>
306
- )}
307
-
308
- <Link href={config.logo.href} className="transition-opacity duration-200 hover:opacity-80">
309
- {config.logo.element}
310
- </Link>
311
- </div>
312
-
313
- {/* Center: Navigation */}
314
- {config.navigation && config.navigation.items.length > 0 && (
315
- <nav
316
- className={cn(
317
- "hidden lg:flex items-center gap-2",
318
- config.navigation.position === 'center' && "absolute left-1/2 transform -translate-x-1/2",
319
- config.navigation.position === 'right' && "ml-auto mr-4"
320
- )}
321
- role="navigation"
322
- aria-label="Main navigation"
323
- >
324
- {config.navigation.items.map(renderNavigationItem)}
325
- </nav>
326
- )}
327
-
328
- {/* Right: Actions */}
329
- <div className="flex items-center justify-end gap-3 flex-shrink-0 self-stretch">
330
- {/* Desktop Actions — banded with the nav/burger breakpoint (lg) so the
331
- desktop right-cluster and the mobile toggle never co-show. */}
332
- {config.actions?.right && (
333
- <div className="hidden lg:flex items-center gap-3">
334
- {config.actions.right}
335
- </div>
336
- )}
337
-
338
- {config.actions?.persistent && config.actions.persistent.length > 0 && (
339
- <div className="flex items-center">
340
- {config.actions.persistent}
341
- </div>
342
- )}
343
-
344
- {/* Mobile Menu Toggle */}
345
- {config.mobile && config.mobile.enabled && (
346
- <Button
347
- variant="outline"
348
- size="icon"
349
- className="flex lg:hidden"
350
- onClick={() => {
351
- config.mobile?.onToggle?.()
352
- }}
353
- aria-label={config.mobile?.isOpen ? "Close menu" : "Open menu"}
354
- aria-expanded={config.mobile?.isOpen ?? false}
355
- // Conditional: the panel unmounts when closed, so an unconditional
356
- // reference would dangle (axe aria-valid-attr-value).
357
- aria-controls={config.mobile?.isOpen ? MOBILE_NAV_PANEL_ID : undefined}
358
- leftIcon={config.mobile?.menuIcon || <Menu01Icon />}
359
- />
360
- )}
361
-
362
- {config.mingo?.enabled && (
363
- <MingoAiButton source={config.mingo.source} icon={config.mingo.icon} label={config.mingo.label} className={config.mingo.className} />
301
+ backgroundClassName={config.backgroundColor}
302
+ centerBreakpoint="lg"
303
+ leading={
304
+ <>
305
+ {/* Length-guarded: platform configs often pass `left: []`, and an
306
+ empty array is truthy — without the guard the cell would render
307
+ as a bare divider + padding. */}
308
+ {!!config.actions?.left?.length && (
309
+ <div className="flex h-full items-center border-r border-ods-border px-[var(--spacing-system-xs)]">
310
+ {config.actions.left}
311
+ </div>
312
+ )}
313
+ {/* Mobile/tablet menu toggle — a leading cell per the ODS spec
314
+ (banded with the nav breakpoint `lg` so the desktop nav and the
315
+ toggle never co-show). */}
316
+ {config.mobile?.enabled && (
317
+ <HeaderButton
318
+ className="lg:hidden border-r border-ods-border"
319
+ onClick={() => {
320
+ config.mobile?.onToggle?.();
321
+ }}
322
+ isActive={config.mobile?.isOpen ?? false}
323
+ aria-label={config.mobile?.isOpen ? 'Close menu' : 'Open menu'}
324
+ aria-expanded={config.mobile?.isOpen ?? false}
325
+ // Conditional: the panel unmounts when closed, so an unconditional
326
+ // reference would dangle (axe aria-valid-attr-value).
327
+ aria-controls={config.mobile?.isOpen ? MOBILE_NAV_PANEL_ID : undefined}
328
+ icon={
329
+ config.mobile?.isOpen
330
+ ? config.mobile?.closeIcon || <XmarkIcon className="w-4 h-4 md:w-6 md:h-6" />
331
+ : config.mobile?.menuIcon || <Menu01Icon className="w-4 h-4 md:w-6 md:h-6" />
332
+ }
333
+ />
334
+ )}
335
+ </>
336
+ }
337
+ logo={
338
+ <Link href={config.logo.href} className="transition-opacity duration-200 hover:opacity-80">
339
+ {config.logo.element}
340
+ </Link>
341
+ }
342
+ logoClassName={hasLeftCells ? 'lg:pl-[var(--spacing-system-l)]' : undefined}
343
+ center={
344
+ hasNav ? (
345
+ <nav
346
+ className={cn(
347
+ 'flex items-center gap-2',
348
+ // True centering relative to the bar (not the leftover flex
349
+ // space) — same treatment the pre-unification header had.
350
+ config.navigation?.position === 'center' && 'absolute left-1/2 -translate-x-1/2',
351
+ )}
352
+ role="navigation"
353
+ aria-label="Main navigation"
354
+ >
355
+ {config.navigation?.items.map(renderNavigationItem)}
356
+ </nav>
357
+ ) : undefined
358
+ }
359
+ centerClassName={cn(
360
+ config.navigation?.position === 'left' && 'justify-start',
361
+ config.navigation?.position === 'right' && 'justify-end pr-[var(--spacing-system-m)]',
364
362
  )}
365
- </div>
366
- </header>
363
+ cta={
364
+ hasCta ? (
365
+ <>
366
+ {/* Desktop actions — banded with the nav/burger breakpoint (lg)
367
+ so the desktop right-cluster and the mobile toggle never
368
+ co-show. */}
369
+ {!!config.actions?.right?.length && (
370
+ <div className="hidden lg:flex items-center gap-3">{config.actions.right}</div>
371
+ )}
372
+ {config.actions?.persistent && config.actions.persistent.length > 0 && (
373
+ <div className="flex items-center">{config.actions.persistent}</div>
374
+ )}
375
+ </>
376
+ ) : undefined
377
+ }
378
+ ctaClassName="gap-3"
379
+ sideActions={
380
+ config.mingo?.enabled ? (
381
+ <MingoAiButton
382
+ source={config.mingo.source}
383
+ icon={config.mingo.icon}
384
+ label={config.mingo.label}
385
+ className={config.mingo.className}
386
+ />
387
+ ) : undefined
388
+ }
389
+ />
367
390
  </div>
368
- )
369
- }
391
+ );
392
+ }
@@ -50,6 +50,9 @@ export type { MobileBurgerMenuProps } from './mobile-burger-menu'
50
50
  export { HeaderButton } from './header-button'
51
51
  export type { HeaderButtonProps } from './header-button'
52
52
 
53
+ export { TopNavigation } from './top-navigation'
54
+ export type { TopNavigationProps, TopNavigationCenterBreakpoint } from './top-navigation'
55
+
53
56
  export { HeaderMingoButton } from './header-mingo-button'
54
57
  export type { HeaderMingoButtonProps } from './header-mingo-button'
55
58