@falcondev-oss/nuxt-layers-base 0.41.3 → 0.42.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/app/components/layout/LayoutNavbar.tsx +140 -13
- package/app/components/u/UOverflowButtons.tsx +141 -0
- package/app/components/u/URibbonCard.tsx +51 -28
- package/app/components/u/URibbonSection.tsx +6 -3
- package/app/components/u/UToggleButton.tsx +9 -3
- package/app/composables/useAvailableWidth.ts +130 -0
- package/package.json +1 -1
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
import type { MaybeRefOrGetter, Ref, VNode } from 'vue'
|
|
12
12
|
import type { ToolbarTool } from '../../composables/useToolbar'
|
|
13
13
|
import type { AddPropertyPrefix } from '../../types/helpers'
|
|
14
|
+
import { useResizeObserver } from '@vueuse/core'
|
|
14
15
|
import * as R from 'remeda'
|
|
15
16
|
import {
|
|
16
17
|
ForwardSlots,
|
|
@@ -20,8 +21,10 @@ import {
|
|
|
20
21
|
UDashboardSidebarCollapse,
|
|
21
22
|
UDashboardToolbar,
|
|
22
23
|
UNavigationMenu,
|
|
24
|
+
UOverflowButtons,
|
|
23
25
|
USeparator,
|
|
24
26
|
} from '#components'
|
|
27
|
+
import { useAvailableWidth } from '../../composables/useAvailableWidth'
|
|
25
28
|
import { toolbarToolsKey } from '../../composables/useToolbar'
|
|
26
29
|
import { mergeSlotClass } from '../../utils/ui'
|
|
27
30
|
|
|
@@ -87,13 +90,64 @@ export default defineSetupComponent(
|
|
|
87
90
|
...providedTools.value.map((tool) => toValue(tool)),
|
|
88
91
|
])
|
|
89
92
|
|
|
90
|
-
|
|
91
|
-
|
|
93
|
+
/** `compact`: tighter rows, for the wrapped toolbar where the tools are the only line. */
|
|
94
|
+
const toolsMenu = (compact = false) => (
|
|
95
|
+
<UNavigationMenu
|
|
96
|
+
items={toolItems.value}
|
|
97
|
+
ui={{
|
|
98
|
+
...props.tools?.ui,
|
|
99
|
+
...(compact && {
|
|
100
|
+
item: mergeSlotClass(props.tools?.ui?.item, 'py-1'),
|
|
101
|
+
}),
|
|
102
|
+
}}
|
|
103
|
+
/>
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
// how much of the tabs' toolbar is left for the tools, next to the tabs. Measured off an
|
|
107
|
+
// anchor that stays at the end of that toolbar, so the answer doesn't change once the
|
|
108
|
+
// tools have moved to a toolbar of their own — they'd have no way back.
|
|
109
|
+
const toolsAnchor = ref<HTMLElement>()
|
|
110
|
+
const toolsSpace = useAvailableWidth(toolsAnchor)
|
|
111
|
+
// only ever the inline row — the wrapped one is compact, and a width measured there
|
|
112
|
+
// would not be the width the tools need to come back up
|
|
113
|
+
const toolsRow = ref<HTMLElement>()
|
|
114
|
+
// `shrink-0`, so this stays the tools' natural width even once the row runs short
|
|
115
|
+
const toolsWidth = ref(0)
|
|
116
|
+
useResizeObserver(toolsRow, () => {
|
|
117
|
+
const width = toolsRow.value?.offsetWidth
|
|
118
|
+
if (width) toolsWidth.value = width
|
|
119
|
+
})
|
|
120
|
+
// while wrapped the tools are out of the inline row, so nothing would re-measure them
|
|
121
|
+
// there. Forget the width when they change, and they come back up to be measured again.
|
|
122
|
+
watch(toolItems, () => {
|
|
123
|
+
toolsWidth.value = 0
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
const toolsWrapped = computed(
|
|
127
|
+
() => toolsSpace.value < toolsWidth.value + 7 /* 2*gap-1.5 + 1px divider */,
|
|
128
|
+
)
|
|
129
|
+
// the tools sit flush right, so what is left of the row past their own width is the
|
|
130
|
+
// clear space between them and the tabs
|
|
131
|
+
const toolsCrowded = computed(
|
|
132
|
+
() => !toolsWrapped.value && toolsSpace.value - toolsWidth.value < 3 * 16 /* 3rem */,
|
|
133
|
+
)
|
|
134
|
+
/** A divider goes between the tabs and the tools whenever they share a line and sit
|
|
135
|
+
* close enough to run together. Left-aligned tools always do — they follow the tabs. */
|
|
136
|
+
const showDivider = computed(
|
|
137
|
+
() =>
|
|
138
|
+
!!props.tabs &&
|
|
139
|
+
toolItems.value.length > 0 &&
|
|
140
|
+
!toolsWrapped.value &&
|
|
141
|
+
(props.tools?.left || toolsCrowded.value),
|
|
92
142
|
)
|
|
93
143
|
|
|
94
144
|
const navbarUi = computed<DashboardNavbarProps['ui']>(() => ({
|
|
95
145
|
...props.navbar?.ui,
|
|
96
146
|
toggle: mergeSlotClass(props.navbar?.ui?.toggle, '-ml-1'),
|
|
147
|
+
// Sized by its own content up to half the row, never squeezed by the actions: the edge
|
|
148
|
+
// they measure their room from has to stay put, or an action that gives way frees space
|
|
149
|
+
// for the title to grow into and comes straight back.
|
|
150
|
+
left: mergeSlotClass(props.navbar?.ui?.left, 'max-w-1/2 shrink-0'),
|
|
97
151
|
...(props.navbar?.breadcrumb && {
|
|
98
152
|
root: mergeSlotClass(props.navbar.ui?.root, 'h-auto min-h-(--ui-header-height) py-2'),
|
|
99
153
|
}),
|
|
@@ -109,6 +163,7 @@ export default defineSetupComponent(
|
|
|
109
163
|
<ForwardSlots slots={navbarSlots.value}>
|
|
110
164
|
<UDashboardNavbar
|
|
111
165
|
ui={navbarUi.value}
|
|
166
|
+
{...useAvailableWidth.root}
|
|
112
167
|
class="bg-white"
|
|
113
168
|
title={props.navbar.title}
|
|
114
169
|
v-slots={vSlots(UDashboardNavbar, {
|
|
@@ -118,7 +173,14 @@ export default defineSetupComponent(
|
|
|
118
173
|
: []),
|
|
119
174
|
|
|
120
175
|
<div class="flex min-w-0 flex-col items-start gap-0.5">
|
|
121
|
-
<div
|
|
176
|
+
<div
|
|
177
|
+
class={[
|
|
178
|
+
'flex min-w-0 items-center gap-1.5',
|
|
179
|
+
// the breadcrumb sets the column's width and the title
|
|
180
|
+
// truncates into it, instead of the other way round
|
|
181
|
+
props.navbar?.breadcrumb && 'w-0 min-w-full',
|
|
182
|
+
]}
|
|
183
|
+
>
|
|
122
184
|
<h1 class="text-highlighted truncate font-semibold">
|
|
123
185
|
{slots['navbar-title']?.() ?? props.navbar?.title}
|
|
124
186
|
</h1>
|
|
@@ -145,9 +207,9 @@ export default defineSetupComponent(
|
|
|
145
207
|
],
|
|
146
208
|
right: (slotProps) => [
|
|
147
209
|
<>{slots['navbar-right']?.(slotProps)}</>,
|
|
148
|
-
<
|
|
210
|
+
<UOverflowButtons id="navbar-actions">
|
|
149
211
|
{slots['navbar-actions']?.()}
|
|
150
|
-
</
|
|
212
|
+
</UOverflowButtons>,
|
|
151
213
|
],
|
|
152
214
|
})}
|
|
153
215
|
/>
|
|
@@ -157,30 +219,95 @@ export default defineSetupComponent(
|
|
|
157
219
|
...(props.tabs || toolItems.value.length > 0
|
|
158
220
|
? [
|
|
159
221
|
<UDashboardToolbar
|
|
160
|
-
ui={
|
|
222
|
+
ui={{
|
|
223
|
+
...props.toolbarUi,
|
|
224
|
+
// right-aligned tools: stretched, so the space held open past the
|
|
225
|
+
// tabs is the row's own and the divider can centre itself in it
|
|
226
|
+
...(!props.tools?.left &&
|
|
227
|
+
props.tabs && {
|
|
228
|
+
left: mergeSlotClass(props.toolbarUi?.left, 'grow'),
|
|
229
|
+
}),
|
|
230
|
+
}}
|
|
231
|
+
{...useAvailableWidth.root}
|
|
161
232
|
class={['bg-white', (props.tabs || props.tools?.left) && '*:first:-ml-2']}
|
|
162
233
|
v-slots={vSlots(UDashboardToolbar, {
|
|
163
234
|
...((props.tabs || props.tools?.left) && {
|
|
164
235
|
left: () => [
|
|
236
|
+
// the fixed edge the room for the tools is measured from
|
|
165
237
|
...(props.tabs
|
|
166
238
|
? [
|
|
167
239
|
<UNavigationMenu
|
|
168
240
|
items={props.tabs.items}
|
|
169
241
|
highlight
|
|
170
242
|
variant="link"
|
|
243
|
+
class="shrink-0"
|
|
171
244
|
ui={props.tabs.ui}
|
|
172
245
|
/>,
|
|
173
246
|
]
|
|
174
247
|
: []),
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
...(props.tools?.left
|
|
248
|
+
// Everything past the tabs is room the tools may take, so all of it
|
|
249
|
+
// counts as free for the measurement, whether the tools are
|
|
250
|
+
// standing in it or not, and they keep their way back.
|
|
251
|
+
...(props.tools?.left
|
|
252
|
+
? [
|
|
253
|
+
...(showDivider.value
|
|
254
|
+
? [
|
|
255
|
+
<USeparator
|
|
256
|
+
{...useAvailableWidth.free}
|
|
257
|
+
orientation="vertical"
|
|
258
|
+
class="h-7"
|
|
259
|
+
/>,
|
|
260
|
+
]
|
|
261
|
+
: []),
|
|
262
|
+
...(toolsWrapped.value
|
|
263
|
+
? []
|
|
264
|
+
: [
|
|
265
|
+
<div
|
|
266
|
+
ref={toolsRow}
|
|
267
|
+
{...useAvailableWidth.free}
|
|
268
|
+
class="shrink-0"
|
|
269
|
+
>
|
|
270
|
+
{toolsMenu()}
|
|
271
|
+
</div>,
|
|
272
|
+
]),
|
|
273
|
+
]
|
|
274
|
+
: // right-aligned: held open here, for the divider to centre in
|
|
275
|
+
[
|
|
276
|
+
<div
|
|
277
|
+
{...useAvailableWidth.free}
|
|
278
|
+
class="flex grow justify-center"
|
|
279
|
+
>
|
|
280
|
+
{showDivider.value ? (
|
|
281
|
+
<USeparator orientation="vertical" class="h-7" />
|
|
282
|
+
) : null}
|
|
283
|
+
</div>,
|
|
284
|
+
]),
|
|
179
285
|
],
|
|
180
286
|
}),
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
287
|
+
right: () => [
|
|
288
|
+
// `hidden`, so it measures the row without taking a place in it
|
|
289
|
+
<div ref={toolsAnchor} class="hidden" />,
|
|
290
|
+
// the room the tools stand in is the room the anchor measures — read
|
|
291
|
+
// as free whether they are in it or not, so they keep their way back
|
|
292
|
+
...(!props.tools?.left && !toolsWrapped.value
|
|
293
|
+
? [
|
|
294
|
+
<div ref={toolsRow} {...useAvailableWidth.free} class="shrink-0">
|
|
295
|
+
{toolsMenu()}
|
|
296
|
+
</div>,
|
|
297
|
+
]
|
|
298
|
+
: []),
|
|
299
|
+
],
|
|
300
|
+
})}
|
|
301
|
+
/>,
|
|
302
|
+
]
|
|
303
|
+
: []),
|
|
304
|
+
...(toolsWrapped.value
|
|
305
|
+
? [
|
|
306
|
+
<UDashboardToolbar
|
|
307
|
+
ui={props.toolbarUi}
|
|
308
|
+
class="min-h-fit! bg-white"
|
|
309
|
+
v-slots={vSlots(UDashboardToolbar, {
|
|
310
|
+
default: () => [<div class="-ml-2 shrink-0">{toolsMenu(true)}</div>],
|
|
184
311
|
})}
|
|
185
312
|
/>,
|
|
186
313
|
]
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { VNode } from 'vue'
|
|
2
|
+
import { useResizeObserver } from '@vueuse/core'
|
|
3
|
+
import { Comment, Fragment, isVNode } from 'vue'
|
|
4
|
+
import { UButton, UPopover } from '#components'
|
|
5
|
+
import { useAvailableWidth } from '../../composables/useAvailableWidth'
|
|
6
|
+
|
|
7
|
+
/** JSX children and `v-if`/`v-for` arrive nested — unpack them to reach the individual actions.
|
|
8
|
+
* A `v-if` that didn't take leaves a comment placeholder behind, which is not an action. */
|
|
9
|
+
function flattenActions(nodes: unknown[]): VNode[] {
|
|
10
|
+
return nodes.flatMap((node) => {
|
|
11
|
+
if (Array.isArray(node)) return flattenActions(node)
|
|
12
|
+
if (!isVNode(node) || node.type === Comment) return []
|
|
13
|
+
if (node.type === Fragment && Array.isArray(node.children)) return flattenActions(node.children)
|
|
14
|
+
return [node]
|
|
15
|
+
})
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** `gap-2` between the actions, as a number to compute with. */
|
|
19
|
+
const GAP = 8
|
|
20
|
+
|
|
21
|
+
/** Out of flow, so the trigger keeps its measurable width without taking up a slot in the row. */
|
|
22
|
+
const PARKED = 'pointer-events-none invisible absolute left-0'
|
|
23
|
+
|
|
24
|
+
export default defineSetupComponent(
|
|
25
|
+
(_: {
|
|
26
|
+
props: {
|
|
27
|
+
id?: string
|
|
28
|
+
}
|
|
29
|
+
// the id only labels the row in the DOM, so it rides along as an inherited attribute
|
|
30
|
+
propKeys: never
|
|
31
|
+
slots: {
|
|
32
|
+
default: () => VNode[]
|
|
33
|
+
}
|
|
34
|
+
}) =>
|
|
35
|
+
options(_, {
|
|
36
|
+
name: 'UOverflowButtons',
|
|
37
|
+
props: [],
|
|
38
|
+
emits: [],
|
|
39
|
+
setup: (_props, { slots }) => {
|
|
40
|
+
const root = ref<HTMLElement>()
|
|
41
|
+
const space = useAvailableWidth(root)
|
|
42
|
+
|
|
43
|
+
const triggerWidth = ref(0)
|
|
44
|
+
/** Action widths by index, kept from when the action last stood in the row — one that has
|
|
45
|
+
* given way is in the popover instead, where its width says nothing about the row. */
|
|
46
|
+
const widths = ref<(number | undefined)[]>([])
|
|
47
|
+
|
|
48
|
+
function measure() {
|
|
49
|
+
const next = [...widths.value]
|
|
50
|
+
let changed = false
|
|
51
|
+
const children = [...(root.value?.children ?? [])] as HTMLElement[]
|
|
52
|
+
for (const child of children) {
|
|
53
|
+
// `github/no-dataset` wants `getAttribute`, `unicorn/dom-node-dataset` wants this
|
|
54
|
+
// eslint-disable-next-line github/no-dataset
|
|
55
|
+
const index = child.dataset.actionIndex
|
|
56
|
+
const width = child.offsetWidth
|
|
57
|
+
if (index === undefined) {
|
|
58
|
+
triggerWidth.value = width
|
|
59
|
+
} else if (next[Number(index)] !== width) {
|
|
60
|
+
next[Number(index)] = width
|
|
61
|
+
changed = true
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (changed) widths.value = next
|
|
65
|
+
}
|
|
66
|
+
onMounted(measure)
|
|
67
|
+
onUpdated(measure)
|
|
68
|
+
// an action that only reaches its final width later — an icon, a font — resizes the row
|
|
69
|
+
useResizeObserver(root, measure)
|
|
70
|
+
|
|
71
|
+
/** How many actions have to give way, counting from the left. */
|
|
72
|
+
function overflowCount(count: number) {
|
|
73
|
+
const actions = Array.from({ length: count }, (_, index) => widths.value[index])
|
|
74
|
+
// an action that has never stood in the row has no width yet: show it and measure it
|
|
75
|
+
if (actions.includes(undefined)) return 0
|
|
76
|
+
|
|
77
|
+
const known = actions as number[]
|
|
78
|
+
const full = known.reduce((sum, width) => sum + width + GAP, -GAP)
|
|
79
|
+
if (full <= space.value) return 0
|
|
80
|
+
|
|
81
|
+
let used = triggerWidth.value
|
|
82
|
+
let visible = 0
|
|
83
|
+
for (const width of known.toReversed()) {
|
|
84
|
+
used += width + GAP
|
|
85
|
+
if (used > space.value) break
|
|
86
|
+
visible++
|
|
87
|
+
}
|
|
88
|
+
return count - visible
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return () => {
|
|
92
|
+
const actions = flattenActions(slots.default?.() ?? [])
|
|
93
|
+
const overflowing = overflowCount(actions.length)
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<div ref={root} class="relative flex items-center gap-2">
|
|
97
|
+
{/* always rendered, so its width is known before the row has to make room for it */}
|
|
98
|
+
<div class={overflowing > 0 ? 'shrink-0' : PARKED}>
|
|
99
|
+
<UPopover
|
|
100
|
+
// remounted when the row stops overflowing, so an open menu doesn't stay
|
|
101
|
+
// open and empty once its actions have gone back into the row
|
|
102
|
+
key={String(overflowing > 0)}
|
|
103
|
+
v-slots={vSlots(UPopover, {
|
|
104
|
+
content: () => [
|
|
105
|
+
<div class="flex flex-col items-stretch gap-2 p-2">
|
|
106
|
+
{/* a second call to the slot, but for the actions the row leaves out —
|
|
107
|
+
no action is ever mounted in both places.
|
|
108
|
+
Rightmost first, so the menu continues where the row leaves off. */}
|
|
109
|
+
{flattenActions(slots.default?.() ?? [])
|
|
110
|
+
.slice(0, overflowing)
|
|
111
|
+
.toReversed()}
|
|
112
|
+
</div>,
|
|
113
|
+
],
|
|
114
|
+
})}
|
|
115
|
+
>
|
|
116
|
+
<UButton
|
|
117
|
+
icon="i-lucide-ellipsis"
|
|
118
|
+
color="neutral"
|
|
119
|
+
variant="subtle"
|
|
120
|
+
aria-label="Weitere Aktionen"
|
|
121
|
+
/>
|
|
122
|
+
</UPopover>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
{actions.slice(overflowing).map((action, index) => (
|
|
126
|
+
// `shrink-0`, so a row that has run short measures the action's natural width
|
|
127
|
+
// — the width it would need to stay — rather than its squeezed one
|
|
128
|
+
<div
|
|
129
|
+
key={overflowing + index}
|
|
130
|
+
data-action-index={overflowing + index}
|
|
131
|
+
class="shrink-0"
|
|
132
|
+
>
|
|
133
|
+
{action}
|
|
134
|
+
</div>
|
|
135
|
+
))}
|
|
136
|
+
</div>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
}),
|
|
141
|
+
)
|
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
import type { CardProps, CardSlots } from '@nuxt/ui'
|
|
2
2
|
import type { VNode } from 'vue'
|
|
3
3
|
import { useForwardProps } from 'reka-ui'
|
|
4
|
-
import { omit } from 'remeda'
|
|
4
|
+
import { omit, partition } from 'remeda'
|
|
5
|
+
import { Fragment } from 'vue'
|
|
5
6
|
import { UCard } from '#components'
|
|
6
7
|
import { mergeSlotClass } from '../../utils/ui'
|
|
7
8
|
|
|
9
|
+
/** `v-if`/`v-for` in the slot arrive as fragments — unpack them to reach the sections. */
|
|
10
|
+
function flattenSections(nodes: VNode[]): VNode[] {
|
|
11
|
+
return nodes.flatMap((node) =>
|
|
12
|
+
node.type === Fragment && Array.isArray(node.children)
|
|
13
|
+
? flattenSections(node.children as VNode[])
|
|
14
|
+
: [node],
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
|
|
8
18
|
export default defineSetupComponent(
|
|
9
19
|
(_: {
|
|
10
20
|
props: CardProps
|
|
@@ -21,34 +31,47 @@ export default defineSetupComponent(
|
|
|
21
31
|
setup: (props, { slots, attrs }) => {
|
|
22
32
|
const forwarded = useForwardProps(props)
|
|
23
33
|
|
|
24
|
-
return () =>
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
return () => {
|
|
35
|
+
const [endSections, leadingSections] = slots.ribbon
|
|
36
|
+
? partition(flattenSections(slots.ribbon()), (node) => Boolean(node.props?.end))
|
|
37
|
+
: [[], []]
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div class="flex flex-col">
|
|
41
|
+
{slots.ribbon ? (
|
|
42
|
+
// the ribbon's lower edge runs behind the card, so the card keeps its own rounded top;
|
|
43
|
+
// sections draw their own top/left border shifted by -1px, so the outer ones get clipped
|
|
44
|
+
<div
|
|
45
|
+
class={[
|
|
46
|
+
'ring-default bg-elevated flex flex-wrap justify-end overflow-hidden rounded-t-lg shadow-[inset_0_2px_3px_-2px_rgb(0_0_0/0.06),inset_2px_0_3px_-2px_rgb(0_0_0/0.06),inset_-2px_0_3px_-2px_rgb(0_0_0/0.06)] ring',
|
|
47
|
+
slots.default ? '-mb-2 pb-2' : 'rounded-b-lg',
|
|
48
|
+
]}
|
|
49
|
+
>
|
|
50
|
+
{leadingSections}
|
|
51
|
+
|
|
52
|
+
{endSections.length > 0 ? (
|
|
53
|
+
// one flex item, so the end sections wrap onto their own line as a group
|
|
54
|
+
// instead of splitting up — the leading sections give way first
|
|
55
|
+
<div class="flex flex-wrap justify-end">{endSections}</div>
|
|
56
|
+
) : null}
|
|
57
|
+
</div>
|
|
58
|
+
) : null}
|
|
37
59
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
60
|
+
{slots.default ? (
|
|
61
|
+
<UCard
|
|
62
|
+
{...attrs}
|
|
63
|
+
{...forwarded.value}
|
|
64
|
+
ui={{
|
|
65
|
+
...forwarded.value.ui,
|
|
66
|
+
body: mergeSlotClass(forwarded.value.ui?.body, 'p-0!'),
|
|
67
|
+
}}
|
|
68
|
+
class="relative shadow-[0_-2px_3px_-1px_rgb(0_0_0/0.08)]"
|
|
69
|
+
v-slots={vSlots(UCard, omit(slots, ['ribbon']))}
|
|
70
|
+
/>
|
|
71
|
+
) : null}
|
|
72
|
+
</div>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
52
75
|
},
|
|
53
76
|
}),
|
|
54
77
|
)
|
|
@@ -21,9 +21,12 @@ export default defineSetupComponent(
|
|
|
21
21
|
<div
|
|
22
22
|
data-end={props.end || undefined}
|
|
23
23
|
class={[
|
|
24
|
-
'flex
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
'border-default -mt-px -ml-px flex min-w-fit flex-col gap-1.5 border-l px-3 py-2',
|
|
25
|
+
// the row separator spans the full ribbon width (clipped by its overflow-hidden), so a
|
|
26
|
+
// partially filled wrapped line still gets an unbroken line above it
|
|
27
|
+
'before:border-default relative before:absolute before:inset-x-[-100vw] before:top-0 before:border-t',
|
|
28
|
+
// end sections hug their content, so the leading sections absorb the spare space
|
|
29
|
+
props.end ? 'flex-none' : 'flex-1',
|
|
27
30
|
]}
|
|
28
31
|
>
|
|
29
32
|
{props.title ? <p class="text-dimmed text-[10px] leading-none">{props.title}</p> : null}
|
|
@@ -19,7 +19,12 @@ export default defineSetupComponent(
|
|
|
19
19
|
<UButton
|
|
20
20
|
color="neutral"
|
|
21
21
|
variant="soft"
|
|
22
|
-
class={[
|
|
22
|
+
class={[
|
|
23
|
+
'ring-accented px-1.5! py-1.5! ring ring-inset',
|
|
24
|
+
props.modelValue && 'bg-white',
|
|
25
|
+
]}
|
|
26
|
+
label={props.label}
|
|
27
|
+
ui={{ label: props.modelValue ? undefined : 'text-toned' }}
|
|
23
28
|
aria-pressed={props.modelValue}
|
|
24
29
|
onClick={() => {
|
|
25
30
|
emit('update:modelValue', !props.modelValue)
|
|
@@ -30,13 +35,14 @@ export default defineSetupComponent(
|
|
|
30
35
|
<span
|
|
31
36
|
class={[
|
|
32
37
|
'flex size-5 items-center justify-center rounded-sm transition-colors',
|
|
33
|
-
props.modelValue
|
|
38
|
+
props.modelValue
|
|
39
|
+
? 'bg-primary text-inverted'
|
|
40
|
+
: 'text-muted bg-white/50 shadow-[inset_0_0_2px_1px_rgb(0_0_0/0.04),inset_0_0_2px_rgb(0_0_0/0.1)]',
|
|
34
41
|
]}
|
|
35
42
|
>
|
|
36
43
|
<Icon name={props.icon} class="size-4" />
|
|
37
44
|
</span>
|
|
38
45
|
),
|
|
39
|
-
default: () => props.label,
|
|
40
46
|
}}
|
|
41
47
|
</UButton>
|
|
42
48
|
),
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { MaybeComputedElementRef } from '@vueuse/core'
|
|
2
|
+
import { unrefElement, useResizeObserver } from '@vueuse/core'
|
|
3
|
+
|
|
4
|
+
/** `Number()` stops at the `px` suffix computed styles come with. */
|
|
5
|
+
// eslint-disable-next-line unicorn/prefer-number-coercion
|
|
6
|
+
const px = (value: string) => Number.parseFloat(value)
|
|
7
|
+
|
|
8
|
+
const FREE_ATTR = 'data-available-width-free'
|
|
9
|
+
const ROOT_ATTR = 'data-available-width-root'
|
|
10
|
+
|
|
11
|
+
const FREE = `[${FREE_ATTR}]`
|
|
12
|
+
const ROOT = `[${ROOT_ATTR}]`
|
|
13
|
+
|
|
14
|
+
/** The element next to `node` on `side`, skipping the ones that count as free. */
|
|
15
|
+
function siblingOf(node: Element | null | undefined, side: 'left' | 'right') {
|
|
16
|
+
const step = (el: Element) =>
|
|
17
|
+
side === 'left' ? el.previousElementSibling : el.nextElementSibling
|
|
18
|
+
let sibling = node && step(node)
|
|
19
|
+
while (sibling?.matches(FREE)) sibling = step(sibling)
|
|
20
|
+
return (sibling ?? undefined) as HTMLElement | undefined
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const leftOf = (node: Element | null | undefined) => siblingOf(node, 'left')
|
|
24
|
+
const rightOf = (node: Element | null | undefined) => siblingOf(node, 'right')
|
|
25
|
+
|
|
26
|
+
/** Which edge of an element the content left of `target` ends at. One that trails off into
|
|
27
|
+
* opted-out children hands over to the last child that isn't; one made of nothing but those
|
|
28
|
+
* ends where it begins, however wide it grew. */
|
|
29
|
+
function contentEdge(el: HTMLElement): { el: HTMLElement; side: 'left' | 'right' } {
|
|
30
|
+
if (!el.lastElementChild?.matches(FREE)) return { el, side: 'right' }
|
|
31
|
+
const last = leftOf(el.lastElementChild)
|
|
32
|
+
return last ? contentEdge(last) : { el, side: 'left' }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The elements bounding `target` on either side, and the box they sit in. */
|
|
36
|
+
function anchors(target: MaybeComputedElementRef) {
|
|
37
|
+
let node = unrefElement(target) as HTMLElement | null | undefined
|
|
38
|
+
let blocker: HTMLElement | undefined
|
|
39
|
+
while (node && !node.matches(ROOT)) {
|
|
40
|
+
// the innermost right-hand neighbour along the climb: room `target` may not spill into
|
|
41
|
+
blocker ??= rightOf(node)
|
|
42
|
+
if (leftOf(node)) break
|
|
43
|
+
node = node.parentElement
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// at the root the climb ends whether or not something sits to its left: that is another row
|
|
47
|
+
const neighbor = node?.matches(ROOT) ? undefined : leftOf(node)
|
|
48
|
+
return {
|
|
49
|
+
// the edge may sit inside the neighbour, past the opted-out elements it ends with
|
|
50
|
+
edge: neighbor ? contentEdge(neighbor) : undefined,
|
|
51
|
+
blocker,
|
|
52
|
+
container: (node?.matches(ROOT) ? node : node?.parentElement) ?? undefined,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* How much horizontal room `target` has to itself: from the right edge of the content left of it
|
|
58
|
+
* to the left edge of whatever follows it, or to their shared container's content box where
|
|
59
|
+
* nothing does. Stays meaningful when `target` overflows — unlike its own width, which is why it
|
|
60
|
+
* can decide what still fits.
|
|
61
|
+
*
|
|
62
|
+
* useAvailableWidth.root
|
|
63
|
+
* ┌───────────────────────────────────────────────────────┐
|
|
64
|
+
* │ ┌───────────┐ ┌╌╌╌╌╌╌╌╌╌┐ ┌────────┐ ┌─────────┐ │
|
|
65
|
+
* │ │ neighbour │ ╎ free ╎ │ target │ │ blocker │ │
|
|
66
|
+
* │ └───────────┘ └╌╌╌╌╌╌╌╌╌┘ └────────┘ └─────────┘ │
|
|
67
|
+
* └─────────────────┬──────────────────────┬──────────────┘
|
|
68
|
+
* from to
|
|
69
|
+
* └─────── space ───────┘
|
|
70
|
+
*
|
|
71
|
+
* Elements spread with `useAvailableWidth.free` are read as empty space rather than as content
|
|
72
|
+
* — the measurement runs straight through them, so `target` may grow into the room they hold. The
|
|
73
|
+
* row containing `target` should carry `useAvailableWidth.root`: without it a target with nothing
|
|
74
|
+
* to its left keeps climbing and ends up measured against something elsewhere on the page.
|
|
75
|
+
*
|
|
76
|
+
* Either neighbour may be missing, in which case that end falls back to the container's content
|
|
77
|
+
* box. `target` itself is never measured — which is what keeps the answer meaningful once it
|
|
78
|
+
* overflows, and what lets an element that has given way find its way back.
|
|
79
|
+
*/
|
|
80
|
+
export function useAvailableWidth(target: MaybeComputedElementRef) {
|
|
81
|
+
// until measured, whatever asks gets "plenty" and renders in full
|
|
82
|
+
const space = ref(Infinity)
|
|
83
|
+
|
|
84
|
+
// which elements bound the gap is a fact about the current DOM, not a reactive derivation of
|
|
85
|
+
// `target` — re-resolve it on every measure, or the observer keeps watching elements that left
|
|
86
|
+
const container = shallowRef<HTMLElement>()
|
|
87
|
+
const edge = shallowRef<HTMLElement>()
|
|
88
|
+
const blocker = shallowRef<HTMLElement>()
|
|
89
|
+
|
|
90
|
+
function measure() {
|
|
91
|
+
const anchor = anchors(target)
|
|
92
|
+
container.value = anchor.container
|
|
93
|
+
edge.value = anchor.edge?.el
|
|
94
|
+
blocker.value = anchor.blocker
|
|
95
|
+
// detached, or no row to measure in: back to "plenty", not a stale answer about a gone DOM
|
|
96
|
+
if (!anchor.container) {
|
|
97
|
+
space.value = Infinity
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const style = getComputedStyle(anchor.container)
|
|
102
|
+
const box = anchor.container.getBoundingClientRect()
|
|
103
|
+
// the container's own gap is not room `target` may use — unless that side stands open
|
|
104
|
+
const gap = px(style.columnGap) || 0
|
|
105
|
+
|
|
106
|
+
const from = anchor.edge
|
|
107
|
+
? anchor.edge.el.getBoundingClientRect()[anchor.edge.side] +
|
|
108
|
+
(anchor.edge.side === 'right' ? gap : 0)
|
|
109
|
+
: box.left + px(style.paddingLeft)
|
|
110
|
+
const to = anchor.blocker
|
|
111
|
+
? anchor.blocker.getBoundingClientRect().left - gap
|
|
112
|
+
: box.right - px(style.paddingRight)
|
|
113
|
+
|
|
114
|
+
space.value = to - from
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
useResizeObserver([container, edge, blocker], measure)
|
|
118
|
+
onMounted(measure)
|
|
119
|
+
// the elements bounding the gap come and go with the surrounding component's re-renders
|
|
120
|
+
onUpdated(measure)
|
|
121
|
+
|
|
122
|
+
return space
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Spread onto the box the search for a target's neighbours stops at. */
|
|
126
|
+
useAvailableWidth.root = { [ROOT_ATTR]: '' }
|
|
127
|
+
|
|
128
|
+
/** Spread onto an element that only fills room the others left over — a divider centred in the
|
|
129
|
+
* gap, say. It sits in the flow, but the width it covers still counts as free. */
|
|
130
|
+
useAvailableWidth.free = { [FREE_ATTR]: '' }
|