@nexxtmove/ui 1.42.0 → 1.43.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
@@ -75,6 +75,7 @@ var s = {
75
75
  NexxtTimelineTrack: "components/organisms/TimelineTrack/TimelineTrack.vue",
76
76
  NexxtToaster: "components/organisms/Toaster/Toaster.vue",
77
77
  NexxtVideoPlayer: "components/organisms/VideoPlayer/VideoPlayer.vue",
78
+ NexxtEditorDimensionField: "components/editor/atoms/EditorDimensionField/EditorDimensionField.vue",
78
79
  NexxtEditorSettingsBlock: "components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.vue",
79
80
  NexxtEditorAlignItems: "components/editor/molecules/EditorAlignItems/EditorAlignItems.vue",
80
81
  NexxtEditorBackgroundSize: "components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.vue",
@@ -90,6 +91,8 @@ var s = {
90
91
  NexxtEditorMaxWidth: "components/editor/molecules/EditorMaxWidth/EditorMaxWidth.vue",
91
92
  NexxtEditorPadding: "components/editor/molecules/EditorPadding/EditorPadding.vue",
92
93
  NexxtEditorRadius: "components/editor/molecules/EditorRadius/EditorRadius.vue",
94
+ NexxtEditorSize: "components/editor/molecules/EditorSize/EditorSize.vue",
95
+ NexxtEditorSizeLimit: "components/editor/molecules/EditorSizeLimit/EditorSizeLimit.vue",
93
96
  NexxtEditorTextAlign: "components/editor/molecules/EditorTextAlign/EditorTextAlign.vue"
94
97
  }, c = ["@nexxtmove/ui/ui.css", "@nexxtmove/ui/dist/ui.css"], l = (e, t) => e.some((e) => e === t || c.includes(e)), u = i({
95
98
  meta: {
package/dist/ui.css CHANGED
@@ -1,2 +1,2 @@
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)}}
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}}}input[data-snap][data-v-17eb933b]{animation:snap-in-17eb933b .18s var(--ease-out-strong)}input[data-snap=up][data-v-17eb933b]{--snap-from:2px}input[data-snap=down][data-v-17eb933b]{--snap-from:-2px}@keyframes snap-in-17eb933b{0%{opacity:0;transform:translateY(var(--snap-from))}to{opacity:1;transform:translateY(0)}}@media (prefers-reduced-motion:reduce){input[data-snap][data-v-17eb933b]{animation:1ms snap-hold-17eb933b}@keyframes snap-hold-17eb933b{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.42.0",
4
+ "version": "1.43.0",
5
5
  "exports": {
6
6
  ".": {
7
7
  "types": "./dist/index.d.ts",
@@ -0,0 +1,456 @@
1
+ <script lang="ts">
2
+ /** A unit a dimension can be written in. */
3
+ export type EditorDimensionUnit = 'px' | '%'
4
+
5
+ /** A dimension as the field edits it: a whole number and the unit it is in. */
6
+ export interface EditorDimensionValue {
7
+ value: number
8
+ unit: EditorDimensionUnit
9
+ }
10
+
11
+ /**
12
+ * The range a value is clamped to per unit, unless the `min` / `max` props say
13
+ * otherwise. The px range starts at 40 rather than 0: below that an element
14
+ * set to a fixed size is too small to select on the canvas again.
15
+ */
16
+ export const EDITOR_DIMENSION_LIMITS = {
17
+ px: { min: 40, max: 1600 },
18
+ '%': { min: 1, max: 100 },
19
+ } as const satisfies Record<EditorDimensionUnit, { min: number; max: number }>
20
+
21
+ /** Overridable copy of the field. Every key is optional. */
22
+ export interface EditorDimensionFieldMessages {
23
+ /** Shown in an empty `optional` field, where empty means none. Defaults to `'Geen'`. */
24
+ placeholder?: string
25
+ /** Accessible name of the unit selector, when more than one unit is offered. Defaults to `'Eenheid'`. */
26
+ unit?: string
27
+ /** How the px unit is written. Defaults to `'px'`. */
28
+ px?: string
29
+ /** How the percentage unit is written. Defaults to `'%'`. */
30
+ percent?: string
31
+ }
32
+
33
+ export interface NexxtEditorDimensionFieldProps {
34
+ /** Accessible name of the input; the unit is appended to it. Not rendered visibly. */
35
+ label: string
36
+ /**
37
+ * The units the user can pick from, in the order they are offered. With one
38
+ * unit it is shown as plain text, with more as a selector. Defaults to `['px']`.
39
+ */
40
+ units?: EditorDimensionUnit[]
41
+ /** Lowest value per unit, merged over `EDITOR_DIMENSION_LIMITS`. */
42
+ min?: Partial<Record<EditorDimensionUnit, number>>
43
+ /** Highest value per unit, merged over `EDITOR_DIMENSION_LIMITS`. */
44
+ max?: Partial<Record<EditorDimensionUnit, number>>
45
+ /**
46
+ * An emptied field commits `null` -- none -- and shows the placeholder.
47
+ * Without it, emptying the field is a typo and the old value comes back.
48
+ */
49
+ optional?: boolean
50
+ /**
51
+ * Copy of the placeholder and the units. Defaults are Dutch and are merged
52
+ * per key, so overriding one keeps the defaults of the others. Copy is never
53
+ * translated inside the component.
54
+ */
55
+ messages?: EditorDimensionFieldMessages
56
+ }
57
+
58
+ const DEFAULT_MESSAGES = {
59
+ placeholder: 'Geen',
60
+ unit: 'Eenheid',
61
+ px: 'px',
62
+ percent: '%',
63
+ } satisfies Required<EditorDimensionFieldMessages>
64
+
65
+ const UNIT_MESSAGE = { px: 'px', '%': 'percent' } as const
66
+
67
+ // Only plain digits count. The number input already turns "12abc" into an
68
+ // empty value, but it lets "1.5", "-5" and "1e2" through as valid numbers --
69
+ // none of them a whole number the user typed as such.
70
+ const DIGITS = /^\d+$/
71
+ </script>
72
+
73
+ <script lang="ts" setup>
74
+ import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
75
+ import NexxtIcon from '../../../atoms/Icon/Icon.vue'
76
+
77
+ defineOptions({ name: 'NexxtEditorDimensionField' })
78
+
79
+ const {
80
+ label,
81
+ units = ['px'],
82
+ min,
83
+ max,
84
+ optional = false,
85
+ messages,
86
+ } = defineProps<NexxtEditorDimensionFieldProps>()
87
+
88
+ const emit = defineEmits<{
89
+ /**
90
+ * One edit is finished: a typed value was confirmed (blur or Enter), the
91
+ * unit was switched, or an arrow key was let go after stepping. The model has
92
+ * already been written by then. Arrow keys write the model on every step, so
93
+ * a consumer that folds model writes into one undo step closes it here --
94
+ * a typed value then is a step of its own, a held arrow key one step in all.
95
+ * Not emitted when nothing changed.
96
+ */
97
+ commit: []
98
+ }>()
99
+
100
+ /** The dimension, or `null` for none. */
101
+ const model = defineModel<EditorDimensionValue | null>({ default: null })
102
+
103
+ const copy = computed(() => ({ ...DEFAULT_MESSAGES, ...messages }))
104
+
105
+ /**
106
+ * The unit the field is in. Follows the model, but is kept here as well: an
107
+ * empty field has no model to carry it, and the unit picked there is the one
108
+ * the next typed value is written in.
109
+ */
110
+ const unit = ref<EditorDimensionUnit>(model.value?.unit ?? units[0])
111
+
112
+ watch(
113
+ () => model.value?.unit,
114
+ (next) => {
115
+ if (next) unit.value = next
116
+ },
117
+ )
118
+
119
+ const unitText = (value: EditorDimensionUnit) => copy.value[UNIT_MESSAGE[value]]
120
+
121
+ const limitsOf = (value: EditorDimensionUnit) => ({
122
+ min: min?.[value] ?? EDITOR_DIMENSION_LIMITS[value].min,
123
+ max: max?.[value] ?? EDITOR_DIMENSION_LIMITS[value].max,
124
+ })
125
+
126
+ /** The range of the current unit, mirrored on the input's `min` / `max`. */
127
+ const limits = computed(() => limitsOf(unit.value))
128
+
129
+ const clamp = (value: number, to: EditorDimensionUnit) => {
130
+ const limits = limitsOf(to)
131
+
132
+ return Math.min(Math.max(Math.round(value), limits.min), limits.max)
133
+ }
134
+
135
+ const shown = () => (model.value === null ? '' : String(model.value.value))
136
+
137
+ const inputEl = useTemplateRef<HTMLInputElement>('input')
138
+
139
+ /**
140
+ * Text the number input could not parse, such as "12a". The input reports an
141
+ * empty `value` for it, so without this check a typo in an `optional` field
142
+ * would be committed as none.
143
+ */
144
+ const isTypo = () => inputEl.value?.validity.badInput === true
145
+
146
+ /**
147
+ * Shows the model again. The element is written as well: for unparseable text
148
+ * the input's `value` is already empty, so when the model is empty too, the
149
+ * binding sees no change and would leave the typed text standing.
150
+ */
151
+ const revert = () => {
152
+ draft.value = shown()
153
+ if (inputEl.value) inputEl.value.value = draft.value
154
+ }
155
+
156
+ /**
157
+ * What the input shows while it is being edited. It is deliberately not the
158
+ * model: a half-typed "1" on the way to "180" would be clamped up to the
159
+ * minimum under the caret, so nothing leaves the field until it is confirmed.
160
+ */
161
+ const draft = ref(shown())
162
+
163
+ /** Direction of the one-shot snap animation, cleared once it has played. */
164
+ const snapped = ref<'up' | 'down'>()
165
+
166
+ // The model stays the source of truth: a change from outside -- an undo, a
167
+ // clamp, another element selected -- replaces the draft.
168
+ watch(
169
+ () => model.value?.value ?? null,
170
+ () => {
171
+ draft.value = shown()
172
+ },
173
+ )
174
+
175
+ /** Arrow steps written since the last `commit`, waiting for the key to be let go. */
176
+ let stepping = false
177
+
178
+ /** Writes the model, and reports whether that changed anything. */
179
+ const write = (next: EditorDimensionValue | null) => {
180
+ const current = model.value
181
+ if (
182
+ next === null ? current === null : next.value === current?.value && next.unit === current.unit
183
+ )
184
+ return false
185
+
186
+ model.value = next
187
+ return true
188
+ }
189
+
190
+ const finish = () => {
191
+ stepping = false
192
+ emit('commit')
193
+ }
194
+
195
+ const selectAll = () => {
196
+ inputEl.value?.focus()
197
+ inputEl.value?.select()
198
+ }
199
+
200
+ defineExpose({
201
+ /**
202
+ * Moves focus into the input and selects its value, so typing replaces it.
203
+ * Pass `{ preventScroll: true }` while the field is still being revealed in a
204
+ * clipping box, which the browser would otherwise scroll to bring it in view.
205
+ */
206
+ focus: (options?: FocusOptions) => {
207
+ inputEl.value?.focus(options)
208
+ inputEl.value?.select()
209
+ },
210
+ })
211
+
212
+ /**
213
+ * Pointer-down anywhere in the field lands in the input with its value
214
+ * selected, the same as the padding fields -- except on the unit selector,
215
+ * which has to open.
216
+ */
217
+ const onPointerDown = (event: PointerEvent) => {
218
+ if (event.pointerType !== 'mouse') return
219
+ if ((event.target as Element).closest('select')) return
220
+ if (document.activeElement === inputEl.value) return
221
+
222
+ event.preventDefault()
223
+ selectAll()
224
+ }
225
+
226
+ const commit = async () => {
227
+ const typed = draft.value.trim()
228
+ const typo = isTypo()
229
+ let changed = false
230
+
231
+ if (typo) {
232
+ // Left alone: the old value comes back below.
233
+ } else if (typed === '') {
234
+ if (optional) changed = write(null)
235
+ } else if (DIGITS.test(typed)) {
236
+ changed = write({ value: clamp(Number(typed), unit.value), unit: unit.value })
237
+ }
238
+
239
+ // A blur in the middle of an arrow stream ends that stream as well.
240
+ if (changed || stepping) finish()
241
+
242
+ // Whatever was made of it: clamped, emptied, or the old value back for text
243
+ // that is no number -- and for an emptied field that may not be empty.
244
+ await nextTick()
245
+ revert()
246
+
247
+ if (!DIGITS.test(typed) || model.value === null || Number(typed) === model.value.value) return
248
+
249
+ // Restart the one-shot, or two clamps the same way would only animate once.
250
+ snapped.value = undefined
251
+ await nextTick()
252
+ snapped.value = model.value.value > Number(typed) ? 'up' : 'down'
253
+ }
254
+
255
+ /** From none, the first step lands on the minimum, whichever way it goes. */
256
+ const step = async (direction: 1 | -1, size: number) => {
257
+ const current = model.value
258
+ const next =
259
+ current === null
260
+ ? limitsOf(unit.value).min
261
+ : clamp(current.value + direction * size, current.unit)
262
+
263
+ if (write({ value: next, unit: current?.unit ?? unit.value })) stepping = true
264
+
265
+ await nextTick()
266
+ draft.value = shown()
267
+ }
268
+
269
+ const onKeydown = async (event: KeyboardEvent) => {
270
+ if (event.key === 'Enter') {
271
+ event.preventDefault()
272
+ await commit()
273
+
274
+ return selectAll()
275
+ }
276
+
277
+ if (event.key === 'Escape') {
278
+ // Only swallowed when there is typing to undo. With nothing to revert,
279
+ // Escape belongs to whatever popover or panel the field sits in.
280
+ if (draft.value === shown() && !isTypo()) return
281
+
282
+ event.stopPropagation()
283
+ revert()
284
+
285
+ return selectAll()
286
+ }
287
+
288
+ if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
289
+ event.preventDefault()
290
+
291
+ return step(event.key === 'ArrowUp' ? 1 : -1, event.shiftKey ? 10 : 1)
292
+ }
293
+ }
294
+
295
+ // A held arrow key repeats its keydown but lets go only once, so the keyup is
296
+ // where a stream of steps ends -- the keyboard's counterpart of a slider's
297
+ // pointerup.
298
+ const onKeyup = (event: KeyboardEvent) => {
299
+ if ((event.key === 'ArrowUp' || event.key === 'ArrowDown') && stepping) finish()
300
+ }
301
+
302
+ /**
303
+ * Whether the unit selector was last reached with a pointer. Chromium counts a
304
+ * clicked `<select>` as `:focus-visible`, so without this the keyboard focus
305
+ * style would stay on after a unit is picked with the mouse. A key on the
306
+ * selector, or leaving it, hands the style back to the keyboard.
307
+ */
308
+ const unitByPointer = ref(false)
309
+
310
+ /**
311
+ * A pointer went down on the selector in the current task. A click focuses in
312
+ * the same task as its pointerdown; Safari and Firefox on macOS do not focus a
313
+ * clicked select at all, so a focus after this has passed came from the keyboard.
314
+ */
315
+ let pointerPending = false
316
+
317
+ const onUnitPointerDown = () => {
318
+ unitByPointer.value = true
319
+ pointerPending = true
320
+ setTimeout(() => (pointerPending = false))
321
+ }
322
+
323
+ const onUnitFocus = () => {
324
+ unitByPointer.value = pointerPending
325
+ }
326
+
327
+ /** A value already there is clamped into the new unit's range; none stays none. */
328
+ const onUnit = (event: Event) => {
329
+ const next = (event.target as HTMLSelectElement).value as EditorDimensionUnit
330
+ unit.value = next
331
+
332
+ if (model.value === null) return
333
+ if (write({ value: clamp(model.value.value, next), unit: next })) finish()
334
+ }
335
+ </script>
336
+
337
+ <template>
338
+ <!--
339
+ The look of the padding fields: NexxtInputField forces `min-w-60` on its
340
+ input, which does not fit two to a row in an editor sidebar. Focus in the
341
+ field -- the input or the unit selector -- outranks hover: the hover ring
342
+ only applies while nothing inside has focus.
343
+ -->
344
+ <div
345
+ 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:not-focus-within:ring-gray-300 motion-reduce:transition-none"
346
+ @pointerdown="onPointerDown"
347
+ >
348
+ <!--
349
+ A number input reports an empty `value` for anything it cannot parse, so
350
+ "12a" would arrive as an emptied field; `isTypo` tells the two apart. The
351
+ native spinners are hidden, as in NexxtInputField: the arrow keys step
352
+ the value, and the padding fields have none either.
353
+ -->
354
+ <input
355
+ ref="input"
356
+ type="number"
357
+ inputmode="numeric"
358
+ step="1"
359
+ :min="limits.min"
360
+ :max="limits.max"
361
+ autocomplete="off"
362
+ :placeholder="optional ? copy.placeholder : undefined"
363
+ :aria-label="`${label} (${unitText(unit)})`"
364
+ :value="draft"
365
+ :data-snap="snapped"
366
+ class="h-10 min-w-0 flex-1 [appearance:textfield] bg-transparent small-normal text-gray-900 tabular-nums placeholder-gray-700 focus:outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
367
+ @input="draft = ($event.target as HTMLInputElement).value"
368
+ @focus="selectAll"
369
+ @blur="commit"
370
+ @keydown="onKeydown"
371
+ @keyup="onKeyup"
372
+ @animationend="snapped = undefined"
373
+ />
374
+
375
+ <!--
376
+ The unit stays in sight on an empty field as well: it is the unit the next
377
+ typed value is written in, and with more than one it can be picked first.
378
+ -->
379
+ <span
380
+ v-if="units.length > 1"
381
+ data-testid="editor-dimension-unit"
382
+ class="relative flex shrink-0 items-center"
383
+ >
384
+ <!-- Native, so it opens the platform picker on touch and needs no popover. -->
385
+ <select
386
+ :value="unit"
387
+ :aria-label="copy.unit"
388
+ :data-pointer="unitByPointer || undefined"
389
+ class="cursor-pointer appearance-none rounded-sm bg-transparent pr-4 pl-1 small-normal text-gray-700 hover:text-gray-900 focus:outline-none focus-visible:not-data-pointer:bg-gray-100 focus-visible:not-data-pointer:text-gray-900"
390
+ @pointerdown="onUnitPointerDown"
391
+ @focus="onUnitFocus"
392
+ @keydown="unitByPointer = false"
393
+ @blur="unitByPointer = false"
394
+ @change="onUnit"
395
+ >
396
+ <option v-for="option in units" :key="option" :value="option">
397
+ {{ unitText(option) }}
398
+ </option>
399
+ </select>
400
+ <span class="pointer-events-none absolute right-0 text-xs text-gray-700" aria-hidden="true">
401
+ <NexxtIcon name="chevron-down" />
402
+ </span>
403
+ </span>
404
+
405
+ <span
406
+ v-else
407
+ data-testid="editor-dimension-unit"
408
+ class="shrink-0 small-normal text-gray-700 select-none"
409
+ aria-hidden="true"
410
+ >
411
+ {{ unitText(unit) }}
412
+ </span>
413
+ </div>
414
+ </template>
415
+
416
+ <style scoped>
417
+ /* A committed value that was clamped is replaced rather than edited, so it
418
+ arrives as its own number, travelling the way the value moved. */
419
+ input[data-snap] {
420
+ animation: snap-in 180ms var(--ease-out-strong);
421
+ }
422
+
423
+ input[data-snap='up'] {
424
+ --snap-from: 2px;
425
+ }
426
+
427
+ input[data-snap='down'] {
428
+ --snap-from: -2px;
429
+ }
430
+
431
+ @keyframes snap-in {
432
+ from {
433
+ opacity: 0;
434
+ transform: translateY(var(--snap-from));
435
+ }
436
+ to {
437
+ opacity: 1;
438
+ transform: translateY(0);
439
+ }
440
+ }
441
+
442
+ /* Not `animation: none`: that fires no `animationend`, so the attribute would
443
+ stay on and the next clamp the same way would have nothing to retrigger. */
444
+ @media (prefers-reduced-motion: reduce) {
445
+ input[data-snap] {
446
+ animation: snap-hold 1ms;
447
+ }
448
+
449
+ @keyframes snap-hold {
450
+ from,
451
+ to {
452
+ opacity: 1;
453
+ }
454
+ }
455
+ }
456
+ </style>