@nexxtmove/ui 1.52.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
+ `;
@@ -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",
package/src/index.ts CHANGED
@@ -34,6 +34,14 @@ export type {
34
34
  NexxtPlaceholderInputMessages,
35
35
  NexxtPlaceholderInputProps,
36
36
  } from './components/molecules/PlaceholderInput/PlaceholderInput.vue'
37
+ export { EDITOR_ALIGNMENT_POSITIONS } from './components/editor/molecules/EditorAlignmentGrid/EditorAlignmentGrid.vue'
38
+ export type {
39
+ EditorAlignmentDirection,
40
+ EditorAlignmentGridMessages,
41
+ EditorAlignmentMode,
42
+ EditorAlignmentPosition,
43
+ EditorAlignmentValue,
44
+ } from './components/editor/molecules/EditorAlignmentGrid/EditorAlignmentGrid.vue'
37
45
  export { EDITOR_ALIGN_ITEMS_OPTIONS } from './components/editor/molecules/EditorAlignItems/EditorAlignItems.vue'
38
46
  export type {
39
47
  EditorAlignItemsMessages,
@@ -211,6 +219,7 @@ export { default as NexxtDatePicker } from './components/organisms/DatePicker/Da
211
219
  export { default as NexxtDoughnutChart } from './components/molecules/DoughnutChart/DoughnutChart.vue'
212
220
  export { default as NexxtDropdownButton } from './components/organisms/DropdownButton/DropdownButton.vue'
213
221
  export { default as NexxtEditorAlignItems } from './components/editor/molecules/EditorAlignItems/EditorAlignItems.vue'
222
+ export { default as NexxtEditorAlignmentGrid } from './components/editor/molecules/EditorAlignmentGrid/EditorAlignmentGrid.vue'
214
223
  export { default as NexxtEditorAnchor } from './components/editor/molecules/EditorAnchor/EditorAnchor.vue'
215
224
  export { default as NexxtEditorBackgroundSize } from './components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.vue'
216
225
  export { default as NexxtEditorBorder } from './components/editor/molecules/EditorBorder/EditorBorder.vue'