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/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
+ }
@@ -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'