@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redseed/redseed-ui-vue3",
3
- "version": "10.3.3",
3
+ "version": "10.4.0",
4
4
  "description": "RedSeed UI Vue 3 components",
5
5
  "main": "index.js",
6
6
  "repository": "https://github.com/redseedtraining/redseed-ui",
@@ -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 { ChevronDownIcon } from '@heroicons/vue/24/outline'
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 :to="triggerTarget"
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
- <ButtonTertiary
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
- :aria-label="triggerLabel || undefined"
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>
@@ -1,6 +1,8 @@
1
1
 
2
2
  import Disclosure from './Disclosure.vue'
3
+ import DisclosureTrigger from './DisclosureTrigger.vue'
3
4
 
4
5
  export {
5
- Disclosure
6
- }
6
+ Disclosure,
7
+ DisclosureTrigger
8
+ }
@@ -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
+ }