@redseed/redseed-ui-vue3 8.61.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/docs/00-component-overview.md +1 -1
- package/docs/01-layout-components.md +0 -28
- package/docs/08-media-components.md +15 -10
- package/package.json +1 -1
- package/src/components/Card/MetricCard.vue +16 -1
- package/src/components/Form/FormFieldsLogin.vue +49 -47
- package/src/components/Form/FormFieldsRegister.vue +74 -72
- package/src/components/Icon/IconSlot.vue +19 -0
- package/src/components/Layout/PageHeader.vue +14 -0
- package/src/components/List/ListItem.vue +43 -2
- package/src/components/MessageBox/MessageBox.vue +169 -31
- package/src/components/Section/SectionFooter.vue +198 -8
- package/src/components/Section/SectionHeader.vue +4 -1
- 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 -2
- package/src/components/StageDial/StageDial.vue +13 -0
- package/src/components/Switcher/Switcher.vue +47 -1
- package/src/components/Switcher/SwitcherItem.vue +22 -2
- package/src/components/TabSlider/TabSlider.vue +18 -1
- package/src/components/TabSlider/TabSliderItem.vue +22 -2
- package/src/composables/useTablistNavigation.js +107 -0
- package/src/components/Section/HeroSection.vue +0 -35
|
@@ -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
|
|
|
@@ -19,14 +19,102 @@ const props = defineProps({
|
|
|
19
19
|
variant: {
|
|
20
20
|
type: String,
|
|
21
21
|
default: 'default',
|
|
22
|
-
validator: (value) => ['default', 'info', 'success', 'warning', 'error'].includes(value)
|
|
23
|
-
}
|
|
22
|
+
validator: (value) => ['default', 'info', 'success', 'warning', 'error', 'ai'].includes(value)
|
|
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
|
|
@@ -34,13 +122,34 @@ const isClosed = ref(props.closed)
|
|
|
34
122
|
* `default` variant is a plain container, not a status message, so it gets no
|
|
35
123
|
* live role (avoids announcing arbitrary static content). Both roles carry an
|
|
36
124
|
* implicit aria-live, so no separate attribute is needed.
|
|
125
|
+
*
|
|
126
|
+
* `ai` gets no live role either, and deliberately: an AI insight is content the
|
|
127
|
+
* reader chooses to read, not a status change. Announcing one on mount would
|
|
128
|
+
* interrupt for something nobody asked about.
|
|
37
129
|
*/
|
|
38
130
|
const liveRole = computed(() => {
|
|
39
|
-
if (
|
|
40
|
-
if (['info', 'success', 'warning'].includes(
|
|
131
|
+
if (safeVariant.value === 'error') return 'alert'
|
|
132
|
+
if (['info', 'success', 'warning'].includes(safeVariant.value)) return 'status'
|
|
41
133
|
return undefined
|
|
42
134
|
})
|
|
43
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
|
+
|
|
44
153
|
function close() {
|
|
45
154
|
isClosed.value = true
|
|
46
155
|
emit('close')
|
|
@@ -50,41 +159,70 @@ function close() {
|
|
|
50
159
|
<div v-if="!isClosed"
|
|
51
160
|
:class="[
|
|
52
161
|
'rsui-message-box',
|
|
53
|
-
`rsui-message-box--${
|
|
162
|
+
`rsui-message-box--${safeVariant}`,
|
|
163
|
+
{ 'rsui-message-box--with-icon': hasIcon() },
|
|
54
164
|
]"
|
|
55
165
|
:role="liveRole"
|
|
56
166
|
>
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
<XMarkIcon aria-hidden="true"></XMarkIcon>
|
|
69
|
-
</Icon>
|
|
70
|
-
</button>
|
|
71
|
-
</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>
|
|
72
178
|
</div>
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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>
|
|
76
185
|
</div>
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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"
|
|
81
202
|
@click="close"
|
|
82
203
|
>
|
|
83
|
-
|
|
84
|
-
<XMarkIcon aria-hidden="true"></XMarkIcon>
|
|
85
|
-
</Icon>
|
|
204
|
+
{{ dismissLabel }}
|
|
86
205
|
</button>
|
|
206
|
+
|
|
207
|
+
<slot name="actions" :close="close"></slot>
|
|
87
208
|
</div>
|
|
88
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>
|
|
89
227
|
</div>
|
|
90
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>
|
|
@@ -140,7 +140,10 @@ const titleTag = computed(() => props.headingLevel ? `h${props.headingLevel}` :
|
|
|
140
140
|
<slot name="more-actions"
|
|
141
141
|
:handleMoreActionsClick="handleMoreActionsClick"
|
|
142
142
|
>
|
|
143
|
-
|
|
143
|
+
<!-- sm, not the md default: this is the header's own overflow control, so it
|
|
144
|
+
follows the same action sizing the header documents and the stories use.
|
|
145
|
+
Left md it stood 42px against a 36px title and 38px sibling actions. -->
|
|
146
|
+
<ButtonTertiary sm @click="handleMoreActionsClick">
|
|
144
147
|
<slot name="more-actions-label">
|
|
145
148
|
<Icon>
|
|
146
149
|
<EllipsisVerticalIcon />
|