@flamingo-stack/openframe-frontend-core 0.0.490 → 0.0.491
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-VUXNXWOQ.cjs → chunk-2RTNC3KP.cjs} +1036 -873
- package/dist/chunk-2RTNC3KP.cjs.map +1 -0
- package/dist/{chunk-VDZO6IDR.js → chunk-2YQK6LHR.js} +5 -5
- package/dist/{chunk-BZMOEWWS.js → chunk-35CTCUJ7.js} +6 -6
- package/dist/{chunk-GUTYUMGV.cjs → chunk-6O3YTVLG.cjs} +12 -12
- package/dist/{chunk-GUTYUMGV.cjs.map → chunk-6O3YTVLG.cjs.map} +1 -1
- package/dist/{chunk-RW2LPL72.js → chunk-6WTCJI5R.js} +2 -2
- package/dist/{chunk-WLM3F3WU.js → chunk-6XNWT2KC.js} +3 -3
- package/dist/{chunk-IEZ3YBAP.cjs → chunk-7NRRONK5.cjs} +32 -32
- package/dist/{chunk-IEZ3YBAP.cjs.map → chunk-7NRRONK5.cjs.map} +1 -1
- package/dist/{chunk-OQIMH2WQ.js → chunk-CYIUPI7J.js} +1167 -1004
- package/dist/{chunk-OQIMH2WQ.js.map → chunk-CYIUPI7J.js.map} +1 -1
- package/dist/{chunk-6CJ2K7BD.js → chunk-CZTKQVIQ.js} +3 -3
- package/dist/{chunk-3IEVA5AO.cjs → chunk-EHHX22JL.cjs} +93 -93
- package/dist/{chunk-3IEVA5AO.cjs.map → chunk-EHHX22JL.cjs.map} +1 -1
- package/dist/{chunk-7I42V2OD.cjs → chunk-GL33BHGZ.cjs} +73 -73
- package/dist/{chunk-7I42V2OD.cjs.map → chunk-GL33BHGZ.cjs.map} +1 -1
- package/dist/{chunk-AIRNG27G.js → chunk-GTMW4KY7.js} +6 -6
- package/dist/{chunk-EJQUYZXF.cjs → chunk-HRMOCJKU.cjs} +12 -12
- package/dist/{chunk-EJQUYZXF.cjs.map → chunk-HRMOCJKU.cjs.map} +1 -1
- package/dist/{chunk-AW63ET2A.cjs → chunk-ITV6LSG7.cjs} +19 -19
- package/dist/{chunk-AW63ET2A.cjs.map → chunk-ITV6LSG7.cjs.map} +1 -1
- package/dist/{chunk-6CFFISUC.js → chunk-IUGJAILN.js} +3 -3
- package/dist/{chunk-FY5XAW3H.cjs → chunk-J2V4B6FF.cjs} +18 -18
- package/dist/{chunk-FY5XAW3H.cjs.map → chunk-J2V4B6FF.cjs.map} +1 -1
- package/dist/{chunk-UZZT65PQ.cjs → chunk-PVSXTCJY.cjs} +40 -40
- package/dist/{chunk-UZZT65PQ.cjs.map → chunk-PVSXTCJY.cjs.map} +1 -1
- package/dist/{chunk-VL2YMDPH.js → chunk-QF54RPHZ.js} +6 -6
- package/dist/{chunk-GZK7FWJJ.js → chunk-QKWOBV55.js} +12 -12
- package/dist/chunk-QKWOBV55.js.map +1 -0
- package/dist/{chunk-D5LQMRPU.cjs → chunk-QNFAZIFC.cjs} +5 -5
- package/dist/{chunk-D5LQMRPU.cjs.map → chunk-QNFAZIFC.cjs.map} +1 -1
- package/dist/{chunk-STYA77PH.cjs → chunk-SGL6URIJ.cjs} +12 -12
- package/dist/chunk-SGL6URIJ.cjs.map +1 -0
- package/dist/{chunk-EGXCWZYP.js → chunk-T4BUS6JV.js} +3 -3
- 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-MPYDX5WX.js → chunk-U2JIGUXF.js} +4 -4
- package/dist/{chunk-2MKUHJ3O.cjs → chunk-VD4QDSP7.cjs} +40 -40
- package/dist/{chunk-2MKUHJ3O.cjs.map → chunk-VD4QDSP7.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-GMPYR3MX.cjs → chunk-YKE2KZ2Y.cjs} +33 -33
- package/dist/{chunk-GMPYR3MX.cjs.map → chunk-YKE2KZ2Y.cjs.map} +1 -1
- package/dist/{chunk-NGEFYPY7.js → chunk-ZDUJ6BDI.js} +4 -4
- package/dist/{chunk-6QK3ONDD.js → chunk-ZEC5U5T7.js} +2 -2
- 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/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/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/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/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/ui/.field-wrapper.md +5 -4
- package/src/components/ui/.tab-navigation.md +14 -1
- 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/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 +26 -4
- 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-VDZO6IDR.js.map → chunk-2YQK6LHR.js.map} +0 -0
- /package/dist/{chunk-BZMOEWWS.js.map → chunk-35CTCUJ7.js.map} +0 -0
- /package/dist/{chunk-RW2LPL72.js.map → chunk-6WTCJI5R.js.map} +0 -0
- /package/dist/{chunk-WLM3F3WU.js.map → chunk-6XNWT2KC.js.map} +0 -0
- /package/dist/{chunk-6CJ2K7BD.js.map → chunk-CZTKQVIQ.js.map} +0 -0
- /package/dist/{chunk-AIRNG27G.js.map → chunk-GTMW4KY7.js.map} +0 -0
- /package/dist/{chunk-6CFFISUC.js.map → chunk-IUGJAILN.js.map} +0 -0
- /package/dist/{chunk-VL2YMDPH.js.map → chunk-QF54RPHZ.js.map} +0 -0
- /package/dist/{chunk-EGXCWZYP.js.map → chunk-T4BUS6JV.js.map} +0 -0
- /package/dist/{chunk-MPYDX5WX.js.map → chunk-U2JIGUXF.js.map} +0 -0
- /package/dist/{chunk-NGEFYPY7.js.map → chunk-ZDUJ6BDI.js.map} +0 -0
- /package/dist/{chunk-6QK3ONDD.js.map → chunk-ZEC5U5T7.js.map} +0 -0
|
@@ -6,6 +6,7 @@ import { Chevron02DownIcon } from '../icons-v2-generated'
|
|
|
6
6
|
import { ActionsMenuDropdown, type ActionsMenuGroup, type ActionsMenuItem } from './actions-menu'
|
|
7
7
|
import type { ButtonProps, SplitButtonIconAction } from './button'
|
|
8
8
|
import { Button, SplitButton } from './button'
|
|
9
|
+
import { Skeleton } from './skeleton'
|
|
9
10
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip'
|
|
10
11
|
|
|
11
12
|
export type PageActionButton = {
|
|
@@ -206,26 +207,104 @@ export interface PageActionsProps {
|
|
|
206
207
|
*/
|
|
207
208
|
selector?: React.ReactNode
|
|
208
209
|
className?: string
|
|
210
|
+
/**
|
|
211
|
+
* Render placeholders instead of the actions — for pages whose action SET
|
|
212
|
+
* depends on data still in flight. Opt-in: a page whose actions are already
|
|
213
|
+
* final while its title loads keeps rendering them.
|
|
214
|
+
*/
|
|
215
|
+
loading?: boolean
|
|
209
216
|
}
|
|
210
217
|
|
|
211
218
|
const ACTIONS_GAP = 'gap-[var(--spacing-system-xs)]'
|
|
212
219
|
|
|
220
|
+
/** Matches `Button` size="icon" / size="default" exactly, so a placeholder and the
|
|
221
|
+
* button that replaces it occupy the same box at both breakpoints. `rounded-md`
|
|
222
|
+
* is the same token `Button` itself uses — a raw `6px` would agree today and
|
|
223
|
+
* drift the moment a platform overrides `--radius`. */
|
|
224
|
+
const SKELETON_HEIGHT = 'h-11 md:h-12'
|
|
225
|
+
const SKELETON_ICON_WIDTH = 'w-11 md:w-12'
|
|
226
|
+
const SKELETON_LABEL_WIDTH = 'w-[120px] md:w-[140px]'
|
|
227
|
+
const SKELETON_RADIUS = 'rounded-md'
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Placeholders for actions whose SHAPE isn't known yet — the page is still
|
|
231
|
+
* loading the record that decides which actions exist (e.g. an archived entity
|
|
232
|
+
* offers "Unarchive" where an active one offers "Edit").
|
|
233
|
+
*
|
|
234
|
+
* Why not render the actions anyway: a button is a promise. Painting the
|
|
235
|
+
* optimistic set means a flash of the wrong header AND a live click target that
|
|
236
|
+
* can act on the wrong assumption before the data lands. A grey box promises
|
|
237
|
+
* nothing and can't be clicked.
|
|
238
|
+
*
|
|
239
|
+
* The placeholders are derived from the actions the caller passed — one per
|
|
240
|
+
* action, each sized like the button that action WILL render — so the common
|
|
241
|
+
* case (the data confirms that set) settles with no layout shift at all. Sizing
|
|
242
|
+
* per action matters: `renderRawActionButton` collapses a label-less action (or
|
|
243
|
+
* one flagged `iconOnlyOnDesktop`) to a square `size="icon"` button, and a
|
|
244
|
+
* 140px-wide placeholder snapping to 48px is exactly the shift this is meant to
|
|
245
|
+
* avoid.
|
|
246
|
+
*
|
|
247
|
+
* An empty list still yields one placeholder: `loading` means the action set is
|
|
248
|
+
* unknown, not known-empty. Its shape comes from `emptyFallback`, because what
|
|
249
|
+
* an empty header settles into differs per variant — `icon-buttons` and
|
|
250
|
+
* `menu-primary` fall back to the square "…" overflow trigger, `primary-buttons`
|
|
251
|
+
* has no overflow trigger at all and settles into a labelled button.
|
|
252
|
+
*/
|
|
253
|
+
function ActionSkeletons({
|
|
254
|
+
actions,
|
|
255
|
+
fullWidth,
|
|
256
|
+
emptyFallback = 'icon',
|
|
257
|
+
}: {
|
|
258
|
+
actions: PageActionButton[]
|
|
259
|
+
fullWidth?: boolean
|
|
260
|
+
emptyFallback?: 'icon' | 'label'
|
|
261
|
+
}) {
|
|
262
|
+
const items = actions.length > 0 ? actions : [null]
|
|
263
|
+
return (
|
|
264
|
+
<>
|
|
265
|
+
{items.map((action, idx) => {
|
|
266
|
+
// Mirrors `renderRawActionButton`'s `isIconOnly`. The mobile bottom bar
|
|
267
|
+
// stretches only labelled buttons (`fullWidth: !!action.label`), so an
|
|
268
|
+
// icon action keeps its square box there too.
|
|
269
|
+
const isIconOnly = action ? !action.label || !!action.iconOnlyOnDesktop : emptyFallback === 'icon'
|
|
270
|
+
return (
|
|
271
|
+
<Skeleton
|
|
272
|
+
// Static placeholder list — index IS the identity here.
|
|
273
|
+
key={`action-skeleton-${idx}`}
|
|
274
|
+
className={cn(
|
|
275
|
+
SKELETON_HEIGHT,
|
|
276
|
+
SKELETON_RADIUS,
|
|
277
|
+
isIconOnly ? SKELETON_ICON_WIDTH : fullWidth ? 'flex-1' : SKELETON_LABEL_WIDTH,
|
|
278
|
+
)}
|
|
279
|
+
/>
|
|
280
|
+
)
|
|
281
|
+
})}
|
|
282
|
+
</>
|
|
283
|
+
)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Mobile placeholder for the variants that collapse to ONE trigger (icon button or "…"). */
|
|
287
|
+
function MobileTriggerSkeleton() {
|
|
288
|
+
return <Skeleton className={cn(SKELETON_HEIGHT, SKELETON_ICON_WIDTH, SKELETON_RADIUS)} />
|
|
289
|
+
}
|
|
290
|
+
|
|
213
291
|
export function PageActions({
|
|
214
292
|
variant = 'icon-buttons',
|
|
215
293
|
actions,
|
|
216
294
|
menuActions,
|
|
217
295
|
selector,
|
|
218
296
|
className,
|
|
297
|
+
loading,
|
|
219
298
|
}: PageActionsProps) {
|
|
220
299
|
if (variant === 'icon-buttons') {
|
|
221
|
-
return <IconButtonsVariant actions={actions} menuActions={menuActions} selector={selector} className={className} />
|
|
300
|
+
return <IconButtonsVariant actions={actions} menuActions={menuActions} selector={selector} className={className} loading={loading} />
|
|
222
301
|
}
|
|
223
302
|
|
|
224
303
|
if (variant === 'menu-primary') {
|
|
225
|
-
return <MenuPrimaryVariant actions={actions} menuActions={menuActions || []} selector={selector} className={className} />
|
|
304
|
+
return <MenuPrimaryVariant actions={actions} menuActions={menuActions || []} selector={selector} className={className} loading={loading} />
|
|
226
305
|
}
|
|
227
306
|
|
|
228
|
-
return <PrimaryButtonsVariant actions={actions} className={className} />
|
|
307
|
+
return <PrimaryButtonsVariant actions={actions} className={className} loading={loading} />
|
|
229
308
|
}
|
|
230
309
|
|
|
231
310
|
function IconButtonsVariant({
|
|
@@ -233,11 +312,13 @@ function IconButtonsVariant({
|
|
|
233
312
|
menuActions,
|
|
234
313
|
selector,
|
|
235
314
|
className,
|
|
315
|
+
loading,
|
|
236
316
|
}: {
|
|
237
317
|
actions: PageActionButton[]
|
|
238
318
|
menuActions?: ActionsMenuGroup[]
|
|
239
319
|
selector?: React.ReactNode
|
|
240
320
|
className?: string
|
|
321
|
+
loading?: boolean
|
|
241
322
|
}) {
|
|
242
323
|
const desktopActions = actions.filter(a => !a.showOnlyMobile)
|
|
243
324
|
const hasMenuActions = !!menuActions && menuActions.some(g => g.items.length > 0)
|
|
@@ -257,17 +338,25 @@ function IconButtonsVariant({
|
|
|
257
338
|
{/* Desktop: every action as an icon button + optional overflow menu */}
|
|
258
339
|
<div className={cn('hidden md:flex items-center', ACTIONS_GAP, className)}>
|
|
259
340
|
{selector}
|
|
260
|
-
{
|
|
261
|
-
<
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
341
|
+
{loading ? (
|
|
342
|
+
<ActionSkeletons actions={desktopActions} />
|
|
343
|
+
) : (
|
|
344
|
+
<>
|
|
345
|
+
{desktopActions.map((action, idx) => (
|
|
346
|
+
<React.Fragment key={actionKey(action, idx)}>
|
|
347
|
+
{renderActionButton(action)}
|
|
348
|
+
</React.Fragment>
|
|
349
|
+
))}
|
|
350
|
+
{hasMenuActions && <ActionsMenuDropdown groups={menuActions} />}
|
|
351
|
+
</>
|
|
352
|
+
)}
|
|
266
353
|
</div>
|
|
267
354
|
|
|
268
355
|
{/* Mobile: single icon button OR all actions merged into one "..." menu */}
|
|
269
356
|
<div className={cn('flex md:hidden', className)}>
|
|
270
|
-
{
|
|
357
|
+
{loading ? (
|
|
358
|
+
<MobileTriggerSkeleton />
|
|
359
|
+
) : useSingleActionMobile && singleAction ? (
|
|
271
360
|
renderActionButton(singleAction, { iconOnly: true })
|
|
272
361
|
) : hasMobileMenuItems ? (
|
|
273
362
|
<ActionsMenuDropdown groups={mobileMenuGroups} />
|
|
@@ -284,9 +373,11 @@ function IconButtonsVariant({
|
|
|
284
373
|
function PrimaryButtonsVariant({
|
|
285
374
|
actions,
|
|
286
375
|
className,
|
|
376
|
+
loading,
|
|
287
377
|
}: {
|
|
288
378
|
actions: PageActionButton[]
|
|
289
379
|
className?: string
|
|
380
|
+
loading?: boolean
|
|
290
381
|
}) {
|
|
291
382
|
// Sort: outline first, accent last (rightmost on desktop).
|
|
292
383
|
const sortedActions = [...actions].sort((a, b) => {
|
|
@@ -300,14 +391,20 @@ function PrimaryButtonsVariant({
|
|
|
300
391
|
return (
|
|
301
392
|
<>
|
|
302
393
|
<div className={cn('hidden md:flex items-center', ACTIONS_GAP, className)}>
|
|
303
|
-
{
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
)
|
|
394
|
+
{loading ? (
|
|
395
|
+
// No overflow trigger in this variant — an unknown action set settles
|
|
396
|
+
// into a labelled button, not a square one.
|
|
397
|
+
<ActionSkeletons actions={desktopActions} emptyFallback="label" />
|
|
398
|
+
) : (
|
|
399
|
+
desktopActions.map((action, idx) => (
|
|
400
|
+
<React.Fragment key={`desktop-${actionKey(action, idx)}`}>
|
|
401
|
+
{renderActionButton(action)}
|
|
402
|
+
</React.Fragment>
|
|
403
|
+
))
|
|
404
|
+
)}
|
|
308
405
|
</div>
|
|
309
406
|
|
|
310
|
-
<MobileBottomActions actions={sortedActions} />
|
|
407
|
+
<MobileBottomActions actions={sortedActions} loading={loading} />
|
|
311
408
|
</>
|
|
312
409
|
)
|
|
313
410
|
}
|
|
@@ -321,11 +418,13 @@ function MenuPrimaryVariant({
|
|
|
321
418
|
menuActions,
|
|
322
419
|
selector,
|
|
323
420
|
className,
|
|
421
|
+
loading,
|
|
324
422
|
}: {
|
|
325
423
|
actions: PageActionButton[]
|
|
326
424
|
menuActions: ActionsMenuGroup[]
|
|
327
425
|
selector?: React.ReactNode
|
|
328
426
|
className?: string
|
|
427
|
+
loading?: boolean
|
|
329
428
|
}) {
|
|
330
429
|
const desktopActions = actions.filter(a => !a.showOnlyMobile)
|
|
331
430
|
const hasMenuActions = menuActions.some(g => g.items.length > 0)
|
|
@@ -340,34 +439,47 @@ function MenuPrimaryVariant({
|
|
|
340
439
|
<>
|
|
341
440
|
<div className={cn('hidden md:flex items-center', ACTIONS_GAP, className)}>
|
|
342
441
|
{selector}
|
|
343
|
-
{
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
442
|
+
{loading ? (
|
|
443
|
+
<ActionSkeletons actions={desktopActions} />
|
|
444
|
+
) : (
|
|
445
|
+
<>
|
|
446
|
+
{hasMenuActions && <ActionsMenuDropdown groups={menuActions} />}
|
|
447
|
+
{desktopActions.map((action, idx) => (
|
|
448
|
+
<React.Fragment key={`desktop-${actionKey(action, idx)}`}>
|
|
449
|
+
{renderActionButton({ ...action, variant: action.variant || 'accent' })}
|
|
450
|
+
</React.Fragment>
|
|
451
|
+
))}
|
|
452
|
+
</>
|
|
453
|
+
)}
|
|
349
454
|
</div>
|
|
350
455
|
|
|
351
456
|
<div className={cn('flex md:hidden', className)}>
|
|
352
|
-
{hasMobileMenuItems ? <ActionsMenuDropdown groups={mobileMenuGroups} /> : null}
|
|
457
|
+
{loading ? <MobileTriggerSkeleton /> : hasMobileMenuItems ? <ActionsMenuDropdown groups={mobileMenuGroups} /> : null}
|
|
353
458
|
</div>
|
|
354
459
|
</>
|
|
355
460
|
)
|
|
356
461
|
}
|
|
357
462
|
|
|
358
|
-
function MobileBottomActions({ actions }: { actions: PageActionButton[] }) {
|
|
463
|
+
function MobileBottomActions({ actions, loading }: { actions: PageActionButton[]; loading?: boolean }) {
|
|
359
464
|
return (
|
|
360
465
|
<div className={cn(
|
|
361
466
|
'fixed md:hidden bottom-0 left-0 right-0 z-50',
|
|
362
467
|
'bg-ods-card border-t border-ods-border',
|
|
363
|
-
|
|
468
|
+
// `lf` (24px at every breakpoint), NOT `l` — this bar is `md:hidden`, so the
|
|
469
|
+
// only value it ever uses is the MOBILE one, and `l` is 16px there. The
|
|
470
|
+
// fixed variant is what keeps the 24px the bar has always had.
|
|
471
|
+
'flex items-start p-[var(--spacing-system-lf)]',
|
|
364
472
|
ACTIONS_GAP,
|
|
365
473
|
)}>
|
|
366
|
-
{
|
|
367
|
-
<
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
474
|
+
{loading ? (
|
|
475
|
+
<ActionSkeletons actions={actions} fullWidth emptyFallback="label" />
|
|
476
|
+
) : (
|
|
477
|
+
actions.map((action, idx) => (
|
|
478
|
+
<React.Fragment key={`mobile-${actionKey(action, idx)}`}>
|
|
479
|
+
{renderActionButton(action, { fullWidth: !!action.label })}
|
|
480
|
+
</React.Fragment>
|
|
481
|
+
))
|
|
482
|
+
)}
|
|
371
483
|
</div>
|
|
372
484
|
)
|
|
373
485
|
}
|
|
@@ -81,6 +81,10 @@ const RadioGroupBlock = React.forwardRef<
|
|
|
81
81
|
<div className={cn("relative flex w-full flex-col", className)}>
|
|
82
82
|
<RadioGroupPrimitive.Root
|
|
83
83
|
ref={ref}
|
|
84
|
+
// Same marker every field exposes — it is how a form finds (and
|
|
85
|
+
// scrolls to) the first control that failed validation. The GROUP is
|
|
86
|
+
// the field here; the individual options are not separately invalid.
|
|
87
|
+
data-invalid={error ? true : undefined}
|
|
84
88
|
className={cn(
|
|
85
89
|
"w-full",
|
|
86
90
|
isGrouped
|
|
@@ -106,6 +110,10 @@ const RadioGroupBlock = React.forwardRef<
|
|
|
106
110
|
"flex w-full items-center gap-3",
|
|
107
111
|
"transition-colors duration-200",
|
|
108
112
|
isDisabled ? "cursor-not-allowed" : "cursor-pointer",
|
|
113
|
+
// Hover / active move the BACKGROUND only, like Input and
|
|
114
|
+
// CheckboxBlock — the border stays put so an unselected row
|
|
115
|
+
// never flashes "selected" under the cursor.
|
|
116
|
+
!isDisabled && "hover:bg-ods-bg-hover active:bg-ods-bg-active",
|
|
109
117
|
isGrouped
|
|
110
118
|
? cn(
|
|
111
119
|
"bg-ods-card px-[var(--spacing-system-sf)] py-[var(--spacing-system-xs,8px)]",
|
|
@@ -114,12 +122,17 @@ const RadioGroupBlock = React.forwardRef<
|
|
|
114
122
|
)
|
|
115
123
|
: cn(
|
|
116
124
|
"rounded-[6px] border bg-ods-card p-[var(--spacing-system-sf)]",
|
|
117
|
-
!isDisabled && "hover:border-ods-accent/30",
|
|
118
125
|
!isDisabled &&
|
|
119
126
|
"has-[[data-state=checked]]:bg-[var(--ods-system-greys-soft-grey)]/30 has-[[data-state=checked]]:border-ods-accent/40",
|
|
120
127
|
error ? "border-ods-error" : "border-ods-border"
|
|
121
128
|
),
|
|
122
|
-
|
|
129
|
+
// Disabled matches a disabled field: flat `ods-bg` fill and
|
|
130
|
+
// greyed text, not a fade of the whole row. The greying lives
|
|
131
|
+
// on the TEXT COLUMN below, not here: as a descendant rule on
|
|
132
|
+
// the row it also repainted whatever `trailing` renders — a
|
|
133
|
+
// `Tag`'s label is a span too, and it went invisible against
|
|
134
|
+
// its own fill.
|
|
135
|
+
isDisabled && "bg-ods-bg",
|
|
123
136
|
itemClassName
|
|
124
137
|
)}
|
|
125
138
|
>
|
|
@@ -132,7 +145,10 @@ const RadioGroupBlock = React.forwardRef<
|
|
|
132
145
|
error ? "border-ods-error" : "border-[var(--color-border-strong)]",
|
|
133
146
|
"transition-colors duration-150",
|
|
134
147
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ods-accent focus-visible:ring-offset-2 focus-visible:ring-offset-ods-card",
|
|
148
|
+
// Disabled: the EMPTY dot dims with the text; a selected one
|
|
149
|
+
// keeps its accent so "on but locked" stays readable.
|
|
135
150
|
"disabled:cursor-not-allowed",
|
|
151
|
+
"disabled:data-[state=unchecked]:bg-ods-bg disabled:data-[state=unchecked]:border-ods-border",
|
|
136
152
|
"data-[state=checked]:border-ods-accent"
|
|
137
153
|
)}
|
|
138
154
|
>
|
|
@@ -140,7 +156,16 @@ const RadioGroupBlock = React.forwardRef<
|
|
|
140
156
|
<span className="block h-1/2 w-1/2 rounded-full bg-ods-accent" />
|
|
141
157
|
</RadioGroupPrimitive.Indicator>
|
|
142
158
|
</RadioGroupPrimitive.Item>
|
|
143
|
-
<div
|
|
159
|
+
<div
|
|
160
|
+
className={cn(
|
|
161
|
+
"flex min-w-0 flex-1 flex-col justify-center",
|
|
162
|
+
// Direct children only — these are the two component-owned
|
|
163
|
+
// wrappers. `option.label` / `option.description` are
|
|
164
|
+
// ReactNode, so a descendant rule would repaint content the
|
|
165
|
+
// caller styled itself.
|
|
166
|
+
isDisabled && "[&>span]:text-ods-text-disabled",
|
|
167
|
+
)}
|
|
168
|
+
>
|
|
144
169
|
<span
|
|
145
170
|
className={cn(
|
|
146
171
|
"font-[family-name:var(--font-h4-family)] font-[number:var(--font-h4-weight)] text-[length:var(--font-size-h4-body)] leading-[24px]",
|
|
@@ -106,7 +106,8 @@ const innerInputStyles = cn(
|
|
|
106
106
|
"flex-1 min-w-[60px] bg-transparent border-none outline-none",
|
|
107
107
|
"text-h4",
|
|
108
108
|
"text-ods-text-primary placeholder:text-ods-text-secondary",
|
|
109
|
-
|
|
109
|
+
// Disabled - match Input exactly (value greys out, placeholder dims further)
|
|
110
|
+
"disabled:cursor-not-allowed disabled:text-ods-text-disabled disabled:placeholder:text-ods-border",
|
|
110
111
|
"touch-manipulation"
|
|
111
112
|
)
|
|
112
113
|
|
|
@@ -42,7 +42,14 @@ const SelectTrigger = React.forwardRef<
|
|
|
42
42
|
"enabled:hover:bg-ods-bg-hover enabled:hover:border-ods-border-hover enabled:active:bg-ods-bg-active enabled:active:border-ods-border-active",
|
|
43
43
|
!isInvalid && "data-[state=open]:border-ods-accent data-[state=open]:hover:border-ods-accent",
|
|
44
44
|
"group",
|
|
45
|
+
// Disabled - match Input exactly: value greys out, placeholder dims
|
|
46
|
+
// further. The `data-[placeholder]` rule above is a class+attribute
|
|
47
|
+
// selector, so the disabled placeholder rule stacks `:disabled` on top
|
|
48
|
+
// of it to win on specificity rather than on source order.
|
|
45
49
|
"disabled:!cursor-not-allowed disabled:bg-ods-bg",
|
|
50
|
+
"disabled:text-ods-text-disabled disabled:data-[placeholder]:text-ods-border",
|
|
51
|
+
// The chevron sets its own colour — grey it with the value.
|
|
52
|
+
"disabled:[&_svg]:text-ods-text-disabled",
|
|
46
53
|
"transition-colors duration-200 cursor-pointer",
|
|
47
54
|
"[&>span]:line-clamp-1",
|
|
48
55
|
isInvalid && "border-ods-error enabled:hover:border-ods-error enabled:active:border-ods-error data-[state=open]:border-ods-error",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react'
|
|
3
|
+
import React, { useState, useEffect, useDeferredValue, useMemo, useRef, useCallback } from 'react'
|
|
4
4
|
import { useSearchParams, useRouter, usePathname } from '../../embed-shims/next-navigation'
|
|
5
5
|
import { cn } from '../../utils/cn'
|
|
6
6
|
|
|
@@ -38,8 +38,14 @@ interface TabNavigationProps {
|
|
|
38
38
|
urlSync?: boolean | TabNavigationUrlSyncOptions
|
|
39
39
|
defaultTab?: string // Fallback when no valid tab in URL or initial value
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Render prop for the tab BODY. Receives the tab to render and, additively,
|
|
43
|
+
* a `isStale` flag — true while the bar has already moved to a newly clicked
|
|
44
|
+
* tab but the body is still showing the PREVIOUS one (see `deferredActiveTab`
|
|
45
|
+
* below). Use it to dim the body or show a pending hint; without it a slow
|
|
46
|
+
* tab looks like a tab that simply did nothing.
|
|
47
|
+
*/
|
|
48
|
+
children?: (activeTab: string, state: { isStale: boolean }) => React.ReactNode
|
|
43
49
|
}
|
|
44
50
|
|
|
45
51
|
export function TabNavigation({
|
|
@@ -119,6 +125,22 @@ export function TabNavigation({
|
|
|
119
125
|
}
|
|
120
126
|
}
|
|
121
127
|
|
|
128
|
+
// The tab BAR follows the click immediately; the tab BODY is deferred, which
|
|
129
|
+
// makes React treat swapping it as a transition. That is the whole fix for the
|
|
130
|
+
// flash: a tab whose data is not in the client cache yet SUSPENDS the moment it
|
|
131
|
+
// mounts, and outside a transition React has to show that Suspense fallback —
|
|
132
|
+
// so the skeleton appears for a frame before the real content lands. Inside a
|
|
133
|
+
// transition React keeps the previous tab on screen and swaps only once the new
|
|
134
|
+
// one is ready. Deferring the body rather than the whole state keeps the
|
|
135
|
+
// underline responsive, so the click still feels instant.
|
|
136
|
+
//
|
|
137
|
+
// The cost of that trade is that a SLOW tab leaves the previous tab's content
|
|
138
|
+
// under the new tab's underline with nothing saying so — worse than a skeleton,
|
|
139
|
+
// because stale data reads as fresh data. `isStale` is handed to the render prop
|
|
140
|
+
// so the body can mark itself while it waits.
|
|
141
|
+
const deferredActiveTab = useDeferredValue(activeTab)
|
|
142
|
+
const isStale = activeTab !== deferredActiveTab
|
|
143
|
+
|
|
122
144
|
const scrollRef = useRef<HTMLDivElement>(null)
|
|
123
145
|
const activeTabRef = useRef<HTMLButtonElement>(null)
|
|
124
146
|
const isFirstActiveScrollRef = useRef(true)
|
|
@@ -291,7 +313,7 @@ export function TabNavigation({
|
|
|
291
313
|
</div>
|
|
292
314
|
|
|
293
315
|
{/* Render children with active tab if provided */}
|
|
294
|
-
{children && children(
|
|
316
|
+
{children && children(deferredActiveTab, { isStale })}
|
|
295
317
|
</>
|
|
296
318
|
)
|
|
297
319
|
}
|
|
@@ -140,7 +140,13 @@ export function TagSearchInput<T = string>({
|
|
|
140
140
|
"has-[:focus]:border-ods-accent",
|
|
141
141
|
!disabled && "hover:bg-ods-bg-hover hover:border-ods-border-hover",
|
|
142
142
|
disabled && "bg-ods-bg",
|
|
143
|
-
disabled && "cursor-not-allowed
|
|
143
|
+
disabled && "cursor-not-allowed",
|
|
144
|
+
// Match Input: grey the text and the icons, don't fade the whole
|
|
145
|
+
// control (a blanket opacity also washes out the border). Icons only —
|
|
146
|
+
// this container has no adornment spans of its own, so a `[&_span]`
|
|
147
|
+
// rule would ONLY have reached the selected `Tag` chips, whose label is
|
|
148
|
+
// a span, and greyed them into their own fill in the light theme.
|
|
149
|
+
"has-[:disabled]:[&_svg]:text-ods-text-disabled",
|
|
144
150
|
className,
|
|
145
151
|
)}
|
|
146
152
|
onClick={() => {
|
|
@@ -199,7 +205,8 @@ export function TagSearchInput<T = string>({
|
|
|
199
205
|
"flex-1 min-w-[60px] bg-transparent border-none outline-none",
|
|
200
206
|
"text-h4",
|
|
201
207
|
"text-ods-text-primary placeholder:text-ods-text-secondary",
|
|
202
|
-
|
|
208
|
+
// Disabled - match Input exactly (value greys out, placeholder dims further)
|
|
209
|
+
"disabled:cursor-not-allowed disabled:text-ods-text-disabled disabled:placeholder:text-ods-border",
|
|
203
210
|
)}
|
|
204
211
|
/>
|
|
205
212
|
</div>
|
|
@@ -119,6 +119,12 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|
|
119
119
|
!hideBorder && cn("rounded-[6px] border bg-ods-card border-ods-border", accentBorder),
|
|
120
120
|
!hideBorder && !props.disabled && "hover:bg-ods-bg-hover hover:border-ods-border-hover active:bg-ods-bg-active active:border-ods-border-active",
|
|
121
121
|
!hideBorder && props.disabled && "!cursor-not-allowed bg-ods-bg",
|
|
122
|
+
// Start / end icons carry their own colour — grey them with the value
|
|
123
|
+
// so a disabled field reads as one flat colour. Scoped to the DIRECT
|
|
124
|
+
// span children (the two icon wrappers): the nodes inside them are
|
|
125
|
+
// rendered verbatim so callers can pass an interactive trigger and own
|
|
126
|
+
// its styling, and a descendant rule would overwrite exactly that.
|
|
127
|
+
"has-[:disabled]:[&>span]:text-ods-text-disabled has-[:disabled]:[&_svg]:text-ods-text-disabled",
|
|
122
128
|
!hideBorder && isInvalid && cn("border-ods-error hover:border-ods-error", accentBorderError),
|
|
123
129
|
hideBorder && "bg-transparent",
|
|
124
130
|
)}
|
|
@@ -150,7 +156,8 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|
|
150
156
|
// internally instead of pushing the icon out of frame.
|
|
151
157
|
"max-h-[160px] overflow-y-auto",
|
|
152
158
|
"scrollbar-thin scrollbar-track-transparent scrollbar-thumb-ods-border/30 hover:scrollbar-thumb-ods-text-secondary/30",
|
|
153
|
-
|
|
159
|
+
// Disabled - match Input exactly (value greys out, placeholder dims further)
|
|
160
|
+
"disabled:cursor-not-allowed disabled:text-ods-text-disabled disabled:placeholder:text-ods-border",
|
|
154
161
|
className,
|
|
155
162
|
)}
|
|
156
163
|
{...props}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import { type ReactNode } from 'react'
|
|
4
|
+
import { useIsTruncated } from '../../hooks/ui/use-is-truncated'
|
|
4
5
|
import { cn } from '../../utils/cn'
|
|
5
6
|
import { FloatingTooltip } from './floating-tooltip'
|
|
6
7
|
|
|
@@ -69,24 +70,8 @@ export function TruncateText({
|
|
|
69
70
|
tone = 'primary',
|
|
70
71
|
mono = false,
|
|
71
72
|
}: TruncateTextProps) {
|
|
72
|
-
const ref = useRef<HTMLSpanElement>(null)
|
|
73
|
-
const [isTruncated, setIsTruncated] = useState(false)
|
|
74
73
|
const isMultiLine = lines > 1
|
|
75
|
-
|
|
76
|
-
useEffect(() => {
|
|
77
|
-
const el = ref.current
|
|
78
|
-
if (!el) return
|
|
79
|
-
const check = () => {
|
|
80
|
-
const overflows = isMultiLine
|
|
81
|
-
? el.scrollHeight > el.clientHeight + 1
|
|
82
|
-
: el.scrollWidth > el.clientWidth + 1
|
|
83
|
-
setIsTruncated(overflows)
|
|
84
|
-
}
|
|
85
|
-
check()
|
|
86
|
-
const ro = new ResizeObserver(check)
|
|
87
|
-
ro.observe(el)
|
|
88
|
-
return () => ro.disconnect()
|
|
89
|
-
}, [children, isMultiLine])
|
|
74
|
+
const { ref, isTruncated } = useIsTruncated<HTMLSpanElement>(children, { multiline: isMultiLine })
|
|
90
75
|
|
|
91
76
|
const clampClass = isMultiLine
|
|
92
77
|
? LINE_CLAMP_CLASS[lines as Exclude<typeof lines, 1>]
|
package/src/hooks/ui/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ export * from './use-focus-trap'
|
|
|
6
6
|
export * from './use-header-height'
|
|
7
7
|
export * from './use-horizontal-scrollbar'
|
|
8
8
|
export * from './use-image-edge-color'
|
|
9
|
+
export * from './use-is-truncated'
|
|
9
10
|
export * from './use-local-storage'
|
|
10
11
|
export * from './use-marquee-engine'
|
|
11
12
|
export * from './use-media-query'
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
export interface UseIsTruncatedOptions {
|
|
6
|
+
/** Measure vertical clipping (`line-clamp-N`) instead of the single-line ellipsis. */
|
|
7
|
+
multiline?: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Tracks whether an element's text is ACTUALLY clipped — the condition for
|
|
12
|
+
* offering a "full value" tooltip. Text that fits needs no tooltip, and showing
|
|
13
|
+
* one anyway just repeats what the user is already reading.
|
|
14
|
+
*
|
|
15
|
+
* Three things can change the answer, and all three are watched: the box
|
|
16
|
+
* resizing (`ResizeObserver`), the text itself changing, and the measured
|
|
17
|
+
* ELEMENT changing. None of them implies the others — new text inside a
|
|
18
|
+
* width-constrained box leaves the box exactly as it was, so the observer never
|
|
19
|
+
* fires; and a caller that swaps between render branches (adornment vs plain)
|
|
20
|
+
* mounts a different node under the same text, which a `useRef` would leave
|
|
21
|
+
* measured against the old, detached node. Pass the rendered value as `value`.
|
|
22
|
+
*
|
|
23
|
+
* `ref` is a CALLBACK ref, not a `RefObject` — that is what makes the element
|
|
24
|
+
* itself a dependency. Attach it exactly like a normal ref; there is no
|
|
25
|
+
* `.current` to read.
|
|
26
|
+
*
|
|
27
|
+
* ```tsx
|
|
28
|
+
* const { ref, isTruncated } = useIsTruncated<HTMLHeadingElement>(title)
|
|
29
|
+
* <FloatingTooltip content={title} disabled={!isTruncated}>
|
|
30
|
+
* <h1 ref={ref} className="truncate">{title}</h1>
|
|
31
|
+
* </FloatingTooltip>
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export function useIsTruncated<T extends HTMLElement>(value: unknown, options?: UseIsTruncatedOptions) {
|
|
35
|
+
const multiline = options?.multiline ?? false
|
|
36
|
+
const [element, setElement] = useState<T | null>(null)
|
|
37
|
+
const [isTruncated, setIsTruncated] = useState(false)
|
|
38
|
+
|
|
39
|
+
// Wrapped so the identity stays stable across renders: passing the raw
|
|
40
|
+
// `useState` setter as a ref would be stable too, but this keeps the public
|
|
41
|
+
// shape a plain `RefCallback<T>` instead of a state dispatcher.
|
|
42
|
+
const ref = useCallback((node: T | null) => setElement(node), [])
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (!element) {
|
|
46
|
+
setIsTruncated(false)
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
// The 1px tolerance absorbs sub-pixel layout rounding, which otherwise
|
|
50
|
+
// reports a permanent fractional "overflow" at some zoom levels / DPRs and
|
|
51
|
+
// leaves a tooltip armed on text that is fully visible.
|
|
52
|
+
const check = () => {
|
|
53
|
+
setIsTruncated(
|
|
54
|
+
multiline
|
|
55
|
+
? element.scrollHeight > element.clientHeight + 1
|
|
56
|
+
: element.scrollWidth > element.clientWidth + 1,
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
check()
|
|
60
|
+
const observer = new ResizeObserver(check)
|
|
61
|
+
observer.observe(element)
|
|
62
|
+
return () => observer.disconnect()
|
|
63
|
+
}, [element, value, multiline])
|
|
64
|
+
|
|
65
|
+
return { ref, isTruncated }
|
|
66
|
+
}
|