@nexxtmove/ui 1.50.0 → 1.52.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/molecules/EditorAnchor/EditorAnchor.vue.d.ts +103 -0
- package/dist/components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.vue.d.ts +12 -0
- package/dist/components/editor/molecules/EditorOverrideStatus/EditorOverrideStatus.vue.d.ts +44 -0
- package/dist/components/organisms/IconPicker/_IconPickerGrid.vue.d.ts +2 -2
- package/dist/index.d.ts +4 -0
- package/dist/index.js +1199 -949
- package/dist/nuxt.js +2 -0
- package/package.json +1 -1
- package/src/components/editor/molecules/EditorAnchor/EditorAnchor.vue +242 -0
- package/src/components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.vue +25 -3
- package/src/components/editor/molecules/EditorOverrideStatus/EditorOverrideStatus.vue +213 -0
- package/src/components.json +2 -0
- package/src/index.ts +9 -0
package/dist/nuxt.js
CHANGED
|
@@ -80,6 +80,7 @@ var s = {
|
|
|
80
80
|
NexxtEditorSettingsBlock: "components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.vue",
|
|
81
81
|
NexxtEditorSettingsGroup: "components/editor/atoms/EditorSettingsGroup/EditorSettingsGroup.vue",
|
|
82
82
|
NexxtEditorAlignItems: "components/editor/molecules/EditorAlignItems/EditorAlignItems.vue",
|
|
83
|
+
NexxtEditorAnchor: "components/editor/molecules/EditorAnchor/EditorAnchor.vue",
|
|
83
84
|
NexxtEditorBackgroundSize: "components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.vue",
|
|
84
85
|
NexxtEditorBorder: "components/editor/molecules/EditorBorder/EditorBorder.vue",
|
|
85
86
|
NexxtEditorColor: "components/editor/molecules/EditorColor/EditorColor.vue",
|
|
@@ -93,6 +94,7 @@ var s = {
|
|
|
93
94
|
NexxtEditorLineHeight: "components/editor/molecules/EditorLineHeight/EditorLineHeight.vue",
|
|
94
95
|
NexxtEditorLineStyle: "components/editor/molecules/EditorLineStyle/EditorLineStyle.vue",
|
|
95
96
|
NexxtEditorMaxWidth: "components/editor/molecules/EditorMaxWidth/EditorMaxWidth.vue",
|
|
97
|
+
NexxtEditorOverrideStatus: "components/editor/molecules/EditorOverrideStatus/EditorOverrideStatus.vue",
|
|
96
98
|
NexxtEditorPadding: "components/editor/molecules/EditorPadding/EditorPadding.vue",
|
|
97
99
|
NexxtEditorRadius: "components/editor/molecules/EditorRadius/EditorRadius.vue",
|
|
98
100
|
NexxtEditorShadow: "components/editor/molecules/EditorShadow/EditorShadow.vue",
|
package/package.json
CHANGED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/** The horizontal part of an anchor point. */
|
|
3
|
+
export type EditorAnchorX = 'left' | 'center' | 'right'
|
|
4
|
+
|
|
5
|
+
/** The vertical part of an anchor point. */
|
|
6
|
+
export type EditorAnchorY = 'top' | 'center' | 'bottom'
|
|
7
|
+
|
|
8
|
+
/** The point something is anchored to: a corner, an edge or the middle. */
|
|
9
|
+
export interface EditorAnchorValue {
|
|
10
|
+
x: EditorAnchorX
|
|
11
|
+
y: EditorAnchorY
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The nine cells, in reading order: row by row from the top, left to right.
|
|
16
|
+
* The labels are not in here; they arrive through `messages`.
|
|
17
|
+
*/
|
|
18
|
+
export const EDITOR_ANCHOR_OPTIONS = [
|
|
19
|
+
{ x: 'left', y: 'top' },
|
|
20
|
+
{ x: 'center', y: 'top' },
|
|
21
|
+
{ x: 'right', y: 'top' },
|
|
22
|
+
{ x: 'left', y: 'center' },
|
|
23
|
+
{ x: 'center', y: 'center' },
|
|
24
|
+
{ x: 'right', y: 'center' },
|
|
25
|
+
{ x: 'left', y: 'bottom' },
|
|
26
|
+
{ x: 'center', y: 'bottom' },
|
|
27
|
+
{ x: 'right', y: 'bottom' },
|
|
28
|
+
] as const satisfies readonly EditorAnchorValue[]
|
|
29
|
+
|
|
30
|
+
/** Overridable copy for the nine cells. Every key is optional. */
|
|
31
|
+
export interface EditorAnchorMessages {
|
|
32
|
+
/** Name of the top left cell. Defaults to `'Linksboven'`. */
|
|
33
|
+
topLeft?: string
|
|
34
|
+
/** Name of the top cell. Defaults to `'Boven'`. */
|
|
35
|
+
top?: string
|
|
36
|
+
/** Name of the top right cell. Defaults to `'Rechtsboven'`. */
|
|
37
|
+
topRight?: string
|
|
38
|
+
/** Name of the left cell. Defaults to `'Links'`. */
|
|
39
|
+
left?: string
|
|
40
|
+
/** Name of the middle cell. Defaults to `'Midden'`. */
|
|
41
|
+
center?: string
|
|
42
|
+
/** Name of the right cell. Defaults to `'Rechts'`. */
|
|
43
|
+
right?: string
|
|
44
|
+
/** Name of the bottom left cell. Defaults to `'Linksonder'`. */
|
|
45
|
+
bottomLeft?: string
|
|
46
|
+
/** Name of the bottom cell. Defaults to `'Onder'`. */
|
|
47
|
+
bottom?: string
|
|
48
|
+
/** Name of the bottom right cell. Defaults to `'Rechtsonder'`. */
|
|
49
|
+
bottomRight?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface NexxtEditorAnchorProps {
|
|
53
|
+
/**
|
|
54
|
+
* Accessible name of the radiogroup. Defaults to the Dutch
|
|
55
|
+
* `'Plek van de afbeelding'`; the library has no i18n layer, so a consumer
|
|
56
|
+
* in another language passes its own copy in. Only shown when `showLabel`
|
|
57
|
+
* is set.
|
|
58
|
+
*/
|
|
59
|
+
label?: string
|
|
60
|
+
/**
|
|
61
|
+
* Show `label` as a header above the grid. Off by default: the editor puts
|
|
62
|
+
* the name of the setting next to the grid itself.
|
|
63
|
+
*/
|
|
64
|
+
showLabel?: boolean
|
|
65
|
+
/**
|
|
66
|
+
* Names of the nine cells. Defaults are Dutch and are merged per key, so
|
|
67
|
+
* overriding one keeps the defaults of the others. Copy is never translated
|
|
68
|
+
* inside the component.
|
|
69
|
+
*/
|
|
70
|
+
messages?: EditorAnchorMessages
|
|
71
|
+
/** Block every interaction: no cell can be focused or chosen. */
|
|
72
|
+
disabled?: boolean
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const DEFAULT_MESSAGES = {
|
|
76
|
+
topLeft: 'Linksboven',
|
|
77
|
+
top: 'Boven',
|
|
78
|
+
topRight: 'Rechtsboven',
|
|
79
|
+
left: 'Links',
|
|
80
|
+
center: 'Midden',
|
|
81
|
+
right: 'Rechts',
|
|
82
|
+
bottomLeft: 'Linksonder',
|
|
83
|
+
bottom: 'Onder',
|
|
84
|
+
bottomRight: 'Rechtsonder',
|
|
85
|
+
} satisfies Required<EditorAnchorMessages>
|
|
86
|
+
|
|
87
|
+
/** The messages key of each cell, by row and then by column. */
|
|
88
|
+
const MESSAGE_KEYS: Record<EditorAnchorY, Record<EditorAnchorX, keyof EditorAnchorMessages>> = {
|
|
89
|
+
top: { left: 'topLeft', center: 'top', right: 'topRight' },
|
|
90
|
+
center: { left: 'left', center: 'center', right: 'right' },
|
|
91
|
+
bottom: { left: 'bottomLeft', center: 'bottom', right: 'bottomRight' },
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const COLUMNS = 3
|
|
95
|
+
const ROWS = EDITOR_ANCHOR_OPTIONS.length / COLUMNS
|
|
96
|
+
|
|
97
|
+
/** The cell that takes the tab stop while none is chosen. */
|
|
98
|
+
const MIDDLE = 4
|
|
99
|
+
</script>
|
|
100
|
+
|
|
101
|
+
<script lang="ts" setup>
|
|
102
|
+
import { computed, useId, useTemplateRef } from 'vue'
|
|
103
|
+
|
|
104
|
+
defineOptions({ name: 'NexxtEditorAnchor', inheritAttrs: false })
|
|
105
|
+
|
|
106
|
+
const {
|
|
107
|
+
label = 'Plek van de afbeelding',
|
|
108
|
+
showLabel = false,
|
|
109
|
+
messages,
|
|
110
|
+
disabled = false,
|
|
111
|
+
} = defineProps<NexxtEditorAnchorProps>()
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The chosen cell. `null` while none is, e.g. for a stored position that no
|
|
115
|
+
* cell stands for.
|
|
116
|
+
*/
|
|
117
|
+
const model = defineModel<EditorAnchorValue | null>({ required: true })
|
|
118
|
+
|
|
119
|
+
const labelId = useId()
|
|
120
|
+
|
|
121
|
+
const cellEls = useTemplateRef<HTMLButtonElement[]>('cellButtons')
|
|
122
|
+
|
|
123
|
+
const copy = computed(() => ({ ...DEFAULT_MESSAGES, ...messages }))
|
|
124
|
+
|
|
125
|
+
const selectedIndex = computed(() => {
|
|
126
|
+
const index = EDITOR_ANCHOR_OPTIONS.findIndex(
|
|
127
|
+
({ x, y }) => x === model.value?.x && y === model.value?.y,
|
|
128
|
+
)
|
|
129
|
+
return index === -1 ? null : index
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
/** Roving tabindex: one tab stop, on the chosen cell or else on the middle. */
|
|
133
|
+
const tabStop = computed(() => selectedIndex.value ?? MIDDLE)
|
|
134
|
+
|
|
135
|
+
const cells = computed(() =>
|
|
136
|
+
EDITOR_ANCHOR_OPTIONS.map(({ x, y }, index) => {
|
|
137
|
+
const checked = index === selectedIndex.value
|
|
138
|
+
return {
|
|
139
|
+
index,
|
|
140
|
+
label: copy.value[MESSAGE_KEYS[y][x]],
|
|
141
|
+
checked,
|
|
142
|
+
tabindex: index === tabStop.value ? 0 : -1,
|
|
143
|
+
// -600, not -500: the white dot on -500 reaches only 2.78:1. The focus
|
|
144
|
+
// outline below is -600 for the same reason, against the white around it.
|
|
145
|
+
cellClass: [
|
|
146
|
+
checked ? 'bg-cornflower-blue-600' : !disabled && 'hover:bg-gray-100',
|
|
147
|
+
disabled ? 'cursor-not-allowed' : 'cursor-pointer',
|
|
148
|
+
],
|
|
149
|
+
dotClass: checked ? 'size-2.5 bg-white' : 'size-1.5 bg-gray-400',
|
|
150
|
+
}
|
|
151
|
+
}),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
/** The visible header names the group when shown; otherwise the label does. */
|
|
155
|
+
const groupName = computed(() =>
|
|
156
|
+
showLabel ? { 'aria-labelledby': labelId } : { 'aria-label': label },
|
|
157
|
+
)
|
|
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
|
+
</script>
|
|
194
|
+
|
|
195
|
+
<template>
|
|
196
|
+
<p v-if="showLabel" :id="labelId" class="mb-1.5 small-normal text-gray-900">{{ label }}</p>
|
|
197
|
+
<!--
|
|
198
|
+
`w-fit`: the grid columns are fractions, so in block flow the ring would
|
|
199
|
+
stretch to the full width of the parent.
|
|
200
|
+
-->
|
|
201
|
+
<div
|
|
202
|
+
role="radiogroup"
|
|
203
|
+
v-bind="{ ...groupName, ...$attrs }"
|
|
204
|
+
:aria-disabled="disabled || undefined"
|
|
205
|
+
:class="[
|
|
206
|
+
'grid w-fit grid-cols-3 gap-1 rounded-lg p-1 ring ring-gray-200',
|
|
207
|
+
disabled && 'opacity-50',
|
|
208
|
+
]"
|
|
209
|
+
>
|
|
210
|
+
<!--
|
|
211
|
+
No `focus:outline-none` next to the focus-visible outline: in Tailwind v4
|
|
212
|
+
it sets the outline style to none, which the focus-visible variant does
|
|
213
|
+
not set back, so the ring would never show. The outline is -600, not
|
|
214
|
+
-500: -500 reaches 2.78:1 on white, short of the 3:1 for non-text.
|
|
215
|
+
-->
|
|
216
|
+
<button
|
|
217
|
+
v-for="cell in cells"
|
|
218
|
+
:key="cell.index"
|
|
219
|
+
ref="cellButtons"
|
|
220
|
+
type="button"
|
|
221
|
+
role="radio"
|
|
222
|
+
:aria-checked="cell.checked"
|
|
223
|
+
:aria-label="cell.label"
|
|
224
|
+
:tabindex="cell.tabindex"
|
|
225
|
+
:disabled="disabled"
|
|
226
|
+
:class="[
|
|
227
|
+
'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',
|
|
228
|
+
cell.cellClass,
|
|
229
|
+
]"
|
|
230
|
+
@click="choose(cell.index)"
|
|
231
|
+
@keydown="onKeydown($event, cell.index)"
|
|
232
|
+
>
|
|
233
|
+
<span
|
|
234
|
+
aria-hidden="true"
|
|
235
|
+
:class="[
|
|
236
|
+
'rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none',
|
|
237
|
+
cell.dotClass,
|
|
238
|
+
]"
|
|
239
|
+
/>
|
|
240
|
+
</button>
|
|
241
|
+
</div>
|
|
242
|
+
</template>
|
|
@@ -41,6 +41,18 @@ export interface NexxtEditorBackgroundSizeProps {
|
|
|
41
41
|
* others. Copy is never translated inside the component.
|
|
42
42
|
*/
|
|
43
43
|
messages?: EditorBackgroundSizeMessages
|
|
44
|
+
/**
|
|
45
|
+
* The options to offer, as a subset of the three. They are always shown in
|
|
46
|
+
* the order of `EDITOR_BACKGROUND_SIZE_OPTIONS`, whatever order this list is
|
|
47
|
+
* in; a duplicate or unknown value is ignored. Defaults to all three.
|
|
48
|
+
*/
|
|
49
|
+
options?: EditorBackgroundSizeValue[]
|
|
50
|
+
/**
|
|
51
|
+
* Render only the options, without the settings block and its header, for a
|
|
52
|
+
* consumer that puts the control in a block of its own. `label` then names
|
|
53
|
+
* the options toolbar only and is not shown.
|
|
54
|
+
*/
|
|
55
|
+
bare?: boolean
|
|
44
56
|
}
|
|
45
57
|
|
|
46
58
|
const DEFAULT_MESSAGES = {
|
|
@@ -58,7 +70,12 @@ import type { NexxtToggleGroupOption } from '../../../molecules/ToggleGroup/Togg
|
|
|
58
70
|
|
|
59
71
|
defineOptions({ name: 'NexxtEditorBackgroundSize' })
|
|
60
72
|
|
|
61
|
-
const {
|
|
73
|
+
const {
|
|
74
|
+
label = 'Vulling',
|
|
75
|
+
messages,
|
|
76
|
+
options: offered,
|
|
77
|
+
bare = false,
|
|
78
|
+
} = defineProps<NexxtEditorBackgroundSizeProps>()
|
|
62
79
|
|
|
63
80
|
/** The selected background size, as the Tailwind class to apply. */
|
|
64
81
|
const model = defineModel<EditorBackgroundSizeValue>({ required: true })
|
|
@@ -83,8 +100,12 @@ const ICONS: Record<
|
|
|
83
100
|
auto: 'image',
|
|
84
101
|
}
|
|
85
102
|
|
|
103
|
+
// Filtered from the full list rather than mapped from `offered`, so the order is
|
|
104
|
+
// always the canonical one and a duplicate in `offered` cannot double a button.
|
|
86
105
|
const options = computed<NexxtToggleGroupOption[]>(() =>
|
|
87
|
-
EDITOR_BACKGROUND_SIZE_OPTIONS.
|
|
106
|
+
EDITOR_BACKGROUND_SIZE_OPTIONS.filter(
|
|
107
|
+
({ token }) => !offered || offered.includes(`bg-${token}`),
|
|
108
|
+
).map(({ token }) => ({
|
|
88
109
|
value: `bg-${token}`,
|
|
89
110
|
icon: ICONS[token],
|
|
90
111
|
ariaLabel: copy.value[token],
|
|
@@ -104,7 +125,8 @@ const selected = computed({
|
|
|
104
125
|
</script>
|
|
105
126
|
|
|
106
127
|
<template>
|
|
107
|
-
<
|
|
128
|
+
<NexxtToggleGroup v-if="bare" v-model="selected" :options="options" :aria-label="label" />
|
|
129
|
+
<NexxtEditorSettingsBlock v-else :label="label">
|
|
108
130
|
<NexxtToggleGroup v-model="selected" :options="options" :aria-label="label" />
|
|
109
131
|
</NexxtEditorSettingsBlock>
|
|
110
132
|
</template>
|
|
@@ -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
|
+
"NexxtEditorAnchor": "components/editor/molecules/EditorAnchor/EditorAnchor.vue",
|
|
80
81
|
"NexxtEditorBackgroundSize": "components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.vue",
|
|
81
82
|
"NexxtEditorBorder": "components/editor/molecules/EditorBorder/EditorBorder.vue",
|
|
82
83
|
"NexxtEditorColor": "components/editor/molecules/EditorColor/EditorColor.vue",
|
|
@@ -90,6 +91,7 @@
|
|
|
90
91
|
"NexxtEditorLineHeight": "components/editor/molecules/EditorLineHeight/EditorLineHeight.vue",
|
|
91
92
|
"NexxtEditorLineStyle": "components/editor/molecules/EditorLineStyle/EditorLineStyle.vue",
|
|
92
93
|
"NexxtEditorMaxWidth": "components/editor/molecules/EditorMaxWidth/EditorMaxWidth.vue",
|
|
94
|
+
"NexxtEditorOverrideStatus": "components/editor/molecules/EditorOverrideStatus/EditorOverrideStatus.vue",
|
|
93
95
|
"NexxtEditorPadding": "components/editor/molecules/EditorPadding/EditorPadding.vue",
|
|
94
96
|
"NexxtEditorRadius": "components/editor/molecules/EditorRadius/EditorRadius.vue",
|
|
95
97
|
"NexxtEditorShadow": "components/editor/molecules/EditorShadow/EditorShadow.vue",
|
package/src/index.ts
CHANGED
|
@@ -39,6 +39,13 @@ export type {
|
|
|
39
39
|
EditorAlignItemsMessages,
|
|
40
40
|
EditorAlignItemsValue,
|
|
41
41
|
} from './components/editor/molecules/EditorAlignItems/EditorAlignItems.vue'
|
|
42
|
+
export { EDITOR_ANCHOR_OPTIONS } from './components/editor/molecules/EditorAnchor/EditorAnchor.vue'
|
|
43
|
+
export type {
|
|
44
|
+
EditorAnchorMessages,
|
|
45
|
+
EditorAnchorValue,
|
|
46
|
+
EditorAnchorX,
|
|
47
|
+
EditorAnchorY,
|
|
48
|
+
} from './components/editor/molecules/EditorAnchor/EditorAnchor.vue'
|
|
42
49
|
export { EDITOR_BACKGROUND_SIZE_OPTIONS } from './components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.vue'
|
|
43
50
|
export type {
|
|
44
51
|
EditorBackgroundSizeMessages,
|
|
@@ -204,6 +211,7 @@ export { default as NexxtDatePicker } from './components/organisms/DatePicker/Da
|
|
|
204
211
|
export { default as NexxtDoughnutChart } from './components/molecules/DoughnutChart/DoughnutChart.vue'
|
|
205
212
|
export { default as NexxtDropdownButton } from './components/organisms/DropdownButton/DropdownButton.vue'
|
|
206
213
|
export { default as NexxtEditorAlignItems } from './components/editor/molecules/EditorAlignItems/EditorAlignItems.vue'
|
|
214
|
+
export { default as NexxtEditorAnchor } from './components/editor/molecules/EditorAnchor/EditorAnchor.vue'
|
|
207
215
|
export { default as NexxtEditorBackgroundSize } from './components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.vue'
|
|
208
216
|
export { default as NexxtEditorBorder } from './components/editor/molecules/EditorBorder/EditorBorder.vue'
|
|
209
217
|
export { default as NexxtEditorColor } from './components/editor/molecules/EditorColor/EditorColor.vue'
|
|
@@ -218,6 +226,7 @@ export { default as NexxtEditorLetterSpacing } from './components/editor/molecul
|
|
|
218
226
|
export { default as NexxtEditorLineHeight } from './components/editor/molecules/EditorLineHeight/EditorLineHeight.vue'
|
|
219
227
|
export { default as NexxtEditorLineStyle } from './components/editor/molecules/EditorLineStyle/EditorLineStyle.vue'
|
|
220
228
|
export { default as NexxtEditorMaxWidth } from './components/editor/molecules/EditorMaxWidth/EditorMaxWidth.vue'
|
|
229
|
+
export { default as NexxtEditorOverrideStatus } from './components/editor/molecules/EditorOverrideStatus/EditorOverrideStatus.vue'
|
|
221
230
|
export { default as NexxtEditorPadding } from './components/editor/molecules/EditorPadding/EditorPadding.vue'
|
|
222
231
|
export { default as NexxtEditorRadius } from './components/editor/molecules/EditorRadius/EditorRadius.vue'
|
|
223
232
|
export { default as NexxtEditorSettingsBlock } from './components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.vue'
|