@redseed/redseed-ui-vue3 8.69.0 → 9.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redseed/redseed-ui-vue3",
3
- "version": "8.69.0",
3
+ "version": "9.0.0",
4
4
  "description": "RedSeed UI Vue 3 components",
5
5
  "main": "index.js",
6
6
  "repository": "https://github.com/redseedtraining/redseed-ui",
@@ -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: true,
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: true,
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,5 +1,5 @@
1
1
  <script setup>
2
- import { computed, ref, toRefs, useAttrs } from 'vue'
2
+ import { computed, inject, ref, toRefs, useAttrs } from 'vue'
3
3
  import ButtonTertiary from '../Button/ButtonTertiary.vue'
4
4
  import Icon from '../Icon/Icon.vue'
5
5
  import { useResponsiveWidth } from '../../helpers'
@@ -45,9 +45,21 @@ const props = defineProps({
45
45
  type: Boolean,
46
46
  default: true,
47
47
  },
48
+ /**
49
+ * Pay for a bottom edge because nothing sits beneath this header.
50
+ *
51
+ * Left unset, a CardHeader inside a Card works it out: Card provides whether
52
+ * it will render a body, and a header with no body beneath it pads itself.
53
+ * That way the correct rendering is what you get, and this prop is an
54
+ * override rather than a requirement — it defaulted to false, which made the
55
+ * broken rendering the default.
56
+ *
57
+ * Null rather than false so an explicit `false` can still win. A CardHeader
58
+ * used outside a Card gets no answer and keeps the old behaviour.
59
+ */
48
60
  headerOnlyCard: {
49
61
  type: Boolean,
50
- default: false,
62
+ default: null,
51
63
  },
52
64
  avatarTop: {
53
65
  type: Boolean,
@@ -67,8 +79,22 @@ const { responsiveWidth } = useResponsiveWidth(cardHeaderElement, 640)
67
79
 
68
80
  const emit = defineEmits(['click:more-actions'])
69
81
 
82
+ /**
83
+ * `null` means "no opinion from the consumer", so fall through to what the
84
+ * enclosing Card says. Outside a Card there is nothing to inject and the value
85
+ * is null, which lands on the old behaviour.
86
+ */
87
+ const cardHasBody = inject('rsuiCardHasBody', null)
88
+
89
+ const isHeaderOnly = computed(() => {
90
+ if (headerOnlyCard.value !== null) return headerOnlyCard.value
91
+ if (cardHasBody) return !cardHasBody.value
92
+
93
+ return false
94
+ })
95
+
70
96
  const shouldPadBottom = computed(() =>
71
- headerOnlyCard.value ? true : showDivider.value
97
+ isHeaderOnly.value ? true : showDivider.value
72
98
  )
73
99
 
74
100
  function handleMoreActionsClick() {
@@ -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: true,
16
+ default: false,
10
17
  },
11
18
  hoverable: {
12
19
  type: Boolean,
13
- default: true,
20
+ default: false,
14
21
  },
15
22
  bordered: {
16
23
  type: Boolean,
@@ -1,4 +1,6 @@
1
1
  <script setup>
2
+ import { computed, useId } from 'vue'
3
+ import { ArrowUpRightIcon, ArrowDownRightIcon, ArrowRightIcon } from '@heroicons/vue/24/outline'
2
4
  import Card from './Card.vue'
3
5
  import FlexContainer from '../FlexContainer/FlexContainer.vue'
4
6
 
@@ -34,12 +36,99 @@ const props = defineProps({
34
36
  // assume light-on-dark: surface, ink, muted ink and rule move together.
35
37
  //
36
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.
37
45
  tone: {
38
46
  type: String,
39
47
  default: null,
40
- 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: '',
67
+ },
68
+ // DIRECTION of travel — which arrow. Deliberately separate from `sentiment`,
69
+ // because an arrow direction and a colour are not the same decision:
70
+ // attrition up is bad, completions up is good. One prop driving both would
71
+ // render every metric where rising is bad in green.
72
+ trend: {
73
+ type: String,
74
+ default: null,
75
+ validator: value => ['up', 'down', 'flat'].includes(value),
76
+ },
77
+ // Whether that direction is GOOD NEWS — colour only. Defaults to deriving
78
+ // from `trend` (up => positive) so the common case stays terse, but it is
79
+ // independently overridable, which is the entire point of the split.
80
+ sentiment: {
81
+ type: String,
82
+ default: null,
83
+ validator: value => ['positive', 'negative', 'neutral'].includes(value),
84
+ },
85
+ // WHERE the comparison sits relative to the figure.
86
+ //
87
+ // below the comparison is a caption under the number (the default, and
88
+ // what every existing tile already renders).
89
+ // inline the comparison sits on the number's own row, immediately beside
90
+ // it, centred against its line box.
91
+ //
92
+ // A string rather than an `inlineComparison` boolean because the reference
93
+ // carries a third shape too — the change as a bordered chip pushed to the far
94
+ // right of the number row — and a boolean would have to be retired to add it.
95
+ //
96
+ // Inline is the tighter tile: it buys back a line of height, which is what
97
+ // makes a dense dashboard row of these work. It costs horizontal room, so the
98
+ // row wraps back to two lines rather than overflowing when the tile is narrow.
99
+ comparisonPlacement: {
100
+ type: String,
101
+ default: 'below',
102
+ validator: value => ['below', 'inline'].includes(value),
41
103
  },
42
104
  })
105
+
106
+ // The terse default. `sentiment` is read first so an explicit value always wins,
107
+ // including 'neutral', which is why this tests for null rather than falsiness.
108
+ const TREND_SENTIMENT = {
109
+ up: 'positive',
110
+ down: 'negative',
111
+ flat: 'neutral',
112
+ }
113
+
114
+ const resolvedSentiment = computed(() => {
115
+ if (props.sentiment !== null) return props.sentiment
116
+
117
+ return TREND_SENTIMENT[props.trend] ?? 'neutral'
118
+ })
119
+
120
+ const TREND_ICON = {
121
+ up: ArrowUpRightIcon,
122
+ down: ArrowDownRightIcon,
123
+ flat: ArrowRightIcon,
124
+ }
125
+
126
+ const trendIcon = computed(() => TREND_ICON[props.trend] ?? null)
127
+
128
+ // Ties the comparison to the number for assistive tech, so the trend is not read
129
+ // as a loose fragment floating after the figure. DOM order already puts them
130
+ // adjacent; aria-describedby is what survives the number being queried alone.
131
+ const comparisonId = useId()
43
132
  </script>
44
133
 
45
134
  <template>
@@ -52,6 +141,8 @@ const props = defineProps({
52
141
  :clickable="props.clickable"
53
142
  :hoverable="props.hoverable"
54
143
  :pressed="active"
144
+ :accent="props.accent"
145
+ :color="props.color"
55
146
  ph-component="MetricCard"
56
147
  >
57
148
  <template v-if="$slots['aria-label']" #aria-label>
@@ -69,19 +160,82 @@ const props = defineProps({
69
160
  <div v-if="$slots.icon" class="rsui-metric-card__icon" aria-hidden="true">
70
161
  <slot name="icon"></slot>
71
162
  </div>
72
- <div class="rsui-metric-card__label">
73
- <slot name="label"></slot>
163
+ <div class="rsui-metric-card__heading-text">
164
+ <div class="rsui-metric-card__label">
165
+ <slot name="label"></slot>
166
+ </div>
167
+
168
+ <div v-if="$slots.description" class="rsui-metric-card__description">
169
+ <slot name="description"></slot>
170
+ </div>
74
171
  </div>
75
172
  </div>
76
173
 
174
+ <!-- The figure and the thing that qualifies it, as one unit. The wrapper
175
+ stacks them by default, which renders exactly as the two siblings did
176
+ before it existed, and turns into a row when comparisonPlacement is
177
+ inline. Keeping both placements on the same two elements is what lets
178
+ the a11y wiring, the sentiment modifiers and the tonal overrides stay
179
+ placement-agnostic — only the direction of this one box changes. -->
77
180
  <div :class="[
78
- 'rsui-metric-card__number',
181
+ 'rsui-metric-card__figure',
182
+ `rsui-metric-card__figure--${props.comparisonPlacement}`,
79
183
  { 'rsui-metric-card--center': alignment === 'center' },
80
184
  { 'rsui-metric-card--left': alignment === 'left' },
81
185
  { 'rsui-metric-card--right': alignment === 'right' },
82
186
  ]"
83
187
  >
84
- <slot name="number"></slot>
188
+ <div :class="[
189
+ 'rsui-metric-card__number',
190
+ { 'rsui-metric-card--center': alignment === 'center' },
191
+ { 'rsui-metric-card--left': alignment === 'left' },
192
+ { 'rsui-metric-card--right': alignment === 'right' },
193
+ ]"
194
+ :aria-describedby="$slots.comparison ? comparisonId : undefined"
195
+ >
196
+ <slot name="number"></slot>
197
+ </div>
198
+
199
+ <!-- With the number at every labelFirst setting, matching the reference
200
+ shape: the comparison qualifies the figure, not the label, so it
201
+ stays with the figure wherever the label goes. -->
202
+ <div v-if="$slots.comparison"
203
+ :class="[
204
+ 'rsui-metric-card__comparison-row',
205
+ { 'rsui-metric-card--center': alignment === 'center' },
206
+ { 'rsui-metric-card--left': alignment === 'left' },
207
+ { 'rsui-metric-card--right': alignment === 'right' },
208
+ ]"
209
+ >
210
+ <span :id="comparisonId"
211
+ :class="[
212
+ 'rsui-metric-card__comparison',
213
+ `rsui-metric-card__comparison--${resolvedSentiment}`,
214
+ ]"
215
+ >
216
+ <span v-if="trendIcon" class="rsui-metric-card__comparison-icon" aria-hidden="true">
217
+ <component :is="trendIcon" />
218
+ </span>
219
+
220
+ <slot name="comparison"></slot>
221
+
222
+ <!-- The quieter half of the reference's "Change and text": the change
223
+ carries the sentiment colour, the phrase that qualifies it drops to
224
+ tertiary ink so the eye lands on the figure first.
225
+
226
+ A second slot rather than asking the consumer to wrap the phrase in
227
+ a class themselves. The split is a colour decision, which belongs to
228
+ the design system; the words and their translation stay with the
229
+ consumer, exactly as the comparison itself does.
230
+
231
+ Inside the comparison span, so it sits within the region the number's
232
+ aria-describedby points at — "175.95% Increase vs last month" is one
233
+ description of the figure, not a fragment plus an orphan. -->
234
+ <span v-if="$slots.comparisonContext" class="rsui-metric-card__comparison-context">
235
+ <slot name="comparisonContext"></slot>
236
+ </span>
237
+ </span>
238
+ </div>
85
239
  </div>
86
240
 
87
241
  <div v-if="!labelFirst"
@@ -95,8 +249,14 @@ const props = defineProps({
95
249
  <div v-if="$slots.icon" class="rsui-metric-card__icon" aria-hidden="true">
96
250
  <slot name="icon"></slot>
97
251
  </div>
98
- <div class="rsui-metric-card__label">
99
- <slot name="label"></slot>
252
+ <div class="rsui-metric-card__heading-text">
253
+ <div class="rsui-metric-card__label">
254
+ <slot name="label"></slot>
255
+ </div>
256
+
257
+ <div v-if="$slots.description" class="rsui-metric-card__description">
258
+ <slot name="description"></slot>
259
+ </div>
100
260
  </div>
101
261
  </div>
102
262
  </FlexContainer>
@@ -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="showImage"
115
+ <div v-if="showsImage()"
44
116
  class="rsui-empty__image"
45
117
  >
46
118
  <slot name="image">
@@ -15,10 +15,37 @@ const props = defineProps({
15
15
  //
16
16
  // A toned header supplies its own padding, so place it outside the page's content
17
17
  // gutter and let the band bleed. Omit the prop and nothing changes.
18
+ //
19
+ // `bloom` is the brand's hero treatment — a green-to-mauve sweep with three radial
20
+ // washes over it — rather than a flat fill like the other three. It is a tone rather
21
+ // than a modifier layered over one because its wash alphas are a measured contrast
22
+ // budget against its own base sweep; over a different background the measurement
23
+ // does not hold. See page_header.css for the figures.
18
24
  tone: {
19
25
  type: String,
20
26
  default: null,
21
- validator: value => ['green', 'teal', 'light'].includes(value),
27
+ validator: value => ['green', 'teal', 'light', 'bloom'].includes(value),
28
+ },
29
+ // Bleeds a toned band to the viewport edges while keeping its text on the page's own
30
+ // gutter, so the greeting lines up with the logo and nav above it rather than sitting
31
+ // 24px from the screen edge.
32
+ //
33
+ // This is what a toned header wants in an app shell, and the note on `tone` — "place
34
+ // it outside the page's content gutter" — is the version of it a consumer has to build
35
+ // themselves. With this the header stays INSIDE the container where it belongs in the
36
+ // document, and only its paint escapes.
37
+ //
38
+ // Requires `overflow-x: clip` on the document (html and body): the bleed is measured
39
+ // in vw, which includes the scrollbar gutter, so it overshoots by half a scrollbar
40
+ // each side. Clip it on the document rather than on the page root — clipping the root
41
+ // clips the bleed itself, and the band's box reaches the edge while its paint stops at
42
+ // the container. `clip` rather than `hidden`, so neither element becomes a scroll
43
+ // container and position: sticky keeps working.
44
+ //
45
+ // No effect without a tone: there is no band to bleed.
46
+ bleed: {
47
+ type: Boolean,
48
+ default: false,
22
49
  },
23
50
  })
24
51
 
@@ -30,6 +57,7 @@ const pageHeaderClass = computed(() => [
30
57
  'rsui-page-header--profile': props.profile,
31
58
  'rsui-page-header--toned': !!props.tone,
32
59
  [`rsui-page-header--tone-${props.tone}`]: !!props.tone,
60
+ 'rsui-page-header--bleed': !!props.tone && props.bleed,
33
61
  },
34
62
  ])
35
63
  </script>
@@ -41,6 +69,22 @@ const pageHeaderClass = computed(() => [
41
69
  <slot name="avatar"></slot>
42
70
  </div>
43
71
  <div class="rsui-page-header__title-text">
72
+ <!--
73
+ A small label ABOVE the h1, matching CardHeader's #eyebrow: the
74
+ line a page uses to say what kind of thing it is, or what day it
75
+ is, before it says which one. Without it the only home for that
76
+ line is #title, which puts it inside the h1 and gives the page two
77
+ headings in one.
78
+
79
+ Gated on slot presence alone rather than on a showEyebrow prop
80
+ like CardHeader's, because every other slot on this component is
81
+ gated the same way and a prop that only ever hides an absent
82
+ element earns nothing.
83
+ -->
84
+ <div v-if="$slots.eyebrow" class="rsui-page-header__eyebrow">
85
+ <slot name="eyebrow"></slot>
86
+ </div>
87
+
44
88
  <div class="rsui-page-header__title-text-container">
45
89
  <h1>
46
90
  <slot name="title"></slot>
@@ -55,6 +99,24 @@ const pageHeaderClass = computed(() => [
55
99
  </div>
56
100
  </div>
57
101
 
102
+ <!--
103
+ A row of summary tiles belonging to the header itself — the counts a page
104
+ leads with, as MetricCards rather than as label/value pairs.
105
+
106
+ Inside __top rather than under it, so it inherits the row/column the
107
+ header already switches at lg: stacked under the title on a phone, beside
108
+ it on a desktop. That is one layout rule, not a second set of breakpoints.
109
+
110
+ NOT the meta slot. __meta is `hidden lg:flex` — a grid of label/value
111
+ facts that collapses into a modal on a phone (see #369) — and a count the
112
+ page leads with is not a fact you go looking for in a modal. It also
113
+ carries the tonal rule above it, which puts a line between the title and
114
+ anything placed there.
115
+ -->
116
+ <div v-if="$slots.stats" class="rsui-page-header__stats">
117
+ <slot name="stats"></slot>
118
+ </div>
119
+
58
120
  <div v-if="$slots.actions" class="rsui-page-header__status-actions">
59
121
 
60
122
 
@@ -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
- const focusable = modal.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')
115
- if (focusable) {
116
- focusable.focus()
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
- onMounted(() => document.addEventListener('keydown', closeOnEscape))
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
 
@@ -38,12 +38,24 @@ const props = defineProps({
38
38
  /**
39
39
  * Heading level (1-6) for the title. When set, the title renders as a real
40
40
  * <h1>-<h6> so consuming pages get a correct document outline (WCAG 1.3.1,
41
- * 2.4.6). Left unset it stays a non-heading <span>, keeping existing
42
- * consumers unaffected — pick the level that fits the page's heading order.
41
+ * 2.4.6).
42
+ *
43
+ * Defaults to 2. It used to default to null, which rendered a non-heading
44
+ * <span> — and three quarters of section titles in the product took that
45
+ * default: 72 of 97. Those sections did not exist for anyone navigating by
46
+ * heading, which is a WCAG 1.3.1 failure rather than untidiness, and it is
47
+ * not something a consumer can be expected to remember at every call site.
48
+ *
49
+ * 2 rather than 3 because PageHeader already renders the page title as <h1>,
50
+ * so a section beneath it wants 2 almost every time; a nested section asks
51
+ * for 3, as the 25 call sites that already pass a level are doing.
52
+ *
53
+ * Explicit null is still honoured, for a title that genuinely is not a
54
+ * section heading — a toolbar label rather than a landmark.
43
55
  */
44
56
  headingLevel: {
45
57
  type: [Number, String],
46
- default: null,
58
+ default: 2,
47
59
  validator: (value) => value === null || ['1', '2', '3', '4', '5', '6'].includes(String(value)),
48
60
  },
49
61
  })
@@ -73,7 +85,7 @@ const showToolbar = computed(() => {
73
85
 
74
86
  /**
75
87
  * Element used to render the title — a real heading when a level is supplied,
76
- * otherwise a non-heading <span> (preserves the previous, level-less markup).
88
+ * otherwise a non-heading <span>, which now requires passing null explicitly.
77
89
  */
78
90
  const titleTag = computed(() => props.headingLevel ? `h${props.headingLevel}` : 'span')
79
91
  </script>
@@ -37,12 +37,20 @@ const props = defineProps({
37
37
  },
38
38
  /**
39
39
  * Heading level (1-6) for the slider title. When set, the title renders
40
- * as a real <h1>-<h6> for a correct document outline; left unset it stays
41
- * a non-heading <span> so existing consumers are unaffected.
40
+ * as a real <h1>-<h6> for a correct document outline.
41
+ *
42
+ * Defaults to 2, matching SectionHeader. Declared here rather than left to
43
+ * SectionHeader's own default because this component forwards the value
44
+ * explicitly — so a null default would have passed null through and kept
45
+ * every slider title a non-heading <span> while every other section title
46
+ * became a heading. A default a call site cannot reach is the same trap
47
+ * CardGroup and List hit with Empty's showImage. See #337.
48
+ *
49
+ * Explicit null is still honoured for a title that is not a section heading.
42
50
  */
43
51
  headingLevel: {
44
52
  type: [Number, String],
45
- default: null,
53
+ default: 2,
46
54
  validator: (value) => value === null || ['1', '2', '3', '4', '5', '6'].includes(String(value)),
47
55
  },
48
56
  /**