@redseed/redseed-ui-vue3 8.60.0 → 8.62.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/index.js +1 -0
- 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/Layout/PageHeader.vue +14 -0
- package/src/components/List/ListItem.vue +43 -2
- package/src/components/MessageBox/MessageBox.vue +5 -1
- package/src/components/Section/SectionHeader.vue +4 -1
- package/src/components/Section/index.js +0 -2
- package/src/components/StageDial/StageDial.vue +279 -0
- package/src/components/StageDial/index.js +5 -0
- package/src/components/Switcher/Switcher.vue +26 -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
|
@@ -7,7 +7,7 @@ A comprehensive Vue 3 component library designed for building modern, accessible
|
|
|
7
7
|
### 1. [Layout Components](./01-layout-components.md)
|
|
8
8
|
Structural components for organizing page content, including layouts, headers, sections, and page structure.
|
|
9
9
|
|
|
10
|
-
**Key Components:** SingleColumnLayout, TwoColumnLayout, PageHeader, SectionHeader
|
|
10
|
+
**Key Components:** SingleColumnLayout, TwoColumnLayout, PageHeader, SectionHeader
|
|
11
11
|
|
|
12
12
|
### 2. [Button Components](./02-button-components.md)
|
|
13
13
|
Interactive elements for user actions, with various styles and grouping options.
|
|
@@ -116,34 +116,6 @@ Layout components provide the structural foundation for organizing content on pa
|
|
|
116
116
|
</template>
|
|
117
117
|
```
|
|
118
118
|
|
|
119
|
-
### HeroSection
|
|
120
|
-
**When to use:** For landing pages, marketing pages, or any page that needs a prominent introduction section with header content and optional button cards.
|
|
121
|
-
**Implementation:**
|
|
122
|
-
```vue
|
|
123
|
-
<template>
|
|
124
|
-
<HeroSection>
|
|
125
|
-
<template #header>
|
|
126
|
-
<SectionHeader>
|
|
127
|
-
Section header
|
|
128
|
-
<template #subtitle>
|
|
129
|
-
Section header subtitle
|
|
130
|
-
</template>
|
|
131
|
-
</SectionHeader>
|
|
132
|
-
</template>
|
|
133
|
-
|
|
134
|
-
<ButtonCard>
|
|
135
|
-
<template #icon>
|
|
136
|
-
<FaceSmileIcon />
|
|
137
|
-
</template>
|
|
138
|
-
Button card title
|
|
139
|
-
<template #supporting-text>
|
|
140
|
-
Supporting text
|
|
141
|
-
</template>
|
|
142
|
-
</ButtonCard>
|
|
143
|
-
</HeroSection>
|
|
144
|
-
</template>
|
|
145
|
-
```
|
|
146
|
-
|
|
147
119
|
## Best Practices
|
|
148
120
|
|
|
149
121
|
1. **Consistent Structure:** Use layout components consistently across your application to maintain visual hierarchy and user experience.
|
|
@@ -147,19 +147,24 @@ Media components provide consistent ways to display images, logos, and other vis
|
|
|
147
147
|
|
|
148
148
|
## Common Use Cases
|
|
149
149
|
|
|
150
|
-
###
|
|
150
|
+
### Banner Section with Image
|
|
151
151
|
```vue
|
|
152
152
|
<template>
|
|
153
|
-
<
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
153
|
+
<Section>
|
|
154
|
+
<template #header>
|
|
155
|
+
<SectionHeader>
|
|
156
|
+
Welcome to Our Platform
|
|
157
|
+
<template #subtitle>
|
|
158
|
+
The best solution for your needs
|
|
159
|
+
</template>
|
|
160
|
+
</SectionHeader>
|
|
161
|
+
</template>
|
|
162
|
+
|
|
163
|
+
<Image16By9
|
|
164
|
+
:src="bannerImageUrl"
|
|
165
|
+
:alt="bannerImageAlt"
|
|
161
166
|
/>
|
|
162
|
-
</
|
|
167
|
+
</Section>
|
|
163
168
|
</template>
|
|
164
169
|
```
|
|
165
170
|
|
package/index.js
CHANGED
|
@@ -42,6 +42,7 @@ export * from './src/components/Section'
|
|
|
42
42
|
export * from './src/components/Skeleton'
|
|
43
43
|
export * from './src/components/Social'
|
|
44
44
|
export * from './src/components/Sorting'
|
|
45
|
+
export * from './src/components/StageDial'
|
|
45
46
|
export * from './src/components/Switcher'
|
|
46
47
|
export * from './src/components/TabSlider'
|
|
47
48
|
export * from './src/components/Table'
|
package/package.json
CHANGED
|
@@ -28,12 +28,27 @@ const props = defineProps({
|
|
|
28
28
|
type: Boolean,
|
|
29
29
|
default: null,
|
|
30
30
|
},
|
|
31
|
+
// Puts the tile on a tonal surface with ink to match, using the same named set as
|
|
32
|
+
// PageHeader's `tone` so a tile inside a toned header can be given the matching name.
|
|
33
|
+
// Named tones rather than a colour because white-labelling means the component cannot
|
|
34
|
+
// assume light-on-dark: surface, ink, muted ink and rule move together.
|
|
35
|
+
//
|
|
36
|
+
// Not combinable with Card's semantic variants (brand, success, …) — pick one.
|
|
37
|
+
tone: {
|
|
38
|
+
type: String,
|
|
39
|
+
default: null,
|
|
40
|
+
validator: value => ['green', 'teal', 'light'].includes(value),
|
|
41
|
+
},
|
|
31
42
|
})
|
|
32
43
|
</script>
|
|
33
44
|
|
|
34
45
|
<template>
|
|
35
46
|
<Card class="rsui-metric-card"
|
|
36
|
-
:class="{
|
|
47
|
+
:class="{
|
|
48
|
+
'rsui-metric-card--active': active === true,
|
|
49
|
+
'rsui-metric-card--toned': !!props.tone,
|
|
50
|
+
[`rsui-metric-card--tone-${props.tone}`]: !!props.tone,
|
|
51
|
+
}"
|
|
37
52
|
:clickable="props.clickable"
|
|
38
53
|
:hoverable="props.hoverable"
|
|
39
54
|
:pressed="active"
|
|
@@ -22,64 +22,66 @@ const props = defineProps({
|
|
|
22
22
|
const formRef = ref(props.form)
|
|
23
23
|
</script>
|
|
24
24
|
<template>
|
|
25
|
-
<
|
|
25
|
+
<div class="rsui-form-fields-login">
|
|
26
|
+
<slot name="top-fields" :formRef="formRef"></slot>
|
|
26
27
|
|
|
27
|
-
<slot name="email-username" :formRef="formRef">
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
<slot name="email-username" :formRef="formRef">
|
|
29
|
+
<FormFieldEmail
|
|
30
|
+
v-model="formRef.email"
|
|
31
|
+
id="email"
|
|
32
|
+
name="email"
|
|
33
|
+
>
|
|
34
|
+
<template #label>
|
|
35
|
+
<slot name="email-label">
|
|
36
|
+
Email
|
|
37
|
+
</slot>
|
|
38
|
+
</template>
|
|
39
|
+
|
|
40
|
+
<template #error v-if="formRef.errors.email">
|
|
41
|
+
{{ formRef.errors.email }}
|
|
42
|
+
</template>
|
|
43
|
+
</FormFieldEmail>
|
|
44
|
+
</slot>
|
|
45
|
+
|
|
46
|
+
<FormFieldPassword
|
|
47
|
+
v-model="formRef.password"
|
|
48
|
+
id="password"
|
|
49
|
+
name="password"
|
|
32
50
|
>
|
|
33
51
|
<template #label>
|
|
34
|
-
<slot name="
|
|
35
|
-
|
|
52
|
+
<slot name="password-label">
|
|
53
|
+
Password
|
|
36
54
|
</slot>
|
|
37
55
|
</template>
|
|
38
56
|
|
|
39
|
-
<template #error v-if="formRef.errors.
|
|
40
|
-
{{ formRef.errors.
|
|
57
|
+
<template #error v-if="formRef.errors.password">
|
|
58
|
+
{{ formRef.errors.password }}
|
|
41
59
|
</template>
|
|
42
|
-
</
|
|
43
|
-
</slot>
|
|
44
|
-
|
|
45
|
-
<FormFieldPassword
|
|
46
|
-
v-model="formRef.password"
|
|
47
|
-
id="password"
|
|
48
|
-
name="password"
|
|
49
|
-
>
|
|
50
|
-
<template #label>
|
|
51
|
-
<slot name="password-label">
|
|
52
|
-
Password
|
|
53
|
-
</slot>
|
|
54
|
-
</template>
|
|
60
|
+
</FormFieldPassword>
|
|
55
61
|
|
|
56
|
-
<
|
|
57
|
-
{{ formRef.errors.password }}
|
|
58
|
-
</template>
|
|
59
|
-
</FormFieldPassword>
|
|
62
|
+
<slot name="bottom-fields" :formRef="formRef"></slot>
|
|
60
63
|
|
|
61
|
-
<
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
>
|
|
66
|
-
<slot name="forgot"></slot>
|
|
67
|
-
</FormFieldSlot>
|
|
64
|
+
<FormFieldSlot v-if="$slots.forgot"
|
|
65
|
+
class="rsui-form-field-forgot"
|
|
66
|
+
>
|
|
67
|
+
<slot name="forgot"></slot>
|
|
68
|
+
</FormFieldSlot>
|
|
68
69
|
|
|
69
|
-
<slot name="submit"
|
|
70
|
-
|
|
71
|
-
>
|
|
72
|
-
<ButtonPrimaryFull lg
|
|
73
|
-
:disabled="form.processing"
|
|
74
|
-
type="submit"
|
|
70
|
+
<slot name="submit"
|
|
71
|
+
:formRef="formRef"
|
|
75
72
|
>
|
|
76
|
-
<
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
73
|
+
<ButtonPrimaryFull lg
|
|
74
|
+
:disabled="form.processing"
|
|
75
|
+
type="submit"
|
|
76
|
+
>
|
|
77
|
+
<slot name="submit-label">
|
|
78
|
+
Log in
|
|
79
|
+
</slot>
|
|
80
|
+
</ButtonPrimaryFull>
|
|
81
|
+
</slot>
|
|
81
82
|
|
|
82
|
-
<div v-if="$slots.signup" class="rsui-form-login__signup">
|
|
83
|
-
|
|
83
|
+
<div v-if="$slots.signup" class="rsui-form-login__signup">
|
|
84
|
+
<slot name="signup"></slot>
|
|
85
|
+
</div>
|
|
84
86
|
</div>
|
|
85
87
|
</template>
|
|
@@ -25,93 +25,95 @@ const props = defineProps({
|
|
|
25
25
|
const formRef = ref(props.form)
|
|
26
26
|
</script>
|
|
27
27
|
<template>
|
|
28
|
-
<
|
|
28
|
+
<div class="rsui-form-fields-register">
|
|
29
|
+
<slot name="top-fields" :formRef="formRef"></slot>
|
|
29
30
|
|
|
30
|
-
<slot name="name-fields"
|
|
31
|
-
|
|
32
|
-
>
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
31
|
+
<slot name="name-fields"
|
|
32
|
+
:formRef="formRef"
|
|
33
|
+
>
|
|
34
|
+
<FormFieldText
|
|
35
|
+
v-model="formRef.name"
|
|
36
|
+
id="name"
|
|
37
|
+
name="name"
|
|
38
|
+
>
|
|
39
|
+
<template #label>
|
|
40
|
+
<slot name="name-label">
|
|
41
|
+
Name
|
|
42
|
+
</slot>
|
|
43
|
+
</template>
|
|
44
|
+
|
|
45
|
+
<template #error v-if="formRef.errors.name">
|
|
46
|
+
{{ formRef.errors.name }}
|
|
47
|
+
</template>
|
|
48
|
+
</FormFieldText>
|
|
49
|
+
</slot>
|
|
50
|
+
|
|
51
|
+
<FormFieldEmail
|
|
52
|
+
v-model="formRef.email"
|
|
53
|
+
id="email"
|
|
54
|
+
name="email"
|
|
37
55
|
>
|
|
38
56
|
<template #label>
|
|
39
|
-
<slot name="
|
|
40
|
-
|
|
57
|
+
<slot name="email-label">
|
|
58
|
+
Email
|
|
41
59
|
</slot>
|
|
42
60
|
</template>
|
|
43
61
|
|
|
44
|
-
<template #error v-if="formRef.errors.
|
|
45
|
-
{{ formRef.errors.
|
|
62
|
+
<template #error v-if="formRef.errors.email">
|
|
63
|
+
{{ formRef.errors.email }}
|
|
46
64
|
</template>
|
|
47
|
-
</
|
|
48
|
-
</slot>
|
|
49
|
-
|
|
50
|
-
<FormFieldEmail
|
|
51
|
-
v-model="formRef.email"
|
|
52
|
-
id="email"
|
|
53
|
-
name="email"
|
|
54
|
-
>
|
|
55
|
-
<template #label>
|
|
56
|
-
<slot name="email-label">
|
|
57
|
-
Email
|
|
58
|
-
</slot>
|
|
59
|
-
</template>
|
|
65
|
+
</FormFieldEmail>
|
|
60
66
|
|
|
61
|
-
<
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
<
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
>
|
|
71
|
-
<template #label>
|
|
72
|
-
<slot name="password-label">
|
|
73
|
-
Password
|
|
74
|
-
</slot>
|
|
75
|
-
</template>
|
|
67
|
+
<FormFieldPasswordToggle
|
|
68
|
+
v-model="formRef.password"
|
|
69
|
+
id="password"
|
|
70
|
+
name="password"
|
|
71
|
+
>
|
|
72
|
+
<template #label>
|
|
73
|
+
<slot name="password-label">
|
|
74
|
+
Password
|
|
75
|
+
</slot>
|
|
76
|
+
</template>
|
|
76
77
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
</FormFieldPasswordToggle>
|
|
78
|
+
<template #error v-if="formRef.errors.password">
|
|
79
|
+
{{ formRef.errors.password }}
|
|
80
|
+
</template>
|
|
81
|
+
</FormFieldPasswordToggle>
|
|
81
82
|
|
|
82
|
-
<slot name="bottom-fields" :formRef="formRef"></slot>
|
|
83
|
+
<slot name="bottom-fields" :formRef="formRef"></slot>
|
|
83
84
|
|
|
84
|
-
<FormFieldCheckbox
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
>
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
85
|
+
<FormFieldCheckbox
|
|
86
|
+
v-if="$slots.terms"
|
|
87
|
+
v-model="formRef.terms"
|
|
88
|
+
id="terms"
|
|
89
|
+
name="terms"
|
|
90
|
+
>
|
|
91
|
+
<template #label>
|
|
92
|
+
<slot name="terms"></slot>
|
|
93
|
+
</template>
|
|
93
94
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
</FormFieldCheckbox>
|
|
95
|
+
<template #error v-if="formRef.errors.terms">
|
|
96
|
+
{{ formRef.errors.terms }}
|
|
97
|
+
</template>
|
|
98
|
+
</FormFieldCheckbox>
|
|
98
99
|
|
|
99
|
-
<slot name="recaptcha" :formRef="formRef"></slot>
|
|
100
|
+
<slot name="recaptcha" :formRef="formRef"></slot>
|
|
100
101
|
|
|
101
|
-
<slot name="submit"
|
|
102
|
-
|
|
103
|
-
>
|
|
104
|
-
<ButtonPrimaryFull lg
|
|
105
|
-
:disabled="form.processing"
|
|
106
|
-
type="submit"
|
|
102
|
+
<slot name="submit"
|
|
103
|
+
:formRef="formRef"
|
|
107
104
|
>
|
|
108
|
-
<
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
105
|
+
<ButtonPrimaryFull lg
|
|
106
|
+
:disabled="form.processing"
|
|
107
|
+
type="submit"
|
|
108
|
+
>
|
|
109
|
+
<slot name="submit-label">
|
|
110
|
+
Register
|
|
111
|
+
</slot>
|
|
112
|
+
</ButtonPrimaryFull>
|
|
113
|
+
</slot>
|
|
113
114
|
|
|
114
|
-
<div v-if="$slots.signin" class="rsui-form-register__signin">
|
|
115
|
-
|
|
115
|
+
<div v-if="$slots.signin" class="rsui-form-register__signin">
|
|
116
|
+
<slot name="signin"></slot>
|
|
117
|
+
</div>
|
|
116
118
|
</div>
|
|
117
119
|
</template>
|
|
@@ -8,6 +8,18 @@ const props = defineProps({
|
|
|
8
8
|
type: Boolean,
|
|
9
9
|
default: false,
|
|
10
10
|
},
|
|
11
|
+
// Puts the header on a tonal band and flips its ink to match. Named tones rather than a
|
|
12
|
+
// background colour, because white-labelling means the component cannot assume
|
|
13
|
+
// light-on-dark: hand it a colour alone and a client who picks a light one gets white
|
|
14
|
+
// text on white. Background, ink, muted ink and rule move together as a set.
|
|
15
|
+
//
|
|
16
|
+
// A toned header supplies its own padding, so place it outside the page's content
|
|
17
|
+
// gutter and let the band bleed. Omit the prop and nothing changes.
|
|
18
|
+
tone: {
|
|
19
|
+
type: String,
|
|
20
|
+
default: null,
|
|
21
|
+
validator: value => ['green', 'teal', 'light'].includes(value),
|
|
22
|
+
},
|
|
11
23
|
})
|
|
12
24
|
|
|
13
25
|
const showMetaModal = ref(false)
|
|
@@ -16,6 +28,8 @@ const pageHeaderClass = computed(() => [
|
|
|
16
28
|
'rsui-page-header',
|
|
17
29
|
{
|
|
18
30
|
'rsui-page-header--profile': props.profile,
|
|
31
|
+
'rsui-page-header--toned': !!props.tone,
|
|
32
|
+
[`rsui-page-header--tone-${props.tone}`]: !!props.tone,
|
|
19
33
|
},
|
|
20
34
|
])
|
|
21
35
|
</script>
|
|
@@ -17,6 +17,29 @@ const props = defineProps({
|
|
|
17
17
|
type: Boolean,
|
|
18
18
|
default: false,
|
|
19
19
|
},
|
|
20
|
+
// Draws a coloured rail down the leading edge, so a list of mixed kinds can be scanned
|
|
21
|
+
// by colour. Semantic names rather than brand ones, matching MessageBox's vocabulary —
|
|
22
|
+
// the rail is decoration, so it only has to clear WCAG 1.4.11 at 3:1.
|
|
23
|
+
accent: {
|
|
24
|
+
type: String,
|
|
25
|
+
default: null,
|
|
26
|
+
validator: value => ['warning', 'info', 'success', 'error', 'neutral'].includes(value),
|
|
27
|
+
},
|
|
28
|
+
// Keeps meta and description rendered at every width. They are dropped below 480px by
|
|
29
|
+
// default, which is right for a dense list but wrong for a row whose second line carries
|
|
30
|
+
// the detail that makes it actionable.
|
|
31
|
+
alwaysShowBody: {
|
|
32
|
+
type: Boolean,
|
|
33
|
+
default: false,
|
|
34
|
+
},
|
|
35
|
+
// Size of the default action button. Defaults to the xs it has always used, which
|
|
36
|
+
// measures 34px. `lg` is the first size that clears the 44px touch target a phone
|
|
37
|
+
// wants — measured 46px; `md` is 42px and does not.
|
|
38
|
+
actionSize: {
|
|
39
|
+
type: String,
|
|
40
|
+
default: 'xs',
|
|
41
|
+
validator: value => ['xs', 'sm', 'md', 'lg', 'xl'].includes(value),
|
|
42
|
+
},
|
|
20
43
|
})
|
|
21
44
|
|
|
22
45
|
const listItemElement = ref(null)
|
|
@@ -28,7 +51,7 @@ const isWide = computed(() => !!responsiveWidth.value.specific)
|
|
|
28
51
|
const slots = useSlots()
|
|
29
52
|
|
|
30
53
|
const shouldShowBody = computed(() => {
|
|
31
|
-
return isWide.value && (slots.meta || slots.description)
|
|
54
|
+
return (isWide.value || props.alwaysShowBody) && (slots.meta || slots.description)
|
|
32
55
|
})
|
|
33
56
|
|
|
34
57
|
const listItemClass = computed(() => [
|
|
@@ -37,6 +60,8 @@ const listItemClass = computed(() => [
|
|
|
37
60
|
'rsui-list-item--clickable': props.clickable,
|
|
38
61
|
'rsui-list-item--avatar': props.avatar,
|
|
39
62
|
'rsui-list-item--wide': isWide.value,
|
|
63
|
+
'rsui-list-item--accented': !!props.accent,
|
|
64
|
+
[`rsui-list-item--accent-${props.accent}`]: !!props.accent,
|
|
40
65
|
}
|
|
41
66
|
])
|
|
42
67
|
|
|
@@ -75,6 +100,15 @@ function clickItem(event) {
|
|
|
75
100
|
The peer class is used to show hover effect on the whole item with the bg div.
|
|
76
101
|
-->
|
|
77
102
|
<div class="rsui-list-item__header peer">
|
|
103
|
+
<!--
|
|
104
|
+
The icon sits inline at the start of the row, unlike the avatar below, which
|
|
105
|
+
hangs outside the row box on a negative offset. Two different jobs: an avatar
|
|
106
|
+
identifies a person, an icon classifies the row, so they are separate slots
|
|
107
|
+
and can be used together.
|
|
108
|
+
-->
|
|
109
|
+
<div v-if="$slots.icon" class="rsui-list-item__icon" aria-hidden="true">
|
|
110
|
+
<slot name="icon"></slot>
|
|
111
|
+
</div>
|
|
78
112
|
<div v-if="avatar" class="rsui-list-item__avatar">
|
|
79
113
|
<slot name="avatar">
|
|
80
114
|
<div class="rsui-list-item__avatar-placeholder">
|
|
@@ -92,7 +126,14 @@ function clickItem(event) {
|
|
|
92
126
|
</div>
|
|
93
127
|
<div v-if="action" class="rsui-list-item__action">
|
|
94
128
|
<slot name="action" :clickAction="clickAction">
|
|
95
|
-
<ButtonTertiary
|
|
129
|
+
<ButtonTertiary
|
|
130
|
+
:xs="props.actionSize === 'xs'"
|
|
131
|
+
:sm="props.actionSize === 'sm'"
|
|
132
|
+
:md="props.actionSize === 'md'"
|
|
133
|
+
:lg="props.actionSize === 'lg'"
|
|
134
|
+
:xl="props.actionSize === 'xl'"
|
|
135
|
+
@click="clickAction"
|
|
136
|
+
>
|
|
96
137
|
<slot name="action-label">Action</slot>
|
|
97
138
|
</ButtonTertiary>
|
|
98
139
|
</slot>
|
|
@@ -19,7 +19,7 @@ const props = defineProps({
|
|
|
19
19
|
variant: {
|
|
20
20
|
type: String,
|
|
21
21
|
default: 'default',
|
|
22
|
-
validator: (value) => ['default', 'info', 'success', 'warning', 'error'].includes(value)
|
|
22
|
+
validator: (value) => ['default', 'info', 'success', 'warning', 'error', 'ai'].includes(value)
|
|
23
23
|
}
|
|
24
24
|
})
|
|
25
25
|
|
|
@@ -34,6 +34,10 @@ const isClosed = ref(props.closed)
|
|
|
34
34
|
* `default` variant is a plain container, not a status message, so it gets no
|
|
35
35
|
* live role (avoids announcing arbitrary static content). Both roles carry an
|
|
36
36
|
* implicit aria-live, so no separate attribute is needed.
|
|
37
|
+
*
|
|
38
|
+
* `ai` gets no live role either, and deliberately: an AI insight is content the
|
|
39
|
+
* reader chooses to read, not a status change. Announcing one on mount would
|
|
40
|
+
* interrupt for something nobody asked about.
|
|
37
41
|
*/
|
|
38
42
|
const liveRole = computed(() => {
|
|
39
43
|
if (props.variant === 'error') return 'alert'
|
|
@@ -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 />
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import HeroSection from './HeroSection.vue'
|
|
2
1
|
import Section from './Section.vue'
|
|
3
2
|
import SectionFooter from './SectionFooter.vue'
|
|
4
3
|
import SectionHeader from './SectionHeader.vue'
|
|
@@ -6,7 +5,6 @@ import SectionSlider from './SectionSlider.vue'
|
|
|
6
5
|
import SectionSliderItem from './SectionSliderItem.vue'
|
|
7
6
|
|
|
8
7
|
export {
|
|
9
|
-
HeroSection,
|
|
10
8
|
Section,
|
|
11
9
|
SectionFooter,
|
|
12
10
|
SectionHeader,
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, computed, onMounted } from 'vue'
|
|
3
|
+
|
|
4
|
+
// A circular stage indicator built from DISCRETE segments — one per named stage,
|
|
5
|
+
// filled up to the stage reached. Deliberately not ProgressCircle: a continuous
|
|
6
|
+
// ring filled two-thirds reads as "67%", where three separate segments with two
|
|
7
|
+
// filled reads as "the second of three named stages". For anything describing
|
|
8
|
+
// capability or maturity rather than completion, the continuous version turns a
|
|
9
|
+
// developmental stage into a score, which is the whole thing this avoids.
|
|
10
|
+
const props = defineProps({
|
|
11
|
+
// Ordered stage labels, lowest first (e.g. ['Emerging', 'Developing', 'Strong']).
|
|
12
|
+
stages: {
|
|
13
|
+
type: Array,
|
|
14
|
+
default: () => [],
|
|
15
|
+
},
|
|
16
|
+
// The stage reached — fills every segment up to and including it. Matched
|
|
17
|
+
// against `stages` case-insensitively and trimmed, so both 'Developing' and
|
|
18
|
+
// 'developing' resolve. No match (or an empty value) fills nothing, which is
|
|
19
|
+
// the honest rendering for "no stage yet" rather than defaulting to stage one.
|
|
20
|
+
current: {
|
|
21
|
+
type: String,
|
|
22
|
+
default: '',
|
|
23
|
+
},
|
|
24
|
+
// The name of a RUNTIME CSS custom property to use as the accent for the
|
|
25
|
+
// filled segments — applied as `var(--<color>, <neutral>)`, same convention
|
|
26
|
+
// as Pill's `color` prop. Pass a real runtime theme variable, e.g. the
|
|
27
|
+
// consuming app's palette (`color7` → `var(--color7)`). NOTE: RSUI's own
|
|
28
|
+
// Tailwind `@theme` color tokens (e.g. `--color-brand-500`) are inlined into
|
|
29
|
+
// utilities, not exposed as runtime vars, so they will NOT resolve here. An
|
|
30
|
+
// UNRESOLVABLE (undefined) token degrades to a neutral grey rather than an
|
|
31
|
+
// invisible dial. Optional — falls back to a neutral accent when omitted, so
|
|
32
|
+
// RSUI stays theme-agnostic.
|
|
33
|
+
color: {
|
|
34
|
+
type: String,
|
|
35
|
+
default: '',
|
|
36
|
+
},
|
|
37
|
+
// Arc degrees. The default 270 leaves a 90° gap at the bottom for a label,
|
|
38
|
+
// and the empty span is always centred on the bottom whatever the sweep.
|
|
39
|
+
sweep: {
|
|
40
|
+
type: Number,
|
|
41
|
+
default: 270,
|
|
42
|
+
},
|
|
43
|
+
// Set when the dial sits on a coloured or dark surface — a tonal PageHeader band, say.
|
|
44
|
+
// The centre content has no colour of its own and inherits the page ink, which on a dark
|
|
45
|
+
// band is near-invisible; this flips it to the on-colour token. Named to match
|
|
46
|
+
// BodyText's `onColor` rather than PageHeader's `tone`, because the dial has no surface
|
|
47
|
+
// of its own: it is transparent and only ever reacts to what is behind it, so the three
|
|
48
|
+
// tone names would be two behaviours wearing three labels.
|
|
49
|
+
//
|
|
50
|
+
// The track is deliberately untouched — see stage_dial.css for the measurements.
|
|
51
|
+
onColor: {
|
|
52
|
+
type: Boolean,
|
|
53
|
+
default: false,
|
|
54
|
+
},
|
|
55
|
+
// Opt-in draw-in: filled segments animate their dash length from 0, staggered
|
|
56
|
+
// by segment. No-ops under `prefers-reduced-motion` (handled in CSS).
|
|
57
|
+
animate: {
|
|
58
|
+
type: Boolean,
|
|
59
|
+
default: false,
|
|
60
|
+
},
|
|
61
|
+
// Extra delay in ms before this dial's first segment draws. Lets a row of
|
|
62
|
+
// dials be staggered into a wave by passing an increasing delay per dial.
|
|
63
|
+
delay: {
|
|
64
|
+
type: Number,
|
|
65
|
+
default: 0,
|
|
66
|
+
},
|
|
67
|
+
// Optional prefix for the generated accessible label, e.g. 'Lead self' →
|
|
68
|
+
// "Lead self: Developing, stage 2 of 3".
|
|
69
|
+
label: {
|
|
70
|
+
type: String,
|
|
71
|
+
default: '',
|
|
72
|
+
},
|
|
73
|
+
// Size ramp. Booleans, only one applies at a time — matches the RSUI
|
|
74
|
+
// size-prop convention. Defaults to md.
|
|
75
|
+
sm: {
|
|
76
|
+
type: Boolean,
|
|
77
|
+
default: false,
|
|
78
|
+
},
|
|
79
|
+
md: {
|
|
80
|
+
type: Boolean,
|
|
81
|
+
default: false,
|
|
82
|
+
},
|
|
83
|
+
lg: {
|
|
84
|
+
type: Boolean,
|
|
85
|
+
default: false,
|
|
86
|
+
},
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
// --- geometry ---------------------------------------------------------------
|
|
90
|
+
// Everything is drawn in a 100x100 viewBox so the whole dial scales with the
|
|
91
|
+
// CSS box: the stroke is in user units, so it scales too.
|
|
92
|
+
const RADIUS = 42
|
|
93
|
+
const STROKE = 9
|
|
94
|
+
// Preferred gap between segments, in path units. It MUST exceed the stroke
|
|
95
|
+
// width: with `stroke-linecap: round` the caps extend half the stroke width past
|
|
96
|
+
// each dash end, so a gap narrower than the stroke closes up and the segments
|
|
97
|
+
// render as one continuous sweep — exactly the gauge look this component exists
|
|
98
|
+
// to avoid. Twice the stroke is what reads unmistakably as separate stages.
|
|
99
|
+
const PREFERRED_GAP = 18
|
|
100
|
+
// Stagger between consecutive segments drawing in.
|
|
101
|
+
const SEGMENT_STAGGER = 130
|
|
102
|
+
|
|
103
|
+
const circumference = 2 * Math.PI * RADIUS
|
|
104
|
+
|
|
105
|
+
// Clamped so a nonsense sweep still renders something sane.
|
|
106
|
+
const safeSweep = computed(() => {
|
|
107
|
+
if (props.sweep < 30) return 30
|
|
108
|
+
if (props.sweep > 360) return 360
|
|
109
|
+
|
|
110
|
+
return props.sweep
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
const stageCount = computed(() => props.stages.length)
|
|
114
|
+
|
|
115
|
+
// Total path length the segments and gaps share.
|
|
116
|
+
const arcLength = computed(() => circumference * (safeSweep.value / 360))
|
|
117
|
+
|
|
118
|
+
// The gap shrinks below the preferred value rather than letting the gaps eat the
|
|
119
|
+
// arc, so a dial with many stages (or a tight sweep) still shows segments. Capped
|
|
120
|
+
// at a third of each stage's nominal share of the arc, which keeps segments at
|
|
121
|
+
// least twice the width of the gaps between them however many stages there are.
|
|
122
|
+
const gapLength = computed(() => {
|
|
123
|
+
if (stageCount.value < 2) return 0
|
|
124
|
+
|
|
125
|
+
return Math.min(PREFERRED_GAP, arcLength.value / (stageCount.value * 3))
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
const segmentLength = computed(() => {
|
|
129
|
+
if (! stageCount.value) return 0
|
|
130
|
+
|
|
131
|
+
const gapTotal = gapLength.value * (stageCount.value - 1)
|
|
132
|
+
|
|
133
|
+
return (arcLength.value - gapTotal) / stageCount.value
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
// An SVG circle's path starts at 3 o'clock and runs clockwise, so an un-rotated
|
|
137
|
+
// arc leaves its empty span centred at `sweep / 2 + 180`. Rotating the whole
|
|
138
|
+
// group by the difference moves that span to the bottom (90°), which is where the
|
|
139
|
+
// label goes — so the arc stays visually centred at any sweep.
|
|
140
|
+
const rotation = computed(() => (90 - (safeSweep.value / 2 + 180) + 360) % 360)
|
|
141
|
+
|
|
142
|
+
// --- stages -----------------------------------------------------------------
|
|
143
|
+
// Compared lowercased so a consumer can pass either the label or a lowercase key.
|
|
144
|
+
const normalisedStages = computed(() =>
|
|
145
|
+
props.stages.map((stage) => String(stage).trim().toLowerCase()),
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
const currentIndex = computed(() =>
|
|
149
|
+
normalisedStages.value.indexOf(String(props.current).trim().toLowerCase()),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
// Segments up to and including the reached stage. Everything is derived from
|
|
153
|
+
// this leading run, so unreached segments are never rendered at all — a
|
|
154
|
+
// zero-length dash still paints a dot under round caps, so they have to be left
|
|
155
|
+
// out rather than drawn at length 0.
|
|
156
|
+
const filledCount = computed(() => currentIndex.value + 1)
|
|
157
|
+
|
|
158
|
+
const segments = computed(() =>
|
|
159
|
+
props.stages.map((stage, index) => ({
|
|
160
|
+
stage,
|
|
161
|
+
index,
|
|
162
|
+
// Each segment starts one segment-plus-gap further round than the last.
|
|
163
|
+
offset: -(index * (segmentLength.value + gapLength.value)),
|
|
164
|
+
transitionDelay: `${props.delay + index * SEGMENT_STAGGER}ms`,
|
|
165
|
+
})),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
const filledSegments = computed(() => segments.value.slice(0, filledCount.value))
|
|
169
|
+
|
|
170
|
+
// --- animation --------------------------------------------------------------
|
|
171
|
+
// Starts drawn when not animating, so a static dial paints in one frame.
|
|
172
|
+
const isDrawn = ref(! props.animate)
|
|
173
|
+
|
|
174
|
+
onMounted(() => {
|
|
175
|
+
if (! props.animate) return
|
|
176
|
+
|
|
177
|
+
// A frame later, so the transition has an initial value to move away from.
|
|
178
|
+
requestAnimationFrame(() => {
|
|
179
|
+
isDrawn.value = true
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
const dashArray = computed(() =>
|
|
184
|
+
isDrawn.value
|
|
185
|
+
? `${segmentLength.value} ${circumference}`
|
|
186
|
+
: `0 ${circumference}`,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
const trackDashArray = computed(() => `${segmentLength.value} ${circumference}`)
|
|
190
|
+
|
|
191
|
+
// --- presentation -----------------------------------------------------------
|
|
192
|
+
// Largest flag wins so a consumer passing more than one still gets a defined
|
|
193
|
+
// result, and an unsized dial defaults to md.
|
|
194
|
+
const activeSize = computed(() => {
|
|
195
|
+
if (props.lg) return 'lg'
|
|
196
|
+
if (props.md) return 'md'
|
|
197
|
+
if (props.sm) return 'sm'
|
|
198
|
+
|
|
199
|
+
return 'md'
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
const stageDialClass = computed(() => [
|
|
203
|
+
'rsui-stage-dial',
|
|
204
|
+
`rsui-stage-dial--${activeSize.value}`,
|
|
205
|
+
{
|
|
206
|
+
'rsui-stage-dial--animate': props.animate,
|
|
207
|
+
'rsui-stage-dial--on-color': props.onColor,
|
|
208
|
+
},
|
|
209
|
+
])
|
|
210
|
+
|
|
211
|
+
// When no color is supplied we leave the property unset so the CSS neutral
|
|
212
|
+
// fallback applies. When a color IS supplied it carries an inline fallback to
|
|
213
|
+
// the neutral grey, so an UNRESOLVABLE (undefined) token degrades to grey rather
|
|
214
|
+
// than invisible segments. Note: var()'s fallback does not fire for a token that
|
|
215
|
+
// *resolves* to transparent/empty — passing a real colour is the consumer's
|
|
216
|
+
// responsibility.
|
|
217
|
+
const accentStyle = computed(() =>
|
|
218
|
+
props.color
|
|
219
|
+
? { '--rsui-stage-dial-accent': `var(--${props.color}, var(--Colors-Grey-500))` }
|
|
220
|
+
: {},
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
// The dial is a picture of a stage, not a percentage, so it gets `role="img"`
|
|
224
|
+
// with a spelled-out label rather than progressbar semantics — an
|
|
225
|
+
// `aria-valuenow` would reintroduce the score reading in the accessibility tree.
|
|
226
|
+
const accessibleLabel = computed(() => {
|
|
227
|
+
if (! stageCount.value) return props.label
|
|
228
|
+
|
|
229
|
+
const reached = currentIndex.value
|
|
230
|
+
const stageText = reached === -1
|
|
231
|
+
? `no stage reached, ${stageCount.value} stages`
|
|
232
|
+
: `${props.stages[reached]}, stage ${reached + 1} of ${stageCount.value}`
|
|
233
|
+
|
|
234
|
+
return props.label ? `${props.label}: ${stageText}` : stageText
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
// warn in development when the geometry will visually merge the segments
|
|
238
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
239
|
+
if (stageCount.value > 1 && gapLength.value <= STROKE) {
|
|
240
|
+
console.warn(
|
|
241
|
+
'[RSUI] StageDial segments will render as one continuous sweep: the gap between them is narrower than the stroke width. Widen `sweep` or pass fewer stages.',
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
</script>
|
|
246
|
+
<template>
|
|
247
|
+
<div :class="stageDialClass"
|
|
248
|
+
:style="accentStyle"
|
|
249
|
+
role="img"
|
|
250
|
+
:aria-label="accessibleLabel"
|
|
251
|
+
>
|
|
252
|
+
<svg class="rsui-stage-dial__svg" viewBox="0 0 100 100" aria-hidden="true" focusable="false">
|
|
253
|
+
<g :transform="`rotate(${rotation} 50 50)`">
|
|
254
|
+
<circle v-for="segment in segments"
|
|
255
|
+
:key="`track-${segment.index}`"
|
|
256
|
+
class="rsui-stage-dial__track"
|
|
257
|
+
cx="50"
|
|
258
|
+
cy="50"
|
|
259
|
+
:r="RADIUS"
|
|
260
|
+
:stroke-dasharray="trackDashArray"
|
|
261
|
+
:stroke-dashoffset="segment.offset"
|
|
262
|
+
></circle>
|
|
263
|
+
<circle v-for="segment in filledSegments"
|
|
264
|
+
:key="`fill-${segment.index}`"
|
|
265
|
+
class="rsui-stage-dial__fill"
|
|
266
|
+
cx="50"
|
|
267
|
+
cy="50"
|
|
268
|
+
:r="RADIUS"
|
|
269
|
+
:stroke-dasharray="dashArray"
|
|
270
|
+
:stroke-dashoffset="segment.offset"
|
|
271
|
+
:style="{ transitionDelay: segment.transitionDelay }"
|
|
272
|
+
></circle>
|
|
273
|
+
</g>
|
|
274
|
+
</svg>
|
|
275
|
+
<div class="rsui-stage-dial__content">
|
|
276
|
+
<slot></slot>
|
|
277
|
+
</div>
|
|
278
|
+
</div>
|
|
279
|
+
</template>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { ref } from 'vue'
|
|
3
3
|
import Icon from '../Icon/Icon.vue'
|
|
4
4
|
import SwitcherItem from './SwitcherItem.vue'
|
|
5
|
+
import { useTablistNavigation } from '../../composables/useTablistNavigation.js'
|
|
5
6
|
|
|
6
7
|
const props = defineProps({
|
|
7
8
|
items: {
|
|
@@ -12,12 +13,29 @@ const props = defineProps({
|
|
|
12
13
|
type: Boolean,
|
|
13
14
|
default: false,
|
|
14
15
|
},
|
|
16
|
+
// Renders the group as a filter chip set instead of a segmented control: no tonal
|
|
17
|
+
// container, pill-shaped items, and one row that scrolls sideways rather than stacking
|
|
18
|
+
// on a narrow screen. Opt-in, so existing consumers keep the segmented layout.
|
|
19
|
+
chips: {
|
|
20
|
+
type: Boolean,
|
|
21
|
+
default: false,
|
|
22
|
+
},
|
|
23
|
+
// Names the tablist for assistive technology. A tablist that only ever appears once on
|
|
24
|
+
// a page can go without, but any page carrying two of them needs each one labelled.
|
|
25
|
+
ariaLabel: {
|
|
26
|
+
type: String,
|
|
27
|
+
default: undefined,
|
|
28
|
+
},
|
|
15
29
|
})
|
|
16
30
|
|
|
17
31
|
const emit = defineEmits(['change'])
|
|
18
32
|
|
|
19
33
|
const activeItem = ref(props.items.find(item => item.active) || props.items[0])
|
|
20
34
|
|
|
35
|
+
// Arrow / Home / End key handling for the tabs pattern — see useTablistNavigation for why
|
|
36
|
+
// focus moves without changing the selection.
|
|
37
|
+
const { tablistElement, handleKeydown } = useTablistNavigation()
|
|
38
|
+
|
|
21
39
|
function setActiveItem(item) {
|
|
22
40
|
if (activeItem.value == item) return
|
|
23
41
|
activeItem.value = item
|
|
@@ -25,15 +43,22 @@ function setActiveItem(item) {
|
|
|
25
43
|
}
|
|
26
44
|
</script>
|
|
27
45
|
<template>
|
|
28
|
-
<div
|
|
46
|
+
<div ref="tablistElement"
|
|
47
|
+
class="rsui-switcher"
|
|
29
48
|
:class="{
|
|
30
49
|
'rsui-switcher--full': props.full,
|
|
50
|
+
'rsui-switcher--chips': props.chips,
|
|
31
51
|
}"
|
|
52
|
+
role="tablist"
|
|
53
|
+
:aria-label="props.ariaLabel"
|
|
54
|
+
@keydown="handleKeydown"
|
|
32
55
|
>
|
|
33
56
|
<SwitcherItem v-for="item in items"
|
|
34
57
|
:key="item.id"
|
|
35
58
|
:active="activeItem.id == item.id"
|
|
36
59
|
:disabled="item.disabled"
|
|
60
|
+
:tabId="item.tabId"
|
|
61
|
+
:panelId="item.panelId"
|
|
37
62
|
@click="setActiveItem(item)"
|
|
38
63
|
>
|
|
39
64
|
<Icon v-if="item.icon">
|
|
@@ -8,6 +8,19 @@ const props = defineProps({
|
|
|
8
8
|
type: Boolean,
|
|
9
9
|
default: false,
|
|
10
10
|
},
|
|
11
|
+
// ID applied to the tab button itself, so the panel can point back at it with
|
|
12
|
+
// aria-labelledby and be named by its tab. Optional, like panelId.
|
|
13
|
+
tabId: {
|
|
14
|
+
type: String,
|
|
15
|
+
default: undefined,
|
|
16
|
+
},
|
|
17
|
+
// ID of the element this tab controls. Optional because Switcher does not own the
|
|
18
|
+
// panel — the consumer renders it and gives it `role="tabpanel"`. Supplying it here
|
|
19
|
+
// completes the tabs pattern for assistive technology.
|
|
20
|
+
panelId: {
|
|
21
|
+
type: String,
|
|
22
|
+
default: undefined,
|
|
23
|
+
},
|
|
11
24
|
})
|
|
12
25
|
|
|
13
26
|
const emit = defineEmits(['click'])
|
|
@@ -19,16 +32,23 @@ function handleClick() {
|
|
|
19
32
|
}
|
|
20
33
|
</script>
|
|
21
34
|
<template>
|
|
22
|
-
<
|
|
35
|
+
<button type="button"
|
|
36
|
+
:id="props.tabId"
|
|
37
|
+
class="rsui-switcher-item"
|
|
23
38
|
:class="{
|
|
24
39
|
'rsui-switcher-item--active': props.active && !props.disabled,
|
|
25
40
|
'rsui-switcher-item--disabled': props.disabled,
|
|
26
41
|
}"
|
|
42
|
+
role="tab"
|
|
43
|
+
:aria-selected="props.active"
|
|
44
|
+
:aria-disabled="props.disabled ? true : undefined"
|
|
45
|
+
:aria-controls="props.panelId"
|
|
46
|
+
:tabindex="props.active ? 0 : -1"
|
|
27
47
|
@click="handleClick"
|
|
28
48
|
>
|
|
29
49
|
<slot></slot>
|
|
30
50
|
<div v-if="$slots.badge" class="rsui-switcher-item__badge">
|
|
31
51
|
<slot name="badge"></slot>
|
|
32
52
|
</div>
|
|
33
|
-
</
|
|
53
|
+
</button>
|
|
34
54
|
</template>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { ref } from 'vue'
|
|
3
3
|
import Icon from '../Icon/Icon.vue'
|
|
4
4
|
import TabSliderItem from './TabSliderItem.vue'
|
|
5
|
+
import { useTablistNavigation } from '../../composables/useTablistNavigation.js'
|
|
5
6
|
|
|
6
7
|
const props = defineProps({
|
|
7
8
|
items: {
|
|
@@ -12,12 +13,22 @@ const props = defineProps({
|
|
|
12
13
|
type: Boolean,
|
|
13
14
|
default: false,
|
|
14
15
|
},
|
|
16
|
+
// Names the tablist for assistive technology. A tablist that only ever appears once on
|
|
17
|
+
// a page can go without, but any page carrying two of them needs each one labelled.
|
|
18
|
+
ariaLabel: {
|
|
19
|
+
type: String,
|
|
20
|
+
default: undefined,
|
|
21
|
+
},
|
|
15
22
|
})
|
|
16
23
|
|
|
17
24
|
const emit = defineEmits(['change'])
|
|
18
25
|
|
|
19
26
|
const activeItem = ref(props.items.find(item => item.active) || props.items[0])
|
|
20
27
|
|
|
28
|
+
// Arrow / Home / End key handling for the tabs pattern — see useTablistNavigation for why
|
|
29
|
+
// focus moves without changing the selection.
|
|
30
|
+
const { tablistElement, handleKeydown } = useTablistNavigation()
|
|
31
|
+
|
|
21
32
|
function setActiveItem(item) {
|
|
22
33
|
if (activeItem.value == item) return
|
|
23
34
|
activeItem.value = item
|
|
@@ -25,15 +36,21 @@ function setActiveItem(item) {
|
|
|
25
36
|
}
|
|
26
37
|
</script>
|
|
27
38
|
<template>
|
|
28
|
-
<div
|
|
39
|
+
<div ref="tablistElement"
|
|
40
|
+
class="rsui-tab-slider"
|
|
29
41
|
:class="{
|
|
30
42
|
'rsui-tab-slider--full': props.full,
|
|
31
43
|
}"
|
|
44
|
+
role="tablist"
|
|
45
|
+
:aria-label="props.ariaLabel"
|
|
46
|
+
@keydown="handleKeydown"
|
|
32
47
|
>
|
|
33
48
|
<TabSliderItem v-for="item in items"
|
|
34
49
|
:key="item.id"
|
|
35
50
|
:active="activeItem.id == item.id"
|
|
36
51
|
:disabled="item.disabled"
|
|
52
|
+
:tabId="item.tabId"
|
|
53
|
+
:panelId="item.panelId"
|
|
37
54
|
@click="setActiveItem(item)"
|
|
38
55
|
>
|
|
39
56
|
<Icon v-if="item.icon">
|
|
@@ -8,6 +8,19 @@ const props = defineProps({
|
|
|
8
8
|
type: Boolean,
|
|
9
9
|
default: false,
|
|
10
10
|
},
|
|
11
|
+
// ID applied to the tab button itself, so the panel can point back at it with
|
|
12
|
+
// aria-labelledby and be named by its tab. Optional, like panelId.
|
|
13
|
+
tabId: {
|
|
14
|
+
type: String,
|
|
15
|
+
default: undefined,
|
|
16
|
+
},
|
|
17
|
+
// ID of the element this tab controls. Optional because TabSlider does not own the
|
|
18
|
+
// panel — the consumer renders it and gives it `role="tabpanel"`. Supplying it here
|
|
19
|
+
// completes the tabs pattern for assistive technology.
|
|
20
|
+
panelId: {
|
|
21
|
+
type: String,
|
|
22
|
+
default: undefined,
|
|
23
|
+
},
|
|
11
24
|
})
|
|
12
25
|
|
|
13
26
|
const emit = defineEmits(['click'])
|
|
@@ -19,16 +32,23 @@ function handleClick() {
|
|
|
19
32
|
}
|
|
20
33
|
</script>
|
|
21
34
|
<template>
|
|
22
|
-
<
|
|
35
|
+
<button type="button"
|
|
36
|
+
:id="props.tabId"
|
|
37
|
+
class="rsui-tab-slider-item"
|
|
23
38
|
:class="{
|
|
24
39
|
'rsui-tab-slider-item--active': props.active && !props.disabled,
|
|
25
40
|
'rsui-tab-slider-item--disabled': props.disabled,
|
|
26
41
|
}"
|
|
42
|
+
role="tab"
|
|
43
|
+
:aria-selected="props.active"
|
|
44
|
+
:aria-disabled="props.disabled ? true : undefined"
|
|
45
|
+
:aria-controls="props.panelId"
|
|
46
|
+
:tabindex="props.active ? 0 : -1"
|
|
27
47
|
@click="handleClick"
|
|
28
48
|
>
|
|
29
49
|
<slot></slot>
|
|
30
50
|
<div v-if="$slots.badge" class="rsui-tab-slider-item__badge">
|
|
31
51
|
<slot name="badge"></slot>
|
|
32
52
|
</div>
|
|
33
|
-
</
|
|
53
|
+
</button>
|
|
34
54
|
</template>
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Keyboard navigation for the WAI-ARIA tabs pattern, shared by TabSlider and Switcher.
|
|
5
|
+
*
|
|
6
|
+
* Both components render a `role="tablist"` whose children are `role="tab"` buttons using
|
|
7
|
+
* a roving tabindex: the selected tab carries `tabindex="0"` and the rest `tabindex="-1"`,
|
|
8
|
+
* so the tablist is a single Tab stop and the arrow keys move between tabs inside it.
|
|
9
|
+
*
|
|
10
|
+
* MANUAL ACTIVATION: the arrow keys move focus only — they do not change the selection.
|
|
11
|
+
* The APG permits either this or automatic activation (selection follows focus). Manual is
|
|
12
|
+
* the right default for a shared library: consumers commonly load data in response to
|
|
13
|
+
* `change`, and automatic activation would emit `change` for every tab arrowed past on the
|
|
14
|
+
* way to the intended one. Enter and Space activate the focused tab, and because each tab
|
|
15
|
+
* is a native <button> those come for free with no key handler of our own.
|
|
16
|
+
*
|
|
17
|
+
* Disabled tabs are marked `aria-disabled` rather than given the `disabled` attribute — the
|
|
18
|
+
* APG keeps them discoverable — but they are skipped by the arrow keys so keyboard users
|
|
19
|
+
* are not parked on a tab they cannot activate.
|
|
20
|
+
*
|
|
21
|
+
* Both arrow axes are handled because these two tablists are not both horizontal:
|
|
22
|
+
* `.rsui-switcher` is `flex-col sm:flex-row`, so it stacks vertically on small screens.
|
|
23
|
+
* Home and End jump to the first and last enabled tab. Navigation wraps at both ends.
|
|
24
|
+
*/
|
|
25
|
+
export function useTablistNavigation() {
|
|
26
|
+
const tablistElement = ref(null)
|
|
27
|
+
|
|
28
|
+
const NAVIGATION_KEYS = [
|
|
29
|
+
'ArrowRight',
|
|
30
|
+
'ArrowLeft',
|
|
31
|
+
'ArrowDown',
|
|
32
|
+
'ArrowUp',
|
|
33
|
+
'Home',
|
|
34
|
+
'End',
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Every tab the arrow keys are allowed to land on, in DOM order.
|
|
39
|
+
*/
|
|
40
|
+
function enabledTabs() {
|
|
41
|
+
if (!tablistElement.value) return []
|
|
42
|
+
|
|
43
|
+
return Array.from(
|
|
44
|
+
tablistElement.value.querySelectorAll('[role="tab"]:not([aria-disabled="true"])')
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The tab that currently holds focus. Resolved with closest() because focus can sit on a
|
|
50
|
+
* descendant of the button (an icon, a badge) rather than the button itself.
|
|
51
|
+
*/
|
|
52
|
+
function focusedTabIndex(tabs) {
|
|
53
|
+
const active = document.activeElement
|
|
54
|
+
|
|
55
|
+
if (!active) return -1
|
|
56
|
+
|
|
57
|
+
return tabs.indexOf(active.closest('[role="tab"]'))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function handleKeydown(event) {
|
|
61
|
+
if (!NAVIGATION_KEYS.includes(event.key)) return
|
|
62
|
+
|
|
63
|
+
const tabs = enabledTabs()
|
|
64
|
+
|
|
65
|
+
if (!tabs.length) return
|
|
66
|
+
|
|
67
|
+
const currentIndex = focusedTabIndex(tabs)
|
|
68
|
+
|
|
69
|
+
// Focus sits outside the tablist (or on a disabled tab) — start from the first tab
|
|
70
|
+
// rather than computing an offset from a meaningless position.
|
|
71
|
+
if (currentIndex === -1) {
|
|
72
|
+
event.preventDefault()
|
|
73
|
+
tabs[0].focus()
|
|
74
|
+
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const lastIndex = tabs.length - 1
|
|
79
|
+
let nextIndex = currentIndex
|
|
80
|
+
|
|
81
|
+
if (event.key === 'Home') {
|
|
82
|
+
nextIndex = 0
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (event.key === 'End') {
|
|
86
|
+
nextIndex = lastIndex
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
|
|
90
|
+
nextIndex = currentIndex === lastIndex ? 0 : currentIndex + 1
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
|
|
94
|
+
nextIndex = currentIndex === 0 ? lastIndex : currentIndex - 1
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Claim the key only once a move is actually happening, so an unhandled arrow still
|
|
98
|
+
// scrolls the page as the user expects.
|
|
99
|
+
event.preventDefault()
|
|
100
|
+
tabs[nextIndex].focus()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
tablistElement,
|
|
105
|
+
handleKeydown,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
<script setup>
|
|
2
|
-
defineProps({
|
|
3
|
-
showContent: {
|
|
4
|
-
type: Boolean,
|
|
5
|
-
default: true,
|
|
6
|
-
},
|
|
7
|
-
})
|
|
8
|
-
</script>
|
|
9
|
-
<template>
|
|
10
|
-
<div class="rsui-hero-section">
|
|
11
|
-
<slot name="header"></slot>
|
|
12
|
-
|
|
13
|
-
<div v-if="$slots.content"
|
|
14
|
-
:class="[
|
|
15
|
-
'rsui-hero-section__content',
|
|
16
|
-
{
|
|
17
|
-
'rsui-hero-section__content--hidden': !showContent,
|
|
18
|
-
}
|
|
19
|
-
]"
|
|
20
|
-
>
|
|
21
|
-
<slot name="content"></slot>
|
|
22
|
-
</div>
|
|
23
|
-
|
|
24
|
-
<div v-if="$slots.default"
|
|
25
|
-
:class="[
|
|
26
|
-
'rsui-hero-section__cards',
|
|
27
|
-
{
|
|
28
|
-
'rsui-hero-section__cards--hidden': !showContent,
|
|
29
|
-
}
|
|
30
|
-
]"
|
|
31
|
-
>
|
|
32
|
-
<slot></slot>
|
|
33
|
-
</div>
|
|
34
|
-
</div>
|
|
35
|
-
</template>
|