@tremolo-ui/dom 0.6.0 → 0.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tremolo-ui/dom",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "framework-agnostic DOM layer used in @tremolo-ui/*",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -19,7 +19,7 @@
19
19
  "format": "prettier --write ."
20
20
  },
21
21
  "dependencies": {
22
- "@tremolo-ui/functions": "^0.6.0"
22
+ "@tremolo-ui/functions": "^0.8.0"
23
23
  },
24
24
  "files": [
25
25
  "dist",
@@ -0,0 +1,75 @@
1
+ /**
2
+ * What an `accept` rule is matched against.
3
+ *
4
+ * `File` satisfies it, and so does `DataTransferItem` — which matters while a
5
+ * drag is still in the air, since the browser reports the type of what is
6
+ * being dragged but withholds the name.
7
+ */
8
+ export interface AcceptCandidate {
9
+ /** The file name, when it is known. */
10
+ name?: string
11
+ /** The MIME type, or `''` when the browser has no type for it. */
12
+ type: string
13
+ }
14
+
15
+ /**
16
+ * Does a file satisfy an `accept` attribute?
17
+ *
18
+ * `accept` is written the way the HTML attribute is: a comma separated list of
19
+ * extensions (`.wav`), MIME types (`audio/wav`) and type groups (`audio/*`).
20
+ * Anything that matches one entry is accepted, and an empty or missing
21
+ * `accept` takes everything.
22
+ *
23
+ * **The browser's own `accept` is only a hint to the file picker.** A person
24
+ * can switch it to "All Files", drag a file in, or pick one the picker was
25
+ * never asked about, so what arrives still has to be checked.
26
+ *
27
+ * A rule that cannot be decided is not treated as a rejection: with no `name`,
28
+ * an extension rule says nothing either way, and `accept=".wav"` reports a
29
+ * match rather than refusing a file it has not seen the name of. The name is
30
+ * there by the time the file is dropped, which is when the answer counts.
31
+ */
32
+ export function matchesAccept(
33
+ candidate: AcceptCandidate,
34
+ accept?: string,
35
+ ): boolean {
36
+ const rules = (accept ?? '')
37
+ .split(',')
38
+ .map((rule) => rule.trim().toLowerCase())
39
+ .filter(Boolean)
40
+ if (rules.length === 0) return true
41
+
42
+ const name = candidate.name?.toLowerCase()
43
+ const type = candidate.type.toLowerCase()
44
+ let undecided = false
45
+
46
+ for (const rule of rules) {
47
+ if (rule.startsWith('.')) {
48
+ if (name === undefined) undecided = true
49
+ else if (name.endsWith(rule)) return true
50
+ } else if (rule.endsWith('/*')) {
51
+ if (type.startsWith(rule.slice(0, -1))) return true
52
+ } else if (type === rule) {
53
+ return true
54
+ }
55
+ }
56
+
57
+ return undecided
58
+ }
59
+
60
+ /**
61
+ * Split files into those that satisfy `accept` and those that do not, keeping
62
+ * their order. See {@link matchesAccept}.
63
+ */
64
+ export function partitionByAccept(
65
+ files: Iterable<File>,
66
+ accept?: string,
67
+ ): { accepted: File[]; rejected: File[] } {
68
+ const accepted: File[] = []
69
+ const rejected: File[] = []
70
+ for (const file of files) {
71
+ if (matchesAccept(file, accept)) accepted.push(file)
72
+ else rejected.push(file)
73
+ }
74
+ return { accepted, rejected }
75
+ }
@@ -0,0 +1,200 @@
1
+ import { matchesAccept, partitionByAccept } from './accept'
2
+
3
+ /** What is in the air over the element. */
4
+ export interface DropZoneState {
5
+ /** Files are being dragged over the element. */
6
+ over: boolean
7
+ /**
8
+ * None of what is being dragged matches `accept`, as far as can be told
9
+ * before the drop.
10
+ *
11
+ * The browser reports the type of what is being dragged but withholds the
12
+ * name, so a rule written as an extension cannot be decided yet and is not
13
+ * counted against the drag. It is decided on the drop, where the name is.
14
+ */
15
+ invalid: boolean
16
+ }
17
+
18
+ export interface DropZoneOptions {
19
+ /**
20
+ * Which files to take, written the way the `accept` attribute of a file
21
+ * input is: a comma separated list of extensions (`.wav`), MIME types
22
+ * (`audio/wav`) and type groups (`audio/*`).
23
+ */
24
+ accept?: string
25
+ /**
26
+ * Take more than one file from a single drop. With it off, only the first
27
+ * accepted file is reported, as a file input without `multiple` does.
28
+ *
29
+ * @default false
30
+ */
31
+ multiple?: boolean
32
+ /**
33
+ * Refuse the drop. The drag is still swallowed rather than let through: an
34
+ * unhandled drop makes the browser leave the page and open the file.
35
+ *
36
+ * @default false
37
+ */
38
+ disabled?: boolean
39
+
40
+ /** Called with the dropped files that match `accept`. */
41
+ onDrop?: (files: File[], event: DragEvent) => void
42
+ /**
43
+ * Called with the dropped files that do not match `accept`, so that the
44
+ * reason can be shown. It comes after `onDrop` for the same drop, so a list
45
+ * of rejected files can be cleared in `onDrop` and filled here.
46
+ */
47
+ onReject?: (files: File[], event: DragEvent) => void
48
+ /** Called whenever {@link DropZoneInstance.state} would change. */
49
+ onStateChange?: (state: DropZoneState) => void
50
+ }
51
+
52
+ export interface DropZoneInstance {
53
+ /** What is in the air over the element, right now. */
54
+ readonly state: DropZoneState
55
+ /** Replace the given options, keeping the listeners in place. */
56
+ update: (options: DropZoneOptions) => void
57
+ destroy: () => void
58
+ }
59
+
60
+ /**
61
+ * Take files dropped onto an element.
62
+ *
63
+ * ```ts
64
+ * const zone = createDropZone(element, {
65
+ * accept: 'audio/*',
66
+ * onDrop: (files) => load(files[0]),
67
+ * })
68
+ * ```
69
+ *
70
+ * The element needs no attribute of its own: a drop target is made by
71
+ * cancelling `dragover`, which this does.
72
+ */
73
+ export function createDropZone(
74
+ element: Element,
75
+ options: DropZoneOptions = {},
76
+ ): DropZoneInstance {
77
+ let opts = options
78
+ let state: DropZoneState = { over: false, invalid: false }
79
+
80
+ /**
81
+ * `dragenter` and `dragleave` fire for descendants too, so moving between
82
+ * two children of the zone leaves before it enters. Counting the pairs is
83
+ * what keeps the state from flickering off in the middle of the element.
84
+ */
85
+ let depth = 0
86
+
87
+ function setState(next: DropZoneState) {
88
+ if (next.over === state.over && next.invalid === state.invalid) return
89
+ state = next
90
+ opts.onStateChange?.(state)
91
+ }
92
+
93
+ function carriesFiles(transfer: DataTransfer | null) {
94
+ // `types` is the only thing that can be trusted during a drag; `files` is
95
+ // empty until the drop.
96
+ return !!transfer?.types.includes('Files')
97
+ }
98
+
99
+ /** Whether anything being dragged could still be accepted. */
100
+ function anyAcceptable(transfer: DataTransfer | null) {
101
+ const items = Array.from(transfer?.items ?? []).filter(
102
+ (item) => item.kind === 'file',
103
+ )
104
+ // Some browsers hand over no items at all, only the `Files` type. Nothing
105
+ // is known, so nothing is refused.
106
+ if (items.length === 0) return true
107
+ return items.some((item) => matchesAccept({ type: item.type }, opts.accept))
108
+ }
109
+
110
+ function onDragEnter(event: DragEvent) {
111
+ if (!carriesFiles(event.dataTransfer)) return
112
+ // Cancelled as well as `dragover`: a target that only cancels one of the
113
+ // two is not a drop target in every browser.
114
+ event.preventDefault()
115
+ depth += 1
116
+ setState({ over: true, invalid: !anyAcceptable(event.dataTransfer) })
117
+ }
118
+
119
+ function onDragOver(event: DragEvent) {
120
+ if (!carriesFiles(event.dataTransfer)) return
121
+ event.preventDefault()
122
+ if (event.dataTransfer) {
123
+ // Decides the cursor the pointer shows, and whether a drop is offered.
124
+ event.dataTransfer.dropEffect =
125
+ opts.disabled || state.invalid ? 'none' : 'copy'
126
+ }
127
+ // A drag that began outside the document can arrive without a `dragenter`
128
+ // the listener saw, so the state is settled here too.
129
+ if (!state.over) {
130
+ depth = Math.max(depth, 1)
131
+ setState({ over: true, invalid: !anyAcceptable(event.dataTransfer) })
132
+ }
133
+ }
134
+
135
+ function onDragLeave(event: DragEvent) {
136
+ if (!carriesFiles(event.dataTransfer)) return
137
+ depth = Math.max(0, depth - 1)
138
+ if (depth === 0) setState({ over: false, invalid: false })
139
+ }
140
+
141
+ function onDrop(event: DragEvent) {
142
+ if (!carriesFiles(event.dataTransfer)) return
143
+ // Always cancelled, even while disabled: an unhandled drop makes the
144
+ // browser leave the page and open the file.
145
+ event.preventDefault()
146
+ depth = 0
147
+ setState({ over: false, invalid: false })
148
+ if (opts.disabled) return
149
+
150
+ const { accepted, rejected } = partitionByAccept(
151
+ event.dataTransfer?.files ?? [],
152
+ opts.accept,
153
+ )
154
+
155
+ const taken = opts.multiple ? accepted : accepted.slice(0, 1)
156
+ if (taken.length > 0) opts.onDrop?.(taken, event)
157
+ if (rejected.length > 0) opts.onReject?.(rejected, event)
158
+ }
159
+
160
+ /**
161
+ * A drag that ends anywhere else — dropped on another element, cancelled
162
+ * with Esc, taken out of the window — sends no `dragleave` here, and the
163
+ * element would stay marked as a drop target for good.
164
+ */
165
+ function onDragEndAnywhere() {
166
+ depth = 0
167
+ setState({ over: false, invalid: false })
168
+ }
169
+
170
+ const handlers = {
171
+ dragenter: onDragEnter,
172
+ dragover: onDragOver,
173
+ dragleave: onDragLeave,
174
+ drop: onDrop,
175
+ } as const
176
+
177
+ for (const [type, handler] of Object.entries(handlers)) {
178
+ element.addEventListener(type, handler as EventListener)
179
+ }
180
+
181
+ const doc = element.ownerDocument
182
+ doc?.addEventListener('dragend', onDragEndAnywhere)
183
+ doc?.addEventListener('drop', onDragEndAnywhere)
184
+
185
+ return {
186
+ get state() {
187
+ return state
188
+ },
189
+ update: (next) => {
190
+ opts = { ...opts, ...next }
191
+ },
192
+ destroy: () => {
193
+ for (const [type, handler] of Object.entries(handlers)) {
194
+ element.removeEventListener(type, handler as EventListener)
195
+ }
196
+ doc?.removeEventListener('dragend', onDragEndAnywhere)
197
+ doc?.removeEventListener('drop', onDragEndAnywhere)
198
+ },
199
+ }
200
+ }
package/src/index.ts CHANGED
@@ -13,6 +13,52 @@ export {
13
13
  type DrawingState,
14
14
  type DrawingStateValue,
15
15
  } from './canvas/context'
