@dxos/react-ui-form 0.7.5-main.9d2a38b → 0.7.5-main.c41020f

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 (83) hide show
  1. package/dist/lib/browser/index.mjs +1014 -571
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/node/index.cjs +1031 -599
  5. package/dist/lib/node/index.cjs.map +4 -4
  6. package/dist/lib/node/meta.json +1 -1
  7. package/dist/lib/node-esm/index.mjs +1014 -571
  8. package/dist/lib/node-esm/index.mjs.map +4 -4
  9. package/dist/lib/node-esm/meta.json +1 -1
  10. package/dist/types/src/components/FieldEditor/FieldEditor.d.ts +1 -2
  11. package/dist/types/src/components/FieldEditor/FieldEditor.d.ts.map +1 -1
  12. package/dist/types/src/components/Form/ArrayField.d.ts +15 -0
  13. package/dist/types/src/components/Form/ArrayField.d.ts.map +1 -0
  14. package/dist/types/src/components/Form/Defaults.d.ts +6 -7
  15. package/dist/types/src/components/Form/Defaults.d.ts.map +1 -1
  16. package/dist/types/src/components/Form/Form.d.ts +12 -21
  17. package/dist/types/src/components/Form/Form.d.ts.map +1 -1
  18. package/dist/types/src/components/Form/Form.stories.d.ts +2 -1
  19. package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
  20. package/dist/types/src/components/Form/FormActions.d.ts +6 -0
  21. package/dist/types/src/components/Form/FormActions.d.ts.map +1 -0
  22. package/dist/types/src/components/Form/FormContent.d.ts +25 -0
  23. package/dist/types/src/components/Form/FormContent.d.ts.map +1 -0
  24. package/dist/types/src/components/Form/FormContext.d.ts +23 -0
  25. package/dist/types/src/components/Form/FormContext.d.ts.map +1 -0
  26. package/dist/types/src/components/Form/Input.d.ts +7 -8
  27. package/dist/types/src/components/Form/Input.d.ts.map +1 -1
  28. package/dist/types/src/components/Form/custom/GeoPointInput.d.ts +1 -3
  29. package/dist/types/src/components/Form/custom/GeoPointInput.d.ts.map +1 -1
  30. package/dist/types/src/components/Form/custom/SelectOptionsInput.d.ts +3 -0
  31. package/dist/types/src/components/Form/custom/SelectOptionsInput.d.ts.map +1 -0
  32. package/dist/types/src/components/Form/custom/SelectOptionsInput.stories.d.ts +7 -0
  33. package/dist/types/src/components/Form/custom/SelectOptionsInput.stories.d.ts.map +1 -0
  34. package/dist/types/src/components/Form/custom/TupleInput.d.ts +2 -4
  35. package/dist/types/src/components/Form/custom/TupleInput.d.ts.map +1 -1
  36. package/dist/types/src/components/Form/custom/index.d.ts +1 -0
  37. package/dist/types/src/components/Form/custom/index.d.ts.map +1 -1
  38. package/dist/types/src/components/Form/factory.d.ts +2 -2
  39. package/dist/types/src/components/Form/factory.d.ts.map +1 -1
  40. package/dist/types/src/components/Form/index.d.ts +2 -0
  41. package/dist/types/src/components/Form/index.d.ts.map +1 -1
  42. package/dist/types/src/components/ViewEditor/Test.stories.d.ts +1 -2
  43. package/dist/types/src/components/ViewEditor/Test.stories.d.ts.map +1 -1
  44. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts +1 -2
  45. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
  46. package/dist/types/src/components/deprecated/Form.d.ts +1 -2
  47. package/dist/types/src/components/deprecated/Form.d.ts.map +1 -1
  48. package/dist/types/src/components/deprecated/Form.stories.d.ts +1 -2
  49. package/dist/types/src/components/deprecated/Form.stories.d.ts.map +1 -1
  50. package/dist/types/src/components/deprecated/FormInput.d.ts +6 -1
  51. package/dist/types/src/components/deprecated/FormInput.d.ts.map +1 -1
  52. package/dist/types/src/components/testing.d.ts +3 -3
  53. package/dist/types/src/components/testing.d.ts.map +1 -1
  54. package/dist/types/src/hooks/useForm.d.ts +11 -11
  55. package/dist/types/src/hooks/useForm.d.ts.map +1 -1
  56. package/dist/types/src/translations.d.ts +6 -0
  57. package/dist/types/src/translations.d.ts.map +1 -1
  58. package/dist/types/src/util/field.d.ts.map +1 -1
  59. package/dist/types/src/util/formatting.d.ts.map +1 -1
  60. package/package.json +29 -27
  61. package/src/components/FieldEditor/FieldEditor.stories.tsx +1 -1
  62. package/src/components/FieldEditor/FieldEditor.tsx +7 -6
  63. package/src/components/Form/ArrayField.tsx +111 -0
  64. package/src/components/Form/Defaults.tsx +78 -34
  65. package/src/components/Form/Form.stories.tsx +15 -5
  66. package/src/components/Form/Form.tsx +50 -355
  67. package/src/components/Form/FormActions.tsx +41 -0
  68. package/src/components/Form/FormContent.tsx +170 -0
  69. package/src/components/Form/FormContext.tsx +96 -0
  70. package/src/components/Form/Input.tsx +5 -6
  71. package/src/components/Form/custom/GeoPointInput.tsx +44 -23
  72. package/src/components/Form/custom/SelectOptionsInput.stories.tsx +58 -0
  73. package/src/components/Form/custom/SelectOptionsInput.tsx +190 -0
  74. package/src/components/Form/custom/TupleInput.tsx +5 -9
  75. package/src/components/Form/custom/index.ts +1 -0
  76. package/src/components/Form/factory.tsx +6 -7
  77. package/src/components/Form/index.ts +3 -0
  78. package/src/components/ViewEditor/ViewEditor.tsx +7 -4
  79. package/src/components/deprecated/FormInput.tsx +20 -7
  80. package/src/hooks/useForm.ts +116 -79
  81. package/src/translations.ts +9 -1
  82. package/src/util/field.ts +19 -1
  83. package/src/util/formatting.ts +29 -1
