@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redseed/redseed-ui-vue3",
3
- "version": "8.70.0",
3
+ "version": "10.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,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: true,
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: false,
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
- headerOnlyCard.value ? true : showDivider.value
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="showMoreActions"
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: 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,
@@ -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
- if (document.getElementById(triggerId)) canTeleportTrigger.value = true
95
- if (document.getElementById(contentId)) canTeleportContent.value = true
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
- const observer = new MutationObserver(() => {
102
- setTeleport()
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
- <Teleport v-if="canTeleportTrigger"
125
- :to="`#${triggerId}`"
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
- <Teleport v-if="canTeleportContent"
159
- :to="`#${contentId}`"
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="showImage"
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
- const showMetaModal = ref(false)
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
- <div v-if="$slots['meta']" class="rsui-page-header__meta">
112
- <slot name="meta"></slot>
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
- 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
 
@@ -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: true,
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). Left unset it stays a non-heading <span>, keeping existing
42
- * consumers unaffected — pick the level that fits the page's heading order.
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: null,
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
- const showToolbar = computed(() => {
68
- const showMoreActions = props.showMoreActions && slots['more-actions']
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 props.showActions
71
- && (slots.actions || showMoreActions)
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> (preserves the previous, level-less markup).
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': showToolbar || $slots.icon,
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="showToolbar"
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="showMoreActions"
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; left unset it stays
41
- * a non-heading <span> so existing consumers are unaffected.
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: null,
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
+ }