@redseed/redseed-ui-vue3 8.63.0 → 8.65.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/index.js +1 -0
- package/package.json +1 -1
- package/src/components/Button/ButtonPrimary.vue +7 -1
- package/src/components/Button/ButtonPrimaryFull.vue +8 -2
- package/src/components/Button/ButtonSecondary.vue +15 -1
- package/src/components/Button/ButtonSecondaryFull.vue +10 -1
- package/src/components/Button/ButtonSlot.vue +19 -0
- package/src/components/Button/ButtonTertiary.vue +15 -1
- package/src/components/Button/ButtonTertiaryFull.vue +10 -1
- package/src/components/Card/Card.vue +12 -0
- package/src/components/Card/CardHeader.vue +34 -8
- package/src/components/Category/CategoryList.vue +177 -0
- package/src/components/Category/index.js +5 -0
- package/src/components/Layout/PageHeader.vue +13 -0
- package/src/components/MetaInfo/MetaInfo.vue +25 -2
- package/src/components/Section/SectionFooter.vue +10 -37
- package/src/components/Section/SectionHeader.vue +6 -1
- package/src/helpers/href.js +46 -0
package/index.js
CHANGED
|
@@ -12,6 +12,7 @@ export * from './src/components/Button'
|
|
|
12
12
|
export * from './src/components/ButtonGroup'
|
|
13
13
|
export * from './src/components/Card'
|
|
14
14
|
export * from './src/components/CardGroup'
|
|
15
|
+
export * from './src/components/Category'
|
|
15
16
|
export * from './src/components/Chart'
|
|
16
17
|
export * from './src/components/Comment'
|
|
17
18
|
export * from './src/components/Disclosure'
|
package/package.json
CHANGED
|
@@ -7,6 +7,12 @@ const props = defineProps({
|
|
|
7
7
|
type: Boolean,
|
|
8
8
|
default: false,
|
|
9
9
|
},
|
|
10
|
+
// Declared rather than left to fall through: a prop named onXxx is ambiguous
|
|
11
|
+
// with an event listener, so it is forwarded by name. See ButtonSlot.
|
|
12
|
+
onColor: {
|
|
13
|
+
type: Boolean,
|
|
14
|
+
default: false,
|
|
15
|
+
},
|
|
10
16
|
})
|
|
11
17
|
|
|
12
18
|
const buttonClass = computed(() => [
|
|
@@ -17,7 +23,7 @@ const buttonClass = computed(() => [
|
|
|
17
23
|
])
|
|
18
24
|
</script>
|
|
19
25
|
<template>
|
|
20
|
-
<ButtonSlot :class="buttonClass">
|
|
26
|
+
<ButtonSlot :class="buttonClass" :on-color="props.onColor">
|
|
21
27
|
<slot></slot>
|
|
22
28
|
</ButtonSlot>
|
|
23
29
|
</template>
|
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import ButtonPrimary from './ButtonPrimary.vue'
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
// Forwarded by name for the same reason ButtonPrimary declares it: a prop called onXxx
|
|
5
|
+
// is ambiguous with an event listener, so it is not left to fall through.
|
|
6
|
+
const props = defineProps({
|
|
5
7
|
invert: {
|
|
6
8
|
type: Boolean,
|
|
7
9
|
default: false,
|
|
8
10
|
},
|
|
11
|
+
onColor: {
|
|
12
|
+
type: Boolean,
|
|
13
|
+
default: false,
|
|
14
|
+
},
|
|
9
15
|
})
|
|
10
16
|
</script>
|
|
11
17
|
<template>
|
|
12
|
-
<ButtonPrimary full :invert="invert">
|
|
18
|
+
<ButtonPrimary full :on-color="props.onColor" :invert="props.invert">
|
|
13
19
|
<slot></slot>
|
|
14
20
|
</ButtonPrimary>
|
|
15
21
|
</template>
|
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
+
import { computed } from 'vue'
|
|
2
3
|
import ButtonSlot from './ButtonSlot.vue'
|
|
4
|
+
|
|
5
|
+
const props = defineProps({
|
|
6
|
+
// Declared rather than left to fall through: a prop named onXxx is ambiguous
|
|
7
|
+
// with an event listener, so it is forwarded by name. See ButtonSlot.
|
|
8
|
+
onColor: {
|
|
9
|
+
type: Boolean,
|
|
10
|
+
default: false,
|
|
11
|
+
},
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
const buttonClass = computed(() => [
|
|
15
|
+
'rsui-button-secondary',
|
|
16
|
+
])
|
|
3
17
|
</script>
|
|
4
18
|
<template>
|
|
5
|
-
<ButtonSlot class="
|
|
19
|
+
<ButtonSlot :class="buttonClass" :on-color="props.onColor">
|
|
6
20
|
<slot></slot>
|
|
7
21
|
</ButtonSlot>
|
|
8
22
|
</template>
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import ButtonSecondary from './ButtonSecondary.vue'
|
|
3
|
+
|
|
4
|
+
// Forwarded by name for the same reason ButtonSecondary declares it: a prop called onXxx
|
|
5
|
+
// is ambiguous with an event listener, so it is not left to fall through.
|
|
6
|
+
const props = defineProps({
|
|
7
|
+
onColor: {
|
|
8
|
+
type: Boolean,
|
|
9
|
+
default: false,
|
|
10
|
+
},
|
|
11
|
+
})
|
|
3
12
|
</script>
|
|
4
13
|
<template>
|
|
5
|
-
<ButtonSecondary full>
|
|
14
|
+
<ButtonSecondary full :on-color="props.onColor">
|
|
6
15
|
<slot></slot>
|
|
7
16
|
</ButtonSecondary>
|
|
8
17
|
</template>
|
|
@@ -35,6 +35,22 @@ const props = defineProps({
|
|
|
35
35
|
default: 'center',
|
|
36
36
|
validator: (value) => ['left', 'center', 'right'].includes(value)
|
|
37
37
|
},
|
|
38
|
+
// Set when the button sits on a COLOURED or DARK surface — a tonal PageHeader
|
|
39
|
+
// band, a toned MetricCard, the AI insight panel. Every variant is drawn for a
|
|
40
|
+
// light ground and has no correct rendering on a dark one: secondary's white
|
|
41
|
+
// fill disappears, tertiary's ink goes near-invisible.
|
|
42
|
+
//
|
|
43
|
+
// Named to match BodyText's and StageDial's `onColor` rather than the existing
|
|
44
|
+
// `invert` on Primary and Danger, which is a different thing: invert means a
|
|
45
|
+
// transparent fill with brand ink, an outline treatment for a LIGHT surface.
|
|
46
|
+
// The two compose.
|
|
47
|
+
//
|
|
48
|
+
// The ink for the filled treatment comes from the surface, through
|
|
49
|
+
// --rsui-button-on-color-ink — the button cannot know what it is sitting on.
|
|
50
|
+
onColor: {
|
|
51
|
+
type: Boolean,
|
|
52
|
+
default: false,
|
|
53
|
+
},
|
|
38
54
|
})
|
|
39
55
|
|
|
40
56
|
defineEmits(['click'])
|
|
@@ -90,6 +106,9 @@ const buttonSlotClass = computed(() => [
|
|
|
90
106
|
// button full width
|
|
91
107
|
'rsui-button-slot--full': props.full,
|
|
92
108
|
|
|
109
|
+
// button on a coloured or dark surface
|
|
110
|
+
'rsui-button-slot--on-color': props.onColor,
|
|
111
|
+
|
|
93
112
|
// button alignment
|
|
94
113
|
'rsui-button-slot--left': props.alignment === 'left',
|
|
95
114
|
'rsui-button-slot--center': props.alignment === 'center',
|
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
+
import { computed } from 'vue'
|
|
2
3
|
import ButtonSlot from './ButtonSlot.vue'
|
|
4
|
+
|
|
5
|
+
const props = defineProps({
|
|
6
|
+
// Declared rather than left to fall through: a prop named onXxx is ambiguous
|
|
7
|
+
// with an event listener, so it is forwarded by name. See ButtonSlot.
|
|
8
|
+
onColor: {
|
|
9
|
+
type: Boolean,
|
|
10
|
+
default: false,
|
|
11
|
+
},
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
const buttonClass = computed(() => [
|
|
15
|
+
'rsui-button-tertiary',
|
|
16
|
+
])
|
|
3
17
|
</script>
|
|
4
18
|
<template>
|
|
5
|
-
<ButtonSlot class="
|
|
19
|
+
<ButtonSlot :class="buttonClass" :on-color="props.onColor">
|
|
6
20
|
<slot></slot>
|
|
7
21
|
</ButtonSlot>
|
|
8
22
|
</template>
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import ButtonTertiary from './ButtonTertiary.vue'
|
|
3
|
+
|
|
4
|
+
// Forwarded by name for the same reason ButtonTertiary declares it: a prop called onXxx
|
|
5
|
+
// is ambiguous with an event listener, so it is not left to fall through.
|
|
6
|
+
const props = defineProps({
|
|
7
|
+
onColor: {
|
|
8
|
+
type: Boolean,
|
|
9
|
+
default: false,
|
|
10
|
+
},
|
|
11
|
+
})
|
|
3
12
|
</script>
|
|
4
13
|
<template>
|
|
5
|
-
<ButtonTertiary full>
|
|
14
|
+
<ButtonTertiary full :on-color="props.onColor">
|
|
6
15
|
<slot></slot>
|
|
7
16
|
</ButtonTertiary>
|
|
8
17
|
</template>
|
|
@@ -27,6 +27,17 @@ const props = defineProps({
|
|
|
27
27
|
type: Boolean,
|
|
28
28
|
default: false,
|
|
29
29
|
},
|
|
30
|
+
// The AI family at full strength: a BOLD dark surface rather than the pale
|
|
31
|
+
// lilac wash `ai` gives. Every other Card variant is a light tint, so this is
|
|
32
|
+
// the first card that has to carry light type — it adapts CardHeader's ink and
|
|
33
|
+
// flips the hover scrim, both of which assume a light ground.
|
|
34
|
+
//
|
|
35
|
+
// BodyText inside it is NOT adapted: that component has its own `onColor` prop,
|
|
36
|
+
// which is the explicit route and would only fight an override from here.
|
|
37
|
+
aiBold: {
|
|
38
|
+
type: Boolean,
|
|
39
|
+
default: false,
|
|
40
|
+
},
|
|
30
41
|
secondary: {
|
|
31
42
|
type: Boolean,
|
|
32
43
|
default: false,
|
|
@@ -111,6 +122,7 @@ const cardClass = computed(() => [
|
|
|
111
122
|
'rsui-card--clickable': props.clickable && !props.disabled,
|
|
112
123
|
'rsui-card--bordered': props.bordered,
|
|
113
124
|
'rsui-card--ai': props.ai,
|
|
125
|
+
'rsui-card--ai-bold': props.aiBold,
|
|
114
126
|
'rsui-card--secondary': props.secondary,
|
|
115
127
|
'rsui-card--brand': props.brand,
|
|
116
128
|
'rsui-card--success': props.success,
|
|
@@ -14,6 +14,12 @@ const props = defineProps({
|
|
|
14
14
|
type: Boolean,
|
|
15
15
|
default: true,
|
|
16
16
|
},
|
|
17
|
+
// Matches showBadge and showSubtitle: the slot is opt-in by being filled, and
|
|
18
|
+
// this exists so a consumer can suppress it without unbinding the slot.
|
|
19
|
+
showEyebrow: {
|
|
20
|
+
type: Boolean,
|
|
21
|
+
default: true,
|
|
22
|
+
},
|
|
17
23
|
showSubtitle: {
|
|
18
24
|
type: Boolean,
|
|
19
25
|
default: true,
|
|
@@ -26,9 +32,14 @@ const props = defineProps({
|
|
|
26
32
|
type: Boolean,
|
|
27
33
|
default: true,
|
|
28
34
|
},
|
|
35
|
+
// Default false: Material 3 cards are one continuous padded surface, and a
|
|
36
|
+
// full-bleed rule between the header and the body made every card read as two
|
|
37
|
+
// stacked boxes rather than one tile. Still available for the cases that want
|
|
38
|
+
// it — a header over a table, or a disclosure — where it separates two genuinely
|
|
39
|
+
// different regions rather than splitting one.
|
|
29
40
|
showDivider: {
|
|
30
41
|
type: Boolean,
|
|
31
|
-
default:
|
|
42
|
+
default: false,
|
|
32
43
|
},
|
|
33
44
|
singleLine: {
|
|
34
45
|
type: Boolean,
|
|
@@ -95,6 +106,19 @@ function handleMoreActionsClick() {
|
|
|
95
106
|
|
|
96
107
|
<div class="rsui-card-header__text">
|
|
97
108
|
|
|
109
|
+
<!--
|
|
110
|
+
Eyebrow slot, optional. ABOVE the title, which is the one position
|
|
111
|
+
the header had no slot for: badge and subtitle both render below it
|
|
112
|
+
and the flex column has no order overrides, so there was no way to
|
|
113
|
+
put a label over a card title without hand-rolling the header.
|
|
114
|
+
Takes plain text or a badge — BadgeRedSeedAI is the common case.
|
|
115
|
+
-->
|
|
116
|
+
<div class="rsui-card-header__eyebrow"
|
|
117
|
+
v-if="showEyebrow && $slots.eyebrow"
|
|
118
|
+
>
|
|
119
|
+
<slot name="eyebrow"></slot>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
98
122
|
<!-- Title slot, default slot -->
|
|
99
123
|
<div class="rsui-card-header__title-container">
|
|
100
124
|
<div :class="[
|
|
@@ -119,13 +143,6 @@ function handleMoreActionsClick() {
|
|
|
119
143
|
>
|
|
120
144
|
<slot name="subtitle"></slot>
|
|
121
145
|
</div>
|
|
122
|
-
|
|
123
|
-
<!-- Badge slot, optional -->
|
|
124
|
-
<div class="rsui-card-header__badge"
|
|
125
|
-
v-if="showBadge && $slots.badge"
|
|
126
|
-
>
|
|
127
|
-
<slot name="badge"></slot>
|
|
128
|
-
</div>
|
|
129
146
|
</div>
|
|
130
147
|
|
|
131
148
|
<!-- Actions slot, optional -->
|
|
@@ -158,6 +175,15 @@ function handleMoreActionsClick() {
|
|
|
158
175
|
</div>
|
|
159
176
|
</div>
|
|
160
177
|
|
|
178
|
+
<!-- Badge slot, optional. A sibling of __header rather than a child of __text, so it
|
|
179
|
+
sits on the card's own left edge — level with the body below it — instead of being
|
|
180
|
+
indented past the avatar with the title. -->
|
|
181
|
+
<div class="rsui-card-header__badge"
|
|
182
|
+
v-if="showBadge && $slots.badge"
|
|
183
|
+
>
|
|
184
|
+
<slot name="badge"></slot>
|
|
185
|
+
</div>
|
|
186
|
+
|
|
161
187
|
<!-- Mobile actions slot, optional -->
|
|
162
188
|
<div class="rsui-card-header__actions-mobile"
|
|
163
189
|
v-if="showActions && !responsiveWidth.specific && $slots.actions"
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, computed } from 'vue'
|
|
3
|
+
import Badge from '../Badge/Badge.vue'
|
|
4
|
+
import { isSafeHref } from '../../helpers/href'
|
|
5
|
+
|
|
6
|
+
const props = defineProps({
|
|
7
|
+
/**
|
|
8
|
+
* The categories, in the order the record gives them. Entries are either a string
|
|
9
|
+
* or `{ label, href }` — see `href` below for when the second form applies.
|
|
10
|
+
*
|
|
11
|
+
* Never sorted. The first category is usually the primary one, so alphabetising
|
|
12
|
+
* throws away the only ordering information the record carries.
|
|
13
|
+
*/
|
|
14
|
+
items: {
|
|
15
|
+
type: Array,
|
|
16
|
+
default: () => [],
|
|
17
|
+
},
|
|
18
|
+
/**
|
|
19
|
+
* Accessible name for the list. A bare row of badges is four unexplained words to a
|
|
20
|
+
* screen reader; named, it announces as "Categories, list, 4 items".
|
|
21
|
+
*/
|
|
22
|
+
label: {
|
|
23
|
+
type: String,
|
|
24
|
+
default: 'Categories',
|
|
25
|
+
},
|
|
26
|
+
/**
|
|
27
|
+
* How many categories show before the rest are counted. Four is the cap the header
|
|
28
|
+
* standards set: eight categories is four lines of a phone screen before the meta
|
|
29
|
+
* even starts.
|
|
30
|
+
*
|
|
31
|
+
* Zero — or anything below it — caps nothing, for the rare row that must show in
|
|
32
|
+
* full. There is deliberately no horizontal-scroller option, for the same reason a
|
|
33
|
+
* table never scrolls the page sideways.
|
|
34
|
+
*/
|
|
35
|
+
cap: {
|
|
36
|
+
type: Number,
|
|
37
|
+
default: 4,
|
|
38
|
+
},
|
|
39
|
+
/**
|
|
40
|
+
* Toggle labels. `{count}` is substituted rather than concatenated, so a translation
|
|
41
|
+
* can put the number wherever its grammar needs it.
|
|
42
|
+
*/
|
|
43
|
+
moreLabel: {
|
|
44
|
+
type: String,
|
|
45
|
+
default: '+{count} more',
|
|
46
|
+
},
|
|
47
|
+
fewerLabel: {
|
|
48
|
+
type: String,
|
|
49
|
+
default: 'Show fewer',
|
|
50
|
+
},
|
|
51
|
+
/**
|
|
52
|
+
* The toggle's accessible name. "+4 more" on its own says nothing about what there
|
|
53
|
+
* are four more of, and that visible text is otherwise the whole name.
|
|
54
|
+
*
|
|
55
|
+
* Each one extends its visible label rather than replacing it, which WCAG 2.5.3
|
|
56
|
+
* Label in Name requires: a name of "Show all 8 categories" over a button reading
|
|
57
|
+
* "+4 more" gives a speech-input user nothing to say.
|
|
58
|
+
*/
|
|
59
|
+
expandLabel: {
|
|
60
|
+
type: String,
|
|
61
|
+
default: '+{count} more categories',
|
|
62
|
+
},
|
|
63
|
+
collapseLabel: {
|
|
64
|
+
type: String,
|
|
65
|
+
default: 'Show fewer categories',
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const emit = defineEmits(['toggle'])
|
|
70
|
+
|
|
71
|
+
const isExpanded = ref(false)
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Strings and `{ label, href }` both arrive here as the same shape, so the template has
|
|
75
|
+
* one branch rather than two.
|
|
76
|
+
*
|
|
77
|
+
* Labels are coerced and trimmed rather than dereferenced: `default: () => []` only
|
|
78
|
+
* applies to `undefined`, and Vue skips type checking entirely for a `null` on a
|
|
79
|
+
* non-required prop, so a null label arrives with no warning and `.trim()` on it would
|
|
80
|
+
* throw out of setup and take the parent subtree down.
|
|
81
|
+
*
|
|
82
|
+
* Empty labels are dropped. An empty badge is a rendering artefact — a grey pill with
|
|
83
|
+
* nothing in it — and it would also count against the cap, pushing a real category into
|
|
84
|
+
* the hidden remainder.
|
|
85
|
+
*/
|
|
86
|
+
const categories = computed(() => {
|
|
87
|
+
// Normalise both accepted item shapes, then drop the ones with nothing to render —
|
|
88
|
+
// done in one chain so an empty label cannot survive as a blank badge.
|
|
89
|
+
return props.items
|
|
90
|
+
.map(item => {
|
|
91
|
+
const isObject = item !== null && typeof item === 'object'
|
|
92
|
+
const rawLabel = isObject ? item.label : item
|
|
93
|
+
const rawHref = isObject ? item.href : ''
|
|
94
|
+
const href = String(rawHref ?? '').trim()
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
label: String(rawLabel ?? '').trim(),
|
|
98
|
+
// A refused href degrades to a label rather than to nothing. The category
|
|
99
|
+
// is still a true fact about the record; only its link was unusable.
|
|
100
|
+
href: isSafeHref(href) ? href : '',
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
.filter(category => category.label !== '')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const isCapped = computed(() => props.cap > 0 && categories.value.length > props.cap)
|
|
107
|
+
|
|
108
|
+
const visibleCategories = computed(() => {
|
|
109
|
+
if (!isCapped.value) return categories.value
|
|
110
|
+
if (isExpanded.value) return categories.value
|
|
111
|
+
|
|
112
|
+
return categories.value.slice(0, props.cap)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
const hiddenCount = computed(() => categories.value.length - props.cap)
|
|
116
|
+
|
|
117
|
+
function withCount(template, count) {
|
|
118
|
+
return String(template ?? '').replace('{count}', count)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const toggleText = computed(() => {
|
|
122
|
+
if (isExpanded.value) return props.fewerLabel
|
|
123
|
+
|
|
124
|
+
return withCount(props.moreLabel, hiddenCount.value)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* A disclosure, not a one-way reveal. Removing the toggle on expand would drop keyboard
|
|
129
|
+
* focus to the body mid-interaction, and `aria-expanded` needs something to describe.
|
|
130
|
+
* The visible resting state is still just "+4 more".
|
|
131
|
+
*/
|
|
132
|
+
const toggleAccessibleName = computed(() => {
|
|
133
|
+
if (isExpanded.value) return withCount(props.collapseLabel, props.cap)
|
|
134
|
+
|
|
135
|
+
return withCount(props.expandLabel, hiddenCount.value)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
function toggle() {
|
|
139
|
+
isExpanded.value = !isExpanded.value
|
|
140
|
+
|
|
141
|
+
emit('toggle', isExpanded.value)
|
|
142
|
+
}
|
|
143
|
+
</script>
|
|
144
|
+
<template>
|
|
145
|
+
<div v-if="categories.length > 0" class="rsui-category-list">
|
|
146
|
+
<ul class="rsui-category-list__items" :aria-label="label">
|
|
147
|
+
<li v-for="(category, index) in visibleCategories"
|
|
148
|
+
:key="index"
|
|
149
|
+
class="rsui-category-list__item"
|
|
150
|
+
>
|
|
151
|
+
<a v-if="category.href !== ''"
|
|
152
|
+
class="rsui-category-list__link"
|
|
153
|
+
:href="category.href"
|
|
154
|
+
>
|
|
155
|
+
<Badge neutral>{{ category.label }}</Badge>
|
|
156
|
+
</a>
|
|
157
|
+
<Badge v-if="category.href === ''" neutral>{{ category.label }}</Badge>
|
|
158
|
+
</li>
|
|
159
|
+
</ul>
|
|
160
|
+
|
|
161
|
+
<!--
|
|
162
|
+
Outside the list, because the toggle is not a category. Sibling to the `ul`
|
|
163
|
+
rather than inside it so the list keeps its semantics: `display: contents` on a
|
|
164
|
+
`ul` would let the items join this row directly, but it also drops list
|
|
165
|
+
semantics in Chrome and Safari, which is the one thing the row exists to keep.
|
|
166
|
+
-->
|
|
167
|
+
<button v-if="isCapped"
|
|
168
|
+
type="button"
|
|
169
|
+
class="rsui-category-list__more"
|
|
170
|
+
:aria-label="toggleAccessibleName"
|
|
171
|
+
:aria-expanded="isExpanded"
|
|
172
|
+
@click="toggle"
|
|
173
|
+
>
|
|
174
|
+
{{ toggleText }}
|
|
175
|
+
</button>
|
|
176
|
+
</div>
|
|
177
|
+
</template>
|
|
@@ -95,6 +95,19 @@ const pageHeaderClass = computed(() => [
|
|
|
95
95
|
</div>
|
|
96
96
|
</div>
|
|
97
97
|
|
|
98
|
+
<!--
|
|
99
|
+
Between the subtitle and the meta, and outside `__top` for the same reason the
|
|
100
|
+
meta row is: both are full-width rows under the title block rather than
|
|
101
|
+
columns beside it.
|
|
102
|
+
|
|
103
|
+
Unlike the meta it is never hidden. The meta collapses into a modal below lg
|
|
104
|
+
because it is a grid of label/value pairs; categories are a wrapping row of
|
|
105
|
+
badges, and the cap is what keeps them short on a phone.
|
|
106
|
+
-->
|
|
107
|
+
<div v-if="$slots['categories']" class="rsui-page-header__categories">
|
|
108
|
+
<slot name="categories"></slot>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
98
111
|
<div v-if="$slots['meta']" class="rsui-page-header__meta">
|
|
99
112
|
<slot name="meta"></slot>
|
|
100
113
|
</div>
|
|
@@ -16,6 +16,18 @@ const props = defineProps({
|
|
|
16
16
|
type: Boolean,
|
|
17
17
|
default: false
|
|
18
18
|
},
|
|
19
|
+
// Size ramp. Booleans, only one applies at a time — the library convention, and
|
|
20
|
+
// the same three steps StageDial uses. `sm` is the default and renders exactly
|
|
21
|
+
// as the unnamed default did, so nothing changes for existing consumers.
|
|
22
|
+
//
|
|
23
|
+
// Note the names sit one step below the type scale they produce: sm is text-xs,
|
|
24
|
+
// md is text-sm, lg is text-base. That predates this change and is left alone,
|
|
25
|
+
// because `md` and `lg` are used in consuming apps and renaming them would be a
|
|
26
|
+
// breaking change for a cosmetic gain.
|
|
27
|
+
sm: {
|
|
28
|
+
type: Boolean,
|
|
29
|
+
default: false
|
|
30
|
+
},
|
|
19
31
|
md: {
|
|
20
32
|
type: Boolean,
|
|
21
33
|
default: false
|
|
@@ -26,12 +38,23 @@ const props = defineProps({
|
|
|
26
38
|
}
|
|
27
39
|
})
|
|
28
40
|
|
|
41
|
+
// Largest flag wins, so passing more than one still gives a defined result. It used
|
|
42
|
+
// to spread the booleans straight onto the class list, which meant `<MetaInfo md lg>`
|
|
43
|
+
// emitted BOTH modifiers and the winner came down to the order meta_info.css
|
|
44
|
+
// happened to be imported in.
|
|
45
|
+
const activeSize = computed(() => {
|
|
46
|
+
if (props.lg) return 'lg'
|
|
47
|
+
if (props.md) return 'md'
|
|
48
|
+
if (props.sm) return 'sm'
|
|
49
|
+
|
|
50
|
+
return 'sm'
|
|
51
|
+
})
|
|
52
|
+
|
|
29
53
|
const metaInfoClass = computed(() => [
|
|
30
54
|
'rsui-meta-info',
|
|
55
|
+
`rsui-meta-info--${activeSize.value}`,
|
|
31
56
|
{
|
|
32
57
|
'rsui-meta-info--inline': props.inline,
|
|
33
|
-
'rsui-meta-info--md': props.md,
|
|
34
|
-
'rsui-meta-info--lg': props.lg,
|
|
35
58
|
}
|
|
36
59
|
])
|
|
37
60
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import { computed, watchEffect, useSlots, Comment, Fragment } from 'vue'
|
|
3
|
+
import { isSafeHref } from '../../helpers/href'
|
|
3
4
|
|
|
4
5
|
const props = defineProps({
|
|
5
6
|
showContent: {
|
|
@@ -42,44 +43,16 @@ const viewAllLabelText = computed(() => String(props.viewAllLabel ?? '').trim())
|
|
|
42
43
|
const rawViewAllHref = computed(() => String(props.viewAllHref ?? '').trim())
|
|
43
44
|
|
|
44
45
|
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
|
|
52
|
-
|
|
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.
|
|
46
|
+
* `javascript:` and `data:` in an href are script execution, and this is a
|
|
47
|
+
* declared prop on a design-system component rather than an attribute
|
|
48
|
+
* passthrough — a consuming app can reasonably expect the anchor it did not
|
|
49
|
+
* write to be safe. Anything unrecognised is refused loudly, since a link that
|
|
50
|
+
* quietly stops working is the failure this component exists to avoid.
|
|
51
|
+
*
|
|
52
|
+
* The rule itself lives in `helpers/href`, because CategoryList builds an anchor
|
|
53
|
+
* out of a declared prop the same way and two copies of a safety check drift.
|
|
67
54
|
*/
|
|
68
|
-
|
|
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
|
-
})
|
|
55
|
+
const isViewAllHrefSafe = computed(() => isSafeHref(rawViewAllHref.value))
|
|
83
56
|
|
|
84
57
|
const hasViewAllLabel = computed(() => viewAllLabelText.value !== '')
|
|
85
58
|
|
|
@@ -22,9 +22,14 @@ const props = defineProps({
|
|
|
22
22
|
type: Boolean,
|
|
23
23
|
default: true,
|
|
24
24
|
},
|
|
25
|
+
// Default false. It was true, but no CSS rule existed behind the --divider class,
|
|
26
|
+
// so every consumer has been seeing no rule regardless. The rule exists now; keeping
|
|
27
|
+
// the default at true would make hairlines appear under every section header in every
|
|
28
|
+
// app off the back of a spacing fix. False preserves what they actually see today and
|
|
29
|
+
// makes the prop work for anyone who opts in. Matches CardHeader.
|
|
25
30
|
showDivider: {
|
|
26
31
|
type: Boolean,
|
|
27
|
-
default:
|
|
32
|
+
default: false,
|
|
28
33
|
},
|
|
29
34
|
noClamp: {
|
|
30
35
|
type: Boolean,
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a URL is safe to put in an `href` this library renders itself.
|
|
3
|
+
*
|
|
4
|
+
* Internal on purpose — not re-exported from `helpers/index.js`. It is a guard for
|
|
5
|
+
* components that build an anchor out of a declared prop, not a public utility, and
|
|
6
|
+
* consuming apps have their own escaping rules.
|
|
7
|
+
*
|
|
8
|
+
* The rule is the one SectionFooter's view-all link established: a consuming app can
|
|
9
|
+
* reasonably expect an anchor it did not write to be safe, so `javascript:` and
|
|
10
|
+
* `data:` — which are script execution, not navigation — are refused, and so is
|
|
11
|
+
* anything else with an unrecognised scheme.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Schemes an anchor may safely carry. */
|
|
15
|
+
const SAFE_HREF_SCHEME = /^(https?:|mailto:|tel:)/i
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `//evil.com` is protocol-relative: it starts with a slash but navigates to another
|
|
19
|
+
* host entirely, so a leading-slash test alone would wave it through.
|
|
20
|
+
*/
|
|
21
|
+
const PROTOCOL_RELATIVE = /^\/\//
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Anything with a scheme has one before the first slash. No colon in that span means a
|
|
25
|
+
* relative path — `courses/1`, `./x`, `../x`, `#anchor`, `?q=1` — which cannot leave the
|
|
26
|
+
* site and so needs no allow-list of its own.
|
|
27
|
+
*/
|
|
28
|
+
function isRelativeHref(href) {
|
|
29
|
+
const beforeFirstSlash = href.split('/')[0]
|
|
30
|
+
|
|
31
|
+
return !beforeFirstSlash.includes(':')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* An empty href counts as safe: it means "no link was asked for", which callers handle
|
|
36
|
+
* by rendering no anchor at all rather than by rejecting it.
|
|
37
|
+
*/
|
|
38
|
+
export function isSafeHref(href) {
|
|
39
|
+
const trimmed = String(href ?? '').trim()
|
|
40
|
+
|
|
41
|
+
if (trimmed === '') return true
|
|
42
|
+
if (PROTOCOL_RELATIVE.test(trimmed)) return false
|
|
43
|
+
if (SAFE_HREF_SCHEME.test(trimmed)) return true
|
|
44
|
+
|
|
45
|
+
return isRelativeHref(trimmed)
|
|
46
|
+
}
|