proje-react-panel 1.7.0 → 1.9.0
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/.vscode/settings.json +1 -1
- package/README.md +43 -3
- package/dist/__tests__/components/form/CustomField.test.d.ts +4 -0
- package/dist/__tests__/components/form/FormGroups.test.d.ts +1 -0
- package/dist/__tests__/components/form/RichTextField.test.d.ts +4 -0
- package/dist/__tests__/optionalTiptap.test.d.ts +4 -0
- package/dist/components/form/CustomField.d.ts +9 -0
- package/dist/components/form/RichTextField.d.ts +7 -0
- package/dist/decorators/form/Form.d.ts +8 -0
- package/dist/decorators/form/Input.d.ts +5 -1
- package/dist/decorators/form/inputs/CustomInput.d.ts +16 -0
- package/dist/decorators/form/inputs/RichTextInput.d.ts +13 -0
- package/dist/index.cjs.js +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.esm.js +2 -2
- package/{DASHBOARD_GUIDE.md → guides/DASHBOARD_GUIDE.md} +1 -1
- package/guides/USAGE.md +85 -0
- package/how_to.md +302 -0
- package/jest.config.js +1 -0
- package/package.json +17 -1
- package/src/__tests__/components/form/CustomField.test.tsx +92 -0
- package/src/__tests__/components/form/FormGroups.test.tsx +194 -0
- package/src/__tests__/components/form/RichTextField.test.tsx +181 -0
- package/src/__tests__/optionalTiptap.test.tsx +89 -0
- package/src/components/form/CustomField.tsx +34 -0
- package/src/components/form/FormField.tsx +13 -2
- package/src/components/form/InnerForm.tsx +101 -19
- package/src/components/form/RichTextField.tsx +218 -0
- package/src/components/layout/Layout.tsx +18 -2
- package/src/components/list/ListPage.tsx +1 -1
- package/src/decorators/form/Form.ts +11 -0
- package/src/decorators/form/Input.ts +6 -1
- package/src/decorators/form/inputs/CustomInput.ts +26 -0
- package/src/decorators/form/inputs/RichTextInput.ts +38 -0
- package/src/index.ts +13 -1
- package/src/styles/components/rich-text.scss +115 -0
- package/src/styles/form.scss +48 -0
- package/src/styles/index.scss +1 -0
- package/dist/components/DashboardContainer.d.ts +0 -7
- package/dist/components/DashboardGrid.d.ts +0 -9
- package/dist/components/DashboardItem.d.ts +0 -10
- package/dist/components/ThemeSwitcher.d.ts +0 -7
- package/dist/store/themeStore.d.ts +0 -23
- /package/{AUTH_LAYOUT_EXAMPLE.md → guides/AUTH_LAYOUT_EXAMPLE.md} +0 -0
- /package/{AUTH_LAYOUT_GUIDE.md → guides/AUTH_LAYOUT_GUIDE.md} +0 -0
- /package/{COLOR_SYSTEM_GUIDE.md → guides/COLOR_SYSTEM_GUIDE.md} +0 -0
- /package/{IMPLEMENTATION_GUIDE.md → guides/IMPLEMENTATION_GUIDE.md} +0 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { useController, useFormContext } from 'react-hook-form';
|
|
3
|
+
import type { Editor } from '@tiptap/react';
|
|
4
|
+
import { InputConfiguration } from '../../decorators/form/Input';
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_RICH_TEXT_TOOLBAR,
|
|
7
|
+
RichTextInputConfiguration,
|
|
8
|
+
RichTextToolbarItem,
|
|
9
|
+
} from '../../decorators/form/inputs/RichTextInput';
|
|
10
|
+
|
|
11
|
+
const DEFAULT_MIN_HEIGHT = 160;
|
|
12
|
+
|
|
13
|
+
export interface RichTextFieldProps {
|
|
14
|
+
input: InputConfiguration;
|
|
15
|
+
fieldName: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface Tiptap {
|
|
19
|
+
react: typeof import('@tiptap/react');
|
|
20
|
+
starterKit: (typeof import('@tiptap/starter-kit'))['default'];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
//NOTE: @tiptap/* is an optional peer dependency, so it may only be reached through a dynamic
|
|
24
|
+
// import — a static one would put ~100kb (and a hard install requirement) on every panel,
|
|
25
|
+
// including the ones that never declare a richtext field. The promise is cached at module level
|
|
26
|
+
// so a form with several richtext fields loads the editor once.
|
|
27
|
+
let tiptapPromise: Promise<Tiptap> | null = null;
|
|
28
|
+
|
|
29
|
+
function loadTiptap(): Promise<Tiptap> {
|
|
30
|
+
if (!tiptapPromise) {
|
|
31
|
+
const pending = Promise.all([import('@tiptap/react'), import('@tiptap/starter-kit')]).then(
|
|
32
|
+
([react, starterKit]) => ({ react, starterKit: starterKit.default })
|
|
33
|
+
);
|
|
34
|
+
// A failed load is not cached: a chunk that failed to arrive should be retried on the next
|
|
35
|
+
// mount instead of leaving the field broken for the rest of the session.
|
|
36
|
+
pending.catch(() => {
|
|
37
|
+
tiptapPromise = null;
|
|
38
|
+
});
|
|
39
|
+
tiptapPromise = pending;
|
|
40
|
+
}
|
|
41
|
+
return tiptapPromise;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface ToolbarButton {
|
|
45
|
+
label: string;
|
|
46
|
+
title: string;
|
|
47
|
+
isActive: (editor: Editor) => boolean;
|
|
48
|
+
run: (editor: Editor) => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const TOOLBAR_BUTTONS: Record<RichTextToolbarItem, ToolbarButton> = {
|
|
52
|
+
bold: {
|
|
53
|
+
label: 'B',
|
|
54
|
+
title: 'Bold',
|
|
55
|
+
isActive: editor => editor.isActive('bold'),
|
|
56
|
+
run: editor => editor.chain().focus().toggleBold().run(),
|
|
57
|
+
},
|
|
58
|
+
italic: {
|
|
59
|
+
label: 'I',
|
|
60
|
+
title: 'Italic',
|
|
61
|
+
isActive: editor => editor.isActive('italic'),
|
|
62
|
+
run: editor => editor.chain().focus().toggleItalic().run(),
|
|
63
|
+
},
|
|
64
|
+
heading: {
|
|
65
|
+
label: 'H2',
|
|
66
|
+
title: 'Heading',
|
|
67
|
+
isActive: editor => editor.isActive('heading', { level: 2 }),
|
|
68
|
+
run: editor => editor.chain().focus().toggleHeading({ level: 2 }).run(),
|
|
69
|
+
},
|
|
70
|
+
bulletList: {
|
|
71
|
+
label: '••',
|
|
72
|
+
title: 'Bullet list',
|
|
73
|
+
isActive: editor => editor.isActive('bulletList'),
|
|
74
|
+
run: editor => editor.chain().focus().toggleBulletList().run(),
|
|
75
|
+
},
|
|
76
|
+
orderedList: {
|
|
77
|
+
label: '1.',
|
|
78
|
+
title: 'Numbered list',
|
|
79
|
+
isActive: editor => editor.isActive('orderedList'),
|
|
80
|
+
run: editor => editor.chain().focus().toggleOrderedList().run(),
|
|
81
|
+
},
|
|
82
|
+
link: {
|
|
83
|
+
label: '🔗',
|
|
84
|
+
title: 'Link',
|
|
85
|
+
isActive: editor => editor.isActive('link'),
|
|
86
|
+
run: editor => {
|
|
87
|
+
const current = editor.getAttributes('link').href as string | undefined;
|
|
88
|
+
const url = window.prompt('Link URL', current ?? 'https://');
|
|
89
|
+
//NOTE: null is "cancelled", empty string is "remove the link".
|
|
90
|
+
if (url === null) return;
|
|
91
|
+
if (url === '') {
|
|
92
|
+
editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export function RichTextField({ input, fieldName }: RichTextFieldProps) {
|
|
101
|
+
const [tiptap, setTiptap] = useState<Tiptap | null>(null);
|
|
102
|
+
const [loadFailed, setLoadFailed] = useState(false);
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
let active = true;
|
|
106
|
+
loadTiptap().then(
|
|
107
|
+
modules => {
|
|
108
|
+
if (active) setTiptap(modules);
|
|
109
|
+
},
|
|
110
|
+
(error: unknown) => {
|
|
111
|
+
console.error(
|
|
112
|
+
"proje-react-panel: a 'richtext' input needs @tiptap/react and @tiptap/starter-kit installed.",
|
|
113
|
+
error
|
|
114
|
+
);
|
|
115
|
+
if (active) setLoadFailed(true);
|
|
116
|
+
}
|
|
117
|
+
);
|
|
118
|
+
return () => {
|
|
119
|
+
active = false;
|
|
120
|
+
};
|
|
121
|
+
}, []);
|
|
122
|
+
|
|
123
|
+
if (loadFailed) {
|
|
124
|
+
return (
|
|
125
|
+
<div className="rich-text-missing" role="alert">
|
|
126
|
+
Rich text editor unavailable — install @tiptap/react and @tiptap/starter-kit.
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!tiptap) {
|
|
132
|
+
return <div className="rich-text-loading">Loading editor…</div>;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return <RichTextEditor input={input} fieldName={fieldName} tiptap={tiptap} />;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function RichTextEditor({ input, fieldName, tiptap }: RichTextFieldProps & { tiptap: Tiptap }) {
|
|
139
|
+
const richInput = input as RichTextInputConfiguration;
|
|
140
|
+
// Hooks off a dynamically loaded module: safe because the module object is cached, so the
|
|
141
|
+
// hook identity never changes between renders of this component.
|
|
142
|
+
const { EditorContent, useEditor } = tiptap.react;
|
|
143
|
+
const { control } = useFormContext();
|
|
144
|
+
//NOTE: register() would bind the value to a DOM node; TipTap owns an uncontrolled
|
|
145
|
+
// contenteditable of its own, so the value goes through the controller instead.
|
|
146
|
+
const { field } = useController({ name: fieldName, control });
|
|
147
|
+
const toolbar = richInput.toolbar ?? DEFAULT_RICH_TEXT_TOOLBAR;
|
|
148
|
+
const incomingHtml = typeof field.value === 'string' ? field.value : '';
|
|
149
|
+
|
|
150
|
+
// The HTML the editor last emitted or received. The outside -> editor sync compares against
|
|
151
|
+
// this instead of the previous prop value: while the user types, react-hook-form hands our
|
|
152
|
+
// own HTML straight back, and calling setContent for that would jump the caret to the start.
|
|
153
|
+
const editorHtmlRef = useRef<string>(incomingHtml);
|
|
154
|
+
|
|
155
|
+
// onUpdate is captured when the editor is created; the ref keeps it pointed at the live handler.
|
|
156
|
+
const onChangeRef = useRef(field.onChange);
|
|
157
|
+
onChangeRef.current = field.onChange;
|
|
158
|
+
|
|
159
|
+
const editor = useEditor({
|
|
160
|
+
extensions: [tiptap.starterKit],
|
|
161
|
+
content: editorHtmlRef.current,
|
|
162
|
+
//NOTE: toolbar highlighting reads editor state, so the field has to re-render with it.
|
|
163
|
+
shouldRerenderOnTransaction: true,
|
|
164
|
+
onUpdate: ({ editor: updatedEditor }) => {
|
|
165
|
+
// An empty document still serializes to '<p></p>'; report it as empty so required
|
|
166
|
+
// validators (@IsNotEmpty and friends) see what the user actually sees.
|
|
167
|
+
const html = updatedEditor.isEmpty ? '' : updatedEditor.getHTML();
|
|
168
|
+
editorHtmlRef.current = html;
|
|
169
|
+
onChangeRef.current(html);
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
if (!editor) return;
|
|
175
|
+
// getDetailsData resolves after the form mounts, so the value can arrive late — but only
|
|
176
|
+
// replace the document when it really differs from what the editor already holds.
|
|
177
|
+
if (incomingHtml === editorHtmlRef.current) return;
|
|
178
|
+
editorHtmlRef.current = incomingHtml;
|
|
179
|
+
editor.commands.setContent(incomingHtml, { emitUpdate: false });
|
|
180
|
+
}, [editor, incomingHtml]);
|
|
181
|
+
|
|
182
|
+
const buttons = useMemo(
|
|
183
|
+
() => toolbar.map(item => ({ item, ...TOOLBAR_BUTTONS[item] })),
|
|
184
|
+
[toolbar]
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<div className="rich-text-field">
|
|
189
|
+
<div className="rich-text-toolbar" role="toolbar" aria-label={`${input.label ?? fieldName}`}>
|
|
190
|
+
{buttons.map(button => {
|
|
191
|
+
const active = editor ? button.isActive(editor) : false;
|
|
192
|
+
return (
|
|
193
|
+
<button
|
|
194
|
+
key={button.item}
|
|
195
|
+
type="button"
|
|
196
|
+
title={button.title}
|
|
197
|
+
aria-label={button.title}
|
|
198
|
+
aria-pressed={active}
|
|
199
|
+
disabled={!editor}
|
|
200
|
+
className={`rich-text-toolbar-button${active ? ' is-active' : ''}`}
|
|
201
|
+
onClick={() => editor && button.run(editor)}
|
|
202
|
+
>
|
|
203
|
+
{button.label}
|
|
204
|
+
</button>
|
|
205
|
+
);
|
|
206
|
+
})}
|
|
207
|
+
</div>
|
|
208
|
+
<EditorContent
|
|
209
|
+
editor={editor}
|
|
210
|
+
id={fieldName}
|
|
211
|
+
data-testid={`rich-text-${fieldName}`}
|
|
212
|
+
data-placeholder={input.placeholder}
|
|
213
|
+
className={`rich-text-content${!editor || editor.isEmpty ? ' is-empty' : ''}`}
|
|
214
|
+
style={{ minHeight: `${richInput.minHeight ?? DEFAULT_MIN_HEIGHT}px` }}
|
|
215
|
+
/>
|
|
216
|
+
</div>
|
|
217
|
+
);
|
|
218
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import React from 'react';
|
|
1
|
+
import React, { useEffect, useRef } from 'react';
|
|
2
2
|
import { SideBar } from './SideBar';
|
|
3
|
+
import { LoadingScreen } from '../LoadingScreen';
|
|
3
4
|
import { useAppStore } from '../../store/store';
|
|
4
5
|
|
|
5
6
|
export function Layout<IconType>({
|
|
@@ -16,8 +17,23 @@ export function Layout<IconType>({
|
|
|
16
17
|
const { user } = useAppStore(s => ({
|
|
17
18
|
user: s.user,
|
|
18
19
|
}));
|
|
19
|
-
|
|
20
|
+
const redirected = useRef(false);
|
|
21
|
+
|
|
22
|
+
// Yonlendirme render govdesinde degil effect'te yapiliyor: render sirasindaki
|
|
23
|
+
// yan etki React kuralini ihlal ediyor ve StrictMode'da iki kez tetikleniyordu.
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (user || redirected.current) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
redirected.current = true;
|
|
20
29
|
logout?.('redirect');
|
|
30
|
+
}, [user, logout]);
|
|
31
|
+
|
|
32
|
+
// Oturum yokken children RENDER EDILMEZ. Eskiden `logout('redirect')` cagrilip
|
|
33
|
+
// yine de tum layout donuluyordu; tarayici korumali ekrani boyayip ancak
|
|
34
|
+
// ondan sonra login'e gidiyordu (panelin bir an gorunup kaybolmasi).
|
|
35
|
+
if (!user) {
|
|
36
|
+
return <LoadingScreen id="layout-auth-redirect" />;
|
|
21
37
|
}
|
|
22
38
|
|
|
23
39
|
return (
|
|
@@ -5,12 +5,21 @@ import { GetDetailsDataFN } from '../details/Details';
|
|
|
5
5
|
const DETAILS_METADATA_KEY = 'DetailsMetaData';
|
|
6
6
|
export type OnSubmitFN<T, L = T> = (data: T | FormData) => Promise<L>;
|
|
7
7
|
|
|
8
|
+
export interface FormGroup {
|
|
9
|
+
key: string;
|
|
10
|
+
label: string;
|
|
11
|
+
collapsible?: boolean;
|
|
12
|
+
defaultCollapsed?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
8
15
|
interface FormOptions<T extends AnyClass, L = T> {
|
|
9
16
|
onSubmit: OnSubmitFN<T, L>;
|
|
10
17
|
onSubmitSuccess?: (data: L) => void;
|
|
11
18
|
getDetailsData?: GetDetailsDataFN<T>;
|
|
12
19
|
type?: 'json' | 'formData';
|
|
13
20
|
redirectSuccessUrl?: string;
|
|
21
|
+
//NOTE: declares the section headings and their order; fields opt in with @Input({ group }).
|
|
22
|
+
groups?: FormGroup[];
|
|
14
23
|
}
|
|
15
24
|
|
|
16
25
|
export interface FormConfiguration<T extends AnyClass, L = T> {
|
|
@@ -19,6 +28,7 @@ export interface FormConfiguration<T extends AnyClass, L = T> {
|
|
|
19
28
|
getDetailsData?: GetDetailsDataFN<T>;
|
|
20
29
|
type: 'json' | 'formData';
|
|
21
30
|
redirectSuccessUrl?: string;
|
|
31
|
+
groups?: FormGroup[];
|
|
22
32
|
}
|
|
23
33
|
|
|
24
34
|
export function Form<T extends AnyClass, L = T>(options?: FormOptions<T, L>): ClassDecorator {
|
|
@@ -45,6 +55,7 @@ export function getFormConfiguration<T extends AnyClass, K extends AnyClassConst
|
|
|
45
55
|
getDetailsData: formOptions.getDetailsData,
|
|
46
56
|
type: formOptions.type ?? 'json',
|
|
47
57
|
redirectSuccessUrl: formOptions.redirectSuccessUrl,
|
|
58
|
+
groups: formOptions.groups,
|
|
48
59
|
};
|
|
49
60
|
return formConfiguration;
|
|
50
61
|
}
|
|
@@ -8,7 +8,7 @@ const isFieldSensitive = (fieldName: string): boolean => {
|
|
|
8
8
|
};
|
|
9
9
|
|
|
10
10
|
export type InputTypes = 'input' | 'textarea' | 'file-upload' | 'checkbox' | 'hidden' | 'nested';
|
|
11
|
-
export type ExtendedInputTypes = InputTypes | 'select';
|
|
11
|
+
export type ExtendedInputTypes = InputTypes | 'select' | 'custom' | 'richtext';
|
|
12
12
|
|
|
13
13
|
export interface InputOptions {
|
|
14
14
|
type?: InputTypes;
|
|
@@ -19,6 +19,9 @@ export interface InputOptions {
|
|
|
19
19
|
defaultValue?: string;
|
|
20
20
|
includeInCSV?: boolean;
|
|
21
21
|
includeInJSON?: boolean;
|
|
22
|
+
//NOTE: the visual section this field belongs to, matched against @Form({ groups }) by key.
|
|
23
|
+
group?: string;
|
|
24
|
+
rows?: number;
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
export interface ExtendedInputOptions extends Omit<InputOptions, 'type'> {
|
|
@@ -36,6 +39,8 @@ export interface InputConfiguration {
|
|
|
36
39
|
defaultValue?: string;
|
|
37
40
|
includeInCSV: boolean;
|
|
38
41
|
includeInJSON: boolean;
|
|
42
|
+
group?: string;
|
|
43
|
+
rows?: number;
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
export function Input(options?: InputOptions): PropertyDecorator {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { ExtendedInput, ExtendedInputOptions, InputConfiguration, InputOptions } from '../Input';
|
|
3
|
+
|
|
4
|
+
export interface CustomRenderProps {
|
|
5
|
+
fieldName: string;
|
|
6
|
+
value: unknown;
|
|
7
|
+
onChange: (value: unknown) => void;
|
|
8
|
+
error?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface CustomInputOptions extends Omit<InputOptions, 'type'> {
|
|
12
|
+
render: (props: CustomRenderProps) => ReactNode;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CustomInputConfiguration extends InputConfiguration {
|
|
16
|
+
type: 'custom';
|
|
17
|
+
render: (props: CustomRenderProps) => ReactNode;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
//NOTE: the render function travels through Reflect metadata like any other option value.
|
|
21
|
+
export function CustomInput(options: CustomInputOptions): PropertyDecorator {
|
|
22
|
+
return ExtendedInput({
|
|
23
|
+
...options,
|
|
24
|
+
type: 'custom',
|
|
25
|
+
} as ExtendedInputOptions);
|
|
26
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { ExtendedInput, ExtendedInputOptions, InputConfiguration, InputOptions } from '../Input';
|
|
2
|
+
|
|
3
|
+
export type RichTextToolbarItem =
|
|
4
|
+
| 'bold'
|
|
5
|
+
| 'italic'
|
|
6
|
+
| 'heading'
|
|
7
|
+
| 'bulletList'
|
|
8
|
+
| 'orderedList'
|
|
9
|
+
| 'link';
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_RICH_TEXT_TOOLBAR: RichTextToolbarItem[] = [
|
|
12
|
+
'bold',
|
|
13
|
+
'italic',
|
|
14
|
+
'heading',
|
|
15
|
+
'bulletList',
|
|
16
|
+
'orderedList',
|
|
17
|
+
'link',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
export interface RichTextInputOptions extends InputOptions {
|
|
21
|
+
toolbar?: RichTextToolbarItem[];
|
|
22
|
+
minHeight?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RichTextInputConfiguration extends InputConfiguration {
|
|
26
|
+
type: 'richtext';
|
|
27
|
+
toolbar?: RichTextToolbarItem[];
|
|
28
|
+
minHeight?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
//NOTE: the editor itself lives behind a lazy import in FormField, because @tiptap/* is an
|
|
32
|
+
// optional peer dependency — this decorator is safe to import without it installed.
|
|
33
|
+
export function RichTextInput(options?: RichTextInputOptions): PropertyDecorator {
|
|
34
|
+
return ExtendedInput({
|
|
35
|
+
...options,
|
|
36
|
+
type: 'richtext',
|
|
37
|
+
} as ExtendedInputOptions);
|
|
38
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -18,9 +18,21 @@ export { LinkCell } from './decorators/list/cells/LinkCell';
|
|
|
18
18
|
|
|
19
19
|
//FORM
|
|
20
20
|
export { FormPage } from './components/form/FormPage';
|
|
21
|
-
export { Form, type OnSubmitFN } from './decorators/form/Form';
|
|
21
|
+
export { Form, type OnSubmitFN, type FormGroup } from './decorators/form/Form';
|
|
22
22
|
export { Input } from './decorators/form/Input';
|
|
23
23
|
export { SelectInput } from './decorators/form/inputs/SelectInput';
|
|
24
|
+
export {
|
|
25
|
+
RichTextInput,
|
|
26
|
+
DEFAULT_RICH_TEXT_TOOLBAR,
|
|
27
|
+
type RichTextInputOptions,
|
|
28
|
+
type RichTextInputConfiguration,
|
|
29
|
+
type RichTextToolbarItem,
|
|
30
|
+
} from './decorators/form/inputs/RichTextInput';
|
|
31
|
+
export {
|
|
32
|
+
CustomInput,
|
|
33
|
+
type CustomInputOptions,
|
|
34
|
+
type CustomRenderProps,
|
|
35
|
+
} from './decorators/form/inputs/CustomInput';
|
|
24
36
|
//for nested form fields
|
|
25
37
|
export { getInputFields } from './decorators/form/Input';
|
|
26
38
|
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Colors mirror the react-select control (see SelectStyles.ts) so a richtext field and a
|
|
2
|
+
// select field sitting next to each other in the same form read as the same widget family.
|
|
3
|
+
$rich-text-surface: #1f2937;
|
|
4
|
+
$rich-text-border: #374151;
|
|
5
|
+
$rich-text-accent: #6366f1;
|
|
6
|
+
$rich-text-muted: #9ca3af;
|
|
7
|
+
|
|
8
|
+
.rich-text-field {
|
|
9
|
+
border: 1px solid $rich-text-border;
|
|
10
|
+
border-radius: 6px;
|
|
11
|
+
background-color: $rich-text-surface;
|
|
12
|
+
color: #ffffff;
|
|
13
|
+
overflow: hidden;
|
|
14
|
+
|
|
15
|
+
&:focus-within {
|
|
16
|
+
border-color: $rich-text-accent;
|
|
17
|
+
box-shadow: 0 0 0 1px $rich-text-accent;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.rich-text-toolbar {
|
|
21
|
+
display: flex;
|
|
22
|
+
flex-wrap: wrap;
|
|
23
|
+
gap: 4px;
|
|
24
|
+
padding: 6px;
|
|
25
|
+
border-bottom: 1px solid $rich-text-border;
|
|
26
|
+
background-color: $rich-text-surface;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.rich-text-toolbar-button {
|
|
30
|
+
min-width: 32px;
|
|
31
|
+
height: 28px;
|
|
32
|
+
padding: 0 8px;
|
|
33
|
+
font-size: 14px;
|
|
34
|
+
line-height: 1;
|
|
35
|
+
cursor: pointer;
|
|
36
|
+
color: #ffffff;
|
|
37
|
+
background-color: transparent;
|
|
38
|
+
border: 1px solid transparent;
|
|
39
|
+
border-radius: 4px;
|
|
40
|
+
transition:
|
|
41
|
+
background-color 0.2s,
|
|
42
|
+
border-color 0.2s;
|
|
43
|
+
|
|
44
|
+
&:hover:not(:disabled) {
|
|
45
|
+
background-color: $rich-text-border;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
&.is-active {
|
|
49
|
+
background-color: $rich-text-accent;
|
|
50
|
+
border-color: $rich-text-accent;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
&:disabled {
|
|
54
|
+
opacity: 0.5;
|
|
55
|
+
cursor: not-allowed;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.rich-text-content {
|
|
60
|
+
position: relative;
|
|
61
|
+
padding: 10px;
|
|
62
|
+
font-size: 16px;
|
|
63
|
+
|
|
64
|
+
// The placeholder is CSS-only on purpose: @tiptap/extension-placeholder would be a third
|
|
65
|
+
// package for consumers to install, for one line of grey text.
|
|
66
|
+
&.is-empty::before {
|
|
67
|
+
content: attr(data-placeholder);
|
|
68
|
+
position: absolute;
|
|
69
|
+
top: 10px;
|
|
70
|
+
left: 10px;
|
|
71
|
+
color: $rich-text-muted;
|
|
72
|
+
pointer-events: none;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.ProseMirror {
|
|
76
|
+
outline: none;
|
|
77
|
+
min-height: inherit;
|
|
78
|
+
|
|
79
|
+
> * + * {
|
|
80
|
+
margin-top: 0.6em;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
p,
|
|
84
|
+
h1,
|
|
85
|
+
h2,
|
|
86
|
+
h3 {
|
|
87
|
+
margin: 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
ul,
|
|
91
|
+
ol {
|
|
92
|
+
padding-left: 1.4em;
|
|
93
|
+
margin: 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
a {
|
|
97
|
+
color: $rich-text-accent;
|
|
98
|
+
text-decoration: underline;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.rich-text-loading,
|
|
105
|
+
.rich-text-missing {
|
|
106
|
+
padding: 10px;
|
|
107
|
+
border: 1px dashed $rich-text-border;
|
|
108
|
+
border-radius: 6px;
|
|
109
|
+
color: $rich-text-muted;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.rich-text-missing {
|
|
113
|
+
color: var(--prp-color-error);
|
|
114
|
+
border-color: var(--prp-color-error);
|
|
115
|
+
}
|
package/src/styles/form.scss
CHANGED
|
@@ -90,6 +90,54 @@
|
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
.form-group-section {
|
|
94
|
+
border: 1px solid var(--prp-border-primary);
|
|
95
|
+
border-radius: 6px;
|
|
96
|
+
background-color: var(--prp-bg-secondary);
|
|
97
|
+
padding: 16px;
|
|
98
|
+
margin: 0 0 20px 0;
|
|
99
|
+
min-width: 0;
|
|
100
|
+
|
|
101
|
+
.form-group-legend,
|
|
102
|
+
.form-group-summary {
|
|
103
|
+
font-weight: bold;
|
|
104
|
+
font-size: 15px;
|
|
105
|
+
color: var(--prp-text-primary);
|
|
106
|
+
padding: 0 6px;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.form-group-summary {
|
|
110
|
+
cursor: pointer;
|
|
111
|
+
list-style: none;
|
|
112
|
+
display: flex;
|
|
113
|
+
align-items: center;
|
|
114
|
+
gap: 6px;
|
|
115
|
+
padding: 0;
|
|
116
|
+
|
|
117
|
+
&::-webkit-details-marker {
|
|
118
|
+
display: none;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
&:before {
|
|
122
|
+
content: '▾';
|
|
123
|
+
color: var(--prp-text-muted);
|
|
124
|
+
transition: transform 0.2s;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
details:not([open]) > .form-group-summary:before {
|
|
129
|
+
transform: rotate(-90deg);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.form-group-fields {
|
|
133
|
+
margin-top: 12px;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.form-field:last-child {
|
|
137
|
+
margin-bottom: 0;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
93
141
|
.submit-button {
|
|
94
142
|
padding: 12px;
|
|
95
143
|
font-size: 18px;
|
package/src/styles/index.scss
CHANGED
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
interface DashboardGridProps {
|
|
3
|
-
children: React.ReactNode;
|
|
4
|
-
columns?: number;
|
|
5
|
-
gap?: string;
|
|
6
|
-
className?: string;
|
|
7
|
-
}
|
|
8
|
-
export declare function DashboardGrid({ children, columns, gap, className, }: DashboardGridProps): React.JSX.Element;
|
|
9
|
-
export {};
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
interface DashboardItemProps {
|
|
3
|
-
targetNumber: number;
|
|
4
|
-
duration?: number;
|
|
5
|
-
image: React.ReactNode;
|
|
6
|
-
text: string;
|
|
7
|
-
className?: string;
|
|
8
|
-
}
|
|
9
|
-
export declare function DashboardItem({ targetNumber, duration, image, text, className, }: DashboardItemProps): React.JSX.Element;
|
|
10
|
-
export {};
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
export type ThemeMode = 'light' | 'dark' | 'automatic';
|
|
2
|
-
interface ThemeState {
|
|
3
|
-
theme: ThemeMode;
|
|
4
|
-
resolvedTheme: 'light' | 'dark';
|
|
5
|
-
setTheme: (theme: ThemeMode) => void;
|
|
6
|
-
initializeTheme: () => void;
|
|
7
|
-
}
|
|
8
|
-
export declare const useThemeStore: import("zustand/traditional").UseBoundStoreWithEqualityFn<Omit<import("zustand/vanilla").StoreApi<ThemeState>, "persist"> & {
|
|
9
|
-
persist: {
|
|
10
|
-
setOptions: (options: Partial<import("zustand/middleware").PersistOptions<ThemeState, unknown>>) => void;
|
|
11
|
-
clearStorage: () => void;
|
|
12
|
-
rehydrate: () => Promise<void> | void;
|
|
13
|
-
hasHydrated: () => boolean;
|
|
14
|
-
onHydrate: (fn: (state: ThemeState) => void) => () => void;
|
|
15
|
-
onFinishHydration: (fn: (state: ThemeState) => void) => () => void;
|
|
16
|
-
getOptions: () => Partial<import("zustand/middleware").PersistOptions<ThemeState, unknown>>;
|
|
17
|
-
};
|
|
18
|
-
}>;
|
|
19
|
-
export declare const getTheme: () => ThemeMode;
|
|
20
|
-
export declare const getResolvedTheme: () => "light" | "dark";
|
|
21
|
-
export declare const setTheme: (theme: ThemeMode) => void;
|
|
22
|
-
export declare const initializeTheme: () => void;
|
|
23
|
-
export {};
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|