frappe-ui 1.0.0-beta.69 → 1.0.0-beta.70

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 (172) hide show
  1. package/experimental/CodeEditor/CodeEditor.api.md +2 -2
  2. package/experimental.ts +0 -1
  3. package/package.json +5 -2
  4. package/scripts/migrate-data-v1.js +424 -0
  5. package/src/charts/tokens.ts +2 -2
  6. package/src/components/Alert/Alert.api.md +1 -1
  7. package/src/components/Alert/Alert.md +36 -0
  8. package/src/components/Alert/types.ts +1 -1
  9. package/src/components/BottomSheet/BottomSheet.md +9 -2
  10. package/src/components/BottomSheet/BottomSheet.test.ts +31 -0
  11. package/src/components/BottomSheet/BottomSheet.vue +5 -1
  12. package/src/components/Breadcrumbs/Breadcrumbs.md +16 -0
  13. package/src/components/Breadcrumbs/types.ts +2 -2
  14. package/src/components/Button/Button.api.md +1 -1
  15. package/src/components/Button/types.ts +2 -2
  16. package/src/components/Checkbox/Checkbox.api.md +2 -2
  17. package/src/components/Checkbox/Checkbox.cy.ts +50 -0
  18. package/src/components/Checkbox/Checkbox.md +23 -0
  19. package/src/components/Checkbox/Checkbox.vue +24 -2
  20. package/src/components/Combobox/Combobox.api.md +5 -6
  21. package/src/components/Combobox/Combobox.cy.ts +37 -0
  22. package/src/components/Combobox/Combobox.vue +21 -11
  23. package/src/components/Combobox/types.ts +6 -3
  24. package/src/components/DatePicker/DatePicker.api.md +8 -8
  25. package/src/components/DatePicker/DatePicker.cy.ts +74 -0
  26. package/src/components/DatePicker/DatePicker.md +27 -0
  27. package/src/components/DatePicker/DatePicker.vue +10 -2
  28. package/src/components/DatePicker/DateRangePicker.cy.ts +19 -0
  29. package/src/components/DatePicker/DateRangePicker.vue +10 -2
  30. package/src/components/DatePicker/DateTimePicker.cy.ts +19 -0
  31. package/src/components/DatePicker/DateTimePicker.vue +10 -2
  32. package/src/components/DatePicker/index.ts +26 -1
  33. package/src/components/DatePicker/stories/Range.vue +6 -6
  34. package/src/components/DatePicker/types.ts +10 -5
  35. package/src/components/DesktopShell/DesktopShell.api.md +1 -1
  36. package/src/components/DesktopShell/DesktopShell.cy.ts +73 -2
  37. package/src/components/DesktopShell/DesktopShell.md +41 -6
  38. package/src/components/DesktopShell/DesktopShell.vue +18 -13
  39. package/src/components/DesktopShell/types.ts +15 -2
  40. package/src/components/Dialog/Dialog.api.md +8 -2
  41. package/src/components/Dialog/Dialog.cy.ts +106 -2
  42. package/src/components/Dialog/Dialog.md +48 -0
  43. package/src/components/Dialog/Dialog.vue +44 -24
  44. package/src/components/Dialog/index.ts +0 -1
  45. package/src/components/Dialog/types.ts +12 -8
  46. package/src/components/Duration/Duration.api.md +17 -2
  47. package/src/components/Duration/Duration.cy.ts +39 -0
  48. package/src/components/Duration/Duration.md +14 -1
  49. package/src/components/Duration/Duration.vue +17 -1
  50. package/src/components/Duration/types.ts +6 -5
  51. package/src/components/ErrorMessage/ErrorMessage.api.md +2 -2
  52. package/src/components/ErrorMessage/ErrorMessage.md +8 -0
  53. package/src/components/ErrorMessage/ErrorMessage.test.ts +57 -1
  54. package/src/components/ErrorMessage/ErrorMessage.vue +22 -21
  55. package/src/components/ErrorMessage/index.ts +1 -1
  56. package/src/components/ErrorMessage/stories/SeveralMessages.vue +12 -0
  57. package/src/components/ErrorMessage/types.ts +7 -2
  58. package/src/components/FormControl/FormControl.api.md +1 -1
  59. package/src/components/FormControl/FormControl.cy.ts +34 -0
  60. package/src/components/FormControl/FormControl.md +7 -0
  61. package/src/components/FormControl/FormControl.vue +17 -2
  62. package/src/components/FormControl/types.ts +2 -2
  63. package/src/components/FormLabel/FormLabel.cy.ts +9 -0
  64. package/src/components/FormLabel/FormLabel.vue +1 -1
  65. package/src/components/Menu/types.ts +2 -2
  66. package/src/components/MobileNav/MobileNav.api.md +1 -1
  67. package/src/components/MobileNav/types.ts +2 -2
  68. package/src/components/MobileShell/MobileShell.cy.ts +64 -2
  69. package/src/components/MobileShell/MobileShell.md +10 -4
  70. package/src/components/MobileShell/MobileShell.vue +23 -1
  71. package/src/components/MultiSelect/MultiSelect.api.md +3 -3
  72. package/src/components/MultiSelect/types.ts +6 -3
  73. package/src/components/PageHeader/PageHeader.api.md +1 -1
  74. package/src/components/PageHeader/PageHeader.cy.ts +40 -0
  75. package/src/components/PageHeader/PageHeader.md +14 -4
  76. package/src/components/PageHeader/PageHeaderBase.vue +8 -3
  77. package/src/components/PageHeader/PageHeaderMobile.vue +3 -3
  78. package/src/components/PageHeader/PageHeaderTarget.vue +5 -0
  79. package/src/components/PageHeader/PageHeaderTitle.vue +3 -4
  80. package/src/components/PageHeader/index.ts +10 -0
  81. package/src/components/PageHeader/target.ts +12 -1
  82. package/src/components/PageHeader/types.ts +7 -2
  83. package/src/components/PageHeader/useHeaderBack.ts +5 -4
  84. package/src/components/Password/Password.api.md +2 -2
  85. package/src/components/Radio/Radio.api.md +2 -2
  86. package/src/components/Radio/Radio.cy.ts +52 -0
  87. package/src/components/Radio/Radio.md +23 -0
  88. package/src/components/Radio/RadioGroup.vue +36 -3
  89. package/src/components/Radio/types.ts +5 -2
  90. package/src/components/Rating/Rating.api.md +3 -3
  91. package/src/components/Rating/Rating.cy.ts +53 -0
  92. package/src/components/Rating/Rating.md +10 -0
  93. package/src/components/Rating/Rating.vue +36 -2
  94. package/src/components/ScrollArea/ScrollArea.api.md +4 -20
  95. package/src/components/ScrollArea/ScrollArea.md +14 -4
  96. package/src/components/ScrollArea/index.ts +4 -6
  97. package/src/components/Select/Select.api.md +5 -5
  98. package/src/components/Select/Select.cy.ts +87 -0
  99. package/src/components/Select/Select.md +4 -1
  100. package/src/components/Select/Select.vue +25 -7
  101. package/src/components/Select/types.test.ts +25 -0
  102. package/src/components/Select/types.ts +12 -6
  103. package/src/components/Sidebar/Sidebar.api.md +1 -1
  104. package/src/components/Sidebar/Sidebar.md +4 -0
  105. package/src/components/Sidebar/types.ts +2 -2
  106. package/src/components/SidebarRail/SidebarRail.api.md +1 -1
  107. package/src/components/SidebarRail/types.ts +2 -2
  108. package/src/components/Slider/Slider.api.md +2 -2
  109. package/src/components/Slider/Slider.cy.ts +25 -0
  110. package/src/components/Slider/Slider.md +18 -0
  111. package/src/components/Slider/Slider.vue +16 -1
  112. package/src/components/Switch/Switch.api.md +2 -2
  113. package/src/components/Switch/Switch.cy.ts +36 -0
  114. package/src/components/Switch/Switch.md +23 -0
  115. package/src/components/Switch/Switch.vue +25 -3
  116. package/src/components/TabButtons/types.ts +2 -2
  117. package/src/components/Tabs/Tabs.api.md +1 -1
  118. package/src/components/Tabs/types.ts +3 -3
  119. package/src/components/TextInput/TextInput.api.md +2 -2
  120. package/src/components/TextInput/TextInput.cy.ts +34 -0
  121. package/src/components/TextInput/TextInput.md +16 -0
  122. package/src/components/TextInput/types.ts +6 -5
  123. package/src/components/Textarea/Textarea.api.md +2 -2
  124. package/src/components/TimePicker/TimePicker.api.md +4 -24
  125. package/src/components/TimePicker/TimePicker.cy.ts +127 -8
  126. package/src/components/TimePicker/TimePicker.md +27 -0
  127. package/src/components/TimePicker/TimePicker.vue +22 -20
  128. package/src/components/TimePicker/index.ts +1 -1
  129. package/src/components/TimePicker/types.ts +12 -6
  130. package/src/components/Toast/Toast.md +28 -0
  131. package/src/components/Toast/Toast.test.ts +10 -0
  132. package/src/components/Toast/types.ts +27 -0
  133. package/src/components/shared/picker/PickerShell.vue +30 -1
  134. package/src/components/shared/picker/types.ts +23 -6
  135. package/src/components/shared/route.ts +47 -0
  136. package/src/components/shared/selection/types.ts +57 -4
  137. package/src/composables/inputTypes.ts +16 -0
  138. package/src/composables/useColorScheme.spec.ts +227 -1
  139. package/src/composables/useColorScheme.ts +116 -6
  140. package/src/composables/useInputLabeling.ts +12 -26
  141. package/src/composables/useShellScrolled.spec.ts +53 -8
  142. package/src/composables/useShellScrolled.ts +52 -9
  143. package/src/data-fetching/staleWrites.test.ts +2 -1
  144. package/src/data-fetching/useAction.ts +10 -1
  145. package/src/data-fetching/useCall/useCall.test.ts +382 -2
  146. package/src/data-fetching/useCall/useCall.ts +73 -3
  147. package/src/data-fetching/useDoc/useDoc.test.ts +60 -4
  148. package/src/data-fetching/useDoc/useDoc.ts +36 -3
  149. package/src/data-fetching/useFrappeFetch.ts +28 -0
  150. package/src/data-fetching/useIsolatedCall.ts +20 -6
  151. package/src/data-fetching/useNewDoc/useNewDoc.test.ts +24 -0
  152. package/src/data-fetching/useNewDoc/useNewDoc.ts +22 -4
  153. package/src/exports.test.ts +191 -0
  154. package/src/index.ts +42 -11
  155. package/src/mocks/handlers.ts +8 -1
  156. package/src/molecules/editor/exports.test.ts +43 -0
  157. package/src/molecules/editor/index.ts +121 -2
  158. package/src/resources/index.ts +2 -2
  159. package/src/utils/call.ts +5 -9
  160. package/src/utils/dayjs.ts +7 -0
  161. package/src/utils/dialog.ts +15 -19
  162. package/src/utils/errorLines.test.ts +50 -0
  163. package/src/utils/errorLines.ts +36 -0
  164. package/src/utils/fileUploadHandler.test.ts +97 -0
  165. package/src/utils/fileUploadHandler.ts +20 -4
  166. package/src/utils/frappeRequest.ts +14 -5
  167. package/src/utils/iconString.test.ts +71 -0
  168. package/src/utils/iconString.ts +62 -0
  169. package/src/utils/plugin.test.ts +20 -0
  170. package/src/utils/plugin.ts +2 -2
  171. package/src/utils/useFileUpload.test.ts +157 -18
  172. package/src/utils/useFileUpload.ts +58 -23
