@falcondev-oss/nuxt-layers-base 0.37.2 → 0.38.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.
@@ -13,7 +13,7 @@
13
13
  }
14
14
 
15
15
  body {
16
- background: var(--ui-color-neutral-50);
16
+ background: color-mix(in oklch, var(--ui-color-neutral-50) 50%, white);
17
17
  }
18
18
 
19
19
  button:not(:disabled),
@@ -0,0 +1,30 @@
1
+ import type { Component, Slot, VNode } from 'vue'
2
+
3
+ /**
4
+ * Passes a set of slot functions on to the component in the default slot.
5
+ *
6
+ * Slots can only be declared where a component is written, so forwarding a dynamic set of them has
7
+ * to happen from the outside: the target's vnode is re-created with `slots` merged into the slots
8
+ * written inline. Inline slots win on conflict.
9
+ */
10
+ export default defineSetupComponent(
11
+ (_: {
12
+ props: {
13
+ slots: Record<string, Slot | undefined>
14
+ }
15
+ slots: { default: () => VNode[] }
16
+ }) =>
17
+ options(_, {
18
+ props: ['slots'],
19
+ emits: [],
20
+ setup: (props, ctx) => () => {
21
+ const [target] = ctx.slots.default?.() ?? []
22
+ if (!target) return null
23
+
24
+ // dropping `_` makes Vue re-normalize the merged slots instead of treating them as stable
25
+ const { _: _slotFlag, ...inlineSlots } = (target.children ?? {}) as Record<string, unknown>
26
+
27
+ return h(target.type as Component, target.props, { ...props.slots, ...inlineSlots })
28
+ },
29
+ }),
30
+ )
@@ -1,5 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import type {
3
+ BreadcrumbItem,
4
+ BreadcrumbProps,
3
5
  DashboardNavbarProps,
4
6
  DashboardNavbarSlots,
5
7
  DashboardPanelProps,
@@ -7,67 +9,128 @@ import type {
7
9
  NavigationMenuItem,
8
10
  NavigationMenuProps,
9
11
  } from '@nuxt/ui'
12
+ import type { MaybeRefOrGetter, Ref } from 'vue'
13
+ import type { ToolbarTool } from '../../composables/useToolbar'
10
14
  import type { AddPropertyPrefix } from '../../types/helpers'
11
15
  import * as R from 'remeda'
16
+ import { toolbarToolsKey } from '../../composables/useToolbar'
17
+ import { mergeSlotClass } from '../../utils/ui'
12
18
 
13
- defineProps<{
19
+ const props = defineProps<{
14
20
  panel?: DashboardPanelProps
15
21
  navbar?: {
16
- showSidebarToggle?: boolean
22
+ sidebarToggle?: boolean
17
23
  title?: string
24
+ breadcrumb?: BreadcrumbItem[]
25
+ breadcrumbUi?: BreadcrumbProps<BreadcrumbItem>['ui']
18
26
  ui?: DashboardNavbarProps['ui']
19
27
  }
20
- toolbar?: {
28
+ tabs?: {
21
29
  items?: NavigationMenuItem[]
22
- itemsEnd?: NavigationMenuItem[]
23
- ui?: DashboardToolbarProps['ui']
24
- itemsUi?: NavigationMenuProps['ui']
25
- itemsEndUi?: NavigationMenuProps['ui']
30
+ ui?: NavigationMenuProps['ui']
26
31
  }
32
+ tools?: {
33
+ items?: NavigationMenuItem[]
34
+ ui?: NavigationMenuProps['ui']
35
+ }
36
+ toolbarUi?: DashboardToolbarProps['ui']
27
37
  }>()
28
38
 
29
39
  const slots = defineSlots<
30
40
  {
31
- default: any
41
+ 'default': any
42
+ 'navbar-title': any
43
+ 'navbar-trailing': any
44
+ 'navbar-actions': any
32
45
  } & AddPropertyPrefix<DashboardNavbarSlots, 'navbar'>
33
46
  >()
34
47
 
35
- const omitNavbarSlots = ['navbar-leading'] satisfies (keyof typeof slots)[]
48
+ // rendered manually in the `left` slot below
49
+ const omitNavbarSlots = [
50
+ 'navbar-left',
51
+ 'navbar-leading',
52
+ 'navbar-title',
53
+ 'navbar-trailing',
54
+ 'navbar-right',
55
+ 'navbar-actions',
56
+ ] satisfies (keyof typeof slots)[]
36
57
  const navbarSlots = computed(() =>
37
58
  R.pipe(
38
59
  slots,
39
60
  R.pickBy((_, key) => key.startsWith('navbar-')),
40
61
  R.omit(omitNavbarSlots),
41
- R.keys(),
42
- R.pullObject(
43
- (key) => key.replace('navbar-', ''),
44
- (key) => key,
45
- ),
62
+ R.mapKeys((key) => key.replace('navbar-', '')),
46
63
  ),
47
64
  )
65
+
66
+ // added by the content rendered inside this navbar through `useToolbar()`
67
+ // `ref()`'s deep unwrapping blows up on the recursive `NavigationMenuItem` type
68
+ const providedTools = ref([]) as Ref<MaybeRefOrGetter<ToolbarTool>[]>
69
+ provide(toolbarToolsKey, providedTools)
70
+
71
+ const toolItems = computed(() => [
72
+ ...(props.tools?.items ?? []),
73
+ ...providedTools.value.map((tool) => toValue(tool)),
74
+ ])
75
+
76
+ const navbarUi = computed<DashboardNavbarProps['ui']>(() => ({
77
+ ...props.navbar?.ui,
78
+ toggle: mergeSlotClass(props.navbar?.ui?.toggle, '-ml-1'),
79
+ ...(props.navbar?.breadcrumb && {
80
+ root: mergeSlotClass(props.navbar.ui?.root, 'h-auto min-h-(--ui-header-height) py-2'),
81
+ }),
82
+ }))
48
83
  </script>
49
84
 
50
85
  <template>
51
86
  <UDashboardPanel v-bind="panel">
52
87
  <template #header>
53
- <UDashboardNavbar v-if="navbar" :ui="navbar.ui" class="bg-white" :title="navbar.title">
54
- <template v-if="navbar.showSidebarToggle" #leading>
55
- <UDashboardSidebarCollapse />
56
- </template>
88
+ <ForwardSlots v-if="navbar" :slots="navbarSlots">
89
+ <UDashboardNavbar :ui="navbarUi" class="bg-white" :title="navbar.title">
90
+ <template #left>
91
+ <UDashboardSidebarCollapse v-if="navbar.sidebarToggle" class="-ml-1" />
92
+
93
+ <div class="flex min-w-0 flex-col items-start gap-0.5">
94
+ <div class="flex min-w-0 items-center gap-1.5">
95
+ <h1 class="text-highlighted truncate font-semibold">
96
+ <slot name="navbar-title">{{ navbar.title }}</slot>
97
+ </h1>
98
+ <slot name="navbar-trailing" />
99
+ </div>
57
100
 
58
- <template v-for="(originalName, slotName) in navbarSlots" #[slotName]="slotData">
59
- <!-- @vue-ignore -->
60
- <slot :name="originalName" v-bind="slotData || {}" />
101
+ <UBreadcrumb
102
+ v-if="navbar.breadcrumb"
103
+ :items="navbar.breadcrumb"
104
+ :ui="{
105
+ ...navbar.breadcrumbUi,
106
+ link: mergeSlotClass(navbar.breadcrumbUi?.link, 'text-xs'),
107
+ separatorIcon: mergeSlotClass(navbar.breadcrumbUi?.separatorIcon, 'size-3.5'),
108
+ }"
109
+ />
110
+ </div>
111
+ </template>
112
+
113
+ <template #right>
114
+ <!-- eslint-disable-next-line vue/require-explicit-slots -->
115
+ <slot name="navbar-right" />
116
+ <div id="navbar-actions" class="flex items-center gap-2">
117
+ <slot name="navbar-actions" />
118
+ </div>
119
+ </template>
120
+ </UDashboardNavbar>
121
+ </ForwardSlots>
122
+ <UDashboardToolbar
123
+ v-if="tabs || toolItems.length"
124
+ :ui="toolbarUi"
125
+ class="bg-white *:first:-ml-2"
126
+ >
127
+ <template #left>
128
+ <UNavigationMenu v-if="tabs" :items="tabs.items" highlight variant="link" :ui="tabs.ui" />
129
+ <UNavigationMenu v-else :items="toolItems" highlight :ui="tools?.ui" />
130
+ </template>
131
+ <template v-if="tabs" #right>
132
+ <UNavigationMenu :items="toolItems" highlight :ui="tools?.ui" />
61
133
  </template>
62
- </UDashboardNavbar>
63
- <UDashboardToolbar v-if="toolbar" :ui="toolbar.ui" class="bg-white">
64
- <UNavigationMenu :items="toolbar.items" highlight :ui="toolbar.itemsUi" />
65
- <UNavigationMenu
66
- :items="toolbar.itemsEnd"
67
- class="ml-auto"
68
- highlight
69
- :ui="toolbar.itemsEndUi"
70
- />
71
134
  </UDashboardToolbar>
72
135
  </template>
73
136
  <template #body>
@@ -2,6 +2,7 @@
2
2
  import type {
3
3
  ArrayOrNested,
4
4
  AvatarProps,
5
+ DashboardSearchProps,
5
6
  DashboardSidebarProps,
6
7
  DropdownMenuItem,
7
8
  NavigationMenuItem,
@@ -15,6 +16,7 @@ defineProps<{
15
16
  src?: string
16
17
  iconSrc?: string
17
18
  }
19
+ search?: DashboardSearchProps
18
20
  items?: NavigationMenuItem[]
19
21
  bottomItems?: NavigationMenuItem[]
20
22
  userMenu?: {
@@ -55,18 +57,21 @@ const config = useRuntimeConfig()
55
57
  <slot v-if="!collapsed" name="logo">
56
58
  <img v-if="logo?.src" class="h-5 w-auto shrink-0" :src="logo.src" />
57
59
  </slot>
58
- <slot v-if="collapsed || (!logo?.src && !slots.logo)" name="icon">
59
- <img
60
- v-if="logo?.iconSrc"
61
- class="size-5"
62
- :src="logo.iconSrc"
63
- :class="{
64
- 'mx-auto': collapsed,
65
- }"
66
- />
67
- </slot>
60
+ <div v-if="collapsed || (!logo?.src && !slots.logo)" :class="{ 'mx-auto': collapsed }">
61
+ <slot name="icon">
62
+ <img v-if="logo?.iconSrc" class="size-5" :src="logo.iconSrc" />
63
+ </slot>
64
+ </div>
68
65
  </template>
69
66
  <template #default="{ collapsed }">
67
+ <!-- height and negative margins line the border up with the `LayoutNavbar` toolbar's, cancelling the sidebar body's padding -->
68
+ <div
69
+ v-if="search"
70
+ class="border-default -mx-4 -mt-2 -mb-2 flex h-[calc(--spacing(12)+1px)] shrink-0 items-center border-b px-4 sm:max-lg:-mx-6 sm:max-lg:px-6"
71
+ >
72
+ <UDashboardSearchButton :collapsed="collapsed" variant="outline" tooltip block />
73
+ </div>
74
+
70
75
  <UNavigationMenu
71
76
  v-if="items"
72
77
  :collapsed="collapsed"
@@ -105,6 +110,8 @@ const config = useRuntimeConfig()
105
110
  </template>
106
111
  </UDashboardSidebar>
107
112
 
113
+ <UDashboardSearch v-if="search" :color-mode="false" v-bind="search" />
114
+
108
115
  <slot />
109
116
  </UDashboardGroup>
110
117
  </template>
@@ -0,0 +1,43 @@
1
+ <script setup lang="ts">
2
+ import type { CardProps, CardSlots } from '@nuxt/ui'
3
+ import { useForwardProps } from 'reka-ui'
4
+
5
+ const props = defineProps<CardProps>()
6
+ const slots = defineSlots<
7
+ CardSlots & {
8
+ /** Ribbon strip above the body — fill it with `URibbonSection`s. */
9
+ ribbon?: () => any
10
+ }
11
+ >()
12
+
13
+ const forwarded = useForwardProps(props)
14
+
15
+ const cardSlots = computed(
16
+ () => Object.keys(slots).filter((name) => name !== 'ribbon') as (keyof CardSlots)[],
17
+ )
18
+ </script>
19
+
20
+ <template>
21
+ <div class="flex flex-col">
22
+ <!-- the ribbon's lower edge runs behind the card, so the card keeps its own rounded top -->
23
+ <div
24
+ v-if="slots.ribbon"
25
+ class="divide-default ring-default bg-elevated -mb-2 flex items-stretch divide-x overflow-x-auto rounded-t-lg pb-2 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"
26
+ >
27
+ <slot name="ribbon" />
28
+ </div>
29
+
30
+ <UCard
31
+ v-bind="forwarded"
32
+ :ui="{
33
+ body: 'p-0!',
34
+ ...forwarded.ui,
35
+ }"
36
+ class="ring-accented relative shadow-[0_-2px_3px_-1px_rgb(0_0_0/0.08)]"
37
+ >
38
+ <template v-for="name of cardSlots" #[name]>
39
+ <slot :name />
40
+ </template>
41
+ </UCard>
42
+ </div>
43
+ </template>
@@ -0,0 +1,29 @@
1
+ <script setup lang="ts">
2
+ defineOptions({
3
+ inheritAttrs: false,
4
+ })
5
+
6
+ defineProps<{
7
+ title?: string
8
+ end?: boolean
9
+ }>()
10
+
11
+ defineSlots<{
12
+ default: () => any
13
+ }>()
14
+ </script>
15
+
16
+ <template>
17
+ <div
18
+ :data-end="end || undefined"
19
+ class="flex shrink-0 flex-col gap-1.5 px-3 py-2"
20
+ :class="end && 'border-default [&:not([data-end]~*)]:ml-auto [&:not([data-end]~*)]:border-l'"
21
+ >
22
+ <p v-if="title" class="text-dimmed text-[10px] leading-none">
23
+ {{ title }}
24
+ </p>
25
+ <div class="flex flex-1 flex-wrap items-center gap-1" v-bind="$attrs">
26
+ <slot />
27
+ </div>
28
+ </div>
29
+ </template>
@@ -0,0 +1,41 @@
1
+ /**
2
+ * pins a table's column widths at their measured auto-layout size, so filtering rows out can't
3
+ * resize the columns underneath the user
4
+ *
5
+ * pass the `UTable` template ref — the widths re-measure when the table resizes or a column is
6
+ * shown or hidden, and `freeze()` re-measures them by hand once late data has rendered
7
+ */
8
+ export function useStableColumnWidths(
9
+ table: MaybeRefOrGetter<{ $el?: HTMLElement } | null | undefined>,
10
+ ) {
11
+ const tableEl = computed(() => toValue(table)?.$el?.querySelector('table') ?? null)
12
+ const headerRow = computed(() => tableEl.value?.tHead?.rows[0] ?? null)
13
+
14
+ /** the table width the current widths were measured at */
15
+ let measuredAt = 0
16
+
17
+ function freeze() {
18
+ const el = tableEl.value
19
+ const cells = headerRow.value && [...headerRow.value.cells]
20
+ if (!el || !cells) return
21
+
22
+ // measure the natural layout first — the pinned widths would otherwise fix the old one in place
23
+ el.style.tableLayout = ''
24
+ for (const cell of cells) cell.style.width = ''
25
+
26
+ const widths = cells.map((cell) => cell.getBoundingClientRect().width)
27
+ for (const [index, cell] of cells.entries()) cell.style.width = `${widths[index]!}px`
28
+ el.style.tableLayout = 'fixed'
29
+ measuredAt = el.getBoundingClientRect().width
30
+ }
31
+
32
+ onMounted(freeze)
33
+ // a resize gives the columns a different share of the table, so they re-measure
34
+ useResizeObserver(tableEl, () => {
35
+ if (tableEl.value?.getBoundingClientRect().width !== measuredAt) freeze()
36
+ })
37
+ // showing or hiding a column adds or removes header cells, so the pinned widths no longer line up
38
+ useMutationObserver(headerRow, freeze, { childList: true })
39
+
40
+ return { freeze }
41
+ }
@@ -0,0 +1,42 @@
1
+ import type { NavigationMenuItem } from '@nuxt/ui'
2
+ import type { InjectionKey, MaybeRefOrGetter, Ref } from 'vue'
3
+
4
+ export type ToolbarTool = NavigationMenuItem
5
+
6
+ export const toolbarToolsKey = Symbol('toolbar-tools') as InjectionKey<
7
+ Ref<MaybeRefOrGetter<ToolbarTool>[]>
8
+ >
9
+
10
+ /**
11
+ * Tools of the enclosing `LayoutNavbar`'s toolbar, so that anything rendered inside it — a nested
12
+ * `NuxtPage`, a deeply nested component — can contribute to it. Whoever renders `LayoutNavbar`
13
+ * itself passes its tools as props instead.
14
+ */
15
+ export function useToolbar() {
16
+ const injected = inject(toolbarToolsKey)
17
+ if (!injected) throw new Error('useToolbar() has to be called inside a `LayoutNavbar`')
18
+ const toolSources = injected
19
+
20
+ /**
21
+ * Appends a tool to the toolbar. Pass a getter to keep the tool reactive.
22
+ *
23
+ * The tool is removed again when the calling scope is disposed, i.e. when the component that
24
+ * added it unmounts.
25
+ */
26
+ function addTool(tool: MaybeRefOrGetter<ToolbarTool>) {
27
+ toolSources.value.push(tool)
28
+
29
+ const remove = () => {
30
+ const index = toolSources.value.indexOf(tool)
31
+ if (index !== -1) toolSources.value.splice(index, 1)
32
+ }
33
+ onScopeDispose(remove, true)
34
+
35
+ return remove
36
+ }
37
+
38
+ return {
39
+ tools: computed(() => toolSources.value.map((tool) => toValue(tool))),
40
+ addTool,
41
+ }
42
+ }
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.37.2",
4
+ "version": "0.38.0",
5
5
  "description": "Nuxt layer with lots of useful helpers and @nuxt/ui components",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -1,22 +0,0 @@
1
- <script setup lang="ts">
2
- import type { CardProps, CardSlots } from '@nuxt/ui'
3
- import { useForwardProps } from 'reka-ui'
4
- const props = defineProps<CardProps>()
5
- const slots = defineSlots<CardSlots>()
6
-
7
- const forwarded = useForwardProps(props)
8
- </script>
9
-
10
- <template>
11
- <UCard
12
- v-bind="forwarded"
13
- :ui="{
14
- body: 'p-0!',
15
- ...forwarded.ui,
16
- }"
17
- >
18
- <template v-for="(_, name) of slots" #[name]>
19
- <slot :name />
20
- </template>
21
- </UCard>
22
- </template>
@@ -1,3 +0,0 @@
1
- <template>
2
- <NuxtWelcome />
3
- </template>
@@ -1,55 +0,0 @@
1
- /* eslint-disable ts/no-empty-object-type */
2
- import type { AllUnionFields, UnionToTuple } from 'type-fest'
3
- import type {
4
- ComponentOptionsMixin,
5
- CreateComponentPublicInstanceWithMixins,
6
- EmitsOptions,
7
- EmitsToProps,
8
- PublicProps,
9
- RenderFunction,
10
- SetupContext,
11
- SlotsType,
12
- } from 'vue'
13
-
14
- export function defineSetupComponentOld<
15
- const Opts extends {
16
- props: Record<string, any>
17
- emits: Record<string, any>
18
- slots: Record<string, any>
19
- },
20
- Setup extends (
21
- props: Props,
22
- ctx: SetupContext<Opts['emits'], SlotsType<Partial<Opts['slots']>>>,
23
- ) => RenderFunction | Promise<RenderFunction>,
24
- const PropsRuntime extends Readonly<UnionToTuple<keyof AllUnionFields<Opts['props']>>>,
25
- E extends EmitsOptions = Opts['emits'],
26
- Props extends Record<string, any> = Opts['props'] & EmitsToProps<E>,
27
- PP = PublicProps & { vSlots?: Opts['slots'] },
28
- S extends SlotsType<Partial<Opts['slots']>> = SlotsType<Partial<Opts['slots']>>,
29
- >(
30
- define: (opts: Opts) => { props: PropsRuntime },
31
- setup: Setup,
32
- ): new (
33
- props: Opts['props'],
34
- ) => CreateComponentPublicInstanceWithMixins<
35
- Props,
36
- {},
37
- {},
38
- {},
39
- {},
40
- ComponentOptionsMixin,
41
- ComponentOptionsMixin,
42
- E,
43
- PP,
44
- {},
45
- false,
46
- {},
47
- S
48
- > {
49
- // eslint-disable-next-line ts/no-unsafe-argument
50
- const opts = define({} as any)
51
- // eslint-disable-next-line ts/no-unsafe-return
52
- return defineComponent(setup, {
53
- props: opts.props as unknown as string[],
54
- }) as any
55
- }