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 +10 -0
- package/package.json +9 -1
- package/src/components/Card.vue +1 -1
- package/src/components/Toast/Toast.sanitize.test.ts +50 -0
- package/src/components/Toast/Toast.test.ts +5 -0
- package/src/components/Toast/ToastProvider.vue +9 -0
- package/src/components/Toast/toast.ts +32 -8
- package/tailwind/colorPalette.js +3 -7
- package/tailwind/figma-tokens-to-theme.js +5 -0
- package/tailwind/generated/effects.json +12 -12
- package/tailwind/migrate-tokens-v2.js +14 -3
- package/tailwind/plugin.js +8 -6
- package/tailwind/tokens.js +0 -6
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.
|
|
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": {
|
package/src/components/Card.vue
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div class="flex flex-col rounded-lg border bg-white px-6 py-5
|
|
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:
|
|
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(
|
|
86
|
+
return sonnerToast.success(safeMessage, data)
|
|
71
87
|
case 'error':
|
|
72
|
-
return sonnerToast.error(
|
|
88
|
+
return sonnerToast.error(safeMessage, data)
|
|
73
89
|
case 'warning':
|
|
74
|
-
return sonnerToast.warning(
|
|
90
|
+
return sonnerToast.warning(safeMessage, data)
|
|
75
91
|
case 'info':
|
|
76
|
-
return sonnerToast.info(
|
|
92
|
+
return sonnerToast.info(safeMessage, data)
|
|
77
93
|
default:
|
|
78
|
-
return sonnerToast(
|
|
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,
|
package/tailwind/colorPalette.js
CHANGED
|
@@ -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).
|
|
135
|
-
//
|
|
136
|
-
//
|
|
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
|
|
5
|
-
"base": "0px
|
|
6
|
-
"md": "0px
|
|
7
|
-
"lg": "0px
|
|
8
|
-
"xl": "0px
|
|
9
|
-
"2xl": "0px
|
|
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
|
|
13
|
-
"base": "0px
|
|
14
|
-
"md": "0px
|
|
15
|
-
"lg": "0px
|
|
16
|
-
"xl": "0px
|
|
17
|
-
"2xl": "0px
|
|
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:
|
|
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(
|
|
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
|
|
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()
|
package/tailwind/plugin.js
CHANGED
|
@@ -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: {
|
package/tailwind/tokens.js
CHANGED
|
@@ -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
|