@flamingo-stack/openframe-frontend-core 0.0.490 → 0.0.491-snapshot.20260729083908
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-FY5XAW3H.cjs → chunk-2XAAFAQT.cjs} +18 -18
- package/dist/{chunk-FY5XAW3H.cjs.map → chunk-2XAAFAQT.cjs.map} +1 -1
- package/dist/{chunk-AIRNG27G.js → chunk-4AWRPTKE.js} +6 -6
- package/dist/{chunk-6CFFISUC.js → chunk-4J3CB4KO.js} +3 -3
- package/dist/{chunk-VUXNXWOQ.cjs → chunk-4MLOMMDX.cjs} +1900 -1600
- package/dist/chunk-4MLOMMDX.cjs.map +1 -0
- package/dist/{chunk-OQIMH2WQ.js → chunk-56J7RLMM.js} +1514 -1214
- package/dist/{chunk-OQIMH2WQ.js.map → chunk-56J7RLMM.js.map} +1 -1
- package/dist/{chunk-7I42V2OD.cjs → chunk-5ICPQAUI.cjs} +73 -73
- package/dist/{chunk-7I42V2OD.cjs.map → chunk-5ICPQAUI.cjs.map} +1 -1
- package/dist/{chunk-BZMOEWWS.js → chunk-5RHJVPL7.js} +6 -6
- package/dist/{chunk-RW2LPL72.js → chunk-6WTCJI5R.js} +2 -2
- package/dist/{chunk-VDZO6IDR.js → chunk-BTIRYGDT.js} +5 -5
- package/dist/{chunk-D5LQMRPU.cjs → chunk-DY6AJNVK.cjs} +5 -5
- package/dist/{chunk-D5LQMRPU.cjs.map → chunk-DY6AJNVK.cjs.map} +1 -1
- package/dist/{chunk-MPYDX5WX.js → chunk-FG5ALORQ.js} +4 -4
- package/dist/{chunk-2MKUHJ3O.cjs → chunk-J2J36PEN.cjs} +40 -40
- package/dist/{chunk-2MKUHJ3O.cjs.map → chunk-J2J36PEN.cjs.map} +1 -1
- package/dist/{chunk-AW63ET2A.cjs → chunk-LIRXKEOE.cjs} +19 -19
- package/dist/{chunk-AW63ET2A.cjs.map → chunk-LIRXKEOE.cjs.map} +1 -1
- package/dist/{chunk-UZZT65PQ.cjs → chunk-LVDI7R7T.cjs} +40 -40
- package/dist/{chunk-UZZT65PQ.cjs.map → chunk-LVDI7R7T.cjs.map} +1 -1
- package/dist/{chunk-GUTYUMGV.cjs → chunk-M4SCYPCR.cjs} +12 -12
- package/dist/{chunk-GUTYUMGV.cjs.map → chunk-M4SCYPCR.cjs.map} +1 -1
- package/dist/{chunk-GMPYR3MX.cjs → chunk-O6S6Q52W.cjs} +33 -33
- package/dist/{chunk-GMPYR3MX.cjs.map → chunk-O6S6Q52W.cjs.map} +1 -1
- package/dist/{chunk-IEZ3YBAP.cjs → chunk-OFFYASUY.cjs} +32 -32
- package/dist/{chunk-IEZ3YBAP.cjs.map → chunk-OFFYASUY.cjs.map} +1 -1
- package/dist/{chunk-NGEFYPY7.js → chunk-P3ITKTUE.js} +4 -4
- package/dist/{chunk-3IEVA5AO.cjs → chunk-PI5BMKV3.cjs} +93 -93
- package/dist/{chunk-3IEVA5AO.cjs.map → chunk-PI5BMKV3.cjs.map} +1 -1
- package/dist/{chunk-WLM3F3WU.js → chunk-QHRNQTW5.js} +3 -3
- package/dist/{chunk-GZK7FWJJ.js → chunk-QKWOBV55.js} +12 -12
- package/dist/chunk-QKWOBV55.js.map +1 -0
- package/dist/{chunk-STYA77PH.cjs → chunk-SGL6URIJ.cjs} +12 -12
- package/dist/chunk-SGL6URIJ.cjs.map +1 -0
- package/dist/{chunk-6QK3ONDD.js → chunk-SZWMJIH3.js} +2 -2
- package/dist/{chunk-KP5KRRD6.cjs → chunk-THC3K262.cjs} +3 -3
- package/dist/{chunk-KP5KRRD6.cjs.map → chunk-THC3K262.cjs.map} +1 -1
- package/dist/{chunk-BIKOJ4FH.cjs → chunk-TTYJAZ7P.cjs} +58 -32
- package/dist/chunk-TTYJAZ7P.cjs.map +1 -0
- package/dist/{chunk-6CJ2K7BD.js → chunk-U5LU56JE.js} +3 -3
- package/dist/{chunk-EJQUYZXF.cjs → chunk-VML66FJQ.cjs} +12 -12
- package/dist/{chunk-EJQUYZXF.cjs.map → chunk-VML66FJQ.cjs.map} +1 -1
- package/dist/{chunk-PB3BTZDU.js → chunk-WEUNPE3S.js} +139 -113
- package/dist/chunk-WEUNPE3S.js.map +1 -0
- package/dist/{chunk-EGXCWZYP.js → chunk-WKWZ7LVC.js} +3 -3
- package/dist/{chunk-VL2YMDPH.js → chunk-WPFLV4QJ.js} +6 -6
- package/dist/components/case-studies/index.cjs +10 -10
- package/dist/components/case-studies/index.js +4 -4
- package/dist/components/chat/index.cjs +4 -4
- package/dist/components/chat/index.js +3 -3
- package/dist/components/contact/index.cjs +5 -5
- package/dist/components/contact/index.js +4 -4
- package/dist/components/docs/index.cjs +7 -7
- package/dist/components/docs/index.js +6 -6
- package/dist/components/embeds/index.cjs +5 -5
- package/dist/components/embeds/index.js +4 -4
- package/dist/components/faq/index.cjs +6 -6
- package/dist/components/faq/index.js +5 -5
- package/dist/components/features/floating-walkthrough-video.d.ts.map +1 -1
- package/dist/components/features/index.cjs +4 -4
- package/dist/components/features/index.js +3 -3
- package/dist/components/features/select-button.d.ts.map +1 -1
- package/dist/components/help-center-pages/index.cjs +23 -23
- package/dist/components/help-center-pages/index.js +14 -14
- package/dist/components/index.cjs +161 -161
- package/dist/components/index.js +12 -12
- package/dist/components/layout/page-layout.d.ts +7 -1
- package/dist/components/layout/page-layout.d.ts.map +1 -1
- package/dist/components/layout/title-block.d.ts +23 -1
- package/dist/components/layout/title-block.d.ts.map +1 -1
- package/dist/components/navigation/index.cjs +4 -4
- package/dist/components/navigation/index.js +3 -3
- package/dist/components/navigation/navigation-sidebar-item.d.ts +23 -1
- package/dist/components/navigation/navigation-sidebar-item.d.ts.map +1 -1
- package/dist/components/navigation/navigation-sidebar.d.ts.map +1 -1
- package/dist/components/onboarding-guides/index.cjs +8 -8
- package/dist/components/onboarding-guides/index.js +7 -7
- package/dist/components/related-content/index.cjs +6 -6
- package/dist/components/related-content/index.js +5 -5
- package/dist/components/tickets/index.cjs +7 -7
- package/dist/components/tickets/index.js +6 -6
- package/dist/components/ui/autocomplete.d.ts.map +1 -1
- package/dist/components/ui/checkbox-block.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-skeleton.d.ts.map +1 -1
- package/dist/components/ui/date-picker.d.ts.map +1 -1
- package/dist/components/ui/field-wrapper.d.ts +10 -1
- package/dist/components/ui/field-wrapper.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/floating-tooltip.d.ts +8 -1
- package/dist/components/ui/floating-tooltip.d.ts.map +1 -1
- package/dist/components/ui/index.cjs +4 -4
- package/dist/components/ui/index.js +3 -3
- package/dist/components/ui/input-trigger.d.ts.map +1 -1
- package/dist/components/ui/input.d.ts.map +1 -1
- package/dist/components/ui/page-actions.d.ts +7 -1
- package/dist/components/ui/page-actions.d.ts.map +1 -1
- package/dist/components/ui/radio-group.d.ts.map +1 -1
- package/dist/components/ui/search-input.d.ts.map +1 -1
- package/dist/components/ui/select.d.ts.map +1 -1
- package/dist/components/ui/tab-navigation.d.ts +10 -1
- package/dist/components/ui/tab-navigation.d.ts.map +1 -1
- package/dist/components/ui/tag-search-input.d.ts.map +1 -1
- package/dist/components/ui/textarea.d.ts.map +1 -1
- package/dist/components/ui/truncate-text.d.ts +2 -2
- package/dist/components/ui/truncate-text.d.ts.map +1 -1
- package/dist/hooks/index.cjs +4 -2
- package/dist/hooks/index.cjs.map +1 -1
- package/dist/hooks/index.js +3 -1
- package/dist/hooks/ui/index.d.ts +1 -0
- package/dist/hooks/ui/index.d.ts.map +1 -1
- package/dist/hooks/ui/use-is-truncated.d.ts +33 -0
- package/dist/hooks/ui/use-is-truncated.d.ts.map +1 -0
- package/dist/index.cjs +6 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +5 -3
- package/package.json +1 -1
- package/src/components/features/floating-walkthrough-video.tsx +3 -1
- package/src/components/features/select-button.tsx +4 -1
- package/src/components/layout/page-layout.tsx +16 -0
- package/src/components/layout/title-block.tsx +106 -7
- package/src/components/navigation/navigation-sidebar-item.tsx +80 -30
- package/src/components/navigation/navigation-sidebar.tsx +106 -46
- package/src/components/ui/.field-wrapper.md +5 -4
- package/src/components/ui/.tab-navigation.md +14 -1
- package/src/components/ui/__tests__/tab-navigation.test.tsx +159 -0
- package/src/components/ui/autocomplete.tsx +13 -1
- package/src/components/ui/button/button.tsx +1 -1
- package/src/components/ui/checkbox-block.tsx +27 -3
- package/src/components/ui/data-table/data-table-column-filter.tsx +7 -1
- package/src/components/ui/data-table/data-table-header.tsx +4 -1
- package/src/components/ui/data-table/data-table-skeleton.tsx +7 -0
- package/src/components/ui/date-picker.tsx +15 -1
- package/src/components/ui/field-wrapper.tsx +12 -6
- package/src/components/ui/floating-tooltip.tsx +9 -1
- package/src/components/ui/input-trigger.tsx +8 -1
- package/src/components/ui/input.tsx +8 -1
- package/src/components/ui/page-actions.tsx +142 -30
- package/src/components/ui/radio-group.tsx +28 -3
- package/src/components/ui/search-input.tsx +2 -1
- package/src/components/ui/select.tsx +7 -0
- package/src/components/ui/tab-navigation.tsx +379 -158
- package/src/components/ui/tag-search-input.tsx +9 -2
- package/src/components/ui/textarea.tsx +8 -1
- package/src/components/ui/truncate-text.tsx +3 -18
- package/src/hooks/ui/index.ts +1 -0
- package/src/hooks/ui/use-is-truncated.ts +66 -0
- package/dist/chunk-BIKOJ4FH.cjs.map +0 -1
- package/dist/chunk-GZK7FWJJ.js.map +0 -1
- package/dist/chunk-PB3BTZDU.js.map +0 -1
- package/dist/chunk-STYA77PH.cjs.map +0 -1
- package/dist/chunk-VUXNXWOQ.cjs.map +0 -1
- /package/dist/{chunk-AIRNG27G.js.map → chunk-4AWRPTKE.js.map} +0 -0
- /package/dist/{chunk-6CFFISUC.js.map → chunk-4J3CB4KO.js.map} +0 -0
- /package/dist/{chunk-BZMOEWWS.js.map → chunk-5RHJVPL7.js.map} +0 -0
- /package/dist/{chunk-RW2LPL72.js.map → chunk-6WTCJI5R.js.map} +0 -0
- /package/dist/{chunk-VDZO6IDR.js.map → chunk-BTIRYGDT.js.map} +0 -0
- /package/dist/{chunk-MPYDX5WX.js.map → chunk-FG5ALORQ.js.map} +0 -0
- /package/dist/{chunk-NGEFYPY7.js.map → chunk-P3ITKTUE.js.map} +0 -0
- /package/dist/{chunk-WLM3F3WU.js.map → chunk-QHRNQTW5.js.map} +0 -0
- /package/dist/{chunk-6QK3ONDD.js.map → chunk-SZWMJIH3.js.map} +0 -0
- /package/dist/{chunk-6CJ2K7BD.js.map → chunk-U5LU56JE.js.map} +0 -0
- /package/dist/{chunk-EGXCWZYP.js.map → chunk-WKWZ7LVC.js.map} +0 -0
- /package/dist/{chunk-VL2YMDPH.js.map → chunk-WPFLV4QJ.js.map} +0 -0
|
@@ -40,6 +40,14 @@
|
|
|
40
40
|
* byte-identical. Do NOT change defaults or touch anything else here.
|
|
41
41
|
*
|
|
42
42
|
* SANCTIONED EXCEPTION (2026-07, explicit human sign-off): the OPTIONAL
|
|
43
|
+
* `subtitleRow` prop. Defaults to `'when-set'` — the subtitle row keeps
|
|
44
|
+
* rendering only when `subtitle` is truthy, so every existing caller is
|
|
45
|
+
* byte-identical. The other two values decide how a page trades a header that
|
|
46
|
+
* never moves against a header that never shows an empty line; see the prop's
|
|
47
|
+
* own doc for which to pick. Additive + default-preserving; do NOT change the
|
|
48
|
+
* default.
|
|
49
|
+
*
|
|
50
|
+
* SANCTIONED EXCEPTION (2026-07, explicit human sign-off): the OPTIONAL
|
|
43
51
|
* `titleWrap` prop. Defaults to `false` — the frozen single-line `truncate`
|
|
44
52
|
* baseline is unchanged for every existing caller. Content DETAIL pages (whose
|
|
45
53
|
* h1 is CMS data of arbitrary length — releases, legal docs, FAQ docs, dev
|
|
@@ -58,12 +66,27 @@
|
|
|
58
66
|
* so its natural width drives the wrap while `truncate` still clamps a title
|
|
59
67
|
* that alone exceeds the container. The mobile (base) layout is untouched.
|
|
60
68
|
* This is the new frozen baseline; do NOT re-introduce breakpoint stacking.
|
|
69
|
+
*
|
|
70
|
+
* SANCTIONED EXCEPTION (2026-07, explicit human sign-off): overflow tooltips on
|
|
71
|
+
* the title and subtitle. This one CHANGES the baseline for every caller: the
|
|
72
|
+
* native `title=""` attribute on both is replaced by the ODS `FloatingTooltip`,
|
|
73
|
+
* armed only when the text is genuinely clipped (`useIsTruncated`). Both stay
|
|
74
|
+
* single-line — that is the design, and the tooltip is what makes a clipped
|
|
75
|
+
* title readable instead of merely pretty. Nothing is lost for assistive tech:
|
|
76
|
+
* `text-overflow: ellipsis` never hid the text from the accessibility tree, so
|
|
77
|
+
* the full string was always exposed; the attribute only ever served a mouse.
|
|
78
|
+
* The wrapper `div` the tooltip needs goes OUTSIDE the `h1`/`p` — never inside:
|
|
79
|
+
* a `div` inside a `p` is auto-closed by the HTML parser, which would split the
|
|
80
|
+
* subtitle paragraph and desync hydration. The `titleWrap` branch is untouched
|
|
81
|
+
* (wrapped text never clips, so it never needs a tooltip).
|
|
61
82
|
* ========================================================================== */
|
|
62
83
|
|
|
63
84
|
import React from 'react'
|
|
85
|
+
import { useIsTruncated } from '../../hooks/ui/use-is-truncated'
|
|
64
86
|
import { cn } from '../../utils/cn'
|
|
65
87
|
import type { ActionsMenuGroup } from '../ui/actions-menu'
|
|
66
88
|
import { EntityImage } from '../ui/entity-image'
|
|
89
|
+
import { FloatingTooltip } from '../ui/floating-tooltip'
|
|
67
90
|
import { PageActions, type PageActionButton } from '../ui/page-actions'
|
|
68
91
|
import { BackButton } from './back-button'
|
|
69
92
|
|
|
@@ -106,6 +129,28 @@ export interface TitleBlockProps {
|
|
|
106
129
|
* (typography + surrounding markup unchanged, so header height is identical to loaded).
|
|
107
130
|
* Additive + default-preserving: omit it and every existing caller is byte-identical. */
|
|
108
131
|
loading?: boolean
|
|
132
|
+
/**
|
|
133
|
+
* Render the ACTIONS as placeholders too. Separate from `loading` on purpose:
|
|
134
|
+
* a page whose action set is already final (it depends on the route, not on
|
|
135
|
+
* the record) keeps showing its real buttons while the title loads.
|
|
136
|
+
*/
|
|
137
|
+
loadingActions?: boolean
|
|
138
|
+
/**
|
|
139
|
+
* When the subtitle row occupies the layout. The subtitle is often optional
|
|
140
|
+
* RECORD data — unknown while loading, sometimes absent afterwards — and there
|
|
141
|
+
* is no setting that both fills the loading header and never leaves a blank
|
|
142
|
+
* line, so the caller picks which one matters.
|
|
143
|
+
*
|
|
144
|
+
* - `'when-set'` (default) — only when `subtitle` has text.
|
|
145
|
+
* - `'while-loading'` — also during `loading`, as a skeleton bar. The header
|
|
146
|
+
* looks complete while it loads, and the row collapses if the record turns
|
|
147
|
+
* out to have no subtitle (so that case settles one line shorter).
|
|
148
|
+
* - `'always'` — the row never collapses; an empty subtitle holds its line
|
|
149
|
+
* with an invisible spacer. The header cannot change height. Use it when the
|
|
150
|
+
* loaded page ALWAYS has a subtitle — typically a standalone skeleton
|
|
151
|
+
* component matching a page whose subtitle is guaranteed.
|
|
152
|
+
*/
|
|
153
|
+
subtitleRow?: 'when-set' | 'while-loading' | 'always'
|
|
109
154
|
/** When true, a long title WRAPS onto multiple lines instead of the frozen single-line
|
|
110
155
|
* ellipsis clamp. For content detail pages whose h1 is CMS data of arbitrary length.
|
|
111
156
|
* Additive + default-preserving: omit it and every existing caller is byte-identical. */
|
|
@@ -129,6 +174,22 @@ function TitleTextSkeleton({ widthClass, heightClass }: { widthClass: string; he
|
|
|
129
174
|
)
|
|
130
175
|
}
|
|
131
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Bar geometry per title size, following the ODS type scale across breakpoints
|
|
179
|
+
* (`md` = 800px, `lg` = 1280px here — the SAME widths the typography tokens
|
|
180
|
+
* switch at, so bar and text step together).
|
|
181
|
+
*
|
|
182
|
+
* `h2` needs no `lg` step because `text-h2` has none: it is 24/32 on mobile and
|
|
183
|
+
* 32/40 from tablet up. `text-h1` scales at both breakpoints (40 → 48 → 56), so
|
|
184
|
+
* its bar does too — reusing the h2 bar there would leave a stamp-sized smudge
|
|
185
|
+
* inside a 64px line. Heights stay well under the line box's ascent on purpose;
|
|
186
|
+
* see `TitleTextSkeleton` for why that is what keeps the header height honest.
|
|
187
|
+
*/
|
|
188
|
+
const TITLE_SKELETON_SIZE = {
|
|
189
|
+
h1: { width: 'w-56 md:w-80 lg:w-96', height: 'h-7 md:h-8 lg:h-9' },
|
|
190
|
+
h2: { width: 'w-48 md:w-72', height: 'h-4 md:h-6' },
|
|
191
|
+
} as const
|
|
192
|
+
|
|
132
193
|
export function TitleBlock({
|
|
133
194
|
title,
|
|
134
195
|
subtitle,
|
|
@@ -143,14 +204,28 @@ export function TitleBlock({
|
|
|
143
204
|
titleSize = 'h2',
|
|
144
205
|
titleAdornment,
|
|
145
206
|
loading,
|
|
207
|
+
loadingActions,
|
|
208
|
+
subtitleRow = 'when-set',
|
|
146
209
|
titleWrap = false,
|
|
147
210
|
}: TitleBlockProps) {
|
|
211
|
+
const hasSubtitleRow =
|
|
212
|
+
!!subtitle || subtitleRow === 'always' || (subtitleRow === 'while-loading' && !!loading)
|
|
148
213
|
const hasActions = actions && actions.length > 0
|
|
149
214
|
const hasMenuActions = !!menuActions && menuActions.some(g => g.items.length > 0)
|
|
150
215
|
const titleClass = titleSize === 'h1' ? 'text-h1' : 'text-h2'
|
|
151
216
|
// Frozen baseline is the single-line `truncate`; `titleWrap` swaps it for
|
|
152
217
|
// multi-line wrapping (break-words guards pathological unbroken tokens).
|
|
153
218
|
const titleOverflowClass = titleWrap ? 'break-words' : 'truncate'
|
|
219
|
+
const skeletonSize = TITLE_SKELETON_SIZE[titleSize]
|
|
220
|
+
|
|
221
|
+
// Exactly one title element renders per pass, so one ref covers every branch.
|
|
222
|
+
// The tooltip arms itself only on real clipping — repeating a fully visible
|
|
223
|
+
// title back to the user is noise, and `titleWrap`/loading never clip at all.
|
|
224
|
+
const { ref: titleRef, isTruncated: titleTruncated } = useIsTruncated<HTMLHeadingElement>(loading ? null : title)
|
|
225
|
+
const { ref: subtitleRef, isTruncated: subtitleTruncated } = useIsTruncated<HTMLParagraphElement>(
|
|
226
|
+
loading ? null : subtitle,
|
|
227
|
+
)
|
|
228
|
+
const titleNode = loading ? <TitleTextSkeleton widthClass={skeletonSize.width} heightClass={skeletonSize.height} /> : title
|
|
154
229
|
|
|
155
230
|
return (
|
|
156
231
|
<div
|
|
@@ -183,7 +258,7 @@ export function TitleBlock({
|
|
|
183
258
|
className="hidden md:inline-flex"
|
|
184
259
|
/>
|
|
185
260
|
)}
|
|
186
|
-
{(image ||
|
|
261
|
+
{(image || hasSubtitleRow || loading) ? (
|
|
187
262
|
<div className="flex items-center gap-[var(--spacing-system-m)] min-w-0 w-full">
|
|
188
263
|
{image && (
|
|
189
264
|
<EntityImage
|
|
@@ -196,15 +271,32 @@ export function TitleBlock({
|
|
|
196
271
|
{(loading || title) && (
|
|
197
272
|
titleAdornment ? (
|
|
198
273
|
<div className="flex items-center gap-[var(--spacing-system-m)] min-w-0 w-full">
|
|
199
|
-
|
|
274
|
+
{/* `min-w-0` moves onto the tooltip's wrapper: the wrapper is
|
|
275
|
+
now the flex item, and without it the row refuses to
|
|
276
|
+
shrink and pushes the adornment out. */}
|
|
277
|
+
<FloatingTooltip content={title} side="bottom" disabled={!titleTruncated} triggerClassName="min-w-0">
|
|
278
|
+
<h1 ref={titleRef} className={cn(titleClass, 'text-ods-text-primary min-w-0', titleOverflowClass)}>{titleNode}</h1>
|
|
279
|
+
</FloatingTooltip>
|
|
200
280
|
<span className="shrink-0">{titleAdornment}</span>
|
|
201
281
|
</div>
|
|
202
282
|
) : (
|
|
203
|
-
<
|
|
283
|
+
<FloatingTooltip content={title} side="bottom" disabled={!titleTruncated}>
|
|
284
|
+
<h1 ref={titleRef} className={cn(titleClass, 'text-ods-text-primary', titleOverflowClass)}>{titleNode}</h1>
|
|
285
|
+
</FloatingTooltip>
|
|
204
286
|
)
|
|
205
287
|
)}
|
|
206
|
-
{
|
|
207
|
-
|
|
288
|
+
{hasSubtitleRow && (
|
|
289
|
+
/* The NBSP is a pure spacer, reached only in `'always'` mode: an
|
|
290
|
+
empty `p` collapses to zero height (its only content would be
|
|
291
|
+
collapsible whitespace), which is exactly the shift that mode
|
|
292
|
+
exists to prevent. Hidden from AT — it carries no meaning. */
|
|
293
|
+
<FloatingTooltip content={subtitle} side="bottom" disabled={!subtitleTruncated}>
|
|
294
|
+
<p
|
|
295
|
+
ref={subtitleRef}
|
|
296
|
+
className="text-h6 text-ods-text-secondary truncate"
|
|
297
|
+
aria-hidden={!loading && !subtitle ? true : undefined}
|
|
298
|
+
>{loading ? <TitleTextSkeleton widthClass="w-28 md:w-36" heightClass="h-2.5 md:h-3" /> : (subtitle || '\u00A0')}</p>
|
|
299
|
+
</FloatingTooltip>
|
|
208
300
|
)}
|
|
209
301
|
</div>
|
|
210
302
|
</div>
|
|
@@ -212,7 +304,9 @@ export function TitleBlock({
|
|
|
212
304
|
title && (
|
|
213
305
|
titleAdornment ? (
|
|
214
306
|
<div className="flex items-center gap-[var(--spacing-system-m)] min-w-0 w-full">
|
|
215
|
-
<
|
|
307
|
+
<FloatingTooltip content={title} side="bottom" disabled={!titleTruncated} triggerClassName="min-w-0">
|
|
308
|
+
<h1 ref={titleRef} className={cn(titleClass, 'text-ods-text-primary min-w-0', titleOverflowClass)}>{title}</h1>
|
|
309
|
+
</FloatingTooltip>
|
|
216
310
|
<span className="shrink-0">{titleAdornment}</span>
|
|
217
311
|
</div>
|
|
218
312
|
) : (
|
|
@@ -226,13 +320,18 @@ export function TitleBlock({
|
|
|
226
320
|
)}
|
|
227
321
|
</div>
|
|
228
322
|
|
|
229
|
-
{
|
|
323
|
+
{/* `loadingActions` opens this gate on its own: the case the flag exists for
|
|
324
|
+
is a page that does not YET know its action set, which is exactly the
|
|
325
|
+
page that passes `actions={[]}` — gating on `hasActions` alone would
|
|
326
|
+
render nothing and then pop the real buttons in. */}
|
|
327
|
+
{(hasActions || hasMenuActions || selector || loadingActions) && (
|
|
230
328
|
<div className="flex gap-2 items-center shrink-0">
|
|
231
329
|
<PageActions
|
|
232
330
|
variant={actionsVariant}
|
|
233
331
|
actions={actions ?? []}
|
|
234
332
|
menuActions={menuActions}
|
|
235
333
|
selector={selector}
|
|
334
|
+
loading={loadingActions}
|
|
236
335
|
/>
|
|
237
336
|
</div>
|
|
238
337
|
)}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
-
import { cloneElement, type ReactElement } from 'react'
|
|
3
|
+
import { cloneElement, memo, type ReactElement, type ReactNode } from 'react'
|
|
4
|
+
import Link from '../../embed-shims/next-link'
|
|
4
5
|
import { NavigationSidebarItem } from '../../types/navigation'
|
|
5
6
|
import { cn } from '../../utils'
|
|
6
7
|
|
|
@@ -11,46 +12,72 @@ interface IconProps {
|
|
|
11
12
|
|
|
12
13
|
export interface NavigationSidebarItemButtonProps {
|
|
13
14
|
item: NavigationSidebarItem
|
|
15
|
+
/** "You are on this page" — pathname-derived, and the only thing that carries `aria-current`. */
|
|
16
|
+
isActive: boolean
|
|
17
|
+
/**
|
|
18
|
+
* "You clicked this one and it has not arrived yet" — deliberately a separate,
|
|
19
|
+
* weaker state. Never promoted to active: the accent belongs to the page you
|
|
20
|
+
* are actually on.
|
|
21
|
+
*/
|
|
22
|
+
isPending: boolean
|
|
14
23
|
showLabel: boolean
|
|
15
24
|
disabled: boolean
|
|
16
25
|
onClick: (item: NavigationSidebarItem, event?: React.MouseEvent) => void
|
|
17
26
|
}
|
|
18
27
|
|
|
19
|
-
|
|
28
|
+
/**
|
|
29
|
+
* One sidebar entry.
|
|
30
|
+
*
|
|
31
|
+
* Rendered as a real anchor whenever it has a `path` — which is what buys the
|
|
32
|
+
* two things a `<button>` + `router.push` cannot: Next prefetches links that
|
|
33
|
+
* are in the viewport (so the section is usually already loaded by the time it
|
|
34
|
+
* is clicked, instead of every first visit paying a cold segment fetch), and
|
|
35
|
+
* the browser's own affordances work — middle-click, ⌘/Ctrl-click, "open in new
|
|
36
|
+
* tab", copy link, and a status-bar URL on hover.
|
|
37
|
+
*
|
|
38
|
+
* Falls back to a `<button>` for entries that only carry an `onClick` (no
|
|
39
|
+
* destination to link to) and for the disabled state, which an anchor cannot
|
|
40
|
+
* express.
|
|
41
|
+
*/
|
|
42
|
+
export const NavigationSidebarItemButton = memo(function NavigationSidebarItemButton({
|
|
20
43
|
item,
|
|
44
|
+
isActive,
|
|
45
|
+
isPending,
|
|
21
46
|
showLabel,
|
|
22
47
|
disabled,
|
|
23
48
|
onClick,
|
|
24
49
|
}: NavigationSidebarItemButtonProps) {
|
|
25
|
-
const isActive = item.isActive ?? false
|
|
26
50
|
const unreadCount = item.unreadCount ?? 0
|
|
27
51
|
const hasUnread = unreadCount > 0
|
|
28
52
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
const className = cn(
|
|
54
|
+
"w-full flex items-center justify-start relative",
|
|
55
|
+
"h-14 p-[var(--spacing-system-m)]",
|
|
56
|
+
// 150ms, not 300: the outgoing and incoming entries cross-fade, so for the
|
|
57
|
+
// whole duration TWO rows read as half-active. At 300ms that window is long
|
|
58
|
+
// enough to look like a flicker rather than a transition.
|
|
59
|
+
"transition-colors duration-150",
|
|
60
|
+
"[&_svg]:transition-colors [&_svg]:duration-150",
|
|
61
|
+
"before:content-[''] before:absolute before:left-0 before:top-0 before:bottom-0 before:w-1",
|
|
62
|
+
"before:transition-colors before:duration-150",
|
|
63
|
+
// Pending borrows the HOVER weight and nothing else — no accent bar, no
|
|
64
|
+
// accent text, no `aria-current`. It says "this row is working", not "this
|
|
65
|
+
// is where you are", and it reads as the row staying pressed under the
|
|
66
|
+
// cursor, which is exactly what happened.
|
|
67
|
+
!isActive && !disabled && "hover:bg-ods-bg-hover text-ods-text-primary [&_svg]:fill-ods-text-secondary",
|
|
68
|
+
!isActive && !disabled && isPending && "bg-ods-bg-hover",
|
|
69
|
+
!isActive && disabled && "text-ods-text-secondary [&_svg]:fill-ods-text-secondary",
|
|
70
|
+
isActive && !disabled && [
|
|
71
|
+
"bg-[var(--ods-open-yellow-light)] text-ods-accent",
|
|
72
|
+
"[&_svg]:fill-ods-accent",
|
|
73
|
+
"before:bg-ods-accent",
|
|
74
|
+
],
|
|
75
|
+
isActive && disabled && "text-ods-text-secondary [&_svg]:fill-ods-text-secondary",
|
|
76
|
+
disabled && "cursor-not-allowed opacity-50",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
const content: ReactNode = (
|
|
80
|
+
<>
|
|
54
81
|
<div className="relative flex items-center justify-center flex-shrink-0">
|
|
55
82
|
{cloneElement(item.icon as ReactElement<IconProps>, {
|
|
56
83
|
color: isActive && !disabled ? "text-ods-accent" : "text-ods-text-secondary",
|
|
@@ -77,6 +104,29 @@ export function NavigationSidebarItemButton({
|
|
|
77
104
|
</span>
|
|
78
105
|
</span>
|
|
79
106
|
)}
|
|
80
|
-
|
|
107
|
+
</>
|
|
81
108
|
)
|
|
82
|
-
|
|
109
|
+
|
|
110
|
+
const shared = {
|
|
111
|
+
className,
|
|
112
|
+
title: !showLabel ? item.label : undefined,
|
|
113
|
+
'aria-label': item.label,
|
|
114
|
+
'aria-current': isActive ? ('page' as const) : undefined,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// An entry with a custom onClick has no destination of its own, and a
|
|
118
|
+
// disabled one must not be followable — both stay buttons.
|
|
119
|
+
if (disabled || !item.path || item.onClick) {
|
|
120
|
+
return (
|
|
121
|
+
<button type="button" onClick={(event) => onClick(item, event)} disabled={disabled} {...shared}>
|
|
122
|
+
{content}
|
|
123
|
+
</button>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<Link href={item.path} onClick={(event) => onClick(item, event)} {...shared}>
|
|
129
|
+
{content}
|
|
130
|
+
</Link>
|
|
131
|
+
)
|
|
132
|
+
})
|
|
@@ -13,6 +13,10 @@ const MINIMIZED_WIDTH = 56 // 3.5rem = 56px
|
|
|
13
13
|
const EXPANDED_WIDTH = 224 // 14rem = 224px
|
|
14
14
|
const STORAGE_KEY = 'of.navigationSidebar.minimized'
|
|
15
15
|
|
|
16
|
+
/** A click the browser will resolve itself — new tab, new window, middle button. */
|
|
17
|
+
const isModifiedClick = (event?: React.MouseEvent): boolean =>
|
|
18
|
+
!!event && (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0)
|
|
19
|
+
|
|
16
20
|
export interface NavigationSidebarProps {
|
|
17
21
|
config: NavigationSidebarConfig
|
|
18
22
|
/**
|
|
@@ -75,17 +79,68 @@ export function NavigationSidebar({ config, disabled = false }: NavigationSideba
|
|
|
75
79
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
76
80
|
}, [isOverlayOpen, closeOverlay])
|
|
77
81
|
|
|
82
|
+
// The entry the user just clicked, held until the route commits.
|
|
83
|
+
//
|
|
84
|
+
// PENDING IS NOT ACTIVE, and keeping the two apart is the whole point. Active
|
|
85
|
+
// means "this is the page you are on" — it carries the accent bar and
|
|
86
|
+
// `aria-current="page"`, and it stays derived from the pathname, so it is
|
|
87
|
+
// never asserted before it is true. Pending only means "this is the one you
|
|
88
|
+
// clicked": a click deserves an answer, but the answer must not be a claim
|
|
89
|
+
// about where you are. Folding pending into active would light the accent on
|
|
90
|
+
// a section still loading and announce it as the current page to a screen
|
|
91
|
+
// reader — a statement that is simply false until the router says otherwise.
|
|
92
|
+
const [pendingItemId, setPendingItemId] = useState<string | null>(null)
|
|
93
|
+
const committedActiveId = useMemo(
|
|
94
|
+
() => config.items.find(item => item.isActive)?.id ?? null,
|
|
95
|
+
[config.items],
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
// Any committed change clears it — including one that landed somewhere else
|
|
99
|
+
// entirely (a redirect, a link elsewhere on the page). A navigation that never
|
|
100
|
+
// commits leaves a faint hold on the row it started from; because pending is
|
|
101
|
+
// only ever a soft hint, that costs nothing but a stale hover-weight tint,
|
|
102
|
+
// where a stale ACTIVE state would have been a lie about the current page.
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
setPendingItemId(null)
|
|
105
|
+
}, [committedActiveId])
|
|
106
|
+
|
|
78
107
|
const handleItemClick = useCallback((item: NavigationSidebarItem, event?: React.MouseEvent) => {
|
|
79
108
|
event?.stopPropagation()
|
|
80
109
|
|
|
81
110
|
if (item.onClick) {
|
|
82
111
|
item.onClick()
|
|
83
|
-
|
|
84
|
-
|
|
112
|
+
if (isTablet) setTabletMinimized(true)
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!item.path) return
|
|
117
|
+
|
|
118
|
+
// ⌘/Ctrl/Shift-click and middle-click open the link somewhere else. THIS
|
|
119
|
+
// window is not going anywhere, so the browser is left to it: no
|
|
120
|
+
// preventDefault, no optimistic highlight, no closing the tablet overlay.
|
|
121
|
+
// Entries render as real anchors precisely so those gestures work at all.
|
|
122
|
+
if (isModifiedClick(event)) return
|
|
123
|
+
|
|
124
|
+
// A plain click stays the host's to perform, through `onNavigate` exactly
|
|
125
|
+
// as before — the anchor is here for the href (Next prefetches links in the
|
|
126
|
+
// viewport) and for the browser affordances, not to take over routing.
|
|
127
|
+
//
|
|
128
|
+
// `onNavigate` is optional, though, and with no host router there is nothing
|
|
129
|
+
// to hand the click to: swallowing it would leave a real anchor, with a real
|
|
130
|
+
// href, that does nothing at all. So the guard is on having somewhere to
|
|
131
|
+
// send it — otherwise the anchor navigates on its own and we only close the
|
|
132
|
+
// overlay behind it.
|
|
133
|
+
if (config.onNavigate) {
|
|
134
|
+
event?.preventDefault()
|
|
135
|
+
// Re-clicking the page you are already on starts no navigation, so there
|
|
136
|
+
// is nothing to mark — and nothing would ever clear it, since the
|
|
137
|
+
// committed active id is not about to change.
|
|
138
|
+
if (item.id !== committedActiveId) setPendingItemId(item.id)
|
|
139
|
+
config.onNavigate(item.path)
|
|
85
140
|
}
|
|
86
141
|
|
|
87
142
|
if (isTablet) setTabletMinimized(true)
|
|
88
|
-
}, [config, isTablet])
|
|
143
|
+
}, [config, isTablet, committedActiveId])
|
|
89
144
|
|
|
90
145
|
const { primaryItems, secondaryItems } = useMemo(() => ({
|
|
91
146
|
primaryItems: config.items.filter(item => item.section !== 'secondary'),
|
|
@@ -97,16 +152,21 @@ export function NavigationSidebar({ config, disabled = false }: NavigationSideba
|
|
|
97
152
|
[minimized],
|
|
98
153
|
)
|
|
99
154
|
|
|
100
|
-
|
|
101
|
-
|
|
155
|
+
// There used to be an `isHydrated` gate here — `isMdUp !== undefined && ...`
|
|
156
|
+
// — meant to hold the sidebar's contents back until the media queries
|
|
157
|
+
// resolved. It could never be false: the `?? false` above had already
|
|
158
|
+
// swallowed the `undefined` it tested for. Removed rather than repaired,
|
|
159
|
+
// because repairing it would be the regression: the contents are server-
|
|
160
|
+
// rendered today, and gating them behind a client-only media query would
|
|
161
|
+
// trade a first paint of the real navigation for an empty rail.
|
|
102
162
|
useLayoutEffect(() => {
|
|
103
|
-
if (
|
|
163
|
+
if (!transitionsEnabled) {
|
|
104
164
|
const id = requestAnimationFrame(() => {
|
|
105
165
|
setTransitionsEnabled(true)
|
|
106
166
|
})
|
|
107
167
|
return () => cancelAnimationFrame(id)
|
|
108
168
|
}
|
|
109
|
-
}, [
|
|
169
|
+
}, [transitionsEnabled])
|
|
110
170
|
|
|
111
171
|
return (
|
|
112
172
|
<>
|
|
@@ -148,45 +208,45 @@ export function NavigationSidebar({ config, disabled = false }: NavigationSideba
|
|
|
148
208
|
style={{ width: sidebarWidth }}
|
|
149
209
|
aria-label="Main navigation sidebar"
|
|
150
210
|
>
|
|
151
|
-
{
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
<
|
|
157
|
-
{
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
)}
|
|
181
|
-
</
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
211
|
+
<NavigationSidebarHeader minimized={minimized} />
|
|
212
|
+
|
|
213
|
+
<div className="flex-1 flex flex-col justify-between py-4 overflow-y-auto">
|
|
214
|
+
<nav className="flex flex-col" aria-label="Primary navigation">
|
|
215
|
+
{primaryItems.map(item => (
|
|
216
|
+
<NavigationSidebarItemButton
|
|
217
|
+
key={item.id}
|
|
218
|
+
item={item}
|
|
219
|
+
isActive={item.id === committedActiveId}
|
|
220
|
+
isPending={item.id === pendingItemId}
|
|
221
|
+
showLabel={showLabel}
|
|
222
|
+
disabled={disabled}
|
|
223
|
+
onClick={handleItemClick}
|
|
224
|
+
/>
|
|
225
|
+
))}
|
|
226
|
+
</nav>
|
|
227
|
+
|
|
228
|
+
{secondaryItems.length > 0 && (
|
|
229
|
+
<nav className="flex flex-col" aria-label="Secondary navigation">
|
|
230
|
+
{secondaryItems.map(item => (
|
|
231
|
+
<NavigationSidebarItemButton
|
|
232
|
+
key={item.id}
|
|
233
|
+
item={item}
|
|
234
|
+
isActive={item.id === committedActiveId}
|
|
235
|
+
isPending={item.id === pendingItemId}
|
|
236
|
+
showLabel={showLabel}
|
|
237
|
+
disabled={disabled}
|
|
238
|
+
onClick={handleItemClick}
|
|
239
|
+
/>
|
|
240
|
+
))}
|
|
241
|
+
</nav>
|
|
242
|
+
)}
|
|
243
|
+
</div>
|
|
244
|
+
|
|
245
|
+
<NavigationSidebarToggle
|
|
246
|
+
minimized={minimized}
|
|
247
|
+
showLabel={showLabel}
|
|
248
|
+
onToggle={handleToggle}
|
|
249
|
+
/>
|
|
190
250
|
</aside>
|
|
191
251
|
</>
|
|
192
252
|
)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- source-hash:
|
|
1
|
+
<!-- source-hash: 674794aef9f624e4f74a99dae56b9617 -->
|
|
2
2
|
A layout wrapper component that adds an optional label above and a status message below any form field, handling the visual chrome (label + feedback text) while letting the field itself remain unstyled.
|
|
3
3
|
|
|
4
4
|
## Key Components
|
|
@@ -11,15 +11,16 @@ A `forwardRef` component that wraps form fields with consistent label and feedba
|
|
|
11
11
|
| Prop | Type | Default | Description |
|
|
12
12
|
|------|------|---------|-------------|
|
|
13
13
|
| `label` | `string` | — | Label text rendered above the field |
|
|
14
|
-
| `error` | `string` | — | Status/feedback message rendered below the field |
|
|
14
|
+
| `error` | `string` | — | Status/feedback message rendered below the field (out of flow) |
|
|
15
15
|
| `errorVariant` | `"error" \| "warning" \| "success" \| "muted"` | `"error"` | Color variant for the feedback message |
|
|
16
16
|
| `className` | `string` | — | Additional class applied to the outer wrapper |
|
|
17
17
|
| `children` | `React.ReactNode` | — | The field element(s) to wrap |
|
|
18
18
|
|
|
19
19
|
**Behavior:**
|
|
20
20
|
- When neither `label` nor `error` is present, the wrapper renders as `contents` (no layout box introduced).
|
|
21
|
-
- When either exists, it renders as a full-width flex column.
|
|
22
|
-
- The feedback message
|
|
21
|
+
- When either exists, it renders as a full-width **relative** flex column.
|
|
22
|
+
- The feedback message is a single line, ellipsized on overflow; the full text is accessible via the native `title` tooltip.
|
|
23
|
+
- The message is positioned **absolutely** (`bottom-0` + `translate-y-full`), so showing or hiding it never changes the field's height — no layout shift on validation. The trade-off: it hangs BELOW the wrapper box and overlaps whatever follows, so a form stacking fields must leave at least ~20px of vertical gap (e.g. `gap-[var(--spacing-system-lf)]`) for it to land in clear space. `CheckboxBlock` uses the same treatment.
|
|
23
24
|
|
|
24
25
|
**Color Variants:**
|
|
25
26
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- source-hash:
|
|
1
|
+
<!-- source-hash: ef59a6aa7c2983608cd2d9c678c34323 -->
|
|
2
2
|
Accessible, scrollable tab navigation bar component with optional URL synchronization, overflow scroll shadows, and support for both controlled and URL-driven active tab state.
|
|
3
3
|
|
|
4
4
|
## Key Components
|
|
@@ -46,6 +46,19 @@ Configuration object for URL synchronization:
|
|
|
46
46
|
{(activeTab) => <ContentPanel tab={activeTab} />}
|
|
47
47
|
</TabNavigation>
|
|
48
48
|
|
|
49
|
+
// Render prop with the pending flag. The tab BODY is deferred
|
|
50
|
+
// (`useDeferredValue`), so the underline moves instantly while the body keeps
|
|
51
|
+
// showing the previous tab until the new one is ready — no Suspense-fallback
|
|
52
|
+
// flash. `isStale` is true during that window; mark the body so stale data
|
|
53
|
+
// doesn't read as fresh data.
|
|
54
|
+
<TabNavigation tabs={tabs} urlSync>
|
|
55
|
+
{(activeTab, { isStale }) => (
|
|
56
|
+
<div className={cn(isStale && 'opacity-60 transition-opacity')}>
|
|
57
|
+
<ContentPanel tab={activeTab} />
|
|
58
|
+
</div>
|
|
59
|
+
)}
|
|
60
|
+
</TabNavigation>
|
|
61
|
+
|
|
49
62
|
// Stretched tabs with forced gradients
|
|
50
63
|
<TabNavigation
|
|
51
64
|
tabs={tabs}
|