hlmnhkl-ui 0.33.0 → 0.35.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
@@ -65,7 +65,8 @@ 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
- - **65 free components**, from Button, Dialog, Select and Table to Toast and Retime, one speed setting for every animation. See [COMPONENTS.md](COMPONENTS.md).
68
+ - **71 free components**, from Button, Dialog, Select and Table to Toast and Retime, one speed setting for every animation. See [COMPONENTS.md](COMPONENTS.md).
69
+ - **Controls that feel made by hand.** Dial, RulerScrubber and PickerWheel are drawn onto their marks like a magnet, glide after a flick without flying past, and settle without a bounce. Sheet rests at set heights, HoldToConfirm burns a line round its edge, SwipeDeck throws its cards and SplitFlap flips like a station board.
69
70
  - **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
71
  - **Light and dark.** Dark follows the system, or set `data-theme="dark"` on `<html>`.
71
72
  - **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.
@@ -74,7 +75,7 @@ Setup for other tools: [MCP server](https://ui.hlmnhkl.my/mcp).
74
75
 
75
76
  ## Pro
76
77
 
77
- 72 more components (such as DataGrid, Calendar, Chart, KanbanBoard and Combobox), page blocks, full templates and brand themes are coming as hlmnhkl/ui Pro. You can see them on the [docs site](https://ui.hlmnhkl.my).
78
+ 76 more components (such as DataGrid, Calendar, Chart, KanbanBoard, Combobox and WeekSchedule), page blocks, full templates and brand themes are coming as hlmnhkl/ui Pro. You can see them on the [docs site](https://ui.hlmnhkl.my).
78
79
 
79
80
  ## Licence
80
81
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hlmnhkl-ui",
3
- "version": "0.33.0",
3
+ "version": "0.35.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'