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,132 @@
|
|
|
1
|
+
import type { IntrinsicElements, Stateful, Stateless, WithChildren } from 'ajo'
|
|
2
|
+
import { 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 { Checkbox, type CheckboxArgs } from './checkbox'
|
|
7
|
+
|
|
8
|
+
/** Arguments for a group that coordinates checkbox values. */
|
|
9
|
+
export type CheckboxGroupArgs = WithChildren<OmitArg<IntrinsicElements['div'], 'defaultValue' | 'value'> & {
|
|
10
|
+
/** Controlled checked values. */
|
|
11
|
+
value?: string[]
|
|
12
|
+
/** Initial checked values for uncontrolled usage. */
|
|
13
|
+
defaultValue?: string[]
|
|
14
|
+
/** Called whenever the checked values change. */
|
|
15
|
+
onValueChange?: (value: string[], event: Event) => void
|
|
16
|
+
/** Disable every item in the group. */
|
|
17
|
+
disabled?: boolean
|
|
18
|
+
/** Shared checkbox input name. */
|
|
19
|
+
name?: string
|
|
20
|
+
}>
|
|
21
|
+
|
|
22
|
+
/** Arguments for one Checkbox registered with a CheckboxGroup. */
|
|
23
|
+
export type CheckboxGroupItemArgs = OmitArg<CheckboxArgs, 'checked' | 'defaultChecked' | 'onCheckedChange'> & {
|
|
24
|
+
/** Item value used by the parent checkbox group. */
|
|
25
|
+
value: string
|
|
26
|
+
} & FixedArgs<'checked' | 'defaultChecked' | 'onCheckedChange'>
|
|
27
|
+
|
|
28
|
+
type CheckboxGroupContextValue = {
|
|
29
|
+
checked: (value: string) => boolean
|
|
30
|
+
disabled?: boolean
|
|
31
|
+
name?: string
|
|
32
|
+
toggle: (value: string, checked: boolean, event: Event) => void
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type CheckboxGroupRootArgs = WithChildren<{
|
|
36
|
+
defaultValue?: string[]
|
|
37
|
+
disabled?: boolean
|
|
38
|
+
name?: string
|
|
39
|
+
onValueChange?: (value: string[], event: Event) => void
|
|
40
|
+
value?: string[]
|
|
41
|
+
}>
|
|
42
|
+
|
|
43
|
+
const CheckboxGroupContext = context<CheckboxGroupContextValue | null>(null)
|
|
44
|
+
|
|
45
|
+
const CheckboxGroupRoot: Stateful<CheckboxGroupRootArgs> = function* ({ defaultValue }) {
|
|
46
|
+
let disabled = false
|
|
47
|
+
let name: string | undefined
|
|
48
|
+
let onValueChange: CheckboxGroupRootArgs['onValueChange']
|
|
49
|
+
const sel = selection(this, {
|
|
50
|
+
multiple: () => true,
|
|
51
|
+
fallback: strings(defaultValue),
|
|
52
|
+
onChange: (next, event) => onValueChange?.(next, event as Event),
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const checked = (value: string) =>
|
|
56
|
+
sel.has(value)
|
|
57
|
+
|
|
58
|
+
const change = (value: string, _checked: boolean, event: Event) => {
|
|
59
|
+
if (disabled) return
|
|
60
|
+
sel.toggle(value, event)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const args of this) {
|
|
64
|
+
disabled = Boolean(args.disabled)
|
|
65
|
+
name = args.name
|
|
66
|
+
onValueChange = args.onValueChange
|
|
67
|
+
sel.sync(args.value != null ? strings(args.value) : undefined)
|
|
68
|
+
|
|
69
|
+
CheckboxGroupContext({ checked, disabled, name, toggle: change })
|
|
70
|
+
|
|
71
|
+
yield <>{args.children}</>
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
/** Unstyled checkbox group cascading checked values, name, and disabled state to its items. */
|
|
77
|
+
const CheckboxGroup: Stateless<CheckboxGroupArgs> = ({
|
|
78
|
+
children,
|
|
79
|
+
defaultValue,
|
|
80
|
+
disabled,
|
|
81
|
+
name,
|
|
82
|
+
onValueChange,
|
|
83
|
+
role = 'group',
|
|
84
|
+
value,
|
|
85
|
+
...attrs
|
|
86
|
+
}) => {
|
|
87
|
+
const disabledFlag = Boolean(disabled)
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<CheckboxGroupRoot
|
|
91
|
+
{...rootAttrs(attrs)}
|
|
92
|
+
defaultValue={defaultValue}
|
|
93
|
+
disabled={disabledFlag}
|
|
94
|
+
name={name}
|
|
95
|
+
onValueChange={onValueChange}
|
|
96
|
+
value={value}
|
|
97
|
+
attr:aria-disabled={flag(disabledFlag)}
|
|
98
|
+
attr:data-disabled={flag(disabledFlag)}
|
|
99
|
+
attr:data-slot="checkbox-group"
|
|
100
|
+
attr:role={role}
|
|
101
|
+
>
|
|
102
|
+
{children}
|
|
103
|
+
</CheckboxGroupRoot>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Unstyled native checkbox item wired to the nearest group. */
|
|
108
|
+
const CheckboxGroupItem: Stateless<CheckboxGroupItemArgs> = ({
|
|
109
|
+
disabled,
|
|
110
|
+
name,
|
|
111
|
+
value,
|
|
112
|
+
...attrs
|
|
113
|
+
}) => {
|
|
114
|
+
const group = CheckboxGroupContext()
|
|
115
|
+
const item = String(value)
|
|
116
|
+
const checked = group?.checked(item) ?? false
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<Checkbox
|
|
120
|
+
{...attrs}
|
|
121
|
+
checked={checked}
|
|
122
|
+
data-slot="checkbox-group-item"
|
|
123
|
+
disabled={disabled ?? group?.disabled}
|
|
124
|
+
name={name ?? group?.name}
|
|
125
|
+
set:checked={checked}
|
|
126
|
+
value={item}
|
|
127
|
+
onCheckedChange={(next, event) => group?.toggle(item, next, event)}
|
|
128
|
+
/>
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export { CheckboxGroup, CheckboxGroupItem }
|
package/src/checkbox.tsx
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { IntrinsicElements, Stateful, Stateless } from 'ajo'
|
|
2
|
+
import { callHandler, callRef, dom } from 'ajo-cloves'
|
|
3
|
+
import { ariaChecked, bool, flag, syncCheckedState, type CheckedState } from './utils'
|
|
4
|
+
|
|
5
|
+
/** Visual and ARIA state exposed by a Checkbox. */
|
|
6
|
+
export type CheckboxState = CheckedState
|
|
7
|
+
|
|
8
|
+
/** Arguments for the native checkbox and its visual companion. */
|
|
9
|
+
export type CheckboxArgs = IntrinsicElements['input'] & {
|
|
10
|
+
/** Classes for the checked indicator element. */
|
|
11
|
+
checkedIndicatorClass?: string
|
|
12
|
+
/** Classes for the native input element. */
|
|
13
|
+
inputClass?: string
|
|
14
|
+
/** Classes for the indeterminate indicator element. */
|
|
15
|
+
indeterminateIndicatorClass?: string
|
|
16
|
+
/** Called when the native checkbox state changes. */
|
|
17
|
+
onCheckedChange?: (checked: boolean, event: Event) => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type CheckboxRootArgs = {
|
|
21
|
+
checkedIndicatorClass?: string
|
|
22
|
+
inputAttrs: IntrinsicElements['input']
|
|
23
|
+
inputClass?: string
|
|
24
|
+
indeterminateIndicatorClass?: string
|
|
25
|
+
onCheckedChange?: CheckboxArgs['onCheckedChange']
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const stateFromAttrs = (attrs: IntrinsicElements['input']): CheckboxState => {
|
|
29
|
+
if (bool(attrs['set:indeterminate'])) return 'indeterminate'
|
|
30
|
+
return bool(attrs['set:checked'] ?? attrs.checked ?? attrs.defaultChecked) ? 'checked' : 'unchecked'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const CheckboxRoot: Stateful<CheckboxRootArgs, 'span'> = function* () {
|
|
34
|
+
let input: HTMLInputElement | null = null
|
|
35
|
+
|
|
36
|
+
const sync = () => {
|
|
37
|
+
if (input) syncCheckedState(input, dom(this) ? this : null)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
for (const {
|
|
41
|
+
checkedIndicatorClass,
|
|
42
|
+
inputAttrs,
|
|
43
|
+
inputClass,
|
|
44
|
+
indeterminateIndicatorClass,
|
|
45
|
+
onCheckedChange,
|
|
46
|
+
} of this) {
|
|
47
|
+
const {
|
|
48
|
+
class: _class,
|
|
49
|
+
ref,
|
|
50
|
+
'set:onchange': onChange,
|
|
51
|
+
'set:oninput': onInput,
|
|
52
|
+
type: _type,
|
|
53
|
+
...attrs
|
|
54
|
+
} = inputAttrs
|
|
55
|
+
const state = stateFromAttrs(inputAttrs)
|
|
56
|
+
|
|
57
|
+
if (dom(this)) this.dataset.state = state
|
|
58
|
+
|
|
59
|
+
yield (
|
|
60
|
+
<>
|
|
61
|
+
<input
|
|
62
|
+
{...attrs}
|
|
63
|
+
aria-checked={ariaChecked(state)}
|
|
64
|
+
class={inputClass}
|
|
65
|
+
data-slot="checkbox-input"
|
|
66
|
+
data-state={state}
|
|
67
|
+
ref={element => {
|
|
68
|
+
input = element
|
|
69
|
+
callRef(ref, element)
|
|
70
|
+
if (element) queueMicrotask(sync)
|
|
71
|
+
}}
|
|
72
|
+
set:onchange={(event: Event) => {
|
|
73
|
+
callHandler(onChange, event)
|
|
74
|
+
sync()
|
|
75
|
+
onCheckedChange?.((event.currentTarget as HTMLInputElement).checked, event)
|
|
76
|
+
}}
|
|
77
|
+
set:oninput={(event: Event) => {
|
|
78
|
+
callHandler(onInput, event)
|
|
79
|
+
sync()
|
|
80
|
+
}}
|
|
81
|
+
type="checkbox"
|
|
82
|
+
/>
|
|
83
|
+
<span
|
|
84
|
+
aria-hidden="true"
|
|
85
|
+
class={checkedIndicatorClass}
|
|
86
|
+
data-slot="checkbox-indicator"
|
|
87
|
+
data-state="checked"
|
|
88
|
+
/>
|
|
89
|
+
<span
|
|
90
|
+
aria-hidden="true"
|
|
91
|
+
class={indeterminateIndicatorClass}
|
|
92
|
+
data-slot="checkbox-indicator"
|
|
93
|
+
data-state="indeterminate"
|
|
94
|
+
/>
|
|
95
|
+
</>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
CheckboxRoot.is = 'span'
|
|
101
|
+
|
|
102
|
+
/** Unstyled native checkbox with state slots and form behavior. */
|
|
103
|
+
const Checkbox: Stateless<CheckboxArgs> = ({
|
|
104
|
+
checkedIndicatorClass,
|
|
105
|
+
class: classes,
|
|
106
|
+
disabled,
|
|
107
|
+
inputClass,
|
|
108
|
+
indeterminateIndicatorClass,
|
|
109
|
+
onCheckedChange,
|
|
110
|
+
...attrs
|
|
111
|
+
}) => {
|
|
112
|
+
const inputAttrs = { ...attrs, disabled }
|
|
113
|
+
const state = stateFromAttrs(inputAttrs)
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<CheckboxRoot
|
|
117
|
+
checkedIndicatorClass={checkedIndicatorClass}
|
|
118
|
+
inputAttrs={inputAttrs}
|
|
119
|
+
inputClass={inputClass}
|
|
120
|
+
indeterminateIndicatorClass={indeterminateIndicatorClass}
|
|
121
|
+
onCheckedChange={onCheckedChange}
|
|
122
|
+
attr:class={classes}
|
|
123
|
+
attr:data-disabled={flag(disabled)}
|
|
124
|
+
attr:data-slot={attrs['data-slot'] ?? 'checkbox'}
|
|
125
|
+
attr:data-state={state}
|
|
126
|
+
/>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export { Checkbox }
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import type { IntrinsicElements, Stateful, Stateless, WithChildren } from 'ajo'
|
|
2
|
+
import type { OmitArg } from './utils'
|
|
3
|
+
import { callHandler, controlled, dom, id, statefulRootAttrs as rootAttrs } from 'ajo-cloves'
|
|
4
|
+
import { context } from 'ajo/context'
|
|
5
|
+
import { flag } from './utils'
|
|
6
|
+
|
|
7
|
+
/** Arguments for the controlled or uncontrolled disclosure root. */
|
|
8
|
+
export type CollapsibleArgs = WithChildren<OmitArg<IntrinsicElements['details'], 'open'> & {
|
|
9
|
+
/** Controlled open state. */
|
|
10
|
+
open?: boolean
|
|
11
|
+
/** Initial open state for uncontrolled usage. */
|
|
12
|
+
defaultOpen?: boolean
|
|
13
|
+
/** Disable the trigger. */
|
|
14
|
+
disabled?: boolean
|
|
15
|
+
/** Called whenever open state changes. */
|
|
16
|
+
onOpenChange?: (open: boolean, event?: Event) => void
|
|
17
|
+
}>
|
|
18
|
+
|
|
19
|
+
/** Arguments for the native summary that toggles a Collapsible. */
|
|
20
|
+
export type CollapsibleTriggerArgs = WithChildren<IntrinsicElements['summary'] & {
|
|
21
|
+
/** Disable the trigger. */
|
|
22
|
+
disabled?: boolean
|
|
23
|
+
/** Keep the trigger focusable but block toggling. */
|
|
24
|
+
locked?: boolean
|
|
25
|
+
}>
|
|
26
|
+
|
|
27
|
+
/** Arguments for the content revealed by a Collapsible. */
|
|
28
|
+
export type CollapsibleContentArgs = WithChildren<IntrinsicElements['div']>
|
|
29
|
+
|
|
30
|
+
type CollapsibleRootArgs = WithChildren<{
|
|
31
|
+
defaultOpen?: boolean
|
|
32
|
+
disabled?: boolean
|
|
33
|
+
onOpenChange?: (open: boolean, event?: Event) => void
|
|
34
|
+
open?: boolean
|
|
35
|
+
}>
|
|
36
|
+
|
|
37
|
+
/** Live disclosure state shared with composed Collapsible parts. */
|
|
38
|
+
export type CollapsibleContextValue = {
|
|
39
|
+
contentId: string
|
|
40
|
+
disabled: boolean
|
|
41
|
+
open: boolean
|
|
42
|
+
setOpen: (open: boolean, event?: Event) => void
|
|
43
|
+
triggerId: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Composition context shared by Collapsible parts and derivative families such as Accordion. */
|
|
47
|
+
export const CollapsibleContext = context<CollapsibleContextValue | null>(null)
|
|
48
|
+
|
|
49
|
+
const CollapsibleRoot: Stateful<CollapsibleRootArgs, 'details'> = function* ({ defaultOpen, open }) {
|
|
50
|
+
const rootId = id('collapsible')
|
|
51
|
+
let disabled = false
|
|
52
|
+
let onOpenChange: CollapsibleRootArgs['onOpenChange']
|
|
53
|
+
const state = controlled<boolean>(this, {
|
|
54
|
+
fallback: Boolean(open ?? defaultOpen),
|
|
55
|
+
onChange: (next, event) => onOpenChange?.(next, event),
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const syncRoot = () => {
|
|
59
|
+
if (!dom(this)) return
|
|
60
|
+
|
|
61
|
+
if (state.value) this.setAttribute('open', '')
|
|
62
|
+
else this.removeAttribute('open')
|
|
63
|
+
|
|
64
|
+
this.setAttribute('data-state', state.value ? 'open' : 'closed')
|
|
65
|
+
if (disabled) {
|
|
66
|
+
this.setAttribute('data-disabled', 'true')
|
|
67
|
+
this.setAttribute('aria-disabled', 'true')
|
|
68
|
+
} else {
|
|
69
|
+
this.removeAttribute('data-disabled')
|
|
70
|
+
this.removeAttribute('aria-disabled')
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const setOpen = (next: boolean, event?: Event) => {
|
|
75
|
+
if (disabled || next === state.value) return
|
|
76
|
+
state.set(next, event)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const args of this) {
|
|
80
|
+
disabled = Boolean(args.disabled)
|
|
81
|
+
onOpenChange = args.onOpenChange
|
|
82
|
+
state.sync(args.open != null ? Boolean(args.open) : undefined)
|
|
83
|
+
|
|
84
|
+
syncRoot()
|
|
85
|
+
|
|
86
|
+
CollapsibleContext({
|
|
87
|
+
contentId: `${rootId}-content`,
|
|
88
|
+
disabled,
|
|
89
|
+
open: state.value,
|
|
90
|
+
setOpen,
|
|
91
|
+
triggerId: `${rootId}-trigger`,
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
yield <>{args.children}</>
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
CollapsibleRoot.is = 'details'
|
|
99
|
+
|
|
100
|
+
/** Unstyled collapsible disclosure rendered as a native details element. */
|
|
101
|
+
const Collapsible: Stateless<CollapsibleArgs> = ({
|
|
102
|
+
children,
|
|
103
|
+
defaultOpen,
|
|
104
|
+
disabled,
|
|
105
|
+
onOpenChange,
|
|
106
|
+
open,
|
|
107
|
+
...attrs
|
|
108
|
+
}) => {
|
|
109
|
+
const opened = Boolean(open ?? defaultOpen)
|
|
110
|
+
const disabledFlag = Boolean(disabled)
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<CollapsibleRoot
|
|
114
|
+
attr:data-slot="collapsible"
|
|
115
|
+
{...rootAttrs(attrs)}
|
|
116
|
+
defaultOpen={defaultOpen}
|
|
117
|
+
disabled={disabledFlag}
|
|
118
|
+
onOpenChange={onOpenChange}
|
|
119
|
+
open={open}
|
|
120
|
+
attr:aria-disabled={flag(disabledFlag)}
|
|
121
|
+
attr:data-disabled={flag(disabledFlag)}
|
|
122
|
+
attr:data-state={opened ? 'open' : 'closed'}
|
|
123
|
+
attr:open={opened || undefined}
|
|
124
|
+
set:open={opened}
|
|
125
|
+
>
|
|
126
|
+
{children}
|
|
127
|
+
</CollapsibleRoot>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Unstyled disclosure trigger rendered as a native summary element. */
|
|
132
|
+
const CollapsibleTrigger: Stateless<CollapsibleTriggerArgs> = ({
|
|
133
|
+
children,
|
|
134
|
+
disabled,
|
|
135
|
+
id: idArg,
|
|
136
|
+
locked,
|
|
137
|
+
'set:onclick': onClick,
|
|
138
|
+
...attrs
|
|
139
|
+
}) => {
|
|
140
|
+
const collapsible = CollapsibleContext()
|
|
141
|
+
const disabledFlag = Boolean(disabled ?? collapsible?.disabled)
|
|
142
|
+
const unavailable = Boolean(disabledFlag || locked)
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<summary
|
|
146
|
+
data-slot="collapsible-trigger"
|
|
147
|
+
{...attrs}
|
|
148
|
+
aria-controls={collapsible?.contentId}
|
|
149
|
+
aria-disabled={unavailable ? 'true' : undefined}
|
|
150
|
+
aria-expanded={collapsible?.open ? 'true' : 'false'}
|
|
151
|
+
data-disabled={flag(disabledFlag)}
|
|
152
|
+
data-state={collapsible?.open ? 'open' : 'closed'}
|
|
153
|
+
id={idArg ?? collapsible?.triggerId}
|
|
154
|
+
set:onclick={(event: Event) => {
|
|
155
|
+
callHandler(onClick, event)
|
|
156
|
+
const prevented = event.defaultPrevented
|
|
157
|
+
event.preventDefault()
|
|
158
|
+
if (prevented || unavailable) return
|
|
159
|
+
collapsible?.setOpen(!collapsible.open, event)
|
|
160
|
+
}}
|
|
161
|
+
tabindex={disabledFlag ? -1 : undefined}
|
|
162
|
+
>
|
|
163
|
+
{children}
|
|
164
|
+
</summary>
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Unstyled content panel natively shown or hidden by its details root. */
|
|
169
|
+
const CollapsibleContent: Stateless<CollapsibleContentArgs> = ({
|
|
170
|
+
children,
|
|
171
|
+
...attrs
|
|
172
|
+
}) => {
|
|
173
|
+
const collapsible = CollapsibleContext()
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<div
|
|
177
|
+
data-slot="collapsible-content"
|
|
178
|
+
{...attrs}
|
|
179
|
+
aria-labelledby={collapsible?.triggerId}
|
|
180
|
+
data-state={collapsible?.open ? 'open' : 'closed'}
|
|
181
|
+
id={collapsible?.contentId}
|
|
182
|
+
>
|
|
183
|
+
{children}
|
|
184
|
+
</div>
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export { Collapsible, CollapsibleContent, CollapsibleTrigger }
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
export type ItemsOptions = {
|
|
2
|
+
/** Exclude disabled items. Default: true. */
|
|
3
|
+
enabled?: boolean
|
|
4
|
+
/** Require rendered layout (an offsetParent). Default: true. */
|
|
5
|
+
rendered?: boolean
|
|
6
|
+
/** Exclude [hidden] items. Default: true. */
|
|
7
|
+
shown?: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type ItemAttrs = {
|
|
11
|
+
'data-item': string
|
|
12
|
+
'data-disabled'?: 'true'
|
|
13
|
+
'data-label'?: string
|
|
14
|
+
'data-value'?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type SweepOptions = {
|
|
18
|
+
empty?: string
|
|
19
|
+
group?: string
|
|
20
|
+
separator?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const enabledOf = (item: HTMLElement) =>
|
|
24
|
+
item.dataset.disabled !== 'true' && !(item as HTMLElement & { disabled?: boolean }).disabled
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* One item protocol for list-like families (menus, select, combobox, command):
|
|
28
|
+
* items are marked `data-item="<kind>"` and carry `data-value`, `data-label`,
|
|
29
|
+
* and `data-disabled`; the kind keeps nested families from cross-matching.
|
|
30
|
+
* Discovery is by DOM query (house pattern), highlight is `data-highlighted`.
|
|
31
|
+
*/
|
|
32
|
+
export const collection = (kind: string, defaults: ItemsOptions = {}) => {
|
|
33
|
+
const selector = `[data-item="${kind}"]`
|
|
34
|
+
const sweepDefaults: Required<SweepOptions> = {
|
|
35
|
+
empty: `[data-slot="${kind}-empty"]`,
|
|
36
|
+
group: `[data-slot="${kind}-group"]`,
|
|
37
|
+
separator: `[data-slot="${kind}-separator"]`,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const all = (root: ParentNode | null | undefined) =>
|
|
41
|
+
root ? Array.from(root.querySelectorAll<HTMLElement>(selector)) : []
|
|
42
|
+
|
|
43
|
+
const items = (root: ParentNode | null | undefined, opts: ItemsOptions = {}) => {
|
|
44
|
+
const enabled = opts.enabled ?? defaults.enabled ?? true
|
|
45
|
+
const rendered = opts.rendered ?? defaults.rendered ?? true
|
|
46
|
+
const shown = opts.shown ?? defaults.shown ?? true
|
|
47
|
+
|
|
48
|
+
return all(root).filter(item =>
|
|
49
|
+
(!shown || !item.hidden) &&
|
|
50
|
+
(!rendered || item.offsetParent !== null) &&
|
|
51
|
+
(!enabled || enabledOf(item)))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const clearHighlight = (root: ParentNode | null | undefined) => {
|
|
55
|
+
for (const item of all(root)) delete item.dataset.highlighted
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Marks one item highlighted and clears the rest; does not move focus. */
|
|
59
|
+
const highlight = (root: ParentNode | null | undefined, target: HTMLElement | undefined) => {
|
|
60
|
+
if (!target) return
|
|
61
|
+
|
|
62
|
+
target.dataset.highlighted = 'true'
|
|
63
|
+
for (const item of all(root)) {
|
|
64
|
+
if (item !== target) delete item.dataset.highlighted
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Focus strategy: moves real focus and mirrors it in data-highlighted. */
|
|
69
|
+
const focusItem = (root: ParentNode | null | undefined, target: HTMLElement | undefined) => {
|
|
70
|
+
if (!target || !(root instanceof Node) || !root.contains(target)) return
|
|
71
|
+
|
|
72
|
+
target.focus()
|
|
73
|
+
highlight(root, target)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Focuses the first or last matching item. */
|
|
77
|
+
const edge = (root: ParentNode | null | undefined, which: 'first' | 'last', opts?: ItemsOptions) => {
|
|
78
|
+
const list = items(root, opts)
|
|
79
|
+
focusItem(root, which === 'first' ? list[0] : list[list.length - 1])
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Resolves the item containing an event target. */
|
|
83
|
+
const item = (event: Event) =>
|
|
84
|
+
(event.target as HTMLElement | null)?.closest<HTMLElement>(selector)
|
|
85
|
+
|
|
86
|
+
/** Marker attrs for one item, meant for JSX spread. */
|
|
87
|
+
const attrs = (opts: { disabled?: boolean; label?: string; value?: string } = {}): ItemAttrs => ({
|
|
88
|
+
'data-item': kind,
|
|
89
|
+
'data-disabled': opts.disabled ? 'true' : undefined,
|
|
90
|
+
'data-label': opts.label,
|
|
91
|
+
'data-value': opts.value,
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Hides empty groups (respecting data-force-mount) and toggles empty
|
|
96
|
+
* states against the current visible set; returns the visible items.
|
|
97
|
+
* Separators survive only between visible items: leading, trailing,
|
|
98
|
+
* and stacked separators hide as filtering empties their groups.
|
|
99
|
+
*/
|
|
100
|
+
const sweep = (root: HTMLElement, opts?: SweepOptions) => {
|
|
101
|
+
const empty = opts?.empty ?? sweepDefaults.empty
|
|
102
|
+
const group = opts?.group ?? sweepDefaults.group
|
|
103
|
+
const separator = opts?.separator ?? sweepDefaults.separator
|
|
104
|
+
|
|
105
|
+
if (group) {
|
|
106
|
+
for (const element of root.querySelectorAll<HTMLElement>(group)) {
|
|
107
|
+
if (element.dataset.forceMount === 'true') {
|
|
108
|
+
element.hidden = false
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
element.hidden = !element.querySelector<HTMLElement>(`${selector}:not([hidden])`)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// A group hidden by the previous filter makes its children non-rendered.
|
|
116
|
+
// Reconcile groups first so clearing the filter can measure them again.
|
|
117
|
+
const visible = items(root)
|
|
118
|
+
|
|
119
|
+
if (separator) {
|
|
120
|
+
const separators = new Set(root.querySelectorAll<HTMLElement>(separator))
|
|
121
|
+
const order = [...visible, ...separators].sort((a, b) =>
|
|
122
|
+
a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1)
|
|
123
|
+
const keep = new Set<HTMLElement>()
|
|
124
|
+
|
|
125
|
+
let afterItem = false
|
|
126
|
+
for (const element of order) {
|
|
127
|
+
if (!separators.has(element)) { afterItem = true; continue }
|
|
128
|
+
if (afterItem) keep.add(element)
|
|
129
|
+
afterItem = false
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let beforeItem = false
|
|
133
|
+
for (let index = order.length - 1; index >= 0; index--) {
|
|
134
|
+
const element = order[index]
|
|
135
|
+
if (!separators.has(element)) { beforeItem = true; continue }
|
|
136
|
+
if (!beforeItem) keep.delete(element)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (const separator of separators) separator.hidden = !keep.has(separator)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (empty) {
|
|
143
|
+
for (const element of root.querySelectorAll<HTMLElement>(empty)) {
|
|
144
|
+
element.hidden = visible.length > 0
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return visible
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { all, attrs, clearHighlight, edge, focusItem, highlight, item, items, selector, sweep }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export type Collection = ReturnType<typeof collection>
|