@supatype/ui 0.1.0-alpha.9
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/.turbo/turbo-build.log +4 -0
- package/.turbo/turbo-typecheck.log +4 -0
- package/dist/Badge.d.ts +6 -0
- package/dist/Badge.d.ts.map +1 -0
- package/dist/Badge.js +12 -0
- package/dist/Badge.js.map +1 -0
- package/dist/Button.d.ts +7 -0
- package/dist/Button.d.ts.map +1 -0
- package/dist/Button.js +17 -0
- package/dist/Button.js.map +1 -0
- package/dist/Card.d.ts +6 -0
- package/dist/Card.d.ts.map +1 -0
- package/dist/Card.js +12 -0
- package/dist/Card.js.map +1 -0
- package/dist/Input.d.ts +7 -0
- package/dist/Input.d.ts.map +1 -0
- package/dist/Input.js +9 -0
- package/dist/Input.js.map +1 -0
- package/dist/RichTextEditor.d.ts +21 -0
- package/dist/RichTextEditor.d.ts.map +1 -0
- package/dist/RichTextEditor.js +100 -0
- package/dist/RichTextEditor.js.map +1 -0
- package/dist/Skeleton.d.ts +3 -0
- package/dist/Skeleton.d.ts.map +1 -0
- package/dist/Skeleton.js +6 -0
- package/dist/Skeleton.js.map +1 -0
- package/dist/ThemeProvider.d.ts +14 -0
- package/dist/ThemeProvider.d.ts.map +1 -0
- package/dist/ThemeProvider.js +45 -0
- package/dist/ThemeProvider.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/richtext-utils.d.ts +13 -0
- package/dist/richtext-utils.d.ts.map +1 -0
- package/dist/richtext-utils.js +109 -0
- package/dist/richtext-utils.js.map +1 -0
- package/package.json +53 -0
- package/src/Badge.tsx +27 -0
- package/src/Button.tsx +36 -0
- package/src/Card.tsx +29 -0
- package/src/Input.tsx +37 -0
- package/src/RichTextEditor.tsx +206 -0
- package/src/Skeleton.tsx +11 -0
- package/src/ThemeProvider.tsx +64 -0
- package/src/index.ts +22 -0
- package/src/richtext-utils.ts +112 -0
- package/tsconfig.json +11 -0
- package/tsconfig.tsbuildinfo +1 -0
package/src/Badge.tsx
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import { clsx } from "clsx"
|
|
3
|
+
|
|
4
|
+
export interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
|
|
5
|
+
variant?: "default" | "success" | "warning" | "danger" | "purple"
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const Badge: React.FC<BadgeProps> = ({ variant = "default", className, children, ...props }) => {
|
|
9
|
+
return (
|
|
10
|
+
<span
|
|
11
|
+
className={clsx(
|
|
12
|
+
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
|
|
13
|
+
{
|
|
14
|
+
"bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300": variant === "default",
|
|
15
|
+
"bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300": variant === "success",
|
|
16
|
+
"bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300": variant === "warning",
|
|
17
|
+
"bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300": variant === "danger",
|
|
18
|
+
"bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300": variant === "purple",
|
|
19
|
+
},
|
|
20
|
+
className,
|
|
21
|
+
)}
|
|
22
|
+
{...props}
|
|
23
|
+
>
|
|
24
|
+
{children}
|
|
25
|
+
</span>
|
|
26
|
+
)
|
|
27
|
+
}
|
package/src/Button.tsx
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import { clsx } from "clsx"
|
|
3
|
+
|
|
4
|
+
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
5
|
+
variant?: "primary" | "secondary" | "ghost" | "danger"
|
|
6
|
+
size?: "sm" | "md" | "lg"
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
10
|
+
({ variant = "primary", size = "md", className, children, ...props }, ref) => {
|
|
11
|
+
return (
|
|
12
|
+
<button
|
|
13
|
+
ref={ref}
|
|
14
|
+
className={clsx(
|
|
15
|
+
"inline-flex items-center justify-center rounded-lg font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
|
16
|
+
{
|
|
17
|
+
"bg-purple-600 text-white hover:bg-purple-700": variant === "primary",
|
|
18
|
+
"border border-neutral-300 bg-white text-neutral-900 hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100 dark:hover:bg-neutral-800": variant === "secondary",
|
|
19
|
+
"text-neutral-600 hover:bg-neutral-100 dark:text-neutral-400 dark:hover:bg-neutral-800": variant === "ghost",
|
|
20
|
+
"bg-red-600 text-white hover:bg-red-700": variant === "danger",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"h-8 px-3 text-sm": size === "sm",
|
|
24
|
+
"h-10 px-4 text-sm": size === "md",
|
|
25
|
+
"h-12 px-6 text-base": size === "lg",
|
|
26
|
+
},
|
|
27
|
+
className,
|
|
28
|
+
)}
|
|
29
|
+
{...props}
|
|
30
|
+
>
|
|
31
|
+
{children}
|
|
32
|
+
</button>
|
|
33
|
+
)
|
|
34
|
+
},
|
|
35
|
+
)
|
|
36
|
+
Button.displayName = "Button"
|
package/src/Card.tsx
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import { clsx } from "clsx"
|
|
3
|
+
|
|
4
|
+
export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
5
|
+
padding?: "sm" | "md" | "lg"
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const Card = React.forwardRef<HTMLDivElement, CardProps>(
|
|
9
|
+
({ padding = "md", className, children, ...props }, ref) => {
|
|
10
|
+
return (
|
|
11
|
+
<div
|
|
12
|
+
ref={ref}
|
|
13
|
+
className={clsx(
|
|
14
|
+
"rounded-xl border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900",
|
|
15
|
+
{
|
|
16
|
+
"p-4": padding === "sm",
|
|
17
|
+
"p-6": padding === "md",
|
|
18
|
+
"p-8": padding === "lg",
|
|
19
|
+
},
|
|
20
|
+
className,
|
|
21
|
+
)}
|
|
22
|
+
{...props}
|
|
23
|
+
>
|
|
24
|
+
{children}
|
|
25
|
+
</div>
|
|
26
|
+
)
|
|
27
|
+
},
|
|
28
|
+
)
|
|
29
|
+
Card.displayName = "Card"
|
package/src/Input.tsx
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import { clsx } from "clsx"
|
|
3
|
+
|
|
4
|
+
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
|
5
|
+
label?: string
|
|
6
|
+
error?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
10
|
+
({ label, error, className, id, ...props }, ref) => {
|
|
11
|
+
const inputId = id ?? label?.toLowerCase().replace(/\s+/g, "-")
|
|
12
|
+
return (
|
|
13
|
+
<div className="flex flex-col gap-1.5">
|
|
14
|
+
{label && (
|
|
15
|
+
<label htmlFor={inputId} className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
|
16
|
+
{label}
|
|
17
|
+
</label>
|
|
18
|
+
)}
|
|
19
|
+
<input
|
|
20
|
+
ref={ref}
|
|
21
|
+
id={inputId}
|
|
22
|
+
className={clsx(
|
|
23
|
+
"h-10 rounded-lg border px-3 text-sm transition-colors",
|
|
24
|
+
"border-neutral-300 bg-white text-neutral-900 placeholder:text-neutral-500",
|
|
25
|
+
"dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100 dark:placeholder:text-neutral-500",
|
|
26
|
+
"focus:border-purple-500 focus:outline-none focus:ring-1 focus:ring-purple-500",
|
|
27
|
+
error && "border-red-500 focus:border-red-500 focus:ring-red-500",
|
|
28
|
+
className,
|
|
29
|
+
)}
|
|
30
|
+
{...props}
|
|
31
|
+
/>
|
|
32
|
+
{error && <p className="text-sm text-red-500">{error}</p>}
|
|
33
|
+
</div>
|
|
34
|
+
)
|
|
35
|
+
},
|
|
36
|
+
)
|
|
37
|
+
Input.displayName = "Input"
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import React, { useCallback, useEffect, useMemo, useRef } from "react"
|
|
4
|
+
import type { SerializedEditorState } from "@supatype/types/lexical"
|
|
5
|
+
import { LexicalComposer } from "@lexical/react/LexicalComposer"
|
|
6
|
+
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"
|
|
7
|
+
import { ContentEditable } from "@lexical/react/LexicalContentEditable"
|
|
8
|
+
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"
|
|
9
|
+
import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"
|
|
10
|
+
import { ListPlugin } from "@lexical/react/LexicalListPlugin"
|
|
11
|
+
import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin"
|
|
12
|
+
import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"
|
|
13
|
+
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
|
|
14
|
+
import {
|
|
15
|
+
$getSelection,
|
|
16
|
+
$isRangeSelection,
|
|
17
|
+
FORMAT_TEXT_COMMAND,
|
|
18
|
+
type EditorState,
|
|
19
|
+
type LexicalEditor,
|
|
20
|
+
type TextFormatType,
|
|
21
|
+
} from "lexical"
|
|
22
|
+
import { $createHeadingNode, HeadingNode, QuoteNode } from "@lexical/rich-text"
|
|
23
|
+
import { ListNode, ListItemNode, INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND } from "@lexical/list"
|
|
24
|
+
import { AutoLinkNode, LinkNode } from "@lexical/link"
|
|
25
|
+
import { $setBlocksType } from "@lexical/selection"
|
|
26
|
+
import { $createParagraphNode, $getRoot } from "lexical"
|
|
27
|
+
import clsx from "clsx"
|
|
28
|
+
|
|
29
|
+
const EDITOR_NODES = [HeadingNode, QuoteNode, ListNode, ListItemNode, LinkNode, AutoLinkNode]
|
|
30
|
+
|
|
31
|
+
const EDITOR_THEME = {
|
|
32
|
+
heading: { h1: "su-rt-h1", h2: "su-rt-h2", h3: "su-rt-h3" },
|
|
33
|
+
text: { bold: "su-rt-bold", italic: "su-rt-italic", underline: "su-rt-underline", strikethrough: "su-rt-strikethrough" },
|
|
34
|
+
list: { ul: "su-rt-ul", ol: "su-rt-ol", listitem: "su-rt-li" },
|
|
35
|
+
link: "su-rt-link",
|
|
36
|
+
paragraph: "su-rt-p",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function ToolbarPlugin(): React.ReactElement {
|
|
40
|
+
const [editor] = useLexicalComposerContext()
|
|
41
|
+
|
|
42
|
+
const format = useCallback((fmt: TextFormatType) => {
|
|
43
|
+
editor.dispatchCommand(FORMAT_TEXT_COMMAND, fmt)
|
|
44
|
+
}, [editor])
|
|
45
|
+
|
|
46
|
+
const setHeading = useCallback((tag: "h1" | "h2" | "h3") => {
|
|
47
|
+
editor.update(() => {
|
|
48
|
+
const selection = $getSelection()
|
|
49
|
+
if ($isRangeSelection(selection)) {
|
|
50
|
+
$setBlocksType(selection, () => $createHeadingNode(tag))
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
}, [editor])
|
|
54
|
+
|
|
55
|
+
const setParagraph = useCallback(() => {
|
|
56
|
+
editor.update(() => {
|
|
57
|
+
const selection = $getSelection()
|
|
58
|
+
if ($isRangeSelection(selection)) {
|
|
59
|
+
$setBlocksType(selection, () => $createParagraphNode())
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
}, [editor])
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div className="su-richtext-toolbar" role="toolbar" aria-label="Formatting">
|
|
66
|
+
<button type="button" title="Bold" onMouseDown={(e) => { e.preventDefault(); format("bold") }}>
|
|
67
|
+
B
|
|
68
|
+
</button>
|
|
69
|
+
<button type="button" title="Italic" onMouseDown={(e) => { e.preventDefault(); format("italic") }}>
|
|
70
|
+
I
|
|
71
|
+
</button>
|
|
72
|
+
<button type="button" title="Underline" onMouseDown={(e) => { e.preventDefault(); format("underline") }}>
|
|
73
|
+
U
|
|
74
|
+
</button>
|
|
75
|
+
<span className="su-richtext-toolbar-divider" aria-hidden />
|
|
76
|
+
<button type="button" title="Heading 1" onMouseDown={(e) => { e.preventDefault(); setHeading("h1") }}>
|
|
77
|
+
H1
|
|
78
|
+
</button>
|
|
79
|
+
<button type="button" title="Heading 2" onMouseDown={(e) => { e.preventDefault(); setHeading("h2") }}>
|
|
80
|
+
H2
|
|
81
|
+
</button>
|
|
82
|
+
<button type="button" title="Paragraph" onMouseDown={(e) => { e.preventDefault(); setParagraph() }}>
|
|
83
|
+
¶
|
|
84
|
+
</button>
|
|
85
|
+
<span className="su-richtext-toolbar-divider" aria-hidden />
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
title="Bullet list"
|
|
89
|
+
onMouseDown={(e) => {
|
|
90
|
+
e.preventDefault()
|
|
91
|
+
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined)
|
|
92
|
+
}}
|
|
93
|
+
>
|
|
94
|
+
•
|
|
95
|
+
</button>
|
|
96
|
+
<button
|
|
97
|
+
type="button"
|
|
98
|
+
title="Numbered list"
|
|
99
|
+
onMouseDown={(e) => {
|
|
100
|
+
e.preventDefault()
|
|
101
|
+
editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined)
|
|
102
|
+
}}
|
|
103
|
+
>
|
|
104
|
+
1.
|
|
105
|
+
</button>
|
|
106
|
+
</div>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function InitialStatePlugin({ value }: { value: SerializedEditorState | null | undefined }): null {
|
|
111
|
+
const [editor] = useLexicalComposerContext()
|
|
112
|
+
const loaded = useRef(false)
|
|
113
|
+
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
if (loaded.current) return
|
|
116
|
+
loaded.current = true
|
|
117
|
+
if (value !== null && value !== undefined && typeof value === "object") {
|
|
118
|
+
try {
|
|
119
|
+
const state = editor.parseEditorState(JSON.stringify(value))
|
|
120
|
+
editor.setEditorState(state)
|
|
121
|
+
} catch {
|
|
122
|
+
editor.update(() => {
|
|
123
|
+
const root = $getRoot()
|
|
124
|
+
root.clear()
|
|
125
|
+
root.append($createParagraphNode())
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}, [editor, value])
|
|
130
|
+
|
|
131
|
+
return null
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface RichTextEditorProps {
|
|
135
|
+
/** Initial document (Lexical JSON). Updates only on mount; use `documentKey` to reload. */
|
|
136
|
+
value?: SerializedEditorState | null | undefined
|
|
137
|
+
onChange: (json: SerializedEditorState) => void
|
|
138
|
+
placeholder?: string | undefined
|
|
139
|
+
className?: string | undefined
|
|
140
|
+
editable?: boolean | undefined
|
|
141
|
+
/** Change when switching documents so Lexical remounts with new `value`. */
|
|
142
|
+
documentKey?: string | undefined
|
|
143
|
+
/** `id` on the editable surface (e.g. for `<label htmlFor>`). */
|
|
144
|
+
contentEditableId?: string | undefined
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Lexical-based rich text editor aligned with `@supatype/types` `RichText` / `SerializedEditorState`.
|
|
149
|
+
*
|
|
150
|
+
* Peer dependencies: `lexical`, `@lexical/react`, `@lexical/rich-text`, `@lexical/list`, `@lexical/link`, `@lexical/selection`.
|
|
151
|
+
*/
|
|
152
|
+
export function RichTextEditor({
|
|
153
|
+
value,
|
|
154
|
+
onChange,
|
|
155
|
+
placeholder = "Write something…",
|
|
156
|
+
className,
|
|
157
|
+
editable = true,
|
|
158
|
+
documentKey = "default",
|
|
159
|
+
contentEditableId,
|
|
160
|
+
}: RichTextEditorProps): React.ReactElement {
|
|
161
|
+
const initialConfig = useMemo(
|
|
162
|
+
() => ({
|
|
163
|
+
namespace: `supatype-richtext-${documentKey}`,
|
|
164
|
+
theme: EDITOR_THEME,
|
|
165
|
+
nodes: EDITOR_NODES,
|
|
166
|
+
editable,
|
|
167
|
+
onError: (err: Error) => {
|
|
168
|
+
console.error("[RichTextEditor]", err)
|
|
169
|
+
},
|
|
170
|
+
}),
|
|
171
|
+
[documentKey, editable],
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
const handleChange = useCallback(
|
|
175
|
+
(editorState: EditorState, _editor: LexicalEditor) => {
|
|
176
|
+
onChange(editorState.toJSON() as SerializedEditorState)
|
|
177
|
+
},
|
|
178
|
+
[onChange],
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<div className={clsx("su-richtext", className, !editable && "su-richtext-readonly")}>
|
|
183
|
+
<LexicalComposer key={documentKey} initialConfig={initialConfig}>
|
|
184
|
+
{editable ? <ToolbarPlugin /> : null}
|
|
185
|
+
<InitialStatePlugin value={value} />
|
|
186
|
+
<div className="su-richtext-surface">
|
|
187
|
+
<RichTextPlugin
|
|
188
|
+
contentEditable={
|
|
189
|
+
<ContentEditable
|
|
190
|
+
id={contentEditableId}
|
|
191
|
+
className="su-richtext-editable"
|
|
192
|
+
aria-label={placeholder}
|
|
193
|
+
/>
|
|
194
|
+
}
|
|
195
|
+
placeholder={<div className="su-richtext-placeholder">{placeholder}</div>}
|
|
196
|
+
ErrorBoundary={LexicalErrorBoundary}
|
|
197
|
+
/>
|
|
198
|
+
</div>
|
|
199
|
+
<HistoryPlugin />
|
|
200
|
+
<ListPlugin />
|
|
201
|
+
<LinkPlugin />
|
|
202
|
+
<OnChangePlugin onChange={handleChange} ignoreSelectionChange />
|
|
203
|
+
</LexicalComposer>
|
|
204
|
+
</div>
|
|
205
|
+
)
|
|
206
|
+
}
|
package/src/Skeleton.tsx
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import { clsx } from "clsx"
|
|
3
|
+
|
|
4
|
+
export const Skeleton: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => {
|
|
5
|
+
return (
|
|
6
|
+
<div
|
|
7
|
+
className={clsx("animate-pulse rounded-md bg-neutral-200 dark:bg-neutral-800", className)}
|
|
8
|
+
{...props}
|
|
9
|
+
/>
|
|
10
|
+
)
|
|
11
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import React, { createContext, useContext, useEffect, useState } from "react"
|
|
2
|
+
|
|
3
|
+
type Theme = "light" | "dark" | "system"
|
|
4
|
+
|
|
5
|
+
interface ThemeContextValue {
|
|
6
|
+
theme: Theme
|
|
7
|
+
resolvedTheme: "light" | "dark"
|
|
8
|
+
setTheme: (theme: Theme) => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const ThemeContext = createContext<ThemeContextValue | null>(null)
|
|
12
|
+
|
|
13
|
+
export function useTheme(): ThemeContextValue {
|
|
14
|
+
const ctx = useContext(ThemeContext)
|
|
15
|
+
if (!ctx) throw new Error("useTheme must be used within a ThemeProvider")
|
|
16
|
+
return ctx
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getSystemTheme(): "light" | "dark" {
|
|
20
|
+
if (typeof window === "undefined") return "dark"
|
|
21
|
+
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const ThemeProvider: React.FC<{ defaultTheme?: Theme; children: React.ReactNode }> = ({
|
|
25
|
+
defaultTheme = "system",
|
|
26
|
+
children,
|
|
27
|
+
}) => {
|
|
28
|
+
const [theme, setThemeState] = useState<Theme>(() => {
|
|
29
|
+
if (typeof window === "undefined") return defaultTheme
|
|
30
|
+
return (localStorage.getItem("supatype-theme") as Theme) ?? defaultTheme
|
|
31
|
+
})
|
|
32
|
+
const [resolvedTheme, setResolved] = useState<"light" | "dark">(() =>
|
|
33
|
+
theme === "system" ? getSystemTheme() : theme,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const resolved = theme === "system" ? getSystemTheme() : theme
|
|
38
|
+
setResolved(resolved)
|
|
39
|
+
document.documentElement.classList.toggle("dark", resolved === "dark")
|
|
40
|
+
}, [theme])
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (theme !== "system") return
|
|
44
|
+
const mq = window.matchMedia("(prefers-color-scheme: dark)")
|
|
45
|
+
const handler = () => {
|
|
46
|
+
const resolved = getSystemTheme()
|
|
47
|
+
setResolved(resolved)
|
|
48
|
+
document.documentElement.classList.toggle("dark", resolved === "dark")
|
|
49
|
+
}
|
|
50
|
+
mq.addEventListener("change", handler)
|
|
51
|
+
return () => mq.removeEventListener("change", handler)
|
|
52
|
+
}, [theme])
|
|
53
|
+
|
|
54
|
+
const setTheme = (t: Theme) => {
|
|
55
|
+
setThemeState(t)
|
|
56
|
+
localStorage.setItem("supatype-theme", t)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme }}>
|
|
61
|
+
{children}
|
|
62
|
+
</ThemeContext.Provider>
|
|
63
|
+
)
|
|
64
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// @supatype/ui — Shared component library
|
|
2
|
+
|
|
3
|
+
export { Button } from "./Button.js"
|
|
4
|
+
export type { ButtonProps } from "./Button.js"
|
|
5
|
+
|
|
6
|
+
export { Input } from "./Input.js"
|
|
7
|
+
export type { InputProps } from "./Input.js"
|
|
8
|
+
|
|
9
|
+
export { Card } from "./Card.js"
|
|
10
|
+
export type { CardProps } from "./Card.js"
|
|
11
|
+
|
|
12
|
+
export { Badge } from "./Badge.js"
|
|
13
|
+
export type { BadgeProps } from "./Badge.js"
|
|
14
|
+
|
|
15
|
+
export { Skeleton } from "./Skeleton.js"
|
|
16
|
+
|
|
17
|
+
export { ThemeProvider, useTheme } from "./ThemeProvider.js"
|
|
18
|
+
|
|
19
|
+
export { RichTextEditor } from "./RichTextEditor.js"
|
|
20
|
+
export type { RichTextEditorProps } from "./RichTextEditor.js"
|
|
21
|
+
export { emptyRichTextDocument, normalizeRichTextDefault, richTextIsEmpty, stringToRichTextDocument } from "./richtext-utils.js"
|
|
22
|
+
export type { SerializedEditorState, SerializedLexicalNode } from "@supatype/types/lexical"
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { SerializedEditorState, SerializedLexicalNode } from "@supatype/types/lexical"
|
|
2
|
+
|
|
3
|
+
/** Lexical-compatible empty document (one empty paragraph). */
|
|
4
|
+
export function emptyRichTextDocument(): SerializedEditorState {
|
|
5
|
+
return {
|
|
6
|
+
root: {
|
|
7
|
+
type: "root",
|
|
8
|
+
format: "",
|
|
9
|
+
indent: 0,
|
|
10
|
+
version: 1,
|
|
11
|
+
children: [
|
|
12
|
+
{
|
|
13
|
+
type: "paragraph",
|
|
14
|
+
format: "",
|
|
15
|
+
indent: 0,
|
|
16
|
+
version: 1,
|
|
17
|
+
children: [
|
|
18
|
+
{
|
|
19
|
+
type: "text",
|
|
20
|
+
text: "",
|
|
21
|
+
format: 0,
|
|
22
|
+
style: "",
|
|
23
|
+
mode: "normal",
|
|
24
|
+
detail: 0,
|
|
25
|
+
version: 1,
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Plain sentence → minimal Lexical document (single paragraph). Not HTML — literal text only. */
|
|
35
|
+
export function stringToRichTextDocument(text: string): SerializedEditorState {
|
|
36
|
+
return {
|
|
37
|
+
root: {
|
|
38
|
+
type: "root",
|
|
39
|
+
format: "",
|
|
40
|
+
indent: 0,
|
|
41
|
+
version: 1,
|
|
42
|
+
children: [
|
|
43
|
+
{
|
|
44
|
+
type: "paragraph",
|
|
45
|
+
format: "",
|
|
46
|
+
indent: 0,
|
|
47
|
+
version: 1,
|
|
48
|
+
children: [
|
|
49
|
+
{
|
|
50
|
+
type: "text",
|
|
51
|
+
text,
|
|
52
|
+
format: 0,
|
|
53
|
+
style: "",
|
|
54
|
+
mode: "normal",
|
|
55
|
+
detail: 0,
|
|
56
|
+
version: 1,
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isLexicalDocument(value: unknown): value is SerializedEditorState {
|
|
66
|
+
return (
|
|
67
|
+
typeof value === "object" &&
|
|
68
|
+
value !== null &&
|
|
69
|
+
"root" in value &&
|
|
70
|
+
typeof (value as SerializedEditorState).root === "object"
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Normalize a RichText default or seed value: Lexical object passthrough, JSON Lexical string, or plain string → Lexical.
|
|
76
|
+
* Does not parse HTML.
|
|
77
|
+
*/
|
|
78
|
+
export function normalizeRichTextDefault(
|
|
79
|
+
value: SerializedEditorState | string | null | undefined,
|
|
80
|
+
): SerializedEditorState | null {
|
|
81
|
+
if (value === null || value === undefined) return null
|
|
82
|
+
if (isLexicalDocument(value)) return value
|
|
83
|
+
if (typeof value === "string") {
|
|
84
|
+
const trimmed = value.trim()
|
|
85
|
+
if (trimmed.startsWith("{")) {
|
|
86
|
+
try {
|
|
87
|
+
const parsed = JSON.parse(trimmed) as unknown
|
|
88
|
+
if (isLexicalDocument(parsed)) return parsed
|
|
89
|
+
} catch {
|
|
90
|
+
// fall through — treat as plain text
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return stringToRichTextDocument(value)
|
|
94
|
+
}
|
|
95
|
+
return null
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function collectText(node: SerializedLexicalNode): string {
|
|
99
|
+
let out = ""
|
|
100
|
+
if (typeof node.text === "string") out += node.text
|
|
101
|
+
const children = node.children
|
|
102
|
+
if (Array.isArray(children)) {
|
|
103
|
+
for (const c of children) out += collectText(c as SerializedLexicalNode)
|
|
104
|
+
}
|
|
105
|
+
return out
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** True when the editor has no visible text (whitespace-only counts as empty). */
|
|
109
|
+
export function richTextIsEmpty(state: SerializedEditorState | null | undefined): boolean {
|
|
110
|
+
if (state === null || state === undefined) return true
|
|
111
|
+
return collectText(state.root).trim() === ""
|
|
112
|
+
}
|