@nexxtmove/ui 1.52.0 → 1.54.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.
@@ -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>
@@ -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"