16
+ export {
17
+ matchesAccept,
18
+ partitionByAccept,
19
+ type AcceptCandidate,
20
+ } from './file/accept'
21
+ export {
22
+ createDropZone,
23
+ type DropZoneInstance,
24
+ type DropZoneOptions,
25
+ type DropZoneState,
26
+ } from './file/drop-zone'
27
+ export { applyDelta } from './input/apply-delta'
28
+ export { checkSteps, type CheckStepsOptions } from './input/check-steps'
29
+ export {
30
+ DEFAULT_DRAG_SENSITIVITY,
31
+ DEFAULT_KEYBOARD_OPTIONS,
32
+ DEFAULT_WHEEL_OPTIONS,
33
+ } from './input/defaults'
34
+ export {
35
+ arrowKeyDirection,
36
+ arrowKeyMove,
37
+ isArrowKey,
38
+ wheelDirection,
39
+ wheelMove,
40
+ type ArrowKey,
41
+ type AxisMove,
42
+ type WheelDirectionOptions,
43
+ } from './input/direction'
44
+ export {
45
+ mapModifier,
46
+ selectModifier,
47
+ type InputEventOption,
48
+ type Modifier,
49
+ type ModifierMap,
50
+ type ModifierState,
51
+ type ModifierValue,
52
+ } from './input/modifiers'
53
+ export {
54
+ KNOB_VIEWBOX_SIZE,
55
+ knobAngles,
56
+ knobArcPath,
57
+ knobArcPoint,
58
+ knobArcRadius,
59
+ type KnobAngleOptions,
60
+ type KnobAngles,
61
+ } from './knob/geometry'
16
62
  export {
17
63
  createMIDIAccess,
18
64
  NOT_SUPPORTED,
@@ -30,12 +76,59 @@ export {
30
76
  type MIDIInputInstance,
31
77
  } from './midi/input'
32
78
  export { createMIDIMessage, type MIDIMessageInstance } from './midi/message'
79
+ export {
80
+ createStepperDrag,
81
+ type StepperDragInstance,
82
+ type StepperDragOptions,
83
+ } from './number-input/stepper-drag'
84
+ export {
85
+ commitNumberInputText,
86
+ numberInputBounds,
87
+ numberInputRanges,
88
+ nudgeNumberInput,
89
+ type NumberInputRanges,
90
+ type NumberInputValueOptions,
91
+ } from './number-input/value'
92
+ export {
93
+ caretAtDecimalOffset,
94
+ caretDecimalOffset,
95
+ numberSpan,
96
+ parseNumberText,
97
+ type NumberSpan,
98
+ } from './number-input/text'
99
+ export { replaceOptions } from './options/replace'
100
+ export {
101
+ blackKeyWidth,
102
+ fitWhiteKeyWidth,
103
+ getNoteRangeArray,
104
+ noteAt,
105
+ notePosition,
106
+ pianoWidth,
107
+ type NoteRange,
108
+ type PianoLayout,
109
+ } from './piano/layout'
110
+ export {
111
+ SHORTCUTS,
112
+ type KeyboardShortcuts,
113
+ type KeyboardShortcutsScope,
114
+ } from './piano/shortcuts'
33
115
  export {
34
116
  createPianoInput,
35
117
  type NoteSource,
36
118
  type PianoInputInstance,
37
119
  type PianoInputOptions,
38
120
  } from './piano'
121
+ export {
122
+ clampPoint,
123
+ createPointsEditor,
124
+ POINT_AXIS,
125
+ POINTS_EDITOR_DEFAULT_KEYBOARD,
126
+ POINTS_EDITOR_DEFAULT_WHEEL,
127
+ type PointPosition,
128
+ type PointsEditorInstance,
129
+ type PointsEditorOptions,
130
+ type PointsEditorPoint,
131
+ } from './points-editor'
39
132
  export {
40
133
  createDrag,
41
134
  type DragInstance,
@@ -52,11 +145,17 @@ export {
52
145
  type DragValueOptions,
53
146
  type MappingContext,
54
147
  } from './pointer/drag-value'
148
+ export {
149
+ createLongPress,
150
+ type LongPressInstance,
151
+ type LongPressOptions,
152
+ } from './pointer/long-press'
55
153
  export {
56
154
  createWheel,
57
155
  type WheelInstance,
58
156
  type WheelOptions,
59
157
  } from './pointer/wheel'
158
+ export { valuePercent } from './position'
60
159
  export {
61
160
  createSelectionBox,
62
161
  selectionBoxCovers,
@@ -65,4 +164,6 @@ export {
65
164
  type SelectionBoxOptions,
66
165
  type SelectionBoxRect,
67
166
  } from './selection/box'
167
+ export { sliderMarks, type MarksOptions, type SliderMark } from './slider/marks'
168
+ export { cssLength, visuallyHiddenStyle } from './style'
68
169
  export { toXY, type XY, type XYInput } from './xy'
@@ -0,0 +1,73 @@
1
+ import {
2
+ clamp,
3
+ linearScale,
4
+ stepValue,
5
+ toPrecision,
6
+ type ValueRange,
7
+ } from '@tremolo-ui/functions'
8
+
9
+ import {
10
+ selectModifier,
11
+ type InputEventOption,
12
+ type ModifierState,
13
+ type ModifierValue,
14
+ } from './modifiers'
15
+
16
+ /**
17
+ * Move a value by an amount of input, as reported by a wheel or an arrow key.
18
+ *
19
+ * The pipeline matches {@link createDragValue}: scale, then step, then clamp.
20
+ * Which key or which sign of `deltaY` counts as which direction is left to the
21
+ * caller, since it differs per component.
22
+ *
23
+ * @param direction which way, and how many times, to apply the option. The
24
+ * size of one step is `option[1]`, so this is normally `1` or `-1`.
25
+ *
26
+ * @param modifiers the event, for `options` that name a modifier key. See
27
+ * {@link selectModifier}.
28
+ *
29
+ * @example
30
+ * // ArrowDown on a slider whose keyboard option is ['raw', 1]
31
+ * applyDelta(value, -1, keyboard, { min, max, step, scale })
32
+ *
33
+ * @example
34
+ * // Shift+ArrowDown, where `keyboard` is { default: …, shift: ['raw', 0.1] }
35
+ * applyDelta(value, -1, keyboard, range, event)
36
+ */
37
+ export function applyDelta(
38
+ value: number,
39
+ direction: number,
40
+ options: ModifierValue<InputEventOption>,
41
+ { min, max, step, scale = linearScale }: ValueRange,
42
+ modifiers?: ModifierState,
43
+ ): number {
44
+ if (min >= max) throw new RangeError('requirements: min < max')
45
+ if (step !== undefined && (!Number.isFinite(step) || step <= 0)) {
46
+ throw new RangeError(
47
+ 'applyDelta step: requirements: finite and greater than 0',
48
+ )
49
+ }
50
+
51
+ const {
52
+ value: [mode, amount],
53
+ modifier,
54
+ } = selectModifier(options, modifiers)
55
+
56
+ const x = direction * amount
57
+ const next =
58
+ mode === 'normalized'
59
+ ? scale.denormalize(scale.normalize(value, min, max) + x, min, max)
60
+ : value + x
61
+
62
+ // Naming a modifier is a deliberate request to move off the grid, so `step`
63
+ // does not apply to it. Without this a finer amount would round straight
64
+ // back to where it started: `stepValue(3 + 0.1, 1)` is 3.
65
+ const quantum = modifier === null ? step : undefined
66
+ const stepped = quantum !== undefined ? stepValue(next, quantum) : next
67
+
68
+ // Rounded before the clamp, so that `min` and `max` still have the last
69
+ // word and the value can land on them exactly. Without this the artefact
70
+ // accumulates: with no `step` to round it back, twelve presses of a 0.1
71
+ // modifier amount reach 5.699999999999998 rather than 5.7.
72
+ return clamp(toPrecision(stepped), min, max)
73
+ }
@@ -0,0 +1,144 @@
1
+ import { linearScale, type ValueRange } from '@tremolo-ui/functions'
2
+
3
+ import { applyDelta } from './apply-delta'
4
+ import {
5
+ type InputEventOption,
6
+ type ModifierState,
7
+ type ModifierValue,
8
+ } from './modifiers'
9
+
10
+ /** Positions probed across the travel. The ends are left out so that the
11
+ * clamp at `min` and `max` cannot be mistaken for a press that does nothing. */
12
+ const PROBES = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]
13
+
14
+ const NONE: ModifierState = {
15
+ shiftKey: false,
16
+ altKey: false,
17
+ ctrlKey: false,
18
+ metaKey: false,
19
+ }
20
+
21
+ const MODIFIER_STATE: Record<string, ModifierState> = {
22
+ shift: { ...NONE, shiftKey: true },
23
+ alt: { ...NONE, altKey: true },
24
+ ctrl: { ...NONE, ctrlKey: true },
25
+ meta: { ...NONE, metaKey: true },
26
+ }
27
+
28
+ /** The held keys that select each entry of a modifier-aware input option. */
29
+ function entries(options: ModifierValue<InputEventOption>): ModifierState[] {
30
+ if (Array.isArray(options)) return [NONE]
31
+ return Object.keys(options).map((key) => MODIFIER_STATE[key] ?? NONE)
32
+ }
33
+
34
+ interface Outcome {
35
+ /** The press changed the value at least once across the travel. */
36
+ moved: boolean
37
+ /** The change reached the displayed text at least once. */
38
+ visible: boolean
39
+ }
40
+
41
+ /**
42
+ * Press every entry of `options` at nine points along the travel and report
43
+ * whether anything came of it.
44
+ *
45
+ * Run against `applyDelta` itself rather than against a reading of `step`:
46
+ * the whole point is that the amount, the step and the scale interact, and
47
+ * the pipeline is the only thing that knows how.
48
+ */
49
+ function probe(
50
+ options: ModifierValue<InputEventOption>,
51
+ range: ValueRange,
52
+ format?: (value: number) => string,
53
+ ): Outcome {
54
+ const { min, max, scale = linearScale } = range
55
+ const outcome: Outcome = { moved: false, visible: false }
56
+ for (const modifiers of entries(options)) {
57
+ for (const position of PROBES) {
58
+ const value = scale.denormalize(position, min, max)
59
+ const up = applyDelta(value, 1, options, range, modifiers)
60
+ const down = applyDelta(value, -1, options, range, modifiers)
61
+ if (up !== value || down !== value) outcome.moved = true
62
+ if (!format) continue
63
+ const shown = format(value)
64
+ if (format(up) !== shown || format(down) !== shown) outcome.visible = true
65
+ }
66
+ }
67
+ return outcome
68
+ }
69
+
70
+ export interface CheckStepsOptions {
71
+ /** The component, for the message. */
72
+ component: string
73
+ /** The axis, for a component that has more than one. */
74
+ axis?: string
75
+ /**
76
+ * The range to probe, or `null` to check nothing. An unbounded input has no
77
+ * travel to sample, so `NumberInput` passes `null` when `min` and `max` are
78
+ * not both there.
79
+ */
80
+ range: ValueRange | null
81
+ keyboard?: ModifierValue<InputEventOption> | null
82
+ wheel?: ModifierValue<InputEventOption> | null
83
+ /**
84
+ * How the value is displayed, where the component shows one. Called with
85
+ * probe values only.
86
+ */
87
+ format?: (value: number) => string
88
+ }
89
+
90
+ /**
91
+ * The warnings to show when a key press or a wheel notch cannot produce a
92
+ * change the user can see. Empty when there is nothing to say.
93
+ *
94
+ * Two settings that are each fine on their own can cancel out, and nothing
95
+ * fails when they do — the control simply sits there:
96
+ *
97
+ * - **`step` coarser than the amount.** `keyboard={['raw', 0.1]}` with
98
+ * `step={1}` rounds every press straight back to where it started
99
+ * - **the display coarser than the amount.** A `format` showing two decimals
100
+ * of a kHz value cannot show a press worth 1 Hz
101
+ *
102
+ * The second is only reported when the press is invisible at *every* point
103
+ * along the travel. A display that rounds is a deliberate choice and is
104
+ * normally right — it is being too coarse everywhere that makes it a mistake.
105
+ *
106
+ * **Meant for development builds only.** It probes the whole travel, so call
107
+ * it behind an inline `process.env.NODE_ENV` check: the bundler then drops
108
+ * the call, and this function and its messages with it.
109
+ */
110
+ export function checkSteps({
111
+ component,
112
+ axis,
113
+ range,
114
+ keyboard,
115
+ wheel,
116
+ format,
117
+ }: CheckStepsOptions): string[] {
118
+ if (!range || !(range.min < range.max)) return []
119
+ const where = axis ? `${component} (${axis})` : component
120
+ const warnings: string[] = []
121
+ for (const [name, options] of [
122
+ ['keyboard', keyboard],
123
+ ['wheel', wheel],
124
+ ] as const) {
125
+ if (!options) continue
126
+ const { moved, visible } = probe(options, range, format)
127
+ if (!moved) {
128
+ warnings.push(
129
+ `[tremolo-ui] ${where}: \`${name}\` cannot move the value.` +
130
+ (range.step !== undefined
131
+ ? ` Each press is smaller than \`step\` (${range.step}), so it rounds`
132
+ : ' Each press rounds') +
133
+ ' straight back to where it started.',
134
+ )
135
+ } else if (format && !visible) {
136
+ warnings.push(
137
+ `[tremolo-ui] ${where}: \`${name}\` moves the value, but \`format\`` +
138
+ ' shows the same text before and after, everywhere in the range.' +
139
+ ' The display is too coarse for it to be seen.',
140
+ )
141
+ }
142
+ }
143
+ return warnings
144
+ }
@@ -0,0 +1,32 @@
1
+ import { type InputEventOption, type ModifierValue } from './modifiers'
2
+
3
+ /**
4
+ * The keyboard amount used by Knob, NumberInput, Slider, and XYPad by default:
5
+ * 1 per press in the units of the value, and 0.1 with shift held. That is one
6
+ * `step` only while `step` is 1; a coarser `step` rounds 1 straight back, which
7
+ * `checkSteps` warns about.
8
+ *
9
+ * A modifier entry is not snapped to `step`, which is what lets the finer
10
+ * amount move at all — see `applyDelta`.
11
+ */
12
+ export const DEFAULT_KEYBOARD_OPTIONS: ModifierValue<InputEventOption> = {
13
+ default: ['raw', 1],
14
+ shift: ['raw', 0.1],
15
+ }
16
+
17
+ /**
18
+ * The wheel amount used by Knob, NumberInput, Slider, and XYPad by default.
19
+ *
20
+ * Browsers turn shift+wheel into horizontal scrolling, which empties `deltaY`
21
+ * and fills `deltaX`, so no modifier is bound here.
22
+ */
23
+ export const DEFAULT_WHEEL_OPTIONS: ModifierValue<InputEventOption> = ['raw', 1]
24
+
25
+ /**
26
+ * The drag sensitivity used by value controls by default. Shift makes the
27
+ * same movement cover a tenth of the range, matching the arrow keys.
28
+ */
29
+ export const DEFAULT_DRAG_SENSITIVITY: ModifierValue<number> = {
30
+ default: 1,
31
+ shift: 0.1,
32
+ }