@nexxtmove/ui 1.42.0 → 1.44.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-15feed8b]{animation:snap-in-15feed8b .18s var(--ease-out-strong)}input[data-snap=up][data-v-15feed8b]{--snap-from:2px}input[data-snap=down][data-v-15feed8b]{--snap-from:-2px}@keyframes snap-in-15feed8b{0%{opacity:0;transform:translateY(var(--snap-from))}to{opacity:1;transform:translateY(0)}}@media (prefers-reduced-motion:reduce){input[data-snap][data-v-15feed8b]{animation:1ms snap-hold-15feed8b}@keyframes snap-hold-15feed8b{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.44.0",
5
5
  "exports": {
6
6
  ".": {
7
7
  "types": "./dist/index.d.ts",
@@ -0,0 +1,472 @@
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
+ /**
101
+ * The dimension, or `null` for none. A `null` from outside -- another element
102
+ * selected, an undo -- resets the unit to the first of `units`; a field the
103
+ * user emptied keeps the unit picked in it. A value from outside brings its
104
+ * own unit.
105
+ */
106
+ const model = defineModel<EditorDimensionValue | null>({ default: null })
107
+
108
+ const copy = computed(() => ({ ...DEFAULT_MESSAGES, ...messages }))
109
+
110
+ /**
111
+ * The unit the field is in. Follows the model, but is kept here as well: an
112
+ * empty field has no model to carry it, and the unit picked there is the one
113
+ * the next typed value is written in.
114
+ */
115
+ const unit = ref<EditorDimensionUnit>(model.value?.unit ?? units[0])
116
+
117
+ /**
118
+ * Set while the field's own `null` is on its way through the model. Without
119
+ * it, the user emptying the field could not be told apart from a `null` from
120
+ * outside, and only the latter resets the unit.
121
+ */
122
+ let emptiedHere = false
123
+
124
+ watch(
125
+ () => model.value,
126
+ (next) => {
127
+ if (next) unit.value = next.unit
128
+ else if (!emptiedHere) unit.value = units[0]
129
+
130
+ emptiedHere = false
131
+ },
132
+ )
133
+
134
+ const unitText = (value: EditorDimensionUnit) => copy.value[UNIT_MESSAGE[value]]
135
+
136
+ const limitsOf = (value: EditorDimensionUnit) => ({
137
+ min: min?.[value] ?? EDITOR_DIMENSION_LIMITS[value].min,
138
+ max: max?.[value] ?? EDITOR_DIMENSION_LIMITS[value].max,
139
+ })
140
+
141
+ /** The range of the current unit, mirrored on the input's `min` / `max`. */
142
+ const limits = computed(() => limitsOf(unit.value))
143
+
144
+ const clamp = (value: number, to: EditorDimensionUnit) => {
145
+ const limits = limitsOf(to)
146
+
147
+ return Math.min(Math.max(Math.round(value), limits.min), limits.max)
148
+ }
149
+
150
+ const shown = () => (model.value === null ? '' : String(model.value.value))
151
+
152
+ const inputEl = useTemplateRef<HTMLInputElement>('input')
153
+
154
+ /**
155
+ * Text the number input could not parse, such as "12a". The input reports an
156
+ * empty `value` for it, so without this check a typo in an `optional` field
157
+ * would be committed as none.
158
+ */
159
+ const isTypo = () => inputEl.value?.validity.badInput === true
160
+
161
+ /**
162
+ * Shows the model again. The element is written as well: for unparseable text
163
+ * the input's `value` is already empty, so when the model is empty too, the
164
+ * binding sees no change and would leave the typed text standing.
165
+ */
166
+ const revert = () => {
167
+ draft.value = shown()
168
+ if (inputEl.value) inputEl.value.value = draft.value
169
+ }
170
+
171
+ /**
172
+ * What the input shows while it is being edited. It is deliberately not the
173
+ * model: a half-typed "1" on the way to "180" would be clamped up to the
174
+ * minimum under the caret, so nothing leaves the field until it is confirmed.
175
+ */
176
+ const draft = ref(shown())
177
+
178
+ /** Direction of the one-shot snap animation, cleared once it has played. */
179
+ const snapped = ref<'up' | 'down'>()
180
+
181
+ // The model stays the source of truth: a change from outside -- an undo, a
182
+ // clamp, another element selected -- replaces the draft.
183
+ watch(
184
+ () => model.value?.value ?? null,
185
+ () => {
186
+ draft.value = shown()
187
+ },
188
+ )
189
+
190
+ /** Arrow steps written since the last `commit`, waiting for the key to be let go. */
191
+ let stepping = false
192
+
193
+ /** Writes the model, and reports whether that changed anything. */
194
+ const write = (next: EditorDimensionValue | null) => {
195
+ const current = model.value
196
+ if (
197
+ next === null ? current === null : next.value === current?.value && next.unit === current.unit
198
+ )
199
+ return false
200
+
201
+ emptiedHere = next === null
202
+ model.value = next
203
+ return true
204
+ }
205
+
206
+ const finish = () => {
207
+ stepping = false
208
+ emit('commit')
209
+ }
210
+
211
+ const selectAll = () => {
212
+ inputEl.value?.focus()
213
+ inputEl.value?.select()
214
+ }
215
+
216
+ defineExpose({
217
+ /**
218
+ * Moves focus into the input and selects its value, so typing replaces it.
219
+ * Pass `{ preventScroll: true }` while the field is still being revealed in a
220
+ * clipping box, which the browser would otherwise scroll to bring it in view.
221
+ */
222
+ focus: (options?: FocusOptions) => {
223
+ inputEl.value?.focus(options)
224
+ inputEl.value?.select()
225
+ },
226
+ })
227
+
228
+ /**
229
+ * Pointer-down anywhere in the field lands in the input with its value
230
+ * selected, the same as the padding fields -- except on the unit selector,
231
+ * which has to open.
232
+ */
233
+ const onPointerDown = (event: PointerEvent) => {
234
+ if (event.pointerType !== 'mouse') return
235
+ if ((event.target as Element).closest('select')) return
236
+ if (document.activeElement === inputEl.value) return
237
+
238
+ event.preventDefault()
239
+ selectAll()
240
+ }
241
+
242
+ const commit = async () => {
243
+ const typed = draft.value.trim()
244
+ const typo = isTypo()
245
+ let changed = false
246
+
247
+ if (typo) {
248
+ // Left alone: the old value comes back below.
249
+ } else if (typed === '') {
250
+ if (optional) changed = write(null)
251
+ } else if (DIGITS.test(typed)) {
252
+ changed = write({ value: clamp(Number(typed), unit.value), unit: unit.value })
253
+ }
254
+
255
+ // A blur in the middle of an arrow stream ends that stream as well.
256
+ if (changed || stepping) finish()
257
+
258
+ // Whatever was made of it: clamped, emptied, or the old value back for text
259
+ // that is no number -- and for an emptied field that may not be empty.
260
+ await nextTick()
261
+ revert()
262
+
263
+ if (!DIGITS.test(typed) || model.value === null || Number(typed) === model.value.value) return
264
+
265
+ // Restart the one-shot, or two clamps the same way would only animate once.
266
+ snapped.value = undefined
267
+ await nextTick()
268
+ snapped.value = model.value.value > Number(typed) ? 'up' : 'down'
269
+ }
270
+
271
+ /** From none, the first step lands on the minimum, whichever way it goes. */
272
+ const step = async (direction: 1 | -1, size: number) => {
273
+ const current = model.value
274
+ const next =
275
+ current === null
276
+ ? limitsOf(unit.value).min
277
+ : clamp(current.value + direction * size, current.unit)
278
+
279
+ if (write({ value: next, unit: current?.unit ?? unit.value })) stepping = true
280
+
281
+ await nextTick()
282
+ draft.value = shown()
283
+ }
284
+
285
+ const onKeydown = async (event: KeyboardEvent) => {
286
+ if (event.key === 'Enter') {
287
+ event.preventDefault()
288
+ await commit()
289
+
290
+ return selectAll()
291
+ }
292
+
293
+ if (event.key === 'Escape') {
294
+ // Only swallowed when there is typing to undo. With nothing to revert,
295
+ // Escape belongs to whatever popover or panel the field sits in.
296
+ if (draft.value === shown() && !isTypo()) return
297
+
298
+ event.stopPropagation()
299
+ revert()
300
+
301
+ return selectAll()
302
+ }
303
+
304
+ if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
305
+ event.preventDefault()
306
+
307
+ return step(event.key === 'ArrowUp' ? 1 : -1, event.shiftKey ? 10 : 1)
308
+ }
309
+ }
310
+
311
+ // A held arrow key repeats its keydown but lets go only once, so the keyup is
312
+ // where a stream of steps ends -- the keyboard's counterpart of a slider's
313
+ // pointerup.
314
+ const onKeyup = (event: KeyboardEvent) => {
315
+ if ((event.key === 'ArrowUp' || event.key === 'ArrowDown') && stepping) finish()
316
+ }
317
+
318
+ /**
319
+ * Whether the unit selector was last reached with a pointer. Chromium counts a
320
+ * clicked `<select>` as `:focus-visible`, so without this the keyboard focus
321
+ * style would stay on after a unit is picked with the mouse. A key on the
322
+ * selector, or leaving it, hands the style back to the keyboard.
323
+ */
324
+ const unitByPointer = ref(false)
325
+
326
+ /**
327
+ * A pointer went down on the selector in the current task. A click focuses in
328
+ * the same task as its pointerdown; Safari and Firefox on macOS do not focus a
329
+ * clicked select at all, so a focus after this has passed came from the keyboard.
330
+ */
331
+ let pointerPending = false
332
+
333
+ const onUnitPointerDown = () => {
334
+ unitByPointer.value = true
335
+ pointerPending = true
336
+ setTimeout(() => (pointerPending = false))
337
+ }
338
+
339
+ const onUnitFocus = () => {
340
+ unitByPointer.value = pointerPending
341
+ }
342
+
343
+ /** A value already there is clamped into the new unit's range; none stays none. */
344
+ const onUnit = (event: Event) => {
345
+ const next = (event.target as HTMLSelectElement).value as EditorDimensionUnit
346
+ unit.value = next
347
+
348
+ if (model.value === null) return
349
+ if (write({ value: clamp(model.value.value, next), unit: next })) finish()
350
+ }
351
+ </script>
352
+
353
+ <template>
354
+ <!--
355
+ The look of the padding fields: NexxtInputField forces `min-w-60` on its
356
+ input, which does not fit two to a row in an editor sidebar. Focus in the
357
+ field -- the input or the unit selector -- outranks hover: the hover ring
358
+ only applies while nothing inside has focus.
359
+ -->
360
+ <div
361
+ 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"
362
+ @pointerdown="onPointerDown"
363
+ >
364
+ <!--
365
+ A number input reports an empty `value` for anything it cannot parse, so
366
+ "12a" would arrive as an emptied field; `isTypo` tells the two apart. The
367
+ native spinners are hidden, as in NexxtInputField: the arrow keys step
368
+ the value, and the padding fields have none either.
369
+ -->
370
+ <input
371
+ ref="input"
372
+ type="number"
373
+ inputmode="numeric"
374
+ step="1"
375
+ :min="limits.min"
376
+ :max="limits.max"
377
+ autocomplete="off"
378
+ :placeholder="optional ? copy.placeholder : undefined"
379
+ :aria-label="`${label} (${unitText(unit)})`"
380
+ :value="draft"
381
+ :data-snap="snapped"
382
+ 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"
383
+ @input="draft = ($event.target as HTMLInputElement).value"
384
+ @focus="selectAll"
385
+ @blur="commit"
386
+ @keydown="onKeydown"
387
+ @keyup="onKeyup"
388
+ @animationend="snapped = undefined"
389
+ />
390
+
391
+ <!--
392
+ The unit stays in sight on an empty field as well: it is the unit the next
393
+ typed value is written in, and with more than one it can be picked first.
394
+ -->
395
+ <span
396
+ v-if="units.length > 1"
397
+ data-testid="editor-dimension-unit"
398
+ class="relative flex shrink-0 items-center"
399
+ >
400
+ <!-- Native, so it opens the platform picker on touch and needs no popover. -->
401
+ <select
402
+ :value="unit"
403
+ :aria-label="copy.unit"
404
+ :data-pointer="unitByPointer || undefined"
405
+ 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"
406
+ @pointerdown="onUnitPointerDown"
407
+ @focus="onUnitFocus"
408
+ @keydown="unitByPointer = false"
409
+ @blur="unitByPointer = false"
410
+ @change="onUnit"
411
+ >
412
+ <option v-for="option in units" :key="option" :value="option">
413
+ {{ unitText(option) }}
414
+ </option>
415
+ </select>
416
+ <span class="pointer-events-none absolute right-0 text-xs text-gray-700" aria-hidden="true">
417
+ <NexxtIcon name="chevron-down" />
418
+ </span>
419
+ </span>
420
+
421
+ <span
422
+ v-else
423
+ data-testid="editor-dimension-unit"
424
+ class="shrink-0 small-normal text-gray-700 select-none"
425
+ aria-hidden="true"
426
+ >
427
+ {{ unitText(unit) }}
428
+ </span>
429
+ </div>
430
+ </template>
431
+
432
+ <style scoped>
433
+ /* A committed value that was clamped is replaced rather than edited, so it
434
+ arrives as its own number, travelling the way the value moved. */
435
+ input[data-snap] {
436
+ animation: snap-in 180ms var(--ease-out-strong);
437
+ }
438
+
439
+ input[data-snap='up'] {
440
+ --snap-from: 2px;
441
+ }
442
+
443
+ input[data-snap='down'] {
444
+ --snap-from: -2px;
445
+ }
446
+
447
+ @keyframes snap-in {
448
+ from {
449
+ opacity: 0;
450
+ transform: translateY(var(--snap-from));
451
+ }
452
+ to {
453
+ opacity: 1;
454
+ transform: translateY(0);
455
+ }
456
+ }
457
+
458
+ /* Not `animation: none`: that fires no `animationend`, so the attribute would
459
+ stay on and the next clamp the same way would have nothing to retrigger. */
460
+ @media (prefers-reduced-motion: reduce) {
461
+ input[data-snap] {
462
+ animation: snap-hold 1ms;
463
+ }
464
+
465
+ @keyframes snap-hold {
466
+ from,
467
+ to {
468
+ opacity: 1;
469
+ }
470
+ }
471
+ }
472
+ </style>
@@ -37,6 +37,12 @@ export interface EditorMaxWidthMessages {
37
37
  unit?: string
38
38
  }
39
39
 
40
+ /**
41
+ * Sets a width on Tailwind's `max-w-*` steps, stored as the class.
42
+ *
43
+ * @deprecated Use `NexxtEditorSizeLimit`: a free maximum in px or % that can
44
+ * be none.
45
+ */
40
46
  export interface NexxtEditorMaxWidthProps {
41
47
  /**
42
48
  * Header text of the settings block. Defaults to the Dutch