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