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/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
|
+
}
|
package/src/scrolling.ts
ADDED
|
@@ -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
|
+
}
|
package/src/selection.ts
ADDED
|
@@ -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
|
+
}
|
package/src/typeahead.ts
ADDED
|
@@ -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
|
+
}
|