@lynn123411/dsh-model-submenu 0.3.10 → 0.3.12

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.
@@ -3,9 +3,10 @@
3
3
  *
4
4
  * The Host's second menu cell is a button that drills into a list of levels.
5
5
  * This slider sits inside that same cell, between the Host's label and value, so
6
- * the levels are reachable without leaving the menu. Levels are discrete: the
7
- * knob snaps to one detent per level, the drag only moves the knob, and the
8
- * release commits once. Keyboard arrows step and commit immediately.
6
+ * the levels are reachable without leaving the menu. The levels are discrete but
7
+ * the drag is not: the knob follows the pointer continuously, the level it
8
+ * reports is the detent nearest that position, and letting go eases the knob onto
9
+ * that detent and commits once. Keyboard arrows step and commit immediately.
9
10
  *
10
11
  * The fill takes the level's color, so the track and the two captions read as
11
12
  * one signal: "off" is neutral, the levels above it walk a blue-to-violet ramp,
@@ -14,10 +15,10 @@
14
15
  * "Provider default" is not a detent: it is a state, drawn as an empty track
15
16
  * with a hollow knob, so it never reads as the lowest level.
16
17
  */
17
- import { useRef, useState, type KeyboardEvent, type PointerEvent } from 'react';
18
+ import { useEffect, useRef, useState, type KeyboardEvent, type PointerEvent } from 'react';
18
19
  import type { EffortChoice } from './selection.ts';
19
20
  import { LEVEL_GRADIENT, isTopLevel, levelColor } from './level-color.ts';
20
- import { knobOffset, offsetOf, stepAt } from './slider.ts';
21
+ import { detentAt, fillWidth, fractionAt, knobOffset, offsetOf } from './slider.ts';
21
22
  import css from './ModelOptions.module.css';
22
23
 
23
24
  /** Props of the slider: the levels, the one in force, and the commit path. */
