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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hlmnhkl-ui",
3
- "version": "0.21.1",
3
+ "version": "0.22.0",
4
4
  "description": "React components in a monochrome style. Copy them into your app with npx hlmnhkl-ui add <name>.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://ui.hlmnhkl.my",
@@ -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: 0.45; cursor: not-allowed; }
41
+ .trigger:disabled { opacity: var(--hl-disabled-opacity); cursor: not-allowed; }
42
42
 
43
43
  .trigger:focus-visible {
44
44
  outline: none;
@@ -1,5 +1,6 @@
1
1
  .root {
2
2
  display: inline-flex;
3
+ width: fit-content;
3
4
  font-variant-numeric: tabular-nums;
4
5
  line-height: 1.15;
5
6
  }
@@ -1,5 +1,6 @@
1
1
  .root {
2
2
  display: inline-flex;
3
+ width: fit-content;
3
4
  align-items: center;
4
5
  gap: var(--hl-space-2);
5
6
  max-width: 100%;
@@ -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: 0.45;
60
+ opacity: var(--hl-disabled-opacity);
59
61
  }
60
62
 
61
63
  .root[data-loading] {
@@ -6,7 +6,7 @@
6
6
  padding-block: var(--hl-space-4);
7
7
  }
8
8
 
9
- .root[data-disabled] { opacity: 0.5; }
9
+ .root[data-disabled] { opacity: var(--hl-disabled-opacity); }
10
10
 
11
11
  .boxWrap {
12
12
  position: relative;
@@ -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: 0.45; cursor: not-allowed; }
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 {
@@ -2,6 +2,7 @@
2
2
  --s: var(--hl-control-h-md);
3
3
  position: relative;
4
4
  display: inline-flex;
5
+ width: fit-content;
5
6
  align-items: center;
6
7
  justify-content: center;
7
8
  gap: var(--hl-space-3);
@@ -4,7 +4,7 @@
4
4
  min-width: 0;
5
5
  }
6
6
 
7
- .root[data-disabled] { opacity: 0.6; }
7
+ .root[data-disabled] { opacity: var(--hl-disabled-opacity); }
8
8
 
9
9
  .label {
10
10
  margin-bottom: var(--hl-space-3);
@@ -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: 0.45; cursor: not-allowed; }
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); }
@@ -64,7 +64,7 @@
64
64
  .field[data-disabled] {
65
65
  background: var(--hl-color-surface-sunken);
66
66
  cursor: not-allowed;
67
- opacity: 0.6;
67
+ opacity: var(--hl-disabled-opacity);
68
68
  }
69
69
 
70
70
  .adorn {
@@ -1,5 +1,6 @@
1
1
  .root {
2
2
  display: inline-flex;
3
+ width: fit-content;
3
4
  align-items: center;
4
5
  justify-content: center;
5
6
  min-width: var(--hl-kbd-h);
@@ -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: 0.35; cursor: not-allowed; }
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 {
@@ -30,7 +30,7 @@
30
30
  padding-block: var(--hl-space-4);
31
31
  }
32
32
 
33
- .root[data-disabled] { opacity: 0.5; }
33
+ .root[data-disabled] { opacity: var(--hl-disabled-opacity); }
34
34
 
35
35
  .circleWrap {
36
36
  position: relative;
@@ -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: 0.4; cursor: not-allowed; }
78
+ .item:disabled { opacity: var(--hl-disabled-opacity); cursor: not-allowed; }
75
79
 
76
80
  .item:focus-visible {
77
81
  outline: none;
@@ -5,7 +5,7 @@
5
5
  min-width: 0;
6
6
  }
7
7
 
8
- .root[data-disabled] { opacity: 0.5; }
8
+ .root[data-disabled] { opacity: var(--hl-disabled-opacity); }
9
9
 
10
10
  .head {
11
11
  display: flex;
@@ -10,7 +10,7 @@
10
10
  justify-content: space-between;
11
11
  }
12
12
 
13
- .root[data-disabled] { opacity: 0.5; }
13
+ .root[data-disabled] { opacity: var(--hl-disabled-opacity); }
14
14
 
15
15
  .track {
16
16
  --w: var(--hl-switch-w);
@@ -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: 0.4; cursor: not-allowed; }
49
+ .tab:disabled { opacity: var(--hl-disabled-opacity); cursor: not-allowed; }
50
50
 
51
51
  .tab:focus-visible {
52
52
  outline: none;
@@ -1,5 +1,6 @@
1
1
  .root {
2
2
  display: inline-flex;
3
+ width: fit-content;
3
4
  align-items: center;
4
5
  max-width: 100%;
5
6
  height: var(--hl-control-h-sm);
@@ -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 = document.activeElement as HTMLElement | null
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
@@ -181,3 +181,4 @@ export const GripIcon = (p: IconProps) => (
181
181
  <path d="M5 9h14M5 15h14" />
182
182
  </Svg>
183
183
  )
184
+ export const FilterIcon = (p: IconProps) => <Svg {...p}><path d="M4.5 6h15l-5.75 7v5.5l-3.5-2V13z" /></Svg>
@@ -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
+ }