@redseed/redseed-ui-vue3 10.0.0 → 10.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,388 @@
1
+ <script setup>
2
+ import { computed, onBeforeUnmount, onUpdated, ref, useSlots, watch, watchEffect } from 'vue'
3
+ import Icon from '../Icon/Icon.vue'
4
+ import { XMarkIcon } from '@heroicons/vue/24/outline'
5
+ import { rendersContent } from '../../helpers/slots'
6
+
7
+ /**
8
+ * A brief confirmation that an action completed, bottom-left, past tense.
9
+ *
10
+ * Distinct from MessageBox, which describes the state of the thing on screen and stays
11
+ * until the page changes:
12
+ *
13
+ * MessageBox Toast
14
+ * placement in the page flow fixed, bottom-left
15
+ * lifetime until the page changes auto-dismisses, UNLESS it carries an action
16
+ * purpose state of the thing on screen an action completed
17
+ *
18
+ * Declarative rather than imperative. `v-model` is the whole API, which means the
19
+ * consumer owns when it shows — including from an Inertia flash prop, which is how the
20
+ * LMS already drives its banners. An imperative `useToast()` would be a thin layer over
21
+ * this component rather than a different one, so it can be added later without changing
22
+ * anything here.
23
+ *
24
+ * ONE AT A TIME. Every toast is `position: fixed` at the same bottom-left coordinates
25
+ * with the same z-index, so a second one renders exactly on top of the first — the second
26
+ * hides it completely, and a screen reader announces both while only one is visible.
27
+ * There is no stacking or queueing here, so the consumer must show one at a time. A
28
+ * dev-only warning below says so if two are ever open at once.
29
+ */
30
+ const props = defineProps({
31
+ /**
32
+ * Whether the toast is showing. Use with v-model.
33
+ */
34
+ modelValue: {
35
+ type: Boolean,
36
+ default: false,
37
+ },
38
+ /**
39
+ * How long it stays, in milliseconds. 0 keeps it up until it is dismissed.
40
+ *
41
+ * The default follows Material's snackbar: long enough to read a short sentence,
42
+ * short enough not to sit over the page.
43
+ *
44
+ * IGNORED when the toast carries an action — see `visibleDuration`. A toast that
45
+ * offers Undo and then takes it away is the failure this component must not have.
46
+ */
47
+ duration: {
48
+ type: Number,
49
+ default: 5000,
50
+ // Negative, NaN and Infinity all pass `type: Number` and all make setTimeout fire
51
+ // immediately — a toast that paints and vanishes in the same frame, which reads
52
+ // as "it never showed". Rejected here and defended again in visibleDuration(),
53
+ // because validators only warn and are stripped from production builds.
54
+ validator: (value) => Number.isFinite(value) && value >= 0,
55
+ },
56
+ /**
57
+ * Errors are assertive; confirmations are not.
58
+ *
59
+ * A confirmation announces politely, after whatever the screen reader is currently
60
+ * saying. An error interrupts, because the reader needs to know their action did not
61
+ * take effect before they move on.
62
+ */
63
+ variant: {
64
+ type: String,
65
+ default: 'default',
66
+ // The list is written out here rather than referencing VARIANTS below, and that
67
+ // is forced rather than sloppy: defineProps() is hoisted outside setup(), so it
68
+ // cannot close over a local. A test asserts the two stay in step.
69
+ validator: (value) => ['default', 'success', 'error'].includes(value),
70
+ },
71
+ closeLabel: {
72
+ type: String,
73
+ default: 'Dismiss',
74
+ },
75
+ })
76
+
77
+ const emit = defineEmits(['update:modelValue', 'close'])
78
+
79
+ const VARIANTS = ['default', 'success', 'error']
80
+
81
+ const isKnownVariant = computed(() => VARIANTS.includes(props.variant))
82
+
83
+ const safeVariant = computed(() => isKnownVariant.value ? props.variant : 'default')
84
+
85
+ const slots = useSlots()
86
+
87
+ /**
88
+ * Whether the action slot draws anything.
89
+ *
90
+ * A function, never a computed: useSlots() is not reactive, so a computed over it caches
91
+ * its first answer for the life of the component (#332).
92
+ *
93
+ * And it asks what the slot RENDERS, not whether it was passed — `<template #action>`
94
+ * around a falsy v-if is a slot that exists and draws nothing, which would otherwise
95
+ * strand an ordinary confirmation on screen forever, waiting to be dismissed by hand.
96
+ * The scope is forwarded because a consumer destructuring `{ close }` compiles to a
97
+ * function that throws when probed bare.
98
+ */
99
+ function rendersAction() {
100
+ return rendersContent(slots.action, { close })
101
+ }
102
+
103
+ /**
104
+ * The probe's answer, as a tracked value.
105
+ *
106
+ * Avoiding the `computed` cache is necessary but NOT sufficient, which is the bug this
107
+ * ref exists to fix: a function that nothing re-invokes caches exactly as hard as a
108
+ * computed does. The timer watcher below only re-runs on modelValue, duration and
109
+ * isPaused — the action's rendered state is none of those — so a `v-if` inside the slot
110
+ * could flip with nothing re-deciding the lifetime:
111
+ *
112
+ * - Undo appears at t=2s while a 5s timer is already armed, and the toast dismisses at
113
+ * t=5s taking Undo with it. Exactly the WCAG 2.2.1 failure this component is built to
114
+ * prevent, arriving through the back door.
115
+ * - Undo disappears, and the ordinary confirmation left behind never dismisses.
116
+ *
117
+ * onUpdated is the hook that sees it: a stable slot's reactive reads are tracked by THIS
118
+ * component's render effect, so the Toast re-renders when the consumer's condition flips.
119
+ * Guarded on an actual change, because restarting on every update would let an unrelated
120
+ * parent re-render hold the countdown open indefinitely.
121
+ */
122
+ const hasAction = ref(rendersAction())
123
+
124
+ onUpdated(() => {
125
+ const rendersNow = rendersAction()
126
+
127
+ if (rendersNow === hasAction.value) return
128
+
129
+ hasAction.value = rendersNow
130
+ })
131
+
132
+ /**
133
+ * A toast carrying an action DOES NOT auto-dismiss. `duration` is ignored entirely.
134
+ *
135
+ * This started as a doubled duration, which is not a fix. A keyboard user cannot pause
136
+ * what they have not reached yet: pause-on-focus only helps once focus is already inside
137
+ * the toast, and arriving there is the whole race — Tab has to walk from wherever they
138
+ * are, and the toast is teleported to the end of the body. Doubling 5s to 10s just moves
139
+ * the line they have to beat.
140
+ *
141
+ * So an auto-dismissing toast carrying the only route to Undo is a time limit on that
142
+ * action (WCAG 2.2.1), and the honest resolutions are the two ends: no action, or no
143
+ * auto-dismiss. It takes the second. A toast with an action stays until it is dismissed —
144
+ * by the action, the close button, or Escape.
145
+ *
146
+ * The countdown still pauses on hover and focus for the no-action case, where it is a
147
+ * courtesy to a slow reader rather than the only thing standing between them and a
148
+ * control they cannot reach.
149
+ */
150
+ function visibleDuration() {
151
+ if (hasAction.value) return 0
152
+
153
+ // Defended here as well as in the validator: validators only warn, and are stripped
154
+ // from production builds entirely.
155
+ if (!Number.isFinite(props.duration) || props.duration < 0) return 0
156
+
157
+ // setTimeout clamps anything past a signed 32-bit millisecond count to immediate,
158
+ // which would turn "a very long toast" into "no toast".
159
+ return Math.min(props.duration, 2_147_483_647)
160
+ }
161
+
162
+ /**
163
+ * WCAG 2.2.1: the countdown pauses while the pointer is over the toast or while focus is
164
+ * inside it. Someone reading it is not finished with it, and a toast that vanishes
165
+ * mid-sentence is the failure this guards against.
166
+ *
167
+ * Only reachable in the no-action case — a toast carrying an action has no countdown to
168
+ * pause in the first place.
169
+ */
170
+ const isPaused = ref(false)
171
+
172
+ let timer = null
173
+
174
+ function clearTimer() {
175
+ if (timer === null) return
176
+
177
+ clearTimeout(timer)
178
+ timer = null
179
+ }
180
+
181
+ function startTimer() {
182
+ clearTimer()
183
+
184
+ /*
185
+ * Closed: reset the pause and stop, before anything else.
186
+ *
187
+ * The reset matters because `mouseleave` does NOT fire for an element removed from
188
+ * under the pointer — verified in Chrome: no boundary event at removal, and none on
189
+ * the next mouse move. So a toast closed from outside (a route change, an Inertia
190
+ * visit clearing the flash) while the pointer happened to be resting over it would
191
+ * leave isPaused stuck true, and every later toast from this same instance would hit
192
+ * the guard below and never arm a timer. A toast that silently stops dismissing, with
193
+ * no way back except hovering it and moving away.
194
+ *
195
+ * Stopping here also keeps the slot probe out of the closed case. visibleDuration()
196
+ * runs the consumer's render code, and running it for a toast Vue has not rendered
197
+ * can throw out of a consumer expression that is only safe once the toast is up —
198
+ * `deleted.name` on a null — which Vue routes to app.config.errorHandler and reports
199
+ * as a production error for a toast nobody saw.
200
+ */
201
+ if (!props.modelValue) {
202
+ isPaused.value = false
203
+
204
+ return
205
+ }
206
+
207
+ const duration = visibleDuration()
208
+
209
+ if (duration === 0 || isPaused.value) return
210
+
211
+ timer = setTimeout(() => close('timeout'), duration)
212
+ }
213
+
214
+ function close(reason = 'dismiss') {
215
+ clearTimer()
216
+ emit('update:modelValue', false)
217
+ emit('close', reason)
218
+ }
219
+
220
+ /*
221
+ * The timer restarts on every input that changes it: opening and closing, pausing and
222
+ * resuming, a duration the consumer changes while it is up, and the action appearing or
223
+ * disappearing. Pausing then resuming gives the full duration again rather than the
224
+ * remainder — the reader has just looked away from it, so time already spent reading is
225
+ * not time they still have.
226
+ *
227
+ * Load-bearing and easy to break: this watcher is PRE-FLUSH and deduped by job id, which
228
+ * is what makes Tab between the action and the close button safe. `focusout` bubbles and
229
+ * fires before the matching `focusin`, so isPaused goes true -> false -> true inside one
230
+ * tick and startTimer runs once, at flush, reading the final value. Switching this to
231
+ * `flush: 'sync'` would restart the countdown in the middle of a keyboard journey
232
+ * through the toast.
233
+ */
234
+ watch(
235
+ () => [props.modelValue, props.duration, isPaused.value, hasAction.value],
236
+ startTimer,
237
+ { immediate: true },
238
+ )
239
+
240
+ onBeforeUnmount(clearTimer)
241
+
242
+ function pause() {
243
+ isPaused.value = true
244
+ }
245
+
246
+ function resume() {
247
+ isPaused.value = false
248
+ }
249
+
250
+ /**
251
+ * Escape dismisses, matching every other transient surface in the library.
252
+ *
253
+ * Bound to the toast rather than to the document: a toast does not trap focus and does
254
+ * not own the page, so swallowing Escape from wherever the user happens to be would take
255
+ * it from a dialog or a menu that has a better claim on it.
256
+ */
257
+ function handleKeydown(event) {
258
+ if (event.key !== 'Escape' || event.repeat) return
259
+
260
+ close('escape')
261
+ }
262
+
263
+ const toastClass = computed(() => [
264
+ 'rsui-toast',
265
+ `rsui-toast--${safeVariant.value}`,
266
+ ])
267
+
268
+ /**
269
+ * Dev-only, matching MessageBox, ButtonSlot and LinkSlot — this package ships raw `.vue`
270
+ * source, so an ungated warn reaches real users' consoles.
271
+ */
272
+ watchEffect(() => {
273
+ if (process.env.NODE_ENV === 'production') return
274
+
275
+ if (!isKnownVariant.value) {
276
+ console.warn(`[RSUI] Toast: variant "${props.variant}" is not supported (${VARIANTS.join(', ')}). Falling back to "default".`)
277
+ }
278
+
279
+ if (!Number.isFinite(props.duration) || props.duration < 0) {
280
+ console.warn(`[RSUI] Toast: duration must be a finite, non-negative number of milliseconds; received ${props.duration}. Staying up until dismissed. Use 0 to ask for that deliberately.`)
281
+ }
282
+ // The floor is this library's, not the specification's. WCAG 2.2.1 requires a time
283
+ // limit to be adjustable, extendable or switchable off — it names no minimum display
284
+ // time. 5000ms is Material's guidance, and quoting a number as if the criterion
285
+ // contained it is the kind of claim that gets read back in an audit.
286
+ else if (props.duration !== 0 && props.duration < 5000 && !hasAction.value) {
287
+ console.warn(`[RSUI] Toast: duration ${props.duration}ms is below the 5000ms this library treats as the floor for a message that disappears on its own. Use 0 to keep it up until dismissed.`)
288
+ }
289
+
290
+ if (props.modelValue && hasAction.value && props.duration !== 5000) {
291
+ console.warn('[RSUI] Toast: `duration` is ignored when the toast carries an action — an actionable toast never auto-dismisses, because handing someone an Undo and then taking it away is a time limit on that action (WCAG 2.2.1). Remove the prop, or remove the action.')
292
+ }
293
+
294
+ if (!props.modelValue) return
295
+
296
+ if (!rendersContent(slots.default)) {
297
+ console.warn('[RSUI] Toast: no message. The default slot is what the toast is for — an empty live region announces nothing and renders a pill containing only a close button.')
298
+ }
299
+
300
+ if (document.querySelectorAll('.rsui-toast').length > 1) {
301
+ console.warn('[RSUI] Toast: more than one toast is open. They are all fixed to the same bottom-left position, so they render exactly on top of each other and a screen reader announces every one while only the last is visible. Show one at a time.')
302
+ }
303
+
304
+ /*
305
+ * An ACTIONABLE toast raised over a modal is a keyboard trap, and a silent one.
306
+ *
307
+ * Modal binds trapTab to the document and pulls focus back into its panel on every
308
+ * Tab (Modal.vue:195). The toast is teleported to the end of the body, outside that
309
+ * panel, so it cannot be reached — its position on screen is irrelevant, which is why
310
+ * a centred modal and a bottom-left toast not overlapping does not help.
311
+ *
312
+ * Narrowed to the actionable case on purpose. A plain confirmation is also unreachable
313
+ * while the modal is up, but it auto-dismisses and unsticks itself, so warning about
314
+ * it would be noise for a situation that resolves on its own. An actionable toast
315
+ * never auto-dismisses, cannot be tabbed to, and cannot be Escaped (that handler is
316
+ * bound to the toast element) — the only exit is a mouse. That is the case worth a
317
+ * warning, and it is rare: it needs an Undo raised while a dialog is open.
318
+ *
319
+ * The real fix exempts .rsui-toast from Modal's trapTab and lifts the toast above the
320
+ * modal's z-index. That is a change to Modal and belongs in its own PR; this turns an
321
+ * invisible trap into a message in the meantime.
322
+ */
323
+ if (hasAction.value && document.querySelector('.rsui-modal, .rsui-drawer')) {
324
+ console.warn('[RSUI] Toast: an actionable toast was opened while a modal or drawer is on screen. The dialog traps Tab inside its own panel, so a keyboard user cannot reach this toast — and it never auto-dismisses, so there is no way out but a mouse. Errors and actions raised inside a dialog belong in the dialog.')
325
+ }
326
+ })
327
+ </script>
328
+ <template>
329
+ <!--
330
+ Teleported to the body so the toast is positioned against the viewport rather than
331
+ against whichever ancestor happens to carry a transform, a filter or a containment —
332
+ any of which makes `position: fixed` resolve to that ancestor instead, and none of
333
+ which the toast can see from where it is written.
334
+ -->
335
+ <Teleport to="body">
336
+ <div v-if="modelValue"
337
+ :class="toastClass"
338
+ :role="safeVariant === 'error' ? 'alert' : 'status'"
339
+ :aria-live="safeVariant === 'error' ? 'assertive' : 'polite'"
340
+ @mouseenter="pause"
341
+ @mouseleave="resume"
342
+ @focusin="pause"
343
+ @focusout="resume"
344
+ @keydown="handleKeydown"
345
+ >
346
+ <!--
347
+ rendersContent, not `$slots.icon` — a slot that exists and draws nothing would
348
+ otherwise emit an empty flex child, and the row's column gap would open 12px
349
+ of unexplained space. Measured. This is the presence-versus-renders bug
350
+ helpers/slots.js exists for, and the timer already gets it right.
351
+ -->
352
+ <div v-if="rendersContent($slots.icon)" class="rsui-toast__icon">
353
+ <slot name="icon"></slot>
354
+ </div>
355
+
356
+ <div class="rsui-toast__message">
357
+ <slot></slot>
358
+ </div>
359
+
360
+ <!--
361
+ One action, per Material's snackbar. Undo belongs here whenever undoing is
362
+ possible; the slot is scoped with `close` so the action can dismiss the toast
363
+ it lives in without the consumer tracking the model itself.
364
+
365
+ Calls rendersAction() rather than reading the hasAction ref, and that is
366
+ load-bearing: invoking the slot HERE, inside the render, is what subscribes
367
+ this component to the consumer's own `v-if` condition. Read the ref instead
368
+ and a hidden action is never probed during render, so nothing tracks the
369
+ condition, the component never re-renders when it flips, and onUpdated never
370
+ fires to restart the timer. The ref would freeze at its first answer — the
371
+ exact #332 shape, one level up.
372
+ -->
373
+ <div v-if="rendersAction()" class="rsui-toast__action">
374
+ <slot name="action" :close="close"></slot>
375
+ </div>
376
+
377
+ <button type="button"
378
+ class="rsui-toast__close"
379
+ :aria-label="closeLabel"
380
+ @click="close('dismiss')"
381
+ >
382
+ <Icon sm>
383
+ <XMarkIcon aria-hidden="true" />
384
+ </Icon>
385
+ </button>
386
+ </div>
387
+ </Teleport>
388
+ </template>
@@ -0,0 +1,5 @@
1
+ import Toast from './Toast.vue'
2
+
3
+ export {
4
+ Toast,
5
+ }
@@ -1,28 +1,85 @@
1
- import { inject, ref } from 'vue'
1
+ import { computed, inject, useAttrs, useSlots } from 'vue'
2
2
 
