@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.
Files changed (126) hide show
  1. package/dist/{chunk-LAQXIOKO.js → chunk-3TFFD2DF.js} +6 -6
  2. package/dist/{chunk-JC2UARDN.cjs → chunk-45BJHYMZ.cjs} +31 -31
  3. package/dist/{chunk-JC2UARDN.cjs.map → chunk-45BJHYMZ.cjs.map} +1 -1
  4. package/dist/{chunk-Y53BXHK2.cjs → chunk-4F6PM3PZ.cjs} +1316 -1079
  5. package/dist/chunk-4F6PM3PZ.cjs.map +1 -0
  6. package/dist/{chunk-6A4Q54YT.js → chunk-4MHFBAWI.js} +2 -2
  7. package/dist/{chunk-CPGFDMXS.js → chunk-4YLOTTXF.js} +5 -5
  8. package/dist/{chunk-XXXONWLE.cjs → chunk-5WLQP2TA.cjs} +61 -61
  9. package/dist/{chunk-XXXONWLE.cjs.map → chunk-5WLQP2TA.cjs.map} +1 -1
  10. package/dist/{chunk-D65WB4OJ.cjs → chunk-6UQWG5I3.cjs} +5 -5
  11. package/dist/{chunk-D65WB4OJ.cjs.map → chunk-6UQWG5I3.cjs.map} +1 -1
  12. package/dist/{chunk-ZKMDCEML.js → chunk-7VBPGRVN.js} +4979 -4742
  13. package/dist/chunk-7VBPGRVN.js.map +1 -0
  14. package/dist/{chunk-ZICOTVNH.cjs → chunk-AAUR5R54.cjs} +9 -9
  15. package/dist/{chunk-ZICOTVNH.cjs.map → chunk-AAUR5R54.cjs.map} +1 -1
  16. package/dist/{chunk-BPJI4IO6.js → chunk-BTO3EDRL.js} +2 -2
  17. package/dist/{chunk-W3YNZ3ZS.cjs → chunk-DIPOEVUM.cjs} +3 -3
  18. package/dist/{chunk-W3YNZ3ZS.cjs.map → chunk-DIPOEVUM.cjs.map} +1 -1
  19. package/dist/{chunk-FDIYSQNO.cjs → chunk-EJYTNLS3.cjs} +4 -4
  20. package/dist/{chunk-FDIYSQNO.cjs.map → chunk-EJYTNLS3.cjs.map} +1 -1
  21. package/dist/{chunk-PGSUFJ5X.js → chunk-EZRCK5UW.js} +2 -2
  22. package/dist/{chunk-VP24YWHP.js → chunk-H7A76OL4.js} +2 -2
  23. package/dist/{chunk-7EVH77ZK.js → chunk-I56UVAQU.js} +4 -4
  24. package/dist/{chunk-IRVTRJVV.js → chunk-IK3EES6I.js} +2 -2
  25. package/dist/{chunk-DMYUV5NS.cjs → chunk-JHJR4LW3.cjs} +14 -14
  26. package/dist/{chunk-DMYUV5NS.cjs.map → chunk-JHJR4LW3.cjs.map} +1 -1
  27. package/dist/{chunk-GJACMJS3.cjs → chunk-JWZ6BY72.cjs} +26 -26
  28. package/dist/{chunk-GJACMJS3.cjs.map → chunk-JWZ6BY72.cjs.map} +1 -1
  29. package/dist/{chunk-CHMR77XI.js → chunk-KPSTBHVJ.js} +2 -2
  30. package/dist/{chunk-SJVNTV4V.cjs → chunk-LGXE2AEV.cjs} +26 -26
  31. package/dist/{chunk-SJVNTV4V.cjs.map → chunk-LGXE2AEV.cjs.map} +1 -1
  32. package/dist/{chunk-XZZ7V5AA.cjs → chunk-OPPURTBM.cjs} +11 -11
  33. package/dist/{chunk-XZZ7V5AA.cjs.map → chunk-OPPURTBM.cjs.map} +1 -1
  34. package/dist/{chunk-Q2X6HBRM.js → chunk-QKQSCSDG.js} +2 -2
  35. package/dist/{chunk-YNLTPZGW.js → chunk-SDO4UX2O.js} +4 -4
  36. package/dist/{chunk-RKM7BOLW.cjs → chunk-TZ4DGS4Q.cjs} +87 -87
  37. package/dist/{chunk-RKM7BOLW.cjs.map → chunk-TZ4DGS4Q.cjs.map} +1 -1
  38. package/dist/{chunk-FDU22CUY.cjs → chunk-UFXN4XQU.cjs} +7 -7
  39. package/dist/{chunk-FDU22CUY.cjs.map → chunk-UFXN4XQU.cjs.map} +1 -1
  40. package/dist/{chunk-RHQ3JCW4.js → chunk-VRZNLFUN.js} +2 -2
  41. package/dist/{chunk-I7GS3XJ2.cjs → chunk-YIMLAERD.cjs} +37 -37
  42. package/dist/{chunk-I7GS3XJ2.cjs.map → chunk-YIMLAERD.cjs.map} +1 -1
  43. package/dist/{chunk-K6MT67YI.js → chunk-YJ4HQMTZ.js} +2 -2
  44. package/dist/components/case-studies/index.cjs +8 -8
  45. package/dist/components/case-studies/index.js +2 -2
  46. package/dist/components/chat/index.cjs +2 -2
  47. package/dist/components/chat/index.js +1 -1
  48. package/dist/components/contact/index.cjs +3 -3
  49. package/dist/components/contact/index.js +2 -2
  50. package/dist/components/docs/index.cjs +5 -5
  51. package/dist/components/docs/index.js +4 -4
  52. package/dist/components/embeds/index.cjs +3 -3
  53. package/dist/components/embeds/index.js +2 -2
  54. package/dist/components/faq/index.cjs +3 -3
  55. package/dist/components/faq/index.js +2 -2
  56. package/dist/components/features/index.cjs +2 -2
  57. package/dist/components/features/index.js +1 -1
  58. package/dist/components/form.d.ts +1 -1
  59. package/dist/components/help-center-pages/index.cjs +22 -22
  60. package/dist/components/help-center-pages/index.js +13 -13
  61. package/dist/components/index.cjs +144 -132
  62. package/dist/components/index.cjs.map +1 -1
  63. package/dist/components/index.js +23 -11
  64. package/dist/components/index.js.map +1 -1
  65. package/dist/components/meeting-scheduler/index.cjs +34 -34
  66. package/dist/components/meeting-scheduler/index.js +3 -3
  67. package/dist/components/navigation/index.cjs +2 -2
  68. package/dist/components/navigation/index.js +1 -1
  69. package/dist/components/onboarding-guides/index.cjs +5 -5
  70. package/dist/components/onboarding-guides/index.js +4 -4
  71. package/dist/components/related-content/index.cjs +3 -3
  72. package/dist/components/related-content/index.js +2 -2
  73. package/dist/components/tickets/index.cjs +6 -6
  74. package/dist/components/tickets/index.js +5 -5
  75. package/dist/components/ui/dropdown-button.d.ts +42 -11
  76. package/dist/components/ui/dropdown-button.d.ts.map +1 -1
  77. package/dist/components/ui/field.d.ts +38 -0
  78. package/dist/components/ui/field.d.ts.map +1 -0
  79. package/dist/components/ui/index.cjs +14 -2
  80. package/dist/components/ui/index.cjs.map +1 -1
  81. package/dist/components/ui/index.d.ts +4 -0
  82. package/dist/components/ui/index.d.ts.map +1 -1
  83. package/dist/components/ui/index.js +13 -1
  84. package/dist/components/ui/info-hint.d.ts +23 -0
  85. package/dist/components/ui/info-hint.d.ts.map +1 -0
  86. package/dist/components/ui/json-detail-list.d.ts +9 -0
  87. package/dist/components/ui/json-detail-list.d.ts.map +1 -0
  88. package/dist/components/ui/modal-guarded-close.d.ts +50 -0
  89. package/dist/components/ui/modal-guarded-close.d.ts.map +1 -0
  90. package/dist/components/ui/modal-v2.d.ts.map +1 -1
  91. package/dist/components/ui/tabs.d.ts +19 -5
  92. package/dist/components/ui/tabs.d.ts.map +1 -1
  93. package/dist/index.cjs +14 -2
  94. package/dist/index.cjs.map +1 -1
  95. package/dist/index.js +13 -1
  96. package/package.json +3 -2
  97. package/src/components/ui/dropdown-button.tsx +109 -46
  98. package/src/components/ui/field.tsx +74 -0
  99. package/src/components/ui/index.ts +4 -0
  100. package/src/components/ui/info-hint.tsx +74 -0
  101. package/src/components/ui/json-detail-list.tsx +112 -0
  102. package/src/components/ui/modal-guarded-close.tsx +133 -0
  103. package/src/components/ui/modal-v2.tsx +12 -5
  104. package/src/components/ui/tabs.tsx +75 -20
  105. package/src/stories/DropdownButton.stories.tsx +109 -0
  106. package/src/stories/Field.stories.tsx +70 -0
  107. package/src/stories/InfoHint.stories.tsx +65 -0
  108. package/src/stories/JsonDetailList.stories.tsx +73 -0
  109. package/src/stories/ModalGuardedClose.stories.tsx +82 -0
  110. package/src/stories/Tabs.stories.tsx +101 -0
  111. package/src/styles/ods-colors.css +22 -0
  112. package/dist/chunk-Y53BXHK2.cjs.map +0 -1
  113. package/dist/chunk-ZKMDCEML.js.map +0 -1
  114. /package/dist/{chunk-LAQXIOKO.js.map → chunk-3TFFD2DF.js.map} +0 -0
  115. /package/dist/{chunk-6A4Q54YT.js.map → chunk-4MHFBAWI.js.map} +0 -0
  116. /package/dist/{chunk-CPGFDMXS.js.map → chunk-4YLOTTXF.js.map} +0 -0
  117. /package/dist/{chunk-BPJI4IO6.js.map → chunk-BTO3EDRL.js.map} +0 -0
  118. /package/dist/{chunk-PGSUFJ5X.js.map → chunk-EZRCK5UW.js.map} +0 -0
  119. /package/dist/{chunk-VP24YWHP.js.map → chunk-H7A76OL4.js.map} +0 -0
  120. /package/dist/{chunk-7EVH77ZK.js.map → chunk-I56UVAQU.js.map} +0 -0
  121. /package/dist/{chunk-IRVTRJVV.js.map → chunk-IK3EES6I.js.map} +0 -0
  122. /package/dist/{chunk-CHMR77XI.js.map → chunk-KPSTBHVJ.js.map} +0 -0
  123. /package/dist/{chunk-Q2X6HBRM.js.map → chunk-QKQSCSDG.js.map} +0 -0
  124. /package/dist/{chunk-YNLTPZGW.js.map → chunk-SDO4UX2O.js.map} +0 -0
  125. /package/dist/{chunk-RHQ3JCW4.js.map → chunk-VRZNLFUN.js.map} +0 -0
  126. /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 { ActionsMenuDropdown, type ActionsMenuItem } from './actions-menu'
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: string
15
+ label: React.ReactNode
11
16
  /** Optional leading icon rendered before the label. */
12
17
  icon?: React.ReactNode
13
- /** Items shown in the dropdown. */
14
- items: ActionsMenuItem[]
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
- * A card-colored trigger button that opens a dropdown of actions. The button
24
- * has a visual seam between the label and the trailing chevron, but the
25
- * entire surface is a single click target. The wrapper lightens on hover and
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
- * Used standalone or via `PageActions` when a `PageActionButton` has
29
- * `dropdownItems` set.
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
- triggerAriaLabel={ariaLabel || label}
59
- customTrigger={
60
- <button
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 { usePreventScroll } from "@react-aria/overlays"
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
- // Shared ref-counted scroll lock (react-aria) — restores prior styles on
146
- // release instead of clobbering to 'unset'.
147
- usePreventScroll({ isDisabled: !isOpen })
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
- if (event.key === 'Escape') {
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
  )