frappe-ui 1.0.0-beta.6 → 1.0.0-beta.8

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/internals.ts ADDED
@@ -0,0 +1,10 @@
1
+ // frappe-ui internals — UNSTABLE. No backward-compatibility promise.
2
+
3
+ export { inputFontSizeClasses } from './src/components/Combobox/utils'
4
+ export {
5
+ InputLabel,
6
+ InputDescription,
7
+ InputError,
8
+ LabelingWrapper,
9
+ } from './src/components/InputLabeling'
10
+ export { useInputLabeling } from './src/composables/useInputLabeling'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frappe-ui",
3
- "version": "1.0.0-beta.6",
3
+ "version": "1.0.0-beta.8",
4
4
  "description": "A set of components and utilities for rapid UI development",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -13,6 +13,9 @@
13
13
  "#composables/*": "./src/composables/*",
14
14
  "#utils/*": "./src/utils/*"
15
15
  },
16
+ "bin": {
17
+ "tokens-v2": "./tailwind/migrate-tokens-v2.js"
18
+ },
16
19
  "scripts": {
17
20
  "test": "vitest --run",
18
21
  "test:coverage": "vitest --run --coverage",
@@ -42,6 +45,10 @@
42
45
  "import": "./src/index.ts",
43
46
  "types": "./src/index.ts"
44
47
  },
48
+ "./internals": {
49
+ "import": "./internals.ts",
50
+ "types": "./internals.ts"
51
+ },
45
52
  "./frappe": {
46
53
  "import": "./frappe/index.js"
47
54
  },
@@ -87,6 +94,7 @@
87
94
  "vite",
88
95
  "icons",
89
96
  "tailwind",
97
+ "internals.ts",
90
98
  "tsconfig.base.json"
91
99
  ],
