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.
Files changed (35) hide show
  1. package/package.json +3 -2
  2. package/scripts/migrate-destinations-v1.js +229 -0
  3. package/src/components/Button/Button.api.md +2 -2
  4. package/src/components/Button/Button.cy.ts +4 -4
  5. package/src/components/Button/Button.vue +2 -2
  6. package/src/components/Button/types.ts +2 -2
  7. package/src/components/MobileNav/MobileNav.api.md +8 -2
  8. package/src/components/MobileNav/MobileNav.cy.ts +18 -5
  9. package/src/components/MobileNav/MobileNav.md +6 -5
  10. package/src/components/MobileNav/MobileNavItem.vue +10 -6
  11. package/src/components/MobileNav/types.ts +5 -2
  12. package/src/components/PageHeader/PageHeader.api.md +1 -1
  13. package/src/components/PageHeader/PageHeader.cy.ts +24 -0
  14. package/src/components/PageHeader/PageHeader.md +3 -2
  15. package/src/components/PageHeader/PageHeaderBackButton.vue +1 -1
  16. package/src/components/PageHeader/types.ts +1 -1
  17. package/src/components/PageHeader/useHeaderBack.ts +8 -8
  18. package/src/components/Sidebar/Sidebar.api.md +8 -2
  19. package/src/components/Sidebar/Sidebar.cy.ts +13 -4
  20. package/src/components/Sidebar/Sidebar.md +5 -3
  21. package/src/components/Sidebar/SidebarItem.vue +19 -8
  22. package/src/components/Sidebar/types.ts +5 -2
  23. package/src/components/SidebarRail/SidebarRail.api.md +7 -1
  24. package/src/components/SidebarRail/SidebarRail.cy.ts +14 -3
  25. package/src/components/SidebarRail/SidebarRail.md +2 -1
  26. package/src/components/SidebarRail/SidebarRailItem.vue +9 -9
  27. package/src/components/SidebarRail/types.ts +4 -1
  28. package/src/components/TabButtons/TabButtons.md +4 -5
  29. package/src/components/TabButtons/TabButtons.vue +3 -3
  30. package/src/components/TabButtons/types.ts +11 -4
  31. package/src/molecules/list/List.cy.ts +6 -4
  32. package/src/molecules/list/ListRow.vue +4 -1
  33. package/src/molecules/list/list.api.md +8 -2
  34. package/src/molecules/list/list.md +4 -3
  35. 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.65",
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))
@@ -83,8 +83,8 @@
83
83
  type: 'string | kt | Tt'
84
84
  },
85
85
  {
86
- name: 'link',
87
- description: 'External link URL',
86
+ name: 'href',
87
+ description: 'External URL, opened in a new tab.',
88
88
  required: false,
89
89
  type: 'string'
90
90
  },
@@ -167,10 +167,10 @@ describe('<Button />', () => {
167
167
  cy.get('button').should('not.exist')
168
168
  })
169
169
 
