@passui/react 0.1.19 → 0.1.21

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 (84) hide show
  1. package/README.md +38 -0
  2. package/THIRD_PARTY_NOTICES.md +25 -0
  3. package/dist/types/src/components/accordion/accordion.d.ts +19 -0
  4. package/dist/types/src/components/aspect-ratio/aspect-ratio.d.ts +7 -0
  5. package/dist/types/src/components/breadcrumb/breadcrumb.d.ts +16 -0
  6. package/dist/types/src/components/button/button.d.ts +2 -0
  7. package/dist/types/src/components/code-block/code-block.d.ts +10 -0
  8. package/dist/types/src/components/combo-box/combo-box.d.ts +9 -0
  9. package/dist/types/src/components/command/command.d.ts +18 -0
  10. package/dist/types/src/components/content-unavailable-view/content-unavailable-view.d.ts +9 -0
  11. package/dist/types/src/components/context-menu/context-menu-store.d.ts +2 -2
  12. package/dist/types/src/components/context-menu/menu-reveal.d.ts +3 -8
  13. package/dist/types/src/components/dialog/dialog-store.d.ts +3 -1
  14. package/dist/types/src/components/disclosure-group/disclosure-group.d.ts +3 -1
  15. package/dist/types/src/components/keyboard-shortcut/keyboard-shortcut.d.ts +5 -0
  16. package/dist/types/src/components/menu/menu.d.ts +1 -1
  17. package/dist/types/src/components/one-time-code/one-time-code.d.ts +23 -0
  18. package/dist/types/src/components/picker/picker.d.ts +7 -1
  19. package/dist/types/src/components/popover/anchored-surface.d.ts +13 -0
  20. package/dist/types/src/components/popover/popover.d.ts +5 -1
  21. package/dist/types/src/components/progress-view/progress-view.d.ts +1 -1
  22. package/dist/types/src/components/progress-view/use-liquid-progress.d.ts +3 -0
  23. package/dist/types/src/components/radio-group/radio-group.d.ts +17 -0
  24. package/dist/types/src/components/search-field/expanding-search-field.d.ts +5 -0
  25. package/dist/types/src/components/search-field/search-field.d.ts +5 -1
  26. package/dist/types/src/components/sheet/sheet.d.ts +2 -0
  27. package/dist/types/src/components/sidebar/sidebar.d.ts +3 -2
  28. package/dist/types/src/components/skeleton/skeleton.d.ts +6 -0
  29. package/dist/types/src/components/slosh-slider/liquid-step.d.ts +6 -0
  30. package/dist/types/src/components/slosh-slider/slosh-slider.d.ts +18 -0
  31. package/dist/types/src/components/stepper/rolling-digit.d.ts +11 -0
  32. package/dist/types/src/components/text-field/text-field.d.ts +3 -1
  33. package/dist/types/src/components/wake-slider/wake-slider.d.ts +12 -0
  34. package/dist/types/src/hooks/use-anchored-disclosure.d.ts +9 -0
  35. package/dist/types/src/hooks/use-anchored-placement.d.ts +1 -1
  36. package/dist/types/src/index.d.ts +13 -0
  37. package/dist/types/src/lib/anchored-placement.d.ts +2 -2
  38. package/package.json +60 -4
  39. package/src/components/accordion/accordion.tsx +48 -0
  40. package/src/components/aspect-ratio/aspect-ratio.tsx +22 -0
  41. package/src/components/breadcrumb/breadcrumb.tsx +53 -0
  42. package/src/components/button/button.tsx +5 -1
  43. package/src/components/code-block/code-block.tsx +41 -0
  44. package/src/components/combo-box/combo-box.tsx +13 -0
  45. package/src/components/command/command.tsx +119 -0
  46. package/src/components/compact-date-picker/compact-date-picker.tsx +40 -84
  47. package/src/components/compact-time-picker/compact-time-picker.tsx +44 -81
  48. package/src/components/content-unavailable-view/content-unavailable-view.tsx +37 -0
  49. package/src/components/context-menu/context-menu-host.tsx +28 -24
  50. package/src/components/context-menu/context-menu-store.ts +3 -4
  51. package/src/components/context-menu/menu-reveal.ts +5 -2
  52. package/src/components/dialog/dialog-host.tsx +13 -7
  53. package/src/components/dialog/dialog-store.ts +15 -4
  54. package/src/components/disclosure-group/disclosure-group.tsx +19 -10
  55. package/src/components/keyboard-shortcut/keyboard-shortcut.tsx +17 -0
  56. package/src/components/menu/menu.tsx +1 -1
  57. package/src/components/one-time-code/one-time-code.tsx +198 -0
  58. package/src/components/picker/picker.tsx +121 -108
  59. package/src/components/popover/anchored-surface.tsx +223 -0
  60. package/src/components/popover/popover.tsx +149 -20
  61. package/src/components/progress-view/progress-view.tsx +6 -1
  62. package/src/components/progress-view/use-liquid-progress.ts +43 -0
  63. package/src/components/radio-group/radio-group.tsx +63 -0
  64. package/src/components/search-field/expanding-search-field.tsx +183 -0
  65. package/src/components/search-field/search-field.tsx +41 -25
  66. package/src/components/search-list/search-list.tsx +16 -7
  67. package/src/components/sheet/sheet.tsx +4 -1
  68. package/src/components/sidebar/sidebar.tsx +21 -12
  69. package/src/components/skeleton/skeleton.tsx +19 -0
  70. package/src/components/slosh-slider/liquid-step.ts +42 -0
  71. package/src/components/slosh-slider/slosh-slider.tsx +251 -0
  72. package/src/components/stepper/rolling-digit.tsx +77 -0
  73. package/src/components/stepper/stepper.tsx +50 -29
  74. package/src/components/text-field/text-field.tsx +45 -7
  75. package/src/components/toggle/toggle.tsx +147 -30
  76. package/src/components/wake-slider/wake-slider.tsx +237 -0
  77. package/src/hooks/use-anchored-disclosure.ts +23 -0
  78. package/src/hooks/use-anchored-placement.ts +7 -4
  79. package/src/icons/icons.tsx +150 -150
  80. package/src/index.ts +18 -0
  81. package/src/lib/anchored-placement.ts +7 -3
  82. package/src/styles/code-block.css +33 -0
  83. package/src/styles/motion-controls.css +189 -0
  84. package/src/styles/styles.css +3 -0
