@dxos/react-ui-form 0.8.4-main.74a063c4e0 → 0.8.4-main.765dc60934

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 (134) hide show
  1. package/LICENSE +102 -5
  2. package/dist/lib/browser/index.mjs +873 -446
  3. package/dist/lib/browser/index.mjs.map +4 -4
  4. package/dist/lib/browser/meta.json +1 -1
  5. package/dist/lib/browser/translations.mjs +82 -0
  6. package/dist/lib/browser/translations.mjs.map +7 -0
  7. package/dist/lib/node-esm/index.mjs +873 -446
  8. package/dist/lib/node-esm/index.mjs.map +4 -4
  9. package/dist/lib/node-esm/meta.json +1 -1
  10. package/dist/lib/node-esm/translations.mjs +84 -0
  11. package/dist/lib/node-esm/translations.mjs.map +7 -0
  12. package/dist/types/src/components/FieldEditor/FieldEditor.d.ts.map +1 -1
  13. package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts +63 -61
  14. package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts.map +1 -1
  15. package/dist/types/src/components/Form/Form.d.ts +34 -12
  16. package/dist/types/src/components/Form/Form.d.ts.map +1 -1
  17. package/dist/types/src/components/Form/Form.stories.d.ts +73 -71
  18. package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
  19. package/dist/types/src/components/Form/FormField.d.ts +5 -5
  20. package/dist/types/src/components/Form/FormField.d.ts.map +1 -1
  21. package/dist/types/src/components/Form/FormFieldComponent.d.ts +8 -6
  22. package/dist/types/src/components/Form/FormFieldComponent.d.ts.map +1 -1
  23. package/dist/types/src/components/Form/FormFieldComponent.stories.d.ts.map +1 -1
  24. package/dist/types/src/components/Form/FormFieldSet.d.ts +5 -8
  25. package/dist/types/src/components/Form/FormFieldSet.d.ts.map +1 -1
  26. package/dist/types/src/components/Form/fields/ArrayField.d.ts +4 -4
  27. package/dist/types/src/components/Form/fields/ArrayField.d.ts.map +1 -1
  28. package/dist/types/src/components/Form/fields/BooleanField.d.ts.map +1 -1
  29. package/dist/types/src/components/Form/fields/GeoPointField.d.ts.map +1 -1
  30. package/dist/types/src/components/Form/fields/MarkdownField.d.ts +8 -1
  31. package/dist/types/src/components/Form/fields/MarkdownField.d.ts.map +1 -1
  32. package/dist/types/src/components/Form/fields/MarkdownField.stories.d.ts +84 -0
  33. package/dist/types/src/components/Form/fields/MarkdownField.stories.d.ts.map +1 -0
  34. package/dist/types/src/components/Form/fields/NumberField.d.ts.map +1 -1
  35. package/dist/types/src/components/Form/fields/RefField.d.ts +10 -4
  36. package/dist/types/src/components/Form/fields/RefField.d.ts.map +1 -1
  37. package/dist/types/src/components/Form/fields/SelectField.d.ts.map +1 -1
  38. package/dist/types/src/components/Form/fields/SelectOptionField.d.ts.map +1 -1
  39. package/dist/types/src/components/Form/fields/SelectOptionField.stories.d.ts +63 -61
  40. package/dist/types/src/components/Form/fields/SelectOptionField.stories.d.ts.map +1 -1
  41. package/dist/types/src/components/Form/fields/TextAreaField.d.ts.map +1 -1
  42. package/dist/types/src/components/Form/fields/TextField.d.ts +1 -1
  43. package/dist/types/src/components/Form/fields/TextField.d.ts.map +1 -1
  44. package/dist/types/src/components/Form/fields/TupleField.d.ts.map +1 -1
  45. package/dist/types/src/components/ObjectForm/ObjectForm.d.ts +9 -0
  46. package/dist/types/src/components/ObjectForm/ObjectForm.d.ts.map +1 -0
  47. package/dist/types/src/components/ObjectForm/index.d.ts +2 -0
  48. package/dist/types/src/components/ObjectForm/index.d.ts.map +1 -0
  49. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts +17 -3
  50. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts.map +1 -1
  51. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts +63 -61
  52. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts.map +1 -1
  53. package/dist/types/src/components/ObjectProperties/ObjectProperties.d.ts +9 -0
  54. package/dist/types/src/components/ObjectProperties/ObjectProperties.d.ts.map +1 -0
  55. package/dist/types/src/components/ObjectProperties/ObjectProperties.stories.d.ts +127 -0
  56. package/dist/types/src/components/ObjectProperties/ObjectProperties.stories.d.ts.map +1 -0
  57. package/dist/types/src/components/ObjectProperties/ObjectProperties.test.d.ts +2 -0
  58. package/dist/types/src/components/ObjectProperties/ObjectProperties.test.d.ts.map +1 -0
  59. package/dist/types/src/components/ObjectProperties/index.d.ts +2 -0
  60. package/dist/types/src/components/ObjectProperties/index.d.ts.map +1 -0
  61. package/dist/types/src/components/Settings/Settings.d.ts +24 -12
  62. package/dist/types/src/components/Settings/Settings.d.ts.map +1 -1
  63. package/dist/types/src/components/Settings/Settings.stories.d.ts +64 -61
  64. package/dist/types/src/components/Settings/Settings.stories.d.ts.map +1 -1
  65. package/dist/types/src/components/Settings/SettingsFieldSet.d.ts +43 -0
  66. package/dist/types/src/components/Settings/SettingsFieldSet.d.ts.map +1 -0
  67. package/dist/types/src/components/Settings/SettingsItem.d.ts +16 -0
  68. package/dist/types/src/components/Settings/SettingsItem.d.ts.map +1 -0
  69. package/dist/types/src/components/Settings/SettingsSelectField.d.ts +18 -0
  70. package/dist/types/src/components/Settings/SettingsSelectField.d.ts.map +1 -0
  71. package/dist/types/src/components/Settings/index.d.ts +2 -0
  72. package/dist/types/src/components/Settings/index.d.ts.map +1 -1
  73. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts +3 -4
  74. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
  75. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +63 -61
  76. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
  77. package/dist/types/src/components/index.d.ts +2 -0
  78. package/dist/types/src/components/index.d.ts.map +1 -1
  79. package/dist/types/src/components/testing.d.ts +4 -4
  80. package/dist/types/src/components/testing.d.ts.map +1 -1
  81. package/dist/types/src/hooks/useFormHandler.d.ts.map +1 -1
  82. package/dist/types/src/hooks/useKeyHandler.d.ts.map +1 -1
  83. package/dist/types/src/index.d.ts +0 -2
  84. package/dist/types/src/index.d.ts.map +1 -1
  85. package/dist/types/src/translations.d.ts +63 -61
  86. package/dist/types/src/translations.d.ts.map +1 -1
  87. package/dist/types/src/util/field-type.d.ts +19 -0
  88. package/dist/types/src/util/field-type.d.ts.map +1 -0
  89. package/dist/types/src/util/field.d.ts.map +1 -1
  90. package/dist/types/src/util/formatting.d.ts.map +1 -1
  91. package/dist/types/src/util/index.d.ts +1 -0
  92. package/dist/types/src/util/index.d.ts.map +1 -1
  93. package/dist/types/src/util/properties.d.ts.map +1 -1
  94. package/dist/types/src/util/refs.d.ts.map +1 -1
  95. package/dist/types/tsconfig.tsbuildinfo +1 -1
  96. package/package.json +40 -38
  97. package/src/components/FieldEditor/FieldEditor.stories.tsx +2 -1
  98. package/src/components/FieldEditor/FieldEditor.tsx +3 -2
  99. package/src/components/Form/Form.stories.tsx +9 -19
  100. package/src/components/Form/Form.tsx +59 -7
  101. package/src/components/Form/FormField.tsx +12 -10
  102. package/src/components/Form/FormFieldComponent.tsx +6 -3
  103. package/src/components/Form/FormFieldSet.tsx +47 -31
  104. package/src/components/Form/fields/ArrayField.tsx +7 -6
  105. package/src/components/Form/fields/GeoPointField.tsx +3 -2
  106. package/src/components/Form/fields/MarkdownField.stories.tsx +206 -0
  107. package/src/components/Form/fields/MarkdownField.tsx +135 -45
  108. package/src/components/Form/fields/RefField.tsx +61 -30
  109. package/src/components/Form/fields/SelectOptionField.stories.tsx +8 -5
  110. package/src/components/Form/fields/SelectOptionField.tsx +6 -5
  111. package/src/components/Form/fields/TextField.tsx +2 -2
  112. package/src/components/ObjectForm/ObjectForm.tsx +114 -0
  113. package/src/components/ObjectForm/index.ts +5 -0
  114. package/src/components/ObjectPicker/ObjectPicker.stories.tsx +9 -6
  115. package/src/components/ObjectPicker/ObjectPicker.tsx +28 -12
  116. package/src/components/ObjectProperties/ObjectProperties.stories.tsx +362 -0
  117. package/src/components/ObjectProperties/ObjectProperties.test.tsx +101 -0
  118. package/src/components/ObjectProperties/ObjectProperties.tsx +141 -0
  119. package/src/components/ObjectProperties/index.ts +5 -0
  120. package/src/components/Settings/AUDIT.md +52 -0
  121. package/src/components/Settings/Settings.stories.tsx +50 -1
  122. package/src/components/Settings/Settings.tsx +7 -56
  123. package/src/components/Settings/SettingsFieldSet.tsx +257 -0
  124. package/src/components/Settings/SettingsItem.tsx +58 -0
  125. package/src/components/Settings/SettingsSelectField.tsx +59 -0
  126. package/src/components/Settings/index.ts +2 -0
  127. package/src/components/ViewEditor/ViewEditor.stories.tsx +10 -10
  128. package/src/components/ViewEditor/ViewEditor.tsx +19 -26
  129. package/src/components/index.ts +2 -0
  130. package/src/components/testing.tsx +20 -21
  131. package/src/index.ts +0 -4
  132. package/src/translations.ts +7 -3
  133. package/src/util/field-type.ts +60 -0
  134. package/src/util/index.ts +1 -0