170
- it('renders an anchor when link is provided', () => {
170
+ it('renders an anchor when href is provided', () => {
171
171
  cy.mount(Button, {
172
172
  props: {
173
- link: 'https://frappe.io/docs',
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 link is blocked', () => {
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
- link: 'https://frappe.io/docs',
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.link) {
254
+ if (!isDisabled.value && props.href) {
255
255
  return {
256
256
  is: 'a',
257
257
  props: {
258
- href: props.link,
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 link URL */
47
- link: { type: String, default: undefined },
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: 'to',
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 `to` resolves to the current route.',
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 `to`', () => {
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', to: '/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('is a button (not a link) when `to` is already the current route', () => {
54
+ it('renders a plain anchor for href', () => {
55
55
  cy.mount(MobileNavItem, {
56
- props: { label: 'Home', icon: 'lucide-house', to: '/' },
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', to: '/' },
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 `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.
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 `to` matches the current route. The default slot receives `{ active }`
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" :to="{ name: 'More' }" :active="isMoreRoute">
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="to && !isCurrent"
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(() => (hasRouter.value ? RouterLink : 'a'))
97
+ const linkComponent = computed(() =>
98
+ props.route && hasRouter.value ? RouterLink : 'a',
99
+ )
98
100
  const linkAttrs = computed(() =>
99
- hasRouter.value
100
- ? { to: props.to }
101
- : { href: typeof props.to === 'string' ? props.to : undefined },
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.to && globals?.$router ? globals.$router.resolve(props.to) : null,
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
- to?: RouteLocationRaw
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 `to` resolves to the current route.
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: 'to',
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 `to` when there is no in-app history to go
19
- back to, so give it a `to` that makes the page recoverable on a cold load.
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
 
@@ -21,6 +21,6 @@ const props = withDefaults(defineProps<PageHeaderBackButtonProps>(), {
21
21
  const router = useRouter()
22
22
 
23
23
  function onClick() {
24
- navigateBack(router, props.to)
24
+ navigateBack(router, props.fallbackRoute)
25
25
  }
26
26
  </script>
@@ -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
- to?: RouteLocationRaw
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 `to` when that
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 `to` is only the
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
- to?: RouteLocationRaw,
69
+ fallbackRoute?: RouteLocationRaw,
70
70
  ): Promise<void> {
71
- if (!to) {
71
+ if (!fallbackRoute) {
72
72
  router.back()
73
73
  return
74
74
  }
75
75
 
76
76
  if (!hasAppHistory(router)) {
77
- await router.push(to)
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(to)
87
+ await router.push(fallbackRoute)
88
88
  }
89
89
  }
@@ -76,14 +76,20 @@
76
76
  type: 'string'
77
77
  },
78
78
  {
79
- name: 'to',
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 `to` against the current route.',
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 a router link when `to` is set, a button otherwise', () => {
124
+ it('renders links for `route` and `href`, and a button otherwise', () => {
125
125
  cy.mount(SidebarItem, {
126
- props: { label: 'Deals', to: '/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', to: '/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', to: '/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 `to` to render a router link; omit it for a button. `active` drives
39
- `data-state`; when omitted it's inferred by matching `to` against the current
40
- route. A click invokes `onClick` (bound from `@click`) in both cases.
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="to"
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 { computed, getCurrentInstance, inject, onMounted, ref, useTemplateRef } from 'vue'
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 `to` is a string — we can't resolve a route-location object).
167
- const linkComponent = computed(() => (hasRouter.value ? RouterLink : 'a'))
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
- hasRouter.value
170
- ? { to: props.to }
171
- : { href: typeof props.to === 'string' ? props.to : undefined },
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.to && globals?.$router ? globals.$router.resolve(props.to) : null,
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
- to?: RouteLocationRaw
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 `to` against the current route.
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: 'to',
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 `to`', () => {
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 a router link when `to` is set', () => {
45
+ it('renders links for `route` and `href`', () => {
46
46
  cy.mount(SidebarRailItem, {
47
- props: { label: 'Search', icon: 'lucide-search', to: '/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 `to` to render a router link; omit it to get a button that emits `click`.
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
- Link vs. button are split with v-if/v-else rather than a dynamic
10
- <component :is>: a raw 'button' string there resolves to a
11
- globally-registered <Button> in consumer apps, and reka's trigger can't
12
- forward its element ref through a functional wrapper. The inner content is
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
- <RouterLink
16
- v-if="to"
17
- :to="to"
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
- </RouterLink>
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
- to?: RouteLocationRaw
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 `label`,
115
- if provided, is automatically exposed as accessibility text. Use `iconLeft`
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` value surfaces as the native `title` attribute rather
121
- than the floating `<Tooltip>` popover. Wrap the `TabButtons` instance in a
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, tooltip } = button
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) ? String(label) : tooltip
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
- : button.tooltip
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?: string | number
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: { button: TabButton; checked: boolean; disabled: boolean }) => any
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: { button: TabButton; checked: boolean; disabled: boolean }) => any
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 with `to`, buttons with a click listener, divs otherwise', () => {
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', { to: { name: 'Item', params: { id: '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', { to: { name: 'Item', params: { id: '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="to" :to="to" custom v-slot="{ href, navigate }">
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: 'to',
71
- description: 'Renders the row as a RouterLink. Without `to`, a row with a click\nlistener renders as a button; otherwise a plain div.',
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 `to` render as
16
- RouterLinks, rows with a click listener as buttons — real interactive elements,
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 `to`
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 `to`, a row with a click
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
- to?: RouteLocationRaw
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`