@@ -0,0 +1,170 @@
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
+ <CustomComponent
74
+ type={type}
75
+ format={format}
76
+ label={label}
77
+ inputOnly={inline}
78
+ placeholder={placeholder}
79
+ disabled={readonly}
80
+ {...inputProps}
81
+ />
82
+ );
83
+ }
84
+
85
+ if (array) {
86
+ return <ArrayField property={property} path={path} inputProps={inputProps} readonly={readonly} Custom={Custom} />;
87
+ }
88
+
89
+ const InputComponent = getInputComponent(type, format);
90
+ if (InputComponent) {
91
+ return (
92
+ <div role='none'>
93
+ <InputComponent
94
+ type={type}
95
+ format={format}
96
+ label={label}
97
+ inputOnly={inline}
98
+ placeholder={placeholder}
99
+ disabled={readonly}
100
+ {...inputProps}
101
+ />
102
+ </div>
103
+ );
104
+ }
105
+
106
+ if (options) {
107
+ return (
108
+ <div role='none'>
109
+ <SelectInput
110
+ type={type}
111
+ format={format}
112
+ disabled={readonly}
113
+ inputOnly={inline}
114
+ label={label}
115
+ options={options.map((option) => ({ value: option, label: String(option) }))}
116
+ placeholder={placeholder}
117
+ {...inputProps}
118
+ />
119
+ </div>
120
+ );
121
+ }
122
+
123
+ if (type === 'object') {
124
+ const baseNode = findNode(ast, isDiscriminatedUnion);
125
+ const typeLiteral = baseNode
126
+ ? getDiscriminatedType(baseNode, inputProps.getValue() as any)
127
+ : findNode(ast, AST.isTypeLiteral);
128
+
129
+ if (typeLiteral) {
130
+ return (
131
+ <div role='none'>
132
+ {!inline && <div>{label}</div>}
133
+ <FormFields schema={S.make(typeLiteral)} path={path} readonly={readonly} Custom={Custom} />
134
+ </div>
135
+ );
136
+ }
137
+ }
138
+
139
+ return null;
140
+ };
141
+
142
+ export const FormFields = ({ schema, path, filter, sort, readonly, lookupComponent, Custom }: FormContentProps) => {
143
+ const values = useFormValues(path);
144
+
145
+ const properties = useMemo(() => {
146
+ const props = getSchemaProperties(schema.ast, values);
147
+ const filtered = filter ? filter(props) : props;
148
+
149
+ return sort ? filtered.sort((a, b) => sort.indexOf(a.name) - sort.indexOf(b.name)) : filtered;
150
+ }, [schema, values, filter, sort]);
151
+
152
+ return (
153
+ <div role='form' className={mx('flex flex-col w-full gap-2')}>
154
+ {properties
155
+ .map((property) => {
156
+ return (
157
+ <FormField
158
+ key={property.name}
159
+ property={property}
160
+ path={[...(path ?? []), property.name]}
161
+ readonly={readonly}
162
+ lookupComponent={lookupComponent}
163
+ Custom={Custom}
164
+ />
165
+ );
166
+ })
167
+ .filter(isNotFalsy)}
168
+ </div>
169
+ );
170
+ };
@@ -0,0 +1,96 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import React, { createContext, useContext, useEffect, useMemo, 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
+ const stablePath = useMemo(() => path, [Array.isArray(path) ? path.join('.') : path]);
38
+ return useMemo(
39
+ () => ({
40
+ getStatus: () => getStatus(stablePath),
41
+ getValue: () => getFormValue(stablePath),
42
+ onValueChange: (type: SimpleType, value: any) => onValueChange(stablePath, type, value),
43
+ onBlur: () => onTouched(stablePath),
44
+ }),
45
+ [getStatus, getFormValue, onValueChange, onTouched, stablePath],
46
+ );
47
+ };
48
+
49
+ export const FormProvider = ({
50
+ children,
51
+ formRef,
52
+ autoSave,
53
+ ...formOptions
54
+ }: PropsWithChildren<
55
+ FormOptions<any> & {
56
+ formRef?: React.RefObject<HTMLDivElement>;
57
+ autoSave?: boolean;
58
+ }
59
+ >) => {
60
+ const form = useForm(formOptions);
61
+
62
+ useEffect(() => {
63
+ if (!formRef?.current) {
64
+ return;
65
+ }
66
+
67
+ const handleKeyDown = (event: KeyboardEvent) => {
68
+ const keyIsEnter = event.key === 'Enter';
69
+ const modifierUsed = event.ctrlKey || event.altKey || event.metaKey || event.shiftKey;
70
+ const inputIsTextarea = (event.target as HTMLElement).tagName.toLowerCase() === 'textarea';
71
+ const inputOptOut = (event.target as HTMLElement).hasAttribute('data-no-submit');
72
+
73
+ // Regular inputs: Submit on Enter (no modifiers).
74
+ const shouldSubmitRegularInput = !inputIsTextarea && keyIsEnter && !modifierUsed;
75
+
76
+ // Textareas: Submit only on Meta+Enter.
77
+ const shouldSubmitTextarea = inputIsTextarea && keyIsEnter && event.metaKey;
78
+
79
+ if ((shouldSubmitRegularInput || shouldSubmitTextarea) && !inputOptOut) {
80
+ if (!autoSave && form.canSave) {
81
+ form.handleSave();
82
+ }
83
+ if (autoSave && form.formIsValid) {
84
+ (event.target as HTMLElement).blur();
85
+ }
86
+ }
87
+ };
88
+
89
+ const formElement = formRef.current;
90
+
91
+ formElement.addEventListener('keydown', handleKeyDown);
92
+ return () => formElement.removeEventListener('keydown', handleKeyDown);
93
+ }, [formRef, form.canSave, form.formIsValid, form.handleSave, autoSave]);
94
+
95
+ return <FormContext.Provider value={form}>{children}</FormContext.Provider>;
96
+ };
@@ -4,30 +4,29 @@
4
4
 
