@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/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/components/editor/molecules/EditorOverrideStatus/EditorOverrideStatus.vue.d.ts +44 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +1595 -1144
- package/dist/nuxt.js +2 -0
- package/package.json +1 -1
- 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/editor/molecules/EditorOverrideStatus/EditorOverrideStatus.vue +213 -0
- package/src/components.json +2 -0
- package/src/index.ts +10 -0
|
@@ -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"
|
|
@@ -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
|
|
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
|
|
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 [&>label]:flex [&>label]:w-full [&>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 [&>label]:flex [&>label]:w-full [&>label]:gap-3">
|
|
66
|
+
<p class="small-normal text-gray-900">Tussenruimte</p>
|
|
67
|
+
</div>
|
|
68
|
+
<div class="grid grid-rows-[1fr]">
|
|
69
|
+
<!--
|
|
70
|
+
\`overflow-hidden\` is what makes the 0fr collapse clip, but it also clips
|
|
71
|
+
a focus ring on a full-width control in the slot, and the block has no
|
|
72
|
+
horizontal padding of its own. \`-mx-1\` widens the clipping box by 4px on
|
|
73
|
+
each side and the \`px-1\` below pushes the content back, so the ring has
|
|
74
|
+
room while the content keeps the same x position as the label.
|
|
75
|
+
-->
|
|
76
|
+
<div class="-mx-1 min-h-0 overflow-hidden opacity-100">
|
|
77
|
+
<!-- Padding sits on the inner element so nothing is left over at 0fr. -->
|
|
78
|
+
<div class="px-1 pt-1.5 pb-1">
|
|
79
|
+
<div class="flex items-center gap-4">
|
|
80
|
+
<!--
|
|
81
|
+
Slider's own \`showValue\` prints its model, which here is the step index
|
|
82
|
+
rather than the gap, so the readout is rendered next to it instead. The
|
|
83
|
+
thumb has the same problem in \`aria-valuenow\`, which \`aria-valuetext\`
|
|
84
|
+
overrides with the string the readout shows. The block label is a <p>,
|
|
85
|
+
not a <label>, so the name goes on the thumb explicitly.
|
|
86
|
+
-->
|
|
87
|
+
<div class="flex w-full flex-col gap-2">
|
|
88
|
+
<!--v-if-->
|
|
89
|
+
<div class="flex items-center gap-4"><span data-slider-impl="" dir="ltr" data-orientation="horizontal" style="--reka-slider-thumb-transform: translateX(-50%);" class="relative flex w-full touch-none items-center select-none cursor-pointer" aria-disabled="false"><span data-orientation="horizontal" class="relative w-full grow rounded-full bg-gray-200 h-1"><span data-orientation="horizontal" style="left: 0%; right: 100%;" class="absolute h-full rounded-full bg-cornflower-blue-500"></span></span><span data-reka-collection-item="" aria-label="Tussenruimte" aria-valuetext="0 px" class="block size-4 rounded-full border shadow-sm transition-colors border-gray-200 bg-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cornflower-blue-500" role="slider" tabindex="0" data-orientation="horizontal" aria-valuemin="0" aria-valuemax="14" aria-orientation="horizontal" style="transform: var(--reka-slider-thumb-transform); position: absolute; left: calc(0% + 0px); display: none;"></span>
|
|
90
|
+
<!--v-if--></span>
|
|
91
|
+
<!--v-if-->
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
<!--
|
|
95
|
+
gray-700, not the gray-500 Slider uses for its own readout: axe puts
|
|
96
|
+
gray-500 on white at 1.84:1, well under the 4.5:1 it needs at this size.
|
|
97
|
+
--><span class="min-w-12 shrink-0 text-right small-normal whitespace-nowrap text-gray-700">0 px</span>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
</div>"
|
|
103
|
+
`;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
export interface NexxtEditorOverrideStatusProps {
|
|
3
|
+
/** Whether the setting differs from where its value comes from without an override. */
|
|
4
|
+
overridden: boolean
|
|
5
|
+
/**
|
|
6
|
+
* The status while the setting is not overridden, such as `'Volgt huisstijl'`
|
|
7
|
+
* or `'Grootte 100% (origineel)'`. With `hideWhenDefault` it is not shown,
|
|
8
|
+
* but it is still announced when a reset brings the setting back.
|
|
9
|
+
*/
|
|
10
|
+
label: string
|
|
11
|
+
/**
|
|
12
|
+
* The status while the setting is overridden, shown next to a dot. Defaults
|
|
13
|
+
* to the Dutch `'Aangepast'`; a value such as `'Grootte 150%'` works too.
|
|
14
|
+
*/
|
|
15
|
+
overriddenLabel?: string
|
|
16
|
+
/**
|
|
17
|
+
* Text of the reset button, shown only while overridden, such as
|
|
18
|
+
* `'Terug naar huisstijl'`.
|
|
19
|
+
*/
|
|
20
|
+
resetLabel: string
|
|
21
|
+
/** Render no status line at all while the setting is not overridden. */
|
|
22
|
+
hideWhenDefault?: boolean
|
|
23
|
+
/**
|
|
24
|
+
* A selector, matched inside the default slot, for the element that takes the
|
|
25
|
+
* focus after a reset. Defaults to the first focusable element in the slot.
|
|
26
|
+
* When it matches nothing focusable, the first focusable element is used, and
|
|
27
|
+
* without one the component itself takes the focus.
|
|
28
|
+
*/
|
|
29
|
+
focusTarget?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Everything that can take the focus; `tabIndex` and state are checked per element. */
|
|
33
|
+
const FOCUSABLE =
|
|
34
|
+
'a[href], button, input, select, textarea, [tabindex], [contenteditable="true"], [contenteditable=""]'
|
|
35
|
+
|
|
36
|
+
/** How long an announcement stays in the live region before it is cleared again. */
|
|
37
|
+
const ANNOUNCEMENT_MS = 1000
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<script lang="ts" setup>
|
|
41
|
+
import { computed, nextTick, onBeforeUnmount, ref, useTemplateRef, watch } from 'vue'
|
|
42
|
+
|
|
43
|
+
defineOptions({ name: 'NexxtEditorOverrideStatus' })
|
|
44
|
+
|
|
45
|
+
const {
|
|
46
|
+
overridden,
|
|
47
|
+
label,
|
|
48
|
+
overriddenLabel = 'Aangepast',
|
|
49
|
+
resetLabel,
|
|
50
|
+
hideWhenDefault = false,
|
|
51
|
+
focusTarget,
|
|
52
|
+
} = defineProps<NexxtEditorOverrideStatusProps>()
|
|
53
|
+
|
|
54
|
+
const emit = defineEmits<{
|
|
55
|
+
/** The reset button was pressed. The parent clears the override. */
|
|
56
|
+
reset: []
|
|
57
|
+
}>()
|
|
58
|
+
|
|
59
|
+
defineSlots<{
|
|
60
|
+
/** The control the status belongs to, rendered above the status line. */
|
|
61
|
+
default?: () => unknown
|
|
62
|
+
}>()
|
|
63
|
+
|
|
64
|
+
const root = useTemplateRef<HTMLElement>('root')
|
|
65
|
+
const row = useTemplateRef<HTMLElement>('row')
|
|
66
|
+
|
|
67
|
+
const status = computed(() => (overridden ? overriddenLabel : label))
|
|
68
|
+
|
|
69
|
+
const showRow = computed(() => overridden || !hideWhenDefault)
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Whether an element can take the focus right now. `tabIndex` is -1 for the
|
|
73
|
+
* cells a roving tabindex skips, so only the tab stop of such a group counts.
|
|
74
|
+
*/
|
|
75
|
+
const canFocus = (el: Element): el is HTMLElement =>
|
|
76
|
+
el instanceof HTMLElement &&
|
|
77
|
+
el.tabIndex >= 0 &&
|
|
78
|
+
!el.matches(':disabled') &&
|
|
79
|
+
!el.closest('[inert]') &&
|
|
80
|
+
// Not in jsdom; a real browser also skips elements that are not rendered.
|
|
81
|
+
(typeof el.checkVisibility !== 'function' || el.checkVisibility())
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Candidates in the slot only: the status line itself is about to go. An
|
|
85
|
+
* invalid selector matches nothing rather than throwing, so a typo in
|
|
86
|
+
* `focusTarget` cannot stop the reset.
|
|
87
|
+
*/
|
|
88
|
+
const inSlot = (selector: string) => {
|
|
89
|
+
let matches: Element[]
|
|
90
|
+
try {
|
|
91
|
+
matches = [...(root.value?.querySelectorAll(selector) ?? [])]
|
|
92
|
+
} catch {
|
|
93
|
+
// `process.env.NODE_ENV` stays in the library build, so the consumer's
|
|
94
|
+
// bundler decides whether this runs (as in NexxtTabs).
|
|
95
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
96
|
+
console.warn(
|
|
97
|
+
`[NexxtEditorOverrideStatus] \`focusTarget\` is not a valid selector: "${selector}". The focus goes to the first focusable element instead.`,
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
return []
|
|
101
|
+
}
|
|
102
|
+
return matches.filter((el): el is HTMLElement => !row.value?.contains(el) && canFocus(el))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The last resort: the component itself, focusable for this one move only. */
|
|
106
|
+
const focusRoot = () => {
|
|
107
|
+
const el = root.value
|
|
108
|
+
if (!el) return
|
|
109
|
+
const release = () => el.removeAttribute('tabindex')
|
|
110
|
+
el.setAttribute('tabindex', '-1')
|
|
111
|
+
el.addEventListener('blur', release, { once: true })
|
|
112
|
+
el.focus()
|
|
113
|
+
// Without the focus there is no blur to wait for: take the tabindex off now.
|
|
114
|
+
if (document.activeElement !== el) {
|
|
115
|
+
el.removeEventListener('blur', release)
|
|
116
|
+
release()
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const focusControl = () => {
|
|
121
|
+
const target = (focusTarget ? inSlot(focusTarget)[0] : undefined) ?? inSlot(FOCUSABLE)[0]
|
|
122
|
+
target?.focus()
|
|
123
|
+
if (!target || document.activeElement !== target) focusRoot()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const focusIsLost = () => {
|
|
127
|
+
const active = document.activeElement
|
|
128
|
+
return !active || active === document.body || !root.value?.contains(active)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The reset button goes with the override, which would drop the focus on
|
|
133
|
+
* `body`. The focus moves to the control first, while the button still
|
|
134
|
+
* exists; after the update it is checked again, in case the reset replaced the
|
|
135
|
+
* control it landed on. The reset is emitted whatever the focus move does.
|
|
136
|
+
*/
|
|
137
|
+
const onReset = async () => {
|
|
138
|
+
try {
|
|
139
|
+
focusControl()
|
|
140
|
+
} finally {
|
|
141
|
+
emit('reset')
|
|
142
|
+
}
|
|
143
|
+
await nextTick()
|
|
144
|
+
if (focusIsLost()) focusControl()
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Only the switch between overridden and not is announced, with the status
|
|
149
|
+
* that belongs to the new state. A new `overriddenLabel` within the override,
|
|
150
|
+
* such as "Grootte 110%" on every arrow key, stays silent: the control already
|
|
151
|
+
* announces its own value. And only while the focus is inside: then the switch
|
|
152
|
+
* follows from what the user just did (a reset, a change in the control).
|
|
153
|
+
* Switching to another element in the editor changes every status at once,
|
|
154
|
+
* and that stays silent. Cleared afterwards, so the copy is not read twice
|
|
155
|
+
* when browsing.
|
|
156
|
+
*/
|
|
157
|
+
const announcement = ref('')
|
|
158
|
+
let clearTimer: ReturnType<typeof setTimeout> | undefined
|
|
159
|
+
|
|
160
|
+
watch(
|
|
161
|
+
() => overridden,
|
|
162
|
+
() => {
|
|
163
|
+
if (!root.value?.contains(document.activeElement)) return
|
|
164
|
+
announcement.value = status.value
|
|
165
|
+
clearTimeout(clearTimer)
|
|
166
|
+
clearTimer = setTimeout(() => (announcement.value = ''), ANNOUNCEMENT_MS)
|
|
167
|
+
},
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
onBeforeUnmount(() => clearTimeout(clearTimer))
|
|
171
|
+
</script>
|
|
172
|
+
|
|
173
|
+
<template>
|
|
174
|
+
<div
|
|
175
|
+
ref="root"
|
|
176
|
+
class="rounded-sm outline-cornflower-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2"
|
|
177
|
+
>
|
|
178
|
+
<!--
|
|
179
|
+
`rounded-sm` and the outline only show when the root itself holds the
|
|
180
|
+
focus: the fallback after a reset when the slot has nothing focusable.
|
|
181
|
+
-->
|
|
182
|
+
<slot />
|
|
183
|
+
|
|
184
|
+
<div
|
|
185
|
+
v-if="showRow"
|
|
186
|
+
ref="row"
|
|
187
|
+
:class="['flex items-center justify-between gap-3', $slots.default && 'pt-2']"
|
|
188
|
+
>
|
|
189
|
+
<span
|
|
190
|
+
v-if="overridden"
|
|
191
|
+
class="flex items-center gap-1.5 extra-small-normal text-cornflower-blue-700"
|
|
192
|
+
>
|
|
193
|
+
<span aria-hidden="true" class="size-1.5 shrink-0 rounded-full bg-cornflower-blue-500" />
|
|
194
|
+
{{ status }}
|
|
195
|
+
</span>
|
|
196
|
+
<span v-else class="extra-small-normal text-gray-700">{{ status }}</span>
|
|
197
|
+
|
|
198
|
+
<!-- No `focus:outline-none`: in Tailwind v4 it hides the focus-visible ring. -->
|
|
199
|
+
<button
|
|
200
|
+
v-if="overridden"
|
|
201
|
+
type="button"
|
|
202
|
+
class="shrink-0 cursor-pointer rounded-sm extra-small-link text-gray-800 hover:text-gray-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cornflower-blue-600"
|
|
203
|
+
@click="onReset"
|
|
204
|
+
>
|
|
205
|
+
{{ resetLabel }}
|
|
206
|
+
</button>
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
<div role="status" aria-live="polite" aria-atomic="true" class="sr-only">
|
|
210
|
+
{{ announcement }}
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
</template>
|
package/src/components.json
CHANGED
|
@@ -77,6 +77,7 @@
|
|
|
77
77
|
"NexxtEditorSettingsBlock": "components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.vue",
|
|
78
78
|
"NexxtEditorSettingsGroup": "components/editor/atoms/EditorSettingsGroup/EditorSettingsGroup.vue",
|
|
79
79
|
"NexxtEditorAlignItems": "components/editor/molecules/EditorAlignItems/EditorAlignItems.vue",
|
|
80
|
+
"NexxtEditorAlignmentGrid": "components/editor/molecules/EditorAlignmentGrid/EditorAlignmentGrid.vue",
|
|
80
81
|
"NexxtEditorAnchor": "components/editor/molecules/EditorAnchor/EditorAnchor.vue",
|
|
81
82
|
"NexxtEditorBackgroundSize": "components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.vue",
|
|
82
83
|
"NexxtEditorBorder": "components/editor/molecules/EditorBorder/EditorBorder.vue",
|
|
@@ -91,6 +92,7 @@
|
|
|
91
92
|
"NexxtEditorLineHeight": "components/editor/molecules/EditorLineHeight/EditorLineHeight.vue",
|
|
92
93
|
"NexxtEditorLineStyle": "components/editor/molecules/EditorLineStyle/EditorLineStyle.vue",
|
|
93
94
|
"NexxtEditorMaxWidth": "components/editor/molecules/EditorMaxWidth/EditorMaxWidth.vue",
|
|
95
|
+
"NexxtEditorOverrideStatus": "components/editor/molecules/EditorOverrideStatus/EditorOverrideStatus.vue",
|
|
94
96
|
"NexxtEditorPadding": "components/editor/molecules/EditorPadding/EditorPadding.vue",
|
|
95
97
|
"NexxtEditorRadius": "components/editor/molecules/EditorRadius/EditorRadius.vue",
|
|
96
98
|
"NexxtEditorShadow": "components/editor/molecules/EditorShadow/EditorShadow.vue",
|