@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 +1 -1
- package/src/components/Card/CardHeader.vue +36 -4
- package/src/components/Layout/TwoColumnLayout.vue +1 -36
- package/src/components/Section/SectionHeader.vue +29 -36
- package/src/composables/useColumnOnAWiderPage.js +61 -0
- package/src/helpers/ResponsiveWidth.js +3 -0
- package/src/helpers/slots.js +31 -0
package/package.json
CHANGED
|
@@ -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="
|
|
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 && !
|
|
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
|
|
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,
|
|
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
|
-
*
|
|
130
|
-
* there is no layout around this header.
|
|
137
|
+
* The page rule applies to a SINGLE action only.
|
|
131
138
|
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
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
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
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
|
-
*
|
|
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.
|
|
148
151
|
*/
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const isColumnBesideAnother = computed(() => twoColumnLayout?.isSideBySide.value ?? false)
|
|
152
|
+
function keepsActionsInline() {
|
|
153
|
+
if (responsiveWidth.value.specific) return true
|
|
152
154
|
|
|
153
|
-
|
|
154
|
-
|
|
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
|
}
|
package/src/helpers/slots.js
CHANGED
|
@@ -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
|
+
}
|