@flamingo-stack/openframe-frontend-core 0.0.498 → 0.0.499-snapshot.20260730180712
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-MSB3PEPP.cjs → chunk-4NDGD2YH.cjs} +19 -19
- package/dist/{chunk-MSB3PEPP.cjs.map → chunk-4NDGD2YH.cjs.map} +1 -1
- package/dist/{chunk-VOOEX4IZ.js → chunk-5CY6YKWY.js} +6 -6
- package/dist/{chunk-XSNKSMB3.js → chunk-6TJJFQ74.js} +6 -6
- package/dist/{chunk-TTYJAZ7P.cjs → chunk-C5R2OX7A.cjs} +9 -4
- package/dist/chunk-C5R2OX7A.cjs.map +1 -0
- package/dist/{chunk-ZBTIN7RC.cjs → chunk-ETU6VFOL.cjs} +40 -40
- package/dist/{chunk-ZBTIN7RC.cjs.map → chunk-ETU6VFOL.cjs.map} +1 -1
- package/dist/{chunk-ZTE32A7C.cjs → chunk-GDUWGCPP.cjs} +12 -12
- package/dist/{chunk-ZTE32A7C.cjs.map → chunk-GDUWGCPP.cjs.map} +1 -1
- package/dist/{chunk-P47NN7RY.js → chunk-GSONSIWG.js} +28 -3
- package/dist/chunk-GSONSIWG.js.map +1 -0
- package/dist/{chunk-THC3K262.cjs → chunk-I64ZGPA6.cjs} +3 -3
- package/dist/{chunk-THC3K262.cjs.map → chunk-I64ZGPA6.cjs.map} +1 -1
- package/dist/{chunk-4NIBKEED.cjs → chunk-IDRKW6MS.cjs} +12 -12
- package/dist/{chunk-4NIBKEED.cjs.map → chunk-IDRKW6MS.cjs.map} +1 -1
- package/dist/{chunk-W24U7ZUV.cjs → chunk-KQKU4VXB.cjs} +40 -15
- package/dist/chunk-KQKU4VXB.cjs.map +1 -0
- package/dist/{chunk-SGL6URIJ.cjs → chunk-L5CI57RM.cjs} +9 -5
- package/dist/chunk-L5CI57RM.cjs.map +1 -0
- package/dist/{chunk-4EHQEMN4.cjs → chunk-LN2X4XAB.cjs} +18 -18
- package/dist/{chunk-4EHQEMN4.cjs.map → chunk-LN2X4XAB.cjs.map} +1 -1
- package/dist/{chunk-VLWVM6ZS.js → chunk-P3B65FBX.js} +4 -4
- package/dist/{chunk-2OPD6KRB.js → chunk-P5UMIPKR.js} +5 -5
- package/dist/{chunk-WEUNPE3S.js → chunk-P7IOCYGD.js} +53 -48
- package/dist/chunk-P7IOCYGD.js.map +1 -0
- package/dist/{chunk-ZEZTITQZ.cjs → chunk-PAO45LBQ.cjs} +1162 -931
- package/dist/chunk-PAO45LBQ.cjs.map +1 -0
- package/dist/{chunk-G6ARA2HD.js → chunk-QQAKDJPE.js} +3 -3
- package/dist/{chunk-KK4M4URN.js → chunk-R2IGOYHU.js} +3 -3
- package/dist/{chunk-A3Z63ZL5.cjs → chunk-S3COVC7H.cjs} +73 -73
- package/dist/{chunk-A3Z63ZL5.cjs.map → chunk-S3COVC7H.cjs.map} +1 -1
- package/dist/{chunk-OYPUQJJ2.js → chunk-SOX7CGFX.js} +342 -111
- package/dist/{chunk-OYPUQJJ2.js.map → chunk-SOX7CGFX.js.map} +1 -1
- package/dist/{chunk-T67MZAQJ.cjs → chunk-TJCUGL7S.cjs} +33 -33
- package/dist/{chunk-T67MZAQJ.cjs.map → chunk-TJCUGL7S.cjs.map} +1 -1
- package/dist/{chunk-K5HHLTSG.js → chunk-UM2YFCPE.js} +3 -3
- package/dist/{chunk-MVORKLWX.cjs → chunk-V5ALIJZ6.cjs} +32 -32
- package/dist/{chunk-MVORKLWX.cjs.map → chunk-V5ALIJZ6.cjs.map} +1 -1
- package/dist/{chunk-2R25CGSO.cjs → chunk-VLI6ESJN.cjs} +93 -93
- package/dist/{chunk-2R25CGSO.cjs.map → chunk-VLI6ESJN.cjs.map} +1 -1
- package/dist/{chunk-MF3VJKHT.js → chunk-VMDQDD5Y.js} +2 -2
- package/dist/{chunk-6WTCJI5R.js → chunk-VPQBQJ4S.js} +2 -2
- package/dist/{chunk-5ZL7PA63.js → chunk-W6Y66G4K.js} +3 -3
- package/dist/{chunk-QKWOBV55.js → chunk-WHJGRRK2.js} +9 -5
- package/dist/chunk-WHJGRRK2.js.map +1 -0
- package/dist/{chunk-6GAJETAP.js → chunk-XBLRT3MX.js} +4 -4
- package/dist/{chunk-ZHNWWFIM.cjs → chunk-YSN2AZJR.cjs} +5 -5
- package/dist/{chunk-ZHNWWFIM.cjs.map → chunk-YSN2AZJR.cjs.map} +1 -1
- package/dist/{chunk-XI44QDCD.cjs → chunk-ZLJCE75C.cjs} +40 -40
- package/dist/{chunk-XI44QDCD.cjs.map → chunk-ZLJCE75C.cjs.map} +1 -1
- package/dist/{chunk-W4XZ3FBZ.js → chunk-ZU27I5NZ.js} +6 -6
- package/dist/components/case-studies/index.cjs +11 -11
- package/dist/components/case-studies/index.js +5 -5
- package/dist/components/chat/hooks/use-jetstream-dialog-subscription.d.ts +5 -2
- package/dist/components/chat/hooks/use-jetstream-dialog-subscription.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +5 -5
- package/dist/components/chat/index.js +4 -4
- package/dist/components/chat/types/api.types.d.ts +9 -2
- package/dist/components/chat/types/api.types.d.ts.map +1 -1
- package/dist/components/contact/index.cjs +6 -6
- package/dist/components/contact/index.js +5 -5
- package/dist/components/docs/index.cjs +8 -8
- package/dist/components/docs/index.js +7 -7
- package/dist/components/embeds/index.cjs +6 -6
- package/dist/components/embeds/index.js +5 -5
- package/dist/components/faq/index.cjs +7 -7
- package/dist/components/faq/index.js +6 -6
- package/dist/components/features/filters-dropdown.d.ts.map +1 -1
- package/dist/components/features/index.cjs +5 -5
- package/dist/components/features/index.js +4 -4
- package/dist/components/help-center-pages/index.cjs +24 -24
- package/dist/components/help-center-pages/index.js +15 -15
- package/dist/components/index.cjs +166 -162
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +17 -13
- package/dist/components/index.js.map +1 -1
- package/dist/components/navigation/app-header.d.ts +48 -0
- package/dist/components/navigation/app-header.d.ts.map +1 -1
- package/dist/components/navigation/header-mingo-button.d.ts.map +1 -1
- package/dist/components/navigation/index.cjs +7 -5
- package/dist/components/navigation/index.cjs.map +1 -1
- package/dist/components/navigation/index.d.ts +2 -2
- package/dist/components/navigation/index.d.ts.map +1 -1
- package/dist/components/navigation/index.js +6 -4
- package/dist/components/navigation/navigation-sidebar-item.d.ts +14 -0
- package/dist/components/navigation/navigation-sidebar-item.d.ts.map +1 -1
- package/dist/components/navigation/navigation-sidebar-toggle.d.ts.map +1 -1
- package/dist/components/navigation/navigation-sidebar.d.ts +18 -0
- package/dist/components/navigation/navigation-sidebar.d.ts.map +1 -1
- package/dist/components/onboarding-guides/index.cjs +9 -9
- package/dist/components/onboarding-guides/index.js +8 -8
- package/dist/components/related-content/index.cjs +7 -7
- package/dist/components/related-content/index.js +6 -6
- package/dist/components/tickets/index.cjs +8 -8
- package/dist/components/tickets/index.js +7 -7
- package/dist/components/ui/button/button-styles.d.ts +1 -0
- package/dist/components/ui/button/button-styles.d.ts.map +1 -1
- package/dist/components/ui/button/button.d.ts.map +1 -1
- package/dist/components/ui/data-table/data-table-body.d.ts.map +1 -1
- package/dist/components/ui/data-table/data-table-column-filter.d.ts.map +1 -1
- package/dist/components/ui/data-table/data-table-header.d.ts.map +1 -1
- package/dist/components/ui/data-table/data-table-row.d.ts.map +1 -1
- package/dist/components/ui/data-table/data-table-skeleton.d.ts +12 -0
- package/dist/components/ui/data-table/data-table-skeleton.d.ts.map +1 -1
- package/dist/components/ui/data-table/index.d.ts +1 -1
- package/dist/components/ui/data-table/index.d.ts.map +1 -1
- package/dist/components/ui/data-table/utils.d.ts +18 -0
- package/dist/components/ui/data-table/utils.d.ts.map +1 -1
- package/dist/components/ui/file-manager/index.cjs +50 -50
- package/dist/components/ui/file-manager/index.js +1 -1
- package/dist/components/ui/index.cjs +7 -5
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.js +6 -4
- package/dist/hooks/index.cjs +5 -3
- package/dist/hooks/index.cjs.map +1 -1
- package/dist/hooks/index.js +4 -2
- package/dist/hooks/ui/index.d.ts +1 -0
- package/dist/hooks/ui/index.d.ts.map +1 -1
- package/dist/hooks/ui/use-isomorphic-layout-effect.d.ts +17 -0
- package/dist/hooks/ui/use-isomorphic-layout-effect.d.ts.map +1 -0
- package/dist/index.cjs +11 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +10 -4
- package/dist/nats/index.cjs +2 -2
- package/dist/nats/index.js +1 -1
- package/dist/nats/nats.d.ts +18 -1
- package/dist/nats/nats.d.ts.map +1 -1
- package/dist/types/navigation.d.ts +24 -0
- package/dist/types/navigation.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/chat/hooks/.use-jetstream-dialog-subscription.md +7 -3
- package/src/components/chat/hooks/use-jetstream-dialog-subscription.ts +77 -5
- package/src/components/chat/types/api.types.ts +9 -2
- package/src/components/features/filters-dropdown.tsx +17 -5
- package/src/components/navigation/app-header.tsx +178 -0
- package/src/components/navigation/header-mingo-button.tsx +5 -0
- package/src/components/navigation/index.ts +2 -2
- package/src/components/navigation/navigation-sidebar-item.tsx +22 -0
- package/src/components/navigation/navigation-sidebar-toggle.tsx +10 -6
- package/src/components/navigation/navigation-sidebar.tsx +187 -44
- package/src/components/ui/button/button-styles.ts +11 -0
- package/src/components/ui/button/button.tsx +6 -3
- package/src/components/ui/button/split-button.tsx +2 -2
- package/src/components/ui/data-table/data-table-body.tsx +5 -2
- package/src/components/ui/data-table/data-table-column-filter.tsx +54 -25
- package/src/components/ui/data-table/data-table-header.tsx +134 -45
- package/src/components/ui/data-table/data-table-row.tsx +3 -2
- package/src/components/ui/data-table/data-table-skeleton.tsx +17 -2
- package/src/components/ui/data-table/index.ts +1 -1
- package/src/components/ui/data-table/utils.ts +49 -3
- package/src/hooks/ui/index.ts +1 -0
- package/src/hooks/ui/use-isomorphic-layout-effect.ts +20 -0
- package/src/nats/.nats.md +2 -2
- package/src/nats/nats.ts +70 -5
- package/src/types/navigation.ts +21 -0
- package/dist/chunk-P47NN7RY.js.map +0 -1
- package/dist/chunk-QKWOBV55.js.map +0 -1
- package/dist/chunk-SGL6URIJ.cjs.map +0 -1
- package/dist/chunk-TTYJAZ7P.cjs.map +0 -1
- package/dist/chunk-W24U7ZUV.cjs.map +0 -1
- package/dist/chunk-WEUNPE3S.js.map +0 -1
- package/dist/chunk-ZEZTITQZ.cjs.map +0 -1
- /package/dist/{chunk-VOOEX4IZ.js.map → chunk-5CY6YKWY.js.map} +0 -0
- /package/dist/{chunk-XSNKSMB3.js.map → chunk-6TJJFQ74.js.map} +0 -0
- /package/dist/{chunk-VLWVM6ZS.js.map → chunk-P3B65FBX.js.map} +0 -0
- /package/dist/{chunk-2OPD6KRB.js.map → chunk-P5UMIPKR.js.map} +0 -0
- /package/dist/{chunk-G6ARA2HD.js.map → chunk-QQAKDJPE.js.map} +0 -0
- /package/dist/{chunk-KK4M4URN.js.map → chunk-R2IGOYHU.js.map} +0 -0
- /package/dist/{chunk-K5HHLTSG.js.map → chunk-UM2YFCPE.js.map} +0 -0
- /package/dist/{chunk-MF3VJKHT.js.map → chunk-VMDQDD5Y.js.map} +0 -0
- /package/dist/{chunk-6WTCJI5R.js.map → chunk-VPQBQJ4S.js.map} +0 -0
- /package/dist/{chunk-5ZL7PA63.js.map → chunk-W6Y66G4K.js.map} +0 -0
- /package/dist/{chunk-6GAJETAP.js.map → chunk-XBLRT3MX.js.map} +0 -0
- /package/dist/{chunk-W4XZ3FBZ.js.map → chunk-ZU27I5NZ.js.map} +0 -0
|
@@ -50,8 +50,57 @@ export interface AppHeaderProps {
|
|
|
50
50
|
* EXCEPT the mobile burger menu toggle, which remains interactive.
|
|
51
51
|
*/
|
|
52
52
|
disabled?: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Draw placeholder cells instead of the live controls.
|
|
55
|
+
*
|
|
56
|
+
* Solves a problem this header cannot solve on its own: its mobile/desktop split
|
|
57
|
+
* is decided by `useMdUp()`, which answers `undefined` until an effect has run —
|
|
58
|
+
* and `?? false` turns that into "mobile". The first render is therefore always
|
|
59
|
+
* the phone header (burger + wordmark, no side actions), which on a desktop load
|
|
60
|
+
* is a visible flash before the real layout appears. It is also what a host has
|
|
61
|
+
* to show while the user and the action flags are still loading.
|
|
62
|
+
*
|
|
63
|
+
* The placeholder decides mobile vs desktop in CSS instead, so it is correct at
|
|
64
|
+
* every width on the very first paint, server-rendered included.
|
|
65
|
+
*/
|
|
66
|
+
loading?: boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Shape of the trailing action cells `loading` reserves.
|
|
69
|
+
*
|
|
70
|
+
* Needed because the `show*` props are usually the very thing the host is still
|
|
71
|
+
* waiting on — they are typically driven by feature flags or permissions, so during
|
|
72
|
+
* `loading` they all read `false` and the placeholder collapses to whatever is
|
|
73
|
+
* hardcoded on (often just the avatar), which looks nothing like the loaded header.
|
|
74
|
+
*
|
|
75
|
+
* Pass the cells the header settles on, in order — see `HeaderLoadingCell` for the
|
|
76
|
+
* footprints. A bare number is accepted as shorthand for all-`'icon'`.
|
|
77
|
+
*
|
|
78
|
+
* Unlike the sidebar's rows, a cell that turns out not to exist is cheap here: the
|
|
79
|
+
* cells are a right-aligned cluster in otherwise empty space, so one disappearing
|
|
80
|
+
* shifts nothing else on the page.
|
|
81
|
+
*
|
|
82
|
+
* Defaults to the shape implied by the `show*` props, which is right for a host whose
|
|
83
|
+
* header composition is known up front.
|
|
84
|
+
*/
|
|
85
|
+
loadingActionCells?: number | ReadonlyArray<HeaderLoadingCell>;
|
|
53
86
|
}
|
|
54
87
|
|
|
88
|
+
/**
|
|
89
|
+
* A trailing header cell's footprint while loading — see `loadingActionCells`.
|
|
90
|
+
*
|
|
91
|
+
* The breakpoint-scoped members exist because several live cells are themselves
|
|
92
|
+
* responsive, and a placeholder that ignores that is wider than the header it hands
|
|
93
|
+
* off to on exactly the viewports where the cluster is tightest:
|
|
94
|
+
*
|
|
95
|
+
* - `'icon'` — a fixed 48/56px cell at every width (time tracker, notifications).
|
|
96
|
+
* - `'icon-md'` — the same cell, from md only (the avatar: `isMdUp && showUser`).
|
|
97
|
+
* - `'icon-lg'` — the same cell, from lg only (the organization filter, which the
|
|
98
|
+
* live header renders `hidden lg:flex`).
|
|
99
|
+
* - `'wide'` — the Mingo launcher: a 48px square below md, where the live button is
|
|
100
|
+
* `iconOnly`, and the fixed 140px labelled cell from md.
|
|
101
|
+
*/
|
|
102
|
+
export type HeaderLoadingCell = 'icon' | 'icon-md' | 'icon-lg' | 'wide';
|
|
103
|
+
|
|
55
104
|
export const AppHeader = React.memo(function AppHeader({
|
|
56
105
|
showSearch,
|
|
57
106
|
onSearch,
|
|
@@ -75,9 +124,36 @@ export const AppHeader = React.memo(function AppHeader({
|
|
|
75
124
|
isMobileMenuOpen,
|
|
76
125
|
onToggleMobileMenu,
|
|
77
126
|
disabled = false,
|
|
127
|
+
loading = false,
|
|
128
|
+
loadingActionCells,
|
|
78
129
|
}: AppHeaderProps) {
|
|
79
130
|
const isMdUp = useMdUp() ?? false;
|
|
80
131
|
|
|
132
|
+
// After the only hook above, so the hook order is identical in both branches.
|
|
133
|
+
if (loading) {
|
|
134
|
+
return (
|
|
135
|
+
<AppHeaderSkeleton
|
|
136
|
+
showSearch={showSearch}
|
|
137
|
+
actionCells={
|
|
138
|
+
loadingActionCells ??
|
|
139
|
+
// Fallback: what the `show*` props imply, in the order the live cells are
|
|
140
|
+
// mounted below. Only correct for a host that knows its header composition
|
|
141
|
+
// before it starts loading. The cell types carry each control's own
|
|
142
|
+
// breakpoint — the organization filter is `hidden lg:flex`, the avatar is
|
|
143
|
+
// gated on `isMdUp`, and Mingo is `iconOnly` below md.
|
|
144
|
+
([
|
|
145
|
+
showOrganizations && 'icon-lg',
|
|
146
|
+
showTimeTracker && 'icon',
|
|
147
|
+
showNotifications && 'icon',
|
|
148
|
+
showUser && 'icon-md',
|
|
149
|
+
showMingoAI && 'wide',
|
|
150
|
+
].filter(Boolean) as HeaderLoadingCell[])
|
|
151
|
+
}
|
|
152
|
+
className={className}
|
|
153
|
+
/>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
81
157
|
const dimmedClass = disabled ? 'pointer-events-none opacity-50' : '';
|
|
82
158
|
// Cells carry their own dividers in the unified TopNavigation model
|
|
83
159
|
// (the shell no longer applies divide-x).
|
|
@@ -268,3 +344,105 @@ function NotificationsHeaderButton({ fallbackUnreadCount, disabled, dimmedClass
|
|
|
268
344
|
}
|
|
269
345
|
|
|
270
346
|
export default AppHeader;
|
|
347
|
+
|
|
348
|
+
// Literal class strings — Tailwind's scanner needs to see them spelled out.
|
|
349
|
+
const CELL_VISIBILITY: Record<Exclude<HeaderLoadingCell, 'wide'>, string | undefined> = {
|
|
350
|
+
icon: undefined,
|
|
351
|
+
'icon-md': 'hidden md:flex',
|
|
352
|
+
'icon-lg': 'hidden lg:flex',
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
/** One trailing action cell placeholder — mirrors the live cells' 48/56px box. */
|
|
356
|
+
function HeaderCellSkeleton({ className }: { className?: string }) {
|
|
357
|
+
return (
|
|
358
|
+
<div
|
|
359
|
+
className={cn(
|
|
360
|
+
'flex h-full w-12 shrink-0 items-center justify-center border-l border-ods-border md:w-14',
|
|
361
|
+
className,
|
|
362
|
+
)}
|
|
363
|
+
>
|
|
364
|
+
<div className="h-4 w-4 animate-pulse rounded bg-ods-border md:h-6 md:w-6" />
|
|
365
|
+
</div>
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Wide cell placeholder — mirrors `HeaderMingoButton` at both widths, which are
|
|
371
|
+
* two different shapes: the live button gets `iconOnly={!isMdUp}`, so below md it
|
|
372
|
+
* drops its wordmark and collapses to the same 48px square as any other cell, and
|
|
373
|
+
* from md it is the fixed 140px labelled cell. Reserving 140px-worth of icon,
|
|
374
|
+
* label, gap and padding on a phone would make the placeholder cluster ~70px
|
|
375
|
+
* wider than the header it hands off to.
|
|
376
|
+
*/
|
|
377
|
+
function HeaderWideCellSkeleton() {
|
|
378
|
+
return (
|
|
379
|
+
<div className="flex h-full w-12 shrink-0 items-center justify-center gap-2 border-l border-ods-border md:w-[140px] md:px-4">
|
|
380
|
+
<div className="h-4 w-4 shrink-0 animate-pulse rounded bg-ods-border md:h-6 md:w-6" />
|
|
381
|
+
<div className="hidden h-5 animate-pulse rounded bg-ods-border md:block md:w-[72px]" />
|
|
382
|
+
</div>
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* `AppHeader`'s loading state — see `AppHeaderProps.loading`.
|
|
388
|
+
*
|
|
389
|
+
* Keep in sync with `AppHeader` above; a diverging placeholder makes the handoff
|
|
390
|
+
* jump. Every breakpoint decision here is a Tailwind variant on purpose: this is the
|
|
391
|
+
* one thing the live header cannot do (its split runs through `useMdUp()`), and it
|
|
392
|
+
* is the whole reason this branch exists.
|
|
393
|
+
*/
|
|
394
|
+
function AppHeaderSkeleton({
|
|
395
|
+
showSearch,
|
|
396
|
+
actionCells,
|
|
397
|
+
className,
|
|
398
|
+
}: {
|
|
399
|
+
showSearch?: boolean;
|
|
400
|
+
actionCells: number | ReadonlyArray<HeaderLoadingCell>;
|
|
401
|
+
className?: string;
|
|
402
|
+
}) {
|
|
403
|
+
const cells: ReadonlyArray<HeaderLoadingCell> =
|
|
404
|
+
typeof actionCells === 'number'
|
|
405
|
+
? Array.from({ length: Math.max(0, actionCells) }, () => 'icon' as const)
|
|
406
|
+
: actionCells;
|
|
407
|
+
return (
|
|
408
|
+
<TopNavigation
|
|
409
|
+
className={cn('sticky top-0 z-40', className)}
|
|
410
|
+
centerBreakpoint="md"
|
|
411
|
+
aria-busy="true"
|
|
412
|
+
leading={
|
|
413
|
+
// Burger cell: mobile only, in CSS.
|
|
414
|
+
<div className="flex h-full w-12 shrink-0 items-center justify-center border-r border-ods-border md:hidden">
|
|
415
|
+
<div className="h-4 w-4 animate-pulse rounded bg-ods-border" />
|
|
416
|
+
</div>
|
|
417
|
+
}
|
|
418
|
+
logo={
|
|
419
|
+
<>
|
|
420
|
+
<div className="h-6 w-6 shrink-0 animate-pulse rounded bg-ods-border" />
|
|
421
|
+
<div className="h-4 w-24 animate-pulse rounded bg-ods-border" />
|
|
422
|
+
</>
|
|
423
|
+
}
|
|
424
|
+
// `md:hidden` belongs on the WRAPPER, not the placeholder inside it:
|
|
425
|
+
// `TopNavigation` builds its padded logo zone whenever `logo` is truthy, and
|
|
426
|
+
// that padding runs to 24px at md and 80px at lg. The live header passes
|
|
427
|
+
// `false` here from md up, so the zone does not exist at all — leaving the
|
|
428
|
+
// wrapper visible would inset the search field by 80px on desktop and shift
|
|
429
|
+
// it back on handoff, which is the exact jump this branch removes.
|
|
430
|
+
logoClassName="gap-2 md:hidden"
|
|
431
|
+
// `TopNavigation` already hides the center zone below `centerBreakpoint`.
|
|
432
|
+
center={showSearch ? <div className="h-10 w-full animate-pulse rounded-md bg-ods-border" /> : undefined}
|
|
433
|
+
sideActions={
|
|
434
|
+
<>
|
|
435
|
+
{/* Mobile-only search trigger; from md the search lives in the center zone. */}
|
|
436
|
+
{showSearch && <HeaderCellSkeleton className="md:hidden" />}
|
|
437
|
+
{cells.map((cell, i) =>
|
|
438
|
+
cell === 'wide' ? (
|
|
439
|
+
<HeaderWideCellSkeleton key={i} />
|
|
440
|
+
) : (
|
|
441
|
+
<HeaderCellSkeleton key={i} className={CELL_VISIBILITY[cell]} />
|
|
442
|
+
),
|
|
443
|
+
)}
|
|
444
|
+
</>
|
|
445
|
+
}
|
|
446
|
+
/>
|
|
447
|
+
);
|
|
448
|
+
}
|
|
@@ -39,6 +39,11 @@ export function HeaderMingoButton({
|
|
|
39
39
|
aria-pressed={isActive}
|
|
40
40
|
className={cn(
|
|
41
41
|
'flex items-center shrink-0 h-full gap-2 px-4',
|
|
42
|
+
// Fixed 140px cell from md, so the header's right-hand cluster has a
|
|
43
|
+
// known width and a loading placeholder can reserve it exactly
|
|
44
|
+
// (`AppHeaderProps.loadingActionCells` → `'wide'`). Content-width below
|
|
45
|
+
// md, and in `iconOnly` mode, where the control is a square affordance.
|
|
46
|
+
!iconOnly && 'md:w-[140px] md:justify-center',
|
|
42
47
|
'transition-colors duration-200',
|
|
43
48
|
'focus:outline-none focus-visible:ring-2 focus-visible:ring-ods-accent',
|
|
44
49
|
// Transparent at rest so the cell inherits the bar's background
|
|
@@ -22,11 +22,11 @@ export type { SlidingSidebarProps } from './sliding-sidebar'
|
|
|
22
22
|
export { StickySectionNav, useSectionNavigation } from './sticky-section-nav'
|
|
23
23
|
export type { StickyNavSection } from './sticky-section-nav'
|
|
24
24
|
|
|
25
|
-
export { NavigationSidebar } from './navigation-sidebar'
|
|
25
|
+
export { NAVIGATION_SIDEBAR_WIDTH_VAR, NavigationSidebar } from './navigation-sidebar'
|
|
26
26
|
export type { NavigationSidebarProps } from './navigation-sidebar'
|
|
27
27
|
|
|
28
28
|
export { AppHeader } from './app-header'
|
|
29
|
-
export type { AppHeaderProps } from './app-header'
|
|
29
|
+
export type { AppHeaderProps, HeaderLoadingCell } from './app-header'
|
|
30
30
|
|
|
31
31
|
export { AppLayout, useAppLayoutDrawerContainer } from './app-layout'
|
|
32
32
|
export type { AppLayoutProps } from './app-layout'
|
|
@@ -130,3 +130,25 @@ export const NavigationSidebarItemButton = memo(function NavigationSidebarItemBu
|
|
|
130
130
|
</Link>
|
|
131
131
|
)
|
|
132
132
|
})
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* One placeholder entry, for `NavigationSidebarConfig.loading`.
|
|
136
|
+
*
|
|
137
|
+
* Deliberately in this file, beside `NavigationSidebarItemButton`: the two must
|
|
138
|
+
* agree on `h-14`, the `p-[var(--spacing-system-m)]` inset, the 24px icon box and
|
|
139
|
+
* the label column, or the nav resizes on the handoff. Same reason
|
|
140
|
+
* `header-skeleton.tsx` sits beside `header.tsx`.
|
|
141
|
+
*
|
|
142
|
+
* `showLabel` is the sidebar's own minimized state, so the caller passes what it
|
|
143
|
+
* already passes the real row — no separate width handling.
|
|
144
|
+
*/
|
|
145
|
+
export function NavigationSidebarItemSkeleton({ showLabel }: { showLabel: boolean }) {
|
|
146
|
+
return (
|
|
147
|
+
<div className="w-full flex items-center justify-start h-14 p-[var(--spacing-system-m)]" aria-hidden="true">
|
|
148
|
+
<div className="relative flex items-center justify-center flex-shrink-0">
|
|
149
|
+
<div className="h-6 w-6 animate-pulse rounded bg-ods-border" />
|
|
150
|
+
</div>
|
|
151
|
+
{showLabel && <div className="h-4 flex-1 ml-[var(--spacing-system-xs)] animate-pulse rounded bg-ods-border" />}
|
|
152
|
+
</div>
|
|
153
|
+
)
|
|
154
|
+
}
|
|
@@ -30,12 +30,16 @@ export function NavigationSidebarToggle({
|
|
|
30
30
|
aria-label={"Hide Menu"}
|
|
31
31
|
>
|
|
32
32
|
<div className="flex items-center justify-center flex-shrink-0">
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
33
|
+
{/* Direction follows the rail's measured width (the `of-nav-sidebar`
|
|
34
|
+
container declared in `navigation-sidebar.tsx`), not `minimized`:
|
|
35
|
+
before hydration that prop can only carry the server's answer, and
|
|
36
|
+
a chevron pointing the wrong way in a 56px rail is the one piece of
|
|
37
|
+
sidebar state a user actually notices arriving late.
|
|
38
|
+
|
|
39
|
+
Collapsed is the default because container queries are min-width —
|
|
40
|
+
the rail is narrower than every threshold, so it needs no query of
|
|
41
|
+
its own. */}
|
|
42
|
+
<Chevrons03LeftIcon className="rotate-180 @[140px]/of-nav-sidebar:rotate-0 text-ods-text-secondary w-6 h-6 transition-transform duration-300" />
|
|
39
43
|
</div>
|
|
40
44
|
|
|
41
45
|
<span
|
|
@@ -1,18 +1,115 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
-
import { useCallback, useEffect,
|
|
3
|
+
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
4
|
+
import { useIsomorphicLayoutEffect } from '../../hooks/ui/use-isomorphic-layout-effect'
|
|
4
5
|
import { useLocalStorage } from '../../hooks/ui/use-local-storage'
|
|
5
6
|
import { useLgUp, useMdUp } from '../../hooks/ui/use-media-query'
|
|
6
7
|
import { NavigationSidebarConfig, NavigationSidebarItem } from '../../types/navigation'
|
|
7
8
|
import { cn } from '../../utils'
|
|
8
9
|
import { NavigationSidebarHeader } from './navigation-sidebar-header'
|
|
9
|
-
import { NavigationSidebarItemButton } from './navigation-sidebar-item'
|
|
10
|
+
import { NavigationSidebarItemButton, NavigationSidebarItemSkeleton } from './navigation-sidebar-item'
|
|
10
11
|
import { NavigationSidebarToggle } from './navigation-sidebar-toggle'
|
|
11
12
|
|
|
12
13
|
const MINIMIZED_WIDTH = 56 // 3.5rem = 56px
|
|
13
14
|
const EXPANDED_WIDTH = 224 // 14rem = 224px
|
|
14
15
|
const STORAGE_KEY = 'of.navigationSidebar.minimized'
|
|
15
16
|
|
|
17
|
+
/**
|
|
18
|
+
* The desktop width, as a custom property on `:root`. Read by the `lg:` rule in
|
|
19
|
+
* {@link SIDEBAR_GEOMETRY_CLASSES} and written by this component whenever the
|
|
20
|
+
* user toggles — but NEVER during render.
|
|
21
|
+
*
|
|
22
|
+
* It has to be a global variable rather than an inline style because of WHEN the
|
|
23
|
+
* value is knowable. The width follows a preference in `localStorage`, which the
|
|
24
|
+
* server cannot read: any markup derived from it differs between the server's
|
|
25
|
+
* HTML and the client's hydration render, and React tears the tree down and
|
|
26
|
+
* regenerates it. Seeding this property from a tiny script in `<head>` moves the
|
|
27
|
+
* preference OUT of React's rendered output — the markup becomes identical on
|
|
28
|
+
* both sides, while the width is already correct on the very first paint.
|
|
29
|
+
*
|
|
30
|
+
* Consumers are expected to seed it before first paint; unseeded, the fallback
|
|
31
|
+
* in the class below paints the expanded width until this component's effect
|
|
32
|
+
* catches up, which is the flash the seed exists to remove.
|
|
33
|
+
*/
|
|
34
|
+
export const NAVIGATION_SIDEBAR_WIDTH_VAR = '--of-navigation-sidebar-width'
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The rail as a query container, so the parts of the sidebar that follow its
|
|
38
|
+
* collapsed state can read that state off the rail's own width — currently the
|
|
39
|
+
* collapse chevron's direction, in `navigation-sidebar-toggle.tsx`.
|
|
40
|
+
*
|
|
41
|
+
* There is no second source of truth for "is it collapsed", and that is the
|
|
42
|
+
* point: the width already has to be correct on the first paint and at every
|
|
43
|
+
* breakpoint, so anything derived from it is too — the seeded desktop
|
|
44
|
+
* preference, the tablet rail and an overlay opened on tablet all come along for
|
|
45
|
+
* free. A parallel copy would have to be kept in step with each of the three
|
|
46
|
+
* separately, and the custom property that used to live here fell out of step on
|
|
47
|
+
* the last one: it could not see the tablet rail, so that case got its own media
|
|
48
|
+
* query, which in turn could not see an open overlay and pointed the chevron
|
|
49
|
+
* backwards under it.
|
|
50
|
+
*
|
|
51
|
+
* Queried as `@[140px]/of-nav-sidebar:` — 140px is the midpoint between the 56px
|
|
52
|
+
* rail and the 224px expanded sidebar, so the width only crosses it mid-toggle.
|
|
53
|
+
* Container queries are min-width, so a follower styles its COLLAPSED look as the
|
|
54
|
+
* default and lets the query take it back. Written out literally at each use
|
|
55
|
+
* rather than interpolated from a constant: Tailwind scans source text, and a
|
|
56
|
+
* class it cannot see spelled out is a class it never generates.
|
|
57
|
+
*/
|
|
58
|
+
const SIDEBAR_CONTAINER = '@container/of-nav-sidebar'
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Where the sidebar sits and how wide it is, per breakpoint — as literal classes,
|
|
62
|
+
* because this cannot be decided in JavaScript in time.
|
|
63
|
+
*
|
|
64
|
+
* `useMdUp`/`useLgUp` answer `undefined` until an effect has run, so `isTablet`
|
|
65
|
+
* is false on the first render — the SERVER's render included. A tablet
|
|
66
|
+
* therefore received the desktop branch in its HTML (`relative`, `width:224px`,
|
|
67
|
+
* an inline style no class could outrank) and only snapped to the 56px rail once
|
|
68
|
+
* hydration and an effect had both completed. That is the wide sidebar that
|
|
69
|
+
* flashed on every load, and no amount of JS could fix it: the server has no
|
|
70
|
+
* viewport to consult, so the answer has to be deferred to the one consumer that
|
|
71
|
+
* always knows — the browser's own media evaluation.
|
|
72
|
+
*
|
|
73
|
+
* The state that a viewport CANNOT determine still lives outside the render: the
|
|
74
|
+
* persisted desktop preference reaches the `lg:` rule through
|
|
75
|
+
* {@link NAVIGATION_SIDEBAR_WIDTH_VAR}. An inline width would be simpler than a
|
|
76
|
+
* custom property, but it would also override the tablet rail on the very first
|
|
77
|
+
* paint, which is the bug being fixed.
|
|
78
|
+
*
|
|
79
|
+
* The tablet WIDTH is deliberately not here — see {@link SIDEBAR_TABLET_WIDTH}.
|
|
80
|
+
*
|
|
81
|
+
* Widths are `w-14`/`w-56` — the class equivalents of `MINIMIZED_WIDTH` and
|
|
82
|
+
* `EXPANDED_WIDTH`, as is the `14rem` fallback below. Keep them in step; a class
|
|
83
|
+
* string cannot interpolate a constant.
|
|
84
|
+
*/
|
|
85
|
+
const SIDEBAR_GEOMETRY_CLASSES = [
|
|
86
|
+
// Tablet: float over the content, anchored to the layout row (`absolute`
|
|
87
|
+
// within AppLayout's `relative` row) — NOT the viewport — so an optional
|
|
88
|
+
// `topBar` above the row is not overlapped. With no topBar the row spans the
|
|
89
|
+
// full viewport, so this is visually identical to a viewport-fixed sidebar.
|
|
90
|
+
'md:absolute md:inset-y-0 md:left-0 md:z-[45]',
|
|
91
|
+
// Desktop: back in the flex flow, width from the persisted preference. The
|
|
92
|
+
// fallback IS the default, so the property needs no global declaration and an
|
|
93
|
+
// unseeded consumer gets the full sidebar rather than a rail with no labels.
|
|
94
|
+
'lg:relative lg:inset-auto lg:z-auto lg:h-full lg:w-[var(--of-navigation-sidebar-width,14rem)]',
|
|
95
|
+
].join(' ')
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The tablet width, as ONE `md:` class rather than a rail plus an override.
|
|
99
|
+
*
|
|
100
|
+
* Tablet is a 56px rail by design and only widens for an overlay the user has
|
|
101
|
+
* opened — state no viewport can imply, so it has to come from JS. Written as
|
|
102
|
+
* `md:w-14` + `max-lg:w-56` the two rules both match between md and lg at equal
|
|
103
|
+
* specificity, and Tailwind emits the `max-lg` block BEFORE the `md` one, so
|
|
104
|
+
* source order hands the tablet straight back to the rail. Picking one class
|
|
105
|
+
* removes the contest instead of trying to win it — and keeps the `!important`
|
|
106
|
+
* that winning it would need out of the tree.
|
|
107
|
+
*
|
|
108
|
+
* `lg:` still governs desktop either way: it is emitted after `md:`, so an
|
|
109
|
+
* overlay left open while the window widens does not leak into the desktop width.
|
|
110
|
+
*/
|
|
111
|
+
const SIDEBAR_TABLET_WIDTH = { rail: 'md:w-14', overlay: 'md:w-56' } as const
|
|
112
|
+
|
|
16
113
|
/** A click the browser will resolve itself — new tab, new window, middle button. */
|
|
17
114
|
const isModifiedClick = (event?: React.MouseEvent): boolean =>
|
|
18
115
|
!!event && (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0)
|
|
@@ -47,7 +144,22 @@ export function NavigationSidebar({ config, disabled = false }: NavigationSideba
|
|
|
47
144
|
if (isTablet) setTabletMinimized(true)
|
|
48
145
|
}, [isTablet])
|
|
49
146
|
|
|
50
|
-
|
|
147
|
+
// `useLocalStorage` reads the store SYNCHRONOUSLY in its initializer, so on the
|
|
148
|
+
// hydration render it already knows a preference the server could not. Anything
|
|
149
|
+
// derived from it — labels, `aria-hidden`, the chevron, the placeholder rows —
|
|
150
|
+
// then differs from the server's HTML, and React throws out the whole tree and
|
|
151
|
+
// regenerates it. So until hydration is done, this renders the server's answer
|
|
152
|
+
// and nothing else.
|
|
153
|
+
//
|
|
154
|
+
// That costs nothing visually. The width is already correct at that point (it
|
|
155
|
+
// comes from the seeded CSS var, not from here), and at the minimized width
|
|
156
|
+
// every label is a `flex-1` in a rail with no room to give — zero-wide whatever
|
|
157
|
+
// this says. What flips on the next commit is opacity and margin on boxes that
|
|
158
|
+
// were never visible.
|
|
159
|
+
const [hydrated, setHydrated] = useState(false)
|
|
160
|
+
useEffect(() => setHydrated(true), [])
|
|
161
|
+
|
|
162
|
+
const minimized = hydrated ? (isTablet ? tabletMinimized : desktopMinimized) : (config.minimized ?? false)
|
|
51
163
|
|
|
52
164
|
// Enable transitions only after the correct width is painted
|
|
53
165
|
const [transitionsEnabled, setTransitionsEnabled] = useState(false)
|
|
@@ -147,11 +259,35 @@ export function NavigationSidebar({ config, disabled = false }: NavigationSideba
|
|
|
147
259
|
secondaryItems: config.items.filter(item => item.section === 'secondary'),
|
|
148
260
|
}), [config.items])
|
|
149
261
|
|
|
262
|
+
// Placeholder rows while the host cannot know its entries yet — see
|
|
263
|
+
// `NavigationSidebarConfig.loading`. Only the ROWS are stood in for: the header,
|
|
264
|
+
// the collapse toggle, the widths and the tablet overlay all behave exactly as
|
|
265
|
+
// when loaded, so this is the loaded rail with unknown contents rather than a
|
|
266
|
+
// separate skeleton sidebar to keep in sync.
|
|
267
|
+
const loadingRows = useMemo(() => {
|
|
268
|
+
if (!config.loading) return null
|
|
269
|
+
const primary = config.loadingRows?.primary ?? 7
|
|
270
|
+
const secondary = config.loadingRows?.secondary ?? 2
|
|
271
|
+
return {
|
|
272
|
+
primary: Array.from({ length: Math.max(0, primary) }, (_, i) => `primary-${i}`),
|
|
273
|
+
secondary: Array.from({ length: Math.max(0, secondary) }, (_, i) => `secondary-${i}`),
|
|
274
|
+
}
|
|
275
|
+
}, [config.loading, config.loadingRows?.primary, config.loadingRows?.secondary])
|
|
276
|
+
|
|
150
277
|
const sidebarWidth = useMemo(
|
|
151
278
|
() => (minimized ? `${MINIMIZED_WIDTH}px` : `${EXPANDED_WIDTH}px`),
|
|
152
279
|
[minimized],
|
|
153
280
|
)
|
|
154
281
|
|
|
282
|
+
// Published to CSS from an EFFECT, never from render. Before hydration the
|
|
283
|
+
// width belongs to whatever seeded `NAVIGATION_SIDEBAR_WIDTH_VAR` in `<head>`
|
|
284
|
+
// — put it in the markup instead and it becomes part of what React hydrates,
|
|
285
|
+
// which is precisely what cannot agree across the server boundary. Afterwards
|
|
286
|
+
// this is what a toggle moves.
|
|
287
|
+
useIsomorphicLayoutEffect(() => {
|
|
288
|
+
document.documentElement.style.setProperty(NAVIGATION_SIDEBAR_WIDTH_VAR, sidebarWidth)
|
|
289
|
+
}, [sidebarWidth])
|
|
290
|
+
|
|
155
291
|
// There used to be an `isHydrated` gate here — `isMdUp !== undefined && ...`
|
|
156
292
|
// — meant to hold the sidebar's contents back until the media queries
|
|
157
293
|
// resolved. It could never be false: the `?? false` above had already
|
|
@@ -159,7 +295,7 @@ export function NavigationSidebar({ config, disabled = false }: NavigationSideba
|
|
|
159
295
|
// because repairing it would be the regression: the contents are server-
|
|
160
296
|
// rendered today, and gating them behind a client-only media query would
|
|
161
297
|
// trade a first paint of the real navigation for an empty rail.
|
|
162
|
-
|
|
298
|
+
useIsomorphicLayoutEffect(() => {
|
|
163
299
|
if (!transitionsEnabled) {
|
|
164
300
|
const id = requestAnimationFrame(() => {
|
|
165
301
|
setTransitionsEnabled(true)
|
|
@@ -183,61 +319,68 @@ export function NavigationSidebar({ config, disabled = false }: NavigationSideba
|
|
|
183
319
|
/>
|
|
184
320
|
|
|
185
321
|
{/* Flex-flow placeholder — reserves the collapsed 56px slot on tablet so
|
|
186
|
-
the main content keeps its position while the sidebar floats above it
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
/>
|
|
193
|
-
)}
|
|
322
|
+
the main content keeps its position while the sidebar floats above it.
|
|
323
|
+
Rendered unconditionally and scoped by classes for the same reason the
|
|
324
|
+
geometry is: gated on `isTablet`, it was missing from the server HTML
|
|
325
|
+
and from the first client paint — exactly when the sidebar it
|
|
326
|
+
compensates for has already gone `absolute`. */}
|
|
327
|
+
<div className="h-full hidden md:block lg:hidden w-14 flex-shrink-0" aria-hidden="true" />
|
|
194
328
|
|
|
195
329
|
<aside
|
|
196
330
|
className={cn(
|
|
331
|
+
SIDEBAR_CONTAINER,
|
|
197
332
|
"flex-col hidden md:flex flex-shrink-0",
|
|
198
333
|
"bg-ods-card border-r border-ods-border",
|
|
199
|
-
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
|
|
204
|
-
isTablet ? "absolute inset-y-0 left-0 z-[45]" : "relative h-full",
|
|
334
|
+
SIDEBAR_GEOMETRY_CLASSES,
|
|
335
|
+
// The one width a viewport cannot imply: an overlay the user opened on
|
|
336
|
+
// tablet. Only ever true after a click, so the first paint always gets
|
|
337
|
+
// the rail.
|
|
338
|
+
isOverlayOpen ? SIDEBAR_TABLET_WIDTH.overlay : SIDEBAR_TABLET_WIDTH.rail,
|
|
205
339
|
transitionsEnabled && "transition-[width] duration-300",
|
|
206
340
|
config.className,
|
|
207
341
|
)}
|
|
208
|
-
style={{ width: sidebarWidth }}
|
|
209
342
|
aria-label="Main navigation sidebar"
|
|
210
343
|
>
|
|
211
344
|
<NavigationSidebarHeader minimized={minimized} />
|
|
212
345
|
|
|
213
346
|
<div className="flex-1 flex flex-col justify-between py-4 overflow-y-auto">
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
347
|
+
{/* `aria-busy` on the nav, not the rows: the region is what is loading,
|
|
348
|
+
and each placeholder row is already `aria-hidden`. */}
|
|
349
|
+
<nav className="flex flex-col" aria-label="Primary navigation" aria-busy={!!loadingRows}>
|
|
350
|
+
{loadingRows
|
|
351
|
+
? loadingRows.primary.map(key => (
|
|
352
|
+
<NavigationSidebarItemSkeleton key={key} showLabel={showLabel} />
|
|
353
|
+
))
|
|
354
|
+
: primaryItems.map(item => (
|
|
355
|
+
<NavigationSidebarItemButton
|
|
356
|
+
key={item.id}
|
|
357
|
+
item={item}
|
|
358
|
+
isActive={item.id === committedActiveId}
|
|
359
|
+
isPending={item.id === pendingItemId}
|
|
360
|
+
showLabel={showLabel}
|
|
361
|
+
disabled={disabled}
|
|
362
|
+
onClick={handleItemClick}
|
|
363
|
+
/>
|
|
364
|
+
))}
|
|
226
365
|
</nav>
|
|
227
366
|
|
|
228
|
-
{secondaryItems.length > 0 && (
|
|
229
|
-
<nav className="flex flex-col" aria-label="Secondary navigation">
|
|
230
|
-
{
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
367
|
+
{(loadingRows ? loadingRows.secondary.length > 0 : secondaryItems.length > 0) && (
|
|
368
|
+
<nav className="flex flex-col" aria-label="Secondary navigation" aria-busy={!!loadingRows}>
|
|
369
|
+
{loadingRows
|
|
370
|
+
? loadingRows.secondary.map(key => (
|
|
371
|
+
<NavigationSidebarItemSkeleton key={key} showLabel={showLabel} />
|
|
372
|
+
))
|
|
373
|
+
: secondaryItems.map(item => (
|
|
374
|
+
<NavigationSidebarItemButton
|
|
375
|
+
key={item.id}
|
|
376
|
+
item={item}
|
|
377
|
+
isActive={item.id === committedActiveId}
|
|
378
|
+
isPending={item.id === pendingItemId}
|
|
379
|
+
showLabel={showLabel}
|
|
380
|
+
disabled={disabled}
|
|
381
|
+
onClick={handleItemClick}
|
|
382
|
+
/>
|
|
383
|
+
))}
|
|
241
384
|
</nav>
|
|
242
385
|
)}
|
|
243
386
|
</div>
|
|
@@ -32,6 +32,17 @@ export const buttonSurfaceClasses = {
|
|
|
32
32
|
export const outlineBorderClasses =
|
|
33
33
|
"border border-ods-border hover:border-ods-border-hover active:border-ods-border-active disabled:border-ods-border aria-disabled:border-ods-border"
|
|
34
34
|
|
|
35
|
+
// Glyph scale for the split-layout button family: 16px below md, 24px from md.
|
|
36
|
+
//
|
|
37
|
+
// Shared because the same page-header action renders through EITHER of the two
|
|
38
|
+
// split layouts depending on its shape — `SplitButton` for two click targets, and
|
|
39
|
+
// `Button` + `splitIcon` for one with a decorative trailing glyph — and the two sit
|
|
40
|
+
// side by side in the same row. They used to disagree: `SplitButton` carried this
|
|
41
|
+
// responsive scale while the `Button` split slots re-declared a flat 20px, so a
|
|
42
|
+
// "submenu" action's icons rendered 4px smaller than its neighbour's on desktop.
|
|
43
|
+
// `Button`'s own flat `[&_svg]:h-5` stays as it is for ordinary (non-split) buttons.
|
|
44
|
+
export const splitGlyphSizeClasses = "[&_svg]:h-4 [&_svg]:w-4 md:[&_svg]:h-6 md:[&_svg]:w-6"
|
|
45
|
+
|
|
35
46
|
// Color of the vertical seam between the main and icon halves (split layouts).
|
|
36
47
|
export const splitDividerColorClasses = {
|
|
37
48
|
accent: "border-ods-accent-active",
|
|
@@ -6,7 +6,7 @@ import Link from "../../../embed-shims/next-link"
|
|
|
6
6
|
import React from "react"
|
|
7
7
|
|
|
8
8
|
import { cn } from "../../../utils/cn"
|
|
9
|
-
import { buttonSurfaceClasses, outlineBorderClasses, splitDividerColorClasses } from "./button-styles"
|
|
9
|
+
import { buttonSurfaceClasses, outlineBorderClasses, splitDividerColorClasses, splitGlyphSizeClasses } from "./button-styles"
|
|
10
10
|
|
|
11
11
|
// Default layout: centered single content area, padding/gap on the button itself.
|
|
12
12
|
const buttonVariants = cva(
|
|
@@ -104,14 +104,17 @@ const splitShellVariants = cva(
|
|
|
104
104
|
)
|
|
105
105
|
|
|
106
106
|
const splitSlotVariants = cva(
|
|
107
|
-
|
|
107
|
+
// Glyph size lives per-size, not in the base: cva concatenates base + variant
|
|
108
|
+
// without tailwind-merge, so a base `[&_svg]:h-5` and a variant `[&_svg]:h-4`
|
|
109
|
+
// would both ship and the winner would come down to stylesheet order.
|
|
110
|
+
["inline-flex items-center justify-center", "[&_svg]:shrink-0"],
|
|
108
111
|
{
|
|
109
112
|
variants: {
|
|
110
113
|
slot: {
|
|
111
114
|
main: "gap-[var(--spacing-system-xsf)]",
|
|
112
115
|
icon: "border-l",
|
|
113
116
|
},
|
|
114
|
-
size: { default:
|
|
117
|
+
size: { default: splitGlyphSizeClasses, small: "[&_svg]:h-4 [&_svg]:w-4" },
|
|
115
118
|
variant: { accent: "", outline: "", transparent: "", destructive: "", warning: "" },
|
|
116
119
|
},
|
|
117
120
|
compoundVariants: [
|
|
@@ -5,7 +5,7 @@ import Link from "../../../embed-shims/next-link"
|
|
|
5
5
|
import React from "react"
|
|
6
6
|
|
|
7
7
|
import { cn } from "../../../utils/cn"
|
|
8
|
-
import { buttonSurfaceClasses, splitDividerColorClasses } from "./button-styles"
|
|
8
|
+
import { buttonSurfaceClasses, splitDividerColorClasses, splitGlyphSizeClasses } from "./button-styles"
|
|
9
9
|
|
|
10
10
|
// Two independent interactive halves: each a `<button>` or `<a>`. The seam is
|
|
11
11
|
// a 1px border on the left edge of the icon half, colored per variant. For a
|
|
@@ -29,7 +29,7 @@ const splitHalfVariants = cva(splitHalfBase, {
|
|
|
29
29
|
warning: buttonSurfaceClasses.warning,
|
|
30
30
|
},
|
|
31
31
|
size: {
|
|
32
|
-
default:
|
|
32
|
+
default: `h-10 md:h-12 px-[var(--spacing-system-m)] py-[var(--spacing-system-sf)] text-h3 ${splitGlyphSizeClasses}`,
|
|
33
33
|
small: "h-6 md:h-8 px-[var(--spacing-system-xs)] text-h5 [&_svg]:h-3 [&_svg]:w-3 md:[&_svg]:h-4 md:[&_svg]:w-4",
|
|
34
34
|
},
|
|
35
35
|
side: { main: "", icon: "" },
|