@nexxtmove/ui 1.51.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/nuxt.js CHANGED
@@ -94,6 +94,7 @@ var s = {
94
94
  NexxtEditorLineHeight: "components/editor/molecules/EditorLineHeight/EditorLineHeight.vue",
95
95
  NexxtEditorLineStyle: "components/editor/molecules/EditorLineStyle/EditorLineStyle.vue",
96
96
  NexxtEditorMaxWidth: "components/editor/molecules/EditorMaxWidth/EditorMaxWidth.vue",
97
+ NexxtEditorOverrideStatus: "components/editor/molecules/EditorOverrideStatus/EditorOverrideStatus.vue",
97
98
  NexxtEditorPadding: "components/editor/molecules/EditorPadding/EditorPadding.vue",
98
99
  NexxtEditorRadius: "components/editor/molecules/EditorRadius/EditorRadius.vue",
99
100
  NexxtEditorShadow: "components/editor/molecules/EditorShadow/EditorShadow.vue",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nexxtmove/ui",
3
3
  "type": "module",
4
- "version": "1.51.0",
4
+ "version": "1.52.0",
5
5
  "exports": {
6
6
  ".": {
7
7
  "types": "./dist/index.d.ts",
@@ -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>
@@ -91,6 +91,7 @@
91
91
  "NexxtEditorLineHeight": "components/editor/molecules/EditorLineHeight/EditorLineHeight.vue",
92
92
  "NexxtEditorLineStyle": "components/editor/molecules/EditorLineStyle/EditorLineStyle.vue",
93
93
  "NexxtEditorMaxWidth": "components/editor/molecules/EditorMaxWidth/EditorMaxWidth.vue",
94
+ "NexxtEditorOverrideStatus": "components/editor/molecules/EditorOverrideStatus/EditorOverrideStatus.vue",
94
95
  "NexxtEditorPadding": "components/editor/molecules/EditorPadding/EditorPadding.vue",
95
96
  "NexxtEditorRadius": "components/editor/molecules/EditorRadius/EditorRadius.vue",
96
97
  "NexxtEditorShadow": "components/editor/molecules/EditorShadow/EditorShadow.vue",
package/src/index.ts CHANGED
@@ -226,6 +226,7 @@ export { default as NexxtEditorLetterSpacing } from './components/editor/molecul
226
226
  export { default as NexxtEditorLineHeight } from './components/editor/molecules/EditorLineHeight/EditorLineHeight.vue'
227
227
  export { default as NexxtEditorLineStyle } from './components/editor/molecules/EditorLineStyle/EditorLineStyle.vue'
228
228
  export { default as NexxtEditorMaxWidth } from './components/editor/molecules/EditorMaxWidth/EditorMaxWidth.vue'
229
+ export { default as NexxtEditorOverrideStatus } from './components/editor/molecules/EditorOverrideStatus/EditorOverrideStatus.vue'
229
230
  export { default as NexxtEditorPadding } from './components/editor/molecules/EditorPadding/EditorPadding.vue'
230
231
  export { default as NexxtEditorRadius } from './components/editor/molecules/EditorRadius/EditorRadius.vue'
231
232
  export { default as NexxtEditorSettingsBlock } from './components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.vue'