@dxos/react-ui-form 0.7.5-labs.e27f9b9 → 0.7.5-main.2567c87
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/dist/lib/browser/index.mjs +467 -726
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/node/index.cjs +506 -750
- package/dist/lib/node/index.cjs.map +4 -4
- package/dist/lib/node/meta.json +1 -1
- package/dist/lib/node-esm/index.mjs +467 -726
- package/dist/lib/node-esm/index.mjs.map +4 -4
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/types/src/components/FieldEditor/FieldEditor.d.ts.map +1 -1
- package/dist/types/src/components/Form/Defaults.d.ts +6 -5
- package/dist/types/src/components/Form/Defaults.d.ts.map +1 -1
- package/dist/types/src/components/Form/Form.d.ts +21 -12
- package/dist/types/src/components/Form/Form.d.ts.map +1 -1
- package/dist/types/src/components/Form/Form.stories.d.ts +1 -2
- package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/Input.d.ts +6 -5
- package/dist/types/src/components/Form/Input.d.ts.map +1 -1
- package/dist/types/src/components/Form/custom/GeoPointInput.d.ts +2 -1
- package/dist/types/src/components/Form/custom/GeoPointInput.d.ts.map +1 -1
- package/dist/types/src/components/Form/custom/TupleInput.d.ts +2 -1
- package/dist/types/src/components/Form/custom/TupleInput.d.ts.map +1 -1
- package/dist/types/src/components/Form/custom/index.d.ts +0 -1
- package/dist/types/src/components/Form/custom/index.d.ts.map +1 -1
- package/dist/types/src/components/Form/factory.d.ts +2 -2
- package/dist/types/src/components/Form/factory.d.ts.map +1 -1
- package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
- package/dist/types/src/components/deprecated/FormInput.d.ts +0 -5
- package/dist/types/src/components/deprecated/FormInput.d.ts.map +1 -1
- package/dist/types/src/hooks/useForm.d.ts +11 -10
- package/dist/types/src/hooks/useForm.d.ts.map +1 -1
- package/dist/types/src/translations.d.ts +0 -6
- package/dist/types/src/translations.d.ts.map +1 -1
- package/package.json +27 -28
- package/src/components/FieldEditor/FieldEditor.stories.tsx +1 -1
- package/src/components/FieldEditor/FieldEditor.tsx +6 -7
- package/src/components/Form/Defaults.tsx +33 -20
- package/src/components/Form/Form.stories.tsx +5 -15
- package/src/components/Form/Form.tsx +355 -46
- package/src/components/Form/Input.tsx +6 -5
- package/src/components/Form/custom/GeoPointInput.tsx +15 -6
- package/src/components/Form/custom/TupleInput.tsx +9 -5
- package/src/components/Form/custom/index.ts +0 -1
- package/src/components/Form/factory.tsx +5 -2
- package/src/components/ViewEditor/ViewEditor.tsx +2 -5
- package/src/components/deprecated/FormInput.tsx +7 -20
- package/src/hooks/useForm.ts +49 -57
- package/src/translations.ts +0 -8
- package/dist/types/src/components/Form/ArrayField.d.ts +0 -16
- package/dist/types/src/components/Form/ArrayField.d.ts.map +0 -1
- package/dist/types/src/components/Form/FormActions.d.ts +0 -7
- package/dist/types/src/components/Form/FormActions.d.ts.map +0 -1
- package/dist/types/src/components/Form/FormContent.d.ts +0 -26
- package/dist/types/src/components/Form/FormContent.d.ts.map +0 -1
- package/dist/types/src/components/Form/FormContext.d.ts +0 -20
- package/dist/types/src/components/Form/FormContext.d.ts.map +0 -1
- package/dist/types/src/components/Form/custom/SelectOptionsInput.d.ts +0 -4
- package/dist/types/src/components/Form/custom/SelectOptionsInput.d.ts.map +0 -1
- package/dist/types/src/components/Form/custom/SelectOptionsInput.stories.d.ts +0 -7
- package/dist/types/src/components/Form/custom/SelectOptionsInput.stories.d.ts.map +0 -1
- package/src/components/Form/ArrayField.tsx +0 -111
- package/src/components/Form/FormActions.tsx +0 -41
- package/src/components/Form/FormContent.tsx +0 -173
- package/src/components/Form/FormContext.tsx +0 -48
- package/src/components/Form/custom/SelectOptionsInput.stories.tsx +0 -58
- package/src/components/Form/custom/SelectOptionsInput.tsx +0 -192
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
//
|
|
2
|
-
// Copyright 2025 DXOS.org
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
import { pipe } from 'effect';
|
|
6
|
-
import { capitalize } from 'effect/String';
|
|
7
|
-
import React, { useCallback, useMemo } from 'react';
|
|
8
|
-
|
|
9
|
-
import { AST } from '@dxos/echo-schema';
|
|
10
|
-
import { findNode, getDiscriminatedType, isDiscriminatedUnion, SimpleType } from '@dxos/effect';
|
|
11
|
-
import { invariant } from '@dxos/invariant';
|
|
12
|
-
import { IconButton, useTranslation } from '@dxos/react-ui';
|
|
13
|
-
import { mx } from '@dxos/react-ui-theme';
|
|
14
|
-
import { getSchemaProperties, type SchemaProperty } from '@dxos/schema';
|
|
15
|
-
|
|
16
|
-
import { type ComponentLookup } from './Form';
|
|
17
|
-
import { FormField } from './FormContent';
|
|
18
|
-
import { useFormValues, type FormInputStateProps } from './FormContext';
|
|
19
|
-
import { InputHeader, type InputComponent } from './Input';
|
|
20
|
-
import { translationKey } from '../../translations';
|
|
21
|
-
|
|
22
|
-
const padding = 'px-2';
|
|
23
|
-
|
|
24
|
-
type ArrayFieldProps = {
|
|
25
|
-
property: SchemaProperty<any>;
|
|
26
|
-
readonly?: boolean;
|
|
27
|
-
inputProps: FormInputStateProps;
|
|
28
|
-
path?: (string | number)[];
|
|
29
|
-
Custom?: Partial<Record<string, InputComponent>>;
|
|
30
|
-
lookupComponent?: ComponentLookup;
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
export const ArrayField = ({ property, readonly, path, inputProps, Custom, lookupComponent }: ArrayFieldProps) => {
|
|
34
|
-
const { t } = useTranslation(translationKey);
|
|
35
|
-
const { ast, name, type, title } = property;
|
|
36
|
-
const values = useFormValues(path ?? []) as any[];
|
|
37
|
-
invariant(Array.isArray(values), `Values at path ${path?.join('.')} must be an array.`);
|
|
38
|
-
const label = title ?? pipe(name, capitalize);
|
|
39
|
-
|
|
40
|
-
const tupleType = findNode(ast, AST.isTupleType);
|
|
41
|
-
const elementType = (tupleType as AST.TupleType | undefined)?.rest[0]?.type;
|
|
42
|
-
|
|
43
|
-
const getDefaultObjectValue = (typeNode: AST.AST): any => {
|
|
44
|
-
const baseNode = findNode(typeNode, isDiscriminatedUnion);
|
|
45
|
-
const typeLiteral = baseNode ? getDiscriminatedType(baseNode, {}) : findNode(typeNode, AST.isTypeLiteral);
|
|
46
|
-
if (!typeLiteral) {
|
|
47
|
-
return {};
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
return Object.fromEntries(getSchemaProperties(typeLiteral, {}).map((prop) => [prop.name, prop.defaultValue]));
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
const newValue = useMemo(() => {
|
|
54
|
-
if (type === 'object' && elementType) {
|
|
55
|
-
return getDefaultObjectValue(elementType);
|
|
56
|
-
} else {
|
|
57
|
-
return SimpleType.getDefaultValue(type);
|
|
58
|
-
}
|
|
59
|
-
}, [type, elementType]);
|
|
60
|
-
|
|
61
|
-
const handleAdd = useCallback(() => {
|
|
62
|
-
inputProps.onValueChange(type, [...values, newValue]);
|
|
63
|
-
}, [type, elementType, inputProps, newValue, values]);
|
|
64
|
-
|
|
65
|
-
const handleRemove = useCallback(
|
|
66
|
-
(index: number) => {
|
|
67
|
-
const newValues = values.filter((_, i) => i !== index);
|
|
68
|
-
inputProps.onValueChange(type, newValues);
|
|
69
|
-
},
|
|
70
|
-
[type, inputProps, values],
|
|
71
|
-
);
|
|
72
|
-
|
|
73
|
-
if (!elementType) {
|
|
74
|
-
return null;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
return (
|
|
78
|
-
<div role='none' className={mx(padding)}>
|
|
79
|
-
<InputHeader>{label}</InputHeader>
|
|
80
|
-
<div role='none' className='flex flex-col gap-1'>
|
|
81
|
-
{values.map((_value, index) => (
|
|
82
|
-
<div key={index} role='none' className='flex items-center gap-1'>
|
|
83
|
-
<div role='none' className='flex-1'>
|
|
84
|
-
<FormField
|
|
85
|
-
property={{
|
|
86
|
-
...property,
|
|
87
|
-
array: false, // NOTE(ZaymonFC): This breaks arrays of arrays but ¯\_(ツ)_/¯. Ping me if you need that.
|
|
88
|
-
ast: elementType,
|
|
89
|
-
}}
|
|
90
|
-
path={[...(path ?? []), index]}
|
|
91
|
-
readonly={readonly}
|
|
92
|
-
inline
|
|
93
|
-
Custom={Custom}
|
|
94
|
-
lookupComponent={lookupComponent}
|
|
95
|
-
/>
|
|
96
|
-
</div>
|
|
97
|
-
<IconButton
|
|
98
|
-
icon='ph--trash--regular'
|
|
99
|
-
iconOnly
|
|
100
|
-
label={t('button remove')}
|
|
101
|
-
onClick={() => handleRemove(index)}
|
|
102
|
-
/>
|
|
103
|
-
</div>
|
|
104
|
-
))}
|
|
105
|
-
</div>
|
|
106
|
-
<div role='none' className='flex justify-between items-center plb-1'>
|
|
107
|
-
<IconButton icon='ph--plus--regular' iconOnly label={t('button add')} onClick={handleAdd} />
|
|
108
|
-
</div>
|
|
109
|
-
</div>
|
|
110
|
-
);
|
|
111
|
-
};
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
//
|
|
2
|
-
// Copyright 2025 DXOS.org
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
import React from 'react';
|
|
6
|
-
|
|
7
|
-
import { IconButton, useTranslation } from '@dxos/react-ui';
|
|
8
|
-
import { mx } from '@dxos/react-ui-theme';
|
|
9
|
-
|
|
10
|
-
import { useFormContext } from './FormContext';
|
|
11
|
-
import { translationKey } from '../../translations';
|
|
12
|
-
|
|
13
|
-
export type FormActionsProps = {
|
|
14
|
-
readonly?: boolean;
|
|
15
|
-
onCancel?: () => void;
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
export const FormActions = ({ onCancel, readonly }: FormActionsProps) => {
|
|
19
|
-
const { t } = useTranslation(translationKey);
|
|
20
|
-
const { canSave, handleSave } = useFormContext();
|
|
21
|
-
|
|
22
|
-
return (
|
|
23
|
-
<div role='none' className='flex justify-center'>
|
|
24
|
-
<div role='none' className={mx(onCancel && !readonly && 'grid grid-cols-2 gap-2')}>
|
|
25
|
-
{onCancel && !readonly && (
|
|
26
|
-
<IconButton data-testid='cancel-button' icon='ph--x--regular' label={t('button cancel')} onClick={onCancel} />
|
|
27
|
-
)}
|
|
28
|
-
{handleSave && (
|
|
29
|
-
<IconButton
|
|
30
|
-
type='submit'
|
|
31
|
-
data-testid='save-button'
|
|
32
|
-
disabled={!canSave}
|
|
33
|
-
icon='ph--check--regular'
|
|
34
|
-
label={t('button save')}
|
|
35
|
-
onClick={handleSave}
|
|
36
|
-
/>
|
|
37
|
-
)}
|
|
38
|
-
</div>
|
|
39
|
-
</div>
|
|
40
|
-
);
|
|
41
|
-
};
|
|
@@ -1,173 +0,0 @@
|
|
|
1
|
-
//
|
|
2
|
-
// Copyright 2024 DXOS.org
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
import { pipe } from 'effect';
|
|
6
|
-
import { capitalize } from 'effect/String';
|
|
7
|
-
import React, { useMemo } from 'react';
|
|
8
|
-
|
|
9
|
-
import { AST, S } from '@dxos/echo-schema';
|
|
10
|
-
import { createJsonPath, findNode, getDiscriminatedType, isDiscriminatedUnion } from '@dxos/effect';
|
|
11
|
-
import { mx } from '@dxos/react-ui-theme';
|
|
12
|
-
import { getSchemaProperties, type SchemaProperty } from '@dxos/schema';
|
|
13
|
-
import { isNotFalsy } from '@dxos/util';
|
|
14
|
-
|
|
15
|
-
import { ArrayField } from './ArrayField';
|
|
16
|
-
import { SelectInput } from './Defaults';
|
|
17
|
-
import { type ComponentLookup } from './Form';
|
|
18
|
-
import { useInputProps, useFormValues } from './FormContext';
|
|
19
|
-
import { type InputComponent } from './Input';
|
|
20
|
-
import { getInputComponent } from './factory';
|
|
21
|
-
|
|
22
|
-
export type FormContentProps = {
|
|
23
|
-
schema: S.Schema.All;
|
|
24
|
-
path?: (string | number)[];
|
|
25
|
-
filter?: (props: SchemaProperty<any>[]) => SchemaProperty<any>[];
|
|
26
|
-
sort?: string[];
|
|
27
|
-
readonly?: boolean;
|
|
28
|
-
lookupComponent?: ComponentLookup;
|
|
29
|
-
Custom?: Partial<Record<string, InputComponent>>;
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
export type FormFieldProps = {
|
|
33
|
-
property: SchemaProperty<any>;
|
|
34
|
-
path?: (string | number)[];
|
|
35
|
-
readonly?: boolean;
|
|
36
|
-
/** Used to indicate if input should be presented inline (e.g. for array items). */
|
|
37
|
-
inline?: boolean;
|
|
38
|
-
lookupComponent?: ComponentLookup;
|
|
39
|
-
Custom?: Partial<Record<string, InputComponent>>;
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
export const FormField = ({ property, path, readonly, inline, lookupComponent, Custom }: FormFieldProps) => {
|
|
43
|
-
const inputProps = useInputProps(path);
|
|
44
|
-
const { ast, name, type, format, title, description, options, examples, array } = property;
|
|
45
|
-
|
|
46
|
-
const label = useMemo(() => title ?? pipe(name, capitalize), [title, name]);
|
|
47
|
-
const placeholder = useMemo(
|
|
48
|
-
() => (examples?.length ? `Example: "${examples[0]}"` : description),
|
|
49
|
-
[examples, description],
|
|
50
|
-
);
|
|
51
|
-
|
|
52
|
-
const FoundComponent = lookupComponent?.({
|
|
53
|
-
prop: name,
|
|
54
|
-
schema: S.make(ast),
|
|
55
|
-
inputProps: {
|
|
56
|
-
type,
|
|
57
|
-
format,
|
|
58
|
-
label,
|
|
59
|
-
disabled: readonly,
|
|
60
|
-
placeholder,
|
|
61
|
-
...inputProps,
|
|
62
|
-
},
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
if (FoundComponent) {
|
|
66
|
-
return <div role='none'>{FoundComponent}</div>;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const jsonPath = createJsonPath(path ?? []);
|
|
70
|
-
const CustomComponent = Custom?.[jsonPath];
|
|
71
|
-
if (CustomComponent) {
|
|
72
|
-
return (
|
|
73
|
-
// TODO(ZaymonFC): We should try to remove this div wrapper.
|
|
74
|
-
<div role='none'>
|
|
75
|
-
<CustomComponent
|
|
76
|
-
type={type}
|
|
77
|
-
format={format}
|
|
78
|
-
label={label}
|
|
79
|
-
inputOnly={inline}
|
|
80
|
-
placeholder={placeholder}
|
|
81
|
-
disabled={readonly}
|
|
82
|
-
{...inputProps}
|
|
83
|
-
/>
|
|
84
|
-
</div>
|
|
85
|
-
);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
if (array) {
|
|
89
|
-
return <ArrayField property={property} path={path} inputProps={inputProps} readonly={readonly} Custom={Custom} />;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const InputComponent = getInputComponent(type, format);
|
|
93
|
-
if (InputComponent) {
|
|
94
|
-
return (
|
|
95
|
-
<div role='none'>
|
|
96
|
-
<InputComponent
|
|
97
|
-
type={type}
|
|
98
|
-
format={format}
|
|
99
|
-
label={label}
|
|
100
|
-
inputOnly={inline}
|
|
101
|
-
placeholder={placeholder}
|
|
102
|
-
disabled={readonly}
|
|
103
|
-
{...inputProps}
|
|
104
|
-
/>
|
|
105
|
-
</div>
|
|
106
|
-
);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
if (options) {
|
|
110
|
-
return (
|
|
111
|
-
<div role='none'>
|
|
112
|
-
<SelectInput
|
|
113
|
-
type={type}
|
|
114
|
-
format={format}
|
|
115
|
-
disabled={readonly}
|
|
116
|
-
inputOnly={inline}
|
|
117
|
-
label={label}
|
|
118
|
-
options={options.map((option) => ({ value: option, label: String(option) }))}
|
|
119
|
-
placeholder={placeholder}
|
|
120
|
-
{...inputProps}
|
|
121
|
-
/>
|
|
122
|
-
</div>
|
|
123
|
-
);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
if (type === 'object') {
|
|
127
|
-
const baseNode = findNode(ast, isDiscriminatedUnion);
|
|
128
|
-
const typeLiteral = baseNode
|
|
129
|
-
? getDiscriminatedType(baseNode, inputProps.getValue() as any)
|
|
130
|
-
: findNode(ast, AST.isTypeLiteral);
|
|
131
|
-
|
|
132
|
-
if (typeLiteral) {
|
|
133
|
-
return (
|
|
134
|
-
<div role='none'>
|
|
135
|
-
{!inline && <div>{label}</div>}
|
|
136
|
-
<FormFields schema={S.make(typeLiteral)} path={path} readonly={readonly} Custom={Custom} />
|
|
137
|
-
</div>
|
|
138
|
-
);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
return null;
|
|
143
|
-
};
|
|
144
|
-
|
|
145
|
-
export const FormFields = ({ schema, path, filter, sort, readonly, lookupComponent, Custom }: FormContentProps) => {
|
|
146
|
-
const values = useFormValues(path);
|
|
147
|
-
|
|
148
|
-
const properties = useMemo(() => {
|
|
149
|
-
const props = getSchemaProperties(schema.ast, values);
|
|
150
|
-
const filtered = filter ? filter(props) : props;
|
|
151
|
-
|
|
152
|
-
return sort ? filtered.sort((a, b) => sort.indexOf(a.name) - sort.indexOf(b.name)) : filtered;
|
|
153
|
-
}, [schema, values, filter, sort]);
|
|
154
|
-
|
|
155
|
-
return (
|
|
156
|
-
<div role='form' className={mx('flex flex-col w-full gap-2 py-2')}>
|
|
157
|
-
{properties
|
|
158
|
-
.map((property) => {
|
|
159
|
-
return (
|
|
160
|
-
<FormField
|
|
161
|
-
key={property.name}
|
|
162
|
-
property={property}
|
|
163
|
-
path={[...(path ?? []), property.name]}
|
|
164
|
-
readonly={readonly}
|
|
165
|
-
lookupComponent={lookupComponent}
|
|
166
|
-
Custom={Custom}
|
|
167
|
-
/>
|
|
168
|
-
);
|
|
169
|
-
})
|
|
170
|
-
.filter(isNotFalsy)}
|
|
171
|
-
</div>
|
|
172
|
-
);
|
|
173
|
-
};
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
//
|
|
2
|
-
// Copyright 2025 DXOS.org
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
import React, { createContext, useContext, type FocusEvent, type PropsWithChildren } from 'react';
|
|
6
|
-
|
|
7
|
-
import { raise } from '@dxos/debug';
|
|
8
|
-
import { type BaseObject, getValue } from '@dxos/echo-schema';
|
|
9
|
-
import { createJsonPath, type SimpleType } from '@dxos/effect';
|
|
10
|
-
|
|
11
|
-
import { type FormHandler, type FormOptions, useForm } from '../../hooks';
|
|
12
|
-
|
|
13
|
-
type FormContextValue<T extends BaseObject> = FormHandler<T>;
|
|
14
|
-
|
|
15
|
-
const FormContext = createContext<FormContextValue<any> | undefined>(undefined);
|
|
16
|
-
|
|
17
|
-
export const useFormContext = <T extends BaseObject>(): FormContextValue<T> => {
|
|
18
|
-
return useContext(FormContext) ?? raise(new Error('Missing FormContext'));
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
export const useFormValues = (path: (string | number)[] = []): any => {
|
|
22
|
-
const { values: formValues } = useFormContext();
|
|
23
|
-
const jsonPath = createJsonPath(path);
|
|
24
|
-
return getValue(formValues, jsonPath) as BaseObject;
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
export type FormInputStateProps = {
|
|
28
|
-
getStatus: () => { status?: 'error'; error?: string };
|
|
29
|
-
getValue: <V>() => V | undefined;
|
|
30
|
-
onValueChange: (type: SimpleType, value: any) => void;
|
|
31
|
-
onBlur: (event: FocusEvent<HTMLElement>) => void;
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
export const useInputProps = (path: (string | number)[] = []): FormInputStateProps => {
|
|
35
|
-
const { getStatus, getValue: getFormValue, onValueChange, onTouched } = useFormContext();
|
|
36
|
-
|
|
37
|
-
return {
|
|
38
|
-
getStatus: () => getStatus(path),
|
|
39
|
-
getValue: () => getFormValue(path),
|
|
40
|
-
onValueChange: (type: SimpleType, value: any) => onValueChange(path, type, value),
|
|
41
|
-
onBlur: () => onTouched(path),
|
|
42
|
-
};
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
export const FormProvider = ({ children, ...formOptions }: PropsWithChildren<FormOptions<any>>) => {
|
|
46
|
-
const formHandler = useForm(formOptions);
|
|
47
|
-
return <FormContext.Provider value={formHandler}>{children}</FormContext.Provider>;
|
|
48
|
-
};
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
//
|
|
2
|
-
// Copyright 2025 DXOS.org
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
import '@dxos-theme';
|
|
6
|
-
|
|
7
|
-
import { type Meta, type StoryObj } from '@storybook/react';
|
|
8
|
-
import React, { useState } from 'react';
|
|
9
|
-
|
|
10
|
-
import { S, SelectOptionSchema, type SelectOption } from '@dxos/echo-schema';
|
|
11
|
-
import { withLayout, withTheme } from '@dxos/storybook-utils';
|
|
12
|
-
|
|
13
|
-
import { SelectOptionInput } from './SelectOptionsInput';
|
|
14
|
-
import translations from '../../../translations';
|
|
15
|
-
import { TestLayout, TestPanel } from '../../testing';
|
|
16
|
-
import { Form } from '../Form';
|
|
17
|
-
|
|
18
|
-
const schema = S.Struct({
|
|
19
|
-
options: S.Array(SelectOptionSchema).pipe(S.mutable),
|
|
20
|
-
}).pipe(S.mutable);
|
|
21
|
-
|
|
22
|
-
const DefaultStory = () => {
|
|
23
|
-
const [values, setValues] = useState<{ options: SelectOption[] }>({
|
|
24
|
-
// TODO(Zaymon): Pick colors from our limited set.
|
|
25
|
-
options: [
|
|
26
|
-
{ id: 'opt_1', title: 'First option', color: 'amber' },
|
|
27
|
-
{ id: 'opt_2', title: 'Second option', color: 'emerald' },
|
|
28
|
-
{ id: 'opt_3', title: 'Third option', color: 'rose' },
|
|
29
|
-
],
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
return (
|
|
33
|
-
<TestLayout>
|
|
34
|
-
<TestPanel>
|
|
35
|
-
<Form
|
|
36
|
-
schema={schema}
|
|
37
|
-
values={values}
|
|
38
|
-
onSave={(vals) => setValues(vals)}
|
|
39
|
-
Custom={{ options: SelectOptionInput }}
|
|
40
|
-
/>
|
|
41
|
-
</TestPanel>
|
|
42
|
-
</TestLayout>
|
|
43
|
-
);
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
const meta: Meta = {
|
|
47
|
-
title: 'ui/react-ui-form/SelectOptionsInput',
|
|
48
|
-
component: SelectOptionInput,
|
|
49
|
-
decorators: [withTheme, withLayout({ tooltips: true })],
|
|
50
|
-
parameters: { translations },
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
export default meta;
|
|
54
|
-
type Story = StoryObj;
|
|
55
|
-
|
|
56
|
-
export const Default: Story = {
|
|
57
|
-
render: DefaultStory,
|
|
58
|
-
};
|
|
@@ -1,192 +0,0 @@
|
|
|
1
|
-
//
|
|
2
|
-
// Copyright 2025 DXOS.org
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
import React, { useCallback, useEffect, useState, type ChangeEvent, useRef } from 'react';
|
|
6
|
-
|
|
7
|
-
import { type SelectOption } from '@dxos/echo-schema';
|
|
8
|
-
import { PublicKey } from '@dxos/keys';
|
|
9
|
-
import { type ChromaticPalette, Icon, IconButton, Input, Tag, Toolbar, useTranslation } from '@dxos/react-ui';
|
|
10
|
-
import { List } from '@dxos/react-ui-list';
|
|
11
|
-
import { HuePickerToolbarButton } from '@dxos/react-ui-pickers';
|
|
12
|
-
import { hueTokenThemes, mx } from '@dxos/react-ui-theme';
|
|
13
|
-
|
|
14
|
-
import { translationKey } from '../../../translations';
|
|
15
|
-
import { InputHeader, type InputProps } from '../Input';
|
|
16
|
-
|
|
17
|
-
// TODO(ZaymonFC): Handle disabled.
|
|
18
|
-
export const SelectOptionInput = ({ type, label, disabled, getStatus, getValue, onValueChange }: InputProps) => {
|
|
19
|
-
const [selected, setSelectedId] = useState<string | null>(null);
|
|
20
|
-
const [isNewOption, setIsNewOption] = useState(false);
|
|
21
|
-
const inputRef = useRef<HTMLInputElement>(null);
|
|
22
|
-
const { t } = useTranslation(translationKey);
|
|
23
|
-
const { status, error } = getStatus();
|
|
24
|
-
const options = getValue<SelectOption[] | undefined>();
|
|
25
|
-
|
|
26
|
-
// Initialization.
|
|
27
|
-
useEffect(() => {
|
|
28
|
-
if (options === undefined) {
|
|
29
|
-
onValueChange(type, []);
|
|
30
|
-
}
|
|
31
|
-
}, [options, onValueChange, type]);
|
|
32
|
-
|
|
33
|
-
const randomHue = () => {
|
|
34
|
-
const usedHues = new Set(options?.map((option) => option.color) ?? []);
|
|
35
|
-
const hueKeys = Object.keys(hueTokenThemes) as Array<keyof typeof hueTokenThemes>;
|
|
36
|
-
|
|
37
|
-
// Pick a random unused option from hueTokenThemes.
|
|
38
|
-
const unusedHues = hueKeys.filter((hue) => !usedHues.has(hue));
|
|
39
|
-
if (unusedHues.length > 0) {
|
|
40
|
-
return unusedHues[Math.floor(Math.random() * unusedHues.length)];
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// Pick a random one that hasn't been used in the last three options
|
|
44
|
-
const lastThree = new Set(options?.slice(-3).map((option) => option.color) ?? []);
|
|
45
|
-
const availableHues = hueKeys.filter((hue) => !lastThree.has(hue));
|
|
46
|
-
return (
|
|
47
|
-
availableHues[Math.floor(Math.random() * availableHues.length)] ??
|
|
48
|
-
hueKeys[Math.floor(Math.random() * hueKeys.length)]
|
|
49
|
-
);
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
const handleAdd = useCallback(() => {
|
|
53
|
-
const newId = PublicKey.random().truncate();
|
|
54
|
-
const newOption = { id: newId, title: '', color: randomHue() };
|
|
55
|
-
onValueChange(type, [...(options ?? []), newOption]);
|
|
56
|
-
setSelectedId(newId);
|
|
57
|
-
setIsNewOption(true);
|
|
58
|
-
}, [options, type, onValueChange]);
|
|
59
|
-
|
|
60
|
-
const handleDelete = useCallback(
|
|
61
|
-
(id: string) => {
|
|
62
|
-
const newOptions = options?.filter((option) => option.id !== id) ?? [];
|
|
63
|
-
onValueChange(type, newOptions);
|
|
64
|
-
setSelectedId(null);
|
|
65
|
-
},
|
|
66
|
-
[options, type, onValueChange],
|
|
67
|
-
);
|
|
68
|
-
|
|
69
|
-
const handleMove = useCallback(
|
|
70
|
-
(from: number, to: number) => {
|
|
71
|
-
if (!options) {
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const newOptions = [...options];
|
|
76
|
-
const [removed] = newOptions.splice(from, 1);
|
|
77
|
-
newOptions.splice(to, 0, removed);
|
|
78
|
-
onValueChange(type, newOptions);
|
|
79
|
-
},
|
|
80
|
-
[options, type, onValueChange],
|
|
81
|
-
);
|
|
82
|
-
|
|
83
|
-
const handleClick = useCallback((id: string) => {
|
|
84
|
-
setSelectedId((current) => (current === id ? null : id));
|
|
85
|
-
}, []);
|
|
86
|
-
|
|
87
|
-
const handleTitleChange = useCallback(
|
|
88
|
-
(id: string) => (e: ChangeEvent<HTMLInputElement>) => {
|
|
89
|
-
const newOptions = options?.map((o) => (o.id === id ? { ...o, title: e.target.value } : o));
|
|
90
|
-
onValueChange(type, newOptions ?? []);
|
|
91
|
-
},
|
|
92
|
-
[options, type, onValueChange],
|
|
93
|
-
);
|
|
94
|
-
|
|
95
|
-
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
96
|
-
if (e.key === 'Enter') {
|
|
97
|
-
setSelectedId(null);
|
|
98
|
-
}
|
|
99
|
-
}, []);
|
|
100
|
-
|
|
101
|
-
const handleColorChange = useCallback(
|
|
102
|
-
(id: string) => (hue: string) => {
|
|
103
|
-
const newOptions = options?.map((o) => (o.id === id ? { ...o, color: hue } : o));
|
|
104
|
-
onValueChange(type, newOptions ?? []);
|
|
105
|
-
},
|
|
106
|
-
[options, type, onValueChange],
|
|
107
|
-
);
|
|
108
|
-
|
|
109
|
-
useEffect(() => {
|
|
110
|
-
if (selected && isNewOption && inputRef.current) {
|
|
111
|
-
inputRef.current.focus();
|
|
112
|
-
setIsNewOption(false);
|
|
113
|
-
}
|
|
114
|
-
}, [selected, isNewOption]);
|
|
115
|
-
|
|
116
|
-
return (
|
|
117
|
-
<Input.Root validationValence={status}>
|
|
118
|
-
<InputHeader error={error}>
|
|
119
|
-
<Input.Label>{label}</Input.Label>
|
|
120
|
-
</InputHeader>
|
|
121
|
-
<div role='none'>
|
|
122
|
-
{options && (
|
|
123
|
-
<List.Root items={options} isItem={(item) => true} onMove={handleMove}>
|
|
124
|
-
{({ items }) => (
|
|
125
|
-
<div role='list' className='w-full overflow-auto'>
|
|
126
|
-
{items?.map((item) => (
|
|
127
|
-
<div key={item.id}>
|
|
128
|
-
<List.Item
|
|
129
|
-
role='button'
|
|
130
|
-
item={item}
|
|
131
|
-
classNames={mx('p-1 plb-2', 'flex flex-col', 'cursor-pointer', 'hover:bg-hoverOverlay')}
|
|
132
|
-
aria-expanded={selected === item.id}
|
|
133
|
-
>
|
|
134
|
-
<div className='flex items-center'>
|
|
135
|
-
<List.ItemDragHandle />
|
|
136
|
-
<List.ItemTitle onClick={() => handleClick(item.id)} classNames='flex-1'>
|
|
137
|
-
{/* TODO(ZaymonFC): Move spacer into Tag component. */}
|
|
138
|
-
<Tag palette={item.color as ChromaticPalette}>{item.title || '\u200b'}</Tag>
|
|
139
|
-
</List.ItemTitle>
|
|
140
|
-
<Icon
|
|
141
|
-
icon={selected === item.id ? 'ph--caret-down--regular' : 'ph--caret-right--regular'}
|
|
142
|
-
onClick={() => handleClick(item.id)}
|
|
143
|
-
/>
|
|
144
|
-
</div>
|
|
145
|
-
{selected === item.id && (
|
|
146
|
-
<div className='mis-4 mbs-2 flex flex-col gap-1'>
|
|
147
|
-
{/* 16px to match drag handle width. */}
|
|
148
|
-
<Input.Label classNames='text-xs'>{t('select option label')}</Input.Label>
|
|
149
|
-
<div className='flex flex-row items-center gap-1'>
|
|
150
|
-
<Input.TextInput
|
|
151
|
-
placeholder={t('select option label placeholder')}
|
|
152
|
-
ref={selected === item.id ? inputRef : undefined}
|
|
153
|
-
value={item.title}
|
|
154
|
-
onChange={handleTitleChange(item.id)}
|
|
155
|
-
onKeyDown={handleKeyDown}
|
|
156
|
-
classNames='flex-1'
|
|
157
|
-
/>
|
|
158
|
-
<Toolbar.Root classNames='p-0 m-0 !is-auto'>
|
|
159
|
-
<HuePickerToolbarButton hue={item.color} onChangeHue={handleColorChange(item.id)} />
|
|
160
|
-
</Toolbar.Root>
|
|
161
|
-
<IconButton
|
|
162
|
-
icon='ph--trash--fill'
|
|
163
|
-
iconOnly
|
|
164
|
-
label={t('select option delete')}
|
|
165
|
-
onClick={() => handleDelete(item.id)}
|
|
166
|
-
/>
|
|
167
|
-
</div>
|
|
168
|
-
</div>
|
|
169
|
-
)}
|
|
170
|
-
</List.Item>
|
|
171
|
-
</div>
|
|
172
|
-
))}
|
|
173
|
-
<div role='listitem'>
|
|
174
|
-
<div
|
|
175
|
-
role='button'
|
|
176
|
-
className={mx('p-1 plb-2', 'flex flex-col', 'cursor-pointer', 'hover:bg-hoverOverlay')}
|
|
177
|
-
onClick={handleAdd}
|
|
178
|
-
>
|
|
179
|
-
<div className='flex items-center gap-1'>
|
|
180
|
-
<Icon icon='ph--plus--regular' />
|
|
181
|
-
<span className='text-sm'>{t('select option add')}</span>
|
|
182
|
-
</div>
|
|
183
|
-
</div>
|
|
184
|
-
</div>
|
|
185
|
-
</div>
|
|
186
|
-
)}
|
|
187
|
-
</List.Root>
|
|
188
|
-
)}
|
|
189
|
-
</div>
|
|
190
|
-
</Input.Root>
|
|
191
|
-
);
|
|
192
|
-
};
|