@@ -3,13 +3,15 @@
3
3
  //
4
4
 
5
5
  import { type Meta, type StoryObj } from '@storybook/react-vite';
6
+ import * as Schema from 'effect/Schema';
6
7
  import React from 'react';
7
8
 
8
9
  import { random } from '@dxos/random';
9
10
  import { Button, Input } from '@dxos/react-ui';
10
11
  import { withLayout, withTheme } from '@dxos/react-ui/testing';
11
12
 
12
- import { translations } from '../../translations';
13
+ import { translations } from '#translations';
14
+
13
15
  import { Settings } from './Settings';
14
16
 
15
17
  random.seed(132);
@@ -53,3 +55,50 @@ export default meta;
53
55
  type Story = StoryObj<typeof meta>;
54
56
 
55
57
  export const Default: Story = {};
58
+
59
+ const TestViewModes = ['preview', 'readonly', 'source'] as const;
60
+ const TestViewMode = Schema.Union(...TestViewModes.map((mode) => Schema.Literal(mode)));
61
+
62
+ const TestSettings = Schema.mutable(
63
+ Schema.Struct({
64
+ viewMode: TestViewMode.annotations({ title: 'View mode', description: 'Default document view mode.' }),
65
+ toolbar: Schema.optional(
66
+ Schema.Boolean.annotations({ title: 'Show toolbar', description: 'Display formatting toolbar.' }),
67
+ ),
68
+ fontSize: Schema.optional(
69
+ Schema.Number.annotations({ title: 'Font size', description: 'Editor font size in pixels.' }),
70
+ ),
71
+ placeholder: Schema.optional(
72
+ Schema.String.annotations({ title: 'Placeholder', description: 'Default placeholder text.' }),
73
+ ),
74
+ debug: Schema.optional(Schema.Boolean.annotations({ title: 'Debug mode', description: 'Enable debug features.' })),
75
+ }),
76
+ );
77
+
78
+ type TestSettings = Schema.Schema.Type<typeof TestSettings>;
79
+
80
+ const FieldSetStory = () => {
81
+ const [values, setValues] = React.useState<TestSettings>({
82
+ viewMode: 'preview',
83
+ toolbar: true,
84
+ fontSize: 14,
85
+ debug: false,
86
+ });
87
+
88
+ return (
89
+ <Settings.Viewport>
90
+ <Settings.Section title='Plugin Settings (Auto-generated)'>
91
+ <Settings.FieldSet
92
+ schema={TestSettings}
93
+ values={values}
94
+ onValuesChanged={setValues}
95
+ visible={(path, values) => path !== 'placeholder' || !!values.debug}
96
+ />
97
+ </Settings.Section>
98
+ </Settings.Viewport>
99
+ );
100
+ };
101
+
102
+ export const FieldSet: Story = {
103
+ render: FieldSetStory,
104
+ };
@@ -4,16 +4,17 @@
4
4
 