@@ -1,14 +1,10 @@
1
- import { AnimatePresence, motion, useReducedMotion } from "motion/react"
2
- import { type KeyboardEvent, useEffect, useId, useLayoutEffect, useRef, useState } from "react"
1
+ import { useAnchoredDisclosure } from "../../hooks/use-anchored-disclosure"
2
+ import { AnchoredSurface } from "../popover/anchored-surface"
3
+ import { type KeyboardEvent, useEffect, useId, useRef } from "react"
3
4
  import { createPortal } from "react-dom"
4
- import { FLOATING_LAYER } from "../../hooks/use-dismiss"
5
- import { useFloatingDismiss } from "../../hooks/use-floating-dismiss"
6
5
  import { ClockIcon } from "../../icons/icons"
7
- import { useAnchoredPlacement } from "../../hooks/use-anchored-placement"
8
6
  import { cn } from "../../lib/class-names"
9
- import { registerFloatingLayer } from "../../lib/floating-layers"
10
7
  import { haptic } from "../../lib/haptics"
11
- import { floatingPanelMotion } from "../../motion/transitions"
12
8
  import { pickerTriggerStyle } from "../picker/picker-trigger"
13
9
  import {
14
10
  formatTime,
@@ -53,7 +49,8 @@ export function CompactTimePicker({
53
49
  className,
54
50
  disabled = false,
55
51
  }: CompactTimePickerProps) {
56
- const [open, setOpen] = useState(false)
52
+ const panel = useAnchoredDisclosure(disabled)
53
+ const { open } = panel
57
54
  const trigger = useRef<HTMLButtonElement>(null)
58
55
  const panelId = useId()
59
56
  const chosen = parseTime(value)
@@ -75,7 +72,7 @@ export function CompactTimePicker({
75
72
  aria-expanded={open && !disabled}
76
73
  aria-controls={open && !disabled ? panelId : undefined}
77
74
  disabled={disabled}
78
- onClick={() => setOpen((current) => !current)}
75
+ onClick={() => panel.toggle()}
79
76
  className={pickerTriggerStyle(className)}
80
77
  >
81
78
  <span
@@ -90,33 +87,32 @@ export function CompactTimePicker({
90
87
  </button>
91
88
  {typeof document !== "undefined" &&
92
89
  createPortal(
93
- <AnimatePresence>
94
- {open && !disabled && trigger.current && (
95
- <TimeColumns
96
- id={panelId}
97
- anchor={trigger.current}
98
- label={label}
99
- locale={locale}
100
- time={
101
- chosen ?? {
102
- hour: 9,
103
- minute: 0,
104
- }
90
+ panel.mounted && trigger.current ? (
91
+ <TimeColumns
92
+ open={open}
93
+ onExited={panel.onExited}
94
+ id={panelId}
95
+ anchor={trigger.current}
96
+ label={label}
97
+ locale={locale}
98
+ time={
99
+ chosen ?? {
100
+ hour: 9,
101
+ minute: 0,
105
102
  }
106
- minuteStep={minuteStep}
107
- labels={labels}
108
- onChange={(next) => {
109
- haptic("selection")
110
- onChange(formatTime(next))
111
- }}
112
- onClose={(restoreFocus) => {
113
- setOpen(false)
114
- if (restoreFocus)
115
- trigger.current?.focus({ preventScroll: true })
116
- }}
117
- />
118
- )}
119
- </AnimatePresence>,
103
+ }
104
+ minuteStep={minuteStep}
105
+ labels={labels}
106
+ onChange={(next) => {
107
+ haptic("selection")
108
+ onChange(formatTime(next))
109
+ }}
110
+ onClose={(restoreFocus) => {
111
+ panel.close()
112
+ if (restoreFocus) trigger.current?.focus({ preventScroll: true })
113
+ }}
114
+ />
115
+ ) : null,
120
116
  document.body
121
117
  )}
122
118
  </>
@@ -129,6 +125,8 @@ interface ColumnItem {
129
125
  }
130
126
 
131
127
  function TimeColumns({
128
+ open,
129
+ onExited,
132
130
  id,
133
131
  anchor,
134
132
  label,
@@ -139,6 +137,8 @@ function TimeColumns({
139
137
  onChange,
140
138
  onClose,
141
139
  }: {
140
+ open: boolean
141
+ onExited: () => void
142
142
  id: string
143
143
  anchor: HTMLElement
144
144
  label: string
@@ -150,9 +150,7 @@ function TimeColumns({
150
150
  onClose: (restoreFocus: boolean) => void
151
151
  }) {
152
152
  const root = useRef<HTMLDivElement>(null)
153
- const reducedMotion = useReducedMotion()
154
153
  const twelve = usesTwelveHours(locale)
155
- const placement = useAnchoredPlacement(anchor, twelve ? 240 : 180, 280)
156
154
  const number = new Intl.NumberFormat(locale, {
157
155
  minimumIntegerDigits: 2,
158
156
  })
@@ -186,54 +184,19 @@ function TimeColumns({
186
184
  { key: 1, text: periodName(true) },
187
185
  ]
188
186
 
189
- useLayoutEffect(() => {
190
- if (!root.current) return
191
- return registerFloatingLayer(root.current, anchor)
192
- }, [anchor])
193
-
194
- useFloatingDismiss(root, anchor, onClose)
195
-
196
187
  return (
197
- <motion.div
188
+ <AnchoredSurface
189
+ open={open}
190
+ onExited={onExited}
198
191
  ref={root}
192
+ anchor={anchor}
193
+ width={twelve ? 240 : 180}
194
+ needed={280}
195
+ onClose={onClose}
199
196
  id={id}
200
197
  role="dialog"
201
198
  aria-label={label}
202
- initial={
203
- reducedMotion
204
- ? { opacity: 0 }
205
- : {
206
- opacity: 0,
207
- scale: 1,
208
- y: placement.above ? 6 : -6,
209
- }
210
- }
211
- animate={{ opacity: 1, scale: 1, y: 0 }}
212
- exit={
213
- reducedMotion
214
- ? { opacity: 0 }
215
- : {
216
- opacity: 0,
217
- scale: 1,
218
- y: placement.above ? 4 : -4,
219
- }
220
- }
221
- transition={
222
- reducedMotion
223
- ? { duration: 0 }
224
- : floatingPanelMotion(placement.above ? "above" : "below").transition
225
- }
226
- style={{
227
- left: placement.left,
228
- width: placement.width,
229
- top: placement.top,
230
- bottom: placement.bottom,
231
- maxHeight: placement.maxHeight,
232
- overflowY: placement.maxHeight ? "auto" : undefined,
233
- transformOrigin: placement.above ? "50% 100%" : "50% 0%",
234
- }}
235
- className="glass-menu fixed z-[80] flex gap-1 rounded-3xl p-2"
236
- {...{ [FLOATING_LAYER]: "" }}
199
+ contentClassName="flex-row gap-1 overflow-hidden p-2"
237
200
  onKeyDown={(event) => {
238
201
  if (event.key !== "Escape") return
239
202
  event.preventDefault()
@@ -272,7 +235,7 @@ function TimeColumns({
272
235
  }
273
236
  />
274
237
  )}
275
- </motion.div>
238
+ </AnchoredSurface>
276
239
  )
277
240
  }
278
241
 
@@ -0,0 +1,37 @@
1
+ import type { ReactNode, ComponentProps } from "react"
2
+ import { cn } from "../../lib/class-names"
3
+
4
+ export interface ContentUnavailableViewProps extends Omit<ComponentProps<"div">, "title"> {
5
+ title: string
6
+ description?: string
7
+ icon?: ReactNode
8
+ actions?: ReactNode
9
+ }
10
+
11
+ /** A focused empty or unavailable state with an optional next action. */
12
+ export function ContentUnavailableView({
13
+ title,
14
+ description,
15
+ icon,
16
+ actions,
17
+ className,
18
+ ...props
19
+ }: ContentUnavailableViewProps) {
20
+ return (
21
+ <div
22
+ {...props}
23
+ className={cn("flex flex-col items-center gap-3 px-6 py-10 text-center", className)}
24
+ >
25
+ {icon && (
26
+ <span aria-hidden="true" className="text-label-tertiary">
27
+ {icon}
28
+ </span>
29
+ )}
30
+ <p className="text-headline font-semibold text-label">{title}</p>
31
+ {description && (
32
+ <p className="max-w-sm text-footnote text-label-secondary">{description}</p>
33
+ )}
34
+ {actions && <div className="mt-1 flex flex-wrap justify-center gap-2">{actions}</div>}
35
+ </div>
36
+ )
37
+ }
@@ -36,6 +36,7 @@ function Menu({ menu }: { menu: OpenMenu }) {
36
36
  maxHeight: undefined as number | undefined,
37
37
  width: 0,
38
38
  height: 0,
39
+ radius: 0,
39
40
  ready: false,
40
41
  })
41
42
  const [focused, setFocused] = useState(-1)
@@ -102,6 +103,7 @@ function Menu({ menu }: { menu: OpenMenu }) {
102
103
  bottom: top + (viewport?.height ?? window.innerHeight) - edges.bottom,
103
104
  }
104
105
  ),
106
+ radius: Number.parseFloat(getComputedStyle(element).borderTopLeftRadius) || 0,
105
107
  ready: true,
106
108
  }
107
109
  setPosition((current) =>
@@ -112,6 +114,7 @@ function Menu({ menu }: { menu: OpenMenu }) {
112
114
  current.maxHeight === next.maxHeight &&
113
115
  current.width === next.width &&
114
116
  current.height === next.height &&
117
+ current.radius === next.radius &&
115
118
  current.ready === next.ready
116
119
  ? current
117
120
  : next
@@ -190,8 +193,7 @@ function Menu({ menu }: { menu: OpenMenu }) {
190
193
  )
191
194
  let actionIndex = -1
192
195
  return (
193
- <motion.div
194
- key={position.ready ? "surface" : "measure"}
196
+ <div
195
197
  ref={ref}
196
198
  role="menu"
197
199
  tabIndex={-1}
@@ -248,7 +250,7 @@ function Menu({ menu }: { menu: OpenMenu }) {
248
250
  }}
249
251
  {...{ [FLOATING_LAYER]: "" }}
250
252
  className={cn(
251
- "fixed z-75 isolate flex min-w-50 max-w-[min(280px,calc(100vw-24px))] flex-col rounded-3xl p-1.5",
253
+ "fixed z-75 isolate flex min-w-50 max-w-[min(280px,calc(100vw-24px))] flex-col rounded-3xl p-1.5 outline-none",
252
254
  menu.touch && "min-w-60",
253
255
  !present && "pointer-events-none"
254
256
  )}
@@ -263,26 +265,28 @@ function Menu({ menu }: { menu: OpenMenu }) {
263
265
  }}
264
266
  onContextMenu={(event) => event.preventDefault()}
265
267
  >
266
- <motion.div
267
- aria-hidden="true"
268
- className="glass-menu pointer-events-none absolute top-0 left-0 -z-10 rounded-3xl"
269
- style={{ width: "100%", height: "100%" }}
270
- initial={reducedMotion || !position.ready ? false : source}
271
- animate={
272
- position.ready
273
- ? {
274
- x: 0,
275
- y: 0,
276
- width: position.width,
277
- height: position.height,
278
- opacity: 1,
279
- borderRadius: "var(--radius-3xl)",
280
- }
281
- : undefined
282
- }
283
- exit={reducedMotion ? { opacity: 0 } : source}
284
- transition={reducedMotion ? { duration: 0 } : spring.smooth}
285
- />
268
+ {position.ready && (
269
+ <motion.div
270
+ aria-hidden="true"
271
+ className="glass-menu pointer-events-none absolute top-0 left-0 -z-10 rounded-3xl"
272
+ style={{ width: "100%", height: "100%" }}
273
+ initial={reducedMotion || !position.ready ? false : source}
274
+ animate={
275
+ position.ready
276
+ ? {
277
+ x: 0,
278
+ y: 0,
279
+ width: position.width,
280
+ height: position.height,
281
+ opacity: 1,
282
+ borderRadius: position.radius,
283
+ }
284
+ : undefined
285
+ }
286
+ exit={reducedMotion ? { opacity: 0 } : source}
287
+ transition={reducedMotion ? { duration: 0 } : spring.smooth}
288
+ />
289
+ )}
286
290
  <motion.div
287
291
  ref={content}
288
292
  className="flex flex-col overflow-y-auto overscroll-contain"
@@ -370,6 +374,6 @@ function Menu({ menu }: { menu: OpenMenu }) {
370
374
  )
371
375
  })}
372
376
  </motion.div>
373
- </motion.div>
377
+ </div>
374
378
  )
375
379
  }
@@ -81,8 +81,8 @@ export function openFollowUpMenu(items: ContextMenuItem[]) {
81
81
  }
82
82
 
83
83
  /**
84
- * Opens a menu from a control, the way a SwiftUI `Menu` does: under it (or
85
- * above it), lined up with its leading or trailing edge. Leading and
84
+ * Expands a control into a menu, lined up with its leading or trailing
85
+ * edge and growing down (or up) from the control's bounds. Leading and
86
86
  * trailing follow the text direction, so right-to-left languages mirror.
87
87
  * The menu's real size decides where it goes; nothing is guessed.
88
88
  */
@@ -105,12 +105,11 @@ export function openMenu(
105
105
  const rtl = getComputedStyle(anchor).direction === "rtl"
106
106
  // Trailing is the right edge in left-to-right text, the left edge in right-to-left.
107
107
  const right = (edge === "trailing") !== rtl
108
- const gap = 6
109
108
  menu = {
110
109
  id: nextId++,
111
110
  anchor,
112
111
  x: right ? (toolbarRect ?? rect).right : (toolbarRect ?? rect).left,
113
- y: toolbar ? rect.top : above ? rect.top - gap : rect.bottom + gap,
112
+ y: toolbar ? rect.top : above ? rect.bottom : rect.top,
114
113
  items,
115
114
  touch: false,
116
115
  alignX: right ? "end" : "start",
@@ -6,7 +6,10 @@ export type MenuRect = {
6
6
  }
7
7
 
8
8
  /** Animate the material's bounds independently so menu text never stretches. */
9
- export function menuSurfaceReveal(source: MenuRect | undefined, target: MenuRect) {
9
+ export function menuSurfaceReveal(
10
+ source: MenuRect | undefined,
11
+ target: MenuRect & { radius?: number }
12
+ ) {
10
13
  if (
11
14
  !source ||
12
15
  source.width <= 0 ||
@@ -20,7 +23,7 @@ export function menuSurfaceReveal(source: MenuRect | undefined, target: MenuRect
20
23
  width: target.width,
21
24
  height: target.height,
22
25
  opacity: 0,
23
- borderRadius: "var(--radius-3xl)",
26
+ borderRadius: target.radius ?? 0,
24
27
  }
25
28
  }
26
29
  return {
@@ -2,7 +2,13 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react"
2
2
  import { useEffect, useRef, useSyncExternalStore } from "react"
3
3
  import { Button } from "../button/button"
4
4
  import { spring } from "../../motion/transitions"
5
- import { getDialog, type PendingDialog, settleDialog, subscribeDialogs } from "./dialog-store"
5
+ import {
6
+ advanceDialogQueue,
7
+ getDialog,
8
+ type PendingDialog,
9
+ settleDialog,
10
+ subscribeDialogs,
11
+ } from "./dialog-store"
6
12
 
7
13
  /**
8
14
  * Renders confirmation dialogs: a glass card that springs up over blurred
@@ -12,7 +18,7 @@ import { getDialog, type PendingDialog, settleDialog, subscribeDialogs } from ".
12
18
  export function DialogHost() {
13
19
  const dialog = useSyncExternalStore(subscribeDialogs, getDialog, () => null)
14
20
  return (
15
- <AnimatePresence>
21
+ <AnimatePresence mode="wait" onExitComplete={advanceDialogQueue}>
16
22
  {dialog && <ConfirmCard key={dialog.id} dialog={dialog} />}
17
23
  </AnimatePresence>
18
24
  )
@@ -31,7 +37,7 @@ function ConfirmCard({ dialog }: { dialog: PendingDialog }) {
31
37
  const onKey = (event: KeyboardEvent) => {
32
38
  if (event.key === "Escape") {
33
39
  event.preventDefault()
34
- settleDialog(false)
40
+ settleDialog(false, dialog.id)
35
41
  }
36
42
  // Keep focus inside the dialog.
37
43
  if (event.key === "Tab") {
@@ -48,7 +54,7 @@ function ConfirmCard({ dialog }: { dialog: PendingDialog }) {
48
54
  window.removeEventListener("keydown", onKey)
49
55
  previous?.focus?.()
50
56
  }
51
- }, [dialog.destructive])
57
+ }, [dialog.destructive, dialog.id])
52
58
 
53
59
  return (
54
60
  <div className="fixed inset-0 z-[70] flex items-center justify-center p-5">
@@ -59,7 +65,7 @@ function ConfirmCard({ dialog }: { dialog: PendingDialog }) {
59
65
  animate={{ opacity: 1 }}
60
66
  exit={{ opacity: 0 }}
61
67
  transition={reducedMotion ? { duration: 0 } : spring.smooth}
62
- onClick={() => settleDialog(false)}
68
+ onClick={() => settleDialog(false, dialog.id)}
63
69
  />
64
70
  <motion.div
65
71
  role="alertdialog"
@@ -90,7 +96,7 @@ function ConfirmCard({ dialog }: { dialog: PendingDialog }) {
90
96
  ref={cancelRef}
91
97
  type="button"
92
98
  variant="secondary"
93
- onClick={() => settleDialog(false)}
99
+ onClick={() => settleDialog(false, dialog.id)}
94
100
  className="min-w-0 grow rounded-2xl"
95
101
  >
96
102
  {dialog.cancelLabel ?? "Cancel"}
@@ -99,7 +105,7 @@ function ConfirmCard({ dialog }: { dialog: PendingDialog }) {
99
105
  ref={confirmRef}
100
106
  type="button"
101
107
  variant={dialog.destructive ? "destructive" : "default"}
102
- onClick={() => settleDialog(true)}
108
+ onClick={() => settleDialog(true, dialog.id)}
103
109
  className="min-w-0 grow rounded-2xl"
104
110
  >
105
111
  {dialog.confirmLabel ?? "OK"}
@@ -20,6 +20,7 @@ export interface PendingDialog extends ConfirmOptions {
20
20
  let current: PendingDialog | null = null
21
21
  let nextId = 1
22
22
  const queue: PendingDialog[] = []
23
+ let exitingId: number | null = null
23
24
  const listeners = new Set<() => void>()
24
25
 
25
26
  const emit = () => {
@@ -29,15 +30,25 @@ const emit = () => {
29
30
  export function confirmDialog(options: ConfirmOptions): Promise<boolean> {
30
31
  return new Promise((resolve) => {
31
32
  const dialog = { ...options, id: nextId++, resolve }
32
- if (current) queue.push(dialog)
33
+ if (current || exitingId !== null) queue.push(dialog)
33
34
  else current = dialog
34
35
  emit()
35
36
  })
36
37
  }
37
38
 
38
- export function settleDialog(confirmed: boolean) {
39
- if (!current) return
40
- current.resolve(confirmed)
39
+ export function settleDialog(confirmed: boolean, id?: number) {
40
+ if (!current || exitingId !== null || (id !== undefined && current.id !== id)) return
41
+ const settled = current
42
+ exitingId = settled.id
43
+ current = null
44
+ settled.resolve(confirmed)
45
+ emit()
46
+ }
47
+
48
+ /** Advance queued confirmations only after the dismissed card has exited. */
49
+ export function advanceDialogQueue() {
50
+ if (exitingId === null) return
51
+ exitingId = null
41
52
  current = queue.shift() ?? null
42
53
  emit()
43
54
  }
@@ -1,3 +1,4 @@
1
+ import { Button } from "../button/button"
1
2
  import { type ReactNode, useId } from "react"
2
3
  import { ChevronDownIcon } from "../../icons/icons"
3
4
  import { cn } from "../../lib/class-names"
@@ -10,6 +11,8 @@ export interface DisclosureGroupProps {
10
11
  children: ReactNode
11
12
  disabled?: boolean
12
13
  className?: string
14
+ /** Trigger layout when composed inside a shared surface such as Accordion. */
15
+ triggerClassName?: string
13
16
  /** Supporting text beneath the title. */
14
17
  description?: string
15
18
  }
@@ -22,14 +25,17 @@ export function DisclosureGroup({
22
25
  children,
23
26
  disabled = false,
24
27
  className,
28
+ triggerClassName,
25
29
  description,
26
30
  }: DisclosureGroupProps) {
27
31
  const id = useId()
28
32
 
29
33
  return (
30
- <div className={cn("min-w-0", className)}>
34
+ <div className={cn("w-full min-w-0", className)}>
31
35
  <h3 className="m-0">
32
- <button
36
+ <Button
37
+ variant="ghost"
38
+ contentClassName="w-full min-w-0 justify-between gap-3"
33
39
  type="button"
34
40
  id={`${id}-trigger`}
35
41
  aria-expanded={open}
@@ -37,15 +43,16 @@ export function DisclosureGroup({
37
43
  disabled={disabled}
38
44
  onClick={() => onOpenChange(!open)}
39
45
  className={cn(
40
- "flex min-h-11 w-full items-center justify-between gap-3 rounded-xl px-3 py-2 text-start",
41
- "text-subheadline text-label hover:bg-fill/60 focus-visible:outline-2 focus-visible:outline-offset-2",
42
- "focus-visible:outline-accent-text disabled:cursor-not-allowed disabled:opacity-40"
46
+ "h-auto min-h-11 w-full rounded-xl px-4 py-3 text-start active:scale-100",
47
+ "text-subheadline font-normal text-label hover:bg-fill/60 focus-visible:ring-inset focus-visible:ring-offset-0",
48
+ "focus-visible:outline-accent-text disabled:cursor-not-allowed disabled:opacity-40",
49
+ triggerClassName
43
50
  )}
44
51
  >
45
52
  <span className="flex min-w-0 flex-col gap-0.5">
46
- <span className="font-semibold">{label}</span>
53
+ <span className="font-medium break-words whitespace-normal">{label}</span>
47
54
  {description && (
48
- <span className="text-footnote font-normal text-label-secondary">
55
+ <span className="text-footnote leading-footnote font-normal break-words whitespace-normal text-label-secondary">
49
56
  {description}
50
57
  </span>
51
58
  )}
@@ -58,11 +65,13 @@ export function DisclosureGroup({
58
65
  open && "rotate-180"
59
66
  )}
60
67
  />
61
- </button>
68
+ </Button>
62
69
  </h3>
63
- <section id={id} aria-labelledby={`${id}-trigger`}>
70
+ <section id={id} aria-labelledby={`${id}-trigger`} aria-hidden={!open} inert={!open}>
64
71
  <InlineDisclosure open={open}>
65
- <div className="px-3 pb-3">{children}</div>
72
+ <div className="min-w-0 px-4 pb-4 text-subheadline leading-subheadline text-label-secondary [overflow-wrap:anywhere]">
73
+ {children}
74
+ </div>
66
75
  </InlineDisclosure>
67
76
  </section>
68
77
  </div>
@@ -0,0 +1,17 @@
1
+ import type { ComponentProps } from "react"
2
+ import { cn } from "../../lib/class-names"
3
+
4
+ export interface KeyboardShortcutProps extends ComponentProps<"kbd"> {}
5
+
6
+ /** A quiet keycap for a shortcut supplied and localized by the application. */
7
+ export function KeyboardShortcut({ className, ...props }: KeyboardShortcutProps) {
8
+ return (
9
+ <kbd
10
+ {...props}
11
+ className={cn(
12
+ "inline-flex shrink-0 items-center rounded-md border border-separator/70 bg-fill/50 px-1.5 py-0.5 font-mono text-caption2 leading-none text-label-secondary",
13
+ className
14
+ )}
15
+ />
16
+ )
17
+ }
@@ -17,7 +17,7 @@ export interface MenuProps extends Omit<ButtonProps, "onClick" | "children"> {
17
17
  children: ReactNode
18
18
  /** Logical edge used to align the menu, including RTL. @default "leading" */
19
19
  edge?: ContextMenuEdge
20
- /** Open above the trigger instead of below it. @default false */
20
+ /** Expand upward from the trigger instead of downward. @default false */
21
21
  above?: boolean
22
22
  }
23
23