92
100
  "repository": {
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <div class="flex flex-col rounded-lg border bg-white px-6 py-5 shadow">
2
+ <div class="flex flex-col rounded-lg border bg-white px-6 py-5">
3
3
  <div class="flex items-baseline justify-between">
4
4
  <div class="flex items-baseline space-x-2">
5
5
  <div class="flex items-center space-x-2" v-if="$slots['actions-left']">
@@ -0,0 +1,50 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ *
4
+ * Sanitization is verified against the REAL DOMPurify (needs a DOM, hence the
5
+ * jsdom environment) — the allow-list wiring is covered separately in
6
+ * Toast.test.ts with a stubbed DOMPurify.
7
+ */
8
+
9
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
10
+ import type { VNode } from 'vue'
11
+
12
+ const sonnerSpy = Object.assign(vi.fn(), {
13
+ success: vi.fn(),
14
+ error: vi.fn(),
15
+ warning: vi.fn(),
16
+ info: vi.fn(),
17
+ })
18
+
19
+ vi.mock('vue-sonner', () => ({ toast: sonnerSpy }))
20
+
21
+ const { toast } = await import('./toast')
22
+
23
+ // renderSafeHTML returns `() => h('span', { innerHTML })`. Pull that render
24
+ // function off the sonner spy, invoke it, and read the sanitized markup back.
25
+ function sanitizedHTML(): string {
26
+ const [message] = sonnerSpy.success.mock.calls[0]!
27
+ const vnode = (message as () => VNode)()
28
+ return (vnode.props as { innerHTML: string }).innerHTML
29
+ }
30
+
31
+ beforeEach(() => sonnerSpy.success.mockClear())
32
+
33
+ describe('Toast v1 — DOMPurify stripping', () => {
34
+ it('strips tags outside the allow-list while keeping their text content', () => {
35
+ toast.success('<strong>safe</strong><div>nested</div>')
36
+ const html = sanitizedHTML()
37
+ expect(html).toContain('<strong>safe</strong>')
38
+ expect(html).not.toContain('<div>')
39
+ expect(html).toContain('nested')
40
+ })
41
+
42
+ it('removes script and event-handler payloads to prevent XSS', () => {
43
+ toast.success('<b>ok</b><img src=x onerror=alert(1)><script>alert(2)<\/script>')
44
+ const html = sanitizedHTML()
45
+ expect(html).toContain('<b>ok</b>')
46
+ expect(html).not.toContain('<img')
47
+ expect(html).not.toContain('onerror')
48
+ expect(html).not.toContain('<script')
49
+ })
50
+ })
@@ -23,6 +23,11 @@ const sonnerSpy = Object.assign(vi.fn(), {
23
23
 
24
24
  vi.mock('vue-sonner', () => ({ toast: sonnerSpy }))
25
25
 
26
+ // Every toast path runs through renderSafeHTML → DOMPurify.sanitize, which
27
+ // needs a DOM. Stub it with a passthrough so this node-environment file doesn't
28
+ // crash; the real sanitization is verified in Toast.sanitize.test.ts (jsdom).
29
+ vi.mock('dompurify', () => ({ default: { sanitize: (html: string) => html } }))
30
+
26
31
  const { toast } = await import('./toast')
27
32
 
28
33
  beforeEach(() => {
@@ -40,6 +40,15 @@ import { Toaster } from 'vue-sonner'
40
40
  height: auto;
41
41
  }
42
42
 
43
+ /* In the collapsed stack vue-sonner clamps every non-front toast to the front
44
+ toast's height (--front-toast-height) and hides their content. The content is
45
+ hidden only on [data-styled='true'] toasts, which `unstyled` strips — so a
46
+ multiline toast behind a single-line one spills its extra lines out the top.
47
+ Replicate sonner's intended behavior and fade the collapsed back toasts out. */
48
+ [data-sonner-toast][data-expanded='false'][data-front='false'] > * {
49
+ opacity: 0;
50
+ }
51
+
43
52
  .sonner-loading-wrapper {
44
53
  position: static;
45
54
  }
@@ -1,9 +1,24 @@
1
+ import DOMPurify from 'dompurify'
1
2
  import { h, isVNode, type Component, type VNode } from 'vue'
2
3
  import { toast as sonnerToast } from 'vue-sonner'
3
4
  import { warnDeprecated } from '../../utils/warnDeprecated'
4
5
 
5
6
  type ToastType = 'success' | 'error' | 'warning' | 'info'
6
7
 
8
+ type SonnerData = Parameters<typeof sonnerToast>[1]
9
+
10
+ // Tags that are safe to render inside a toast message. Anything outside this set is stripped by DOMPurify.
11
+ const ALLOWED_TAGS = ['a', 'em', 'strong', 'i', 'b', 'u']
12
+
13
+ // Sonner renders a string message as plain text and a VNode via
14
+ // `<component :is>`. To render safe HTML we sanitize the string and return a
15
+ // render function; non-string values (already VNodes/components) pass through.
16
+ function renderSafeHTML<T>(message: T): T | (() => VNode) {
17
+ if (typeof message !== 'string') return message
18
+ const html = DOMPurify.sanitize(message, { ALLOWED_TAGS })
19
+ return () => h('span', { innerHTML: html })
20
+ }
21
+
7
22
  interface LegacyCreateOptions {
8
23
  id?: string | number
9
24
  message: string
@@ -62,20 +77,21 @@ function isLegacyObject(arg: unknown): arg is LegacyToastObject {
62
77
 
63
78
  function dispatch(
64
79
  type: ToastType | undefined,
65
- message: string,
66
- data: Parameters<typeof sonnerToast>[1],
80
+ message: string | Component | VNode,
81
+ data: SonnerData,
67
82
  ) {
83
+ const safeMessage = renderSafeHTML(message)
68
84
  switch (type) {
69
85
  case 'success':
70
- return sonnerToast.success(message, data)
86
+ return sonnerToast.success(safeMessage, data)
71
87
  case 'error':
72
- return sonnerToast.error(message, data)
88
+ return sonnerToast.error(safeMessage, data)
73
89
  case 'warning':
74
- return sonnerToast.warning(message, data)
90
+ return sonnerToast.warning(safeMessage, data)
75
91
  case 'info':
76
- return sonnerToast.info(message, data)
92
+ return sonnerToast.info(safeMessage, data)
77
93
  default:
78
- return sonnerToast(message, data)
94
+ return sonnerToast(safeMessage, data)
79
95
  }
80
96
  }
81
97
 
@@ -107,7 +123,7 @@ function toastFn(
107
123
  if (isLegacyObject(message)) {
108
124
  return callLegacyObject(message)
109
125
  }
110
- return sonnerToast(message as string, options)
126
+ return sonnerToast(renderSafeHTML(message as string), options)
111
127
  }
112
128
 
113
129
  function create(options: LegacyCreateOptions) {
@@ -142,6 +158,14 @@ function removeAll() {
142
158
  }
143
159
 
144
160
  export const toast = Object.assign(toastFn, sonnerToast, {
161
+ success: (message: string | Component | VNode, data?: SonnerData) =>
162
+ dispatch('success', message, data),
163
+ error: (message: string | Component | VNode, data?: SonnerData) =>
164
+ dispatch('error', message, data),
165
+ warning: (message: string | Component | VNode, data?: SonnerData) =>
166
+ dispatch('warning', message, data),
167
+ info: (message: string | Component | VNode, data?: SonnerData) =>
168
+ dispatch('info', message, data),
145
169
  create,
146
170
  remove,
147
171
  removeAll,
@@ -131,10 +131,9 @@ function generateSemanticColors() {
131
131
  // Emit `--elevation-*` and `--focus-*` CSS variables. Elevation uses the
132
132
  // Figma `light/*` values in both modes (matches how Espresso 2.0 actually
133
133
  // applies shadows in dark mode — see the dark-mode page in Figma, which
134
- // references `elevation/light/*` exclusively). The Figma `dark/*` set is
135
- // exposed as `--dark-elevation-*` for opt-in use via `shadow-dark-*`.
136
- // Focus rings still mode-swap. Theme-independent entries (e.g.
137
- // `elevation.custom.status`) land in `:root` only.
134
+ // references `elevation/light/*` exclusively). Focus rings still mode-swap.
135
+ // Theme-independent entries (e.g. `elevation.custom.status`) land in
136
+ // `:root` only.
138
137
  function generateEffectVariables() {
139
138
  const output = {
140
139
  ':root': {},
@@ -144,9 +143,6 @@ function generateEffectVariables() {
144
143
  for (const [step, value] of Object.entries(effectsData.elevation.light)) {
145
144
  output[':root'][`--elevation-${step}`] = value
146
145
  }
147
- for (const [step, value] of Object.entries(effectsData.elevation.dark)) {
148
- output[':root'][`--dark-elevation-${step}`] = value
149
- }
150
146
  for (const [name, value] of Object.entries(effectsData.elevation.custom)) {
151
147
  output[':root'][`--elevation-${name}`] = value
152
148
  }
@@ -316,8 +316,13 @@ function buildEffects() {
316
316
  return out
317
317
  }
318
318
 
319
+ // Figma paints its effect array back-to-front (index 0 = bottom of the stack),
320
+ // while CSS box-shadow paints front-to-back (first listed = on top). Reverse the
321
+ // layers so the composed CSS string matches Figma's visual stacking order.
319
322
  function shadowToCss(layers) {
320
323
  return layers
324
+ .slice()
325
+ .reverse()
321
326
  .map((layer) => {
322
327
  const parts = [
323
328
  layer.inset ? 'inset' : null,
@@ -1,20 +1,20 @@
1
1
  {
2
2
  "elevation": {
3
3
  "light": {
4
- "sm": "0px 1px 3px 0px #00000024, 0px 0px 1px 0px #00000024, inset 0px 0.25px 1.5px 0px #ffffff14",
5
- "base": "0px 2px 5px 0px #00000024, 0px 0px 1.5px 0px #00000029, inset 0px 0.25px 1.5px 0px #ffffff14",
6
- "md": "0px 6px 12px -2px #0000001f, 0px 0px 6px 2px #00000008, 0px 0px 1.5px 0px #00000026, inset 0px 0.25px 1.5px 0px #ffffff14",
7
- "lg": "0px 18px 22px -6px #0000001a, 0px 0px 6px 3px #00000008, 0px 0px 1.5px 0px #0000002e",
8
- "xl": "0px 24px 30px -8px #0000001a, 0px 0px 10px 2px #0000000a, 0px 0px 1px 0px #00000033, inset 0px 0.25px 2px 0px #ffffff26",
9
- "2xl": "0px 44px 52px -10px #0000001a, 0px 0px 10px 2px #00000008, 0px 0px 1.5px 0px #00000040, inset 0px 0.1px 2px 0px #ffffff14"
4
+ "sm": "inset 0px 0.25px 1.5px 0px #ffffff29, 0px 0px 1px 0px #00000033, 0px 1px 3px 0px #00000024",
5
+ "base": "inset 0px 0.25px 1.5px 0px #ffffff29, 0px 0px 1.5px 0px #00000029, 0px 2px 5px 0px #00000024",
6
+ "md": "inset 0px 0.25px 1.5px 0px #ffffff29, 0px 0px 1.5px 0px #00000026, 0px 0px 6px 2px #0000000a, 0px 6px 12px -2px #00000014",
7
+ "lg": "inset 0px 0.25px 1.5px 0px #ffffff29, 0px 0px 1.5px 0px #0000002e, 0px 0px 6px 3px #00000008, 0px 18px 22px -6px #0000001a",
8
+ "xl": "inset 0px 0.25px 1.5px 0px #ffffff29, 0px 0px 1px 0px #00000033, 0px 0px 10px 2px #0000000a, 0px 24px 30px -8px #0000001a",
9
+ "2xl": "inset 0px 0.25px 2px 0px #ffffff29, 0px 0px 1.5px 0px #00000040, 0px 0px 10px 2px #00000008, 0px 44px 52px -10px #0000001a"
10
10
  },
11
11
  "dark": {
12
- "sm": "0px 1px 3px 0px #000000b2, 0px 0px 14px 0px #0000002e, inset 0px 0.5px 0.5px 0.5px #ffffff08",
13
- "base": "0px 2px 5px 0px #00000099, 0px 0px 14px 0px #0000002e, inset 0px 0.5px 0.5px 0.5px #ffffff08",
14
- "md": "0px 6px 12px -2px #00000099, 0px 0px 16px 2px #00000033, inset 0px 0.5px 0.5px 0.5px #ffffff08",
15
- "lg": "0px 18px 20px -8px #00000085, 0px 0px 16px 0px #0000001a, inset 0px 0.5px 1.5px 0.5px #ffffff0a",
16
- "xl": "0px 26px 34px -6px #0000006b, 0px 0px 14px 2px #0000001f, inset 0px 0.5px 1.5px 0.5px #ffffff0a",
17
- "2xl": "0px 44px 52px -4px #0000006b, 0px 0px 14px 10px #0000001f, inset 0px 0.5px 1.5px 0.5px #ffffff0f"
12
+ "sm": "inset 0px 0.5px 0.5px 0.5px #ffffff08, 0px 0px 14px 0px #0000002e, 0px 1px 3px 0px #000000b2",
13
+ "base": "inset 0px 0.5px 0.5px 0.5px #ffffff08, 0px 0px 14px 0px #0000002e, 0px 2px 5px 0px #00000099",
14
+ "md": "inset 0px 0.5px 0.5px 0.5px #ffffff08, 0px 0px 16px 2px #00000033, 0px 6px 12px -2px #00000099",
15
+ "lg": "inset 0px 0.5px 1.5px 0.5px #ffffff0a, 0px 0px 16px 0px #0000001a, 0px 18px 20px -8px #00000085",
16
+ "xl": "inset 0px 0.5px 1.5px 0.5px #ffffff0a, 0px 0px 14px 2px #0000001f, 0px 26px 34px -6px #0000006b",
17
+ "2xl": "inset 0px 0.5px 1.5px 0.5px #ffffff0f, 0px 0px 14px 10px #0000001f, 0px 44px 52px -4px #0000006b"
18
18
  },
19
19
  "custom": {
20
20
  "status": "0px 0px 0px 1.5px #ffffff"
@@ -1,3 +1,4 @@
1
+ #!/usr/bin/env node
1
2
  /**
2
3
  * Espresso v2 token migration codemod.
3
4
  *
@@ -13,7 +14,7 @@
13
14
  * class, which carries the correct per-weight letter-spacing (see
14
15
  * `mergeWeightClasses`).
15
16
  *
16
- * Usage: node tailwind/migrate-tokens-v2.js [--dry-run] [--force] <dir-or-file...>
17
+ * Usage: tokens-v2 [--dry-run] [--force] <dir-or-file...>
17
18
  *
18
19
  * IMPORTANT: the token replacement is single-pass/simultaneous. Several renames
19
20
  * chain (outline red-2→3, red-3→4, red-4→5); applying them sequentially would
@@ -28,6 +29,8 @@ import fs from 'fs'
28
29
  import path from 'path'
29
30
  import { fileURLToPath } from 'url'
30
31
 
32
+ const USAGE = 'Usage: tokens-v2 [--dry-run] [--force] <dir-or-file...>'
33
+
31
34
  // ---------- MAPPING ----------
32
35
 
33
36
  // Per-category renames: old suffix → new suffix.
@@ -322,12 +325,18 @@ function* walk(target) {
322
325
 
323
326
  function main() {
324
327
  const args = process.argv.slice(2)
328
+ const help = args.includes('--help') || args.includes('-h')
325
329
  const dryRun = args.includes('--dry-run')
326
330
  const force = args.includes('--force')
327
331
  const targets = args.filter((a) => !a.startsWith('--'))
328
332
 
333
+ if (help) {
334
+ console.log(USAGE)
335
+ return
336
+ }
337
+
329
338
  if (targets.length === 0) {
330
- console.error('Usage: node tailwind/migrate-tokens-v2.js [--dry-run] [--force] <dir-or-file...>')
339
+ console.error(USAGE)
331
340
  process.exit(1)
332
341
  }
333
342
 
@@ -387,5 +396,7 @@ function main() {
387
396
  }
388
397
  }
389
398
 
390
- const isCLI = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
399
+ const scriptPath = fileURLToPath(import.meta.url)
400
+ const invokedPath = process.argv[1]
401
+ const isCLI = invokedPath && fs.realpathSync(invokedPath) === fs.realpathSync(scriptPath)
391
402
  if (isCLI) main()
@@ -169,6 +169,14 @@ let globalStyles = (theme) => ({
169
169
  backgroundSize: '1.13em',
170
170
  backgroundPosition: 'right 0.44rem center',
171
171
  },
172
+ // A bare `<p>` reads as body copy, so it defaults to a relaxed line-height
173
+ // instead of the tight value baked into the `text-<size>` utilities. Kept at
174
+ // element specificity (0,0,1) with `:where()` (which adds none) so any explicit
175
+ // `text-*` / `text-p-*` line-height wins, and scoped out of rich text so the
176
+ // `prose` / editor line-heights are untouched.
177
+ 'p:not(:where(.prose, .ProseMirror) *)': {
178
+ lineHeight: '1.5',
179
+ },
172
180
  // Global keyboard focus indicator (espresso v2 focus/default token).
173
181
  // Lives in the base layer so any utility on the element can override it:
174
182
  // suppress with `focus-visible:outline-none`, retheme with
@@ -216,12 +224,6 @@ export default plugin(
216
224
  xl: 'var(--elevation-xl)',
217
225
  '2xl': 'var(--elevation-2xl)',
218
226
  status: 'var(--elevation-status)',
219
- 'dark-sm': 'var(--dark-elevation-sm)',
220
- 'dark-base': 'var(--dark-elevation-base)',
221
- 'dark-md': 'var(--dark-elevation-md)',
222
- 'dark-lg': 'var(--dark-elevation-lg)',
223
- 'dark-xl': 'var(--dark-elevation-xl)',
224
- 'dark-2xl': 'var(--dark-elevation-2xl)',
225
227
  },
226
228
  container: {
227
229
  padding: {
@@ -16,12 +16,6 @@ const boxShadow = {
16
16
  ...effectsData.elevation.light,
17
17
  DEFAULT: effectsData.elevation.light.base,
18
18
  ...effectsData.elevation.custom,
19
- ...Object.fromEntries(
20
- Object.entries(effectsData.elevation.dark).map(([key, value]) => [
21
- `dark-${key}`,
22
- value,
23
- ]),
24
- ),
25
19
  }
26
20
 
27
21
  // lineHeight, letterSpacing and fontWeight are baked into each size tuple by