@mxfb/tools 1.0.5 → 1.0.6
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/components/Date/index.d.ts +45 -0
- package/components/Date/index.js +65 -0
- package/components/Date/styles.module.css +0 -0
- package/components/Date/types.d.ts +39 -0
- package/components/Date/types.js +37 -0
- package/components/Embed/SpotifyEmbed/index.d.ts +13 -0
- package/components/Embed/SpotifyEmbed/index.js +38 -0
- package/components/Embed/SpotifyEmbed/styles.module.css +0 -0
- package/components/Embed/VimeoEmbed/index.d.ts +14 -0
- package/components/Embed/VimeoEmbed/index.js +40 -0
- package/components/Embed/VimeoEmbed/styles.module.css +0 -0
- package/components/Embed/YoutubeEmbed/index.d.ts +18 -0
- package/components/Embed/YoutubeEmbed/index.js +45 -0
- package/components/Embed/YoutubeEmbed/styles.module.css +0 -0
- package/components/Embed/index.d.ts +44 -0
- package/components/Embed/index.js +71 -0
- package/components/Embed/styles.module.css +0 -0
- package/components/Embed/types.d.ts +22 -0
- package/components/Embed/types.js +7 -0
- package/components/JsonEditor/index.d.ts +227 -0
- package/components/JsonEditor/index.js +281 -0
- package/components/JsonEditor/styles.module.css +0 -0
- package/components/JsonEditor/types.d.ts +5 -0
- package/components/JsonEditor/types.js +1 -0
- package/components/JsonEditor/utils.d.ts +3 -0
- package/components/JsonEditor/utils.js +18 -0
- package/components/ListLoader/index.controlled.d.ts +87 -0
- package/components/ListLoader/index.controlled.js +108 -0
- package/components/ListLoader/index.d.ts +76 -0
- package/components/ListLoader/index.js +162 -0
- package/components/ListLoader/styles.module.css +0 -0
- package/components/index.d.ts +4 -0
- package/components/index.js +4 -0
- package/components/public-classnames.d.ts +6 -0
- package/components/public-classnames.js +6 -0
- package/package.json +63 -2
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { type FunctionComponent, type EventHandler, type ChangeEvent } from 'react';
|
|
2
|
+
import { type ZodType, type core } from 'zod';
|
|
3
|
+
import type { WithClassName } from '../utils/types.js';
|
|
4
|
+
import type { JsonValue } from './types.js';
|
|
5
|
+
/**
|
|
6
|
+
* Props for the {@link JsonEditor} component.
|
|
7
|
+
*
|
|
8
|
+
* @property defaultValue - Initial JSON value rendered by the editor. Defaults to `null`.
|
|
9
|
+
* @property onChange - Called after any value change with the updated JSON value.
|
|
10
|
+
* @property onValidationError - Called when the updated value fails schema validation,
|
|
11
|
+
* with the list of Zod issues. Not called when no `schema` is provided.
|
|
12
|
+
* @property schema - Optional Zod schema used to validate the value after each change.
|
|
13
|
+
* When provided, validation runs after `onChange` is dispatched.
|
|
14
|
+
* @property preventSameNameKeys - Refuses a record key rename that would collide
|
|
15
|
+
* with a sibling key, instead of letting the renamed entry replace it. Applies to
|
|
16
|
+
* every record in the tree. Defaults to `false`.
|
|
17
|
+
* @property className - Additional class name(s) applied to the root element.
|
|
18
|
+
*/
|
|
19
|
+
export type Props = WithClassName<{
|
|
20
|
+
defaultValue?: JsonValue;
|
|
21
|
+
onChange?: (val: JsonValue) => void;
|
|
22
|
+
onValidationError?: (issues: core.$ZodIssue[]) => void;
|
|
23
|
+
schema?: ZodType<JsonValue>;
|
|
24
|
+
preventSameNameKeys?: boolean;
|
|
25
|
+
}>;
|
|
26
|
+
/**
|
|
27
|
+
* Interactive JSON editor component.
|
|
28
|
+
*
|
|
29
|
+
* Renders a recursive value editor tree alongside a live JSON preview.
|
|
30
|
+
* Supports all JSON value types: strings, numbers, booleans, null, arrays,
|
|
31
|
+
* and records. Each node exposes a type selector and a type-appropriate input.
|
|
32
|
+
*
|
|
33
|
+
* When a `schema` is provided, the current value is validated against it after
|
|
34
|
+
* every change. Validation errors are reported through `onValidationError` and
|
|
35
|
+
* do not block editing.
|
|
36
|
+
*
|
|
37
|
+
* ### CSS elements
|
|
38
|
+
* - `value` — wraps each node in the editor tree; receives a type modifier
|
|
39
|
+
* matching the current value type (`string`, `number`, `boolean`, `null`, `array`, `record`).
|
|
40
|
+
* - `type` — the type selector control.
|
|
41
|
+
* - `string`, `number`, `boolean`, `null` — primitive value inputs.
|
|
42
|
+
* - `record` — record entry list; `record-item` — one entry of it.
|
|
43
|
+
* - `array` — array item list; `array-item` — one item of it.
|
|
44
|
+
* - `prop-name` — key input for record entries.
|
|
45
|
+
* - `create-prop` — button to append a new entry to a record or array.
|
|
46
|
+
* - `delete-prop` — button to remove an existing entry.
|
|
47
|
+
* - `lift-prop` — button to move an array entry up.
|
|
48
|
+
* - `drop-prop` — button to move an array entry down.
|
|
49
|
+
* - `preview` — the serialized JSON preview.
|
|
50
|
+
*
|
|
51
|
+
* @param props - Component properties.
|
|
52
|
+
* @see {@link Props}
|
|
53
|
+
* @returns A recursive JSON value editor with a live serialized preview.
|
|
54
|
+
*
|
|
55
|
+
* @remarks
|
|
56
|
+
* **The editor is uncontrolled, at every level of the tree, and there is no
|
|
57
|
+
* controlled variant.** Each node seeds its state from its `defaultValue` on
|
|
58
|
+
* mount and never reads it again, so handing a mounted editor a new
|
|
59
|
+
* `defaultValue` changes nothing on screen — remount it with a different `key`
|
|
60
|
+
* to load another document. A controlled mode would mean rebuilding the state
|
|
61
|
+
* model so the whole tree reads from a single owner, which is why none of these
|
|
62
|
+
* components accepts a `value` prop.
|
|
63
|
+
*/
|
|
64
|
+
export declare const JsonEditor: FunctionComponent<Props>;
|
|
65
|
+
/**
|
|
66
|
+
* Props for the {@link ValueEditor} component.
|
|
67
|
+
*
|
|
68
|
+
* @property defaultValue - Initial value of this node. Read on mount only.
|
|
69
|
+
* @property onChange - Called after the node's value changed, with the new value.
|
|
70
|
+
* @property path - Keys and indices leading to this node from the document root,
|
|
71
|
+
* exposed on the node as `data-path` and passed down to its children.
|
|
72
|
+
* @property preventSameNameKeys - Forwarded to every record below this node.
|
|
73
|
+
* @property className - Additional class name(s) applied to the root element.
|
|
74
|
+
*/
|
|
75
|
+
export type ValueEditorProps = WithClassName<{
|
|
76
|
+
defaultValue?: JsonValue;
|
|
77
|
+
onChange?: (newValue: JsonValue) => void;
|
|
78
|
+
path?: Array<string | number>;
|
|
79
|
+
preventSameNameKeys?: boolean;
|
|
80
|
+
}>;
|
|
81
|
+
/**
|
|
82
|
+
* One node of the editor tree: a type selector, plus the editor matching the
|
|
83
|
+
* value's current type. Switching type replaces the value with an empty one of
|
|
84
|
+
* the new type.
|
|
85
|
+
*
|
|
86
|
+
* @param props - Component properties.
|
|
87
|
+
* @see {@link ValueEditorProps}
|
|
88
|
+
* @returns A span holding the type selector and the type-appropriate editor.
|
|
89
|
+
*/
|
|
90
|
+
export declare const ValueEditor: FunctionComponent<ValueEditorProps>;
|
|
91
|
+
/**
|
|
92
|
+
* Props for the {@link StringEditor} component.
|
|
93
|
+
*
|
|
94
|
+
* @property type - Which control to render. Defaults to a single-line `input`.
|
|
95
|
+
* @property defaultValue - Initial text. Read on mount only.
|
|
96
|
+
* @property onChange - Native change handler, forwarded to the control.
|
|
97
|
+
* @property className - Additional class name(s) applied to the control.
|
|
98
|
+
*/
|
|
99
|
+
export type StringEditorProps = WithClassName<{
|
|
100
|
+
type?: 'input' | 'textarea';
|
|
101
|
+
defaultValue?: string;
|
|
102
|
+
onChange?: EventHandler<ChangeEvent<HTMLTextAreaElement | HTMLInputElement>>;
|
|
103
|
+
}>;
|
|
104
|
+
/**
|
|
105
|
+
* The editor for a string value.
|
|
106
|
+
*
|
|
107
|
+
* @param props - Component properties.
|
|
108
|
+
* @see {@link StringEditorProps}
|
|
109
|
+
* @returns A textarea or a text input, depending on `type`.
|
|
110
|
+
*/
|
|
111
|
+
export declare const StringEditor: FunctionComponent<StringEditorProps>;
|
|
112
|
+
/**
|
|
113
|
+
* Props for the {@link NumberEditor} component.
|
|
114
|
+
*
|
|
115
|
+
* @property defaultValue - Initial number. Read on mount only.
|
|
116
|
+
* @property onChange - Native change handler, forwarded to the input.
|
|
117
|
+
* @property className - Additional class name(s) applied to the input.
|
|
118
|
+
*/
|
|
119
|
+
export type NumberEditorProps = WithClassName<{
|
|
120
|
+
defaultValue?: number;
|
|
121
|
+
onChange?: EventHandler<ChangeEvent<HTMLInputElement>>;
|
|
122
|
+
}>;
|
|
123
|
+
/**
|
|
124
|
+
* The editor for a number value.
|
|
125
|
+
*
|
|
126
|
+
* @param props - Component properties.
|
|
127
|
+
* @see {@link NumberEditorProps}
|
|
128
|
+
* @returns A number input.
|
|
129
|
+
*/
|
|
130
|
+
export declare const NumberEditor: FunctionComponent<NumberEditorProps>;
|
|
131
|
+
/**
|
|
132
|
+
* Props for the {@link BooleanEditor} component.
|
|
133
|
+
*
|
|
134
|
+
* @property defaultValue - Initial checked state. Read on mount only.
|
|
135
|
+
* @property onChange - Native change handler, forwarded to the input.
|
|
136
|
+
* @property className - Additional class name(s) applied to the input.
|
|
137
|
+
*/
|
|
138
|
+
export type BooleanEditorProps = WithClassName<{
|
|
139
|
+
defaultValue?: boolean;
|
|
140
|
+
onChange?: EventHandler<ChangeEvent<HTMLInputElement>>;
|
|
141
|
+
}>;
|
|
142
|
+
/**
|
|
143
|
+
* The editor for a boolean value.
|
|
144
|
+
*
|
|
145
|
+
* @param props - Component properties.
|
|
146
|
+
* @see {@link BooleanEditorProps}
|
|
147
|
+
* @returns A checkbox.
|
|
148
|
+
*/
|
|
149
|
+
export declare const BooleanEditor: FunctionComponent<BooleanEditorProps>;
|
|
150
|
+
/**
|
|
151
|
+
* Props for the {@link NullEditor} component.
|
|
152
|
+
*
|
|
153
|
+
* @property className - Additional class name(s) applied to the root element.
|
|
154
|
+
*/
|
|
155
|
+
export type NullEditorProps = WithClassName<Record<string, never>>;
|
|
156
|
+
/**
|
|
157
|
+
* The editor for a null value — there is nothing to edit, so it only states the
|
|
158
|
+
* type.
|
|
159
|
+
*
|
|
160
|
+
* @param props - Component properties.
|
|
161
|
+
* @see {@link NullEditorProps}
|
|
162
|
+
* @returns A span reading `null`.
|
|
163
|
+
*/
|
|
164
|
+
export declare const NullEditor: FunctionComponent<NullEditorProps>;
|
|
165
|
+
/**
|
|
166
|
+
* Props for the {@link RecordEditor} component.
|
|
167
|
+
*
|
|
168
|
+
* @property defaultValue - Initial entries. Read on mount only.
|
|
169
|
+
* @property onChange - Called after any entry is added, removed, renamed or
|
|
170
|
+
* edited, with the rebuilt record.
|
|
171
|
+
* @property path - Keys and indices leading to this record from the document
|
|
172
|
+
* root, extended with each entry's key before being passed down.
|
|
173
|
+
* @property preventSameNameKeys - Refuses a rename that would collide with a
|
|
174
|
+
* sibling key, instead of letting the renamed entry replace it. Defaults to
|
|
175
|
+
* `false`. Forwarded to every record nested below.
|
|
176
|
+
* @property className - Additional class name(s) applied to the root element.
|
|
177
|
+
*/
|
|
178
|
+
export type RecordEditorProps = WithClassName<{
|
|
179
|
+
defaultValue?: Record<string, JsonValue>;
|
|
180
|
+
onChange?: (newValue: Record<string, JsonValue>) => void;
|
|
181
|
+
path?: Array<string | number>;
|
|
182
|
+
preventSameNameKeys?: boolean;
|
|
183
|
+
}>;
|
|
184
|
+
/**
|
|
185
|
+
* The editor for a record: one renamable, removable entry per key, each holding
|
|
186
|
+
* a nested {@link ValueEditor}.
|
|
187
|
+
*
|
|
188
|
+
* @param props - Component properties.
|
|
189
|
+
* @see {@link RecordEditorProps}
|
|
190
|
+
* @returns A list of entries, followed by the button that appends one.
|
|
191
|
+
*
|
|
192
|
+
* @remarks
|
|
193
|
+
* Entries are held in a `Map` keyed by property name, each carrying a stable id
|
|
194
|
+
* used as the React key — so renaming a property doesn't remount its editor and
|
|
195
|
+
* lose the subtree's state.
|
|
196
|
+
*/
|
|
197
|
+
export declare const RecordEditor: FunctionComponent<RecordEditorProps>;
|
|
198
|
+
/**
|
|
199
|
+
* Props for the {@link ArrayEditor} component.
|
|
200
|
+
*
|
|
201
|
+
* @property defaultValue - Initial items. Read on mount only.
|
|
202
|
+
* @property onChange - Called after any item is added, removed, moved or edited,
|
|
203
|
+
* with the rebuilt array.
|
|
204
|
+
* @property path - Keys and indices leading to this array from the document
|
|
205
|
+
* root, extended with each item's index before being passed down.
|
|
206
|
+
* @property preventSameNameKeys - Forwarded to every record nested below.
|
|
207
|
+
* @property className - Additional class name(s) applied to the root element.
|
|
208
|
+
*/
|
|
209
|
+
export type ArrayEditorProps = WithClassName<{
|
|
210
|
+
defaultValue?: JsonValue[];
|
|
211
|
+
onChange?: (newValue: JsonValue[]) => void;
|
|
212
|
+
path?: Array<string | number>;
|
|
213
|
+
preventSameNameKeys?: boolean;
|
|
214
|
+
}>;
|
|
215
|
+
/**
|
|
216
|
+
* The editor for an array: one removable, movable item per index, each holding a
|
|
217
|
+
* nested {@link ValueEditor}.
|
|
218
|
+
*
|
|
219
|
+
* @param props - Component properties.
|
|
220
|
+
* @see {@link ArrayEditorProps}
|
|
221
|
+
* @returns An ordered list of items, followed by the button that appends one.
|
|
222
|
+
*
|
|
223
|
+
* @remarks
|
|
224
|
+
* Items are held in a `Map` keyed by a stable id used as the React key, so
|
|
225
|
+
* reordering moves the editors rather than remounting them.
|
|
226
|
+
*/
|
|
227
|
+
export declare const ArrayEditor: FunctionComponent<ArrayEditorProps>;
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { ZodError } from 'zod';
|
|
4
|
+
import { isNonNullObject } from '@design-edito/tools/agnostic/objects/is-object/index.js';
|
|
5
|
+
import { randomHash } from '@design-edito/tools/agnostic/random/uuid/index.js';
|
|
6
|
+
import { Input } from '@design-edito/tools/components/Input/index.js';
|
|
7
|
+
import { Select } from '@design-edito/tools/components/Select/index.js';
|
|
8
|
+
import { Textarea } from '@design-edito/tools/components/Textarea/index.js';
|
|
9
|
+
import { mergeClassNames } from '../utils/index.js';
|
|
10
|
+
import { c, getValueType } from './utils.js';
|
|
11
|
+
/**
|
|
12
|
+
* Interactive JSON editor component.
|
|
13
|
+
*
|
|
14
|
+
* Renders a recursive value editor tree alongside a live JSON preview.
|
|
15
|
+
* Supports all JSON value types: strings, numbers, booleans, null, arrays,
|
|
16
|
+
* and records. Each node exposes a type selector and a type-appropriate input.
|
|
17
|
+
*
|
|
18
|
+
* When a `schema` is provided, the current value is validated against it after
|
|
19
|
+
* every change. Validation errors are reported through `onValidationError` and
|
|
20
|
+
* do not block editing.
|
|
21
|
+
*
|
|
22
|
+
* ### CSS elements
|
|
23
|
+
* - `value` — wraps each node in the editor tree; receives a type modifier
|
|
24
|
+
* matching the current value type (`string`, `number`, `boolean`, `null`, `array`, `record`).
|
|
25
|
+
* - `type` — the type selector control.
|
|
26
|
+
* - `string`, `number`, `boolean`, `null` — primitive value inputs.
|
|
27
|
+
* - `record` — record entry list; `record-item` — one entry of it.
|
|
28
|
+
* - `array` — array item list; `array-item` — one item of it.
|
|
29
|
+
* - `prop-name` — key input for record entries.
|
|
30
|
+
* - `create-prop` — button to append a new entry to a record or array.
|
|
31
|
+
* - `delete-prop` — button to remove an existing entry.
|
|
32
|
+
* - `lift-prop` — button to move an array entry up.
|
|
33
|
+
* - `drop-prop` — button to move an array entry down.
|
|
34
|
+
* - `preview` — the serialized JSON preview.
|
|
35
|
+
*
|
|
36
|
+
* @param props - Component properties.
|
|
37
|
+
* @see {@link Props}
|
|
38
|
+
* @returns A recursive JSON value editor with a live serialized preview.
|
|
39
|
+
*
|
|
40
|
+
* @remarks
|
|
41
|
+
* **The editor is uncontrolled, at every level of the tree, and there is no
|
|
42
|
+
* controlled variant.** Each node seeds its state from its `defaultValue` on
|
|
43
|
+
* mount and never reads it again, so handing a mounted editor a new
|
|
44
|
+
* `defaultValue` changes nothing on screen — remount it with a different `key`
|
|
45
|
+
* to load another document. A controlled mode would mean rebuilding the state
|
|
46
|
+
* model so the whole tree reads from a single owner, which is why none of these
|
|
47
|
+
* components accepts a `value` prop.
|
|
48
|
+
*/
|
|
49
|
+
export const JsonEditor = ({ defaultValue = null, onChange, onValidationError, schema, preventSameNameKeys, className }) => {
|
|
50
|
+
const [value, setValue] = useState(defaultValue);
|
|
51
|
+
const setValueAndDispatch = (val) => {
|
|
52
|
+
setValue(val);
|
|
53
|
+
onChange?.(val);
|
|
54
|
+
try {
|
|
55
|
+
schema?.parse(val);
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
if (err instanceof ZodError) {
|
|
59
|
+
const { issues } = err;
|
|
60
|
+
onValidationError?.(issues);
|
|
61
|
+
}
|
|
62
|
+
else
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
const rootClss = mergeClassNames(c(), className);
|
|
67
|
+
return _jsxs("div", { className: rootClss, children: [_jsx(ValueEditor, { defaultValue: defaultValue, onChange: val => setValueAndDispatch(val), preventSameNameKeys: preventSameNameKeys, path: [] }), _jsx("pre", { className: c('preview'), children: JSON.stringify(value, null, 2) })] });
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* One node of the editor tree: a type selector, plus the editor matching the
|
|
71
|
+
* value's current type. Switching type replaces the value with an empty one of
|
|
72
|
+
* the new type.
|
|
73
|
+
*
|
|
74
|
+
* @param props - Component properties.
|
|
75
|
+
* @see {@link ValueEditorProps}
|
|
76
|
+
* @returns A span holding the type selector and the type-appropriate editor.
|
|
77
|
+
*/
|
|
78
|
+
export const ValueEditor = ({ defaultValue = null, onChange, path = [], preventSameNameKeys, className }) => {
|
|
79
|
+
// State
|
|
80
|
+
const [value, setValue] = useState(defaultValue);
|
|
81
|
+
const setValueAndDispatch = (newValue) => {
|
|
82
|
+
setValue(newValue);
|
|
83
|
+
onChange?.(newValue);
|
|
84
|
+
};
|
|
85
|
+
// Handlers
|
|
86
|
+
const handleTypeSelectChange = e => {
|
|
87
|
+
const newType = e.target.value;
|
|
88
|
+
if (newType === 'string')
|
|
89
|
+
return setValueAndDispatch('');
|
|
90
|
+
if (newType === 'number')
|
|
91
|
+
return setValueAndDispatch(0);
|
|
92
|
+
if (newType === 'boolean')
|
|
93
|
+
return setValueAndDispatch(false);
|
|
94
|
+
if (newType === 'null')
|
|
95
|
+
return setValueAndDispatch(null);
|
|
96
|
+
if (newType === 'record')
|
|
97
|
+
return setValueAndDispatch({});
|
|
98
|
+
if (newType === 'array')
|
|
99
|
+
return setValueAndDispatch([]);
|
|
100
|
+
};
|
|
101
|
+
const handleStringValueChange = e => setValueAndDispatch(e.target.value);
|
|
102
|
+
const handleNumberValueChange = e => setValueAndDispatch(parseFloat(e.target.value));
|
|
103
|
+
const handleBooleanValueChange = e => setValueAndDispatch(e.target.checked);
|
|
104
|
+
const handleRecordValueChange = e => setValueAndDispatch(e);
|
|
105
|
+
const handleArrayValueChange = e => setValueAndDispatch(e);
|
|
106
|
+
// Rendering
|
|
107
|
+
const valueType = getValueType(value);
|
|
108
|
+
const valueEditorClss = mergeClassNames(c('value', valueType), className);
|
|
109
|
+
const pathStringDataAttr = path.map(e => e.toString()).join('.');
|
|
110
|
+
return _jsxs("span", { className: valueEditorClss, "data-path": pathStringDataAttr, children: [_jsxs(Select, { className: c('type'), value: valueType, onChange: handleTypeSelectChange, children: [_jsx("option", { value: 'string', children: "string" }), _jsx("option", { value: 'number', children: "number" }), _jsx("option", { value: 'boolean', children: "boolean" }), _jsx("option", { value: 'null', children: "null" }), _jsx("option", { value: 'record', children: "record" }), _jsx("option", { value: 'array', children: "array" })] }), typeof value === 'string' && _jsx(StringEditor, { type: 'textarea', defaultValue: value, onChange: handleStringValueChange }), typeof value === 'number' && _jsx(NumberEditor, { defaultValue: value, onChange: handleNumberValueChange }), typeof value === 'boolean' && _jsx(BooleanEditor, { defaultValue: value, onChange: handleBooleanValueChange }), value === null && _jsx(NullEditor, {}), isNonNullObject(value) && !Array.isArray(value) && _jsx(RecordEditor, { defaultValue: value, onChange: handleRecordValueChange, preventSameNameKeys: preventSameNameKeys, path: path }), Array.isArray(value) && _jsx(ArrayEditor, { defaultValue: value, onChange: handleArrayValueChange, preventSameNameKeys: preventSameNameKeys, path: path })] });
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* The editor for a string value.
|
|
114
|
+
*
|
|
115
|
+
* @param props - Component properties.
|
|
116
|
+
* @see {@link StringEditorProps}
|
|
117
|
+
* @returns A textarea or a text input, depending on `type`.
|
|
118
|
+
*/
|
|
119
|
+
export const StringEditor = ({ type, defaultValue, onChange, className }) => type === 'textarea'
|
|
120
|
+
? _jsx(Textarea, { className: mergeClassNames(c('string'), className), defaultValue: defaultValue ?? '', onChange: onChange })
|
|
121
|
+
: _jsx(Input, { type: 'text', className: mergeClassNames(c('string'), className), defaultValue: defaultValue ?? '', onChange: onChange });
|
|
122
|
+
/**
|
|
123
|
+
* The editor for a number value.
|
|
124
|
+
*
|
|
125
|
+
* @param props - Component properties.
|
|
126
|
+
* @see {@link NumberEditorProps}
|
|
127
|
+
* @returns A number input.
|
|
128
|
+
*/
|
|
129
|
+
export const NumberEditor = ({ defaultValue, onChange, className }) => _jsx(Input, { className: mergeClassNames(c('number'), className), type: 'number', defaultValue: defaultValue ?? 0, onChange: onChange });
|
|
130
|
+
/**
|
|
131
|
+
* The editor for a boolean value.
|
|
132
|
+
*
|
|
133
|
+
* @param props - Component properties.
|
|
134
|
+
* @see {@link BooleanEditorProps}
|
|
135
|
+
* @returns A checkbox.
|
|
136
|
+
*/
|
|
137
|
+
export const BooleanEditor = ({ defaultValue, onChange, className }) => _jsx(Input, { className: mergeClassNames(c('boolean'), className), type: 'checkbox', defaultChecked: defaultValue ?? false, onChange: onChange });
|
|
138
|
+
/**
|
|
139
|
+
* The editor for a null value — there is nothing to edit, so it only states the
|
|
140
|
+
* type.
|
|
141
|
+
*
|
|
142
|
+
* @param props - Component properties.
|
|
143
|
+
* @see {@link NullEditorProps}
|
|
144
|
+
* @returns A span reading `null`.
|
|
145
|
+
*/
|
|
146
|
+
export const NullEditor = ({ className }) => _jsx("span", { className: mergeClassNames(c('null'), className), children: "null" });
|
|
147
|
+
/**
|
|
148
|
+
* The editor for a record: one renamable, removable entry per key, each holding
|
|
149
|
+
* a nested {@link ValueEditor}.
|
|
150
|
+
*
|
|
151
|
+
* @param props - Component properties.
|
|
152
|
+
* @see {@link RecordEditorProps}
|
|
153
|
+
* @returns A list of entries, followed by the button that appends one.
|
|
154
|
+
*
|
|
155
|
+
* @remarks
|
|
156
|
+
* Entries are held in a `Map` keyed by property name, each carrying a stable id
|
|
157
|
+
* used as the React key — so renaming a property doesn't remount its editor and
|
|
158
|
+
* lose the subtree's state.
|
|
159
|
+
*/
|
|
160
|
+
export const RecordEditor = ({ defaultValue = {}, onChange, path, preventSameNameKeys = false, className }) => {
|
|
161
|
+
const [value, setValue] = useState(new Map(Object
|
|
162
|
+
.entries(defaultValue)
|
|
163
|
+
.map(([key, val]) => [key, {
|
|
164
|
+
id: randomHash(8),
|
|
165
|
+
val
|
|
166
|
+
}])));
|
|
167
|
+
const [refusedRenames, setRefusedRenames] = useState(0);
|
|
168
|
+
const setValueAndDispatch = (newValue) => {
|
|
169
|
+
setValue(newValue);
|
|
170
|
+
onChange?.(Array.from(newValue).reduce((acc, [key, { val }]) => ({
|
|
171
|
+
...acc,
|
|
172
|
+
[key]: val
|
|
173
|
+
}), {}));
|
|
174
|
+
};
|
|
175
|
+
const handleDeleteProp = (key) => {
|
|
176
|
+
const newValue = new Map(value);
|
|
177
|
+
newValue.delete(key);
|
|
178
|
+
setValueAndDispatch(newValue);
|
|
179
|
+
};
|
|
180
|
+
const handleCreateProp = () => {
|
|
181
|
+
let propName = randomHash(4);
|
|
182
|
+
while (value.get(propName) !== undefined) {
|
|
183
|
+
propName = randomHash(4);
|
|
184
|
+
}
|
|
185
|
+
const newValue = new Map(value);
|
|
186
|
+
newValue.set(propName, { id: randomHash(8), val: null });
|
|
187
|
+
setValueAndDispatch(newValue);
|
|
188
|
+
};
|
|
189
|
+
const handleRenameProp = (oldName) => e => {
|
|
190
|
+
const newName = e.target.value;
|
|
191
|
+
if (newName === oldName)
|
|
192
|
+
return;
|
|
193
|
+
if (preventSameNameKeys && value.has(newName)) {
|
|
194
|
+
// Refusing leaves the state untouched, and the name input is uncontrolled —
|
|
195
|
+
// it would go on showing what was typed. Bumping this counter is what puts
|
|
196
|
+
// the field back: it feeds the input's key, so the input remounts and reads
|
|
197
|
+
// its `defaultValue` again.
|
|
198
|
+
setRefusedRenames(count => count + 1);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
// Dropping the colliding entry first is what makes the outcome predictable:
|
|
202
|
+
// the renamed entry wins, and keeps its own position in the record. Rebuilding
|
|
203
|
+
// the Map without this leaves two entries under the same key, and `new Map`
|
|
204
|
+
// silently keeps the last one — losing the value being renamed.
|
|
205
|
+
const newValue = new Map(Array
|
|
206
|
+
.from(value)
|
|
207
|
+
.filter(([key]) => key !== newName)
|
|
208
|
+
.map(([key, entry]) => [key === oldName ? newName : key, entry]));
|
|
209
|
+
setValueAndDispatch(newValue);
|
|
210
|
+
};
|
|
211
|
+
const handleChangeProp = (changedKey) => (newValue) => {
|
|
212
|
+
const newVal = new Map(Array.from(value).map(([key, { id, val }]) => [key, {
|
|
213
|
+
id,
|
|
214
|
+
val: key === changedKey ? newValue : val
|
|
215
|
+
}]));
|
|
216
|
+
setValueAndDispatch(newVal);
|
|
217
|
+
};
|
|
218
|
+
return _jsxs("ul", { className: mergeClassNames(c('record'), className), children: [Array.from(value).map(([key, { id, val }]) => _jsxs("li", { className: c('record-item'), "data-key": key, "data-path": [...(path ?? []), key].join('.'), children: [_jsx("button", { className: c('delete-prop'), onClick: () => handleDeleteProp(key), children: "x" }), _jsx("span", { className: c('prop-name'), children: _jsx(StringEditor, { type: 'input', defaultValue: key, onChange: handleRenameProp(key) }, `${id}-${refusedRenames}`) }), _jsx(ValueEditor, { defaultValue: val, onChange: handleChangeProp(key), preventSameNameKeys: preventSameNameKeys, path: [...(path ?? []), key] })] }, id)), _jsx("button", { className: c('create-prop'), onClick: handleCreateProp, children: "+" })] });
|
|
219
|
+
};
|
|
220
|
+
/**
|
|
221
|
+
* The editor for an array: one removable, movable item per index, each holding a
|
|
222
|
+
* nested {@link ValueEditor}.
|
|
223
|
+
*
|
|
224
|
+
* @param props - Component properties.
|
|
225
|
+
* @see {@link ArrayEditorProps}
|
|
226
|
+
* @returns An ordered list of items, followed by the button that appends one.
|
|
227
|
+
*
|
|
228
|
+
* @remarks
|
|
229
|
+
* Items are held in a `Map` keyed by a stable id used as the React key, so
|
|
230
|
+
* reordering moves the editors rather than remounting them.
|
|
231
|
+
*/
|
|
232
|
+
export const ArrayEditor = ({ defaultValue = [], onChange, path, preventSameNameKeys, className }) => {
|
|
233
|
+
const [value, setValue] = useState(new Map(defaultValue.map(val => [randomHash(8), val])));
|
|
234
|
+
const setValueAndDispatch = (newValue) => {
|
|
235
|
+
setValue(newValue);
|
|
236
|
+
onChange?.(Array.from(newValue).map(([, val]) => val));
|
|
237
|
+
};
|
|
238
|
+
const handleDeleteProp = (pos) => setValueAndDispatch(new Map([
|
|
239
|
+
...Array.from(value).slice(0, pos),
|
|
240
|
+
...Array.from(value).slice(pos + 1)
|
|
241
|
+
]));
|
|
242
|
+
const handleCreateProp = () => {
|
|
243
|
+
const newValue = new Map([...Array.from(value), [randomHash(8), null]]);
|
|
244
|
+
setValueAndDispatch(newValue);
|
|
245
|
+
};
|
|
246
|
+
const handleChangeProp = (pos) => (newValue) => setValueAndDispatch(new Map([
|
|
247
|
+
...Array.from(value).slice(0, pos),
|
|
248
|
+
[Array.from(value)[pos]?.[0] ?? randomHash(8), newValue],
|
|
249
|
+
...Array.from(value).slice(pos + 1)
|
|
250
|
+
]));
|
|
251
|
+
// Swapping with the entry before, so position 0 has nothing to swap with. The
|
|
252
|
+
// guard is what keeps `pos - 1` from reaching back into the array as `-1`.
|
|
253
|
+
const handleLiftProp = (pos) => {
|
|
254
|
+
if (pos <= 0)
|
|
255
|
+
return;
|
|
256
|
+
const entries = Array.from(value);
|
|
257
|
+
setValueAndDispatch(new Map([
|
|
258
|
+
...entries.slice(0, pos - 1),
|
|
259
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
260
|
+
entries[pos],
|
|
261
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
262
|
+
entries[pos - 1],
|
|
263
|
+
...entries.slice(pos + 1)
|
|
264
|
+
]));
|
|
265
|
+
};
|
|
266
|
+
// Same, mirrored: the last entry has nothing to swap with.
|
|
267
|
+
const handleDropProp = (pos) => {
|
|
268
|
+
const entries = Array.from(value);
|
|
269
|
+
if (pos < 0 || pos >= entries.length - 1)
|
|
270
|
+
return;
|
|
271
|
+
setValueAndDispatch(new Map([
|
|
272
|
+
...entries.slice(0, pos),
|
|
273
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
274
|
+
entries[pos + 1],
|
|
275
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
276
|
+
entries[pos],
|
|
277
|
+
...entries.slice(pos + 2)
|
|
278
|
+
]));
|
|
279
|
+
};
|
|
280
|
+
return _jsxs("ol", { className: mergeClassNames(c('array'), className), children: [Array.from(value).map(([id, val], pos) => _jsxs("li", { className: c('array-item'), "data-path": [...(path ?? []), pos].join('.'), children: [_jsx("button", { className: c('delete-prop'), onClick: () => handleDeleteProp(pos), children: "x" }), _jsx("button", { className: c('lift-prop'), disabled: pos === 0, onClick: () => handleLiftProp(pos), children: "\u2191" }), _jsx("button", { className: c('drop-prop'), disabled: pos === value.size - 1, onClick: () => handleDropProp(pos), children: "\u2193" }), _jsx(ValueEditor, { defaultValue: val, onChange: handleChangeProp(pos), preventSameNameKeys: preventSameNameKeys, path: [...(path ?? []), pos] })] }, id)), _jsx("button", { className: c('create-prop'), onClick: handleCreateProp, children: "+" })] });
|
|
281
|
+
};
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { clss } from '@design-edito/tools/agnostic/css/clss/index.js';
|
|
2
|
+
import { isNonNullObject } from '@design-edito/tools/agnostic/objects/is-object/index.js';
|
|
3
|
+
import { jsonEditor as publicClassName } from '../public-classnames.js';
|
|
4
|
+
import cssModule from './styles.module.css';
|
|
5
|
+
export const c = clss(publicClassName, { cssModule });
|
|
6
|
+
export function getValueType(value) {
|
|
7
|
+
if (value === null)
|
|
8
|
+
return 'null';
|
|
9
|
+
if (Array.isArray(value))
|
|
10
|
+
return 'array';
|
|
11
|
+
if (isNonNullObject(value))
|
|
12
|
+
return 'record';
|
|
13
|
+
if (typeof value === 'string')
|
|
14
|
+
return 'string';
|
|
15
|
+
if (typeof value === 'number')
|
|
16
|
+
return 'number';
|
|
17
|
+
return 'boolean';
|
|
18
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { ObserverOptions } from '@design-edito/tools/components/utils/intersection/index.js';
|
|
3
|
+
import type { WithClassName } from '../utils/types.js';
|
|
4
|
+
/**
|
|
5
|
+
* Props for the {@link ControlledListLoader} component.
|
|
6
|
+
*
|
|
7
|
+
* @template T - The item type carried by each page.
|
|
8
|
+
*
|
|
9
|
+
* @property pages - Page positions the consumer wants rendered. Entries outside
|
|
10
|
+
* `firstPagePos`–`lastPagePos` are ignored, as are entries absent from `itemsPages`.
|
|
11
|
+
* @property firstPagePos - Lowest page position that exists. No load button is
|
|
12
|
+
* ever rendered below it.
|
|
13
|
+
* @property lastPagePos - Highest page position that exists. No load button is
|
|
14
|
+
* ever rendered above it.
|
|
15
|
+
* @property itemsPages - Already-loaded items, keyed by page position. The
|
|
16
|
+
* component holds no data of its own and renders only what this map contains.
|
|
17
|
+
* @property filter - Decides whether an item is rendered. Applied before deduplication.
|
|
18
|
+
* @property display - Renders a single item.
|
|
19
|
+
* @property getIdentifier - Item identity, used as React key and to deduplicate.
|
|
20
|
+
* When two rendered items share an identifier, only the later one in page order
|
|
21
|
+
* survives. Returning `undefined` opts an item out of deduplication, and falls
|
|
22
|
+
* back to a positional key.
|
|
23
|
+
* @property loadingPages - Page positions currently being fetched. Exposed on the
|
|
24
|
+
* root element as `data-loading-pages`.
|
|
25
|
+
* @property onLoadPageClicked - Called with the page position a load button targets,
|
|
26
|
+
* either on click or on viewport entry when auto-loading is enabled.
|
|
27
|
+
* @property autoLoadPrevWhenVisible - Wraps the leading load button in an
|
|
28
|
+
* {@link IntersectionObserverComponent} and fires `onLoadPageClicked` when it enters the viewport.
|
|
29
|
+
* @property autoLoadNextWhenVisible - Same, for the trailing load button.
|
|
30
|
+
* @property threshold - How much of a load button has to be in view before it
|
|
31
|
+
* counts as visible, forwarded to each observer.
|
|
32
|
+
* @property root - Their root. Defaults to the viewport.
|
|
33
|
+
* @property rootMargin - Grows or shrinks that root before measuring — the way to
|
|
34
|
+
* start loading a page before its button actually shows.
|
|
35
|
+
* @property className - Additional class name(s) applied to the root element.
|
|
36
|
+
*/
|
|
37
|
+
export type Props<T> = WithClassName<ObserverOptions & {
|
|
38
|
+
pages: number[];
|
|
39
|
+
firstPagePos: number;
|
|
40
|
+
lastPagePos: number;
|
|
41
|
+
itemsPages: Map<number, T[]>;
|
|
42
|
+
filter: (item: T) => boolean;
|
|
43
|
+
display: (item: T) => ReactNode;
|
|
44
|
+
getIdentifier?: (item: T) => string | undefined;
|
|
45
|
+
loadingPages?: number[];
|
|
46
|
+
onLoadPageClicked?: (pagePos: number) => void;
|
|
47
|
+
autoLoadPrevWhenVisible?: boolean;
|
|
48
|
+
autoLoadNextWhenVisible?: boolean;
|
|
49
|
+
}>;
|
|
50
|
+
/**
|
|
51
|
+
* Presentational layer of the paginated list. Renders already-loaded pages as a
|
|
52
|
+
* single flat item list, and interleaves load buttons wherever a page is missing.
|
|
53
|
+
*
|
|
54
|
+
* This component holds no state and performs no fetching. Loaded pages are sorted
|
|
55
|
+
* by position and flattened, so items appear in page order then in item order,
|
|
56
|
+
* with no per-page wrapper element.
|
|
57
|
+
*
|
|
58
|
+
* ### Load buttons
|
|
59
|
+
* One button is rendered per missing page position:
|
|
60
|
+
* - before the first loaded page, unless it is already `firstPagePos`,
|
|
61
|
+
* - in place of each page missing between two loaded pages,
|
|
62
|
+
* - after the last loaded page, unless it is already `lastPagePos`.
|
|
63
|
+
*
|
|
64
|
+
* ### Root element modifiers
|
|
65
|
+
* - `--loading` — at least one page is being fetched.
|
|
66
|
+
*
|
|
67
|
+
* ### CSS elements
|
|
68
|
+
* - `item` — wraps each rendered item. Carries `data-page`.
|
|
69
|
+
* - `load` — a load button. Rendered empty, so its label belongs in CSS.
|
|
70
|
+
* Carries `data-page` and a `prev`, `next` or `gap` modifier.
|
|
71
|
+
* - `load-observer` — the intersection observer wrapping an auto-loading button.
|
|
72
|
+
* Present only when `autoLoadPrevWhenVisible` or `autoLoadNextWhenVisible` is on.
|
|
73
|
+
* Carries a `prev` or `next` modifier.
|
|
74
|
+
*
|
|
75
|
+
* ### Data attributes on the root element
|
|
76
|
+
* - `data-loading-pages` — comma-separated ascending list of the pages being
|
|
77
|
+
* fetched, e.g. `"1,3,6"`. Absent entirely when nothing is loading.
|
|
78
|
+
*
|
|
79
|
+
* @param props - Component properties.
|
|
80
|
+
* @see {@link Props}
|
|
81
|
+
* @returns A flat list of items with load buttons in place of missing pages.
|
|
82
|
+
*
|
|
83
|
+
* @remarks
|
|
84
|
+
* Deduplication runs after `filter`, so an item hidden by `filter` never evicts
|
|
85
|
+
* an earlier duplicate.
|
|
86
|
+
*/
|
|
87
|
+
export declare const ControlledListLoader: <T>({ className, pages, firstPagePos, lastPagePos, itemsPages, filter, display, getIdentifier, loadingPages, onLoadPageClicked, autoLoadPrevWhenVisible, autoLoadNextWhenVisible, threshold, root, rootMargin }: Props<T>) => ReactNode;
|