frappe-ui 1.0.0-beta.67 → 1.0.0-beta.68

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frappe-ui",
3
- "version": "1.0.0-beta.67",
3
+ "version": "1.0.0-beta.68",
4
4
  "description": "A set of components and utilities for rapid UI development",
5
5
  "engines": {
6
6
  "node": ">=20.19.0"
@@ -19,6 +19,7 @@
19
19
  "#utils/*": "./src/utils/*"
20
20
  },
21
21
  "bin": {
22
+ "base-props-v1": "./scripts/migrate-base-props-v1.js",
22
23
  "tokens-v2": "./tailwind/migrate-tokens-v2.js",
23
24
  "shortcuts-v1": "./scripts/migrate-shortcuts-v1.js",
24
25
  "editor-v1": "./scripts/migrate-editor-v1.js",
@@ -104,6 +105,7 @@
104
105
  "tailwind",
105
106
  "vitepress",
106
107
  "scripts/migrate-shortcuts-v1.js",
108
+ "scripts/migrate-base-props-v1.js",
107
109
  "scripts/migrate-editor-v1.js",
108
110
  "experimental",
109
111
  "experimental.ts",
@@ -116,6 +118,7 @@
116
118
  "author": "Frappe Technologies Pvt. Ltd.",
117
119
  "license": "MIT",
118
120
  "dependencies": {
121
+ "@vue/compiler-sfc": "^3.5.13",
119
122
  "@codemirror/lang-css": "^6.3.0",
120
123
  "@codemirror/lang-html": "^6.4.9",
121
124
  "@codemirror/lang-javascript": "^6.2.2",
@@ -0,0 +1,379 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { parse as parseSfc } from '@vue/compiler-sfc'
7
+
8
+ const NodeTypes = {
9
+ ELEMENT: 1,
10
+ SIMPLE_EXPRESSION: 4,
11
+ ATTRIBUTE: 6,
12
+ DIRECTIVE: 7,
13
+ }
14
+
15
+ const USAGE = `Usage: base-props-v1 [--dry-run] <dir-or-file...>
16
+
17
+ Renames the v1 base-component props on statically named Vue component tags:
18
+ Icon name -> icon (optional canonical spelling)
19
+ Divider position -> align
20
+ Progress intervals + intervalCount -> numeric intervals`
21
+
22
+ const COMPONENTS = new Set(['Icon', 'Divider', 'Progress'])
23
+
24
+ function walk(target, visited = new Set()) {
25
+ if (fs.lstatSync(target).isSymbolicLink()) return []
26
+ const resolved = fs.realpathSync(target)
27
+ if (visited.has(resolved)) return []
28
+ visited.add(resolved)
29
+ const stat = fs.statSync(resolved)
30
+ if (stat.isFile()) return path.extname(target) === '.vue' ? [target] : []
31
+ return fs.readdirSync(resolved, { withFileTypes: true }).flatMap((entry) => {
32
+ if (entry.name === 'node_modules' || entry.name === '.git') return []
33
+ return walk(path.join(resolved, entry.name), visited)
34
+ })
35
+ }
36
+
37
+ function kebabCase(name) {
38
+ return name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
39
+ }
40
+
41
+ function setAlias(aliases, binding, component) {
42
+ aliases.set(binding, component)
43
+ aliases.set(kebabCase(binding), component)
44
+ }
45
+
46
+ function importedComponentAliases(source) {
47
+ const aliases = new Map()
48
+ for (const name of COMPONENTS) setAlias(aliases, name, name)
49
+ const importPattern = /import\s*\{([\s\S]*?)\}\s*from\s*['"]([^'"]+)['"]/g
50
+ for (const match of source.matchAll(importPattern)) {
51
+ for (const part of match[1].split(',')) {
52
+ const binding = part
53
+ .trim()
54
+ .match(/^(Icon|Divider|Progress)(?:\s+as\s+([A-Za-z_$][\w$]*))?$/)
55
+ if (!binding) continue
56
+ const name = binding[2] || binding[1]
57
+ if (match[2] === 'frappe-ui') setAlias(aliases, name, binding[1])
58
+ else {
59
+ aliases.delete(name)
60
+ aliases.delete(kebabCase(name))
61
+ }
62
+ }
63
+ }
64
+ for (const match of source.matchAll(
65
+ /import\s+([A-Za-z_$][\w$]*)\s+from\s*['"]([^'"]+)['"]/g,
66
+ )) {
67
+ const [, binding, specifier] = match
68
+ const component = [...COMPONENTS].find((name) =>
69
+ specifier.endsWith(`/${name}/${name}.vue`),
70
+ )
71
+ if (component) setAlias(aliases, binding, component)
72
+ else {
73
+ aliases.delete(binding)
74
+ aliases.delete(kebabCase(binding))
75
+ }
76
+ }
77
+ return aliases
78
+ }
79
+
80
+ function propName(prop) {
81
+ if (prop.type === NodeTypes.ATTRIBUTE) return prop.name
82
+ if (
83
+ prop.type === NodeTypes.DIRECTIVE &&
84
+ prop.name === 'bind' &&
85
+ prop.arg?.type === NodeTypes.SIMPLE_EXPRESSION &&
86
+ prop.arg.isStatic
87
+ ) {
88
+ return prop.arg.content
89
+ }
90
+ }
91
+
92
+ function renamePropEdit(prop, from, to, offset) {
93
+ if (
94
+ prop.type === NodeTypes.DIRECTIVE &&
95
+ prop.name === 'bind' &&
96
+ prop.exp === undefined
97
+ ) {
98
+ const renamed = prop.loc.source.replace(from, to)
99
+ return {
100
+ start: offset + prop.loc.start.offset,
101
+ end: offset + prop.loc.end.offset,
102
+ text: `${renamed}="${from}"`,
103
+ description: `${from} -> ${to}`,
104
+ }
105
+ }
106
+ const relative = prop.loc.source.indexOf(from)
107
+ return {
108
+ start: offset + prop.loc.start.offset + relative,
109
+ end: offset + prop.loc.start.offset + relative + from.length,
110
+ text: to,
111
+ description: `${from} -> ${to}`,
112
+ }
113
+ }
114
+
115
+ function boundExpression(prop) {
116
+ if (prop?.type !== NodeTypes.DIRECTIVE || prop.name !== 'bind')
117
+ return undefined
118
+ const match = prop.loc.source.match(/^(?::|v-bind:)[^=]+=(['"])([\s\S]*)\1$/)
119
+ return match?.[2]
120
+ }
121
+
122
+ function countExpression(prop) {
123
+ if (prop.type === NodeTypes.ATTRIBUTE) return prop.value?.content
124
+ return boundExpression(prop)
125
+ }
126
+
127
+ function looksNumericExpression(expression) {
128
+ return (
129
+ /^\d+(?:\.\d+)?$/.test(expression) ||
130
+ /(?:^|\.)(?:length|size)$/.test(expression) ||
131
+ /^(?:Number|parseInt|parseFloat)\s*\(/.test(expression) ||
132
+ /[+*/%]|\?[^:]+:\s*(?:undefined|\d)/.test(expression)
133
+ )
134
+ }
135
+
136
+ function progressEdit(element, offset, refusals) {
137
+ const intervals = element.props.find((prop) => propName(prop) === 'intervals')
138
+ const count = element.props.find((prop) =>
139
+ ['intervalCount', 'interval-count'].includes(propName(prop)),
140
+ )
141
+ if (!count && !intervals) return []
142
+
143
+ if (count && !intervals) {
144
+ refusals.push({
145
+ line: count.loc.start.line,
146
+ message:
147
+ 'Progress has intervalCount without intervals; choose continuous or interval mode',
148
+ })
149
+ return []
150
+ }
151
+
152
+ const condition = boundExpression(intervals)
153
+ const staticValue =
154
+ intervals.type === NodeTypes.ATTRIBUTE
155
+ ? intervals.value?.content
156
+ : undefined
157
+ const bareBoolean =
158
+ intervals.type === NodeTypes.ATTRIBUTE && intervals.value === undefined
159
+ const numericStatic =
160
+ staticValue !== undefined && /^\d+(?:\.\d+)?$/.test(staticValue)
161
+ const shorthandBinding =
162
+ intervals.type === NodeTypes.DIRECTIVE && condition === undefined
163
+ // A dynamic `:intervals` without `intervalCount` may already be the v1
164
+ // numeric API. Leaving it alone makes repeat runs idempotent.
165
+ if (!count && shorthandBinding) {
166
+ refusals.push({
167
+ line: intervals.loc.start.line,
168
+ message:
169
+ 'Progress shorthand :intervals may still be the v0 boolean mode; replace it with a segment count',
170
+ })
171
+ return []
172
+ }
173
+ if (
174
+ !count &&
175
+ !bareBoolean &&
176
+ staticValue !== 'true' &&
177
+ !numericStatic &&
178
+ condition !== 'true' &&
179
+ condition !== 'false'
180
+ ) {
181
+ if (!looksNumericExpression(condition)) {
182
+ refusals.push({
183
+ line: intervals.loc.start.line,
184
+ message: `Progress ${intervals.loc.source} may still be the v0 boolean mode; replace it with a segment count`,
185
+ })
186
+ }
187
+ return []
188
+ }
189
+
190
+ if (condition === 'false') {
191
+ const removeProp = (prop, description) => {
192
+ let start = prop.loc.start.offset
193
+ while (
194
+ start > element.loc.start.offset &&
195
+ /\s/.test(
196
+ element.loc.source[start - element.loc.start.offset - 1] || '',
197
+ )
198
+ )
199
+ start--
200
+ return {
201
+ start: offset + start,
202
+ end: offset + prop.loc.end.offset,
203
+ text: '',
204
+ description,
205
+ }
206
+ }
207
+ return [
208
+ removeProp(intervals, 'remove disabled Progress interval mode'),
209
+ ...(count
210
+ ? [removeProp(count, 'remove unused Progress intervalCount')]
211
+ : []),
212
+ ]
213
+ }
214
+
215
+ let replacement
216
+ if (!count && numericStatic) {
217
+ replacement = `:intervals="${staticValue}"`
218
+ } else {
219
+ const countValue = count ? countExpression(count) : '6'
220
+ if (!countValue) {
221
+ refusals.push({
222
+ line: count.loc.start.line,
223
+ message: 'Progress intervalCount has no static value to migrate',
224
+ })
225
+ return []
226
+ }
227
+ if (condition && condition !== 'true') {
228
+ replacement = `:intervals="(${condition}) ? (${countValue}) : undefined"`
229
+ } else {
230
+ replacement = `:intervals="${countValue}"`
231
+ }
232
+ }
233
+
234
+ const edits = [
235
+ {
236
+ start: offset + intervals.loc.start.offset,
237
+ end: offset + intervals.loc.end.offset,
238
+ text: replacement,
239
+ description: 'Progress intervals + intervalCount -> numeric intervals',
240
+ },
241
+ ]
242
+ if (count) {
243
+ let countStart = count.loc.start.offset
244
+ let countEnd = count.loc.end.offset
245
+ if (countStart > intervals.loc.end.offset) {
246
+ while (
247
+ countStart > intervals.loc.end.offset &&
248
+ /\s/.test(
249
+ element.loc.source[countStart - element.loc.start.offset - 1] || '',
250
+ )
251
+ )
252
+ countStart--
253
+ } else {
254
+ while (
255
+ countEnd < intervals.loc.start.offset &&
256
+ /\s/.test(element.loc.source[countEnd - element.loc.start.offset] || '')
257
+ )
258
+ countEnd++
259
+ }
260
+ edits.push({
261
+ start: offset + countStart,
262
+ end: offset + countEnd,
263
+ text: '',
264
+ description: 'remove Progress intervalCount',
265
+ })
266
+ }
267
+ return edits
268
+ }
269
+
270
+ export function migrateBaseProps(source) {
271
+ const parsed = parseSfc(source, { filename: 'component.vue' })
272
+ if (parsed.errors.length) {
273
+ return {
274
+ migrated: source,
275
+ changes: [],
276
+ refusals: [
277
+ { line: 1, message: `Vue parse error: ${String(parsed.errors[0])}` },
278
+ ],
279
+ }
280
+ }
281
+ const block = parsed.descriptor.template
282
+ if (!block) return { migrated: source, changes: [], refusals: [] }
283
+
284
+ const aliases = importedComponentAliases(source)
285
+ const ast = block.ast
286
+ const edits = []
287
+ const refusals = []
288
+
289
+ function visit(node) {
290
+ if (node.type === NodeTypes.ELEMENT) {
291
+ const component = aliases.get(node.tag)
292
+ if (component === 'Icon') {
293
+ const name = node.props.find((prop) => propName(prop) === 'name')
294
+ const icon = node.props.find((prop) => propName(prop) === 'icon')
295
+ // Both props are a supported contract. Leave the lower-precedence name
296
+ // intact instead of creating a duplicate icon binding.
297
+ if (name && !icon) {
298
+ edits.push(renamePropEdit(name, 'name', 'icon', 0))
299
+ }
300
+ } else if (component === 'Divider') {
301
+ for (const prop of node.props) {
302
+ if (propName(prop) === 'position')
303
+ edits.push(renamePropEdit(prop, 'position', 'align', 0))
304
+ }
305
+ } else if (component === 'Progress') {
306
+ edits.push(...progressEdit(node, 0, refusals))
307
+ }
308
+ }
309
+ for (const child of node.children || []) visit(child)
310
+ }
311
+ visit(ast)
312
+
313
+ if (refusals.length) return { migrated: source, changes: [], refusals }
314
+ const migrated = [...edits]
315
+ .sort((a, b) => b.start - a.start)
316
+ .reduce(
317
+ (text, edit) =>
318
+ text.slice(0, edit.start) + edit.text + text.slice(edit.end),
319
+ source,
320
+ )
321
+ return { migrated, changes: edits, refusals }
322
+ }
323
+
324
+ function main() {
325
+ const args = process.argv.slice(2)
326
+ if (args.includes('--help') || args.includes('-h')) {
327
+ console.log(USAGE)
328
+ return
329
+ }
330
+ const unknown = args.filter(
331
+ (arg) => arg.startsWith('-') && arg !== '--dry-run',
332
+ )
333
+ const targets = args.filter((arg) => !arg.startsWith('-'))
334
+ if (unknown.length || !targets.length) {
335
+ if (unknown.length) console.error(`Unknown option: ${unknown.join(' ')}`)
336
+ console.error(USAGE)
337
+ process.exit(1)
338
+ }
339
+
340
+ for (const target of targets) {
341
+ if (!fs.existsSync(target)) {
342
+ console.error(`Invalid path: ${target}`)
343
+ process.exit(1)
344
+ }
345
+ }
346
+
347
+ const dryRun = args.includes('--dry-run')
348
+ const files = [...new Set(targets.flatMap((target) => walk(target)))]
349
+ let changed = 0
350
+ const refusals = []
351
+ for (const file of files) {
352
+ const source = fs.readFileSync(file, 'utf8')
353
+ const result = migrateBaseProps(source)
354
+ if (result.refusals.length) {
355
+ for (const refusal of result.refusals) refusals.push({ file, ...refusal })
356
+ continue
357
+ }
358
+ if (!result.changes.length) continue
359
+ changed++
360
+ if (!dryRun) fs.writeFileSync(file, result.migrated)
361
+ console.log(`${file} (${result.changes.length})`)
362
+ }
363
+ console.log(
364
+ `\n${dryRun ? '[dry-run] would update' : 'Updated'} ${changed} files`,
365
+ )
366
+ if (refusals.length) {
367
+ console.error(
368
+ `\nNot converted — ${refusals.length} sites need a decision; affected files were left unchanged:`,
369
+ )
370
+ for (const refusal of refusals)
371
+ console.error(` ${refusal.file}:L${refusal.line} ${refusal.message}`)
372
+ process.exit(1)
373
+ }
374
+ }
375
+
376
+ const scriptPath = fileURLToPath(import.meta.url)
377
+ const invokedPath = process.argv[1]
378
+ if (invokedPath && fs.realpathSync(invokedPath) === fs.realpathSync(scriptPath))
379
+ main()
@@ -30,7 +30,7 @@
30
30
  name: 'label',
31
31
  description: 'Content displayed inside the badge',
32
32
  required: false,
33
- type: 'Label | string | number'
33
+ type: 'string | number'
34
34
  }
35
35
  ]
36
36
 
@@ -1,7 +1,3 @@
1
- interface Label {
2
- toString(): string
3
- }
4
-
5
1
  export interface BadgeProps {
6
2
  /** Visual color theme of the badge */
7
3
  theme?: 'gray' | 'blue' | 'green' | 'amber' | 'red' | 'violet'
@@ -13,5 +9,5 @@ export interface BadgeProps {
13
9
  variant?: 'solid' | 'subtle' | 'outline' | 'ghost'
14
10
 
15
11
  /** Content displayed inside the badge */
16
- label?: Label | string | number
12
+ label?: string | number
17
13
  }
@@ -13,7 +13,7 @@
13
13
  default: '"horizontal"'
14
14
  },
15
15
  {
16
- name: 'position',
16
+ name: 'align',
17
17
  description: 'Where the action sits along the rule. Ignored without an `action`, since a\nplain rule has nothing to place.',
18
18
  required: false,
19
19
  type: '"start" | "center" | "end"',
@@ -41,6 +41,29 @@ describe('Divider', () => {
41
41
  cy.get('div').should('exist')
42
42
  })
43
43
 
44
+ it('accepts shared Button action fields', () => {
45
+ cy.mount(Divider, {
46
+ props: {
47
+ action: { label: 'Delete', theme: 'red', variant: 'solid', size: 'md' },
48
+ },
49
+ })
50
+
51
+ cy.contains('button', 'Delete')
52
+ .should('have.class', 'bg-surface-red-7')
53
+ .and('have.class', 'h-8')
54
+ .and('have.class', 'text-base-medium')
55
+ })
56
+
57
+ it('aligns an action along the divider', () => {
58
+ cy.mount(Divider, {
59
+ props: { align: 'start', action: { label: 'Add row' } },
60
+ })
61
+
62
+ cy.contains('button', 'Add row')
63
+ .parent()
64
+ .should('have.class', 'justify-start')
65
+ })
66
+
44
67
  it('does not overlap sibling content in vertical action mode', () => {
45
68
  cy.mount({
46
69
  render() {
@@ -2,6 +2,9 @@
2
2
 
3
3
  Separates content visually, either horizontally or vertically. It can also host a small inline action.
4
4
 
5
+ Use `align="start|center|end"` to place an action along the rule. The action
6
+ accepts the shared button fields such as `theme`, `variant`, `size`, and icons.
7
+
5
8
  ## Playground
6
9
 
7
10
  <ComponentPlayground name="Divider" />
@@ -13,7 +13,7 @@ const knobs: Knob[] = [
13
13
  ],
14
14
  },
15
15
  {
16
- name: 'position',
16
+ name: 'align',
17
17
  type: 'tabs',
18
18
  default: 'center',
19
19
  options: [
@@ -29,7 +29,7 @@ function buildCode(v: Record<string, any>) {
29
29
  const attrs: string[] = []
30
30
  if (v.orientation !== 'horizontal')
31
31
  attrs.push(`orientation="${v.orientation}"`)
32
- if (v.position !== 'center') attrs.push(`position="${v.position}"`)
32
+ if (v.align !== 'center') attrs.push(`align="${v.align}"`)
33
33
  if (v.withAction) attrs.push(`:action="{ label: 'Add row' }"`)
34
34
  if (!attrs.length) return '<Divider />'
35
35
  return ['<Divider', ...attrs.map((a) => ' ' + a), '/>'].join('\n')
@@ -50,7 +50,7 @@ function buildCode(v: Record<string, any>) {
50
50
  <span class="text-sm text-ink-gray-6 self-center">Before</span>
51
51
  <Divider
52
52
  orientation="vertical"
53
- :position="values.position"
53
+ :align="values.align"
54
54
  flex-item
55
55
  :action="values.withAction ? { label: 'Add row' } : undefined"
56
56
  />
@@ -58,7 +58,7 @@ function buildCode(v: Record<string, any>) {
58
58
  </template>
59
59
  <Divider
60
60
  v-else
61
- :position="values.position"
61
+ :align="values.align"
62
62
  :action="values.withAction ? { label: 'Add row' } : undefined"
63
63
  />
64
64
  </div>
@@ -7,11 +7,8 @@
7
7
  :class="actionLineClasses"
8
8
  />
9
9
  <Button
10
- :label="props.action.label"
11
- :loading="props.action.loading"
10
+ v-bind="actionButtonProps"
12
11
  class="relative z-10"
13
- size="sm"
14
- variant="outline"
15
12
  @click="actionOnClick"
16
13
  />
17
14
  </div>
@@ -22,13 +19,20 @@
22
19
  <script lang="ts" setup>
23
20
  import { computed } from 'vue'
24
21
  import { Button } from '../../index'
22
+ import { mergeActionProps } from '../shared/action'
25
23
  import type { DividerProps } from './types'
26
24
 
27
25
  const props = withDefaults(defineProps<DividerProps>(), {
28
26
  orientation: 'horizontal',
29
- position: 'center',
27
+ align: 'center',
30
28
  })
31
29
 
30
+ const actionButtonProps = computed(() =>
31
+ props.action
32
+ ? mergeActionProps({ size: 'sm', variant: 'outline' }, props.action)
33
+ : undefined,
34
+ )
35
+
32
36
  const actionOnClick = computed(() => {
33
37
  return props.action?.onClick
34
38
  })
@@ -54,7 +58,7 @@ const actionContainerClasses = computed(() => {
54
58
  center: 'justify-center',
55
59
  start: 'justify-start pl-4',
56
60
  end: 'justify-end pr-4',
57
- }[props.position]
61
+ }[props.align]
58
62
 
59
63
  return [...baseClasses, 'flex w-full min-h-7 items-center', positionClasses]
60
64
  }
@@ -64,7 +68,7 @@ const actionContainerClasses = computed(() => {
64
68
  center: 'items-center',
65
69
  start: 'items-start pt-4',
66
70
  end: 'items-end pb-4',
67
- }[props.position]
71
+ }[props.align]
68
72
 
69
73
  return [...baseClasses, 'flex justify-center', heightClasses, positionClasses]
70
74
  })
@@ -15,7 +15,7 @@
15
15
  <Divider
16
16
  orientation="vertical"
17
17
  flex-item
18
- position="center"
18
+ align="center"
19
19
  :action="{ label: 'Edit', onClick }"
20
20
  />
21
21
  <span class="text-base text-ink-gray-7">Panel B</span>
@@ -1,14 +1,7 @@
1
- /** The inline action a `Divider` can host on its rule. */
2
- export interface DividerAction {
3
- /** Text of the action button. */
4
- label: string
1
+ import type { Action } from '../shared/action'
5
2
 
6
- /** Called when the action button is clicked. */
7
- onClick?: () => any
8
-
9
- /** Shows a spinner in the action button and blocks further clicks. */
10
- loading?: boolean
11
- }
3
+ /** A shared button action with no component-specific callback context. */
4
+ export type DividerAction = Action<void> & { label: string }
12
5
 
13
6
  export interface DividerProps {
14
7
  /** Which way the rule runs. */
@@ -18,7 +11,7 @@ export interface DividerProps {
18
11
  * Where the action sits along the rule. Ignored without an `action`, since a
19
12
  * plain rule has nothing to place.
20
13
  */
21
- position?: 'start' | 'center' | 'end'
14
+ align?: 'start' | 'center' | 'end'
22
15
 
23
16
  /**
24
17
  * Stretches the divider to the cross-axis size of its flex parent
@@ -6,10 +6,16 @@
6
6
 
7
7
  const propsData = [
8
8
  {
9
- name: 'name',
9
+ name: 'icon',
10
10
  description: 'Icon source. Supported forms:\n- `lucide-*` string → rendered via the Tailwind mask plugin.\n- emoji / symbol string → rendered as plain text.\n- Vue component → rendered via `<component :is>`.\nAny other string (e.g. a bare feather-style name) is unsupported —\nit renders nothing and warns once in dev. Falsy values render nothing.',
11
11
  required: false,
12
12
  type: 'string | Component | null'
13
+ },
14
+ {
15
+ name: 'name',
16
+ description: 'Supported alternate name for `icon`. `icon` takes precedence when both\nprops are supplied.',
17
+ required: false,
18
+ type: 'string | Component | null'
13
19
  }
14
20
  ]
15
21
  </script>
@@ -3,7 +3,7 @@ import Icon from './Icon.vue'
3
3
 
4
4
  describe('<Icon />', () => {
5
5
  it('renders a lucide string as a span with the class', () => {
6
- cy.mount(Icon, { props: { name: 'lucide-star' } })
6
+ cy.mount(Icon, { props: { icon: 'lucide-star' } })
7
7
  cy.get('span').should('have.class', 'lucide-star')
8
8
  cy.get('span').should('have.attr', 'aria-hidden', 'true')
9
9
  cy.get('span').should('be.empty')
@@ -11,7 +11,7 @@ describe('<Icon />', () => {
11
11
 
12
12
  it('passes through fallthrough class on lucide spans', () => {
13
13
  cy.mount(Icon, {
14
- props: { name: 'lucide-check' },
14
+ props: { icon: 'lucide-check' },
15
15
  attrs: { class: 'size-4 text-ink-gray-6' },
16
16
  })
17
17
  cy.get('span')
@@ -21,18 +21,18 @@ describe('<Icon />', () => {
21
21
  })
22
22
 
23
23
  it('renders an emoji string as text content', () => {
24
- cy.mount(Icon, { props: { name: '🚀' } })
24
+ cy.mount(Icon, { props: { icon: '🚀' } })
25
25
  cy.get('span').should('have.text', '🚀')
26
26
  cy.get('span').should('have.attr', 'aria-hidden', 'true')
27
27
  })
28
28
 
29
29
  it('treats other glyphs (no ASCII letters/digits) as emoji', () => {
30
- cy.mount(Icon, { props: { name: '✨' } })
30
+ cy.mount(Icon, { props: { icon: '✨' } })
31
31
  cy.get('span').should('have.text', '✨')
32
32
  })
33
33
 
34
34
  it('renders nothing for an unsupported (non-lucide, non-emoji) string', () => {
35
- cy.mount(Icon, { props: { name: 'check' } })
35
+ cy.mount(Icon, { props: { icon: 'check' } })
36
36
  cy.get('span').should('not.exist')
37
37
  cy.get('svg').should('not.exist')
38
38
  })
@@ -44,10 +44,42 @@ describe('<Icon />', () => {
44
44
  return h('svg', { 'data-cy': 'custom-icon' })
45
45
  },
46
46
  }
47
- cy.mount(Icon, { props: { name: CustomIcon } })
47
+ cy.mount(Icon, { props: { icon: CustomIcon } })
48
48
  cy.get('[data-cy="custom-icon"]').should('exist')
49
49
  })
50
50
 
51
+ it('supports the name prop with string and Component values', () => {
52
+ const CustomIcon = {
53
+ render() {
54
+ return h('svg', { 'data-cy': 'name-component' })
55
+ },
56
+ }
57
+
58
+ cy.mount(Icon, { props: { name: 'lucide-star' } })
59
+ cy.get('span').should('have.class', 'lucide-star')
60
+
61
+ cy.mount(Icon, { props: { name: CustomIcon } })
62
+ cy.get('[data-cy="name-component"]').should('exist')
63
+ })
64
+
65
+ it('prefers icon unless it is undefined', () => {
66
+ cy.mount(Icon, {
67
+ props: { icon: 'lucide-check', name: 'lucide-star' },
68
+ })
69
+ cy.get('span')
70
+ .should('have.class', 'lucide-check')
71
+ .and('not.have.class', 'lucide-star')
72
+
73
+ cy.mount(Icon, { props: { icon: undefined, name: 'lucide-star' } })
74
+ cy.get('span').should('have.class', 'lucide-star')
75
+ })
76
+
77
+ it('treats an explicit null icon as the selected empty value', () => {
78
+ cy.mount(Icon, { props: { icon: null, name: 'lucide-star' } })
79
+ cy.get('span').should('not.exist')
80
+ cy.get('svg').should('not.exist')
81
+ })
82
+
51
83
  it('passes fallthrough attrs to the rendered Component', () => {
52
84
  const CustomIcon = {
53
85
  name: 'custom-icon',
@@ -56,22 +88,22 @@ describe('<Icon />', () => {
56
88
  },
57
89
  }
58
90
  cy.mount(Icon, {
59
- props: { name: CustomIcon },
91
+ props: { icon: CustomIcon },
60
92
  attrs: { class: 'size-5' },
61
93
  })
62
94
  cy.get('[data-cy="custom-icon"]').should('have.class', 'size-5')
63
95
  })
64
96
 
65
- it('renders nothing when name is falsy', () => {
66
- cy.mount(Icon, { props: { name: null } })
97
+ it('renders nothing when the resolved value is empty or omitted', () => {
98
+ cy.mount(Icon, { props: { icon: '' } })
67
99
  cy.get('span').should('not.exist')
68
100
  cy.get('svg').should('not.exist')
69
101
 
70
- cy.mount(Icon, { props: { name: '' } })
102
+ cy.mount(Icon, { props: {} })
71
103
  cy.get('span').should('not.exist')
72
104
  cy.get('svg').should('not.exist')
73
105
 
74
- cy.mount(Icon, { props: {} })
106
+ cy.mount(Icon, { props: { name: null } })
75
107
  cy.get('span').should('not.exist')
76
108
  cy.get('svg').should('not.exist')
77
109
  })
@@ -6,6 +6,17 @@ components take an icon prop directly and render it through `Icon`
6
6
  internally — reach for `Icon` yourself only when you need an icon outside
7
7
  of one of those props.
8
8
 
9
+ Pass the source through the canonical `icon` prop:
10
+
11
+ ```vue
12
+ <Icon icon="lucide-circle-check" class="size-4" />
13
+ ```
14
+
15
+ The existing `name` prop remains fully supported with the same value types.
16
+ When both props are present, `icon` takes precedence; an explicit `icon=""` or
17
+ `:icon="null"` intentionally renders nothing, while `:icon="undefined"` falls
18
+ back to `name`.
19
+
9
20
  ## Lucide string
10
21
 
11
22
  <ComponentPreview name="Icon-Lucide" />
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { watchEffect } from 'vue'
2
+ import { computed, watchEffect } from 'vue'
3
3
  import {
4
4
  isEmojiIconString,
5
5
  isLucideIconString,
@@ -11,28 +11,35 @@ defineOptions({ inheritAttrs: false })
11
11
 
12
12
  const props = defineProps<IconProps>()
13
13
 
14
+ const resolvedIcon = computed(() =>
15
+ props.icon !== undefined ? props.icon : props.name,
16
+ )
17
+
14
18
  watchEffect(() => {
15
- warnUnsupportedIconString('Icon', 'name', props.name)
19
+ const sourceProp = props.icon !== undefined ? 'icon' : 'name'
20
+ warnUnsupportedIconString('Icon', sourceProp, resolvedIcon.value)
16
21
  })
17
22
  </script>
18
23
 
19
24
  <template>
20
25
  <span
21
- v-if="typeof name === 'string' && isLucideIconString(name)"
22
- :class="[name]"
26
+ v-if="typeof resolvedIcon === 'string' && isLucideIconString(resolvedIcon)"
27
+ :class="[resolvedIcon]"
23
28
  v-bind="$attrs"
24
29
  aria-hidden="true"
25
30
  />
26
31
  <span
27
- v-else-if="typeof name === 'string' && isEmojiIconString(name)"
32
+ v-else-if="
33
+ typeof resolvedIcon === 'string' && isEmojiIconString(resolvedIcon)
34
+ "
28
35
  class="inline-flex items-center justify-center leading-none"
29
36
  v-bind="$attrs"
30
37
  aria-hidden="true"
31
- >{{ name }}</span
38
+ >{{ resolvedIcon }}</span
32
39
  >
33
40
  <component
34
- v-else-if="name && typeof name !== 'string'"
35
- :is="name"
41
+ v-else-if="resolvedIcon && typeof resolvedIcon !== 'string'"
42
+ :is="resolvedIcon"
36
43
  v-bind="$attrs"
37
44
  aria-hidden="true"
38
45
  />
@@ -20,5 +20,5 @@ const CustomIcon = () =>
20
20
  </script>
21
21
 
22
22
  <template>
23
- <Icon :name="CustomIcon" class="text-ink-gray-8" />
23
+ <Icon :icon="CustomIcon" class="text-ink-gray-8" />
24
24
  </template>
@@ -4,9 +4,9 @@ import { Icon } from 'frappe-ui'
4
4
 
5
5
  <template>
6
6
  <div class="flex items-center gap-4 text-ink-gray-8">
7
- <Icon name="lucide-plus" class="size-5" />
8
- <Icon name="lucide-trash-2" class="size-5" />
9
- <Icon name="lucide-check" class="size-5" />
10
- <Icon name="🎉" class="text-xl" />
7
+ <Icon icon="lucide-plus" class="size-5" />
8
+ <Icon icon="lucide-trash-2" class="size-5" />
9
+ <Icon icon="lucide-check" class="size-5" />
10
+ <Icon icon="🎉" class="text-xl" />
11
11
  </div>
12
12
  </template>
@@ -9,5 +9,11 @@ export interface IconProps {
9
9
  * Any other string (e.g. a bare feather-style name) is unsupported —
10
10
  * it renders nothing and warns once in dev. Falsy values render nothing.
11
11
  */
12
+ icon?: string | Component | null
13
+
14
+ /**
15
+ * Supported alternate name for `icon`. `icon` takes precedence when both
16
+ * props are supplied.
17
+ */
12
18
  name?: string | Component | null
13
19
  }
@@ -34,17 +34,9 @@
34
34
  },
35
35
  {
36
36
  name: 'intervals',
37
- description: 'Whether to show interval markers on the progress bar',
37
+ description: 'Number of interval markers to show; omit for a continuous progress bar',
38
38
  required: false,
39
- type: 'boolean',
40
- default: 'false'
41
- },
42
- {
43
- name: 'intervalCount',
44
- description: 'Number of intervals to display if `intervals` is true',
45
- required: false,
46
- type: 'number',
47
- default: '6'
39
+ type: 'number'
48
40
  }
49
41
  ]
50
42
 
@@ -42,12 +42,23 @@ describe('Progress', () => {
42
42
  }
43
43
  })
44
44
 
45
- it('hint prop & slot', () => {
45
+ it('renders the label without a hint', () => {
46
+ cy.mount(Progress, { props: { label: 'Uploading', value: 20 } })
47
+
48
+ cy.contains('span', 'Uploading').should('be.visible')
49
+ cy.contains('20%').should('not.exist')
50
+ })
51
+
52
+ it('renders the hint prop without a label', () => {
53
+ cy.mount(Progress, { props: { value: 20, hint: true } })
54
+
55
+ cy.contains('span', '20%').should('be.visible')
56
+ })
57
+
58
+ it('renders the hint slot without a label or hint prop', () => {
46
59
  cy.mount(Progress, {
47
60
  props: {
48
- label: 'label',
49
61
  value: 20,
50
- hint: true,
51
62
  },
52
63
  slots: {
53
64
  hint: () => 'hint 20%',
@@ -65,8 +76,7 @@ describe('Progress', () => {
65
76
  props: {
66
77
  label: 'label',
67
78
  value,
68
- intervals: true,
69
- intervalCount,
79
+ intervals: intervalCount,
70
80
  },
71
81
  })
72
82
 
@@ -109,7 +119,7 @@ describe('Progress', () => {
109
119
  props: {
110
120
  label: 'label',
111
121
  value: 60,
112
- intervals: true,
122
+ intervals: 6,
113
123
  },
114
124
  })
115
125
 
@@ -31,8 +31,8 @@ bar. Completing items is the most common source of a progress value.
31
31
 
32
32
  ## Multi-step form
33
33
 
34
- `intervals` turns the bar into a step indicator — one segment per step, filled
35
- up to the current one. This is what the interval variant is for.
34
+ Pass the number of segments to `intervals` to turn the bar into a step
35
+ indicator, filled up to the current step. Omit it for a continuous bar.
36
36
 
37
37
  <ComponentPreview name="Progress-MultiStepForm" />
38
38
 
@@ -17,8 +17,7 @@ const knobs: Knob[] = [
17
17
  ],
18
18
  },
19
19
  { name: 'hint', type: 'switch', default: true },
20
- { name: 'intervals', type: 'switch', default: false },
21
- { name: 'intervalCount', type: 'text', default: '6', width: '4rem' },
20
+ { name: 'intervals', type: 'text', default: '', width: '4rem' },
22
21
  ]
23
22
 
24
23
  function buildCode(v: Record<string, any>) {
@@ -26,11 +25,8 @@ function buildCode(v: Record<string, any>) {
26
25
  if (v.label) attrs.push(`label="${v.label}"`)
27
26
  if (v.size !== 'sm') attrs.push(`size="${v.size}"`)
28
27
  if (v.hint) attrs.push('hint')
29
- if (v.intervals) {
30
- attrs.push('intervals')
31
- const ic = Number(v.intervalCount)
32
- if (ic && ic !== 6) attrs.push(`:interval-count="${ic}"`)
33
- }
28
+ const intervals = Number(v.intervals)
29
+ if (intervals > 0) attrs.push(`:intervals="${intervals}"`)
34
30
  return ['<Progress', ...attrs.map((a) => ' ' + a), '/>'].join('\n')
35
31
  }
36
32
  </script>
@@ -44,8 +40,7 @@ function buildCode(v: Record<string, any>) {
44
40
  :label="values.label || undefined"
45
41
  :size="values.size"
46
42
  :hint="values.hint"
47
- :intervals="values.intervals"
48
- :interval-count="Number(values.intervalCount) || 6"
43
+ :intervals="Number(values.intervals) || undefined"
49
44
  />
50
45
  </div>
51
46
  </template>
@@ -1,7 +1,7 @@
1
1
  <template>
2
2
  <div class="w-full space-y-[10px]">
3
3
  <div
4
- v-if="props.label || props.hint"
4
+ v-if="props.label || props.hint || $slots.hint"
5
5
  class="flex items-baseline justify-between"
6
6
  >
7
7
  <span v-if="props.label" class="text-base-medium text-ink-gray-8">
@@ -42,7 +42,7 @@
42
42
  <!-- Interval Progress Bar -->
43
43
  <div
44
44
  v-else
45
- v-for="index in intervalCount"
45
+ v-for="index in props.intervals"
46
46
  class="h-full w-full"
47
47
  :class="
48
48
  index <= filledIntervalCount
@@ -66,8 +66,6 @@ const props = withDefaults(defineProps<ProgressProps>(), {
66
66
  size: 'sm',
67
67
  hint: false,
68
68
  label: '',
69
- intervals: false,
70
- intervalCount: 6,
71
69
  })
72
70
 
73
71
  const indicatorContainerClasses = computed(() => {
@@ -104,7 +102,7 @@ const valueLabel = computed(
104
102
  )
105
103
 
106
104
  const filledIntervalCount = computed(() =>
107
- Math.round((clampedValue.value / MAX_VALUE) * props.intervalCount),
105
+ Math.round((clampedValue.value / MAX_VALUE) * (props.intervals ?? 0)),
108
106
  )
109
107
 
110
108
  defineSlots<{
@@ -21,12 +21,7 @@ function continueForm() {
21
21
 
22
22
  <template>
23
23
  <div class="w-full max-w-[400px] rounded-7 border border-outline-gray-1 p-4">
24
- <Progress
25
- :value="percent"
26
- :label="steps[step]"
27
- :intervals="true"
28
- :interval-count="steps.length"
29
- >
24
+ <Progress :value="percent" :label="steps[step]" :intervals="steps.length">
30
25
  <template #hint>
31
26
  <span class="text-base text-ink-gray-5">
32
27
  Step {{ step + 1 }} of {{ steps.length }}
@@ -11,9 +11,6 @@ export interface ProgressProps {
11
11
  /** Whether to show a hint/tooltip for the progress value */
12
12
  hint?: boolean
13
13
 
14
- /** Whether to show interval markers on the progress bar */
15
- intervals?: boolean
16
-
17
- /** Number of intervals to display if `intervals` is true */
18
- intervalCount?: number
14
+ /** Number of interval markers to show; omit for a continuous progress bar */
15
+ intervals?: number
19
16
  }
@@ -1,15 +1,15 @@
1
1
  /**
2
2
  * Shared action-button config for components that render caller-provided
3
- * actions (`Dialog`, `Alert`, `SidebarCard`): `ButtonProps` plus a
4
- * context-aware `onClick`.
3
+ * actions (`Dialog`, `Alert`, `SidebarCard`, `Divider`): `ButtonProps` plus
4
+ * `onClick`.
5
5
  */
6
6
  import type { ButtonProps } from '../Button'
7
7
 
8
8
  /**
9
9
  * Internal generic behind the public action types (`AlertAction`,
10
- * `DialogAction`): `ButtonProps` plus an `onClick` that receives the
11
- * component's context — `{ close }` for Dialog, `{ dismiss }` for Alert
12
- * and SidebarCard. Not exported from the package root.
10
+ * `DialogAction`, `DividerAction`): `ButtonProps` plus `onClick`. Dialog
11
+ * receives `{ close }`, Alert and SidebarCard receive `{ dismiss }`, and
12
+ * Divider has no callback context. Not exported from the package root.
13
13
  */
14
14
  export type Action<Ctx> = ButtonProps & {
15
15
  /** Called on click with the component's context (e.g. `{ dismiss }` or `{ close }`). */
@@ -108,8 +108,8 @@ function hasLabel(label: PillProps['label']) {
108
108
 
109
109
  <template>
110
110
  <span :class="rootClasses" :data-state="active ? 'active' : 'inactive'">
111
- <Icon v-if="icon" :name="icon" :class="iconClass" />
112
- <Icon v-else-if="iconLeft" :name="iconLeft" :class="iconClass" />
111
+ <Icon v-if="icon" :icon="icon" :class="iconClass" />
112
+ <Icon v-else-if="iconLeft" :icon="iconLeft" :class="iconClass" />
113
113
 
114
114
  <span
115
115
  v-if="$slots.prefix"