@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/components/editor/atoms/EditorDimensionField/EditorDimensionField.vue.d.ts +79 -0
- package/dist/components/editor/molecules/EditorSize/EditorSize.vue.d.ts +97 -0
- package/dist/components/editor/molecules/EditorSizeLimit/EditorSizeLimit.vue.d.ts +33 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +1257 -973
- package/dist/nuxt.js +3 -0
- package/dist/ui.css +1 -1
- package/package.json +1 -1
- package/src/components/editor/atoms/EditorDimensionField/EditorDimensionField.vue +456 -0
- package/src/components/editor/molecules/EditorSize/EditorSize.vue +254 -0
- package/src/components/editor/molecules/EditorSizeLimit/EditorSizeLimit.vue +64 -0
- package/src/components.json +3 -0
- package/src/index.ts +16 -0
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
|
@@ -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>
|