frappe-ui 1.0.0-beta.19 → 1.0.0-beta.20

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.
Files changed (85) hide show
  1. package/package.json +8 -1
  2. package/src/components/DesktopShell/DesktopShell.api.md +32 -0
  3. package/src/components/DesktopShell/DesktopShell.cy.ts +39 -0
  4. package/src/components/DesktopShell/DesktopShell.md +31 -0
  5. package/src/components/DesktopShell/DesktopShell.vue +58 -0
  6. package/src/components/DesktopShell/index.ts +3 -0
  7. package/src/components/DesktopShell/stories/Default.vue +97 -0
  8. package/src/components/DesktopShell/types.ts +2 -0
  9. package/src/components/MobileNav/MobileNav.api.md +71 -0
  10. package/src/components/MobileNav/MobileNav.cy.ts +89 -0
  11. package/src/components/MobileNav/MobileNav.md +34 -0
  12. package/src/components/MobileNav/MobileNav.vue +15 -0
  13. package/src/components/MobileNav/MobileNavItem.vue +125 -0
  14. package/src/components/MobileNav/index.ts +4 -0
  15. package/src/components/MobileNav/stories/Default.vue +40 -0
  16. package/src/components/MobileNav/types.ts +33 -0
  17. package/src/components/MobileShell/MobileShell.api.md +23 -0
  18. package/src/components/MobileShell/MobileShell.cy.ts +25 -0
  19. package/src/components/MobileShell/MobileShell.md +24 -0
  20. package/src/components/MobileShell/MobileShell.vue +41 -0
  21. package/src/components/MobileShell/index.ts +3 -0
  22. package/src/components/MobileShell/stories/Default.vue +75 -0
  23. package/src/components/MobileShell/types.ts +7 -0
  24. package/src/components/PageHeader/PageHeader.api.md +113 -0
  25. package/src/components/PageHeader/PageHeader.cy.ts +68 -0
  26. package/src/components/PageHeader/PageHeader.md +25 -0
  27. package/src/components/PageHeader/PageHeader.vue +13 -0
  28. package/src/components/PageHeader/PageHeaderBackButton.vue +29 -0
  29. package/src/components/PageHeader/PageHeaderBase.vue +51 -0
  30. package/src/components/PageHeader/PageHeaderMobile.vue +68 -0
  31. package/src/components/PageHeader/PageHeaderMobileTitle.vue +16 -0
  32. package/src/components/PageHeader/PageHeaderTarget.vue +13 -0
  33. package/src/components/PageHeader/index.ts +6 -0
  34. package/src/components/PageHeader/stories/Default.vue +19 -0
  35. package/src/components/PageHeader/stories/Mobile.vue +24 -0
  36. package/src/components/PageHeader/target.ts +19 -0
  37. package/src/components/PageHeader/types.ts +19 -0
  38. package/src/components/Rail/Rail.api.md +92 -0
  39. package/src/components/Rail/Rail.cy.ts +84 -0
  40. package/src/components/Rail/Rail.md +30 -0
  41. package/src/components/Rail/Rail.vue +16 -0
  42. package/src/components/Rail/RailItem.vue +134 -0
  43. package/src/components/Rail/RailItemBadge.vue +64 -0
  44. package/src/components/Rail/index.ts +2 -0
  45. package/src/components/Rail/stories/Default.vue +44 -0
  46. package/src/components/Rail/types.ts +37 -0
  47. package/src/components/ScrollArea/ScrollBar.vue +15 -3
  48. package/src/components/Sidebar/Sidebar.api.md +111 -74
  49. package/src/components/Sidebar/Sidebar.cy.ts +127 -76
  50. package/src/components/Sidebar/Sidebar.md +47 -3
  51. package/src/components/Sidebar/Sidebar.vue +64 -56
  52. package/src/components/Sidebar/SidebarCollapseToggle.vue +23 -0
  53. package/src/components/Sidebar/SidebarItem.vue +130 -97
  54. package/src/components/Sidebar/SidebarItemIcon.vue +25 -0
  55. package/src/components/Sidebar/SidebarLabel.vue +34 -0
  56. package/src/components/Sidebar/SidebarSection.vue +29 -14
  57. package/src/components/Sidebar/index.ts +13 -2
  58. package/src/components/Sidebar/stories/Collapsed.vue +40 -0
  59. package/src/components/Sidebar/stories/Default.vue +153 -0
  60. package/src/components/Sidebar/stories/Legacy.vue +36 -0
  61. package/src/components/Sidebar/types.ts +97 -20
  62. package/src/composables/useScreenSize.ts +46 -0
  63. package/src/composables/useScrollContainer.ts +108 -0
  64. package/src/index.ts +7 -0
  65. package/src/molecules/list/List.cy.ts +305 -0
  66. package/src/molecules/list/List.vue +42 -0
  67. package/src/molecules/list/ListCell.vue +15 -0
  68. package/src/molecules/list/ListHeader.vue +26 -0
  69. package/src/molecules/list/ListHeaderCell.vue +26 -0
  70. package/src/molecules/list/ListHeaderCellSort.vue +72 -0
  71. package/src/molecules/list/ListRow.vue +48 -0
  72. package/src/molecules/list/ListRowBase.vue +79 -0
  73. package/src/molecules/list/ListRows.vue +82 -0
  74. package/src/molecules/list/index.ts +23 -0
  75. package/src/molecules/list/list-context.ts +28 -0
  76. package/src/molecules/list/list.api.md +223 -0
  77. package/src/molecules/list/list.md +91 -0
  78. package/src/molecules/list/stories/Columns.vue +103 -0
  79. package/src/molecules/list/stories/Feed.vue +105 -0
  80. package/src/molecules/list/stories/Virtual.vue +31 -0
  81. package/src/molecules/list/style.css +140 -0
  82. package/src/molecules/list/types.ts +66 -0
  83. package/src/molecules/list/useVirtualRows.ts +80 -0
  84. package/tailwind/plugin.js +17 -1
  85. package/src/components/Sidebar/stories/Example.vue +0 -76
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frappe-ui",
3
- "version": "1.0.0-beta.19",
3
+ "version": "1.0.0-beta.20",
4
4
  "description": "A set of components and utilities for rapid UI development",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -56,6 +56,10 @@
