ajo-ui 0.1.0
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/LICENSE +15 -0
- package/README.md +126 -0
- package/dist/accordion.js +130 -0
- package/dist/avatar.js +54 -0
- package/dist/calendar.js +2 -0
- package/dist/carousel.js +239 -0
- package/dist/chart.js +790 -0
- package/dist/checkbox-group.js +70 -0
- package/dist/checkbox.js +77 -0
- package/dist/chunks/bar-CVafh6C1.js +99 -0
- package/dist/chunks/calendar-q8jZ8Cxr.js +1923 -0
- package/dist/chunks/collection-DtRB63U4.js +111 -0
- package/dist/chunks/data-table-DpkWv4y4.js +1039 -0
- package/dist/chunks/menu-B45IyHHC.js +704 -0
- package/dist/chunks/native-BJdhd9XJ.js +20 -0
- package/dist/chunks/popup-C8Bb3l_g.js +459 -0
- package/dist/chunks/popup-surface-BDCgtVU0.js +18 -0
- package/dist/chunks/position-D6_i_SRn.js +434 -0
- package/dist/chunks/virtual-list-B7hjGkjk.js +413 -0
- package/dist/collapsible.js +106 -0
- package/dist/command.js +263 -0
- package/dist/context-menu.js +112 -0
- package/dist/data-table.js +5 -0
- package/dist/dialog.js +207 -0
- package/dist/direction.js +22 -0
- package/dist/drawer.js +139 -0
- package/dist/field.js +26 -0
- package/dist/index.js +38 -0
- package/dist/input-date.js +994 -0
- package/dist/input-group.js +52 -0
- package/dist/input-otp.js +179 -0
- package/dist/menu.js +2 -0
- package/dist/menubar.js +236 -0
- package/dist/message-scroller.js +446 -0
- package/dist/navigation-menu.js +330 -0
- package/dist/popover.js +307 -0
- package/dist/progress.js +39 -0
- package/dist/radio-group.js +107 -0
- package/dist/resizable.js +172 -0
- package/dist/select.js +961 -0
- package/dist/sidebar.js +343 -0
- package/dist/slider.js +259 -0
- package/dist/switch.js +53 -0
- package/dist/tabs.js +182 -0
- package/dist/toast.js +492 -0
- package/dist/toggle-group.js +111 -0
- package/dist/toggle.js +52 -0
- package/dist/toolbar.js +127 -0
- package/dist/tooltip.js +196 -0
- package/dist/utils.js +104 -0
- package/dist/virtual-list.js +2 -0
- package/package.json +250 -0
- package/src/accordion.tsx +261 -0
- package/src/availability.ts +261 -0
- package/src/avatar.tsx +99 -0
- package/src/bar.ts +156 -0
- package/src/calendar.tsx +1441 -0
- package/src/carousel.tsx +424 -0
- package/src/chart.tsx +1194 -0
- package/src/checkbox-group.tsx +132 -0
- package/src/checkbox.tsx +130 -0
- package/src/collapsible.tsx +188 -0
- package/src/collection.ts +154 -0
- package/src/command.tsx +511 -0
- package/src/context-menu.tsx +233 -0
- package/src/data-table-contract.ts +143 -0
- package/src/data-table-model.ts +760 -0
- package/src/data-table.tsx +475 -0
- package/src/dialog.tsx +393 -0
- package/src/direction.tsx +45 -0
- package/src/drawer.tsx +251 -0
- package/src/field.tsx +61 -0
- package/src/index.ts +37 -0
- package/src/input-date.tsx +1539 -0
- package/src/input-group.tsx +142 -0
- package/src/input-otp.tsx +324 -0
- package/src/menu-cluster.ts +124 -0
- package/src/menu.tsx +1095 -0
- package/src/menubar.tsx +459 -0
- package/src/message-scroller.tsx +732 -0
- package/src/native.ts +26 -0
- package/src/navigation-menu.tsx +578 -0
- package/src/popover.tsx +519 -0
- package/src/popup-surface.tsx +31 -0
- package/src/popup.ts +569 -0
- package/src/position.ts +523 -0
- package/src/progress.tsx +70 -0
- package/src/radio-group.tsx +186 -0
- package/src/resizable.tsx +310 -0
- package/src/segments.ts +922 -0
- package/src/select.tsx +1501 -0
- package/src/sidebar.tsx +683 -0
- package/src/slider.tsx +424 -0
- package/src/switch.tsx +104 -0
- package/src/tabs.tsx +314 -0
- package/src/toast.tsx +923 -0
- package/src/toggle-group.tsx +249 -0
- package/src/toggle.tsx +91 -0
- package/src/toolbar.tsx +212 -0
- package/src/tooltip.tsx +359 -0
- package/src/utils.ts +204 -0
- package/src/virtual-list.tsx +205 -0
- package/src/virtual.ts +385 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import type { IntrinsicElements, Stateful, Stateless, WithChildren } from 'ajo'
|
|
2
|
+
import { listen, roving, selection, statefulRootAttrs as rootAttrs } from 'ajo-cloves'
|
|
3
|
+
import { context } from 'ajo/context'
|
|
4
|
+
import type { FixedArgs, OmitArg } from './utils'
|
|
5
|
+
import { flag, strings } from './utils'
|
|
6
|
+
import { Toggle, type ToggleArgs } from './toggle'
|
|
7
|
+
|
|
8
|
+
/** Selection cardinality of a toggle group. */
|
|
9
|
+
export type ToggleGroupType = 'multiple' | 'single'
|
|
10
|
+
/** Layout and keyboard-navigation axis of a toggle group. */
|
|
11
|
+
export type ToggleGroupOrientation = 'horizontal' | 'vertical'
|
|
12
|
+
|
|
13
|
+
type ToggleGroupSharedArgs = WithChildren<OmitArg<IntrinsicElements['div'], 'defaultValue' | 'type' | 'value'> & {
|
|
14
|
+
/** Disable every item in the group. */
|
|
15
|
+
disabled?: boolean
|
|
16
|
+
/** Allow arrow-key focus to wrap at the ends. */
|
|
17
|
+
loop?: boolean
|
|
18
|
+
/** Layout orientation. */
|
|
19
|
+
orientation?: ToggleGroupOrientation
|
|
20
|
+
/** Shared item size marker. */
|
|
21
|
+
size?: string
|
|
22
|
+
/** Spacing marker shared with items. */
|
|
23
|
+
spacing?: number
|
|
24
|
+
/** Shared item variant marker. */
|
|
25
|
+
variant?: string
|
|
26
|
+
}>
|
|
27
|
+
|
|
28
|
+
/** Props for a toggle group that selects at most one value. */
|
|
29
|
+
export type ToggleGroupSingleArgs = ToggleGroupSharedArgs & {
|
|
30
|
+
type?: 'single'
|
|
31
|
+
/** Controlled selected value. */
|
|
32
|
+
value?: string
|
|
33
|
+
/** Initial selected value for uncontrolled usage. */
|
|
34
|
+
defaultValue?: string
|
|
35
|
+
/** Called whenever the selected value changes. */
|
|
36
|
+
onValueChange?: (value: string, event: Event) => void
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Props for a toggle group that selects multiple values. */
|
|
40
|
+
export type ToggleGroupMultipleArgs = ToggleGroupSharedArgs & {
|
|
41
|
+
type: 'multiple'
|
|
42
|
+
/** Controlled selected values. */
|
|
43
|
+
value?: string[]
|
|
44
|
+
/** Initial selected values for uncontrolled usage. */
|
|
45
|
+
defaultValue?: string[]
|
|
46
|
+
/** Called whenever the selected values change. */
|
|
47
|
+
onValueChange?: (value: string[], event: Event) => void
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Props accepted by either single- or multiple-selection toggle groups. */
|
|
51
|
+
export type ToggleGroupArgs = ToggleGroupSingleArgs | ToggleGroupMultipleArgs
|
|
52
|
+
|
|
53
|
+
/** Props for a toggle button whose pressed state belongs to its group. */
|
|
54
|
+
export type ToggleGroupItemArgs = OmitArg<ToggleArgs, 'defaultPressed' | 'onPressedChange' | 'pressed'> & {
|
|
55
|
+
/** Item value used by the parent toggle group. */
|
|
56
|
+
value: string
|
|
57
|
+
/** Item size marker. */
|
|
58
|
+
size?: string
|
|
59
|
+
/** Item variant marker. */
|
|
60
|
+
variant?: string
|
|
61
|
+
} & FixedArgs<'defaultPressed' | 'onPressedChange' | 'pressed'>
|
|
62
|
+
|
|
63
|
+
/** State and styling markers exposed to descendant toggle-group items. */
|
|
64
|
+
export type ToggleGroupContextValue = {
|
|
65
|
+
disabled?: boolean
|
|
66
|
+
orientation: ToggleGroupOrientation
|
|
67
|
+
pressed: (value: string) => boolean
|
|
68
|
+
size?: string
|
|
69
|
+
spacing: number
|
|
70
|
+
toggle: (value: string, pressed: boolean, event: Event) => void
|
|
71
|
+
type: ToggleGroupType
|
|
72
|
+
variant?: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type ToggleGroupRootArgs = WithChildren<{
|
|
76
|
+
defaultValue?: string | string[]
|
|
77
|
+
disabled?: boolean
|
|
78
|
+
loop?: boolean
|
|
79
|
+
onValueChange?: ((value: string, event: Event) => void) | ((value: string[], event: Event) => void)
|
|
80
|
+
orientation: ToggleGroupOrientation
|
|
81
|
+
size?: string
|
|
82
|
+
spacing: number
|
|
83
|
+
type: ToggleGroupType
|
|
84
|
+
value?: string | string[]
|
|
85
|
+
variant?: string
|
|
86
|
+
}>
|
|
87
|
+
|
|
88
|
+
/** Composition context exposing ToggleGroup state and styling markers to descendant items. */
|
|
89
|
+
export const ToggleGroupContext = context<ToggleGroupContextValue | null>(null)
|
|
90
|
+
|
|
91
|
+
const selected = (type: ToggleGroupType, value: unknown) =>
|
|
92
|
+
type === 'multiple'
|
|
93
|
+
? strings(value)
|
|
94
|
+
: value == null || value === '' ? [] : [String(value)]
|
|
95
|
+
|
|
96
|
+
const isButton = (value: EventTarget | null): value is HTMLButtonElement =>
|
|
97
|
+
value instanceof HTMLButtonElement && value.dataset.slot === 'toggle-group-item'
|
|
98
|
+
|
|
99
|
+
const focusableItems = (root: HTMLElement) =>
|
|
100
|
+
Array.from(root.querySelectorAll<HTMLButtonElement>('button[data-slot="toggle-group-item"]'))
|
|
101
|
+
.filter(button => !button.disabled)
|
|
102
|
+
|
|
103
|
+
const ToggleGroupRoot: Stateful<ToggleGroupRootArgs> = function* ({ defaultValue, type: initialType }) {
|
|
104
|
+
let disabled = false
|
|
105
|
+
let loop = true
|
|
106
|
+
let onValueChange: ToggleGroupRootArgs['onValueChange']
|
|
107
|
+
let orientation: ToggleGroupOrientation = 'horizontal'
|
|
108
|
+
let type: ToggleGroupType = initialType
|
|
109
|
+
const sel = selection(this, {
|
|
110
|
+
multiple: () => type === 'multiple',
|
|
111
|
+
fallback: selected(initialType, defaultValue),
|
|
112
|
+
onChange: (next, event) => {
|
|
113
|
+
if (type === 'multiple') {
|
|
114
|
+
(onValueChange as ((value: string[], event: Event) => void) | undefined)?.(next, event as Event)
|
|
115
|
+
} else {
|
|
116
|
+
(onValueChange as ((value: string, event: Event) => void) | undefined)?.(next[0] ?? '', event as Event)
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
const pressed = (value: string) =>
|
|
122
|
+
sel.has(value)
|
|
123
|
+
|
|
124
|
+
const change = (value: string, _nextPressed: boolean, event: Event) => {
|
|
125
|
+
if (disabled) return
|
|
126
|
+
sel.toggle(value, event)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const nav = roving(this, {
|
|
130
|
+
items: () => focusableItems(this),
|
|
131
|
+
orientation: () => orientation,
|
|
132
|
+
loop: () => loop,
|
|
133
|
+
onMove: target => target.focus(),
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
listen(this, 'keydown', (event: KeyboardEvent) => {
|
|
137
|
+
if (!isButton(event.target)) return
|
|
138
|
+
// Inside a Toolbar the group's items join the toolbar's roving instead
|
|
139
|
+
// (Base UI model): one tab stop, arrows traverse into and out of the
|
|
140
|
+
// group without double-roving. The toolbar must be within the group's
|
|
141
|
+
// own layer: a group inside popover/dialog content that is a DOM
|
|
142
|
+
// descendant of a toolbar keeps its own roving (the toolbar's control
|
|
143
|
+
// row excludes it).
|
|
144
|
+
if (this.closest('[role="toolbar"], [popover], dialog')?.matches('[role="toolbar"]')) return
|
|
145
|
+
nav.handle(event)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
for (const args of this) {
|
|
149
|
+
type = args.type
|
|
150
|
+
orientation = args.orientation
|
|
151
|
+
disabled = Boolean(args.disabled)
|
|
152
|
+
loop = args.loop !== false
|
|
153
|
+
onValueChange = args.onValueChange
|
|
154
|
+
sel.sync(args.value != null ? selected(type, args.value) : undefined)
|
|
155
|
+
|
|
156
|
+
ToggleGroupContext({
|
|
157
|
+
disabled,
|
|
158
|
+
orientation,
|
|
159
|
+
pressed,
|
|
160
|
+
size: args.size,
|
|
161
|
+
spacing: args.spacing,
|
|
162
|
+
toggle: change,
|
|
163
|
+
type,
|
|
164
|
+
variant: args.variant,
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
yield <>{args.children}</>
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
/** Unstyled toggle group with selection state and roving keyboard focus. */
|
|
173
|
+
const ToggleGroup: Stateless<ToggleGroupArgs> = ({
|
|
174
|
+
children,
|
|
175
|
+
defaultValue,
|
|
176
|
+
disabled,
|
|
177
|
+
loop = true,
|
|
178
|
+
onValueChange,
|
|
179
|
+
orientation = 'horizontal',
|
|
180
|
+
role = 'group',
|
|
181
|
+
size,
|
|
182
|
+
spacing = 2,
|
|
183
|
+
type = 'single',
|
|
184
|
+
value,
|
|
185
|
+
variant,
|
|
186
|
+
...attrs
|
|
187
|
+
}) => {
|
|
188
|
+
const disabledFlag = Boolean(disabled)
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<ToggleGroupRoot
|
|
192
|
+
{...rootAttrs(attrs)}
|
|
193
|
+
defaultValue={defaultValue}
|
|
194
|
+
disabled={disabledFlag}
|
|
195
|
+
loop={loop}
|
|
196
|
+
onValueChange={onValueChange}
|
|
197
|
+
orientation={orientation}
|
|
198
|
+
size={size}
|
|
199
|
+
spacing={spacing}
|
|
200
|
+
type={type}
|
|
201
|
+
value={value}
|
|
202
|
+
variant={variant}
|
|
203
|
+
attr:aria-disabled={flag(disabledFlag)}
|
|
204
|
+
attr:aria-orientation={orientation}
|
|
205
|
+
attr:data-disabled={flag(disabledFlag)}
|
|
206
|
+
attr:data-orientation={orientation}
|
|
207
|
+
attr:data-size={size}
|
|
208
|
+
attr:data-slot="toggle-group"
|
|
209
|
+
attr:data-spacing={spacing}
|
|
210
|
+
attr:data-variant={variant}
|
|
211
|
+
attr:role={role}
|
|
212
|
+
>
|
|
213
|
+
{children}
|
|
214
|
+
</ToggleGroupRoot>
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Unstyled toggle group item wired to the nearest group. */
|
|
219
|
+
const ToggleGroupItem: Stateless<ToggleGroupItemArgs> = ({
|
|
220
|
+
disabled,
|
|
221
|
+
size,
|
|
222
|
+
value,
|
|
223
|
+
variant,
|
|
224
|
+
...attrs
|
|
225
|
+
}) => {
|
|
226
|
+
const group = ToggleGroupContext()
|
|
227
|
+
const itemValue = String(value)
|
|
228
|
+
const itemSize = size ?? group?.size
|
|
229
|
+
const itemVariant = variant ?? group?.variant
|
|
230
|
+
const disabledFlag = Boolean(disabled ?? group?.disabled)
|
|
231
|
+
const pressed = group?.pressed(itemValue) ?? false
|
|
232
|
+
|
|
233
|
+
return (
|
|
234
|
+
<Toggle
|
|
235
|
+
{...attrs}
|
|
236
|
+
data-orientation={group?.orientation ?? 'horizontal'}
|
|
237
|
+
data-size={itemSize}
|
|
238
|
+
data-slot="toggle-group-item"
|
|
239
|
+
data-spacing={group?.spacing ?? 2}
|
|
240
|
+
data-variant={itemVariant}
|
|
241
|
+
disabled={disabledFlag}
|
|
242
|
+
pressed={pressed}
|
|
243
|
+
value={itemValue}
|
|
244
|
+
onPressedChange={(next, event) => group?.toggle(itemValue, next, event)}
|
|
245
|
+
/>
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export { ToggleGroup, ToggleGroupItem }
|
package/src/toggle.tsx
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { IntrinsicElements, Stateful, Stateless, WithChildren } from 'ajo'
|
|
2
|
+
import type { OmitArg } from './utils'
|
|
3
|
+
import { callHandler, controlled, dom, statefulRootAttrs as rootAttrs } from 'ajo-cloves'
|
|
4
|
+
|
|
5
|
+
/** Props for a controlled or uncontrolled two-state toggle button. */
|
|
6
|
+
export type ToggleArgs = OmitArg<IntrinsicElements['button'], 'children'> & WithChildren<{
|
|
7
|
+
/** Controlled pressed state. */
|
|
8
|
+
pressed?: boolean
|
|
9
|
+
/** Initial pressed state for uncontrolled usage. */
|
|
10
|
+
defaultPressed?: boolean
|
|
11
|
+
/** Called when the pressed state changes. */
|
|
12
|
+
onPressedChange?: (pressed: boolean, event: Event) => void
|
|
13
|
+
}>
|
|
14
|
+
|
|
15
|
+
type ToggleRootArgs = WithChildren<{
|
|
16
|
+
defaultPressed?: boolean
|
|
17
|
+
disabled?: boolean
|
|
18
|
+
onClick?: unknown
|
|
19
|
+
onPressedChange?: ToggleArgs['onPressedChange']
|
|
20
|
+
pressed?: boolean
|
|
21
|
+
}>
|
|
22
|
+
|
|
23
|
+
const pressedAttribute = (pressed: boolean) => pressed ? 'true' : 'false'
|
|
24
|
+
const stateAttribute = (pressed: boolean) => pressed ? 'on' : 'off'
|
|
25
|
+
|
|
26
|
+
const ToggleRoot: Stateful<ToggleRootArgs, 'button'> = function* ({ defaultPressed, pressed }) {
|
|
27
|
+
let disabled = false
|
|
28
|
+
let onClick: unknown
|
|
29
|
+
let onPressedChange: ToggleArgs['onPressedChange']
|
|
30
|
+
const state = controlled<boolean>(this, {
|
|
31
|
+
fallback: Boolean(pressed ?? defaultPressed),
|
|
32
|
+
onChange: (next, event) => onPressedChange?.(next, event!),
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
for (const args of this) {
|
|
36
|
+
disabled = Boolean(args.disabled)
|
|
37
|
+
onClick = args.onClick
|
|
38
|
+
onPressedChange = args.onPressedChange
|
|
39
|
+
state.sync(args.pressed != null ? Boolean(args.pressed) : undefined)
|
|
40
|
+
|
|
41
|
+
if (dom(this)) {
|
|
42
|
+
this.dataset.state = stateAttribute(state.value)
|
|
43
|
+
this.setAttribute('aria-pressed', pressedAttribute(state.value))
|
|
44
|
+
this.onclick = event => {
|
|
45
|
+
callHandler(onClick, event)
|
|
46
|
+
if (event.defaultPrevented || disabled) return
|
|
47
|
+
|
|
48
|
+
state.set(!state.value, event)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
yield <>{args.children}</>
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
ToggleRoot.is = 'button'
|
|
57
|
+
|
|
58
|
+
/** Unstyled two-state button using aria-pressed. */
|
|
59
|
+
const Toggle: Stateless<ToggleArgs> = ({
|
|
60
|
+
children,
|
|
61
|
+
defaultPressed,
|
|
62
|
+
disabled,
|
|
63
|
+
onPressedChange,
|
|
64
|
+
pressed,
|
|
65
|
+
type = 'button',
|
|
66
|
+
'set:onclick': onClick,
|
|
67
|
+
...attrs
|
|
68
|
+
}) => {
|
|
69
|
+
const state = stateAttribute(Boolean(pressed ?? defaultPressed))
|
|
70
|
+
const disabledFlag = Boolean(disabled)
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<ToggleRoot
|
|
74
|
+
{...rootAttrs(attrs)}
|
|
75
|
+
defaultPressed={defaultPressed}
|
|
76
|
+
disabled={disabledFlag}
|
|
77
|
+
onClick={onClick}
|
|
78
|
+
onPressedChange={onPressedChange}
|
|
79
|
+
pressed={pressed}
|
|
80
|
+
attr:aria-pressed={pressedAttribute(state === 'on')}
|
|
81
|
+
attr:data-state={state}
|
|
82
|
+
attr:data-slot={attrs['data-slot'] ?? 'toggle'}
|
|
83
|
+
attr:disabled={disabledFlag || undefined}
|
|
84
|
+
attr:type={type}
|
|
85
|
+
>
|
|
86
|
+
{children}
|
|
87
|
+
</ToggleRoot>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export { Toggle }
|
package/src/toolbar.tsx
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import type { IntrinsicElements, Stateful, Stateless, WithChildren } from 'ajo'
|
|
2
|
+
import type { OmitArg } from './utils'
|
|
3
|
+
import { dom, listen, roving, statefulRootAttrs as rootAttrs } from 'ajo-cloves'
|
|
4
|
+
import { context } from 'ajo/context'
|
|
5
|
+
import { DirectionContext } from './direction'
|
|
6
|
+
|
|
7
|
+
/** Layout and keyboard-navigation axis of a toolbar. */
|
|
8
|
+
export type ToolbarOrientation = 'horizontal' | 'vertical'
|
|
9
|
+
|
|
10
|
+
/** Props for a toolbar with roving focus among descendant controls. */
|
|
11
|
+
export type ToolbarArgs = WithChildren<OmitArg<IntrinsicElements['div'], 'dir'> & {
|
|
12
|
+
/** Text direction for horizontal arrow-key navigation. Defaults to the nearest DirectionProvider. */
|
|
13
|
+
dir?: 'ltr' | 'rtl'
|
|
14
|
+
/** Allow arrow-key focus to wrap at the ends. */
|
|
15
|
+
loop?: boolean
|
|
16
|
+
/** Toolbar orientation. */
|
|
17
|
+
orientation?: ToolbarOrientation
|
|
18
|
+
}>
|
|
19
|
+
|
|
20
|
+
/** Props for a separator whose orientation follows the parent toolbar. */
|
|
21
|
+
export type ToolbarSeparatorArgs = IntrinsicElements['div']
|
|
22
|
+
|
|
23
|
+
type ToolbarRootArgs = WithChildren<{
|
|
24
|
+
dir: 'ltr' | 'rtl'
|
|
25
|
+
loop: boolean
|
|
26
|
+
orientation: ToolbarOrientation
|
|
27
|
+
}>
|
|
28
|
+
|
|
29
|
+
type ToolbarContextValue = {
|
|
30
|
+
orientation: ToolbarOrientation
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const ToolbarContext = context<ToolbarContextValue | null>(null)
|
|
34
|
+
|
|
35
|
+
const CONTROL_SELECTOR = 'button, [href], input, select, textarea, summary, [tabindex]'
|
|
36
|
+
|
|
37
|
+
// Focusable toolbar controls in DOM order (the house trigger-row filter:
|
|
38
|
+
// enabled and visible; summary joins for details-based facets), scoped to
|
|
39
|
+
// the toolbar's own layer: controls inside floating popover content opened
|
|
40
|
+
// from the toolbar (menus, selects) or inside a composed <dialog> never join
|
|
41
|
+
// the row. Visibility uses checkVisibility because closed-details content is
|
|
42
|
+
// content-visibility-hidden in Chromium (offsetParent stays non-null there);
|
|
43
|
+
// the offsetParent fallback covers engines without it.
|
|
44
|
+
const controls = (root: HTMLElement) => {
|
|
45
|
+
const layer = root.closest('[popover], dialog')
|
|
46
|
+
return Array.from(root.querySelectorAll<HTMLElement>(CONTROL_SELECTOR)).filter(control =>
|
|
47
|
+
!(control as HTMLButtonElement).disabled &&
|
|
48
|
+
(control.checkVisibility?.() ?? control.offsetParent !== null) &&
|
|
49
|
+
control.closest('[popover], dialog') === layer,
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const NON_TEXT_INPUTS = new Set(['button', 'checkbox', 'color', 'file', 'hidden', 'image', 'radio', 'range', 'reset', 'submit'])
|
|
54
|
+
|
|
55
|
+
// Text-entry controls own their caret keys (APG): roving must not steal them.
|
|
56
|
+
const textEntry = (element: HTMLElement) =>
|
|
57
|
+
element instanceof HTMLTextAreaElement ||
|
|
58
|
+
element.isContentEditable ||
|
|
59
|
+
(element instanceof HTMLInputElement && !NON_TEXT_INPUTS.has(element.type))
|
|
60
|
+
|
|
61
|
+
// A text control releases an arrow key only when caret handling no longer
|
|
62
|
+
// needs it: a collapsed caret sitting at the matching edge. Home/End and
|
|
63
|
+
// vertical arrows always stay with the caret. The escape edge follows the
|
|
64
|
+
// control's own resolved direction (a dir="auto" or forced-ltr input inside
|
|
65
|
+
// an rtl toolbar keeps its productive caret keys); the toolbar dir only
|
|
66
|
+
// steers roving movement.
|
|
67
|
+
const caretEscapes = (target: HTMLElement, event: KeyboardEvent) => {
|
|
68
|
+
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return false
|
|
69
|
+
if (!(target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement)) return false
|
|
70
|
+
const caret = target.selectionStart
|
|
71
|
+
if (caret == null || caret !== target.selectionEnd) return false
|
|
72
|
+
const rtl = getComputedStyle(target).direction === 'rtl'
|
|
73
|
+
return (event.key === 'ArrowLeft') !== rtl ? caret === 0 : caret === target.value.length
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const ToolbarRoot: Stateful<ToolbarRootArgs> = function* () {
|
|
77
|
+
let active: HTMLElement | null = null
|
|
78
|
+
let dir: 'ltr' | 'rtl' = 'ltr'
|
|
79
|
+
let loop = true
|
|
80
|
+
let orientation: ToolbarOrientation = 'horizontal'
|
|
81
|
+
let queued = false
|
|
82
|
+
|
|
83
|
+
// Single tab stop over the live control row (roving tabindex): the active
|
|
84
|
+
// control keeps tabindex 0, the rest get -1; focusing any control moves the
|
|
85
|
+
// stop, and the stop is repaired to the first control in DOM order when the
|
|
86
|
+
// active one unmounts, hides, or disables.
|
|
87
|
+
// Toolbar keeps this separate from bar(): it navigates arbitrary controls,
|
|
88
|
+
// not an open-value trigger row with follow policy.
|
|
89
|
+
const apply = () => {
|
|
90
|
+
if (!dom(this)) return
|
|
91
|
+
const row = controls(this)
|
|
92
|
+
if (!active || !row.includes(active)) active = row[0] ?? null
|
|
93
|
+
for (const control of row) {
|
|
94
|
+
const value = control === active ? '0' : '-1'
|
|
95
|
+
if (control.getAttribute('tabindex') !== value) control.setAttribute('tabindex', value)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const sync = () => {
|
|
100
|
+
if (queued) return
|
|
101
|
+
queued = true
|
|
102
|
+
queueMicrotask(() => {
|
|
103
|
+
queued = false
|
|
104
|
+
apply()
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const nav = roving(this, {
|
|
109
|
+
items: () => controls(this),
|
|
110
|
+
orientation: () => orientation,
|
|
111
|
+
dir: () => dir,
|
|
112
|
+
loop: () => loop,
|
|
113
|
+
onMove: target => target.focus(),
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
listen(this, 'focusin', (event: FocusEvent) => {
|
|
117
|
+
const target = event.target as HTMLElement | null
|
|
118
|
+
if (!target || !controls(this).includes(target)) return
|
|
119
|
+
active = target
|
|
120
|
+
apply()
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
listen(this, 'keydown', (event: KeyboardEvent) => {
|
|
124
|
+
const target = event.target as HTMLElement | null
|
|
125
|
+
// A composed widget that already consumed the key (open menu, slider,
|
|
126
|
+
// a toggle group outside a toolbar) wins over toolbar roving, and so
|
|
127
|
+
// does an active IME composition (keyCode 229 covers engines that
|
|
128
|
+
// fire the arrow before isComposing settles): an arrow at a caret
|
|
129
|
+
// edge mid-composition must not move focus and abort the composition.
|
|
130
|
+
if (!target || event.defaultPrevented || event.isComposing || event.keyCode === 229) return
|
|
131
|
+
if (!controls(this).includes(target)) return
|
|
132
|
+
if (textEntry(target) && !caretEscapes(target, event)) return
|
|
133
|
+
nav.handle(event)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
if (dom(this)) {
|
|
137
|
+
// Controls can mount/unmount or flip disabled/hidden from a nested
|
|
138
|
+
// component's own render without this component re-rendering; watch the
|
|
139
|
+
// subtree and repair the tab stop. The equality guard in apply() keeps
|
|
140
|
+
// its own tabindex writes from re-firing the observer.
|
|
141
|
+
const observer = new MutationObserver(sync)
|
|
142
|
+
observer.observe(this, {
|
|
143
|
+
attributeFilter: ['disabled', 'hidden', 'tabindex'],
|
|
144
|
+
attributes: true,
|
|
145
|
+
childList: true,
|
|
146
|
+
subtree: true,
|
|
147
|
+
})
|
|
148
|
+
this.signal.addEventListener('abort', () => observer.disconnect())
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
for (const args of this) {
|
|
152
|
+
dir = args.dir
|
|
153
|
+
loop = args.loop
|
|
154
|
+
orientation = args.orientation
|
|
155
|
+
|
|
156
|
+
ToolbarContext({ orientation })
|
|
157
|
+
|
|
158
|
+
sync()
|
|
159
|
+
yield <>{args.children}</>
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
/** Unstyled toolbar: arbitrary controls behind a single tab stop with dir-aware arrow-key roving (APG toolbar pattern). Button, ToggleGroup, Select, and Input compose inside; nested ToggleGroup items join the toolbar roving. */
|
|
165
|
+
const Toolbar: Stateless<ToolbarArgs> = ({
|
|
166
|
+
children,
|
|
167
|
+
'data-slot': slot = 'toolbar',
|
|
168
|
+
dir,
|
|
169
|
+
loop = true,
|
|
170
|
+
orientation = 'horizontal',
|
|
171
|
+
role = 'toolbar',
|
|
172
|
+
...attrs
|
|
173
|
+
}) => {
|
|
174
|
+
const resolvedDir = dir ?? DirectionContext()
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<ToolbarRoot
|
|
178
|
+
{...rootAttrs(attrs)}
|
|
179
|
+
dir={resolvedDir}
|
|
180
|
+
loop={loop}
|
|
181
|
+
orientation={orientation}
|
|
182
|
+
attr:aria-orientation={orientation}
|
|
183
|
+
attr:data-orientation={orientation}
|
|
184
|
+
attr:data-slot={slot}
|
|
185
|
+
attr:dir={resolvedDir}
|
|
186
|
+
attr:role={role}
|
|
187
|
+
>
|
|
188
|
+
{children}
|
|
189
|
+
</ToolbarRoot>
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Unstyled separator between toolbar groups, oriented across the toolbar axis. */
|
|
194
|
+
const ToolbarSeparator: Stateless<ToolbarSeparatorArgs> = ({
|
|
195
|
+
role = 'separator',
|
|
196
|
+
...attrs
|
|
197
|
+
}) => {
|
|
198
|
+
const toolbar = ToolbarContext()
|
|
199
|
+
const orientation = (toolbar?.orientation ?? 'horizontal') === 'horizontal' ? 'vertical' : 'horizontal'
|
|
200
|
+
|
|
201
|
+
return (
|
|
202
|
+
<div
|
|
203
|
+
{...attrs}
|
|
204
|
+
aria-orientation={orientation}
|
|
205
|
+
data-orientation={orientation}
|
|
206
|
+
data-slot="toolbar-separator"
|
|
207
|
+
role={role}
|
|
208
|
+
/>
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export { Toolbar, ToolbarSeparator }
|