hlmnhkl-ui 0.21.1 → 0.22.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/README.md +1 -1
- package/package.json +1 -1
- package/scripts/pro-components.json +5 -0
- package/src/components/Accordion/Accordion.module.css +1 -1
- package/src/components/AnimatedNumber/AnimatedNumber.module.css +1 -0
- package/src/components/Badge/Badge.module.css +1 -0
- package/src/components/Breadcrumb/Breadcrumb.module.css +6 -0
- package/src/components/Button/Button.module.css +3 -1
- package/src/components/Checkbox/Checkbox.module.css +1 -1
- package/src/components/Collapsible/Collapsible.module.css +1 -1
- package/src/components/CopyButton/CopyButton.module.css +1 -0
- package/src/components/FormField/FormField.module.css +1 -1
- package/src/components/IconButton/IconButton.module.css +1 -1
- package/src/components/Input/Input.module.css +1 -1
- package/src/components/Kbd/Kbd.module.css +1 -0
- package/src/components/Pagination/Pagination.module.css +1 -1
- package/src/components/Radio/Radio.module.css +1 -1
- package/src/components/Retime/Retime.tsx +4 -0
- package/src/components/SegmentedControl/SegmentedControl.module.css +5 -1
- package/src/components/Slider/Slider.module.css +1 -1
- package/src/components/Switch/Switch.module.css +1 -1
- package/src/components/Tabs/Tabs.module.css +1 -1
- package/src/components/Tag/Tag.module.css +1 -0
- package/src/internal/focus.ts +24 -1
- package/src/internal/icons.tsx +1 -0
- package/src/internal/scrollFrame.ts +224 -0
- package/src/tokens.css +34 -0
package/README.md
CHANGED
|
@@ -60,7 +60,7 @@ Every copied file starts with one line naming hlmnhkl/ui and the MIT licence, so
|
|
|
60
60
|
- **50 free components**, from Button, Dialog, Select and Table to Toast and Retime, one speed setting for every animation. See [COMPONENTS.md](COMPONENTS.md).
|
|
61
61
|
- **One look, set by tokens.** Every colour, size and timing comes from one file, `tokens.css`. Colour appears only to mean something: good, caution or bad.
|
|
62
62
|
- **Light and dark.** Dark follows the system, or set `data-theme="dark"` on `<html>`.
|
|
63
|
-
- **Accessible.** Keyboard support per the WAI-ARIA patterns, visible focus, screen reader labels, and reduced motion. On touch screens every control gives at least a 44px target, even the small sizes; only the six PIN boxes and the seven calendar days get narrower when the row has no room, about 36px wide on a 320px phone.
|
|
63
|
+
- **Accessible.** Keyboard support per the WAI-ARIA patterns, visible focus, screen reader labels, and reduced motion. On touch screens every control gives at least a 44px target, even the small sizes; only the six PIN boxes and the seven calendar days get narrower when the row has no room, about 36px wide on a 320px phone. CalendarHeatmap's days are small by design, since a year must fit on screen; its month table gives the same numbers at full size.
|
|
64
64
|
- **Small.** React 19 and CSS Modules. No other runtime packages: no animation library, no icon package.
|
|
65
65
|
- **Works in** Vite, Next.js (App Router) and React Router.
|
|
66
66
|
|
package/package.json
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"ContextMenu",
|
|
16
16
|
"Countdown",
|
|
17
17
|
"CurrencyInput",
|
|
18
|
+
"DataGrid",
|
|
18
19
|
"DatePicker",
|
|
19
20
|
"DateRangePicker",
|
|
20
21
|
"DescriptionList",
|
|
@@ -48,14 +49,18 @@
|
|
|
48
49
|
"ResizablePanels",
|
|
49
50
|
"RichTextEditor",
|
|
50
51
|
"Route",
|
|
52
|
+
"ScrollDraft",
|
|
53
|
+
"ScrollRuler",
|
|
51
54
|
"Sidebar",
|
|
52
55
|
"SignaturePad",
|
|
56
|
+
"SmoothScroll",
|
|
53
57
|
"SortableList",
|
|
54
58
|
"Sparkline",
|
|
55
59
|
"Stat",
|
|
56
60
|
"Stepper",
|
|
57
61
|
"SwipeActions",
|
|
58
62
|
"TagInput",
|
|
63
|
+
"TextRise",
|
|
59
64
|
"TimePicker",
|
|
60
65
|
"Timeline",
|
|
61
66
|
"ToggleGroup",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
@media (hover: hover) {
|
|
39
39
|
.trigger:hover:not(:disabled) { background: var(--hl-color-surface-hover); }
|
|
40
40
|
}
|
|
41
|
-
.trigger:disabled { opacity:
|
|
41
|
+
.trigger:disabled { opacity: var(--hl-disabled-opacity); cursor: not-allowed; }
|
|
42
42
|
|
|
43
43
|
.trigger:focus-visible {
|
|
44
44
|
outline: none;
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
.more {
|
|
69
|
+
justify-content: center;
|
|
69
70
|
border: 0;
|
|
70
71
|
background: transparent;
|
|
71
72
|
font-size: inherit;
|
|
@@ -73,6 +74,11 @@
|
|
|
73
74
|
letter-spacing: var(--hl-tracking-wide);
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
/* The dots alone are too narrow to tap. */
|
|
78
|
+
@media (pointer: coarse) {
|
|
79
|
+
.more { min-width: var(--hl-hit-min); }
|
|
80
|
+
}
|
|
81
|
+
|
|
76
82
|
@media (hover: hover) {
|
|
77
83
|
.more:hover { color: var(--hl-color-fg); background: var(--hl-color-surface-hover); }
|
|
78
84
|
}
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
--h: var(--hl-control-h-md);
|
|
3
3
|
position: relative;
|
|
4
4
|
display: inline-grid;
|
|
5
|
+
/* Its own width in a column layout too; fullWidth (.full) still fills the row. */
|
|
6
|
+
width: fit-content;
|
|
5
7
|
grid-template-columns: minmax(0, auto);
|
|
6
8
|
place-items: center;
|
|
7
9
|
max-width: 100%;
|
|
@@ -55,7 +57,7 @@
|
|
|
55
57
|
.root:disabled,
|
|
56
58
|
.root[aria-disabled="true"]:not([data-loading]) {
|
|
57
59
|
cursor: not-allowed;
|
|
58
|
-
opacity:
|
|
60
|
+
opacity: var(--hl-disabled-opacity);
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
.root[data-loading] {
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
.trigger:hover:not(:disabled) { background: var(--hl-color-surface-hover); }
|
|
30
30
|
}
|
|
31
31
|
.trigger:active:not(:disabled) { transform: scale(var(--hl-press-scale)); }
|
|
32
|
-
.trigger:disabled { opacity:
|
|
32
|
+
.trigger:disabled { opacity: var(--hl-disabled-opacity); cursor: not-allowed; }
|
|
33
33
|
.trigger:focus-visible { outline: none; box-shadow: var(--hl-focus-ring); }
|
|
34
34
|
|
|
35
35
|
.label {
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
.root:hover:not(:disabled) .icon { transform: scale(1.08); }
|
|
62
62
|
}
|
|
63
63
|
.root:active:not(:disabled) { transform: scale(var(--hl-press-scale-strong)); transition-duration: var(--hl-motion-instant); }
|
|
64
|
-
.root:disabled { opacity:
|
|
64
|
+
.root:disabled { opacity: var(--hl-disabled-opacity); cursor: not-allowed; }
|
|
65
65
|
|
|
66
66
|
@media (hover: hover) {
|
|
67
67
|
.ghost:hover:not(:disabled) { background: var(--hl-color-surface-hover); }
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
}
|
|
71
71
|
.btn:active:not(:disabled) { transform: scale(var(--hl-press-scale-strong)); }
|
|
72
72
|
.btn[aria-current="page"] { color: var(--hl-color-accent-fg); }
|
|
73
|
-
.btn:disabled { opacity:
|
|
73
|
+
.btn:disabled { opacity: var(--hl-disabled-opacity); cursor: not-allowed; }
|
|
74
74
|
.btn:focus-visible { outline: none; box-shadow: var(--hl-focus-ring); }
|
|
75
75
|
|
|
76
76
|
.edge {
|
|
@@ -48,6 +48,8 @@ export type RetimeProps = {
|
|
|
48
48
|
size?: 'sm' | 'md'
|
|
49
49
|
/** Text for each speed, so you can translate it. */
|
|
50
50
|
labels?: Partial<RetimeLabels>
|
|
51
|
+
/** Fills the row, with the speeds sharing it evenly, as in a settings panel. */
|
|
52
|
+
fullWidth?: boolean
|
|
51
53
|
/** Extra class for the root element. */
|
|
52
54
|
className?: string
|
|
53
55
|
}
|
|
@@ -60,6 +62,7 @@ export function Retime({
|
|
|
60
62
|
remember = true,
|
|
61
63
|
size = 'md',
|
|
62
64
|
labels: labelsProp,
|
|
65
|
+
fullWidth,
|
|
63
66
|
className,
|
|
64
67
|
}: RetimeProps) {
|
|
65
68
|
const labels = { ...defaultLabels, ...labelsProp }
|
|
@@ -106,6 +109,7 @@ export function Retime({
|
|
|
106
109
|
value={speed}
|
|
107
110
|
onValueChange={(v) => setSpeed(v as RetimeSpeed)}
|
|
108
111
|
options={(['off', 'slower', 'normal', 'faster'] as const).map((s) => ({ value: s, label: labels[s] }))}
|
|
112
|
+
fullWidth={fullWidth}
|
|
109
113
|
className={className}
|
|
110
114
|
/>
|
|
111
115
|
)
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
.root {
|
|
2
2
|
position: relative;
|
|
3
3
|
display: inline-flex;
|
|
4
|
+
/* Its own width in a column layout too; fullWidth (.full) still fills the row. */
|
|
5
|
+
width: fit-content;
|
|
4
6
|
padding: var(--hl-space-1);
|
|
5
7
|
border: var(--hl-line) solid var(--hl-color-border);
|
|
6
8
|
border-radius: var(--hl-radius-control);
|
|
@@ -30,7 +32,9 @@
|
|
|
30
32
|
width var(--hl-motion-slow) var(--hl-ease-out);
|
|
31
33
|
}
|
|
32
34
|
|
|
35
|
+
/* Options share any room the track has beyond its own width, so a stretched track never ends in an empty gap. */
|
|
33
36
|
.item {
|
|
37
|
+
flex: 1 0 auto;
|
|
34
38
|
position: relative;
|
|
35
39
|
display: inline-flex;
|
|
36
40
|
align-items: center;
|
|
@@ -71,7 +75,7 @@
|
|
|
71
75
|
}
|
|
72
76
|
.item:active:not(:disabled) { transform: scale(var(--hl-press-scale)); }
|
|
73
77
|
.item[data-on] { color: var(--hl-color-fg); }
|
|
74
|
-
.item:disabled { opacity:
|
|
78
|
+
.item:disabled { opacity: var(--hl-disabled-opacity); cursor: not-allowed; }
|
|
75
79
|
|
|
76
80
|
.item:focus-visible {
|
|
77
81
|
outline: none;
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
.tab:hover:not(:disabled) { color: var(--hl-color-fg); background: var(--hl-color-surface-hover); }
|
|
47
47
|
}
|
|
48
48
|
.tab[aria-selected="true"] { color: var(--hl-color-fg); }
|
|
49
|
-
.tab:disabled { opacity:
|
|
49
|
+
.tab:disabled { opacity: var(--hl-disabled-opacity); cursor: not-allowed; }
|
|
50
50
|
|
|
51
51
|
.tab:focus-visible {
|
|
52
52
|
outline: none;
|
package/src/internal/focus.ts
CHANGED
|
@@ -9,6 +9,29 @@ export function focusables(root: HTMLElement): HTMLElement[] {
|
|
|
9
9
|
)
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
// Safari, on iPhone and on Mac, does not focus a button when it is tapped or clicked. So the control
|
|
13
|
+
// that opened a layer is often not the focused element; remember the last one pressed instead.
|
|
14
|
+
// Watched from the moment this file loads, since a layer only mounts after the press that opens it.
|
|
15
|
+
let lastPressed: HTMLElement | null = null
|
|
16
|
+
if (typeof document !== 'undefined') {
|
|
17
|
+
document.addEventListener(
|
|
18
|
+
'pointerdown',
|
|
19
|
+
(e) => {
|
|
20
|
+
lastPressed = (e.target instanceof Element ? e.target.closest<HTMLElement>(FOCUSABLE) : null) ?? null
|
|
21
|
+
},
|
|
22
|
+
true,
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Where focus should go back to: the focused element, unless it is the page or a box around the
|
|
27
|
+
// control that was just pressed. Then that control.
|
|
28
|
+
function returnTarget(): HTMLElement | null {
|
|
29
|
+
const active = document.activeElement as HTMLElement | null
|
|
30
|
+
const pressed = lastPressed && lastPressed.isConnected ? lastPressed : null
|
|
31
|
+
if (pressed && (!active || active === document.body || (active !== pressed && active.contains(pressed)))) return pressed
|
|
32
|
+
return active
|
|
33
|
+
}
|
|
34
|
+
|
|
12
35
|
export function useFocusTrap(
|
|
13
36
|
ref: RefObject<HTMLElement | null>,
|
|
14
37
|
active: boolean,
|
|
@@ -19,7 +42,7 @@ export function useFocusTrap(
|
|
|
19
42
|
if (!active) return
|
|
20
43
|
const root = ref.current
|
|
21
44
|
if (!root) return
|
|
22
|
-
const previous =
|
|
45
|
+
const previous = returnTarget()
|
|
23
46
|
// Close buttons opt out of first focus, so focus lands on the content or the safe action.
|
|
24
47
|
const list = focusables(root)
|
|
25
48
|
const first = initialFocus?.current ?? list.find((el) => !el.hasAttribute('data-skip-initial-focus')) ?? list[0] ?? root
|
package/src/internal/icons.tsx
CHANGED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useRef } from 'react'
|
|
2
|
+
import { motionMs, prefersReducedMotion } from './motion'
|
|
3
|
+
|
|
4
|
+
// One frame loop for every scroll part on the page. It runs only while the page moves, and stops
|
|
5
|
+
// after a moment of stillness; a scroll, a resize or a change in the page's size wakes it again.
|
|
6
|
+
|
|
7
|
+
/** Where the page is (px) and how fast it moves (px/s, signed). */
|
|
8
|
+
export type ScrollFrame = { y: number; v: number }
|
|
9
|
+
|
|
10
|
+
/** Reads layout and returns the writes, so every part reads before any part writes. */
|
|
11
|
+
export type ScrollReader = (frame: ScrollFrame) => void | (() => void)
|
|
12
|
+
|
|
13
|
+
export type ScrollTokens = { ease: number; start: number; end: number; step: number; readEvery: number }
|
|
14
|
+
|
|
15
|
+
const readers = new Set<{ current: ScrollReader }>()
|
|
16
|
+
let frame: ScrollFrame = { y: 0, v: 0 }
|
|
17
|
+
let last = { y: 0, t: -1 }
|
|
18
|
+
let raf = 0
|
|
19
|
+
let still = 0
|
|
20
|
+
let observer: ResizeObserver | null = null
|
|
21
|
+
let reduce: MediaQueryList | null = null
|
|
22
|
+
let tokens: ScrollTokens | null = null
|
|
23
|
+
|
|
24
|
+
export function readScrollTokens(): ScrollTokens {
|
|
25
|
+
const s = getComputedStyle(document.documentElement)
|
|
26
|
+
const n = (name: string) => parseFloat(s.getPropertyValue(name))
|
|
27
|
+
return { ease: n('--hl-scroll-ease'), start: n('--hl-draft-start'), end: n('--hl-draft-end'), step: n('--hl-ruler-step'), readEvery: motionMs('fast') }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The tokens, read once each time the loop starts or the window changes size. */
|
|
31
|
+
export const scrollTokens = () => (tokens ??= readScrollTokens())
|
|
32
|
+
|
|
33
|
+
/** Progress from 0 to 1 as an element's top moves from `start` to `end` (shares of the screen height). */
|
|
34
|
+
export function bandProgress(top: number, start: number, end: number) {
|
|
35
|
+
const p = (start - top / window.innerHeight) / (start - end)
|
|
36
|
+
return Math.max(0, Math.min(1, p))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function run(f: ScrollFrame) {
|
|
40
|
+
const writes: Array<() => void> = []
|
|
41
|
+
readers.forEach((r) => {
|
|
42
|
+
const w = r.current(f)
|
|
43
|
+
if (w) writes.push(w)
|
|
44
|
+
})
|
|
45
|
+
writes.forEach((w) => w())
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function tick(t: number) {
|
|
49
|
+
const y = window.scrollY
|
|
50
|
+
// The first frame after a wake only takes its bearings: its time can be earlier than the wake's.
|
|
51
|
+
let v = frame.v
|
|
52
|
+
if (last.t >= 0) {
|
|
53
|
+
v += (((y - last.y) / Math.max(1, t - last.t)) * 1000 - v) * 0.2
|
|
54
|
+
if (Math.abs(v) < 1) v = 0
|
|
55
|
+
}
|
|
56
|
+
const moved = y !== last.y
|
|
57
|
+
last = { y, t }
|
|
58
|
+
frame = { y, v }
|
|
59
|
+
run(frame)
|
|
60
|
+
still = !moved && v === 0 ? still + 1 : 0
|
|
61
|
+
raf = still > 30 ? 0 : requestAnimationFrame(tick)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function wake() {
|
|
65
|
+
still = 0
|
|
66
|
+
if (raf) return
|
|
67
|
+
tokens = null
|
|
68
|
+
last = { y: window.scrollY, t: -1 }
|
|
69
|
+
raf = requestAnimationFrame(tick)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function onResize() {
|
|
73
|
+
tokens = null
|
|
74
|
+
wake()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function start() {
|
|
78
|
+
window.addEventListener('scroll', wake, { passive: true })
|
|
79
|
+
window.addEventListener('resize', onResize)
|
|
80
|
+
// Fonts, pictures or an opened panel can move things without a scroll.
|
|
81
|
+
observer = new ResizeObserver(wake)
|
|
82
|
+
observer.observe(document.body)
|
|
83
|
+
reduce = window.matchMedia('(prefers-reduced-motion: reduce)')
|
|
84
|
+
reduce.addEventListener('change', wake)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function stop() {
|
|
88
|
+
window.removeEventListener('scroll', wake)
|
|
89
|
+
window.removeEventListener('resize', onResize)
|
|
90
|
+
observer?.disconnect()
|
|
91
|
+
observer = null
|
|
92
|
+
reduce?.removeEventListener('change', wake)
|
|
93
|
+
reduce = null
|
|
94
|
+
cancelAnimationFrame(raf)
|
|
95
|
+
raf = 0
|
|
96
|
+
frame = { y: 0, v: 0 }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Calls `read` once on mount and then every frame while the page moves. */
|
|
100
|
+
export function useScrollFrame(read: ScrollReader, active = true) {
|
|
101
|
+
const ref = useRef(read)
|
|
102
|
+
useLayoutEffect(() => {
|
|
103
|
+
ref.current = read
|
|
104
|
+
})
|
|
105
|
+
useLayoutEffect(() => {
|
|
106
|
+
if (!active) return
|
|
107
|
+
if (!readers.size) start()
|
|
108
|
+
readers.add(ref)
|
|
109
|
+
mounting.add(ref)
|
|
110
|
+
if (mounting.size === 1) queueMicrotask(runMounts)
|
|
111
|
+
wake()
|
|
112
|
+
return () => {
|
|
113
|
+
readers.delete(ref)
|
|
114
|
+
mounting.delete(ref)
|
|
115
|
+
if (!readers.size) stop()
|
|
116
|
+
}
|
|
117
|
+
}, [active])
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Parts that mount together read once, together, before the first paint, so the page lays out once.
|
|
121
|
+
const mounting = new Set<{ current: ScrollReader }>()
|
|
122
|
+
|
|
123
|
+
function runMounts() {
|
|
124
|
+
const writes: Array<() => void> = []
|
|
125
|
+
const f = { y: window.scrollY, v: frame.v }
|
|
126
|
+
mounting.forEach((r) => {
|
|
127
|
+
const w = r.current(f)
|
|
128
|
+
if (w) writes.push(w)
|
|
129
|
+
})
|
|
130
|
+
mounting.clear()
|
|
131
|
+
writes.forEach((w) => w())
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Weighted wheel scrolling, shared by every SmoothScroll on the page. The last one mounted sets the ease.
|
|
135
|
+
const wheelUsers: Array<{ ease?: number }> = []
|
|
136
|
+
let fine: MediaQueryList | null = null
|
|
137
|
+
const glide = { target: 0, current: 0, set: 0, ease: 1, t0: 0, id: 0 }
|
|
138
|
+
|
|
139
|
+
const maxScroll = () => document.documentElement.scrollHeight - document.documentElement.clientHeight
|
|
140
|
+
|
|
141
|
+
const locked = () => document.body.style.overflow === 'hidden'
|
|
142
|
+
|
|
143
|
+
function step(t: number) {
|
|
144
|
+
const g = glide
|
|
145
|
+
// Something else moved the page (a key, the scrollbar, a link), or a dialog locked it: let it lead.
|
|
146
|
+
if (locked() || Math.abs(window.scrollY - g.set) > 2) {
|
|
147
|
+
g.id = 0
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
// A frame's time can be a little before the wheel event's, so never step backwards.
|
|
151
|
+
const k = 1 - Math.pow(1 - g.ease, Math.min(4, Math.max(0, t - g.t0) / (1000 / 60)))
|
|
152
|
+
g.t0 = Math.max(g.t0, t)
|
|
153
|
+
g.current += (g.target - g.current) * k
|
|
154
|
+
if (Math.abs(g.target - g.current) < 0.5) g.current = g.target
|
|
155
|
+
g.set = Math.round(g.current)
|
|
156
|
+
// Instant, so a page with scroll-behavior: smooth does not ease each step a second time.
|
|
157
|
+
window.scrollTo({ top: g.set, behavior: 'instant' })
|
|
158
|
+
g.id = g.current === g.target ? 0 : requestAnimationFrame(step)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function stopGlide() {
|
|
162
|
+
cancelAnimationFrame(glide.id)
|
|
163
|
+
glide.id = 0
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// The nearest box that can still scroll this way takes the wheel itself, and so does one that keeps
|
|
167
|
+
// its overscroll to itself. The event's path also walks into shadow roots.
|
|
168
|
+
function scrollsInside(path: EventTarget[], dy: number) {
|
|
169
|
+
for (const el of path) {
|
|
170
|
+
if (el === document.documentElement) break
|
|
171
|
+
if (!(el instanceof Element)) continue
|
|
172
|
+
const cs = getComputedStyle(el)
|
|
173
|
+
if ((cs.overflowY === 'auto' || cs.overflowY === 'scroll') && el.scrollHeight > el.clientHeight) {
|
|
174
|
+
if (cs.overscrollBehaviorY !== 'auto') return true
|
|
175
|
+
if (dy < 0 ? el.scrollTop > 0 : el.scrollTop + el.clientHeight < el.scrollHeight) return true
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return false
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function onWheel(e: WheelEvent) {
|
|
182
|
+
// A dialog or sheet has locked the page: leave it still.
|
|
183
|
+
if (locked()) return stopGlide()
|
|
184
|
+
// Pinch zoomed, the wheel pans the zoomed view, so it stays native.
|
|
185
|
+
if (prefersReducedMotion() || !fine?.matches || (window.visualViewport?.scale ?? 1) > 1) return
|
|
186
|
+
if (e.ctrlKey || e.defaultPrevented || Math.abs(e.deltaX) > Math.abs(e.deltaY) || scrollsInside(e.composedPath(), e.deltaY)) return
|
|
187
|
+
e.preventDefault()
|
|
188
|
+
const g = glide
|
|
189
|
+
if (!g.id) {
|
|
190
|
+
g.target = g.current = g.set = window.scrollY
|
|
191
|
+
g.ease = wheelUsers[wheelUsers.length - 1]?.ease ?? readScrollTokens().ease
|
|
192
|
+
}
|
|
193
|
+
const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? window.innerHeight : 1
|
|
194
|
+
g.target = Math.max(0, Math.min(maxScroll(), g.target + e.deltaY * unit))
|
|
195
|
+
if (!g.id) {
|
|
196
|
+
g.t0 = performance.now()
|
|
197
|
+
g.id = requestAnimationFrame(step)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Any other way of moving the page takes over at once, so a key or the scrollbar never fights a glide.
|
|
202
|
+
const handOver = ['keydown', 'mousedown', 'touchstart'] as const
|
|
203
|
+
|
|
204
|
+
/** Weighted wheel scrolling for mouse and trackpad while mounted. Keys, the scrollbar and links stay native. */
|
|
205
|
+
export function useSmoothWheel(ease: number | undefined, disabled: boolean) {
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
if (disabled) return
|
|
208
|
+
const user = { ease }
|
|
209
|
+
if (!wheelUsers.length) {
|
|
210
|
+
fine = window.matchMedia('(hover: hover) and (pointer: fine)')
|
|
211
|
+
window.addEventListener('wheel', onWheel, { passive: false })
|
|
212
|
+
handOver.forEach((t) => window.addEventListener(t, stopGlide, { passive: true }))
|
|
213
|
+
}
|
|
214
|
+
wheelUsers.push(user)
|
|
215
|
+
return () => {
|
|
216
|
+
wheelUsers.splice(wheelUsers.indexOf(user), 1)
|
|
217
|
+
if (wheelUsers.length) return
|
|
218
|
+
window.removeEventListener('wheel', onWheel)
|
|
219
|
+
handOver.forEach((t) => window.removeEventListener(t, stopGlide))
|
|
220
|
+
stopGlide()
|
|
221
|
+
fine = null
|
|
222
|
+
}
|
|
223
|
+
}, [ease, disabled])
|
|
224
|
+
}
|
package/src/tokens.css
CHANGED
|
@@ -522,7 +522,41 @@
|
|
|
522
522
|
--hl-countdown-cell: 72px;
|
|
523
523
|
--hl-countdown-cell-sm: 52px;
|
|
524
524
|
|
|
525
|
+
/* Data grid: scroll area height, a column's default, smallest and largest width, one keyboard
|
|
526
|
+
resize step, resize handle hit width (a touch screen widens it to --hl-hit-min), the tallest
|
|
527
|
+
a filter's checkbox list grows, and the sort order badge */
|
|
528
|
+
--hl-datagrid-h: 480px;
|
|
529
|
+
--hl-datagrid-col-w: 160px;
|
|
530
|
+
--hl-datagrid-col-min: 96px;
|
|
531
|
+
--hl-datagrid-col-max: 640px;
|
|
532
|
+
--hl-datagrid-resize-step: 16px;
|
|
533
|
+
--hl-datagrid-resize-hit: 12px;
|
|
534
|
+
--hl-datagrid-options-h: 320px;
|
|
535
|
+
--hl-datagrid-order: 16px;
|
|
536
|
+
|
|
525
537
|
/* Touch lists: one swipe action's width, and how far a pull must go before it refreshes */
|
|
526
538
|
--hl-swipe-action-w: 80px;
|
|
527
539
|
--hl-pull-distance: 72px;
|
|
528
540
|
}
|
|
541
|
+
|
|
542
|
+
:root,
|
|
543
|
+
[data-theme="light"] {
|
|
544
|
+
/* SmoothScroll: the share of the distance left that a wheel glide covers each frame. 1 is native. */
|
|
545
|
+
--hl-scroll-ease: 0.1;
|
|
546
|
+
/* ScrollDraft and TextRise: where an element's top starts and ends its progress, as a share of the screen height */
|
|
547
|
+
--hl-draft-start: 0.85;
|
|
548
|
+
--hl-draft-end: 0.4;
|
|
549
|
+
/* TextRise: how many words of the progress each word takes to arrive, and room for descenders in its mask */
|
|
550
|
+
--hl-rise-span: 3;
|
|
551
|
+
--hl-rise-room: 0.14em;
|
|
552
|
+
/* ScrollRuler: its width, and the page pixels between two short ticks (a number every five) */
|
|
553
|
+
--hl-ruler-w: 48px;
|
|
554
|
+
--hl-ruler-step: 100px;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
@media (prefers-reduced-motion: reduce) {
|
|
558
|
+
:root,
|
|
559
|
+
[data-theme] {
|
|
560
|
+
--hl-scroll-ease: 1;
|
|
561
|
+
}
|
|
562
|
+
}
|