@redseed/redseed-ui-vue3 8.70.0 → 10.0.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/package.json +1 -1
- package/src/components/Card/Card.vue +35 -3
- package/src/components/Card/CardHeader.vue +66 -5
- package/src/components/Card/CardHorizontal.vue +9 -2
- package/src/components/Card/MetricCard.vue +27 -1
- package/src/components/Disclosure/Disclosure.vue +41 -12
- package/src/components/Empty/Empty.vue +74 -2
- package/src/components/Layout/PageHeader.vue +165 -33
- package/src/components/Layout/PageHeaderStat.vue +84 -0
- package/src/components/Layout/index.js +2 -0
- package/src/components/Modal/Modal.vue +107 -5
- package/src/components/Section/SectionHeader.vue +81 -13
- package/src/components/Section/SectionSlider.vue +21 -4
- package/src/helpers/unknownProps.js +72 -0
package/package.json
CHANGED
|
@@ -1,15 +1,32 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { computed, ref, useSlots } from 'vue'
|
|
2
|
+
import { computed, provide, ref, useSlots } from 'vue'
|
|
3
3
|
import { useResponsiveWidth } from '../../helpers'
|
|
4
4
|
|
|
5
5
|
const props = defineProps({
|
|
6
|
+
/**
|
|
7
|
+
* Whether the whole card is a control.
|
|
8
|
+
*
|
|
9
|
+
* Opt-in. It used to default to true, so every card was a focusable
|
|
10
|
+
* role="button" with a hover state until a consumer said otherwise — 44 call
|
|
11
|
+
* sites passing `:clickable="false"` and 38 passing `:hoverable="false"`,
|
|
12
|
+
* against exactly one card that opted in. Most cards are containers, and only
|
|
13
|
+
* an actionable thing may carry an actionable role (WCAG 4.1.2). A hover
|
|
14
|
+
* state, a pointer cursor and a focus ring are promises.
|
|
15
|
+
*
|
|
16
|
+
* `ButtonCard` exists for the deliberate case and passes this explicitly.
|
|
17
|
+
*/
|
|
6
18
|
clickable: {
|
|
7
19
|
type: Boolean,
|
|
8
|
-
default:
|
|
20
|
+
default: false,
|
|
9
21
|
},
|
|
22
|
+
/**
|
|
23
|
+
* Hover feedback. Opt-in for the same reason: a card that opens nothing
|
|
24
|
+
* should not respond as though it does. A clickable card usually wants this
|
|
25
|
+
* too, and asks for it.
|
|
26
|
+
*/
|
|
10
27
|
hoverable: {
|
|
11
28
|
type: Boolean,
|
|
12
|
-
default:
|
|
29
|
+
default: false,
|
|
13
30
|
},
|
|
14
31
|
padded: {
|
|
15
32
|
type: Boolean,
|
|
@@ -155,6 +172,21 @@ const accentStyle = computed(() =>
|
|
|
155
172
|
|
|
156
173
|
const slots = useSlots()
|
|
157
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Whether this card will render a body, handed down so a CardHeader in the
|
|
177
|
+
* `header` slot can pay for its own bottom edge when there is nothing beneath it
|
|
178
|
+
* to borrow one from.
|
|
179
|
+
*
|
|
180
|
+
* `.rsui-card-header` is `pt-space-lg pb-0` on purpose — it takes its bottom edge
|
|
181
|
+
* from the body's padding. With no body, nobody pays, and the title sits flush
|
|
182
|
+
* against the card's border. `headerOnlyCard` existed for that but defaulted to
|
|
183
|
+
* false, so the broken rendering was the default and the correct one had to be
|
|
184
|
+
* asked for. CheckboxCard fell into it and it took a visual bug report to find.
|
|
185
|
+
*
|
|
186
|
+
* Mirrors the body's own `v-if` exactly, so the two cannot drift.
|
|
187
|
+
*/
|
|
188
|
+
provide('rsuiCardHasBody', computed(() => Boolean(slots.default || slots.meta)))
|
|
189
|
+
|
|
158
190
|
const cardElement = ref(null)
|
|
159
191
|
const { responsiveWidth } = useResponsiveWidth(cardElement)
|
|
160
192
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { computed, 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
|
|
@@ -45,9 +64,21 @@ const props = defineProps({
|
|
|
45
64
|
type: Boolean,
|
|
46
65
|
default: true,
|
|
47
66
|
},
|
|
67
|
+
/**
|
|
68
|
+
* Pay for a bottom edge because nothing sits beneath this header.
|
|
69
|
+
*
|
|
70
|
+
* Left unset, a CardHeader inside a Card works it out: Card provides whether
|
|
71
|
+
* it will render a body, and a header with no body beneath it pads itself.
|
|
72
|
+
* That way the correct rendering is what you get, and this prop is an
|
|
73
|
+
* override rather than a requirement — it defaulted to false, which made the
|
|
74
|
+
* broken rendering the default.
|
|
75
|
+
*
|
|
76
|
+
* Null rather than false so an explicit `false` can still win. A CardHeader
|
|
77
|
+
* used outside a Card gets no answer and keeps the old behaviour.
|
|
78
|
+
*/
|
|
48
79
|
headerOnlyCard: {
|
|
49
80
|
type: Boolean,
|
|
50
|
-
default:
|
|
81
|
+
default: null,
|
|
51
82
|
},
|
|
52
83
|
avatarTop: {
|
|
53
84
|
type: Boolean,
|
|
@@ -59,6 +90,22 @@ const { showDivider, headerOnlyCard } = toRefs(props)
|
|
|
59
90
|
|
|
60
91
|
const attrs = useAttrs()
|
|
61
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
|
+
|
|
62
109
|
const isClickable = computed(() => !!attrs.onClick)
|
|
63
110
|
|
|
64
111
|
const cardHeaderElement = ref(null)
|
|
@@ -67,8 +114,22 @@ const { responsiveWidth } = useResponsiveWidth(cardHeaderElement, 640)
|
|
|
67
114
|
|
|
68
115
|
const emit = defineEmits(['click:more-actions'])
|
|
69
116
|
|
|
117
|
+
/**
|
|
118
|
+
* `null` means "no opinion from the consumer", so fall through to what the
|
|
119
|
+
* enclosing Card says. Outside a Card there is nothing to inject and the value
|
|
120
|
+
* is null, which lands on the old behaviour.
|
|
121
|
+
*/
|
|
122
|
+
const cardHasBody = inject('rsuiCardHasBody', null)
|
|
123
|
+
|
|
124
|
+
const isHeaderOnly = computed(() => {
|
|
125
|
+
if (headerOnlyCard.value !== null) return headerOnlyCard.value
|
|
126
|
+
if (cardHasBody) return !cardHasBody.value
|
|
127
|
+
|
|
128
|
+
return false
|
|
129
|
+
})
|
|
130
|
+
|
|
70
131
|
const shouldPadBottom = computed(() =>
|
|
71
|
-
|
|
132
|
+
isHeaderOnly.value ? true : showDivider.value
|
|
72
133
|
)
|
|
73
134
|
|
|
74
135
|
function handleMoreActionsClick() {
|
|
@@ -157,7 +218,7 @@ function handleMoreActionsClick() {
|
|
|
157
218
|
</div>
|
|
158
219
|
|
|
159
220
|
<!-- More actions slot, optional -->
|
|
160
|
-
<div v-if="
|
|
221
|
+
<div v-if="showsMoreActions()"
|
|
161
222
|
class="rsui-card-header__more-actions"
|
|
162
223
|
>
|
|
163
224
|
<slot name="more-actions"
|
|
@@ -4,13 +4,20 @@ import { useResizeObserver, useMutationObserver } from '@vueuse/core'
|
|
|
4
4
|
import Card from './Card.vue'
|
|
5
5
|
|
|
6
6
|
const props = defineProps({
|
|
7
|
+
/**
|
|
8
|
+
* Declared here and forwarded to Card, so these defaults must track Card's —
|
|
9
|
+
* a true default here would have kept every CardHorizontal a focusable
|
|
10
|
+
* role="button" after Card's own flip, unreachable from a call site. Same
|
|
11
|
+
* trap SectionSlider hit with headingLevel and CardGroup/List with Empty's
|
|
12
|
+
* showImage. See #335.
|
|
13
|
+
*/
|
|
7
14
|
clickable: {
|
|
8
15
|
type: Boolean,
|
|
9
|
-
default:
|
|
16
|
+
default: false,
|
|
10
17
|
},
|
|
11
18
|
hoverable: {
|
|
12
19
|
type: Boolean,
|
|
13
|
-
default:
|
|
20
|
+
default: false,
|
|
14
21
|
},
|
|
15
22
|
bordered: {
|
|
16
23
|
type: Boolean,
|
|
@@ -36,10 +36,34 @@ const props = defineProps({
|
|
|
36
36
|
// assume light-on-dark: surface, ink, muted ink and rule move together.
|
|
37
37
|
//
|
|
38
38
|
// Not combinable with Card's semantic variants (brand, success, …) — pick one.
|
|
39
|
+
//
|
|
40
|
+
// `bloom` is for a MetricCard sitting on PageHeader's bloom band — not for that
|
|
41
|
+
// header's own stats strip, which is PageHeaderStat. Like the others it names an
|
|
42
|
+
// opaque surface rather than a translucent white: the band's own colour changes
|
|
43
|
+
// across its width, so a tile that let it through would have a different contrast
|
|
44
|
+
// ratio at each end of the same row. See metric_card.css for the measurement.
|
|
39
45
|
tone: {
|
|
40
46
|
type: String,
|
|
41
47
|
default: null,
|
|
42
|
-
validator: value => ['green', 'teal', 'light'].includes(value),
|
|
48
|
+
validator: value => ['green', 'teal', 'light', 'bloom'].includes(value),
|
|
49
|
+
},
|
|
50
|
+
// Which edge carries a colour accent, and the token that paints it — both passed
|
|
51
|
+
// straight through to the underlying Card, which already implements them.
|
|
52
|
+
//
|
|
53
|
+
// A stat tile is the case the accent was made for: a strip of counts where one of
|
|
54
|
+
// them is the one to act on, and a rail down its leading edge says so without giving
|
|
55
|
+
// that tile a different surface from the rest of the row.
|
|
56
|
+
accent: {
|
|
57
|
+
type: String,
|
|
58
|
+
default: 'none',
|
|
59
|
+
validator: value => ['none', 'left', 'top'].includes(value),
|
|
60
|
+
},
|
|
61
|
+
// A runtime CSS variable NAME, not a colour — `Colors-Warm-Yellow-500` paints the
|
|
62
|
+
// rail Warm Yellow. Card's own note applies: an RSUI @theme token will not resolve,
|
|
63
|
+
// and an unresolvable one degrades to neutral grey rather than to an invisible edge.
|
|
64
|
+
color: {
|
|
65
|
+
type: String,
|
|
66
|
+
default: '',
|
|
43
67
|
},
|
|
44
68
|
// DIRECTION of travel — which arrow. Deliberately separate from `sentiment`,
|
|
45
69
|
// because an arrow direction and a colour are not the same decision:
|
|
@@ -117,6 +141,8 @@ const comparisonId = useId()
|
|
|
117
141
|
:clickable="props.clickable"
|
|
118
142
|
:hoverable="props.hoverable"
|
|
119
143
|
:pressed="active"
|
|
144
|
+
:accent="props.accent"
|
|
145
|
+
:color="props.color"
|
|
120
146
|
ph-component="MetricCard"
|
|
121
147
|
>
|
|
122
148
|
<template v-if="$slots['aria-label']" #aria-label>
|
|
@@ -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,5 +1,5 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { ref, computed } from 'vue'
|
|
2
|
+
import { ref, computed, useSlots } from 'vue'
|
|
3
3
|
import ButtonPrimary from '../Button/ButtonPrimary.vue'
|
|
4
4
|
import ButtonSecondary from '../Button/ButtonSecondary.vue'
|
|
5
5
|
import ButtonTertiary from '../Button/ButtonTertiary.vue'
|
|
@@ -7,14 +7,84 @@ import BodyText from '../HTML/BodyText.vue'
|
|
|
7
7
|
import IconCircleBackground from '../Icon/IconCircleBackground.vue'
|
|
8
8
|
import { ExclamationCircleIcon } from '@heroicons/vue/24/outline'
|
|
9
9
|
import { useResponsiveWidth } from '../../helpers'
|
|
10
|
+
import { rendersContent } from '../../helpers/slots'
|
|
10
11
|
|
|
11
12
|
const props = defineProps({
|
|
13
|
+
/**
|
|
14
|
+
* Whether to show the illustration above the message.
|
|
15
|
+
*
|
|
16
|
+
* Opt-in, because an empty state is a short message in a panel and a circled
|
|
17
|
+
* exclamation mark above it reads as a warning about a list that is merely
|
|
18
|
+
* new. Two thirds of call sites were already turning it off, which is the
|
|
19
|
+
* definition of a wrong default.
|
|
20
|
+
*
|
|
21
|
+
* It could not be fixed at the call sites: CardGroup and List render their
|
|
22
|
+
* own Empty and pass no `showImage`, so those four instances were unreachable
|
|
23
|
+
* from consuming code and showed the illustration whatever a consumer did.
|
|
24
|
+
* Flipping the default is the only change that reaches them. See #348.
|
|
25
|
+
*
|
|
26
|
+
* Note this gates the `image` slot too, so a consumer supplying their own
|
|
27
|
+
* illustration sets `showImage` alongside it.
|
|
28
|
+
*/
|
|
12
29
|
showImage: {
|
|
30
|
+
type: Boolean,
|
|
31
|
+
default: null,
|
|
32
|
+
},
|
|
33
|
+
/**
|
|
34
|
+
* Announce this empty state to a screen reader (WCAG 4.1.3).
|
|
35
|
+
*
|
|
36
|
+
* On by default. A list that changes state has to say so — someone who has
|
|
37
|
+
* just filtered a table to nothing gets silence where the rows were, and on
|
|
38
|
+
* a phone there is no peripheral view of the page to fall back on.
|
|
39
|
+
*
|
|
40
|
+
* Defaulting this to `false` would repeat the mistake this component was
|
|
41
|
+
* just fixed for: the right behaviour has to be what you get without asking.
|
|
42
|
+
* Turn it off for an empty state that is simply page content rather than the
|
|
43
|
+
* result of something the user did.
|
|
44
|
+
*
|
|
45
|
+
* A caveat worth knowing, because the attribute alone does not buy as much as
|
|
46
|
+
* it looks: a live region announces CHANGES made after it is registered. A
|
|
47
|
+
* region that is already present when the page loads stays quiet, which is
|
|
48
|
+
* what you want. But an Empty that is inserted wholesale — `v-if` flipping,
|
|
49
|
+
* which is what CardGroup and List both do — is announced inconsistently
|
|
50
|
+
* across screen readers, because the region and its content arrive together.
|
|
51
|
+
* For a guaranteed announcement the region has to outlive the change, which
|
|
52
|
+
* only the consumer can arrange.
|
|
53
|
+
*/
|
|
54
|
+
announce: {
|
|
13
55
|
type: Boolean,
|
|
14
56
|
default: true,
|
|
15
57
|
},
|
|
16
58
|
})
|
|
17
59
|
|
|
60
|
+
const slots = useSlots()
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Whether the illustration region renders. Three states, because two were not
|
|
64
|
+
* enough to flip the default without deleting work.
|
|
65
|
+
*
|
|
66
|
+
* unset — show only if a consumer supplied an `image` slot that renders
|
|
67
|
+
* something. Passing the slot IS the opt-in.
|
|
68
|
+
* true — show, falling back to the built-in icon.
|
|
69
|
+
* false — hide, even with a slot supplied.
|
|
70
|
+
*
|
|
71
|
+
* `showImage` used to default to `true`, so every empty state led with a circled
|
|
72
|
+
* exclamation mark that reads as a warning about a list that is merely new — and
|
|
73
|
+
* two thirds of call sites turned it off. But the prop gates the `image` slot as
|
|
74
|
+
* well as the icon, so flipping it to a plain `false` would have silently deleted
|
|
75
|
+
* the illustration from everyone who had chosen their own: 14 places in the LMS,
|
|
76
|
+
* each a deliberate choice, none of them erroring. Hence the third state.
|
|
77
|
+
*
|
|
78
|
+
* Rendered output rather than slot presence, and a plain function rather than a
|
|
79
|
+
* computed: `useSlots()` is not reactive, so a computed over it caches its first
|
|
80
|
+
* answer for the life of the component. See helpers/slots.
|
|
81
|
+
*/
|
|
82
|
+
function showsImage() {
|
|
83
|
+
if (props.showImage !== null) return props.showImage
|
|
84
|
+
|
|
85
|
+
return rendersContent(slots.image)
|
|
86
|
+
}
|
|
87
|
+
|
|
18
88
|
const emit = defineEmits(['clickPrimaryAction', 'clickSecondaryAction', 'clickTertiaryAction'])
|
|
19
89
|
|
|
20
90
|
const emptyElement = ref(null)
|
|
@@ -34,13 +104,15 @@ const emptyClass = computed(() => [
|
|
|
34
104
|
<div v-if="$slots.title || $slots.default"
|
|
35
105
|
ref="emptyElement"
|
|
36
106
|
:class="emptyClass"
|
|
107
|
+
:role="announce ? 'status' : undefined"
|
|
108
|
+
:aria-live="announce ? 'polite' : undefined"
|
|
37
109
|
>
|
|
38
110
|
<!--
|
|
39
111
|
Image slot with a default icon.
|
|
40
112
|
This slot is used to render the image.
|
|
41
113
|
The image is used to display an icon or an image.
|
|
42
114
|
-->
|
|
43
|
-
<div v-if="
|
|
115
|
+
<div v-if="showsImage()"
|
|
44
116
|
class="rsui-empty__image"
|
|
45
117
|
>
|
|
46
118
|
<slot name="image">
|
|
@@ -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({
|
|
@@ -15,14 +14,99 @@ const props = defineProps({
|
|
|
15
14
|
//
|
|
16
15
|
// A toned header supplies its own padding, so place it outside the page's content
|
|
17
16
|
// gutter and let the band bleed. Omit the prop and nothing changes.
|
|
17
|
+
//
|
|
18
|
+
// `bloom` is the brand's hero treatment — a green-to-mauve sweep with three radial
|
|
19
|
+
// washes over it — rather than a flat fill like the other three. It is a tone rather
|
|
20
|
+
// than a modifier layered over one because its wash alphas are a measured contrast
|
|
21
|
+
// budget against its own base sweep; over a different background the measurement
|
|
22
|
+
// does not hold. See page_header.css for the figures.
|
|
18
23
|
tone: {
|
|
19
24
|
type: String,
|
|
20
25
|
default: null,
|
|
21
|
-
validator: value => ['green', 'teal', 'light'].includes(value),
|
|
26
|
+
validator: value => ['green', 'teal', 'light', 'bloom'].includes(value),
|
|
27
|
+
},
|
|
28
|
+
// Bleeds a toned band to the viewport edges while keeping its text on the page's own
|
|
29
|
+
// gutter, so the greeting lines up with the logo and nav above it rather than sitting
|
|
30
|
+
// 24px from the screen edge.
|
|
31
|
+
//
|
|
32
|
+
// This is what a toned header wants in an app shell, and the note on `tone` — "place
|
|
33
|
+
// it outside the page's content gutter" — is the version of it a consumer has to build
|
|
34
|
+
// themselves. With this the header stays INSIDE the container where it belongs in the
|
|
35
|
+
// document, and only its paint escapes.
|
|
36
|
+
//
|
|
37
|
+
// Requires `overflow-x: clip` on the document (html and body): the bleed is measured
|
|
38
|
+
// in vw, which includes the scrollbar gutter, so it overshoots by half a scrollbar
|
|
39
|
+
// each side. Clip it on the document rather than on the page root — clipping the root
|
|
40
|
+
// clips the bleed itself, and the band's box reaches the edge while its paint stops at
|
|
41
|
+
// the container. `clip` rather than `hidden`, so neither element becomes a scroll
|
|
42
|
+
// container and position: sticky keeps working.
|
|
43
|
+
//
|
|
44
|
+
// No effect without a tone: there is no band to bleed.
|
|
45
|
+
bleed: {
|
|
46
|
+
type: Boolean,
|
|
47
|
+
default: false,
|
|
22
48
|
},
|
|
23
49
|
})
|
|
24
50
|
|
|
25
|
-
|
|
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
|
+
})
|
|
26
110
|
|
|
27
111
|
const pageHeaderClass = computed(() => [
|
|
28
112
|
'rsui-page-header',
|
|
@@ -30,6 +114,7 @@ const pageHeaderClass = computed(() => [
|
|
|
30
114
|
'rsui-page-header--profile': props.profile,
|
|
31
115
|
'rsui-page-header--toned': !!props.tone,
|
|
32
116
|
[`rsui-page-header--tone-${props.tone}`]: !!props.tone,
|
|
117
|
+
'rsui-page-header--bleed': !!props.tone && props.bleed,
|
|
33
118
|
},
|
|
34
119
|
])
|
|
35
120
|
</script>
|
|
@@ -41,6 +126,22 @@ const pageHeaderClass = computed(() => [
|
|
|
41
126
|
<slot name="avatar"></slot>
|
|
42
127
|
</div>
|
|
43
128
|
<div class="rsui-page-header__title-text">
|
|
129
|
+
<!--
|
|
130
|
+
A small label ABOVE the h1, matching CardHeader's #eyebrow: the
|
|
131
|
+
line a page uses to say what kind of thing it is, or what day it
|
|
132
|
+
is, before it says which one. Without it the only home for that
|
|
133
|
+
line is #title, which puts it inside the h1 and gives the page two
|
|
134
|
+
headings in one.
|
|
135
|
+
|
|
136
|
+
Gated on slot presence alone rather than on a showEyebrow prop
|
|
137
|
+
like CardHeader's, because every other slot on this component is
|
|
138
|
+
gated the same way and a prop that only ever hides an absent
|
|
139
|
+
element earns nothing.
|
|
140
|
+
-->
|
|
141
|
+
<div v-if="$slots.eyebrow" class="rsui-page-header__eyebrow">
|
|
142
|
+
<slot name="eyebrow"></slot>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
44
145
|
<div class="rsui-page-header__title-text-container">
|
|
45
146
|
<h1>
|
|
46
147
|
<slot name="title"></slot>
|
|
@@ -55,37 +156,28 @@ const pageHeaderClass = computed(() => [
|
|
|
55
156
|
</div>
|
|
56
157
|
</div>
|
|
57
158
|
|
|
159
|
+
<!--
|
|
160
|
+
A row of summary tiles belonging to the header itself — the counts a page
|
|
161
|
+
leads with, as MetricCards rather than as label/value pairs.
|
|
162
|
+
|
|
163
|
+
Inside __top rather than under it, so it inherits the row/column the
|
|
164
|
+
header already switches at lg: stacked under the title on a phone, beside
|
|
165
|
+
it on a desktop. That is one layout rule, not a second set of breakpoints.
|
|
166
|
+
|
|
167
|
+
NOT the meta slot. __meta is `hidden lg:flex` — a grid of label/value
|
|
168
|
+
facts that collapses into a modal on a phone (see #369) — and a count the
|
|
169
|
+
page leads with is not a fact you go looking for in a modal. It also
|
|
170
|
+
carries the tonal rule above it, which puts a line between the title and
|
|
171
|
+
anything placed there.
|
|
172
|
+
-->
|
|
173
|
+
<div v-if="$slots.stats" class="rsui-page-header__stats">
|
|
174
|
+
<slot name="stats"></slot>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
58
177
|
<div v-if="$slots.actions" class="rsui-page-header__status-actions">
|
|
59
178
|
|
|
60
179
|
|
|
61
180
|
<div class="rsui-page-header__actions">
|
|
62
|
-
<div v-if="$slots['meta-action-label'] && $slots['meta']" class="rsui-page-header__meta-action">
|
|
63
|
-
<ButtonTertiary @click="showMetaModal = true">
|
|
64
|
-
<slot name="meta-action-label"></slot>
|
|
65
|
-
</ButtonTertiary>
|
|
66
|
-
|
|
67
|
-
<Modal sm v-if="$slots['meta']" class="rsui-page-header__meta-modal" :show="showMetaModal"
|
|
68
|
-
@close="showMetaModal = false">
|
|
69
|
-
<template #header v-if="$slots['meta-modal-header']">
|
|
70
|
-
<slot name="meta-modal-header"></slot>
|
|
71
|
-
</template>
|
|
72
|
-
|
|
73
|
-
<div v-if="$slots.meta" class="rsui-page-header__meta-modal__body">
|
|
74
|
-
<slot name="meta"></slot>
|
|
75
|
-
</div>
|
|
76
|
-
|
|
77
|
-
<template #footer v-if="$slots['meta-modal-footer'] || $slots['meta-modal-close-label']">
|
|
78
|
-
<div v-if="$slots['meta-modal-footer']">
|
|
79
|
-
<slot name="meta-modal-footer"></slot>
|
|
80
|
-
</div>
|
|
81
|
-
|
|
82
|
-
<ButtonTertiary @click="showMetaModal = false">
|
|
83
|
-
<slot name="meta-modal-close-label"></slot>
|
|
84
|
-
</ButtonTertiary>
|
|
85
|
-
</template>
|
|
86
|
-
</Modal>
|
|
87
|
-
</div>
|
|
88
|
-
|
|
89
181
|
<slot name="actions"></slot>
|
|
90
182
|
</div>
|
|
91
183
|
</div>
|
|
@@ -108,8 +200,48 @@ const pageHeaderClass = computed(() => [
|
|
|
108
200
|
<slot name="categories"></slot>
|
|
109
201
|
</div>
|
|
110
202
|
|
|
111
|
-
|
|
112
|
-
|
|
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>
|
|
113
245
|
</div>
|
|
114
246
|
</div>
|
|
115
247
|
</template>
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
One count in PageHeader's #stats row: a large figure with a small label under it,
|
|
6
|
+
on a tile that lets the header's band through.
|
|
7
|
+
|
|
8
|
+
NOT a MetricCard. The two look alike in a listing and are different objects: a
|
|
9
|
+
MetricCard is a Card — 24px padding, the sans number step, a comparison, a trend, a
|
|
10
|
+
sentiment — and is sized by the grid it sits in. This is a 150px tile, 12px/16px of
|
|
11
|
+
padding, a serif figure at 46px and nothing else, because it sits in a band beside a
|
|
12
|
+
title rather than in a dashboard. Reproducing it out of MetricCard meant overriding
|
|
13
|
+
most of MetricCard from the header's stylesheet, which is the smell this component
|
|
14
|
+
exists to remove. Reach for MetricCard when the tile is the content of the page.
|
|
15
|
+
*/
|
|
16
|
+
const props = defineProps({
|
|
17
|
+
// Renders a <button> rather than a <div>. A count that scrolls to its section or
|
|
18
|
+
// opens a filtered list is a control and has to be one — but a stat that only reports
|
|
19
|
+
// a number is not, and a button that does nothing is worse than a div (WCAG 4.1.2).
|
|
20
|
+
clickable: {
|
|
21
|
+
type: Boolean,
|
|
22
|
+
default: false,
|
|
23
|
+
},
|
|
24
|
+
// Renders an <a> instead, for a count whose destination is a page. Wins over
|
|
25
|
+
// `clickable`, since a link that is also a button is neither.
|
|
26
|
+
href: {
|
|
27
|
+
type: String,
|
|
28
|
+
default: '',
|
|
29
|
+
},
|
|
30
|
+
// Paints the rail down the tile's leading edge. A runtime CSS variable NAME, the same
|
|
31
|
+
// contract Card's `color` takes: `Colors-Warm-Yellow-500`, not a colour. RSUI's own
|
|
32
|
+
// @theme tokens are inlined into utilities and will NOT resolve here.
|
|
33
|
+
//
|
|
34
|
+
// The rail marks the one count in a row that is asking for something, without giving
|
|
35
|
+
// that tile a different surface from the rest. Unset, the edge is reserved and
|
|
36
|
+
// transparent, so a tile with a rail and one without still line up.
|
|
37
|
+
accent: {
|
|
38
|
+
type: String,
|
|
39
|
+
default: '',
|
|
40
|
+
},
|
|
41
|
+
// Names the control for assistive technology when the visible label is a fragment —
|
|
42
|
+
// "8 / To do" reads as two unrelated strings out of context.
|
|
43
|
+
ariaLabel: {
|
|
44
|
+
type: String,
|
|
45
|
+
default: '',
|
|
46
|
+
},
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
defineEmits(['click'])
|
|
50
|
+
|
|
51
|
+
const tag = computed(() => {
|
|
52
|
+
if (props.href) return 'a'
|
|
53
|
+
if (props.clickable) return 'button'
|
|
54
|
+
|
|
55
|
+
return 'div'
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// Only set when an accent is supplied, so the CSS transparent default holds otherwise.
|
|
59
|
+
// Carries an inline fallback for the same reason Card's does: an unresolvable token
|
|
60
|
+
// degrades to a neutral rail rather than to an invisible one.
|
|
61
|
+
const accentStyle = computed(() =>
|
|
62
|
+
props.accent
|
|
63
|
+
? { '--rsui-page-header-stat-accent': `var(--${props.accent}, var(--Colors-Grey-500))` }
|
|
64
|
+
: {},
|
|
65
|
+
)
|
|
66
|
+
</script>
|
|
67
|
+
<template>
|
|
68
|
+
<component :is="tag"
|
|
69
|
+
class="rsui-page-header__stat"
|
|
70
|
+
:class="{ 'rsui-page-header__stat--interactive': tag !== 'div' }"
|
|
71
|
+
:style="accentStyle"
|
|
72
|
+
:type="tag === 'button' ? 'button' : undefined"
|
|
73
|
+
:href="tag === 'a' ? props.href : undefined"
|
|
74
|
+
:aria-label="props.ariaLabel || undefined"
|
|
75
|
+
@click="$emit('click', $event)"
|
|
76
|
+
>
|
|
77
|
+
<span class="rsui-page-header__stat-value">
|
|
78
|
+
<slot name="value"></slot>
|
|
79
|
+
</span>
|
|
80
|
+
<span class="rsui-page-header__stat-label">
|
|
81
|
+
<slot name="label"></slot>
|
|
82
|
+
</span>
|
|
83
|
+
</component>
|
|
84
|
+
</template>
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import PageHeader from './PageHeader.vue'
|
|
2
|
+
import PageHeaderStat from './PageHeaderStat.vue'
|
|
2
3
|
import SidebarLayout from './SidebarLayout.vue'
|
|
3
4
|
import SingleColumnLayout from './SingleColumnLayout.vue'
|
|
4
5
|
import TwoColumnLayout from './TwoColumnLayout.vue'
|
|
5
6
|
|
|
6
7
|
export {
|
|
7
8
|
PageHeader,
|
|
9
|
+
PageHeaderStat,
|
|
8
10
|
SidebarLayout,
|
|
9
11
|
SingleColumnLayout,
|
|
10
12
|
TwoColumnLayout,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
2
|
+
import { computed, nextTick, onMounted, onUnmounted, ref, useSlots, watch, watchEffect } from 'vue'
|
|
3
3
|
|
|
4
4
|
// Apply all attributes to the input element, not the wrapper div
|
|
5
5
|
defineOptions({
|
|
@@ -111,9 +111,11 @@ watch(() => props.show, (value) => {
|
|
|
111
111
|
nextTick(() => {
|
|
112
112
|
const modal = modalContentRef.value
|
|
113
113
|
if (!modal) return
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
114
|
+
|
|
115
|
+
const [firstFocusable] = focusableItems(modal)
|
|
116
|
+
|
|
117
|
+
if (firstFocusable) {
|
|
118
|
+
firstFocusable.focus()
|
|
117
119
|
} else {
|
|
118
120
|
modal.focus()
|
|
119
121
|
}
|
|
@@ -141,10 +143,110 @@ function closeOnEscape(e) {
|
|
|
141
143
|
}
|
|
142
144
|
}
|
|
143
145
|
|
|
144
|
-
|
|
146
|
+
/**
|
|
147
|
+
* `:not([disabled])` matters: the open-focus path used to omit it and could land
|
|
148
|
+
* focus on a disabled control, which then looks like focus went nowhere.
|
|
149
|
+
*/
|
|
150
|
+
const FOCUSABLE_SELECTOR = [
|
|
151
|
+
'button:not([disabled])',
|
|
152
|
+
'[href]',
|
|
153
|
+
'input:not([disabled])',
|
|
154
|
+
'select:not([disabled])',
|
|
155
|
+
'textarea:not([disabled])',
|
|
156
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
157
|
+
].join(', ')
|
|
158
|
+
|
|
159
|
+
function focusableItems(modal) {
|
|
160
|
+
return [...modal.querySelectorAll(FOCUSABLE_SELECTOR)]
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The focus trap (WCAG 2.4.3). Nothing intercepted Tab, so focus walked out of
|
|
165
|
+
* the dialog and into the page behind it, where it could reach controls under an
|
|
166
|
+
* overlay it cannot see. `aria-modal="true"` does not help here — it makes screen
|
|
167
|
+
* readers treat outside content as inert, and does nothing to the tab order.
|
|
168
|
+
* Measured before the fix: a button outside the dialog stayed reachable.
|
|
169
|
+
*
|
|
170
|
+
* On `document` rather than the dialog, so it still catches Tab if focus has
|
|
171
|
+
* ended up outside — a click through a gap, or a stale activeElement — and pulls
|
|
172
|
+
* it back rather than only wrapping at the two ends.
|
|
173
|
+
*/
|
|
174
|
+
function trapTab(e) {
|
|
175
|
+
if (e.key !== 'Tab' || !props.show) return
|
|
176
|
+
|
|
177
|
+
const modal = modalContentRef.value
|
|
178
|
+
if (!modal) return
|
|
179
|
+
|
|
180
|
+
const items = focusableItems(modal)
|
|
181
|
+
|
|
182
|
+
// A dialog with nothing focusable still must not leak: the panel itself is
|
|
183
|
+
// `tabindex="-1"`, so it can hold focus even though it is not tabbable.
|
|
184
|
+
if (items.length === 0) {
|
|
185
|
+
e.preventDefault()
|
|
186
|
+
modal.focus()
|
|
187
|
+
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const first = items[0]
|
|
192
|
+
const last = items[items.length - 1]
|
|
193
|
+
const active = document.activeElement
|
|
194
|
+
|
|
195
|
+
if (!modal.contains(active)) {
|
|
196
|
+
e.preventDefault()
|
|
197
|
+
first.focus()
|
|
198
|
+
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!e.shiftKey && active === last) {
|
|
203
|
+
e.preventDefault()
|
|
204
|
+
first.focus()
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (e.shiftKey && active === first) {
|
|
208
|
+
e.preventDefault()
|
|
209
|
+
last.focus()
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const slots = useSlots()
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* `aria-labelledby` is bound to the header slot's id, so a modal with no header
|
|
217
|
+
* announces as "dialog" and nothing else. Eleven of the 45 in the product are in
|
|
218
|
+
* that state, one of which archives a team. The component knows, so it says so
|
|
219
|
+
* once rather than being missed eleven times.
|
|
220
|
+
*
|
|
221
|
+
* Slot *presence*, deliberately, rather than rendered output: the header slot is
|
|
222
|
+
* scoped (`:close`), and probing a scoped slot without passing its scope throws
|
|
223
|
+
* out of a consumer's own destructuring — the bug fixed in #330. Presence is
|
|
224
|
+
* enough to catch the case this is about.
|
|
225
|
+
*
|
|
226
|
+
* Dev-only and deferred by a tick, matching SectionFooter and ButtonSlot: this
|
|
227
|
+
* package ships raw `.vue` source, so an ungated warn reaches real users, and a
|
|
228
|
+
* modal whose header arrives a tick later should not be scolded for it.
|
|
229
|
+
*/
|
|
230
|
+
watchEffect((onCleanup) => {
|
|
231
|
+
if (process.env.NODE_ENV === 'production') return
|
|
232
|
+
if (!props.show) return
|
|
233
|
+
if (slots.header) return
|
|
234
|
+
|
|
235
|
+
const timer = setTimeout(() => console.warn(
|
|
236
|
+
'[RSUI] Modal: rendered with no `header` slot, so `aria-labelledby` is unset and the dialog announces as "dialog" with no name. Give it a header, or an accessible name another way.',
|
|
237
|
+
), 0)
|
|
238
|
+
|
|
239
|
+
onCleanup(() => clearTimeout(timer))
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
onMounted(() => {
|
|
243
|
+
document.addEventListener('keydown', closeOnEscape)
|
|
244
|
+
document.addEventListener('keydown', trapTab)
|
|
245
|
+
})
|
|
145
246
|
|
|
146
247
|
onUnmounted(() => {
|
|
147
248
|
document.removeEventListener('keydown', closeOnEscape)
|
|
249
|
+
document.removeEventListener('keydown', trapTab)
|
|
148
250
|
document.body.style.overflow = null
|
|
149
251
|
})
|
|
150
252
|
|
|
@@ -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
|
|
@@ -38,12 +49,24 @@ const props = defineProps({
|
|
|
38
49
|
/**
|
|
39
50
|
* Heading level (1-6) for the title. When set, the title renders as a real
|
|
40
51
|
* <h1>-<h6> so consuming pages get a correct document outline (WCAG 1.3.1,
|
|
41
|
-
* 2.4.6).
|
|
42
|
-
*
|
|
52
|
+
* 2.4.6).
|
|
53
|
+
*
|
|
54
|
+
* Defaults to 2. It used to default to null, which rendered a non-heading
|
|
55
|
+
* <span> — and three quarters of section titles in the product took that
|
|
56
|
+
* default: 72 of 97. Those sections did not exist for anyone navigating by
|
|
57
|
+
* heading, which is a WCAG 1.3.1 failure rather than untidiness, and it is
|
|
58
|
+
* not something a consumer can be expected to remember at every call site.
|
|
59
|
+
*
|
|
60
|
+
* 2 rather than 3 because PageHeader already renders the page title as <h1>,
|
|
61
|
+
* so a section beneath it wants 2 almost every time; a nested section asks
|
|
62
|
+
* for 3, as the 25 call sites that already pass a level are doing.
|
|
63
|
+
*
|
|
64
|
+
* Explicit null is still honoured, for a title that genuinely is not a
|
|
65
|
+
* section heading — a toolbar label rather than a landmark.
|
|
43
66
|
*/
|
|
44
67
|
headingLevel: {
|
|
45
68
|
type: [Number, String],
|
|
46
|
-
default:
|
|
69
|
+
default: 2,
|
|
47
70
|
validator: (value) => value === null || ['1', '2', '3', '4', '5', '6'].includes(String(value)),
|
|
48
71
|
},
|
|
49
72
|
})
|
|
@@ -60,20 +83,61 @@ function handleMoreActionsClick() {
|
|
|
60
83
|
|
|
61
84
|
const attrs = useAttrs()
|
|
62
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
|
+
*/
|
|
63
99
|
const isClickable = computed(() => !!attrs.onClick)
|
|
64
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
|
+
|
|
65
112
|
const slots = useSlots()
|
|
66
113
|
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
69
120
|
|
|
70
|
-
return
|
|
71
|
-
|
|
72
|
-
|
|
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
|
+
}
|
|
73
137
|
|
|
74
138
|
/**
|
|
75
139
|
* Element used to render the title — a real heading when a level is supplied,
|
|
76
|
-
* otherwise a non-heading <span
|
|
140
|
+
* otherwise a non-heading <span>, which now requires passing null explicitly.
|
|
77
141
|
*/
|
|
78
142
|
const titleTag = computed(() => props.headingLevel ? `h${props.headingLevel}` : 'span')
|
|
79
143
|
</script>
|
|
@@ -86,6 +150,10 @@ const titleTag = computed(() => props.headingLevel ? `h${props.headingLevel}` :
|
|
|
86
150
|
'rsui-section-header--clickable': isClickable,
|
|
87
151
|
}
|
|
88
152
|
]"
|
|
153
|
+
:role="isClickable ? 'button' : undefined"
|
|
154
|
+
:tabindex="isClickable ? 0 : undefined"
|
|
155
|
+
@keydown.enter.self.prevent="handleKeydown"
|
|
156
|
+
@keydown.space.self.prevent="handleKeydown"
|
|
89
157
|
>
|
|
90
158
|
<div class="rsui-section-header__header">
|
|
91
159
|
|
|
@@ -98,7 +166,7 @@ const titleTag = computed(() => props.headingLevel ? `h${props.headingLevel}` :
|
|
|
98
166
|
|
|
99
167
|
<div :class="{
|
|
100
168
|
'rsui-section-header__text': true,
|
|
101
|
-
'rsui-section-header__text--with-toolbar':
|
|
169
|
+
'rsui-section-header__text--with-toolbar': showsToolbar() || $slots.icon,
|
|
102
170
|
}">
|
|
103
171
|
|
|
104
172
|
<!-- Title slot, default slot -->
|
|
@@ -129,7 +197,7 @@ const titleTag = computed(() => props.headingLevel ? `h${props.headingLevel}` :
|
|
|
129
197
|
|
|
130
198
|
<!-- Actions slot, optional -->
|
|
131
199
|
<div class="rsui-section-header__toolbar"
|
|
132
|
-
v-if="
|
|
200
|
+
v-if="showsToolbar()"
|
|
133
201
|
>
|
|
134
202
|
<!-- Desktop actions slot, optional -->
|
|
135
203
|
<div class="rsui-section-header__actions-desktop"
|
|
@@ -139,7 +207,7 @@ const titleTag = computed(() => props.headingLevel ? `h${props.headingLevel}` :
|
|
|
139
207
|
</div>
|
|
140
208
|
|
|
141
209
|
<!-- More actions slot, optional -->
|
|
142
|
-
<div v-if="
|
|
210
|
+
<div v-if="showsMoreActions()"
|
|
143
211
|
class="rsui-section-header__more-actions"
|
|
144
212
|
>
|
|
145
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'
|
|
@@ -37,12 +38,20 @@ const props = defineProps({
|
|
|
37
38
|
},
|
|
38
39
|
/**
|
|
39
40
|
* Heading level (1-6) for the slider title. When set, the title renders
|
|
40
|
-
* as a real <h1>-<h6> for a correct document outline
|
|
41
|
-
*
|
|
41
|
+
* as a real <h1>-<h6> for a correct document outline.
|
|
42
|
+
*
|
|
43
|
+
* Defaults to 2, matching SectionHeader. Declared here rather than left to
|
|
44
|
+
* SectionHeader's own default because this component forwards the value
|
|
45
|
+
* explicitly — so a null default would have passed null through and kept
|
|
46
|
+
* every slider title a non-heading <span> while every other section title
|
|
47
|
+
* became a heading. A default a call site cannot reach is the same trap
|
|
48
|
+
* CardGroup and List hit with Empty's showImage. See #337.
|
|
49
|
+
*
|
|
50
|
+
* Explicit null is still honoured for a title that is not a section heading.
|
|
42
51
|
*/
|
|
43
52
|
headingLevel: {
|
|
44
53
|
type: [Number, String],
|
|
45
|
-
default:
|
|
54
|
+
default: 2,
|
|
46
55
|
validator: (value) => value === null || ['1', '2', '3', '4', '5', '6'].includes(String(value)),
|
|
47
56
|
},
|
|
48
57
|
/**
|
|
@@ -122,6 +131,14 @@ const sliderClasses = computed(() => [
|
|
|
122
131
|
|
|
123
132
|
const slots = useSlots()
|
|
124
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
|
+
|
|
125
142
|
/**
|
|
126
143
|
* A consumer-supplied `actions` slot always wins and always renders in the
|
|
127
144
|
* header — that slot exists so consumers can put their own controls up there.
|
|
@@ -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
|
+
}
|