5
5
  import React, { type PropsWithChildren } from 'react';
6
6
 
7
- import { Input, type Label, ScrollArea, type ThemedClassName, toLocalizedString, useTranslation } from '@dxos/react-ui';
7
+ import { type Label, ScrollArea, type ThemedClassName, toLocalizedString, useTranslation } from '@dxos/react-ui';
8
8
  import { composable, composableProps, mx } from '@dxos/ui-theme';
9
9
 
10
- import { translationKey } from '../../translations';
10
+ import { translationKey } from '#translations';
11
+
12
+ import { SettingsFieldSet } from './SettingsFieldSet';
13
+ import { SettingsItem, SettingsPanel } from './SettingsItem';
11
14
 
12
15
  const SETTINGS_ROOT_NAME = 'Settings.Root';
13
16
  const SETTINGS_VIEWPORT_NAME = 'Settings.Viewport';
14
17
  const SETTINGS_SECTION_NAME = 'Settings.Section';
15
- const SETTINGS_PANEL_NAME = 'Settings.Panel';
16
- const SETTINGS_ITEM_NAME = 'Settings.Item';
17
18
 
18
19
  //
19
20
  // Root (headless — no styling, no scroll).
@@ -35,9 +36,7 @@ const SettingsViewport = composable<HTMLDivElement>(({ children, ...props }, for
35
36
  return (
36
37
  <ScrollArea.Root {...composableProps(props)} orientation='vertical' centered thin ref={forwardedRef}>
37
38
  <ScrollArea.Viewport classNames='px-4 pointer-coarse:px-2'>
38
- <div role='none' className='dx-document flex flex-col gap-2 py-4'>
39
- {children}
40
- </div>
39
+ <div className='dx-document flex flex-col gap-2 py-4'>{children}</div>
41
40
  </ScrollArea.Viewport>
42
41
  </ScrollArea.Root>
43
42
  );
@@ -67,55 +66,6 @@ const SettingsSection = ({ title, description, children }: SettingsSectionProps)
67
66
 
68
67
  SettingsSection.displayName = SETTINGS_SECTION_NAME;
69
68
 
70
- //
71
- // Panel (bordered card container).
72
- //
73
-
74
- type SettingsPanelProps = ThemedClassName<PropsWithChildren>;
75
-
76
- const SettingsPanel = ({ classNames, children }: SettingsPanelProps) => {
77
- return (
78
- <div role='none' className={mx('flex flex-col gap-3 p-trim-md border border-separator rounded-sm', classNames)}>
79
- {children}
80
- </div>
81
- );
82
- };
83
-
84
- SettingsPanel.displayName = SETTINGS_PANEL_NAME;
85
-
86
- //
87
- // Item (label + optional description + control, proper label semantics).
88
- //
89
-
90
- type SettingsItemProps = PropsWithChildren<{
91
- title: Label;
92
- description?: Label;
93
- }>;
94
-
95
- const SettingsItem = ({ title, description = '', children }: SettingsItemProps) => {
96
- const { t } = useTranslation(translationKey);
97
-
98
- return (
99
- <Input.Root>
100
- <SettingsPanel classNames='grid grid-cols-1 md:grid-cols-[1fr_1fr] gap-x-trim-lg gap-y-0'>
101
- <Input.Label classNames='pb-trim-md text-base-surface-text text-lg md:col-span-2'>
102
- {toLocalizedString(title, t)}
103
- </Input.Label>
104
- <Input.DescriptionAndValidation>
105
- <Input.Description classNames='text-base text-description'>
106
- {toLocalizedString(description, t)}
107
- </Input.Description>
108
- </Input.DescriptionAndValidation>
109
- <div role='none' className='text-end py-1'>
110
- {children}
111
- </div>
112
- </SettingsPanel>
113
- </Input.Root>
114
- );
115
- };
116
-
117
- SettingsItem.displayName = SETTINGS_ITEM_NAME;
118
-
119
69
  //
120
70
  // Settings
121
71
  //
@@ -126,4 +76,5 @@ export const Settings = {
126
76
  Section: SettingsSection,
127
77
  Panel: SettingsPanel,
128
78
  Item: SettingsItem,
79
+ FieldSet: SettingsFieldSet,
129
80
  };
@@ -0,0 +1,257 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import * as SchemaAST from 'effect/SchemaAST';
6
+ import * as String from 'effect/String';
7
+ import React, { useCallback, useMemo } from 'react';
8
+
9
+ import { getAnnotation, type SchemaProperty } from '@dxos/effect';
10
+ import { Input, Select } from '@dxos/react-ui';
11
+
12
+ import { getFormProperties, type SelectOption, detectFieldType, getSelectOptionsFromAst } from '../../util';
13
+ import { SettingsItem } from './SettingsItem';
14
+
15
+ //
16
+ // Types
17
+ //
18
+
19
+ /**
20
+ * Props for a custom field renderer.
21
+ * The renderer provides the control only; SettingsItem wrapper is handled by FieldSet.
22
+ */
23
+ export type SettingsFieldProps<T = any> = {
24
+ value: T;
25
+ onChange: (value: T) => void;
26
+ readonly?: boolean;
27
+ };
28
+
29
+ /**
30
+ * Map of property names to either a custom field renderer or a nested field map for struct fields.
31
+ * When a value type `T` is provided, the map shape is checked against the schema's value type.
32
+ */
33
+ export type SettingsFieldMap<T extends Record<string, any> = Record<string, any>> = {
34
+ [K in keyof T]?: NonNullable<T[K]> extends Record<string, any>
35
+ ? SettingsFieldMap<NonNullable<T[K]>>
36
+ : React.FC<SettingsFieldProps<T[K]>>;
37
+ };
38
+
39
+ export type SettingsFieldSetProps<T extends Record<string, any> = Record<string, any>> = {
40
+ /** Effect Schema for the settings object. */
41
+ schema: { ast: SchemaAST.AST };
42
+
43
+ /** Current settings values. */
44
+ values: T;
45
+
46
+ /** Callback when any value changes. Receives a new complete object. */
47
+ onValuesChanged?: (values: T) => void;
48
+
49
+ /** When true, all controls are disabled. */
50
+ readonly?: boolean;
51
+
52
+ /** Map of property names to custom field renderers. Nested objects map to struct sub-fields. */
53
+ fieldMap?: SettingsFieldMap<T>;
54
+
55
+ /** Control field visibility. Receives the dotted path (e.g. `'modelDefaults.edge'`) at every level. */
56
+ visible?: (path: string, values: T) => boolean;
57
+
58
+ /** Override the order of fields. Fields not listed are appended in schema order. Applied per scope. */
59
+ sort?: string[];
60
+
61
+ /** Internal: dot-path prefix used by recursive struct rendering. */
62
+ pathPrefix?: string;
63
+ };
64
+
65
+ //
66
+ // Component
67
+ //
68
+
69
+ export const SettingsFieldSet = <T extends Record<string, any>>({
70
+ schema,
71
+ values,
72
+ onValuesChanged,
73
+ readonly,
74
+ fieldMap,
75
+ visible,
76
+ sort,
77
+ pathPrefix = '',
78
+ }: SettingsFieldSetProps<T>) => {
79
+ const properties = useMemo(() => {
80
+ const props = getFormProperties(schema.ast);
81
+ if (!sort) {
82
+ return props;
83
+ }
84
+ const rank = (name: string) => {
85
+ const idx = sort.indexOf(name);
86
+ return idx === -1 ? Number.POSITIVE_INFINITY : idx;
87
+ };
88
+ return [...props].sort(({ name: a }, { name: b }) => rank(a.toString()) - rank(b.toString()));
89
+ }, [schema, sort]);
90
+
91
+ const handleChange = useCallback(
92
+ (name: string, value: unknown) => {
93
+ onValuesChanged?.({ ...values, [name]: value } as T);
94
+ },
95
+ [values, onValuesChanged],
96
+ );
97
+
98
+ return (
99
+ <>
100
+ {properties.map((property) => {
101
+ const name = property.name.toString();
102
+ const path = pathPrefix ? `${pathPrefix}.${name}` : name;
103
+ if (visible && !visible(path, values)) {
104
+ return null;
105
+ }
106
+
107
+ const entry = fieldMap?.[name as keyof typeof fieldMap] as
108
+ | React.FC<SettingsFieldProps>
109
+ | SettingsFieldMap
110
+ | undefined;
111
+ return (
112
+ <SettingsFieldItem
113
+ key={name}
114
+ path={path}
115
+ property={property}
116
+ value={values[name]}
117
+ onChange={(value) => handleChange(name, value)}
118
+ readonly={readonly}
119
+ customField={typeof entry === 'function' ? entry : undefined}
120
+ nestedFieldMap={typeof entry === 'object' ? entry : undefined}
121
+ visible={visible as (path: string, values: any) => boolean}
122
+ sort={sort}
123
+ />
124
+ );
125
+ })}
126
+ </>
127
+ );
128
+ };
129
+
130
+ SettingsFieldSet.displayName = 'Settings.FieldSet';
131
+
132
+ //
133
+ // Internal: render a single settings field.
134
+ //
135
+
136
+ type SettingsFieldItemProps = {
137
+ property: SchemaProperty;
138
+ path: string;
139
+ value: any;
140
+ onChange: (value: any) => void;
141
+ readonly?: boolean;
142
+ customField?: React.FC<SettingsFieldProps>;
143
+ nestedFieldMap?: SettingsFieldMap;
144
+ visible?: (path: string, values: any) => boolean;
145
+ sort?: string[];
146
+ };
147
+
148
+ const SettingsFieldItem = ({
149
+ property,
150
+ path,
151
+ value,
152
+ onChange,
153
+ readonly,
154
+ customField,
155
+ nestedFieldMap,
156
+ visible,
157
+ sort,
158
+ }: SettingsFieldItemProps) => {
159
+ const { type } = property;
160
+ const name = property.name.toString();
161
+ const title = getAnnotation<string>(SchemaAST.TitleAnnotationId)(type) ?? String.capitalize(name);
162
+ const description = getAnnotation<string>(SchemaAST.DescriptionAnnotationId)(type);
163
+
164
+ // Custom field renderer.
165
+ if (customField) {
166
+ const CustomField = customField;
167
+ return (
168
+ <SettingsItem title={title} description={description}>
169
+ <CustomField value={value} onChange={onChange} readonly={readonly} />
170
+ </SettingsItem>
171
+ );
172
+ }
173
+
174
+ const fieldType = detectFieldType(type);
175
+
176
+ switch (fieldType) {
177
+ case 'struct':
178
+ return (
179
+ <SettingsFieldSet
180
+ schema={{ ast: type }}
181
+ values={value ?? {}}
182
+ onValuesChanged={onChange}
183
+ readonly={readonly}
184
+ fieldMap={nestedFieldMap}
185
+ visible={visible}
186
+ sort={sort}
187
+ pathPrefix={path}
188
+ />
189
+ );
190
+
191
+ case 'boolean':
192
+ return (
193
+ <SettingsItem title={title} description={description}>
194
+ <Input.Switch disabled={readonly} checked={!!value} onCheckedChange={(checked) => onChange(!!checked)} />
195
+ </SettingsItem>
196
+ );
197
+
198
+ case 'select': {
199
+ const options = getSelectOptionsFromAst(type) ?? [];
200
+ return (
201
+ <SettingsItem title={title} description={description}>
202
+ <Select.Root
203
+ disabled={readonly}
204
+ value={value == null ? '' : globalThis.String(value)}
205
+ onValueChange={(selected) => {
206
+ const matched = options.find((option) => globalThis.String(option.value) === selected);
207
+ onChange(matched?.value ?? selected);
208
+ }}
209
+ >
210
+ <Select.TriggerButton disabled={readonly} />
211
+ <Select.Portal>
212
+ <Select.Content>
213
+ <Select.Viewport>
214
+ {options.map((option: SelectOption) => (
215
+ <Select.Option key={globalThis.String(option.value)} value={globalThis.String(option.value)}>
216
+ {option.label ?? globalThis.String(option.value)}
217
+ </Select.Option>
218
+ ))}
219
+ </Select.Viewport>
220
+ <Select.Arrow />
221
+ </Select.Content>
222
+ </Select.Portal>
223
+ </Select.Root>
224
+ </SettingsItem>
225
+ );
226
+ }
227
+
228
+ case 'string':
229
+ return (
230
+ <SettingsItem title={title} description={description}>
231
+ <Input.TextInput disabled={readonly} value={value ?? ''} onChange={(event) => onChange(event.target.value)} />
232
+ </SettingsItem>
233
+ );
234
+
235
+ case 'number':
236
+ return (
237
+ <SettingsItem title={title} description={description}>
238
+ <Input.TextInput
239
+ disabled={readonly}
240
+ type='number'
241
+ value={value ?? ''}
242
+ onChange={(event) => {
243
+ if (event.target.value === '') {
244
+ onChange(undefined);
245
+ return;
246
+ }
247
+ const parsed = Number(event.target.value);
248
+ onChange(Number.isNaN(parsed) ? undefined : parsed);
249
+ }}
250
+ />
251
+ </SettingsItem>
252
+ );
253
+
254
+ default:
255
+ return null;
256
+ }
257
+ };
@@ -0,0 +1,58 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import React, { type PropsWithChildren } from 'react';
6
+
7
+ import { Input, type Label, type ThemedClassName, toLocalizedString, useTranslation } from '@dxos/react-ui';
8
+ import { mx } from '@dxos/ui-theme';
9
+
10
+ import { translationKey } from '#translations';
11
+
12
+ const SETTINGS_PANEL_NAME = 'Settings.Panel';
13
+ const SETTINGS_ITEM_NAME = 'Settings.Item';
14
+
15
+ //
16
+ // Panel (bordered card container).
17
+ //
18
+
19
+ type SettingsPanelProps = ThemedClassName<PropsWithChildren>;
20
+
21
+ export const SettingsPanel = ({ classNames, children }: SettingsPanelProps) => {
22
+ return (
23
+ <div className={mx('flex flex-col gap-3 p-trim-md border border-separator rounded-sm', classNames)}>{children}</div>
24
+ );
25
+ };
26
+
27
+ SettingsPanel.displayName = SETTINGS_PANEL_NAME;
28
+
29
+ //
30
+ // Item (label + optional description + control, proper label semantics).
31
+ //
32
+
33
+ type SettingsItemProps = PropsWithChildren<{
34
+ title: Label;
35
+ description?: Label;
36
+ }>;
37
+
38
+ export const SettingsItem = ({ title, description = '', children }: SettingsItemProps) => {
39
+ const { t } = useTranslation(translationKey);
40
+
41
+ return (
42
+ <Input.Root>
43
+ <SettingsPanel classNames='grid grid-cols-1 md:grid-cols-[1fr_1fr] gap-x-trim-lg gap-y-0'>
44
+ <Input.Label classNames='pb-trim-md text-base-foreground text-lg md:col-span-2'>
45
+ {toLocalizedString(title, t)}
46
+ </Input.Label>
47
+ <Input.DescriptionAndValidation>
48
+ <Input.Description classNames='text-base text-description'>
49
+ {toLocalizedString(description, t)}
50
+ </Input.Description>
51
+ </Input.DescriptionAndValidation>
52
+ <div className='text-end py-1'>{children}</div>
53
+ </SettingsPanel>
54
+ </Input.Root>
55
+ );
56
+ };
57
+
58
+ SettingsItem.displayName = SETTINGS_ITEM_NAME;
@@ -0,0 +1,59 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import React from 'react';
6
+
7
+ import { Select } from '@dxos/react-ui';
8
+
9
+ import { type SettingsFieldProps } from './SettingsFieldSet';
10
+
11
+ export type SelectFieldOption = {
12
+ value: string;
13
+ label?: string;
14
+ };
15
+
16
+ export type CreateSelectFieldOptions = {
17
+ /** Options to display. Strings are used as both value and label. */
18
+ options: ReadonlyArray<string | SelectFieldOption>;
19
+ /** Label for the sentinel option that maps to `undefined`. Defaults to `'Default'`. Pass `null` to omit. */
20
+ defaultLabel?: string | null;
21
+ };
22
+
23
+ /**
24
+ * Factory for a `SettingsFieldSet` `fieldMap` entry that renders a Select control
25
+ * over a fixed list of options, with an optional sentinel for `undefined`.
26
+ */
27
+ export const createSelectField = ({
28
+ options,
29
+ defaultLabel = 'Default',
30
+ }: CreateSelectFieldOptions): React.FC<SettingsFieldProps<string | undefined>> => {
31
+ const normalized = options.map((option) => (typeof option === 'string' ? { value: option, label: option } : option));
32
+ const hasDefault = defaultLabel !== null;
33
+ // Per-instance sentinel avoids collisions with real option values; empty string when the
34
+ // sentinel is omitted so `undefined` maps to a value Select.Root treats as "no selection".
35
+ const sentinel = hasDefault ? `__default__${Math.random().toString(36).slice(2)}` : '';
36
+
37
+ return ({ value, onChange, readonly }) => (
38
+ <Select.Root
39
+ disabled={readonly}
40
+ value={value ?? sentinel}
41
+ onValueChange={(next) => onChange(hasDefault && next === sentinel ? undefined : next)}
42
+ >
43
+ <Select.TriggerButton disabled={readonly} />
44
+ <Select.Portal>
45
+ <Select.Content>
46
+ <Select.Viewport>
47
+ {hasDefault && <Select.Option value={sentinel}>{defaultLabel}</Select.Option>}
48
+ {normalized.map((option) => (
49
+ <Select.Option key={option.value} value={option.value}>
50
+ {option.label ?? option.value}
51
+ </Select.Option>
52
+ ))}
53
+ </Select.Viewport>
54
+ <Select.Arrow />
55
+ </Select.Content>
56
+ </Select.Portal>
57
+ </Select.Root>
58
+ );
59
+ };
@@ -3,3 +3,5 @@
3
3
  //
4
4
 
5
5
  export * from './Settings';
6
+ export { type SettingsFieldMap, type SettingsFieldProps, type SettingsFieldSetProps } from './SettingsFieldSet';
7
+ export { createSelectField, type CreateSelectFieldOptions, type SelectFieldOption } from './SettingsSelectField';
@@ -6,8 +6,7 @@ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
6
  import * as Schema from 'effect/Schema';
7
7
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
8
8
 
9
- import { DXN, Filter, JsonSchema, Obj, Query, type QueryAST, Tag, Type } from '@dxos/echo';
10
- import { type View } from '@dxos/echo';
9
+ import { DXN, Filter, JsonSchema, Obj, Query, type QueryAST, Tag, Type, type View } from '@dxos/echo';
11
10
  import { type EchoSchema, Format, type Mutable } from '@dxos/echo/internal';
12
11
  import { useQuery } from '@dxos/react-client/echo';
13
12
  import { useClientStory, withClientProvider } from '@dxos/react-client/testing';
@@ -16,7 +15,8 @@ import { withLayout, withTheme } from '@dxos/react-ui/testing';
16
15
  import { type ProjectionModel, ViewModel, getTypenameFromQuery } from '@dxos/schema';
17
16
  import { Employer, Organization, Person, Pipeline } from '@dxos/types';
18
17
 
19
- import { translations } from '../../translations';
18
+ import { translations } from '#translations';
19
+
20
20
  import { TestLayout, VIEW_EDITOR_DEBUG_SYMBOL } from '../testing';
21
21
  import { ViewEditor, type ViewEditorProps } from './ViewEditor';
22
22
 
@@ -89,9 +89,9 @@ const DefaultStory = (props: DefaultStoryProps) => {
89
89
 
90
90
  if (props.mode === 'tag') {
91
91
  const queue = target && DXN.tryParse(target) ? target : undefined;
92
- const query = queue ? Query.fromAst(newQuery).from({ queues: [queue] }) : Query.fromAst(newQuery);
93
- Obj.change(view, (obj) => {
94
- obj.query.ast = query.ast as Mutable<typeof query.ast>;
92
+ const query = queue ? Query.fromAst(newQuery).from({ feeds: [queue] }) : Query.fromAst(newQuery);
93
+ Obj.update(view, (view) => {
94
+ view.query.ast = query.ast as Mutable<typeof query.ast>;
95
95
  });
96
96
 
97
97
  const typename = getTypenameFromQuery(query.ast);
@@ -104,13 +104,13 @@ const DefaultStory = (props: DefaultStoryProps) => {
104
104
  query,
105
105
  jsonSchema: newSchema.jsonSchema,
106
106
  });
107
- Obj.change(view, (obj) => {
108
- obj.projection = Obj.getSnapshot(newView).projection as Mutable<typeof obj.projection>;
107
+ Obj.update(view, (view) => {
108
+ view.projection = Obj.getSnapshot(newView).projection as Mutable<typeof view.projection>;
109
109
  });
110
110
  setSchema(() => newSchema);
111
111
  } else {
112
- Obj.change(view, (obj) => {
113
- obj.query.ast = newQuery as Mutable<typeof newQuery>;
112
+ Obj.update(view, (view) => {
113
+ view.query.ast = newQuery as Mutable<typeof newQuery>;
114
114
  });
115
115
  schema.updateTypename(getTypenameFromQuery(newQuery));
116
116
  }