@mxfb/tools 1.0.5 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/components/Date/index.d.ts +45 -0
  2. package/components/Date/index.js +65 -0
  3. package/components/Date/styles.module.css +0 -0
  4. package/components/Date/types.d.ts +39 -0
  5. package/components/Date/types.js +37 -0
  6. package/components/Embed/SpotifyEmbed/index.d.ts +13 -0
  7. package/components/Embed/SpotifyEmbed/index.js +38 -0
  8. package/components/Embed/SpotifyEmbed/styles.module.css +0 -0
  9. package/components/Embed/VimeoEmbed/index.d.ts +14 -0
  10. package/components/Embed/VimeoEmbed/index.js +40 -0
  11. package/components/Embed/VimeoEmbed/styles.module.css +0 -0
  12. package/components/Embed/YoutubeEmbed/index.d.ts +18 -0
  13. package/components/Embed/YoutubeEmbed/index.js +45 -0
  14. package/components/Embed/YoutubeEmbed/styles.module.css +0 -0
  15. package/components/Embed/index.d.ts +44 -0
  16. package/components/Embed/index.js +71 -0
  17. package/components/Embed/styles.module.css +0 -0
  18. package/components/Embed/types.d.ts +22 -0
  19. package/components/Embed/types.js +7 -0
  20. package/components/JsonEditor/index.d.ts +227 -0
  21. package/components/JsonEditor/index.js +281 -0
  22. package/components/JsonEditor/styles.module.css +0 -0
  23. package/components/JsonEditor/types.d.ts +5 -0
  24. package/components/JsonEditor/types.js +1 -0
  25. package/components/JsonEditor/utils.d.ts +3 -0
  26. package/components/JsonEditor/utils.js +18 -0
  27. package/components/ListLoader/index.controlled.d.ts +87 -0
  28. package/components/ListLoader/index.controlled.js +108 -0
  29. package/components/ListLoader/index.d.ts +76 -0
  30. package/components/ListLoader/index.js +162 -0
  31. package/components/ListLoader/styles.module.css +0 -0
  32. package/components/index.d.ts +4 -0
  33. package/components/index.js +4 -0
  34. package/components/public-classnames.d.ts +6 -0
  35. package/components/public-classnames.js +6 -0
  36. package/package.json +64 -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,5 @@
1
+ export type JsonPrimitive = string | number | boolean | null;
2
+ export type JsonValue = JsonPrimitive | JsonValue[] | {
3
+ [k: string]: JsonValue;
4
+ };
5
+ export type ValueType = 'string' | 'number' | 'boolean' | 'null' | 'array' | 'record';
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ import type { JsonValue, ValueType } from './types.js';
2
+ export declare const c: import("@design-edito/tools/agnostic/css/clss/index.js").ClssMaker;
3
+ export declare function getValueType(value: JsonValue): ValueType;
@@ -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;