56
56
  "import": "./src/molecules/editor/index.ts",
57
57
  "types": "./src/molecules/editor/index.ts"
58
58
  },
59
+ "./list": {
60
+ "import": "./src/molecules/list/index.ts",
61
+ "types": "./src/molecules/list/index.ts"
62
+ },
59
63
  "./code-editor": {
60
64
  "import": "./src/components/CodeEditor/index.ts",
61
65
  "types": "./src/components/CodeEditor/index.ts"
@@ -91,6 +95,9 @@
91
95
  "./editor-style.css": {
92
96
  "import": "./src/molecules/editor/style.css"
93
97
  },
98
+ "./list-style.css": {
99
+ "import": "./src/molecules/list/style.css"
100
+ },
94
101
  "./hljs-theme.css": {
95
102
  "import": "./src/components/TextEditor/hljs-github.css"
96
103
  },
@@ -0,0 +1,32 @@
1
+ <!-- Auto Generated by scripts/propsgen.ts -->
2
+ <script setup>
3
+ import PropsTable from '@/components/Docs/PropsTable.vue'
4
+ import SlotsTable from '@/components/Docs/SlotsTable.vue'
5
+ import EmitsTable from '@/components/Docs/EmitsTable.vue'
6
+
7
+ const propsData = []
8
+
9
+ const slotsData = [
10
+ {
11
+ name: 'rail',
12
+ description: '',
13
+ type: '{}'
14
+ },
15
+ {
16
+ name: 'sidebar',
17
+ description: '',
18
+ type: '{}'
19
+ },
20
+ {
21
+ name: 'default',
22
+ description: '',
23
+ type: '{}'
24
+ }
25
+ ]
26
+ </script>
27
+
28
+ ## API Reference
29
+
30
+ <PropsTable name="DesktopShell" :data="propsData"/>
31
+
32
+ <SlotsTable :data="slotsData"/>
@@ -0,0 +1,39 @@
1
+ import { h } from 'vue'
2
+ import DesktopShell from './DesktopShell.vue'
3
+ import { getScrollContainer } from '../../composables/useScrollContainer'
4
+
5
+ describe('<DesktopShell />', () => {
6
+ it('renders the rail, sidebar, and default content slots', () => {
7
+ cy.mount(DesktopShell, {
8
+ slots: {
9
+ rail: () => h('div', { 'data-test': 'rail' }, 'rail'),
10
+ sidebar: () => h('div', { 'data-test': 'sidebar' }, 'sidebar'),
11
+ default: () => h('div', { 'data-test': 'page' }, 'page body'),
12
+ },
13
+ })
14
+ cy.get('[data-slot=desktop-shell]').should('exist')
15
+ cy.get('[data-test=rail]').should('exist')
16
+ cy.get('[data-test=sidebar]').should('exist')
17
+ cy.get('[data-slot=desktop-shell-content]').should(
18
+ 'contain.text',
19
+ 'page body',
20
+ )
21
+ })
22
+
23
+ it('renders the structural content region without app styling', () => {
24
+ cy.mount(DesktopShell)
25
+ cy.get('[data-slot=desktop-shell-content]')
26
+ .should('have.class', 'flex')
27
+ .and('have.class', 'min-w-0')
28
+ .and('have.class', 'flex-1')
29
+ .and('not.have.class', 'rounded-lg')
30
+ })
31
+
32
+ it('registers its scroll region so getScrollContainer() resolves', () => {
33
+ cy.mount(DesktopShell, { slots: { default: () => h('div', 'content') } })
34
+ cy.get('[data-slot=desktop-shell-content]').should('exist')
35
+ cy.then(() => {
36
+ expect(getScrollContainer()).to.not.be.null
37
+ })
38
+ })
39
+ })
@@ -0,0 +1,31 @@
1
+ # DesktopShell
2
+
3
+ The desktop app frame. `DesktopShell` arranges an app's `Rail` and `Sidebar`
4
+ alongside the main content, and owns the skeleton the content needs: a pinned
5
+ `PageHeaderTarget` and a registered scroll region. Its mobile counterpart is a
6
+ separate family — [`MobileShell`](/docs/components/mobileshell) — because the
7
+ two are different navigation models, not one responsive component.
8
+
9
+ <ComponentPreview name="DesktopShell-Default" />
10
+
11
+ Put the icon column in `#rail`, the navigation panel in `#sidebar` (render it
12
+ conditionally to hide it on routes that don't need it), and the routed page in
13
+ the default slot. Pages declare their own `PageHeader` anywhere — it teleports
14
+ to the target the shell pins above the scroll region.
15
+
16
+ ## Scroll container
17
+
18
+ The shell registers its scroll region into a small module registry, so
19
+ [`useScrollContainer()`](/docs/other/utilities) and the plain
20
+ `getScrollContainer()` resolve it with no wiring — a page can scroll-to-top or a
21
+ router `scrollBehavior` can read the scroll offset without the app owning a
22
+ global. Because the registry is a stack, swapping `DesktopShell` for
23
+ `MobileShell` on a viewport change hands the active container over cleanly.
24
+
25
+ ## Theming the content region
26
+
27
+ The content region exposes `data-slot="desktop-shell-content"` for app-level
28
+ styling. Target that slot in CSS when an app needs a card, gutter, border,
29
+ background, or other product-specific surface treatment.
30
+
31
+ <!-- @include: ./DesktopShell.api.md -->
@@ -0,0 +1,58 @@
1
+ <template>
2
+ <div data-slot="desktop-shell" class="flex h-full">
3
+ <slot name="rail" />
4
+ <slot name="sidebar" />
5
+
6
+ <div
7
+ data-slot="desktop-shell-content"
8
+ class="flex min-w-0 flex-1 flex-col overflow-hidden"
9
+ >
10
+ <!-- Pages teleport their headers here; it stays pinned above the scroll. -->
11
+ <PageHeaderTarget />
12
+
13
+ <!--
14
+ The scroll region. Registered into the shared scroll-container registry
15
+ (via ScrollArea's exposed viewport element) so `useScrollContainer()` /
16
+ `getScrollContainer()` resolve it with zero app wiring.
17
+ -->
18
+ <ScrollArea ref="scrollArea" class="min-h-0 flex-1">
19
+ <slot />
20
+ </ScrollArea>
21
+ </div>
22
+ </div>
23
+ </template>
24
+
25
+ <script setup lang="ts">
26
+ import { onBeforeUnmount, ref, watch } from 'vue'
27
+ import ScrollArea from '../ScrollArea/ScrollArea.vue'
28
+ import PageHeaderTarget from '../PageHeader/PageHeaderTarget.vue'
29
+ import {
30
+ registerScrollContainer,
31
+ unregisterScrollContainer,
32
+ } from '../../composables/useScrollContainer'
33
+
34
+ const scrollArea = ref<InstanceType<typeof ScrollArea> | null>(null)
35
+
36
+ // Register the real scrolling viewport once ScrollArea exposes it, and follow it
37
+ // if it ever remounts. Unregister on teardown so a layout swap hands over to the
38
+ // other shell cleanly.
39
+ let registered: HTMLElement | null = null
40
+ watch(
41
+ () => scrollArea.value?.viewportElement ?? null,
42
+ (el) => {
43
+ if (registered && registered !== el) {
44
+ unregisterScrollContainer(registered)
45
+ registered = null
46
+ }
47
+ if (el && el !== registered) {
48
+ registerScrollContainer(el)
49
+ registered = el
50
+ }
51
+ },
52
+ { immediate: true },
53
+ )
54
+
55
+ onBeforeUnmount(() => {
56
+ if (registered) unregisterScrollContainer(registered)
57
+ })
58
+ </script>
@@ -0,0 +1,3 @@
1
+ export { default as DesktopShell } from './DesktopShell.vue'
2
+
3
+ export type { DesktopShellProps } from './types'
@@ -0,0 +1,97 @@
1
+ <script setup lang="ts">
2
+ import { ref } from 'vue'
3
+ import {
4
+ DesktopShell,
5
+ Rail,
6
+ RailItem,
7
+ Sidebar,
8
+ SidebarItem,
9
+ SidebarLabel,
10
+ PageHeader,
11
+ Button,
12
+ } from 'frappe-ui'
13
+
14
+ const community = ref('design')
15
+ const communities = [
16
+ { id: 'design', initials: 'DE' },
17
+ { id: 'engineering', initials: 'EN' },
18
+ { id: 'marketing', initials: 'MK' },
19
+ ]
20
+
21
+ const space = ref('Website')
22
+ const spaces = [
23
+ 'Website',
24
+ 'Brand',
25
+ 'Illustration',
26
+ 'Design System',
27
+ 'Marketing Site',
28
+ 'Mobile App',
29
+ 'Research',
30
+ 'Archive',
31
+ ]
32
+ </script>
33
+
34
+ <template>
35
+ <!-- Bounded height so the shell's `h-full` has something to fill in the preview. -->
36
+ <div class="h-[560px] overflow-hidden rounded-md border bg-surface-white">
37
+ <DesktopShell>
38
+ <template #rail>
39
+ <Rail>
40
+ <RailItem
41
+ label="Home"
42
+ variant="ghost"
43
+ icon="lucide-house"
44
+ :active="community === ''"
45
+ @click="community = ''"
46
+ />
47
+ <div class="flex w-full flex-1 flex-col items-center gap-3 pt-3">
48
+ <RailItem
49
+ v-for="c in communities"
50
+ :key="c.id"
51
+ :label="c.id"
52
+ :active="community === c.id"
53
+ @click="community = c.id"
54
+ >
55
+ <span class="text-2xs-medium uppercase text-ink-gray-5">{{ c.initials }}</span>
56
+ </RailItem>
57
+ </div>
58
+ <RailItem label="Search" variant="ghost" icon="lucide-search" />
59
+ </Rail>
60
+ </template>
61
+
62
+ <template #sidebar>
63
+ <Sidebar disable-collapse width="14rem">
64
+ <div class="px-3 py-2 text-base font-medium text-ink-gray-8">Acme Design</div>
65
+ <div class="flex-1 overflow-y-auto px-2 pb-2">
66
+ <SidebarLabel class="px-2">Spaces</SidebarLabel>
67
+ <SidebarItem
68
+ v-for="s in spaces"
69
+ :key="s"
70
+ :active="s === space"
71
+ @click="space = s"
72
+ >
73
+ <template #prefix><span class="lucide-hash size-4" aria-hidden="true" /></template>
74
+ {{ s }}
75
+ </SidebarItem>
76
+ </div>
77
+ </Sidebar>
78
+ </template>
79
+
80
+ <!-- Declared in the page; teleports to the target the shell pins on top. -->
81
+ <PageHeader>
82
+ <span class="text-lg font-semibold text-ink-gray-8">{{ space }}</span>
83
+ <Button variant="subtle" icon-left="lucide-plus" label="New post" />
84
+ </PageHeader>
85
+
86
+ <div class="p-5">
87
+ <p
88
+ v-for="n in 40"
89
+ :key="n"
90
+ class="mb-3 text-base text-ink-gray-7"
91
+ >
92
+ Row {{ n }} — scroll the content; the header stays pinned above it.
93
+ </p>
94
+ </div>
95
+ </DesktopShell>
96
+ </div>
97
+ </template>
@@ -0,0 +1,2 @@
1
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
2
+ export interface DesktopShellProps {}
@@ -0,0 +1,71 @@
1
+ <!-- Auto Generated by scripts/propsgen.ts -->
2
+ <script setup>
3
+ import PropsTable from '@/components/Docs/PropsTable.vue'
4
+ import SlotsTable from '@/components/Docs/SlotsTable.vue'
5
+ import EmitsTable from '@/components/Docs/EmitsTable.vue'
6
+
7
+ const mobileNavSlots = [
8
+ {
9
+ name: 'default',
10
+ description: '',
11
+ type: '{}'
12
+ }
13
+ ]
14
+
15
+ const mobileNavItemProps = [
16
+ {
17
+ name: 'label',
18
+ description: 'Label under the icon; also the accessible name.',
19
+ required: true,
20
+ type: 'string'
21
+ },
22
+ {
23
+ name: 'icon',
24
+ description: 'Icon CSS class, e.g. `lucide-home`. Ignored when the default slot is used\n(for a custom glyph or an avatar).',
25
+ required: false,
26
+ type: 'string'
27
+ },
28
+ {
29
+ name: 'to',
30
+ description: 'Navigation target. Renders a router link. Tapping the item while it is\nalready the current route scrolls the shell to the top instead of\nre-navigating.',
31
+ required: false,
32
+ type: 'string | kt | Tt'
33
+ },
34
+ {
35
+ name: 'active',
36
+ description: 'Highlight this item. Independent of the current route so one tab can stay\nlit across a whole section (e.g. Home across community routes). Defaults to\nwhether `to` resolves to the current route.',
37
+ required: false,
38
+ type: 'boolean'
39
+ }
40
+ ]
41
+
42
+ const mobileNavItemSlots = [
43
+ {
44
+ name: 'default',
45
+ description: '',
46
+ type: '{ active: boolean; }'
47
+ }
48
+ ]
49
+
50
+ const mobileNavItemEmits = [
51
+ {
52
+ name: 'click',
53
+ description: '',
54
+ type: '[event: MouseEvent]'
55
+ }
56
+ ]
57
+ </script>
58
+ ## API Reference
59
+
60
+ ### MobileNav
61
+
62
+ <SlotsTable :data="mobileNavSlots"/>
63
+
64
+ ### MobileNavItem
65
+
66
+ <PropsTable folder="MobileNav" name="MobileNavItem" :data="mobileNavItemProps"/>
67
+
68
+ <SlotsTable :data="mobileNavItemSlots"/>
69
+
70
+ <EmitsTable :data="mobileNavItemEmits"/>
71
+
@@ -0,0 +1,89 @@
1
+ import { h } from 'vue'
2
+ import { createMemoryHistory, createRouter } from 'vue-router'
3
+ import MobileNav from './MobileNav.vue'
4
+ import MobileNavItem from './MobileNavItem.vue'
5
+ import {
6
+ registerScrollContainer,
7
+ unregisterScrollContainer,
8
+ } from '../../composables/useScrollContainer'
9
+
10
+ function routerAt(path: string) {
11
+ const router = createRouter({
12
+ history: createMemoryHistory(),
13
+ routes: [
14
+ { path: '/', name: 'home', component: { template: '<div />' } },
15
+ { path: '/search', name: 'search', component: { template: '<div />' } },
16
+ ],
17
+ })
18
+ router.push(path)
19
+ return router
20
+ }
21
+
22
+ describe('<MobileNav />', () => {
23
+ it('lays its items out as an equal-width grid', () => {
24
+ cy.mount(MobileNav, {
25
+ slots: {
26
+ default: () => [
27
+ h(MobileNavItem, { label: 'Home', icon: 'lucide-house' }),
28
+ h(MobileNavItem, { label: 'Search', icon: 'lucide-search' }),
29
+ ],
30
+ },
31
+ })
32
+ cy.get('[data-slot=mobile-nav]').should('have.class', 'grid')
33
+ cy.get('[data-slot=mobile-nav-item]').should('have.length', 2)
34
+ })
35
+ })
36
+
37
+ describe('<MobileNavItem />', () => {
38
+ it('renders a button and emits click when there is no `to`', () => {
39
+ const onClick = cy.stub().as('click')
40
+ cy.mount(MobileNavItem, { props: { label: 'Home', icon: 'lucide-house', onClick } })
41
+ cy.get('button[data-slot=mobile-nav-item]').should('exist')
42
+ cy.get('button').click()
43
+ cy.get('@click').should('have.been.calledOnce')
44
+ })
45
+
46
+ it('renders a router link to a different route', () => {
47
+ cy.mount(MobileNavItem, {
48
+ props: { label: 'Search', icon: 'lucide-search', to: '/search' },
49
+ global: { plugins: [routerAt('/')] },
50
+ })
51
+ cy.get('a[data-slot=mobile-nav-item]').should('have.attr', 'href', '/search')
52
+ })
53
+
54
+ it('is a button (not a link) when `to` is already the current route', () => {
55
+ cy.mount(MobileNavItem, {
56
+ props: { label: 'Home', icon: 'lucide-house', to: '/' },
57
+ global: { plugins: [routerAt('/')] },
58
+ })
59
+ cy.get('button[data-slot=mobile-nav-item]').should('exist')
60
+ cy.get('a[data-slot=mobile-nav-item]').should('not.exist')
61
+ cy.get('[data-slot=mobile-nav-item]').should('have.attr', 'aria-current', 'page')
62
+ })
63
+
64
+ it('scrolls the registered container to the top when the current tab is tapped', () => {
65
+ const scroller = document.createElement('div')
66
+ Object.defineProperty(scroller, 'scrollHeight', { value: 2000 })
67
+ const scrollTo = cy.stub().as('scrollTo')
68
+ scroller.scrollTo = scrollTo
69
+ registerScrollContainer(scroller)
70
+
71
+ cy.mount(MobileNavItem, {
72
+ props: { label: 'Home', icon: 'lucide-house', to: '/' },
73
+ global: { plugins: [routerAt('/')] },
74
+ })
75
+ cy.get('button[data-slot=mobile-nav-item]').click()
76
+ cy.get('@scrollTo')
77
+ .should('have.been.calledOnce')
78
+ .then(() => unregisterScrollContainer(scroller))
79
+ })
80
+
81
+ it('reflects `active` in data-state and exposes it to the default slot', () => {
82
+ cy.mount(MobileNavItem, {
83
+ props: { label: 'You', active: true },
84
+ slots: { default: (props: { active: boolean }) => h('span', `active:${props.active}`) },
85
+ })
86
+ cy.get('[data-slot=mobile-nav-item][data-state=active]').should('exist')
87
+ cy.get('[data-slot=mobile-nav-item]').should('contain.text', 'active:true')
88
+ })
89
+ })
@@ -0,0 +1,34 @@
1
+ # MobileNav
2
+
3
+ The bottom tab bar for a [`MobileShell`](/docs/components/mobileshell).
4
+ `MobileNav` is a grid frame; each `MobileNavItem` becomes one equal-width tab, so
5
+ the bar adapts to any number of items.
6
+
7
+ <ComponentPreview name="MobileNav-Default" />
8
+
9
+ ## MobileNavItem
10
+
11
+ Each item takes a `label`, an `icon` (or a default slot for custom content like
12
+ an avatar), and a `to` target. It renders a router link when navigating
13
+ somewhere new, and — when it's already the current route — a button that scrolls
14
+ the shell's scroll container to the top instead of re-navigating.
15
+
16
+ `active` controls the highlight and is independent of the current route, so one
17
+ tab can stay lit across a whole section (e.g. Home across every community route)
18
+ while tapping it still navigates home. When `active` is omitted it defaults to
19
+ whether `to` matches the current route. The default slot receives `{ active }`
20
+ so custom content — an avatar, a badge — can react to the highlight:
21
+
22
+ ```vue
23
+ <MobileNavItem label="You" :to="{ name: 'More' }" :active="isMoreRoute">
24
+ <template #default="{ active }">
25
+ <UserAvatar :user="me" :class="{ 'ring-2 ring-outline-gray-4': active }" />
26
+ </template>
27
+ </MobileNavItem>
28
+ ```
29
+
30
+ Like `SidebarItem`, `MobileNavItem` is router-optional: mounted without
31
+ vue-router it degrades to a plain `<a>`/`<button>` with no warnings, so it works
32
+ in docs, tests, and embedded use.
33
+
34
+ <!-- @include: ./MobileNav.api.md -->
@@ -0,0 +1,15 @@
1
+ <template>
2
+ <nav
3
+ data-slot="mobile-nav"
4
+ class="grid shrink-0 auto-cols-fr grid-flow-col border-t border-outline-gray-2 bg-surface-elevation-2 [@media(display-mode:standalone)]:pb-4"
5
+ >
6
+ <slot />
7
+ </nav>
8
+ </template>
9
+
10
+ <script setup lang="ts">
11
+ // A bare grid frame: each MobileNavItem becomes one equal-width column
12
+ // (auto-cols-fr + grid-flow-col), so the bar adapts to any item count without
13
+ // hardcoding a column class. Bottom padding clears the home indicator in an
14
+ // installed PWA (display-mode: standalone).
15
+ </script>
@@ -0,0 +1,125 @@
1
+ <template>
2
+ <!--
3
+ A real link when navigating somewhere new; a plain button when this item is
4
+ already the current route (tap = scroll to top) or has no target. Switching
5
+ the element on `isCurrent` — rather than preventing RouterLink's default —
6
+ keeps the scroll-vs-navigate decision unambiguous. The 'button' string can't
7
+ go through <component :is> (it collides with the global <Button>), so the
8
+ button stays a literal v-else; the inner content is shared.
9
+ -->
10
+ <component
11
+ :is="linkComponent"
12
+ v-if="to && !isCurrent"
13
+ v-bind="linkAttrs"
14
+ data-slot="mobile-nav-item"
15
+ :data-state="resolvedActive ? 'active' : 'inactive'"
16
+ :aria-label="label"
17
+ :aria-current="isCurrent ? 'page' : undefined"
18
+ class="flex min-h-14 flex-col items-center justify-center gap-1 py-2 transition active:scale-95"
19
+ @click="emit('click', $event)"
20
+ >
21
+ <slot :active="resolvedActive">
22
+ <span
23
+ v-if="typeof icon === 'string'"
24
+ :class="[icon, resolvedActive ? 'text-ink-gray-8' : 'text-ink-gray-5']"
25
+ class="size-6"
26
+ aria-hidden="true"
27
+ />
28
+ <component
29
+ v-else-if="icon"
30
+ :is="icon"
31
+ :class="resolvedActive ? 'text-ink-gray-8' : 'text-ink-gray-5'"
32
+ class="size-6"
33
+ aria-hidden="true"
34
+ />
35
+ </slot>
36
+ <span
37
+ class="text-xs-medium"
38
+ :class="resolvedActive ? 'text-ink-gray-8' : 'text-ink-gray-5'"
39
+ >
40
+ {{ label }}
41
+ </span>
42
+ </component>
43
+
44
+ <button
45
+ v-else
46
+ type="button"
47
+ data-slot="mobile-nav-item"
48
+ :data-state="resolvedActive ? 'active' : 'inactive'"
49
+ :aria-label="label"
50
+ :aria-current="isCurrent ? 'page' : undefined"
51
+ class="flex min-h-14 flex-col items-center justify-center gap-1 py-2 transition active:scale-95"
52
+ @click="onButtonClick"
53
+ >
54
+ <slot :active="resolvedActive">
55
+ <span
56
+ v-if="typeof icon === 'string'"
57
+ :class="[icon, resolvedActive ? 'text-ink-gray-8' : 'text-ink-gray-5']"
58
+ class="size-6"
59
+ aria-hidden="true"
60
+ />
61
+ <component
62
+ v-else-if="icon"
63
+ :is="icon"
64
+ :class="resolvedActive ? 'text-ink-gray-8' : 'text-ink-gray-5'"
65
+ class="size-6"
66
+ aria-hidden="true"
67
+ />
68
+ </slot>
69
+ <span
70
+ class="text-xs-medium"
71
+ :class="resolvedActive ? 'text-ink-gray-8' : 'text-ink-gray-5'"
72
+ >
73
+ {{ label }}
74
+ </span>
75
+ </button>
76
+ </template>
77
+
78
+ <script setup lang="ts">
79
+ import { computed, getCurrentInstance } from 'vue'
80
+ import { RouterLink } from 'vue-router'
81
+ import { scrollToTop } from '../../composables/useScrollContainer'
82
+ import type { MobileNavItemProps } from './types'
83
+
84
+ const props = defineProps<MobileNavItemProps>()
85
+ const emit = defineEmits<{ click: [event: MouseEvent] }>()
86
+
87
+ // Read router/route off global properties (not useRouter/useRoute) so the item
88
+ // works without warnings when mounted outside a vue-router app — docs, tests,
89
+ // embedded use. Same approach as SidebarItem.
90
+ const globals = getCurrentInstance()?.appContext.config.globalProperties
91
+ const hasRouter = computed(() => Boolean(globals?.$router))
92
+
93
+ const linkComponent = computed(() => (hasRouter.value ? RouterLink : 'a'))
94
+ const linkAttrs = computed(() =>
95
+ hasRouter.value
96
+ ? { to: props.to }
97
+ : { href: typeof props.to === 'string' ? props.to : undefined },
98
+ )
99
+
100
+ const resolvedRoute = computed(() =>
101
+ props.to && globals?.$router ? globals.$router.resolve(props.to) : null,
102
+ )
103
+
104
+ // Is this item's target the exact current route? Drives scroll-vs-navigate and
105
+ // aria-current — distinct from `active`, which may span a whole section.
106
+ const isCurrent = computed(() => {
107
+ const target = resolvedRoute.value
108
+ const current = globals?.$route
109
+ if (!target || !current) return false
110
+ return target.name
111
+ ? current.name === target.name
112
+ : current.path === target.path
113
+ })
114
+
115
+ // Explicit `active` wins (broad section highlight); otherwise fall back to the
116
+ // current-route match so simple navs light up the current item automatically.
117
+ const resolvedActive = computed(() => props.active ?? isCurrent.value)
118
+
119
+ function onButtonClick(event: MouseEvent) {
120
+ // Reached only when this item is already current (or has no target): a tap
121
+ // scrolls the shell's scroll container to the top.
122
+ if (isCurrent.value) scrollToTop()
123
+ emit('click', event)
124
+ }
125
+ </script>
@@ -0,0 +1,4 @@
1
+ export { default as MobileNav } from './MobileNav.vue'
2
+ export { default as MobileNavItem } from './MobileNavItem.vue'
3
+
4
+ export type { MobileNavProps, MobileNavItemProps } from './types'