frappe-ui 1.0.0-beta.65 → 1.0.0-beta.66
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 +3 -2
- package/scripts/migrate-destinations-v1.js +229 -0
- package/src/components/Button/Button.api.md +2 -2
- package/src/components/Button/Button.cy.ts +4 -4
- package/src/components/Button/Button.vue +2 -2
- package/src/components/Button/types.ts +2 -2
- package/src/components/MobileNav/MobileNav.api.md +8 -2
- package/src/components/MobileNav/MobileNav.cy.ts +18 -5
- package/src/components/MobileNav/MobileNav.md +6 -5
- package/src/components/MobileNav/MobileNavItem.vue +10 -6
- package/src/components/MobileNav/types.ts +5 -2
- package/src/components/PageHeader/PageHeader.api.md +1 -1
- package/src/components/PageHeader/PageHeader.cy.ts +24 -0
- package/src/components/PageHeader/PageHeader.md +3 -2
- package/src/components/PageHeader/PageHeaderBackButton.vue +1 -1
- package/src/components/PageHeader/types.ts +1 -1
- package/src/components/PageHeader/useHeaderBack.ts +8 -8
- package/src/components/Sidebar/Sidebar.api.md +8 -2
- package/src/components/Sidebar/Sidebar.cy.ts +13 -4
- package/src/components/Sidebar/Sidebar.md +5 -3
- package/src/components/Sidebar/SidebarItem.vue +19 -8
- package/src/components/Sidebar/types.ts +5 -2
- package/src/components/SidebarRail/SidebarRail.api.md +7 -1
- package/src/components/SidebarRail/SidebarRail.cy.ts +14 -3
- package/src/components/SidebarRail/SidebarRail.md +2 -1
- package/src/components/SidebarRail/SidebarRailItem.vue +9 -9
- package/src/components/SidebarRail/types.ts +4 -1
- package/src/components/TabButtons/TabButtons.md +4 -5
- package/src/components/TabButtons/TabButtons.vue +3 -3
- package/src/components/TabButtons/types.ts +11 -4
- package/src/molecules/list/List.cy.ts +6 -4
- package/src/molecules/list/ListRow.vue +4 -1
- package/src/molecules/list/list.api.md +8 -2
- package/src/molecules/list/list.md +4 -3
- package/src/molecules/list/types.ts +5 -2
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.66",
|
|
4
4
|
"description": "A set of components and utilities for rapid UI development",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=20.19.0"
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
},
|
|
21
21
|
"bin": {
|
|
22
22
|
"tokens-v2": "./tailwind/migrate-tokens-v2.js",
|
|
23
|
-
"shortcuts-v1": "./scripts/migrate-shortcuts-v1.js"
|
|
23
|
+
"shortcuts-v1": "./scripts/migrate-shortcuts-v1.js",
|
|
24
|
+
"destinations-v1": "./scripts/migrate-destinations-v1.js"
|
|
24
25
|
},
|
|
25
26
|
"scripts": {
|
|
26
27
|
"test": "vitest --run",
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Rename pre-v1 navigation props to the v1 destination vocabulary.
|
|
3
|
+
* Usage: destinations-v1 [--dry-run] <dir-or-file...>
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'node:fs'
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
import { fileURLToPath } from 'node:url'
|
|
8
|
+
|
|
9
|
+
const USAGE = 'Usage: destinations-v1 [--dry-run] <dir-or-file...>'
|
|
10
|
+
const EXTENSIONS = new Set(['.vue', '.md'])
|
|
11
|
+
const PROP_RENAMES = {
|
|
12
|
+
Button: { link: 'href' },
|
|
13
|
+
PageHeaderBackButton: { to: 'fallback-route' },
|
|
14
|
+
ListRow: { to: 'route' },
|
|
15
|
+
SidebarItem: { to: 'route' },
|
|
16
|
+
SidebarRailItem: { to: 'route' },
|
|
17
|
+
MobileNavItem: { to: 'route' },
|
|
18
|
+
}
|
|
19
|
+
const kebab = (name) =>
|
|
20
|
+
name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
|
|
21
|
+
|
|
22
|
+
function componentAliases(source) {
|
|
23
|
+
const aliases = new Map()
|
|
24
|
+
const imports =
|
|
25
|
+
/import\s*\{([^}]+)\}\s*from\s*['"]frappe-ui(?:\/[^'"]*)?['"]/g
|
|
26
|
+
for (const match of source.matchAll(imports)) {
|
|
27
|
+
for (const binding of match[1].split(',')) {
|
|
28
|
+
const [imported, local] = binding
|
|
29
|
+
.trim()
|
|
30
|
+
.replace(/^type\s+/, '')
|
|
31
|
+
.split(/\s+as\s+/)
|
|
32
|
+
if (PROP_RENAMES[imported]) {
|
|
33
|
+
const binding = local || imported
|
|
34
|
+
aliases.set(binding, imported)
|
|
35
|
+
aliases.set(kebab(binding), imported)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// Local template bindings shadow imports with the same local name.
|
|
40
|
+
const localImports =
|
|
41
|
+
/import\s+([^'";]+?)\s+from\s*['"](?!frappe-ui(?:\/|['"]))[^'"]+['"]/g
|
|
42
|
+
for (const match of source.matchAll(localImports)) {
|
|
43
|
+
const clause = match[1].trim()
|
|
44
|
+
const bindings = []
|
|
45
|
+
if (!clause.startsWith('{') && !clause.startsWith('*')) {
|
|
46
|
+
bindings.push(clause.split(',')[0].trim())
|
|
47
|
+
}
|
|
48
|
+
const named = clause.match(/\{([^}]+)\}/)?.[1]
|
|
49
|
+
if (named) {
|
|
50
|
+
for (const binding of named.split(',')) {
|
|
51
|
+
bindings.push(
|
|
52
|
+
binding
|
|
53
|
+
.trim()
|
|
54
|
+
.split(/\s+as\s+/)
|
|
55
|
+
.at(-1),
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
for (const name of bindings) {
|
|
60
|
+
if (!PROP_RENAMES[name]) continue
|
|
61
|
+
aliases.delete(name)
|
|
62
|
+
aliases.delete(kebab(name))
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return aliases
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function renameAttributes(tag, renames) {
|
|
69
|
+
let migrated = ''
|
|
70
|
+
let cursor = 0
|
|
71
|
+
let quote
|
|
72
|
+
while (cursor < tag.length) {
|
|
73
|
+
const character = tag[cursor]
|
|
74
|
+
if (quote) {
|
|
75
|
+
migrated += character
|
|
76
|
+
if (character === quote && tag[cursor - 1] !== '\\') quote = undefined
|
|
77
|
+
cursor += 1
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
if (character === '"' || character === "'") {
|
|
81
|
+
quote = character
|
|
82
|
+
migrated += character
|
|
83
|
+
cursor += 1
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
if (!/\s/.test(character)) {
|
|
87
|
+
migrated += character
|
|
88
|
+
cursor += 1
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
migrated += character
|
|
92
|
+
cursor += 1
|
|
93
|
+
const match = tag
|
|
94
|
+
.slice(cursor)
|
|
95
|
+
.match(
|
|
96
|
+
/^(v-bind:|[:.]?)([A-Za-z][\w-]*)((?:\.[A-Za-z][\w-]*)*)(?=[\s=/>])/,
|
|
97
|
+
)
|
|
98
|
+
if (!match) continue
|
|
99
|
+
const replacement = renames[match[2]]
|
|
100
|
+
if (replacement) {
|
|
101
|
+
const isShorthandBinding =
|
|
102
|
+
match[1] && tag[cursor + match[0].length] !== '='
|
|
103
|
+
migrated += `${match[1]}${replacement}${match[3]}`
|
|
104
|
+
if (isShorthandBinding) migrated += `="${match[2]}"`
|
|
105
|
+
} else {
|
|
106
|
+
migrated += match[0]
|
|
107
|
+
}
|
|
108
|
+
cursor += match[0].length
|
|
109
|
+
}
|
|
110
|
+
return migrated
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function migrateMarkup(source, aliases) {
|
|
114
|
+
let migrated = ''
|
|
115
|
+
let cursor = 0
|
|
116
|
+
while (cursor < source.length) {
|
|
117
|
+
const start = source.indexOf('<', cursor)
|
|
118
|
+
if (start === -1) return migrated + source.slice(cursor)
|
|
119
|
+
migrated += source.slice(cursor, start)
|
|
120
|
+
if (source.startsWith('<!--', start)) {
|
|
121
|
+
const commentEnd = source.indexOf('-->', start + 4)
|
|
122
|
+
const next = commentEnd === -1 ? source.length : commentEnd + 3
|
|
123
|
+
migrated += source.slice(start, next)
|
|
124
|
+
cursor = next
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
const nameMatch = source.slice(start + 1).match(/^([A-Za-z][\w.-]*)/)
|
|
128
|
+
if (!nameMatch) {
|
|
129
|
+
migrated += '<'
|
|
130
|
+
cursor = start + 1
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
let end = start + 1 + nameMatch[1].length
|
|
134
|
+
let quote
|
|
135
|
+
for (; end < source.length; end += 1) {
|
|
136
|
+
const character = source[end]
|
|
137
|
+
if (quote) {
|
|
138
|
+
if (character === quote && source[end - 1] !== '\\') quote = undefined
|
|
139
|
+
} else if (character === '"' || character === "'") {
|
|
140
|
+
quote = character
|
|
141
|
+
} else if (character === '>') {
|
|
142
|
+
break
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (end === source.length) return migrated + source.slice(start)
|
|
146
|
+
const tag = source.slice(start, end + 1)
|
|
147
|
+
const component = aliases.get(nameMatch[1])
|
|
148
|
+
if (!component) {
|
|
149
|
+
migrated += tag
|
|
150
|
+
cursor = end + 1
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
migrated += renameAttributes(tag, PROP_RENAMES[component])
|
|
154
|
+
cursor = end + 1
|
|
155
|
+
}
|
|
156
|
+
return migrated
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Rename destination attributes on template components explicitly imported
|
|
161
|
+
* from frappe-ui, leaving scripts and ambiguous global components unchanged.
|
|
162
|
+
*/
|
|
163
|
+
export function migrateDestinations(source) {
|
|
164
|
+
const aliases = componentAliases(source)
|
|
165
|
+
const templateStart = source.indexOf('<template')
|
|
166
|
+
const templateEnd = source.lastIndexOf('</template>')
|
|
167
|
+
if (templateStart === -1 || templateEnd < templateStart)
|
|
168
|
+
return migrateMarkup(source, aliases)
|
|
169
|
+
const end = templateEnd + '</template>'.length
|
|
170
|
+
return (
|
|
171
|
+
source.slice(0, templateStart) +
|
|
172
|
+
migrateMarkup(source.slice(templateStart, end), aliases) +
|
|
173
|
+
source.slice(end)
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function filesIn(target, seen = new Set()) {
|
|
178
|
+
const stat = fs.lstatSync(target)
|
|
179
|
+
if (stat.isSymbolicLink()) return []
|
|
180
|
+
if (stat.isFile()) return EXTENSIONS.has(path.extname(target)) ? [target] : []
|
|
181
|
+
const real = fs.realpathSync(target)
|
|
182
|
+
if (seen.has(real)) return []
|
|
183
|
+
seen.add(real)
|
|
184
|
+
return fs
|
|
185
|
+
.readdirSync(target, { withFileTypes: true })
|
|
186
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
187
|
+
.flatMap((entry) => {
|
|
188
|
+
if (['node_modules', '.git', 'dist', 'build'].includes(entry.name))
|
|
189
|
+
return []
|
|
190
|
+
return filesIn(path.join(target, entry.name), seen)
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Run the destinations-v1 CLI and return its process exit code. */
|
|
195
|
+
export function run(argv) {
|
|
196
|
+
const dryRun = argv.includes('--dry-run')
|
|
197
|
+
const unknown = argv.find((arg) => arg.startsWith('-') && arg !== '--dry-run')
|
|
198
|
+
if (unknown) {
|
|
199
|
+
console.error(`Unknown option: ${unknown}\n${USAGE}`)
|
|
200
|
+
return 1
|
|
201
|
+
}
|
|
202
|
+
const targets = argv.filter((arg) => arg !== '--dry-run')
|
|
203
|
+
if (!targets.length) {
|
|
204
|
+
console.error(USAGE)
|
|
205
|
+
return 1
|
|
206
|
+
}
|
|
207
|
+
let changed = 0
|
|
208
|
+
const files = [
|
|
209
|
+
...new Set(targets.flatMap((target) => filesIn(path.resolve(target)))),
|
|
210
|
+
]
|
|
211
|
+
for (const file of files) {
|
|
212
|
+
const source = fs.readFileSync(file, 'utf8')
|
|
213
|
+
const migrated = migrateDestinations(source)
|
|
214
|
+
if (migrated === source) continue
|
|
215
|
+
changed += 1
|
|
216
|
+
if (!dryRun) fs.writeFileSync(file, migrated)
|
|
217
|
+
console.log(`${dryRun ? 'Would migrate' : 'Migrated'} ${file}`)
|
|
218
|
+
}
|
|
219
|
+
console.log(
|
|
220
|
+
`${dryRun ? 'Would migrate' : 'Migrated'} ${changed} file${changed === 1 ? '' : 's'}.`,
|
|
221
|
+
)
|
|
222
|
+
return 0
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const script = fileURLToPath(import.meta.url)
|
|
226
|
+
const isMain =
|
|
227
|
+
process.argv[1] &&
|
|
228
|
+
fs.realpathSync(process.argv[1]) === fs.realpathSync(script)
|
|
229
|
+
if (isMain) process.exitCode = run(process.argv.slice(2))
|
|
@@ -167,10 +167,10 @@ describe('<Button />', () => {
|
|
|
167
167
|
cy.get('button').should('not.exist')
|
|
168
168
|
})
|
|
169
169
|
|
|
170
|
-
it('renders an anchor when
|
|
170
|
+
it('renders an anchor when href is provided', () => {
|
|
171
171
|
cy.mount(Button, {
|
|
172
172
|
props: {
|
|
173
|
-
|
|
173
|
+
href: 'https://frappe.io/docs',
|
|
174
174
|
label: 'Docs',
|
|
175
175
|
},
|
|
176
176
|
})
|
|
@@ -193,7 +193,7 @@ describe('<Button />', () => {
|
|
|
193
193
|
cy.get('button').should('be.disabled')
|
|
194
194
|
})
|
|
195
195
|
|
|
196
|
-
it('falls back to a disabled button when route or
|
|
196
|
+
it('falls back to a disabled button when route or href is blocked', () => {
|
|
197
197
|
const router = createTestRouter()
|
|
198
198
|
|
|
199
199
|
cy.mount(Button, {
|
|
@@ -211,7 +211,7 @@ describe('<Button />', () => {
|
|
|
211
211
|
|
|
212
212
|
cy.mount(Button, {
|
|
213
213
|
props: {
|
|
214
|
-
|
|
214
|
+
href: 'https://frappe.io/docs',
|
|
215
215
|
loading: true,
|
|
216
216
|
loadingText: 'Loading docs',
|
|
217
217
|
label: 'Docs',
|
|
@@ -251,11 +251,11 @@ export default defineComponent({
|
|
|
251
251
|
if (!isDisabled.value && props.route) {
|
|
252
252
|
return { is: RouterLink, props: { to: props.route } }
|
|
253
253
|
}
|
|
254
|
-
if (!isDisabled.value && props.
|
|
254
|
+
if (!isDisabled.value && props.href) {
|
|
255
255
|
return {
|
|
256
256
|
is: 'a',
|
|
257
257
|
props: {
|
|
258
|
-
href: props.
|
|
258
|
+
href: props.href,
|
|
259
259
|
target: '_blank',
|
|
260
260
|
rel: 'noreferrer noopener',
|
|
261
261
|
},
|
|
@@ -43,8 +43,8 @@ export const buttonProps = {
|
|
|
43
43
|
type: [String, Object] as PropType<RouterLinkProps['to']>,
|
|
44
44
|
default: undefined,
|
|
45
45
|
},
|
|
46
|
-
/** External
|
|
47
|
-
|
|
46
|
+
/** External URL, opened in a new tab. */
|
|
47
|
+
href: { type: String, default: undefined },
|
|
48
48
|
/** Native button type */
|
|
49
49
|
type: {
|
|
50
50
|
type: String as PropType<'button' | 'submit' | 'reset'>,
|
|
@@ -26,14 +26,20 @@
|
|
|
26
26
|
type: 'string | Component'
|
|
27
27
|
},
|
|
28
28
|
{
|
|
29
|
-
name: '
|
|
29
|
+
name: 'route',
|
|
30
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
31
|
required: false,
|
|
32
32
|
type: 'string | kt | Tt'
|
|
33
33
|
},
|
|
34
|
+
{
|
|
35
|
+
name: 'href',
|
|
36
|
+
description: 'External URL. Used when `route` is absent; renders a native same-tab anchor.',
|
|
37
|
+
required: false,
|
|
38
|
+
type: 'string'
|
|
39
|
+
},
|
|
34
40
|
{
|
|
35
41
|
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 `
|
|
42
|
+
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 `route` resolves to the current route.',
|
|
37
43
|
required: false,
|
|
38
44
|
type: 'boolean'
|
|
39
45
|
}
|
|
@@ -35,7 +35,7 @@ describe('<MobileNav />', () => {
|
|
|
35
35
|
})
|
|
36
36
|
|
|
37
37
|
describe('<MobileNavItem />', () => {
|
|
38
|
-
it('renders a button and emits click when there is no
|
|
38
|
+
it('renders a button and emits click when there is no destination', () => {
|
|
39
39
|
const onClick = cy.stub().as('click')
|
|
40
40
|
cy.mount(MobileNavItem, { props: { label: 'Home', icon: 'lucide-house', onClick } })
|
|
41
41
|
cy.get('button[data-slot=mobile-nav-item]').should('exist')
|
|
@@ -45,15 +45,28 @@ describe('<MobileNavItem />', () => {
|
|
|
45
45
|
|
|
46
46
|
it('renders a router link to a different route', () => {
|
|
47
47
|
cy.mount(MobileNavItem, {
|
|
48
|
-
props: { label: 'Search', icon: 'lucide-search',
|
|
48
|
+
props: { label: 'Search', icon: 'lucide-search', route: '/search' },
|
|
49
49
|
global: { plugins: [routerAt('/')] },
|
|
50
50
|
})
|
|
51
51
|
cy.get('a[data-slot=mobile-nav-item]').should('have.attr', 'href', '/search')
|
|
52
52
|
})
|
|
53
53
|
|
|
54
|
-
it('
|
|
54
|
+
it('renders a plain anchor for href', () => {
|
|
55
55
|
cy.mount(MobileNavItem, {
|
|
56
|
-
props: { label: '
|
|
56
|
+
props: { label: 'Docs', icon: 'lucide-book-open', href: 'https://frappe.io/docs' },
|
|
57
|
+
})
|
|
58
|
+
cy.get('a[data-slot=mobile-nav-item]').should('have.attr', 'href', 'https://frappe.io/docs')
|
|
59
|
+
|
|
60
|
+
cy.mount(MobileNavItem, {
|
|
61
|
+
props: { label: 'Route wins', route: '/search', href: 'https://frappe.io/docs' },
|
|
62
|
+
global: { plugins: [routerAt('/')] },
|
|
63
|
+
})
|
|
64
|
+
cy.get('a[data-slot=mobile-nav-item]').should('have.attr', 'href', '/search')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('is a button (not a link) when `route` is already the current route', () => {
|
|
68
|
+
cy.mount(MobileNavItem, {
|
|
69
|
+
props: { label: 'Home', icon: 'lucide-house', route: '/' },
|
|
57
70
|
global: { plugins: [routerAt('/')] },
|
|
58
71
|
})
|
|
59
72
|
cy.get('button[data-slot=mobile-nav-item]').should('exist')
|
|
@@ -69,7 +82,7 @@ describe('<MobileNavItem />', () => {
|
|
|
69
82
|
registerShellScrollContainer(scroller)
|
|
70
83
|
|
|
71
84
|
cy.mount(MobileNavItem, {
|
|
72
|
-
props: { label: 'Home', icon: 'lucide-house',
|
|
85
|
+
props: { label: 'Home', icon: 'lucide-house', route: '/' },
|
|
73
86
|
global: { plugins: [routerAt('/')] },
|
|
74
87
|
})
|
|
75
88
|
cy.get('button[data-slot=mobile-nav-item]').click()
|
|
@@ -9,18 +9,19 @@ the bar adapts to any number of items.
|
|
|
9
9
|
## MobileNavItem
|
|
10
10
|
|
|
11
11
|
Each item takes a `label`, an `icon` (or a default slot for custom content like
|
|
12
|
-
an avatar), and a `
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
an avatar), and a `route` or `href` target (`route` takes precedence). `href`
|
|
13
|
+
renders a native same-tab anchor. A `route` renders a link when navigating
|
|
14
|
+
somewhere new and — when it is already current — a button that scrolls the
|
|
15
|
+
shell's scroll container to the top instead of re-navigating.
|
|
15
16
|
|
|
16
17
|
`active` controls the highlight and is independent of the current route, so one
|
|
17
18
|
tab can stay lit across a whole section (e.g. Home across every community route)
|
|
18
19
|
while tapping it still navigates home. When `active` is omitted it defaults to
|
|
19
|
-
whether `
|
|
20
|
+
whether `route` matches the current route. The default slot receives `{ active }`
|
|
20
21
|
so custom content — an avatar, a badge — can react to the highlight:
|
|
21
22
|
|
|
22
23
|
```vue
|
|
23
|
-
<MobileNavItem label="You" :
|
|
24
|
+
<MobileNavItem label="You" :route="{ name: 'More' }" :active="isMoreRoute">
|
|
24
25
|
<template #default="{ active }">
|
|
25
26
|
<UserAvatar :user="me" :class="{ 'ring-2 ring-outline-gray-4': active }" />
|
|
26
27
|
</template>
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
-->
|
|
10
10
|
<component
|
|
11
11
|
:is="linkComponent"
|
|
12
|
-
v-if="
|
|
12
|
+
v-if="(route && !isCurrent) || (!route && href)"
|
|
13
13
|
v-bind="linkAttrs"
|
|
14
14
|
data-slot="mobile-nav-item"
|
|
15
15
|
:data-state="resolvedActive ? 'active' : 'inactive'"
|
|
@@ -94,15 +94,19 @@ defineSlots<{
|
|
|
94
94
|
const globals = getCurrentInstance()?.appContext.config.globalProperties
|
|
95
95
|
const hasRouter = computed(() => Boolean(globals?.$router))
|
|
96
96
|
|
|
97
|
-
const linkComponent = computed(() =>
|
|
97
|
+
const linkComponent = computed(() =>
|
|
98
|
+
props.route && hasRouter.value ? RouterLink : 'a',
|
|
99
|
+
)
|
|
98
100
|
const linkAttrs = computed(() =>
|
|
99
|
-
|
|
100
|
-
?
|
|
101
|
-
|
|
101
|
+
props.route
|
|
102
|
+
? hasRouter.value
|
|
103
|
+
? { to: props.route }
|
|
104
|
+
: { href: typeof props.route === 'string' ? props.route : undefined }
|
|
105
|
+
: { href: props.href },
|
|
102
106
|
)
|
|
103
107
|
|
|
104
108
|
const resolvedRoute = computed(() =>
|
|
105
|
-
props.
|
|
109
|
+
props.route && globals?.$router ? globals.$router.resolve(props.route) : null,
|
|
106
110
|
)
|
|
107
111
|
|
|
108
112
|
// Is this item's target the exact current route? Drives scroll-vs-navigate and
|
|
@@ -22,12 +22,15 @@ export interface MobileNavItemProps {
|
|
|
22
22
|
* already the current route scrolls the shell to the top instead of
|
|
23
23
|
* re-navigating.
|
|
24
24
|
*/
|
|
25
|
-
|
|
25
|
+
route?: RouteLocationRaw
|
|
26
|
+
|
|
27
|
+
/** External URL. Used when `route` is absent; renders a native same-tab anchor. */
|
|
28
|
+
href?: string
|
|
26
29
|
|
|
27
30
|
/**
|
|
28
31
|
* Highlight this item. Independent of the current route so one tab can stay
|
|
29
32
|
* lit across a whole section (e.g. Home across community routes). Defaults to
|
|
30
|
-
* whether `
|
|
33
|
+
* whether `route` resolves to the current route.
|
|
31
34
|
*/
|
|
32
35
|
active?: boolean
|
|
33
36
|
}
|
|
@@ -88,7 +88,7 @@
|
|
|
88
88
|
|
|
89
89
|
const pageHeaderBackButtonProps = [
|
|
90
90
|
{
|
|
91
|
-
name: '
|
|
91
|
+
name: 'fallbackRoute',
|
|
92
92
|
description: 'Fallback destination, used only when there is no in-app history to go back to\n(a cold load onto a deep link). Taps otherwise go back through history.',
|
|
93
93
|
required: false,
|
|
94
94
|
type: 'string | kt | Tt'
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { defineComponent, h } from 'vue'
|
|
2
|
+
import { createMemoryHistory, createRouter } from 'vue-router'
|
|
2
3
|
import PageHeader from './PageHeader.vue'
|
|
3
4
|
import PageHeaderBase from './PageHeaderBase.vue'
|
|
5
|
+
import PageHeaderBackButton from './PageHeaderBackButton.vue'
|
|
4
6
|
import PageHeaderTarget from './PageHeaderTarget.vue'
|
|
5
7
|
import PageHeaderMobile from './PageHeaderMobile.vue'
|
|
6
8
|
import PageHeaderMobileTitle from './PageHeaderMobileTitle.vue'
|
|
@@ -70,6 +72,28 @@ describe('PageHeader', () => {
|
|
|
70
72
|
})
|
|
71
73
|
})
|
|
72
74
|
|
|
75
|
+
describe('PageHeaderBackButton', () => {
|
|
76
|
+
it('uses fallbackRoute after a cold load', () => {
|
|
77
|
+
const router = createRouter({
|
|
78
|
+
history: createMemoryHistory(),
|
|
79
|
+
routes: [
|
|
80
|
+
{ path: '/new', component: { render: () => null } },
|
|
81
|
+
{ path: '/drafts', component: { render: () => null } },
|
|
82
|
+
],
|
|
83
|
+
})
|
|
84
|
+
router.push('/new')
|
|
85
|
+
cy.wrap(router.isReady()).then(() => {
|
|
86
|
+
cy.mount(PageHeaderBackButton, {
|
|
87
|
+
props: { fallbackRoute: '/drafts' },
|
|
88
|
+
global: { plugins: [router] },
|
|
89
|
+
})
|
|
90
|
+
cy.get('button').click().then(() => {
|
|
91
|
+
expect(router.currentRoute.value.fullPath).to.equal('/drafts')
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
73
97
|
describe('PageHeaderTitle', () => {
|
|
74
98
|
it('renders `title`, overridden by the default slot', () => {
|
|
75
99
|
cy.mount(PageHeaderTitle, { props: { title: 'From prop' } })
|
|
@@ -15,8 +15,9 @@ Keeps its title centered regardless of the `#left` / `#right` control widths, on
|
|
|
15
15
|
a single line with a trailing ellipsis.
|
|
16
16
|
|
|
17
17
|
`PageHeaderBackButton` goes back through history, the way the browser's own back
|
|
18
|
-
button does. It only navigates to `
|
|
19
|
-
back to, so give it a
|
|
18
|
+
button does. It only navigates to `fallbackRoute` when there is no in-app
|
|
19
|
+
history to go back to, so give it a fallback that makes the page recoverable
|
|
20
|
+
on a cold load.
|
|
20
21
|
|
|
21
22
|
<ComponentPreview name="PageHeader-Mobile" />
|
|
22
23
|
|
|
@@ -15,7 +15,7 @@ export interface PageHeaderBackButtonProps {
|
|
|
15
15
|
* Fallback destination, used only when there is no in-app history to go back to
|
|
16
16
|
* (a cold load onto a deep link). Taps otherwise go back through history.
|
|
17
17
|
*/
|
|
18
|
-
|
|
18
|
+
fallbackRoute?: RouteLocationRaw
|
|
19
19
|
|
|
20
20
|
/** Accessible label for the button. */
|
|
21
21
|
label?: string
|
|
@@ -52,13 +52,13 @@ function whenNavigationSettles(router: Router): Promise<void> {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
/**
|
|
55
|
-
* Go back the way the browser's back button would, and fall back to
|
|
56
|
-
* is not possible.
|
|
55
|
+
* Go back the way the browser's back button would, and fall back to
|
|
56
|
+
* `fallbackRoute` when that is not possible.
|
|
57
57
|
*
|
|
58
58
|
* A back button that always navigates to a fixed destination is not a back button:
|
|
59
59
|
* it drops the user wherever the page author guessed they came from, which is wrong
|
|
60
|
-
* for every other entry path into that page. So history wins, and
|
|
61
|
-
* recovery route for when there is no history to walk.
|
|
60
|
+
* for every other entry path into that page. So history wins, and
|
|
61
|
+
* `fallbackRoute` is only the recovery route for when there is no history to walk.
|
|
62
62
|
*
|
|
63
63
|
* The post-navigation check covers entries that bounce: a guard that rejects the pop,
|
|
64
64
|
* or a previous entry that redirects straight back to where we already are. Without
|
|
@@ -66,15 +66,15 @@ function whenNavigationSettles(router: Router): Promise<void> {
|
|
|
66
66
|
*/
|
|
67
67
|
export async function navigateBack(
|
|
68
68
|
router: Router,
|
|
69
|
-
|
|
69
|
+
fallbackRoute?: RouteLocationRaw,
|
|
70
70
|
): Promise<void> {
|
|
71
|
-
if (!
|
|
71
|
+
if (!fallbackRoute) {
|
|
72
72
|
router.back()
|
|
73
73
|
return
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
if (!hasAppHistory(router)) {
|
|
77
|
-
await router.push(
|
|
77
|
+
await router.push(fallbackRoute)
|
|
78
78
|
return
|
|
79
79
|
}
|
|
80
80
|
|
|
@@ -84,6 +84,6 @@ export async function navigateBack(
|
|
|
84
84
|
await settled
|
|
85
85
|
|
|
86
86
|
if (router.currentRoute.value.fullPath === origin) {
|
|
87
|
-
await router.push(
|
|
87
|
+
await router.push(fallbackRoute)
|
|
88
88
|
}
|
|
89
89
|
}
|
|
@@ -76,14 +76,20 @@
|
|
|
76
76
|
type: 'string'
|
|
77
77
|
},
|
|
78
78
|
{
|
|
79
|
-
name: '
|
|
79
|
+
name: 'route',
|
|
80
80
|
description: 'Navigation target. When set the row\'s main area renders as a router link;\notherwise it renders as a button. A click still invokes `onClick`.',
|
|
81
81
|
required: false,
|
|
82
82
|
type: 'string | kt | Tt'
|
|
83
83
|
},
|
|
84
|
+
{
|
|
85
|
+
name: 'href',
|
|
86
|
+
description: 'External URL. Used when `route` is absent; renders a native same-tab anchor.',
|
|
87
|
+
required: false,
|
|
88
|
+
type: 'string'
|
|
89
|
+
},
|
|
84
90
|
{
|
|
85
91
|
name: 'active',
|
|
86
|
-
description: 'Marks the row active (`data-state="active"`). When omitted, active state is\ninferred by matching `
|
|
92
|
+
description: 'Marks the row active (`data-state="active"`). When omitted, active state is\ninferred by matching `route` against the current route.',
|
|
87
93
|
required: false,
|
|
88
94
|
type: 'boolean'
|
|
89
95
|
},
|
|
@@ -121,13 +121,22 @@ describe('<SidebarSection />', () => {
|
|
|
121
121
|
})
|
|
122
122
|
|
|
123
123
|
describe('<SidebarItem />', () => {
|
|
124
|
-
it('renders
|
|
124
|
+
it('renders links for `route` and `href`, and a button otherwise', () => {
|
|
125
125
|
cy.mount(SidebarItem, {
|
|
126
|
-
props: { label: 'Deals',
|
|
126
|
+
props: { label: 'Deals', route: '/deals' },
|
|
127
127
|
global: { plugins: [createTestRouter()] },
|
|
128
128
|
})
|
|
129
129
|
cy.get('a[href="/deals"]').should('exist')
|
|
130
130
|
|
|
131
|
+
cy.mount(SidebarItem, { props: { label: 'Docs', href: 'https://frappe.io/docs' } })
|
|
132
|
+
cy.get('[data-slot=sidebar-item] > a').should('have.attr', 'href', 'https://frappe.io/docs')
|
|
133
|
+
|
|
134
|
+
cy.mount(SidebarItem, {
|
|
135
|
+
props: { label: 'Route wins', route: '/deals', href: 'https://frappe.io/docs' },
|
|
136
|
+
global: { plugins: [createTestRouter()] },
|
|
137
|
+
})
|
|
138
|
+
cy.get('[data-slot=sidebar-item] > a').should('have.attr', 'href', '/deals')
|
|
139
|
+
|
|
131
140
|
const onClick = cy.stub().as('click')
|
|
132
141
|
cy.mount(SidebarItem, { props: { label: 'Action', onClick } })
|
|
133
142
|
cy.get('button').should('exist').click()
|
|
@@ -158,7 +167,7 @@ describe('<SidebarItem />', () => {
|
|
|
158
167
|
it('keeps a #suffix options button a sibling of the link (not nested inside it)', () => {
|
|
159
168
|
const onOptions = cy.stub().as('options')
|
|
160
169
|
cy.mount(SidebarItem, {
|
|
161
|
-
props: { label: 'Design',
|
|
170
|
+
props: { label: 'Design', route: '/design' },
|
|
162
171
|
slots: {
|
|
163
172
|
suffix: () =>
|
|
164
173
|
h('button', { 'data-test': 'options', onClick: onOptions }, '...'),
|
|
@@ -172,7 +181,7 @@ describe('<SidebarItem />', () => {
|
|
|
172
181
|
})
|
|
173
182
|
|
|
174
183
|
it('is keyboard reachable and shows a visible focus-visible outline', () => {
|
|
175
|
-
cy.mount(SidebarItem, { props: { label: 'Design',
|
|
184
|
+
cy.mount(SidebarItem, { props: { label: 'Design', route: '/design' } })
|
|
176
185
|
cy.get('a')
|
|
177
186
|
.focus()
|
|
178
187
|
.should('have.focus')
|
|
@@ -35,9 +35,11 @@ trailing zone, so an options menu in `#suffix` isn't nested inside the link
|
|
|
35
35
|
adornments like a lock icon here next to the text.
|
|
36
36
|
- `#suffix` — the trailing zone: an unread count, an options `…` menu, etc.
|
|
37
37
|
|
|
38
|
-
Set `
|
|
39
|
-
`
|
|
40
|
-
|
|
38
|
+
Set `route` to render a router link or `href` for a native same-tab anchor;
|
|
39
|
+
`route` takes precedence when both are set. Omit both for a button. `active` drives
|
|
40
|
+
`data-state`; when omitted it's inferred by matching
|
|
41
|
+
`route` against the current route. A click invokes `onClick` (bound from
|
|
42
|
+
`@click`) in every case.
|
|
41
43
|
|
|
42
44
|
## SidebarHeader
|
|
43
45
|
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
-->
|
|
20
20
|
<component
|
|
21
21
|
:is="linkComponent"
|
|
22
|
-
v-if="
|
|
22
|
+
v-if="route || href"
|
|
23
23
|
v-bind="linkAttrs"
|
|
24
24
|
:accesskey="accessKey"
|
|
25
25
|
:aria-label="tooltipText || undefined"
|
|
@@ -117,7 +117,14 @@
|
|
|
117
117
|
</template>
|
|
118
118
|
|
|
119
119
|
<script setup lang="ts">
|
|
120
|
-
import {
|
|
120
|
+
import {
|
|
121
|
+
computed,
|
|
122
|
+
getCurrentInstance,
|
|
123
|
+
inject,
|
|
124
|
+
onMounted,
|
|
125
|
+
ref,
|
|
126
|
+
useTemplateRef,
|
|
127
|
+
} from 'vue'
|
|
121
128
|
import { RouterLink } from 'vue-router'
|
|
122
129
|
import Tooltip from '../Tooltip/Tooltip.vue'
|
|
123
130
|
import SidebarItemIcon from './SidebarItemIcon.vue'
|
|
@@ -163,16 +170,20 @@ const globals = getCurrentInstance()?.appContext.config.globalProperties
|
|
|
163
170
|
const hasRouter = computed(() => Boolean(globals?.$router))
|
|
164
171
|
|
|
165
172
|
// With a router, render RouterLink; without one, degrade to a plain <a> (with an
|
|
166
|
-
// href only when `
|
|
167
|
-
const linkComponent = computed(() =>
|
|
173
|
+
// href only when `route` is a string — we can't resolve a route-location object).
|
|
174
|
+
const linkComponent = computed(() =>
|
|
175
|
+
props.route && hasRouter.value ? RouterLink : 'a',
|
|
176
|
+
)
|
|
168
177
|
const linkAttrs = computed(() =>
|
|
169
|
-
|
|
170
|
-
?
|
|
171
|
-
|
|
178
|
+
props.route
|
|
179
|
+
? hasRouter.value
|
|
180
|
+
? { to: props.route }
|
|
181
|
+
: { href: typeof props.route === 'string' ? props.route : undefined }
|
|
182
|
+
: { href: props.href },
|
|
172
183
|
)
|
|
173
184
|
|
|
174
185
|
const resolvedRoute = computed(() =>
|
|
175
|
-
props.
|
|
186
|
+
props.route && globals?.$router ? globals.$router.resolve(props.route) : null,
|
|
176
187
|
)
|
|
177
188
|
|
|
178
189
|
// Explicit `active` wins; otherwise infer from the current route so
|
|
@@ -49,11 +49,14 @@ export interface SidebarItemProps {
|
|
|
49
49
|
* Navigation target. When set the row's main area renders as a router link;
|
|
50
50
|
* otherwise it renders as a button. A click still invokes `onClick`.
|
|
51
51
|
*/
|
|
52
|
-
|
|
52
|
+
route?: RouteLocationRaw
|
|
53
|
+
|
|
54
|
+
/** External URL. Used when `route` is absent; renders a native same-tab anchor. */
|
|
55
|
+
href?: string
|
|
53
56
|
|
|
54
57
|
/**
|
|
55
58
|
* Marks the row active (`data-state="active"`). When omitted, active state is
|
|
56
|
-
* inferred by matching `
|
|
59
|
+
* inferred by matching `route` against the current route.
|
|
57
60
|
*/
|
|
58
61
|
active?: boolean
|
|
59
62
|
|
|
@@ -32,11 +32,17 @@
|
|
|
32
32
|
type: 'string | Component'
|
|
33
33
|
},
|
|
34
34
|
{
|
|
35
|
-
name: '
|
|
35
|
+
name: 'route',
|
|
36
36
|
description: 'Navigation target. When set, the item renders as a router link; otherwise\nit renders as a button. A `click` event fires in both cases.',
|
|
37
37
|
required: false,
|
|
38
38
|
type: 'string | kt | Tt'
|
|
39
39
|
},
|
|
40
|
+
{
|
|
41
|
+
name: 'href',
|
|
42
|
+
description: 'External URL. Used when `route` is absent; renders a native same-tab anchor.',
|
|
43
|
+
required: false,
|
|
44
|
+
type: 'string'
|
|
45
|
+
},
|
|
40
46
|
{
|
|
41
47
|
name: 'active',
|
|
42
48
|
description: 'Marks the item as the current destination (indicator bar / active fill).',
|
|
@@ -32,7 +32,7 @@ describe('<SidebarRail />', () => {
|
|
|
32
32
|
})
|
|
33
33
|
|
|
34
34
|
describe('<SidebarRailItem />', () => {
|
|
35
|
-
it('renders a button and emits click when there is no
|
|
35
|
+
it('renders a button and emits click when there is no destination', () => {
|
|
36
36
|
const onClick = cy.stub().as('click')
|
|
37
37
|
cy.mount(SidebarRailItem, {
|
|
38
38
|
props: { label: 'Search', icon: 'lucide-search', onClick },
|
|
@@ -42,9 +42,20 @@ describe('<SidebarRailItem />', () => {
|
|
|
42
42
|
cy.get('@click').should('have.been.calledOnce')
|
|
43
43
|
})
|
|
44
44
|
|
|
45
|
-
it('renders
|
|
45
|
+
it('renders links for `route` and `href`', () => {
|
|
46
46
|
cy.mount(SidebarRailItem, {
|
|
47
|
-
props: { label: 'Search', icon: 'lucide-search',
|
|
47
|
+
props: { label: 'Search', icon: 'lucide-search', route: '/search' },
|
|
48
|
+
global: { plugins: [createTestRouter()] },
|
|
49
|
+
})
|
|
50
|
+
cy.get('a[data-slot=sidebar-rail-item]').should('have.attr', 'href', '/search')
|
|
51
|
+
|
|
52
|
+
cy.mount(SidebarRailItem, {
|
|
53
|
+
props: { label: 'Docs', icon: 'lucide-book-open', href: 'https://frappe.io/docs' },
|
|
54
|
+
})
|
|
55
|
+
cy.get('a[data-slot=sidebar-rail-item]').should('have.attr', 'href', 'https://frappe.io/docs')
|
|
56
|
+
|
|
57
|
+
cy.mount(SidebarRailItem, {
|
|
58
|
+
props: { label: 'Route wins', route: '/search', href: 'https://frappe.io/docs' },
|
|
48
59
|
global: { plugins: [createTestRouter()] },
|
|
49
60
|
})
|
|
50
61
|
cy.get('a[data-slot=sidebar-rail-item]').should('have.attr', 'href', '/search')
|
|
@@ -27,7 +27,8 @@ an optional unread `badge`. Two visual treatments:
|
|
|
27
27
|
- `variant="ghost"` — transparent until hovered, raised when active. Pass an
|
|
28
28
|
`icon` for a shortcut like Search or Notifications.
|
|
29
29
|
|
|
30
|
-
Set `
|
|
30
|
+
Set `route` to render a router link or `href` for a native same-tab anchor;
|
|
31
|
+
`route` takes precedence when both are set. Omit both to get a button that emits `click`.
|
|
31
32
|
The `badge` count shows as a pill (`badgeStyle="count"`) or a dot
|
|
32
33
|
(`badgeStyle="dot"`); either way it folds into the item's accessible label, and
|
|
33
34
|
a dot surfaces the real number in the tooltip. The badge pill teleports to
|
|
@@ -6,15 +6,15 @@
|
|
|
6
6
|
-->
|
|
7
7
|
<Tooltip side="right">
|
|
8
8
|
<!--
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
identical in both branches.
|
|
9
|
+
Router and plain links share a dynamic component. The button stays a
|
|
10
|
+
literal v-else because a raw 'button' string can resolve to the globally
|
|
11
|
+
registered <Button> in consumer apps. The inner content is identical in
|
|
12
|
+
both branches.
|
|
14
13
|
-->
|
|
15
|
-
<
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
<component
|
|
15
|
+
:is="route ? RouterLink : 'a'"
|
|
16
|
+
v-if="route || href"
|
|
17
|
+
v-bind="route ? { to: route } : { href }"
|
|
18
18
|
data-slot="sidebar-rail-item"
|
|
19
19
|
:data-variant="variant"
|
|
20
20
|
:data-state="active ? 'active' : 'inactive'"
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
/>
|
|
44
44
|
</slot>
|
|
45
45
|
<SidebarRailItemBadge :count="badge" :variant="badgeStyle" />
|
|
46
|
-
</
|
|
46
|
+
</component>
|
|
47
47
|
|
|
48
48
|
<button
|
|
49
49
|
v-else
|
|
@@ -21,7 +21,10 @@ export interface SidebarRailItemProps {
|
|
|
21
21
|
* Navigation target. When set, the item renders as a router link; otherwise
|
|
22
22
|
* it renders as a button. A `click` event fires in both cases.
|
|
23
23
|
*/
|
|
24
|
-
|
|
24
|
+
route?: RouteLocationRaw
|
|
25
|
+
|
|
26
|
+
/** External URL. Used when `route` is absent; renders a native same-tab anchor. */
|
|
27
|
+
href?: string
|
|
25
28
|
|
|
26
29
|
/** Marks the item as the current destination (indicator bar / active fill). */
|
|
27
30
|
active?: boolean
|
|
@@ -111,12 +111,11 @@ entries:
|
|
|
111
111
|
- `theme`, `variant`, `size`, `loading`, `prefix` on individual options are no
|
|
112
112
|
longer honored. Use `Button` or `Pill` directly if you need per-tab theming or
|
|
113
113
|
a loading spinner.
|
|
114
|
-
- `hideLabel` on options is gone. Use `icon` for an icon-only tab — its
|
|
115
|
-
|
|
114
|
+
- `hideLabel` on options is gone. Use `icon` for an icon-only tab — its required
|
|
115
|
+
string `label` is automatically exposed as accessibility text. Use `iconLeft`
|
|
116
116
|
for an accent icon before a visible label, and the `#suffix` slot for
|
|
117
117
|
trailing content.
|
|
118
118
|
- `route` and `href` on options are honored: a tab renders as a `<RouterLink>`
|
|
119
119
|
when `route` is set, or an `<a href target=_blank>` when `href` is set.
|
|
120
|
-
- The per-tab `tooltip`
|
|
121
|
-
|
|
122
|
-
custom tooltip if you need styled behavior.
|
|
120
|
+
- The per-tab `tooltip` field is removed. Put help text in app-owned UI when a
|
|
121
|
+
label alone is not enough.
|
|
@@ -59,11 +59,11 @@ watchEffect(() => {
|
|
|
59
59
|
|
|
60
60
|
const resolvedButtons = computed(() => {
|
|
61
61
|
return options.value.map((button) => {
|
|
62
|
-
const { value, label, icon
|
|
62
|
+
const { value, label, icon } = button
|
|
63
63
|
|
|
64
64
|
const isIconOnly = Boolean(icon)
|
|
65
65
|
const visibleLabel = hasLabel(label) && !isIconOnly
|
|
66
|
-
const accessibleLabel = hasLabel(label) ?
|
|
66
|
+
const accessibleLabel = hasLabel(label) ? label : undefined
|
|
67
67
|
|
|
68
68
|
return {
|
|
69
69
|
...button,
|
|
@@ -355,7 +355,7 @@ function tabElementProps(button: (typeof resolvedButtons.value)[number]) {
|
|
|
355
355
|
:title="
|
|
356
356
|
button.accessibleLabel && !button.visibleLabel
|
|
357
357
|
? button.accessibleLabel
|
|
358
|
-
:
|
|
358
|
+
: undefined
|
|
359
359
|
"
|
|
360
360
|
:class="[
|
|
361
361
|
tabShellClasses,
|
|
@@ -11,13 +11,12 @@ export type TabButtonValue = TabValue
|
|
|
11
11
|
export type TabButtonIcon = TabIcon
|
|
12
12
|
export interface TabButton {
|
|
13
13
|
value: TabButtonValue
|
|
14
|
-
label
|
|
14
|
+
label: string
|
|
15
15
|
/** Icon-only tab; `label` becomes accessibility text. */
|
|
16
16
|
icon?: TabButtonIcon
|
|
17
17
|
/** Leading accent icon, rendered next to the visible label. */
|
|
18
18
|
iconLeft?: TabButtonIcon
|
|
19
19
|
disabled?: boolean
|
|
20
|
-
tooltip?: string
|
|
21
20
|
/** Renders the tab as a `<RouterLink>` to the given target. */
|
|
22
21
|
route?: RouteLocationRaw
|
|
23
22
|
/** Renders the tab as an `<a href>`, opens in a new tab. */
|
|
@@ -51,7 +50,15 @@ export interface TabButtonsEmits {
|
|
|
51
50
|
|
|
52
51
|
export interface TabButtonsSlots {
|
|
53
52
|
/** Slot before the tab button label. */
|
|
54
|
-
prefix?: (props: {
|
|
53
|
+
prefix?: (props: {
|
|
54
|
+
button: TabButton
|
|
55
|
+
checked: boolean
|
|
56
|
+
disabled: boolean
|
|
57
|
+
}) => any
|
|
55
58
|
/** Slot after the tab button label. */
|
|
56
|
-
suffix?: (props: {
|
|
59
|
+
suffix?: (props: {
|
|
60
|
+
button: TabButton
|
|
61
|
+
checked: boolean
|
|
62
|
+
disabled: boolean
|
|
63
|
+
}) => any
|
|
57
64
|
}
|
|
@@ -66,22 +66,24 @@ describe('List (feed mode)', () => {
|
|
|
66
66
|
})
|
|
67
67
|
})
|
|
68
68
|
|
|
69
|
-
it('renders rows as links
|
|
69
|
+
it('renders rows with `route` or `href` as links, click rows as buttons, and static rows as divs', () => {
|
|
70
70
|
const clicked = cy.spy().as('rowClick')
|
|
71
71
|
cy.mount(
|
|
72
72
|
{
|
|
73
73
|
render: () =>
|
|
74
74
|
h(List, () => [
|
|
75
|
-
feedRow('1', {
|
|
75
|
+
feedRow('1', { route: { name: 'Item', params: { id: '1' } } }),
|
|
76
|
+
feedRow('external', { href: 'https://frappe.io' }),
|
|
76
77
|
feedRow('2', { onClick: clicked }),
|
|
77
78
|
feedRow('3'),
|
|
78
79
|
]),
|
|
79
80
|
},
|
|
80
81
|
{ global: { plugins: [makeRouter()] } },
|
|
81
82
|
)
|
|
82
|
-
cy.get('a[data-slot=list-row]')
|
|
83
|
+
cy.get('a[data-slot=list-row]').eq(0)
|
|
83
84
|
.should('have.attr', 'href', '/item/1')
|
|
84
85
|
.and('have.attr', 'data-interactive')
|
|
86
|
+
cy.get('a[data-slot=list-row]').eq(1).should('have.attr', 'href', 'https://frappe.io')
|
|
85
87
|
cy.get('button[data-slot=list-row]').click()
|
|
86
88
|
cy.get('@rowClick').should('have.been.calledOnce')
|
|
87
89
|
cy.get('div[data-slot=list-row]').should('exist')
|
|
@@ -94,7 +96,7 @@ describe('List (feed mode)', () => {
|
|
|
94
96
|
render: () => [
|
|
95
97
|
h(RouterView),
|
|
96
98
|
h(List, () => [
|
|
97
|
-
feedRow('1', {
|
|
99
|
+
feedRow('1', { route: { name: 'Item', params: { id: '1' } } }),
|
|
98
100
|
]),
|
|
99
101
|
],
|
|
100
102
|
},
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
<!-- `custom` so this row's single click handler decides between toggle and
|
|
3
3
|
navigate — with RouterLink's own handler in play, selection could not
|
|
4
4
|
reliably preventDefault before it navigates. -->
|
|
5
|
-
<RouterLink v-if="
|
|
5
|
+
<RouterLink v-if="route" :to="route" custom v-slot="{ href, navigate }">
|
|
6
6
|
<ListRowBase
|
|
7
7
|
tag="a"
|
|
8
8
|
:href="href"
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
<slot />
|
|
13
13
|
</ListRowBase>
|
|
14
14
|
</RouterLink>
|
|
15
|
+
<ListRowBase v-else-if="href" tag="a" :href="href" :value="value" @click="onClick">
|
|
16
|
+
<slot />
|
|
17
|
+
</ListRowBase>
|
|
15
18
|
<ListRowBase
|
|
16
19
|
v-else
|
|
17
20
|
:tag="isInteractive() ? 'button' : 'div'"
|
|
@@ -67,11 +67,17 @@
|
|
|
67
67
|
|
|
68
68
|
const listRowProps = [
|
|
69
69
|
{
|
|
70
|
-
name: '
|
|
71
|
-
description: 'Renders the row as a RouterLink. Without `
|
|
70
|
+
name: 'route',
|
|
71
|
+
description: 'Renders the row as a RouterLink. Without `route` or `href`, a row with a click\nlistener renders as a button; otherwise a plain div.',
|
|
72
72
|
required: false,
|
|
73
73
|
type: 'string | kt | Tt'
|
|
74
74
|
},
|
|
75
|
+
{
|
|
76
|
+
name: 'href',
|
|
77
|
+
description: 'External URL. Used when `route` is absent; renders a native same-tab anchor.',
|
|
78
|
+
required: false,
|
|
79
|
+
type: 'string'
|
|
80
|
+
},
|
|
75
81
|
{
|
|
76
82
|
name: 'value',
|
|
77
83
|
description: 'Row key — the `selection` key when `selectable` and the `v-model:active`\nkey. Required whenever the list uses either.',
|
|
@@ -12,8 +12,9 @@ Not to be confused with the config-driven `ListView` in `frappe-ui/experimental`
|
|
|
12
12
|
## Feed mode
|
|
13
13
|
|
|
14
14
|
The default template (`auto`, `minmax(0,1fr)`, `auto`) fits leading media,
|
|
15
|
-
content, and a right-aligned trailing cell. Rows with `
|
|
16
|
-
RouterLinks, rows with
|
|
15
|
+
content, and a right-aligned trailing cell. Rows with `route` render as
|
|
16
|
+
RouterLinks, rows with `href` as native same-tab anchors, and rows with a click
|
|
17
|
+
listener as buttons — real interactive elements,
|
|
17
18
|
so a row is clickable _or_ carries inline action buttons, never both (nested
|
|
18
19
|
interactive controls are invalid HTML); [row actions](#row-actions) shows how to
|
|
19
20
|
combine them. `selectable` reveals the animated checkbox column and switches row
|
|
@@ -58,7 +59,7 @@ A master–detail list (a mail inbox, a file browser) tracks one open row. Bind
|
|
|
58
59
|
`v-model:active` to a row `value` and the List owns the rest: it highlights that
|
|
59
60
|
row and hides the dividers hugging it — above and below — so its rounded surface
|
|
60
61
|
floats free, like a hovered row but persistent. Clicking a row sets `active`;
|
|
61
|
-
unlike `selectable`, activation is additive, so the row's own `@click` and `
|
|
62
|
+
unlike `selectable`, activation is additive, so the row's own `@click` and `route`
|
|
62
63
|
navigation still run. It's single-select and independent of the multi-select
|
|
63
64
|
checkbox `selection` — and works in feed or column mode.
|
|
64
65
|
|
|
@@ -83,10 +83,13 @@ export interface ListProps {
|
|
|
83
83
|
|
|
84
84
|
export interface ListRowProps {
|
|
85
85
|
/**
|
|
86
|
-
* Renders the row as a RouterLink. Without `
|
|
86
|
+
* Renders the row as a RouterLink. Without `route` or `href`, a row with a click
|
|
87
87
|
* listener renders as a button; otherwise a plain div.
|
|
88
88
|
*/
|
|
89
|
-
|
|
89
|
+
route?: RouteLocationRaw
|
|
90
|
+
|
|
91
|
+
/** External URL. Used when `route` is absent; renders a native same-tab anchor. */
|
|
92
|
+
href?: string
|
|
90
93
|
|
|
91
94
|
/**
|
|
92
95
|
* Row key — the `selection` key when `selectable` and the `v-model:active`
|