@redseed/redseed-ui-vue3 11.0.0 → 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.0",
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,11 +1,12 @@
1
1
  <script setup>
2
2
  import { ref, computed, useAttrs, useSlots } from 'vue'
3
- import { rendersContent } from '../../helpers/slots'
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,7 +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)
135
+
136
+ /**
137
+ * The page rule applies to a SINGLE action only.
138
+ *
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.
142
+ *
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.
147
+ *
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.
151
+ */
152
+ function keepsActionsInline() {
153
+ if (responsiveWidth.value.specific) return true
154
+
155
+ return isColumnOnAWiderPage.value && countsRenderedNodes(slots.actions) === 1
156
+ }
127
157
 
128
158
  const emit = defineEmits(['click:more-actions'])
129
159
 
@@ -225,7 +255,11 @@ const titleTag = computed(() => HEADING_LEVELS.includes(String(props.headingLeve
225
255
  // It used to come from `md:` — a viewport query at 768 — while the icon
226
256
  // gate, the actions swap and the badge stacking all read the container at
227
257
  // 640, so a narrow column on a wide screen got both layouts at once. #420.
228
- 'rsui-section-header--narrow': !responsiveWidth.specific,
258
+ 'rsui-section-header--narrow': !keepsActionsInline(),
259
+ // Container only, deliberately NOT the one above. Where the actions go is a
260
+ // question about the page; whether the title has room is a question about this
261
+ // column, and the two want different answers in an aside on a wide screen.
262
+ 'rsui-section-header--narrow-column': !responsiveWidth.specific,
229
263
  }
230
264
  ]"
231
265
  :role="isClickable ? 'button' : undefined"
@@ -300,7 +334,7 @@ const titleTag = computed(() => HEADING_LEVELS.includes(String(props.headingLeve
300
334
  >
301
335
  <!-- Desktop actions slot, optional -->
302
336
  <div class="rsui-section-header__actions-desktop"
303
- v-if="responsiveWidth.specific && $slots.actions"
337
+ v-if="keepsActionsInline() && $slots.actions"
304
338
  >
305
339
  <slot name="actions"></slot>
306
340
  </div>
@@ -329,7 +363,7 @@ const titleTag = computed(() => HEADING_LEVELS.includes(String(props.headingLeve
329
363
 
330
364
  <!-- Mobile actions slot, optional -->
331
365
  <div class="rsui-section-header__actions-mobile"
332
- v-if="showMobileActions && !responsiveWidth.specific && $slots.actions"
366
+ v-if="showMobileActions && !keepsActionsInline() && $slots.actions"
333
367
  >
334
368
  <slot name="actions"></slot>
335
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
+ }