@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.
@@ -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
- const toolsMenu = () => (
91
- <UNavigationMenu items={toolItems.value} highlight ui={props.tools?.ui} />
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 class="flex min-w-0 items-center gap-1.5">
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
- <div id="navbar-actions" class="flex items-center gap-2">
210
+ <UOverflowButtons id="navbar-actions">
149
211
  {slots['navbar-actions']?.()}
150
- </div>,
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={props.toolbarUi}
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
- ...(props.tabs && props.tools?.left && toolItems.value.length > 0
176
- ? [<USeparator orientation="vertical" class="h-7" />]
177
- : []),
178
- ...(props.tools?.left ? [toolsMenu()] : []),
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
- ...(!props.tools?.left && {
182
- right: () => [toolsMenu()],
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
- <div class="flex flex-col">
26
- {slots.ribbon ? (
27
- // the ribbon's lower edge runs behind the card, so the card keeps its own rounded top
28
- <div
29
- class={[
30
- 'divide-default ring-default bg-elevated flex items-stretch divide-x overflow-x-auto 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',
31
- slots.default ? '-mb-2 pb-2' : 'rounded-b-lg',
32
- ]}
33
- >
34
- {slots.ribbon()}
35
- </div>
36
- ) : null}
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
- {slots.default ? (
39
- <UCard
40
- {...attrs}
41
- {...forwarded.value}
42
- ui={{
43
- ...forwarded.value.ui,
44
- body: mergeSlotClass(forwarded.value.ui?.body, 'p-0!'),
45
- }}
46
- class="relative shadow-[0_-2px_3px_-1px_rgb(0_0_0/0.08)]"
47
- v-slots={vSlots(UCard, omit(slots, ['ribbon']))}
48
- />
49
- ) : null}
50
- </div>
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 shrink-0 flex-col gap-1.5 px-3 py-2',
25
- props.end &&
26
- 'border-default [&:not([data-end]~*)]:ml-auto [&:not([data-end]~*)]:border-l',
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={['ring-accented px-1.5! ring', props.modelValue && 'bg-accented']}
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 ? 'bg-primary text-inverted' : 'text-muted',
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]: '' }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@falcondev-oss/nuxt-layers-base",
3
3
  "type": "module",
4
- "version": "0.41.3",
4
+ "version": "0.42.0",
5
5
  "description": "Nuxt layer with lots of useful helpers and @nuxt/ui components",
6
6
  "license": "MIT",
7
7
  "repository": {