@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.
- package/dist/components/atoms/EnergyLabel/EnergyLabel.vue.d.ts +16 -2
- package/dist/components/editor/composables/useRadioGrid.d.ts +48 -0
- package/dist/components/editor/molecules/EditorAlignItems/EditorAlignItems.vue.d.ts +2 -2
- package/dist/components/editor/molecules/EditorAlignmentGrid/EditorAlignmentGrid.vue.d.ts +101 -0
- package/dist/components/editor/molecules/EditorGap/EditorGap.vue.d.ts +32 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +1568 -1196
- package/dist/nuxt.js +1 -0
- package/dist/ui.css +2 -1
- package/package.json +1 -1
- package/src/components/atoms/EnergyLabel/EnergyLabel.vue +50 -10
- package/src/components/editor/composables/useRadioGrid.ts +155 -0
- package/src/components/editor/molecules/EditorAlignmentGrid/EditorAlignmentGrid.vue +515 -0
- package/src/components/editor/molecules/EditorAnchor/EditorAnchor.vue +25 -41
- package/src/components/editor/molecules/EditorAnchor/__snapshots__/EditorAnchor.test.ts.snap +65 -0
- package/src/components/editor/molecules/EditorGap/EditorGap.vue +76 -2
- package/src/components/editor/molecules/EditorGap/__snapshots__/EditorGap.test.ts.snap +103 -0
- package/src/components.json +1 -0
- package/src/index.ts +10 -0
- package/src/nuxt.ts +6 -2
|
@@ -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
|
|
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
|
-
|
|
133
|
-
const
|
|
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="
|
|
203
|
+
:ref="cellRef(cell.index)"
|
|
220
204
|
type="button"
|
|
221
205
|
role="radio"
|
|
222
206
|
:aria-checked="cell.checked"
|