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.
- package/package.json +8 -1
- package/src/components/DesktopShell/DesktopShell.api.md +32 -0
- package/src/components/DesktopShell/DesktopShell.cy.ts +39 -0
- package/src/components/DesktopShell/DesktopShell.md +31 -0
- package/src/components/DesktopShell/DesktopShell.vue +58 -0
- package/src/components/DesktopShell/index.ts +3 -0
- package/src/components/DesktopShell/stories/Default.vue +97 -0
- package/src/components/DesktopShell/types.ts +2 -0
- package/src/components/MobileNav/MobileNav.api.md +71 -0
- package/src/components/MobileNav/MobileNav.cy.ts +89 -0
- package/src/components/MobileNav/MobileNav.md +34 -0
- package/src/components/MobileNav/MobileNav.vue +15 -0
- package/src/components/MobileNav/MobileNavItem.vue +125 -0
- package/src/components/MobileNav/index.ts +4 -0
- package/src/components/MobileNav/stories/Default.vue +40 -0
- package/src/components/MobileNav/types.ts +33 -0
- package/src/components/MobileShell/MobileShell.api.md +23 -0
- package/src/components/MobileShell/MobileShell.cy.ts +25 -0
- package/src/components/MobileShell/MobileShell.md +24 -0
- package/src/components/MobileShell/MobileShell.vue +41 -0
- package/src/components/MobileShell/index.ts +3 -0
- package/src/components/MobileShell/stories/Default.vue +75 -0
- package/src/components/MobileShell/types.ts +7 -0
- package/src/components/PageHeader/PageHeader.api.md +113 -0
- package/src/components/PageHeader/PageHeader.cy.ts +68 -0
- package/src/components/PageHeader/PageHeader.md +25 -0
- package/src/components/PageHeader/PageHeader.vue +13 -0
- package/src/components/PageHeader/PageHeaderBackButton.vue +29 -0
- package/src/components/PageHeader/PageHeaderBase.vue +51 -0
- package/src/components/PageHeader/PageHeaderMobile.vue +68 -0
- package/src/components/PageHeader/PageHeaderMobileTitle.vue +16 -0
- package/src/components/PageHeader/PageHeaderTarget.vue +13 -0
- package/src/components/PageHeader/index.ts +6 -0
- package/src/components/PageHeader/stories/Default.vue +19 -0
- package/src/components/PageHeader/stories/Mobile.vue +24 -0
- package/src/components/PageHeader/target.ts +19 -0
- package/src/components/PageHeader/types.ts +19 -0
- package/src/components/Rail/Rail.api.md +92 -0
- package/src/components/Rail/Rail.cy.ts +84 -0
- package/src/components/Rail/Rail.md +30 -0
- package/src/components/Rail/Rail.vue +16 -0
- package/src/components/Rail/RailItem.vue +134 -0
- package/src/components/Rail/RailItemBadge.vue +64 -0
- package/src/components/Rail/index.ts +2 -0
- package/src/components/Rail/stories/Default.vue +44 -0
- package/src/components/Rail/types.ts +37 -0
- package/src/components/ScrollArea/ScrollBar.vue +15 -3
- package/src/components/Sidebar/Sidebar.api.md +111 -74
- package/src/components/Sidebar/Sidebar.cy.ts +127 -76
- package/src/components/Sidebar/Sidebar.md +47 -3
- package/src/components/Sidebar/Sidebar.vue +64 -56
- package/src/components/Sidebar/SidebarCollapseToggle.vue +23 -0
- package/src/components/Sidebar/SidebarItem.vue +130 -97
- package/src/components/Sidebar/SidebarItemIcon.vue +25 -0
- package/src/components/Sidebar/SidebarLabel.vue +34 -0
- package/src/components/Sidebar/SidebarSection.vue +29 -14
- package/src/components/Sidebar/index.ts +13 -2
- package/src/components/Sidebar/stories/Collapsed.vue +40 -0
- package/src/components/Sidebar/stories/Default.vue +153 -0
- package/src/components/Sidebar/stories/Legacy.vue +36 -0
- package/src/components/Sidebar/types.ts +97 -20
- package/src/composables/useScreenSize.ts +46 -0
- package/src/composables/useScrollContainer.ts +108 -0
- package/src/index.ts +7 -0
- package/src/molecules/list/List.cy.ts +305 -0
- package/src/molecules/list/List.vue +42 -0
- package/src/molecules/list/ListCell.vue +15 -0
- package/src/molecules/list/ListHeader.vue +26 -0
- package/src/molecules/list/ListHeaderCell.vue +26 -0
- package/src/molecules/list/ListHeaderCellSort.vue +72 -0
- package/src/molecules/list/ListRow.vue +48 -0
- package/src/molecules/list/ListRowBase.vue +79 -0
- package/src/molecules/list/ListRows.vue +82 -0
- package/src/molecules/list/index.ts +23 -0
- package/src/molecules/list/list-context.ts +28 -0
- package/src/molecules/list/list.api.md +223 -0
- package/src/molecules/list/list.md +91 -0
- package/src/molecules/list/stories/Columns.vue +103 -0
- package/src/molecules/list/stories/Feed.vue +105 -0
- package/src/molecules/list/stories/Virtual.vue +31 -0
- package/src/molecules/list/style.css +140 -0
- package/src/molecules/list/types.ts +66 -0
- package/src/molecules/list/useVirtualRows.ts +80 -0
- package/tailwind/plugin.js +17 -1
- 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.
|
|
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,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,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>
|