5
5
  import React, { type FC, type PropsWithChildren } from 'react';
6
6
 
7
- import { type BaseObject, type FormatEnum, type PropertyKey } from '@dxos/echo-schema';
7
+ import { type FormatEnum } from '@dxos/echo-schema';
8
8
  import { type SimpleType } from '@dxos/effect';
9
9
  import { Icon, Tooltip } from '@dxos/react-ui';
10
10
  import { errorText } from '@dxos/react-ui-theme';
11
11
 
12
- import { type FormHandler } from '../../hooks';
12
+ import { type FormInputStateProps } from './FormContext';
13
13
 
14
14
  /**
15
15
  * Props passed to input components.
16
16
  */
17
- export type InputProps<T extends BaseObject> = {
18
- property: PropertyKey<T>;
17
+ export type InputProps = {
19
18
  type: SimpleType;
20
19
  format?: FormatEnum;
21
20
  label?: string;
22
21
  disabled?: boolean;
23
22
  placeholder?: string;
24
23
  inputOnly?: boolean;
25
- } & Pick<FormHandler<T>, 'getStatus' | 'getValue' | 'onValueChange' | 'onBlur'>;
24
+ } & FormInputStateProps;
26
25
 
27
26
  /**
28
27
  * Form input component.
29
28
  */
