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/indicator.ts
ADDED
|
@@ -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
|
+
}
|
package/src/overflow.ts
ADDED
|
@@ -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
|
+
}
|