3
- // Injection key used by FormFieldSlot to provide a11y values
3
+ // Injection key used by FormFieldSlot to hand these values to anything nested deeper.
4
4
  export const FormFieldA11yKey = Symbol('FormFieldA11y')
5
5
 
6
6
  /**
7
- * Consumer side of a provide/inject pattern for WCAG a11y attributes.
7
+ * The three WCAG wiring values every field control needs:
8
8
  *
9
- * FormFieldSlot (the wrapper) provides three reactive values under FormFieldA11yKey:
10
- * - inputId: a unique ID (from _.uniqueId()) for <label for> / <input id> association
11
- * - ariaDescribedby: computed string linking to help/error text element IDs
12
- * - ariaInvalid: computed boolean, true when an error slot is present
9
+ * - inputId the id on the control, and the target of the label's `for`
10
+ * - ariaDescribedby points at the help and error text, when either is present
11
+ * - ariaInvalid true while an error slot is rendered
13
12
  *
14
- * Child input components (FormFieldText, Checkbox, Select, Toggle, etc.) call this
15
- * composable to inject those values and bind them to their <input> elements.
13
+ * THE ID IS GENERATED HERE, by the field component, and handed DOWN to FormFieldSlot.
14
+ * That direction is the whole point, and it used to run the other way (#342).
16
15
  *
17
- * Safe to use outside FormFieldSlot — returns ref(null) fallbacks so ARIA attributes
18
- * simply won't render when there's no wrapper providing help/error context.
16
+ * FormFieldSlot generates an id too, and provides it under FormFieldA11yKey — but
17
+ * FormFieldSlot is the field's CHILD: it is the root element of FormFieldText's template,
18
+ * not its wrapper. provide/inject only ever flows parent to child, so a field injecting
19
+ * what its own child provides gets nothing and silently fell back to ref(null). Measured
20
+ * before the fix, with no consumer id passed:
21
+ *
22
+ * label[for] form-field-1
23
+ * input id undefined
24
+ *
25
+ * The label pointed at an element that did not exist, and aria-describedby never rendered,
26
+ * so help and error text did not reach the control either. WCAG 3.3.2 and 1.3.1, on every
27
+ * field in the library that did not pass an id by hand.
28
+ *
29
+ * Five components had already worked around it by recomputing a local `effectiveId`, and
30
+ * in four of those the workaround was itself broken: the control took the local id while
31
+ * the label took FormFieldSlot's separate one, so the two still disagreed. Those are gone;
32
+ * this is the single place an id is minted.
33
+ *
34
+ * A consumer-supplied `id` still wins, so nothing changes for a call site that passes one.
35
+ *
36
+ * The inject is kept for the genuinely nested case — a control rendered *inside* a
37
+ * FormFieldSlot's slots rather than being its parent. When there is a provider above, its
38
+ * values win, which keeps a nested control on the same id as the label above it.
19
39
  */
