@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.
package/dist/nuxt.js CHANGED
@@ -80,6 +80,7 @@ var s = {
80
80
  NexxtEditorSettingsBlock: "components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.vue",
81
81
  NexxtEditorSettingsGroup: "components/editor/atoms/EditorSettingsGroup/EditorSettingsGroup.vue",
82
82
  NexxtEditorAlignItems: "components/editor/molecules/EditorAlignItems/EditorAlignItems.vue",
83
+ NexxtEditorAlignmentGrid: "components/editor/molecules/EditorAlignmentGrid/EditorAlignmentGrid.vue",
83
84
  NexxtEditorAnchor: "components/editor/molecules/EditorAnchor/EditorAnchor.vue",
84
85
  NexxtEditorBackgroundSize: "components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.vue",
85
86
  NexxtEditorBorder: "components/editor/molecules/EditorBorder/EditorBorder.vue",
@@ -94,6 +95,7 @@ var s = {
94
95
  NexxtEditorLineHeight: "components/editor/molecules/EditorLineHeight/EditorLineHeight.vue",
95
96
  NexxtEditorLineStyle: "components/editor/molecules/EditorLineStyle/EditorLineStyle.vue",
96
97
  NexxtEditorMaxWidth: "components/editor/molecules/EditorMaxWidth/EditorMaxWidth.vue",
98
+ NexxtEditorOverrideStatus: "components/editor/molecules/EditorOverrideStatus/EditorOverrideStatus.vue",
97
99
  NexxtEditorPadding: "components/editor/molecules/EditorPadding/EditorPadding.vue",
98
100
  NexxtEditorRadius: "components/editor/molecules/EditorRadius/EditorRadius.vue",
99
101
  NexxtEditorShadow: "components/editor/molecules/EditorShadow/EditorShadow.vue",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nexxtmove/ui",
3
3
  "type": "module",
4
- "version": "1.51.0",
4
+ "version": "1.53.0",
5
5
  "exports": {
6
6
  ".": {
7
7
  "types": "./dist/index.d.ts",
@@ -0,0 +1,155 @@
1
+ import { computed, toValue, type ComponentPublicInstance, type MaybeRefOrGetter } from 'vue'
2
+
3
+ /**
4
+ * Keyboard behaviour of a two-dimensional radiogroup (WAI-ARIA APG radio group,
5
+ * in 2D): one tab stop, arrows that move *and* choose, no wrap at the edges.
6
+ *
7
+ * Positions are cell indices in reading order, `row * columns + column`. A
8
+ * radio that spans several cells (a band) is registered on one of its cells;
9
+ * `positions` lists those, so the arrows only move along the radios that
10
+ * exist. Cell rendering stays with the component.
11
+ */
12
+ export interface UseRadioGridOptions {
13
+ /** Number of rows of the grid. */
14
+ rows: MaybeRefOrGetter<number>
15
+ /** Number of columns of the grid. */
16
+ columns: MaybeRefOrGetter<number>
17
+ /**
18
+ * The positions that hold a radio, in any order. Defaults to every cell. An
19
+ * arrow towards a cell that is not in here does not move.
20
+ */
21
+ positions?: MaybeRefOrGetter<readonly number[]>
22
+ /** The chosen position, or `null` while no radio is chosen. */
23
+ selected: MaybeRefOrGetter<number | null>
24
+ /**
25
+ * The position that takes the tab stop while none is chosen. Without one, or
26
+ * when it returns a position that holds no radio, the first position does.
27
+ */
28
+ preferred?: () => number | null
29
+ /**
30
+ * What Home and End jump to: the first and last radio of the current row, or
31
+ * of the whole grid. Defaults to `'grid'`.
32
+ */
33
+ homeEnd?: 'row' | 'grid'
34
+ /** Called with the position an arrow, Home or End lands on. */
35
+ onChoose: (position: number) => void
36
+ /**
37
+ * Called when the focus leaves the grid after one or more keyboard choices:
38
+ * the end of a keyboard run, where a consumer closes its undo step.
39
+ */
40
+ onLeave?: () => void
41
+ }
42
+
43
+ export const useRadioGrid = (options: UseRadioGridOptions) => {
44
+ const { homeEnd = 'grid', onChoose, onLeave } = options
45
+
46
+ const elements = new Map<number, HTMLElement>()
47
+
48
+ /** Whether a keyboard choice happened since the focus last left the grid. */
49
+ let pending = false
50
+
51
+ const valid = computed(() => {
52
+ const all = toValue(options.positions)
53
+ const count = toValue(options.rows) * toValue(options.columns)
54
+ return [...(all ?? Array.from({ length: count }, (_, index) => index))].sort((a, b) => a - b)
55
+ })
56
+
57
+ /** Roving tabindex: the chosen radio, else the preferred one, else the first. */
58
+ const tabStop = computed(() => {
59
+ const selected = toValue(options.selected)
60
+ if (selected !== null && valid.value.includes(selected)) return selected
61
+ const preferred = options.preferred?.() ?? null
62
+ if (preferred !== null && valid.value.includes(preferred)) return preferred
63
+ return valid.value[0] ?? null
64
+ })
65
+
66
+ /**
67
+ * Function ref for the radio on `position`, so the arrows can focus it. On
68
+ * unmount only a detached element is dropped: in a crossfade the leaving
69
+ * layer unmounts after the entering one registered the same position.
70
+ */
71
+ const cellRef = (position: number) => (el: Element | ComponentPublicInstance | null) => {
72
+ if (el instanceof HTMLElement) {
73
+ elements.set(position, el)
74
+ return
75
+ }
76
+ for (const [key, stored] of elements) if (!stored.isConnected) elements.delete(key)
77
+ }
78
+
79
+ const step = (position: number, rowStep: number, columnStep: number) => {
80
+ const columns = toValue(options.columns)
81
+ const row = Math.floor(position / columns) + rowStep
82
+ const column = (position % columns) + columnStep
83
+ if (row < 0 || row >= toValue(options.rows) || column < 0 || column >= columns) return position
84
+ const next = row * columns + column
85
+ return valid.value.includes(next) ? next : position
86
+ }
87
+
88
+ const ends = (position: number): [number, number] => {
89
+ const columns = toValue(options.columns)
90
+ const row = Math.floor(position / columns)
91
+ const pool =
92
+ homeEnd === 'row'
93
+ ? valid.value.filter((candidate) => Math.floor(candidate / columns) === row)
94
+ : valid.value
95
+ return [pool[0] ?? position, pool[pool.length - 1] ?? position]
96
+ }
97
+
98
+ /**
99
+ * An arrow moves one cell and chooses it; at an edge, or towards a cell that
100
+ * holds no radio, it stays. A key with a modifier belongs to the browser or
101
+ * the OS (Alt+Left is back, Cmd+Left is line start), so it is left alone: no
102
+ * choice, no preventDefault.
103
+ */
104
+ const onKeydown = (event: KeyboardEvent, position: number) => {
105
+ if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
106
+
107
+ const [first, last] = ends(position)
108
+ const moves: Record<string, () => number> = {
109
+ ArrowLeft: () => step(position, 0, -1),
110
+ ArrowRight: () => step(position, 0, 1),
111
+ ArrowUp: () => step(position, -1, 0),
112
+ ArrowDown: () => step(position, 1, 0),
113
+ Home: () => first,
114
+ End: () => last,
115
+ }
116
+ const next = moves[event.key]?.()
117
+
118
+ if (next === undefined) return
119
+
120
+ // Also when the arrow does not move: otherwise it scrolls the panel.
121
+ event.preventDefault()
122
+ if (next === position && next === toValue(options.selected)) return
123
+
124
+ onChoose(next)
125
+ pending = true
126
+ elements.get(next)?.focus()
127
+ }
128
+
129
+ /** Bind on the element that holds every radio. */
130
+ const onFocusout = (event: FocusEvent) => {
131
+ const container = event.currentTarget as HTMLElement | null
132
+ const to = event.relatedTarget as Node | null
133
+ if (to && container?.contains(to)) return
134
+ if (!pending) return
135
+ pending = false
136
+ onLeave?.()
137
+ }
138
+
139
+ /**
140
+ * Ends the keyboard run without signalling it: call it when the consumer
141
+ * committed by other means (a click), so leaving the grid does not commit a
142
+ * second time.
143
+ */
144
+ const reset = () => {
145
+ pending = false
146
+ }
147
+
148
+ /** Puts the focus on the tab stop, e.g. after the radios were re-rendered. */
149
+ const focusTabStop = () => {
150
+ const position = tabStop.value
151
+ if (position !== null) elements.get(position)?.focus()
152
+ }
153
+
154
+ return { tabStop, cellRef, onKeydown, onFocusout, reset, focusTabStop }
155
+ }
@@ -0,0 +1,515 @@
1
+ <script lang="ts">
2
+ /** The three places on one axis, in reading order: left/top, middle, right/bottom. */
3
+ export const EDITOR_ALIGNMENT_POSITIONS = ['start', 'center', 'end'] as const
4
+
5
+ /** A visual place on one axis: `start` is left or top, `end` is right or bottom. */
6
+ export type EditorAlignmentPosition = (typeof EDITOR_ALIGNMENT_POSITIONS)[number]
7
+
8
+ /**
9
+ * Where the content sits, as a visual place: `x` horizontal, `y` vertical,
10
+ * whatever the direction. `null` for an axis that does not count in a band
11
+ * mode; the grid writes `null` there, and reads only the axis that counts.
12
+ */
13
+ export interface EditorAlignmentValue {
14
+ x: EditorAlignmentPosition | null
15
+ y: EditorAlignmentPosition | null
16
+ }
17
+
18
+ /** The direction the content stacks in. Decides the glyph and which way bands run. */
19
+ export type EditorAlignmentDirection = 'row' | 'column'
20
+
21
+ /**
22
+ * What can be chosen: `'both'` axes (nine cells), only the `'cross'` axis
23
+ * (three bands along the main axis), or only the `'main'` axis (three bands
24
+ * along the cross axis).
25
+ */
26
+ export type EditorAlignmentMode = 'both' | 'cross' | 'main'
27
+
28
+ /** Overridable copy. Every key is optional. */
29
+ export interface EditorAlignmentGridMessages {
30
+ /** Name of the top left cell. Defaults to `'Linksboven'`. */
31
+ topLeft?: string
32
+ /** Name of the top middle cell. Defaults to `'Midden boven'`. */
33
+ topCenter?: string
34
+ /** Name of the top right cell. Defaults to `'Rechtsboven'`. */
35
+ topRight?: string
36
+ /** Name of the middle left cell. Defaults to `'Links midden'`. */
37
+ centerLeft?: string
38
+ /** Name of the middle cell. Defaults to `'Midden'`. */
39
+ center?: string
40
+ /** Name of the middle right cell. Defaults to `'Rechts midden'`. */
41
+ centerRight?: string
42
+ /** Name of the bottom left cell. Defaults to `'Linksonder'`. */
43
+ bottomLeft?: string
44
+ /** Name of the bottom middle cell. Defaults to `'Midden onder'`. */
45
+ bottomCenter?: string
46
+ /** Name of the bottom right cell. Defaults to `'Rechtsonder'`. */
47
+ bottomRight?: string
48
+ /** Name of the top band. Defaults to `'Boven'`. */
49
+ top?: string
50
+ /** Name of the middle band, horizontal or vertical. Defaults to `'Midden'`. */
51
+ middle?: string
52
+ /** Name of the bottom band. Defaults to `'Onder'`. */
53
+ bottom?: string
54
+ /** Name of the left band. Defaults to `'Links'`. */
55
+ left?: string
56
+ /** Name of the right band. Defaults to `'Rechts'`. */
57
+ right?: string
58
+ /** The main axis as a place while `auto` is on, in the stretched readout. Defaults to `'Auto'`. */
59
+ auto?: string
60
+ /** Readout while `auto` is on; `{position}` is the band. Defaults to `'Auto · {position}'`. */
61
+ autoValue?: string
62
+ /** Readout while `stretched` is on; `{position}` is the main-axis place. Defaults to `'Uitgerekt · {position}'`. */
63
+ stretchedValue?: string
64
+ }
65
+
66
+ export interface NexxtEditorAlignmentGridProps {
67
+ /**
68
+ * Header text of the settings block, and the name of the radiogroup.
69
+ * Defaults to the Dutch `'Uitlijning'`; the library has no i18n layer, so a
70
+ * consumer in another language passes its own copy in.
71
+ */
72
+ label?: string
73
+ /** The direction the content stacks in. Defaults to `'row'`. */
74
+ direction?: EditorAlignmentDirection
75
+ /** What can be chosen. Defaults to `'both'`, the nine cells. */
76
+ mode?: EditorAlignmentMode
77
+ /**
78
+ * The main axis distributes the space (Auto): in `'cross'` mode the bands
79
+ * draw their bars spread out, and the readout says `autoValue`.
80
+ */
81
+ auto?: boolean
82
+ /**
83
+ * The content is stretched over the cross axis (a legacy setting): no radio
84
+ * is checked, and a band with long bars marks the main-axis place. Every
85
+ * radio stays clickable; a choice is the way out.
86
+ */
87
+ stretched?: boolean
88
+ /**
89
+ * Copy of the cells, bands and readout. Defaults are Dutch and are merged
90
+ * per key. Copy is never translated inside the component.
91
+ */
92
+ messages?: EditorAlignmentGridMessages
93
+ }
94
+
95
+ const DEFAULT_MESSAGES = {
96
+ topLeft: 'Linksboven',
97
+ topCenter: 'Midden boven',
98
+ topRight: 'Rechtsboven',
99
+ centerLeft: 'Links midden',
100
+ center: 'Midden',
101
+ centerRight: 'Rechts midden',
102
+ bottomLeft: 'Linksonder',
103
+ bottomCenter: 'Midden onder',
104
+ bottomRight: 'Rechtsonder',
105
+ top: 'Boven',
106
+ middle: 'Midden',
107
+ bottom: 'Onder',
108
+ left: 'Links',
109
+ right: 'Rechts',
110
+ auto: 'Auto',
111
+ autoValue: 'Auto · {position}',
112
+ stretchedValue: 'Uitgerekt · {position}',
113
+ } satisfies Required<EditorAlignmentGridMessages>
114
+
115
+ type Axis = 'x' | 'y'
116
+ type Copy = Required<EditorAlignmentGridMessages>
117
+
118
+ /** Cell names by row (`y`) and then column (`x`). */
119
+ const CELL_KEYS: Record<EditorAlignmentPosition, Record<EditorAlignmentPosition, keyof Copy>> = {
120
+ start: { start: 'topLeft', center: 'topCenter', end: 'topRight' },
121
+ center: { start: 'centerLeft', center: 'center', end: 'centerRight' },
122
+ end: { start: 'bottomLeft', center: 'bottomCenter', end: 'bottomRight' },
123
+ }
124
+
125
+ /**
126
+ * Band names by the axis the band stands for. "Midden" is the same word for
127
+ * both orientations, so there are five keys, not six (spec §7).
128
+ */
129
+ const BAND_KEYS: Record<Axis, Record<EditorAlignmentPosition, keyof Copy>> = {
130
+ x: { start: 'left', center: 'middle', end: 'right' },
131
+ y: { start: 'top', center: 'middle', end: 'bottom' },
132
+ }
133
+
134
+ const SIZE = 3
135
+
136
+ /** Bar sizes of the glyph: lengths along the cross axis. */
137
+ const BARS = [
138
+ { tall: 'h-3', wide: 'w-3' },
139
+ { tall: 'h-4', wide: 'w-4' },
140
+ { tall: 'h-2', wide: 'w-2' },
141
+ ] as const
142
+
143
+ const ITEMS: Record<EditorAlignmentPosition, string> = {
144
+ start: 'items-start',
145
+ center: 'items-center',
146
+ end: 'items-end',
147
+ }
148
+
149
+ /** Grid placement of a band over the cross axis, by its line. Literals, so Tailwind emits them. */
150
+ const COLUMN_START = ['col-start-1', 'col-start-2', 'col-start-3'] as const
151
+ const ROW_START = ['row-start-1', 'row-start-2', 'row-start-3'] as const
152
+ </script>
153
+
154
+ <script lang="ts" setup>
155
+ import { computed, nextTick, useTemplateRef, watch } from 'vue'
156
+ import NexxtEditorSettingsBlock from '../../atoms/EditorSettingsBlock/EditorSettingsBlock.vue'
157
+ import { useRadioGrid } from '../../composables/useRadioGrid'
158
+
159
+ defineOptions({ name: 'NexxtEditorAlignmentGrid', inheritAttrs: false })
160
+
161
+ const {
162
+ label = 'Uitlijning',
163
+ direction = 'row',
164
+ mode = 'both',
165
+ auto = false,
166
+ stretched = false,
167
+ messages,
168
+ } = defineProps<NexxtEditorAlignmentGridProps>()
169
+
170
+ const emit = defineEmits<{
171
+ /**
172
+ * One edit is finished: after a click, and when the focus leaves the grid
173
+ * after a run of arrow keys. A consumer that folds model writes into one undo
174
+ * step closes it here.
175
+ */
176
+ commit: []
177
+ }>()
178
+
179
+ /** The chosen place. Every choice writes it: a click, an arrow, Space. */
180
+ const model = defineModel<EditorAlignmentValue>({ required: true })
181
+
182
+ const copy = computed<Copy>(() => ({ ...DEFAULT_MESSAGES, ...messages }))
183
+
184
+ const at = (position: EditorAlignmentPosition | null) =>
185
+ position === null ? null : EDITOR_ALIGNMENT_POSITIONS.indexOf(position)
186
+
187
+ const mainAxis = computed<Axis>(() => (direction === 'row' ? 'x' : 'y'))
188
+ const crossAxis = computed<Axis>(() => (direction === 'row' ? 'y' : 'x'))
189
+
190
+ /** The axis the bands stand for; `null` with nine cells. */
191
+ const bandAxis = computed<Axis | null>(() =>
192
+ mode === 'both' ? null : mode === 'cross' ? crossAxis.value : mainAxis.value,
193
+ )
194
+
195
+ /**
196
+ * The cell a band is registered on: the first of its row for a horizontal band
197
+ * (one per vertical place), the first of its column for a vertical one.
198
+ */
199
+ const bandCell = (axis: Axis, index: number) => (axis === 'y' ? index * SIZE : index)
200
+
201
+ const positions = computed(() => {
202
+ const axis = bandAxis.value
203
+ if (!axis) return Array.from({ length: SIZE * SIZE }, (_, index) => index)
204
+ return [0, 1, 2].map((index) => bandCell(axis, index))
205
+ })
206
+
207
+ const selected = computed(() => {
208
+ if (stretched) return null
209
+ const axis = bandAxis.value
210
+ if (axis) {
211
+ const index = at(model.value[axis])
212
+ return index === null ? null : bandCell(axis, index)
213
+ }
214
+ const x = at(model.value.x)
215
+ const y = at(model.value.y)
216
+ return x === null || y === null ? null : y * SIZE + x
217
+ })
218
+
219
+ /** The main-axis place, which a stretched grid marks; the start without one. */
220
+ const mainIndex = computed(() => at(model.value[mainAxis.value]) ?? 0)
221
+
222
+ /** Without a chosen radio (stretched): the main-axis place × the middle. */
223
+ const preferred = () => {
224
+ const cell = mainAxis.value === 'x' ? SIZE + mainIndex.value : mainIndex.value * SIZE + 1
225
+ const axis = bandAxis.value
226
+ if (!axis) return cell
227
+ return axis === 'y' ? Math.floor(cell / SIZE) * SIZE : cell % SIZE
228
+ }
229
+
230
+ const valueAt = (position: number): EditorAlignmentValue => {
231
+ const x = EDITOR_ALIGNMENT_POSITIONS[position % SIZE]
232
+ const y = EDITOR_ALIGNMENT_POSITIONS[Math.floor(position / SIZE)]
233
+ const axis = bandAxis.value
234
+ if (axis === 'x') return { x, y: null }
235
+ if (axis === 'y') return { x: null, y }
236
+ return { x, y }
237
+ }
238
+
239
+ /** Writes the place; `false` when it was already chosen. */
240
+ const choose = (position: number) => {
241
+ if (position === selected.value) return false
242
+ model.value = valueAt(position)
243
+ return true
244
+ }
245
+
246
+ const { tabStop, cellRef, onKeydown, onFocusout, reset, focusTabStop } = useRadioGrid({
247
+ rows: SIZE,
248
+ columns: SIZE,
249
+ positions,
250
+ selected,
251
+ preferred,
252
+ homeEnd: 'row',
253
+ onChoose: choose,
254
+ onLeave: () => emit('commit'),
255
+ })
256
+
257
+ /**
258
+ * A click, or Space and Enter through the button's own click. Its commit also
259
+ * closes a keyboard run before it, so leaving the grid afterwards adds none.
260
+ */
261
+ const onClick = (position: number) => {
262
+ if (!choose(position)) return
263
+ emit('commit')
264
+ reset()
265
+ }
266
+
267
+ const format = (template: string, position: string) => template.replace('{position}', position)
268
+
269
+ const bandName = (axis: Axis, position: EditorAlignmentPosition) =>
270
+ copy.value[BAND_KEYS[axis][position]]
271
+
272
+ /** The readout next to the grid: only what has an effect (spec §4.2). */
273
+ const readout = computed(() => {
274
+ const { x, y } = model.value
275
+ if (stretched) {
276
+ const main = model.value[mainAxis.value] ?? 'start'
277
+ return format(
278
+ copy.value.stretchedValue,
279
+ auto ? copy.value.auto : bandName(mainAxis.value, main),
280
+ )
281
+ }
282
+ const axis = bandAxis.value
283
+ if (axis) {
284
+ const position = model.value[axis]
285
+ if (!position) return ''
286
+ const name = bandName(axis, position)
287
+ return auto && mode === 'cross' ? format(copy.value.autoValue, name) : name
288
+ }
289
+ return x && y ? copy.value[CELL_KEYS[y][x]] : ''
290
+ })
291
+
292
+ /** Spread bars: Auto in cross mode, where the main axis distributes. */
293
+ const spread = computed(() => auto && mode === 'cross')
294
+
295
+ /** Bars stand in a row and lie in a column. */
296
+ const standing = computed(() => direction === 'row')
297
+
298
+ const radios = computed(() =>
299
+ positions.value.map((position) => {
300
+ const x = EDITOR_ALIGNMENT_POSITIONS[position % SIZE]
301
+ const y = EDITOR_ALIGNMENT_POSITIONS[Math.floor(position / SIZE)]
302
+ const axis = bandAxis.value
303
+ const checked = position === selected.value
304
+ // The cross-axis place the bars line up on: the cell's own in nine cells
305
+ // and in cross mode; the middle in main mode, where the cross axis has no
306
+ // effect.
307
+ const align: EditorAlignmentPosition = mode === 'main' ? 'center' : standing.value ? y : x
308
+ const horizontalBand = axis === 'y'
309
+ const inStretchedBand =
310
+ stretched &&
311
+ (mode === 'cross' ||
312
+ (mainAxis.value === 'x' ? at(x) === mainIndex.value : at(y) === mainIndex.value))
313
+ return {
314
+ position,
315
+ checked,
316
+ label: axis ? bandName(axis, axis === 'x' ? x : y) : copy.value[CELL_KEYS[y][x]],
317
+ tabindex: position === tabStop.value ? 0 : -1,
318
+ span: axis ? (horizontalBand ? 'col-span-3' : 'row-span-3') : '',
319
+ // A band's long side, where spread bars and dots run.
320
+ track: !axis
321
+ ? ''
322
+ : horizontalBand
323
+ ? 'w-full px-2 justify-between'
324
+ : 'h-full flex-col py-2 justify-between',
325
+ align: ITEMS[align],
326
+ showDots: !inStretchedBand,
327
+ dots: axis && spread.value ? 3 : 1,
328
+ }
329
+ }),
330
+ )
331
+
332
+ /** The stretched bands: all three in cross mode, else one over the cross axis at the main place. */
333
+ const stretchedBands = computed(() => {
334
+ if (!stretched) return []
335
+ if (mode === 'cross') {
336
+ return [0, 1, 2].map((index) =>
337
+ standing.value
338
+ ? {
339
+ key: index,
340
+ place: `col-span-3 ${ROW_START[index]}`,
341
+ bars: 'w-full px-2 justify-between',
342
+ }
343
+ : {
344
+ key: index,
345
+ place: `row-span-3 ${COLUMN_START[index]}`,
346
+ bars: 'h-full flex-col py-2 justify-between',
347
+ },
348
+ )
349
+ }
350
+ return [
351
+ standing.value
352
+ ? {
353
+ key: 0,
354
+ place: `row-span-3 row-start-1 ${COLUMN_START[mainIndex.value]}`,
355
+ bars: 'h-full py-2 justify-center',
356
+ }
357
+ : {
358
+ key: 0,
359
+ place: `col-span-3 col-start-1 ${ROW_START[mainIndex.value]}`,
360
+ bars: 'w-full flex-col px-2 justify-center',
361
+ },
362
+ ]
363
+ })
364
+
365
+ /** Changes of mode or direction crossfade the cell layer. */
366
+ const layerKey = computed(() => `${mode}-${direction}`)
367
+
368
+ const group = useTemplateRef<HTMLElement>('group')
369
+
370
+ /**
371
+ * A switch while the focus is in the grid moves it to the tab stop of the new
372
+ * layer, once that is mounted, rather than letting it drop to `body` with the
373
+ * old one. The focus stays inside, so it is no end of a keyboard run and
374
+ * commits nothing; leaving the grid does, later.
375
+ */
376
+ watch(
377
+ layerKey,
378
+ () => {
379
+ if (!group.value?.contains(document.activeElement)) return
380
+ nextTick(focusTabStop)
381
+ },
382
+ { flush: 'pre' },
383
+ )
384
+
385
+ // The leaving layer is still in the DOM for 150 ms; inert keeps its radios out
386
+ // of the tab order and the accessibility tree meanwhile. Set a tick later, so
387
+ // the watcher above has moved the focus out first: making the focused button
388
+ // inert would blur it to `body`.
389
+ const onBeforeLeave = (el: Element) => {
390
+ nextTick(() => {
391
+ ;(el as HTMLElement).inert = true
392
+ })
393
+ }
394
+ </script>
395
+
396
+ <template>
397
+ <NexxtEditorSettingsBlock :label="label">
398
+ <div class="flex items-center gap-4">
399
+ <div class="size-28 shrink-0 rounded-xl bg-gray-100 p-1">
400
+ <div
401
+ ref="group"
402
+ role="radiogroup"
403
+ :aria-label="label"
404
+ v-bind="$attrs"
405
+ class="relative size-full"
406
+ @focusout="onFocusout"
407
+ >
408
+ <Transition
409
+ enter-active-class="transition-opacity duration-150 ease-out motion-reduce:transition-none"
410
+ enter-from-class="opacity-0"
411
+ leave-active-class="absolute inset-0 transition-opacity duration-150 ease-in motion-reduce:transition-none"
412
+ leave-to-class="opacity-0"
413
+ @before-leave="onBeforeLeave"
414
+ >
415
+ <div :key="layerKey" class="size-full">
416
+ <!-- The stretched marker: drawing only, under the radios, which stay clickable. -->
417
+ <div
418
+ v-if="stretchedBands.length"
419
+ aria-hidden="true"
420
+ class="pointer-events-none absolute inset-0 grid grid-cols-3 grid-rows-3 gap-1"
421
+ >
422
+ <div
423
+ v-for="band in stretchedBands"
424
+ :key="band.key"
425
+ :class="['flex rounded-lg bg-white text-cornflower-blue-600', band.place]"
426
+ >
427
+ <div :class="['flex size-full items-stretch gap-0.5', band.bars]">
428
+ <span
429
+ v-for="(_, index) in BARS"
430
+ :key="index"
431
+ :class="['rounded-full bg-current', standing ? 'w-1' : 'h-1']"
432
+ />
433
+ </div>
434
+ </div>
435
+ </div>
436
+
437
+ <!--
438
+ No `focus:outline-none`: in Tailwind v4 it hides the focus-visible
439
+ ring. The ring is -600, not -500: -500 reaches 2.78:1 on white.
440
+ -->
441
+ <div
442
+ :class="[
443
+ 'relative grid size-full grid-cols-3 grid-rows-3 gap-1',
444
+ bandAxis === 'x' && 'grid-flow-col',
445
+ ]"
446
+ >
447
+ <button
448
+ v-for="radio in radios"
449
+ :key="radio.position"
450
+ :ref="cellRef(radio.position)"
451
+ type="button"
452
+ role="radio"
453
+ :aria-checked="radio.checked"
454
+ :aria-label="radio.label"
455
+ :tabindex="radio.tabindex"
456
+ :class="[
457
+ 'group relative flex items-center justify-center rounded-lg outline-cornflower-blue-600 transition-colors duration-150 ease-out focus-visible:outline-2 focus-visible:outline-offset-0 motion-reduce:transition-none',
458
+ radio.span,
459
+ radio.checked ? 'bg-white' : 'cursor-pointer',
460
+ ]"
461
+ @click="onClick(radio.position)"
462
+ @keydown="onKeydown($event, radio.position)"
463
+ >
464
+ <span
465
+ v-if="radio.showDots && !radio.checked"
466
+ aria-hidden="true"
467
+ :class="[
468
+ 'flex items-center transition-opacity duration-150 ease-out group-hover:opacity-0 motion-reduce:transition-none',
469
+ radio.dots > 1 ? radio.track : '',
470
+ ]"
471
+ >
472
+ <span
473
+ v-for="dot in radio.dots"
474
+ :key="dot"
475
+ class="size-1 shrink-0 rounded-full bg-gray-700"
476
+ />
477
+ </span>
478
+ <span
479
+ aria-hidden="true"
480
+ :class="[
481
+ 'absolute inset-0 flex items-center justify-center transition-[opacity,color] duration-150 ease-out motion-reduce:transition-none',
482
+ radio.checked
483
+ ? 'text-cornflower-blue-600'
484
+ : 'text-gray-700 opacity-0 group-hover:opacity-100',
485
+ ]"
486
+ >
487
+ <span
488
+ :class="[
489
+ 'flex gap-0.5',
490
+ standing ? 'h-4' : 'w-4 flex-col',
491
+ radio.align,
492
+ spread && radio.track,
493
+ ]"
494
+ >
495
+ <span
496
+ v-for="(bar, index) in BARS"
497
+ :key="index"
498
+ :class="[
499
+ 'shrink-0 rounded-full bg-current',
500
+ standing ? ['w-1', bar.tall] : ['h-1', bar.wide],
501
+ ]"
502
+ />
503
+ </span>
504
+ </span>
505
+ </button>
506
+ </div>
507
+ </div>
508
+ </Transition>
509
+ </div>
510
+ </div>
511
+
512
+ <span aria-hidden="true" class="min-w-0 small-normal text-gray-700">{{ readout }}</span>
513
+ </div>
514
+ </NexxtEditorSettingsBlock>
515
+ </template>