@nexxtmove/ui 1.51.0 → 1.53.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.
@@ -99,7 +99,8 @@ const MIDDLE = 4
99
99
  </script>
100
100
 
101
101
  <script lang="ts" setup>
102
- import { computed, useId, useTemplateRef } from 'vue'
102
+ import { computed, useId } from 'vue'
103
+ import { useRadioGrid } from '../../composables/useRadioGrid'
103
104
 
104
105
  defineOptions({ name: 'NexxtEditorAnchor', inheritAttrs: false })
105
106
 
@@ -118,8 +119,6 @@ const model = defineModel<EditorAnchorValue | null>({ required: true })
118
119
 
119
120
  const labelId = useId()
120
121
 
121
- const cellEls = useTemplateRef<HTMLButtonElement[]>('cellButtons')
122
-
123
122
  const copy = computed(() => ({ ...DEFAULT_MESSAGES, ...messages }))
124
123
 
125
124
  const selectedIndex = computed(() => {
@@ -129,8 +128,28 @@ const selectedIndex = computed(() => {
129
128
  return index === -1 ? null : index
130
129
  })
131
130
 
132
- /** Roving tabindex: one tab stop, on the chosen cell or else on the middle. */
133
- const tabStop = computed(() => selectedIndex.value ?? MIDDLE)
131
+ const choose = (index: number) => {
132
+ const cell = EDITOR_ANCHOR_OPTIONS[index]
133
+
134
+ if (disabled || !cell || index === selectedIndex.value) return
135
+
136
+ model.value = { x: cell.x, y: cell.y }
137
+ }
138
+
139
+ /**
140
+ * The radio pattern in two dimensions: an arrow moves and chooses at once and
141
+ * stops at the edge; Home and End jump to the first and the last cell. One tab
142
+ * stop, on the chosen cell or else on the middle. Modifier keys are left to the
143
+ * browser.
144
+ */
145
+ const { tabStop, cellRef, onKeydown } = useRadioGrid({
146
+ rows: ROWS,
147
+ columns: COLUMNS,
148
+ selected: selectedIndex,
149
+ preferred: () => MIDDLE,
150
+ homeEnd: 'grid',
151
+ onChoose: choose,
152
+ })
134
153
 
135
154
  const cells = computed(() =>
136
155
  EDITOR_ANCHOR_OPTIONS.map(({ x, y }, index) => {
@@ -155,41 +174,6 @@ const cells = computed(() =>
155
174
  const groupName = computed(() =>
156
175
  showLabel ? { 'aria-labelledby': labelId } : { 'aria-label': label },
157
176
  )
158
-
159
- const choose = (index: number) => {
160
- const cell = EDITOR_ANCHOR_OPTIONS[index]
161
-
162
- if (disabled || !cell || index === selectedIndex.value) return
163
-
164
- model.value = { x: cell.x, y: cell.y }
165
- }
166
-
167
- /**
168
- * The radio pattern in two dimensions: an arrow moves and chooses at once and
169
- * stops at the edge; Home and End jump to the first and the last cell. A key
170
- * with a modifier belongs to the browser or the OS (Alt+Left is back, Cmd+Left
171
- * is line start), so it is left alone: no choice, no preventDefault.
172
- */
173
- const onKeydown = (event: KeyboardEvent, index: number) => {
174
- if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
175
-
176
- const row = Math.floor(index / COLUMNS)
177
- const column = index % COLUMNS
178
- const next = {
179
- ArrowLeft: column > 0 ? index - 1 : index,
180
- ArrowRight: column < COLUMNS - 1 ? index + 1 : index,
181
- ArrowUp: row > 0 ? index - COLUMNS : index,
182
- ArrowDown: row < ROWS - 1 ? index + COLUMNS : index,
183
- Home: 0,
184
- End: EDITOR_ANCHOR_OPTIONS.length - 1,
185
- }[event.key]
186
-
187
- if (next === undefined) return
188
-
189
- event.preventDefault()
190
- choose(next)
191
- cellEls.value?.[next]?.focus()
192
- }
193
177
  </script>
194
178
 
195
179
  <template>
@@ -216,7 +200,7 @@ const onKeydown = (event: KeyboardEvent, index: number) => {
216
200
  <button
217
201
  v-for="cell in cells"
218
202
  :key="cell.index"
219
- ref="cellButtons"
203
+ :ref="cellRef(cell.index)"
220
204
  type="button"
221
205
  role="radio"
222
206
  :aria-checked="cell.checked"
@@ -0,0 +1,65 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`EditorAnchor markup > renders unchanged with a chosen cell 1`] = `
4
+ "<!--v-if-->
5
+ <!--
6
+ \`w-fit\`: the grid columns are fractions, so in block flow the ring would
7
+ stretch to the full width of the parent.
8
+ -->
9
+ <div role="radiogroup" aria-label="Plek van de afbeelding" aria-describedby="hint" class="grid w-fit grid-cols-3 gap-1 rounded-lg p-1 ring ring-gray-200">
10
+ <!--
11
+ No \`focus:outline-none\` next to the focus-visible outline: in Tailwind v4
12
+ it sets the outline style to none, which the focus-visible variant does
13
+ not set back, so the ring would never show. The outline is -600, not
14
+ -500: -500 reaches 2.78:1 on white, short of the 3:1 for non-text.
15
+ --><button type="button" role="radio" aria-checked="false" aria-label="Linksboven" tabindex="-1" class="flex size-7 items-center justify-center rounded-md outline-cornflower-blue-600 transition-colors duration-150 ease-out focus-visible:outline-2 motion-reduce:transition-none hover:bg-gray-100 cursor-pointer"><span aria-hidden="true" class="rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none size-1.5 bg-gray-400"></span></button><button type="button" role="radio" aria-checked="false" aria-label="Boven" tabindex="-1" class="flex size-7 items-center justify-center rounded-md outline-cornflower-blue-600 transition-colors duration-150 ease-out focus-visible:outline-2 motion-reduce:transition-none hover:bg-gray-100 cursor-pointer"><span aria-hidden="true" class="rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none size-1.5 bg-gray-400"></span></button><button type="button" role="radio" aria-checked="false" aria-label="Rechtsboven" tabindex="-1" class="flex size-7 items-center justify-center rounded-md outline-cornflower-blue-600 transition-colors duration-150 ease-out focus-visible:outline-2 motion-reduce:transition-none hover:bg-gray-100 cursor-pointer"><span aria-hidden="true" class="rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none size-1.5 bg-gray-400"></span></button><button type="button" role="radio" aria-checked="false" aria-label="Links" tabindex="-1" class="flex size-7 items-center justify-center rounded-md outline-cornflower-blue-600 transition-colors duration-150 ease-out focus-visible:outline-2 motion-reduce:transition-none hover:bg-gray-100 cursor-pointer"><span aria-hidden="true" class="rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none size-1.5 bg-gray-400"></span></button><button type="button" role="radio" aria-checked="false" aria-label="Midden" tabindex="-1" class="flex size-7 items-center justify-center rounded-md outline-cornflower-blue-600 transition-colors duration-150 ease-out focus-visible:outline-2 motion-reduce:transition-none hover:bg-gray-100 cursor-pointer"><span aria-hidden="true" class="rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none size-1.5 bg-gray-400"></span></button><button type="button" role="radio" aria-checked="false" aria-label="Rechts" tabindex="-1" class="flex size-7 items-center justify-center rounded-md outline-cornflower-blue-600 transition-colors duration-150 ease-out focus-visible:outline-2 motion-reduce:transition-none hover:bg-gray-100 cursor-pointer"><span aria-hidden="true" class="rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none size-1.5 bg-gray-400"></span></button><button type="button" role="radio" aria-checked="false" aria-label="Linksonder" tabindex="-1" class="flex size-7 items-center justify-center rounded-md outline-cornflower-blue-600 transition-colors duration-150 ease-out focus-visible:outline-2 motion-reduce:transition-none hover:bg-gray-100 cursor-pointer"><span aria-hidden="true" class="rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none size-1.5 bg-gray-400"></span></button><button type="button" role="radio" aria-checked="false" aria-label="Onder" tabindex="-1" class="flex size-7 items-center justify-center rounded-md outline-cornflower-blue-600 transition-colors duration-150 ease-out focus-visible:outline-2 motion-reduce:transition-none hover:bg-gray-100 cursor-pointer"><span aria-hidden="true" class="rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none size-1.5 bg-gray-400"></span></button><button type="button" role="radio" aria-checked="true" aria-label="Rechtsonder" tabindex="0" class="flex size-7 items-center justify-center rounded-md outline-cornflower-blue-600 transition-colors duration-150 ease-out focus-visible:outline-2 motion-reduce:transition-none bg-cornflower-blue-600 cursor-pointer"><span aria-hidden="true" class="rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none size-2.5 bg-white"></span></button>
16
+ </div>"
17
+ `;
18
+
19
+ exports[`EditorAnchor markup > renders unchanged with a visible label 1`] = `
20
+ "<p id="v-0" class="mb-1.5 small-normal text-gray-900">Plek van de afbeelding</p>
21
+ <!--
22
+ \`w-fit\`: the grid columns are fractions, so in block flow the ring would
23
+ stretch to the full width of the parent.
24
+ -->
25
+ <div role="radiogroup" aria-labelledby="v-0" aria-describedby="hint" class="grid w-fit grid-cols-3 gap-1 rounded-lg p-1 ring ring-gray-200">
26
+ <!--
27
+ No \`focus:outline-none\` next to the focus-visible outline: in Tailwind v4
28
+ it sets the outline style to none, which the focus-visible variant does
29
+ not set back, so the ring would never show. The outline is -600, not
30
+ -500: -500 reaches 2.78:1 on white, short of the 3:1 for non-text.
31
+ --><button type="button" role="radio" aria-checked="false" aria-label="Linksboven" tabindex="-1" class="flex size-7 items-center justify-center rounded-md outline-cornflower-blue-600 transition-colors duration-150 ease-out focus-visible:outline-2 motion-reduce:transition-none hover:bg-gray-100 cursor-pointer"><span aria-hidden="true" class="rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none size-1.5 bg-gray-400"></span></button><button type="button" role="radio" aria-checked="false" aria-label="Boven" tabindex="-1" class="flex size-7 items-center justify-center rounded-md outline-cornflower-blue-600 transition-colors duration-150 ease-out focus-visible:outline-2 motion-reduce:transition-none hover:bg-gray-100 cursor-pointer"><span aria-hidden="true" class="rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none size-1.5 bg-gray-400"></span></button><button type="button" role="radio" aria-checked="false" aria-label="Rechtsboven" tabindex="-1" class="flex size-7 items-center justify-center rounded-md outline-cornflower-blue-600 transition-colors duration-150 ease-out focus-visible:outline-2 motion-reduce:transition-none hover:bg-gray-100 cursor-pointer"><span aria-hidden="true" class="rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none size-1.5 bg-gray-400"></span></button><button type="button" role="radio" aria-checked="false" aria-label="Links" tabindex="-1" class="flex size-7 items-center justify-center rounded-md outline-cornflower-blue-600 transition-colors duration-150 ease-out focus-visible:outline-2 motion-reduce:transition-none hover:bg-gray-100 cursor-pointer"><span aria-hidden="true" class="rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none size-1.5 bg-gray-400"></span></button><button type="button" role="radio" aria-checked="true" aria-label="Midden" tabindex="0" class="flex size-7 items-center justify-center rounded-md outline-cornflower-blue-600 transition-colors duration-150 ease-out focus-visible:outline-2 motion-reduce:transition-none bg-cornflower-blue-600 cursor-pointer"><span aria-hidden="true" class="rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none size-2.5 bg-white"></span></button><button type="button" role="radio" aria-checked="false" aria-label="Rechts" tabindex="-1" class="flex size-7 items-center justify-center rounded-md outline-cornflower-blue-600 transition-colors duration-150 ease-out focus-visible:outline-2 motion-reduce:transition-none hover:bg-gray-100 cursor-pointer"><span aria-hidden="true" class="rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none size-1.5 bg-gray-400"></span></button><button type="button" role="radio" aria-checked="false" aria-label="Linksonder" tabindex="-1" class="flex size-7 items-center justify-center rounded-md outline-cornflower-blue-600 transition-colors duration-150 ease-out focus-visible:outline-2 motion-reduce:transition-none hover:bg-gray-100 cursor-pointer"><span aria-hidden="true" class="rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none size-1.5 bg-gray-400"></span></button><button type="button" role="radio" aria-checked="false" aria-label="Onder" tabindex="-1" class="flex size-7 items-center justify-center rounded-md outline-cornflower-blue-600 transition-colors duration-150 ease-out focus-visible:outline-2 motion-reduce:transition-none hover:bg-gray-100 cursor-pointer"><span aria-hidden="true" class="rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none size-1.5 bg-gray-400"></span></button><button type="button" role="radio" aria-checked="false" aria-label="Rechtsonder" tabindex="-1" class="flex size-7 items-center justify-center rounded-md outline-cornflower-blue-600 transition-colors duration-150 ease-out focus-visible:outline-2 motion-reduce:transition-none hover:bg-gray-100 cursor-pointer"><span aria-hidden="true" class="rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none size-1.5 bg-gray-400"></span></button>
32
+ </div>"
33
+ `;
34
+
35
+ exports[`EditorAnchor markup > renders unchanged with disabled 1`] = `
36
+ "<!--v-if-->
37
+ <!--
38
+ \`w-fit\`: the grid columns are fractions, so in block flow the ring would
39
+ stretch to the full width of the parent.
40
+ -->
41
+ <div role="radiogroup" aria-label="Plek van de afbeelding" aria-describedby="hint" aria-disabled="true" class="grid w-fit grid-cols-3 gap-1 rounded-lg p-1 ring ring-gray-200 opacity-50">
42
+ <!--
43
+ No \`focus:outline-none\` next to the focus-visible outline: in Tailwind v4
44
+ it sets the outline style to none, which the focus-visible variant does
45
+ not set back, so the ring would never show. The outline is -600, not
46
+ -500: -500 reaches 2.78:1 on white, short of the 3:1 for non-text.
47
+ --><button type="button" role="radio" aria-checked="true" aria-label="Linksboven" tabindex="0" disabled="" class="flex size-7 items-center justify-center rounded-md outline-cornflower-blue-600 transition-colors duration-150 ease-out focus-visible:outline-2 motion-reduce:transition-none bg-cornflower-blue-600 cursor-not-allowed"><span aria-hidden="true" class="rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none size-2.5 bg-white"></span></button><button type="button" role="radio" aria-checked="false" aria-label="Boven" tabindex="-1" disabled="" class="flex size-7 items-center justify-center rounded-md outline-cornflower-blue-600 transition-colors duration-150 ease-out focus-visible:outline-2 motion-reduce:transition-none cursor-not-allowed"><span aria-hidden="true" class="rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none size-1.5 bg-gray-400"></span></button><button type="button" role="radio" aria-checked="false" aria-label="Rechtsboven" tabindex="-1" disabled="" class="flex size-7 items-center justify-center rounded-md outline-cornflower-blue-600 transition-colors duration-150 ease-out focus-visible:outline-2 motion-reduce:transition-none cursor-not-allowed"><span aria-hidden="true" class="rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none size-1.5 bg-gray-400"></span></button><button type="button" role="radio" aria-checked="false" aria-label="Links" tabindex="-1" disabled="" class="flex size-7 items-center justify-center rounded-md outline-cornflower-blue-600 transition-colors duration-150 ease-out focus-visible:outline-2 motion-reduce:transition-none cursor-not-allowed"><span aria-hidden="true" class="rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none size-1.5 bg-gray-400"></span></button><button type="button" role="radio" aria-checked="false" aria-label="Midden" tabindex="-1" disabled="" class="flex size-7 items-center justify-center rounded-md outline-cornflower-blue-600 transition-colors duration-150 ease-out focus-visible:outline-2 motion-reduce:transition-none cursor-not-allowed"><span aria-hidden="true" class="rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none size-1.5 bg-gray-400"></span></button><button type="button" role="radio" aria-checked="false" aria-label="Rechts" tabindex="-1" disabled="" class="flex size-7 items-center justify-center rounded-md outline-cornflower-blue-600 transition-colors duration-150 ease-out focus-visible:outline-2 motion-reduce:transition-none cursor-not-allowed"><span aria-hidden="true" class="rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none size-1.5 bg-gray-400"></span></button><button type="button" role="radio" aria-checked="false" aria-label="Linksonder" tabindex="-1" disabled="" class="flex size-7 items-center justify-center rounded-md outline-cornflower-blue-600 transition-colors duration-150 ease-out focus-visible:outline-2 motion-reduce:transition-none cursor-not-allowed"><span aria-hidden="true" class="rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none size-1.5 bg-gray-400"></span></button><button type="button" role="radio" aria-checked="false" aria-label="Onder" tabindex="-1" disabled="" class="flex size-7 items-center justify-center rounded-md outline-cornflower-blue-600 transition-colors duration-150 ease-out focus-visible:outline-2 motion-reduce:transition-none cursor-not-allowed"><span aria-hidden="true" class="rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none size-1.5 bg-gray-400"></span></button><button type="button" role="radio" aria-checked="false" aria-label="Rechtsonder" tabindex="-1" disabled="" class="flex size-7 items-center justify-center rounded-md outline-cornflower-blue-600 transition-colors duration-150 ease-out focus-visible:outline-2 motion-reduce:transition-none cursor-not-allowed"><span aria-hidden="true" class="rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none size-1.5 bg-gray-400"></span></button>
48
+ </div>"
49
+ `;
50
+
51
+ exports[`EditorAnchor markup > renders unchanged with no choice 1`] = `
52
+ "<!--v-if-->
53
+ <!--
54
+ \`w-fit\`: the grid columns are fractions, so in block flow the ring would
55
+ stretch to the full width of the parent.
56
+ -->
57
+ <div role="radiogroup" aria-label="Plek van de afbeelding" aria-describedby="hint" class="grid w-fit grid-cols-3 gap-1 rounded-lg p-1 ring ring-gray-200">
58
+ <!--
59
+ No \`focus:outline-none\` next to the focus-visible outline: in Tailwind v4
60
+ it sets the outline style to none, which the focus-visible variant does
61
+ not set back, so the ring would never show. The outline is -600, not
62
+ -500: -500 reaches 2.78:1 on white, short of the 3:1 for non-text.
63
+ --><button type="button" role="radio" aria-checked="false" aria-label="Linksboven" tabindex="-1" class="flex size-7 items-center justify-center rounded-md outline-cornflower-blue-600 transition-colors duration-150 ease-out focus-visible:outline-2 motion-reduce:transition-none hover:bg-gray-100 cursor-pointer"><span aria-hidden="true" class="rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none size-1.5 bg-gray-400"></span></button><button type="button" role="radio" aria-checked="false" aria-label="Boven" tabindex="-1" class="flex size-7 items-center justify-center rounded-md outline-cornflower-blue-600 transition-colors duration-150 ease-out focus-visible:outline-2 motion-reduce:transition-none hover:bg-gray-100 cursor-pointer"><span aria-hidden="true" class="rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none size-1.5 bg-gray-400"></span></button><button type="button" role="radio" aria-checked="false" aria-label="Rechtsboven" tabindex="-1" class="flex size-7 items-center justify-center rounded-md outline-cornflower-blue-600 transition-colors duration-150 ease-out focus-visible:outline-2 motion-reduce:transition-none hover:bg-gray-100 cursor-pointer"><span aria-hidden="true" class="rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none size-1.5 bg-gray-400"></span></button><button type="button" role="radio" aria-checked="false" aria-label="Links" tabindex="-1" class="flex size-7 items-center justify-center rounded-md outline-cornflower-blue-600 transition-colors duration-150 ease-out focus-visible:outline-2 motion-reduce:transition-none hover:bg-gray-100 cursor-pointer"><span aria-hidden="true" class="rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none size-1.5 bg-gray-400"></span></button><button type="button" role="radio" aria-checked="false" aria-label="Midden" tabindex="0" class="flex size-7 items-center justify-center rounded-md outline-cornflower-blue-600 transition-colors duration-150 ease-out focus-visible:outline-2 motion-reduce:transition-none hover:bg-gray-100 cursor-pointer"><span aria-hidden="true" class="rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none size-1.5 bg-gray-400"></span></button><button type="button" role="radio" aria-checked="false" aria-label="Rechts" tabindex="-1" class="flex size-7 items-center justify-center rounded-md outline-cornflower-blue-600 transition-colors duration-150 ease-out focus-visible:outline-2 motion-reduce:transition-none hover:bg-gray-100 cursor-pointer"><span aria-hidden="true" class="rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none size-1.5 bg-gray-400"></span></button><button type="button" role="radio" aria-checked="false" aria-label="Linksonder" tabindex="-1" class="flex size-7 items-center justify-center rounded-md outline-cornflower-blue-600 transition-colors duration-150 ease-out focus-visible:outline-2 motion-reduce:transition-none hover:bg-gray-100 cursor-pointer"><span aria-hidden="true" class="rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none size-1.5 bg-gray-400"></span></button><button type="button" role="radio" aria-checked="false" aria-label="Onder" tabindex="-1" class="flex size-7 items-center justify-center rounded-md outline-cornflower-blue-600 transition-colors duration-150 ease-out focus-visible:outline-2 motion-reduce:transition-none hover:bg-gray-100 cursor-pointer"><span aria-hidden="true" class="rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none size-1.5 bg-gray-400"></span></button><button type="button" role="radio" aria-checked="false" aria-label="Rechtsonder" tabindex="-1" class="flex size-7 items-center justify-center rounded-md outline-cornflower-blue-600 transition-colors duration-150 ease-out focus-visible:outline-2 motion-reduce:transition-none hover:bg-gray-100 cursor-pointer"><span aria-hidden="true" class="rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none size-1.5 bg-gray-400"></span></button>
64
+ </div>"
65
+ `;
@@ -33,10 +33,25 @@ export const EDITOR_GAP_STEPS = [
33
33
  */
34
34
  export type EditorGapValue = `gap-${(typeof EDITOR_GAP_STEPS)[number]['token']}`
35
35
 
36
- /** Overridable copy of the value readout. Every key is optional. */
36
+ /** Overridable copy. Every key is optional. */
37
37
  export interface EditorGapMessages {
38
38
  /** Unit shown behind the value. Defaults to `'px'`. */
39
39
  unit?: string
40
+ /** The fixed option, with the `auto` model bound. Defaults to `'Vast'`. */
41
+ fixed?: string
42
+ /** The auto option, with the `auto` model bound. Defaults to `'Auto'`. */
43
+ auto?: string
44
+ /**
45
+ * Name of the slider while Auto is on, when the gap is a minimum rather than
46
+ * the gap itself. Defaults to `'Tussenruimte, minimaal'`; on Vast the slider
47
+ * is named by `label`.
48
+ */
49
+ minimumName?: string
50
+ /**
51
+ * Name of the Vast | Auto toolbar, so it is not named like the slider.
52
+ * Defaults to `'Soort tussenruimte'`.
53
+ */
54
+ modeName?: string
40
55
  }
41
56
 
42
57
  export interface NexxtEditorGapProps {
@@ -55,6 +70,10 @@ export interface NexxtEditorGapProps {
55
70
 
56
71
  const DEFAULT_MESSAGES = {
57
72
  unit: 'px',
73
+ fixed: 'Vast',
74
+ auto: 'Auto',
75
+ minimumName: 'Tussenruimte, minimaal',
76
+ modeName: 'Soort tussenruimte',
58
77
  } satisfies Required<EditorGapMessages>
59
78
  </script>
60
79
 
@@ -62,6 +81,7 @@ const DEFAULT_MESSAGES = {
62
81
  import { computed } from 'vue'
63
82
  import NexxtEditorSettingsBlock from '../../atoms/EditorSettingsBlock/EditorSettingsBlock.vue'
64
83
  import NexxtSlider from '../../../atoms/Slider/Slider.vue'
84
+ import NexxtToggleGroup from '../../../molecules/ToggleGroup/ToggleGroup.vue'
65
85
 
66
86
  defineOptions({ name: 'NexxtEditorGap' })
67
87
 
@@ -70,6 +90,38 @@ const { label = 'Tussenruimte', messages } = defineProps<NexxtEditorGapProps>()
70
90
  /** The selected gap, as the Tailwind class to apply. */
71
91
  const model = defineModel<EditorGapValue>({ required: true })
72
92
 
93
+ /**
94
+ * Optional: whether the main axis distributes the space (Auto). Only when it
95
+ * is bound does the block offer Vast | Auto above the slider; the slider then
96
+ * sets the minimum gap. The component writes nothing else for Auto, and the
97
+ * gap model does not change when it is switched.
98
+ */
99
+ // `default: undefined` on purpose: a boolean prop without a default is cast to
100
+ // `false` when it is absent, and then an unbound model could not be told apart
101
+ // from Auto switched off.
102
+ const auto = defineModel<boolean>('auto', { default: undefined })
103
+
104
+ /**
105
+ * Bound by the consumer: an unbound model stays `undefined`. The template
106
+ * renders two whole branches rather than `v-if`s inside one, so without it the
107
+ * markup is exactly what it always was, without even an empty comment node.
108
+ */
109
+ const offersAuto = computed(() => auto.value !== undefined)
110
+
111
+ const autoOptions = computed(() => [
112
+ { value: 'fixed', label: copy.value.fixed },
113
+ { value: 'auto', label: copy.value.auto },
114
+ ])
115
+
116
+ const autoChoice = computed({
117
+ get: () => (auto.value ? 'auto' : 'fixed'),
118
+ set: (value: string) => {
119
+ auto.value = value === 'auto'
120
+ },
121
+ })
122
+
123
+ const sliderName = computed(() => (auto.value ? copy.value.minimumName : label))
124
+
73
125
  const copy = computed(() => ({ ...DEFAULT_MESSAGES, ...messages }))
74
126
 
75
127
  const maxIndex = EDITOR_GAP_STEPS.length - 1
@@ -101,7 +153,29 @@ const displayValue = computed(() => `${EDITOR_GAP_STEPS[stepIndex.value].px} ${c
101
153
 
102
154
  <template>
103
155
  <NexxtEditorSettingsBlock :label="label">
104
- <div class="flex items-center gap-4">
156
+ <div v-if="offersAuto" class="flex flex-col gap-3">
157
+ <NexxtToggleGroup v-model="autoChoice" :options="autoOptions" :aria-label="copy.modeName" />
158
+
159
+ <!--
160
+ The slider and readout exactly as below, see the notes there, so they
161
+ keep their width and place on Vast, on Auto and without the model. Only
162
+ the slider's name changes with Auto.
163
+ -->
164
+ <div class="flex items-center gap-4">
165
+ <NexxtSlider
166
+ v-model="sliderValue"
167
+ :min="0"
168
+ :max="maxIndex"
169
+ :step="1"
170
+ :aria-label="sliderName"
171
+ :aria-valuetext="displayValue"
172
+ />
173
+ <span class="min-w-12 shrink-0 text-right small-normal whitespace-nowrap text-gray-700">
174
+ {{ displayValue }}
175
+ </span>
176
+ </div>
177
+ </div>
178
+ <div v-else class="flex items-center gap-4">
105
179
  <!--
106
180
  Slider's own `showValue` prints its model, which here is the step index
107
181
  rather than the gap, so the readout is rendered next to it instead. The
@@ -0,0 +1,103 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`EditorGap markup without auto > renders unchanged with a value on the scale 1`] = `
4
+ "<div class="py-4">
5
+ <!--
6
+ Switch.vue sets \`inheritAttrs: false\` and binds \`$attrs\` on its sr-only
7
+ input, so a class on <Switch> never reaches its root <label>. The child
8
+ variant below styles that label from the header instead, which keeps the
9
+ whole row clickable and the accessible name on the real <label>. The gap
10
+ has to go on that label too: the header has a single child in both modes,
11
+ so its own \`gap\` would never apply and the spacing would silently fall
12
+ back to the \`gap-2\` that Switch.vue sets for itself.
13
+ -->
14
+ <div class="flex items-center [&amp;>label]:flex [&amp;>label]:w-full [&amp;>label]:gap-3">
15
+ <p class="small-normal text-gray-900">Tussenruimte</p>
16
+ </div>
17
+ <div class="grid grid-rows-[1fr]">
18
+ <!--
19
+ \`overflow-hidden\` is what makes the 0fr collapse clip, but it also clips
20
+ a focus ring on a full-width control in the slot, and the block has no
21
+ horizontal padding of its own. \`-mx-1\` widens the clipping box by 4px on
22
+ each side and the \`px-1\` below pushes the content back, so the ring has
23
+ room while the content keeps the same x position as the label.
24
+ -->
25
+ <div class="-mx-1 min-h-0 overflow-hidden opacity-100">
26
+ <!-- Padding sits on the inner element so nothing is left over at 0fr. -->
27
+ <div class="px-1 pt-1.5 pb-1">
28
+ <div class="flex items-center gap-4">
29
+ <!--
30
+ Slider's own \`showValue\` prints its model, which here is the step index
31
+ rather than the gap, so the readout is rendered next to it instead. The
32
+ thumb has the same problem in \`aria-valuenow\`, which \`aria-valuetext\`
33
+ overrides with the string the readout shows. The block label is a <p>,
34
+ not a <label>, so the name goes on the thumb explicitly.
35
+ -->
36
+ <div class="flex w-full flex-col gap-2">
37
+ <!--v-if-->
38
+ <div class="flex items-center gap-4"><span data-slider-impl="" dir="ltr" data-orientation="horizontal" style="--reka-slider-thumb-transform: translateX(-50%);" class="relative flex w-full touch-none items-center select-none cursor-pointer" aria-disabled="false"><span data-orientation="horizontal" class="relative w-full grow rounded-full bg-gray-200 h-1"><span data-orientation="horizontal" style="left: 0%; right: 57.14285714285714%;" class="absolute h-full rounded-full bg-cornflower-blue-500"></span></span><span data-reka-collection-item="" aria-label="Tussenruimte" aria-valuetext="24 px" class="block size-4 rounded-full border shadow-sm transition-colors border-gray-200 bg-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cornflower-blue-500" role="slider" tabindex="0" data-orientation="horizontal" aria-valuemin="0" aria-valuemax="14" aria-orientation="horizontal" style="transform: var(--reka-slider-thumb-transform); position: absolute; left: calc(0% + 0px); display: none;"></span>
39
+ <!--v-if--></span>
40
+ <!--v-if-->
41
+ </div>
42
+ </div>
43
+ <!--
44
+ gray-700, not the gray-500 Slider uses for its own readout: axe puts
45
+ gray-500 on white at 1.84:1, well under the 4.5:1 it needs at this size.
46
+ --><span class="min-w-12 shrink-0 text-right small-normal whitespace-nowrap text-gray-700">24 px</span>
47
+ </div>
48
+ </div>
49
+ </div>
50
+ </div>
51
+ </div>"
52
+ `;
53
+
54
+ exports[`EditorGap markup without auto > renders unchanged with a value outside the scale 1`] = `
55
+ "<div class="py-4">
56
+ <!--
57
+ Switch.vue sets \`inheritAttrs: false\` and binds \`$attrs\` on its sr-only
58
+ input, so a class on <Switch> never reaches its root <label>. The child
59
+ variant below styles that label from the header instead, which keeps the
60
+ whole row clickable and the accessible name on the real <label>. The gap
61
+ has to go on that label too: the header has a single child in both modes,
62
+ so its own \`gap\` would never apply and the spacing would silently fall
63
+ back to the \`gap-2\` that Switch.vue sets for itself.
64
+ -->
65
+ <div class="flex items-center [&amp;>label]:flex [&amp;>label]:w-full [&amp;>label]:gap-3">
66
+ <p class="small-normal text-gray-900">Tussenruimte</p>
67
+ </div>
68
+ <div class="grid grid-rows-[1fr]">
69
+ <!--
70
+ \`overflow-hidden\` is what makes the 0fr collapse clip, but it also clips
71
+ a focus ring on a full-width control in the slot, and the block has no
72
+ horizontal padding of its own. \`-mx-1\` widens the clipping box by 4px on
73
+ each side and the \`px-1\` below pushes the content back, so the ring has
74
+ room while the content keeps the same x position as the label.
75
+ -->
76
+ <div class="-mx-1 min-h-0 overflow-hidden opacity-100">
77
+ <!-- Padding sits on the inner element so nothing is left over at 0fr. -->
78
+ <div class="px-1 pt-1.5 pb-1">
79
+ <div class="flex items-center gap-4">
80
+ <!--
81
+ Slider's own \`showValue\` prints its model, which here is the step index
82
+ rather than the gap, so the readout is rendered next to it instead. The
83
+ thumb has the same problem in \`aria-valuenow\`, which \`aria-valuetext\`
84
+ overrides with the string the readout shows. The block label is a <p>,
85
+ not a <label>, so the name goes on the thumb explicitly.
86
+ -->
87
+ <div class="flex w-full flex-col gap-2">
88
+ <!--v-if-->
89
+ <div class="flex items-center gap-4"><span data-slider-impl="" dir="ltr" data-orientation="horizontal" style="--reka-slider-thumb-transform: translateX(-50%);" class="relative flex w-full touch-none items-center select-none cursor-pointer" aria-disabled="false"><span data-orientation="horizontal" class="relative w-full grow rounded-full bg-gray-200 h-1"><span data-orientation="horizontal" style="left: 0%; right: 100%;" class="absolute h-full rounded-full bg-cornflower-blue-500"></span></span><span data-reka-collection-item="" aria-label="Tussenruimte" aria-valuetext="0 px" class="block size-4 rounded-full border shadow-sm transition-colors border-gray-200 bg-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cornflower-blue-500" role="slider" tabindex="0" data-orientation="horizontal" aria-valuemin="0" aria-valuemax="14" aria-orientation="horizontal" style="transform: var(--reka-slider-thumb-transform); position: absolute; left: calc(0% + 0px); display: none;"></span>
90
+ <!--v-if--></span>
91
+ <!--v-if-->
92
+ </div>
93
+ </div>
94
+ <!--
95
+ gray-700, not the gray-500 Slider uses for its own readout: axe puts
96
+ gray-500 on white at 1.84:1, well under the 4.5:1 it needs at this size.
97
+ --><span class="min-w-12 shrink-0 text-right small-normal whitespace-nowrap text-gray-700">0 px</span>
98
+ </div>
99
+ </div>
100
+ </div>
101
+ </div>
102
+ </div>"
103
+ `;
@@ -0,0 +1,213 @@
1
+ <script lang="ts">
2
+ export interface NexxtEditorOverrideStatusProps {
3
+ /** Whether the setting differs from where its value comes from without an override. */
4
+ overridden: boolean
5
+ /**
6
+ * The status while the setting is not overridden, such as `'Volgt huisstijl'`
7
+ * or `'Grootte 100% (origineel)'`. With `hideWhenDefault` it is not shown,
8
+ * but it is still announced when a reset brings the setting back.
9
+ */
10
+ label: string
11
+ /**
12
+ * The status while the setting is overridden, shown next to a dot. Defaults
13
+ * to the Dutch `'Aangepast'`; a value such as `'Grootte 150%'` works too.
14
+ */
15
+ overriddenLabel?: string
16
+ /**
17
+ * Text of the reset button, shown only while overridden, such as
18
+ * `'Terug naar huisstijl'`.
19
+ */
20
+ resetLabel: string
21
+ /** Render no status line at all while the setting is not overridden. */
22
+ hideWhenDefault?: boolean
23
+ /**
24
+ * A selector, matched inside the default slot, for the element that takes the
25
+ * focus after a reset. Defaults to the first focusable element in the slot.
26
+ * When it matches nothing focusable, the first focusable element is used, and
27
+ * without one the component itself takes the focus.
28
+ */
29
+ focusTarget?: string
30
+ }
31
+
32
+ /** Everything that can take the focus; `tabIndex` and state are checked per element. */
33
+ const FOCUSABLE =
34
+ 'a[href], button, input, select, textarea, [tabindex], [contenteditable="true"], [contenteditable=""]'
35
+
36
+ /** How long an announcement stays in the live region before it is cleared again. */
37
+ const ANNOUNCEMENT_MS = 1000
38
+ </script>
39
+
40
+ <script lang="ts" setup>
41
+ import { computed, nextTick, onBeforeUnmount, ref, useTemplateRef, watch } from 'vue'
42
+
43
+ defineOptions({ name: 'NexxtEditorOverrideStatus' })
44
+
45
+ const {
46
+ overridden,
47
+ label,
48
+ overriddenLabel = 'Aangepast',
49
+ resetLabel,
50
+ hideWhenDefault = false,
51
+ focusTarget,
52
+ } = defineProps<NexxtEditorOverrideStatusProps>()
53
+
54
+ const emit = defineEmits<{
55
+ /** The reset button was pressed. The parent clears the override. */
56
+ reset: []
57
+ }>()
58
+
59
+ defineSlots<{
60
+ /** The control the status belongs to, rendered above the status line. */
61
+ default?: () => unknown
62
+ }>()
63
+
64
+ const root = useTemplateRef<HTMLElement>('root')
65
+ const row = useTemplateRef<HTMLElement>('row')
66
+
67
+ const status = computed(() => (overridden ? overriddenLabel : label))
68
+
69
+ const showRow = computed(() => overridden || !hideWhenDefault)
70
+
71
+ /**
72
+ * Whether an element can take the focus right now. `tabIndex` is -1 for the
73
+ * cells a roving tabindex skips, so only the tab stop of such a group counts.
74
+ */
75
+ const canFocus = (el: Element): el is HTMLElement =>
76
+ el instanceof HTMLElement &&
77
+ el.tabIndex >= 0 &&
78
+ !el.matches(':disabled') &&
79
+ !el.closest('[inert]') &&
80
+ // Not in jsdom; a real browser also skips elements that are not rendered.
81
+ (typeof el.checkVisibility !== 'function' || el.checkVisibility())
82
+
83
+ /**
84
+ * Candidates in the slot only: the status line itself is about to go. An
85
+ * invalid selector matches nothing rather than throwing, so a typo in
86
+ * `focusTarget` cannot stop the reset.
87
+ */
88
+ const inSlot = (selector: string) => {
89
+ let matches: Element[]
90
+ try {
91
+ matches = [...(root.value?.querySelectorAll(selector) ?? [])]
92
+ } catch {
93
+ // `process.env.NODE_ENV` stays in the library build, so the consumer's
94
+ // bundler decides whether this runs (as in NexxtTabs).
95
+ if (process.env.NODE_ENV !== 'production') {
96
+ console.warn(
97
+ `[NexxtEditorOverrideStatus] \`focusTarget\` is not a valid selector: "${selector}". The focus goes to the first focusable element instead.`,
98
+ )
99
+ }
100
+ return []
101
+ }
102
+ return matches.filter((el): el is HTMLElement => !row.value?.contains(el) && canFocus(el))
103
+ }
104
+
105
+ /** The last resort: the component itself, focusable for this one move only. */
106
+ const focusRoot = () => {
107
+ const el = root.value
108
+ if (!el) return
109
+ const release = () => el.removeAttribute('tabindex')
110
+ el.setAttribute('tabindex', '-1')
111
+ el.addEventListener('blur', release, { once: true })
112
+ el.focus()
113
+ // Without the focus there is no blur to wait for: take the tabindex off now.
114
+ if (document.activeElement !== el) {
115
+ el.removeEventListener('blur', release)
116
+ release()
117
+ }
118
+ }
119
+
120
+ const focusControl = () => {
121
+ const target = (focusTarget ? inSlot(focusTarget)[0] : undefined) ?? inSlot(FOCUSABLE)[0]
122
+ target?.focus()
123
+ if (!target || document.activeElement !== target) focusRoot()
124
+ }
125
+
126
+ const focusIsLost = () => {
127
+ const active = document.activeElement
128
+ return !active || active === document.body || !root.value?.contains(active)
129
+ }
130
+
131
+ /**
132
+ * The reset button goes with the override, which would drop the focus on
133
+ * `body`. The focus moves to the control first, while the button still
134
+ * exists; after the update it is checked again, in case the reset replaced the
135
+ * control it landed on. The reset is emitted whatever the focus move does.
136
+ */
137
+ const onReset = async () => {
138
+ try {
139
+ focusControl()
140
+ } finally {
141
+ emit('reset')
142
+ }
143
+ await nextTick()
144
+ if (focusIsLost()) focusControl()
145
+ }
146
+
147
+ /**
148
+ * Only the switch between overridden and not is announced, with the status
149
+ * that belongs to the new state. A new `overriddenLabel` within the override,
150
+ * such as "Grootte 110%" on every arrow key, stays silent: the control already
151
+ * announces its own value. And only while the focus is inside: then the switch
152
+ * follows from what the user just did (a reset, a change in the control).
153
+ * Switching to another element in the editor changes every status at once,
154
+ * and that stays silent. Cleared afterwards, so the copy is not read twice
155
+ * when browsing.
156
+ */
157
+ const announcement = ref('')
158
+ let clearTimer: ReturnType<typeof setTimeout> | undefined
159
+
160
+ watch(
161
+ () => overridden,
162
+ () => {
163
+ if (!root.value?.contains(document.activeElement)) return
164
+ announcement.value = status.value
165
+ clearTimeout(clearTimer)
166
+ clearTimer = setTimeout(() => (announcement.value = ''), ANNOUNCEMENT_MS)
167
+ },
168
+ )
169
+
170
+ onBeforeUnmount(() => clearTimeout(clearTimer))
171
+ </script>
172
+
173
+ <template>
174
+ <div
175
+ ref="root"
176
+ class="rounded-sm outline-cornflower-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2"
177
+ >
178
+ <!--
179
+ `rounded-sm` and the outline only show when the root itself holds the
180
+ focus: the fallback after a reset when the slot has nothing focusable.
181
+ -->
182
+ <slot />
183
+
184
+ <div
185
+ v-if="showRow"
186
+ ref="row"
187
+ :class="['flex items-center justify-between gap-3', $slots.default && 'pt-2']"
188
+ >
189
+ <span
190
+ v-if="overridden"
191
+ class="flex items-center gap-1.5 extra-small-normal text-cornflower-blue-700"
192
+ >
193
+ <span aria-hidden="true" class="size-1.5 shrink-0 rounded-full bg-cornflower-blue-500" />
194
+ {{ status }}
195
+ </span>
196
+ <span v-else class="extra-small-normal text-gray-700">{{ status }}</span>
197
+
198
+ <!-- No `focus:outline-none`: in Tailwind v4 it hides the focus-visible ring. -->
199
+ <button
200
+ v-if="overridden"
201
+ type="button"
202
+ class="shrink-0 cursor-pointer rounded-sm extra-small-link text-gray-800 hover:text-gray-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cornflower-blue-600"
203
+ @click="onReset"
204
+ >
205
+ {{ resetLabel }}
206
+ </button>
207
+ </div>
208
+
209
+ <div role="status" aria-live="polite" aria-atomic="true" class="sr-only">
210
+ {{ announcement }}
211
+ </div>
212
+ </div>
213
+ </template>
@@ -77,6 +77,7 @@
77
77
  "NexxtEditorSettingsBlock": "components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.vue",
78
78
  "NexxtEditorSettingsGroup": "components/editor/atoms/EditorSettingsGroup/EditorSettingsGroup.vue",
79
79
  "NexxtEditorAlignItems": "components/editor/molecules/EditorAlignItems/EditorAlignItems.vue",
80
+ "NexxtEditorAlignmentGrid": "components/editor/molecules/EditorAlignmentGrid/EditorAlignmentGrid.vue",
80
81
  "NexxtEditorAnchor": "components/editor/molecules/EditorAnchor/EditorAnchor.vue",
81
82
  "NexxtEditorBackgroundSize": "components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.vue",
82
83
  "NexxtEditorBorder": "components/editor/molecules/EditorBorder/EditorBorder.vue",
@@ -91,6 +92,7 @@
91
92
  "NexxtEditorLineHeight": "components/editor/molecules/EditorLineHeight/EditorLineHeight.vue",
92
93
  "NexxtEditorLineStyle": "components/editor/molecules/EditorLineStyle/EditorLineStyle.vue",
93
94
  "NexxtEditorMaxWidth": "components/editor/molecules/EditorMaxWidth/EditorMaxWidth.vue",
95
+ "NexxtEditorOverrideStatus": "components/editor/molecules/EditorOverrideStatus/EditorOverrideStatus.vue",
94
96
  "NexxtEditorPadding": "components/editor/molecules/EditorPadding/EditorPadding.vue",
95
97
  "NexxtEditorRadius": "components/editor/molecules/EditorRadius/EditorRadius.vue",
96
98
  "NexxtEditorShadow": "components/editor/molecules/EditorShadow/EditorShadow.vue",