@podoba/react 0.0.29 → 0.0.32
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 +45 -6
- package/src/components/brand-page-header.tsx +23 -19
- package/src/components/button.tsx +8 -5
- package/src/components/cta-pill.tsx +2 -2
- package/src/components/dialog.tsx +28 -22
- package/src/components/input.tsx +19 -13
- package/src/components/rich-text-editor.tsx +9 -9
- package/src/components/section-tabs.tsx +34 -12
- package/src/components/select.tsx +19 -13
- package/src/components/text.tsx +25 -4
- package/src/editor/block-editor.tsx +522 -0
- package/src/editor/index.ts +3 -0
- package/src/utils/safe-link-url.ts +13 -0
- package/src/utils/uic.ts +9 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@podoba/react",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.32",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "podoba React components — React Aria Components + Tailwind primitives + layout, built with uic.",
|
|
6
6
|
"repository": {
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
"main": "./src/index.ts",
|
|
14
14
|
"types": "./src/index.ts",
|
|
15
15
|
"exports": {
|
|
16
|
-
".": "./src/index.ts"
|
|
16
|
+
".": "./src/index.ts",
|
|
17
|
+
"./editor": "./src/editor/index.ts"
|
|
17
18
|
},
|
|
18
19
|
"files": [
|
|
19
20
|
"src",
|
|
@@ -27,17 +28,48 @@
|
|
|
27
28
|
"typecheck": "tsc --build"
|
|
28
29
|
},
|
|
29
30
|
"dependencies": {
|
|
30
|
-
"@podoba/tokens": "^0.0.
|
|
31
|
-
"@podoba/tailwind": "^0.0.
|
|
31
|
+
"@podoba/tokens": "^0.0.32",
|
|
32
|
+
"@podoba/tailwind": "^0.0.32",
|
|
32
33
|
"react-aria-components": "1.18.0",
|
|
33
34
|
"class-variance-authority": "0.7.1",
|
|
34
35
|
"clsx": "2.1.1",
|
|
35
36
|
"tailwind-merge": "3.6.0",
|
|
36
37
|
"@radix-ui/react-slot": "1.2.4"
|
|
37
38
|
},
|
|
39
|
+
"//tiptap": "Only `@podoba/react/editor` (BlockEditor) needs Tiptap. Optional peers, not dependencies: the base package must not make every consumer install ProseMirror, and a peer keeps ONE @tiptap/pm in the tree — two copies of prosemirror-model break instanceof across the editor.",
|
|
38
40
|
"peerDependencies": {
|
|
39
41
|
"react": ">=19",
|
|
40
|
-
"react-dom": ">=19"
|
|
42
|
+
"react-dom": ">=19",
|
|
43
|
+
"@tiptap/react": "^3.27.4",
|
|
44
|
+
"@tiptap/pm": "^3.27.4",
|
|
45
|
+
"@tiptap/starter-kit": "^3.27.4",
|
|
46
|
+
"@tiptap/extension-placeholder": "^3.27.4",
|
|
47
|
+
"@tiptap/extension-highlight": "^3.27.4",
|
|
48
|
+
"@tiptap/extension-task-list": "^3.27.4",
|
|
49
|
+
"@tiptap/extension-task-item": "^3.27.4"
|
|
50
|
+
},
|
|
51
|
+
"peerDependenciesMeta": {
|
|
52
|
+
"@tiptap/react": {
|
|
53
|
+
"optional": true
|
|
54
|
+
},
|
|
55
|
+
"@tiptap/pm": {
|
|
56
|
+
"optional": true
|
|
57
|
+
},
|
|
58
|
+
"@tiptap/starter-kit": {
|
|
59
|
+
"optional": true
|
|
60
|
+
},
|
|
61
|
+
"@tiptap/extension-placeholder": {
|
|
62
|
+
"optional": true
|
|
63
|
+
},
|
|
64
|
+
"@tiptap/extension-highlight": {
|
|
65
|
+
"optional": true
|
|
66
|
+
},
|
|
67
|
+
"@tiptap/extension-task-list": {
|
|
68
|
+
"optional": true
|
|
69
|
+
},
|
|
70
|
+
"@tiptap/extension-task-item": {
|
|
71
|
+
"optional": true
|
|
72
|
+
}
|
|
41
73
|
},
|
|
42
74
|
"devDependencies": {
|
|
43
75
|
"react": "^19.2.1",
|
|
@@ -45,6 +77,13 @@
|
|
|
45
77
|
"@types/react": "^19.2.7",
|
|
46
78
|
"@types/react-dom": "^19.2.3",
|
|
47
79
|
"typescript": "^5.9.3",
|
|
48
|
-
"@types/bun": "^1.3.3"
|
|
80
|
+
"@types/bun": "^1.3.3",
|
|
81
|
+
"@tiptap/react": "^3.27.4",
|
|
82
|
+
"@tiptap/pm": "^3.27.4",
|
|
83
|
+
"@tiptap/starter-kit": "^3.27.4",
|
|
84
|
+
"@tiptap/extension-placeholder": "^3.27.4",
|
|
85
|
+
"@tiptap/extension-highlight": "^3.27.4",
|
|
86
|
+
"@tiptap/extension-task-list": "^3.27.4",
|
|
87
|
+
"@tiptap/extension-task-item": "^3.27.4"
|
|
49
88
|
}
|
|
50
89
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ReactNode, useState } from 'react'
|
|
2
2
|
import { Button } from './button'
|
|
3
|
+
import { DisplayHeading } from './text'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* BrandPageHeader — the brand-workspace page header (port of gs-platform
|
|
@@ -36,7 +37,8 @@ export type BrandPageHeaderProps = {
|
|
|
36
37
|
* gs-style "title to go back": a muted, clickable PARENT link rendered as the
|
|
37
38
|
* line ABOVE the title (so the title reads "Parent ⏎ Current"). Pass a real
|
|
38
39
|
* router `<Link>` so it's an anchor (cmd/middle-click, deep-link-safe), exactly
|
|
39
|
-
* like gs's `parentLink`. The header
|
|
40
|
+
* like gs's `parentLink`. The header uses the source's decorative `fg-subtle`
|
|
41
|
+
* (#b3b3b3) with a hover→fg affordance.
|
|
40
42
|
*/
|
|
41
43
|
parentLink?: ReactNode
|
|
42
44
|
/** Optional breadcrumb trail rendered above the greeting. */
|
|
@@ -81,7 +83,7 @@ export function BrandPageHeader({
|
|
|
81
83
|
const isControlled = expandedProp !== undefined
|
|
82
84
|
const expanded = isControlled ? expandedProp : internalExpanded
|
|
83
85
|
const hasExpandable = Boolean(createHub)
|
|
84
|
-
const
|
|
86
|
+
const HeadingTag = `h${headingLevel}` as const
|
|
85
87
|
|
|
86
88
|
const setExpanded = (next: boolean) => {
|
|
87
89
|
if (!isControlled) setInternalExpanded(next)
|
|
@@ -98,8 +100,8 @@ export function BrandPageHeader({
|
|
|
98
100
|
.filter(Boolean)
|
|
99
101
|
.join(' ')}
|
|
100
102
|
>
|
|
101
|
-
<div className="flex flex-col gap-2 sm:
|
|
102
|
-
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
103
|
+
<div className="flex flex-col gap-2 sm:grid sm:grid-cols-3 sm:items-stretch sm:gap-4">
|
|
104
|
+
<div className="flex min-w-0 flex-1 flex-col gap-1 sm:col-span-2">
|
|
103
105
|
{breadcrumbs && breadcrumbs.length > 0 ? (
|
|
104
106
|
<nav aria-label="Breadcrumb" className="flex flex-wrap items-center gap-1 text-compact text-fg-muted">
|
|
105
107
|
{breadcrumbs.map((crumb, i) => (
|
|
@@ -120,25 +122,27 @@ export function BrandPageHeader({
|
|
|
120
122
|
))}
|
|
121
123
|
</nav>
|
|
122
124
|
) : null}
|
|
123
|
-
<
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
125
|
+
<DisplayHeading asChild className="tracking-tight">
|
|
126
|
+
<HeadingTag>
|
|
127
|
+
{parentLink ? (
|
|
128
|
+
// gs "title to go back": muted clickable parent line above the title.
|
|
129
|
+
// `[&_a]` styles the nested router <Link> (anchor) without @app/ui
|
|
130
|
+
// importing the router.
|
|
131
|
+
<>
|
|
132
|
+
<span className="text-fg-subtle transition-colors [&_a:hover]:text-fg [&_a]:text-fg-subtle [&_a]:no-underline [&_a]:outline-none [&_a:focus-visible]:underline">
|
|
133
|
+
{parentLink}
|
|
134
|
+
</span>
|
|
135
|
+
<br />
|
|
136
|
+
</>
|
|
137
|
+
) : null}
|
|
138
|
+
{greeting}
|
|
139
|
+
</HeadingTag>
|
|
140
|
+
</DisplayHeading>
|
|
137
141
|
</div>
|
|
138
142
|
|
|
139
143
|
{cta ? (
|
|
140
144
|
// Hero CTA spans 4 of 12 columns (one third) — the greeting takes the rest.
|
|
141
|
-
<div className="
|
|
145
|
+
<div className="h-full min-w-0">{cta}</div>
|
|
142
146
|
) : ctaLabel ? (
|
|
143
147
|
<div className="shrink-0">
|
|
144
148
|
{hasExpandable ? (
|
|
@@ -19,11 +19,11 @@ export const Button = uic(RACButton, {
|
|
|
19
19
|
'data-[pending]:opacity-70 data-[pending]:cursor-progress',
|
|
20
20
|
variants: {
|
|
21
21
|
variant: {
|
|
22
|
-
// primary (gs `_primary`): base
|
|
23
|
-
// hover neutral-600 (#333333) + `0 0 5px rgba(0,0,0,.5)` shadow →
|
|
24
|
-
// to
|
|
22
|
+
// primary (gs `_primary`): base brand-primary (#0d0d0d light / brand-green on
|
|
23
|
+
// dark) → hover neutral-600 (#333333) + `0 0 5px rgba(0,0,0,.5)` shadow →
|
|
24
|
+
// active back to brand-primary (no shadow).
|
|
25
25
|
primary:
|
|
26
|
-
'bg-
|
|
26
|
+
'bg-brand-primary text-fg-inverted hover:bg-neutral-600 hover:shadow-[0_0_5px_0_rgba(0,0,0,0.5)] data-[pressed]:bg-brand-primary data-[pressed]:shadow-none',
|
|
27
27
|
// secondary: #f7f6f2 (surface-card) + 1px #eceae1 (border); hover bg #eceae1
|
|
28
28
|
// (surface-muted) + border #aba89c (fg-subtle); active bg #eceae1.
|
|
29
29
|
secondary:
|
|
@@ -39,6 +39,9 @@ export const Button = uic(RACButton, {
|
|
|
39
39
|
sm: 'py-2 px-4',
|
|
40
40
|
md: 'py-3 px-6',
|
|
41
41
|
lg: 'py-4 px-8',
|
|
42
|
+
// Task/workflow CTA: exact source 44px height, 24px inline padding,
|
|
43
|
+
// 17/20 medium text.
|
|
44
|
+
prominent: 'h-11 px-6 py-0 text-panel-heading font-medium',
|
|
42
45
|
},
|
|
43
46
|
},
|
|
44
47
|
defaultVariants: {
|
|
@@ -49,5 +52,5 @@ export const Button = uic(RACButton, {
|
|
|
49
52
|
|
|
50
53
|
export type ButtonProps = RACButtonProps & {
|
|
51
54
|
variant?: 'primary' | 'secondary' | 'ghost' | 'destructive'
|
|
52
|
-
size?: 'sm' | 'md' | 'lg'
|
|
55
|
+
size?: 'sm' | 'md' | 'lg' | 'prominent'
|
|
53
56
|
}
|
|
@@ -27,8 +27,8 @@ export interface CtaPillProps {
|
|
|
27
27
|
|
|
28
28
|
export function CtaPill({ lead, emphasis, tail, children }: CtaPillProps) {
|
|
29
29
|
return (
|
|
30
|
-
<div className="flex w-full items-center justify-between gap-
|
|
31
|
-
<p className="min-w-0 text-heading4 font-medium leading-
|
|
30
|
+
<div className="flex h-full min-h-16 w-full items-center justify-between gap-nav-x rounded-lg bg-brand-green py-2.5 pr-3 pl-4.5">
|
|
31
|
+
<p className="min-w-0 text-heading4 font-medium leading-5 tracking-tight text-fg-on-brand">
|
|
32
32
|
{lead} <span className="font-semibold">{emphasis}</span> {tail}
|
|
33
33
|
</p>
|
|
34
34
|
<div className="shrink-0">{children}</div>
|
|
@@ -4,8 +4,8 @@ import {
|
|
|
4
4
|
DialogTrigger as RACDialogTrigger,
|
|
5
5
|
type DialogProps as RACDialogProps,
|
|
6
6
|
Heading,
|
|
7
|
-
Modal,
|
|
8
|
-
ModalOverlay,
|
|
7
|
+
Modal as RACModal,
|
|
8
|
+
ModalOverlay as RACModalOverlay,
|
|
9
9
|
} from 'react-aria-components'
|
|
10
10
|
import { uic } from '../utils/uic'
|
|
11
11
|
|
|
@@ -18,25 +18,31 @@ import { uic } from '../utils/uic'
|
|
|
18
18
|
*/
|
|
19
19
|
export const DialogTrigger = RACDialogTrigger
|
|
20
20
|
|
|
21
|
-
// gs modal backdrop
|
|
22
|
-
|
|
23
|
-
const Overlay = uic(ModalOverlay, {
|
|
21
|
+
// Current gs modal backdrop: warm surface-card scrim at 66% under a 10px blur.
|
|
22
|
+
export const ModalOverlay = uic(RACModalOverlay, {
|
|
24
23
|
displayName: 'DialogOverlay',
|
|
25
24
|
baseClass:
|
|
26
25
|
'fixed inset-0 z-50 flex items-center justify-center p-4 ' +
|
|
27
|
-
'bg-
|
|
28
|
-
'data-[entering]:animate-in data-[exiting]:animate-out',
|
|
26
|
+
'bg-modal-backdrop backdrop-blur-modal-backdrop ' +
|
|
27
|
+
'data-[entering]:animate-modal-overlay-in data-[exiting]:animate-modal-overlay-out',
|
|
29
28
|
})
|
|
30
29
|
|
|
31
|
-
// gs content card: white bg, 20px padding
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
const StyledModal = uic(Modal, {
|
|
30
|
+
// gs content card: white bg, 20px padding, 8px radius and the shared two-layer
|
|
31
|
+
// modal elevation.
|
|
32
|
+
export const ModalSurface = uic(RACModal, {
|
|
35
33
|
displayName: 'DialogModal',
|
|
36
34
|
baseClass:
|
|
37
|
-
'rounded-lg bg-surface p-5 outline-none shadow-
|
|
35
|
+
'rounded-lg bg-surface p-5 outline-none shadow-modal-surface ' +
|
|
36
|
+
'data-[entering]:animate-modal-surface-in data-[exiting]:animate-modal-surface-out',
|
|
38
37
|
})
|
|
39
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Unstyled accessible dialog body for edge-to-edge / split modal compositions.
|
|
41
|
+
* Pair only with the shared {@link ModalOverlay} and {@link ModalSurface}; ordinary
|
|
42
|
+
* form and confirmation dialogs should use the composed {@link Dialog}.
|
|
43
|
+
*/
|
|
44
|
+
export const ModalDialog = RACDialog
|
|
45
|
+
|
|
40
46
|
/**
|
|
41
47
|
* Modal width presets. `md` (default) is the form/confirm dialog (gs DialogContent
|
|
42
48
|
* default, 586px); `sm` is a tighter confirm; `lg`/`xl` host wider, taller content
|
|
@@ -126,18 +132,18 @@ export const Dialog = ({
|
|
|
126
132
|
// when omitted the overlay reads its state from an enclosing DialogTrigger.
|
|
127
133
|
// Either way the RACDialog render-prop `close` resolves against the active
|
|
128
134
|
// overlay state, so `close()` works in both modes.
|
|
129
|
-
<
|
|
130
|
-
// Full-screen is an immersive takeover: swap the light
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
className={isFlex ?
|
|
135
|
+
<ModalOverlay
|
|
136
|
+
// Full-screen is an immersive takeover: swap the light scrim for a heavier
|
|
137
|
+
// backdrop blur so the page behind reads as clearly blurred around the
|
|
138
|
+
// near-fullscreen canvas. (tailwind-merge dedupes the base blur/scrim.)
|
|
139
|
+
className={isFlex ? 'bg-black/25 backdrop-blur-lg' : undefined}
|
|
134
140
|
isOpen={isOpen}
|
|
135
141
|
defaultOpen={defaultOpen}
|
|
136
142
|
onOpenChange={onOpenChange}
|
|
137
143
|
isDismissable={isDismissable}
|
|
138
144
|
>
|
|
139
|
-
<
|
|
140
|
-
<
|
|
145
|
+
<ModalSurface className={SIZE_CLASS[size]}>
|
|
146
|
+
<ModalDialog
|
|
141
147
|
{...props}
|
|
142
148
|
className={isFlex ? 'flex min-h-0 flex-1 flex-col outline-none' : 'outline-none'}
|
|
143
149
|
>
|
|
@@ -200,8 +206,8 @@ export const Dialog = ({
|
|
|
200
206
|
)}
|
|
201
207
|
</>
|
|
202
208
|
)}
|
|
203
|
-
</
|
|
204
|
-
</
|
|
205
|
-
</
|
|
209
|
+
</ModalDialog>
|
|
210
|
+
</ModalSurface>
|
|
211
|
+
</ModalOverlay>
|
|
206
212
|
)
|
|
207
213
|
}
|
package/src/components/input.tsx
CHANGED
|
@@ -20,19 +20,20 @@ import { uic } from '../utils/uic'
|
|
|
20
20
|
*/
|
|
21
21
|
const StyledInput = uic(RACInput, {
|
|
22
22
|
displayName: 'InputControl',
|
|
23
|
-
// White fill so the field reads as editable. The gs original used a
|
|
24
|
-
// (#f7f6f2 → surface-card) fill, but our Card surface is ALSO
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
23
|
+
// White fill + border so the field reads as editable. The gs original used a
|
|
24
|
+
// borderless cream (#f7f6f2 → surface-card) fill, but our Card surface is ALSO
|
|
25
|
+
// surface-card — and in dark theme surface-card and surface are the SAME colour
|
|
26
|
+
// (#242423) — so a borderless cream field vanishes on both. Inverted: active =
|
|
27
|
+
// surface, disabled = the muted cream. border #eceae1 → border · hover #aba89c →
|
|
28
|
+
// fg-subtle · focus #75e7b8 → brand-green · error → danger. This is the shared
|
|
29
|
+
// filled-field skin (textarea / combobox / date-field / number-field /
|
|
30
|
+
// search-field); `fieldSize` adds the single-line height on top.
|
|
30
31
|
baseClass:
|
|
31
32
|
'w-full rounded-lg border border-border bg-surface px-4 text-small text-fg ' +
|
|
32
33
|
'outline-none transition-colors duration-200 placeholder:text-fg-muted ' +
|
|
33
34
|
'data-[hovered]:border-fg-subtle ' +
|
|
34
35
|
'data-[focused]:border-brand-green data-[focused]:ring-2 data-[focused]:ring-ring ' +
|
|
35
|
-
'data-[invalid]:border-danger data-[invalid]:ring-danger ' +
|
|
36
|
+
'data-[invalid]:border-danger data-[invalid]:ring-2 data-[invalid]:ring-danger ' +
|
|
36
37
|
'data-[disabled]:bg-surface-muted data-[disabled]:opacity-60 data-[disabled]:pointer-events-none',
|
|
37
38
|
variants: {
|
|
38
39
|
// `fieldSize` (not `size`) to avoid colliding with the native <input size>
|
|
@@ -41,6 +42,7 @@ const StyledInput = uic(RACInput, {
|
|
|
41
42
|
sm: 'h-8',
|
|
42
43
|
md: 'h-10',
|
|
43
44
|
lg: 'h-12',
|
|
45
|
+
tall: 'h-control-tall',
|
|
44
46
|
},
|
|
45
47
|
},
|
|
46
48
|
defaultVariants: {
|
|
@@ -56,13 +58,17 @@ export type InputProps = TextFieldProps & {
|
|
|
56
58
|
/** Error message; pass a string for a static error or rely on validation. */
|
|
57
59
|
errorMessage?: string
|
|
58
60
|
placeholder?: string
|
|
59
|
-
size?: 'sm' | 'md' | 'lg'
|
|
61
|
+
size?: 'sm' | 'md' | 'lg' | 'tall'
|
|
62
|
+
/** Optional class for the TextField root. */
|
|
63
|
+
rootClassName?: string
|
|
64
|
+
/** Optional class for the inner native input (for product-specific composition). */
|
|
65
|
+
inputClassName?: string
|
|
60
66
|
}
|
|
61
67
|
|
|
62
|
-
export const Input = ({ label, description, errorMessage, placeholder, size, ...props }: InputProps) => (
|
|
63
|
-
<TextField {...props} className=
|
|
64
|
-
<Label className="text-
|
|
65
|
-
<StyledInput placeholder={placeholder} fieldSize={size} />
|
|
68
|
+
export const Input = ({ label, description, errorMessage, placeholder, size, rootClassName, inputClassName, ...props }: InputProps) => (
|
|
69
|
+
<TextField {...props} className={`flex w-full flex-col gap-3 ${rootClassName ?? ''}`}>
|
|
70
|
+
<Label className="text-panel-heading font-medium text-fg">{label}</Label>
|
|
71
|
+
<StyledInput className={inputClassName} placeholder={placeholder} fieldSize={size} />
|
|
66
72
|
{description ? (
|
|
67
73
|
<Text slot="description" className="text-label text-fg-muted">
|
|
68
74
|
{description}
|
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
import { type ClipboardEvent as ReactClipboardEvent, type ReactNode, useEffect, useRef } from 'react'
|
|
2
2
|
import { clsx } from 'clsx'
|
|
3
|
+
import { SAFE_LINK_HINT, safeLinkUrl } from '../utils/safe-link-url'
|
|
3
4
|
import { useInFocusOverlay } from './focus-context'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* RichTextEditor — a dependency-free contentEditable WYSIWYG that emits an HTML
|
|
7
8
|
* string (round-trips with any `set:html` / `dangerouslySetInnerHTML` renderer,
|
|
8
9
|
* so no value migration and no bundled ProseMirror). Ported from pramen's
|
|
9
|
-
* cms-editor `RichText
|
|
10
|
+
* cms-editor `RichText`.
|
|
11
|
+
*
|
|
12
|
+
* Still the right pick for a short caption/bio field. For document-shaped content
|
|
13
|
+
* — blocks, a `/` palette, tables/embeds — reach for `BlockEditor` from
|
|
14
|
+
* `@podoba/react/editor` instead; it is the Tiptap-backed upgrade this component's
|
|
15
|
+
* `execCommand` core cannot grow into. Both emit the same HTML-string value, so
|
|
16
|
+
* swapping one for the other needs no data migration.
|
|
10
17
|
*
|
|
11
18
|
* The `prose prose-sm` body relies on the @tailwindcss/typography plugin, which
|
|
12
19
|
* @podoba/tailwind's preset already registers.
|
|
@@ -32,7 +39,7 @@ const RT_TOOLS: Array<{ label: string; title: string; run: (exec: (c: string, a?
|
|
|
32
39
|
if (!raw) return
|
|
33
40
|
const url = safeLinkUrl(raw)
|
|
34
41
|
if (!url) {
|
|
35
|
-
window.alert(
|
|
42
|
+
window.alert(SAFE_LINK_HINT)
|
|
36
43
|
return
|
|
37
44
|
}
|
|
38
45
|
x('createLink', url)
|
|
@@ -42,13 +49,6 @@ const RT_TOOLS: Array<{ label: string; title: string; run: (exec: (c: string, a?
|
|
|
42
49
|
{ label: 'Clear', title: 'Clear formatting', run: (x) => x('removeFormat') },
|
|
43
50
|
]
|
|
44
51
|
|
|
45
|
-
/** Allow-list for a link href: http(s), mailto, tel, or a relative/anchor path.
|
|
46
|
-
* The prefix allow-list inherently rejects `javascript:`/`data:`/`vbscript:`. */
|
|
47
|
-
function safeLinkUrl(raw: string): string | null {
|
|
48
|
-
const url = raw.trim()
|
|
49
|
-
return /^(https?:\/\/|mailto:|tel:|\/|#)/i.test(url) ? url : null
|
|
50
|
-
}
|
|
51
|
-
|
|
52
52
|
/** Cosmetic scrub (NOT a security boundary — sanitise on the server). */
|
|
53
53
|
function scrubHtml(html: string): string {
|
|
54
54
|
return html
|
|
@@ -36,6 +36,10 @@ export type SectionTabsProps = {
|
|
|
36
36
|
resetLabel?: string
|
|
37
37
|
/** Glyph for the reset control; defaults to a small grid icon. */
|
|
38
38
|
resetIcon?: ReactNode
|
|
39
|
+
/** Visible reset content. When set, renders a source-style text tab (e.g. "Summary"). */
|
|
40
|
+
resetContent?: ReactNode
|
|
41
|
+
/** Whether the reset/overview destination is the currently selected section. */
|
|
42
|
+
isResetSelected?: boolean
|
|
39
43
|
className?: string
|
|
40
44
|
}
|
|
41
45
|
|
|
@@ -49,12 +53,12 @@ const GridGlyph = () => (
|
|
|
49
53
|
)
|
|
50
54
|
|
|
51
55
|
const tabBase =
|
|
52
|
-
'inline-flex h-7 items-center gap-2 rounded-sm px-
|
|
53
|
-
'transition-colors duration-
|
|
56
|
+
'inline-flex h-7 shrink-0 items-center gap-2 rounded-sm px-nav-x py-1.5 text-compact font-normal text-fg-muted outline-none ' +
|
|
57
|
+
'transition-colors duration-150 ease-in-out data-[hovered]:bg-surface-muted data-[hovered]:text-fg ' +
|
|
54
58
|
'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring ' +
|
|
55
59
|
'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50 data-[disabled]:bg-transparent data-[disabled]:text-fg-muted'
|
|
56
60
|
|
|
57
|
-
const tabActive = 'bg-surface-muted
|
|
61
|
+
const tabActive = 'bg-surface-muted text-fg'
|
|
58
62
|
|
|
59
63
|
export function SectionTabs({
|
|
60
64
|
tabs,
|
|
@@ -64,19 +68,37 @@ export function SectionTabs({
|
|
|
64
68
|
onReset,
|
|
65
69
|
resetLabel = 'Reset',
|
|
66
70
|
resetIcon,
|
|
71
|
+
resetContent,
|
|
72
|
+
isResetSelected = false,
|
|
67
73
|
className,
|
|
68
74
|
}: SectionTabsProps) {
|
|
69
75
|
return (
|
|
70
|
-
<div
|
|
76
|
+
<div
|
|
77
|
+
className={[
|
|
78
|
+
'flex min-h-9 w-full flex-nowrap items-start gap-2 overflow-x-auto pb-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden lg:w-2/3 lg:flex-wrap lg:overflow-visible lg:pb-0',
|
|
79
|
+
className,
|
|
80
|
+
]
|
|
81
|
+
.filter(Boolean)
|
|
82
|
+
.join(' ')}
|
|
83
|
+
role="group"
|
|
84
|
+
>
|
|
71
85
|
{onReset ? (
|
|
72
|
-
<
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
86
|
+
<div className="sticky left-0 z-10 shrink-0 bg-surface pr-1 lg:static lg:bg-transparent lg:pr-0">
|
|
87
|
+
<Button
|
|
88
|
+
variant="ghost"
|
|
89
|
+
aria-label={resetLabel}
|
|
90
|
+
aria-pressed={isResetSelected}
|
|
91
|
+
onPress={onReset}
|
|
92
|
+
className={[
|
|
93
|
+
resetContent ? tabBase : 'h-7 w-7 rounded-sm p-0 text-fg-muted data-[hovered]:text-fg',
|
|
94
|
+
isResetSelected ? tabActive : '',
|
|
95
|
+
]
|
|
96
|
+
.filter(Boolean)
|
|
97
|
+
.join(' ')}
|
|
98
|
+
>
|
|
99
|
+
{resetContent ?? resetIcon ?? <GridGlyph />}
|
|
100
|
+
</Button>
|
|
101
|
+
</div>
|
|
80
102
|
) : null}
|
|
81
103
|
{tabs.map((tab) => {
|
|
82
104
|
const selected = active === tab.key
|
|
@@ -21,21 +21,21 @@ import { useInFocusOverlay } from './focus-context'
|
|
|
21
21
|
* RAC handles the listbox ARIA pattern, keyboard navigation, typeahead and
|
|
22
22
|
* focus management. Styling via `uic`. Pass options as `SelectItem` children.
|
|
23
23
|
*
|
|
24
|
-
* Re-skinned
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* hover #eceae1 → surface-muted · text #0d0d0d → fg · placeholder → fg-muted ·
|
|
24
|
+
* Re-skinned to gs-platform's designer spec (Figma GraphicStandard 1.5, node
|
|
25
|
+
* 2115-4271 — `Select.module.scss`): a tall (58px) filled trigger matching our
|
|
26
|
+
* `Input` / `Textarea` so the form controls stay consistent. gs token map:
|
|
27
|
+
* hover border #aba89c → fg-subtle · text #0d0d0d → fg · placeholder → fg-muted ·
|
|
29
28
|
* 8px radius → rounded-lg · 6px item radius → rounded-md · error → danger.
|
|
30
29
|
*/
|
|
31
30
|
const SelectTrigger = uic(RACButton, {
|
|
32
31
|
displayName: 'SelectTrigger',
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
32
|
+
// gs source control geometry (58px tall, 20px inline padding, 8px radius) with
|
|
33
|
+
// the shared bordered fill: gs draws it borderless on cream, but our Card is
|
|
34
|
+
// also surface-card and dark theme collapses surface-card onto surface, so a
|
|
35
|
+
// borderless cream trigger disappears. White fill + border keeps it visible and
|
|
36
|
+
// consistent with the other form controls; hover darkens the border.
|
|
37
37
|
baseClass:
|
|
38
|
-
'flex h-
|
|
38
|
+
'flex h-control-tall w-full items-center justify-between gap-2.5 rounded-lg border border-border bg-surface px-5 ' +
|
|
39
39
|
'text-small text-fg outline-none transition-colors ' +
|
|
40
40
|
'data-[hovered]:border-fg-subtle ' +
|
|
41
41
|
'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring ' +
|
|
@@ -66,6 +66,10 @@ export type SelectProps<T extends object> = RACSelectProps<T> & {
|
|
|
66
66
|
*/
|
|
67
67
|
placeholder: string
|
|
68
68
|
children: ReactNode
|
|
69
|
+
/** Optional class for the Select root. */
|
|
70
|
+
rootClassName?: string
|
|
71
|
+
/** Optional class for the trigger (for product-specific composition). */
|
|
72
|
+
triggerClassName?: string
|
|
69
73
|
}
|
|
70
74
|
|
|
71
75
|
export const Select = <T extends object>({
|
|
@@ -74,6 +78,8 @@ export const Select = <T extends object>({
|
|
|
74
78
|
errorMessage,
|
|
75
79
|
placeholder,
|
|
76
80
|
children,
|
|
81
|
+
rootClassName,
|
|
82
|
+
triggerClassName,
|
|
77
83
|
...props
|
|
78
84
|
}: SelectProps<T>) => {
|
|
79
85
|
// In a focus overlay, show the options inline (seamless) instead of a popover.
|
|
@@ -91,8 +97,8 @@ export const Select = <T extends object>({
|
|
|
91
97
|
const err = <FieldError className="text-label text-danger">{errorMessage}</FieldError>
|
|
92
98
|
|
|
93
99
|
return (
|
|
94
|
-
<RACSelect {...props} placeholder={placeholder} className=
|
|
95
|
-
<Label className="text-
|
|
100
|
+
<RACSelect {...props} placeholder={placeholder} className={`group flex flex-col gap-3 ${rootClassName ?? ''}`}>
|
|
101
|
+
<Label className="text-panel-heading font-medium text-fg">{label}</Label>
|
|
96
102
|
{inFocus ? (
|
|
97
103
|
<>
|
|
98
104
|
{listbox}
|
|
@@ -101,7 +107,7 @@ export const Select = <T extends object>({
|
|
|
101
107
|
</>
|
|
102
108
|
) : (
|
|
103
109
|
<>
|
|
104
|
-
<SelectTrigger>
|
|
110
|
+
<SelectTrigger className={triggerClassName}>
|
|
105
111
|
<SelectValue className="data-[placeholder]:text-fg-muted" />
|
|
106
112
|
{/* gs chevron: 9.5px caret, dark (neutral-400 → fg), non-interactive. */}
|
|
107
113
|
<svg
|
package/src/components/text.tsx
CHANGED
|
@@ -6,10 +6,12 @@ import { uic } from '../utils/uic'
|
|
|
6
6
|
* They express the `@app/tokens` type ramp as variant props so screens stop
|
|
7
7
|
* hand-writing `text-[NNpx]` literals (the scale, not arbitrary pixels). Two ramps:
|
|
8
8
|
*
|
|
9
|
-
* • <Text> — the
|
|
10
|
-
* body · subtitle · title · headline · display).
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* • <Text> — the UI ramp (micro · caption · label · compact · small · callout ·
|
|
10
|
+
* body · subtitle · title · headline · display). Four of these carry the gs
|
|
11
|
+
* source leading with them (compact 13/16, small 14/18, body 16/22,
|
|
12
|
+
* display 28/30); the rest are size-only — pair those with `leading-*` via
|
|
13
|
+
* `className` where a fixed leading matters. `weight` + `tone` cover the
|
|
14
|
+
* common cases.
|
|
13
15
|
* • <Heading> — the semantic heading ramp (`level` 1–5 → text-heading1…5), which
|
|
14
16
|
* DO carry a matching line-height. Renders an <h2> by default; pass `asChild`
|
|
15
17
|
* to project the styles onto the correct heading element for the document
|
|
@@ -20,6 +22,25 @@ import { uic } from '../utils/uic'
|
|
|
20
22
|
* no i18n.
|
|
21
23
|
*/
|
|
22
24
|
|
|
25
|
+
/**
|
|
26
|
+
* The large product heading from the GS reference UI (`label-1` in its original
|
|
27
|
+
* token vocabulary): 30px / 32px / medium with -2px tracking.
|
|
28
|
+
*
|
|
29
|
+
* Kept separate from the 28px semantic Heading ramp because it is a reusable
|
|
30
|
+
* product-display treatment, not a document-outline level. Use `asChild` to project
|
|
31
|
+
* it onto the correct h1–h6 element.
|
|
32
|
+
*/
|
|
33
|
+
export const DisplayHeading = uic('h2', {
|
|
34
|
+
displayName: 'DisplayHeading',
|
|
35
|
+
baseClass: 'text-display-large font-medium text-fg',
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
/** Compact 17px / 20px section title used in workflow and dialog panels. */
|
|
39
|
+
export const PanelHeading = uic('h3', {
|
|
40
|
+
displayName: 'PanelHeading',
|
|
41
|
+
baseClass: 'text-panel-heading font-medium text-fg',
|
|
42
|
+
})
|
|
43
|
+
|
|
23
44
|
export const Text = uic('span', {
|
|
24
45
|
displayName: 'Text',
|
|
25
46
|
baseClass: 'text-fg',
|
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import { EditorContent, useEditor, useEditorState, type Editor } from '@tiptap/react'
|
|
4
|
+
import { BubbleMenu } from '@tiptap/react/menus'
|
|
5
|
+
import type { EditorState } from '@tiptap/pm/state'
|
|
6
|
+
import StarterKit from '@tiptap/starter-kit'
|
|
7
|
+
import Placeholder from '@tiptap/extension-placeholder'
|
|
8
|
+
import Highlight from '@tiptap/extension-highlight'
|
|
9
|
+
import TaskList from '@tiptap/extension-task-list'
|
|
10
|
+
import TaskItem from '@tiptap/extension-task-item'
|
|
11
|
+
import { clsx } from 'clsx'
|
|
12
|
+
import { SAFE_LINK_HINT, safeLinkUrl } from '../utils/safe-link-url'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* BlockEditor — a Notion-style block/rich-text editor for podoba, on Tiptap 3
|
|
16
|
+
* (ProseMirror). Opt-in subpath (`@podoba/react/editor`) so the base library stays
|
|
17
|
+
* free of the ProseMirror weight — importing this is what pulls Tiptap into a bundle.
|
|
18
|
+
* The Tiptap packages are OPTIONAL PEERS: install them alongside @podoba/react only
|
|
19
|
+
* if you import this subpath.
|
|
20
|
+
*
|
|
21
|
+
* Serialises to an **HTML string** (controlled `value` / `onChange(html)`), so it is a
|
|
22
|
+
* drop-in upgrade for any `set:html` / `dangerouslySetInnerHTML` renderer with no value
|
|
23
|
+
* migration. SECURITY: like any editor, it can emit arbitrary HTML — sanitise on the
|
|
24
|
+
* SERVER on write; the `value`/`onChange` contract here is presentation only.
|
|
25
|
+
*
|
|
26
|
+
* CONTROLLED-VALUE CONTRACT: echo `onChange`'s HTML back as `value` VERBATIM. The
|
|
27
|
+
* editor only re-seeds its document when `value` differs from what it last emitted, so
|
|
28
|
+
* a parent that normalises/sanitises before echoing would re-seed on every keystroke
|
|
29
|
+
* and send the caret back to the start. Sanitise on write to your store, not in render.
|
|
30
|
+
*
|
|
31
|
+
* Notion features: `/` slash menu (self-contained — insert paragraph/heading/list/
|
|
32
|
+
* to-do/quote/code/divider), an inline bubble toolbar (bold/italic/strike/highlight/
|
|
33
|
+
* code/link), and StarterKit's markdown input rules. Styling rides `@tailwindcss/
|
|
34
|
+
* typography` (`prose`), which @podoba/tailwind already registers, plus design tokens
|
|
35
|
+
* so it flips under `[data-theme="dark"]`.
|
|
36
|
+
*
|
|
37
|
+
* Prefer this over {@link ../components/rich-text-editor RichTextEditor} for
|
|
38
|
+
* document-shaped content; the dependency-free contentEditable one stays the right
|
|
39
|
+
* pick for a short caption/bio field where ProseMirror is not worth installing.
|
|
40
|
+
*/
|
|
41
|
+
export type BlockEditorProps = {
|
|
42
|
+
/** Controlled HTML value. Must be echoed back verbatim — see the contract above. */
|
|
43
|
+
value: string
|
|
44
|
+
/** Called with the editor's HTML on every change. */
|
|
45
|
+
onChange: (html: string) => void
|
|
46
|
+
/** Empty-document placeholder (also the `/`-hint). */
|
|
47
|
+
placeholder?: string
|
|
48
|
+
/** Optional visible label rendered above the editor; names the editable region. */
|
|
49
|
+
label?: ReactNode
|
|
50
|
+
/** Set false for a read-only render. */
|
|
51
|
+
editable?: boolean
|
|
52
|
+
/** Min height of the editable surface (default 200px). */
|
|
53
|
+
minHeight?: number | string
|
|
54
|
+
/** Class on the outer container. */
|
|
55
|
+
className?: string
|
|
56
|
+
/** Accessible name when there is no visible `label`. */
|
|
57
|
+
'aria-label'?: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type SlashCommand = {
|
|
61
|
+
title: string
|
|
62
|
+
hint: string
|
|
63
|
+
keywords: string[]
|
|
64
|
+
run: (editor: Editor) => void
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The `/` block palette. Kept generic (no domain blocks) — a CMS/app composes richer
|
|
68
|
+
// block types around this at the document level; this is the text-block vocabulary.
|
|
69
|
+
const SLASH_COMMANDS: readonly SlashCommand[] = [
|
|
70
|
+
{ title: 'Text', hint: 'Plain paragraph', keywords: ['text', 'paragraph', 'body', 'p'], run: (e) => e.chain().focus().setParagraph().run() },
|
|
71
|
+
{ title: 'Heading 1', hint: 'Big section heading', keywords: ['h1', 'heading', 'title'], run: (e) => e.chain().focus().toggleHeading({ level: 1 }).run() },
|
|
72
|
+
{ title: 'Heading 2', hint: 'Medium heading', keywords: ['h2', 'subheading'], run: (e) => e.chain().focus().toggleHeading({ level: 2 }).run() },
|
|
73
|
+
{ title: 'Heading 3', hint: 'Small heading', keywords: ['h3'], run: (e) => e.chain().focus().toggleHeading({ level: 3 }).run() },
|
|
74
|
+
{ title: 'Bulleted list', hint: 'Unordered list', keywords: ['bullet', 'unordered', 'ul', 'list'], run: (e) => e.chain().focus().toggleBulletList().run() },
|
|
75
|
+
{ title: 'Numbered list', hint: 'Ordered list', keywords: ['numbered', 'ordered', 'ol', 'list'], run: (e) => e.chain().focus().toggleOrderedList().run() },
|
|
76
|
+
{ title: 'To-do list', hint: 'Checklist', keywords: ['todo', 'task', 'checkbox', 'check'], run: (e) => e.chain().focus().toggleTaskList().run() },
|
|
77
|
+
{ title: 'Quote', hint: 'Block quote', keywords: ['quote', 'blockquote', 'citation'], run: (e) => e.chain().focus().toggleBlockquote().run() },
|
|
78
|
+
{ title: 'Code', hint: 'Code block', keywords: ['code', 'snippet', 'pre'], run: (e) => e.chain().focus().toggleCodeBlock().run() },
|
|
79
|
+
{ title: 'Divider', hint: 'Horizontal rule', keywords: ['divider', 'rule', 'hr', 'separator'], run: (e) => e.chain().focus().setHorizontalRule().run() },
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
/** Open palette: the doc position of the trigger `/`, the query typed after it, and
|
|
83
|
+
* the highlighted row. Screen position is derived from `from` at paint time, never
|
|
84
|
+
* stored — so the menu can be re-placed on scroll/resize without stale coordinates. */
|
|
85
|
+
type SlashState = { from: number; query: string; index: number }
|
|
86
|
+
|
|
87
|
+
/** Filter the palette by title or keyword. Exported for tests. */
|
|
88
|
+
export function filterCommands(query: string): SlashCommand[] {
|
|
89
|
+
const q = query.trim().toLowerCase()
|
|
90
|
+
if (!q) return [...SLASH_COMMANDS]
|
|
91
|
+
return SLASH_COMMANDS.filter((c) => c.title.toLowerCase().includes(q) || c.keywords.some((k) => k.includes(q)))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A `/` only opens the palette at the start of a block or after whitespace — never
|
|
95
|
+
* mid-word, so `and/or`, `https://…` and `/api/v2` stay plain text. Code blocks are
|
|
96
|
+
* excluded outright: a `/` there is always literal. Exported for tests. */
|
|
97
|
+
export function canOpenSlash(state: EditorState, from: number): boolean {
|
|
98
|
+
if (from < 0 || from > state.doc.content.size) return false
|
|
99
|
+
const $from = state.doc.resolve(from)
|
|
100
|
+
if ($from.parent.type.spec.code) return false
|
|
101
|
+
if ($from.parentOffset === 0) return true
|
|
102
|
+
// textBetween returns '' for a non-text node (inline image, mention) — treat that
|
|
103
|
+
// as a boundary too; only a real word character blocks the trigger.
|
|
104
|
+
return /^\s*$/.test($from.parent.textBetween($from.parentOffset - 1, $from.parentOffset))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const btn =
|
|
108
|
+
'inline-flex h-8 min-w-8 items-center justify-center rounded-md px-2 text-small text-fg-muted transition-colors hover:bg-surface-muted hover:text-fg data-[active=true]:bg-surface-muted data-[active=true]:text-fg'
|
|
109
|
+
|
|
110
|
+
/** Marks the bubble toolbar toggles. `active` keys read off the useEditorState
|
|
111
|
+
* snapshot below — Tiptap 3 does NOT re-render on transactions, so a plain
|
|
112
|
+
* `editor.isActive()` read during render would go stale on selection-only changes. */
|
|
113
|
+
const MARK_TOOLS = [
|
|
114
|
+
{ key: 'bold', title: 'Bold', label: <b>B</b>, run: (e: Editor) => e.chain().focus().toggleBold().run() },
|
|
115
|
+
{ key: 'italic', title: 'Italic', label: <i>i</i>, run: (e: Editor) => e.chain().focus().toggleItalic().run() },
|
|
116
|
+
{ key: 'strike', title: 'Strikethrough', label: <s>S</s>, run: (e: Editor) => e.chain().focus().toggleStrike().run() },
|
|
117
|
+
{ key: 'highlight', title: 'Highlight', label: 'H', run: (e: Editor) => e.chain().focus().toggleHighlight().run() },
|
|
118
|
+
{ key: 'code', title: 'Inline code', label: '</>', run: (e: Editor) => e.chain().focus().toggleCode().run() },
|
|
119
|
+
] as const
|
|
120
|
+
|
|
121
|
+
const LinkIcon = () => (
|
|
122
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
123
|
+
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
|
124
|
+
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
|
125
|
+
</svg>
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
export function BlockEditor({ value, onChange, placeholder = "Write, or press '/' for blocks…", label, editable = true, minHeight = 200, className, ...aria }: BlockEditorProps) {
|
|
129
|
+
const onChangeRef = useRef(onChange)
|
|
130
|
+
onChangeRef.current = onChange
|
|
131
|
+
const lastEmitted = useRef<string>(value)
|
|
132
|
+
// The Placeholder extension is configured once at editor creation, so read the
|
|
133
|
+
// current prop through a ref — that keeps `placeholder` live without recreating
|
|
134
|
+
// the whole editor on every change.
|
|
135
|
+
const placeholderRef = useRef(placeholder)
|
|
136
|
+
placeholderRef.current = placeholder
|
|
137
|
+
|
|
138
|
+
// Slash state lives in a REF as well as in React state: ProseMirror handlers run
|
|
139
|
+
// synchronously inside the key event, long before React re-renders, so reading a
|
|
140
|
+
// render-assigned ref would lag a full render (two fast `/`s would double-open).
|
|
141
|
+
// `setSlash` writes both, so handlers always see what the last one wrote.
|
|
142
|
+
const slashRef = useRef<SlashState | null>(null)
|
|
143
|
+
const [slash, setSlashState] = useState<SlashState | null>(null)
|
|
144
|
+
const setSlash = useCallback((next: SlashState | null) => {
|
|
145
|
+
slashRef.current = next
|
|
146
|
+
setSlashState(next)
|
|
147
|
+
}, [])
|
|
148
|
+
|
|
149
|
+
const [linkOpen, setLinkOpen] = useState(false)
|
|
150
|
+
const linkOpenRef = useRef(false)
|
|
151
|
+
linkOpenRef.current = linkOpen
|
|
152
|
+
const [linkDraft, setLinkDraft] = useState('')
|
|
153
|
+
const [linkError, setLinkError] = useState<string | null>(null)
|
|
154
|
+
const linkInputRef = useRef<HTMLInputElement>(null)
|
|
155
|
+
|
|
156
|
+
const menuRef = useRef<HTMLDivElement>(null)
|
|
157
|
+
const [menuPos, setMenuPos] = useState<{ left: number; top: number } | null>(null)
|
|
158
|
+
// The palette portals to <body>, so mount first — a portal has no server render.
|
|
159
|
+
const [mounted, setMounted] = useState(false)
|
|
160
|
+
useEffect(() => setMounted(true), [])
|
|
161
|
+
|
|
162
|
+
const reactId = useId()
|
|
163
|
+
const labelId = `${reactId}-label`
|
|
164
|
+
const listboxId = `${reactId}-slash`
|
|
165
|
+
const optionId = (i: number) => `${listboxId}-option-${i}`
|
|
166
|
+
|
|
167
|
+
// `runSlash` closes over `editor`, which does not exist yet — hand the handlers a
|
|
168
|
+
// ref instead of relying on Tiptap re-applying options after every render.
|
|
169
|
+
const runSlashRef = useRef<(cmd: SlashCommand) => void>(() => {})
|
|
170
|
+
|
|
171
|
+
const ariaLabel = aria['aria-label']
|
|
172
|
+
const filtered = useMemo(() => filterCommands(slash?.query ?? ''), [slash?.query])
|
|
173
|
+
const activeOption = slash ? Math.min(slash.index, Math.max(filtered.length - 1, 0)) : 0
|
|
174
|
+
const paletteOpen = slash !== null && filtered.length > 0
|
|
175
|
+
|
|
176
|
+
const editor = useEditor({
|
|
177
|
+
editable,
|
|
178
|
+
// Explicit: Tiptap otherwise renders immediately and warns on every SSR pass.
|
|
179
|
+
// A published library cannot assume a client-only host.
|
|
180
|
+
immediatelyRender: false,
|
|
181
|
+
extensions: [
|
|
182
|
+
StarterKit.configure({ heading: { levels: [1, 2, 3] }, link: { openOnClick: false, autolink: true } }),
|
|
183
|
+
Highlight,
|
|
184
|
+
TaskList,
|
|
185
|
+
TaskItem.configure({ nested: true }),
|
|
186
|
+
Placeholder.configure({ placeholder: () => placeholderRef.current }),
|
|
187
|
+
],
|
|
188
|
+
content: value || '',
|
|
189
|
+
editorProps: {
|
|
190
|
+
attributes: {
|
|
191
|
+
class: clsx(
|
|
192
|
+
'prose prose-sm max-w-none px-4 py-3 text-fg outline-none',
|
|
193
|
+
'prose-headings:text-fg prose-p:text-fg prose-strong:text-fg prose-a:text-accent-strong',
|
|
194
|
+
'prose-code:text-fg prose-blockquote:text-fg-muted prose-li:text-fg',
|
|
195
|
+
),
|
|
196
|
+
style: `min-height:${typeof minHeight === 'number' ? `${minHeight}px` : minHeight}`,
|
|
197
|
+
// The contenteditable IS the textbox — naming the EditorContent wrapper
|
|
198
|
+
// instead would leave it nameless to assistive tech.
|
|
199
|
+
role: 'textbox',
|
|
200
|
+
'aria-multiline': 'true',
|
|
201
|
+
...(label ? { 'aria-labelledby': labelId } : {}),
|
|
202
|
+
...(ariaLabel ? { 'aria-label': ariaLabel } : {}),
|
|
203
|
+
'aria-expanded': paletteOpen ? 'true' : 'false',
|
|
204
|
+
...(paletteOpen ? { 'aria-controls': listboxId, 'aria-activedescendant': optionId(activeOption) } : {}),
|
|
205
|
+
},
|
|
206
|
+
// The `/` trigger fires on the actual character insert, so it covers every
|
|
207
|
+
// input method (IME, synthetic input, paste of a single char) with one path.
|
|
208
|
+
handleTextInput(view, from, _to, text) {
|
|
209
|
+
if (text !== '/' || slashRef.current) return false
|
|
210
|
+
if (!canOpenSlash(view.state, from)) return false
|
|
211
|
+
setSlash({ from, query: '', index: 0 })
|
|
212
|
+
return false // let the `/` type; onUpdate tracks it as the query prefix
|
|
213
|
+
},
|
|
214
|
+
handleKeyDown(_view, event) {
|
|
215
|
+
const s = slashRef.current
|
|
216
|
+
if (!s) return false
|
|
217
|
+
if (event.key === 'Escape') {
|
|
218
|
+
setSlash(null)
|
|
219
|
+
return true
|
|
220
|
+
}
|
|
221
|
+
const list = filterCommands(s.query)
|
|
222
|
+
// Nothing to pick — close and let the key through. Swallowing Enter and
|
|
223
|
+
// the arrows here is what used to strand the caret after `/api/v2`.
|
|
224
|
+
if (list.length === 0) {
|
|
225
|
+
setSlash(null)
|
|
226
|
+
return false
|
|
227
|
+
}
|
|
228
|
+
if (event.key === 'ArrowDown') {
|
|
229
|
+
setSlash({ ...s, index: (s.index + 1) % list.length })
|
|
230
|
+
return true
|
|
231
|
+
}
|
|
232
|
+
if (event.key === 'ArrowUp') {
|
|
233
|
+
setSlash({ ...s, index: (s.index - 1 + list.length) % list.length })
|
|
234
|
+
return true
|
|
235
|
+
}
|
|
236
|
+
if (event.key === 'Enter' || event.key === 'Tab') {
|
|
237
|
+
runSlashRef.current(list[Math.min(s.index, list.length - 1)])
|
|
238
|
+
return true
|
|
239
|
+
}
|
|
240
|
+
return false
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
onUpdate({ editor }) {
|
|
244
|
+
const html = editor.getHTML()
|
|
245
|
+
lastEmitted.current = html
|
|
246
|
+
onChangeRef.current(html)
|
|
247
|
+
// Track the `/query` the caret is typing after an open palette.
|
|
248
|
+
const s = slashRef.current
|
|
249
|
+
if (!s) return
|
|
250
|
+
const to = editor.state.selection.from
|
|
251
|
+
if (to < s.from) return setSlash(null)
|
|
252
|
+
const text = editor.state.doc.textBetween(s.from, to, '\n', '\n')
|
|
253
|
+
if (!text.startsWith('/')) return setSlash(null)
|
|
254
|
+
const query = text.slice(1)
|
|
255
|
+
// A block query is one word. Whitespace, or a query that matches nothing,
|
|
256
|
+
// means the user is writing prose — close rather than linger invisibly.
|
|
257
|
+
if (/\s/.test(query) || filterCommands(query).length === 0) return setSlash(null)
|
|
258
|
+
setSlash({ ...s, query, index: 0 })
|
|
259
|
+
},
|
|
260
|
+
// A pure caret move (click, arrow, select-all) fires no update — close the
|
|
261
|
+
// palette when the caret leaves the `/query` it belongs to.
|
|
262
|
+
onSelectionUpdate({ editor }) {
|
|
263
|
+
const s = slashRef.current
|
|
264
|
+
if (!s) return
|
|
265
|
+
const to = editor.state.selection.from
|
|
266
|
+
if (to < s.from || to > s.from + s.query.length + 1) setSlash(null)
|
|
267
|
+
},
|
|
268
|
+
onBlur() {
|
|
269
|
+
setSlash(null)
|
|
270
|
+
},
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
// Push EXTERNAL value changes into the editor (switching records, resets) without
|
|
274
|
+
// clobbering the caret on our own keystrokes.
|
|
275
|
+
useEffect(() => {
|
|
276
|
+
if (!editor) return
|
|
277
|
+
if (value !== lastEmitted.current && value !== editor.getHTML()) {
|
|
278
|
+
editor.commands.setContent(value || '', { emitUpdate: false })
|
|
279
|
+
lastEmitted.current = value
|
|
280
|
+
}
|
|
281
|
+
}, [value, editor])
|
|
282
|
+
|
|
283
|
+
// Tiptap's own re-render pass re-applies options with `editable` pinned to the
|
|
284
|
+
// live instance value, so the prop has to be pushed through this side channel.
|
|
285
|
+
useEffect(() => {
|
|
286
|
+
if (editor) editor.setEditable(editable)
|
|
287
|
+
}, [editable, editor])
|
|
288
|
+
|
|
289
|
+
const runSlash = useCallback(
|
|
290
|
+
(cmd: SlashCommand | undefined) => {
|
|
291
|
+
const s = slashRef.current
|
|
292
|
+
if (!editor || !s || !cmd) return
|
|
293
|
+
// Clamp: the doc can have shrunk under an open palette (an external
|
|
294
|
+
// setContent), which would make deleteRange throw on a stale position.
|
|
295
|
+
const size = editor.state.doc.content.size
|
|
296
|
+
const from = Math.min(Math.max(s.from, 0), size)
|
|
297
|
+
const to = Math.min(Math.max(editor.state.selection.from, from), size)
|
|
298
|
+
editor.chain().focus().deleteRange({ from, to }).run()
|
|
299
|
+
cmd.run(editor)
|
|
300
|
+
setSlash(null)
|
|
301
|
+
},
|
|
302
|
+
[editor, setSlash],
|
|
303
|
+
)
|
|
304
|
+
runSlashRef.current = runSlash
|
|
305
|
+
|
|
306
|
+
// Place the palette from the caret's CURRENT viewport coords, clamped into the
|
|
307
|
+
// viewport and flipped above the caret when it would overflow the bottom.
|
|
308
|
+
const placeMenu = useCallback(() => {
|
|
309
|
+
const s = slashRef.current
|
|
310
|
+
const el = menuRef.current
|
|
311
|
+
if (!editor || !s || !el) return
|
|
312
|
+
const size = editor.state.doc.content.size
|
|
313
|
+
if (s.from > size) return setSlash(null)
|
|
314
|
+
const caret = editor.view.coordsAtPos(s.from)
|
|
315
|
+
const rect = el.getBoundingClientRect()
|
|
316
|
+
const gap = 6
|
|
317
|
+
const edge = 8
|
|
318
|
+
let top = caret.bottom + gap
|
|
319
|
+
if (top + rect.height > window.innerHeight - edge) top = Math.max(edge, caret.top - gap - rect.height)
|
|
320
|
+
const left = Math.max(edge, Math.min(caret.left, window.innerWidth - rect.width - edge))
|
|
321
|
+
setMenuPos((prev) => (prev && prev.left === left && prev.top === top ? prev : { left, top }))
|
|
322
|
+
}, [editor, setSlash])
|
|
323
|
+
|
|
324
|
+
useLayoutEffect(() => {
|
|
325
|
+
if (slash) placeMenu()
|
|
326
|
+
else setMenuPos(null)
|
|
327
|
+
}, [slash, placeMenu])
|
|
328
|
+
|
|
329
|
+
// Fixed coordinates go stale the moment anything scrolls — including a scroll
|
|
330
|
+
// inside the editor itself, hence the capture-phase listener.
|
|
331
|
+
useEffect(() => {
|
|
332
|
+
if (!slash) return
|
|
333
|
+
const replace = () => placeMenu()
|
|
334
|
+
window.addEventListener('scroll', replace, true)
|
|
335
|
+
window.addEventListener('resize', replace)
|
|
336
|
+
return () => {
|
|
337
|
+
window.removeEventListener('scroll', replace, true)
|
|
338
|
+
window.removeEventListener('resize', replace)
|
|
339
|
+
}
|
|
340
|
+
}, [slash, placeMenu])
|
|
341
|
+
|
|
342
|
+
// Selection-driven mark states. Tiptap 3's useEditor deliberately does not
|
|
343
|
+
// re-render on transactions; this subscription is what keeps the toolbar honest.
|
|
344
|
+
const active = useEditorState({
|
|
345
|
+
editor,
|
|
346
|
+
selector: ({ editor }) =>
|
|
347
|
+
editor
|
|
348
|
+
? {
|
|
349
|
+
bold: editor.isActive('bold'),
|
|
350
|
+
italic: editor.isActive('italic'),
|
|
351
|
+
strike: editor.isActive('strike'),
|
|
352
|
+
highlight: editor.isActive('highlight'),
|
|
353
|
+
code: editor.isActive('code'),
|
|
354
|
+
link: editor.isActive('link'),
|
|
355
|
+
hasSelection: !editor.state.selection.empty,
|
|
356
|
+
}
|
|
357
|
+
: null,
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
const closeLink = useCallback(() => {
|
|
361
|
+
setLinkOpen(false)
|
|
362
|
+
setLinkError(null)
|
|
363
|
+
linkOpenRef.current = false
|
|
364
|
+
}, [])
|
|
365
|
+
|
|
366
|
+
// The link panel replaces the toolbar in place; a collapsed selection means the
|
|
367
|
+
// bubble menu is on its way out, so it must not strand an open panel.
|
|
368
|
+
useEffect(() => {
|
|
369
|
+
if (linkOpen && active && !active.hasSelection && !active.link) closeLink()
|
|
370
|
+
}, [linkOpen, active, closeLink])
|
|
371
|
+
|
|
372
|
+
useEffect(() => {
|
|
373
|
+
if (linkOpen) linkInputRef.current?.focus()
|
|
374
|
+
}, [linkOpen])
|
|
375
|
+
|
|
376
|
+
const openLink = () => {
|
|
377
|
+
if (!editor) return
|
|
378
|
+
setLinkDraft((editor.getAttributes('link').href as string | undefined) ?? '')
|
|
379
|
+
setLinkError(null)
|
|
380
|
+
linkOpenRef.current = true
|
|
381
|
+
setLinkOpen(true)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const applyLink = () => {
|
|
385
|
+
if (!editor) return
|
|
386
|
+
if (linkDraft.trim() === '') {
|
|
387
|
+
editor.chain().focus().extendMarkRange('link').unsetLink().run()
|
|
388
|
+
closeLink()
|
|
389
|
+
return
|
|
390
|
+
}
|
|
391
|
+
const url = safeLinkUrl(linkDraft)
|
|
392
|
+
if (!url) return setLinkError(SAFE_LINK_HINT)
|
|
393
|
+
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
|
|
394
|
+
closeLink()
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return (
|
|
398
|
+
<div className={clsx('flex w-full flex-col gap-2', className)}>
|
|
399
|
+
{label ? (
|
|
400
|
+
<span id={labelId} className="text-small font-medium text-fg">
|
|
401
|
+
{label}
|
|
402
|
+
</span>
|
|
403
|
+
) : null}
|
|
404
|
+
<div className="relative rounded-lg border border-border bg-surface focus-within:border-brand-green">
|
|
405
|
+
{editor ? (
|
|
406
|
+
<BubbleMenu
|
|
407
|
+
editor={editor}
|
|
408
|
+
// Mirrors Tiptap's default (focus + non-empty selection), plus: stay up
|
|
409
|
+
// while the link panel owns focus, or typing a URL would dismiss itself.
|
|
410
|
+
shouldShow={({ editor, view, state, element }) => {
|
|
411
|
+
const inMenu = element.contains(document.activeElement)
|
|
412
|
+
if (!editor.isEditable || !(view.hasFocus() || inMenu)) return false
|
|
413
|
+
return linkOpenRef.current || !state.selection.empty
|
|
414
|
+
}}
|
|
415
|
+
className="flex flex-col gap-1 rounded-lg border border-border bg-surface-card p-1 shadow-md"
|
|
416
|
+
>
|
|
417
|
+
{linkOpen ? (
|
|
418
|
+
<>
|
|
419
|
+
<div className="flex items-center gap-1">
|
|
420
|
+
<input
|
|
421
|
+
ref={linkInputRef}
|
|
422
|
+
type="text"
|
|
423
|
+
value={linkDraft}
|
|
424
|
+
aria-label="Link URL"
|
|
425
|
+
aria-invalid={linkError ? true : undefined}
|
|
426
|
+
placeholder="https://, mailto:, /path"
|
|
427
|
+
className="h-8 w-56 rounded-md border border-border bg-surface px-2 text-small text-fg outline-none placeholder:text-fg-subtle focus:border-brand-green"
|
|
428
|
+
onChange={(e) => {
|
|
429
|
+
setLinkDraft(e.target.value)
|
|
430
|
+
setLinkError(null)
|
|
431
|
+
}}
|
|
432
|
+
onKeyDown={(e) => {
|
|
433
|
+
if (e.key === 'Enter') {
|
|
434
|
+
e.preventDefault()
|
|
435
|
+
applyLink()
|
|
436
|
+
}
|
|
437
|
+
if (e.key === 'Escape') {
|
|
438
|
+
e.preventDefault()
|
|
439
|
+
closeLink()
|
|
440
|
+
editor.chain().focus().run()
|
|
441
|
+
}
|
|
442
|
+
}}
|
|
443
|
+
/>
|
|
444
|
+
<button type="button" className={btn} onMouseDown={(e) => e.preventDefault()} onClick={applyLink} title="Apply link">
|
|
445
|
+
Apply
|
|
446
|
+
</button>
|
|
447
|
+
<button
|
|
448
|
+
type="button"
|
|
449
|
+
className={btn}
|
|
450
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
451
|
+
onClick={() => {
|
|
452
|
+
closeLink()
|
|
453
|
+
editor.chain().focus().run()
|
|
454
|
+
}}
|
|
455
|
+
title="Cancel"
|
|
456
|
+
>
|
|
457
|
+
Cancel
|
|
458
|
+
</button>
|
|
459
|
+
</div>
|
|
460
|
+
{linkError ? <span className="px-1 pb-0.5 text-caption text-danger">{linkError}</span> : null}
|
|
461
|
+
</>
|
|
462
|
+
) : (
|
|
463
|
+
<div className="flex items-center gap-0.5">
|
|
464
|
+
{MARK_TOOLS.map((tool) => (
|
|
465
|
+
// preventDefault on mousedown keeps the editor selection while the button is clicked.
|
|
466
|
+
<button
|
|
467
|
+
key={tool.key}
|
|
468
|
+
type="button"
|
|
469
|
+
className={btn}
|
|
470
|
+
data-active={active?.[tool.key] ?? false}
|
|
471
|
+
aria-pressed={active?.[tool.key] ?? false}
|
|
472
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
473
|
+
onClick={() => tool.run(editor)}
|
|
474
|
+
title={tool.title}
|
|
475
|
+
>
|
|
476
|
+
{tool.label}
|
|
477
|
+
</button>
|
|
478
|
+
))}
|
|
479
|
+
<button type="button" className={btn} data-active={active?.link ?? false} aria-pressed={active?.link ?? false} onMouseDown={(e) => e.preventDefault()} onClick={openLink} title="Link">
|
|
480
|
+
<LinkIcon />
|
|
481
|
+
</button>
|
|
482
|
+
</div>
|
|
483
|
+
)}
|
|
484
|
+
</BubbleMenu>
|
|
485
|
+
) : null}
|
|
486
|
+
|
|
487
|
+
<EditorContent editor={editor} />
|
|
488
|
+
|
|
489
|
+
{mounted && paletteOpen && slash
|
|
490
|
+
? createPortal(
|
|
491
|
+
<div
|
|
492
|
+
ref={menuRef}
|
|
493
|
+
id={listboxId}
|
|
494
|
+
role="listbox"
|
|
495
|
+
aria-label="Insert block"
|
|
496
|
+
className="fixed z-50 max-h-72 w-64 overflow-auto rounded-lg border border-border bg-surface-card p-1 shadow-md"
|
|
497
|
+
style={{ left: menuPos?.left ?? 0, top: menuPos?.top ?? 0, visibility: menuPos ? 'visible' : 'hidden' }}
|
|
498
|
+
>
|
|
499
|
+
{filtered.map((cmd, i) => (
|
|
500
|
+
// role=option must sit on a plain element — a <button> would override it.
|
|
501
|
+
// Focus stays in the editor; aria-activedescendant on the textbox drives AT.
|
|
502
|
+
<div
|
|
503
|
+
key={cmd.title}
|
|
504
|
+
id={optionId(i)}
|
|
505
|
+
role="option"
|
|
506
|
+
aria-selected={i === activeOption}
|
|
507
|
+
className={clsx('flex w-full cursor-pointer flex-col items-start rounded-md px-3 py-1.5 text-left', i === activeOption ? 'bg-surface-muted' : 'hover:bg-surface-muted')}
|
|
508
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
509
|
+
onClick={() => runSlash(cmd)}
|
|
510
|
+
>
|
|
511
|
+
<span className="text-small text-fg">{cmd.title}</span>
|
|
512
|
+
<span className="text-caption text-fg-subtle">{cmd.hint}</span>
|
|
513
|
+
</div>
|
|
514
|
+
))}
|
|
515
|
+
</div>,
|
|
516
|
+
document.body,
|
|
517
|
+
)
|
|
518
|
+
: null}
|
|
519
|
+
</div>
|
|
520
|
+
</div>
|
|
521
|
+
)
|
|
522
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Allow-list for a link href: http(s), mailto, tel, or a relative/anchor path.
|
|
2
|
+
* The prefix allow-list inherently rejects `javascript:`/`data:`/`vbscript:`.
|
|
3
|
+
*
|
|
4
|
+
* Shared by every editor surface that takes a user-typed URL (RichTextEditor's
|
|
5
|
+
* toolbar, BlockEditor's link panel) so the allow-list can never drift between them.
|
|
6
|
+
* This is a UI guard, NOT the security boundary — sanitise HTML on the SERVER on write. */
|
|
7
|
+
export function safeLinkUrl(raw: string): string | null {
|
|
8
|
+
const url = raw.trim()
|
|
9
|
+
return /^(https?:\/\/|mailto:|tel:|\/|#)/i.test(url) ? url : null
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The message shown when {@link safeLinkUrl} rejects an input. */
|
|
13
|
+
export const SAFE_LINK_HINT = 'Only http(s), mailto, tel, or relative (/, #) links are allowed.'
|
package/src/utils/uic.ts
CHANGED
|
@@ -58,12 +58,15 @@ const twMerge = extendTailwindMerge({
|
|
|
58
58
|
'caption',
|
|
59
59
|
'label',
|
|
60
60
|
'compact',
|
|
61
|
+
'small',
|
|
61
62
|
'callout',
|
|
62
63
|
'body',
|
|
63
64
|
'subtitle',
|
|
64
65
|
'title',
|
|
65
66
|
'headline',
|
|
66
67
|
'display',
|
|
68
|
+
'display-large',
|
|
69
|
+
'panel-heading',
|
|
67
70
|
// Heading ramp (size + line-height).
|
|
68
71
|
'heading1',
|
|
69
72
|
'heading2',
|
|
@@ -75,10 +78,15 @@ const twMerge = extendTailwindMerge({
|
|
|
75
78
|
],
|
|
76
79
|
},
|
|
77
80
|
theme: {
|
|
78
|
-
spacing: ['nav-x'],
|
|
81
|
+
spacing: ['nav-x', 'control-tall'],
|
|
79
82
|
// Custom card/panel radius key (rounded-panel) → dedupes against other
|
|
80
83
|
// rounded-* utilities. xl/2xl are stock keys tailwind-merge already knows.
|
|
81
84
|
radius: ['panel'],
|
|
85
|
+
// Custom blur/shadow keys, so `backdrop-blur-modal-backdrop` dedupes against
|
|
86
|
+
// a caller's `backdrop-blur-lg` (Dialog size="full" does exactly this) and
|
|
87
|
+
// `shadow-modal-surface` isn't misread as a shadow COLOR.
|
|
88
|
+
blur: ['modal-backdrop'],
|
|
89
|
+
shadow: ['modal-surface'],
|
|
82
90
|
},
|
|
83
91
|
},
|
|
84
92
|
})
|