@redseed/redseed-ui-vue3 8.62.0 → 8.63.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/Icon/IconSlot.vue +19 -0
- package/src/components/MessageBox/MessageBox.vue +164 -30
- package/src/components/Section/SectionFooter.vue +198 -8
- package/src/components/Section/SectionSlider.vue +513 -74
- package/src/components/Section/SectionSliderAction.vue +65 -0
- package/src/components/Section/SectionSliderItem.vue +10 -0
- package/src/components/Section/index.js +2 -0
- package/src/components/Switcher/Switcher.vue +21 -0
package/package.json
CHANGED
|
@@ -90,6 +90,24 @@ const props = defineProps({
|
|
|
90
90
|
type: Boolean,
|
|
91
91
|
default: false,
|
|
92
92
|
},
|
|
93
|
+
/**
|
|
94
|
+
* Expand a ring outwards from the icon a few times on arrival, to draw the
|
|
95
|
+
* eye to something that has just appeared.
|
|
96
|
+
*
|
|
97
|
+
* Runs a fixed number of iterations rather than looping. An animation that
|
|
98
|
+
* repeats indefinitely for more than five seconds needs a pause control
|
|
99
|
+
* (WCAG 2.2.2), and an icon has nowhere sensible to put one — a burst on
|
|
100
|
+
* arrival gets the attention without taking on that obligation. Suppressed
|
|
101
|
+
* entirely under `prefers-reduced-motion`.
|
|
102
|
+
*
|
|
103
|
+
* The ring is drawn around the icon's own box, so it suits a round glyph —
|
|
104
|
+
* the circle heroicons, or anything with `circle`. On a square icon it reads
|
|
105
|
+
* as a mistake rather than a pulse.
|
|
106
|
+
*/
|
|
107
|
+
pulse: {
|
|
108
|
+
type: Boolean,
|
|
109
|
+
default: false,
|
|
110
|
+
},
|
|
93
111
|
})
|
|
94
112
|
|
|
95
113
|
/**
|
|
@@ -162,6 +180,7 @@ const iconClass = computed(() => [
|
|
|
162
180
|
*/
|
|
163
181
|
'rsui-icon--background': props.background,
|
|
164
182
|
'rsui-icon--invert': props.invert,
|
|
183
|
+
'rsui-icon--pulse': props.pulse,
|
|
165
184
|
}
|
|
166
185
|
])
|
|
167
186
|
</script>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { ref, computed } from 'vue'
|
|
2
|
+
import { ref, computed, watch, watchEffect, useSlots, Comment, Fragment } from 'vue'
|
|
3
3
|
import Icon from '../Icon/Icon.vue'
|
|
4
4
|
import { XMarkIcon } from '@heroicons/vue/24/outline'
|
|
5
5
|
|
|
@@ -20,13 +20,101 @@ const props = defineProps({
|
|
|
20
20
|
type: String,
|
|
21
21
|
default: 'default',
|
|
22
22
|
validator: (value) => ['default', 'info', 'success', 'warning', 'error', 'ai'].includes(value)
|
|
23
|
-
}
|
|
23
|
+
},
|
|
24
|
+
/**
|
|
25
|
+
* Renders a quiet dismiss action in the actions row, wired to the same close
|
|
26
|
+
* path as the corner button. Owned by the component rather than left to the
|
|
27
|
+
* `actions` slot, so dismissal cannot drift in wording or come unwired.
|
|
28
|
+
*/
|
|
29
|
+
dismissLabel: {
|
|
30
|
+
type: String,
|
|
31
|
+
default: '',
|
|
32
|
+
},
|
|
24
33
|
})
|
|
25
34
|
|
|
26
35
|
const emit = defineEmits(['close'])
|
|
27
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Enforced at render, not just by the prop validator. Validators only warn and are
|
|
39
|
+
* stripped from production builds, so an unrecognised value used to reach the
|
|
40
|
+
* class attribute and the computeds below — and every fallback pointed the wrong
|
|
41
|
+
* way at once: no background (no matching CSS rule), an *information* glyph on
|
|
42
|
+
* what might be an error, and no live region at all, so a screen reader user was
|
|
43
|
+
* never told the operation failed.
|
|
44
|
+
*/
|
|
45
|
+
const VARIANTS = ['default', 'info', 'success', 'warning', 'error', 'ai']
|
|
46
|
+
|
|
47
|
+
const slots = useSlots()
|
|
48
|
+
|
|
28
49
|
const isClosed = ref(props.closed)
|
|
29
50
|
|
|
51
|
+
const isKnownVariant = computed(() => VARIANTS.includes(props.variant))
|
|
52
|
+
|
|
53
|
+
const safeVariant = computed(() => isKnownVariant.value ? props.variant : 'default')
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Dev-only, matching LinkSlot, ButtonSlot, Pill and InlineEditableText — this
|
|
57
|
+
* package ships raw `.vue` source, so an ungated warn reaches real users'
|
|
58
|
+
* consoles. Silence would leave a consumer with an unstyled box, a decoration that
|
|
59
|
+
* never appears, or an unnamed control, and no way to know why.
|
|
60
|
+
*/
|
|
61
|
+
watchEffect(() => {
|
|
62
|
+
if (process.env.NODE_ENV === 'production') return
|
|
63
|
+
|
|
64
|
+
if (!isKnownVariant.value) {
|
|
65
|
+
console.warn(`[RSUI] MessageBox: variant "${props.variant}" is not supported (${VARIANTS.join(', ')}). Falling back to "default".`)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (props.dismissLabel !== '' && props.dismissLabel.trim() === '') {
|
|
69
|
+
console.warn('[RSUI] MessageBox: `dismissLabel` is only whitespace, so no dismiss control has been rendered — a button with no accessible name is worse than no button.')
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* `closed` is a two-way switch, not a starting position. Without this the ref was
|
|
75
|
+
* seeded once at setup and never looked at the prop again, so a consumer could
|
|
76
|
+
* neither close the box by setting it nor re-show a dismissed one by clearing it
|
|
77
|
+
* — and a flash message re-shown on the next visit would silently never appear,
|
|
78
|
+
* because Vue patches the same instance rather than remounting it.
|
|
79
|
+
*/
|
|
80
|
+
watch(() => props.closed, (value) => {
|
|
81
|
+
isClosed.value = value
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A Fragment is a wrapper, so its own presence answers nothing — only what is
|
|
86
|
+
* inside it does. Hence the recursion: a `v-for` compiles to a Fragment and
|
|
87
|
+
* leaves a comment placeholder for each hidden item, so counting children
|
|
88
|
+
* reported content for a list whose every action was filtered out (the usual
|
|
89
|
+
* shape for permission-gated actions), and a nested `<template>` puts another
|
|
90
|
+
* Fragment inside that one.
|
|
91
|
+
*
|
|
92
|
+
* `Array.isArray` rather than recursing straight into `children`, which is a raw
|
|
93
|
+
* string on a Fragment built by hand as `h(Fragment, null, 'text')`. Vue's own
|
|
94
|
+
* `ensureValidVNode` assumes the array and throws on that shape, so answering
|
|
95
|
+
* "renders" here only hands it a slot it cannot render; treating it as empty
|
|
96
|
+
* drops the row instead, which is the quieter of the two failures. Compiled
|
|
97
|
+
* templates always produce the array.
|
|
98
|
+
*/
|
|
99
|
+
function rendersNode(node) {
|
|
100
|
+
if (node.type === Comment) return false
|
|
101
|
+
if (node.type === Fragment) return Array.isArray(node.children) && node.children.some(rendersNode)
|
|
102
|
+
|
|
103
|
+
return true
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Whether a slot renders anything at all. Slot *presence* is not the same
|
|
108
|
+
* question: a slot holding only a falsy `v-if` is still a truthy slot function
|
|
109
|
+
* that renders a comment placeholder, which would put an empty padded row under
|
|
110
|
+
* the message.
|
|
111
|
+
*/
|
|
112
|
+
function rendersContent(slot) {
|
|
113
|
+
const nodes = slot?.()
|
|
114
|
+
|
|
115
|
+
return Boolean(nodes) && nodes.some(rendersNode)
|
|
116
|
+
}
|
|
117
|
+
|
|
30
118
|
/**
|
|
31
119
|
* Announce the box as a live region so flash / MOTD content reaches assistive
|
|
32
120
|
* tech (WCAG 4.1.3 Status Messages). `alert` is assertive — reserved for errors
|
|
@@ -40,11 +128,28 @@ const isClosed = ref(props.closed)
|
|
|
40
128
|
* interrupt for something nobody asked about.
|
|
41
129
|
*/
|
|
42
130
|
const liveRole = computed(() => {
|
|
43
|
-
if (
|
|
44
|
-
if (['info', 'success', 'warning'].includes(
|
|
131
|
+
if (safeVariant.value === 'error') return 'alert'
|
|
132
|
+
if (['info', 'success', 'warning'].includes(safeVariant.value)) return 'status'
|
|
45
133
|
return undefined
|
|
46
134
|
})
|
|
47
135
|
|
|
136
|
+
/**
|
|
137
|
+
* The icon is the consumer's, not ours — matching Pill, MetricCard, ListItem and
|
|
138
|
+
* SectionHeader, which all take one through a slot. RSUI only hardcodes a glyph
|
|
139
|
+
* where it is chrome belonging to the control itself (Disclosure's chevron,
|
|
140
|
+
* Pagination's arrows); the marker beside a message is content, and which glyph
|
|
141
|
+
* says "this came from the model" or "this is a billing notice" is the caller's
|
|
142
|
+
* call, not a mapping this component can guess from a variant name.
|
|
143
|
+
*
|
|
144
|
+
* Colour, shape and the attention pulse all ride on the `Icon` the consumer
|
|
145
|
+
* passes — `<Icon ai circle background pulse>` — so none of it needs a prop here.
|
|
146
|
+
*/
|
|
147
|
+
function hasIcon() {
|
|
148
|
+
return rendersContent(slots.icon)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const hasDismiss = computed(() => props.dismissLabel.trim() !== '')
|
|
152
|
+
|
|
48
153
|
function close() {
|
|
49
154
|
isClosed.value = true
|
|
50
155
|
emit('close')
|
|
@@ -54,41 +159,70 @@ function close() {
|
|
|
54
159
|
<div v-if="!isClosed"
|
|
55
160
|
:class="[
|
|
56
161
|
'rsui-message-box',
|
|
57
|
-
`rsui-message-box--${
|
|
162
|
+
`rsui-message-box--${safeVariant}`,
|
|
163
|
+
{ 'rsui-message-box--with-icon': hasIcon() },
|
|
58
164
|
]"
|
|
59
165
|
:role="liveRole"
|
|
60
166
|
>
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
<XMarkIcon aria-hidden="true"></XMarkIcon>
|
|
73
|
-
</Icon>
|
|
74
|
-
</button>
|
|
75
|
-
</div>
|
|
167
|
+
<!--
|
|
168
|
+
A sibling of the whole text column rather than of the body, so it sits
|
|
169
|
+
beside the title and the text below it indents past it instead of wrapping
|
|
170
|
+
under it.
|
|
171
|
+
|
|
172
|
+
`aria-hidden` because the glyph restates the message rather than adding to
|
|
173
|
+
it, matching Pill and MetricCard — the variant's meaning reaches assistive
|
|
174
|
+
tech through the live region, not through a decorative icon.
|
|
175
|
+
-->
|
|
176
|
+
<div v-if="hasIcon()" class="rsui-message-box__icon" aria-hidden="true">
|
|
177
|
+
<slot name="icon"></slot>
|
|
76
178
|
</div>
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
179
|
+
|
|
180
|
+
<div class="rsui-message-box__main">
|
|
181
|
+
<div v-if="$slots.title" class="rsui-message-box__head">
|
|
182
|
+
<div class="rsui-message-box__title">
|
|
183
|
+
<slot name="title"></slot>
|
|
184
|
+
</div>
|
|
80
185
|
</div>
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
186
|
+
|
|
187
|
+
<div class="rsui-message-box__content">
|
|
188
|
+
<div class="rsui-message-box__body">
|
|
189
|
+
<slot></slot>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<!--
|
|
194
|
+
Actions stay neutral rather than taking the variant colour. Coloured
|
|
195
|
+
text on a coloured tint is a contrast problem, and the action is a way
|
|
196
|
+
out of the message rather than a restatement of its severity.
|
|
197
|
+
-->
|
|
198
|
+
<div v-if="hasDismiss || rendersContent($slots.actions)" class="rsui-message-box__actions">
|
|
199
|
+
<button v-if="hasDismiss"
|
|
200
|
+
type="button"
|
|
201
|
+
class="rsui-message-box__dismiss"
|
|
85
202
|
@click="close"
|
|
86
203
|
>
|
|
87
|
-
|
|
88
|
-
<XMarkIcon aria-hidden="true"></XMarkIcon>
|
|
89
|
-
</Icon>
|
|
204
|
+
{{ dismissLabel }}
|
|
90
205
|
</button>
|
|
206
|
+
|
|
207
|
+
<slot name="actions" :close="close"></slot>
|
|
91
208
|
</div>
|
|
92
209
|
</div>
|
|
210
|
+
|
|
211
|
+
<!--
|
|
212
|
+
Top level rather than inside the head or the body, so it stays in the
|
|
213
|
+
corner whether or not there is a title — the two duplicated buttons this
|
|
214
|
+
replaces each sat in a `justify-between` row to reach the same place.
|
|
215
|
+
-->
|
|
216
|
+
<div v-if="closeable" class="rsui-message-box__close">
|
|
217
|
+
<button type="button"
|
|
218
|
+
class="rsui-message-box__close-icon"
|
|
219
|
+
:aria-label="closeLabel"
|
|
220
|
+
@click="close"
|
|
221
|
+
>
|
|
222
|
+
<Icon secondary>
|
|
223
|
+
<XMarkIcon aria-hidden="true"></XMarkIcon>
|
|
224
|
+
</Icon>
|
|
225
|
+
</button>
|
|
226
|
+
</div>
|
|
93
227
|
</div>
|
|
94
228
|
</template>
|
|
@@ -1,25 +1,215 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
|
|
2
|
+
import { computed, watchEffect, useSlots, Comment, Fragment } from 'vue'
|
|
3
|
+
|
|
4
|
+
const props = defineProps({
|
|
3
5
|
showContent: {
|
|
4
6
|
type: Boolean,
|
|
5
7
|
default: true,
|
|
6
8
|
},
|
|
9
|
+
/**
|
|
10
|
+
* Label for the trailing "view all" link. Setting it, together with
|
|
11
|
+
* `viewAllHref`, is what puts the link in the footer.
|
|
12
|
+
*
|
|
13
|
+
* A prop rather than a slot: it is the link's accessible name, and a prop
|
|
14
|
+
* cannot be present-but-empty the way a slot can. Slot presence read through
|
|
15
|
+
* `useSlots()` is also not reactive, so a link gated on it would never appear
|
|
16
|
+
* for a consumer rendering it conditionally.
|
|
17
|
+
*/
|
|
18
|
+
viewAllLabel: {
|
|
19
|
+
type: String,
|
|
20
|
+
default: '',
|
|
21
|
+
},
|
|
22
|
+
/**
|
|
23
|
+
* Where the view-all link goes. Required alongside the label — an anchor
|
|
24
|
+
* without an href is not a link, and is skipped by keyboard navigation.
|
|
25
|
+
*/
|
|
26
|
+
viewAllHref: {
|
|
27
|
+
type: String,
|
|
28
|
+
default: '',
|
|
29
|
+
},
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Coerced, not dereferenced. `default: ''` only applies to `undefined` — Vue
|
|
34
|
+
* skips type checking entirely for a `null` on a non-required prop, so `null`
|
|
35
|
+
* arrives with no warning at all and `.trim()` on it throws out of setup, taking
|
|
36
|
+
* the whole parent subtree down with it. An absent URL serialises to `null`, not
|
|
37
|
+
* `undefined`, so that is the likely wiring rather than the exotic one. The type
|
|
38
|
+
* validator cannot be relied on either; it is stripped from production builds.
|
|
39
|
+
*/
|
|
40
|
+
const viewAllLabelText = computed(() => String(props.viewAllLabel ?? '').trim())
|
|
41
|
+
|
|
42
|
+
const rawViewAllHref = computed(() => String(props.viewAllHref ?? '').trim())
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Schemes an anchor may safely carry. `javascript:` and `data:` in an href are
|
|
46
|
+
* script execution, and this is a declared prop on a design-system component
|
|
47
|
+
* rather than an attribute passthrough — a consuming app can reasonably expect
|
|
48
|
+
* the anchor it did not write to be safe. Anything else is refused loudly, since
|
|
49
|
+
* a link that quietly stops working is the failure this component exists to
|
|
50
|
+
* avoid.
|
|
51
|
+
*/
|
|
52
|
+
const SAFE_HREF_SCHEME = /^(https?:|mailto:|tel:)/i
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* `//evil.com` is protocol-relative: it starts with a slash but navigates to
|
|
56
|
+
* another host entirely, so a leading-slash test alone would wave it through.
|
|
57
|
+
*/
|
|
58
|
+
const PROTOCOL_RELATIVE = /^\/\//
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Anything with a scheme has one before the first slash. No colon in that span
|
|
62
|
+
* means a relative path — `courses/1`, `./x`, `../x`, `#anchor`, `?q=1` — which
|
|
63
|
+
* cannot leave the site and so needs no allow-list of its own. This is what the
|
|
64
|
+
* warning text has always told consumers to use; the previous check only accepted
|
|
65
|
+
* paths beginning with `/`, and refused the rest while telling them they were
|
|
66
|
+
* fine.
|
|
67
|
+
*/
|
|
68
|
+
function isRelativeHref(href) {
|
|
69
|
+
const beforeFirstSlash = href.split('/')[0]
|
|
70
|
+
|
|
71
|
+
return !beforeFirstSlash.includes(':')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const isViewAllHrefSafe = computed(() => {
|
|
75
|
+
const href = rawViewAllHref.value
|
|
76
|
+
|
|
77
|
+
if (href === '') return true
|
|
78
|
+
if (PROTOCOL_RELATIVE.test(href)) return false
|
|
79
|
+
if (SAFE_HREF_SCHEME.test(href)) return true
|
|
80
|
+
|
|
81
|
+
return isRelativeHref(href)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const hasViewAllLabel = computed(() => viewAllLabelText.value !== '')
|
|
85
|
+
|
|
86
|
+
const hasViewAllHref = computed(() => rawViewAllHref.value !== '' && isViewAllHrefSafe.value)
|
|
87
|
+
|
|
88
|
+
const showViewAll = computed(() => hasViewAllLabel.value && hasViewAllHref.value)
|
|
89
|
+
|
|
90
|
+
const slots = useSlots()
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Whether a slot renders anything at all. Slot *presence* is not the same
|
|
94
|
+
* question: `<template #default><Button v-if="canManage" /></template>` leaves a
|
|
95
|
+
* truthy slot function that renders a comment placeholder, which would still
|
|
96
|
+
* claim a `flex-1` region and shove the centred link into one half of the footer.
|
|
97
|
+
*/
|
|
98
|
+
function rendersContent(slot) {
|
|
99
|
+
const nodes = slot?.()
|
|
100
|
+
|
|
101
|
+
if (!nodes) return false
|
|
102
|
+
|
|
103
|
+
// Comment vnodes are what a falsy `v-if` leaves behind. Fragments wrap lists
|
|
104
|
+
// and `<template v-if>` blocks, so a truthy wrapper around a falsy child is a
|
|
105
|
+
// Fragment whose only child is a Comment — counting its length would call that
|
|
106
|
+
// content and render an empty region claiming `flex-1`.
|
|
107
|
+
return nodes.some(rendersNode)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function rendersNode(node) {
|
|
111
|
+
if (node.type === Comment) return false
|
|
112
|
+
if (node.type === Fragment) return Array.isArray(node.children) && node.children.some(rendersNode)
|
|
113
|
+
|
|
114
|
+
return true
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Whether the view-all has the footer to itself, which is the case that wants
|
|
119
|
+
* centring. With anything alongside it, a greedy region would split the row with
|
|
120
|
+
* `content-start` and squeeze the buttons towards the middle rather than the
|
|
121
|
+
* right edge, so it stays content-sized and sits at the end instead.
|
|
122
|
+
*/
|
|
123
|
+
/*
|
|
124
|
+
* Plain functions, not computeds. `useSlots()` returns a non-reactive object, so
|
|
125
|
+
* a computed wrapping it caches its first answer forever — a consumer putting a
|
|
126
|
+
* `v-if` on the slot itself would never see the region appear or disappear. Called
|
|
127
|
+
* from the template instead, so they are re-evaluated on every render, which is
|
|
128
|
+
* what makes them track.
|
|
129
|
+
*/
|
|
130
|
+
function hasDefaultContent() {
|
|
131
|
+
return rendersContent(slots.default)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function hasStartContent() {
|
|
135
|
+
return props.showContent && rendersContent(slots.content)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isViewAllAlone() {
|
|
139
|
+
return !hasDefaultContent() && !hasStartContent()
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Half a link is not a link, so say so rather than rendering nothing. A label
|
|
144
|
+
* with no href would be an anchor the keyboard skips; an href with no label
|
|
145
|
+
* would be one a screen reader announces as its own URL.
|
|
146
|
+
*
|
|
147
|
+
* Deferred by a tick, and cancelled if the effect re-runs. The two values often
|
|
148
|
+
* arrive separately — a literal label beside an href that is empty until a fetch
|
|
149
|
+
* resolves — and warning about a state that corrects itself a tick later trains
|
|
150
|
+
* everyone to ignore the channel, which costs more than it catches.
|
|
151
|
+
*
|
|
152
|
+
* Dev-only, matching LinkSlot, ButtonSlot, Pill and InlineEditableText. This
|
|
153
|
+
* package ships raw `.vue` source, so an ungated warn reaches real users'
|
|
154
|
+
* consoles.
|
|
155
|
+
*/
|
|
156
|
+
watchEffect((onCleanup) => {
|
|
157
|
+
if (process.env.NODE_ENV === 'production') return
|
|
158
|
+
if (showViewAll.value) return
|
|
159
|
+
|
|
160
|
+
const isUnsafeHref = rawViewAllHref.value !== '' && !isViewAllHrefSafe.value
|
|
161
|
+
const isHalfConfigured = hasViewAllLabel.value !== hasViewAllHref.value
|
|
162
|
+
|
|
163
|
+
if (!isUnsafeHref && !isHalfConfigured) return
|
|
164
|
+
|
|
165
|
+
const message = isUnsafeHref
|
|
166
|
+
? `[RSUI] SectionFooter: refused an unsafe \`viewAllHref\` ("${rawViewAllHref.value}"). Use an http(s), mailto, tel, or relative URL.`
|
|
167
|
+
: `[RSUI] SectionFooter: the view-all link needs both \`viewAllLabel\` and \`viewAllHref\`; \`${hasViewAllLabel.value ? 'viewAllHref' : 'viewAllLabel'}\` is missing, so no link has been rendered.`
|
|
168
|
+
|
|
169
|
+
const timer = setTimeout(() => console.warn(message), 0)
|
|
170
|
+
|
|
171
|
+
onCleanup(() => clearTimeout(timer))
|
|
7
172
|
})
|
|
8
173
|
</script>
|
|
9
174
|
<template>
|
|
10
175
|
<div class="rsui-section-footer">
|
|
11
176
|
<div class="rsui-section-footer__content-start"
|
|
12
|
-
v-if="
|
|
177
|
+
v-if="hasStartContent()"
|
|
13
178
|
>
|
|
14
179
|
<slot name="content"></slot>
|
|
15
180
|
</div>
|
|
16
|
-
<div
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
181
|
+
<div v-if="hasDefaultContent()"
|
|
182
|
+
:class="[
|
|
183
|
+
'rsui-section-footer__content-end',
|
|
184
|
+
{
|
|
185
|
+
'rsui-section-footer__content-end--full': !hasStartContent(),
|
|
186
|
+
}
|
|
187
|
+
]"
|
|
188
|
+
>
|
|
22
189
|
<slot></slot>
|
|
23
190
|
</div>
|
|
191
|
+
|
|
192
|
+
<!--
|
|
193
|
+
Its own centred region rather than tucked into `content-end`, which is
|
|
194
|
+
right-aligned for action buttons. A view-all is not a form action sitting
|
|
195
|
+
at the end of a row — it is the way out of the section, and it usually has
|
|
196
|
+
the footer to itself.
|
|
197
|
+
-->
|
|
198
|
+
<div v-if="showViewAll"
|
|
199
|
+
:class="[
|
|
200
|
+
'rsui-section-footer__view-all-region',
|
|
201
|
+
{
|
|
202
|
+
'rsui-section-footer__view-all-region--full': isViewAllAlone(),
|
|
203
|
+
}
|
|
204
|
+
]"
|
|
205
|
+
>
|
|
206
|
+
<a class="rsui-section-footer__view-all"
|
|
207
|
+
:href="rawViewAllHref"
|
|
208
|
+
data-ph-capture-attribute-component="SectionFooterViewAll"
|
|
209
|
+
data-ph-capture-attribute-label="View all"
|
|
210
|
+
>
|
|
211
|
+
{{ viewAllLabelText }}
|
|
212
|
+
</a>
|
|
213
|
+
</div>
|
|
24
214
|
</div>
|
|
25
215
|
</template>
|
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { ref, computed, useSlots } from 'vue'
|
|
3
|
-
import { useScroll } from '@vueuse/core'
|
|
4
|
-
import { Icon } from '../Icon'
|
|
2
|
+
import { ref, computed, watch, watchEffect, useSlots } from 'vue'
|
|
3
|
+
import { useScroll, useEventListener, watchDebounced } from '@vueuse/core'
|
|
5
4
|
import Section from './Section.vue'
|
|
6
5
|
import SectionHeader from './SectionHeader.vue'
|
|
7
6
|
import SectionSliderItem from './SectionSliderItem.vue'
|
|
8
|
-
import
|
|
7
|
+
import SectionSliderAction from './SectionSliderAction.vue'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Emitted when the trailing view-all card is activated — by click, Enter or
|
|
11
|
+
* Space, since it is a real button. The consumer decides what "view all" means,
|
|
12
|
+
* so routing stays out of the design system.
|
|
13
|
+
*/
|
|
14
|
+
defineEmits(['view-all'])
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Variants the view-all card understands. Enforced at render as well as by the
|
|
18
|
+
* prop validator: validators only warn, and they are stripped from production
|
|
19
|
+
* builds, so an unvetted value would otherwise reach the class attribute and
|
|
20
|
+
* silently render an unstyled card.
|
|
21
|
+
*/
|
|
22
|
+
const VIEW_ALL_VARIANTS = ['primary', 'secondary', 'brand', 'success', 'info', 'warning', 'error', 'ai']
|
|
9
23
|
|
|
10
24
|
const props = defineProps({
|
|
11
25
|
items: {
|
|
@@ -30,6 +44,63 @@ const props = defineProps({
|
|
|
30
44
|
default: null,
|
|
31
45
|
validator: (value) => value === null || ['1', '2', '3', '4', '5', '6'].includes(String(value)),
|
|
32
46
|
},
|
|
47
|
+
/**
|
|
48
|
+
* Move the prev/next arrows out of the header and onto the rail itself,
|
|
49
|
+
* revealed when the pointer is over the cards. Navigation then sits in the
|
|
50
|
+
* thing being navigated rather than in the header, which matters most when
|
|
51
|
+
* several sliders stack on one page and header arrows stop reading as
|
|
52
|
+
* belonging to any particular rail. Opt-in, so existing consumers keep the
|
|
53
|
+
* header arrows.
|
|
54
|
+
*/
|
|
55
|
+
hoverActions: {
|
|
56
|
+
type: Boolean,
|
|
57
|
+
default: false,
|
|
58
|
+
},
|
|
59
|
+
/**
|
|
60
|
+
* What the rail holds, in plural lowercase — "courses", "articles". Used to
|
|
61
|
+
* name the rail itself and to disambiguate the arrows, which otherwise all
|
|
62
|
+
* announce as a bare "Next" on a page with several rails. Left unset the
|
|
63
|
+
* labels stay exactly as they were.
|
|
64
|
+
*/
|
|
65
|
+
navigationLabel: {
|
|
66
|
+
type: String,
|
|
67
|
+
default: '',
|
|
68
|
+
},
|
|
69
|
+
/**
|
|
70
|
+
* Label for the trailing view-all card. Setting it is what puts the card on
|
|
71
|
+
* the rail; left empty there is no card.
|
|
72
|
+
*
|
|
73
|
+
* A prop rather than a slot, for two reasons. It is the button's accessible
|
|
74
|
+
* name, and a prop cannot be present-but-empty the way a slot can — a slot
|
|
75
|
+
* whose content resolves to nothing still counts as supplied, which would
|
|
76
|
+
* ship a focusable button with no name. And slot presence read through
|
|
77
|
+
* `useSlots()` is not reactive, so a card gated on it would never appear for
|
|
78
|
+
* a consumer who renders the slot conditionally.
|
|
79
|
+
*/
|
|
80
|
+
viewAllLabel: {
|
|
81
|
+
type: String,
|
|
82
|
+
default: '',
|
|
83
|
+
},
|
|
84
|
+
/**
|
|
85
|
+
* Colour treatment for the view-all card, using Card's own variant
|
|
86
|
+
* vocabulary. The card composes Card's classes, so each variant brings its
|
|
87
|
+
* own background and border colour straight from `card.css`.
|
|
88
|
+
*
|
|
89
|
+
* Two exceptions, both about the section showing through rather than the card
|
|
90
|
+
* painting over it: the `ai` card variant's gradient is cleared, and on an
|
|
91
|
+
* `--ai` or `--featured` *section* the card goes transparent entirely.
|
|
92
|
+
*
|
|
93
|
+
* `draft` is left out on purpose: it sets `border-0` and draws its boundary
|
|
94
|
+
* with a separate element the button never renders, so it would take the
|
|
95
|
+
* dashed edge away entirely.
|
|
96
|
+
*/
|
|
97
|
+
viewAllVariant: {
|
|
98
|
+
type: String,
|
|
99
|
+
default: 'primary',
|
|
100
|
+
// Literal rather than the VIEW_ALL_VARIANTS const below: defineProps is
|
|
101
|
+
// hoisted out of setup(), so it cannot reference local declarations.
|
|
102
|
+
validator: (value) => ['primary', 'secondary', 'brand', 'success', 'info', 'warning', 'error', 'ai'].includes(value),
|
|
103
|
+
},
|
|
33
104
|
})
|
|
34
105
|
|
|
35
106
|
const sectionVariant = computed(() => {
|
|
@@ -44,11 +115,23 @@ const sliderClasses = computed(() => [
|
|
|
44
115
|
'rsui-section-slider--default': !props.featured && !props.ai,
|
|
45
116
|
'rsui-section-slider--featured': props.featured,
|
|
46
117
|
'rsui-section-slider--ai': props.ai,
|
|
118
|
+
'rsui-section-slider--hover-actions': props.hoverActions,
|
|
47
119
|
},
|
|
48
120
|
])
|
|
49
121
|
|
|
50
122
|
const slots = useSlots()
|
|
51
123
|
|
|
124
|
+
/**
|
|
125
|
+
* A consumer-supplied `actions` slot always wins and always renders in the
|
|
126
|
+
* header — that slot exists so consumers can put their own controls up there.
|
|
127
|
+
* The built-in arrows go to whichever placement `hoverActions` selects.
|
|
128
|
+
*/
|
|
129
|
+
const hasCustomActions = computed(() => Boolean(slots.actions))
|
|
130
|
+
|
|
131
|
+
const showHeaderActions = computed(() => hasCustomActions.value || !props.hoverActions)
|
|
132
|
+
|
|
133
|
+
const showOverlayActions = computed(() => !hasCustomActions.value && props.hoverActions)
|
|
134
|
+
|
|
52
135
|
/**
|
|
53
136
|
* Unique id for the slider
|
|
54
137
|
*/
|
|
@@ -67,7 +150,11 @@ function generateItemId(index) {
|
|
|
67
150
|
const sliderContainerRef = ref(null)
|
|
68
151
|
|
|
69
152
|
/**
|
|
70
|
-
* Whether the slider container is scrolling
|
|
153
|
+
* Whether the slider container is scrolling. Only the flag is taken: `useScroll`
|
|
154
|
+
* also offers `arrivedState`, but it is measured on mount and then only from its
|
|
155
|
+
* own scroll handler, so it goes stale on resize or on items arriving late. Edge
|
|
156
|
+
* detection is derived from the intersection observer instead — see
|
|
157
|
+
* hasPreviousSlide/hasNextSlide below.
|
|
71
158
|
*/
|
|
72
159
|
const { isScrolling } = useScroll(sliderContainerRef)
|
|
73
160
|
|
|
@@ -85,32 +172,99 @@ function removeVisibleItemId(id) {
|
|
|
85
172
|
}
|
|
86
173
|
|
|
87
174
|
/**
|
|
88
|
-
*
|
|
175
|
+
* How many items are fully visible right now — not a maximum, despite the name,
|
|
176
|
+
* which predates this. `SectionSliderItem` observes at threshold 1, so a card
|
|
177
|
+
* peeking at either edge is not counted. The paging arithmetic below uses this as
|
|
178
|
+
* the page size, so it is a live measurement rather than a capacity.
|
|
89
179
|
*/
|
|
90
180
|
const maxVisibleItems = computed(() => visibleItemIds.value.length)
|
|
91
181
|
|
|
92
182
|
/**
|
|
93
|
-
*
|
|
183
|
+
* Whether a trailing "view all" card is on the rail. Driven by the label prop,
|
|
184
|
+
* so it is genuinely reactive and the card can never exist without a name.
|
|
94
185
|
*/
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
186
|
+
/**
|
|
187
|
+
* Coerced, not dereferenced. `default: ''` only covers `undefined` — Vue skips
|
|
188
|
+
* type checking entirely for a `null` on a non-required prop, so `null` arrives
|
|
189
|
+
* unannounced and `.trim()` on it throws out of setup, taking the whole render
|
|
190
|
+
* with it. An absent label serialises to `null`, not `undefined`, so that is the
|
|
191
|
+
* likely wiring rather than the exotic one, and the type validator is stripped
|
|
192
|
+
* from production builds anyway.
|
|
193
|
+
*/
|
|
194
|
+
const viewAllLabelText = computed(() => String(props.viewAllLabel ?? '').trim())
|
|
195
|
+
|
|
196
|
+
const hasViewAllCard = computed(() => viewAllLabelText.value !== '')
|
|
99
197
|
|
|
100
|
-
|
|
198
|
+
/**
|
|
199
|
+
* The icon is decoration on top of the label, so on its own it produces nothing.
|
|
200
|
+
* Say so rather than discarding it silently — a consumer who supplies only the
|
|
201
|
+
* icon sees no card, no icon and no error, which is a long way to a one-word fix.
|
|
202
|
+
*/
|
|
203
|
+
watchEffect(() => {
|
|
204
|
+
// Read the reactive dependency first. Bailing on the non-reactive `slots`
|
|
205
|
+
// lookup before touching `hasViewAllCard` meant the effect recorded no
|
|
206
|
+
// dependency at all on a mount without an icon, so it could never re-run.
|
|
207
|
+
const hasCard = hasViewAllCard.value
|
|
208
|
+
|
|
209
|
+
if (!slots['view-all-icon']) return
|
|
210
|
+
if (hasCard) return
|
|
211
|
+
|
|
212
|
+
console.warn('[SectionSlider] #view-all-icon was supplied without a `viewAllLabel`. The view-all card needs a label for its accessible name, so no card has been rendered.')
|
|
101
213
|
})
|
|
102
214
|
|
|
103
215
|
/**
|
|
104
|
-
*
|
|
216
|
+
* Card's own classes, so the view-all card inherits each variant's background
|
|
217
|
+
* and border colour rather than this file restating them — and stays in step if
|
|
218
|
+
* those tokens are ever retuned. `section_slider.css` is imported after
|
|
219
|
+
* `card.css`, so the dashed edge and the layout below still win at equal
|
|
220
|
+
* specificity.
|
|
105
221
|
*/
|
|
106
|
-
const
|
|
222
|
+
const viewAllClasses = computed(() => {
|
|
223
|
+
const isKnownVariant = VIEW_ALL_VARIANTS.includes(props.viewAllVariant)
|
|
224
|
+
|
|
225
|
+
if (!isKnownVariant) {
|
|
226
|
+
console.warn(`[SectionSlider] viewAllVariant "${props.viewAllVariant}" is not supported (${VIEW_ALL_VARIANTS.join(', ')}). Falling back to "primary".`)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const variant = isKnownVariant ? props.viewAllVariant : 'primary'
|
|
230
|
+
|
|
231
|
+
return [
|
|
232
|
+
'rsui-card',
|
|
233
|
+
'rsui-card--bordered',
|
|
234
|
+
'rsui-section-slider__view-all',
|
|
235
|
+
{
|
|
236
|
+
'rsui-card--secondary': variant === 'secondary',
|
|
237
|
+
'rsui-card--brand': variant === 'brand',
|
|
238
|
+
'rsui-card--success': variant === 'success',
|
|
239
|
+
'rsui-card--info': variant === 'info',
|
|
240
|
+
'rsui-card--warning': variant === 'warning',
|
|
241
|
+
'rsui-card--error': variant === 'error',
|
|
242
|
+
'rsui-card--ai': variant === 'ai',
|
|
243
|
+
},
|
|
244
|
+
]
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The card is a way out of the rail, so with nothing in the rail there is
|
|
249
|
+
* nothing to opt out of — and it would otherwise sit above the empty state,
|
|
250
|
+
* offering "view all" of nothing.
|
|
251
|
+
*/
|
|
252
|
+
const showViewAllCard = computed(() => hasViewAllCard.value && props.items.length > 0)
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* How many cards the rail holds, as opposed to how many *items* the consumer
|
|
256
|
+
* passed. The view-all card occupies a slot, snaps, and has to be reachable, so
|
|
257
|
+
* every piece of paging arithmetic counts it — the announcement being the one
|
|
258
|
+
* deliberate exception.
|
|
259
|
+
*/
|
|
260
|
+
const railItemCount = computed(() => props.items.length + (showViewAllCard.value ? 1 : 0))
|
|
107
261
|
|
|
108
262
|
/**
|
|
109
263
|
* Whether the previous button is disabled
|
|
110
264
|
*/
|
|
111
265
|
const disabledPrevButton = computed(() => isScrolling.value
|
|
112
266
|
|| maxVisibleItems.value === 0
|
|
113
|
-
||
|
|
267
|
+
|| railItemCount.value === maxVisibleItems.value
|
|
114
268
|
)
|
|
115
269
|
|
|
116
270
|
/**
|
|
@@ -118,51 +272,258 @@ const disabledPrevButton = computed(() => isScrolling.value
|
|
|
118
272
|
*/
|
|
119
273
|
const disabledNextButton = computed(() => isScrolling.value
|
|
120
274
|
|| maxVisibleItems.value === 0
|
|
121
|
-
||
|
|
275
|
+
|| railItemCount.value === maxVisibleItems.value
|
|
122
276
|
)
|
|
123
277
|
|
|
124
278
|
/**
|
|
125
|
-
*
|
|
279
|
+
* Whether any content is parked off-screen at all.
|
|
126
280
|
*/
|
|
127
|
-
|
|
128
|
-
|
|
281
|
+
const isScrollable = computed(() => maxVisibleItems.value > 0
|
|
282
|
+
&& railItemCount.value > maxVisibleItems.value
|
|
283
|
+
)
|
|
129
284
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
285
|
+
/**
|
|
286
|
+
* Which items are on screen, as 1-based positions.
|
|
287
|
+
*
|
|
288
|
+
* Ids are `<sliderId>:<position>`, so the position is the second segment.
|
|
289
|
+
* `generateItemId` above is the other half of that contract — change the
|
|
290
|
+
* separator there and this parse has to change with it. Integers only, because
|
|
291
|
+
* `Number('')` is 0, not NaN, and a position of 0 would read as "before the
|
|
292
|
+
* first card" and silently take the wrap branch below.
|
|
293
|
+
*/
|
|
294
|
+
const visibleItemPositions = computed(() => visibleItemIds.value
|
|
295
|
+
.map((id) => Number(id.split(':')[1]))
|
|
296
|
+
.filter((position) => Number.isInteger(position) && position >= 1)
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Where the rail actually sits, as 1-based positions.
|
|
301
|
+
*
|
|
302
|
+
* Both fall back to 1 when nothing is fully visible. That state is unreachable
|
|
303
|
+
* from either arrow — an empty set means `maxVisibleItems` is 0, which disables
|
|
304
|
+
* every arrow in both modes — so the fallback is here to keep the computeds
|
|
305
|
+
* total, not to degrade paging to something smaller.
|
|
306
|
+
*/
|
|
307
|
+
const firstVisiblePosition = computed(() => {
|
|
308
|
+
if (visibleItemPositions.value.length === 0) return 1
|
|
309
|
+
|
|
310
|
+
return _.min(visibleItemPositions.value)
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
const lastVisiblePosition = computed(() => {
|
|
314
|
+
if (visibleItemPositions.value.length === 0) return 1
|
|
315
|
+
|
|
316
|
+
return _.max(visibleItemPositions.value)
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Whether there is anything to reach in either direction, from where the rail
|
|
321
|
+
* currently sits.
|
|
322
|
+
*
|
|
323
|
+
* Derived from the observed positions rather than from `useScroll`'s
|
|
324
|
+
* `arrivedState`, which is measured once on mount and thereafter only inside its
|
|
325
|
+
* own scroll handler — it observes neither resize nor mutation. A rail that
|
|
326
|
+
* mounts with no items measures as already arrived at the right edge, and items
|
|
327
|
+
* fetched after mount fire no scroll event, so the next arrow would latch
|
|
328
|
+
* disabled for the life of the page. From `lg` up that arrow is the only way to
|
|
329
|
+
* move the rail, so the whole feature would be quietly dead. The intersection
|
|
330
|
+
* observer reacts to layout, so these cannot go stale.
|
|
331
|
+
*
|
|
332
|
+
* Deriving both from the same source also means the arrow's disabled state and
|
|
333
|
+
* the wrap branch in showNextSlide agree by construction, rather than by the two
|
|
334
|
+
* happening to coincide.
|
|
335
|
+
*
|
|
336
|
+
* Excludes `isScrolling` on purpose: folding it in would blink a rail arrow out
|
|
337
|
+
* and back on every smooth scroll, because the reveal is `:enabled`-gated.
|
|
338
|
+
*/
|
|
339
|
+
const hasPreviousSlide = computed(() => isScrollable.value && firstVisiblePosition.value > 1)
|
|
340
|
+
|
|
341
|
+
const hasNextSlide = computed(() => isScrollable.value && lastVisiblePosition.value < railItemCount.value)
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Fade the edge that has content behind it, so a rail with its arrows hidden
|
|
345
|
+
* still shows that it moves. Scoped to `hoverActions`: that mode is the one
|
|
346
|
+
* with no resting affordance, and leaving the default header mode untouched
|
|
347
|
+
* keeps existing consumers as they are.
|
|
348
|
+
*/
|
|
349
|
+
const containerClasses = computed(() => [
|
|
350
|
+
'rsui-section-slider__container',
|
|
351
|
+
{
|
|
352
|
+
'rsui-section-slider__container--fade-start': props.hoverActions && hasPreviousSlide.value,
|
|
353
|
+
'rsui-section-slider__container--fade-end': props.hoverActions && hasNextSlide.value,
|
|
354
|
+
},
|
|
355
|
+
])
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Announced after the rail settles. Position rather than "moved", because
|
|
359
|
+
* "showing 5 to 8 of 10" is the thing a screen reader user cannot see and
|
|
360
|
+
* cannot infer from the scroll itself.
|
|
361
|
+
*/
|
|
362
|
+
const announcement = ref('')
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Only announce once the rail has actually been moved. Without this the region
|
|
366
|
+
* fires on first paint, as the observer reports the opening cards, and a screen
|
|
367
|
+
* reader opens the page by reading out a position nobody asked for.
|
|
368
|
+
*/
|
|
369
|
+
const hasRailMoved = ref(false)
|
|
370
|
+
|
|
371
|
+
watch(isScrolling, (scrolling) => {
|
|
372
|
+
if (scrolling) hasRailMoved.value = true
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Announce on settle, not during: `visibleItemIds` churns as cards cross the
|
|
377
|
+
* edge mid-scroll, and a polite region would queue every one of those and read
|
|
378
|
+
* them all out after the fact.
|
|
379
|
+
*
|
|
380
|
+
* Debounced off the positions rather than fired when `isScrolling` clears. The
|
|
381
|
+
* visibility flags come from an IntersectionObserver, whose callbacks are
|
|
382
|
+
* asynchronous and can land after that flag settles — keying off the flag
|
|
383
|
+
* announced the previous position, which for a live region is worse than
|
|
384
|
+
* silence.
|
|
385
|
+
*
|
|
386
|
+
* Watching the raw array and not first/last, because those fall back to 1 when
|
|
387
|
+
* nothing is visible: returning to the head of the rail goes
|
|
388
|
+
* `[4] → [] → … → [1]`, and the empty step already reported 1, so the arrival at
|
|
389
|
+
* the real position 1 was no change at all and never fired. The array's identity
|
|
390
|
+
* changes on every recompute, so no transition is invisible.
|
|
391
|
+
*
|
|
392
|
+
* `isScrolling` rides along as a second trigger. Across a single instantaneous
|
|
393
|
+
* jump of several screens the observer coalesces and can drop the final step,
|
|
394
|
+
* leaving the last position unreported; the scroll flag still settles, and since
|
|
395
|
+
* the callback reads current state when it fires rather than the value that woke
|
|
396
|
+
* it, that is enough to catch up.
|
|
397
|
+
*/
|
|
398
|
+
watchDebounced([visibleItemPositions, isScrolling], () => {
|
|
399
|
+
if (!hasRailMoved.value) return
|
|
400
|
+
if (!isScrollable.value) return
|
|
401
|
+
if (visibleItemPositions.value.length === 0) return
|
|
402
|
+
|
|
403
|
+
/*
|
|
404
|
+
* Nothing real on screen — the view-all card is the only fully visible item,
|
|
405
|
+
* which happens at the end of a narrow rail where one card fills the track.
|
|
406
|
+
* The clamp below would name the last item as showing when the viewer is not
|
|
407
|
+
* on an item at all, so say nothing instead.
|
|
408
|
+
*/
|
|
409
|
+
if (firstVisiblePosition.value > props.items.length) return
|
|
410
|
+
|
|
411
|
+
/*
|
|
412
|
+
* Clamped to the item count, not the rail count. The view-all card sits at
|
|
413
|
+
* position `items.length + 1`, so once it scrolls into view alongside real
|
|
414
|
+
* cards an unclamped `lastVisiblePosition` reads "showing 9 to 11 of 10".
|
|
415
|
+
* Clamping reports the last real item instead, which is what the number is
|
|
416
|
+
* describing.
|
|
417
|
+
*/
|
|
418
|
+
const firstItemPosition = _.clamp(firstVisiblePosition.value, 1, props.items.length)
|
|
419
|
+
const lastItemPosition = _.clamp(lastVisiblePosition.value, 1, props.items.length)
|
|
135
420
|
|
|
136
|
-
|
|
137
|
-
|
|
421
|
+
announcement.value = `Showing ${firstItemPosition} to ${lastItemPosition} of ${props.items.length}`
|
|
422
|
+
}, { debounce: 300 })
|
|
138
423
|
|
|
139
|
-
|
|
140
|
-
|
|
424
|
+
/**
|
|
425
|
+
* WCAG 1.4.13 wants hover-revealed content that obscures other content to be
|
|
426
|
+
* dismissable without moving the pointer, and these arrows sit over the cards.
|
|
427
|
+
* Escape hides them until the pointer leaves the rail.
|
|
428
|
+
*
|
|
429
|
+
* Listens on the document rather than the rail because Escape has to work while
|
|
430
|
+
* the pointer merely hovers — with focus elsewhere, a keydown bound to the rail
|
|
431
|
+
* would never fire. The trade-off is that Escape pressed for some unrelated
|
|
432
|
+
* reason (closing a modal, clearing a field) also sets this, on every rail on the
|
|
433
|
+
* page at once, so the flag is cleared on pointer entry as well as exit —
|
|
434
|
+
* otherwise arriving at a rail that was dismissed while you were elsewhere would
|
|
435
|
+
* show no arrows at all.
|
|
436
|
+
*/
|
|
437
|
+
const isNavigationDismissed = ref(false)
|
|
438
|
+
|
|
439
|
+
useEventListener(document, 'keydown', (event) => {
|
|
440
|
+
if (!props.hoverActions) return
|
|
441
|
+
if (event.key !== 'Escape') return
|
|
442
|
+
|
|
443
|
+
isNavigationDismissed.value = true
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
const viewportClasses = computed(() => [
|
|
447
|
+
'rsui-section-slider__viewport',
|
|
448
|
+
{
|
|
449
|
+
'rsui-section-slider__viewport--navigation-dismissed': isNavigationDismissed.value,
|
|
450
|
+
},
|
|
451
|
+
])
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Bring the item at a 1-based position to the left edge of the rail.
|
|
455
|
+
*
|
|
456
|
+
* Resolved through the container's own children rather than
|
|
457
|
+
* `document.getElementById`. Slider ids come from `_.uniqueId`, which counts per
|
|
458
|
+
* lodash instance, and index.js puts lodash on `window` — so two copies of lodash
|
|
459
|
+
* on one page hand out the same slider id twice, and a global lookup would
|
|
460
|
+
* happily scroll a different slider's rail while finding an element and looking
|
|
461
|
+
* perfectly healthy. Indexing the container cannot address anything outside this
|
|
462
|
+
* instance, and works inside a shadow root.
|
|
463
|
+
*
|
|
464
|
+
* The reduced-motion preference is read at call time rather than cached, so a
|
|
465
|
+
* mid-session change to the OS setting takes effect without a remount. The
|
|
466
|
+
* reduced path passes `'instant'`, not `'auto'`: `'auto'` defers to the
|
|
467
|
+
* stylesheet's `scroll-behavior` rather than overriding it, which would leave
|
|
468
|
+
* this branch doing nothing on its own. The media query in section_slider.css
|
|
469
|
+
* still earns its place — it covers swipe and keyboard scrolling, which never
|
|
470
|
+
* come through here.
|
|
471
|
+
*/
|
|
472
|
+
function scrollToPosition(position) {
|
|
473
|
+
const clampedPosition = _.clamp(position, 1, railItemCount.value)
|
|
474
|
+
const targetElement = sliderContainerRef.value?.children[clampedPosition - 1]
|
|
475
|
+
|
|
476
|
+
if (!targetElement) return
|
|
477
|
+
|
|
478
|
+
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
479
|
+
|
|
480
|
+
targetElement.scrollIntoView({
|
|
481
|
+
behavior: prefersReducedMotion ? 'instant' : 'smooth',
|
|
141
482
|
block: 'nearest',
|
|
142
483
|
inline: 'start',
|
|
143
484
|
})
|
|
144
485
|
}
|
|
145
486
|
|
|
146
487
|
/**
|
|
147
|
-
*
|
|
488
|
+
* Page forward from wherever the rail actually sits.
|
|
489
|
+
*
|
|
490
|
+
* Positions come off the intersection observer rather than a stored index, which
|
|
491
|
+
* is the whole point: a swipe, a keyboard tab onto an off-screen card, or any
|
|
492
|
+
* programmatic scroll used to leave a stored index stale, so the next press
|
|
493
|
+
* moved relative to a position the user had long since scrolled past.
|
|
494
|
+
*
|
|
495
|
+
* Wrapping back to the head is kept from the previous behaviour, for the header
|
|
496
|
+
* arrows that still rely on it. In rail mode it is unreachable by construction:
|
|
497
|
+
* `hasNextSlide` is false on exactly the condition tested here, so the arrow is
|
|
498
|
+
* already disabled.
|
|
148
499
|
*/
|
|
149
|
-
function
|
|
150
|
-
if (
|
|
500
|
+
function showNextSlide() {
|
|
501
|
+
if (!isScrollable.value) return
|
|
151
502
|
|
|
152
|
-
if (
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
503
|
+
if (lastVisiblePosition.value >= railItemCount.value) {
|
|
504
|
+
scrollToPosition(1)
|
|
505
|
+
|
|
506
|
+
return
|
|
156
507
|
}
|
|
157
508
|
|
|
158
|
-
|
|
159
|
-
|
|
509
|
+
scrollToPosition(lastVisiblePosition.value + 1)
|
|
510
|
+
}
|
|
160
511
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
512
|
+
/**
|
|
513
|
+
* Page back from wherever the rail actually sits. Wrapping lands on the final
|
|
514
|
+
* whole page rather than the last item, so the rail does not come to rest with a
|
|
515
|
+
* single card and a screenful of empty track.
|
|
516
|
+
*/
|
|
517
|
+
function showPreviousSlide() {
|
|
518
|
+
if (!isScrollable.value) return
|
|
519
|
+
|
|
520
|
+
if (firstVisiblePosition.value <= 1) {
|
|
521
|
+
scrollToPosition(railItemCount.value - maxVisibleItems.value + 1)
|
|
522
|
+
|
|
523
|
+
return
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
scrollToPosition(firstVisiblePosition.value - maxVisibleItems.value)
|
|
166
527
|
}
|
|
167
528
|
</script>
|
|
168
529
|
|
|
@@ -187,55 +548,133 @@ function showPreviousSlide() {
|
|
|
187
548
|
<slot name="see-all"></slot>
|
|
188
549
|
</template>
|
|
189
550
|
|
|
190
|
-
<template #actions>
|
|
551
|
+
<template v-if="showHeaderActions" #actions>
|
|
191
552
|
<slot name="actions"
|
|
192
553
|
:showNextSlide="showNextSlide"
|
|
193
554
|
:showPreviousSlide="showPreviousSlide"
|
|
194
555
|
:disabledPrevButton="disabledPrevButton"
|
|
195
556
|
:disabledNextButton="disabledNextButton"
|
|
196
557
|
>
|
|
197
|
-
<
|
|
198
|
-
type="button"
|
|
199
|
-
aria-label="Previous"
|
|
558
|
+
<SectionSliderAction direction="prev"
|
|
200
559
|
:disabled="disabledPrevButton"
|
|
201
|
-
|
|
202
|
-
|
|
560
|
+
:invert="featured"
|
|
561
|
+
:context="navigationLabel"
|
|
203
562
|
@click="showPreviousSlide"
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
</Icon>
|
|
208
|
-
</button>
|
|
209
|
-
|
|
210
|
-
<button class="rsui-section-slider__action"
|
|
211
|
-
type="button"
|
|
212
|
-
aria-label="Next"
|
|
563
|
+
/>
|
|
564
|
+
|
|
565
|
+
<SectionSliderAction direction="next"
|
|
213
566
|
:disabled="disabledNextButton"
|
|
214
|
-
|
|
215
|
-
|
|
567
|
+
:invert="featured"
|
|
568
|
+
:context="navigationLabel"
|
|
216
569
|
@click="showNextSlide"
|
|
217
|
-
|
|
218
|
-
<Icon :invert="featured">
|
|
219
|
-
<ChevronRightIcon />
|
|
220
|
-
</Icon>
|
|
221
|
-
</button>
|
|
570
|
+
/>
|
|
222
571
|
</slot>
|
|
223
572
|
</template>
|
|
224
573
|
</SectionHeader>
|
|
225
574
|
</template>
|
|
226
575
|
|
|
227
|
-
<div
|
|
228
|
-
|
|
576
|
+
<div :class="viewportClasses"
|
|
577
|
+
@pointerenter="isNavigationDismissed = false"
|
|
578
|
+
@pointerleave="isNavigationDismissed = false"
|
|
229
579
|
>
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
:
|
|
234
|
-
|
|
235
|
-
|
|
580
|
+
<!--
|
|
581
|
+
Ahead of the track in the DOM so the arrows come before the cards
|
|
582
|
+
in tab order, rather than after all of them. Costs nothing
|
|
583
|
+
visually: the layer is absolutely positioned with a z-index, so
|
|
584
|
+
it paints in the same place either way.
|
|
585
|
+
-->
|
|
586
|
+
<div v-if="showOverlayActions"
|
|
587
|
+
class="rsui-section-slider__actions"
|
|
588
|
+
>
|
|
589
|
+
<SectionSliderAction direction="prev"
|
|
590
|
+
:disabled="!hasPreviousSlide"
|
|
591
|
+
:context="navigationLabel"
|
|
592
|
+
@click="showPreviousSlide"
|
|
593
|
+
/>
|
|
594
|
+
|
|
595
|
+
<SectionSliderAction direction="next"
|
|
596
|
+
:disabled="!hasNextSlide"
|
|
597
|
+
:context="navigationLabel"
|
|
598
|
+
@click="showNextSlide"
|
|
599
|
+
/>
|
|
600
|
+
</div>
|
|
601
|
+
|
|
602
|
+
<div ref="sliderContainerRef"
|
|
603
|
+
:class="containerClasses"
|
|
604
|
+
role="list"
|
|
605
|
+
>
|
|
606
|
+
<SectionSliderItem
|
|
607
|
+
v-for="(item, index) in items"
|
|
608
|
+
:key="index"
|
|
609
|
+
:id="generateItemId(index + 1)"
|
|
610
|
+
@visible="addVisibleItemId"
|
|
611
|
+
@hidden="removeVisibleItemId"
|
|
612
|
+
>
|
|
613
|
+
<slot name="item" :item="item"></slot>
|
|
614
|
+
</SectionSliderItem>
|
|
615
|
+
|
|
616
|
+
<!--
|
|
617
|
+
A real rail item, not a decoration appended after the track:
|
|
618
|
+
it takes the next position id, so the arrows can reach it, the
|
|
619
|
+
observer counts it, and it snaps like any other card.
|
|
620
|
+
|
|
621
|
+
A real <button>, too, rather than a div with a click handler.
|
|
622
|
+
The whole card is the target, so it needs the keyboard
|
|
623
|
+
activation, focus behaviour and accessible name that a button
|
|
624
|
+
gets for free — and it takes its name from the slot content,
|
|
625
|
+
which is why the label goes directly inside rather than in a
|
|
626
|
+
nested control.
|
|
627
|
+
-->
|
|
628
|
+
<!--
|
|
629
|
+
Keyed on the id so a change of position remounts rather than
|
|
630
|
+
patches. This is the one rail item whose id moves — it trails
|
|
631
|
+
`items.length` — and SectionSliderItem only emits on a
|
|
632
|
+
visibility transition or unmount, never on `id` changing. Left
|
|
633
|
+
patched, items arriving after mount would rename the card
|
|
634
|
+
while `visibleItemIds` still held its old id, stranding an
|
|
635
|
+
entry that nothing can ever remove and pinning
|
|
636
|
+
`firstVisiblePosition` at 1 for good. Remounting makes the old
|
|
637
|
+
instance emit `hidden` with the id actually in the set.
|
|
638
|
+
-->
|
|
639
|
+
<SectionSliderItem v-if="showViewAllCard"
|
|
640
|
+
:key="generateItemId(items.length + 1)"
|
|
641
|
+
:id="generateItemId(items.length + 1)"
|
|
642
|
+
@visible="addVisibleItemId"
|
|
643
|
+
@hidden="removeVisibleItemId"
|
|
644
|
+
>
|
|
645
|
+
<button :class="viewAllClasses"
|
|
646
|
+
type="button"
|
|
647
|
+
data-ph-capture-attribute-component="SectionSliderViewAll"
|
|
648
|
+
data-ph-capture-attribute-label="View all"
|
|
649
|
+
@click="$emit('view-all', $event)"
|
|
650
|
+
>
|
|
651
|
+
<!--
|
|
652
|
+
Stacked above the label, the way Empty arranges its
|
|
653
|
+
image slot. No default icon: Empty can assume one
|
|
654
|
+
because it always means the same thing, whereas what
|
|
655
|
+
this card leads to is the consumer's business.
|
|
656
|
+
|
|
657
|
+
Sizing is left to Icon/IconCircleBackground, which
|
|
658
|
+
size their own SVG child — same expectation Empty
|
|
659
|
+
places on its image slot.
|
|
660
|
+
-->
|
|
661
|
+
<span v-if="slots['view-all-icon']"
|
|
662
|
+
class="rsui-section-slider__view-all-icon"
|
|
663
|
+
>
|
|
664
|
+
<slot name="view-all-icon"></slot>
|
|
665
|
+
</span>
|
|
666
|
+
|
|
667
|
+
{{ viewAllLabelText }}
|
|
668
|
+
</button>
|
|
669
|
+
</SectionSliderItem>
|
|
670
|
+
</div>
|
|
671
|
+
|
|
672
|
+
<p class="rsui-section-slider__announcer"
|
|
673
|
+
role="status"
|
|
674
|
+
aria-live="polite"
|
|
236
675
|
>
|
|
237
|
-
|
|
238
|
-
</
|
|
676
|
+
{{ announcement }}
|
|
677
|
+
</p>
|
|
239
678
|
</div>
|
|
240
679
|
|
|
241
680
|
<slot v-if="slots.empty && items.length === 0" name="empty"></slot>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import { Icon } from '../Icon'
|
|
4
|
+
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/vue/24/outline'
|
|
5
|
+
|
|
6
|
+
const props = defineProps({
|
|
7
|
+
direction: {
|
|
8
|
+
type: String,
|
|
9
|
+
required: true,
|
|
10
|
+
validator: (value) => ['prev', 'next'].includes(value),
|
|
11
|
+
},
|
|
12
|
+
disabled: {
|
|
13
|
+
type: Boolean,
|
|
14
|
+
default: false,
|
|
15
|
+
},
|
|
16
|
+
invert: {
|
|
17
|
+
type: Boolean,
|
|
18
|
+
default: false,
|
|
19
|
+
},
|
|
20
|
+
/**
|
|
21
|
+
* What this arrow moves, appended to the accessible name. Several rails on
|
|
22
|
+
* one page otherwise all announce as a bare "Next, button", which is the
|
|
23
|
+
* same ambiguity that moving the arrows onto the rail was meant to solve --
|
|
24
|
+
* except a screen reader user has no rail position to disambiguate from.
|
|
25
|
+
*/
|
|
26
|
+
context: {
|
|
27
|
+
type: String,
|
|
28
|
+
default: '',
|
|
29
|
+
},
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
defineEmits(['click'])
|
|
33
|
+
|
|
34
|
+
const isPrevious = computed(() => props.direction === 'prev')
|
|
35
|
+
|
|
36
|
+
const isNext = computed(() => props.direction === 'next')
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Kept free of `context` on purpose: this value feeds analytics, and folding a
|
|
40
|
+
* per-consumer label into it would fragment the existing capture.
|
|
41
|
+
*/
|
|
42
|
+
const directionLabel = computed(() => isPrevious.value ? 'Previous' : 'Next')
|
|
43
|
+
|
|
44
|
+
const accessibleLabel = computed(() => {
|
|
45
|
+
if (!props.context) return directionLabel.value
|
|
46
|
+
|
|
47
|
+
return `${directionLabel.value} ${props.context}`
|
|
48
|
+
})
|
|
49
|
+
</script>
|
|
50
|
+
|
|
51
|
+
<template>
|
|
52
|
+
<button class="rsui-section-slider__action"
|
|
53
|
+
type="button"
|
|
54
|
+
:aria-label="accessibleLabel"
|
|
55
|
+
:disabled="disabled"
|
|
56
|
+
data-ph-capture-attribute-component="SectionSliderAction"
|
|
57
|
+
:data-ph-capture-attribute-label="directionLabel"
|
|
58
|
+
@click="$emit('click')"
|
|
59
|
+
>
|
|
60
|
+
<Icon :invert="invert">
|
|
61
|
+
<ChevronLeftIcon v-if="isPrevious" />
|
|
62
|
+
<ChevronRightIcon v-if="isNext" />
|
|
63
|
+
</Icon>
|
|
64
|
+
</button>
|
|
65
|
+
</template>
|
|
@@ -34,9 +34,19 @@ onBeforeUnmount(() => {
|
|
|
34
34
|
</script>
|
|
35
35
|
|
|
36
36
|
<template>
|
|
37
|
+
<!--
|
|
38
|
+
Paired with role="list" on the track in SectionSlider. The rail is a list
|
|
39
|
+
of cards, not a carousel: nothing is replaced, every item stays in the DOM.
|
|
40
|
+
|
|
41
|
+
Explicit roles rather than ul/li because this element is `flex`, and
|
|
42
|
+
`display: flex` on an li drops the listitem role in Chrome and Safari.
|
|
43
|
+
Nothing enforces the pairing across the two files, so a consumer using
|
|
44
|
+
SectionSliderItem outside a role="list" parent produces an orphan listitem.
|
|
45
|
+
-->
|
|
37
46
|
<div :id="id"
|
|
38
47
|
ref="itemRef"
|
|
39
48
|
class="rsui-section-slider-item"
|
|
49
|
+
role="listitem"
|
|
40
50
|
>
|
|
41
51
|
<slot></slot>
|
|
42
52
|
</div>
|
|
@@ -2,6 +2,7 @@ import Section from './Section.vue'
|
|
|
2
2
|
import SectionFooter from './SectionFooter.vue'
|
|
3
3
|
import SectionHeader from './SectionHeader.vue'
|
|
4
4
|
import SectionSlider from './SectionSlider.vue'
|
|
5
|
+
import SectionSliderAction from './SectionSliderAction.vue'
|
|
5
6
|
import SectionSliderItem from './SectionSliderItem.vue'
|
|
6
7
|
|
|
7
8
|
export {
|
|
@@ -9,5 +10,6 @@ export {
|
|
|
9
10
|
SectionFooter,
|
|
10
11
|
SectionHeader,
|
|
11
12
|
SectionSlider,
|
|
13
|
+
SectionSliderAction,
|
|
12
14
|
SectionSliderItem,
|
|
13
15
|
}
|
|
@@ -20,6 +20,25 @@ const props = defineProps({
|
|
|
20
20
|
type: Boolean,
|
|
21
21
|
default: false,
|
|
22
22
|
},
|
|
23
|
+
// Outlines the track and lifts the selected item with a shadow, for a switcher on a
|
|
24
|
+
// surface that is not white — the track is marginally *lighter* than a secondary
|
|
25
|
+
// Section, so it does not read as a container there at all. See switcher.css for the
|
|
26
|
+
// token derivation. No effect under `chips`, which has no track. Opt-in.
|
|
27
|
+
bordered: {
|
|
28
|
+
type: Boolean,
|
|
29
|
+
default: false,
|
|
30
|
+
},
|
|
31
|
+
// Keeps the options on one row below `sm` rather than stacking them, which suits a
|
|
32
|
+
// segmented control with long labels but turns a two-option Cards/Table toggle into
|
|
33
|
+
// two full-width rows.
|
|
34
|
+
//
|
|
35
|
+
// Pair it with `full`: `inline` sets the direction, `full` gives it a row to split.
|
|
36
|
+
// On its own the container still shrink-wraps, so there is no row to share. No effect
|
|
37
|
+
// under `chips`, which is already an unstacked row. Opt-in.
|
|
38
|
+
inline: {
|
|
39
|
+
type: Boolean,
|
|
40
|
+
default: false,
|
|
41
|
+
},
|
|
23
42
|
// Names the tablist for assistive technology. A tablist that only ever appears once on
|
|
24
43
|
// a page can go without, but any page carrying two of them needs each one labelled.
|
|
25
44
|
ariaLabel: {
|
|
@@ -48,6 +67,8 @@ function setActiveItem(item) {
|
|
|
48
67
|
:class="{
|
|
49
68
|
'rsui-switcher--full': props.full,
|
|
50
69
|
'rsui-switcher--chips': props.chips,
|
|
70
|
+
'rsui-switcher--bordered': props.bordered,
|
|
71
|
+
'rsui-switcher--inline': props.inline,
|
|
51
72
|
}"
|
|
52
73
|
role="tablist"
|
|
53
74
|
:aria-label="props.ariaLabel"
|