@@ -49,22 +50,41 @@ export function EffortSlider({
49
50
  steps, current, defaultActive, defaultIndex, defaultLabel, busy, label, onCommit,
50
51
  }: EffortSliderProps) {
51
52
  const trackRef = useRef<HTMLDivElement | null>(null);
52
- // The drag index lives in a ref as well as in state: a press and a release can
53
- // arrive in one task, and the release must not read a render that never ran.
53
+ // The drag position lives in a ref as well as in state: a press and a release
54
+ // can arrive in one task, and the release must not read a render that never ran.
54
55
  const dragRef = useRef<number | null>(null);
56
+ // While dragging this is where the pointer is along the travel, not a level:
57
+ // the knob has to move with the pointer, or the drag reads as a series of jumps.
55
58
  const [drag, setDrag] = useState<number | null>(null);
59
+ // After a release that submits, the knob waits on the detent it settled on
60
+ // instead of springing back to the old level while the write is in flight.
61
+ const [hold, setHold] = useState<number | null>(null);
62
+ const dragging = drag !== null;
56
63
  const resting = defaultActive ? defaultIndex : current;
57
- const active = drag ?? resting;
58
- const beginDrag = (index: number | null): void => {
59
- dragRef.current = index;
60
- setDrag(index);
64
+ const active = dragging ? detentAt(drag, steps.length) : resting;
65
+ const knobFraction = drag ?? hold ?? offsetOf(resting, steps.length);
66
+ // The color and the ticks follow the knob, not the level in force: during a
67
+ // drag, and while a commit is in flight, the knob has already gone ahead.
68
+ const shown = dragging || hold !== null ? detentAt(knobFraction, steps.length) : resting;
69
+ const beginDrag = (fraction: number | null): void => {
70
+ dragRef.current = fraction;
71
+ setDrag(fraction);
61
72
  };
62
73
 
63
- const indexAt = (clientX: number): number => {
74
+ // The hold ends as soon as the write does, whether it landed on the new level
75
+ // or was rejected: the displayed level is the truth again either way.
76
+ useEffect(() => {
77
+ if (!busy) setHold(null);
78
+ }, [busy]);
79
+ useEffect(() => {
80
+ setHold(null);
81
+ }, [current, defaultActive]);
82
+
83
+ const fractionAtPointer = (clientX: number): number => {
64
84
  const track = trackRef.current;
65
- if (track === null) return active;
85
+ if (track === null) return dragRef.current ?? knobFraction;
66
86
  const rect = track.getBoundingClientRect();
67
- return stepAt(clientX, rect.left, rect.width, steps.length);
87
+ return fractionAt(clientX, rect.left, rect.width);
68
88
  };
69
89
 
70
90
  const onPointerDown = (event: PointerEvent<HTMLDivElement>): void => {
@@ -73,7 +93,8 @@ export function EffortSlider({
73
93
  // also settle the cell and pull focus out of the slider.
74
94
  event.preventDefault();
75
95
  event.stopPropagation();
76
- beginDrag(indexAt(event.clientX));
96
+ setHold(null);
97
+ beginDrag(fractionAtPointer(event.clientX));
77
98
  try {
78
99
  event.currentTarget.setPointerCapture(event.pointerId);
79
100
  } catch {
@@ -84,18 +105,20 @@ export function EffortSlider({
84
105
 
85
106
  const onPointerMove = (event: PointerEvent<HTMLDivElement>): void => {
86
107
  if (dragRef.current === null || busy) return;
87
- const next = indexAt(event.clientX);
88
- if (next !== dragRef.current) beginDrag(next);
108
+ beginDrag(fractionAtPointer(event.clientX));
89
109
  };
90
110
 
91
111
  const settle = (event: PointerEvent<HTMLDivElement>, commit: boolean): void => {
92
112
  if (dragRef.current === null) return;
93
- const next = indexAt(event.clientX);
113
+ const next = detentAt(fractionAtPointer(event.clientX), steps.length);
94
114
  beginDrag(null);
95
115
  if (!commit) return;
96
116
  // Leaving the provider default is a change even when the knob lands where
97
117
  // the default already pointed.
98
- if (next !== current || defaultActive) onCommit(next);
118
+ if (next !== current || defaultActive) {
119
+ setHold(offsetOf(next, steps.length));
120
+ onCommit(next);
121
+ }
99
122
  };
100
123
 
101
124
  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
@@ -117,13 +140,12 @@ export function EffortSlider({
117
140
  if (next !== current || defaultActive) onCommit(next);
118
141
  };
119
142
 
120
- const knobFraction = offsetOf(active, steps.length);
121
143
  const offset = knobOffset(knobFraction);
122
144
  // The provider default fills nothing: the knob shows where it lands, the
123
145
  // track stays empty so it never reads as a chosen level.
124
- const idle = defaultActive && drag === null;
146
+ const idle = defaultActive && !dragging && hold === null;
125
147
  const filled = idle ? 0 : knobFraction;
126
- const fill = isTopLevel(active, steps.length) ? LEVEL_GRADIENT : levelColor(active, steps.length);
148
+ const fill = isTopLevel(shown, steps.length) ? LEVEL_GRADIENT : levelColor(shown, steps.length);
127
149
  return <div
128
150
  ref={trackRef}
129
151
  className={css.slider}
@@ -133,11 +155,11 @@ export function EffortSlider({
133
155
  aria-valuemin={0}
134
156
  aria-valuemax={Math.max(0, steps.length - 1)}
135
157
  aria-valuenow={active}
136
- aria-valuetext={defaultActive && drag === null ? defaultLabel : steps[active]?.label}
158
+ aria-valuetext={defaultActive && !dragging ? defaultLabel : steps[active]?.label}
137
159
  aria-disabled={busy || undefined}
138
160
  data-busy={busy ? '' : undefined}
139
- data-dragging={drag === null ? undefined : ''}
140
- data-default={defaultActive && drag === null ? '' : undefined}
161
+ data-dragging={dragging ? '' : undefined}
162
+ data-default={idle ? '' : undefined}
141
163
  onPointerDown={onPointerDown}
142
164
  onPointerMove={onPointerMove}
143
165
  onPointerUp={(event) => { settle(event, true); }}
@@ -146,11 +168,11 @@ export function EffortSlider({
146
168
  onClick={(event) => { event.stopPropagation(); }}
147
169
  >
148
170
  <div className={css.sliderTrack} aria-hidden="true">
149
- <div className={css.sliderFill} style={{ width: knobOffset(filled), background: fill }} />
171
+ <div className={css.sliderFill} style={{ width: fillWidth(filled), background: fill }} />
150
172
  {steps.map((step, index) => <span
151
173
  key={step.key}
152
174
  className={css.sliderTick}
153
- data-on={index <= active && !idle ? '' : undefined}
175
+ data-on={index <= shown && !idle ? '' : undefined}
154
176
  style={{ left: knobOffset(offsetOf(index, steps.length)) }}
155
177
  />)}
156
178
  <div className={css.sliderKnob} style={{ left: offset }} />
@@ -41,17 +41,22 @@
41
41
  cursor: default;
42
42
  }
43
43
 
44
+ /* The track is a capsule and the knob a larger circle riding on it: the knob
45
+ * overhangs at both ends, so it reads as a knob rather than as the track's own
46
+ * end cap. */
44
47
  .sliderTrack {
45
48
  position: relative;
46
49
  width: 100%;
47
- height: 20px;
50
+ height: 16px;
48
51
  border-radius: 999px;
49
52
  corner-shape: round;
50
53
  background: var(--dsw-alias-border-l3);
51
54
  }
52
55
 
53
- /* The fill takes the level's color and is clipped to the filled part, so the
54
- * color under the knob is the color the level's text takes. */
56
+ /* The fill is the track's own shape cut at the knob: its right cap carries the
57
+ * track's radius and ends under the knob, so the color runs into the knob
58
+ * instead of stopping at a straight edge, and the last detent reaches the
59
+ * track's end. */
55
60
  .sliderFill {
56
61
  position: absolute;
57
62
  top: 0;
@@ -59,8 +64,10 @@
59
64
  left: 0;
60
65
  border-radius: 999px;
61
66
  corner-shape: round;
62
- overflow: hidden;
63
67
  pointer-events: none;
68
+ /* A level change eases the color to its new width; the drag turns this off so
69
+ * the edge stays under the pointer. */
70
+ transition: width 160ms cubic-bezier(0.2, 0, 0, 1);
64
71
  }
65
72
 
66
73
  /* One detent per level: the dot is the only thing that says the levels are
@@ -86,6 +93,9 @@
86
93
  .sliderKnob {
87
94
  position: absolute;
88
95
  top: 50%;
96
+ /* The provider default draws a 1.5px ring; border-box keeps that ring inside
97
+ * the 20px circle the travel is computed for. */
98
+ box-sizing: border-box;
89
99
  width: 20px;
90
100
  height: 20px;
91
101
  margin: -10px 0 0 -10px;
@@ -94,6 +104,21 @@
94
104
  background: var(--dsw-alias-label-primary-foreground);
95
105
  box-shadow: var(--dsw-elevation-soft);
96
106
  pointer-events: none;
107
+ /* Letting go eases the knob onto the detent it settled on; the drag turns this
108
+ * off so the knob sits exactly under the pointer. */
109
+ transition: left 160ms cubic-bezier(0.2, 0, 0, 1);
110
+ }
111
+
112
+ .slider[data-dragging] .sliderKnob,
113
+ .slider[data-dragging] .sliderFill {
114
+ transition: none;
115
+ }
116
+
117
+ @media (prefers-reduced-motion: reduce) {
118
+ .sliderKnob,
119
+ .sliderFill {
120
+ transition: none;
121
+ }
97
122
  }
98
123
 
99
124
  /* Provider default: nothing is filled and the knob is a hollow ring, so the
@@ -367,6 +392,3 @@
367
392
  flex-wrap: wrap;
368
393
  }
369
394
 
370
- .footerDot {
371
- color: var(--dsw-alias-label-dimmed);
372
- }
@@ -31,7 +31,6 @@ import {
31
31
  searchRows,
32
32
  type CatalogModel,
33
33
  type ModelRow as CatalogRow,
34
- type PickerTab,
35
34
  } from './rows.ts'
36
35
  import { Tag, isMobileDevice } from '@deepseek-ai/dsh-client-ui-primitives'
37
36
  import type { ModelFactsLookup } from './facts-source.ts'
@@ -85,8 +84,6 @@ export function ModelPicker({
85
84
  const listRef = useRef<HTMLDivElement | null>(null)
86
85
  const [query, setQuery] = useState('')
87
86
  const [tab, setTab] = useState(ALL_TAB)
88
- const [highlight, setHighlight] = useState(0)
89
- const tabRefs = useRef(new Map<string, HTMLButtonElement>())
90
87
 
91
88
  const tabs = useMemo(() => pickerTabs(state.groups, t('picker.all')), [state.groups, t])
92
89
  const reason = useMemo(() => reasoningOf(state.groups), [state.groups])
@@ -106,7 +103,6 @@ export function ModelPicker({
106
103
  if (!open) return
107
104
  setQuery('')
108
105
  setTab(ALL_TAB)
109
- setHighlight(0)
110
106
  if (listRef.current !== null) listRef.current.scrollTop = 0
111
107
  // 粗指针设备不自动聚焦文本输入:移动端弹键盘会顶起布局,宿主座位也这样守。
112
108
  const frame = requestAnimationFrame(() => {
@@ -115,34 +111,8 @@ export function ModelPicker({
115
111
  return () => { cancelAnimationFrame(frame) }
116
112
  }, [open])
117
113
 
118
- useEffect(() => {
119
- setHighlight((current) => visible.length === 0 ? 0 : Math.min(current, visible.length - 1))
120
- }, [visible.length])
121
-
122
- useEffect(() => {
123
- const row = listRef.current?.querySelector<HTMLElement>(`[data-row-index="${highlight}"]`)
124
- row?.scrollIntoView({ block: 'nearest' })
125
- }, [highlight])
126
-
127
114
  if (!open) return null
128
115
 
129
- const activeRow = visible[highlight]
130
- const activeId = activeRow === undefined ? undefined : `${id}-row-${highlight}`
131
- const onSearchKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
132
- if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
133
- event.preventDefault()
134
- if (visible.length === 0) return
135
- setHighlight((current) => {
136
- const next = current + (event.key === 'ArrowDown' ? 1 : -1)
137
- return (next + visible.length) % visible.length
138
- })
139
- return
140
- }
141
- if (event.key === 'Enter' && activeRow !== undefined && !busy) {
142
- event.preventDefault()
143
- pick(activeRow)
144
- }
145
- }
146
116
  const pick = (row: CatalogRow) => {
147
117
  const selection = planModelCommit(live, row.provider, row.model, reason, rememberedOf?.(row.provider, row.model))
148
118
  if (selection === null) {
@@ -151,27 +121,6 @@ export function ModelPicker({
151
121
  }
152
122
  onPick(selection)
153
123
  }
154
- /** Move to another tab and hand it the keyboard, in one step. */
155
- const goTab = (next: PickerTab | undefined) => {
156
- if (next === undefined || next.id === tab) return
157
- setTab(next.id)
158
- setHighlight(0)
159
- tabRefs.current.get(next.id)?.focus()
160
- }
161
- const onTabKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
162
- const index = tabs.findIndex((candidate) => candidate.id === tab)
163
- if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
164
- event.preventDefault()
165
- const step = event.key === 'ArrowRight' ? 1 : -1
166
- goTab(tabs[((index === -1 ? 0 : index) + step + tabs.length) % tabs.length])
167
- return
168
- }
169
- if (event.key === 'Home' || event.key === 'End') {
170
- event.preventDefault()
171
- goTab(tabs[event.key === 'Home' ? 0 : tabs.length - 1])
172
- }
173
- }
174
-
175
124
  const failures = state.failures.filter((failure) => tab === ALL_TAB || failure.id === tab)
176
125
  const failed = state.error !== null && state.status === 'error'
177
126
  const loading = state.status === 'loading'
@@ -192,13 +141,11 @@ export function ModelPicker({
192
141
  role="searchbox"
193
142
  aria-label={t('picker.search')}
194
143
  aria-controls={`${id}-list`}
195
- aria-activedescendant={activeId}
196
144
  placeholder={t('picker.search')}
197
145
  icon={<IconSearchOutlineRegular size={14} />}
198
146
  value={query}
199
147
  readOnly={busy}
200
148
  onChange={(event) => { setQuery(event.target.value) }}
201
- onKeyDown={onSearchKeyDown}
202
149
  data-modal-autofocus=""
203
150
  />
204
151
  </div>
@@ -206,10 +153,6 @@ export function ModelPicker({
206
153
  {tabs.map((entry) => (
207
154
  <button
208
155
  key={entry.id}
209
- ref={(node) => {
210
- if (node === null) tabRefs.current.delete(entry.id)
211
- else tabRefs.current.set(entry.id, node)
212
- }}
213
156
  type="button"
214
157
  role="tab"
215
158
  id={`${id}-tab-${entry.id}`}
@@ -217,29 +160,13 @@ export function ModelPicker({
217
160
  aria-controls={`${id}-list`}
218
161
  tabIndex={0}
219
162
  className={css.pickerTab}
220
- onClick={() => {
221
- setTab(entry.id)
222
- setHighlight(0)
223
- }}
224
- onKeyDown={onTabKeyDown}
225
- onFocus={() => {
226
- if (entry.id !== tab) {
227
- setTab(entry.id)
228
- setHighlight(0)
229
- }
230
- }}
163
+ onClick={() => { setTab(entry.id) }}
231
164
  >
232
165
  {entry.label}
233
166
  </button>
234
167
  ))}
235
168
  </div>
236
- <div
237
- ref={listRef}
238
- id={`${id}-list`}
239
- className={css.pickerList}
240
- role="menu"
241
- aria-label={t('picker.title')}
242
- >
169
+ <div ref={listRef} id={`${id}-list`} className={css.pickerList}>
243
170
  {loading && <div className={css.notice}>{t('status.loading')}</div>}
244
171
  {failed && (
245
172
  <div className={css.notice}>
@@ -256,8 +183,8 @@ export function ModelPicker({
256
183
  {visible.length === 0 && !loading && !failed && (
257
184
  <div className={css.notice}>{state.groups.length === 0 ? t('status.empty') : t('picker.searchEmpty')}</div>
258
185
  )}
259
- {visible.map((row, index) => (
260
- <div key={`${row.provider}/${row.model}`} data-row-index={index} id={`${id}-row-${index}`}>
186
+ {visible.map((row) => (
187
+ <div key={`${row.provider}/${row.model}`}>
261
188
  <ModelRow
262
189
  name={row.entry.name}
263
190
  badge={row.entry.badge === undefined
@@ -277,10 +204,7 @@ export function ModelPicker({
277
204
  starLabel={isFavorite(favorites, row.provider, row.model)
278
205
  ? t('picker.unfavorite', { model: row.entry.name })
279
206
  : t('picker.favorite', { model: row.entry.name })}
280
- starTabStop={index === highlight}
281
- rowTabIndex={-1}
282
207
  onPick={() => { pick(row) }}
283
- onFocusRow={() => { setHighlight(index) }}
284
208
  onToggleFavorite={() => { onToggleFavorite(row.provider, row.model) }}
285
209
  />
286
210
  </div>
@@ -288,12 +212,6 @@ export function ModelPicker({
288
212
  </div>
289
213
  <div className={css.pickerFooter}>
290
214
  <span className={css.footerHint}>
291
- <ShortcutKeys keys={['↑', '↓']} />
292
- <span>{t('picker.hintNavigate')}</span>
293
- <span className={css.footerDot}>·</span>
294
- <ShortcutKeys keys={['Enter']} />
295
- <span>{t('picker.hintSelect')}</span>
296
- <span className={css.footerDot}>·</span>
297
215
  <ShortcutKeys keys={['Esc']} />
298
216
  <span>{t('picker.hintClose')}</span>
299
217
  </span>
@@ -27,14 +27,8 @@ export interface ModelRowProps {
27
27
  readonly meta?: string | undefined;
28
28
  /** Localized accessible name for the star control. */
29
29
  readonly starLabel?: string | undefined;
30
- /** Whether the star is a Tab stop right now (the picker's roving highlight). */
31
- readonly starTabStop?: boolean;
32
- /** Tab order of the row itself; the picker keeps rows out of it so ↑/↓ own the walk. */
33
- readonly rowTabIndex?: number;
34
30
  /** Pick this row. */
35
31
  readonly onPick: () => void;
36
- /** The row took the keyboard, so its star becomes reachable. */
37
- readonly onFocusRow?: (() => void) | undefined;
38
32
  /** Toggle this row's favorite. */
39
33
  readonly onToggleFavorite: () => void;
40
34
  /** Extra row content, such as the picker's disabled reason. */
@@ -48,7 +42,7 @@ export interface ModelRowProps {
48
42
  */
49
43
  export function ModelRow({
50
44
  name, badge, selected, busy, favorited, meta, starLabel,
51
- starTabStop = false, rowTabIndex, onPick, onFocusRow, onToggleFavorite, children,
45
+ onPick, onToggleFavorite, children,
52
46
  }: ModelRowProps) {
53
47
  return <div
54
48
  className={css.row}
@@ -57,14 +51,11 @@ export function ModelRow({
57
51
  >
58
52
  <button
59
53
  type="button"
60
- role="menuitemradio"
61
- aria-checked={selected}
62
- tabIndex={rowTabIndex}
54
+ aria-current={selected ? 'true' : undefined}
63
55
  className={css.rowPick}
64
56
  title={name}
65
57
  disabled={busy}
66
58
  onClick={onPick}
67
- onFocus={onFocusRow}
68
59
  >
69
60
  <span className={css.rowCopy}>
70
61
  <span className={css.rowName}>{name}</span>
@@ -77,7 +68,6 @@ export function ModelRow({
77
68
  className={css.starButton}
78
69
  aria-pressed={favorited}
79
70
  aria-label={starLabel}
80
- tabIndex={starTabStop ? 0 : -1}
81
71
  disabled={busy}
82
72
  onClick={onToggleFavorite}
83
73
  >
@@ -19,8 +19,6 @@ export type ModelSubmenuLocaleKey =
19
19
  | 'picker.all'
20
20
  | 'picker.favorite'
21
21
  | 'picker.unfavorite'
22
- | 'picker.hintNavigate'
23
- | 'picker.hintSelect'
24
22
  | 'picker.hintClose'
25
23
  | 'meta.effort'
26
24
  | 'meta.images'
@@ -51,8 +49,6 @@ export const zh: Record<ModelSubmenuLocaleKey, string> = {
51
49
  'picker.all': '全部',
52
50
  'picker.favorite': '收藏 {model}',
53
51
  'picker.unfavorite': '取消收藏 {model}',
54
- 'picker.hintNavigate': '导航',
55
- 'picker.hintSelect': '选择',
56
52
  'picker.hintClose': '关闭',
57
53
  'meta.effort': '思考',
58
54
  'meta.images': '图片输入',
@@ -77,8 +73,6 @@ export const en: Record<ModelSubmenuLocaleKey, string> = {
77
73
  'picker.all': 'All',
78
74
  'picker.favorite': 'Favorite {model}',
79
75
  'picker.unfavorite': 'Remove {model} from favorites',
80
- 'picker.hintNavigate': 'navigate',
81
- 'picker.hintSelect': 'select',
82
76
  'picker.hintClose': 'close',
83
77
  'meta.effort': 'Effort',
84
78
  'meta.images': 'Images',
@@ -1,19 +1,29 @@
1
1
  /**
2
2
  * Geometry of the discrete effort slider.
3
3
  *
4
- * The track carries one detent per level and the knob's centre travels between
5
- * two ends inset by the knob radius, so the knob never overhangs the track. A
6
- * pointer position becomes the detent nearest it; a detent becomes a fraction
7
- * of the track that CSS turns into the knob's centre and the filled width.
4
+ * The track is a capsule and the knob a slightly larger circle riding on it, so
5
+ * the knob overhangs the track instead of merging with its rounded end. The
6
+ * knob's centre travels between two points inset by the knob's radius, so the
7
+ * knob never runs past either end. A pointer position becomes a position along
8
+ * that travel; a position becomes the detent nearest it, or the CSS lengths that
9
+ * put the knob and the fill there.
8
10
  */
9
11
 
10
12
  /**
11
- * Knob diameter in CSS pixels, and the track's height: the slider stands as tall
12
- * as the row's text line. The centre stops half of it from each end of the
13
- * track, so the knob never overhangs.
13
+ * Track height in CSS pixels; the stylesheet keeps the same value. Half of it is
14
+ * the radius of the track's ends and of the fill's cap.
15
+ */
16
+ export const TRACK_HEIGHT = 16;
17
+
18
+ /**
19
+ * Knob diameter in CSS pixels. It is larger than the track, so the knob reads as
20
+ * a knob riding on the track rather than as the track's own end cap.
14
21
  */
15
22
  export const KNOB_SIZE = 20;
16
23
 
24
+ /** The knob's centre keeps its own radius away from either end of the track. */
25
+ const KNOB_RADIUS = KNOB_SIZE / 2;
26
+
17
27
  /**
18
28
  * @param index - detent index.
19
29
  * @param count - number of detents.
@@ -26,26 +36,63 @@ export function offsetOf(index: number, count: number): number {
26
36
  }
27
37
 
28
38
  /**
39
+ * Where a pointer sits along the knob's travel. The drag reads this rather than a
40
+ * detent, so the knob follows the pointer instead of jumping a level at a time.
29
41
  * @param clientX - pointer position in viewport coordinates.
30
42
  * @param left - the track's left edge in viewport coordinates.
31
43
  * @param width - the track's width in pixels.
32
- * @param count - number of detents.
33
- * @returns the detent nearest the pointer, clamped to the two ends.
44
+ * @returns the position as a fraction of the travel, clamped to 0 and 1.
34
45
  */
35
- export function stepAt(clientX: number, left: number, width: number, count: number): number {
36
- if (count <= 1) return 0;
46
+ export function fractionAt(clientX: number, left: number, width: number): number {
37
47
  const usable = width - KNOB_SIZE;
38
48
  if (usable <= 0) return 0;
39
- const fraction = (clientX - left - KNOB_SIZE / 2) / usable;
49
+ const fraction = (clientX - left - KNOB_RADIUS) / usable;
50
+ return Math.min(Math.max(fraction, 0), 1);
51
+ }
52
+
53
+ /**
54
+ * @param fraction - a knob position between 0 and 1.
55
+ * @param count - number of detents.
56
+ * @returns the detent nearest that position.
57
+ */
58
+ export function detentAt(fraction: number, count: number): number {
59
+ if (count <= 1) return 0;
40
60
  const clamped = Math.min(Math.max(fraction, 0), 1);
41
61
  return Math.round(clamped * (count - 1));
42
62
  }
43
63
 
64
+ /**
65
+ * @param clientX - pointer position in viewport coordinates.
66
+ * @param left - the track's left edge in viewport coordinates.
67
+ * @param width - the track's width in pixels.
68
+ * @param count - number of detents.
69
+ * @returns the detent nearest the pointer, clamped to the two ends.
70
+ */
71
+ export function stepAt(clientX: number, left: number, width: number, count: number): number {
72
+ if (count <= 1) return 0;
73
+ return detentAt(fractionAt(clientX, left, width), count);
74
+ }
75
+
44
76
  /**
45
77
  * @param fraction - a knob position between 0 and 1.
46
78
  * @returns the CSS length that puts the knob centre there, inset at both ends.
47
79
  */
48
80
  export function knobOffset(fraction: number): string {
49
81
  const clamped = Math.min(Math.max(fraction, 0), 1);
50
- return `calc(${KNOB_SIZE / 2}px + ${clamped} * (100% - ${KNOB_SIZE}px))`;
82
+ return `calc(${KNOB_RADIUS}px + ${clamped} * (100% - ${KNOB_SIZE}px))`;
83
+ }
84
+
85
+ /**
86
+ * Width of the colored fill for a knob position.
87
+ *
88
+ * The fill is the track's own shape cut at the knob: its right cap carries the
89
+ * track's radius and ends under the knob, so the color runs into the knob
90
+ * instead of stopping at a straight edge, and the last detent reaches the track's
91
+ * end rather than the knob's centre.
92
+ * @param fraction - a knob position between 0 and 1.
93
+ * @returns the CSS width of the fill.
94
+ */
95
+ export function fillWidth(fraction: number): string {
96
+ const clamped = Math.min(Math.max(fraction, 0), 1);
97
+ return `calc(${TRACK_HEIGHT}px + ${clamped} * (100% - ${TRACK_HEIGHT}px))`;
51
98
  }