@redseed/redseed-ui-vue3 10.3.2 → 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.2",
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
+ }
@@ -1,7 +1,8 @@
1
1
  <script setup>
2
- import { ref, computed, watch, watchEffect, useSlots, Comment, Fragment } from 'vue'
2
+ import { ref, computed, watch, watchEffect, useSlots } from 'vue'
3
3
  import Icon from '../Icon/Icon.vue'
4
4
  import { XMarkIcon } from '@heroicons/vue/24/outline'
5
+ import { rendersContent } from '../../helpers/slots'
5
6
 
6
7
  const props = defineProps({
7
8
  closed: {
@@ -81,40 +82,6 @@ watch(() => props.closed, (value) => {
81
82
  isClosed.value = value
82
83
  })
83
84
 
84
- /**
85
- * A Fragment is a wrapper, so its own presence answers nothing — only what is
86
- * inside it does. Hence the recursion: a `v-for` compiles to a Fragment and
87
- * leaves a comment placeholder for each hidden item, so counting children
88
- * reported content for a list whose every action was filtered out (the usual
89
- * shape for permission-gated actions), and a nested `<template>` puts another
90
- * Fragment inside that one.
91
- *
92
- * `Array.isArray` rather than recursing straight into `children`, which is a raw
93
- * string on a Fragment built by hand as `h(Fragment, null, 'text')`. Vue's own
94
- * `ensureValidVNode` assumes the array and throws on that shape, so answering
95
- * "renders" here only hands it a slot it cannot render; treating it as empty
96
- * drops the row instead, which is the quieter of the two failures. Compiled
97
- * templates always produce the array.
98
- */
99
- function rendersNode(node) {
100
- if (node.type === Comment) return false
101
- if (node.type === Fragment) return Array.isArray(node.children) && node.children.some(rendersNode)
102
-
103
- return true
104
- }
105
-
106
- /**
107
- * Whether a slot renders anything at all. Slot *presence* is not the same
108
- * question: a slot holding only a falsy `v-if` is still a truthy slot function
109
- * that renders a comment placeholder, which would put an empty padded row under
110
- * the message.
111
- */
112
- function rendersContent(slot) {
113
- const nodes = slot?.()
114
-
115
- return Boolean(nodes) && nodes.some(rendersNode)
116
- }
117
-
118
85
  /**
119
86
  * Announce the box as a live region so flash / MOTD content reaches assistive
120
87
  * tech (WCAG 4.1.3 Status Messages). `alert` is assertive — reserved for errors
@@ -195,7 +162,15 @@ function close() {
195
162
  text on a coloured tint is a contrast problem, and the action is a way
196
163
  out of the message rather than a restatement of its severity.
197
164
  -->
198
- <div v-if="hasDismiss || rendersContent($slots.actions)" class="rsui-message-box__actions">
165
+ <!--
166
+ The scope is forwarded, and that is the whole of #331. `#actions` is declared
167
+ scoped below — `<slot name="actions" :close="close">` — so a consumer writing
168
+ the documented `<template #actions="{ close }">` compiles to a function that
169
+ destructures its only parameter. Probing it bare passed `undefined` and threw
170
+ `Cannot destructure property 'close' of 'undefined'`, taking the whole
171
+ MessageBox down. Shipped in 8.66.0.
172
+ -->
173
+ <div v-if="hasDismiss || rendersContent($slots.actions, { close })" class="rsui-message-box__actions">
199
174
  <button v-if="hasDismiss"
200
175
  type="button"
201
176
  class="rsui-message-box__dismiss"
@@ -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
+ }
@@ -52,7 +52,15 @@ export function rendersContent(slot, scope) {
52
52
  * Comment vnodes are what a falsy `v-if` leaves behind. Fragments wrap lists and
53
53
  * `<template v-if>` blocks, so a truthy wrapper around a falsy child is a
54
54
  * Fragment whose only child is a Comment — counting length would treat that as
55
- * content.
55
+ * content. A `v-for` over a filtered list is the usual shape, and a nested
56
+ * `<template>` puts another Fragment inside that one.
57
+ *
58
+ * `Array.isArray` rather than recursing straight into `children`, which is a raw
59
+ * string on a Fragment built by hand as `h(Fragment, null, 'text')`. Vue's own
60
+ * `ensureValidVNode` assumes the array and throws on that shape, so answering
61
+ * "renders" there only hands it a slot it cannot render; treating it as empty
62
+ * drops the region instead, which is the quieter of the two failures. Compiled
63
+ * templates always produce the array.
56
64
  */
57
65
  function rendersNode(node) {
58
66
  if (node.type === Comment) return false