@flamingo-stack/openframe-frontend-core 0.0.524 → 0.0.525-1675.4425.1
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-LAQXIOKO.js → chunk-3TFFD2DF.js} +6 -6
- package/dist/{chunk-JC2UARDN.cjs → chunk-45BJHYMZ.cjs} +31 -31
- package/dist/{chunk-JC2UARDN.cjs.map → chunk-45BJHYMZ.cjs.map} +1 -1
- package/dist/{chunk-Y53BXHK2.cjs → chunk-4F6PM3PZ.cjs} +1316 -1079
- package/dist/chunk-4F6PM3PZ.cjs.map +1 -0
- package/dist/{chunk-6A4Q54YT.js → chunk-4MHFBAWI.js} +2 -2
- package/dist/{chunk-CPGFDMXS.js → chunk-4YLOTTXF.js} +5 -5
- package/dist/{chunk-XXXONWLE.cjs → chunk-5WLQP2TA.cjs} +61 -61
- package/dist/{chunk-XXXONWLE.cjs.map → chunk-5WLQP2TA.cjs.map} +1 -1
- package/dist/{chunk-D65WB4OJ.cjs → chunk-6UQWG5I3.cjs} +5 -5
- package/dist/{chunk-D65WB4OJ.cjs.map → chunk-6UQWG5I3.cjs.map} +1 -1
- package/dist/{chunk-ZKMDCEML.js → chunk-7VBPGRVN.js} +4979 -4742
- package/dist/chunk-7VBPGRVN.js.map +1 -0
- package/dist/{chunk-ZICOTVNH.cjs → chunk-AAUR5R54.cjs} +9 -9
- package/dist/{chunk-ZICOTVNH.cjs.map → chunk-AAUR5R54.cjs.map} +1 -1
- package/dist/{chunk-BPJI4IO6.js → chunk-BTO3EDRL.js} +2 -2
- package/dist/{chunk-W3YNZ3ZS.cjs → chunk-DIPOEVUM.cjs} +3 -3
- package/dist/{chunk-W3YNZ3ZS.cjs.map → chunk-DIPOEVUM.cjs.map} +1 -1
- package/dist/{chunk-FDIYSQNO.cjs → chunk-EJYTNLS3.cjs} +4 -4
- package/dist/{chunk-FDIYSQNO.cjs.map → chunk-EJYTNLS3.cjs.map} +1 -1
- package/dist/{chunk-PGSUFJ5X.js → chunk-EZRCK5UW.js} +2 -2
- package/dist/{chunk-VP24YWHP.js → chunk-H7A76OL4.js} +2 -2
- package/dist/{chunk-7EVH77ZK.js → chunk-I56UVAQU.js} +4 -4
- package/dist/{chunk-IRVTRJVV.js → chunk-IK3EES6I.js} +2 -2
- package/dist/{chunk-DMYUV5NS.cjs → chunk-JHJR4LW3.cjs} +14 -14
- package/dist/{chunk-DMYUV5NS.cjs.map → chunk-JHJR4LW3.cjs.map} +1 -1
- package/dist/{chunk-GJACMJS3.cjs → chunk-JWZ6BY72.cjs} +26 -26
- package/dist/{chunk-GJACMJS3.cjs.map → chunk-JWZ6BY72.cjs.map} +1 -1
- package/dist/{chunk-CHMR77XI.js → chunk-KPSTBHVJ.js} +2 -2
- package/dist/{chunk-SJVNTV4V.cjs → chunk-LGXE2AEV.cjs} +26 -26
- package/dist/{chunk-SJVNTV4V.cjs.map → chunk-LGXE2AEV.cjs.map} +1 -1
- package/dist/{chunk-XZZ7V5AA.cjs → chunk-OPPURTBM.cjs} +11 -11
- package/dist/{chunk-XZZ7V5AA.cjs.map → chunk-OPPURTBM.cjs.map} +1 -1
- package/dist/{chunk-Q2X6HBRM.js → chunk-QKQSCSDG.js} +2 -2
- package/dist/{chunk-YNLTPZGW.js → chunk-SDO4UX2O.js} +4 -4
- package/dist/{chunk-RKM7BOLW.cjs → chunk-TZ4DGS4Q.cjs} +87 -87
- package/dist/{chunk-RKM7BOLW.cjs.map → chunk-TZ4DGS4Q.cjs.map} +1 -1
- package/dist/{chunk-FDU22CUY.cjs → chunk-UFXN4XQU.cjs} +7 -7
- package/dist/{chunk-FDU22CUY.cjs.map → chunk-UFXN4XQU.cjs.map} +1 -1
- package/dist/{chunk-RHQ3JCW4.js → chunk-VRZNLFUN.js} +2 -2
- package/dist/{chunk-I7GS3XJ2.cjs → chunk-YIMLAERD.cjs} +37 -37
- package/dist/{chunk-I7GS3XJ2.cjs.map → chunk-YIMLAERD.cjs.map} +1 -1
- package/dist/{chunk-K6MT67YI.js → chunk-YJ4HQMTZ.js} +2 -2
- package/dist/components/case-studies/index.cjs +8 -8
- package/dist/components/case-studies/index.js +2 -2
- package/dist/components/chat/index.cjs +2 -2
- package/dist/components/chat/index.js +1 -1
- package/dist/components/contact/index.cjs +3 -3
- package/dist/components/contact/index.js +2 -2
- package/dist/components/docs/index.cjs +5 -5
- package/dist/components/docs/index.js +4 -4
- package/dist/components/embeds/index.cjs +3 -3
- package/dist/components/embeds/index.js +2 -2
- package/dist/components/faq/index.cjs +3 -3
- package/dist/components/faq/index.js +2 -2
- package/dist/components/features/index.cjs +2 -2
- package/dist/components/features/index.js +1 -1
- package/dist/components/form.d.ts +1 -1
- package/dist/components/help-center-pages/index.cjs +22 -22
- package/dist/components/help-center-pages/index.js +13 -13
- package/dist/components/index.cjs +144 -132
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +23 -11
- package/dist/components/index.js.map +1 -1
- package/dist/components/meeting-scheduler/index.cjs +34 -34
- package/dist/components/meeting-scheduler/index.js +3 -3
- package/dist/components/navigation/index.cjs +2 -2
- package/dist/components/navigation/index.js +1 -1
- package/dist/components/onboarding-guides/index.cjs +5 -5
- package/dist/components/onboarding-guides/index.js +4 -4
- package/dist/components/related-content/index.cjs +3 -3
- package/dist/components/related-content/index.js +2 -2
- package/dist/components/tickets/index.cjs +6 -6
- package/dist/components/tickets/index.js +5 -5
- package/dist/components/ui/dropdown-button.d.ts +42 -11
- package/dist/components/ui/dropdown-button.d.ts.map +1 -1
- package/dist/components/ui/field.d.ts +38 -0
- package/dist/components/ui/field.d.ts.map +1 -0
- package/dist/components/ui/index.cjs +14 -2
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.d.ts +4 -0
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +13 -1
- package/dist/components/ui/info-hint.d.ts +23 -0
- package/dist/components/ui/info-hint.d.ts.map +1 -0
- package/dist/components/ui/json-detail-list.d.ts +9 -0
- package/dist/components/ui/json-detail-list.d.ts.map +1 -0
- package/dist/components/ui/modal-guarded-close.d.ts +50 -0
- package/dist/components/ui/modal-guarded-close.d.ts.map +1 -0
- package/dist/components/ui/modal-v2.d.ts.map +1 -1
- package/dist/components/ui/tabs.d.ts +19 -5
- package/dist/components/ui/tabs.d.ts.map +1 -1
- package/dist/index.cjs +14 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +13 -1
- package/package.json +3 -2
- package/src/components/ui/dropdown-button.tsx +109 -46
- package/src/components/ui/field.tsx +74 -0
- package/src/components/ui/index.ts +4 -0
- package/src/components/ui/info-hint.tsx +74 -0
- package/src/components/ui/json-detail-list.tsx +112 -0
- package/src/components/ui/modal-guarded-close.tsx +133 -0
- package/src/components/ui/modal-v2.tsx +12 -5
- package/src/components/ui/tabs.tsx +75 -20
- package/src/stories/DropdownButton.stories.tsx +109 -0
- package/src/stories/Field.stories.tsx +70 -0
- package/src/stories/InfoHint.stories.tsx +65 -0
- package/src/stories/JsonDetailList.stories.tsx +73 -0
- package/src/stories/ModalGuardedClose.stories.tsx +82 -0
- package/src/stories/Tabs.stories.tsx +101 -0
- package/src/styles/ods-colors.css +22 -0
- package/dist/chunk-Y53BXHK2.cjs.map +0 -1
- package/dist/chunk-ZKMDCEML.js.map +0 -1
- /package/dist/{chunk-LAQXIOKO.js.map → chunk-3TFFD2DF.js.map} +0 -0
- /package/dist/{chunk-6A4Q54YT.js.map → chunk-4MHFBAWI.js.map} +0 -0
- /package/dist/{chunk-CPGFDMXS.js.map → chunk-4YLOTTXF.js.map} +0 -0
- /package/dist/{chunk-BPJI4IO6.js.map → chunk-BTO3EDRL.js.map} +0 -0
- /package/dist/{chunk-PGSUFJ5X.js.map → chunk-EZRCK5UW.js.map} +0 -0
- /package/dist/{chunk-VP24YWHP.js.map → chunk-H7A76OL4.js.map} +0 -0
- /package/dist/{chunk-7EVH77ZK.js.map → chunk-I56UVAQU.js.map} +0 -0
- /package/dist/{chunk-IRVTRJVV.js.map → chunk-IK3EES6I.js.map} +0 -0
- /package/dist/{chunk-CHMR77XI.js.map → chunk-KPSTBHVJ.js.map} +0 -0
- /package/dist/{chunk-Q2X6HBRM.js.map → chunk-QKQSCSDG.js.map} +0 -0
- /package/dist/{chunk-YNLTPZGW.js.map → chunk-SDO4UX2O.js.map} +0 -0
- /package/dist/{chunk-RHQ3JCW4.js.map → chunk-VRZNLFUN.js.map} +0 -0
- /package/dist/{chunk-K6MT67YI.js.map → chunk-YJ4HQMTZ.js.map} +0 -0
|
@@ -3,90 +3,153 @@
|
|
|
3
3
|
import React from 'react'
|
|
4
4
|
import { cn } from '../../utils/cn'
|
|
5
5
|
import { Chevron02DownIcon } from '../icons-v2-generated'
|
|
6
|
-
import {
|
|
6
|
+
import { Button, type ButtonProps } from './button'
|
|
7
|
+
import {
|
|
8
|
+
ActionsMenuDropdown,
|
|
9
|
+
type ActionsMenuGroup,
|
|
10
|
+
type ActionsMenuItem
|
|
11
|
+
} from './actions-menu'
|
|
7
12
|
|
|
8
13
|
export interface DropdownButtonProps {
|
|
9
14
|
/** Button label rendered next to the chevron. */
|
|
10
|
-
label:
|
|
15
|
+
label: React.ReactNode
|
|
11
16
|
/** Optional leading icon rendered before the label. */
|
|
12
17
|
icon?: React.ReactNode
|
|
13
|
-
/**
|
|
14
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Items shown in the dropdown (single flat group). For grouped menus with
|
|
20
|
+
* separators pass `groups` instead — exactly one of the two is required.
|
|
21
|
+
*/
|
|
22
|
+
items?: ActionsMenuItem[]
|
|
23
|
+
/** Grouped items (with optional separators). Takes precedence over `items`. */
|
|
24
|
+
groups?: ActionsMenuGroup[]
|
|
25
|
+
/**
|
|
26
|
+
* Render the trigger as a standard `Button` of this variant (`outline`,
|
|
27
|
+
* `accent`, …) instead of the default card-colored seam trigger. Use this
|
|
28
|
+
* when the dropdown sits in a row of ordinary buttons and must match them.
|
|
29
|
+
*/
|
|
30
|
+
variant?: ButtonProps['variant']
|
|
31
|
+
/** Button size for the `variant` trigger. Ignored without `variant`. */
|
|
32
|
+
size?: ButtonProps['size']
|
|
33
|
+
/** Spinner state for the `variant` trigger. Ignored without `variant`. */
|
|
34
|
+
loading?: boolean
|
|
15
35
|
disabled?: boolean
|
|
16
36
|
className?: string
|
|
17
37
|
ariaLabel?: string
|
|
18
38
|
align?: 'start' | 'center' | 'end'
|
|
19
39
|
side?: 'top' | 'right' | 'bottom' | 'left'
|
|
40
|
+
/**
|
|
41
|
+
* Forwarded to the dropdown content. The standard Radix dropdown→dialog
|
|
42
|
+
* recipe: pass `(e) => e.preventDefault()` when a menu item opens a modal,
|
|
43
|
+
* otherwise closing the menu refocuses its trigger AFTER the dialog has
|
|
44
|
+
* taken focus, yanking focus back OUT of the modal the action just opened.
|
|
45
|
+
*/
|
|
46
|
+
onCloseAutoFocus?: (event: Event) => void
|
|
20
47
|
}
|
|
21
48
|
|
|
22
49
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* while the dropdown is open; the chevron rotates 180° to point up when open.
|
|
50
|
+
* THE multi-action button — a labeled trigger with a chevron opening an
|
|
51
|
+
* `ActionsMenu`. One component, so the trigger styling, chevron, and menu
|
|
52
|
+
* chrome cannot drift between surfaces.
|
|
27
53
|
*
|
|
28
|
-
*
|
|
29
|
-
* `
|
|
54
|
+
* Two trigger looks:
|
|
55
|
+
* - default (no `variant`): the card-colored seam trigger — label and chevron
|
|
56
|
+
* separated by a divider, whole surface one click target, chevron rotates
|
|
57
|
+
* while open. Used standalone or via `PageActions` when a `PageActionButton`
|
|
58
|
+
* has `dropdownItems` set.
|
|
59
|
+
* - `variant="outline"` (or any Button variant): a standard `Button` with the
|
|
60
|
+
* chevron as its right icon, so the trigger matches sibling buttons in the
|
|
61
|
+
* same row — including `loading` and `leftIcon` support.
|
|
62
|
+
*
|
|
63
|
+
* Item clicks close the menu (ActionsMenuDropdown's default; opt out per item
|
|
64
|
+
* with `closeOnSelect: false`).
|
|
30
65
|
*/
|
|
31
66
|
export function DropdownButton({
|
|
32
67
|
label,
|
|
33
68
|
icon,
|
|
34
69
|
items,
|
|
70
|
+
groups,
|
|
71
|
+
variant,
|
|
72
|
+
size = 'small-legacy',
|
|
73
|
+
loading = false,
|
|
35
74
|
disabled,
|
|
36
75
|
className,
|
|
37
76
|
ariaLabel,
|
|
38
77
|
align = 'end',
|
|
39
|
-
side = 'bottom'
|
|
78
|
+
side = 'bottom',
|
|
79
|
+
onCloseAutoFocus
|
|
40
80
|
}: DropdownButtonProps) {
|
|
41
81
|
const [open, setOpen] = React.useState(false)
|
|
42
82
|
|
|
43
83
|
const handleOpenChange = React.useCallback(
|
|
44
84
|
(next: boolean) => {
|
|
45
|
-
if (disabled) return
|
|
85
|
+
if (disabled || loading) return
|
|
46
86
|
setOpen(next)
|
|
47
87
|
},
|
|
48
|
-
[disabled]
|
|
88
|
+
[disabled, loading]
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
const resolvedAriaLabel =
|
|
92
|
+
ariaLabel || (typeof label === 'string' ? label : undefined)
|
|
93
|
+
|
|
94
|
+
const trigger = variant ? (
|
|
95
|
+
<Button
|
|
96
|
+
variant={variant}
|
|
97
|
+
size={size}
|
|
98
|
+
disabled={disabled}
|
|
99
|
+
loading={loading}
|
|
100
|
+
aria-label={resolvedAriaLabel}
|
|
101
|
+
leftIcon={icon}
|
|
102
|
+
rightIcon={
|
|
103
|
+
<Chevron02DownIcon
|
|
104
|
+
className={cn('h-4 w-4 transition-transform duration-fast', open && 'rotate-180')}
|
|
105
|
+
/>
|
|
106
|
+
}
|
|
107
|
+
className={className}
|
|
108
|
+
>
|
|
109
|
+
{label}
|
|
110
|
+
</Button>
|
|
111
|
+
) : (
|
|
112
|
+
<button
|
|
113
|
+
type="button"
|
|
114
|
+
disabled={disabled}
|
|
115
|
+
aria-label={resolvedAriaLabel}
|
|
116
|
+
onPointerDown={disabled ? (e) => e.preventDefault() : undefined}
|
|
117
|
+
className={cn(
|
|
118
|
+
'inline-flex h-12 items-stretch rounded-md border border-ods-border overflow-hidden transition-colors',
|
|
119
|
+
'text-ods-text-primary',
|
|
120
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ods-focus',
|
|
121
|
+
open ? 'bg-ods-bg-hover' : 'bg-ods-card',
|
|
122
|
+
!disabled && !open && 'hover:bg-ods-bg-hover',
|
|
123
|
+
disabled && 'opacity-50 cursor-not-allowed',
|
|
124
|
+
className
|
|
125
|
+
)}
|
|
126
|
+
>
|
|
127
|
+
<span className="flex items-center gap-[var(--spacing-system-xsf)] px-[var(--spacing-system-m)] py-[var(--spacing-system-sf)] text-h3">
|
|
128
|
+
{icon && (
|
|
129
|
+
<span className="flex items-center justify-center [&_svg]:w-6 [&_svg]:h-6">
|
|
130
|
+
{icon}
|
|
131
|
+
</span>
|
|
132
|
+
)}
|
|
133
|
+
<span className="whitespace-nowrap">{label}</span>
|
|
134
|
+
</span>
|
|
135
|
+
<span className="flex items-center justify-center border-l border-ods-border p-[var(--spacing-system-sf)] [&_svg]:w-6 [&_svg]:h-6">
|
|
136
|
+
<Chevron02DownIcon
|
|
137
|
+
className={cn('transition-transform duration-fast', open && 'rotate-180')}
|
|
138
|
+
/>
|
|
139
|
+
</span>
|
|
140
|
+
</button>
|
|
49
141
|
)
|
|
50
142
|
|
|
51
143
|
return (
|
|
52
144
|
<ActionsMenuDropdown
|
|
53
|
-
groups={[{ items }]}
|
|
145
|
+
groups={groups ?? [{ items: items ?? [] }]}
|
|
54
146
|
open={open}
|
|
55
147
|
onOpenChange={handleOpenChange}
|
|
56
148
|
align={align}
|
|
57
149
|
side={side}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
type="button"
|
|
62
|
-
disabled={disabled}
|
|
63
|
-
aria-label={ariaLabel || label}
|
|
64
|
-
onPointerDown={disabled ? (e) => e.preventDefault() : undefined}
|
|
65
|
-
className={cn(
|
|
66
|
-
'inline-flex h-12 items-stretch rounded-md border border-ods-border overflow-hidden transition-colors',
|
|
67
|
-
'text-ods-text-primary',
|
|
68
|
-
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ods-focus',
|
|
69
|
-
open ? 'bg-ods-bg-hover' : 'bg-ods-card',
|
|
70
|
-
!disabled && !open && 'hover:bg-ods-bg-hover',
|
|
71
|
-
disabled && 'opacity-50 cursor-not-allowed',
|
|
72
|
-
className
|
|
73
|
-
)}
|
|
74
|
-
>
|
|
75
|
-
<span className="flex items-center gap-[var(--spacing-system-xsf)] px-[var(--spacing-system-m)] py-[var(--spacing-system-sf)] text-h3">
|
|
76
|
-
{icon && (
|
|
77
|
-
<span className="flex items-center justify-center [&_svg]:w-6 [&_svg]:h-6">
|
|
78
|
-
{icon}
|
|
79
|
-
</span>
|
|
80
|
-
)}
|
|
81
|
-
<span className="whitespace-nowrap">{label}</span>
|
|
82
|
-
</span>
|
|
83
|
-
<span className="flex items-center justify-center border-l border-ods-border p-[var(--spacing-system-sf)] [&_svg]:w-6 [&_svg]:h-6">
|
|
84
|
-
<Chevron02DownIcon
|
|
85
|
-
className={cn('transition-transform duration-fast', open && 'rotate-180')}
|
|
86
|
-
/>
|
|
87
|
-
</span>
|
|
88
|
-
</button>
|
|
89
|
-
}
|
|
150
|
+
onCloseAutoFocus={onCloseAutoFocus}
|
|
151
|
+
triggerAriaLabel={resolvedAriaLabel}
|
|
152
|
+
customTrigger={trigger}
|
|
90
153
|
/>
|
|
91
154
|
)
|
|
92
155
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { Label } from './label'
|
|
5
|
+
import { InfoHint } from './info-hint'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Accessibility props handed to the control by the `Field` render-prop.
|
|
9
|
+
* Spread them onto the input/select/textarea so the label, required flag and
|
|
10
|
+
* error message are all announced.
|
|
11
|
+
*/
|
|
12
|
+
export interface FieldRenderProps {
|
|
13
|
+
id: string
|
|
14
|
+
'aria-required'?: boolean
|
|
15
|
+
'aria-invalid'?: boolean
|
|
16
|
+
'aria-describedby'?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface FieldProps {
|
|
20
|
+
label: string
|
|
21
|
+
/** Definition shown in a hover `InfoHint` next to the label. */
|
|
22
|
+
hint?: React.ReactNode
|
|
23
|
+
required?: boolean
|
|
24
|
+
children: React.ReactNode | ((props: FieldRenderProps) => React.ReactNode)
|
|
25
|
+
error?: string | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* One form field: label, optional hint, control, optional error.
|
|
30
|
+
*
|
|
31
|
+
* Labels are a single line (hints live in the hover InfoHint icon, never in
|
|
32
|
+
* flow), so cells top-align naturally and controls land on the same baseline.
|
|
33
|
+
* Do NOT reintroduce `h-full` + `mt-auto` here: in a grid row it stretches the
|
|
34
|
+
* cell to the tallest sibling and shoves this cell's control down to align with
|
|
35
|
+
* the bottom of the sibling's ERROR text, detaching it from its own label.
|
|
36
|
+
*
|
|
37
|
+
* The label is tied to its control with a generated id, so clicking the label
|
|
38
|
+
* focuses the input and assistive tech announces the field by name. ALWAYS use
|
|
39
|
+
* the render-prop form — `{(f) => <Input {...f} />}` — and spread what it gives
|
|
40
|
+
* you onto the control. Passing a plain node leaves `htmlFor` pointing at an id
|
|
41
|
+
* that exists on no element, which is worse than no association at all: the
|
|
42
|
+
* label looks wired up and announces nothing.
|
|
43
|
+
*/
|
|
44
|
+
export function Field({ label, hint, required, children, error }: FieldProps) {
|
|
45
|
+
const controlId = React.useId()
|
|
46
|
+
const errorId = `${controlId}-error`
|
|
47
|
+
const renderProps: FieldRenderProps = {
|
|
48
|
+
id: controlId,
|
|
49
|
+
// A bare `*` draws a star and tells assistive tech nothing.
|
|
50
|
+
...(required ? { 'aria-required': true } : {}),
|
|
51
|
+
...(error ? { 'aria-invalid': true, 'aria-describedby': errorId } : {}),
|
|
52
|
+
}
|
|
53
|
+
return (
|
|
54
|
+
<div className="flex flex-col gap-[var(--spacing-system-xxs)]">
|
|
55
|
+
<div className="flex items-center gap-1.5">
|
|
56
|
+
<Label htmlFor={controlId} variant="small" className="text-ods-text-primary">
|
|
57
|
+
{label}
|
|
58
|
+
{required && ' *'}
|
|
59
|
+
</Label>
|
|
60
|
+
{hint && <InfoHint label={label}>{hint}</InfoHint>}
|
|
61
|
+
</div>
|
|
62
|
+
<div className="pt-[var(--spacing-system-xxs)]">
|
|
63
|
+
{typeof children === 'function' ? children(renderProps) : children}
|
|
64
|
+
</div>
|
|
65
|
+
{/* `role="alert"` + the id the control points at: a validation message
|
|
66
|
+
rendered as plain text is never announced. */}
|
|
67
|
+
{error && (
|
|
68
|
+
<p id={errorId} role="alert" className="text-h6 text-ods-error">
|
|
69
|
+
{error}
|
|
70
|
+
</p>
|
|
71
|
+
)}
|
|
72
|
+
</div>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
@@ -11,8 +11,11 @@ export * from './checkbox-block'
|
|
|
11
11
|
export * from './checkbox-with-description'
|
|
12
12
|
export * from './date-picker'
|
|
13
13
|
export * from './duration-input'
|
|
14
|
+
export * from './field'
|
|
14
15
|
export * from './field-wrapper'
|
|
15
16
|
export * from './honeypot-field'
|
|
17
|
+
export * from './info-hint'
|
|
18
|
+
export * from './json-detail-list'
|
|
16
19
|
export * from './info-card'
|
|
17
20
|
export * from './info-row'
|
|
18
21
|
export * from './info-section'
|
|
@@ -39,6 +42,7 @@ export * from './dialog'
|
|
|
39
42
|
export * from './image-gallery-modal'
|
|
40
43
|
export * from './modal'
|
|
41
44
|
export * from './modal-v2'
|
|
45
|
+
export * from './modal-guarded-close'
|
|
42
46
|
export * from './separator'
|
|
43
47
|
export * from './sheet'
|
|
44
48
|
export * from './drawer'
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { Info } from 'lucide-react'
|
|
5
|
+
import { Button } from './button'
|
|
6
|
+
import { FloatingTooltip } from './floating-tooltip'
|
|
7
|
+
import { cn } from '../../utils/cn'
|
|
8
|
+
|
|
9
|
+
export interface InfoHintProps {
|
|
10
|
+
children: React.ReactNode
|
|
11
|
+
/** What this hint is ABOUT — 15 buttons all named "More information" name nothing. */
|
|
12
|
+
label?: string
|
|
13
|
+
side?: 'top' | 'right' | 'bottom' | 'left'
|
|
14
|
+
className?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The "what does this mean?" affordance — an ⓘ icon that reveals a definition
|
|
19
|
+
* on hover.
|
|
20
|
+
*
|
|
21
|
+
* Config-heavy screens carry vocabulary that is obvious to whoever built the
|
|
22
|
+
* feature and opaque to everyone else — `judged`, `deterministic`, `observe`,
|
|
23
|
+
* `error`. A term nobody can define is a term nobody configures correctly, so
|
|
24
|
+
* every one of them gets a hint rather than a wiki page somebody has to go find.
|
|
25
|
+
*
|
|
26
|
+
* This is a thin cap over `FloatingTooltip`, not a second tooltip: it fixes the
|
|
27
|
+
* icon, the trigger element and the sizing so every hint looks and behaves the
|
|
28
|
+
* same. All the hover/portal/placement behaviour is the shared component's.
|
|
29
|
+
*/
|
|
30
|
+
export function InfoHint({ children, label, side = 'top', className }: InfoHintProps) {
|
|
31
|
+
const describedById = React.useId()
|
|
32
|
+
return (
|
|
33
|
+
<span className={cn('inline-flex shrink-0 items-center', className)}>
|
|
34
|
+
<FloatingTooltip
|
|
35
|
+
content={children}
|
|
36
|
+
side={side}
|
|
37
|
+
as="span"
|
|
38
|
+
triggerClassName="inline-flex shrink-0 items-center"
|
|
39
|
+
className="max-w-[280px] text-h6"
|
|
40
|
+
>
|
|
41
|
+
{/*
|
|
42
|
+
`icon-inline` is a Button variant, not a className stack. The hint sits
|
|
43
|
+
inside a line of badges, so `icon-sm`'s 32px target would swamp a 10px
|
|
44
|
+
badge — and shrinking it at the call site is exactly the override the
|
|
45
|
+
house rule forbids.
|
|
46
|
+
*/}
|
|
47
|
+
<Button
|
|
48
|
+
type="button"
|
|
49
|
+
variant="transparent"
|
|
50
|
+
size="icon-inline"
|
|
51
|
+
// A hint lives inside interactive rows (e.g. a role="checkbox" rule
|
|
52
|
+
// row): asking "what does this mean?" must never toggle the row it
|
|
53
|
+
// sits in.
|
|
54
|
+
onClick={(e) => e.stopPropagation()}
|
|
55
|
+
aria-label={label ? `About ${label}` : 'More information'}
|
|
56
|
+
aria-describedby={describedById}
|
|
57
|
+
className="text-ods-text-secondary hover:text-ods-text-primary"
|
|
58
|
+
>
|
|
59
|
+
<Info className="h-3.5 w-3.5 shrink-0" />
|
|
60
|
+
</Button>
|
|
61
|
+
</FloatingTooltip>
|
|
62
|
+
{/*
|
|
63
|
+
The tooltip opens on HOVER only — `FloatingTooltip` composes
|
|
64
|
+
`useHover`/`useDismiss`/`useRole` and no `useFocus`, so a keyboard user
|
|
65
|
+
tabbing here would otherwise reach a button that reveals nothing. This
|
|
66
|
+
visually-hidden copy is the same text, exposed via `aria-describedby`,
|
|
67
|
+
so assistive tech reads the definition without depending on hover.
|
|
68
|
+
*/}
|
|
69
|
+
<span id={describedById} className="sr-only">
|
|
70
|
+
{children}
|
|
71
|
+
</span>
|
|
72
|
+
</span>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { formatRelativeTime } from '../../utils/date-utils'
|
|
5
|
+
import { cn } from '../../utils/cn'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* THE generic JSON detail renderer — readable key/value prose for machine
|
|
9
|
+
* payloads whose field names are NOT guaranteed (evidence blobs, audit
|
|
10
|
+
* payloads, webhook bodies). No schema knowledge: keys are humanised,
|
|
11
|
+
* ISO-dates become relative times, nested objects indent, and anything
|
|
12
|
+
* unparseable falls back to raw. Use this instead of `<pre>{JSON.stringify}`
|
|
13
|
+
* or a per-shape bespoke renderer.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/
|
|
17
|
+
|
|
18
|
+
function humanizeKey(key: string): string {
|
|
19
|
+
const spaced = key
|
|
20
|
+
.replace(/[_-]+/g, ' ')
|
|
21
|
+
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
22
|
+
.trim()
|
|
23
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function Value({ value, depth }: { value: unknown; depth: number }) {
|
|
27
|
+
if (value === null || value === undefined) {
|
|
28
|
+
return <span className="text-ods-text-secondary">—</span>
|
|
29
|
+
}
|
|
30
|
+
if (typeof value === 'string') {
|
|
31
|
+
if (ISO_DATE.test(value)) {
|
|
32
|
+
return (
|
|
33
|
+
<span title={value} className="text-ods-text-secondary">
|
|
34
|
+
{formatRelativeTime(value)}
|
|
35
|
+
</span>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
return <span className="whitespace-pre-wrap text-ods-text-primary">{value}</span>
|
|
39
|
+
}
|
|
40
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
41
|
+
return <span className="text-code text-ods-text-primary">{String(value)}</span>
|
|
42
|
+
}
|
|
43
|
+
if (Array.isArray(value)) {
|
|
44
|
+
if (value.every(v => typeof v === 'string' || typeof v === 'number')) {
|
|
45
|
+
return <span className="text-ods-text-primary">{value.join(', ')}</span>
|
|
46
|
+
}
|
|
47
|
+
return (
|
|
48
|
+
<div className="space-y-[var(--spacing-system-xxs)]">
|
|
49
|
+
{value.map((v, i) => (
|
|
50
|
+
<Value key={i} value={v} depth={depth + 1} />
|
|
51
|
+
))}
|
|
52
|
+
</div>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
if (typeof value === 'object' && depth < 3) {
|
|
56
|
+
return <JsonDetailList data={value} depth={depth + 1} />
|
|
57
|
+
}
|
|
58
|
+
return (
|
|
59
|
+
<pre className="whitespace-pre-wrap text-code text-ods-text-secondary">
|
|
60
|
+
{JSON.stringify(value, null, 2)}
|
|
61
|
+
</pre>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface JsonDetailListProps {
|
|
66
|
+
/** Object, array, JSON string, or any scalar — rendered faithfully either way. */
|
|
67
|
+
data: unknown
|
|
68
|
+
/** Internal nesting level — leave at the default from call sites. */
|
|
69
|
+
depth?: number
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function JsonDetailList({ data, depth = 0 }: JsonDetailListProps) {
|
|
73
|
+
const obj =
|
|
74
|
+
typeof data === 'string'
|
|
75
|
+
? (() => {
|
|
76
|
+
try {
|
|
77
|
+
return JSON.parse(data) as unknown
|
|
78
|
+
} catch {
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
})()
|
|
82
|
+
: data
|
|
83
|
+
|
|
84
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
85
|
+
// Not an object — render whatever it is faithfully.
|
|
86
|
+
return typeof data === 'string' ? (
|
|
87
|
+
<p className="whitespace-pre-wrap text-h6 text-ods-text-primary">{data}</p>
|
|
88
|
+
) : (
|
|
89
|
+
<pre className="whitespace-pre-wrap text-code text-ods-text-secondary">
|
|
90
|
+
{JSON.stringify(data, null, 2)}
|
|
91
|
+
</pre>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<dl
|
|
97
|
+
className={cn(
|
|
98
|
+
'space-y-[var(--spacing-system-xsf)]',
|
|
99
|
+
depth > 0 && 'pl-[var(--spacing-system-sf)] border-l border-ods-border',
|
|
100
|
+
)}
|
|
101
|
+
>
|
|
102
|
+
{Object.entries(obj as Record<string, unknown>).map(([key, value]) => (
|
|
103
|
+
<div key={key}>
|
|
104
|
+
<dt className="text-h6 text-ods-text-secondary">{humanizeKey(key)}</dt>
|
|
105
|
+
<dd className="text-h6">
|
|
106
|
+
<Value value={value} depth={depth} />
|
|
107
|
+
</dd>
|
|
108
|
+
</div>
|
|
109
|
+
))}
|
|
110
|
+
</dl>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { cn } from '../../utils/cn'
|
|
5
|
+
import { Button } from './button'
|
|
6
|
+
import {
|
|
7
|
+
ModalV2,
|
|
8
|
+
ModalV2Content,
|
|
9
|
+
ModalV2Footer,
|
|
10
|
+
ModalV2Header,
|
|
11
|
+
ModalV2Title
|
|
12
|
+
} from './modal-v2'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The unified dirty-state contract for modals: a promise-based house confirm
|
|
16
|
+
* (`useConfirm`), a close guard that asks before discarding unsaved edits
|
|
17
|
+
* (`useGuardedClose`), and the standing "Unsaved changes" footer chip
|
|
18
|
+
* (`UnsavedChangesChip`). Form-modal shells compose these three instead of
|
|
19
|
+
* reimplementing the affordance — closing a dirty form must never silently
|
|
20
|
+
* discard the edits, and the chip is the standing signal that edits exist.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* State-driven confirm dialog using `ModalV2` — the house replacement for
|
|
25
|
+
* `window.confirm`. `ask(title, body)` returns a `Promise<boolean>`; the
|
|
26
|
+
* consumer awaits the user's choice and then proceeds. The returned `dialog`
|
|
27
|
+
* element MUST be rendered in the host's JSX (inside the host modal is fine —
|
|
28
|
+
* `ModalV2` stacks).
|
|
29
|
+
*
|
|
30
|
+
* Concurrency: a second `ask` while a dialog is already open resolves `false`
|
|
31
|
+
* immediately — without the guard the second `setPending` would overwrite the
|
|
32
|
+
* first entry and the first Promise would hang forever. The ref-based
|
|
33
|
+
* in-flight flag also covers two synchronous `ask()` calls in the same render
|
|
34
|
+
* tick, which `useState` alone races.
|
|
35
|
+
*/
|
|
36
|
+
export function useConfirm() {
|
|
37
|
+
const [open, setOpen] = React.useState(false)
|
|
38
|
+
const [pending, setPending] = React.useState<{
|
|
39
|
+
title: string
|
|
40
|
+
body: string
|
|
41
|
+
resolve: (ok: boolean) => void
|
|
42
|
+
} | null>(null)
|
|
43
|
+
const askingRef = React.useRef(false)
|
|
44
|
+
const ask = React.useCallback((title: string, body: string) => {
|
|
45
|
+
return new Promise<boolean>((resolve) => {
|
|
46
|
+
if (askingRef.current) {
|
|
47
|
+
resolve(false)
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
askingRef.current = true
|
|
51
|
+
setPending({ title, body, resolve })
|
|
52
|
+
setOpen(true)
|
|
53
|
+
})
|
|
54
|
+
}, [])
|
|
55
|
+
const decide = React.useCallback(
|
|
56
|
+
(ok: boolean) => {
|
|
57
|
+
askingRef.current = false
|
|
58
|
+
setOpen(false)
|
|
59
|
+
pending?.resolve(ok)
|
|
60
|
+
setPending(null)
|
|
61
|
+
},
|
|
62
|
+
[pending]
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
const dialog = pending ? (
|
|
66
|
+
<ModalV2 isOpen={open} onClose={() => decide(false)}>
|
|
67
|
+
<ModalV2Header>
|
|
68
|
+
<ModalV2Title>{pending.title}</ModalV2Title>
|
|
69
|
+
</ModalV2Header>
|
|
70
|
+
<ModalV2Content>
|
|
71
|
+
<p className="text-h6 text-ods-text-secondary">{pending.body}</p>
|
|
72
|
+
</ModalV2Content>
|
|
73
|
+
<ModalV2Footer>
|
|
74
|
+
<Button variant="outline" onClick={() => decide(false)}>
|
|
75
|
+
Cancel
|
|
76
|
+
</Button>
|
|
77
|
+
<Button variant="destructive" onClick={() => decide(true)}>
|
|
78
|
+
Confirm
|
|
79
|
+
</Button>
|
|
80
|
+
</ModalV2Footer>
|
|
81
|
+
</ModalV2>
|
|
82
|
+
) : null
|
|
83
|
+
|
|
84
|
+
return { ask, dialog }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface UseGuardedCloseOptions {
|
|
88
|
+
/** Confirm dialog title. */
|
|
89
|
+
title?: string
|
|
90
|
+
/** Confirm dialog body. */
|
|
91
|
+
body?: string
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Close guard for dirty forms: `guardedClose` runs `onClose` directly while
|
|
96
|
+
* clean, and asks the house confirm first once `dirty` — wire it to the
|
|
97
|
+
* modal's `onClose` AND the Cancel button so every dismissal path is guarded.
|
|
98
|
+
* Render the returned `dialog` in the host's JSX.
|
|
99
|
+
*/
|
|
100
|
+
export function useGuardedClose(
|
|
101
|
+
dirty: boolean,
|
|
102
|
+
onClose: () => void,
|
|
103
|
+
{
|
|
104
|
+
title = 'Discard unsaved changes?',
|
|
105
|
+
body = 'Your edits have not been saved. Close and discard them?'
|
|
106
|
+
}: UseGuardedCloseOptions = {}
|
|
107
|
+
) {
|
|
108
|
+
const { ask, dialog } = useConfirm()
|
|
109
|
+
const guardedClose = React.useCallback(async () => {
|
|
110
|
+
if (dirty && !(await ask(title, body))) return
|
|
111
|
+
onClose()
|
|
112
|
+
}, [dirty, ask, title, body, onClose])
|
|
113
|
+
return { guardedClose, dialog }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface UnsavedChangesChipProps {
|
|
117
|
+
/** Names WHAT is dirty (hover tooltip) — turns "why is this dirty?!" into a hover. */
|
|
118
|
+
detail?: string
|
|
119
|
+
className?: string
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The standing "Unsaved changes" signal for a dirty modal's footer. */
|
|
123
|
+
export function UnsavedChangesChip({ detail, className }: UnsavedChangesChipProps) {
|
|
124
|
+
return (
|
|
125
|
+
<span
|
|
126
|
+
className={cn('text-h6 text-ods-warning', className)}
|
|
127
|
+
role="status"
|
|
128
|
+
title={detail}
|
|
129
|
+
>
|
|
130
|
+
Unsaved changes
|
|
131
|
+
</span>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from "react"
|
|
4
4
|
import { useEffect, useState } from "react"
|
|
5
|
-
import {
|
|
5
|
+
import { RemoveScroll } from "react-remove-scroll"
|
|
6
6
|
import { XmarkIcon } from "../icons-v2-generated"
|
|
7
7
|
import { cn } from "../../utils/cn"
|
|
8
8
|
|
|
@@ -142,14 +142,19 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
|
|
142
142
|
return () => clearTimeout(timeout)
|
|
143
143
|
}, [isOpen])
|
|
144
144
|
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
145
|
+
// Scroll lock via the SAME library Radix primitives use internally
|
|
146
|
+
// (react-remove-scroll is ref-counted and composes with itself). The
|
|
147
|
+
// previous react-aria lock FOUGHT the lock a Radix Select opened inside
|
|
148
|
+
// the modal added on top — body jumped and the page behind went black
|
|
149
|
+
// while the select was open.
|
|
148
150
|
|
|
149
151
|
// Escape key (document-level: top-of-stack semantics for modals)
|
|
150
152
|
useEffect(() => {
|
|
151
153
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
152
|
-
|
|
154
|
+
// A nested Radix layer (Select, DropdownMenu) preventDefaults the
|
|
155
|
+
// Escape it consumes — without this check, closing a select inside
|
|
156
|
+
// the modal closed the WHOLE modal.
|
|
157
|
+
if (event.key === 'Escape' && !event.defaultPrevented) {
|
|
153
158
|
onClose()
|
|
154
159
|
}
|
|
155
160
|
}
|
|
@@ -165,6 +170,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
|
|
165
170
|
const state = isOpen ? "open" : "closed"
|
|
166
171
|
|
|
167
172
|
return (
|
|
173
|
+
<RemoveScroll enabled={isOpen} removeScrollBar={false}>
|
|
168
174
|
<div className="fixed inset-0 z-[1300] flex items-end md:items-center justify-center">
|
|
169
175
|
<div
|
|
170
176
|
data-state={state}
|
|
@@ -218,6 +224,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
|
|
218
224
|
</ModalContext.Provider>
|
|
219
225
|
</div>
|
|
220
226
|
</div>
|
|
227
|
+
</RemoveScroll>
|
|
221
228
|
)
|
|
222
229
|
}
|
|
223
230
|
)
|