30
- export type InputComponent<T extends BaseObject> = FC<InputProps<T>>;
29
+ export type InputComponent = FC<InputProps>;
31
30
 
32
31
  export type InputHeaderProps = PropsWithChildren<{
33
32
  error?: string;
@@ -2,49 +2,70 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import React from 'react';
5
+ import React, { useCallback, useState, useEffect, useMemo } from 'react';
6
6
 
7
- import { type BaseObject, type GeoPoint } from '@dxos/echo-schema';
7
+ import { type GeoPoint, GeoLocation } from '@dxos/echo-schema';
8
8
  import { Input, useTranslation } from '@dxos/react-ui';
9
9
  import { safeParseFloat } from '@dxos/util';
10
10
 
11
11
  import { translationKey } from '../../../translations';
12
12
  import { InputHeader, type InputProps } from '../Input';
13
13
 
14
- export const GeoPointInput = <T extends BaseObject>({
15
- property,
16
- type,
17
- label,
18
- disabled,
19
- getStatus,
20
- getValue,
21
- onValueChange,
22
- onBlur,
23
- }: InputProps<T>) => {
14
+ export const GeoPointInput = ({ type, label, disabled, getStatus, getValue, onValueChange, onBlur }: InputProps) => {
24
15
  const { t } = useTranslation(translationKey);
25
- const { status, error } = getStatus?.(property);
26
- const [lng = 0, lat = 0] = getValue<GeoPoint>(property) ?? [];
16
+ const { status, error } = getStatus();
17
+ const geoPoint = useMemo<GeoPoint>(() => getValue<GeoPoint>() ?? [0, 0], [getValue]);
18
+ const geoLocation = useMemo(() => GeoLocation.fromGeoPoint(geoPoint), [geoPoint]);
19
+
20
+ const [longitudeText, setLongitudeText] = useState(geoLocation.longitude?.toString());
21
+ const [latitudeText, setLatitudeText] = useState(geoLocation.latitude?.toString());
22
+
23
+ useEffect(() => {
24
+ const location = GeoLocation.fromGeoPoint(geoPoint);
25
+ setLongitudeText(location.longitude.toString());
26
+ setLatitudeText(location.latitude.toString());
27
+ }, [geoPoint]);
28
+
29
+ const handleCoordinateChange = useCallback(
30
+ (coordinateType: keyof Pick<GeoLocation, 'longitude' | 'latitude'>, setText: (text: string) => void) =>
31
+ (event: React.ChangeEvent<HTMLInputElement>) => {
32
+ const inputText = event.target.value;
33
+ setText(inputText);
34
+ if (inputText !== '' && inputText !== '-') {
35
+ const coord = safeParseFloat(inputText);
36
+ if (coord !== undefined && !isNaN(coord)) {
37
+ const currentLocation = GeoLocation.fromGeoPoint(getValue<GeoPoint>() ?? [0, 0]);
38
+ const newLocation = { ...currentLocation, [coordinateType]: coord };
39
+ const newValue = GeoLocation.toGeoPoint(newLocation);
40
+ onValueChange(type, newValue);
41
+ }
42
+ }
43
+ },
44
+ [getValue, onValueChange, type],
45
+ );
27
46
 
28
47
  return (
29
48
  <Input.Root validationValence={status}>
30
49
  <InputHeader error={error}>
31
50
  <Input.Label>{label}</Input.Label>
32
51
  </InputHeader>
33
- <div className='grid grid-cols-2 gap-2'>
52
+ <div role='none' className='grid grid-cols-2 gap-2'>
34
53
  <Input.TextInput
35
- type='number'
54
+ type='text'
55
+ pattern='^-?[0-9]*\.?[0-9]*$'
36
56
  disabled={disabled}
37
- placeholder={t('placeholder longitude')}
38
- value={lng}
39
- onChange={(event) => onValueChange(property, type, [lat, safeParseFloat(event.target.value, 0)])}
57
+ placeholder={t('placeholder latitude')}
58
+ value={latitudeText}
59
+ onChange={handleCoordinateChange('latitude', setLatitudeText)}
40
60
  onBlur={onBlur}
41
61
  />
42
62
  <Input.TextInput
43
- type='number'
63
+ type='text'
64
+ pattern='^-?[0-9]*\.?[0-9]*$'
44
65
  disabled={disabled}
45
- placeholder={t('placeholder latitude')}
46
- value={lat}
47
- onChange={(event) => onValueChange(property, type, [safeParseFloat(event.target.value, 0), lng])}
66
+ placeholder={t('placeholder longitude')}
67
+ value={longitudeText}
68
+ onChange={handleCoordinateChange('longitude', setLongitudeText)}
48
69
  onBlur={onBlur}
49
70
  />
50
71
  </div>
@@ -0,0 +1,58 @@
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
+ };
@@ -0,0 +1,190 @@
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 { HuePicker } from '@dxos/react-ui-pickers';
12
+ import { hues, 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
+ // Pick a random unused option from hueTokenThemes.
36
+ const unusedHues = hues.filter((hue) => !usedHues.has(hue));
37
+ if (unusedHues.length > 0) {
38
+ return unusedHues[Math.floor(Math.random() * unusedHues.length)];
39
+ }
40
+
41
+ // Pick a random one that hasn't been used in the last three options
42
+ const lastThree = new Set(options?.slice(-3).map((option) => option.color) ?? []);
43
+ const availableHues = hues.filter((hue) => !lastThree.has(hue));
44
+ return (
45
+ availableHues[Math.floor(Math.random() * availableHues.length)] ?? hues[Math.floor(Math.random() * hues.length)]
46
+ );
47
+ };
48
+
49
+ const handleAdd = useCallback(() => {
50
+ const newId = PublicKey.random().truncate();
51
+ const newOption = { id: newId, title: '', color: randomHue() };
52
+ onValueChange(type, [...(options ?? []), newOption]);
53
+ setSelectedId(newId);
54
+ setIsNewOption(true);
55
+ }, [options, type, onValueChange]);
56
+
57
+ const handleDelete = useCallback(
58
+ (id: string) => {
59
+ const newOptions = options?.filter((option) => option.id !== id) ?? [];
60
+ onValueChange(type, newOptions);
61
+ setSelectedId(null);
62
+ },
63
+ [options, type, onValueChange],
64
+ );
65
+
66
+ const handleMove = useCallback(
67
+ (from: number, to: number) => {
68
+ if (!options) {
69
+ return;
70
+ }
71
+
72
+ const newOptions = [...options];
73
+ const [removed] = newOptions.splice(from, 1);
74
+ newOptions.splice(to, 0, removed);
75
+ onValueChange(type, newOptions);
76
+ },
77
+ [options, type, onValueChange],
78
+ );
79
+
80
+ const handleClick = useCallback((id: string) => {
81
+ setSelectedId((current) => (current === id ? null : id));
82
+ }, []);
83
+
84
+ const handleTitleChange = useCallback(
85
+ (id: string) => (e: ChangeEvent<HTMLInputElement>) => {
86
+ const newOptions = options?.map((o) => (o.id === id ? { ...o, title: e.target.value } : o));
87
+ onValueChange(type, newOptions ?? []);
88
+ },
89
+ [options, type, onValueChange],
90
+ );
91
+
92
+ const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
93
+ if (e.key === 'Enter') {
94
+ setSelectedId(null);
95
+ }
96
+ }, []);
97
+
98
+ const handleColorChange = useCallback(
99
+ (id: string) => (hue: string) => {
100
+ const newOptions = options?.map((o) => (o.id === id ? { ...o, color: hue } : o));
101
+ onValueChange(type, newOptions ?? []);
102
+ },
103
+ [options, type, onValueChange],
104
+ );
105
+
106
+ useEffect(() => {
107
+ if (selected && isNewOption && inputRef.current) {
108
+ inputRef.current.focus();
109
+ setIsNewOption(false);
110
+ }
111
+ }, [selected, isNewOption]);
112
+
113
+ return (
114
+ <Input.Root validationValence={status}>
115
+ <InputHeader error={error}>
116
+ <Input.Label>{label}</Input.Label>
117
+ </InputHeader>
118
+ <div role='none'>
119
+ {options && (
120
+ <List.Root items={options} isItem={(item) => true} onMove={handleMove}>
121
+ {({ items }) => (
122
+ <div role='list' className='w-full overflow-auto'>
123
+ {items?.map((item) => (
124
+ <div key={item.id}>
125
+ <List.Item
126
+ role='button'
127
+ item={item}
128
+ classNames={mx('p-1 plb-2', 'flex flex-col', 'cursor-pointer', 'hover:bg-hoverOverlay')}
129
+ aria-expanded={selected === item.id}
130
+ >
131
+ <div className='flex items-center'>
132
+ <List.ItemDragHandle />
133
+ <List.ItemTitle onClick={() => handleClick(item.id)} classNames='flex-1'>
134
+ {/* TODO(ZaymonFC): Move spacer into Tag component. */}
135
+ <Tag palette={item.color as ChromaticPalette}>{item.title || '\u200b'}</Tag>
136
+ </List.ItemTitle>
137
+ <Icon
138
+ icon={selected === item.id ? 'ph--caret-down--regular' : 'ph--caret-right--regular'}
139
+ onClick={() => handleClick(item.id)}
140
+ />
141
+ </div>
142
+ {selected === item.id && (
143
+ <div className='mis-4 mbs-2 flex flex-col gap-1'>
144
+ {/* 16px to match drag handle width. */}
145
+ <Input.Label classNames='text-xs'>{t('select option label')}</Input.Label>
146
+ <div className='flex flex-row items-center gap-1'>
147
+ <Input.TextInput
148
+ placeholder={t('select option label placeholder')}
149
+ ref={selected === item.id ? inputRef : undefined}
150
+ value={item.title}
151
+ onChange={handleTitleChange(item.id)}
152
+ onKeyDown={handleKeyDown}
153
+ classNames='flex-1'
154
+ data-no-submit
155
+ />
156
+ <Toolbar.Root classNames='p-0 m-0 !is-auto'>
157
+ <HuePicker value={item.color} onChange={handleColorChange(item.id)} />
158
+ </Toolbar.Root>
159
+ <IconButton
160
+ icon='ph--trash--fill'
161
+ iconOnly
162
+ label={t('select option delete')}
163
+ onClick={() => handleDelete(item.id)}
164
+ />
165
+ </div>
166
+ </div>
167
+ )}
168
+ </List.Item>
169
+ </div>
170
+ ))}
171
+ <div role='listitem'>
172
+ <div
173
+ role='button'
174
+ className={mx('p-1 plb-2', 'flex flex-col', 'cursor-pointer', 'hover:bg-hoverOverlay')}
175
+ onClick={handleAdd}
176
+ >
177
+ <div className='flex items-center gap-1'>
178
+ <Icon icon='ph--plus--regular' />
179
+ <span className='text-sm'>{t('select option add')}</span>
180
+ </div>
181
+ </div>
182
+ </div>
183
+ </div>
184
+ )}
185
+ </List.Root>
186
+ )}
187
+ </div>
188
+ </Input.Root>
189
+ );
190
+ };
@@ -4,7 +4,6 @@
4
4
 
