@nexxtmove/ui 1.26.0 → 1.28.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/dist/nuxt.js CHANGED
@@ -49,6 +49,7 @@ var s = {
49
49
  NexxtTimelineLanes: "components/molecules/TimelineLanes/TimelineLanes.vue",
50
50
  NexxtToast: "components/molecules/Toast/Toast.vue",
51
51
  NexxtToggleGroup: "components/molecules/ToggleGroup/ToggleGroup.vue",
52
+ NexxtTree: "components/molecules/Tree/Tree.vue",
52
53
  NexxtCalendar: "components/organisms/Calendar/Calendar.vue",
53
54
  NexxtColorPicker: "components/organisms/ColorPicker/ColorPicker.vue",
54
55
  NexxtCombobox: "components/organisms/Combobox/Combobox.vue",
package/dist/ui.css CHANGED
@@ -1,2 +1,2 @@
1
- rect[data-v-0a5d9b21]{animation-duration:4s;animation-timing-function:cubic-bezier(.77,0,.175,1);animation-iteration-count:infinite}rect[data-v-0a5d9b21]:first-child{animation-name:load-1-0a5d9b21}rect[data-v-0a5d9b21]:nth-child(2){animation-name:load-2-0a5d9b21}rect[data-v-0a5d9b21]:nth-child(3){animation-name:load-3-0a5d9b21}rect[data-v-0a5d9b21]:nth-child(4){animation-name:load-4-0a5d9b21}rect[data-v-0a5d9b21]:nth-child(5){animation-name:load-5-0a5d9b21}rect[data-v-0a5d9b21]:nth-child(6){animation-name:load-6-0a5d9b21}rect[data-v-0a5d9b21]:nth-child(7){animation-name:load-7-0a5d9b21}rect[data-v-0a5d9b21]:nth-child(8){animation-name:load-8-0a5d9b21}@media (prefers-reduced-motion:reduce){rect[data-v-0a5d9b21]{animation:none}svg[data-v-0a5d9b21]{animation:2.5s ease-in-out infinite pulse-0a5d9b21}}@keyframes pulse-0a5d9b21{0%,to{opacity:1}50%{opacity:.65}}@keyframes load-1-0a5d9b21{0%,80%{transform:translate(0)}5%,20%,60%,75%{transform:translate(-25px,25px)}30%,50%{transform:translate(-75px,75px)}}@keyframes load-2-0a5d9b21{5%,75%{transform:translate(0%)}10%,20%,60%,70%{transform:translate(50px)}30%,50%{transform:translateY(50px)}}@keyframes load-3-0a5d9b21{10%,70%{transform:translate(0%)}15%,65%{transform:translateY(-50px)}20%,60%{fill:var(--color-sapphire-500);transform:translateY(-50px)}30%,50%{fill:var(--color-purple-500);transform:translate(-50px)}}@keyframes load-4-0a5d9b21{20%,60%{transform:translate(0%)}30%,50%{transform:translate(50px,-50px)}}@keyframes load-5-0a5d9b21{0%,80%{transform:translate(0%)}5%,20%,60%,75%{transform:translateY(50px)}30%,50%{transform:translate(50px)}}@keyframes load-6-0a5d9b21{5%,75%{transform:translate(0%)}10%,20%,60%,70%{transform:translate(-50px)}30%,50%{transform:translateY(-50px)}}@keyframes load-7-0a5d9b21{10%,70%{transform:translate(0%)}15%,20%,60%,65%{transform:translate(50px)}30%,50%{transform:translate(100px,-50px)}}@keyframes load-8-0a5d9b21{15%,65%{transform:translate(0%)}20%,60%{transform:translateY(-50px)}30%,50%{transform:translate(50px,-100px)}}.timer-circle[data-v-7e3c5bda]{stroke-dashoffset:0;animation:linear forwards toast-timer-7e3c5bda}@keyframes toast-timer-7e3c5bda{to{stroke-dashoffset:var(--fa0d20de)}}
1
+ input[data-snap][data-v-5d3904ac]{animation:snap-in-5d3904ac .18s var(--ease-out-strong)}input[data-snap=up][data-v-5d3904ac]{--snap-from:2px}input[data-snap=down][data-v-5d3904ac]{--snap-from:-2px}@keyframes snap-in-5d3904ac{0%{opacity:0;transform:translateY(var(--snap-from))}to{opacity:1;transform:translateY(0)}}@media (prefers-reduced-motion:reduce){input[data-snap][data-v-5d3904ac]{animation:1ms snap-hold-5d3904ac}@keyframes snap-hold-5d3904ac{0%,to{opacity:1}}}rect[data-v-0a5d9b21]{animation-duration:4s;animation-timing-function:cubic-bezier(.77,0,.175,1);animation-iteration-count:infinite}rect[data-v-0a5d9b21]:first-child{animation-name:load-1-0a5d9b21}rect[data-v-0a5d9b21]:nth-child(2){animation-name:load-2-0a5d9b21}rect[data-v-0a5d9b21]:nth-child(3){animation-name:load-3-0a5d9b21}rect[data-v-0a5d9b21]:nth-child(4){animation-name:load-4-0a5d9b21}rect[data-v-0a5d9b21]:nth-child(5){animation-name:load-5-0a5d9b21}rect[data-v-0a5d9b21]:nth-child(6){animation-name:load-6-0a5d9b21}rect[data-v-0a5d9b21]:nth-child(7){animation-name:load-7-0a5d9b21}rect[data-v-0a5d9b21]:nth-child(8){animation-name:load-8-0a5d9b21}@media (prefers-reduced-motion:reduce){rect[data-v-0a5d9b21]{animation:none}svg[data-v-0a5d9b21]{animation:2.5s ease-in-out infinite pulse-0a5d9b21}}@keyframes pulse-0a5d9b21{0%,to{opacity:1}50%{opacity:.65}}@keyframes load-1-0a5d9b21{0%,80%{transform:translate(0)}5%,20%,60%,75%{transform:translate(-25px,25px)}30%,50%{transform:translate(-75px,75px)}}@keyframes load-2-0a5d9b21{5%,75%{transform:translate(0%)}10%,20%,60%,70%{transform:translate(50px)}30%,50%{transform:translateY(50px)}}@keyframes load-3-0a5d9b21{10%,70%{transform:translate(0%)}15%,65%{transform:translateY(-50px)}20%,60%{fill:var(--color-sapphire-500);transform:translateY(-50px)}30%,50%{fill:var(--color-purple-500);transform:translate(-50px)}}@keyframes load-4-0a5d9b21{20%,60%{transform:translate(0%)}30%,50%{transform:translate(50px,-50px)}}@keyframes load-5-0a5d9b21{0%,80%{transform:translate(0%)}5%,20%,60%,75%{transform:translateY(50px)}30%,50%{transform:translate(50px)}}@keyframes load-6-0a5d9b21{5%,75%{transform:translate(0%)}10%,20%,60%,70%{transform:translate(-50px)}30%,50%{transform:translateY(-50px)}}@keyframes load-7-0a5d9b21{10%,70%{transform:translate(0%)}15%,20%,60%,65%{transform:translate(50px)}30%,50%{transform:translate(100px,-50px)}}@keyframes load-8-0a5d9b21{15%,65%{transform:translate(0%)}20%,60%{transform:translateY(-50px)}30%,50%{transform:translate(50px,-100px)}}.timer-circle[data-v-7e3c5bda]{stroke-dashoffset:0;animation:linear forwards toast-timer-7e3c5bda}@keyframes toast-timer-7e3c5bda{to{stroke-dashoffset:var(--fa0d20de)}}
2
2
  /*$vite$:1*/
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nexxtmove/ui",
3
3
  "type": "module",
4
- "version": "1.26.0",
4
+ "version": "1.28.0",
5
5
  "exports": {
6
6
  ".": {
7
7
  "types": "./dist/index.d.ts",
@@ -2,15 +2,22 @@
2
2
  import type { PaddingIconSide } from './_PaddingIcon.vue'
3
3
 
4
4
  /**
5
- * The steps a field can land on, as the numeric part of the Tailwind class.
6
- * 13 and 15 are missing on purpose: the theme defines `--spacing-0` through
7
- * `--spacing-12` and then only 14 and 16, so offering every integer would
8
- * produce classes that have no token behind them.
5
+ * The steps a field can land on, as the numeric part of the Tailwind class:
6
+ * every integer `--spacing-*` token the theme defines. The list thins out as it
7
+ * climbs -- 13 and 15 are absent, and past 16 it moves in fours and then
8
+ * eights -- so snapping over this list rather than over the px value keeps the
9
+ * field off classes that have no token behind them.
9
10
  */
10
- const PADDING_STEPS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14, 16] as const
11
+ const PADDING_STEPS = [
12
+ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14, 16, 20, 24, 28, 32, 36, 40, 44, 48, 52, 56, 60, 64,
13
+ 72, 80, 96,
14
+ ] as const
11
15
 
12
- /** Highest step, used as the `max` of every field and as the clamp ceiling. */
13
- const MAX_STEP = PADDING_STEPS[PADDING_STEPS.length - 1]
16
+ /** The spacing scale is built on 0.25rem, so `p-4` is 4 × 4 = 16 px. */
17
+ const PX_PER_STEP = 4
18
+
19
+ /** Highest value a field takes, in px: the top step is `p-96`, which is 384 px. */
20
+ const MAX_PX = PADDING_STEPS[PADDING_STEPS.length - 1] * PX_PER_STEP
14
21
 
15
22
  const ALL_SIDES: PaddingIconSide[] = ['top', 'right', 'bottom', 'left']
16
23
 
@@ -36,6 +43,12 @@ export interface EditorPaddingMessages {
36
43
  * fields. Defaults to `'Padding per zijde'`.
37
44
  */
38
45
  toggle?: string
46
+ /**
47
+ * Unit the fields show and read, appended to every accessible name. The
48
+ * values themselves are px whatever this says, so it is copy, not a
49
+ * conversion. Defaults to `'px'`.
50
+ */
51
+ unit?: string
39
52
  }
40
53
 
41
54
  export interface NexxtEditorPaddingProps {
@@ -61,6 +74,7 @@ const DEFAULT_MESSAGES = {
61
74
  bottom: 'Onder',
62
75
  left: 'Links',
63
76
  toggle: 'Padding per zijde',
77
+ unit: 'px',
64
78
  } satisfies Required<EditorPaddingMessages>
65
79
 
66
80
  /** Which sides a `p*-` prefix writes to. */
@@ -105,15 +119,15 @@ const serialize = ({ top, right, bottom, left }: PaddingSides): string => {
105
119
  }
106
120
 
107
121
  /**
108
- * Clamp to 0-16 and snap to the nearest step. A tie goes to the lower step, so
109
- * 13 becomes 12 and 15 becomes 14; that way typing a digit never jumps the
110
- * value further than the next step up.
122
+ * Turn a px value into a step: clamp to 0-384 and take the nearest one. A tie
123
+ * goes to the lower step, so 14 px becomes 12 and 60 px becomes 56; that way a
124
+ * value in between never lands further away than the step above it.
111
125
  */
112
- const snap = (value: number): number => {
113
- const clamped = Math.min(Math.max(value, 0), MAX_STEP)
126
+ const snap = (px: number): number => {
127
+ const clamped = Math.min(Math.max(px, 0), MAX_PX)
114
128
 
115
129
  return PADDING_STEPS.reduce((best, step) =>
116
- Math.abs(step - clamped) < Math.abs(best - clamped) ? step : best,
130
+ Math.abs(step * PX_PER_STEP - clamped) < Math.abs(best * PX_PER_STEP - clamped) ? step : best,
117
131
  )
118
132
  }
119
133
  </script>
@@ -198,25 +212,53 @@ const secondaryFields = computed<PaddingField[]>(() => [
198
212
  // from the tab order. Same trick as EditorSettingsBlock.
199
213
  const secondaryInert = computed(() => (expanded.value ? undefined : true))
200
214
 
201
- // An empty field is a 0, and a 0 is shown as an empty field so the placeholder
202
- // carries it. Typing "0" therefore leaves the bound value unchanged, which is
203
- // what keeps Vue from wiping the character out from under the caret.
215
+ // The fields speak px, not steps: `p-6` reads as 24. A 0 is shown as an empty
216
+ // field so the placeholder carries it, and an empty field commits back as a 0.
204
217
  const display = (side: PaddingIconSide) =>
205
- sides.value[side] === 0 ? '' : String(sides.value[side])
218
+ sides.value[side] === 0 ? '' : String(sides.value[side] * PX_PER_STEP)
206
219
 
207
- // Only plain digits count. `Number.parseInt` would read "1e2" as 1 and "12abc"
208
- // as 12, which silently turns a typo into a value the user did not ask for.
209
- const DIGITS = /^\d+$/
220
+ const apply = (next: PaddingSides) => {
221
+ // A change that changes nothing -- committing 30 where the value is already
222
+ // 28 px, arrowing up at the ceiling, closing a symmetrical block -- writes
223
+ // nothing either, so the editor does not see an edit that was not one.
224
+ if (ALL_SIDES.every((side) => next[side] === sides.value[side])) return
210
225
 
211
- const onInput = (field: PaddingField, typed: string) => {
212
- const raw = typed.trim()
213
- const step = DIGITS.test(raw) ? snap(Number(raw)) : 0
226
+ sides.value = next
227
+ model.value = serialize(next)
228
+ }
214
229
 
230
+ const write = (field: PaddingField, step: number) => {
215
231
  const next = { ...sides.value }
216
232
  for (const side of field.targets) next[side] = step
217
- sides.value = next
218
233
 
219
- model.value = serialize(next)
234
+ apply(next)
235
+ }
236
+
237
+ /** A confirmed field, in px: blur or Enter, not a keystroke. */
238
+ const onCommit = (field: PaddingField, px: number) => write(field, snap(px))
239
+
240
+ /** One step along the list, so the arrow keys never stop on a missing token. */
241
+ const onStep = (field: PaddingField, direction: 1 | -1) => {
242
+ const index = PADDING_STEPS.indexOf(sides.value[field.source] as (typeof PADDING_STEPS)[number])
243
+ // No wrapping: the ends of the list are the ends of the range.
244
+ const next = Math.min(Math.max(index + direction, 0), PADDING_STEPS.length - 1)
245
+
246
+ write(field, PADDING_STEPS[next])
247
+ }
248
+
249
+ /**
250
+ * Closing the block makes the two fields that stay visible the truth: whatever
251
+ * the hidden bottom and left were, they follow top and right. That is written
252
+ * straight away rather than on the next edit, so what is on screen and what is
253
+ * on the element never disagree -- `pt-4 pr-2 pb-6 pl-2` closes as `px-2 py-4`,
254
+ * and the horizontal field then reads what Rechts said. Opening changes nothing:
255
+ * it only shows the two values that were already there.
256
+ */
257
+ const onToggle = () => {
258
+ expanded.value = !expanded.value
259
+ if (expanded.value) return
260
+
261
+ apply({ ...sides.value, bottom: sides.value.top, left: sides.value.right })
220
262
  }
221
263
  </script>
222
264
 
@@ -235,8 +277,9 @@ const onInput = (field: PaddingField, typed: string) => {
235
277
  :sides="field.targets"
236
278
  :label="field.label"
237
279
  :value="display(field.source)"
238
- :max="MAX_STEP"
239
- @input="onInput(field, $event)"
280
+ :unit="copy.unit"
281
+ @commit="onCommit(field, $event)"
282
+ @step="onStep(field, $event)"
240
283
  />
241
284
 
242
285
  <!--
@@ -256,7 +299,7 @@ const onInput = (field: PaddingField, typed: string) => {
256
299
  ? 'bg-cornflower-blue-50 text-cornflower-blue-600 ring-cornflower-blue-500'
257
300
  : 'text-gray-700 ring-gray-200 hover:text-gray-900',
258
301
  ]"
259
- @click="expanded = !expanded"
302
+ @click="onToggle"
260
303
  >
261
304
  <PaddingIcon :sides="ALL_SIDES" />
262
305
  </button>
@@ -300,8 +343,9 @@ const onInput = (field: PaddingField, typed: string) => {
300
343
  :sides="field.targets"
301
344
  :label="field.label"
302
345
  :value="display(field.source)"
303
- :max="MAX_STEP"
304
- @input="onInput(field, $event)"
346
+ :unit="copy.unit"
347
+ @commit="onCommit(field, $event)"
348
+ @step="onStep(field, $event)"
305
349
  />
306
350
  </div>
307
351
  </div>
@@ -1,56 +1,243 @@
1
+ <script lang="ts">
2
+ // Only plain digits count. `Number.parseInt` would read "1e2" as 1 and "12abc"
3
+ // as 12, which silently turns a typo into a value the user did not ask for.
4
+ const DIGITS = /^\d+$/
5
+ </script>
6
+
1
7
  <script lang="ts" setup>
8
+ import { nextTick, ref, useTemplateRef, watch } from 'vue'
2
9
  import PaddingIcon from './_PaddingIcon.vue'
3
10
  import type { PaddingIconSide } from './_PaddingIcon.vue'
4
11
 
5
12
  export interface NexxtEditorPaddingFieldProps {
6
13
  /** Sides the icon marks, which is also what this field edits. */
7
14
  sides: PaddingIconSide[]
8
- /** Accessible name of the input; the settings block label is not a `<label for>`. */
15
+ /** Name of the field; the unit is appended to it for the accessible name. */
9
16
  label: string
10
- /** Value as shown, already normalised. An empty string falls back to the `0` placeholder. */
17
+ /** Value as shown, in the unit below. An empty string falls back to the `0` placeholder. */
11
18
  value: string
12
- /** Highest value the input accepts. */
13
- max: number
19
+ /** Unit of the value, shown behind the input and part of the accessible name. */
20
+ unit: string
14
21
  }
15
22
 
16
23
  defineOptions({ name: 'NexxtEditorPaddingField' })
17
24
 
18
- defineProps<NexxtEditorPaddingFieldProps>()
25
+ const { value } = defineProps<NexxtEditorPaddingFieldProps>()
26
+
27
+ const emit = defineEmits<{
28
+ /**
29
+ * The value the user confirmed, on blur or Enter rather than on every
30
+ * keystroke. Reading the text is this component's job because it owns the
31
+ * input; text that is not a number never gets here. Snapping it onto the
32
+ * scale is the parent's, because it owns the class string.
33
+ */
34
+ commit: [value: number]
35
+ /** One step up or down the parent's list, straight away. */
36
+ step: [direction: 1 | -1]
37
+ }>()
38
+
39
+ const inputEl = useTemplateRef<HTMLInputElement>('input')
40
+
41
+ /**
42
+ * What the input shows while it is being edited. It is deliberately not the
43
+ * model: a half-typed "3" on the way to "30" would snap to 4 and fight the
44
+ * caret, so nothing leaves this component until the value is confirmed.
45
+ */
46
+ const draft = ref(value)
47
+
48
+ /** Direction of the one-shot snap animation, cleared once it has played. */
49
+ const snapped = ref<'up' | 'down'>()
50
+
51
+ // The model stays the source of truth: a change from outside -- the parent
52
+ // snapping a commit included -- replaces the draft.
53
+ watch(
54
+ () => value,
55
+ (next) => {
56
+ draft.value = next
57
+ },
58
+ )
59
+
60
+ /** Focus with the whole value selected, so the next digit replaces it. */
61
+ const selectAll = () => {
62
+ inputEl.value?.focus()
63
+ inputEl.value?.select()
64
+ }
65
+
66
+ /**
67
+ * Pointer-down anywhere in the field -- on the icon or the unit as much as on
68
+ * the input -- lands in the input with its value selected. The default is
69
+ * prevented so the browser does not drop a caret inside that selection again on
70
+ * mouse-up.
71
+ */
72
+ const onPointerDown = (event: PointerEvent) => {
73
+ // Mouse only. Touch and pen keep their native behaviour, because preventing
74
+ // the default there can keep iOS from opening the keyboard -- and `@focus`
75
+ // selects the value on those just the same.
76
+ if (event.pointerType !== 'mouse') return
77
+ if (document.activeElement === inputEl.value) return
78
+
79
+ event.preventDefault()
80
+ selectAll()
81
+ }
19
82
 
20
83
  /**
21
- * The raw string the user typed, on every keystroke rather than on change.
22
- * Parsing and snapping belong to the parent, which owns the class string.
84
+ * Hand the value to the parent and show back whatever it made of it, which is
85
+ * the snapped value, or the value from before for text that was not a number at
86
+ * all. The draft is re-read rather than assumed: a commit that snaps onto the
87
+ * value the field already had leaves the model untouched, so waiting on the prop
88
+ * to change would strand the typed text.
23
89
  */
24
- const emit = defineEmits<{ input: [value: string] }>()
90
+ const commit = async () => {
91
+ const typed = draft.value.trim()
92
+
93
+ // Clearing the field is how a zero is entered; the placeholder already shows
94
+ // it. Anything that is not a number leaves the field without a commit and is
95
+ // replaced by the old value below, the same as Escape -- guessing at "12a"
96
+ // would put a value on the element that nobody asked for.
97
+ if (typed === '') emit('commit', 0)
98
+ else if (DIGITS.test(typed)) emit('commit', Number(typed))
99
+
100
+ await nextTick()
101
+ draft.value = value
102
+
103
+ // Compared as numbers, so "016" against a field already showing 16 is not a
104
+ // change and does not animate.
105
+ if (!DIGITS.test(typed) || Number(typed) === Number(value || '0')) return
106
+
107
+ // Restart the one-shot: re-setting the direction it already has would not
108
+ // retrigger the animation, so two snaps the same way within 180ms would show
109
+ // only the first.
110
+ snapped.value = undefined
111
+ await nextTick()
112
+
113
+ snapped.value = Number(value || '0') > Number(typed) ? 'up' : 'down'
114
+ }
25
115
 
26
- const onInput = (event: Event) => emit('input', (event.target as HTMLInputElement).value)
116
+ /** Arrows write immediately, so the draft only has to catch up. */
117
+ const onStep = async (direction: 1 | -1) => {
118
+ emit('step', direction)
119
+
120
+ await nextTick()
121
+ draft.value = value
122
+ }
123
+
124
+ const onKeydown = async (event: KeyboardEvent) => {
125
+ if (event.key === 'Enter') {
126
+ event.preventDefault()
127
+ await commit()
128
+
129
+ // Select what it landed on, so the field is ready for the next value the
130
+ // same way focus and Escape leave it.
131
+ return selectAll()
132
+ }
133
+
134
+ if (event.key === 'Escape') {
135
+ // Only swallowed when there is typing to undo. With nothing to revert,
136
+ // Escape belongs to whatever popover or panel the field sits in.
137
+ if (draft.value === value) return
138
+
139
+ event.stopPropagation()
140
+ draft.value = value
141
+ return selectAll()
142
+ }
143
+
144
+ if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
145
+ // Or the caret would jump to either end of the value as well.
146
+ event.preventDefault()
147
+ return onStep(event.key === 'ArrowUp' ? 1 : -1)
148
+ }
149
+ }
27
150
  </script>
28
151
 
29
152
  <template>
30
153
  <!--
31
154
  Visually a NexxtInputField, rebuilt here: that component forces `min-w-60`
32
155
  on its input, which blows up this two-column grid, and renders spin buttons
33
- for `type="number"` that make no sense against a step list.
156
+ for `type="number"` that make no sense against a step list. `cursor-text`
157
+ and the pointer handler make the whole row behave as the input it wraps.
158
+
159
+ The inner spacing is asymmetric, the padding is not: `gap-1` with an extra
160
+ `mr-1` on the icon leaves 8px between the icon and the number but only 4px
161
+ between the number and its unit, which reads as one value and buys the number
162
+ the width it needs. At three digits plus a unit the field has little to spare.
34
163
  -->
35
164
  <div
36
- class="flex items-center gap-2 overflow-hidden rounded-lg pl-3 ring ring-gray-200 transition-colors duration-150 ease-out focus-within:ring-cornflower-blue-500 hover:ring-gray-300 motion-reduce:transition-none"
165
+ class="flex cursor-text items-center gap-1 overflow-hidden rounded-lg px-3 whitespace-nowrap ring ring-gray-200 transition-colors duration-150 ease-out focus-within:ring-cornflower-blue-500 hover:ring-gray-300 motion-reduce:transition-none"
166
+ @pointerdown="onPointerDown"
37
167
  >
38
- <PaddingIcon :sides="sides" class="text-gray-900" />
168
+ <PaddingIcon :sides="sides" class="mr-1 text-gray-900" />
39
169
 
40
170
  <!--
41
- `type="number"` for the numeric keypad on mobile; the spinners are hidden
42
- because the value snaps to a step list, not to a fixed interval.
171
+ Text, not `type="number"`: a number input reports an empty `value` for
172
+ anything it cannot parse, so "12a" would arrive as a cleared field and
173
+ snap to 0 mid-word. `inputmode` still asks for the numeric keypad. No
174
+ `maxlength`: it would silently turn a pasted 1000 into 100, where the
175
+ clamp turns it into the 384 the user was reaching for.
43
176
  -->
44
177
  <input
45
- type="number"
178
+ ref="input"
179
+ type="text"
46
180
  inputmode="numeric"
47
- min="0"
48
- :max="max"
181
+ autocomplete="off"
49
182
  placeholder="0"
50
- :aria-label="label"
51
- :value="value"
52
- class="h-10 w-full bg-transparent pr-3 small-normal text-gray-900 placeholder-gray-700 focus:outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
53
- @input="onInput"
183
+ :aria-label="`${label} (${unit})`"
184
+ :value="draft"
185
+ :data-snap="snapped"
186
+ class="h-10 min-w-0 flex-1 bg-transparent text-right small-normal text-gray-900 tabular-nums placeholder-gray-700 focus:outline-none"
187
+ @input="draft = ($event.target as HTMLInputElement).value"
188
+ @focus="selectAll"
189
+ @blur="commit"
190
+ @keydown="onKeydown"
191
+ @animationend="snapped = undefined"
54
192
  />
193
+
194
+ <span class="shrink-0 small-normal text-gray-700 select-none" aria-hidden="true">
195
+ {{ unit }}
196
+ </span>
55
197
  </div>
56
198
  </template>
199
+
200
+ <style scoped>
201
+ /* A committed value that landed on a different step than the one typed is
202
+ replaced rather than edited, so it arrives as its own number: it fades in and
203
+ travels the way the value moved -- up for a higher one, down for a lower. */
204
+ input[data-snap] {
205
+ animation: snap-in 180ms var(--ease-out-strong);
206
+ }
207
+
208
+ input[data-snap='up'] {
209
+ --snap-from: 2px;
210
+ }
211
+
212
+ input[data-snap='down'] {
213
+ --snap-from: -2px;
214
+ }
215
+
216
+ @keyframes snap-in {
217
+ from {
218
+ opacity: 0;
219
+ transform: translateY(var(--snap-from));
220
+ }
221
+ to {
222
+ opacity: 1;
223
+ transform: translateY(0);
224
+ }
225
+ }
226
+
227
+ /* The number itself is the feedback; without motion there is nothing left to
228
+ say that the swap needs, so it just appears. Not `animation: none`: that fires
229
+ no `animationend`, so the attribute would stay on and the next snap in the
230
+ same direction would have nothing to retrigger. A 1ms no-op keeps the event. */
231
+ @media (prefers-reduced-motion: reduce) {
232
+ input[data-snap] {
233
+ animation: snap-hold 1ms;
234
+ }
235
+
236
+ @keyframes snap-hold {
237
+ from,
238
+ to {
239
+ opacity: 1;
240
+ }
241
+ }
242
+ }
243
+ </style>