@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
|
@@ -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,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': !
|
|
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="
|
|
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 && !
|
|
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
|
}
|
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
|
+
}
|