@redseed/redseed-ui-vue3 11.0.1 → 11.0.2

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": "11.0.1",
3
+ "version": "11.0.2",
4
4
  "description": "RedSeed UI Vue 3 components",
5
5
  "main": "index.js",
6
6
  "repository": "https://github.com/redseedtraining/redseed-ui",
@@ -2,10 +2,11 @@
2
2
  import { computed, inject, ref, toRefs, useAttrs, useSlots } from 'vue'
3
3
  import ButtonTertiary from '../Button/ButtonTertiary.vue'
4
4
  import Icon from '../Icon/Icon.vue'
5
- import { rendersContent } from '../../helpers/slots'
5
+ import { rendersContent, countsRenderedNodes } from '../../helpers/slots'
6
6
  import { useResponsiveWidth } from '../../helpers'
7
7
  import DisclosureTrigger from '../Disclosure/DisclosureTrigger.vue'
8
8
  import { useDisclosureTrigger } from '../../composables/useDisclosureTrigger'
9
+ import { useColumnOnAWiderPage } from '../../composables/useColumnOnAWiderPage'
9
10
  import { EllipsisVerticalIcon } from '@heroicons/vue/24/outline'
10
11
 
11
12
  const props = defineProps({
@@ -126,7 +127,38 @@ const isClickable = computed(() => !!attrs.onClick)
126
127
 
127
128
  const cardHeaderElement = ref(null)
128
129
 
129
- const { responsiveWidth } = useResponsiveWidth(cardHeaderElement, 640)
130
+ const { responsiveWidth, width: cardHeaderWidth } = useResponsiveWidth(cardHeaderElement, 640)
131
+
132
+ /*
133
+ * Whether this card is a column on a wider page rather than the page itself — a 425px card
134
+ * in a two-up grid on a 1000px page is a column, and a 425px card on a phone is not. Same
135
+ * rule and same reasoning as SectionHeader; see useColumnOnAWiderPage.
136
+ *
137
+ * 11 lms1 CardHeaders pass `#actions`.
138
+ */
139
+ const isColumnOnAWiderPage = useColumnOnAWiderPage(cardHeaderWidth)
140
+
141
+ /**
142
+ * The page rule applies to a SINGLE action only.
143
+ *
144
+ * One "View all" beside a wrapped title reads better in a narrow column than the same link
145
+ * dropped into a block of its own, and that is what the slot holds in practice — 12 of the
146
+ * 17 lms1 headers using `#actions` hold exactly that.
147
+ *
148
+ * Two controls is a different picture, and asserting otherwise put the #420 squeeze back:
149
+ * in a 400px card the toolbar took 244px of a 398px header and left the title 48px. So the
150
+ * count decides rather than an assumption about what consumers pass, and a header with a
151
+ * real toolbar falls back to its own width the way it always did.
152
+ *
153
+ * A plain function, not a computed: `useSlots()` is not reactive, so a computed over it
154
+ * caches its first answer and a conditionally-rendered action would never be counted. The
155
+ * refs read below are still tracked, because the render effect is what calls this.
156
+ */
157
+ function keepsActionsInline() {
158
+ if (responsiveWidth.value.specific) return true
159
+
160
+ return isColumnOnAWiderPage.value && countsRenderedNodes(slots.actions) === 1
161
+ }
130
162
 
131
163
  const emit = defineEmits(['click:more-actions'])
132
164
 
@@ -247,7 +279,7 @@ function handleMoreActionsClick() {
247
279
  >
248
280
  <!-- Desktop actions slot, optional -->
249
281
  <div class="rsui-card-header__actions-desktop"
250
- v-if="responsiveWidth.specific && $slots.actions"
282
+ v-if="keepsActionsInline() && $slots.actions"
251
283
  >
252
284
  <slot name="actions"></slot>
253
285
  </div>
@@ -282,7 +314,7 @@ function handleMoreActionsClick() {
282
314
 
283
315
  <!-- Mobile actions slot, optional -->
284
316
  <div class="rsui-card-header__actions-mobile"
285
- v-if="showActions && !responsiveWidth.specific && $slots.actions"
317
+ v-if="showActions && !keepsActionsInline() && $slots.actions"
286
318
  >
287
319
  <slot name="actions"></slot>
288
320
  </div>
@@ -1,7 +1,4 @@
1
1
  <script setup>
2
- import { computed, provide, ref } from 'vue'
3
- import { useResponsiveWidth } from '../../helpers'
4
-
5
2
  const props = defineProps({
6
3
  topAside: {
7
4
  type: Boolean,
@@ -17,41 +14,9 @@ const props = defineProps({
17
14
  default: false,
18
15
  },
19
16
  })
20
-
21
- const layoutElement = ref(null)
22
-
23
- /**
24
- * 896, because that is `@4xl` — the container query in `two_column_layout.css` that puts
25
- * the columns side by side. Measured here as well as queried there so components INSIDE a
26
- * column can ask the same question, which CSS alone cannot tell them.
27
- *
28
- * Kept honest by a test asserting the stylesheet still breaks at `@4xl`. Two numbers that
29
- * must agree is a drift risk; an unasserted one is a certainty.
30
- */
31
- const SIDE_BY_SIDE_WIDTH = 896
32
-
33
- const { responsiveWidth } = useResponsiveWidth(layoutElement, SIDE_BY_SIDE_WIDTH)
34
-
35
- /**
36
- * Whether the layout is showing two columns right now.
37
- *
38
- * Provided because a descendant cannot work this out for itself. `SectionHeader` needs it:
39
- * where its actions go depends on whether it is a COLUMN on a page or the page itself, and
40
- * a 330px aside and a 330px phone are the same measurement with opposite answers. Its own
41
- * container cannot tell them apart, and neither can a viewport breakpoint — this collapses
42
- * on its own width, so the viewport it collapses at moves with whatever chrome is around
43
- * it. In Storybook that is 32px of padding and the columns hold down to a 928px viewport.
44
- *
45
- * A ref rather than a value, through a slot boundary, same as `Card` provides
46
- * `rsuiCardHasBody` — slot content is compiled in the parent's scope but mounted with this
47
- * component as its parent instance, so inject resolves here.
48
- */
49
- provide('rsuiTwoColumnLayout', {
50
- isSideBySide: computed(() => responsiveWidth.value.specific),
51
- })
52
17
  </script>
53
18
  <template>
54
- <div ref="layoutElement" class="rsui-two-column-layout">
19
+ <div class="rsui-two-column-layout">
55
20
  <div
56
21
  :class="[
57
22
  'rsui-two-column-layout__container',
@@ -1,11 +1,12 @@
1
1
  <script setup>
2
- import { ref, computed, inject, useAttrs, useSlots } from 'vue'
3
- import { rendersContent } from '../../helpers/slots'
2
+ import { ref, computed, useAttrs, useSlots } from 'vue'
3
+ import { rendersContent, countsRenderedNodes } 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
7
  import DisclosureTrigger from '../Disclosure/DisclosureTrigger.vue'
8
8
  import { useDisclosureTrigger } from '../../composables/useDisclosureTrigger'
9
+ import { useColumnOnAWiderPage } from '../../composables/useColumnOnAWiderPage'
9
10
  import { EllipsisVerticalIcon } from '@heroicons/vue/24/outline'
10
11
 
11
12
  const props = defineProps({
@@ -123,44 +124,36 @@ const props = defineProps({
123
124
 
124
125
  const sectionHeaderElement = ref(null)
125
126
 
126
- const { responsiveWidth } = useResponsiveWidth(sectionHeaderElement, 640)
127
+ const { responsiveWidth, width: headerWidth } = useResponsiveWidth(sectionHeaderElement, 640)
128
+
129
+ /*
130
+ * Where the actions go is a question about the PAGE; how much room the title has is a
131
+ * question about this column. See useColumnOnAWiderPage for why the two cannot share a
132
+ * measurement, and for the three answers that tried.
133
+ */
134
+ const isColumnOnAWiderPage = useColumnOnAWiderPage(headerWidth)
127
135
 
128
136
  /**
129
- * Whether the enclosing `TwoColumnLayout` is currently showing two columns, or null when
130
- * there is no layout around this header.
137
+ * The page rule applies to a SINGLE action only.
131
138
  *
132
- * This is the question `responsiveWidth` cannot answer. The container says how much room
133
- * the header has — truncation, whether a badge fits — and that is the right question for
134
- * everything inside it. Where the ACTIONS go is a different question: a 330px aside on a
135
- * desktop wants its single "View all" beside the title, and the same 330px on a phone
136
- * wants it underneath. Identical container, opposite answers, so the container cannot
137
- * decide it.
139
+ * One "View all" beside a wrapped title reads better in a narrow column than the same link
140
+ * dropped into a block of its own, and that is what the slot holds in practice — 12 of the
141
+ * 17 lms1 headers using `#actions` hold exactly that.
138
142
  *
139
- * A viewport breakpoint cannot decide it either, which is what the first cut of this got
140
- * wrong. `TwoColumnLayout` collapses on a CONTAINER query — 896px of its own width — so
141
- * the viewport it collapses at moves with whatever chrome surrounds it. In Storybook that
142
- * is 32px of padding and the columns hold down to a 928px viewport, and the 64rem constant
143
- * stacked the actions across the whole 928–1023 band while the aside was still an aside.
144
- * More page furniture moves the boundary the other way. No constant fits.
143
+ * Two controls is a different picture, and asserting otherwise put the #420 squeeze back:
144
+ * in a 400px card the toolbar took 244px of a 398px header and left the title 48px. So the
145
+ * count decides rather than an assumption about what consumers pass, and a header with a
146
+ * real toolbar falls back to its own width the way it always did.
145
147
  *
146
- * So the header rides on the layout's own answer rather than approximating it, and the
147
- * two can never disagree.
148
+ * A plain function, not a computed: `useSlots()` is not reactive, so a computed over it
149
+ * caches its first answer and a conditionally-rendered action would never be counted. The
150
+ * refs read below are still tracked, because the render effect is what calls this.
148
151
  */
149
- const twoColumnLayout = inject('rsuiTwoColumnLayout', null)
150
-
151
- const isColumnBesideAnother = computed(() => twoColumnLayout?.isSideBySide.value ?? false)
152
+ function keepsActionsInline() {
153
+ if (responsiveWidth.value.specific) return true
152
154
 
153
- /**
154
- * The actions stay in the header row while this header is one of two columns, however
155
- * narrow the column is. A single "View all" beside a wrapped title reads better in a 330px
156
- * aside than the same link dropped into a left-aligned block below it — and a view-all is
157
- * the only thing in this slot in practice: of 17 lms1 headers using `#actions`, 12 hold
158
- * exactly that and none renders two controls at once.
159
- *
160
- * Once the layout collapses, every column is the full width of the page and the container
161
- * measurement answers it correctly on its own, which is what it did before any of this.
162
- */
163
- const keepsActionsInline = computed(() => responsiveWidth.value.specific || isColumnBesideAnother.value)
155
+ return isColumnOnAWiderPage.value && countsRenderedNodes(slots.actions) === 1
156
+ }
164
157
 
165
158
  const emit = defineEmits(['click:more-actions'])
166
159
 
@@ -262,7 +255,7 @@ const titleTag = computed(() => HEADING_LEVELS.includes(String(props.headingLeve
262
255
  // It used to come from `md:` — a viewport query at 768 — while the icon
263
256
  // gate, the actions swap and the badge stacking all read the container at
264
257
  // 640, so a narrow column on a wide screen got both layouts at once. #420.
265
- 'rsui-section-header--narrow': !keepsActionsInline,
258
+ 'rsui-section-header--narrow': !keepsActionsInline(),
266
259
  // Container only, deliberately NOT the one above. Where the actions go is a
267
260
  // question about the page; whether the title has room is a question about this
268
261
  // column, and the two want different answers in an aside on a wide screen.
@@ -341,7 +334,7 @@ const titleTag = computed(() => HEADING_LEVELS.includes(String(props.headingLeve
341
334
  >
342
335
  <!-- Desktop actions slot, optional -->
343
336
  <div class="rsui-section-header__actions-desktop"
344
- v-if="keepsActionsInline && $slots.actions"
337
+ v-if="keepsActionsInline() && $slots.actions"
345
338
  >
346
339
  <slot name="actions"></slot>
347
340
  </div>
@@ -370,7 +363,7 @@ const titleTag = computed(() => HEADING_LEVELS.includes(String(props.headingLeve
370
363
 
371
364
  <!-- Mobile actions slot, optional -->
372
365
  <div class="rsui-section-header__actions-mobile"
373
- v-if="showMobileActions && !keepsActionsInline && $slots.actions"
366
+ v-if="showMobileActions && !keepsActionsInline() && $slots.actions"
374
367
  >
375
368
  <slot name="actions"></slot>
376
369
  </div>
@@ -0,0 +1,61 @@
1
+ import { computed } from 'vue'
2
+ import { useWindowSize } from '@vueuse/core'
3
+
4
+ /*
5
+ * A phone's worth of page beside it — enough room for a column this element is not in.
6
+ *
7
+ * Real gaps are nowhere near the threshold, in either direction: a two-up grid column on
8
+ * the LMS team overview leaves ~575px, a `TwoColumnLayout` aside 656px at the viewport
9
+ * where it used to break, and a full-bleed card on a phone leaves its page padding, 32px.
10
+ */
11
+ const MIN_PAGE_BESIDE_A_COLUMN = 320
12
+
13
+ /**
14
+ * Whether this element is a column on a wider page, rather than the page itself.
15
+ *
16
+ * This is the question a container measurement cannot answer. The container says how much
17
+ * room an element has — truncation, whether a badge fits — and that is the right question
18
+ * for everything inside it. Where a single ACTION goes is a different question: a 330px
19
+ * aside on a desktop wants its "View all" beside the title, and the same 330px on a phone
20
+ * wants it underneath. Identical container, opposite answers.
21
+ *
22
+ * Three earlier answers, each instructive.
23
+ *
24
+ * `md:flex-row` — a viewport query at 768 while the rest of the header read the container
25
+ * at 640, so a narrow column on a wide screen took the mobile contents and the desktop
26
+ * direction at once and the title was squeezed to 29px. That was #420.
27
+ *
28
+ * A 64rem viewport constant, chosen to match where `TwoColumnLayout` collapses. It
29
+ * collapses on a CONTAINER query at 896px of its own width, so the viewport that happens
30
+ * at moves with whatever chrome surrounds it — 928px in Storybook — and the actions
31
+ * stacked across the whole band in between while the aside was still an aside.
32
+ *
33
+ * Asking `TwoColumnLayout` itself, which was exact and almost never there: of 65 lms1
34
+ * files using `SectionHeader`, 6 are inside one. Six more use a `GridContainer`, one a
35
+ * `SidebarLayout`, and ~52 build their columns from a raw Tailwind grid or a page layout,
36
+ * which have nothing to ask. One LMS page uses both kinds at once.
37
+ *
38
+ * Measuring against the page needs no cooperation from whatever made the column, and
39
+ * cannot drift from it either.
40
+ *
41
+ * Takes the width rather than the element: both callers already measure themselves through
42
+ * `useResponsiveWidth`, and observing the same element twice would mean a second
43
+ * ResizeObserver per header for an answer the first one already has.
44
+ *
45
+ * @param {import('vue').Ref<number>} elementWidth — a live width, 0 until measured.
46
+ */
47
+ export function useColumnOnAWiderPage(elementWidth) {
48
+ const { width: pageWidth } = useWindowSize()
49
+
50
+ const pageBesideElement = computed(() => pageWidth.value - elementWidth.value)
51
+
52
+ /*
53
+ * `useElementBounding` reports 0 until the element is measured, and 0 against any page
54
+ * width looks like the whole page is beside it. Unmeasured is not wide, so it has to be
55
+ * excluded explicitly — otherwise every header renders inline for a frame and settles
56
+ * afterwards, and in happy-dom never settles at all.
57
+ */
58
+ return computed(() =>
59
+ elementWidth.value > 0 && pageBesideElement.value >= MIN_PAGE_BESIDE_A_COLUMN
60
+ )
61
+ }
@@ -53,5 +53,8 @@ export function useResponsiveWidth(elementRef, specific = null) {
53
53
 
54
54
  return {
55
55
  responsiveWidth,
56
+ // The raw measurement, for the questions the breakpoint buckets cannot answer —
57
+ // comparing this element against something other than a fixed number.
58
+ width,
56
59
  }
57
60
  }
@@ -68,3 +68,34 @@ function rendersNode(node) {
68
68
 
69
69
  return true
70
70
  }
71
+
72
+ /**
73
+ * How many things a slot actually renders.
74
+ *
75
+ * Same walk as `rendersContent` and the same caveats — call it from the template or a plain
76
+ * function, never a `computed`, and hand a scoped slot its scope.
77
+ *
78
+ * Exists because "does this render anything" is not always the question. A header keeps a
79
+ * single action beside its title in a narrow column, which reads better than dropping one
80
+ * small link into a block of its own. Two controls in a 400px column is a different
81
+ * picture: the toolbar took 244px of a 398px header and left the title 48px, which is the
82
+ * 29px squeeze of #420 arriving by another route. Counting is what tells those apart.
83
+ */
84
+ export function countsRenderedNodes(slot, scope) {
85
+ const nodes = slot?.(scope)
86
+
87
+ if (!nodes) return 0
88
+
89
+ return nodes.reduce((total, node) => total + countsNode(node), 0)
90
+ }
91
+
92
+ function countsNode(node) {
93
+ if (node.type === Comment) return 0
94
+ if (node.type === Fragment) {
95
+ if (!Array.isArray(node.children)) return 0
96
+
97
+ return node.children.reduce((total, child) => total + countsNode(child), 0)
98
+ }
99
+
100
+ return 1
101
+ }