@nexxtmove/ui 1.27.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/components/editor/molecules/EditorPadding/EditorPadding.vue.d.ts +6 -0
- package/dist/components/editor/molecules/EditorPadding/_PaddingField.vue.d.ts +10 -8
- package/dist/index.js +444 -381
- package/dist/ui.css +1 -1
- package/package.json +1 -1
- package/src/components/editor/molecules/EditorPadding/EditorPadding.vue +74 -30
- package/src/components/editor/molecules/EditorPadding/_PaddingField.vue +208 -21
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
|
@@ -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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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 = [
|
|
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
|
-
/**
|
|
13
|
-
const
|
|
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
|
-
*
|
|
109
|
-
*
|
|
110
|
-
* value further than the
|
|
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 = (
|
|
113
|
-
const clamped = Math.min(Math.max(
|
|
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
|
-
//
|
|
202
|
-
// carries it
|
|
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
|
-
|
|
208
|
-
//
|
|
209
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
239
|
-
@
|
|
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="
|
|
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
|
-
:
|
|
304
|
-
@
|
|
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
|
-
/**
|
|
15
|
+
/** Name of the field; the unit is appended to it for the accessible name. */
|
|
9
16
|
label: string
|
|
10
|
-
/** Value as shown,
|
|
17
|
+
/** Value as shown, in the unit below. An empty string falls back to the `0` placeholder. */
|
|
11
18
|
value: string
|
|
12
|
-
/**
|
|
13
|
-
|
|
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
|
-
*
|
|
22
|
-
*
|
|
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
|
|
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
|
-
|
|
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-
|
|
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"
|
|
42
|
-
|
|
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
|
-
|
|
178
|
+
ref="input"
|
|
179
|
+
type="text"
|
|
46
180
|
inputmode="numeric"
|
|
47
|
-
|
|
48
|
-
:max="max"
|
|
181
|
+
autocomplete="off"
|
|
49
182
|
placeholder="0"
|
|
50
|
-
:aria-label="label"
|
|
51
|
-
:value="
|
|
52
|
-
|
|
53
|
-
|
|
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>
|