@adea-ai/ui 0.94.0 → 0.95.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 (84) hide show
  1. package/README.md +43 -1
  2. package/dist/NOTICE +10 -0
  3. package/dist/components/composites/action-button/action-button.d.ts.map +1 -1
  4. package/dist/components/composites/action-button/action-button.js +2 -0
  5. package/dist/components/composites/action-button/action-button.js.map +1 -1
  6. package/dist/components/composites/scene-controls/index.d.ts +3 -0
  7. package/dist/components/composites/scene-controls/index.d.ts.map +1 -0
  8. package/dist/components/composites/scene-controls/index.js +2 -0
  9. package/dist/components/composites/scene-controls/scene-controls.d.ts +41 -0
  10. package/dist/components/composites/scene-controls/scene-controls.d.ts.map +1 -0
  11. package/dist/components/composites/scene-controls/scene-controls.js +291 -0
  12. package/dist/components/composites/scene-controls/scene-controls.js.map +1 -0
  13. package/dist/components/ui/button/button.d.ts +2 -1
  14. package/dist/components/ui/button/button.d.ts.map +1 -1
  15. package/dist/components/ui/button/button.js +9 -3
  16. package/dist/components/ui/button/button.js.map +1 -1
  17. package/dist/components/ui/dialog/dialog.d.ts.map +1 -1
  18. package/dist/components/ui/dialog/dialog.js +5 -1
  19. package/dist/components/ui/dialog/dialog.js.map +1 -1
  20. package/dist/components/ui/modal-dialog/modal-dialog.d.ts +6 -0
  21. package/dist/components/ui/modal-dialog/modal-dialog.d.ts.map +1 -1
  22. package/dist/components/ui/modal-dialog/modal-dialog.js +4 -0
  23. package/dist/components/ui/modal-dialog/modal-dialog.js.map +1 -1
  24. package/dist/components/ui/sheet/sheet.d.ts +8 -1
  25. package/dist/components/ui/sheet/sheet.d.ts.map +1 -1
  26. package/dist/components/ui/sheet/sheet.js +43 -19
  27. package/dist/components/ui/sheet/sheet.js.map +1 -1
  28. package/dist/components/ui/toggle/toggle.d.ts +1 -1
  29. package/dist/components/ui/toggle/toggle.d.ts.map +1 -1
  30. package/dist/components/ui/toggle/toggle.js +3 -1
  31. package/dist/components/ui/toggle/toggle.js.map +1 -1
  32. package/dist/components/ui/toggle-group/toggle-group.d.ts.map +1 -1
  33. package/dist/components/ui/toggle-group/toggle-group.js +20 -7
  34. package/dist/components/ui/toggle-group/toggle-group.js.map +1 -1
  35. package/dist/index.d.ts +1 -0
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +2 -1
  38. package/dist/lib/tokens.d.ts.map +1 -1
  39. package/dist/lib/tokens.js +10 -0
  40. package/dist/lib/tokens.js.map +1 -1
  41. package/dist/lib/utils.d.ts.map +1 -1
  42. package/dist/lib/utils.js +2 -0
  43. package/dist/lib/utils.js.map +1 -1
  44. package/dist/lib/variants.d.ts +5 -2
  45. package/dist/lib/variants.d.ts.map +1 -1
  46. package/dist/lib/variants.js +7 -3
  47. package/dist/lib/variants.js.map +1 -1
  48. package/dist/lint/index.d.ts.map +1 -1
  49. package/dist/lint.js +2 -1
  50. package/dist/lint.js.map +1 -1
  51. package/dist/r/registry.json +24 -0
  52. package/dist/r/scene-controls.json +24 -0
  53. package/dist/r/src/components/composites/action-button/action-button.tsx +2 -0
  54. package/dist/r/src/components/composites/scene-controls/index.ts +7 -0
  55. package/dist/r/src/components/composites/scene-controls/scene-controls.tsx +282 -0
  56. package/dist/r/src/components/ui/button/button.tsx +20 -2
  57. package/dist/r/src/components/ui/dialog/dialog.tsx +6 -1
  58. package/dist/r/src/components/ui/modal-dialog/modal-dialog.tsx +20 -0
  59. package/dist/r/src/components/ui/sheet/sheet.tsx +37 -4
  60. package/dist/r/src/components/ui/toggle/toggle.tsx +2 -0
  61. package/dist/r/src/components/ui/toggle-group/toggle-group.tsx +18 -1
  62. package/dist/r/src/lib/tokens.ts +10 -0
  63. package/dist/r/src/lib/utils.ts +2 -0
  64. package/dist/r/src/lib/variants.ts +5 -2
  65. package/dist/r/src/styles/base.css +29 -9
  66. package/dist/r/src/styles/theme.css +8 -2
  67. package/package.json +2 -1
  68. package/registry.json +24 -0
  69. package/src/components/composites/action-button/action-button.tsx +2 -0
  70. package/src/components/composites/scene-controls/index.ts +7 -0
  71. package/src/components/composites/scene-controls/scene-controls.tsx +282 -0
  72. package/src/components/ui/button/button.tsx +20 -2
  73. package/src/components/ui/dialog/dialog.tsx +6 -1
  74. package/src/components/ui/modal-dialog/modal-dialog.tsx +20 -0
  75. package/src/components/ui/sheet/sheet.tsx +37 -4
  76. package/src/components/ui/toggle/toggle.tsx +2 -0
  77. package/src/components/ui/toggle-group/toggle-group.tsx +18 -1
  78. package/src/index.ts +1 -0
  79. package/src/lib/tokens.ts +10 -0
  80. package/src/lib/utils.ts +2 -0
  81. package/src/lib/variants.ts +5 -2
  82. package/src/lint/index.ts +1 -0
  83. package/src/styles/base.css +29 -9
  84. package/src/styles/theme.css +8 -2
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "scene-controls",
3
+ "type": "registry:ui",
4
+ "title": "Scene Controls",
5
+ "description": "Shared host-neutral scene movement, jump and zoom controls.",
6
+ "dependencies": [
7
+ "lucide-solid@^1.48.0"
8
+ ],
9
+ "registryDependencies": [
10
+ "@adea-ai/ui/action-button"
11
+ ],
12
+ "files": [
13
+ {
14
+ "path": "src/components/composites/scene-controls/index.ts",
15
+ "type": "registry:ui",
16
+ "target": "components/composites/scene-controls/index.ts"
17
+ },
18
+ {
19
+ "path": "src/components/composites/scene-controls/scene-controls.tsx",
20
+ "type": "registry:ui",
21
+ "target": "components/composites/scene-controls/scene-controls.tsx"
22
+ }
23
+ ]
24
+ }
@@ -84,6 +84,7 @@ export function ActionButton<T extends ValidComponent = 'button'>(props: ActionB
84
84
  'as',
85
85
  'variant',
86
86
  'size',
87
+ 'touchTarget',
87
88
  'class',
88
89
  'children',
89
90
  'disabled',
@@ -106,6 +107,7 @@ export function ActionButton<T extends ValidComponent = 'button'>(props: ActionB
106
107
  ...rest,
107
108
  variant: local.variant,
108
109
  size: local.size,
110
+ touchTarget: local.touchTarget,
109
111
  class: [local.class, local.tooltip && disabled() && 'aria-disabled:pointer-events-auto']
110
112
  .filter(Boolean)
111
113
  .join(' '),
@@ -0,0 +1,7 @@
1
+ /** Shared host-neutral scene movement, jump and zoom controls. */
2
+ export {
3
+ SceneControls,
4
+ type SceneControlLabels,
5
+ type SceneControlsProps,
6
+ type SceneMovementDirection,
7
+ } from './scene-controls'
@@ -0,0 +1,282 @@
1
+ import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, ZoomIn, ZoomOut } from 'lucide-solid'
2
+ import { createSignal, onCleanup, Show, type JSX } from 'solid-js'
3
+ import { ActionButton } from '../action-button'
4
+
5
+ /** A host-neutral direction reported while a movement control is held. */
6
+ export type SceneMovementDirection = 'forward' | 'left' | 'backward' | 'right'
7
+
8
+ /** Accessible names and tooltip text for the shared scene controls. */
9
+ export type SceneControlLabels = {
10
+ movementGroup: string
11
+ forward: string
12
+ forwardTooltip: string
13
+ left: string
14
+ leftTooltip: string
15
+ backward: string
16
+ backwardTooltip: string
17
+ right: string
18
+ rightTooltip: string
19
+ jump: string
20
+ jumpTooltip: string
21
+ zoomGroup: string
22
+ zoomOut: string
23
+ zoomIn: string
24
+ }
25
+
26
+ const DEFAULT_LABELS: SceneControlLabels = {
27
+ movementGroup: 'Scene movement controls',
28
+ forward: 'Move forward',
29
+ forwardTooltip: 'Hold to move forward',
30
+ left: 'Move left',
31
+ leftTooltip: 'Hold to move left',
32
+ backward: 'Move backward',
33
+ backwardTooltip: 'Hold to move backward',
34
+ right: 'Move right',
35
+ rightTooltip: 'Hold to move right',
36
+ jump: 'Jump',
37
+ jumpTooltip: 'Hold to jump',
38
+ zoomGroup: 'Camera zoom',
39
+ zoomOut: 'Zoom out',
40
+ zoomIn: 'Zoom in',
41
+ }
42
+
43
+ const pointerSource = (pointerId: number) => `pointer:${pointerId}`
44
+ const keySource = (key: string) => `key:${key}`
45
+
46
+ export type SceneControlsProps = {
47
+ /** Called on the transition into and out of each held movement direction. */
48
+ onMovementChange?: (direction: SceneMovementDirection, pressed: boolean) => void
49
+ /** Called when the jump action becomes pressed or released. */
50
+ onJumpChange?: (pressed: boolean) => void
51
+ /** Called once when zoom in is activated. */
52
+ onZoomIn?: () => void
53
+ /** Called once when zoom out is activated. */
54
+ onZoomOut?: () => void
55
+ /** Override the default English accessible names and tooltip copy. */
56
+ labels?: Partial<SceneControlLabels>
57
+ }
58
+
59
+ type HoldActionProps = {
60
+ label: string
61
+ tooltip: string
62
+ createChangeCallback: () => (pressed: boolean) => void
63
+ children: JSX.Element
64
+ }
65
+
66
+ /**
67
+ * A held action that reports one down/up pair across pointer and keyboard input.
68
+ *
69
+ * The arrangement, rounded scrim group surfaces and release policy translate
70
+ * Adea's on-screen scene controls; host key synthesis and scene APIs are replaced
71
+ * by callbacks.
72
+ */
73
+ function HoldAction(props: HoldActionProps) {
74
+ const [pressed, setPressed] = createSignal(false)
75
+ const sources = new Set<string>()
76
+ let activeChangeCallback: ((pressed: boolean) => void) | undefined
77
+
78
+ const press = (source: string) => {
79
+ if (sources.has(source)) return
80
+ const wasPressed = sources.size > 0
81
+ sources.add(source)
82
+ if (!wasPressed) {
83
+ setPressed(true)
84
+ activeChangeCallback = props.createChangeCallback()
85
+ activeChangeCallback(true)
86
+ }
87
+ }
88
+
89
+ const release = (source: string) => {
90
+ if (!sources.delete(source) || sources.size > 0) return
91
+ setPressed(false)
92
+ const callback = activeChangeCallback
93
+ activeChangeCallback = undefined
94
+ callback?.(false)
95
+ }
96
+
97
+ const releaseAll = () => {
98
+ if (sources.size === 0) return
99
+ sources.clear()
100
+ setPressed(false)
101
+ const callback = activeChangeCallback
102
+ activeChangeCallback = undefined
103
+ callback?.(false)
104
+ }
105
+
106
+ const onWindowPointerUp = (event: PointerEvent) => release(pointerSource(event.pointerId))
107
+ const onWindowBlur = () => releaseAll()
108
+
109
+ if (typeof window !== 'undefined') {
110
+ window.addEventListener('pointerup', onWindowPointerUp)
111
+ window.addEventListener('blur', onWindowBlur)
112
+ }
113
+
114
+ onCleanup(() => {
115
+ releaseAll()
116
+ if (typeof window !== 'undefined') {
117
+ window.removeEventListener('pointerup', onWindowPointerUp)
118
+ window.removeEventListener('blur', onWindowBlur)
119
+ }
120
+ })
121
+
122
+ return (
123
+ <ActionButton
124
+ type="button"
125
+ variant="secondary"
126
+ size="icon-2xl"
127
+ aria-label={props.label}
128
+ aria-pressed={pressed()}
129
+ tooltip={props.tooltip}
130
+ class="touch-none select-none"
131
+ onContextMenu={(event) => event.preventDefault()}
132
+ onPointerDown={(event) => {
133
+ if (event.button !== 0) return
134
+ event.preventDefault()
135
+ const source = pointerSource(event.pointerId)
136
+ if (sources.has(source)) return
137
+ try {
138
+ event.currentTarget.setPointerCapture(event.pointerId)
139
+ } catch {
140
+ // Pointer capture is not available in every embedded browser. The
141
+ // window pointerup fallback still closes this held action.
142
+ }
143
+ press(source)
144
+ }}
145
+ onPointerUp={(event) => release(pointerSource(event.pointerId))}
146
+ onPointerCancel={(event) => release(pointerSource(event.pointerId))}
147
+ onLostPointerCapture={(event) => release(pointerSource(event.pointerId))}
148
+ onKeyDown={(event) => {
149
+ if (event.key !== ' ' && event.key !== 'Enter') return
150
+ event.preventDefault()
151
+ press(keySource(event.key))
152
+ }}
153
+ onKeyUp={(event) => {
154
+ if (event.key !== ' ' && event.key !== 'Enter') return
155
+ event.preventDefault()
156
+ release(keySource(event.key))
157
+ }}
158
+ onBlur={releaseAll}
159
+ onClick={(event) => {
160
+ // Assistive technology can activate a button without pointer or keyboard
161
+ // down/up events. Treat that activation as one short held action.
162
+ if (event.detail !== 0 || sources.size > 0) return
163
+ press('activation')
164
+ release('activation')
165
+ }}
166
+ >
167
+ {props.children}
168
+ </ActionButton>
169
+ )
170
+ }
171
+
172
+ /**
173
+ * Shared movement, jump and zoom controls for a host-rendered scene.
174
+ *
175
+ * Movement and jump callbacks receive pressed/released state from pointer and
176
+ * keyboard input. Zoom callbacks fire on activation. The host owns positioning,
177
+ * scene state and camera behavior.
178
+ */
179
+ export function SceneControls(props: SceneControlsProps) {
180
+ const labels = () => ({ ...DEFAULT_LABELS, ...props.labels })
181
+
182
+ return (
183
+ <div class="flex min-w-0 max-w-full flex-wrap items-end gap-3">
184
+ <Show when={props.onMovementChange}>
185
+ <div
186
+ role="group"
187
+ aria-label={labels().movementGroup}
188
+ class="glass-scrim grid min-w-0 max-w-full grid-cols-3 gap-1.5 rounded-3xl p-2"
189
+ >
190
+ <span aria-hidden="true" />
191
+ <HoldAction
192
+ label={labels().forward}
193
+ tooltip={labels().forwardTooltip}
194
+ createChangeCallback={() => {
195
+ const callback = props.onMovementChange
196
+ return (pressed) => callback?.('forward', pressed)
197
+ }}
198
+ >
199
+ <ArrowUp aria-hidden="true" />
200
+ </HoldAction>
201
+ <span aria-hidden="true" />
202
+ <HoldAction
203
+ label={labels().left}
204
+ tooltip={labels().leftTooltip}
205
+ createChangeCallback={() => {
206
+ const callback = props.onMovementChange
207
+ return (pressed) => callback?.('left', pressed)
208
+ }}
209
+ >
210
+ <ArrowLeft aria-hidden="true" />
211
+ </HoldAction>
212
+ <HoldAction
213
+ label={labels().backward}
214
+ tooltip={labels().backwardTooltip}
215
+ createChangeCallback={() => {
216
+ const callback = props.onMovementChange
217
+ return (pressed) => callback?.('backward', pressed)
218
+ }}
219
+ >
220
+ <ArrowDown aria-hidden="true" />
221
+ </HoldAction>
222
+ <HoldAction
223
+ label={labels().right}
224
+ tooltip={labels().rightTooltip}
225
+ createChangeCallback={() => {
226
+ const callback = props.onMovementChange
227
+ return (pressed) => callback?.('right', pressed)
228
+ }}
229
+ >
230
+ <ArrowRight aria-hidden="true" />
231
+ </HoldAction>
232
+ </div>
233
+ </Show>
234
+ <div class="ml-auto flex min-w-0 max-w-full flex-col items-end gap-2">
235
+ <Show when={props.onZoomIn || props.onZoomOut}>
236
+ <div
237
+ role="group"
238
+ aria-label={labels().zoomGroup}
239
+ class="glass-scrim flex min-w-0 max-w-full flex-wrap gap-1.5 rounded-2xl p-1.5"
240
+ >
241
+ <Show when={props.onZoomOut}>
242
+ <ActionButton
243
+ type="button"
244
+ variant="secondary"
245
+ size="icon-2xl"
246
+ aria-label={labels().zoomOut}
247
+ tooltip={labels().zoomOut}
248
+ onClick={() => props.onZoomOut?.()}
249
+ >
250
+ <ZoomOut aria-hidden="true" />
251
+ </ActionButton>
252
+ </Show>
253
+ <Show when={props.onZoomIn}>
254
+ <ActionButton
255
+ type="button"
256
+ variant="secondary"
257
+ size="icon-2xl"
258
+ aria-label={labels().zoomIn}
259
+ tooltip={labels().zoomIn}
260
+ onClick={() => props.onZoomIn?.()}
261
+ >
262
+ <ZoomIn aria-hidden="true" />
263
+ </ActionButton>
264
+ </Show>
265
+ </div>
266
+ </Show>
267
+ <Show when={props.onJumpChange}>
268
+ <HoldAction
269
+ label={labels().jump}
270
+ tooltip={labels().jumpTooltip}
271
+ createChangeCallback={() => {
272
+ const callback = props.onJumpChange
273
+ return (pressed) => callback?.(pressed)
274
+ }}
275
+ >
276
+ <ArrowUp aria-hidden="true" />
277
+ </HoldAction>
278
+ </Show>
279
+ </div>
280
+ </div>
281
+ )
282
+ }
@@ -47,6 +47,7 @@ export const buttonVariants = cva(
47
47
  md: `${controlSize.md} [&_svg]:size-4`,
48
48
  lg: `${controlSize.lg} [&_svg]:size-4`,
49
49
  xl: `${controlSize.xl} [&_svg]:size-5`,
50
+ '2xl': `${controlSize['2xl']} [&_svg]:size-6`,
50
51
  /* Square, for an icon with no label. The accessible name then has to
51
52
  come from `aria-label` or a visually hidden label — an icon-only
52
53
  button with neither is the accessibility finding the Storybook lane
@@ -57,6 +58,11 @@ export const buttonVariants = cva(
57
58
  'icon-md': 'size-control-md p-0 [&_svg]:size-4',
58
59
  'icon-lg': 'size-control-lg p-0 [&_svg]:size-4',
59
60
  'icon-xl': 'size-control-xl p-0 [&_svg]:size-5',
61
+ 'icon-2xl': 'size-control-2xl p-0 [&_svg]:size-6',
62
+ },
63
+ /** Opt in to a stable touch target on devices with any coarse pointer. */
64
+ touchTarget: {
65
+ comfortable: 'touch-target-comfortable',
60
66
  },
61
67
  },
62
68
  defaultVariants: {
@@ -76,13 +82,25 @@ export type ButtonProps<T extends ValidComponent = 'button'> = PolymorphicProps<
76
82
  >
77
83
 
78
84
  export function Button<T extends ValidComponent = 'button'>(props: ButtonProps<T>) {
79
- const [local, rest] = splitProps(props as ButtonProps, ['variant', 'size', 'class'])
85
+ const [local, rest] = splitProps(props as ButtonProps, [
86
+ 'variant',
87
+ 'size',
88
+ 'touchTarget',
89
+ 'class',
90
+ ])
80
91
 
81
92
  return (
82
93
  <Polymorphic
83
94
  as="button"
84
95
  type="button"
85
- class={cn(buttonVariants({ variant: local.variant, size: local.size }), local.class)}
96
+ class={cn(
97
+ buttonVariants({
98
+ variant: local.variant,
99
+ size: local.size,
100
+ touchTarget: local.touchTarget,
101
+ }),
102
+ local.class
103
+ )}
86
104
  {...(rest as ComponentProps<'button'>)}
87
105
  />
88
106
  )
@@ -147,11 +147,16 @@ export function createDialogFocusRestoration({
147
147
  if (
148
148
  !(target instanceof HTMLElement) ||
149
149
  target === document.body ||
150
- target === document.documentElement ||
150
+ target === document.documentElement
151
+ ) {
152
+ return
153
+ }
154
+ if (
151
155
  target === element ||
152
156
  element.contains(target) ||
153
157
  target === (opener instanceof HTMLElement ? opener : null)
154
158
  ) {
159
+ if (!cycle.closed) cycle.focusMovedOutside = false
155
160
  return
156
161
  }
157
162
  cycle.focusMovedOutside = true
@@ -33,6 +33,12 @@ import {
33
33
  * handler takes control or focus has moved to another layer. For controlled
34
34
  * dialogs without a composed trigger, pass `restoreFocusRef` when the opener
35
35
  * cannot be inferred from the focused element.
36
+ * 5. **It keeps its own portal out of an enclosing layer's `aria-hidden`
37
+ * bookkeeping.** A non-modal dialog opened while a modal `Sheet` is alive would
38
+ * otherwise be aria-hidden one frame after it mounts — the enclosing layer's
39
+ * hide-outside observer walks late-arriving body children as background. The
40
+ * portal carries the marker that observer exempts; a modal layer opened above
41
+ * still hides it, because that path matches a different attribute.
36
42
  *
37
43
  * Use `Dialog` directly when you need its composition; use this when you want a
38
44
  * dialog that is correct by default. `size="settings"` supplies a wide, bounded
@@ -111,6 +117,20 @@ export function ModalDialog(props: ModalDialogProps) {
111
117
  if (!wasInert[index]) node.removeAttribute('inert')
112
118
  })
113
119
  })
120
+
121
+ // Kobalte's hide-outside observer walks body children added after its layer
122
+ // opened and aria-hides them one frame later, so a non-modal dialog portaled
123
+ // in during that window would land outside the accessibility tree — role
124
+ // queries never resolve it again while the enclosing layer stays open. The
125
+ // observer exempts added nodes carrying its top-layer marker (it checks the
126
+ // React Aria spelling it ported; `data-kb-top-layer` is only matched by a
127
+ // newer layer's initial walk, which must still hide this dialog).
128
+ let portal = element
129
+ while (portal.parentElement !== null && portal.parentElement !== document.body) {
130
+ portal = portal.parentElement
131
+ }
132
+ portal.setAttribute('data-react-aria-top-layer', 'true')
133
+ onCleanup(() => portal.removeAttribute('data-react-aria-top-layer'))
114
134
  })
115
135
 
116
136
  return (
@@ -1,11 +1,15 @@
1
- import { Dialog as KobalteDialog, Content as KobalteDialogContent } from '@kobalte/core/dialog'
1
+ import {
2
+ Dialog as KobalteDialog,
3
+ Content as KobalteDialogContent,
4
+ useDialogContext,
5
+ } from '@kobalte/core/dialog'
2
6
  // AlertDialog shares DialogRoot.Content; Sheet uses the stable named primitive.
3
7
  import { X } from 'lucide-solid'
4
- import type { ComponentProps, JSX } from 'solid-js'
8
+ import type { Accessor, ComponentProps, JSX } from 'solid-js'
5
9
  import { Show, splitProps } from 'solid-js'
6
10
  import { cva, type VariantProps } from '../../../lib/variants'
7
11
  import { cn } from '../../../lib/utils'
8
- import { DialogOverlay } from '../dialog/dialog'
12
+ import { createDialogFocusRestoration, DialogOverlay } from '../dialog/dialog'
9
13
 
10
14
  /**
11
15
  * Sheet.
@@ -18,6 +22,11 @@ import { DialogOverlay } from '../dialog/dialog'
18
22
  * `side` decides which edge; the panel is a full-height column for `start` and
19
23
  * `end`, and a full-width row for `top` and `bottom`. The slide-in direction
20
24
  * follows from the side, so a caller never pairs them by hand.
25
+ *
26
+ * Controlled sheets opened from outside a `SheetTrigger` restore focus to the
27
+ * element focused before opening. Pass `restoreFocusRef` when a stable external
28
+ * opener must be used instead, such as when another layer closes before the
29
+ * sheet opens.
21
30
  */
22
31
  const sheetVariants = cva(
23
32
  'bg-popover text-popover-foreground fixed z-(--z-dialog) flex flex-col gap-4 border-border shadow-lg',
@@ -39,10 +48,27 @@ const sheetVariants = cva(
39
48
  export type SheetContentProps = ComponentProps<typeof KobalteDialogContent> &
40
49
  VariantProps<typeof sheetVariants> & {
41
50
  closeButton?: JSX.Element | false
51
+ /** Supplies the stable external element to focus when the controlled sheet closes. */
52
+ restoreFocusRef?: Accessor<HTMLElement | undefined>
42
53
  }
43
54
 
44
55
  export function SheetContent(props: SheetContentProps) {
45
- const [local, rest] = splitProps(props, ['class', 'side', 'children', 'closeButton'])
56
+ const [local, rest] = splitProps(props, [
57
+ 'class',
58
+ 'side',
59
+ 'children',
60
+ 'closeButton',
61
+ 'restoreFocusRef',
62
+ 'onOpenAutoFocus',
63
+ 'onCloseAutoFocus',
64
+ ])
65
+ const dialog = useDialogContext()
66
+ const focusRestoration = createDialogFocusRestoration({
67
+ open: dialog.isOpen,
68
+ restoreFocusRef: local.restoreFocusRef,
69
+ onOpenAutoFocus: local.onOpenAutoFocus,
70
+ onCloseAutoFocus: local.onCloseAutoFocus,
71
+ })
46
72
 
47
73
  return (
48
74
  <KobalteDialog.Portal>
@@ -55,11 +81,18 @@ export function SheetContent(props: SheetContentProps) {
55
81
  local.class
56
82
  )}
57
83
  {...rest}
84
+ onOpenAutoFocus={focusRestoration.onOpenAutoFocus}
85
+ onCloseAutoFocus={focusRestoration.onCloseAutoFocus}
58
86
  >
59
87
  {local.children}
60
88
  <Show when={local.closeButton !== false}>
89
+ {/* The close action opts into the tab order explicitly. Engines that
90
+ derive tabbability from the DOM instead of the host (Playwright's
91
+ WebKit walks only explicit tabindex and form controls) would skip
92
+ an implicitly-tabbable button and jump past the sheet's end. */}
61
93
  <KobalteDialog.CloseButton
62
94
  aria-label="Close"
95
+ tabindex="0"
63
96
  class="absolute top-3.5 end-3.5 rounded-md p-1 text-muted-foreground transition-colors ease-out outline-none hover:bg-surface-hover hover:text-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-primary-subtle"
64
97
  >
65
98
  {local.closeButton ?? <X class="size-4" />}
@@ -34,12 +34,14 @@ export const toggleVariants = cva(
34
34
  md: `${controlSize.md} [&_svg]:size-4`,
35
35
  lg: `${controlSize.lg} [&_svg]:size-4`,
36
36
  xl: `${controlSize.xl} [&_svg]:size-5`,
37
+ '2xl': `${controlSize['2xl']} [&_svg]:size-6`,
37
38
  'icon-2xs': 'size-control-2xs p-0 [&_svg]:size-3',
38
39
  'icon-xs': 'size-control-xs p-0 [&_svg]:size-3.5',
39
40
  'icon-sm': 'size-control-sm p-0 [&_svg]:size-3.5',
40
41
  'icon-md': 'size-control-md p-0 [&_svg]:size-4',
41
42
  'icon-lg': 'size-control-lg p-0 [&_svg]:size-4',
42
43
  'icon-xl': 'size-control-xl p-0 [&_svg]:size-5',
44
+ 'icon-2xl': 'size-control-2xl p-0 [&_svg]:size-6',
43
45
  },
44
46
  },
45
47
  defaultVariants: { variant: 'default', size: 'md' },
@@ -54,12 +54,29 @@ export function ToggleGroup(props: ToggleGroupProps) {
54
54
  export type ToggleGroupItemProps = ComponentProps<typeof KobalteToggleGroup.Item> &
55
55
  VariantProps<typeof toggleVariants>
56
56
 
57
+ function invokeEventHandler(handler: unknown, event: Event) {
58
+ if (Array.isArray(handler)) {
59
+ const [callback, data] = handler as [(data: unknown, event: Event) => void, unknown]
60
+ callback(data, event)
61
+ } else if (typeof handler === 'function') {
62
+ ;(handler as (event: Event) => void)(event)
63
+ }
64
+ }
65
+
57
66
  export function ToggleGroupItem(props: ToggleGroupItemProps) {
58
- const [local, rest] = splitProps(props, ['class', 'variant', 'size'])
67
+ const [local, rest] = splitProps(props, ['class', 'variant', 'size', 'onKeyDown'])
59
68
 
60
69
  return (
61
70
  <KobalteToggleGroup.Item
62
71
  class={cn(toggleVariants({ variant: local.variant, size: local.size }), local.class)}
72
+ onKeyDown={(event) => {
73
+ invokeEventHandler(local.onKeyDown, event)
74
+ // The group root's selection collection clears the selection and marks
75
+ // Escape handled, which starves an enclosing dialog or drawer of the
76
+ // Escape it needs to dismiss. Stopping at the focused item is the only
77
+ // point before that handler, so pass Escape through from here.
78
+ if (event.key === 'Escape') event.stopPropagation()
79
+ }}
63
80
  {...rest}
64
81
  />
65
82
  )
@@ -480,6 +480,11 @@ export const densityTokens: TokenDefinition[] = [
480
480
  kind: 'dimension',
481
481
  description: '40px — a hero control in an empty state.',
482
482
  },
483
+ {
484
+ name: 'control-height-2xl',
485
+ kind: 'dimension',
486
+ description: '48px — a stable touch target, unchanged by compact density.',
487
+ },
483
488
  {
484
489
  name: 'control-padding-2xs',
485
490
  kind: 'dimension',
@@ -510,6 +515,11 @@ export const densityTokens: TokenDefinition[] = [
510
515
  kind: 'dimension',
511
516
  description: 'Inline padding that pairs with the 40px control.',
512
517
  },
518
+ {
519
+ name: 'control-padding-2xl',
520
+ kind: 'dimension',
521
+ description: 'Inline padding that pairs with the stable 48px touch target.',
522
+ },
513
523
  { name: 'row-height-sm', kind: 'dimension', description: '28px — a dense list row.' },
514
524
  { name: 'row-height-md', kind: 'dimension', description: '32px — the default list row.' },
515
525
  { name: 'row-height-lg', kind: 'dimension', description: '36px — a two-line list row.' },
@@ -36,6 +36,7 @@ export const cn: CnFunction = createCn({
36
36
  'control-md',
37
37
  'control-lg',
38
38
  'control-xl',
39
+ 'control-2xl',
39
40
  'row-sm',
40
41
  'row-md',
41
42
  'row-lg',
@@ -61,6 +62,7 @@ export const cn: CnFunction = createCn({
61
62
  'control-md',
62
63
  'control-lg',
63
64
  'control-xl',
65
+ 'control-2xl',
64
66
  'rail-item',
65
67
  ],
66
68
  },
@@ -11,7 +11,7 @@
11
11
  * rather than inventing synonyms:
12
12
  *
13
13
  * variant what the control *is for* — primary, secondary, destructive, …
14
- * size how much room it takes — 2xs, xs, sm, md, lg, xl
14
+ * size how much room it takes — 2xs, xs, sm, md, lg, xl, 2xl
15
15
  * tone what it *communicates* — neutral, success, warning, …
16
16
  *
17
17
  * A component that needs a fourth axis is usually two components.
@@ -32,6 +32,8 @@ export const controlSize = {
32
32
  md: 'h-control-md px-control-md text-sm gap-2',
33
33
  lg: 'h-control-lg px-control-lg text-sm gap-2',
34
34
  xl: 'h-control-xl px-control-xl text-base gap-2.5',
35
+ /** A stable 48px touch target; compact density intentionally leaves it alone. */
36
+ '2xl': 'h-control-2xl px-control-2xl text-lg gap-3',
35
37
  } as const
36
38
 
37
39
  /** Square counterparts for icon-only controls: the height, with equal width. */
@@ -42,9 +44,10 @@ export const controlSizeIcon = {
42
44
  md: 'size-control-md p-0',
43
45
  lg: 'size-control-lg p-0',
44
46
  xl: 'size-control-xl p-0',
47
+ '2xl': 'size-control-2xl p-0',
45
48
  } as const
46
49
 
47
- export const controlSizes = ['2xs', 'xs', 'sm', 'md', 'lg', 'xl'] as const
50
+ export const controlSizes = ['2xs', 'xs', 'sm', 'md', 'lg', 'xl', '2xl'] as const
48
51
  export type ControlSize = (typeof controlSizes)[number]
49
52
 
50
53
  /**