20
40
  export function useFormFieldA11y() {
21
- const a11y = inject(FormFieldA11yKey, null)
41
+ const provided = inject(FormFieldA11yKey, null)
42
+
43
+ /*
44
+ * Adopt an injected id only when no control owns it yet.
45
+ *
46
+ * A provider above means one of two things, and they look identical from here:
47
+ *
48
+ * a consumer's bare FormFieldSlot wrapping this control — one field, and this IS its
49
+ * control, so it should take the outer id or the label points at nothing;
50
+ *
51
+ * another FIELD's FormFieldSlot, because this control sits in its #prefix, #suffix or
52
+ * default slot — two controls, and taking the outer id puts the same id on both.
53
+ *
54
+ * The second was a real regression: `<FormFieldText><template #prefix><FormFieldText/>`
55
+ * rendered two inputs sharing one id, so `label[for]` resolved to whichever the browser
56
+ * picked. WCAG 4.1.1, and silent — the markup reads correctly and nothing warns.
57
+ *
58
+ * `idClaimed` is the discriminator. A field sets it when it puts the id on its own
59
+ * control; a bare FormFieldSlot leaves it false because it owns no control.
60
+ */
61
+ if (provided && !provided.idClaimed?.value) return provided
62
+
63
+ const attrs = useAttrs()
64
+ const slots = useSlots()
65
+
66
+ const autoId = _.uniqueId('form-field-')
67
+ const inputId = computed(() => attrs.id || autoId)
68
+
69
+ const ariaDescribedby = computed(() => {
70
+ const ids = []
71
+
72
+ if (slots.help) ids.push(`${inputId.value}-help`)
73
+ if (slots.error) ids.push(`${inputId.value}-error`)
74
+
75
+ return ids.length > 0 ? ids.join(' ') : undefined
76
+ })
77
+
78
+ const ariaInvalid = computed(() => slots.error ? true : undefined)
22
79
 
23
80
  return {
24
- inputId: a11y?.inputId ?? ref(null),
25
- ariaDescribedby: a11y?.ariaDescribedby ?? ref(null),
26
- ariaInvalid: a11y?.ariaInvalid ?? ref(null),
81
+ inputId,
82
+ ariaDescribedby,
83
+ ariaInvalid,
27
84
  }
28
85
  }
@@ -15,12 +15,15 @@ import { Comment, Fragment } from 'vue'
15
15
  * their own content down then stand it down for a slot that renders nothing, and
16
16
  * the region ends up empty, or the fallback it was suppressing comes back.
17
17
  *
18
- * Two callers, for two versions of that bug: SectionFooter, where an empty region
19
- * claimed `flex-1` and shoved a centred link into one half of the footer, and
18
+ * Two bugs worth keeping as worked examples. SectionFooter, where an empty region
19
+ * claimed `flex-1` and shoved a centred link into one half of the footer. And
20
20
  * SectionSlider, where an `#actions` slot rendering nothing let the arrows return
21
21
  * to the header while `hoverActions` had already put a pair on the rail — two
22
22
  * pairs on one rail, with identical accessible names.
23
23
  *
24
+ * Deliberately not a count of callers: there are several now, and the number was
25
+ * already stale the last two times someone added one.
26
+ *
24
27
  * Call this from the template or from a plain function, never from a `computed`:
25
28
  * `useSlots()` returns a non-reactive object, so a computed over it caches its
26
29
  * first answer for the life of the component and a conditionally-supplied slot