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.
@@ -0,0 +1,122 @@
1
+ import type { Host } from './core'
2
+ import { dom, frame } from './core'
3
+ import { resize } from './resize'
4
+
5
+ /**
6
+ * Tracks a marked child inside a live container and stamps its box on the
7
+ * container as CSS variables (`--indicator-x/y/w/h`, px in the container's
8
+ * content coordinates) plus a `data-indicator="true"` marker while a mark
9
+ * exists. Themes draw a pseudo-element positioned by the variables and
10
+ * transition it, so the active marker glides between children instead of
11
+ * jumping.
12
+ *
13
+ * On first placement the variables land one frame before the marker
14
+ * attribute, so a theme pseudo-element fades in at its resting position
15
+ * instead of sliding in from the container origin.
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * const mark = indicator(this, {
20
+ * target: () => list,
21
+ * of: container => container.querySelector('[data-state="active"]'),
22
+ * })
23
+ * while (true) {
24
+ * mark.sync()
25
+ * yield ...
26
+ * }
27
+ * ```
28
+ */
29
+ export const indicator = (host: Host, opts: {
30
+ /** Live container that receives the variables; call sync() after refs may have changed. */
31
+ target: () => HTMLElement | null | undefined
32
+ /** Resolves the marked child inside the container. */
33
+ of: (container: HTMLElement) => HTMLElement | null
34
+ /** Container events that can move the mark without a re-render (e.g. focusin). */
35
+ on?: string[]
36
+ }) => {
37
+ if (!dom(host)) {
38
+ return {
39
+ sync() {},
40
+ }
41
+ }
42
+
43
+ let container: HTMLElement | undefined
44
+ let control: AbortController | undefined
45
+ let placed = false
46
+
47
+ const clear = (el: HTMLElement) => {
48
+ el.removeAttribute('data-indicator')
49
+ for (const name of ['x', 'y', 'w', 'h']) el.style.removeProperty(`--indicator-${name}`)
50
+ placed = false
51
+ }
52
+
53
+ // The marker attribute trails the variables by one frame so the theme's
54
+ // transitioned pseudo-element first paints already in position.
55
+ const reveal = frame(() => {
56
+ if (container && placed) container.setAttribute('data-indicator', 'true')
57
+ })
58
+
59
+ const measure = () => {
60
+ if (!container) return
61
+
62
+ const mark = opts.of(container)
63
+
64
+ if (!mark || !container.contains(mark)) {
65
+ container.removeAttribute('data-indicator')
66
+ placed = false
67
+ return
68
+ }
69
+
70
+ // Content coordinates (scroll included) so the mark stays glued to its
71
+ // child while the container scrolls.
72
+ const box = container.getBoundingClientRect()
73
+ const rect = mark.getBoundingClientRect()
74
+ const style = container.style
75
+ style.setProperty('--indicator-x', `${rect.left - box.left + container.scrollLeft}px`)
76
+ style.setProperty('--indicator-y', `${rect.top - box.top + container.scrollTop}px`)
77
+ style.setProperty('--indicator-w', `${rect.width}px`)
78
+ style.setProperty('--indicator-h', `${rect.height}px`)
79
+
80
+ if (placed) return
81
+ placed = true
82
+ reveal()
83
+ }
84
+
85
+ const schedule = frame(measure)
86
+
87
+ const size = resize(host, { target: () => container, onResize: () => schedule() })
88
+
89
+ const retarget = (next: HTMLElement | undefined) => {
90
+ if (next === container) return
91
+
92
+ control?.abort()
93
+ control = undefined
94
+ if (container) clear(container)
95
+ container = next
96
+
97
+ if (!container || !opts.on?.length) return
98
+
99
+ control = new AbortController()
100
+ host.signal.addEventListener('abort', () => control?.abort(), { signal: control.signal })
101
+ for (const type of opts.on) {
102
+ container.addEventListener(type, () => schedule(), { passive: true, signal: control.signal })
103
+ }
104
+ }
105
+
106
+ host.signal.addEventListener('abort', () => {
107
+ schedule.cancel()
108
+ reveal.cancel()
109
+ control?.abort()
110
+ if (container) clear(container)
111
+ container = undefined
112
+ }, { once: true })
113
+
114
+ return {
115
+ sync() {
116
+ if (host.signal.aborted) return
117
+ retarget(opts.target() ?? undefined)
118
+ size.sync()
119
+ schedule()
120
+ },
121
+ }
122
+ }
package/src/label.ts ADDED
@@ -0,0 +1,171 @@
1
+ import type { Host } from './core'
2
+ import { dom, id } from './core'
3
+
4
+ /** Stable ids generated for one labelled field. */
5
+ export type LabelIds = {
6
+ /** Id for the primary control. */
7
+ readonly control: string
8
+ /** Id for the optional description element. */
9
+ readonly description: string
10
+ /** Id for the optional error element. */
11
+ readonly error: string
12
+ /** Id for the label element. */
13
+ readonly label: string
14
+ }
15
+
16
+ /** Plain attributes for native labelable controls. */
17
+ export type LabelControlAttrs = {
18
+ /** Space-separated ids of description/error elements when present. */
19
+ 'aria-describedby'?: string
20
+ /** Error id when the field is invalid. */
21
+ 'aria-errormessage'?: string
22
+ /** Invalid state marker when the field is invalid. */
23
+ 'aria-invalid'?: 'true'
24
+ /** Control id referenced by the label. */
25
+ id: string
26
+ }
27
+
28
+ /** Plain attributes for button-like field controls. */
29
+ export type LabelButtonAttrs = LabelControlAttrs & {
30
+ /** Label id used instead of native label-for activation. */
31
+ 'aria-labelledby': string
32
+ }
33
+
34
+ /** Plain attributes for grouped field surfaces without a control id. */
35
+ export type LabelGroupAttrs = {
36
+ /** Space-separated ids of description/error elements when present. */
37
+ 'aria-describedby'?: string
38
+ /** Label id for the group. */
39
+ 'aria-labelledby': string
40
+ }
41
+
42
+ /** Reactive inputs used to configure one field-labelling view. */
43
+ export type LabelOptions = {
44
+ /** Prefix used for the stable ids. Default: `field`. */
45
+ prefix?: () => string | undefined
46
+ }
47
+
48
+ /** Live field-labelling view returned by the label clove. */
49
+ export type LabelView = {
50
+ /** Stable ids for the field parts. */
51
+ readonly ids: LabelIds
52
+ /** Attributes for button-like controls. */
53
+ readonly buttonAttrs: LabelButtonAttrs
54
+ /** Attributes for native labelable controls. */
55
+ readonly controlAttrs: LabelControlAttrs
56
+ /** Attributes for the description element. */
57
+ readonly descriptionAttrs: { id: string }
58
+ /** Records whether a description element rendered in this pass. */
59
+ describe(present: boolean): void
60
+ /** Attributes for the error element. */
61
+ readonly errorAttrs: { id: string }
62
+ /** Attributes for grouped field surfaces. */
63
+ readonly groupAttrs: LabelGroupAttrs
64
+ /** Attributes for the label element. */
65
+ readonly labelAttrs: { for: string; id: string }
66
+ /** Begins a render pass that may re-register description presence. */
67
+ reset(): void
68
+ /** Updates invalid/error wiring from current args. */
69
+ sync(invalid: boolean): void
70
+ }
71
+
72
+ /** Field labelling: stable ids and label/control/description/error wiring for one form field. */
73
+ export const label = (host: Host, options: LabelOptions = {}): LabelView => {
74
+ const base = id(options.prefix?.() ?? 'field')
75
+ const ids = {
76
+ control: base,
77
+ description: `${base}-description`,
78
+ error: `${base}-error`,
79
+ label: `${base}-label`,
80
+ } as const
81
+ let described = false
82
+ let invalid = false
83
+ let pending = false
84
+ let present = false
85
+ let pass = 0
86
+
87
+ const schedule = () => {
88
+ if (!dom(host) || pending) return
89
+
90
+ pending = true
91
+ queueMicrotask(() => {
92
+ pending = false
93
+ host.next()
94
+ })
95
+ }
96
+
97
+ const describedby = () => {
98
+ const values: string[] = []
99
+ if (described) values.push(ids.description)
100
+ if (invalid) values.push(ids.error)
101
+ return values.join(' ') || undefined
102
+ }
103
+
104
+ const controlAttrs = (): LabelControlAttrs => ({
105
+ id: ids.control,
106
+ 'aria-describedby': describedby(),
107
+ 'aria-invalid': invalid ? 'true' : undefined,
108
+ 'aria-errormessage': invalid ? ids.error : undefined,
109
+ })
110
+
111
+ return {
112
+ ids,
113
+ sync(next) {
114
+ invalid = next
115
+ },
116
+ describe(next) {
117
+ if (next) {
118
+ present = true
119
+ if (described) return
120
+ described = true
121
+ schedule()
122
+ return
123
+ }
124
+
125
+ present = false
126
+ if (!described) return
127
+ described = false
128
+ schedule()
129
+ },
130
+ get labelAttrs() {
131
+ return {
132
+ id: ids.label,
133
+ for: ids.control,
134
+ }
135
+ },
136
+ get controlAttrs() {
137
+ return controlAttrs()
138
+ },
139
+ get buttonAttrs() {
140
+ return {
141
+ ...controlAttrs(),
142
+ 'aria-labelledby': ids.label,
143
+ }
144
+ },
145
+ get groupAttrs() {
146
+ return {
147
+ 'aria-labelledby': ids.label,
148
+ 'aria-describedby': describedby(),
149
+ }
150
+ },
151
+ get descriptionAttrs() {
152
+ return { id: ids.description }
153
+ },
154
+ get errorAttrs() {
155
+ return { id: ids.error }
156
+ },
157
+ reset() {
158
+ present = false
159
+
160
+ if (!described || !dom(host)) return
161
+ const current = ++pass
162
+
163
+ queueMicrotask(() => {
164
+ if (current !== pass || present || !described) return
165
+ host.next(() => {
166
+ described = false
167
+ })
168
+ })
169
+ },
170
+ }
171
+ }
package/src/media.ts ADDED
@@ -0,0 +1,95 @@
1
+ import type { Host } from './core'
2
+ import { dom, shared } from './core'
3
+
4
+ /** Reactive inputs used to configure a shared media-query view. */
5
+ export type MediaOptions = {
6
+ /** Server and unsupported-browser match value. Default: false. */
7
+ fallback?: () => boolean
8
+ /** Current media-query string, read by `sync()`. */
9
+ query: () => string
10
+ }
11
+
12
+ const lists = new Map<string, MediaQueryList>()
13
+
14
+ const fallback = (opts: MediaOptions) => opts.fallback?.() ?? false
15
+
16
+ const start = (query: string) => (notify: () => void) => {
17
+ const list = window.matchMedia(query)
18
+
19
+ lists.set(query, list)
20
+ list.addEventListener('change', notify)
21
+
22
+ return () => {
23
+ list.removeEventListener('change', notify)
24
+ if (lists.get(query) === list) lists.delete(query)
25
+ }
26
+ }
27
+
28
+ const read = (query: string | undefined, opts: MediaOptions) =>
29
+ query ? lists.get(query)?.matches ?? fallback(opts) : fallback(opts)
30
+
31
+ /**
32
+ * Reactive media-query match, shared per query string.
33
+ *
34
+ * @example
35
+ * ```ts
36
+ * const narrow = media(this, { query: () => '(max-width: 768px)' })
37
+ * for (const args of this) {
38
+ * narrow.sync()
39
+ * yield <span>{narrow.matches ? 'narrow' : 'wide'}</span>
40
+ * }
41
+ * ```
42
+ */
43
+ export const media = (host: Host, options: MediaOptions) => {
44
+ const { query } = options
45
+ let active: string | undefined
46
+ let current = fallback(options)
47
+ let scope: AbortController | undefined
48
+
49
+ const stop = () => {
50
+ scope?.abort()
51
+ scope = undefined
52
+ active = undefined
53
+ }
54
+
55
+ const update = () => {
56
+ const next = read(active, options)
57
+ if (next === current) return
58
+
59
+ host.next(() => {
60
+ current = next
61
+ })
62
+ }
63
+
64
+ if (!dom(host) || typeof window.matchMedia != 'function') {
65
+ return {
66
+ get matches() {
67
+ return fallback(options)
68
+ },
69
+ sync() {},
70
+ }
71
+ }
72
+
73
+ host.signal.addEventListener('abort', stop, { once: true })
74
+
75
+ return {
76
+ get matches() {
77
+ return read(active, options)
78
+ },
79
+ sync() {
80
+ if (host.signal.aborted) return
81
+
82
+ const next = query()
83
+ if (next === active) {
84
+ current = read(active, options)
85
+ return
86
+ }
87
+
88
+ scope?.abort()
89
+ scope = new AbortController()
90
+ active = next
91
+ shared(`media:${next}`, start(next), update, scope.signal)
92
+ current = read(active, options)
93
+ },
94
+ }
95
+ }
package/src/move.ts ADDED
@@ -0,0 +1,185 @@
1
+ import type { Host } from './core'
2
+ import { dom, on } from './core'
3
+
4
+ type CaptureElement = HTMLElement | SVGElement
5
+
6
+ type MoveData = {
7
+ /** Client coordinates of the current event. */
8
+ x: number
9
+ y: number
10
+ /** Deltas from the session start. */
11
+ dx: number
12
+ dy: number
13
+ /** True when the session ended via pointercancel, Escape, or capture loss. */
14
+ canceled: boolean
15
+ }
16
+
17
+ type MoveSession = {
18
+ controller: AbortController
19
+ data: MoveData
20
+ element: CaptureElement
21
+ pointer: number
22
+ startX: number
23
+ startY: number
24
+ }
25
+
26
+ type MoveView = {
27
+ /** Begins a drag session from the consumer's own pointerdown. Ignores non-primary buttons. */
28
+ start(event: PointerEvent): boolean
29
+ /** True while a session is running. */
30
+ readonly active: boolean
31
+ }
32
+
33
+ const isCaptureElement = (value: EventTarget | null): value is CaptureElement =>
34
+ value instanceof HTMLElement || value instanceof SVGElement
35
+
36
+ const capture = (event: PointerEvent) =>
37
+ isCaptureElement(event.currentTarget) ? event.currentTarget
38
+ : isCaptureElement(event.target) ? event.target
39
+ : undefined
40
+
41
+ const update = (session: MoveSession, event: PointerEvent, canceled: boolean) => {
42
+ const { data } = session
43
+
44
+ data.x = event.clientX
45
+ data.y = event.clientY
46
+ data.dx = event.clientX - session.startX
47
+ data.dy = event.clientY - session.startY
48
+ data.canceled = canceled
49
+
50
+ return data
51
+ }
52
+
53
+ const release = (session: MoveSession) => {
54
+ try {
55
+ session.element.releasePointerCapture(session.pointer)
56
+ } catch { }
57
+ }
58
+
59
+ /**
60
+ * Pointer-drag session lifecycle: capture, move deltas, and guaranteed cleanup.
61
+ *
62
+ * `data` is one object mutated across callbacks within a session; read it
63
+ * during the callback and do not retain it across events. Consumers own
64
+ * gesture CSS such as `touch-action`, and Escape is reported without
65
+ * preventDefault so consumers can decide that policy.
66
+ *
67
+ * @example
68
+ * ```ts
69
+ * function* Handle(this: Host) {
70
+ * const drag = move(this, { onMove(data) { resize(data.dx, data.dy) } })
71
+ * yield <div set:onpointerdown={event => drag.start(event)} />
72
+ * }
73
+ * ```
74
+ */
75
+ export const move = (host: Host, opts: {
76
+ /** Called once after a drag session starts. */
77
+ onStart?: (data: MoveData, event: PointerEvent) => void
78
+ /** Called for pointer moves during the active session. */
79
+ onMove: (data: MoveData, event: PointerEvent) => void
80
+ /** Called once when the session ends normally or is canceled. */
81
+ onEnd?: (data: MoveData, event: PointerEvent | KeyboardEvent) => void
82
+ }): MoveView => {
83
+ if (!dom(host)) {
84
+ return {
85
+ start(_event: PointerEvent) {
86
+ return false
87
+ },
88
+ get active() {
89
+ return false
90
+ },
91
+ }
92
+ }
93
+
94
+ const lifetime = host.signal
95
+ let session: MoveSession | undefined
96
+
97
+ const finish = (
98
+ current: MoveSession,
99
+ event: PointerEvent | KeyboardEvent,
100
+ canceled: boolean,
101
+ releaseCapture = true,
102
+ ) => {
103
+ if (session !== current || current.controller.signal.aborted) return
104
+
105
+ const data = event instanceof PointerEvent
106
+ ? update(current, event, canceled)
107
+ : current.data
108
+
109
+ if (!(event instanceof PointerEvent)) data.canceled = canceled
110
+
111
+ session = undefined
112
+ current.controller.abort()
113
+ if (releaseCapture) release(current)
114
+ opts.onEnd?.(data, event)
115
+ }
116
+
117
+ return {
118
+ start(event: PointerEvent) {
119
+ if (event.button !== 0 || !event.isPrimary || session || lifetime.aborted) return false
120
+
121
+ const element = capture(event)
122
+ if (!element) return false
123
+
124
+ try {
125
+ element.setPointerCapture(event.pointerId)
126
+ } catch { }
127
+
128
+ const current: MoveSession = {
129
+ controller: new AbortController(),
130
+ data: {
131
+ x: event.clientX,
132
+ y: event.clientY,
133
+ dx: 0,
134
+ dy: 0,
135
+ canceled: false,
136
+ },
137
+ element,
138
+ pointer: event.pointerId,
139
+ startX: event.clientX,
140
+ startY: event.clientY,
141
+ }
142
+ const { signal } = current.controller
143
+
144
+ session = current
145
+
146
+ lifetime.addEventListener('abort', () => {
147
+ if (session !== current || signal.aborted) return
148
+
149
+ session = undefined
150
+ current.controller.abort()
151
+ release(current)
152
+ }, { once: true, signal })
153
+
154
+ on(element, 'pointermove', next => {
155
+ if (next.pointerId !== current.pointer) return
156
+ opts.onMove(update(current, next, false), next)
157
+ }, host, { signal })
158
+
159
+ on(element, 'pointerup', next => {
160
+ if (next.pointerId !== current.pointer) return
161
+ finish(current, next, false)
162
+ }, host, { signal })
163
+
164
+ on(element, 'pointercancel', next => {
165
+ if (next.pointerId !== current.pointer) return
166
+ finish(current, next, true)
167
+ }, host, { signal })
168
+
169
+ on(element, 'lostpointercapture', next => {
170
+ if (next.pointerId !== current.pointer) return
171
+ finish(current, next, true, false)
172
+ }, host, { signal })
173
+
174
+ on(element.ownerDocument, 'keydown', next => {
175
+ if (next.key === 'Escape') finish(current, next, true)
176
+ }, host, { signal })
177
+
178
+ opts.onStart?.(current.data, event)
179
+ return true
180
+ },
181
+ get active() {
182
+ return !!session
183
+ },
184
+ }
185
+ }
@@ -0,0 +1,87 @@
1
+ import type { Host } from './core'
2
+ import { dom, frame } from './core'
3
+ import { resize } from './resize'
4
+ import { scrolling } from './scrolling'
5
+
6
+ const edge = (position: number, size: number, span: number): string | null => {
7
+ if (span - size <= 1) return null
8
+
9
+ // RTL scrollers report negative positions; the stamped values stay
10
+ // inline-logical ("start" = scrolled away from the inline start).
11
+ const offset = Math.abs(position)
12
+ const start = offset > 1
13
+ const end = offset < span - size - 1
14
+
15
+ return start && end ? 'both' : start ? 'start' : 'end'
16
+ }
17
+
18
+ const stamp = (el: HTMLElement, name: string, value: string | null) => {
19
+ if (!value) el.removeAttribute(name)
20
+ else if (el.getAttribute(name) !== value) el.setAttribute(name, value)
21
+ }
22
+
23
+ /**
24
+ * Stamps `data-overflow-x` / `data-overflow-y` ("start" | "end" | "both") on
25
+ * a live scrollable element while content overflows it, tracking scroll,
26
+ * element resize, and re-renders. Themes pair the attrs with edge fades (the
27
+ * `[data-overflow-*]` mask preflight); the attrs are absent while everything
28
+ * fits.
29
+ *
30
+ * @example
31
+ * ```ts
32
+ * const edges = overflow(this, { target: () => list })
33
+ * while (true) {
34
+ * edges.sync()
35
+ * yield <div ref={el => list = el} />
36
+ * }
37
+ * ```
38
+ */
39
+ export const overflow = (host: Host, opts: {
40
+ /** Live element to observe; call sync() after refs may have changed. */
41
+ target: () => HTMLElement | null | undefined
42
+ }) => {
43
+ if (!dom(host)) {
44
+ return {
45
+ sync() {},
46
+ }
47
+ }
48
+
49
+ const measure = (el: HTMLElement) => {
50
+ stamp(el, 'data-overflow-x', edge(el.scrollLeft, el.clientWidth, el.scrollWidth))
51
+ stamp(el, 'data-overflow-y', edge(el.scrollTop, el.clientHeight, el.scrollHeight))
52
+ }
53
+ const clear = (el: HTMLElement) => {
54
+ stamp(el, 'data-overflow-x', null)
55
+ stamp(el, 'data-overflow-y', null)
56
+ }
57
+ let target: HTMLElement | undefined
58
+
59
+ const scroll = scrolling(host, { target: () => target, onScroll: measure })
60
+ const size = resize(host, { target: () => target, onResize: el => measure(el as HTMLElement) })
61
+
62
+ // Content can grow without an element resize or scroll (children added or
63
+ // relabeled); re-measure on every sync so re-renders refresh the stamps.
64
+ const schedule = frame(() => {
65
+ if (target) measure(target)
66
+ })
67
+
68
+ host.signal.addEventListener('abort', () => {
69
+ schedule.cancel()
70
+ if (target) clear(target)
71
+ target = undefined
72
+ }, { once: true })
73
+
74
+ return {
75
+ sync() {
76
+ if (host.signal.aborted) return
77
+ const next = opts.target() ?? undefined
78
+ if (next !== target) {
79
+ if (target) clear(target)
80
+ target = next
81
+ }
82
+ scroll.sync()
83
+ size.sync()
84
+ schedule()
85
+ },
86
+ }
87
+ }