5
5
  import React from 'react';
6
6
 
7
- import { type BaseObject } from '@dxos/echo-schema';
8
7
  import { Input } from '@dxos/react-ui';
9
8
  import { mx } from '@dxos/react-ui-theme';
10
9
  import { safeParseFloat } from '@dxos/util';
@@ -13,9 +12,8 @@ import { InputHeader, type InputProps } from '../Input';
13
12
 
14
13
  const gridCols = ['grid-cols-1', 'grid-cols-2', 'grid-cols-3', 'grid-cols-4'];
15
14
 
16
- export const TupleInput = <T extends BaseObject>({
15
+ export const TupleInput = ({
17
16
  binding,
18
- property,
19
17
  type,
20
18
  label,
21
19
  disabled,
@@ -23,11 +21,11 @@ export const TupleInput = <T extends BaseObject>({
23
21
  getValue,
24
22
  onValueChange,
25
23
  onBlur,
26
- }: InputProps<T> & { binding: string[] }) => {
27
- const { status, error } = getStatus?.(property);
24
+ }: InputProps & { binding: string[] }) => {
25
+ const { status, error } = getStatus();
28
26
 
29
27
  // TODO(burdon): Generalize number/float/string, etc.
30
- const values: Record<string, number> = getValue<Record<string, number>>(property) ?? {};
28
+ const values: Record<string, number> = getValue<Record<string, number>>() ?? {};
31
29
 
32
30
  return (
33
31
  <Input.Root validationValence={status}>
@@ -41,9 +39,7 @@ export const TupleInput = <T extends BaseObject>({
41
39
  type='number'
42
40
  disabled={disabled}
43
41
  value={values[prop]}
44
- onChange={(event) =>
45
- onValueChange(property, type, { ...values, [prop]: safeParseFloat(event.target.value, 0) })
46
- }
42
+ onChange={(event) => onValueChange(type, { ...values, [prop]: safeParseFloat(event.target.value, 0) })}
47
43
  onBlur={onBlur}
48
44
  />
49
45
  ))}