@tremolo-ui/vue 0.7.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.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +13 -0
  3. package/dist/index.cjs +2486 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +1678 -0
  6. package/dist/index.d.cts.map +1 -0
  7. package/dist/index.d.ts +1678 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +2444 -0
  10. package/dist/index.js.map +1 -0
  11. package/package.json +78 -0
  12. package/src/components/AnimationCanvas/AnimationCanvas.ts +105 -0
  13. package/src/components/AnimationCanvas/index.ts +5 -0
  14. package/src/components/DropZone/DropZone.ts +43 -0
  15. package/src/components/DropZone/index.ts +1 -0
  16. package/src/components/FileInput/FileInput.ts +82 -0
  17. package/src/components/FileInput/FileInputTrigger.ts +24 -0
  18. package/src/components/FileInput/context.ts +18 -0
  19. package/src/components/FileInput/index.ts +3 -0
  20. package/src/components/Knob/Knob.ts +237 -0
  21. package/src/components/Knob/KnobActiveLine.ts +31 -0
  22. package/src/components/Knob/KnobInactiveLine.ts +36 -0
  23. package/src/components/Knob/KnobSVGRoot.ts +25 -0
  24. package/src/components/Knob/KnobThumb.ts +53 -0
  25. package/src/components/Knob/context.ts +30 -0
  26. package/src/components/Knob/index.ts +6 -0
  27. package/src/components/NumberInput/NumberInput.ts +258 -0
  28. package/src/components/NumberInput/NumberInputDecrementStepper.ts +7 -0
  29. package/src/components/NumberInput/NumberInputField.ts +128 -0
  30. package/src/components/NumberInput/NumberInputIncrementStepper.ts +7 -0
  31. package/src/components/NumberInput/NumberInputStepper.ts +54 -0
  32. package/src/components/NumberInput/context.ts +63 -0
  33. package/src/components/NumberInput/index.ts +6 -0
  34. package/src/components/NumberInput/stepper-button.ts +65 -0
  35. package/src/components/Piano/Piano.ts +243 -0
  36. package/src/components/Piano/index.ts +1 -0
  37. package/src/components/PointsEditor/PointsEditor.ts +146 -0
  38. package/src/components/PointsEditor/PointsEditorBackground.ts +17 -0
  39. package/src/components/PointsEditor/PointsEditorContainer.ts +73 -0
  40. package/src/components/PointsEditor/PointsEditorPoint.ts +253 -0
  41. package/src/components/PointsEditor/PointsEditorSelectionBox.ts +36 -0
  42. package/src/components/PointsEditor/context.ts +39 -0
  43. package/src/components/PointsEditor/index.ts +9 -0
  44. package/src/components/Slider/Slider.ts +224 -0
  45. package/src/components/Slider/SliderMarks.ts +57 -0
  46. package/src/components/Slider/SliderMarksOption.ts +85 -0
  47. package/src/components/Slider/SliderThumb.ts +92 -0
  48. package/src/components/Slider/SliderTrack.ts +50 -0
  49. package/src/components/Slider/context.ts +33 -0
  50. package/src/components/Slider/index.ts +6 -0
  51. package/src/components/XYPad/XYPad.ts +250 -0
  52. package/src/components/XYPad/XYPadArea.ts +20 -0
  53. package/src/components/XYPad/XYPadThumb.ts +105 -0
  54. package/src/components/XYPad/context.ts +33 -0
  55. package/src/components/XYPad/index.ts +4 -0
  56. package/src/components/_util/context.ts +8 -0
  57. package/src/components/_util/placement.ts +36 -0
  58. package/src/components/_util/props.ts +32 -0
  59. package/src/components/_util/useCheckSteps.ts +22 -0
  60. package/src/components/_util/visually-hidden-range-input.ts +16 -0
  61. package/src/composables/useDrag.ts +22 -0
  62. package/src/composables/useDragValue.ts +20 -0
  63. package/src/composables/useDropZone.ts +32 -0
  64. package/src/composables/useInstance.ts +52 -0
  65. package/src/composables/useLongPress.ts +20 -0
  66. package/src/composables/useMIDIAccess.ts +58 -0
  67. package/src/composables/useMIDIInput.ts +33 -0
  68. package/src/composables/useMIDIMessage.ts +30 -0
  69. package/src/composables/useWheel.ts +23 -0
  70. package/src/index.ts +67 -0