@@ -60,9 +60,9 @@
60
60
  },
61
61
  {
62
62
  name: 'error',
63
- description: 'Error message rendered below the input. When set, the control receives\n`aria-invalid="true"` and `data-state="invalid"`. May be either a string\nor an `Error` object whose `messages?: string[]` is rendered as stacked\nlines (with `Error.message` as the fallback).',
63
+ description: 'Error message rendered below the input. When set, the control receives\n`aria-invalid="true"` and `data-state="invalid"`. Takes a string, an\narray of strings, or an `Error` whose `messages` are rendered as stacked\nlines (with `Error.message` as the fallback). This is the same value\n`ErrorMessage.message` takes. An empty array and an empty string both\nmean no error.',
64
64
  required: false,
65
- type: 'string | FrappeUIError'
65
+ type: 'ErrorMessageValue'
66
66
  },
67
67
  {
68
68
  name: 'required',
package/experimental.ts CHANGED
@@ -10,7 +10,6 @@ export {
10
10
  InputError,
11
11
  } from './src/components/InputLabeling'
12
12
  export { useInputLabeling } from './src/composables/useInputLabeling'
13
- export type { FrappeUIError } from './src/composables/useInputLabeling'
14
13
  export {
15
14
  CodeEditor,
16
15
  CodePreview,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frappe-ui",
3
- "version": "1.0.0-beta.69",
3
+ "version": "1.0.0-beta.70",
4
4
  "description": "A set of components and utilities for rapid UI development",
5
5
  "engines": {
6
6
  "node": ">=20.19.0"
@@ -26,7 +26,8 @@
26
26
  "tokens-v2": "./tailwind/migrate-tokens-v2.js",
27
27
  "shortcuts-v1": "./scripts/migrate-shortcuts-v1.js",
28
28
  "editor-v1": "./scripts/migrate-editor-v1.js",
29
- "destinations-v1": "./scripts/migrate-destinations-v1.js"
29
+ "destinations-v1": "./scripts/migrate-destinations-v1.js",
30
+ "data-v1": "./scripts/migrate-data-v1.js"
30
31
  },
31
32
  "scripts": {
32
33
  "test": "vitest --run",
@@ -113,6 +114,8 @@
113
114
  "scripts/migrate-list-v1.js",
114
115
  "scripts/migrate-navigation-v1.js",
115
116
  "scripts/migrate-overlays-v1.js",
117
+ "scripts/migrate-destinations-v1.js",
118
+ "scripts/migrate-data-v1.js",
116
119
  "experimental",
117
120
  "experimental.ts",
118
121
  "tsconfig.base.json"
@@ -0,0 +1,424 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Data v1 migration codemod.
4
+ *
5
+ * The FrappeUI plugin's `resources` option is a boolean in v1. The object
6
+ * form was never read — the plugin only checked whether the value was set —
7
+ * so every object value becomes `true`:
8
+ *
9
+ * app.use(FrappeUI, { resources: { todos } }) -> app.use(FrappeUI, { resources: true })
10
+ *
11
+ * A value the codemod cannot read (a variable, a call, a spread) is reported
12
+ * and the file is left unchanged.
13
+ *
14
+ * Usage: data-v1 [--dry-run] <dir-or-file...>
15
+ */
16
+
17
+ import fs from 'node:fs'
18
+ import path from 'node:path'
19
+ import { fileURLToPath } from 'node:url'
20
+ import ts from 'typescript'
21
+ import { parse as parseSfc } from '@vue/compiler-sfc'
22
+
23
+ const USAGE = `Usage: data-v1 [--dry-run] <dir-or-file...>
24
+
25
+ Narrows the FrappeUI plugin's resources option to a boolean:
26
+ app.use(FrappeUI, { resources: { … } }) -> app.use(FrappeUI, { resources: true })`
27
+
28
+ const SCRIPT_EXTENSIONS = new Set([
29
+ '.js',
30
+ '.mjs',
31
+ '.cjs',
32
+ '.jsx',
33
+ '.ts',
34
+ '.mts',
35
+ '.cts',
36
+ '.tsx',
37
+ ])
38
+ const EXTENSIONS = new Set([...SCRIPT_EXTENSIONS, '.vue'])
39
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'coverage'])
40
+
41
+ function walk(target, visited = new Set()) {
42
+ if (fs.lstatSync(target).isSymbolicLink()) return []
43
+ const resolved = fs.realpathSync(target)
44
+ if (visited.has(resolved)) return []
45
+ visited.add(resolved)
46
+ if (fs.statSync(resolved).isFile()) {
47
+ return EXTENSIONS.has(path.extname(target)) ? [target] : []
48
+ }
49
+ return fs.readdirSync(resolved, { withFileTypes: true }).flatMap((entry) => {
50
+ if (SKIP_DIRS.has(entry.name)) return []
51
+ return walk(path.join(target, entry.name), visited)
52
+ })
53
+ }
54
+
55
+ function lineAt(source, offset) {
56
+ return source.slice(0, offset).split('\n').length
57
+ }
58
+
59
+ function scriptKindFor(filename) {
60
+ const extension = path.extname(filename)
61
+ if (extension === '.tsx' || extension === '.jsx') return ts.ScriptKind.TSX
62
+ return ts.ScriptKind.TS
63
+ }
64
+
65
+ /**
66
+ * Names the frappe-ui plugin is bound to in this chunk.
67
+ *
68
+ * `FrappeUI` is a named export, so `import { FrappeUI }` and
69
+ * `import { FrappeUI as Plugin }` are the two direct forms. A namespace
70
+ * import is matched through its `.FrappeUI` property access instead.
71
+ */
72
+ function pluginBindings(sourceFile) {
73
+ const direct = new Set()
74
+ const namespaces = new Set()
75
+ for (const statement of sourceFile.statements) {
76
+ if (!ts.isImportDeclaration(statement)) continue
77
+ if (!ts.isStringLiteral(statement.moduleSpecifier)) continue
78
+ if (statement.moduleSpecifier.text !== 'frappe-ui') continue
79
+ const bindings = statement.importClause?.namedBindings
80
+ if (!bindings) continue
81
+ if (ts.isNamespaceImport(bindings)) {
82
+ namespaces.add(bindings.name.text)
83
+ continue
84
+ }
85
+ for (const element of bindings.elements) {
86
+ const imported = element.propertyName?.text ?? element.name.text
87
+ if (imported === 'FrappeUI') direct.add(element.name.text)
88
+ }
89
+ }
90
+ return { direct, namespaces }
91
+ }
92
+
93
+ /** Names a node declares in its own scope. Parameters included. */
94
+ function declaredNames(node) {
95
+ const names = new Set()
96
+ const addBindingName = (name) => {
97
+ if (ts.isIdentifier(name)) {
98
+ names.add(name.text)
99
+ return
100
+ }
101
+ for (const element of name.elements ?? []) {
102
+ if (ts.isOmittedExpression(element)) continue
103
+ addBindingName(element.name)
104
+ }
105
+ }
106
+
107
+ const addStatement = (statement) => {
108
+ if (ts.isVariableStatement(statement)) {
109
+ for (const declaration of statement.declarationList.declarations) {
110
+ addBindingName(declaration.name)
111
+ }
112
+ } else if (
113
+ (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) &&
114
+ statement.name
115
+ ) {
116
+ names.add(statement.name.text)
117
+ }
118
+ }
119
+
120
+ for (const parameter of node.parameters ?? []) addBindingName(parameter.name)
121
+ // `catch (error)` binds its parameter through `variableDeclaration`, not
122
+ // `parameters`.
123
+ if (ts.isCatchClause(node) && node.variableDeclaration) {
124
+ addBindingName(node.variableDeclaration.name)
125
+ }
126
+ // A named function expression binds its own name inside its body, and
127
+ // nowhere else. `const f = function FrappeUI() { … }` shadows the import.
128
+ if (
129
+ (ts.isFunctionExpression(node) || ts.isClassExpression(node)) &&
130
+ node.name
131
+ ) {
132
+ names.add(node.name.text)
133
+ }
134
+ // A source file, a bare block and a namespace body hold their own
135
+ // statements. Everything else that opens a scope keeps them under `body`,
136
+ // which is undefined for those, so read the node itself in those cases.
137
+ const body =
138
+ ts.isSourceFile(node) || ts.isBlock(node) || ts.isModuleBlock(node)
139
+ ? node
140
+ : node.body
141
+ if (body && 'statements' in body) {
142
+ for (const statement of body.statements) addStatement(statement)
143
+ }
144
+ // `switch` bodies share one scope across every clause, and a clause without
145
+ // braces keeps its statements on the clause, not on a block.
146
+ if (ts.isCaseBlock(node)) {
147
+ for (const clause of node.clauses) {
148
+ for (const statement of clause.statements) addStatement(statement)
149
+ }
150
+ }
151
+ // `for (…;;)`, `for…of` and `for…in` all bind their loop variable in the
152
+ // loop's own scope, through `initializer`.
153
+ if (
154
+ (ts.isForStatement(node) ||
155
+ ts.isForOfStatement(node) ||
156
+ ts.isForInStatement(node)) &&
157
+ node.initializer &&
158
+ ts.isVariableDeclarationList(node.initializer)
159
+ ) {
160
+ for (const declaration of node.initializer.declarations) {
161
+ addBindingName(declaration.name)
162
+ }
163
+ }
164
+ return names
165
+ }
166
+
167
+ function opensScope(node) {
168
+ return (
169
+ ts.isSourceFile(node) ||
170
+ ts.isFunctionDeclaration(node) ||
171
+ ts.isFunctionExpression(node) ||
172
+ ts.isArrowFunction(node) ||
173
+ ts.isMethodDeclaration(node) ||
174
+ ts.isConstructorDeclaration(node) ||
175
+ ts.isGetAccessorDeclaration(node) ||
176
+ ts.isSetAccessorDeclaration(node) ||
177
+ ts.isBlock(node) ||
178
+ ts.isCaseBlock(node) ||
179
+ ts.isModuleBlock(node) ||
180
+ ts.isClassExpression(node) ||
181
+ ts.isForStatement(node) ||
182
+ ts.isForOfStatement(node) ||
183
+ ts.isForInStatement(node) ||
184
+ ts.isCatchClause(node)
185
+ )
186
+ }
187
+
188
+ function propertyName(property) {
189
+ const name = property.name
190
+ if (!name) return undefined
191
+ if (ts.isIdentifier(name)) return name.text
192
+ if (ts.isStringLiteral(name)) return name.text
193
+ return undefined
194
+ }
195
+
196
+ /**
197
+ * Rewrites one script chunk. `offset` maps chunk positions back onto the
198
+ * original file so refusal line numbers and edits line up in `.vue` files.
199
+ */
200
+ function migrateChunk(text, offset, filename, source) {
201
+ const sourceFile = ts.createSourceFile(
202
+ filename,
203
+ text,
204
+ ts.ScriptTarget.Latest,
205
+ true,
206
+ scriptKindFor(filename),
207
+ )
208
+ const { direct, namespaces } = pluginBindings(sourceFile)
209
+ if (!direct.size && !namespaces.size) return { edits: [], refusals: [] }
210
+
211
+ const edits = []
212
+ const refusals = []
213
+ const refuse = (node, message) => {
214
+ refusals.push({ line: lineAt(source, offset + node.getStart(sourceFile)), message })
215
+ }
216
+
217
+ /** True when `name` resolves to something other than the frappe-ui import. */
218
+ const isShadowed = (name, scopes) => {
219
+ for (let index = scopes.length - 1; index > 0; index--) {
220
+ if (scopes[index].has(name)) return true
221
+ }
222
+ return false
223
+ }
224
+
225
+ const isPluginArgument = (argument, scopes) => {
226
+ if (ts.isIdentifier(argument)) {
227
+ return direct.has(argument.text) && !isShadowed(argument.text, scopes)
228
+ }
229
+ if (
230
+ ts.isPropertyAccessExpression(argument) &&
231
+ ts.isIdentifier(argument.expression) &&
232
+ argument.name.text === 'FrappeUI'
233
+ ) {
234
+ return (
235
+ namespaces.has(argument.expression.text) &&
236
+ !isShadowed(argument.expression.text, scopes)
237
+ )
238
+ }
239
+ return false
240
+ }
241
+
242
+ const visitCall = (node, scopes) => {
243
+ if (!ts.isPropertyAccessExpression(node.expression)) return
244
+ if (node.expression.name.text !== 'use') return
245
+ const [plugin, options] = node.arguments
246
+ if (!plugin || !isPluginArgument(plugin, scopes)) return
247
+ if (!options) return
248
+
249
+ if (!ts.isObjectLiteralExpression(options)) {
250
+ refuse(
251
+ options,
252
+ 'app.use(FrappeUI, …) receives options this codemod cannot read. Set `resources` to true or false by hand if the value is an object.',
253
+ )
254
+ return
255
+ }
256
+
257
+ for (const property of options.properties) {
258
+ if (ts.isSpreadAssignment(property)) {
259
+ refuse(
260
+ property,
261
+ 'the FrappeUI options object spreads another value that may carry `resources`. Narrow it to a boolean by hand.',
262
+ )
263
+ continue
264
+ }
265
+ if (
266
+ ts.isPropertyAssignment(property) &&
267
+ property.name &&
268
+ ts.isComputedPropertyName(property.name)
269
+ ) {
270
+ refuse(
271
+ property,
272
+ 'the FrappeUI options object uses a computed key that may be `resources`. Narrow it to a boolean by hand.',
273
+ )
274
+ continue
275
+ }
276
+ if (propertyName(property) !== 'resources') continue
277
+
278
+ if (ts.isShorthandPropertyAssignment(property)) {
279
+ refuse(
280
+ property,
281
+ '`resources` is bound to a variable. Pass true or false instead.',
282
+ )
283
+ continue
284
+ }
285
+ if (!ts.isPropertyAssignment(property)) continue
286
+
287
+ const value = property.initializer
288
+ if (
289
+ value.kind === ts.SyntaxKind.TrueKeyword ||
290
+ value.kind === ts.SyntaxKind.FalseKeyword
291
+ ) {
292
+ continue
293
+ }
294
+ if (!ts.isObjectLiteralExpression(value)) {
295
+ refuse(
296
+ value,
297
+ '`resources` is set to a value this codemod cannot read. Pass true or false instead.',
298
+ )
299
+ continue
300
+ }
301
+ edits.push({
302
+ start: offset + value.getStart(sourceFile),
303
+ end: offset + value.getEnd(),
304
+ text: 'true',
305
+ })
306
+ }
307
+ }
308
+
309
+ const visit = (node, scopes) => {
310
+ const nextScopes = opensScope(node)
311
+ ? [...scopes, declaredNames(node)]
312
+ : scopes
313
+ if (ts.isCallExpression(node)) visitCall(node, nextScopes)
314
+ ts.forEachChild(node, (child) => visit(child, nextScopes))
315
+ }
316
+
317
+ visit(sourceFile, [declaredNames(sourceFile)])
318
+ return { edits, refusals }
319
+ }
320
+
321
+ export function migrateData(source, filename = 'source.ts') {
322
+ const chunks = []
323
+ if (path.extname(filename) === '.vue') {
324
+ const parsed = parseSfc(source, { filename })
325
+ if (parsed.errors.length) {
326
+ return {
327
+ migrated: source,
328
+ changes: [],
329
+ refusals: [
330
+ {
331
+ line: 1,
332
+ message: `could not be parsed as a Vue single-file component (${parsed.errors[0].message}). This file is unchanged.`,
333
+ },
334
+ ],
335
+ }
336
+ }
337
+ const { script, scriptSetup } = parsed.descriptor
338
+ for (const block of [script, scriptSetup]) {
339
+ if (!block) continue
340
+ const lang = block.lang === 'tsx' || block.lang === 'jsx' ? 'tsx' : 'ts'
341
+ chunks.push({
342
+ text: block.content,
343
+ offset: block.loc.start.offset,
344
+ name: `${filename}.${lang}`,
345
+ })
346
+ }
347
+ } else {
348
+ chunks.push({ text: source, offset: 0, name: filename })
349
+ }
350
+
351
+ const edits = []
352
+ const refusals = []
353
+ for (const chunk of chunks) {
354
+ const result = migrateChunk(chunk.text, chunk.offset, chunk.name, source)
355
+ edits.push(...result.edits)
356
+ refusals.push(...result.refusals)
357
+ }
358
+
359
+ if (refusals.length) return { migrated: source, changes: [], refusals }
360
+ const migrated = [...edits]
361
+ .sort((a, b) => b.start - a.start)
362
+ .reduce(
363
+ (text, edit) => text.slice(0, edit.start) + edit.text + text.slice(edit.end),
364
+ source,
365
+ )
366
+ return { migrated, changes: edits, refusals }
367
+ }
368
+
369
+ function main() {
370
+ const args = process.argv.slice(2)
371
+ if (args.includes('--help') || args.includes('-h')) {
372
+ console.log(USAGE)
373
+ return
374
+ }
375
+ const unknown = args.filter(
376
+ (arg) => arg.startsWith('-') && arg !== '--dry-run',
377
+ )
378
+ const targets = args.filter((arg) => !arg.startsWith('-'))
379
+ if (unknown.length || !targets.length) {
380
+ if (unknown.length) console.error(`Unknown option: ${unknown.join(' ')}`)
381
+ console.error(USAGE)
382
+ process.exit(1)
383
+ }
384
+
385
+ for (const target of targets) {
386
+ if (!fs.existsSync(target)) {
387
+ console.error(`Invalid path: ${target}`)
388
+ process.exit(1)
389
+ }
390
+ }
391
+
392
+ const dryRun = args.includes('--dry-run')
393
+ const files = [...new Set(targets.flatMap((target) => walk(target)))]
394
+ let changed = 0
395
+ const refusals = []
396
+ for (const file of files) {
397
+ const source = fs.readFileSync(file, 'utf8')
398
+ const result = migrateData(source, file)
399
+ if (result.refusals.length) {
400
+ for (const refusal of result.refusals) refusals.push({ file, ...refusal })
401
+ continue
402
+ }
403
+ if (!result.changes.length) continue
404
+ changed++
405
+ if (!dryRun) fs.writeFileSync(file, result.migrated)
406
+ console.log(`${file} (${result.changes.length})`)
407
+ }
408
+ console.log(
409
+ `\n${dryRun ? '[dry-run] would update' : 'Updated'} ${changed} files`,
410
+ )
411
+ if (refusals.length) {
412
+ console.error(
413
+ `\nNot converted — ${refusals.length} sites need a decision; affected files were left unchanged:`,
414
+ )
415
+ for (const refusal of refusals)
416
+ console.error(` ${refusal.file}:L${refusal.line} ${refusal.message}`)
417
+ process.exit(1)
418
+ }
419
+ }
420
+
421
+ const scriptPath = fileURLToPath(import.meta.url)
422
+ const invokedPath = process.argv[1]
423
+ if (invokedPath && fs.realpathSync(invokedPath) === fs.realpathSync(scriptPath))
424
+ main()
@@ -1,6 +1,6 @@
1
1
  import { computed, type ComputedRef, type Ref } from 'vue'
2
2
  import {
3
- resolvedColorScheme,
3
+ getResolvedColorScheme,
4
4
  type ResolvedColorScheme,
5
5
  } from '../composables/useColorScheme'
6
6
  import { documentAttributes } from './utils'
@@ -207,7 +207,7 @@ function isTransparent(color: string) {
207
207
  * lookup so a subtree that redefines `--chart-*` wins over the document root.
208
208
  */
209
209
  export function resolveChartTokens(el?: HTMLElement | null): ChartTokens {
210
- const scheme = resolvedColorScheme()
210
+ const scheme = getResolvedColorScheme()
211
211
  const fallbacks = FALLBACK_TOKENS[scheme]
212
212
 
213
213
  if (typeof window === 'undefined' || typeof getComputedStyle !== 'function') {
@@ -26,7 +26,7 @@
26
26
  },
27
27
  {
28
28
  name: 'icon',
29
- description: 'Status icon: unset shows the theme\'s auto icon (gray shows the info glyph in black ink), `false` hides it, a `lucide-*` string or Component renders a custom theme-colored glyph',
29
+ description: 'Status icon: unset or `true` shows the theme\'s auto icon (gray shows the info glyph in black ink), `false` hides it, a `lucide-*` string or Component renders a custom theme-colored glyph',
30
30
  required: false,
31
31
  type: 'boolean | string | Component'
32
32
  },
@@ -36,4 +36,40 @@ An info banner with one action and a × button in the corner.
36
36
 
37
37
  <ComponentPreview name="Alert-ImportProgress" />
38
38
 
39
+ ## The icon prop
40
+
41
+ `icon` has three states. Unset or `true` renders the theme's own status glyph
42
+ (gray shows the info glyph in black ink). `false` hides it. A `lucide-*` class
43
+ name or a Vue component renders that glyph instead, in the theme's color.
44
+
45
+ ```vue
46
+ <Alert title="Saved" theme="green" />
47
+ <!-- the theme's auto icon -->
48
+ <Alert title="Saved" theme="green" :icon="true" />
49
+ <!-- the same auto icon, stated -->
50
+ <Alert title="Saved" :icon="false" />
51
+ <!-- no icon -->
52
+ <Alert title="Saved" icon="lucide-rocket" />
53
+ <!-- your own -->
54
+ ```
55
+
56
+ ## Styling
57
+
58
+ The tone is on the root as `data-color`, and the computed layout as
59
+ `data-layout`:
60
+
61
+ | Hook | Meaning |
62
+ | ----------------------------------------------- | ------------------------------------------- |
63
+ | `[data-color="gray\|blue\|green\|amber\|red"]` | the `theme` prop |
64
+ | `[data-layout="row\|banner"]` | one line, or the stacked layout |
65
+
66
+ `data-color`, not `data-theme`: `data-theme` is the light/dark attribute the
67
+ app sets on the document, and an alert must not look like a theme root.
68
+
69
+ ```css
70
+ :where([data-color='red']) {
71
+ /* your overrides */
72
+ }
73
+ ```
74
+
39
75
  <!-- @include: ./Alert.api.md -->
@@ -30,7 +30,7 @@ export const alertProps = {
30
30
  description: { type: String, default: undefined },
31
31
  /** Color theme of the status icon and the row action label; the container never changes with theme */
32
32
  theme: { type: String as PropType<StatusTheme>, default: 'gray' },
33
- /** Status icon: unset shows the theme's auto icon (gray shows the info glyph in black ink), `false` hides it, a `lucide-*` string or Component renders a custom theme-colored glyph */
33
+ /** Status icon: unset or `true` shows the theme's auto icon (gray shows the info glyph in black ink), `false` hides it, a `lucide-*` string or Component renders a custom theme-colored glyph */
34
34
  icon: {
35
35
  type: [Boolean, String, Object, Function] as PropType<
36
36
  boolean | string | Component
@@ -37,8 +37,15 @@ starts inside a scrolled list scrolls that list instead of moving the sheet; it
37
37
  becomes a drag once the list is back at its top. A drag closes the sheet if it
38
38
  passes a distance threshold or ends in a fast flick, and springs back otherwise.
39
39
 
40
- The gesture logic lives in the `useSheetDrag` composable, exported from the
41
- package root if you need the same behaviour on a surface of your own.
40
+ Put `data-no-sheet-drag` on an element that owns its own horizontal or vertical
41
+ gesture — a carousel, a map, a slider — and a drag starting there never moves
42
+ the sheet:
43
+
44
+ ```vue
45
+ <BottomSheet v-model:open="open">
46
+ <ImageCarousel data-no-sheet-drag />
47
+ </BottomSheet>
48
+ ```
42
49
 
43
50
  ## Events
44
51
 
@@ -528,6 +528,37 @@ describe('BottomSheet drag to dismiss', () => {
528
528
  app.unmount()
529
529
  })
530
530
 
531
+ // SHELL-Q9: the opt-out attribute is public and keeps this name.
532
+ it('leaves a gesture that starts on data-no-sheet-drag alone', async () => {
533
+ const { sheet, content, app } = await openSheet()
534
+ const carousel = document.createElement('div')
535
+ carousel.setAttribute('data-no-sheet-drag', '')
536
+ content.appendChild(carousel)
537
+
538
+ touch('touchstart', carousel, 0, 300)
539
+ const move = touch('touchmove', carousel, 0, 320)
540
+
541
+ expect(move.defaultPrevented).toBe(false)
542
+ expect(isDragging(sheet)).toBe(false)
543
+ app.unmount()
544
+ })
545
+
546
+ it('also covers a child of a data-no-sheet-drag element', async () => {
547
+ const { sheet, content, app } = await openSheet()
548
+ const carousel = document.createElement('div')
549
+ carousel.setAttribute('data-no-sheet-drag', '')
550
+ const slide = document.createElement('div')
551
+ carousel.appendChild(slide)
552
+ content.appendChild(carousel)
553
+
554
+ touch('touchstart', slide, 0, 300)
555
+ const move = touch('touchmove', slide, 0, 320)
556
+
557
+ expect(move.defaultPrevented).toBe(false)
558
+ expect(isDragging(sheet)).toBe(false)
559
+ app.unmount()
560
+ })
561
+
531
562
  it('drags a sheet that was mounted closed and opened afterwards', async () => {
532
563
  const { sheet, content, openUpdates, app } = await openSheet(
533
564
  {},
@@ -14,6 +14,7 @@
14
14
  honest when a mobile URL bar collapses.
15
15
  -->
16
16
  <DialogContent
17
+ data-slot="content"
17
18
  class="fixed inset-x-0 bottom-0 z-50 mx-auto flex max-h-[90dvh] w-full max-w-2xl flex-col rounded-t-[36px] bg-surface-base shadow-lg [corner-shape:squircle] bottom-sheet-content focus:outline-none after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-screen after:bg-surface-base"
18
19
  :aria-label="title || 'Bottom sheet'"
19
20
  @escape-key-down="onDismissAttempt"
@@ -101,9 +102,12 @@ const handleRef = ref<HTMLElement | null>(null)
101
102
  * fires exactly once, with the comment, and the gesture is bound to nothing.
102
103
  * `handleRef` is a plain element ref, which Vue re-runs every time the content
103
104
  * mounts and unmounts, so this re-resolves on every open.
105
+ *
106
+ * The query reads the sheet's own `data-slot` hook, the same attribute an app
107
+ * styles it with. `closest` stops at the nearest match, which is this sheet.
104
108
  */
105
109
  const sheetEl = computed(
106
- () => handleRef.value?.closest<HTMLElement>('.bottom-sheet-content') ?? null,
110
+ () => handleRef.value?.closest<HTMLElement>('[data-slot="content"]') ?? null,
107
111
  )
108
112
 
109
113
  /*
@@ -38,4 +38,20 @@ const items = [
38
38
  ]
39
39
  ```
40
40
 
41
+ An item also carries any extra field you put on it. `BreadcrumbItem` keeps an
42
+ open index signature, so a crumb can hold the record it came from and the
43
+ `#prefix` / `#suffix` slots can read it back:
44
+
45
+ ```vue
46
+ <Breadcrumbs :items="items">
47
+ <template #prefix="{ item }">
48
+ <Avatar v-if="item.user" :image="item.user.avatar" size="sm" />
49
+ </template>
50
+ </Breadcrumbs>
51
+ ```
52
+
53
+ Both slots are named for where they render, not for what goes in them:
54
+ `#prefix` renders before the crumb's label and `#suffix` after it. Each
55
+ receives `{ item }`, and they render for every crumb.
56
+
41
57
  <!-- @include: ./Breadcrumbs.api.md -->
@@ -1,11 +1,11 @@
1
- import { RouterLinkProps } from 'vue-router'
1
+ import type { RouteDestination } from '../shared/route'
2
2
 
3
3
  export interface BreadcrumbItem {
4
4
  /** Text shown for the breadcrumb item */
5
5
  label: string
6
6
 
7
7
  /** Route location used when the item is a link */
8
- route?: RouterLinkProps['to']
8
+ route?: RouteDestination
9
9
 
10
10
  /** URL used when the item is a plain link, ignored if `route` is set */
11
11
  href?: string
@@ -80,7 +80,7 @@
80
80
  name: 'route',
81
81
  description: 'Router destination when used as a link',
82
82
  required: false,
83
- type: 'string | kt | Tt'
83
+ type: 'RouteDestination'
84
84
  },
85
85
  {
86
86
  name: 'href',