@redseed/redseed-ui-vue3 9.0.0 → 10.1.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/index.js +1 -0
- package/package.json +1 -1
- package/src/components/Card/CardHeader.vue +38 -3
- package/src/components/Disclosure/Disclosure.vue +41 -12
- package/src/components/Layout/PageHeader.vue +102 -32
- package/src/components/Section/SectionHeader.vue +65 -9
- package/src/components/Section/SectionSlider.vue +10 -1
- package/src/components/Toast/Toast.vue +388 -0
- package/src/components/Toast/index.js +5 -0
- package/src/helpers/slots.js +5 -2
- package/src/helpers/unknownProps.js +72 -0
package/index.js
CHANGED
|
@@ -48,5 +48,6 @@ export * from './src/components/Switcher'
|
|
|
48
48
|
export * from './src/components/TabSlider'
|
|
49
49
|
export * from './src/components/Table'
|
|
50
50
|
export * from './src/components/Toggle'
|
|
51
|
+
export * from './src/components/Toast'
|
|
51
52
|
export * from './src/components/Tooltip'
|
|
52
53
|
export * from './src/helpers'
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { computed, inject, ref, toRefs, useAttrs } from 'vue'
|
|
2
|
+
import { computed, inject, ref, toRefs, useAttrs, useSlots } from 'vue'
|
|
3
3
|
import ButtonTertiary from '../Button/ButtonTertiary.vue'
|
|
4
4
|
import Icon from '../Icon/Icon.vue'
|
|
5
|
+
import { rendersContent } from '../../helpers/slots'
|
|
5
6
|
import { useResponsiveWidth } from '../../helpers'
|
|
6
7
|
import { EllipsisVerticalIcon } from '@heroicons/vue/24/outline'
|
|
7
8
|
|
|
@@ -28,9 +29,27 @@ const props = defineProps({
|
|
|
28
29
|
type: Boolean,
|
|
29
30
|
default: true,
|
|
30
31
|
},
|
|
32
|
+
/**
|
|
33
|
+
* Force the overflow menu on, or suppress it.
|
|
34
|
+
*
|
|
35
|
+
* Left unset, the menu renders when the `more-actions` slot renders something
|
|
36
|
+
* — so a menu whose only option is behind a falsy `v-if` shows no trigger,
|
|
37
|
+
* rather than a three-dot button that opens on nothing. That was the bug: the
|
|
38
|
+
* gate tested the PROP, not what the slot produced.
|
|
39
|
+
*
|
|
40
|
+
* `true` forces it on, which is how a consumer asks for the built-in
|
|
41
|
+
* three-dot button with no slot of their own. `false` suppresses it even with
|
|
42
|
+
* a populated slot.
|
|
43
|
+
*
|
|
44
|
+
* Null rather than false as the default, and the gate is OR rather than AND,
|
|
45
|
+
* so every existing call site behaves identically: the ~38 that pass `false`
|
|
46
|
+
* still suppress, and the ones passing a slot with no prop — 3 in the LMS,
|
|
47
|
+
* plus this library's own Table.vue — still render. That makes the flip safe
|
|
48
|
+
* to ship without coordinating a consumer release. See #345.
|
|
49
|
+
*/
|
|
31
50
|
showMoreActions: {
|
|
32
51
|
type: Boolean,
|
|
33
|
-
default:
|
|
52
|
+
default: null,
|
|
34
53
|
},
|
|
35
54
|
// Default false: Material 3 cards are one continuous padded surface, and a
|
|
36
55
|
// full-bleed rule between the header and the body made every card read as two
|
|
@@ -71,6 +90,22 @@ const { showDivider, headerOnlyCard } = toRefs(props)
|
|
|
71
90
|
|
|
72
91
|
const attrs = useAttrs()
|
|
73
92
|
|
|
93
|
+
const slots = useSlots()
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Whether the overflow region renders. The prop wins when set; otherwise the slot
|
|
97
|
+
* decides by whether it actually produces anything.
|
|
98
|
+
*
|
|
99
|
+
* A plain function rather than a computed: `useSlots()` is not reactive, so a
|
|
100
|
+
* computed over it caches its first answer for the life of the component and a
|
|
101
|
+
* conditionally-supplied menu would never appear. See helpers/slots.
|
|
102
|
+
*/
|
|
103
|
+
function showsMoreActions() {
|
|
104
|
+
if (props.showMoreActions !== null) return props.showMoreActions
|
|
105
|
+
|
|
106
|
+
return rendersContent(slots['more-actions'], { handleMoreActionsClick })
|
|
107
|
+
}
|
|
108
|
+
|
|
74
109
|
const isClickable = computed(() => !!attrs.onClick)
|
|
75
110
|
|
|
76
111
|
const cardHeaderElement = ref(null)
|
|
@@ -183,7 +218,7 @@ function handleMoreActionsClick() {
|
|
|
183
218
|
</div>
|
|
184
219
|
|
|
185
220
|
<!-- More actions slot, optional -->
|
|
186
|
-
<div v-if="
|
|
221
|
+
<div v-if="showsMoreActions()"
|
|
187
222
|
class="rsui-card-header__more-actions"
|
|
188
223
|
>
|
|
189
224
|
<slot name="more-actions"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { ref, watch, watchEffect, nextTick, onMounted } from 'vue'
|
|
2
|
+
import { ref, watch, watchEffect, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
|
3
3
|
import { ChevronDownIcon } from '@heroicons/vue/24/outline'
|
|
4
4
|
import { ButtonTertiary } from '../Button'
|
|
5
5
|
import Icon from '../Icon/Icon.vue'
|
|
@@ -87,28 +87,43 @@ watch(isOpen, (open, wasOpen) => {
|
|
|
87
87
|
}
|
|
88
88
|
}, { flush: 'sync' })
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Whether each teleport target currently exists.
|
|
92
|
+
*
|
|
93
|
+
* Re-checked rather than latched. These used to only ever flip true, and the
|
|
94
|
+
* observer disconnected once both had been found — so a consumer that removed a
|
|
95
|
+
* target left a Teleport mounted against nothing and the trigger vanished.
|
|
96
|
+
* `SectionHeader` does exactly that: it width-gates the slots a target is
|
|
97
|
+
* mounted into, so narrowing a header past 640px took the trigger away and the
|
|
98
|
+
* section could no longer be opened. See #372.
|
|
99
|
+
*/
|
|
90
100
|
const canTeleportTrigger = ref(false)
|
|
91
101
|
const canTeleportContent = ref(false)
|
|
92
102
|
|
|
103
|
+
let observer = null
|
|
104
|
+
|
|
93
105
|
function setTeleport() {
|
|
94
|
-
|
|
95
|
-
|
|
106
|
+
canTeleportTrigger.value = Boolean(document.getElementById(triggerId))
|
|
107
|
+
canTeleportContent.value = Boolean(document.getElementById(contentId))
|
|
96
108
|
}
|
|
97
109
|
|
|
98
110
|
onMounted(() => {
|
|
99
111
|
setTeleport()
|
|
100
112
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
if (canTeleportTrigger.value && canTeleportContent.value) observer.disconnect()
|
|
105
|
-
})
|
|
113
|
+
// Never disconnected: a target can appear AND disappear over a component's
|
|
114
|
+
// life, so there is no point at which the answer is settled.
|
|
115
|
+
observer = new MutationObserver(setTeleport)
|
|
106
116
|
|
|
107
117
|
observer.observe(document.body, {
|
|
108
118
|
childList: true,
|
|
109
119
|
subtree: true,
|
|
110
120
|
})
|
|
111
121
|
})
|
|
122
|
+
|
|
123
|
+
onBeforeUnmount(() => {
|
|
124
|
+
observer?.disconnect()
|
|
125
|
+
observer = null
|
|
126
|
+
})
|
|
112
127
|
</script>
|
|
113
128
|
|
|
114
129
|
<template>
|
|
@@ -121,8 +136,21 @@ onMounted(() => {
|
|
|
121
136
|
></slot>
|
|
122
137
|
</div>
|
|
123
138
|
|
|
124
|
-
|
|
125
|
-
|
|
139
|
+
<!--
|
|
140
|
+
`disabled` rather than `v-if`, so the trigger renders in place when the
|
|
141
|
+
consumer supplies no target instead of not rendering at all.
|
|
142
|
+
|
|
143
|
+
There used to be no second branch: `<Teleport v-if="canTeleportTrigger">`
|
|
144
|
+
and nothing else, so a missing target meant no trigger ANYWHERE rather
|
|
145
|
+
than one in a less good place. `SectionHeader` width-gates the slots a
|
|
146
|
+
target is mounted into, so below 640px of its OWN width — container, not
|
|
147
|
+
viewport — a section simply could not be opened. See #372.
|
|
148
|
+
|
|
149
|
+
Vue's own `disabled` keeps this as one definition rendered in one of two
|
|
150
|
+
places, rather than two copies that can drift.
|
|
151
|
+
-->
|
|
152
|
+
<Teleport :to="`#${triggerId}`"
|
|
153
|
+
:disabled="!canTeleportTrigger"
|
|
126
154
|
>
|
|
127
155
|
<slot name="trigger"
|
|
128
156
|
:handleTrigger="handleTrigger"
|
|
@@ -155,8 +183,9 @@ onMounted(() => {
|
|
|
155
183
|
</slot>
|
|
156
184
|
</Teleport>
|
|
157
185
|
|
|
158
|
-
|
|
159
|
-
|
|
186
|
+
<!-- Same reasoning as the trigger above: in place rather than nowhere. -->
|
|
187
|
+
<Teleport :to="`#${contentId}`"
|
|
188
|
+
:disabled="!canTeleportContent"
|
|
160
189
|
>
|
|
161
190
|
<div ref="contentRef"
|
|
162
191
|
:class="[
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { ref, computed } from 'vue'
|
|
3
|
-
import Modal from '../Modal/Modal.vue'
|
|
2
|
+
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
|
4
3
|
import ButtonTertiary from '../Button/ButtonTertiary.vue'
|
|
5
4
|
|
|
6
5
|
const props = defineProps({
|
|
@@ -49,7 +48,65 @@ const props = defineProps({
|
|
|
49
48
|
},
|
|
50
49
|
})
|
|
51
50
|
|
|
52
|
-
|
|
51
|
+
/**
|
|
52
|
+
* The meta cap, and why the count is measured from the DOM rather than declared.
|
|
53
|
+
*
|
|
54
|
+
* `#meta` is a slot, and every consumer fills it with a `v-for` over their own
|
|
55
|
+
* data — so the component cannot know how many facts there are without either a
|
|
56
|
+
* prop nobody would remember to keep in sync, or walking vnodes. The row itself
|
|
57
|
+
* is capped in CSS with `:nth-child`, which needs no count at all; this observer
|
|
58
|
+
* exists only to decide whether the TOGGLE is needed, and what number to put in
|
|
59
|
+
* it.
|
|
60
|
+
*
|
|
61
|
+
* A MutationObserver rather than a one-off count, because those lists change:
|
|
62
|
+
* a fact arrives with a fetch, or a filter removes one, and a toggle offering
|
|
63
|
+
* "+2 more" when there is one left is worse than no toggle.
|
|
64
|
+
*/
|
|
65
|
+
const META_CAP = 4
|
|
66
|
+
|
|
67
|
+
const metaElement = ref(null)
|
|
68
|
+
const metaCount = ref(0)
|
|
69
|
+
const isMetaExpanded = ref(false)
|
|
70
|
+
|
|
71
|
+
const hiddenMetaCount = computed(() => Math.max(0, metaCount.value - META_CAP))
|
|
72
|
+
|
|
73
|
+
const metaToggleLabel = computed(() =>
|
|
74
|
+
isMetaExpanded.value ? 'Show fewer' : `+${hiddenMetaCount.value} more`
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* WCAG 2.5.3 Label in Name: the accessible name has to contain the visible text,
|
|
79
|
+
* or a speech-input user has nothing to say. Same shape as CategoryList's toggle.
|
|
80
|
+
*/
|
|
81
|
+
const metaToggleAccessibleName = computed(() =>
|
|
82
|
+
isMetaExpanded.value
|
|
83
|
+
? 'Show fewer details'
|
|
84
|
+
: `+${hiddenMetaCount.value} more details`
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
let metaObserver = null
|
|
88
|
+
|
|
89
|
+
function countMetaFacts() {
|
|
90
|
+
if (!metaElement.value) return
|
|
91
|
+
|
|
92
|
+
// Every child is a fact: the toggle lives outside this element precisely so
|
|
93
|
+
// neither this count nor `:nth-child` has to special-case it.
|
|
94
|
+
metaCount.value = metaElement.value.children.length
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
onMounted(() => {
|
|
98
|
+
countMetaFacts()
|
|
99
|
+
|
|
100
|
+
if (!metaElement.value) return
|
|
101
|
+
|
|
102
|
+
metaObserver = new MutationObserver(countMetaFacts)
|
|
103
|
+
metaObserver.observe(metaElement.value, { childList: true })
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
onBeforeUnmount(() => {
|
|
107
|
+
metaObserver?.disconnect()
|
|
108
|
+
metaObserver = null
|
|
109
|
+
})
|
|
53
110
|
|
|
54
111
|
const pageHeaderClass = computed(() => [
|
|
55
112
|
'rsui-page-header',
|
|
@@ -121,33 +178,6 @@ const pageHeaderClass = computed(() => [
|
|
|
121
178
|
|
|
122
179
|
|
|
123
180
|
<div class="rsui-page-header__actions">
|
|
124
|
-
<div v-if="$slots['meta-action-label'] && $slots['meta']" class="rsui-page-header__meta-action">
|
|
125
|
-
<ButtonTertiary @click="showMetaModal = true">
|
|
126
|
-
<slot name="meta-action-label"></slot>
|
|
127
|
-
</ButtonTertiary>
|
|
128
|
-
|
|
129
|
-
<Modal sm v-if="$slots['meta']" class="rsui-page-header__meta-modal" :show="showMetaModal"
|
|
130
|
-
@close="showMetaModal = false">
|
|
131
|
-
<template #header v-if="$slots['meta-modal-header']">
|
|
132
|
-
<slot name="meta-modal-header"></slot>
|
|
133
|
-
</template>
|
|
134
|
-
|
|
135
|
-
<div v-if="$slots.meta" class="rsui-page-header__meta-modal__body">
|
|
136
|
-
<slot name="meta"></slot>
|
|
137
|
-
</div>
|
|
138
|
-
|
|
139
|
-
<template #footer v-if="$slots['meta-modal-footer'] || $slots['meta-modal-close-label']">
|
|
140
|
-
<div v-if="$slots['meta-modal-footer']">
|
|
141
|
-
<slot name="meta-modal-footer"></slot>
|
|
142
|
-
</div>
|
|
143
|
-
|
|
144
|
-
<ButtonTertiary @click="showMetaModal = false">
|
|
145
|
-
<slot name="meta-modal-close-label"></slot>
|
|
146
|
-
</ButtonTertiary>
|
|
147
|
-
</template>
|
|
148
|
-
</Modal>
|
|
149
|
-
</div>
|
|
150
|
-
|
|
151
181
|
<slot name="actions"></slot>
|
|
152
182
|
</div>
|
|
153
183
|
</div>
|
|
@@ -170,8 +200,48 @@ const pageHeaderClass = computed(() => [
|
|
|
170
200
|
<slot name="categories"></slot>
|
|
171
201
|
</div>
|
|
172
202
|
|
|
173
|
-
|
|
174
|
-
|
|
203
|
+
<!--
|
|
204
|
+
Meta stays at every width. It used to be `hidden lg:flex`, with a
|
|
205
|
+
"Details" button opening a modal below lg — so the facts a header
|
|
206
|
+
exists to show disappeared on a phone, which is where a reader has
|
|
207
|
+
least context to spare. See #369.
|
|
208
|
+
|
|
209
|
+
The cap is CSS rather than JS: `#meta` is a slot and its contents are
|
|
210
|
+
usually a `v-for`, so the component cannot count facts without
|
|
211
|
+
inspecting vnodes, and a count taken at setup would be wrong the
|
|
212
|
+
moment the list changed. `:nth-child` does not need to know.
|
|
213
|
+
-->
|
|
214
|
+
<div v-if="$slots['meta']" class="rsui-page-header__meta-region">
|
|
215
|
+
<!--
|
|
216
|
+
The facts and the toggle are siblings in the GRID but the toggle
|
|
217
|
+
is not one of the facts, and `:nth-child` cannot tell the
|
|
218
|
+
difference — put it inside the same element and it takes a
|
|
219
|
+
position, shifting every fact after it and hiding one too many.
|
|
220
|
+
Measured: with six facts the toggle landed fourth and only three
|
|
221
|
+
showed.
|
|
222
|
+
|
|
223
|
+
So the facts own this element alone, and the toggle sits after it
|
|
224
|
+
in a wrapper that participates in the same grid via
|
|
225
|
+
`display: contents`. The count is then honest at every size.
|
|
226
|
+
-->
|
|
227
|
+
<div :class="[
|
|
228
|
+
'rsui-page-header__meta',
|
|
229
|
+
{ 'rsui-page-header__meta--expanded': isMetaExpanded },
|
|
230
|
+
]"
|
|
231
|
+
ref="metaElement"
|
|
232
|
+
>
|
|
233
|
+
<slot name="meta"></slot>
|
|
234
|
+
</div>
|
|
235
|
+
|
|
236
|
+
<button v-if="hiddenMetaCount > 0"
|
|
237
|
+
type="button"
|
|
238
|
+
class="rsui-page-header__meta-toggle"
|
|
239
|
+
:aria-expanded="isMetaExpanded"
|
|
240
|
+
:aria-label="metaToggleAccessibleName"
|
|
241
|
+
@click="isMetaExpanded = !isMetaExpanded"
|
|
242
|
+
>
|
|
243
|
+
{{ metaToggleLabel }}
|
|
244
|
+
</button>
|
|
175
245
|
</div>
|
|
176
246
|
</div>
|
|
177
247
|
</template>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import { ref, computed, useAttrs, useSlots } from 'vue'
|
|
3
|
+
import { rendersContent } from '../../helpers/slots'
|
|
3
4
|
import ButtonTertiary from '../Button/ButtonTertiary.vue'
|
|
4
5
|
import Icon from '../Icon/Icon.vue'
|
|
5
6
|
import { useResponsiveWidth } from '../../helpers'
|
|
@@ -18,9 +19,19 @@ const props = defineProps({
|
|
|
18
19
|
type: Boolean,
|
|
19
20
|
default: true,
|
|
20
21
|
},
|
|
22
|
+
/**
|
|
23
|
+
* Force the overflow menu on, or suppress it. Left unset, the menu renders
|
|
24
|
+
* when the `more-actions` slot renders something — so a menu whose only
|
|
25
|
+
* option is behind a falsy `v-if` shows no trigger rather than a three-dot
|
|
26
|
+
* button that opens on nothing.
|
|
27
|
+
*
|
|
28
|
+
* Null default with an OR gate, so every existing call site is unaffected:
|
|
29
|
+
* the ones passing `false` still suppress, the ones passing a slot with no
|
|
30
|
+
* prop still render. Same shape as CardHeader. See #345.
|
|
31
|
+
*/
|
|
21
32
|
showMoreActions: {
|
|
22
33
|
type: Boolean,
|
|
23
|
-
default:
|
|
34
|
+
default: null,
|
|
24
35
|
},
|
|
25
36
|
// Default false. It was true, but no CSS rule existed behind the --divider class,
|
|
26
37
|
// so every consumer has been seeing no rule regardless. The rule exists now; keeping
|
|
@@ -72,16 +83,57 @@ function handleMoreActionsClick() {
|
|
|
72
83
|
|
|
73
84
|
const attrs = useAttrs()
|
|
74
85
|
|
|
86
|
+
/**
|
|
87
|
+
* A header with a click handler is a control, so it gets a control's semantics —
|
|
88
|
+
* a role, a tab stop and keyboard activation — rather than only a pointer cursor
|
|
89
|
+
* and a hover state.
|
|
90
|
+
*
|
|
91
|
+
* It responded to a click before this, but as a plain <div>: nothing announced
|
|
92
|
+
* it, nothing could reach it by keyboard, and the hover state was a promise it
|
|
93
|
+
* could not keep. See #338.
|
|
94
|
+
*
|
|
95
|
+
* This is also what takes the disclosure trigger out of the width-gated `#icon`
|
|
96
|
+
* slot: with the header itself as the target, a section no longer becomes
|
|
97
|
+
* unopenable below 640px of container width. See #372.
|
|
98
|
+
*/
|
|
75
99
|
const isClickable = computed(() => !!attrs.onClick)
|
|
76
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Enter and Space, the two keys a button answers to. `.self` so a keypress inside
|
|
103
|
+
* the header's own controls — a more-actions menu, a toolbar button — does not
|
|
104
|
+
* also toggle the header, and a repeat guard so a held key fires once.
|
|
105
|
+
*/
|
|
106
|
+
function handleKeydown(event) {
|
|
107
|
+
if (event.repeat) return
|
|
108
|
+
|
|
109
|
+
attrs.onClick?.(event)
|
|
110
|
+
}
|
|
111
|
+
|
|
77
112
|
const slots = useSlots()
|
|
78
113
|
|
|
79
|
-
|
|
80
|
-
|
|
114
|
+
/**
|
|
115
|
+
* Whether the overflow region renders. The prop wins when set; otherwise the slot
|
|
116
|
+
* decides by whether it actually produces anything.
|
|
117
|
+
*/
|
|
118
|
+
function showsMoreActions() {
|
|
119
|
+
if (props.showMoreActions !== null) return props.showMoreActions
|
|
81
120
|
|
|
82
|
-
return
|
|
83
|
-
|
|
84
|
-
|
|
121
|
+
return rendersContent(slots['more-actions'], { handleMoreActionsClick })
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Whether the toolbar region renders at all.
|
|
126
|
+
*
|
|
127
|
+
* A plain function, not a computed. `useSlots()` returns a non-reactive object,
|
|
128
|
+
* so a computed over it cached its first answer for the life of the component —
|
|
129
|
+
* a consumer supplying `#actions` conditionally would never have seen the toolbar
|
|
130
|
+
* appear. That is the #332 defect, and this is the confirmed-broken instance of
|
|
131
|
+
* it: the desktop region is gated here while the mobile one reads `$slots.actions`
|
|
132
|
+
* inline, so the same header worked below 640px and not above it.
|
|
133
|
+
*/
|
|
134
|
+
function showsToolbar() {
|
|
135
|
+
return props.showActions && (rendersContent(slots.actions) || showsMoreActions())
|
|
136
|
+
}
|
|
85
137
|
|
|
86
138
|
/**
|
|
87
139
|
* Element used to render the title — a real heading when a level is supplied,
|
|
@@ -98,6 +150,10 @@ const titleTag = computed(() => props.headingLevel ? `h${props.headingLevel}` :
|
|
|
98
150
|
'rsui-section-header--clickable': isClickable,
|
|
99
151
|
}
|
|
100
152
|
]"
|
|
153
|
+
:role="isClickable ? 'button' : undefined"
|
|
154
|
+
:tabindex="isClickable ? 0 : undefined"
|
|
155
|
+
@keydown.enter.self.prevent="handleKeydown"
|
|
156
|
+
@keydown.space.self.prevent="handleKeydown"
|
|
101
157
|
>
|
|
102
158
|
<div class="rsui-section-header__header">
|
|
103
159
|
|
|
@@ -110,7 +166,7 @@ const titleTag = computed(() => props.headingLevel ? `h${props.headingLevel}` :
|
|
|
110
166
|
|
|
111
167
|
<div :class="{
|
|
112
168
|
'rsui-section-header__text': true,
|
|
113
|
-
'rsui-section-header__text--with-toolbar':
|
|
169
|
+
'rsui-section-header__text--with-toolbar': showsToolbar() || $slots.icon,
|
|
114
170
|
}">
|
|
115
171
|
|
|
116
172
|
<!-- Title slot, default slot -->
|
|
@@ -141,7 +197,7 @@ const titleTag = computed(() => props.headingLevel ? `h${props.headingLevel}` :
|
|
|
141
197
|
|
|
142
198
|
<!-- Actions slot, optional -->
|
|
143
199
|
<div class="rsui-section-header__toolbar"
|
|
144
|
-
v-if="
|
|
200
|
+
v-if="showsToolbar()"
|
|
145
201
|
>
|
|
146
202
|
<!-- Desktop actions slot, optional -->
|
|
147
203
|
<div class="rsui-section-header__actions-desktop"
|
|
@@ -151,7 +207,7 @@ const titleTag = computed(() => props.headingLevel ? `h${props.headingLevel}` :
|
|
|
151
207
|
</div>
|
|
152
208
|
|
|
153
209
|
<!-- More actions slot, optional -->
|
|
154
|
-
<div v-if="
|
|
210
|
+
<div v-if="showsMoreActions()"
|
|
155
211
|
class="rsui-section-header__more-actions"
|
|
156
212
|
>
|
|
157
213
|
<slot name="more-actions"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { ref, computed, watch, watchEffect, useSlots } from 'vue'
|
|
2
|
+
import { ref, computed, watch, watchEffect, useSlots, useAttrs } from 'vue'
|
|
3
3
|
import { rendersContent } from '../../helpers/slots'
|
|
4
|
+
import { warnOnUnknownProps } from '../../helpers/unknownProps'
|
|
4
5
|
import { useScroll, useEventListener, watchDebounced } from '@vueuse/core'
|
|
5
6
|
import Section from './Section.vue'
|
|
6
7
|
import SectionHeader from './SectionHeader.vue'
|
|
@@ -130,6 +131,14 @@ const sliderClasses = computed(() => [
|
|
|
130
131
|
|
|
131
132
|
const slots = useSlots()
|
|
132
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Both sliders in the product pass a `variant` prop this component does not have.
|
|
136
|
+
* Vue drops it into `$attrs`, it lands on the root as a bare DOM attribute, and
|
|
137
|
+
* nothing happens — so the consumer believes they configured something. Dev-only.
|
|
138
|
+
* See #339.
|
|
139
|
+
*/
|
|
140
|
+
warnOnUnknownProps('SectionSlider', useAttrs())
|
|
141
|
+
|
|
133
142
|
/**
|
|
134
143
|
* A consumer-supplied `actions` slot always wins and always renders in the
|
|
135
144
|
* header — that slot exists so consumers can put their own controls up there.
|
|
@@ -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>
|
package/src/helpers/slots.js
CHANGED
|
@@ -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
|
|
19
|
-
* claimed `flex-1` and shoved a centred link into one half of the footer
|
|
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
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { watchEffect } from 'vue'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Warn, in development, when a component is handed an attribute that looks like
|
|
5
|
+
* a prop it does not declare.
|
|
6
|
+
*
|
|
7
|
+
* Internal — not re-exported from `helpers/index.js`. It is a authoring guard for
|
|
8
|
+
* this library's own components, not something a consuming app should call.
|
|
9
|
+
*
|
|
10
|
+
* The case it catches: a consumer passes `variant="featured"` to a component with
|
|
11
|
+
* no `variant` prop. Vue puts it in `$attrs`, it lands on the root as a bare DOM
|
|
12
|
+
* attribute, and nothing happens — no effect, no error, nothing in the console.
|
|
13
|
+
* The consumer believes they have configured something. Both sliders in the
|
|
14
|
+
* product do exactly this (#339).
|
|
15
|
+
*
|
|
16
|
+
* Deliberately conservative about what counts as suspicious, because a warning
|
|
17
|
+
* that cries wolf is worse than none: anything a consumer legitimately passes
|
|
18
|
+
* through is ignored, and only a plain lowercase word with no dash is flagged.
|
|
19
|
+
* That is the shape of a prop name, and it is not the shape of `data-*`,
|
|
20
|
+
* `aria-*`, an event listener, or a standard global attribute.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Attributes any consumer may pass for their own reasons. Not exhaustive — it
|
|
25
|
+
* does not need to be, because the dash and `on*` rules below already exclude
|
|
26
|
+
* most of what is left.
|
|
27
|
+
*/
|
|
28
|
+
const PASS_THROUGH = new Set([
|
|
29
|
+
'id', 'class', 'style', 'title', 'role', 'tabindex', 'hidden', 'slot', 'part',
|
|
30
|
+
'lang', 'dir', 'draggable', 'contenteditable', 'spellcheck', 'translate',
|
|
31
|
+
'autofocus', 'inert', 'popover', 'key', 'ref',
|
|
32
|
+
])
|
|
33
|
+
|
|
34
|
+
function looksLikeAProp(name) {
|
|
35
|
+
if (PASS_THROUGH.has(name)) return false
|
|
36
|
+
|
|
37
|
+
// `data-*`, `aria-*`, and any other namespaced or hyphenated attribute.
|
|
38
|
+
if (name.includes('-')) return false
|
|
39
|
+
|
|
40
|
+
// Event listeners arrive as onClick, onFooBar.
|
|
41
|
+
if (/^on[A-Z]/.test(name)) return false
|
|
42
|
+
|
|
43
|
+
// A prop name is a bare word, optionally camelCased.
|
|
44
|
+
return /^[a-z][a-zA-Z0-9]*$/.test(name)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {string} component Name used in the message, e.g. 'SectionSlider'.
|
|
49
|
+
* @param {object} attrs The component's `useAttrs()` object.
|
|
50
|
+
*
|
|
51
|
+
* Call from `setup()`. Dev-only and deferred by a tick, matching SectionFooter
|
|
52
|
+
* and Modal: this package ships raw `.vue` source, so an ungated warn reaches
|
|
53
|
+
* real users' consoles, and an attribute that is present for one tick during a
|
|
54
|
+
* transition should not be scolded.
|
|
55
|
+
*/
|
|
56
|
+
export function warnOnUnknownProps(component, attrs) {
|
|
57
|
+
if (process.env.NODE_ENV === 'production') return
|
|
58
|
+
|
|
59
|
+
watchEffect((onCleanup) => {
|
|
60
|
+
const suspicious = Object.keys(attrs).filter(looksLikeAProp)
|
|
61
|
+
|
|
62
|
+
if (suspicious.length === 0) return
|
|
63
|
+
|
|
64
|
+
const timer = setTimeout(() => console.warn(
|
|
65
|
+
`[RSUI] ${component}: received ${suspicious.map(name => `\`${name}\``).join(', ')}, which ${suspicious.length === 1 ? 'is not a prop' : 'are not props'} of this component. `
|
|
66
|
+
+ `${suspicious.length === 1 ? 'It has' : 'They have'} landed on the root element as a plain attribute and will do nothing. `
|
|
67
|
+
+ 'Check the spelling, or remove it.',
|
|
68
|
+
), 0)
|
|
69
|
+
|
|
70
|
+
onCleanup(() => clearTimeout(timer))
|
|
71
|
+
})
|
|
72
|
+
}
|