@@ -0,0 +1,253 @@
1
+ import {
2
+ computed,
3
+ defineComponent,
4
+ h,
5
+ mergeProps,
6
+ ref,
7
+ useId,
8
+ watch,
9
+ type PropType,
10
+ } from 'vue'
11
+
12
+ import {
13
+ arrowKeyMove,
14
+ clampPoint,
15
+ createDragValue,
16
+ elementMapping,
17
+ POINT_AXIS,
18
+ selectModifier,
19
+ type InputEventOption,
20
+ type ModifierValue,
21
+ type PointPosition,
22
+ type PointsEditorPoint as PointRegistration,
23
+ } from '@tremolo-ui/dom'
24
+
25
+ import { checkPlacement } from '../_util/placement'
26
+ import { visuallyHiddenRangeInput } from '../_util/visually-hidden-range-input'
27
+
28
+ import { usePointsEditorContext } from './context'
29
+
30
+ type PerAxis = string | Partial<Record<'x' | 'y', string>>
31
+
32
+ const perAxis = (setting: PerAxis | undefined, axis: 'x' | 'y') =>
33
+ typeof setting === 'string' ? setting : setting?.[axis]
34
+
35
+ /** One point, bound with `v-model` as `{ x, y }` from 0 to 1. */
36
+ export const PointsEditorPoint = /* @__PURE__ */ defineComponent({
37
+ name: 'PointsEditorPoint',
38
+ inheritAttrs: false,
39
+ props: {
40
+ /** Where the point is, with `y` growing downwards. Bind it with `v-model`. */
41
+ modelValue: { type: Object as PropType<PointPosition>, required: true },
42
+ /** How the selection refers to this point. One is generated when left out. */
43
+ id: String,
44
+ /** The lowest position the point can take, per axis. */
45
+ min: Object as PropType<Partial<PointPosition>>,
46
+ /** The highest position the point can take, per axis. */
47
+ max: Object as PropType<Partial<PointPosition>>,
48
+ /** Sets `--color`, for the theme to colour the point with. */
49
+ color: String,
50
+ /** Overrides the `disabled` of `PointsEditor`. */
51
+ disabled: { type: Boolean, default: undefined },
52
+ /** Overrides the `readonly` of `PointsEditor`. */
53
+ readonly: { type: Boolean, default: undefined },
54
+ /** Overrides the `wheel` of `PointsEditor`; `null` turns it off. */
55
+ wheel: {
56
+ type: [Array, Object] as PropType<ModifierValue<InputEventOption> | null>,
57
+ default: undefined,
58
+ },
59
+ /** Overrides the `keyboard` of `PointsEditor`; `null` turns it off. */
60
+ keyboard: {
61
+ type: [Array, Object] as PropType<ModifierValue<InputEventOption> | null>,
62
+ default: undefined,
63
+ },
64
+ /** The accessible name of each axis, or one for both. */
65
+ ariaLabel: [String, Object] as PropType<PerAxis>,
66
+ /** What the value of each axis means, or one for both. */
67
+ ariaValuetext: [String, Object] as PropType<PerAxis>,
68
+ },
69
+ emits: {
70
+ 'update:modelValue': (value: PointPosition) => typeof value === 'object',
71
+ dragStart: (value: PointPosition) => typeof value === 'object',
72
+ dragEnd: (value: PointPosition) => typeof value === 'object',
73
+ },
74
+ setup(props, { slots, attrs, emit }) {
75
+ const points = usePointsEditorContext()
76
+ checkPlacement('PointsEditorPoint', 'PointsEditorContainer')
77
+
78
+ const generatedId = useId()
79
+ const id = computed(() => props.id ?? generatedId)
80
+ const disabled = computed(() => props.disabled ?? points.disabled)
81
+ const readonly = computed(() => props.readonly ?? points.readonly)
82
+ const inactive = computed(() => disabled.value || readonly.value)
83
+ const wheel = computed(() =>
84
+ props.wheel === undefined ? points.wheel : props.wheel,
85
+ )
86
+ const keyboard = computed(() =>
87
+ props.keyboard === undefined ? points.keyboard : props.keyboard,
88
+ )
89
+ const current = computed(() =>
90
+ clampPoint(props.modelValue, props.min, props.max),
91
+ )
92
+
93
+ const el = ref<HTMLDivElement | null>(null)
94
+ const x = ref<HTMLInputElement | null>(null)
95
+ const dragging = ref(false)
96
+
97
+ // Read by the editor whenever it moves the selection, so the value can
98
+ // change on every frame of a drag without registering again.
99
+ const read = (): PointRegistration => ({
100
+ value: props.modelValue,
101
+ min: props.min,
102
+ max: props.max,
103
+ readonly: inactive.value,
104
+ onChange: (next) => emit('update:modelValue', next),
105
+ element: el.value,
106
+ wheel: wheel.value,
107
+ })
108
+ watch(
109
+ id,
110
+ (key, _, onCleanup) => onCleanup(points.editor.registerPoint(key, read)),
111
+ { immediate: true },
112
+ )
113
+
114
+ /** Where the pointer was when the drag started. */
115
+ let origin: PointPosition | null = null
116
+
117
+ watch(
118
+ el,
119
+ (element, _, onCleanup) => {
120
+ if (!element) return
121
+ // The value is the position itself: no scaling, and no rounding.
122
+ const drag = createDragValue(element, {
123
+ axis: POINT_AXIS,
124
+ mapping: elementMapping(() => points.container, {
125
+ sensitivity: (state) =>
126
+ selectModifier(points.dragSensitivity, state.event).value,
127
+ }),
128
+ cursor: inactive.value ? undefined : points.cursor,
129
+ shouldStart: () => !disabled.value,
130
+ // A move rather than a position: the point keeps the offset it was
131
+ // grabbed at, and everything else selected moves with it.
132
+ onChange: ([px, py]) => {
133
+ if (origin) {
134
+ points.editor.movePointDrag({
135
+ x: px - origin.x,
136
+ y: py - origin.y,
137
+ })
138
+ }
139
+ },
140
+ onDragStart: ([px, py], state) => {
141
+ points.editor.beginPointDrag(id.value, state.event)
142
+ origin = { x: px, y: py }
143
+ dragging.value = true
144
+ x.value?.focus()
145
+ if (!inactive.value) emit('dragStart', current.value)
146
+ },
147
+ onDragEnd: () => {
148
+ origin = null
149
+ dragging.value = false
150
+ if (!inactive.value) emit('dragEnd', current.value)
151
+ },
152
+ })
153
+ const stop = watch(
154
+ () => (inactive.value ? undefined : points.cursor),
155
+ (cursor) => drag.update({ cursor }),
156
+ )
157
+ onCleanup(() => {
158
+ stop()
159
+ drag.destroy()
160
+ })
161
+ },
162
+ { immediate: true, flush: 'post' },
163
+ )
164
+
165
+ function onInput(axis: 'x' | 'y', target: HTMLInputElement) {
166
+ if (readonly.value) {
167
+ target.value = String(current.value[axis])
168
+ return
169
+ }
170
+ const delta = target.valueAsNumber - props.modelValue[axis]
171
+ points.editor.nudgeSelection(id.value, {
172
+ x: axis === 'x' ? delta : 0,
173
+ y: axis === 'y' ? delta : 0,
174
+ })
175
+ }
176
+
177
+ const input = (axis: 'x' | 'y') =>
178
+ visuallyHiddenRangeInput({
179
+ ref: axis === 'x' ? x : undefined,
180
+ 'data-axis': axis,
181
+ value: current.value[axis],
182
+ min: props.min?.[axis] ?? 0,
183
+ max: props.max?.[axis] ?? 1,
184
+ step: 'any',
185
+ disabled: disabled.value,
186
+ 'aria-readonly': readonly.value,
187
+ 'aria-orientation': axis === 'x' ? 'horizontal' : 'vertical',
188
+ 'aria-label': perAxis(props.ariaLabel, axis) ?? axis,
189
+ 'aria-valuetext': perAxis(props.ariaValuetext, axis),
190
+ onInput: (event: Event) =>
191
+ onInput(axis, event.target as HTMLInputElement),
192
+ })
193
+
194
+ // The visual point has two values, so its semantics live on the two
195
+ // range inputs inside it. tabindex -1 keeps the focus inside when a press
196
+ // lands on the point itself.
197
+ return () => {
198
+ const { style, onFocus, onKeydown, ...rest } = attrs
199
+ return h(
200
+ 'div',
201
+ {
202
+ ref: el,
203
+ tabindex: -1,
204
+ 'data-disabled': disabled.value ? '' : undefined,
205
+ 'data-readonly': readonly.value ? '' : undefined,
206
+ 'data-dragging': dragging.value ? '' : undefined,
207
+ 'data-selected': points.selection.includes(id.value) ? '' : undefined,
208
+ ...rest,
209
+ style: [
210
+ {
211
+ position: 'absolute',
212
+ translate: 'var(--translate, -50% -50%)',
213
+ '--color': props.color,
214
+ },
215
+ style,
216
+ {
217
+ left: `${props.modelValue.x * 100}%`,
218
+ top: `${props.modelValue.y * 100}%`,
219
+ },
220
+ ],
221
+ // Merged with the caller's own, which run after these.
222
+ ...mergeProps(
223
+ {
224
+ onFocus: (event: FocusEvent) => {
225
+ // The point is not the control: what reaches it goes to the input.
226
+ if (!disabled.value && event.target === event.currentTarget) {
227
+ x.value?.focus()
228
+ }
229
+ },
230
+ onKeydown: (event: KeyboardEvent) => {
231
+ // The key picks the axis, whichever input holds the focus. y grows
232
+ // downwards, so ArrowUp moves the point towards 0.
233
+ const move = arrowKeyMove(event.key)
234
+ if (!move) return
235
+ event.preventDefault()
236
+ if (inactive.value || !keyboard.value) return
237
+ points.editor.nudgePoint(
238
+ id.value,
239
+ move.axis === 0 ? 'x' : 'y',
240
+ move.direction,
241
+ keyboard.value,
242
+ event,
243
+ )
244
+ },
245
+ },
246
+ { onFocus, onKeydown },
247
+ ),
248
+ },
249
+ [input('x'), input('y'), slots.default?.()],
250
+ )
251
+ }
252
+ },
253
+ })
@@ -0,0 +1,36 @@
1
+ import { defineComponent, h } from 'vue'
2
+
3
+ import { checkPlacement } from '../_util/placement'
4
+
5
+ import { usePointsEditorContext } from './context'
6
+
7
+ /**
8
+ * The box a drag on empty space draws. Nothing is rendered while no drag is
9
+ * running, and it takes none of the pointer.
10
+ */
11
+ export const PointsEditorSelectionBox = /* @__PURE__ */ defineComponent({
12
+ name: 'PointsEditorSelectionBox',
13
+ setup(_, { slots }) {
14
+ const points = usePointsEditorContext()
15
+ checkPlacement('PointsEditorSelectionBox', 'PointsEditorContainer')
16
+ return () => {
17
+ const box = points.selectionBox
18
+ if (!box) return null
19
+ return h(
20
+ 'div',
21
+ {
22
+ style: {
23
+ position: 'absolute',
24
+ zIndex: 20,
25
+ pointerEvents: 'none',
26
+ left: `${box.x * 100}%`,
27
+ top: `${box.y * 100}%`,
28
+ width: `${box.width * 100}%`,
29
+ height: `${box.height * 100}%`,
30
+ },
31
+ },
32
+ slots.default?.(),
33
+ )
34
+ }
35
+ },
36
+ })
@@ -0,0 +1,39 @@
1
+ import { type InjectionKey } from 'vue'
2
+
3
+ import {
4
+ type InputEventOption,
5
+ type ModifierValue,
6
+ type PointsEditorInstance,
7
+ type SelectionBoxRect,
8
+ } from '@tremolo-ui/dom'
9
+
10
+ import { injectContext } from '../_util/context'
11
+
12
+ /** What `PointsEditor` shares with its parts. The fields are getters. */
13
+ export interface PointsEditorContextValue {
14
+ readonly disabled: boolean
15
+ readonly readonly: boolean
16
+ readonly wheel: ModifierValue<InputEventOption> | null
17
+ readonly keyboard: ModifierValue<InputEventOption> | null
18
+ readonly dragSensitivity: ModifierValue<number>
19
+ readonly cursor: string
20
+ readonly selectable: boolean
21
+ /** Ids of the selected points. Always empty while `selectable` is off. */
22
+ readonly selection: readonly string[]
23
+ /** The selection box, while one is being dragged. */
24
+ readonly selectionBox: SelectionBoxRect | null
25
+ /** The element points are placed in and measured against. */
26
+ readonly container: HTMLElement | null
27
+ /** The selection and the moves, from `createPointsEditor` in `@tremolo-ui/dom`. */
28
+ readonly editor: PointsEditorInstance
29
+ /** @internal `PointsEditorContainer` registers its element. */
30
+ setContainer: (element: HTMLElement | null) => void
31
+ }
32
+
33
+ export const PointsEditorKey: InjectionKey<PointsEditorContextValue> =
34
+ Symbol('PointsEditor')
35
+
36
+ /** The context of the enclosing `PointsEditor`, for a part of your own. */
37
+ export function usePointsEditorContext(): PointsEditorContextValue {
38
+ return injectContext(PointsEditorKey, 'PointsEditor')
39
+ }
@@ -0,0 +1,9 @@
1
+ export {
2
+ usePointsEditorContext,
3
+ type PointsEditorContextValue,
4
+ } from './context'
5
+ export { PointsEditor } from './PointsEditor'
6
+ export { PointsEditorBackground } from './PointsEditorBackground'
7
+ export { PointsEditorContainer } from './PointsEditorContainer'
8
+ export { PointsEditorPoint } from './PointsEditorPoint'
9
+ export { PointsEditorSelectionBox } from './PointsEditorSelectionBox'
@@ -0,0 +1,224 @@
1
+ import { computed, defineComponent, h, provide, ref, type PropType } from 'vue'
2
+
3
+ import {
4
+ applyDelta,
5
+ arrowKeyDirection,
6
+ elementMapping,
7
+ selectModifier,
8
+ valuePercent,
9
+ wheelDirection,
10
+ type AxisOptions,
11
+ type XY,
12
+ } from '@tremolo-ui/dom'
13
+ import { linearScale, type Scale } from '@tremolo-ui/functions'
14
+
15
+ import { useDragValue } from '../../composables/useDragValue'
16
+ import { useWheel } from '../../composables/useWheel'
17
+ import { dragSensitivityProp, keyboardProp, wheelProp } from '../_util/props'
18
+ import { useCheckSteps } from '../_util/useCheckSteps'
19
+
20
+ import { SliderKey } from './context'
21
+
22
+ /** Customizable slider. Bind the value with `v-model`. */
23
+ export const Slider = /* @__PURE__ */ defineComponent({
24
+ name: 'Slider',
25
+ props: {
26
+ /** The current value. Bind it with `v-model`. */
27
+ modelValue: { type: Number, required: true },
28
+ /** The value at the start of the travel. */
29
+ min: { type: Number, required: true },
30
+ /** The value at the end of the travel. */
31
+ max: { type: Number, required: true },
32
+ /** Granularity of the value. @default 1 */
33
+ step: { type: Number, default: 1 },
34
+ /** How the value is distributed across the travel. @default linearScale */
35
+ scale: { type: Object as PropType<Scale>, default: () => linearScale },
36
+ /** Run the slider vertically, with the value growing upwards. */
37
+ vertical: Boolean,
38
+ /** Grow the value the other way. */
39
+ reverse: Boolean,
40
+ /** The cursor to show while dragging. @default { cursor: 'pointer' } */
41
+ externalStyles: Object as PropType<{ cursor?: string }>,
42
+ wheel: wheelProp,
43
+ keyboard: keyboardProp,
44
+ dragSensitivity: dragSensitivityProp,
45
+ /** Make the slider unchangeable and remove it from the tab order. */
46
+ disabled: Boolean,
47
+ /** Make the value unchangeable. */
48
+ readonly: Boolean,
49
+ },
50
+ emits: {
51
+ 'update:modelValue': (value: number) => typeof value === 'number',
52
+ /** A drag started, with the value where the track was pressed. */
53
+ dragStart: (value: number) => typeof value === 'number',
54
+ /** The drag ended, with the value it ended on. */
55
+ dragEnd: (value: number) => typeof value === 'number',
56
+ },
57
+ setup(props, { slots, emit, expose }) {
58
+ const root = ref<HTMLDivElement | null>(null)
59
+ let track: HTMLElement | null = null
60
+ let thumb: HTMLInputElement | null = null
61
+
62
+ const inactive = computed(() => props.disabled || props.readonly)
63
+ // Measured from the left or the top, as CSS places things: vertical and
64
+ // reverse each flip it, and together they cancel out.
65
+ const displayReversed = computed(() => props.vertical !== props.reverse)
66
+ const percent = computed(() =>
67
+ valuePercent(
68
+ props.modelValue,
69
+ { min: props.min, max: props.max, scale: props.scale },
70
+ displayReversed.value,
71
+ ),
72
+ )
73
+ const axis = computed((): AxisOptions => ({
74
+ min: props.min,
75
+ max: props.max,
76
+ step: props.step,
77
+ scale: props.scale,
78
+ reverse: displayReversed.value,
79
+ }))
80
+
81
+ useCheckSteps(() => ({
82
+ component: 'Slider',
83
+ range: axis.value,
84
+ keyboard: props.keyboard,
85
+ wheel: props.wheel,
86
+ }))
87
+
88
+ const change = (next: number) => emit('update:modelValue', next)
89
+
90
+ provide(SliderKey, {
91
+ get value() {
92
+ return props.modelValue
93
+ },
94
+ get min() {
95
+ return props.min
96
+ },
97
+ get max() {
98
+ return props.max
99
+ },
100
+ get step() {
101
+ return props.step
102
+ },
103
+ get scale() {
104
+ return props.scale
105
+ },
106
+ get vertical() {
107
+ return props.vertical
108
+ },
109
+ get reverse() {
110
+ return props.reverse
111
+ },
112
+ get disabled() {
113
+ return props.disabled
114
+ },
115
+ get readonly() {
116
+ return props.readonly
117
+ },
118
+ get percent() {
119
+ return percent.value
120
+ },
121
+ change,
122
+ setTrack: (element) => {
123
+ track = element
124
+ },
125
+ setThumb: (input) => {
126
+ thumb = input
127
+ },
128
+ })
129
+
130
+ const valueOf = (v: XY<number>) => v[props.vertical ? 1 : 0]
131
+
132
+ useDragValue(root, () => ({
133
+ axis: axis.value,
134
+ mapping: elementMapping(() => track, {
135
+ sensitivity: (state) =>
136
+ selectModifier(props.dragSensitivity, state.event).value,
137
+ }),
138
+ cursor: inactive.value
139
+ ? undefined
140
+ : (props.externalStyles?.cursor ?? 'pointer'),
141
+ shouldStart: () => !inactive.value,
142
+ updateOnPointerDown: true,
143
+ onChange: (v) => {
144
+ if (!inactive.value) change(valueOf(v))
145
+ },
146
+ onDragStart: (v) => {
147
+ if (inactive.value) return
148
+ thumb?.focus()
149
+ emit('dragStart', valueOf(v))
150
+ },
151
+ onDragEnd: (v) => {
152
+ if (!inactive.value) emit('dragEnd', valueOf(v))
153
+ },
154
+ }))
155
+
156
+ useWheel(
157
+ root,
158
+ (event) => {
159
+ if (!props.wheel || inactive.value) return
160
+ // A notch the slider does not read — a sideways scroll on a vertical
161
+ // slider — is left to the page rather than swallowed.
162
+ const direction = wheelDirection(event, { horizontal: !props.vertical })
163
+ if (direction === null) return
164
+ event.preventDefault()
165
+ change(
166
+ applyDelta(
167
+ props.modelValue,
168
+ props.reverse ? -direction : direction,
169
+ props.wheel,
170
+ axis.value,
171
+ event,
172
+ ),
173
+ )
174
+ },
175
+ { requireFocus: true },
176
+ )
177
+
178
+ expose({
179
+ /** Move the focus to the thumb. */
180
+ focus: () => {
181
+ if (!props.disabled) thumb?.focus()
182
+ },
183
+ /** Take the focus away from the thumb. */
184
+ blur: () => thumb?.blur(),
185
+ })
186
+
187
+ // The group is the pointer and keyboard event area; the range input in
188
+ // the thumb carries the control semantics. A press lands on the track or
189
+ // the thumb, neither of which can hold focus, and the browser answers that
190
+ // by clearing the focus — tabindex -1 keeps it inside.
191
+ return () =>
192
+ h(
193
+ 'div',
194
+ {
195
+ ref: root,
196
+ role: 'group',
197
+ tabindex: -1,
198
+ 'data-orientation': props.vertical ? 'vertical' : 'horizontal',
199
+ 'data-disabled': props.disabled ? '' : undefined,
200
+ 'data-readonly': props.readonly ? '' : undefined,
201
+ onKeydown: (event: KeyboardEvent) => {
202
+ const direction = arrowKeyDirection(event.key)
203
+ if (direction === null) return
204
+ event.preventDefault()
205
+ if (!props.keyboard || inactive.value) return
206
+ change(
207
+ applyDelta(
208
+ props.modelValue,
209
+ props.reverse ? -direction : direction,
210
+ props.keyboard,
211
+ axis.value,
212
+ event,
213
+ ),
214
+ )
215
+ },
216
+ onFocus: () => {
217
+ if (!props.disabled) thumb?.focus()
218
+ },
219
+ onBlur: () => thumb?.blur(),
220
+ },
221
+ slots.default?.(),
222
+ )
223
+ },
224
+ })
@@ -0,0 +1,57 @@
1
+ import { computed, defineComponent, h, type PropType } from 'vue'
2
+
3
+ import { cssLength, sliderMarks, type MarksOptions } from '@tremolo-ui/dom'
4
+
5
+ import { providePlacement } from '../_util/placement'
6
+
7
+ import { useSliderContext } from './context'
8
+ import { SliderMarksOption } from './SliderMarksOption'
9
+
10
+ /** The marks along the slider: written out as options, or built from `options`. */
11
+ export const SliderMarks = /* @__PURE__ */ defineComponent({
12
+ name: 'SliderMarks',
13
+ props: {
14
+ /** Space between the marks and the track. Sets `--gap`. */
15
+ gap: [Number, String],
16
+ /**
17
+ * Build the marks instead of writing `SliderMarksOption` out: a number
18
+ * puts one every that many, `'step'` one every `step`. The default slot
19
+ * is ignored while it is set.
20
+ */
21
+ options: [String, Number, Object] as PropType<MarksOptions>,
22
+ },
23
+ setup(props, { slots }) {
24
+ const slider = useSliderContext()
25
+ providePlacement('SliderMarks')
26
+ const marks = computed(() => {
27
+ if (props.options === undefined) return []
28
+ const list = sliderMarks(
29
+ props.options,
30
+ slider.min,
31
+ slider.max,
32
+ slider.step,
33
+ )
34
+ // In the order they are on screen.
35
+ return slider.vertical !== slider.reverse ? list.reverse() : list
36
+ })
37
+ // Each option inside is placed against this box.
38
+ return () =>
39
+ h(
40
+ 'div',
41
+ {
42
+ 'data-orientation': slider.vertical ? 'vertical' : 'horizontal',
43
+ style: { '--gap': cssLength(props.gap), position: 'relative' },
44
+ },
45
+ props.options !== undefined
46
+ ? marks.value.map(({ value, mark, label }) =>
47
+ h(SliderMarksOption, {
48
+ key: value,
49
+ value,
50
+ mark,
51
+ label: label ? undefined : null,
52
+ }),
53
+ )
54
+ : slots.default?.(),
55
+ )
56
+ },
57
+ })