ajo-cloves 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 +149 -0
- package/dist/index.js +1317 -0
- package/package.json +52 -0
- package/src/announce.ts +61 -0
- package/src/controlled.ts +44 -0
- package/src/core.ts +254 -0
- package/src/dismiss.ts +53 -0
- package/src/grid.ts +52 -0
- package/src/hotkey.ts +56 -0
- package/src/hover.ts +58 -0
- package/src/index.ts +36 -0
- package/src/indicator.ts +122 -0
- package/src/label.ts +171 -0
- package/src/media.ts +95 -0
- package/src/move.ts +185 -0
- package/src/overflow.ts +87 -0
- package/src/resize.ts +93 -0
- package/src/restore.ts +29 -0
- package/src/roving.ts +77 -0
- package/src/scheme.ts +25 -0
- package/src/scrolling.ts +40 -0
- package/src/selection.ts +55 -0
- package/src/spin.ts +48 -0
- package/src/storage.ts +146 -0
- package/src/timer.ts +70 -0
- package/src/typeahead.ts +50 -0
- package/src/visibility.ts +45 -0
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ajo-cloves",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Reusable stateful component behaviors for Ajo",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist",
|
|
8
|
+
"src",
|
|
9
|
+
"README.md"
|
|
10
|
+
],
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./src/index.ts",
|
|
14
|
+
"default": "./dist/index.js",
|
|
15
|
+
"import": "./dist/index.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"ajo": "^0.1.35"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"ajo": "0.1.35"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=22.18.0"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"ajo",
|
|
29
|
+
"ui",
|
|
30
|
+
"state",
|
|
31
|
+
"components",
|
|
32
|
+
"accessibility"
|
|
33
|
+
],
|
|
34
|
+
"author": "Cristian Falcone",
|
|
35
|
+
"license": "ISC",
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/cristianfalcone/ajo-kit.git",
|
|
39
|
+
"directory": "packages/ajo-cloves"
|
|
40
|
+
},
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/cristianfalcone/ajo-kit/issues"
|
|
43
|
+
},
|
|
44
|
+
"homepage": "https://github.com/cristianfalcone/ajo-kit/tree/main/packages/ajo-cloves#readme",
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "pnpm -w exec tsx scripts/package-build.ts ajo-cloves",
|
|
50
|
+
"test": "pnpm -w exec vitest run packages/ajo-cloves/tests"
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/announce.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Host } from './core'
|
|
2
|
+
import { dom } from './core'
|
|
3
|
+
|
|
4
|
+
type Channel = 'polite' | 'assertive'
|
|
5
|
+
|
|
6
|
+
const hidden = 'position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0'
|
|
7
|
+
|
|
8
|
+
let polite: HTMLDivElement | undefined
|
|
9
|
+
let assertive: HTMLDivElement | undefined
|
|
10
|
+
|
|
11
|
+
const create = (channel: Channel) => {
|
|
12
|
+
|
|
13
|
+
const region = document.createElement('div')
|
|
14
|
+
|
|
15
|
+
region.setAttribute('role', channel === 'polite' ? 'status' : 'alert')
|
|
16
|
+
region.setAttribute('aria-live', channel)
|
|
17
|
+
region.setAttribute('aria-atomic', 'true')
|
|
18
|
+
region.setAttribute('style', hidden)
|
|
19
|
+
document.body.append(region)
|
|
20
|
+
|
|
21
|
+
return region
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const region = (channel: Channel) => {
|
|
25
|
+
|
|
26
|
+
const slot = channel === 'polite' ? polite : assertive
|
|
27
|
+
const next = slot ?? create(channel)
|
|
28
|
+
|
|
29
|
+
if (channel === 'polite') polite = next
|
|
30
|
+
else assertive = next
|
|
31
|
+
|
|
32
|
+
if (!next.isConnected) document.body.append(next)
|
|
33
|
+
return next
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const send = (channel: Channel, message: string) => {
|
|
37
|
+
|
|
38
|
+
const target = region(channel)
|
|
39
|
+
|
|
40
|
+
target.textContent = ''
|
|
41
|
+
queueMicrotask(() => target.textContent = message)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Screen-reader announcements through lazily created document-lifetime aria-live regions. */
|
|
45
|
+
export const announce = (host: Host) => {
|
|
46
|
+
if (!dom(host)) {
|
|
47
|
+
return {
|
|
48
|
+
polite(_message: string) { },
|
|
49
|
+
assertive(_message: string) { },
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
polite(message: string) {
|
|
55
|
+
send('polite', message)
|
|
56
|
+
},
|
|
57
|
+
assertive(message: string) {
|
|
58
|
+
send('assertive', message)
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Host } from './core'
|
|
2
|
+
|
|
3
|
+
/** Controlled/uncontrolled value unification for open/value-style component args. */
|
|
4
|
+
export const controlled = <T>(host: Host, opts: {
|
|
5
|
+
/** Initial uncontrolled value. */
|
|
6
|
+
fallback: T
|
|
7
|
+
/** Called whenever set() or accept() changes the value. */
|
|
8
|
+
onChange?: (value: T, event?: Event) => void
|
|
9
|
+
}) => {
|
|
10
|
+
let local = opts.fallback
|
|
11
|
+
let value = local
|
|
12
|
+
let bound = false
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
get value() {
|
|
16
|
+
return value
|
|
17
|
+
},
|
|
18
|
+
get controlled() {
|
|
19
|
+
return bound
|
|
20
|
+
},
|
|
21
|
+
/** undefined = uncontrolled; any other value (null included) binds. */
|
|
22
|
+
sync(arg: T | null | undefined) {
|
|
23
|
+
bound = arg !== undefined
|
|
24
|
+
value = bound ? arg as T : local
|
|
25
|
+
return value
|
|
26
|
+
},
|
|
27
|
+
set(next: T, event?: Event) {
|
|
28
|
+
opts.onChange?.(next, event)
|
|
29
|
+
if (!bound) local = next
|
|
30
|
+
host.next(() => value = next)
|
|
31
|
+
},
|
|
32
|
+
accept(next: T, event?: Event) {
|
|
33
|
+
if (!bound) local = next
|
|
34
|
+
host.next(() => value = next)
|
|
35
|
+
opts.onChange?.(next, event)
|
|
36
|
+
},
|
|
37
|
+
/** Seeds the uncontrolled value without notifying onChange (lazy defaults). */
|
|
38
|
+
init(next: T) {
|
|
39
|
+
if (bound) return
|
|
40
|
+
local = next
|
|
41
|
+
host.next(() => value = next)
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/core.ts
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import type { Host as AjoHost } from 'ajo'
|
|
2
|
+
|
|
3
|
+
/** Ajo host protocol that cloves bind lifecycle and invalidation to. */
|
|
4
|
+
export type Host<TElement extends object = HTMLElement, TArgs = Record<string, unknown>> = AjoHost<TElement, TArgs>
|
|
5
|
+
|
|
6
|
+
/** True when a value is structurally a DOM element in a document-bearing runtime. */
|
|
7
|
+
export const dom = (value: unknown): value is Element =>
|
|
8
|
+
typeof document != 'undefined' &&
|
|
9
|
+
(value as { nodeType?: unknown } | null)?.nodeType === 1
|
|
10
|
+
|
|
11
|
+
/** True when both Window and Document globals are available. */
|
|
12
|
+
export const browser = () =>
|
|
13
|
+
typeof window != 'undefined' && typeof document != 'undefined'
|
|
14
|
+
|
|
15
|
+
const statefulArg = (key: string) =>
|
|
16
|
+
key === 'key' || key === 'memo' || key === 'ref' || key === 'skip' || key.startsWith('set:')
|
|
17
|
+
|
|
18
|
+
/** Maps rest attrs onto an Ajo stateful host, prefixing DOM attributes with `attr:`. */
|
|
19
|
+
export const statefulRootAttrs = (attrs: Record<string, unknown>) => {
|
|
20
|
+
const result: Record<string, unknown> = {}
|
|
21
|
+
for (const [key, value] of Object.entries(attrs)) result[statefulArg(key) ? key : `attr:${key}`] = value
|
|
22
|
+
return result
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Calls an externally supplied DOM event handler when it is a function. */
|
|
26
|
+
export const callHandler = <EventType extends Event>(handler: unknown, event: EventType) => {
|
|
27
|
+
if (typeof handler === 'function') (handler as (event: EventType) => void)(event)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Calls an externally supplied Ajo ref callback when it is a function. */
|
|
31
|
+
export const callRef = <ElementType>(ref: unknown, element: ElementType | null) => {
|
|
32
|
+
if (typeof ref === 'function') (ref as (element: ElementType | null) => void)(element)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const hostSignal = (host: Host, signal?: AbortSignal) =>
|
|
36
|
+
signal && signal !== host.signal ? AbortSignal.any([signal, host.signal]) : host.signal
|
|
37
|
+
|
|
38
|
+
/** Adds a listener to a DOM host for at most the caller and host lifetimes. */
|
|
39
|
+
export const listen = <EventType extends keyof GlobalEventHandlersEventMap>(
|
|
40
|
+
host: Host,
|
|
41
|
+
type: EventType,
|
|
42
|
+
handler: (event: GlobalEventHandlersEventMap[EventType]) => void,
|
|
43
|
+
opts?: AddEventListenerOptions,
|
|
44
|
+
) => {
|
|
45
|
+
if (!dom(host)) return
|
|
46
|
+
host.addEventListener(type, handler, { ...opts, signal: hostSignal(host, opts?.signal) })
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Adds a listener bound to the host lifecycle. */
|
|
50
|
+
export function on<K extends keyof GlobalEventHandlersEventMap>(
|
|
51
|
+
target: EventTarget,
|
|
52
|
+
type: K,
|
|
53
|
+
fn: (event: GlobalEventHandlersEventMap[K]) => void,
|
|
54
|
+
host: Host,
|
|
55
|
+
opts?: AddEventListenerOptions,
|
|
56
|
+
): void
|
|
57
|
+
export function on(
|
|
58
|
+
target: EventTarget,
|
|
59
|
+
type: string,
|
|
60
|
+
fn: EventListener,
|
|
61
|
+
host: Host,
|
|
62
|
+
opts?: AddEventListenerOptions,
|
|
63
|
+
): void
|
|
64
|
+
export function on(
|
|
65
|
+
target: EventTarget,
|
|
66
|
+
type: string,
|
|
67
|
+
fn: EventListener,
|
|
68
|
+
host: Host,
|
|
69
|
+
opts?: AddEventListenerOptions,
|
|
70
|
+
) {
|
|
71
|
+
target.addEventListener(type, fn, { ...opts, signal: hostSignal(host, opts?.signal) })
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
type SharedStop = () => void
|
|
75
|
+
|
|
76
|
+
const sources = new Map<string, {
|
|
77
|
+
stop: SharedStop
|
|
78
|
+
subscribers: Set<() => void>
|
|
79
|
+
}>()
|
|
80
|
+
|
|
81
|
+
/** Subscribes to a lazily-started shared source; the real source stops with the last unsubscribe. */
|
|
82
|
+
export const shared = (
|
|
83
|
+
key: string,
|
|
84
|
+
start: (notify: () => void) => SharedStop,
|
|
85
|
+
fn: () => void,
|
|
86
|
+
signal: AbortSignal,
|
|
87
|
+
): void => {
|
|
88
|
+
|
|
89
|
+
if (signal.aborted) return
|
|
90
|
+
|
|
91
|
+
let source = sources.get(key)
|
|
92
|
+
const created = !source
|
|
93
|
+
|
|
94
|
+
if (!source) {
|
|
95
|
+
source = {
|
|
96
|
+
stop: () => { },
|
|
97
|
+
subscribers: new Set(),
|
|
98
|
+
}
|
|
99
|
+
sources.set(key, source)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
source.subscribers.add(fn)
|
|
103
|
+
|
|
104
|
+
const unsubscribe = () => {
|
|
105
|
+
|
|
106
|
+
const current = sources.get(key)
|
|
107
|
+
|
|
108
|
+
if (!current) return
|
|
109
|
+
|
|
110
|
+
current.subscribers.delete(fn)
|
|
111
|
+
|
|
112
|
+
if (current.subscribers.size) return
|
|
113
|
+
|
|
114
|
+
current.stop()
|
|
115
|
+
sources.delete(key)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
signal.addEventListener('abort', unsubscribe, { once: true })
|
|
119
|
+
|
|
120
|
+
if (!created) return
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
source.stop = start(() => {
|
|
124
|
+
|
|
125
|
+
const current = sources.get(key)
|
|
126
|
+
|
|
127
|
+
if (!current) return
|
|
128
|
+
|
|
129
|
+
for (const subscriber of [...current.subscribers]) subscriber()
|
|
130
|
+
})
|
|
131
|
+
} catch (error) {
|
|
132
|
+
unsubscribe()
|
|
133
|
+
throw error
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Wraps fn so multiple calls within one frame collapse into one run on the next frame. */
|
|
138
|
+
export const frame = (fn: () => void): (() => void) & { cancel(): void } => {
|
|
139
|
+
|
|
140
|
+
let handle: number | undefined
|
|
141
|
+
|
|
142
|
+
const run = () => {
|
|
143
|
+
handle = undefined
|
|
144
|
+
fn()
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const schedule = (() => {
|
|
148
|
+
|
|
149
|
+
if (typeof requestAnimationFrame == 'undefined') {
|
|
150
|
+
fn()
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (handle != null) return
|
|
155
|
+
|
|
156
|
+
handle = requestAnimationFrame(run)
|
|
157
|
+
|
|
158
|
+
}) as (() => void) & { cancel(): void }
|
|
159
|
+
|
|
160
|
+
schedule.cancel = () => {
|
|
161
|
+
|
|
162
|
+
if (handle == null) return
|
|
163
|
+
if (typeof cancelAnimationFrame != 'undefined') cancelAnimationFrame(handle)
|
|
164
|
+
|
|
165
|
+
handle = undefined
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return schedule
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Owns frame-coalesced binding to one live element target at a time. */
|
|
172
|
+
export const live = <T extends Element>(host: Host, opts: {
|
|
173
|
+
target: () => T | null | undefined
|
|
174
|
+
onChange: (element: T) => void
|
|
175
|
+
bind: (element: T, notify: () => void, signal: AbortSignal) => void
|
|
176
|
+
}) => {
|
|
177
|
+
const signal = host.signal
|
|
178
|
+
let target: T | undefined
|
|
179
|
+
let scope: AbortController | undefined
|
|
180
|
+
|
|
181
|
+
const schedule = frame(() => {
|
|
182
|
+
const current = target
|
|
183
|
+
if (current) opts.onChange(current)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
const stop = () => {
|
|
187
|
+
scope?.abort()
|
|
188
|
+
scope = undefined
|
|
189
|
+
target = undefined
|
|
190
|
+
schedule.cancel()
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
signal.addEventListener('abort', stop, { once: true })
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
sync() {
|
|
197
|
+
if (signal.aborted) return
|
|
198
|
+
|
|
199
|
+
const next = opts.target() ?? undefined
|
|
200
|
+
if (next === target) return
|
|
201
|
+
|
|
202
|
+
scope?.abort()
|
|
203
|
+
scope = undefined
|
|
204
|
+
schedule.cancel()
|
|
205
|
+
target = next
|
|
206
|
+
|
|
207
|
+
if (!next) return
|
|
208
|
+
|
|
209
|
+
const controller = new AbortController()
|
|
210
|
+
const notify = () => {
|
|
211
|
+
if (!controller.signal.aborted && target === next) schedule()
|
|
212
|
+
}
|
|
213
|
+
scope = controller
|
|
214
|
+
try {
|
|
215
|
+
opts.bind(next, notify, controller.signal)
|
|
216
|
+
} catch (error) {
|
|
217
|
+
if (scope === controller) {
|
|
218
|
+
controller.abort()
|
|
219
|
+
scope = undefined
|
|
220
|
+
target = undefined
|
|
221
|
+
schedule.cancel()
|
|
222
|
+
}
|
|
223
|
+
throw error
|
|
224
|
+
}
|
|
225
|
+
notify()
|
|
226
|
+
},
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const counters: Record<string, number> = Object.create(null)
|
|
231
|
+
|
|
232
|
+
/** Monotonic per-prefix unique id. */
|
|
233
|
+
export const id = (prefix: string) => `${prefix}-${counters[prefix] = (counters[prefix] ?? 0) + 1}`
|
|
234
|
+
|
|
235
|
+
/** Stores a value while keeping at most `limit` insertion-ordered cache keys. */
|
|
236
|
+
export const remember = <Key, Value>(
|
|
237
|
+
cache: Map<Key, Value>,
|
|
238
|
+
key: Key,
|
|
239
|
+
value: Value,
|
|
240
|
+
limit = 32,
|
|
241
|
+
): Value => {
|
|
242
|
+
if (!Number.isInteger(limit) || limit < 1) throw new RangeError('remember limit must be a positive integer')
|
|
243
|
+
cache.set(key, value)
|
|
244
|
+
while (cache.size > limit) {
|
|
245
|
+
const oldest = cache.keys().next()
|
|
246
|
+
if (oldest.done) break
|
|
247
|
+
cache.delete(oldest.value)
|
|
248
|
+
}
|
|
249
|
+
return value
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Clamps a number into the inclusive [min, max] range. */
|
|
253
|
+
export const clamp = (value: number, min: number, max: number) =>
|
|
254
|
+
Math.min(Math.max(value, min), max)
|
package/src/dismiss.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Host } from './core'
|
|
2
|
+
import { dom, on } from './core'
|
|
3
|
+
|
|
4
|
+
/** Closes a surface on Escape within the given elements and/or pointerdown outside them. */
|
|
5
|
+
export const dismiss = (host: Host, opts: {
|
|
6
|
+
/** Gate that enables dismissal channels only while true. */
|
|
7
|
+
active: () => boolean
|
|
8
|
+
/** Additional elements treated as inside the surface, such as portaled content. */
|
|
9
|
+
inside?: () => (Element | null | undefined)[]
|
|
10
|
+
/** Escape scope. Defaults to host; document also catches focus that moved away. */
|
|
11
|
+
escape?: false | 'host' | 'document'
|
|
12
|
+
/** Also dismiss on pointerdown outside the host and the inside() elements. Default: false. */
|
|
13
|
+
outside?: boolean
|
|
14
|
+
/** preventDefault the Escape keydown (never applied to outside pointerdown). Default: false. */
|
|
15
|
+
prevent?: boolean
|
|
16
|
+
/** Receives the Escape keydown or outside pointerdown that requested dismissal. */
|
|
17
|
+
onDismiss: (event: Event) => void
|
|
18
|
+
}): void => {
|
|
19
|
+
if (!dom(host)) return
|
|
20
|
+
const document = host.ownerDocument
|
|
21
|
+
const view = document.defaultView
|
|
22
|
+
const node = (value: unknown): value is Node => Boolean(view && value instanceof view.Node)
|
|
23
|
+
|
|
24
|
+
const escape = opts.escape ?? 'host'
|
|
25
|
+
if (escape) {
|
|
26
|
+
on(escape === 'document' ? document : host, 'keydown', event => {
|
|
27
|
+
if (event.key !== 'Escape' || !opts.active()) return
|
|
28
|
+
|
|
29
|
+
if (escape === 'host' && opts.inside) {
|
|
30
|
+
const target = event.target
|
|
31
|
+
const elements = opts.inside().filter((element): element is Element => !!element)
|
|
32
|
+
|
|
33
|
+
if (elements.length && !(node(target) && elements.some(element => element.contains(target)))) return
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (opts.prevent) event.preventDefault()
|
|
37
|
+
opts.onDismiss(event)
|
|
38
|
+
}, host)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (opts.outside) {
|
|
42
|
+
on(document, 'pointerdown', event => {
|
|
43
|
+
if (!opts.active()) return
|
|
44
|
+
|
|
45
|
+
const target = event.target
|
|
46
|
+
if (!node(target)) return
|
|
47
|
+
if ((host as Element).contains(target)) return
|
|
48
|
+
if (opts.inside?.().some(element => element?.contains(target))) return
|
|
49
|
+
|
|
50
|
+
opts.onDismiss(event)
|
|
51
|
+
}, host, { capture: true })
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/grid.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Host } from './core'
|
|
2
|
+
|
|
3
|
+
/** Semantic 2D keyboard movement resolved from grid navigation keys. */
|
|
4
|
+
export type GridMove =
|
|
5
|
+
| { cols: number }
|
|
6
|
+
| { rows: number }
|
|
7
|
+
| { edge: 'start' | 'end'; extent: 'row' | 'all' }
|
|
8
|
+
| { page: number; large: boolean }
|
|
9
|
+
|
|
10
|
+
const move = (event: KeyboardEvent, rtl: boolean): GridMove | undefined => {
|
|
11
|
+
if (event.key === 'ArrowLeft') return { cols: rtl ? 1 : -1 }
|
|
12
|
+
if (event.key === 'ArrowRight') return { cols: rtl ? -1 : 1 }
|
|
13
|
+
if (event.key === 'ArrowUp') return { rows: -1 }
|
|
14
|
+
if (event.key === 'ArrowDown') return { rows: 1 }
|
|
15
|
+
if (event.key === 'Home') return { edge: 'start', extent: event.ctrlKey ? 'all' : 'row' }
|
|
16
|
+
if (event.key === 'End') return { edge: 'end', extent: event.ctrlKey ? 'all' : 'row' }
|
|
17
|
+
if (event.key === 'PageUp') return { page: -1, large: event.shiftKey }
|
|
18
|
+
if (event.key === 'PageDown') return { page: 1, large: event.shiftKey }
|
|
19
|
+
return undefined
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 2D grid keyboard navigation: resolves arrows/Home/End/PageUp/PageDown to semantic movement.
|
|
24
|
+
*
|
|
25
|
+
* The host parameter keeps the clove signature uniform; grid holds no
|
|
26
|
+
* per-host state and needs no cleanup.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```ts
|
|
30
|
+
* const nav = grid(this, { onMove: move => focusCell(move) })
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export const grid = (host: Host, opts: {
|
|
34
|
+
/** Right-to-left column direction. Default: false. */
|
|
35
|
+
rtl?: () => boolean
|
|
36
|
+
/** Receives the semantic movement resolved from a handled keydown. */
|
|
37
|
+
onMove: (move: GridMove, event: KeyboardEvent) => void
|
|
38
|
+
}) => {
|
|
39
|
+
void host
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
/** Routes a keydown. Returns true and prevents default for grid navigation keys. */
|
|
43
|
+
handle(event: KeyboardEvent): boolean {
|
|
44
|
+
const next = move(event, opts.rtl?.() ?? false)
|
|
45
|
+
if (!next) return false
|
|
46
|
+
|
|
47
|
+
event.preventDefault()
|
|
48
|
+
opts.onMove(next, event)
|
|
49
|
+
return true
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/hotkey.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Host } from './core'
|
|
2
|
+
import { dom, on } from './core'
|
|
3
|
+
|
|
4
|
+
const modifier = (token: string) =>
|
|
5
|
+
token === 'mod' || token === 'ctrl' || token === 'meta' || token === 'alt' || token === 'shift'
|
|
6
|
+
|
|
7
|
+
const matches = (chord: string, event: KeyboardEvent) => {
|
|
8
|
+
const tokens = chord
|
|
9
|
+
.split('+')
|
|
10
|
+
.map(token => token.trim().toLowerCase())
|
|
11
|
+
.filter(Boolean)
|
|
12
|
+
const key = tokens.find(token => !modifier(token))
|
|
13
|
+
|
|
14
|
+
if (!key) return false
|
|
15
|
+
|
|
16
|
+
const wantsMod = tokens.includes('mod')
|
|
17
|
+
const wantsCtrl = tokens.includes('ctrl')
|
|
18
|
+
const wantsMeta = tokens.includes('meta')
|
|
19
|
+
const wantsAlt = tokens.includes('alt')
|
|
20
|
+
const wantsShift = tokens.includes('shift')
|
|
21
|
+
const ctrl = event.ctrlKey
|
|
22
|
+
const meta = event.metaKey
|
|
23
|
+
|
|
24
|
+
if (wantsMod) {
|
|
25
|
+
if (ctrl === meta) return false
|
|
26
|
+
} else {
|
|
27
|
+
if (ctrl !== wantsCtrl || meta !== wantsMeta) return false
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (event.altKey !== wantsAlt) return false
|
|
31
|
+
if (event.shiftKey !== wantsShift) return false
|
|
32
|
+
|
|
33
|
+
return event.key.toLowerCase() === key
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Global keyboard shortcut for a single chord like 'mod+b' or 'F8'. */
|
|
37
|
+
export const hotkey = (host: Host, opts: {
|
|
38
|
+
/** Chord: '+'-separated modifiers (mod|ctrl|meta|alt|shift) plus one key token, e.g. 'mod+b', 'F8'. */
|
|
39
|
+
keys: () => string
|
|
40
|
+
/** Called when the active chord matches a keydown. */
|
|
41
|
+
onPress: (event: KeyboardEvent) => void
|
|
42
|
+
/** Gate. Default: () => true. */
|
|
43
|
+
active?: () => boolean
|
|
44
|
+
/** preventDefault on match. Default: true. */
|
|
45
|
+
prevent?: boolean
|
|
46
|
+
}): void => {
|
|
47
|
+
if (!dom(host)) return
|
|
48
|
+
|
|
49
|
+
on(window, 'keydown', event => {
|
|
50
|
+
if (!matches(opts.keys(), event)) return
|
|
51
|
+
if (!(opts.active?.() ?? true)) return
|
|
52
|
+
|
|
53
|
+
if (opts.prevent ?? true) event.preventDefault()
|
|
54
|
+
opts.onPress(event)
|
|
55
|
+
}, host)
|
|
56
|
+
}
|
package/src/hover.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Host } from './core'
|
|
2
|
+
import { timer } from './timer'
|
|
3
|
+
|
|
4
|
+
/** Hover intent: zones hold the surface open; delays gate open/close transitions. */
|
|
5
|
+
export const hover = (host: Host, opts: {
|
|
6
|
+
/** Delay before the first held zone opens the surface, in ms. Default: 0. */
|
|
7
|
+
openDelay?: () => number
|
|
8
|
+
/** Delay before the last released zone closes the surface, in ms. Default: 0. */
|
|
9
|
+
closeDelay?: () => number
|
|
10
|
+
/** Called when the held-zone state opens or closes the surface. */
|
|
11
|
+
onChange: (open: boolean, event: Event) => void
|
|
12
|
+
}) => {
|
|
13
|
+
const zones = new Set<string>()
|
|
14
|
+
const opening = timer(host)
|
|
15
|
+
const closing = timer(host)
|
|
16
|
+
let opened = false
|
|
17
|
+
|
|
18
|
+
const flip = (next: boolean, event: Event) => {
|
|
19
|
+
if (opened === next) return
|
|
20
|
+
opened = next
|
|
21
|
+
opts.onChange(next, event)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
get open() {
|
|
26
|
+
return opened
|
|
27
|
+
},
|
|
28
|
+
hold(zone: string, event: Event) {
|
|
29
|
+
zones.add(zone)
|
|
30
|
+
closing.stop()
|
|
31
|
+
if (opened || opening.running) return
|
|
32
|
+
|
|
33
|
+
const delay = opts.openDelay?.() ?? 0
|
|
34
|
+
if (delay <= 0) flip(true, event)
|
|
35
|
+
else opening.start(delay, () => flip(true, event))
|
|
36
|
+
},
|
|
37
|
+
release(zone: string, event: Event) {
|
|
38
|
+
zones.delete(zone)
|
|
39
|
+
if (zones.size) return
|
|
40
|
+
|
|
41
|
+
opening.stop()
|
|
42
|
+
if (!opened) return
|
|
43
|
+
|
|
44
|
+
const delay = opts.closeDelay?.() ?? 0
|
|
45
|
+
if (delay <= 0) flip(false, event)
|
|
46
|
+
else closing.start(delay, () => flip(false, event))
|
|
47
|
+
},
|
|
48
|
+
sync(open: boolean) {
|
|
49
|
+
opened = open
|
|
50
|
+
},
|
|
51
|
+
/** Stops pending transitions and forgets every held zone without notifying. */
|
|
52
|
+
cancel() {
|
|
53
|
+
opening.stop()
|
|
54
|
+
closing.stop()
|
|
55
|
+
zones.clear()
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export { announce } from './announce'
|
|
2
|
+
export {
|
|
3
|
+
browser,
|
|
4
|
+
callHandler,
|
|
5
|
+
callRef,
|
|
6
|
+
clamp,
|
|
7
|
+
dom,
|
|
8
|
+
frame,
|
|
9
|
+
type Host,
|
|
10
|
+
id,
|
|
11
|
+
listen,
|
|
12
|
+
remember,
|
|
13
|
+
shared,
|
|
14
|
+
statefulRootAttrs,
|
|
15
|
+
} from './core'
|
|
16
|
+
export { controlled } from './controlled'
|
|
17
|
+
export { dismiss } from './dismiss'
|
|
18
|
+
export { grid, type GridMove } from './grid'
|
|
19
|
+
export { hotkey } from './hotkey'
|
|
20
|
+
export { hover } from './hover'
|
|
21
|
+
export { indicator } from './indicator'
|
|
22
|
+
export { label, type LabelView } from './label'
|
|
23
|
+
export { media } from './media'
|
|
24
|
+
export { move } from './move'
|
|
25
|
+
export { overflow } from './overflow'
|
|
26
|
+
export { resize } from './resize'
|
|
27
|
+
export { restore } from './restore'
|
|
28
|
+
export { roving } from './roving'
|
|
29
|
+
export { scheme } from './scheme'
|
|
30
|
+
export { scrolling } from './scrolling'
|
|
31
|
+
export { selection } from './selection'
|
|
32
|
+
export { spin, type SpinMove } from './spin'
|
|
33
|
+
export { storage } from './storage'
|
|
34
|
+
export { timer } from './timer'
|
|
35
|
+
export { typeahead } from './typeahead'
|
|
36
|
+
export { visibility } from './visibility'
|