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,142 @@
|
|
|
1
|
+
import type { IntrinsicElements, Stateless, WithChildren } from 'ajo'
|
|
2
|
+
import { callHandler } from 'ajo-cloves'
|
|
3
|
+
|
|
4
|
+
/** Logical edge occupied by an InputGroup addon. */
|
|
5
|
+
export type InputGroupAddonAlign =
|
|
6
|
+
| 'block-end'
|
|
7
|
+
| 'block-start'
|
|
8
|
+
| 'inline-end'
|
|
9
|
+
| 'inline-start'
|
|
10
|
+
|
|
11
|
+
/** Arguments for a host that groups one control with addons and actions. */
|
|
12
|
+
export type InputGroupArgs = WithChildren<IntrinsicElements['div'] & {
|
|
13
|
+
/** Mark all grouped controls as disabled. */
|
|
14
|
+
disabled?: boolean
|
|
15
|
+
}>
|
|
16
|
+
|
|
17
|
+
/** Arguments for content placed along an InputGroup edge. */
|
|
18
|
+
export type InputGroupAddonArgs = WithChildren<IntrinsicElements['div'] & {
|
|
19
|
+
/** Placement relative to the input control. */
|
|
20
|
+
align?: InputGroupAddonAlign
|
|
21
|
+
}>
|
|
22
|
+
|
|
23
|
+
/** Arguments for a button rendered inside an InputGroup. */
|
|
24
|
+
export type InputGroupButtonArgs = WithChildren<IntrinsicElements['button'] & {
|
|
25
|
+
'data-size'?: string
|
|
26
|
+
}>
|
|
27
|
+
|
|
28
|
+
/** Arguments for non-interactive text rendered inside an InputGroup. */
|
|
29
|
+
export type InputGroupTextArgs = WithChildren<IntrinsicElements['span']>
|
|
30
|
+
|
|
31
|
+
/** Arguments for the native input control owned by an InputGroup. */
|
|
32
|
+
export type InputGroupInputArgs = IntrinsicElements['input'] & {
|
|
33
|
+
'data-slot'?: unknown
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Arguments for the native textarea control owned by an InputGroup. */
|
|
37
|
+
export type InputGroupTextareaArgs = WithChildren<IntrinsicElements['textarea'] & {
|
|
38
|
+
'data-slot'?: unknown
|
|
39
|
+
}>
|
|
40
|
+
|
|
41
|
+
/** Unstyled host that groups a control with addons and actions. */
|
|
42
|
+
const InputGroup: Stateless<InputGroupArgs> = ({
|
|
43
|
+
children,
|
|
44
|
+
disabled,
|
|
45
|
+
role = 'group',
|
|
46
|
+
...attrs
|
|
47
|
+
}) => (
|
|
48
|
+
<div
|
|
49
|
+
{...attrs}
|
|
50
|
+
data-disabled={disabled ? 'true' : undefined}
|
|
51
|
+
data-slot="input-group"
|
|
52
|
+
role={role}
|
|
53
|
+
>
|
|
54
|
+
{children}
|
|
55
|
+
</div>
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
/** Unstyled addon that forwards non-button clicks to the grouped control. */
|
|
59
|
+
const InputGroupAddon: Stateless<InputGroupAddonArgs> = ({
|
|
60
|
+
align = 'inline-start',
|
|
61
|
+
children,
|
|
62
|
+
role = 'group',
|
|
63
|
+
'set:onclick': onclick,
|
|
64
|
+
...attrs
|
|
65
|
+
}) => (
|
|
66
|
+
<div
|
|
67
|
+
{...attrs}
|
|
68
|
+
data-align={align}
|
|
69
|
+
data-slot="input-group-addon"
|
|
70
|
+
role={role}
|
|
71
|
+
set:onclick={(event: MouseEvent) => {
|
|
72
|
+
callHandler(onclick, event)
|
|
73
|
+
const target = event.target as HTMLElement
|
|
74
|
+
if (target.closest('button')) return
|
|
75
|
+
// The control slot wins over document order: a hidden form input
|
|
76
|
+
// rendered before it must never swallow the forwarded focus.
|
|
77
|
+
const parent = (event.currentTarget as HTMLElement).parentElement
|
|
78
|
+
;(parent?.querySelector<HTMLElement>('[data-slot="input-group-control"]')
|
|
79
|
+
?? parent?.querySelector<HTMLElement>('input,textarea'))
|
|
80
|
+
?.focus()
|
|
81
|
+
}}
|
|
82
|
+
>
|
|
83
|
+
{children}
|
|
84
|
+
</div>
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
/** Unstyled button rendered inside an InputGroup. */
|
|
88
|
+
const InputGroupButton: Stateless<InputGroupButtonArgs> = ({
|
|
89
|
+
children,
|
|
90
|
+
'data-slot': slot = 'input-group-button',
|
|
91
|
+
type = 'button',
|
|
92
|
+
...attrs
|
|
93
|
+
}) => (
|
|
94
|
+
<button
|
|
95
|
+
{...attrs}
|
|
96
|
+
data-slot={slot}
|
|
97
|
+
type={type}
|
|
98
|
+
>
|
|
99
|
+
{children}
|
|
100
|
+
</button>
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
/** Unstyled supporting text rendered inside an InputGroup. */
|
|
104
|
+
const InputGroupText: Stateless<InputGroupTextArgs> = ({ children, ...attrs }) => (
|
|
105
|
+
<span {...attrs} data-slot="input-group-text">{children}</span>
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
/** Unstyled native input carrying the InputGroup control slot. */
|
|
109
|
+
const InputGroupInput: Stateless<InputGroupInputArgs> = ({
|
|
110
|
+
'data-slot': slot = 'input-group-control',
|
|
111
|
+
type = 'text',
|
|
112
|
+
...attrs
|
|
113
|
+
}) => (
|
|
114
|
+
<input
|
|
115
|
+
{...attrs}
|
|
116
|
+
data-slot={slot}
|
|
117
|
+
type={type}
|
|
118
|
+
/>
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
/** Unstyled native textarea carrying the InputGroup control slot. */
|
|
122
|
+
const InputGroupTextarea: Stateless<InputGroupTextareaArgs> = ({
|
|
123
|
+
children,
|
|
124
|
+
'data-slot': slot = 'input-group-control',
|
|
125
|
+
...attrs
|
|
126
|
+
}) => (
|
|
127
|
+
<textarea
|
|
128
|
+
{...attrs}
|
|
129
|
+
data-slot={slot}
|
|
130
|
+
>
|
|
131
|
+
{children}
|
|
132
|
+
</textarea>
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
export {
|
|
136
|
+
InputGroup,
|
|
137
|
+
InputGroupAddon,
|
|
138
|
+
InputGroupButton,
|
|
139
|
+
InputGroupInput,
|
|
140
|
+
InputGroupText,
|
|
141
|
+
InputGroupTextarea,
|
|
142
|
+
}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import type { IntrinsicElements, Stateful, Stateless, WithChildren } from 'ajo'
|
|
2
|
+
import { callHandler, callRef, clamp, controlled, dom } from 'ajo-cloves'
|
|
3
|
+
import { context } from 'ajo/context'
|
|
4
|
+
import type { FixedArgs, OmitArg } from './utils'
|
|
5
|
+
|
|
6
|
+
/** Built-in InputOTP pattern accepting one or more ASCII digits. */
|
|
7
|
+
export const REGEXP_ONLY_DIGITS = /^[0-9]+$/
|
|
8
|
+
/** Built-in InputOTP pattern accepting one or more ASCII letters or digits. */
|
|
9
|
+
export const REGEXP_ONLY_DIGITS_AND_CHARS = /^[a-zA-Z0-9]+$/
|
|
10
|
+
|
|
11
|
+
/** Arguments for a controlled or uncontrolled one-time-password input. */
|
|
12
|
+
export type InputOTPArgs = WithChildren<OmitArg<IntrinsicElements['input'], 'children' | 'defaultValue' | 'maxLength' | 'onChange' | 'pattern' | 'value'> & {
|
|
13
|
+
/** Initial uncontrolled value. */
|
|
14
|
+
defaultValue?: string
|
|
15
|
+
/** Classes for the hidden native input. */
|
|
16
|
+
inputClass?: string
|
|
17
|
+
/** Maximum accepted characters. */
|
|
18
|
+
maxLength?: number
|
|
19
|
+
/** Called when the OTP value changes. */
|
|
20
|
+
onValueChange?: (value: string, event?: Event) => void
|
|
21
|
+
/** Called when the OTP reaches `maxLength`. */
|
|
22
|
+
onComplete?: (value: string, event?: Event) => void
|
|
23
|
+
/** Accepted character pattern. */
|
|
24
|
+
pattern?: RegExp | string
|
|
25
|
+
/** Controlled value. */
|
|
26
|
+
value?: string
|
|
27
|
+
}> & FixedArgs<'onChange'>
|
|
28
|
+
|
|
29
|
+
/** Arguments for a visual group of adjacent OTP slots. */
|
|
30
|
+
export type InputOTPGroupArgs = WithChildren<IntrinsicElements['div']>
|
|
31
|
+
|
|
32
|
+
/** Arguments for one indexed visual slot in an InputOTP value. */
|
|
33
|
+
export type InputOTPSlotArgs = WithChildren<IntrinsicElements['div'] & {
|
|
34
|
+
/** Classes for the caret wrapper element. */
|
|
35
|
+
caretClass?: string
|
|
36
|
+
/** Classes for the caret mark element. */
|
|
37
|
+
caretMarkClass?: string
|
|
38
|
+
/** Slot index in the OTP value. */
|
|
39
|
+
index: number
|
|
40
|
+
}>
|
|
41
|
+
|
|
42
|
+
/** Arguments for a separator between visual OTP slot groups. */
|
|
43
|
+
export type InputOTPSeparatorArgs = WithChildren<IntrinsicElements['div']>
|
|
44
|
+
|
|
45
|
+
type InputOTPContextValue = {
|
|
46
|
+
active: number
|
|
47
|
+
disabled: boolean
|
|
48
|
+
focus: (index: number) => void
|
|
49
|
+
maxLength: number
|
|
50
|
+
value: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const InputOTPContext = context<InputOTPContextValue | null>(null)
|
|
54
|
+
|
|
55
|
+
const isRegExp = (value: unknown): value is RegExp =>
|
|
56
|
+
Object.prototype.toString.call(value) === '[object RegExp]'
|
|
57
|
+
|
|
58
|
+
const accepts = (pattern: RegExp | string | undefined, char: string) => {
|
|
59
|
+
if (!pattern) return true
|
|
60
|
+
if (isRegExp(pattern)) {
|
|
61
|
+
pattern.lastIndex = 0
|
|
62
|
+
return pattern.test(char)
|
|
63
|
+
}
|
|
64
|
+
return new RegExp(pattern).test(char)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const clean = (value: unknown, maxLength: number, pattern?: RegExp | string) =>
|
|
68
|
+
String(value ?? '')
|
|
69
|
+
.split('')
|
|
70
|
+
.filter(char => accepts(pattern, char))
|
|
71
|
+
.slice(0, Math.max(1, maxLength))
|
|
72
|
+
.join('')
|
|
73
|
+
|
|
74
|
+
const inputPattern = (pattern: RegExp | string | undefined) =>
|
|
75
|
+
isRegExp(pattern) ? pattern.source : pattern
|
|
76
|
+
|
|
77
|
+
const nextSelection = (input: HTMLInputElement | null, fallback: number) =>
|
|
78
|
+
Math.min(input?.selectionStart ?? fallback, Math.max(0, input?.value.length ?? fallback))
|
|
79
|
+
|
|
80
|
+
const completionState = (value: string, maxLength: number) =>
|
|
81
|
+
value.length === maxLength ? 'complete' : 'incomplete'
|
|
82
|
+
|
|
83
|
+
// The runtime routes `ref` on a stateful component to its host, so the
|
|
84
|
+
// wrapper forwards the consumer's input ref under a plain args key.
|
|
85
|
+
type InputOTPRootArgs = OmitArg<InputOTPArgs, 'ref'> & {
|
|
86
|
+
inputRef?: InputOTPArgs['ref']
|
|
87
|
+
} & FixedArgs<'ref'>
|
|
88
|
+
|
|
89
|
+
const InputOTPRoot: Stateful<InputOTPRootArgs> = function* ({
|
|
90
|
+
defaultValue = '',
|
|
91
|
+
maxLength = 6,
|
|
92
|
+
pattern,
|
|
93
|
+
value,
|
|
94
|
+
}) {
|
|
95
|
+
let input: HTMLInputElement | null = null
|
|
96
|
+
const initial = clean(value ?? defaultValue, maxLength, pattern)
|
|
97
|
+
let active = Math.min(initial.length, Math.max(0, maxLength - 1))
|
|
98
|
+
let onValueChange: InputOTPArgs['onValueChange']
|
|
99
|
+
let onComplete: InputOTPArgs['onComplete']
|
|
100
|
+
let previousComplete = initial.length === maxLength ? initial : ''
|
|
101
|
+
|
|
102
|
+
const emit = (next: string, event?: Event) => {
|
|
103
|
+
onValueChange?.(next, event)
|
|
104
|
+
if (next.length === maxLength && next !== previousComplete) {
|
|
105
|
+
previousComplete = next
|
|
106
|
+
onComplete?.(next, event)
|
|
107
|
+
} else if (next.length !== maxLength) {
|
|
108
|
+
previousComplete = ''
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const state = controlled<string>(this, {
|
|
112
|
+
fallback: initial,
|
|
113
|
+
onChange: (next, event) => emit(next, event),
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
const setValue = (
|
|
117
|
+
next: string,
|
|
118
|
+
args: InputOTPRootArgs,
|
|
119
|
+
nextActive = next.length,
|
|
120
|
+
event?: Event,
|
|
121
|
+
) => {
|
|
122
|
+
const length = args.maxLength ?? maxLength
|
|
123
|
+
const parsed = clean(next, length, args.pattern)
|
|
124
|
+
active = Math.min(nextActive, Math.max(0, length - 1))
|
|
125
|
+
if (input) input.value = parsed
|
|
126
|
+
state.accept(parsed, event)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const focus = (index: number) => {
|
|
130
|
+
active = clamp(index, 0, Math.max(0, maxLength - 1))
|
|
131
|
+
input?.focus()
|
|
132
|
+
input?.setSelectionRange(active, active)
|
|
133
|
+
this.next()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
for (const args of this) {
|
|
137
|
+
const {
|
|
138
|
+
children,
|
|
139
|
+
class: _class,
|
|
140
|
+
defaultValue: _defaultValue,
|
|
141
|
+
inputClass,
|
|
142
|
+
inputRef,
|
|
143
|
+
maxLength: _maxLength,
|
|
144
|
+
onValueChange: change,
|
|
145
|
+
onComplete: complete,
|
|
146
|
+
pattern,
|
|
147
|
+
value,
|
|
148
|
+
'set:onblur': onBlur,
|
|
149
|
+
'set:onfocus': onFocus,
|
|
150
|
+
'set:oninput': onInput,
|
|
151
|
+
'set:onkeyup': onKeyUp,
|
|
152
|
+
'set:onpaste': onPaste,
|
|
153
|
+
...inputAttrs
|
|
154
|
+
} = args
|
|
155
|
+
|
|
156
|
+
maxLength = _maxLength ?? 6
|
|
157
|
+
onValueChange = change
|
|
158
|
+
onComplete = complete
|
|
159
|
+
state.sync(value != null ? clean(value, maxLength, pattern) : undefined)
|
|
160
|
+
if (!state.controlled) {
|
|
161
|
+
const parsed = clean(state.value, maxLength, pattern)
|
|
162
|
+
if (parsed !== state.value) state.init(parsed)
|
|
163
|
+
}
|
|
164
|
+
active = Math.min(active, Math.max(0, maxLength - 1))
|
|
165
|
+
if (dom(this)) this.dataset.state = completionState(state.value, maxLength)
|
|
166
|
+
|
|
167
|
+
InputOTPContext({
|
|
168
|
+
active,
|
|
169
|
+
disabled: Boolean(inputAttrs.disabled),
|
|
170
|
+
focus,
|
|
171
|
+
maxLength,
|
|
172
|
+
value: state.value,
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
yield (
|
|
176
|
+
<>
|
|
177
|
+
<input
|
|
178
|
+
{...inputAttrs}
|
|
179
|
+
aria-label={inputAttrs['aria-label'] ?? 'One-time password'}
|
|
180
|
+
autocomplete={inputAttrs.autocomplete ?? 'one-time-code'}
|
|
181
|
+
class={inputClass}
|
|
182
|
+
data-slot="input-otp-input"
|
|
183
|
+
data-state={completionState(state.value, maxLength)}
|
|
184
|
+
inputmode={inputAttrs.inputmode ?? 'numeric'}
|
|
185
|
+
maxLength={maxLength}
|
|
186
|
+
pattern={inputPattern(pattern)}
|
|
187
|
+
ref={element => {
|
|
188
|
+
input = element
|
|
189
|
+
callRef(inputRef, element)
|
|
190
|
+
}}
|
|
191
|
+
type={inputAttrs.type ?? 'text'}
|
|
192
|
+
set:onblur={(event: FocusEvent) => {
|
|
193
|
+
callHandler(onBlur, event)
|
|
194
|
+
this.next(() => active = -1)
|
|
195
|
+
}}
|
|
196
|
+
set:onfocus={(event: FocusEvent) => {
|
|
197
|
+
callHandler(onFocus, event)
|
|
198
|
+
this.next(() => active = Math.min(state.value.length, Math.max(0, maxLength - 1)))
|
|
199
|
+
}}
|
|
200
|
+
set:oninput={(event: Event) => {
|
|
201
|
+
callHandler(onInput, event)
|
|
202
|
+
const control = event.currentTarget as HTMLInputElement
|
|
203
|
+
setValue(control.value, args, nextSelection(control, control.value.length), event)
|
|
204
|
+
}}
|
|
205
|
+
set:onkeyup={(event: KeyboardEvent) => {
|
|
206
|
+
callHandler(onKeyUp, event)
|
|
207
|
+
this.next(() => {
|
|
208
|
+
active = nextSelection(event.currentTarget as HTMLInputElement, active)
|
|
209
|
+
})
|
|
210
|
+
}}
|
|
211
|
+
set:onpaste={(event: ClipboardEvent) => {
|
|
212
|
+
callHandler(onPaste, event)
|
|
213
|
+
if (event.defaultPrevented) return
|
|
214
|
+
const pasted = event.clipboardData?.getData('text') ?? ''
|
|
215
|
+
if (!pasted) return
|
|
216
|
+
event.preventDefault()
|
|
217
|
+
setValue(pasted, args, pasted.length, event)
|
|
218
|
+
}}
|
|
219
|
+
set:value={state.value}
|
|
220
|
+
/>
|
|
221
|
+
{children}
|
|
222
|
+
</>
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
/** Unstyled one-time password input with visible slot context. */
|
|
229
|
+
const InputOTP: Stateless<InputOTPArgs> = ({
|
|
230
|
+
children,
|
|
231
|
+
class: classes,
|
|
232
|
+
disabled,
|
|
233
|
+
ref,
|
|
234
|
+
...attrs
|
|
235
|
+
}) => {
|
|
236
|
+
const maxLength = attrs.maxLength ?? 6
|
|
237
|
+
const state = completionState(clean(attrs.value ?? attrs.defaultValue, maxLength, attrs.pattern), maxLength)
|
|
238
|
+
|
|
239
|
+
return (
|
|
240
|
+
<InputOTPRoot
|
|
241
|
+
{...attrs}
|
|
242
|
+
disabled={disabled}
|
|
243
|
+
inputRef={ref}
|
|
244
|
+
attr:class={classes}
|
|
245
|
+
attr:data-disabled={disabled ? 'true' : undefined}
|
|
246
|
+
attr:data-slot="input-otp"
|
|
247
|
+
attr:data-state={state}
|
|
248
|
+
>
|
|
249
|
+
{children}
|
|
250
|
+
</InputOTPRoot>
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Unstyled visual group for adjacent OTP slots. */
|
|
255
|
+
const InputOTPGroup: Stateless<InputOTPGroupArgs> = ({
|
|
256
|
+
children,
|
|
257
|
+
'data-slot': slot = 'input-otp-group',
|
|
258
|
+
...attrs
|
|
259
|
+
}) => (
|
|
260
|
+
<div {...attrs} data-slot={slot}>
|
|
261
|
+
{children}
|
|
262
|
+
</div>
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
/** Unstyled OTP slot synchronized with the nearest OTP input. */
|
|
266
|
+
const InputOTPSlot: Stateless<InputOTPSlotArgs> = ({
|
|
267
|
+
caretClass,
|
|
268
|
+
caretMarkClass,
|
|
269
|
+
children,
|
|
270
|
+
index,
|
|
271
|
+
role = 'presentation',
|
|
272
|
+
'set:onclick': onClick,
|
|
273
|
+
...attrs
|
|
274
|
+
}) => {
|
|
275
|
+
const context = InputOTPContext()
|
|
276
|
+
if (!context) throw new Error('InputOTPSlot must be used within an <InputOTP />')
|
|
277
|
+
|
|
278
|
+
const char = context.value[index]
|
|
279
|
+
const active = context.active === index
|
|
280
|
+
const caret = active && !char && !context.disabled
|
|
281
|
+
const state = active ? 'active' : 'inactive'
|
|
282
|
+
|
|
283
|
+
return (
|
|
284
|
+
<div
|
|
285
|
+
{...attrs}
|
|
286
|
+
aria-hidden="true"
|
|
287
|
+
data-active={active ? 'true' : 'false'}
|
|
288
|
+
data-disabled={context.disabled ? 'true' : undefined}
|
|
289
|
+
data-slot="input-otp-slot"
|
|
290
|
+
data-state={state}
|
|
291
|
+
role={role}
|
|
292
|
+
set:onclick={(event: MouseEvent) => {
|
|
293
|
+
callHandler(onClick, event)
|
|
294
|
+
if (!event.defaultPrevented && !context.disabled) context.focus(index)
|
|
295
|
+
}}
|
|
296
|
+
>
|
|
297
|
+
{children ?? char}
|
|
298
|
+
{caret && (
|
|
299
|
+
<div aria-hidden="true" class={caretClass} data-slot="input-otp-caret">
|
|
300
|
+
<div class={caretMarkClass} data-slot="input-otp-caret-mark" />
|
|
301
|
+
</div>
|
|
302
|
+
)}
|
|
303
|
+
</div>
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Unstyled separator between OTP slot groups. */
|
|
308
|
+
const InputOTPSeparator: Stateless<InputOTPSeparatorArgs> = ({
|
|
309
|
+
children,
|
|
310
|
+
role = 'separator',
|
|
311
|
+
'data-slot': slot = 'input-otp-separator',
|
|
312
|
+
...attrs
|
|
313
|
+
}) => (
|
|
314
|
+
<div {...attrs} data-slot={slot} role={role}>
|
|
315
|
+
{children}
|
|
316
|
+
</div>
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
export {
|
|
320
|
+
InputOTP,
|
|
321
|
+
InputOTPGroup,
|
|
322
|
+
InputOTPSeparator,
|
|
323
|
+
InputOTPSlot,
|
|
324
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { context } from 'ajo/context'
|
|
2
|
+
import { collection } from './collection'
|
|
3
|
+
import type { PositionReference } from './position'
|
|
4
|
+
|
|
5
|
+
export type MenuInvocationFocus = 'content' | 'first'
|
|
6
|
+
|
|
7
|
+
type InvokeReference = (
|
|
8
|
+
reference: PositionReference,
|
|
9
|
+
source: HTMLElement,
|
|
10
|
+
event: Event,
|
|
11
|
+
focus: MenuInvocationFocus,
|
|
12
|
+
) => void
|
|
13
|
+
|
|
14
|
+
/** Private ContextMenu adapter that owns virtual-reference identity. */
|
|
15
|
+
export type ContextMenuInvoke = (
|
|
16
|
+
invoke: InvokeReference,
|
|
17
|
+
x: number,
|
|
18
|
+
y: number,
|
|
19
|
+
event: Event,
|
|
20
|
+
source: HTMLElement,
|
|
21
|
+
focus: MenuInvocationFocus,
|
|
22
|
+
) => void
|
|
23
|
+
|
|
24
|
+
type ContextMenuComposition = {
|
|
25
|
+
invoke: ContextMenuInvoke
|
|
26
|
+
profile: 'context'
|
|
27
|
+
restoreFocus: () => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type MenubarComposition = {
|
|
31
|
+
ackFocus: () => boolean
|
|
32
|
+
profile: 'menubar'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type MenuComposition = ContextMenuComposition | MenubarComposition
|
|
36
|
+
|
|
37
|
+
type MenuInvocation = {
|
|
38
|
+
adoptTriggerId: (id?: unknown) => string
|
|
39
|
+
contentId: string
|
|
40
|
+
disabled: boolean
|
|
41
|
+
invoke: (x: number, y: number, event: Event, source: HTMLElement, focus: MenuInvocationFocus) => void
|
|
42
|
+
open: boolean
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const CompositionContext = context<MenuComposition | null>(null)
|
|
46
|
+
const InvocationContext = context<MenuInvocation | null>(null)
|
|
47
|
+
|
|
48
|
+
/** Selects ContextMenu policy for exactly one descendant Menu root. */
|
|
49
|
+
export const provideContextMenuComposition = (invoke: ContextMenuInvoke, restoreFocus: () => void) =>
|
|
50
|
+
CompositionContext({ invoke, profile: 'context', restoreFocus })
|
|
51
|
+
|
|
52
|
+
/** Selects Menubar policy and its commit-time focus acknowledgement. */
|
|
53
|
+
export const provideMenubarComposition = (ackFocus: () => boolean) =>
|
|
54
|
+
CompositionContext({ ackFocus, profile: 'menubar' })
|
|
55
|
+
|
|
56
|
+
/** Returns the private family policy selected by a composing Menu root. */
|
|
57
|
+
export const menuComposition = () => CompositionContext()
|
|
58
|
+
|
|
59
|
+
/** Prevents a composing family policy from leaking into nested Menu roots. */
|
|
60
|
+
export const isolateMenuComposition = () => CompositionContext(null)
|
|
61
|
+
|
|
62
|
+
/** Publishes Menu's private context-invocation controller to its trigger. */
|
|
63
|
+
export const provideMenuInvocation = (value: MenuInvocation) => InvocationContext(value)
|
|
64
|
+
|
|
65
|
+
/** Prevents a context invocation controller from leaking through a nested Menu. */
|
|
66
|
+
export const isolateMenuInvocation = () => InvocationContext(null)
|
|
67
|
+
|
|
68
|
+
/** Returns the nearest private context-invocation controller. */
|
|
69
|
+
export const menuInvocation = () => InvocationContext()
|
|
70
|
+
|
|
71
|
+
/** Collection registry for menu items; disabled items are skipped in the focus order. */
|
|
72
|
+
export const menuItems = collection('menu')
|
|
73
|
+
|
|
74
|
+
/** Matches root and submenu content surfaces. */
|
|
75
|
+
export const SURFACE_SELECTOR = '[data-menu-sub-content="true"],[data-menu-content="true"]'
|
|
76
|
+
|
|
77
|
+
/** Items owned directly by one menu surface, excluding nested submenu items. */
|
|
78
|
+
export const surfaceItems = (surface: HTMLElement | null) =>
|
|
79
|
+
menuItems.items(surface).filter(item => item.closest(SURFACE_SELECTOR) === surface)
|
|
80
|
+
|
|
81
|
+
/** Focuses the first or last direct item of one menu surface. */
|
|
82
|
+
export const focusEdge = (surface: HTMLElement | null, which: 'first' | 'last') => {
|
|
83
|
+
const list = surfaceItems(surface)
|
|
84
|
+
menuItems.focusItem(surface, which === 'first' ? list[0] : list[list.length - 1])
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** One direct submenu branch owned by a menu level. */
|
|
88
|
+
export type MenuBranch = {
|
|
89
|
+
close: (event?: Event) => void
|
|
90
|
+
content: () => HTMLElement | null
|
|
91
|
+
prune: (target: Node, event: Event) => void
|
|
92
|
+
trigger: () => HTMLElement | null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Private tree seam: each menu level owns only its direct submenu branches. */
|
|
96
|
+
export const cluster = () => {
|
|
97
|
+
const branches = new Set<MenuBranch>()
|
|
98
|
+
|
|
99
|
+
const contains = (branch: MenuBranch, target: Node) =>
|
|
100
|
+
Boolean(branch.trigger()?.contains(target) || branch.content()?.contains(target))
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
/** Registers one direct branch for exactly its mounted lifetime. */
|
|
104
|
+
register(branch: MenuBranch) {
|
|
105
|
+
branches.add(branch)
|
|
106
|
+
return () => branches.delete(branch)
|
|
107
|
+
},
|
|
108
|
+
/** Closes every direct branch except an optional active sibling. */
|
|
109
|
+
close(event?: Event, except?: MenuBranch) {
|
|
110
|
+
for (const branch of [...branches]) {
|
|
111
|
+
if (branch !== except) branch.close(event)
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
/** Retains the branch containing target, closes siblings, and recurses. */
|
|
115
|
+
prune(target: Node, event: Event) {
|
|
116
|
+
for (const branch of [...branches]) {
|
|
117
|
+
if (contains(branch, target)) branch.prune(target, event)
|
|
118
|
+
else branch.close(event)
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export type MenuCluster = ReturnType<typeof cluster>
|