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/src/resize.ts ADDED
@@ -0,0 +1,93 @@
1
+ import type { Host } from './core'
2
+ import { dom, live } from './core'
3
+
4
+ type Callback = (el: Element) => void
5
+
6
+ const registry = new WeakMap<Element, Set<Callback>>()
7
+ let observer: ResizeObserver | undefined
8
+ let subscriptions = 0
9
+
10
+ const notify: ResizeObserverCallback = (entries, source) => {
11
+ if (source !== observer) return
12
+ for (const entry of entries) {
13
+ const callbacks = registry.get(entry.target)
14
+ if (!callbacks) continue
15
+
16
+ for (const callback of [...callbacks]) callback(entry.target)
17
+ }
18
+ }
19
+
20
+ const subscribe = (el: Element, fn: Callback, signal: AbortSignal) => {
21
+ if (signal.aborted) return
22
+
23
+ observer ??= new ResizeObserver(notify)
24
+ const currentObserver = observer
25
+
26
+ let callbacks = registry.get(el)
27
+ if (!callbacks) {
28
+ callbacks = new Set()
29
+ try {
30
+ currentObserver.observe(el)
31
+ } catch (error) {
32
+ try { currentObserver.unobserve(el) } catch { }
33
+ if (!subscriptions) {
34
+ try { currentObserver.disconnect() } catch { }
35
+ if (observer === currentObserver) observer = undefined
36
+ }
37
+ throw error
38
+ }
39
+ registry.set(el, callbacks)
40
+ }
41
+
42
+ callbacks.add(fn)
43
+ subscriptions++
44
+
45
+ const unsubscribe = () => {
46
+ const current = registry.get(el)
47
+ if (!current?.delete(fn)) return
48
+
49
+ subscriptions--
50
+ if (!current.size) {
51
+ registry.delete(el)
52
+ observer?.unobserve(el)
53
+ }
54
+
55
+ if (subscriptions) return
56
+
57
+ observer?.disconnect()
58
+ observer = undefined
59
+ }
60
+
61
+ signal.addEventListener('abort', unsubscribe, { once: true })
62
+ }
63
+
64
+ /**
65
+ * Shared ResizeObserver notification for a live target element.
66
+ *
67
+ * @example
68
+ * ```ts
69
+ * const size = resize(this, { target: () => panel, onResize: el => measure(el) })
70
+ * while (true) {
71
+ * size.sync()
72
+ * yield <section ref={el => panel = el} />
73
+ * }
74
+ * ```
75
+ */
76
+ export const resize = (host: Host, opts: {
77
+ /** Live element to observe; call sync() after refs may have changed. */
78
+ target: () => Element | null | undefined
79
+ /** Frame-coalesced on size change and on retarget or initial sync. */
80
+ onResize: (el: Element) => void
81
+ }) => {
82
+ if (!dom(host) || typeof ResizeObserver == 'undefined') {
83
+ return {
84
+ sync() {},
85
+ }
86
+ }
87
+
88
+ return live(host, {
89
+ target: opts.target,
90
+ onChange: opts.onResize,
91
+ bind: (element, notify, signal) => subscribe(element, notify, signal),
92
+ })
93
+ }
package/src/restore.ts ADDED
@@ -0,0 +1,29 @@
1
+ import type { Host } from './core'
2
+ import { dom } from './core'
3
+
4
+ /** Captures the focused element and returns focus to it later. */
5
+ export const restore = (host: Host) => {
6
+ const inert = {
7
+ capture(_element?: HTMLElement | null) {},
8
+ restore() {},
9
+ }
10
+
11
+ if (!dom(host)) return inert
12
+
13
+ let captured: HTMLElement | null = null
14
+
15
+ return {
16
+ capture(element?: HTMLElement | null) {
17
+ captured = arguments.length ? element ?? null : document.activeElement as HTMLElement | null
18
+ },
19
+ restore() {
20
+ const element = captured
21
+ captured = null
22
+ if (!element) return
23
+
24
+ queueMicrotask(() => {
25
+ if (element.isConnected) element.focus()
26
+ })
27
+ },
28
+ }
29
+ }
package/src/roving.ts ADDED
@@ -0,0 +1,77 @@
1
+ import type { Host } from './core'
2
+ import { dom } from './core'
3
+
4
+ type RovingStep = number | 'first' | 'last'
5
+ type RovingOrientation = 'horizontal' | 'vertical' | 'both'
6
+
7
+ const keyStep = (event: KeyboardEvent, orientation: RovingOrientation, dir: 'ltr' | 'rtl'): RovingStep | undefined => {
8
+ if (event.key === 'Home') return 'first'
9
+ if (event.key === 'End') return 'last'
10
+
11
+ if (orientation === 'vertical' || orientation === 'both') {
12
+ if (event.key === 'ArrowUp') return -1
13
+ if (event.key === 'ArrowDown') return 1
14
+ }
15
+
16
+ if (orientation === 'horizontal' || orientation === 'both') {
17
+ if (event.key === 'ArrowLeft') return dir === 'rtl' ? 1 : -1
18
+ if (event.key === 'ArrowRight') return dir === 'rtl' ? -1 : 1
19
+ }
20
+
21
+ return undefined
22
+ }
23
+
24
+ /** List keyboard navigation: resolves arrow/Home/End movement over items; consumers apply focus or virtual effects. */
25
+ export const roving = (host: Host, opts: {
26
+ /** Live list of navigable elements, in visual order. */
27
+ items: () => HTMLElement[]
28
+ /** Axis whose arrow keys move the active item. */
29
+ orientation?: () => RovingOrientation
30
+ /** Text direction for horizontal arrows. */
31
+ dir?: () => 'ltr' | 'rtl'
32
+ /** Wrap at the ends. */
33
+ loop?: () => boolean
34
+ /** Element the next move starts from. */
35
+ current?: () => HTMLElement | null | undefined
36
+ /** Applies the movement. */
37
+ onMove: (target: HTMLElement, event: KeyboardEvent) => void
38
+ }) => {
39
+ const inert = {
40
+ handle: (_event: KeyboardEvent) => false,
41
+ move: (_step: RovingStep, _event: KeyboardEvent) => false,
42
+ }
43
+
44
+ if (!dom(host)) return inert
45
+
46
+ const move = (step: RovingStep, event: KeyboardEvent) => {
47
+ const list = opts.items()
48
+ if (!list.length) return false
49
+
50
+ const active = opts.current?.() ?? document.activeElement
51
+ const index = Math.max(0, list.indexOf(active as HTMLElement))
52
+ const target = step === 'first'
53
+ ? list[0]
54
+ : step === 'last'
55
+ ? list[list.length - 1]
56
+ : list[index + step] ?? ((opts.loop?.() ?? true) ? list[step > 0 ? 0 : list.length - 1] : undefined)
57
+
58
+ if (!target) return false
59
+
60
+ opts.onMove(target, event)
61
+ return true
62
+ }
63
+
64
+ return {
65
+ // A recognized navigation key is always consumed when there are items,
66
+ // even when a loopless boundary produces no movement (no scroll fallthrough).
67
+ handle(event: KeyboardEvent) {
68
+ const step = keyStep(event, opts.orientation?.() ?? 'vertical', opts.dir?.() ?? 'ltr')
69
+ if (step == null || !opts.items().length) return false
70
+
71
+ event.preventDefault()
72
+ move(step, event)
73
+ return true
74
+ },
75
+ move,
76
+ }
77
+ }
package/src/scheme.ts ADDED
@@ -0,0 +1,25 @@
1
+ import type { Host } from './core'
2
+ import { media } from './media'
3
+
4
+ const dark = '(prefers-color-scheme: dark)'
5
+
6
+ /**
7
+ * Reactive OS color-scheme preference.
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * const color = scheme(this)
12
+ * yield <span>{color.dark ? 'dark' : 'light'}</span>
13
+ * ```
14
+ */
15
+ export const scheme = (host: Host) => {
16
+ const query = media(host, { query: () => dark })
17
+
18
+ query.sync()
19
+
20
+ return {
21
+ get dark() {
22
+ return query.matches
23
+ },
24
+ }
25
+ }
@@ -0,0 +1,40 @@
1
+ import type { Host } from './core'
2
+ import { dom, live, on } from './core'
3
+
4
+ /**
5
+ * Scroll tracking for a live target element with leak-proof retargeting.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * const track = scrolling(this, { target: () => list, onScroll: el => read(el.scrollTop) })
10
+ * while (true) {
11
+ * track.sync()
12
+ * yield <div ref={el => list = el} />
13
+ * }
14
+ * ```
15
+ */
16
+ export const scrolling = (host: Host, opts: {
17
+ /** Live element to observe; call sync() after refs may have changed. */
18
+ target: () => HTMLElement | null | undefined
19
+ /** Frame-coalesced on scroll and on retarget or initial sync. */
20
+ onScroll: (el: HTMLElement) => void
21
+ /** Uncoalesced scrollend passthrough. */
22
+ onEnd?: (el: HTMLElement) => void
23
+ }) => {
24
+ if (!dom(host)) {
25
+ return {
26
+ sync() {},
27
+ }
28
+ }
29
+
30
+ return live(host, {
31
+ target: opts.target,
32
+ onChange: opts.onScroll,
33
+ bind: (element, notify, signal) => {
34
+ on(element, 'scroll', notify, host, { passive: true, signal })
35
+ if (opts.onEnd) {
36
+ on(element, 'scrollend', () => opts.onEnd?.(element), host, { passive: true, signal })
37
+ }
38
+ },
39
+ })
40
+ }
@@ -0,0 +1,55 @@
1
+ import type { Host } from './core'
2
+ import { controlled } from './controlled'
3
+
4
+ /** Single/multi selection semantics over a controlled list of values. */
5
+ export const selection = (host: Host, opts: {
6
+ /** Multi-select mode. */
7
+ multiple?: () => boolean
8
+ /** Forbid deselecting the last selected value. */
9
+ required?: () => boolean
10
+ /** Initial uncontrolled values. */
11
+ fallback?: string[]
12
+ /** Called whenever values change. */
13
+ onChange?: (values: string[], event?: Event) => void
14
+ }) => {
15
+ const state = controlled(host, {
16
+ fallback: [...(opts.fallback ?? [])],
17
+ onChange: opts.onChange,
18
+ })
19
+
20
+ return {
21
+ get values() {
22
+ return state.value
23
+ },
24
+ has(value: string) {
25
+ return state.value.includes(value)
26
+ },
27
+ toggle(value: string, event?: Event) {
28
+ const base = state.value
29
+ const exists = base.includes(value)
30
+ let next: string[]
31
+
32
+ if (opts.multiple?.() ?? false) {
33
+ if (exists) {
34
+ if ((opts.required?.() ?? false) && base.length <= 1) return
35
+ next = base.filter(item => item !== value)
36
+ } else {
37
+ next = Array.from(new Set([...base, value]))
38
+ }
39
+ } else if (exists) {
40
+ if (opts.required?.() ?? false) return
41
+ next = []
42
+ } else {
43
+ next = [value]
44
+ }
45
+
46
+ state.set(next, event)
47
+ },
48
+ set(values: string[], event?: Event) {
49
+ state.set([...values], event)
50
+ },
51
+ sync(values: string[] | null | undefined) {
52
+ return state.sync(values == null ? undefined : [...values])
53
+ },
54
+ }
55
+ }
package/src/spin.ts ADDED
@@ -0,0 +1,48 @@
1
+ import type { Host } from './core'
2
+
3
+ /** Semantic spinbutton movement resolved from stepping keys. */
4
+ export type SpinMove =
5
+ | { step: 1 | -1 }
6
+ | { page: 1 | -1 }
7
+ | { edge: 'min' | 'max' }
8
+
9
+ const move = (event: KeyboardEvent): SpinMove | undefined => {
10
+ if (event.key === 'ArrowUp') return { step: 1 }
11
+ if (event.key === 'ArrowDown') return { step: -1 }
12
+ if (event.key === 'PageUp') return { page: 1 }
13
+ if (event.key === 'PageDown') return { page: -1 }
14
+ if (event.key === 'Home') return { edge: 'min' }
15
+ if (event.key === 'End') return { edge: 'max' }
16
+ return undefined
17
+ }
18
+
19
+ /**
20
+ * Spinbutton keyboard stepping: resolves ArrowUp/ArrowDown/PageUp/PageDown/Home/End to semantic movement.
21
+ *
22
+ * The host parameter keeps the clove signature uniform; spin holds no
23
+ * per-host state and needs no cleanup. Aria and value state stay in the
24
+ * consumer; composes with roving when the consumer dispatches spin first.
25
+ *
26
+ * @example
27
+ * ```ts
28
+ * const stepper = spin(this, { onMove: move => stepValue(move) })
29
+ * ```
30
+ */
31
+ export const spin = (host: Host, opts: {
32
+ /** Receives the semantic movement resolved from a handled keydown. */
33
+ onMove: (move: SpinMove, event: KeyboardEvent) => void
34
+ }) => {
35
+ void host
36
+
37
+ return {
38
+ /** Routes a keydown. Returns true and prevents default for spin keys. */
39
+ handle(event: KeyboardEvent): boolean {
40
+ const next = move(event)
41
+ if (!next) return false
42
+
43
+ event.preventDefault()
44
+ opts.onMove(next, event)
45
+ return true
46
+ },
47
+ }
48
+ }
package/src/storage.ts ADDED
@@ -0,0 +1,146 @@
1
+ import type { Host } from './core'
2
+ import { dom, shared } from './core'
3
+
4
+ type Area = 'local' | 'session'
5
+
6
+ type StorageOptions = {
7
+ /** Live storage key. */
8
+ key: () => string
9
+ /** Value returned when storage is unavailable or the key is missing. */
10
+ fallback: string
11
+ /** Storage area. Default: 'local'. */
12
+ area?: Area
13
+ }
14
+
15
+ let last: StorageEvent | undefined
16
+
17
+ const source = (notify: () => void) => {
18
+ const handler = (event: StorageEvent) => {
19
+ last = event
20
+ notify()
21
+ }
22
+
23
+ window.addEventListener('storage', handler)
24
+ return () => {
25
+ window.removeEventListener('storage', handler)
26
+ last = undefined
27
+ }
28
+ }
29
+
30
+ const area = (name: Area) => {
31
+ try {
32
+ return name === 'local' ? window.localStorage : window.sessionStorage
33
+ } catch {
34
+ return undefined
35
+ }
36
+ }
37
+
38
+ const get = (name: Area, key: string, fallback: string) => {
39
+ const store = area(name)
40
+ if (!store) return fallback
41
+
42
+ try {
43
+ return store.getItem(key) ?? fallback
44
+ } catch {
45
+ return fallback
46
+ }
47
+ }
48
+
49
+ const put = (name: Area, key: string, value: string) => {
50
+ const store = area(name)
51
+ if (!store) return
52
+
53
+ try {
54
+ store.setItem(key, value)
55
+ } catch { }
56
+ }
57
+
58
+ const drop = (name: Area, key: string) => {
59
+ const store = area(name)
60
+ if (!store) return
61
+
62
+ try {
63
+ store.removeItem(key)
64
+ } catch { }
65
+ }
66
+
67
+ /**
68
+ * Reactive localStorage or sessionStorage string value with cross-tab sync.
69
+ *
70
+ * @example
71
+ * ```ts
72
+ * const theme = storage(this, { key: () => 'theme', fallback: 'light' })
73
+ * yield <button set:onclick={() => theme.set('dark')}>{theme.value}</button>
74
+ * ```
75
+ */
76
+ export const storage = (host: Host, opts: StorageOptions) => {
77
+ const name = opts.area ?? 'local'
78
+
79
+ if (!dom(host)) {
80
+ return {
81
+ get value() {
82
+ return opts.fallback
83
+ },
84
+ set(_next: string) {},
85
+ remove() {},
86
+ }
87
+ }
88
+
89
+ let key = opts.key()
90
+ let current = get(name, key, opts.fallback)
91
+
92
+ const sync = () => {
93
+ if (host.signal.aborted) return
94
+ const next = opts.key()
95
+ if (next === key) return
96
+
97
+ host.next(() => {
98
+ key = next
99
+ current = get(name, key, opts.fallback)
100
+ })
101
+ }
102
+
103
+ const set = (next: string) => {
104
+ host.next(() => {
105
+ current = next
106
+ })
107
+ }
108
+
109
+ shared('storage', source, () => {
110
+ sync()
111
+
112
+ const event = last
113
+ const store = area(name)
114
+
115
+ if (event?.storageArea && store && event.storageArea !== store) return
116
+ if (event?.key != null && event.key !== key) return
117
+
118
+ const next = event?.key == null ? opts.fallback
119
+ : event.key === key ? event.newValue ?? opts.fallback
120
+ : get(name, key, opts.fallback)
121
+
122
+ if (next === current) return
123
+ host.next(() => {
124
+ current = next
125
+ })
126
+ }, host.signal)
127
+
128
+ return {
129
+ get value() {
130
+ sync()
131
+ return current
132
+ },
133
+ set(next: string) {
134
+ if (host.signal.aborted) return
135
+ sync()
136
+ put(name, key, next)
137
+ set(next)
138
+ },
139
+ remove() {
140
+ if (host.signal.aborted) return
141
+ sync()
142
+ drop(name, key)
143
+ set(opts.fallback)
144
+ },
145
+ }
146
+ }
package/src/timer.ts ADDED
@@ -0,0 +1,70 @@
1
+ import type { Host } from './core'
2
+
3
+ /** Managed one-shot timer with pause/resume, auto-cleared with the host lifecycle. */
4
+ export const timer = (host: Host) => {
5
+ const signal = host.signal
6
+ let handle: ReturnType<typeof setTimeout> | undefined
7
+ let task: (() => void) | undefined
8
+ let due = 0
9
+ let left = 0
10
+ let paused = false
11
+
12
+ const stop = () => {
13
+ if (handle != null) clearTimeout(handle)
14
+ handle = undefined
15
+ task = undefined
16
+ due = 0
17
+ left = 0
18
+ paused = false
19
+ }
20
+
21
+ const done = () => {
22
+ const fn = task
23
+ handle = undefined
24
+ task = undefined
25
+ due = 0
26
+ left = 0
27
+ paused = false
28
+ fn?.()
29
+ }
30
+
31
+ const schedule = (ms: number) => {
32
+ left = Math.max(0, ms)
33
+ due = Date.now() + left
34
+ handle = setTimeout(done, left)
35
+ }
36
+
37
+ const view = {
38
+ start(ms: number, fn: () => void) {
39
+ stop()
40
+ if (signal.aborted) return
41
+ task = fn
42
+ paused = false
43
+ schedule(ms)
44
+ },
45
+ stop,
46
+ pause() {
47
+ if (handle == null) return
48
+ left = Math.max(0, due - Date.now())
49
+ clearTimeout(handle)
50
+ handle = undefined
51
+ paused = true
52
+ },
53
+ resume() {
54
+ if (!paused || !task) return
55
+ paused = false
56
+ schedule(left)
57
+ },
58
+ get running() {
59
+ return handle != null
60
+ },
61
+ get remaining() {
62
+ if (handle) return Math.max(0, due - Date.now())
63
+ return paused ? left : 0
64
+ },
65
+ }
66
+
67
+ signal.addEventListener('abort', stop, { once: true })
68
+
69
+ return view
70
+ }
@@ -0,0 +1,50 @@
1
+ import type { Host } from './core'
2
+ import { dom } from './core'
3
+ import { timer } from './timer'
4
+
5
+ const label = (item: HTMLElement) => item.dataset.label ?? item.textContent?.trim() ?? ''
6
+
7
+ const printable = (event: KeyboardEvent) =>
8
+ event.key.length === 1 && event.key !== ' ' && !event.ctrlKey && !event.metaKey && !event.altKey
9
+
10
+ /** Typeahead: buffers printable keys and matches by label prefix without preventing default. */
11
+ export const typeahead = (host: Host, opts: {
12
+ /** Live list of searchable elements. */
13
+ items: () => HTMLElement[]
14
+ /** Label text for an item. */
15
+ text?: (item: HTMLElement) => string
16
+ /** Buffer reset delay in ms. */
17
+ delay?: () => number
18
+ /** Applies a matching item. */
19
+ onMatch: (item: HTMLElement, event: KeyboardEvent) => void
20
+ }) => {
21
+ const inert = {
22
+ handle: (_event: KeyboardEvent) => false,
23
+ reset() {},
24
+ }
25
+
26
+ if (!dom(host)) return inert
27
+
28
+ const resetter = timer(host)
29
+ let query = ''
30
+
31
+ const reset = () => {
32
+ query = ''
33
+ resetter.stop()
34
+ }
35
+
36
+ return {
37
+ handle(event: KeyboardEvent) {
38
+ if (!printable(event)) return false
39
+
40
+ query = `${query}${event.key}`.toLowerCase()
41
+ resetter.start(opts.delay?.() ?? 600, () => query = '')
42
+
43
+ const text = opts.text ?? label
44
+ const match = opts.items().find(item => text(item).toLowerCase().startsWith(query))
45
+ if (match) opts.onMatch(match, event)
46
+ return true
47
+ },
48
+ reset,
49
+ }
50
+ }
@@ -0,0 +1,45 @@
1
+ import type { Host } from './core'
2
+ import { dom, shared } from './core'
3
+
4
+ const start = (notify: () => void) => {
5
+ document.addEventListener('visibilitychange', notify)
6
+ return () => document.removeEventListener('visibilitychange', notify)
7
+ }
8
+
9
+ const read = () => document.visibilityState !== 'hidden'
10
+
11
+ /**
12
+ * Reactive document visibility.
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * const page = visibility(this)
17
+ * yield <span>{page.visible ? 'visible' : 'hidden'}</span>
18
+ * ```
19
+ */
20
+ export const visibility = (host: Host) => {
21
+ if (!dom(host)) {
22
+ return {
23
+ get visible() {
24
+ return true
25
+ },
26
+ }
27
+ }
28
+
29
+ let current = read()
30
+
31
+ shared('visibility', start, () => {
32
+ const next = read()
33
+ if (next === current) return
34
+
35
+ host.next(() => {
36
+ current = next
37
+ })
38
+ }, host.signal)
39
+
40
+ return {
41
+ get visible() {
42
+ return current
43
+ },
44
+ }
45
+ }