hlmnhkl-ui 0.32.2 → 0.34.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/src/components/Dial/Dial.module.css +303 -0
- package/src/components/Dial/Dial.tsx +291 -0
- package/src/components/Dial/index.ts +1 -0
- package/src/components/HoldToConfirm/HoldToConfirm.module.css +166 -0
- package/src/components/HoldToConfirm/HoldToConfirm.tsx +332 -0
- package/src/components/HoldToConfirm/index.ts +1 -0
- package/src/components/MorphButton/MorphButton.module.css +151 -0
- package/src/components/MorphButton/MorphButton.tsx +174 -0
- package/src/components/MorphButton/index.ts +1 -0
- package/src/components/PickerWheel/PickerWheel.module.css +123 -0
- package/src/components/PickerWheel/PickerWheel.tsx +333 -0
- package/src/components/PickerWheel/index.ts +1 -0
- package/src/components/RulerScrubber/RulerScrubber.module.css +146 -0
- package/src/components/RulerScrubber/RulerScrubber.tsx +206 -0
- package/src/components/RulerScrubber/index.ts +1 -0
- package/src/components/Sheet/Sheet.module.css +65 -0
- package/src/components/Sheet/Sheet.tsx +376 -4
- package/src/internal/Roll.module.css +31 -0
- package/src/internal/Roll.tsx +19 -0
- package/src/internal/feel.ts +419 -0
- package/src/internal/focus.ts +1 -1
- package/src/internal/layers.ts +16 -7
- package/src/internal/magnet.ts +1 -17
- package/src/internal/spring.ts +4 -52
- package/src/internal/useScrub.ts +166 -0
- package/src/tokens.css +30 -2
package/README.md
CHANGED
|
@@ -65,7 +65,7 @@ Setup for other tools: [MCP server](https://ui.hlmnhkl.my/mcp).
|
|
|
65
65
|
## What you get
|
|
66
66
|
|
|
67
67
|
- **The source, not a package.** The command copies plain files into your app. Change anything; there is no update to wait for.
|
|
68
|
-
- **
|
|
68
|
+
- **68 free components**, from Button, Dialog, Select and Table to Toast and Retime, one speed setting for every animation. See [COMPONENTS.md](COMPONENTS.md).
|
|
69
69
|
- **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.
|
|
70
70
|
- **Light and dark.** Dark follows the system, or set `data-theme="dark"` on `<html>`.
|
|
71
71
|
- **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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hlmnhkl-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.0",
|
|
4
4
|
"description": "React components in black, white and grey with one blue accent. Copy them into your app with npx hlmnhkl-ui add <name>.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://ui.hlmnhkl.my",
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
.root {
|
|
2
|
+
display: grid;
|
|
3
|
+
justify-items: center;
|
|
4
|
+
gap: var(--hl-space-6);
|
|
5
|
+
width: 100%;
|
|
6
|
+
color: var(--hl-color-fg);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.head {
|
|
10
|
+
display: grid;
|
|
11
|
+
justify-items: center;
|
|
12
|
+
gap: var(--hl-space-1);
|
|
13
|
+
text-align: center;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.label {
|
|
17
|
+
font-size: var(--hl-text-sm);
|
|
18
|
+
color: var(--hl-color-fg-muted);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.readout {
|
|
22
|
+
font-size: var(--hl-text-4xl);
|
|
23
|
+
font-weight: var(--hl-weight-semibold);
|
|
24
|
+
letter-spacing: var(--hl-tracking-tight);
|
|
25
|
+
line-height: 1;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.detail {
|
|
29
|
+
font-size: var(--hl-text-sm);
|
|
30
|
+
color: var(--hl-color-fg-muted);
|
|
31
|
+
font-variant-numeric: tabular-nums;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* Every dial: grabbed, not selected, and never scrolled by a finger on it. */
|
|
35
|
+
.knob,
|
|
36
|
+
.mercuryHit,
|
|
37
|
+
.arcStage {
|
|
38
|
+
position: relative;
|
|
39
|
+
cursor: grab;
|
|
40
|
+
outline: none;
|
|
41
|
+
user-select: none;
|
|
42
|
+
-webkit-user-select: none;
|
|
43
|
+
-webkit-tap-highlight-color: transparent;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.knob[data-moving],
|
|
47
|
+
.mercuryHit[data-moving],
|
|
48
|
+
.arcStage[data-moving] {
|
|
49
|
+
cursor: grabbing;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* Printed knob */
|
|
53
|
+
|
|
54
|
+
.printed {
|
|
55
|
+
--num-r: calc(var(--hl-dial-size) * -0.29);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.bezel {
|
|
59
|
+
position: relative;
|
|
60
|
+
display: grid;
|
|
61
|
+
place-items: center;
|
|
62
|
+
width: var(--hl-dial-ring);
|
|
63
|
+
height: var(--hl-dial-ring);
|
|
64
|
+
border-radius: var(--hl-radius-full);
|
|
65
|
+
border: var(--hl-line) solid var(--hl-color-border);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.index {
|
|
69
|
+
position: absolute;
|
|
70
|
+
top: var(--hl-space-1);
|
|
71
|
+
left: 50%;
|
|
72
|
+
width: var(--hl-indicator);
|
|
73
|
+
height: var(--hl-space-4);
|
|
74
|
+
margin-left: calc(var(--hl-indicator) / -2);
|
|
75
|
+
border-radius: var(--hl-radius-full);
|
|
76
|
+
background: var(--hl-color-fg);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.knob {
|
|
80
|
+
width: var(--hl-dial-size);
|
|
81
|
+
height: var(--hl-dial-size);
|
|
82
|
+
border-radius: var(--hl-radius-full);
|
|
83
|
+
background: var(--hl-color-surface-raised);
|
|
84
|
+
box-shadow: var(--hl-shadow-2), inset 0 0 0 var(--hl-line) var(--hl-color-border);
|
|
85
|
+
touch-action: none;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.knob:focus-visible {
|
|
89
|
+
box-shadow: var(--hl-focus-ring), var(--hl-shadow-2);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.face {
|
|
93
|
+
position: absolute;
|
|
94
|
+
inset: 0;
|
|
95
|
+
border-radius: inherit;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.spoke,
|
|
99
|
+
.numAt {
|
|
100
|
+
position: absolute;
|
|
101
|
+
left: 50%;
|
|
102
|
+
top: 50%;
|
|
103
|
+
width: 0;
|
|
104
|
+
height: 0;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.numAt {
|
|
108
|
+
left: 0;
|
|
109
|
+
top: 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.tick {
|
|
113
|
+
position: absolute;
|
|
114
|
+
left: calc(var(--hl-indicator) / -2);
|
|
115
|
+
top: calc(var(--hl-dial-size) / -2 + var(--hl-space-3));
|
|
116
|
+
width: var(--hl-indicator);
|
|
117
|
+
height: var(--hl-space-3);
|
|
118
|
+
border-radius: var(--hl-radius-full);
|
|
119
|
+
transform-origin: top center;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.tick[data-major] {
|
|
123
|
+
height: var(--hl-space-5);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.tick[data-strong] {
|
|
127
|
+
left: calc(var(--hl-indicator) * -1);
|
|
128
|
+
width: calc(var(--hl-indicator) * 2);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.num {
|
|
132
|
+
position: absolute;
|
|
133
|
+
left: 0;
|
|
134
|
+
top: 0;
|
|
135
|
+
font-size: var(--hl-text-sm);
|
|
136
|
+
font-variant-numeric: tabular-nums;
|
|
137
|
+
white-space: nowrap;
|
|
138
|
+
line-height: 1;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.cap {
|
|
142
|
+
position: absolute;
|
|
143
|
+
inset: 35%;
|
|
144
|
+
border-radius: var(--hl-radius-full);
|
|
145
|
+
border: var(--hl-line) solid var(--hl-color-border);
|
|
146
|
+
background: var(--hl-color-surface-sunken);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/* Mercury */
|
|
150
|
+
|
|
151
|
+
.mercury {
|
|
152
|
+
gap: var(--hl-space-4);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.mercuryHit {
|
|
156
|
+
width: min(100%, calc(var(--hl-dial-ring) + var(--hl-space-9)));
|
|
157
|
+
border-radius: var(--hl-radius-panel);
|
|
158
|
+
touch-action: none;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.mercuryHit:focus-visible {
|
|
162
|
+
box-shadow: var(--hl-focus-ring);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.mercurySvg {
|
|
166
|
+
display: block;
|
|
167
|
+
width: 100%;
|
|
168
|
+
height: auto;
|
|
169
|
+
overflow: visible;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.metal {
|
|
173
|
+
fill: var(--hl-color-fg);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.groove {
|
|
177
|
+
fill: none;
|
|
178
|
+
stroke: color-mix(in srgb, var(--hl-color-inverse-fg) 22%, transparent);
|
|
179
|
+
stroke-width: var(--hl-line);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.dot {
|
|
183
|
+
fill: var(--hl-color-border-strong);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.dot[data-on] {
|
|
187
|
+
fill: var(--hl-color-accent);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.level {
|
|
191
|
+
font-family: var(--hl-font-sans);
|
|
192
|
+
font-size: var(--hl-text-sm);
|
|
193
|
+
fill: var(--hl-color-fg-muted);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.level[data-on] {
|
|
197
|
+
fill: var(--hl-color-accent);
|
|
198
|
+
font-weight: var(--hl-weight-semibold);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/* Speed arc */
|
|
202
|
+
|
|
203
|
+
.arc {
|
|
204
|
+
gap: var(--hl-space-5);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.arcStage {
|
|
208
|
+
--rim: calc(var(--hl-ruler-h) / 2);
|
|
209
|
+
width: 100%;
|
|
210
|
+
height: var(--hl-ruler-h);
|
|
211
|
+
border-radius: var(--hl-radius-md);
|
|
212
|
+
/* A sideways swipe turns it; up and down still scrolls the page. */
|
|
213
|
+
touch-action: pan-y;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
.arcStage:focus-visible {
|
|
217
|
+
box-shadow: var(--hl-focus-ring);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.arcClip {
|
|
221
|
+
position: absolute;
|
|
222
|
+
inset: 0;
|
|
223
|
+
overflow: hidden;
|
|
224
|
+
border-radius: inherit;
|
|
225
|
+
mask-image: linear-gradient(90deg, transparent, var(--hl-color-fg) 16%, var(--hl-color-fg) 84%, transparent);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.hub {
|
|
229
|
+
position: absolute;
|
|
230
|
+
left: 50%;
|
|
231
|
+
top: calc(var(--rim) + var(--hl-dial-arc-radius));
|
|
232
|
+
width: 0;
|
|
233
|
+
height: 0;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
.disc {
|
|
237
|
+
position: absolute;
|
|
238
|
+
left: calc(var(--hl-dial-arc-radius) * -1);
|
|
239
|
+
top: calc(var(--hl-dial-arc-radius) * -1);
|
|
240
|
+
width: calc(var(--hl-dial-arc-radius) * 2);
|
|
241
|
+
height: calc(var(--hl-dial-arc-radius) * 2);
|
|
242
|
+
border-radius: var(--hl-radius-full);
|
|
243
|
+
background: var(--hl-color-surface-sunken);
|
|
244
|
+
box-shadow: inset 0 var(--hl-line) 0 var(--hl-color-border-strong);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
.arcMark {
|
|
248
|
+
position: absolute;
|
|
249
|
+
left: 0;
|
|
250
|
+
top: 0;
|
|
251
|
+
width: 0;
|
|
252
|
+
height: 0;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
.arcTick {
|
|
256
|
+
position: absolute;
|
|
257
|
+
left: calc(var(--hl-indicator) / -2);
|
|
258
|
+
bottom: 0;
|
|
259
|
+
width: var(--hl-indicator);
|
|
260
|
+
height: var(--hl-space-3);
|
|
261
|
+
border-radius: var(--hl-radius-full);
|
|
262
|
+
transform-origin: bottom center;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.arcTick[data-tier='mid'] {
|
|
266
|
+
height: var(--hl-space-4);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
.arcTick[data-tier='major'] {
|
|
270
|
+
height: var(--hl-space-6);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
.arcNum {
|
|
274
|
+
position: absolute;
|
|
275
|
+
left: 0;
|
|
276
|
+
top: var(--hl-space-4);
|
|
277
|
+
font-size: var(--hl-text-xs);
|
|
278
|
+
font-variant-numeric: tabular-nums;
|
|
279
|
+
white-space: nowrap;
|
|
280
|
+
transform-origin: top center;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
.arcStrong {
|
|
284
|
+
position: absolute;
|
|
285
|
+
left: calc(var(--hl-dot) / -2);
|
|
286
|
+
top: var(--hl-space-8);
|
|
287
|
+
width: var(--hl-dot);
|
|
288
|
+
height: var(--hl-dot);
|
|
289
|
+
border-radius: var(--hl-radius-full);
|
|
290
|
+
background: var(--hl-color-fg-muted);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
.arcIndex {
|
|
294
|
+
position: absolute;
|
|
295
|
+
z-index: 1;
|
|
296
|
+
left: 50%;
|
|
297
|
+
top: var(--hl-space-3);
|
|
298
|
+
width: var(--hl-indicator);
|
|
299
|
+
height: var(--hl-space-5);
|
|
300
|
+
margin-left: calc(var(--hl-indicator) / -2);
|
|
301
|
+
border-radius: var(--hl-radius-full);
|
|
302
|
+
background: var(--hl-color-fg);
|
|
303
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useId, useLayoutEffect, useRef, useState, type ComponentPropsWithRef, type PointerEvent, type ReactNode } from 'react'
|
|
4
|
+
import { cx } from '../../internal/cx'
|
|
5
|
+
import { attract, clamp, lastStep, pull, roundTo, steps, wheelSteps, stepKey, type Mark } from '../../internal/feel'
|
|
6
|
+
import { Roll } from '../../internal/Roll'
|
|
7
|
+
import { useControllable } from '../../internal/useControllable'
|
|
8
|
+
import { ink, sideways, tokenNumber, useOnChange, useScrub } from '../../internal/useScrub'
|
|
9
|
+
import styles from './Dial.module.css'
|
|
10
|
+
|
|
11
|
+
export type DialProps = Omit<ComponentPropsWithRef<'div'>, 'defaultValue' | 'onChange' | 'children'> & {
|
|
12
|
+
/** Visible name of the dial, also read by screen readers. */
|
|
13
|
+
label: string
|
|
14
|
+
/** The value, when you control it. */
|
|
15
|
+
value?: number
|
|
16
|
+
/** Starting value when you do not control it. Undefined starts at min. */
|
|
17
|
+
defaultValue?: number
|
|
18
|
+
/** Called once the dial comes to rest on a new value. */
|
|
19
|
+
onValueChange?: (value: number) => void
|
|
20
|
+
/** Called with the value under the index while the dial still turns. */
|
|
21
|
+
onScrub?: (value: number) => void
|
|
22
|
+
/** Lowest value. */
|
|
23
|
+
min?: number
|
|
24
|
+
/** Highest value. */
|
|
25
|
+
max?: number
|
|
26
|
+
/** Size of one step. Every step is a mark the dial is drawn onto. */
|
|
27
|
+
step?: number
|
|
28
|
+
/** Values with a printed label. Page Up and Page Down jump between them. Defaults to min and max. */
|
|
29
|
+
marks?: number[]
|
|
30
|
+
/** Values that pull harder from further away, such as the usual setting. */
|
|
31
|
+
strong?: number[]
|
|
32
|
+
/** "printed": the scale is printed on the knob and turns under a fixed index. "mercury": a solid knob that reaches for a few named levels. "arc": the rim of a large dial, swiped sideways. */
|
|
33
|
+
variant?: 'printed' | 'mercury' | 'arc'
|
|
34
|
+
/** Turns the value into the readout and what screen readers hear. */
|
|
35
|
+
formatValue?: (value: number) => string
|
|
36
|
+
/** Turns a mark into its printed label. Defaults to formatValue. */
|
|
37
|
+
formatMark?: (value: number) => string
|
|
38
|
+
/** A line under the readout, such as what the value means. */
|
|
39
|
+
detail?: ReactNode
|
|
40
|
+
/** A double click goes back to this value. */
|
|
41
|
+
resetValue?: number
|
|
42
|
+
/** A short buzz on each step while it turns, on phones that can (Android). */
|
|
43
|
+
haptics?: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The printed and mercury dials sweep 270 degrees, with the dead part at the bottom.
|
|
47
|
+
const SWEEP = 270
|
|
48
|
+
|
|
49
|
+
// Mercury is drawn in this box (SVG units): knob, bump and level dots.
|
|
50
|
+
const W = 280
|
|
51
|
+
const H = 252
|
|
52
|
+
const CX = W / 2
|
|
53
|
+
const CY = 128
|
|
54
|
+
const KNOB = 58
|
|
55
|
+
const DOT = 84
|
|
56
|
+
const polar = (deg: number, r: number) => [CX + r * Math.sin((deg * Math.PI) / 180), CY - r * Math.cos((deg * Math.PI) / 180)] as const
|
|
57
|
+
|
|
58
|
+
/** A drag around a centre: the change in angle since the last move, never the angle itself, so a press never jumps. */
|
|
59
|
+
function turn(unitsPerDeg: number, centre: (r: DOMRect) => { x: number; y: number }) {
|
|
60
|
+
return (e: PointerEvent<HTMLElement>, last: { x: number; y: number }, el: HTMLElement) => {
|
|
61
|
+
const c = centre(el.getBoundingClientRect())
|
|
62
|
+
// Near the centre the angle swings wildly, so it does not count.
|
|
63
|
+
if (Math.hypot(e.clientX - c.x, e.clientY - c.y) < 16) return 0
|
|
64
|
+
let d = ((Math.atan2(e.clientY - c.y, e.clientX - c.x) - Math.atan2(last.y - c.y, last.x - c.x)) * 180) / Math.PI
|
|
65
|
+
if (d > 180) d -= 360
|
|
66
|
+
if (d < -180) d += 360
|
|
67
|
+
return d * unitsPerDeg * (e.shiftKey ? 0.25 : 1)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function Dial({
|
|
72
|
+
label,
|
|
73
|
+
value: valueProp,
|
|
74
|
+
defaultValue,
|
|
75
|
+
onValueChange,
|
|
76
|
+
onScrub,
|
|
77
|
+
min = 0,
|
|
78
|
+
max = 100,
|
|
79
|
+
step = 1,
|
|
80
|
+
marks,
|
|
81
|
+
strong = [],
|
|
82
|
+
variant = 'printed',
|
|
83
|
+
formatValue = String,
|
|
84
|
+
formatMark,
|
|
85
|
+
detail,
|
|
86
|
+
resetValue,
|
|
87
|
+
haptics = false,
|
|
88
|
+
className,
|
|
89
|
+
...rest
|
|
90
|
+
}: DialProps) {
|
|
91
|
+
const labelId = useId()
|
|
92
|
+
const detailId = useId()
|
|
93
|
+
const goo = useId().replace(/:/g, '')
|
|
94
|
+
const top = lastStep(min, max, step)
|
|
95
|
+
const [raw, setValue] = useControllable(valueProp, defaultValue ?? min, onValueChange)
|
|
96
|
+
const value = clamp(raw, min, top)
|
|
97
|
+
const all = steps(min, max, step)
|
|
98
|
+
const labelled = (marks ?? [min, max]).map((v) => roundTo(v, step, min))
|
|
99
|
+
const strongSet = new Set(strong.map((v) => roundTo(v, step, min)))
|
|
100
|
+
const pulls: Mark[] = all.map((at) => ({ at, strong: strongSet.has(at) }))
|
|
101
|
+
const land = (p: number) => clamp(roundTo(p, step, min), min, top)
|
|
102
|
+
const markText = formatMark ?? formatValue
|
|
103
|
+
const stepDeg = SWEEP / Math.max(1, all.length - 1)
|
|
104
|
+
// The arc's rim: its radius and the length of one step along it, both tokens.
|
|
105
|
+
const [rim, setRim] = useState<{ r: number; gap: number } | null>(null)
|
|
106
|
+
const wheel = useRef(wheelSteps())
|
|
107
|
+
|
|
108
|
+
const sc = useScrub({
|
|
109
|
+
value,
|
|
110
|
+
min,
|
|
111
|
+
max: top,
|
|
112
|
+
edge: step * (variant === 'arc' ? 0.8 : variant === 'mercury' ? 0.12 : 0.6),
|
|
113
|
+
attract: (r) => attract(r, pulls, step, sc.reach()),
|
|
114
|
+
land,
|
|
115
|
+
coast: variant === 'arc' ? 0.2 : 0,
|
|
116
|
+
maxSpeed: 160 * step,
|
|
117
|
+
rest: step / 50,
|
|
118
|
+
move:
|
|
119
|
+
variant === 'arc'
|
|
120
|
+
? (e, last) => (-(e.clientX - last.x) / (rim?.gap || 1)) * step * (e.shiftKey ? 0.25 : 1)
|
|
121
|
+
: variant === 'mercury'
|
|
122
|
+
? turn(step / stepDeg, (r) => ({ x: r.left + r.width / 2, y: r.top + (r.height * CY) / H }))
|
|
123
|
+
: turn((max - min) / SWEEP, (r) => ({ x: r.left + r.width / 2, y: r.top + r.height / 2 })),
|
|
124
|
+
key: (k, t) => stepKey(k, t, { min, max, step, marks: labelled }),
|
|
125
|
+
wheel:
|
|
126
|
+
variant === 'arc'
|
|
127
|
+
? (e, t) => {
|
|
128
|
+
const dx = sideways(e)
|
|
129
|
+
if (dx === null || !rim?.gap) return null
|
|
130
|
+
const n = wheel.current(dx, rim.gap, e.timeStamp)
|
|
131
|
+
const to = clamp(roundTo(t + n * step, step, min), min, top)
|
|
132
|
+
return to === t && (t === min || t === top) ? null : to
|
|
133
|
+
}
|
|
134
|
+
: undefined,
|
|
135
|
+
onSettle: setValue,
|
|
136
|
+
haptics,
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
useLayoutEffect(() => {
|
|
140
|
+
if (variant !== 'arc') return
|
|
141
|
+
const el = sc.ref.current
|
|
142
|
+
setRim({ r: tokenNumber(el, '--hl-dial-arc-radius'), gap: tokenNumber(el, '--hl-dial-arc-gap') })
|
|
143
|
+
}, [variant, sc.ref])
|
|
144
|
+
|
|
145
|
+
const shown = land(sc.at)
|
|
146
|
+
useOnChange(shown, onScrub)
|
|
147
|
+
|
|
148
|
+
const index = all.indexOf(value)
|
|
149
|
+
const slider = {
|
|
150
|
+
ref: sc.ref,
|
|
151
|
+
role: 'slider',
|
|
152
|
+
tabIndex: 0,
|
|
153
|
+
'aria-labelledby': labelId,
|
|
154
|
+
'aria-describedby': detail ? detailId : undefined,
|
|
155
|
+
'aria-valuemin': min,
|
|
156
|
+
'aria-valuemax': top,
|
|
157
|
+
'aria-valuenow': value,
|
|
158
|
+
'aria-valuetext': variant === 'mercury' && index >= 0 ? `${formatValue(value)}, ${index + 1} of ${all.length}` : formatValue(value),
|
|
159
|
+
'data-moving': sc.moving || undefined,
|
|
160
|
+
onDoubleClick: resetValue === undefined ? undefined : () => sc.go(land(resetValue)),
|
|
161
|
+
...sc.handlers,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const head = (
|
|
165
|
+
<div className={styles.head}>
|
|
166
|
+
<span id={labelId} className={styles.label}>
|
|
167
|
+
{label}
|
|
168
|
+
</span>
|
|
169
|
+
<Roll text={formatValue(shown)} className={styles.readout} />
|
|
170
|
+
{detail && (
|
|
171
|
+
<span id={detailId} className={styles.detail}>
|
|
172
|
+
{detail}
|
|
173
|
+
</span>
|
|
174
|
+
)}
|
|
175
|
+
</div>
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
if (variant === 'mercury') {
|
|
179
|
+
const angle = -135 + ((sc.at - min) / step) * stepDeg
|
|
180
|
+
const [bx, by] = polar(angle, KNOB)
|
|
181
|
+
return (
|
|
182
|
+
<div className={cx(styles.root, styles.mercury, className)} {...rest}>
|
|
183
|
+
{head}
|
|
184
|
+
<div className={styles.mercuryHit} {...slider}>
|
|
185
|
+
<svg viewBox={`0 0 ${W} ${H}`} className={styles.mercurySvg} aria-hidden>
|
|
186
|
+
<defs>
|
|
187
|
+
<filter id={goo} x="-20%" y="-20%" width="140%" height="140%">
|
|
188
|
+
<feGaussianBlur in="SourceGraphic" stdDeviation="5" result="blur" />
|
|
189
|
+
<feColorMatrix in="blur" mode="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 22 -9" result="goo" />
|
|
190
|
+
<feComposite in="SourceGraphic" in2="goo" operator="atop" />
|
|
191
|
+
</filter>
|
|
192
|
+
</defs>
|
|
193
|
+
{all.map((v, i) => {
|
|
194
|
+
const a = -135 + i * stepDeg
|
|
195
|
+
const [x, y] = polar(a, DOT)
|
|
196
|
+
const [lx, ly] = polar(a, DOT + 22)
|
|
197
|
+
const on = v === shown
|
|
198
|
+
return (
|
|
199
|
+
<g key={v}>
|
|
200
|
+
<circle cx={x} cy={y} r={3.5} className={styles.dot} data-on={on || undefined} />
|
|
201
|
+
<text x={lx} y={ly} textAnchor={Math.abs(a) < 1 ? 'middle' : a < 0 ? 'end' : 'start'} dominantBaseline="middle" className={styles.level} data-on={on || undefined}>
|
|
202
|
+
{markText(v)}
|
|
203
|
+
</text>
|
|
204
|
+
</g>
|
|
205
|
+
)
|
|
206
|
+
})}
|
|
207
|
+
<g filter={`url(#${goo})`} className={styles.metal}>
|
|
208
|
+
<circle cx={CX} cy={CY} r={KNOB} />
|
|
209
|
+
<circle cx={bx} cy={by} r={10} />
|
|
210
|
+
{all.map((v, i) => {
|
|
211
|
+
const p = pull(sc.at - v, step)
|
|
212
|
+
const [x, y] = polar(-135 + i * stepDeg, DOT - 12 * p)
|
|
213
|
+
return <circle key={v} cx={x} cy={y} r={p * 9} />
|
|
214
|
+
})}
|
|
215
|
+
</g>
|
|
216
|
+
<circle cx={CX} cy={CY} r={KNOB - 14} className={styles.groove} />
|
|
217
|
+
</svg>
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (variant === 'arc') {
|
|
224
|
+
const degPerStep = rim && rim.r ? (rim.gap / rim.r) * (180 / Math.PI) : 0
|
|
225
|
+
return (
|
|
226
|
+
<div className={cx(styles.root, styles.arc, className)} {...rest}>
|
|
227
|
+
{head}
|
|
228
|
+
<div className={styles.arcStage} {...slider}>
|
|
229
|
+
<span className={styles.arcIndex} aria-hidden />
|
|
230
|
+
<div className={styles.arcClip} aria-hidden>
|
|
231
|
+
<div className={styles.hub}>
|
|
232
|
+
<span className={styles.disc} />
|
|
233
|
+
{degPerStep > 0 &&
|
|
234
|
+
all.map((v, i) => {
|
|
235
|
+
const a = ((v - sc.at) / step) * degPerStep
|
|
236
|
+
if (Math.abs(a) > 22) return null
|
|
237
|
+
const p = pull(sc.at - v, step * 1.2)
|
|
238
|
+
const tier = labelled.includes(v) ? 'major' : i % 2 === 0 ? 'mid' : 'minor'
|
|
239
|
+
return (
|
|
240
|
+
<span key={v} className={styles.arcMark} style={{ transform: `rotate(${a}deg) translateY(calc(var(--hl-dial-arc-radius) * -1))` }}>
|
|
241
|
+
<i className={styles.arcTick} data-tier={tier} style={{ background: ink(p, 'var(--hl-color-border-strong)', 'var(--hl-color-accent)'), transform: `scaleY(${1 + p * 0.7})` }} />
|
|
242
|
+
{tier === 'major' && (
|
|
243
|
+
<span className={styles.arcNum} style={{ color: ink(p), transform: `translateX(-50%) scale(${1 + p * 0.3})` }}>
|
|
244
|
+
{markText(v)}
|
|
245
|
+
</span>
|
|
246
|
+
)}
|
|
247
|
+
{strongSet.has(v) && <span className={styles.arcStrong} />}
|
|
248
|
+
</span>
|
|
249
|
+
)
|
|
250
|
+
})}
|
|
251
|
+
</div>
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const turned = ((sc.at - min) / (max - min || 1)) * SWEEP
|
|
259
|
+
// Every step gets a tick while they fit round the knob; past that only the labelled and strong ones.
|
|
260
|
+
const ticks = all.length <= 49 ? all : all.filter((v) => labelled.includes(v) || strongSet.has(v))
|
|
261
|
+
return (
|
|
262
|
+
<div className={cx(styles.root, styles.printed, className)} {...rest}>
|
|
263
|
+
{head}
|
|
264
|
+
<div className={styles.bezel}>
|
|
265
|
+
<span className={styles.index} aria-hidden />
|
|
266
|
+
<div className={styles.knob} {...slider}>
|
|
267
|
+
<div className={styles.face} style={{ transform: `rotate(${turned}deg)` }} aria-hidden>
|
|
268
|
+
{ticks.map((v) => {
|
|
269
|
+
const at = (-(v - min) / (max - min || 1)) * SWEEP
|
|
270
|
+
const p = pull(sc.at - v, step)
|
|
271
|
+
const major = labelled.includes(v)
|
|
272
|
+
return (
|
|
273
|
+
<span key={v} className={styles.spoke} style={{ transform: `rotate(${at}deg)` }}>
|
|
274
|
+
<i className={styles.tick} data-major={major || undefined} data-strong={strongSet.has(v) || undefined} data-current={v === shown || undefined} style={{ background: ink(p, 'var(--hl-color-border-strong)', 'var(--hl-color-accent)'), transform: `scaleY(${1 + p * 0.5})` }} />
|
|
275
|
+
{major && (
|
|
276
|
+
<span className={styles.numAt} style={{ transform: `translateY(var(--num-r)) rotate(${-(at + turned)}deg)` }}>
|
|
277
|
+
<span className={styles.num} style={{ color: ink(p), transform: `translate(-50%, -50%) scale(${1 + p * 0.45})`, fontWeight: `calc(var(--hl-weight-medium) + (var(--hl-weight-semibold) - var(--hl-weight-medium)) * ${p.toFixed(3)})` }}>
|
|
278
|
+
{markText(v)}
|
|
279
|
+
</span>
|
|
280
|
+
</span>
|
|
281
|
+
)}
|
|
282
|
+
</span>
|
|
283
|
+
)
|
|
284
|
+
})}
|
|
285
|
+
<span className={styles.cap} />
|
|
286
|
+
</div>
|
|
287
|
+
</div>
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
)
|
|
291
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './Dial'
|