@redseed/redseed-ui-vue3 10.3.3 → 10.4.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/CardHeader.vue +35 -0
- package/src/components/Disclosure/Disclosure.vue +60 -22
- package/src/components/Disclosure/DisclosureTrigger.vue +90 -0
- package/src/components/Disclosure/index.js +4 -2
- package/src/components/Section/SectionHeader.vue +58 -1
- package/src/composables/useDisclosureTrigger.js +51 -0
package/package.json
CHANGED
|
@@ -4,6 +4,8 @@ import ButtonTertiary from '../Button/ButtonTertiary.vue'
|
|
|
4
4
|
import Icon from '../Icon/Icon.vue'
|
|
5
5
|
import { rendersContent } from '../../helpers/slots'
|
|
6
6
|
import { useResponsiveWidth } from '../../helpers'
|
|
7
|
+
import DisclosureTrigger from '../Disclosure/DisclosureTrigger.vue'
|
|
8
|
+
import { useDisclosureTrigger } from '../../composables/useDisclosureTrigger'
|
|
7
9
|
import { EllipsisVerticalIcon } from '@heroicons/vue/24/outline'
|
|
8
10
|
|
|
9
11
|
const props = defineProps({
|
|
@@ -11,6 +13,20 @@ const props = defineProps({
|
|
|
11
13
|
type: Boolean,
|
|
12
14
|
default: true,
|
|
13
15
|
},
|
|
16
|
+
/**
|
|
17
|
+
* Render the enclosing Disclosure's trigger in the title row, rather than making
|
|
18
|
+
* the consumer leave an empty `<div :id="triggerId">` for it to be teleported
|
|
19
|
+
* into. Two lms1 call sites put that pad in `#more-actions`, where
|
|
20
|
+
* `:showMoreActions="false"` removes it and takes the trigger with it.
|
|
21
|
+
*
|
|
22
|
+
* Opt-in for the same reason as SectionHeader's: a CardHeader inside an outer
|
|
23
|
+
* Disclosure's content is still a descendant, and every call site that keeps its
|
|
24
|
+
* own pad would otherwise render two triggers. See #407.
|
|
25
|
+
*/
|
|
26
|
+
collapsible: {
|
|
27
|
+
type: Boolean,
|
|
28
|
+
default: false,
|
|
29
|
+
},
|
|
14
30
|
showBadge: {
|
|
15
31
|
type: Boolean,
|
|
16
32
|
default: true,
|
|
@@ -121,6 +137,9 @@ const emit = defineEmits(['click:more-actions'])
|
|
|
121
137
|
*/
|
|
122
138
|
const cardHasBody = inject('rsuiCardHasBody', null)
|
|
123
139
|
|
|
140
|
+
// Claims the enclosing Disclosure's trigger so it does not also render its own.
|
|
141
|
+
const { rendersTrigger } = useDisclosureTrigger(() => props.collapsible)
|
|
142
|
+
|
|
124
143
|
const isHeaderOnly = computed(() => {
|
|
125
144
|
if (headerOnlyCard.value !== null) return headerOnlyCard.value
|
|
126
145
|
if (cardHasBody) return !cardHasBody.value
|
|
@@ -182,10 +201,26 @@ function handleMoreActionsClick() {
|
|
|
182
201
|
|
|
183
202
|
<!-- Title slot, default slot -->
|
|
184
203
|
<div class="rsui-card-header__title-container">
|
|
204
|
+
<!--
|
|
205
|
+
A SIBLING of the title, not a child of it. Unlike SectionHeader's
|
|
206
|
+
`__title` — a flex row holding a separate `__title-text` — this one
|
|
207
|
+
carries the line clamp itself (`--single-line` is `display:
|
|
208
|
+
-webkit-box` with `-webkit-line-clamp`). A control inside a clamped
|
|
209
|
+
box lays out as a block above the words, and counts against the clamp.
|
|
210
|
+
-->
|
|
211
|
+
<div v-if="rendersTrigger()"
|
|
212
|
+
class="rsui-card-header__trigger"
|
|
213
|
+
>
|
|
214
|
+
<DisclosureTrigger sm />
|
|
215
|
+
</div>
|
|
185
216
|
<div :class="[
|
|
186
217
|
'rsui-card-header__title',
|
|
187
218
|
{
|
|
188
219
|
'rsui-card-header__title--single-line': singleLine,
|
|
220
|
+
// __title is w-full, which in this wrapping container would push
|
|
221
|
+
// it onto the line below the trigger. It gives that up only when
|
|
222
|
+
// it is no longer alone on the row.
|
|
223
|
+
'rsui-card-header__title--with-trigger': rendersTrigger(),
|
|
189
224
|
}
|
|
190
225
|
]">
|
|
191
226
|
<slot></slot>
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { ref, shallowRef, watch, watchEffect, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
|
3
|
-
import
|
|
4
|
-
import { ButtonTertiary } from '../Button'
|
|
5
|
-
import Icon from '../Icon/Icon.vue'
|
|
2
|
+
import { ref, shallowRef, computed, watch, watchEffect, nextTick, onMounted, onBeforeUnmount, provide, toRef } from 'vue'
|
|
3
|
+
import DisclosureTrigger from './DisclosureTrigger.vue'
|
|
6
4
|
|
|
7
5
|
defineOptions({
|
|
8
6
|
inheritAttrs: false,
|
|
@@ -43,6 +41,55 @@ function handleTrigger() {
|
|
|
43
41
|
emit('click', { open: isOpen.value })
|
|
44
42
|
}
|
|
45
43
|
|
|
44
|
+
/**
|
|
45
|
+
* How a descendant renders its own trigger without a teleport target.
|
|
46
|
+
*
|
|
47
|
+
* `SectionHeader` and `CardHeader` take a `collapsible` prop and render a
|
|
48
|
+
* `DisclosureTrigger` from this, which is what lets the chevron sit in a title
|
|
49
|
+
* row at every width instead of being posted into an empty div the consumer had
|
|
50
|
+
* to place — and hide, or conditionally render, or wrap in utility classes to
|
|
51
|
+
* position. See #407; #408 removes the teleports once no consumer places a pad.
|
|
52
|
+
*
|
|
53
|
+
* Same shape as `Card` providing `rsuiCardHasBody` to `CardHeader`, including
|
|
54
|
+
* through a slot boundary: slot content is compiled in the parent's scope but
|
|
55
|
+
* mounted with this component as its parent instance, so inject resolves here.
|
|
56
|
+
*
|
|
57
|
+
* Refs rather than values, or a descendant would read the props once and never
|
|
58
|
+
* see `triggerLabel` change.
|
|
59
|
+
*/
|
|
60
|
+
/*
|
|
61
|
+
* How many descendants are rendering this disclosure's trigger themselves.
|
|
62
|
+
*
|
|
63
|
+
* A count rather than a flag: two headers inside one Disclosure is a consumer
|
|
64
|
+
* mistake, but it should not leave the count stuck claimed when only one of them
|
|
65
|
+
* unmounts.
|
|
66
|
+
*/
|
|
67
|
+
const descendantTriggers = ref(0)
|
|
68
|
+
|
|
69
|
+
function claimTrigger() {
|
|
70
|
+
descendantTriggers.value++
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function releaseTrigger() {
|
|
74
|
+
descendantTriggers.value--
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Whether THIS component still renders a trigger. It stands down entirely while a
|
|
78
|
+
// descendant is rendering one — including when a teleport target exists, so a
|
|
79
|
+
// consumer adopting `collapsible` without deleting their old pad gets one trigger
|
|
80
|
+
// rather than two.
|
|
81
|
+
const rendersOwnTrigger = computed(() => descendantTriggers.value === 0)
|
|
82
|
+
|
|
83
|
+
provide('rsuiDisclosure', {
|
|
84
|
+
isOpen,
|
|
85
|
+
handleTrigger,
|
|
86
|
+
contentId,
|
|
87
|
+
triggerLabel: toRef(props, 'triggerLabel'),
|
|
88
|
+
iconProps: toRef(props, 'iconProps'),
|
|
89
|
+
claimTrigger,
|
|
90
|
+
releaseTrigger,
|
|
91
|
+
})
|
|
92
|
+
|
|
46
93
|
const contentRef = ref(null)
|
|
47
94
|
|
|
48
95
|
async function setContentMaxHeight() {
|
|
@@ -161,7 +208,8 @@ onBeforeUnmount(() => {
|
|
|
161
208
|
Vue's own `disabled` keeps this as one definition rendered in one of two
|
|
162
209
|
places, rather than two copies that can drift.
|
|
163
210
|
-->
|
|
164
|
-
<Teleport
|
|
211
|
+
<Teleport v-if="rendersOwnTrigger"
|
|
212
|
+
:to="triggerTarget"
|
|
165
213
|
:disabled="!triggerTarget"
|
|
166
214
|
>
|
|
167
215
|
<slot name="trigger"
|
|
@@ -169,29 +217,19 @@ onBeforeUnmount(() => {
|
|
|
169
217
|
:isOpen="isOpen"
|
|
170
218
|
:contentId="contentId"
|
|
171
219
|
>
|
|
172
|
-
|
|
220
|
+
<!--
|
|
221
|
+
The markup itself moved to DisclosureTrigger so a header can render
|
|
222
|
+
the same button in its own title row. Rendered here it is unchanged:
|
|
223
|
+
ButtonSlot treats an unset size prop and a false one alike.
|
|
224
|
+
-->
|
|
225
|
+
<DisclosureTrigger
|
|
173
226
|
:sm="$attrs.sm"
|
|
174
227
|
:md="$attrs.md"
|
|
175
228
|
:lg="$attrs.lg"
|
|
176
229
|
:xl="$attrs.xl"
|
|
177
230
|
:2xl="$attrs['2xl']"
|
|
178
231
|
:full="$attrs.full"
|
|
179
|
-
|
|
180
|
-
:aria-expanded="isOpen"
|
|
181
|
-
:aria-controls="contentId"
|
|
182
|
-
@click.stop="handleTrigger"
|
|
183
|
-
>
|
|
184
|
-
<Icon v-bind="iconProps">
|
|
185
|
-
<ChevronDownIcon
|
|
186
|
-
:class="[
|
|
187
|
-
'rsui-disclosure__trigger-icon',
|
|
188
|
-
{
|
|
189
|
-
'rsui-disclosure__trigger-icon--open': isOpen,
|
|
190
|
-
}
|
|
191
|
-
]"
|
|
192
|
-
></ChevronDownIcon>
|
|
193
|
-
</Icon>
|
|
194
|
-
</ButtonTertiary>
|
|
232
|
+
/>
|
|
195
233
|
</slot>
|
|
196
234
|
</Teleport>
|
|
197
235
|
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { inject } from 'vue'
|
|
3
|
+
import { ChevronDownIcon } from '@heroicons/vue/24/outline'
|
|
4
|
+
import ButtonTertiary from '../Button/ButtonTertiary.vue'
|
|
5
|
+
import Icon from '../Icon/Icon.vue'
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* The disclosure's chevron button, in one place.
|
|
9
|
+
*
|
|
10
|
+
* It used to live inline as `Disclosure`'s default `#trigger` content, which was
|
|
11
|
+
* fine while the Teleport was the only way to get it into a consumer's header.
|
|
12
|
+
* Now that a header can render its own (see `SectionHeader`'s `collapsible`),
|
|
13
|
+
* that inline definition would have had to be duplicated — and a duplicate is a
|
|
14
|
+
* drift waiting to happen, across `aria-expanded`, `aria-controls`, the label and
|
|
15
|
+
* the rotation class.
|
|
16
|
+
*
|
|
17
|
+
* So it MOVED here rather than being copied. `Disclosure` renders this as its own
|
|
18
|
+
* default, a header renders this, and a consumer placing one by hand renders this.
|
|
19
|
+
* One definition, one appearance, nothing to keep in sync. See #407.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// Size passthrough, matching what Disclosure forwarded from its attrs. ButtonSlot
|
|
23
|
+
// declares these as Boolean/false, so an unset prop and a false one are the same
|
|
24
|
+
// button — the move does not change Disclosure's own rendering.
|
|
25
|
+
defineProps({
|
|
26
|
+
sm: {
|
|
27
|
+
type: Boolean,
|
|
28
|
+
default: false,
|
|
29
|
+
},
|
|
30
|
+
md: {
|
|
31
|
+
type: Boolean,
|
|
32
|
+
default: false,
|
|
33
|
+
},
|
|
34
|
+
lg: {
|
|
35
|
+
type: Boolean,
|
|
36
|
+
default: false,
|
|
37
|
+
},
|
|
38
|
+
xl: {
|
|
39
|
+
type: Boolean,
|
|
40
|
+
default: false,
|
|
41
|
+
},
|
|
42
|
+
'2xl': {
|
|
43
|
+
type: Boolean,
|
|
44
|
+
default: false,
|
|
45
|
+
},
|
|
46
|
+
full: {
|
|
47
|
+
type: Boolean,
|
|
48
|
+
default: false,
|
|
49
|
+
},
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `null` when there is no enclosing Disclosure, and then nothing renders — the
|
|
54
|
+
* same "no opinion, fall through quietly" shape `CardHeader` uses for
|
|
55
|
+
* `rsuiCardHasBody`. A trigger with nothing to toggle is worse than no trigger:
|
|
56
|
+
* it is a control that looks operable and answers to nothing.
|
|
57
|
+
*/
|
|
58
|
+
const disclosure = inject('rsuiDisclosure', null)
|
|
59
|
+
</script>
|
|
60
|
+
<template>
|
|
61
|
+
<ButtonTertiary v-if="disclosure"
|
|
62
|
+
:sm="sm"
|
|
63
|
+
:md="md"
|
|
64
|
+
:lg="lg"
|
|
65
|
+
:xl="xl"
|
|
66
|
+
:2xl="$props['2xl']"
|
|
67
|
+
:full="full"
|
|
68
|
+
:aria-label="disclosure.triggerLabel.value || undefined"
|
|
69
|
+
:aria-expanded="disclosure.isOpen.value"
|
|
70
|
+
:aria-controls="disclosure.contentId"
|
|
71
|
+
@click.stop="disclosure.handleTrigger"
|
|
72
|
+
>
|
|
73
|
+
<!--
|
|
74
|
+
`.stop` above is load-bearing rather than tidiness. A header that is itself
|
|
75
|
+
a control — `SectionHeader` with an `@click`, which is how a whole group
|
|
76
|
+
collapses on tap — would otherwise see this click bubble and toggle a
|
|
77
|
+
second time, landing back where it started. Pinned by a test.
|
|
78
|
+
-->
|
|
79
|
+
<Icon v-bind="disclosure.iconProps.value">
|
|
80
|
+
<ChevronDownIcon
|
|
81
|
+
:class="[
|
|
82
|
+
'rsui-disclosure__trigger-icon',
|
|
83
|
+
{
|
|
84
|
+
'rsui-disclosure__trigger-icon--open': disclosure.isOpen.value,
|
|
85
|
+
}
|
|
86
|
+
]"
|
|
87
|
+
></ChevronDownIcon>
|
|
88
|
+
</Icon>
|
|
89
|
+
</ButtonTertiary>
|
|
90
|
+
</template>
|
|
@@ -4,6 +4,8 @@ import { rendersContent } from '../../helpers/slots'
|
|
|
4
4
|
import ButtonTertiary from '../Button/ButtonTertiary.vue'
|
|
5
5
|
import Icon from '../Icon/Icon.vue'
|
|
6
6
|
import { useResponsiveWidth } from '../../helpers'
|
|
7
|
+
import DisclosureTrigger from '../Disclosure/DisclosureTrigger.vue'
|
|
8
|
+
import { useDisclosureTrigger } from '../../composables/useDisclosureTrigger'
|
|
7
9
|
import { EllipsisVerticalIcon } from '@heroicons/vue/24/outline'
|
|
8
10
|
|
|
9
11
|
const props = defineProps({
|
|
@@ -46,6 +48,46 @@ const props = defineProps({
|
|
|
46
48
|
type: Boolean,
|
|
47
49
|
default: false,
|
|
48
50
|
},
|
|
51
|
+
/**
|
|
52
|
+
* Render the enclosing Disclosure's trigger in the title row.
|
|
53
|
+
*
|
|
54
|
+
* Consumers used to leave an empty `<div :id="triggerId">` for the trigger to be
|
|
55
|
+
* teleported into, and had to choose a slot to leave it in. Every choice was wrong
|
|
56
|
+
* somewhere: `#icon` is width-gated so the control vanished below 640px (#372);
|
|
57
|
+
* `#more-actions` is killed by `:showMoreActions="false"`, which threw
|
|
58
|
+
* `insertBefore` of null; `#actions` renders at every width but puts the chevron at
|
|
59
|
+
* the far end of the header, away from the title it collapses.
|
|
60
|
+
*
|
|
61
|
+
* OPT-IN, and that is load-bearing rather than politeness. A SectionHeader sitting
|
|
62
|
+
* in an outer Disclosure's content is still a descendant in the component tree, so
|
|
63
|
+
* rendering automatically would put chevrons on unrelated headers; and every call
|
|
64
|
+
* site that still places its own pad would get two triggers on upgrade. Default
|
|
65
|
+
* false means upgrading changes nothing until a header asks.
|
|
66
|
+
*
|
|
67
|
+
* Outside a Disclosure this renders nothing rather than warning — there is simply
|
|
68
|
+
* no disclosure to drive. See #407.
|
|
69
|
+
*/
|
|
70
|
+
collapsible: {
|
|
71
|
+
type: Boolean,
|
|
72
|
+
default: false,
|
|
73
|
+
},
|
|
74
|
+
/**
|
|
75
|
+
* Keep the badge on the title line on a narrow header.
|
|
76
|
+
*
|
|
77
|
+
* `--stacked` puts `basis-full` on the title text, so below 640px the badge does
|
|
78
|
+
* not wrap when it runs out of room — it ALWAYS starts a new line. A group header
|
|
79
|
+
* carrying a count then stacks three rows above the first card, which is the second
|
|
80
|
+
* of the two reasons TeamCoachingIndex gives for hand-placing its badge inside the
|
|
81
|
+
* title rather than using `#badge` at all.
|
|
82
|
+
*
|
|
83
|
+
* Separate from `collapsible` on purpose: a collapsible section does not necessarily
|
|
84
|
+
* want an inline badge, and a badge-carrying section need not collapse. Default
|
|
85
|
+
* false, so no existing header moves. See #407.
|
|
86
|
+
*/
|
|
87
|
+
inlineBadge: {
|
|
88
|
+
type: Boolean,
|
|
89
|
+
default: false,
|
|
90
|
+
},
|
|
49
91
|
/**
|
|
50
92
|
* Heading level (1-6) for the title. When set, the title renders as a real
|
|
51
93
|
* <h1>-<h6> so consuming pages get a correct document outline (WCAG 1.3.1,
|
|
@@ -111,6 +153,9 @@ function handleKeydown(event) {
|
|
|
111
153
|
|
|
112
154
|
const slots = useSlots()
|
|
113
155
|
|
|
156
|
+
// Claims the enclosing Disclosure's trigger so it does not also render its own.
|
|
157
|
+
const { rendersTrigger } = useDisclosureTrigger(() => props.collapsible)
|
|
158
|
+
|
|
114
159
|
/**
|
|
115
160
|
* Whether the overflow region renders. The prop wins when set; otherwise the slot
|
|
116
161
|
* decides by whether it actually produces anything.
|
|
@@ -192,8 +237,20 @@ const titleTag = computed(() => HEADING_LEVELS.includes(String(props.headingLeve
|
|
|
192
237
|
<!-- Title slot, default slot -->
|
|
193
238
|
<div :class="[
|
|
194
239
|
'rsui-section-header__title',
|
|
195
|
-
{ 'rsui-section-header__title--stacked': !responsiveWidth.specific },
|
|
240
|
+
{ 'rsui-section-header__title--stacked': !responsiveWidth.specific && !inlineBadge },
|
|
196
241
|
]">
|
|
242
|
+
<!--
|
|
243
|
+
First child of the TITLE row, not a sibling of the whole text block
|
|
244
|
+
the way `#icon` is: `__icon` centres against title AND subtitle
|
|
245
|
+
together, so with a subtitle present the chevron drifts off the line
|
|
246
|
+
it belongs to. `sm` matches the ACTION SIZE guidance in
|
|
247
|
+
section_header.css.
|
|
248
|
+
-->
|
|
249
|
+
<div v-if="rendersTrigger()"
|
|
250
|
+
class="rsui-section-header__trigger"
|
|
251
|
+
>
|
|
252
|
+
<DisclosureTrigger sm />
|
|
253
|
+
</div>
|
|
197
254
|
<component :is="titleTag"
|
|
198
255
|
:class="[
|
|
199
256
|
'rsui-section-header__title-text',
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { inject, watch, onBeforeUnmount } from 'vue'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lets a header render the enclosing Disclosure's trigger in its own markup.
|
|
5
|
+
*
|
|
6
|
+
* The claim is the point. `Disclosure` renders its trigger in place when it finds
|
|
7
|
+
* no teleport target (#406), which is the right fallback when nobody else is
|
|
8
|
+
* rendering one — but a header that renders its own would otherwise leave you with
|
|
9
|
+
* two, its own in the title row and Disclosure's falling out below the card. Both
|
|
10
|
+
* wired to the same disclosure, so both work, which is what makes it easy to miss.
|
|
11
|
+
*
|
|
12
|
+
* So a header claims while it is rendering a trigger, and `Disclosure` stands down
|
|
13
|
+
* for as long as the claim is held. A claim beats a teleport target too: a consumer
|
|
14
|
+
* that adopts `collapsible` without deleting their old `<div :id="triggerId">` gets
|
|
15
|
+
* one trigger in the title row rather than two in different places.
|
|
16
|
+
*
|
|
17
|
+
* Shared by SectionHeader and CardHeader rather than written twice — the release
|
|
18
|
+
* paths (prop flipped off, header unmounted) are exactly where a duplicate would
|
|
19
|
+
* drift. See #407; #408 deletes the teleport and with it the need to claim at all.
|
|
20
|
+
*/
|
|
21
|
+
export function useDisclosureTrigger(wantsTrigger) {
|
|
22
|
+
// `null` outside a Disclosure, the same fallback CardHeader uses for
|
|
23
|
+
// rsuiCardHasBody. `collapsible` then renders nothing: a trigger with nothing to
|
|
24
|
+
// toggle is worse than no trigger, since it looks operable and answers to nothing.
|
|
25
|
+
const disclosure = inject('rsuiDisclosure', null)
|
|
26
|
+
|
|
27
|
+
// Tracked here rather than read back off the context, so a release can never be
|
|
28
|
+
// paired with a claim this header did not make.
|
|
29
|
+
let hasClaimed = false
|
|
30
|
+
|
|
31
|
+
function syncClaim(claims) {
|
|
32
|
+
if (!disclosure?.claimTrigger) return
|
|
33
|
+
if (claims === hasClaimed) return
|
|
34
|
+
|
|
35
|
+
hasClaimed = claims
|
|
36
|
+
|
|
37
|
+
if (claims) disclosure.claimTrigger()
|
|
38
|
+
if (!claims) disclosure.releaseTrigger()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const rendersTrigger = () => Boolean(wantsTrigger() && disclosure)
|
|
42
|
+
|
|
43
|
+
watch(rendersTrigger, syncClaim, { immediate: true })
|
|
44
|
+
|
|
45
|
+
onBeforeUnmount(() => syncClaim(false))
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
disclosure,
|
|
49
|
+
rendersTrigger,
|
|
50
|
+
}
|
|
51
|
+
}
|