@redseed/redseed-ui-vue3 8.69.0 → 9.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/Card/Card.vue +35 -3
- package/src/components/Card/CardHeader.vue +29 -3
- package/src/components/Card/CardHorizontal.vue +9 -2
- package/src/components/Card/MetricCard.vue +167 -7
- package/src/components/Empty/Empty.vue +74 -2
- package/src/components/Layout/PageHeader.vue +63 -1
- package/src/components/Layout/PageHeaderStat.vue +84 -0
- package/src/components/Layout/index.js +2 -0
- package/src/components/Modal/Modal.vue +107 -5
- package/src/components/Section/SectionHeader.vue +16 -4
- package/src/components/Section/SectionSlider.vue +11 -3
package/package.json
CHANGED
|
@@ -1,15 +1,32 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { computed, ref, useSlots } from 'vue'
|
|
2
|
+
import { computed, provide, ref, useSlots } from 'vue'
|
|
3
3
|
import { useResponsiveWidth } from '../../helpers'
|
|
4
4
|
|
|
5
5
|
const props = defineProps({
|
|
6
|
+
/**
|
|
7
|
+
* Whether the whole card is a control.
|
|
8
|
+
*
|
|
9
|
+
* Opt-in. It used to default to true, so every card was a focusable
|
|
10
|
+
* role="button" with a hover state until a consumer said otherwise — 44 call
|
|
11
|
+
* sites passing `:clickable="false"` and 38 passing `:hoverable="false"`,
|
|
12
|
+
* against exactly one card that opted in. Most cards are containers, and only
|
|
13
|
+
* an actionable thing may carry an actionable role (WCAG 4.1.2). A hover
|
|
14
|
+
* state, a pointer cursor and a focus ring are promises.
|
|
15
|
+
*
|
|
16
|
+
* `ButtonCard` exists for the deliberate case and passes this explicitly.
|
|
17
|
+
*/
|
|
6
18
|
clickable: {
|
|
7
19
|
type: Boolean,
|
|
8
|
-
default:
|
|
20
|
+
default: false,
|
|
9
21
|
},
|
|
22
|
+
/**
|
|
23
|
+
* Hover feedback. Opt-in for the same reason: a card that opens nothing
|
|
24
|
+
* should not respond as though it does. A clickable card usually wants this
|
|
25
|
+
* too, and asks for it.
|
|
26
|
+
*/
|
|
10
27
|
hoverable: {
|
|
11
28
|
type: Boolean,
|
|
12
|
-
default:
|
|
29
|
+
default: false,
|
|
13
30
|
},
|
|
14
31
|
padded: {
|
|
15
32
|
type: Boolean,
|
|
@@ -155,6 +172,21 @@ const accentStyle = computed(() =>
|
|
|
155
172
|
|
|
156
173
|
const slots = useSlots()
|
|
157
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Whether this card will render a body, handed down so a CardHeader in the
|
|
177
|
+
* `header` slot can pay for its own bottom edge when there is nothing beneath it
|
|
178
|
+
* to borrow one from.
|
|
179
|
+
*
|
|
180
|
+
* `.rsui-card-header` is `pt-space-lg pb-0` on purpose — it takes its bottom edge
|
|
181
|
+
* from the body's padding. With no body, nobody pays, and the title sits flush
|
|
182
|
+
* against the card's border. `headerOnlyCard` existed for that but defaulted to
|
|
183
|
+
* false, so the broken rendering was the default and the correct one had to be
|
|
184
|
+
* asked for. CheckboxCard fell into it and it took a visual bug report to find.
|
|
185
|
+
*
|
|
186
|
+
* Mirrors the body's own `v-if` exactly, so the two cannot drift.
|
|
187
|
+
*/
|
|
188
|
+
provide('rsuiCardHasBody', computed(() => Boolean(slots.default || slots.meta)))
|
|
189
|
+
|
|
158
190
|
const cardElement = ref(null)
|
|
159
191
|
const { responsiveWidth } = useResponsiveWidth(cardElement)
|
|
160
192
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { computed, ref, toRefs, useAttrs } from 'vue'
|
|
2
|
+
import { computed, inject, ref, toRefs, useAttrs } from 'vue'
|
|
3
3
|
import ButtonTertiary from '../Button/ButtonTertiary.vue'
|
|
4
4
|
import Icon from '../Icon/Icon.vue'
|
|
5
5
|
import { useResponsiveWidth } from '../../helpers'
|
|
@@ -45,9 +45,21 @@ const props = defineProps({
|
|
|
45
45
|
type: Boolean,
|
|
46
46
|
default: true,
|
|
47
47
|
},
|
|
48
|
+
/**
|
|
49
|
+
* Pay for a bottom edge because nothing sits beneath this header.
|
|
50
|
+
*
|
|
51
|
+
* Left unset, a CardHeader inside a Card works it out: Card provides whether
|
|
52
|
+
* it will render a body, and a header with no body beneath it pads itself.
|
|
53
|
+
* That way the correct rendering is what you get, and this prop is an
|
|
54
|
+
* override rather than a requirement — it defaulted to false, which made the
|
|
55
|
+
* broken rendering the default.
|
|
56
|
+
*
|
|
57
|
+
* Null rather than false so an explicit `false` can still win. A CardHeader
|
|
58
|
+
* used outside a Card gets no answer and keeps the old behaviour.
|
|
59
|
+
*/
|
|
48
60
|
headerOnlyCard: {
|
|
49
61
|
type: Boolean,
|
|
50
|
-
default:
|
|
62
|
+
default: null,
|
|
51
63
|
},
|
|
52
64
|
avatarTop: {
|
|
53
65
|
type: Boolean,
|
|
@@ -67,8 +79,22 @@ const { responsiveWidth } = useResponsiveWidth(cardHeaderElement, 640)
|
|
|
67
79
|
|
|
68
80
|
const emit = defineEmits(['click:more-actions'])
|
|
69
81
|
|
|
82
|
+
/**
|
|
83
|
+
* `null` means "no opinion from the consumer", so fall through to what the
|
|
84
|
+
* enclosing Card says. Outside a Card there is nothing to inject and the value
|
|
85
|
+
* is null, which lands on the old behaviour.
|
|
86
|
+
*/
|
|
87
|
+
const cardHasBody = inject('rsuiCardHasBody', null)
|
|
88
|
+
|
|
89
|
+
const isHeaderOnly = computed(() => {
|
|
90
|
+
if (headerOnlyCard.value !== null) return headerOnlyCard.value
|
|
91
|
+
if (cardHasBody) return !cardHasBody.value
|
|
92
|
+
|
|
93
|
+
return false
|
|
94
|
+
})
|
|
95
|
+
|
|
70
96
|
const shouldPadBottom = computed(() =>
|
|
71
|
-
|
|
97
|
+
isHeaderOnly.value ? true : showDivider.value
|
|
72
98
|
)
|
|
73
99
|
|
|
74
100
|
function handleMoreActionsClick() {
|
|
@@ -4,13 +4,20 @@ import { useResizeObserver, useMutationObserver } from '@vueuse/core'
|
|
|
4
4
|
import Card from './Card.vue'
|
|
5
5
|
|
|
6
6
|
const props = defineProps({
|
|
7
|
+
/**
|
|
8
|
+
* Declared here and forwarded to Card, so these defaults must track Card's —
|
|
9
|
+
* a true default here would have kept every CardHorizontal a focusable
|
|
10
|
+
* role="button" after Card's own flip, unreachable from a call site. Same
|
|
11
|
+
* trap SectionSlider hit with headingLevel and CardGroup/List with Empty's
|
|
12
|
+
* showImage. See #335.
|
|
13
|
+
*/
|
|
7
14
|
clickable: {
|
|
8
15
|
type: Boolean,
|
|
9
|
-
default:
|
|
16
|
+
default: false,
|
|
10
17
|
},
|
|
11
18
|
hoverable: {
|
|
12
19
|
type: Boolean,
|
|
13
|
-
default:
|
|
20
|
+
default: false,
|
|
14
21
|
},
|
|
15
22
|
bordered: {
|
|
16
23
|
type: Boolean,
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
+
import { computed, useId } from 'vue'
|
|
3
|
+
import { ArrowUpRightIcon, ArrowDownRightIcon, ArrowRightIcon } from '@heroicons/vue/24/outline'
|
|
2
4
|
import Card from './Card.vue'
|
|
3
5
|
import FlexContainer from '../FlexContainer/FlexContainer.vue'
|
|
4
6
|
|
|
@@ -34,12 +36,99 @@ const props = defineProps({
|
|
|
34
36
|
// assume light-on-dark: surface, ink, muted ink and rule move together.
|
|
35
37
|
//
|
|
36
38
|
// Not combinable with Card's semantic variants (brand, success, …) — pick one.
|
|
39
|
+
//
|
|
40
|
+
// `bloom` is for a MetricCard sitting on PageHeader's bloom band — not for that
|
|
41
|
+
// header's own stats strip, which is PageHeaderStat. Like the others it names an
|
|
42
|
+
// opaque surface rather than a translucent white: the band's own colour changes
|
|
43
|
+
// across its width, so a tile that let it through would have a different contrast
|
|
44
|
+
// ratio at each end of the same row. See metric_card.css for the measurement.
|
|
37
45
|
tone: {
|
|
38
46
|
type: String,
|
|
39
47
|
default: null,
|
|
40
|
-
validator: value => ['green', 'teal', 'light'].includes(value),
|
|
48
|
+
validator: value => ['green', 'teal', 'light', 'bloom'].includes(value),
|
|
49
|
+
},
|
|
50
|
+
// Which edge carries a colour accent, and the token that paints it — both passed
|
|
51
|
+
// straight through to the underlying Card, which already implements them.
|
|
52
|
+
//
|
|
53
|
+
// A stat tile is the case the accent was made for: a strip of counts where one of
|
|
54
|
+
// them is the one to act on, and a rail down its leading edge says so without giving
|
|
55
|
+
// that tile a different surface from the rest of the row.
|
|
56
|
+
accent: {
|
|
57
|
+
type: String,
|
|
58
|
+
default: 'none',
|
|
59
|
+
validator: value => ['none', 'left', 'top'].includes(value),
|
|
60
|
+
},
|
|
61
|
+
// A runtime CSS variable NAME, not a colour — `Colors-Warm-Yellow-500` paints the
|
|
62
|
+
// rail Warm Yellow. Card's own note applies: an RSUI @theme token will not resolve,
|
|
63
|
+
// and an unresolvable one degrades to neutral grey rather than to an invisible edge.
|
|
64
|
+
color: {
|
|
65
|
+
type: String,
|
|
66
|
+
default: '',
|
|
67
|
+
},
|
|
68
|
+
// DIRECTION of travel — which arrow. Deliberately separate from `sentiment`,
|
|
69
|
+
// because an arrow direction and a colour are not the same decision:
|
|
70
|
+
// attrition up is bad, completions up is good. One prop driving both would
|
|
71
|
+
// render every metric where rising is bad in green.
|
|
72
|
+
trend: {
|
|
73
|
+
type: String,
|
|
74
|
+
default: null,
|
|
75
|
+
validator: value => ['up', 'down', 'flat'].includes(value),
|
|
76
|
+
},
|
|
77
|
+
// Whether that direction is GOOD NEWS — colour only. Defaults to deriving
|
|
78
|
+
// from `trend` (up => positive) so the common case stays terse, but it is
|
|
79
|
+
// independently overridable, which is the entire point of the split.
|
|
80
|
+
sentiment: {
|
|
81
|
+
type: String,
|
|
82
|
+
default: null,
|
|
83
|
+
validator: value => ['positive', 'negative', 'neutral'].includes(value),
|
|
84
|
+
},
|
|
85
|
+
// WHERE the comparison sits relative to the figure.
|
|
86
|
+
//
|
|
87
|
+
// below the comparison is a caption under the number (the default, and
|
|
88
|
+
// what every existing tile already renders).
|
|
89
|
+
// inline the comparison sits on the number's own row, immediately beside
|
|
90
|
+
// it, centred against its line box.
|
|
91
|
+
//
|
|
92
|
+
// A string rather than an `inlineComparison` boolean because the reference
|
|
93
|
+
// carries a third shape too — the change as a bordered chip pushed to the far
|
|
94
|
+
// right of the number row — and a boolean would have to be retired to add it.
|
|
95
|
+
//
|
|
96
|
+
// Inline is the tighter tile: it buys back a line of height, which is what
|
|
97
|
+
// makes a dense dashboard row of these work. It costs horizontal room, so the
|
|
98
|
+
// row wraps back to two lines rather than overflowing when the tile is narrow.
|
|
99
|
+
comparisonPlacement: {
|
|
100
|
+
type: String,
|
|
101
|
+
default: 'below',
|
|
102
|
+
validator: value => ['below', 'inline'].includes(value),
|
|
41
103
|
},
|
|
42
104
|
})
|
|
105
|
+
|
|
106
|
+
// The terse default. `sentiment` is read first so an explicit value always wins,
|
|
107
|
+
// including 'neutral', which is why this tests for null rather than falsiness.
|
|
108
|
+
const TREND_SENTIMENT = {
|
|
109
|
+
up: 'positive',
|
|
110
|
+
down: 'negative',
|
|
111
|
+
flat: 'neutral',
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const resolvedSentiment = computed(() => {
|
|
115
|
+
if (props.sentiment !== null) return props.sentiment
|
|
116
|
+
|
|
117
|
+
return TREND_SENTIMENT[props.trend] ?? 'neutral'
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
const TREND_ICON = {
|
|
121
|
+
up: ArrowUpRightIcon,
|
|
122
|
+
down: ArrowDownRightIcon,
|
|
123
|
+
flat: ArrowRightIcon,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const trendIcon = computed(() => TREND_ICON[props.trend] ?? null)
|
|
127
|
+
|
|
128
|
+
// Ties the comparison to the number for assistive tech, so the trend is not read
|
|
129
|
+
// as a loose fragment floating after the figure. DOM order already puts them
|
|
130
|
+
// adjacent; aria-describedby is what survives the number being queried alone.
|
|
131
|
+
const comparisonId = useId()
|
|
43
132
|
</script>
|
|
44
133
|
|
|
45
134
|
<template>
|
|
@@ -52,6 +141,8 @@ const props = defineProps({
|
|
|
52
141
|
:clickable="props.clickable"
|
|
53
142
|
:hoverable="props.hoverable"
|
|
54
143
|
:pressed="active"
|
|
144
|
+
:accent="props.accent"
|
|
145
|
+
:color="props.color"
|
|
55
146
|
ph-component="MetricCard"
|
|
56
147
|
>
|
|
57
148
|
<template v-if="$slots['aria-label']" #aria-label>
|
|
@@ -69,19 +160,82 @@ const props = defineProps({
|
|
|
69
160
|
<div v-if="$slots.icon" class="rsui-metric-card__icon" aria-hidden="true">
|
|
70
161
|
<slot name="icon"></slot>
|
|
71
162
|
</div>
|
|
72
|
-
<div class="rsui-metric-
|
|
73
|
-
<
|
|
163
|
+
<div class="rsui-metric-card__heading-text">
|
|
164
|
+
<div class="rsui-metric-card__label">
|
|
165
|
+
<slot name="label"></slot>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
<div v-if="$slots.description" class="rsui-metric-card__description">
|
|
169
|
+
<slot name="description"></slot>
|
|
170
|
+
</div>
|
|
74
171
|
</div>
|
|
75
172
|
</div>
|
|
76
173
|
|
|
174
|
+
<!-- The figure and the thing that qualifies it, as one unit. The wrapper
|
|
175
|
+
stacks them by default, which renders exactly as the two siblings did
|
|
176
|
+
before it existed, and turns into a row when comparisonPlacement is
|
|
177
|
+
inline. Keeping both placements on the same two elements is what lets
|
|
178
|
+
the a11y wiring, the sentiment modifiers and the tonal overrides stay
|
|
179
|
+
placement-agnostic — only the direction of this one box changes. -->
|
|
77
180
|
<div :class="[
|
|
78
|
-
'rsui-metric-
|
|
181
|
+
'rsui-metric-card__figure',
|
|
182
|
+
`rsui-metric-card__figure--${props.comparisonPlacement}`,
|
|
79
183
|
{ 'rsui-metric-card--center': alignment === 'center' },
|
|
80
184
|
{ 'rsui-metric-card--left': alignment === 'left' },
|
|
81
185
|
{ 'rsui-metric-card--right': alignment === 'right' },
|
|
82
186
|
]"
|
|
83
187
|
>
|
|
84
|
-
<
|
|
188
|
+
<div :class="[
|
|
189
|
+
'rsui-metric-card__number',
|
|
190
|
+
{ 'rsui-metric-card--center': alignment === 'center' },
|
|
191
|
+
{ 'rsui-metric-card--left': alignment === 'left' },
|
|
192
|
+
{ 'rsui-metric-card--right': alignment === 'right' },
|
|
193
|
+
]"
|
|
194
|
+
:aria-describedby="$slots.comparison ? comparisonId : undefined"
|
|
195
|
+
>
|
|
196
|
+
<slot name="number"></slot>
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
<!-- With the number at every labelFirst setting, matching the reference
|
|
200
|
+
shape: the comparison qualifies the figure, not the label, so it
|
|
201
|
+
stays with the figure wherever the label goes. -->
|
|
202
|
+
<div v-if="$slots.comparison"
|
|
203
|
+
:class="[
|
|
204
|
+
'rsui-metric-card__comparison-row',
|
|
205
|
+
{ 'rsui-metric-card--center': alignment === 'center' },
|
|
206
|
+
{ 'rsui-metric-card--left': alignment === 'left' },
|
|
207
|
+
{ 'rsui-metric-card--right': alignment === 'right' },
|
|
208
|
+
]"
|
|
209
|
+
>
|
|
210
|
+
<span :id="comparisonId"
|
|
211
|
+
:class="[
|
|
212
|
+
'rsui-metric-card__comparison',
|
|
213
|
+
`rsui-metric-card__comparison--${resolvedSentiment}`,
|
|
214
|
+
]"
|
|
215
|
+
>
|
|
216
|
+
<span v-if="trendIcon" class="rsui-metric-card__comparison-icon" aria-hidden="true">
|
|
217
|
+
<component :is="trendIcon" />
|
|
218
|
+
</span>
|
|
219
|
+
|
|
220
|
+
<slot name="comparison"></slot>
|
|
221
|
+
|
|
222
|
+
<!-- The quieter half of the reference's "Change and text": the change
|
|
223
|
+
carries the sentiment colour, the phrase that qualifies it drops to
|
|
224
|
+
tertiary ink so the eye lands on the figure first.
|
|
225
|
+
|
|
226
|
+
A second slot rather than asking the consumer to wrap the phrase in
|
|
227
|
+
a class themselves. The split is a colour decision, which belongs to
|
|
228
|
+
the design system; the words and their translation stay with the
|
|
229
|
+
consumer, exactly as the comparison itself does.
|
|
230
|
+
|
|
231
|
+
Inside the comparison span, so it sits within the region the number's
|
|
232
|
+
aria-describedby points at — "175.95% Increase vs last month" is one
|
|
233
|
+
description of the figure, not a fragment plus an orphan. -->
|
|
234
|
+
<span v-if="$slots.comparisonContext" class="rsui-metric-card__comparison-context">
|
|
235
|
+
<slot name="comparisonContext"></slot>
|
|
236
|
+
</span>
|
|
237
|
+
</span>
|
|
238
|
+
</div>
|
|
85
239
|
</div>
|
|
86
240
|
|
|
87
241
|
<div v-if="!labelFirst"
|
|
@@ -95,8 +249,14 @@ const props = defineProps({
|
|
|
95
249
|
<div v-if="$slots.icon" class="rsui-metric-card__icon" aria-hidden="true">
|
|
96
250
|
<slot name="icon"></slot>
|
|
97
251
|
</div>
|
|
98
|
-
<div class="rsui-metric-
|
|
99
|
-
<
|
|
252
|
+
<div class="rsui-metric-card__heading-text">
|
|
253
|
+
<div class="rsui-metric-card__label">
|
|
254
|
+
<slot name="label"></slot>
|
|
255
|
+
</div>
|
|
256
|
+
|
|
257
|
+
<div v-if="$slots.description" class="rsui-metric-card__description">
|
|
258
|
+
<slot name="description"></slot>
|
|
259
|
+
</div>
|
|
100
260
|
</div>
|
|
101
261
|
</div>
|
|
102
262
|
</FlexContainer>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { ref, computed } from 'vue'
|
|
2
|
+
import { ref, computed, useSlots } from 'vue'
|
|
3
3
|
import ButtonPrimary from '../Button/ButtonPrimary.vue'
|
|
4
4
|
import ButtonSecondary from '../Button/ButtonSecondary.vue'
|
|
5
5
|
import ButtonTertiary from '../Button/ButtonTertiary.vue'
|
|
@@ -7,14 +7,84 @@ import BodyText from '../HTML/BodyText.vue'
|
|
|
7
7
|
import IconCircleBackground from '../Icon/IconCircleBackground.vue'
|
|
8
8
|
import { ExclamationCircleIcon } from '@heroicons/vue/24/outline'
|
|
9
9
|
import { useResponsiveWidth } from '../../helpers'
|
|
10
|
+
import { rendersContent } from '../../helpers/slots'
|
|
10
11
|
|
|
11
12
|
const props = defineProps({
|
|
13
|
+
/**
|
|
14
|
+
* Whether to show the illustration above the message.
|
|
15
|
+
*
|
|
16
|
+
* Opt-in, because an empty state is a short message in a panel and a circled
|
|
17
|
+
* exclamation mark above it reads as a warning about a list that is merely
|
|
18
|
+
* new. Two thirds of call sites were already turning it off, which is the
|
|
19
|
+
* definition of a wrong default.
|
|
20
|
+
*
|
|
21
|
+
* It could not be fixed at the call sites: CardGroup and List render their
|
|
22
|
+
* own Empty and pass no `showImage`, so those four instances were unreachable
|
|
23
|
+
* from consuming code and showed the illustration whatever a consumer did.
|
|
24
|
+
* Flipping the default is the only change that reaches them. See #348.
|
|
25
|
+
*
|
|
26
|
+
* Note this gates the `image` slot too, so a consumer supplying their own
|
|
27
|
+
* illustration sets `showImage` alongside it.
|
|
28
|
+
*/
|
|
12
29
|
showImage: {
|
|
30
|
+
type: Boolean,
|
|
31
|
+
default: null,
|
|
32
|
+
},
|
|
33
|
+
/**
|
|
34
|
+
* Announce this empty state to a screen reader (WCAG 4.1.3).
|
|
35
|
+
*
|
|
36
|
+
* On by default. A list that changes state has to say so — someone who has
|
|
37
|
+
* just filtered a table to nothing gets silence where the rows were, and on
|
|
38
|
+
* a phone there is no peripheral view of the page to fall back on.
|
|
39
|
+
*
|
|
40
|
+
* Defaulting this to `false` would repeat the mistake this component was
|
|
41
|
+
* just fixed for: the right behaviour has to be what you get without asking.
|
|
42
|
+
* Turn it off for an empty state that is simply page content rather than the
|
|
43
|
+
* result of something the user did.
|
|
44
|
+
*
|
|
45
|
+
* A caveat worth knowing, because the attribute alone does not buy as much as
|
|
46
|
+
* it looks: a live region announces CHANGES made after it is registered. A
|
|
47
|
+
* region that is already present when the page loads stays quiet, which is
|
|
48
|
+
* what you want. But an Empty that is inserted wholesale — `v-if` flipping,
|
|
49
|
+
* which is what CardGroup and List both do — is announced inconsistently
|
|
50
|
+
* across screen readers, because the region and its content arrive together.
|
|
51
|
+
* For a guaranteed announcement the region has to outlive the change, which
|
|
52
|
+
* only the consumer can arrange.
|
|
53
|
+
*/
|
|
54
|
+
announce: {
|
|
13
55
|
type: Boolean,
|
|
14
56
|
default: true,
|
|
15
57
|
},
|
|
16
58
|
})
|
|
17
59
|
|
|
60
|
+
const slots = useSlots()
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Whether the illustration region renders. Three states, because two were not
|
|
64
|
+
* enough to flip the default without deleting work.
|
|
65
|
+
*
|
|
66
|
+
* unset — show only if a consumer supplied an `image` slot that renders
|
|
67
|
+
* something. Passing the slot IS the opt-in.
|
|
68
|
+
* true — show, falling back to the built-in icon.
|
|
69
|
+
* false — hide, even with a slot supplied.
|
|
70
|
+
*
|
|
71
|
+
* `showImage` used to default to `true`, so every empty state led with a circled
|
|
72
|
+
* exclamation mark that reads as a warning about a list that is merely new — and
|
|
73
|
+
* two thirds of call sites turned it off. But the prop gates the `image` slot as
|
|
74
|
+
* well as the icon, so flipping it to a plain `false` would have silently deleted
|
|
75
|
+
* the illustration from everyone who had chosen their own: 14 places in the LMS,
|
|
76
|
+
* each a deliberate choice, none of them erroring. Hence the third state.
|
|
77
|
+
*
|
|
78
|
+
* Rendered output rather than slot presence, and a plain function rather than a
|
|
79
|
+
* computed: `useSlots()` is not reactive, so a computed over it caches its first
|
|
80
|
+
* answer for the life of the component. See helpers/slots.
|
|
81
|
+
*/
|
|
82
|
+
function showsImage() {
|
|
83
|
+
if (props.showImage !== null) return props.showImage
|
|
84
|
+
|
|
85
|
+
return rendersContent(slots.image)
|
|
86
|
+
}
|
|
87
|
+
|
|
18
88
|
const emit = defineEmits(['clickPrimaryAction', 'clickSecondaryAction', 'clickTertiaryAction'])
|
|
19
89
|
|
|
20
90
|
const emptyElement = ref(null)
|
|
@@ -34,13 +104,15 @@ const emptyClass = computed(() => [
|
|
|
34
104
|
<div v-if="$slots.title || $slots.default"
|
|
35
105
|
ref="emptyElement"
|
|
36
106
|
:class="emptyClass"
|
|
107
|
+
:role="announce ? 'status' : undefined"
|
|
108
|
+
:aria-live="announce ? 'polite' : undefined"
|
|
37
109
|
>
|
|
38
110
|
<!--
|
|
39
111
|
Image slot with a default icon.
|
|
40
112
|
This slot is used to render the image.
|
|
41
113
|
The image is used to display an icon or an image.
|
|
42
114
|
-->
|
|
43
|
-
<div v-if="
|
|
115
|
+
<div v-if="showsImage()"
|
|
44
116
|
class="rsui-empty__image"
|
|
45
117
|
>
|
|
46
118
|
<slot name="image">
|
|
@@ -15,10 +15,37 @@ const props = defineProps({
|
|
|
15
15
|
//
|
|
16
16
|
// A toned header supplies its own padding, so place it outside the page's content
|
|
17
17
|
// gutter and let the band bleed. Omit the prop and nothing changes.
|
|
18
|
+
//
|
|
19
|
+
// `bloom` is the brand's hero treatment — a green-to-mauve sweep with three radial
|
|
20
|
+
// washes over it — rather than a flat fill like the other three. It is a tone rather
|
|
21
|
+
// than a modifier layered over one because its wash alphas are a measured contrast
|
|
22
|
+
// budget against its own base sweep; over a different background the measurement
|
|
23
|
+
// does not hold. See page_header.css for the figures.
|
|
18
24
|
tone: {
|
|
19
25
|
type: String,
|
|
20
26
|
default: null,
|
|
21
|
-
validator: value => ['green', 'teal', 'light'].includes(value),
|
|
27
|
+
validator: value => ['green', 'teal', 'light', 'bloom'].includes(value),
|
|
28
|
+
},
|
|
29
|
+
// Bleeds a toned band to the viewport edges while keeping its text on the page's own
|
|
30
|
+
// gutter, so the greeting lines up with the logo and nav above it rather than sitting
|
|
31
|
+
// 24px from the screen edge.
|
|
32
|
+
//
|
|
33
|
+
// This is what a toned header wants in an app shell, and the note on `tone` — "place
|
|
34
|
+
// it outside the page's content gutter" — is the version of it a consumer has to build
|
|
35
|
+
// themselves. With this the header stays INSIDE the container where it belongs in the
|
|
36
|
+
// document, and only its paint escapes.
|
|
37
|
+
//
|
|
38
|
+
// Requires `overflow-x: clip` on the document (html and body): the bleed is measured
|
|
39
|
+
// in vw, which includes the scrollbar gutter, so it overshoots by half a scrollbar
|
|
40
|
+
// each side. Clip it on the document rather than on the page root — clipping the root
|
|
41
|
+
// clips the bleed itself, and the band's box reaches the edge while its paint stops at
|
|
42
|
+
// the container. `clip` rather than `hidden`, so neither element becomes a scroll
|
|
43
|
+
// container and position: sticky keeps working.
|
|
44
|
+
//
|
|
45
|
+
// No effect without a tone: there is no band to bleed.
|
|
46
|
+
bleed: {
|
|
47
|
+
type: Boolean,
|
|
48
|
+
default: false,
|
|
22
49
|
},
|
|
23
50
|
})
|
|
24
51
|
|
|
@@ -30,6 +57,7 @@ const pageHeaderClass = computed(() => [
|
|
|
30
57
|
'rsui-page-header--profile': props.profile,
|
|
31
58
|
'rsui-page-header--toned': !!props.tone,
|
|
32
59
|
[`rsui-page-header--tone-${props.tone}`]: !!props.tone,
|
|
60
|
+
'rsui-page-header--bleed': !!props.tone && props.bleed,
|
|
33
61
|
},
|
|
34
62
|
])
|
|
35
63
|
</script>
|
|
@@ -41,6 +69,22 @@ const pageHeaderClass = computed(() => [
|
|
|
41
69
|
<slot name="avatar"></slot>
|
|
42
70
|
</div>
|
|
43
71
|
<div class="rsui-page-header__title-text">
|
|
72
|
+
<!--
|
|
73
|
+
A small label ABOVE the h1, matching CardHeader's #eyebrow: the
|
|
74
|
+
line a page uses to say what kind of thing it is, or what day it
|
|
75
|
+
is, before it says which one. Without it the only home for that
|
|
76
|
+
line is #title, which puts it inside the h1 and gives the page two
|
|
77
|
+
headings in one.
|
|
78
|
+
|
|
79
|
+
Gated on slot presence alone rather than on a showEyebrow prop
|
|
80
|
+
like CardHeader's, because every other slot on this component is
|
|
81
|
+
gated the same way and a prop that only ever hides an absent
|
|
82
|
+
element earns nothing.
|
|
83
|
+
-->
|
|
84
|
+
<div v-if="$slots.eyebrow" class="rsui-page-header__eyebrow">
|
|
85
|
+
<slot name="eyebrow"></slot>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
44
88
|
<div class="rsui-page-header__title-text-container">
|
|
45
89
|
<h1>
|
|
46
90
|
<slot name="title"></slot>
|
|
@@ -55,6 +99,24 @@ const pageHeaderClass = computed(() => [
|
|
|
55
99
|
</div>
|
|
56
100
|
</div>
|
|
57
101
|
|
|
102
|
+
<!--
|
|
103
|
+
A row of summary tiles belonging to the header itself — the counts a page
|
|
104
|
+
leads with, as MetricCards rather than as label/value pairs.
|
|
105
|
+
|
|
106
|
+
Inside __top rather than under it, so it inherits the row/column the
|
|
107
|
+
header already switches at lg: stacked under the title on a phone, beside
|
|
108
|
+
it on a desktop. That is one layout rule, not a second set of breakpoints.
|
|
109
|
+
|
|
110
|
+
NOT the meta slot. __meta is `hidden lg:flex` — a grid of label/value
|
|
111
|
+
facts that collapses into a modal on a phone (see #369) — and a count the
|
|
112
|
+
page leads with is not a fact you go looking for in a modal. It also
|
|
113
|
+
carries the tonal rule above it, which puts a line between the title and
|
|
114
|
+
anything placed there.
|
|
115
|
+
-->
|
|
116
|
+
<div v-if="$slots.stats" class="rsui-page-header__stats">
|
|
117
|
+
<slot name="stats"></slot>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
58
120
|
<div v-if="$slots.actions" class="rsui-page-header__status-actions">
|
|
59
121
|
|
|
60
122
|
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
One count in PageHeader's #stats row: a large figure with a small label under it,
|
|
6
|
+
on a tile that lets the header's band through.
|
|
7
|
+
|
|
8
|
+
NOT a MetricCard. The two look alike in a listing and are different objects: a
|
|
9
|
+
MetricCard is a Card — 24px padding, the sans number step, a comparison, a trend, a
|
|
10
|
+
sentiment — and is sized by the grid it sits in. This is a 150px tile, 12px/16px of
|
|
11
|
+
padding, a serif figure at 46px and nothing else, because it sits in a band beside a
|
|
12
|
+
title rather than in a dashboard. Reproducing it out of MetricCard meant overriding
|
|
13
|
+
most of MetricCard from the header's stylesheet, which is the smell this component
|
|
14
|
+
exists to remove. Reach for MetricCard when the tile is the content of the page.
|
|
15
|
+
*/
|
|
16
|
+
const props = defineProps({
|
|
17
|
+
// Renders a <button> rather than a <div>. A count that scrolls to its section or
|
|
18
|
+
// opens a filtered list is a control and has to be one — but a stat that only reports
|
|
19
|
+
// a number is not, and a button that does nothing is worse than a div (WCAG 4.1.2).
|
|
20
|
+
clickable: {
|
|
21
|
+
type: Boolean,
|
|
22
|
+
default: false,
|
|
23
|
+
},
|
|
24
|
+
// Renders an <a> instead, for a count whose destination is a page. Wins over
|
|
25
|
+
// `clickable`, since a link that is also a button is neither.
|
|
26
|
+
href: {
|
|
27
|
+
type: String,
|
|
28
|
+
default: '',
|
|
29
|
+
},
|
|
30
|
+
// Paints the rail down the tile's leading edge. A runtime CSS variable NAME, the same
|
|
31
|
+
// contract Card's `color` takes: `Colors-Warm-Yellow-500`, not a colour. RSUI's own
|
|
32
|
+
// @theme tokens are inlined into utilities and will NOT resolve here.
|
|
33
|
+
//
|
|
34
|
+
// The rail marks the one count in a row that is asking for something, without giving
|
|
35
|
+
// that tile a different surface from the rest. Unset, the edge is reserved and
|
|
36
|
+
// transparent, so a tile with a rail and one without still line up.
|
|
37
|
+
accent: {
|
|
38
|
+
type: String,
|
|
39
|
+
default: '',
|
|
40
|
+
},
|
|
41
|
+
// Names the control for assistive technology when the visible label is a fragment —
|
|
42
|
+
// "8 / To do" reads as two unrelated strings out of context.
|
|
43
|
+
ariaLabel: {
|
|
44
|
+
type: String,
|
|
45
|
+
default: '',
|
|
46
|
+
},
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
defineEmits(['click'])
|
|
50
|
+
|
|
51
|
+
const tag = computed(() => {
|
|
52
|
+
if (props.href) return 'a'
|
|
53
|
+
if (props.clickable) return 'button'
|
|
54
|
+
|
|
55
|
+
return 'div'
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// Only set when an accent is supplied, so the CSS transparent default holds otherwise.
|
|
59
|
+
// Carries an inline fallback for the same reason Card's does: an unresolvable token
|
|
60
|
+
// degrades to a neutral rail rather than to an invisible one.
|
|
61
|
+
const accentStyle = computed(() =>
|
|
62
|
+
props.accent
|
|
63
|
+
? { '--rsui-page-header-stat-accent': `var(--${props.accent}, var(--Colors-Grey-500))` }
|
|
64
|
+
: {},
|
|
65
|
+
)
|
|
66
|
+
</script>
|
|
67
|
+
<template>
|
|
68
|
+
<component :is="tag"
|
|
69
|
+
class="rsui-page-header__stat"
|
|
70
|
+
:class="{ 'rsui-page-header__stat--interactive': tag !== 'div' }"
|
|
71
|
+
:style="accentStyle"
|
|
72
|
+
:type="tag === 'button' ? 'button' : undefined"
|
|
73
|
+
:href="tag === 'a' ? props.href : undefined"
|
|
74
|
+
:aria-label="props.ariaLabel || undefined"
|
|
75
|
+
@click="$emit('click', $event)"
|
|
76
|
+
>
|
|
77
|
+
<span class="rsui-page-header__stat-value">
|
|
78
|
+
<slot name="value"></slot>
|
|
79
|
+
</span>
|
|
80
|
+
<span class="rsui-page-header__stat-label">
|
|
81
|
+
<slot name="label"></slot>
|
|
82
|
+
</span>
|
|
83
|
+
</component>
|
|
84
|
+
</template>
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import PageHeader from './PageHeader.vue'
|
|
2
|
+
import PageHeaderStat from './PageHeaderStat.vue'
|
|
2
3
|
import SidebarLayout from './SidebarLayout.vue'
|
|
3
4
|
import SingleColumnLayout from './SingleColumnLayout.vue'
|
|
4
5
|
import TwoColumnLayout from './TwoColumnLayout.vue'
|
|
5
6
|
|
|
6
7
|
export {
|
|
7
8
|
PageHeader,
|
|
9
|
+
PageHeaderStat,
|
|
8
10
|
SidebarLayout,
|
|
9
11
|
SingleColumnLayout,
|
|
10
12
|
TwoColumnLayout,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
2
|
+
import { computed, nextTick, onMounted, onUnmounted, ref, useSlots, watch, watchEffect } from 'vue'
|
|
3
3
|
|
|
4
4
|
// Apply all attributes to the input element, not the wrapper div
|
|
5
5
|
defineOptions({
|
|
@@ -111,9 +111,11 @@ watch(() => props.show, (value) => {
|
|
|
111
111
|
nextTick(() => {
|
|
112
112
|
const modal = modalContentRef.value
|
|
113
113
|
if (!modal) return
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
114
|
+
|
|
115
|
+
const [firstFocusable] = focusableItems(modal)
|
|
116
|
+
|
|
117
|
+
if (firstFocusable) {
|
|
118
|
+
firstFocusable.focus()
|
|
117
119
|
} else {
|
|
118
120
|
modal.focus()
|
|
119
121
|
}
|
|
@@ -141,10 +143,110 @@ function closeOnEscape(e) {
|
|
|
141
143
|
}
|
|
142
144
|
}
|
|
143
145
|
|
|
144
|
-
|
|
146
|
+
/**
|
|
147
|
+
* `:not([disabled])` matters: the open-focus path used to omit it and could land
|
|
148
|
+
* focus on a disabled control, which then looks like focus went nowhere.
|
|
149
|
+
*/
|
|
150
|
+
const FOCUSABLE_SELECTOR = [
|
|
151
|
+
'button:not([disabled])',
|
|
152
|
+
'[href]',
|
|
153
|
+
'input:not([disabled])',
|
|
154
|
+
'select:not([disabled])',
|
|
155
|
+
'textarea:not([disabled])',
|
|
156
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
157
|
+
].join(', ')
|
|
158
|
+
|
|
159
|
+
function focusableItems(modal) {
|
|
160
|
+
return [...modal.querySelectorAll(FOCUSABLE_SELECTOR)]
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The focus trap (WCAG 2.4.3). Nothing intercepted Tab, so focus walked out of
|
|
165
|
+
* the dialog and into the page behind it, where it could reach controls under an
|
|
166
|
+
* overlay it cannot see. `aria-modal="true"` does not help here — it makes screen
|
|
167
|
+
* readers treat outside content as inert, and does nothing to the tab order.
|
|
168
|
+
* Measured before the fix: a button outside the dialog stayed reachable.
|
|
169
|
+
*
|
|
170
|
+
* On `document` rather than the dialog, so it still catches Tab if focus has
|
|
171
|
+
* ended up outside — a click through a gap, or a stale activeElement — and pulls
|
|
172
|
+
* it back rather than only wrapping at the two ends.
|
|
173
|
+
*/
|
|
174
|
+
function trapTab(e) {
|
|
175
|
+
if (e.key !== 'Tab' || !props.show) return
|
|
176
|
+
|
|
177
|
+
const modal = modalContentRef.value
|
|
178
|
+
if (!modal) return
|
|
179
|
+
|
|
180
|
+
const items = focusableItems(modal)
|
|
181
|
+
|
|
182
|
+
// A dialog with nothing focusable still must not leak: the panel itself is
|
|
183
|
+
// `tabindex="-1"`, so it can hold focus even though it is not tabbable.
|
|
184
|
+
if (items.length === 0) {
|
|
185
|
+
e.preventDefault()
|
|
186
|
+
modal.focus()
|
|
187
|
+
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const first = items[0]
|
|
192
|
+
const last = items[items.length - 1]
|
|
193
|
+
const active = document.activeElement
|
|
194
|
+
|
|
195
|
+
if (!modal.contains(active)) {
|
|
196
|
+
e.preventDefault()
|
|
197
|
+
first.focus()
|
|
198
|
+
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!e.shiftKey && active === last) {
|
|
203
|
+
e.preventDefault()
|
|
204
|
+
first.focus()
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (e.shiftKey && active === first) {
|
|
208
|
+
e.preventDefault()
|
|
209
|
+
last.focus()
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const slots = useSlots()
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* `aria-labelledby` is bound to the header slot's id, so a modal with no header
|
|
217
|
+
* announces as "dialog" and nothing else. Eleven of the 45 in the product are in
|
|
218
|
+
* that state, one of which archives a team. The component knows, so it says so
|
|
219
|
+
* once rather than being missed eleven times.
|
|
220
|
+
*
|
|
221
|
+
* Slot *presence*, deliberately, rather than rendered output: the header slot is
|
|
222
|
+
* scoped (`:close`), and probing a scoped slot without passing its scope throws
|
|
223
|
+
* out of a consumer's own destructuring — the bug fixed in #330. Presence is
|
|
224
|
+
* enough to catch the case this is about.
|
|
225
|
+
*
|
|
226
|
+
* Dev-only and deferred by a tick, matching SectionFooter and ButtonSlot: this
|
|
227
|
+
* package ships raw `.vue` source, so an ungated warn reaches real users, and a
|
|
228
|
+
* modal whose header arrives a tick later should not be scolded for it.
|
|
229
|
+
*/
|
|
230
|
+
watchEffect((onCleanup) => {
|
|
231
|
+
if (process.env.NODE_ENV === 'production') return
|
|
232
|
+
if (!props.show) return
|
|
233
|
+
if (slots.header) return
|
|
234
|
+
|
|
235
|
+
const timer = setTimeout(() => console.warn(
|
|
236
|
+
'[RSUI] Modal: rendered with no `header` slot, so `aria-labelledby` is unset and the dialog announces as "dialog" with no name. Give it a header, or an accessible name another way.',
|
|
237
|
+
), 0)
|
|
238
|
+
|
|
239
|
+
onCleanup(() => clearTimeout(timer))
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
onMounted(() => {
|
|
243
|
+
document.addEventListener('keydown', closeOnEscape)
|
|
244
|
+
document.addEventListener('keydown', trapTab)
|
|
245
|
+
})
|
|
145
246
|
|
|
146
247
|
onUnmounted(() => {
|
|
147
248
|
document.removeEventListener('keydown', closeOnEscape)
|
|
249
|
+
document.removeEventListener('keydown', trapTab)
|
|
148
250
|
document.body.style.overflow = null
|
|
149
251
|
})
|
|
150
252
|
|
|
@@ -38,12 +38,24 @@ const props = defineProps({
|
|
|
38
38
|
/**
|
|
39
39
|
* Heading level (1-6) for the title. When set, the title renders as a real
|
|
40
40
|
* <h1>-<h6> so consuming pages get a correct document outline (WCAG 1.3.1,
|
|
41
|
-
* 2.4.6).
|
|
42
|
-
*
|
|
41
|
+
* 2.4.6).
|
|
42
|
+
*
|
|
43
|
+
* Defaults to 2. It used to default to null, which rendered a non-heading
|
|
44
|
+
* <span> — and three quarters of section titles in the product took that
|
|
45
|
+
* default: 72 of 97. Those sections did not exist for anyone navigating by
|
|
46
|
+
* heading, which is a WCAG 1.3.1 failure rather than untidiness, and it is
|
|
47
|
+
* not something a consumer can be expected to remember at every call site.
|
|
48
|
+
*
|
|
49
|
+
* 2 rather than 3 because PageHeader already renders the page title as <h1>,
|
|
50
|
+
* so a section beneath it wants 2 almost every time; a nested section asks
|
|
51
|
+
* for 3, as the 25 call sites that already pass a level are doing.
|
|
52
|
+
*
|
|
53
|
+
* Explicit null is still honoured, for a title that genuinely is not a
|
|
54
|
+
* section heading — a toolbar label rather than a landmark.
|
|
43
55
|
*/
|
|
44
56
|
headingLevel: {
|
|
45
57
|
type: [Number, String],
|
|
46
|
-
default:
|
|
58
|
+
default: 2,
|
|
47
59
|
validator: (value) => value === null || ['1', '2', '3', '4', '5', '6'].includes(String(value)),
|
|
48
60
|
},
|
|
49
61
|
})
|
|
@@ -73,7 +85,7 @@ const showToolbar = computed(() => {
|
|
|
73
85
|
|
|
74
86
|
/**
|
|
75
87
|
* Element used to render the title — a real heading when a level is supplied,
|
|
76
|
-
* otherwise a non-heading <span
|
|
88
|
+
* otherwise a non-heading <span>, which now requires passing null explicitly.
|
|
77
89
|
*/
|
|
78
90
|
const titleTag = computed(() => props.headingLevel ? `h${props.headingLevel}` : 'span')
|
|
79
91
|
</script>
|
|
@@ -37,12 +37,20 @@ const props = defineProps({
|
|
|
37
37
|
},
|
|
38
38
|
/**
|
|
39
39
|
* Heading level (1-6) for the slider title. When set, the title renders
|
|
40
|
-
* as a real <h1>-<h6> for a correct document outline
|
|
41
|
-
*
|
|
40
|
+
* as a real <h1>-<h6> for a correct document outline.
|
|
41
|
+
*
|
|
42
|
+
* Defaults to 2, matching SectionHeader. Declared here rather than left to
|
|
43
|
+
* SectionHeader's own default because this component forwards the value
|
|
44
|
+
* explicitly — so a null default would have passed null through and kept
|
|
45
|
+
* every slider title a non-heading <span> while every other section title
|
|
46
|
+
* became a heading. A default a call site cannot reach is the same trap
|
|
47
|
+
* CardGroup and List hit with Empty's showImage. See #337.
|
|
48
|
+
*
|
|
49
|
+
* Explicit null is still honoured for a title that is not a section heading.
|
|
42
50
|
*/
|
|
43
51
|
headingLevel: {
|
|
44
52
|
type: [Number, String],
|
|
45
|
-
default:
|
|
53
|
+
default: 2,
|
|
46
54
|
validator: (value) => value === null || ['1', '2', '3', '4', '5', '6'].includes(String(value)),
|
|
47
55
|
},
|
|
48
56
|
/**
|