@dxos/react-ui-form 0.8.4-main.fffef41 → 0.9.0
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/LICENSE +102 -5
- package/README.md +0 -1
- package/dist/lib/browser/index.mjs +4005 -2402
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/browser/translations.mjs +87 -0
- package/dist/lib/browser/translations.mjs.map +7 -0
- package/dist/lib/node-esm/index.mjs +4005 -2402
- package/dist/lib/node-esm/index.mjs.map +4 -4
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/lib/node-esm/translations.mjs +89 -0
- package/dist/lib/node-esm/translations.mjs.map +7 -0
- package/dist/types/src/components/FieldEditor/FieldEditor.d.ts +8 -7
- package/dist/types/src/components/FieldEditor/FieldEditor.d.ts.map +1 -1
- package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts +73 -59
- package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/Form.d.ts +135 -27
- package/dist/types/src/components/Form/Form.d.ts.map +1 -1
- package/dist/types/src/components/Form/Form.stories.d.ts +192 -137
- package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField.d.ts +52 -0
- package/dist/types/src/components/Form/FormField.d.ts.map +1 -0
- package/dist/types/src/components/Form/FormFieldComponent.d.ts +98 -0
- package/dist/types/src/components/Form/FormFieldComponent.d.ts.map +1 -0
- package/dist/types/src/components/Form/FormFieldComponent.stories.d.ts +9 -0
- package/dist/types/src/components/Form/FormFieldComponent.stories.d.ts.map +1 -0
- package/dist/types/src/components/Form/FormFieldSet.d.ts +26 -0
- package/dist/types/src/components/Form/FormFieldSet.d.ts.map +1 -0
- package/dist/types/src/components/Form/FormTooltipsContext.d.ts +19 -0
- package/dist/types/src/components/Form/FormTooltipsContext.d.ts.map +1 -0
- package/dist/types/src/components/Form/Layout/FormLayout.d.ts +50 -0
- package/dist/types/src/components/Form/Layout/FormLayout.d.ts.map +1 -0
- package/dist/types/src/components/Form/Layout/FormLayout.stories.d.ts +123 -0
- package/dist/types/src/components/Form/Layout/FormLayout.stories.d.ts.map +1 -0
- package/dist/types/src/components/Form/Layout/FormLayout.stories.test.d.ts +2 -0
- package/dist/types/src/components/Form/Layout/FormLayout.stories.test.d.ts.map +1 -0
- package/dist/types/src/components/Form/Layout/FormLayout.test.d.ts +2 -0
- package/dist/types/src/components/Form/Layout/FormLayout.test.d.ts.map +1 -0
- package/dist/types/src/components/Form/Layout/annotation.d.ts +30 -0
- package/dist/types/src/components/Form/Layout/annotation.d.ts.map +1 -0
- package/dist/types/src/components/Form/Layout/index.d.ts +4 -0
- package/dist/types/src/components/Form/Layout/index.d.ts.map +1 -0
- package/dist/types/src/components/Form/Layout/parser.d.ts +31 -0
- package/dist/types/src/components/Form/Layout/parser.d.ts.map +1 -0
- package/dist/types/src/components/Form/Layout/parser.test.d.ts +2 -0
- package/dist/types/src/components/Form/Layout/parser.test.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/ArrayField.d.ts +18 -0
- package/dist/types/src/components/Form/fields/ArrayField.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/BooleanField.d.ts +4 -0
- package/dist/types/src/components/Form/fields/BooleanField.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/DateField.d.ts +4 -0
- package/dist/types/src/components/Form/fields/DateField.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/GeoPointField.d.ts +5 -0
- package/dist/types/src/components/Form/fields/GeoPointField.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/InlineRefField.d.ts +13 -0
- package/dist/types/src/components/Form/fields/InlineRefField.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/MarkdownField.d.ts +11 -0
- package/dist/types/src/components/Form/fields/MarkdownField.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/MarkdownField.stories.d.ts +89 -0
- package/dist/types/src/components/Form/fields/MarkdownField.stories.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/NumberField.d.ts +4 -0
- package/dist/types/src/components/Form/fields/NumberField.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/RefField.d.ts +34 -0
- package/dist/types/src/components/Form/fields/RefField.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/RefField.test.d.ts +2 -0
- package/dist/types/src/components/Form/fields/RefField.test.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/SelectField.d.ts +13 -0
- package/dist/types/src/components/Form/fields/SelectField.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/SelectOptionField.d.ts +5 -0
- package/dist/types/src/components/Form/fields/SelectOptionField.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/SelectOptionField.stories.d.ts +89 -0
- package/dist/types/src/components/Form/fields/SelectOptionField.stories.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/TextAreaField.d.ts +4 -0
- package/dist/types/src/components/Form/fields/TextAreaField.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/TextField.d.ts +4 -0
- package/dist/types/src/components/Form/fields/TextField.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/TupleField.d.ts +6 -0
- package/dist/types/src/components/Form/fields/TupleField.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/index.d.ts +14 -0
- package/dist/types/src/components/Form/fields/index.d.ts.map +1 -0
- package/dist/types/src/components/Form/index.d.ts +5 -4
- package/dist/types/src/components/Form/index.d.ts.map +1 -1
- package/dist/types/src/components/Form/meta-tags.d.ts +30 -0
- package/dist/types/src/components/Form/meta-tags.d.ts.map +1 -0
- package/dist/types/src/components/Form/meta-tags.test.d.ts +2 -0
- package/dist/types/src/components/Form/meta-tags.test.d.ts.map +1 -0
- package/dist/types/src/components/ObjectForm/ObjectForm.d.ts +8 -0
- package/dist/types/src/components/ObjectForm/ObjectForm.d.ts.map +1 -0
- package/dist/types/src/components/ObjectForm/ObjectForm.stories.d.ts +87 -0
- package/dist/types/src/components/ObjectForm/ObjectForm.stories.d.ts.map +1 -0
- package/dist/types/src/components/ObjectForm/index.d.ts +2 -0
- package/dist/types/src/components/ObjectForm/index.d.ts.map +1 -0
- package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts +39 -12
- package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts.map +1 -1
- package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts +69 -58
- package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts.map +1 -1
- package/dist/types/src/components/ObjectProperties/ObjectProperties.d.ts +9 -0
- package/dist/types/src/components/ObjectProperties/ObjectProperties.d.ts.map +1 -0
- package/dist/types/src/components/ObjectProperties/ObjectProperties.stories.d.ts +132 -0
- package/dist/types/src/components/ObjectProperties/ObjectProperties.stories.d.ts.map +1 -0
- package/dist/types/src/components/ObjectProperties/ObjectProperties.test.d.ts +2 -0
- package/dist/types/src/components/ObjectProperties/ObjectProperties.test.d.ts.map +1 -0
- package/dist/types/src/components/ObjectProperties/index.d.ts +2 -0
- package/dist/types/src/components/ObjectProperties/index.d.ts.map +1 -0
- package/dist/types/src/components/ObjectTree/ObjectTree.d.ts +9 -0
- package/dist/types/src/components/ObjectTree/ObjectTree.d.ts.map +1 -0
- package/dist/types/src/components/ObjectTree/ObjectTree.stories.d.ts +91 -0
- package/dist/types/src/components/ObjectTree/ObjectTree.stories.d.ts.map +1 -0
- package/dist/types/src/components/ObjectTree/index.d.ts +2 -0
- package/dist/types/src/components/ObjectTree/index.d.ts.map +1 -0
- package/dist/types/src/components/RefEditor/RefEditor.d.ts +73 -0
- package/dist/types/src/components/RefEditor/RefEditor.d.ts.map +1 -0
- package/dist/types/src/components/RefEditor/RefEditor.stories.d.ts +92 -0
- package/dist/types/src/components/RefEditor/RefEditor.stories.d.ts.map +1 -0
- package/dist/types/src/components/RefEditor/index.d.ts +3 -0
- package/dist/types/src/components/RefEditor/index.d.ts.map +1 -0
- package/dist/types/src/components/RefEditor/ref-editor-extension.d.ts +47 -0
- package/dist/types/src/components/RefEditor/ref-editor-extension.d.ts.map +1 -0
- package/dist/types/src/components/Settings/Settings.d.ts +51 -0
- package/dist/types/src/components/Settings/Settings.d.ts.map +1 -0
- package/dist/types/src/components/Settings/Settings.stories.d.ts +89 -0
- package/dist/types/src/components/Settings/Settings.stories.d.ts.map +1 -0
- package/dist/types/src/components/Settings/SettingsFieldSet.d.ts +43 -0
- package/dist/types/src/components/Settings/SettingsFieldSet.d.ts.map +1 -0
- package/dist/types/src/components/Settings/SettingsItem.d.ts +16 -0
- package/dist/types/src/components/Settings/SettingsItem.d.ts.map +1 -0
- package/dist/types/src/components/Settings/SettingsSelectField.d.ts +18 -0
- package/dist/types/src/components/Settings/SettingsSelectField.d.ts.map +1 -0
- package/dist/types/src/components/Settings/index.d.ts +4 -0
- package/dist/types/src/components/Settings/index.d.ts.map +1 -0
- package/dist/types/src/components/ViewEditor/ViewEditor.d.ts +15 -19
- package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
- package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +75 -64
- package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
- package/dist/types/src/components/index.d.ts +5 -1
- package/dist/types/src/components/index.d.ts.map +1 -1
- package/dist/types/src/components/testing.d.ts +13 -6
- package/dist/types/src/components/testing.d.ts.map +1 -1
- package/dist/types/src/hooks/index.d.ts +2 -3
- package/dist/types/src/hooks/index.d.ts.map +1 -1
- package/dist/types/src/hooks/useFormHandler.d.ts +82 -0
- package/dist/types/src/hooks/useFormHandler.d.ts.map +1 -0
- package/dist/types/src/hooks/useKeyHandler.d.ts +7 -0
- package/dist/types/src/hooks/useKeyHandler.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +0 -2
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/translations.d.ts +70 -59
- package/dist/types/src/translations.d.ts.map +1 -1
- package/dist/types/src/util/field-type.d.ts +19 -0
- package/dist/types/src/util/field-type.d.ts.map +1 -0
- package/dist/types/src/util/field.d.ts +3 -3
- package/dist/types/src/util/field.d.ts.map +1 -1
- package/dist/types/src/util/formatting.d.ts +2 -2
- package/dist/types/src/util/formatting.d.ts.map +1 -1
- package/dist/types/src/util/index.d.ts +2 -1
- package/dist/types/src/util/index.d.ts.map +1 -1
- package/dist/types/src/util/properties.d.ts +35 -0
- package/dist/types/src/util/properties.d.ts.map +1 -0
- package/dist/types/src/util/properties.test.d.ts +2 -0
- package/dist/types/src/util/properties.test.d.ts.map +1 -0
- package/dist/types/src/util/refs.d.ts +2 -2
- package/dist/types/src/util/refs.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +64 -52
- package/src/components/FieldEditor/FieldEditor.stories.tsx +44 -25
- package/src/components/FieldEditor/FieldEditor.test.tsx +4 -2
- package/src/components/FieldEditor/FieldEditor.tsx +91 -86
- package/src/components/Form/AUDIT.md +17 -0
- package/src/components/Form/Form.stories.tsx +147 -252
- package/src/components/Form/Form.tsx +461 -108
- package/src/components/Form/FormField.tsx +345 -0
- package/src/components/Form/FormFieldComponent.stories.tsx +65 -0
- package/src/components/Form/FormFieldComponent.tsx +253 -0
- package/src/components/Form/FormFieldSet.tsx +213 -0
- package/src/components/Form/FormTooltipsContext.ts +25 -0
- package/src/components/Form/Layout/FormLayout.stories.test.tsx +51 -0
- package/src/components/Form/Layout/FormLayout.stories.tsx +422 -0
- package/src/components/Form/Layout/FormLayout.test.ts +59 -0
- package/src/components/Form/Layout/FormLayout.tsx +251 -0
- package/src/components/Form/Layout/annotation.ts +38 -0
- package/src/components/Form/Layout/index.ts +7 -0
- package/src/components/Form/Layout/parser.test.ts +120 -0
- package/src/components/Form/Layout/parser.ts +167 -0
- package/src/components/Form/README.md +3 -0
- package/src/components/Form/fields/ArrayField.tsx +173 -0
- package/src/components/Form/fields/BooleanField.tsx +22 -0
- package/src/components/Form/fields/DateField.tsx +101 -0
- package/src/components/Form/fields/GeoPointField.tsx +124 -0
- package/src/components/Form/fields/InlineRefField.tsx +138 -0
- package/src/components/Form/fields/MarkdownField.stories.tsx +206 -0
- package/src/components/Form/fields/MarkdownField.tsx +167 -0
- package/src/components/Form/fields/NumberField.tsx +76 -0
- package/src/components/Form/fields/RefField.test.ts +45 -0
- package/src/components/Form/fields/RefField.tsx +266 -0
- package/src/components/Form/fields/SelectField.tsx +77 -0
- package/src/components/Form/{custom/SelectOptionsInput.stories.tsx → fields/SelectOptionField.stories.tsx} +23 -18
- package/src/components/Form/{custom/SelectOptionsInput.tsx → fields/SelectOptionField.tsx} +33 -26
- package/src/components/Form/fields/TextAreaField.tsx +38 -0
- package/src/components/Form/fields/TextField.tsx +38 -0
- package/src/components/Form/{custom/TupleInput.tsx → fields/TupleField.tsx} +17 -9
- package/src/components/Form/fields/index.ts +17 -0
- package/src/components/Form/index.ts +6 -4
- package/src/components/Form/meta-tags.test.ts +47 -0
- package/src/components/Form/meta-tags.ts +33 -0
- package/src/components/ObjectForm/ObjectForm.stories.tsx +62 -0
- package/src/components/ObjectForm/ObjectForm.tsx +111 -0
- package/src/components/{ControlSection → ObjectForm}/index.ts +1 -1
- package/src/components/ObjectPicker/ObjectPicker.stories.tsx +26 -21
- package/src/components/ObjectPicker/ObjectPicker.tsx +123 -82
- package/src/components/ObjectProperties/ObjectProperties.stories.tsx +350 -0
- package/src/components/ObjectProperties/ObjectProperties.test.tsx +111 -0
- package/src/components/ObjectProperties/ObjectProperties.tsx +146 -0
- package/src/components/ObjectProperties/index.ts +5 -0
- package/src/components/ObjectTree/ObjectTree.stories.tsx +134 -0
- package/src/components/ObjectTree/ObjectTree.tsx +190 -0
- package/src/components/ObjectTree/index.ts +5 -0
- package/src/components/RefEditor/RefEditor.stories.tsx +148 -0
- package/src/components/RefEditor/RefEditor.tsx +275 -0
- package/src/components/RefEditor/index.ts +6 -0
- package/src/components/RefEditor/ref-editor-extension.ts +254 -0
- package/src/components/Settings/AUDIT.md +51 -0
- package/src/components/Settings/Settings.stories.tsx +104 -0
- package/src/components/Settings/Settings.tsx +81 -0
- package/src/components/Settings/SettingsFieldSet.tsx +257 -0
- package/src/components/Settings/SettingsItem.tsx +58 -0
- package/src/components/Settings/SettingsSelectField.tsx +59 -0
- package/src/components/Settings/index.ts +7 -0
- package/src/components/ViewEditor/ViewEditor.stories.tsx +70 -76
- package/src/components/ViewEditor/ViewEditor.test.tsx +17 -13
- package/src/components/ViewEditor/ViewEditor.tsx +340 -226
- package/src/components/index.ts +5 -1
- package/src/components/testing.tsx +24 -11
- package/src/hooks/index.ts +2 -3
- package/src/hooks/useFormHandler.ts +380 -0
- package/src/hooks/useKeyHandler.tsx +37 -0
- package/src/index.ts +0 -4
- package/src/translations.ts +76 -64
- package/src/util/field-type.ts +60 -0
- package/src/util/field.ts +25 -25
- package/src/util/formatting.ts +22 -22
- package/src/util/index.ts +2 -1
- package/src/util/properties.test.ts +153 -0
- package/src/util/properties.ts +100 -0
- package/src/util/refs.ts +16 -15
- package/src/vite-env.d.ts +10 -0
- package/dist/types/src/components/ControlSection/ControlSection.d.ts +0 -23
- package/dist/types/src/components/ControlSection/ControlSection.d.ts.map +0 -1
- package/dist/types/src/components/ControlSection/ControlSection.stories.d.ts +0 -83
- package/dist/types/src/components/ControlSection/ControlSection.stories.d.ts.map +0 -1
- package/dist/types/src/components/ControlSection/index.d.ts +0 -2
- package/dist/types/src/components/ControlSection/index.d.ts.map +0 -1
- 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/Defaults.d.ts +0 -14
- package/dist/types/src/components/Form/Defaults.d.ts.map +0 -1
- package/dist/types/src/components/Form/FormActions.d.ts +0 -9
- package/dist/types/src/components/Form/FormActions.d.ts.map +0 -1
- package/dist/types/src/components/Form/FormContent.d.ts +0 -63
- package/dist/types/src/components/Form/FormContent.d.ts.map +0 -1
- package/dist/types/src/components/Form/FormContext.d.ts +0 -23
- package/dist/types/src/components/Form/FormContext.d.ts.map +0 -1
- package/dist/types/src/components/Form/Input.d.ts +0 -29
- package/dist/types/src/components/Form/Input.d.ts.map +0 -1
- package/dist/types/src/components/Form/RefField.d.ts +0 -22
- package/dist/types/src/components/Form/RefField.d.ts.map +0 -1
- package/dist/types/src/components/Form/custom/GeoPointInput.d.ts +0 -4
- package/dist/types/src/components/Form/custom/GeoPointInput.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 -78
- package/dist/types/src/components/Form/custom/SelectOptionsInput.stories.d.ts.map +0 -1
- package/dist/types/src/components/Form/custom/TupleInput.d.ts +0 -6
- package/dist/types/src/components/Form/custom/TupleInput.d.ts.map +0 -1
- package/dist/types/src/components/Form/custom/index.d.ts +0 -4
- package/dist/types/src/components/Form/custom/index.d.ts.map +0 -1
- package/dist/types/src/components/Form/factory.d.ts +0 -8
- package/dist/types/src/components/Form/factory.d.ts.map +0 -1
- package/dist/types/src/hooks/useForm.d.ts +0 -67
- package/dist/types/src/hooks/useForm.d.ts.map +0 -1
- package/dist/types/src/hooks/useQueryRefOptions.d.ts +0 -29
- package/dist/types/src/hooks/useQueryRefOptions.d.ts.map +0 -1
- package/dist/types/src/hooks/useRefQueryLookupHandler.d.ts +0 -8
- package/dist/types/src/hooks/useRefQueryLookupHandler.d.ts.map +0 -1
- package/dist/types/src/util/schema.d.ts +0 -3
- package/dist/types/src/util/schema.d.ts.map +0 -1
- package/src/components/ControlSection/ControlSection.stories.tsx +0 -38
- package/src/components/ControlSection/ControlSection.tsx +0 -153
- package/src/components/Form/ArrayField.tsx +0 -125
- package/src/components/Form/Defaults.tsx +0 -205
- package/src/components/Form/FormActions.tsx +0 -54
- package/src/components/Form/FormContent.tsx +0 -362
- package/src/components/Form/FormContext.tsx +0 -110
- package/src/components/Form/Input.tsx +0 -54
- package/src/components/Form/RefField.tsx +0 -283
- package/src/components/Form/custom/GeoPointInput.tsx +0 -80
- package/src/components/Form/custom/index.ts +0 -7
- package/src/components/Form/factory.tsx +0 -31
- package/src/hooks/useForm.ts +0 -296
- package/src/hooks/useQueryRefOptions.ts +0 -62
- package/src/hooks/useRefQueryLookupHandler.ts +0 -39
- package/src/util/schema.ts +0 -12
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
|
6
|
+
import React, { Fragment, useState } from 'react';
|
|
7
|
+
|
|
8
|
+
import { type Database, Filter, Obj } from '@dxos/echo';
|
|
9
|
+
import { random } from '@dxos/random';
|
|
10
|
+
import { useQuery } from '@dxos/react-client/echo';
|
|
11
|
+
import { useClientStory, withClientProvider } from '@dxos/react-client/testing';
|
|
12
|
+
import { JsonHighlighter } from '@dxos/react-ui-syntax-highlighter';
|
|
13
|
+
import { withLayout, withTheme } from '@dxos/react-ui/testing';
|
|
14
|
+
import { type ValueGenerator, createObjectFactory } from '@dxos/schema/testing';
|
|
15
|
+
import { Person } from '@dxos/types';
|
|
16
|
+
|
|
17
|
+
import { translations } from '#translations';
|
|
18
|
+
|
|
19
|
+
import { EMAIL_REGEX } from './ref-editor-extension';
|
|
20
|
+
import { RefEditor, type RefEditorProps } from './RefEditor';
|
|
21
|
+
|
|
22
|
+
const generator: ValueGenerator = random as any;
|
|
23
|
+
|
|
24
|
+
random.seed(7);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Generate Person objects into the space. The generator does not populate array fields, so derive
|
|
28
|
+
* email addresses from each person's name (every other person gets a second work alias so the
|
|
29
|
+
* email-mode picker shows multiple entries per person).
|
|
30
|
+
*/
|
|
31
|
+
const generatePeople = async (db: Database.Database, count: number) => {
|
|
32
|
+
const people = await createObjectFactory(db, generator)([{ type: Person.Person, count }]);
|
|
33
|
+
people.forEach((person, index) => {
|
|
34
|
+
Obj.update(person, (person: Obj.Mutable<Person.Person>) => {
|
|
35
|
+
const slug = (person.fullName ?? 'user')
|
|
36
|
+
.toLowerCase()
|
|
37
|
+
.replace(/[^a-z0-9]+/g, '.')
|
|
38
|
+
.replace(/^\.+|\.+$/g, '');
|
|
39
|
+
person.emails = [
|
|
40
|
+
{ value: `${slug}@example.com` },
|
|
41
|
+
...(index % 2 === 0 ? [{ label: 'work', value: `${slug}@work.example.com` }] : []),
|
|
42
|
+
...(index % 4 === 0 ? [{ label: 'private', value: `${random.lorem.word()}@gmail.com` }] : []),
|
|
43
|
+
];
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
return people;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Person email addresses (the values a person can be captured as in email mode). */
|
|
50
|
+
const getPersonValues = (object: Obj.Unknown): readonly string[] =>
|
|
51
|
+
Obj.instanceOf(Person.Person, object) ? (object.emails ?? []).map(({ value }) => value) : [];
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Dense reference grid of the generated people and their email addresses.
|
|
55
|
+
*/
|
|
56
|
+
const PeopleGrid = ({ db }: { db?: Database.Database }) => {
|
|
57
|
+
const people = useQuery(db, Filter.type(Person.Person));
|
|
58
|
+
return (
|
|
59
|
+
<div className='grid grid-cols-[max-content_1fr] gap-x-4 text-sm text-description'>
|
|
60
|
+
{people.flatMap((person) =>
|
|
61
|
+
(person.emails ?? []).map(({ value }) => (
|
|
62
|
+
<Fragment key={`${person.id}-${value}`}>
|
|
63
|
+
<span className='truncate'>{person.fullName}</span>
|
|
64
|
+
<span className='truncate'>{value}</span>
|
|
65
|
+
</Fragment>
|
|
66
|
+
)),
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
type StoryProps = Partial<Pick<RefEditorProps, 'value' | 'mode' | 'activateOnTyping' | 'autoFocus'>>;
|
|
73
|
+
|
|
74
|
+
// NOTE: The Person config (type/match/getValues) is supplied in render, not args — Storybook's
|
|
75
|
+
// arg traversal attempts to mutate the proxied ECHO schema and crashes.
|
|
76
|
+
const DefaultStory = ({ value: initialValue, ...args }: StoryProps) => {
|
|
77
|
+
const { space } = useClientStory();
|
|
78
|
+
const [value, setValue] = useState(initialValue ?? '');
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div className='flex flex-col gap-2'>
|
|
82
|
+
<RefEditor
|
|
83
|
+
{...args}
|
|
84
|
+
value={initialValue}
|
|
85
|
+
type={Person.Person}
|
|
86
|
+
match={EMAIL_REGEX}
|
|
87
|
+
getValues={getPersonValues}
|
|
88
|
+
icon='ph--user--regular'
|
|
89
|
+
db={space?.db}
|
|
90
|
+
onChange={setValue}
|
|
91
|
+
classNames='border'
|
|
92
|
+
/>
|
|
93
|
+
<JsonHighlighter data={{ value }} classNames='text-xs' />
|
|
94
|
+
<PeopleGrid db={space?.db} />
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const meta = {
|
|
100
|
+
title: 'ui/react-ui-form/RefEditor',
|
|
101
|
+
render: DefaultStory,
|
|
102
|
+
decorators: [
|
|
103
|
+
withTheme(),
|
|
104
|
+
withLayout({ layout: 'column', classNames: 'p-2', scroll: true }),
|
|
105
|
+
withClientProvider({
|
|
106
|
+
types: [Person.Person],
|
|
107
|
+
createIdentity: true,
|
|
108
|
+
createSpace: true,
|
|
109
|
+
onCreateSpace: async ({ space }) => {
|
|
110
|
+
await generatePeople(space.db, 8);
|
|
111
|
+
},
|
|
112
|
+
}),
|
|
113
|
+
],
|
|
114
|
+
parameters: {
|
|
115
|
+
translations,
|
|
116
|
+
},
|
|
117
|
+
} satisfies Meta<typeof DefaultStory>;
|
|
118
|
+
|
|
119
|
+
export default meta;
|
|
120
|
+
|
|
121
|
+
type Story = StoryObj<typeof meta>;
|
|
122
|
+
|
|
123
|
+
export const Default: Story = {
|
|
124
|
+
args: {
|
|
125
|
+
autoFocus: true,
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export const ActivateOnTyping: Story = {
|
|
130
|
+
args: {
|
|
131
|
+
autoFocus: true,
|
|
132
|
+
activateOnTyping: true,
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export const EmailMode: Story = {
|
|
137
|
+
args: {
|
|
138
|
+
autoFocus: true,
|
|
139
|
+
mode: 'email',
|
|
140
|
+
activateOnTyping: true,
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export const WithEmail: Story = {
|
|
145
|
+
args: {
|
|
146
|
+
value: 'someone@example.com ',
|
|
147
|
+
},
|
|
148
|
+
};
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type Extension, Prec } from '@codemirror/state';
|
|
6
|
+
import { type EditorView } from '@codemirror/view';
|
|
7
|
+
import React, { forwardRef, useCallback, useEffect, useMemo, useRef } from 'react';
|
|
8
|
+
|
|
9
|
+
import { type Database, Filter, Obj, type Type } from '@dxos/echo';
|
|
10
|
+
import { useQuery } from '@dxos/react-client/echo';
|
|
11
|
+
import { type ThemedClassName, useThemeContext, useTranslation } from '@dxos/react-ui';
|
|
12
|
+
import {
|
|
13
|
+
Editor,
|
|
14
|
+
type EditorController,
|
|
15
|
+
type EditorMenuProviderProps,
|
|
16
|
+
type EditorViewProps,
|
|
17
|
+
type UseEditorMenuProps,
|
|
18
|
+
} from '@dxos/react-ui-editor';
|
|
19
|
+
import { createBasicExtensions, createThemeExtensions, insertAtCursor, keymap } from '@dxos/ui-editor';
|
|
20
|
+
import { getHashHue } from '@dxos/ui-theme';
|
|
21
|
+
|
|
22
|
+
import { translationKey } from '#translations';
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
EMAIL_REGEX,
|
|
26
|
+
NAME_ADDR_REGEX,
|
|
27
|
+
type RefEditorMode,
|
|
28
|
+
type RefInfo,
|
|
29
|
+
formatMailbox,
|
|
30
|
+
refEditor,
|
|
31
|
+
refEditorRedecorate,
|
|
32
|
+
} from './ref-editor-extension';
|
|
33
|
+
|
|
34
|
+
export type RefEditorProps = ThemedClassName<
|
|
35
|
+
{
|
|
36
|
+
/** Database queried for objects of {@link RefEditorProps.type}. */
|
|
37
|
+
db?: Database.Database;
|
|
38
|
+
/** ECHO object type whose instances are offered in the typeahead and referenced as `@<id>`. */
|
|
39
|
+
type: Type.AnyObj;
|
|
40
|
+
value?: string;
|
|
41
|
+
readonly?: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Content model:
|
|
44
|
+
* - `'ref'` (default): the picker only matches existing objects; selection inserts an
|
|
45
|
+
* `@<id>` reference (raw tokens accepted by `match` are also allowed).
|
|
46
|
+
* - `'email'`: the picker shows one item per (object, value) pair and selection captures the
|
|
47
|
+
* value; the content is an RFC 5322 mailbox list (`Name <email>, ...`) with the comma
|
|
48
|
+
* separators hidden inside the tags.
|
|
49
|
+
*/
|
|
50
|
+
mode?: RefEditorMode;
|
|
51
|
+
/** Pluggable raw-token matcher (e.g. {@link EMAIL_REGEX}); matching tokens are accepted as values. */
|
|
52
|
+
match?: RegExp;
|
|
53
|
+
/** Display label for an object; defaults to `Obj.getLabel` falling back to the id. */
|
|
54
|
+
getLabel?: (object: Obj.Unknown) => string;
|
|
55
|
+
/** Values an object can be captured as in `'email'` mode (e.g. a person's email addresses). */
|
|
56
|
+
getValues?: (object: Obj.Unknown) => readonly string[];
|
|
57
|
+
/** Menu item icon. */
|
|
58
|
+
icon?: string;
|
|
59
|
+
/** Already-translated placeholder; defaults to a generic hint. */
|
|
60
|
+
placeholder?: string;
|
|
61
|
+
/** Open the typeahead while typing (the `@` trigger remains available but is not required). */
|
|
62
|
+
activateOnTyping?: boolean;
|
|
63
|
+
} & Omit<EditorViewProps, 'initialValue'> &
|
|
64
|
+
Pick<EditorMenuProviderProps, 'numItems'>
|
|
65
|
+
>;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Single-line input for a list of object references. Typing `@` (or any text, with
|
|
69
|
+
* `activateOnTyping`) opens a typeahead menu matching objects of the given type by label (and
|
|
70
|
+
* by their `getValues` values). The content model depends on {@link RefEditorProps.mode}.
|
|
71
|
+
* Resolved entries render as atomic tags with the object's label; raw tokens accepted by the
|
|
72
|
+
* pluggable `match` regexp render as neutral tags.
|
|
73
|
+
*/
|
|
74
|
+
export const RefEditor = forwardRef<EditorController, RefEditorProps>(
|
|
75
|
+
(
|
|
76
|
+
{
|
|
77
|
+
db,
|
|
78
|
+
type,
|
|
79
|
+
value,
|
|
80
|
+
readonly,
|
|
81
|
+
mode = 'ref',
|
|
82
|
+
match,
|
|
83
|
+
getLabel,
|
|
84
|
+
getValues,
|
|
85
|
+
icon = 'ph--link--regular',
|
|
86
|
+
placeholder: placeholderProp,
|
|
87
|
+
activateOnTyping,
|
|
88
|
+
numItems = 8,
|
|
89
|
+
...props
|
|
90
|
+
},
|
|
91
|
+
forwardedRef,
|
|
92
|
+
) => {
|
|
93
|
+
const { t } = useTranslation(translationKey);
|
|
94
|
+
const { themeMode } = useThemeContext();
|
|
95
|
+
const getObjectLabel = useCallback(
|
|
96
|
+
(object: Obj.Unknown) => getLabel?.(object) ?? Obj.getLabel(object) ?? object.id,
|
|
97
|
+
[getLabel],
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
// Reads go through a ref so the decoration extension stays stable: recreating extensions
|
|
101
|
+
// destroys and recreates the editor, which would blur the input on data changes.
|
|
102
|
+
const filter = useMemo(() => Filter.type(type), [type]);
|
|
103
|
+
const objects = useQuery(db, filter);
|
|
104
|
+
const refs = useMemo(
|
|
105
|
+
() =>
|
|
106
|
+
new Map<string, RefInfo>(
|
|
107
|
+
objects.map((object) => [object.id, { label: getObjectLabel(object), hue: getHashHue(object.id) }]),
|
|
108
|
+
),
|
|
109
|
+
[objects, getObjectLabel],
|
|
110
|
+
);
|
|
111
|
+
const refsRef = useRef(refs);
|
|
112
|
+
refsRef.current = refs;
|
|
113
|
+
|
|
114
|
+
// Internal controller (merged with the forwarded ref) used to re-resolve reference
|
|
115
|
+
// decorations when object data changes after the document was rendered.
|
|
116
|
+
const controllerRef = useRef<EditorController | null>(null);
|
|
117
|
+
const composedRef = useCallback(
|
|
118
|
+
(controller: EditorController | null) => {
|
|
119
|
+
controllerRef.current = controller;
|
|
120
|
+
if (typeof forwardedRef === 'function') {
|
|
121
|
+
forwardedRef(controller);
|
|
122
|
+
} else if (forwardedRef) {
|
|
123
|
+
forwardedRef.current = controller;
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
[forwardedRef],
|
|
127
|
+
);
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
controllerRef.current?.view?.dispatch({ effects: refEditorRedecorate.of(null) });
|
|
130
|
+
}, [refs]);
|
|
131
|
+
|
|
132
|
+
const getMenu = useCallback<NonNullable<UseEditorMenuProps['getMenu']>>(
|
|
133
|
+
async ({ text }) => {
|
|
134
|
+
const query = (text ?? '').replace(/^@/, '').toLowerCase();
|
|
135
|
+
const matchesQuery = (object: Obj.Unknown) =>
|
|
136
|
+
!query ||
|
|
137
|
+
getObjectLabel(object).toLowerCase().includes(query) ||
|
|
138
|
+
(getValues?.(object) ?? []).some((entry) => entry.toLowerCase().includes(query));
|
|
139
|
+
|
|
140
|
+
if (mode === 'email') {
|
|
141
|
+
// One item per (object, value) pair; selection captures the value as an RFC 5322 mailbox.
|
|
142
|
+
const mailboxes = objects
|
|
143
|
+
.flatMap((object) => (getValues?.(object) ?? []).map((entry) => ({ object, entry })))
|
|
144
|
+
.filter(
|
|
145
|
+
({ object, entry }) =>
|
|
146
|
+
!query || getObjectLabel(object).toLowerCase().includes(query) || entry.toLowerCase().includes(query),
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
return [
|
|
150
|
+
{
|
|
151
|
+
id: 'mailboxes',
|
|
152
|
+
items: mailboxes.slice(0, numItems * 2).map(({ object, entry }) => ({
|
|
153
|
+
id: `${object.id}-${entry}`,
|
|
154
|
+
label: `${getObjectLabel(object)} (${entry})`,
|
|
155
|
+
icon,
|
|
156
|
+
onSelect: ({ view, head }: { view: EditorView; head: number }) =>
|
|
157
|
+
insertMailbox(view, head, formatMailbox(getObjectLabel(object), entry)),
|
|
158
|
+
})),
|
|
159
|
+
},
|
|
160
|
+
];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 'ref' mode: only match existing objects.
|
|
164
|
+
return [
|
|
165
|
+
{
|
|
166
|
+
id: 'refs',
|
|
167
|
+
items: objects
|
|
168
|
+
.filter(matchesQuery)
|
|
169
|
+
.slice(0, numItems * 2)
|
|
170
|
+
.map((object) => {
|
|
171
|
+
const label = getObjectLabel(object);
|
|
172
|
+
const detail = getValues?.(object)?.[0];
|
|
173
|
+
return {
|
|
174
|
+
id: object.id,
|
|
175
|
+
label: detail && detail !== label ? `${label} (${detail})` : label,
|
|
176
|
+
icon,
|
|
177
|
+
onSelect: ({ view, head }: { view: EditorView; head: number }) =>
|
|
178
|
+
insertAtCursor(view, head, `@${object.id} `),
|
|
179
|
+
};
|
|
180
|
+
}),
|
|
181
|
+
},
|
|
182
|
+
];
|
|
183
|
+
},
|
|
184
|
+
[objects, numItems, mode, icon, getObjectLabel, getValues],
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
// NOTE: The placeholder is supplied via the popover (Editor.Root) so the trigger hint and the
|
|
188
|
+
// empty-state hint are a single message; a second basic-extension placeholder would stack with it.
|
|
189
|
+
// Must be referentially stable: the menu extension is memoized on it, and a new extension
|
|
190
|
+
// identity destroys and recreates the editor on every keystroke.
|
|
191
|
+
const placeholder = useMemo(
|
|
192
|
+
() => ({
|
|
193
|
+
content: placeholderProp ?? t(activateOnTyping ? 'ref-editor-auto.placeholder' : 'ref-editor.placeholder'),
|
|
194
|
+
focusOnly: false,
|
|
195
|
+
}),
|
|
196
|
+
[t, placeholderProp, activateOnTyping],
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const extensions = useMemo<Extension[]>(
|
|
200
|
+
() => [
|
|
201
|
+
createBasicExtensions({ readOnly: readonly, lineWrapping: false }),
|
|
202
|
+
createThemeExtensions({ themeMode, slots: { scroller: { className: 'scrollbar-none' } } }),
|
|
203
|
+
refEditor({ mode, match, getRef: (id) => refsRef.current.get(id) }),
|
|
204
|
+
Prec.highest(
|
|
205
|
+
keymap.of([
|
|
206
|
+
{
|
|
207
|
+
key: 'Enter',
|
|
208
|
+
// Commit the in-progress token when it matches (insert the mode's separator, the
|
|
209
|
+
// same gesture as typing it); always consume the key (single-line input).
|
|
210
|
+
run: (view) => {
|
|
211
|
+
const { head } = view.state.selection.main;
|
|
212
|
+
const line = view.state.doc.lineAt(head);
|
|
213
|
+
const before = line.text.slice(0, head - line.from);
|
|
214
|
+
if (mode === 'email') {
|
|
215
|
+
const segment = before.slice(before.lastIndexOf(',') + 1).trim();
|
|
216
|
+
if (NAME_ADDR_REGEX.test(segment) || (match ?? EMAIL_REGEX).test(segment)) {
|
|
217
|
+
insertAtCursor(view, head, ', ');
|
|
218
|
+
}
|
|
219
|
+
} else {
|
|
220
|
+
const token = before.slice(before.search(/\S+$/));
|
|
221
|
+
if (token && match?.test(token)) {
|
|
222
|
+
insertAtCursor(view, head, ' ');
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return true;
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
]),
|
|
229
|
+
),
|
|
230
|
+
],
|
|
231
|
+
[readonly, themeMode, mode, match],
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
return (
|
|
235
|
+
<Editor.Root
|
|
236
|
+
extensions={extensions}
|
|
237
|
+
numItems={numItems}
|
|
238
|
+
trigger='@'
|
|
239
|
+
activateOnTyping={activateOnTyping}
|
|
240
|
+
placeholder={placeholder}
|
|
241
|
+
// The menu owns its own matching (labels AND values); the built-in label filter would
|
|
242
|
+
// drop value-matched objects whose label doesn't start with the query.
|
|
243
|
+
filter={false}
|
|
244
|
+
getMenu={getMenu}
|
|
245
|
+
ref={composedRef}
|
|
246
|
+
>
|
|
247
|
+
<Editor.View
|
|
248
|
+
// TOOD(burdon): Use same style as react-ui Input.
|
|
249
|
+
classNames='border border-separator rounded-xs px-2'
|
|
250
|
+
{...props}
|
|
251
|
+
initialValue={value}
|
|
252
|
+
selectionEnd
|
|
253
|
+
/>
|
|
254
|
+
</Editor.Root>
|
|
255
|
+
);
|
|
256
|
+
},
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
RefEditor.displayName = 'RefEditor';
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Insert an RFC 5322 mailbox, replacing the in-progress segment (anything typed after the last
|
|
263
|
+
* comma — multi-word queries can leave words outside the popover's completion range).
|
|
264
|
+
*/
|
|
265
|
+
const insertMailbox = (view: EditorView, head: number, mailbox: string) => {
|
|
266
|
+
const line = view.state.doc.lineAt(head);
|
|
267
|
+
const before = line.text.slice(0, head - line.from);
|
|
268
|
+
const lastComma = before.lastIndexOf(',');
|
|
269
|
+
const from = line.from + (lastComma === -1 ? 0 : lastComma + 1);
|
|
270
|
+
const insert = `${lastComma === -1 ? '' : ' '}${mailbox}, `;
|
|
271
|
+
view.dispatch({
|
|
272
|
+
changes: { from, to: head, insert },
|
|
273
|
+
selection: { anchor: from + insert.length, head: from + insert.length },
|
|
274
|
+
});
|
|
275
|
+
};
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type EditorState, type Extension, RangeSetBuilder, StateEffect, StateField } from '@codemirror/state';
|
|
6
|
+
import { Decoration, type DecorationSet, EditorView, WidgetType } from '@codemirror/view';
|
|
7
|
+
|
|
8
|
+
import { Domino } from '@dxos/ui';
|
|
9
|
+
import { getHashHue, getStyles, mx } from '@dxos/ui-theme';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Display info for a resolved object reference.
|
|
13
|
+
*/
|
|
14
|
+
export type RefInfo = {
|
|
15
|
+
label: string;
|
|
16
|
+
hue?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Content model:
|
|
21
|
+
* - `'ref'`: whitespace-separated tokens; `@<id>` references to existing objects (and raw tokens
|
|
22
|
+
* accepted by the pluggable {@link RefEditorExtensionOptions.match} regexp).
|
|
23
|
+
* - `'email'`: comma-separated RFC 5322 mailboxes (`Name <email>` or bare `email`); the comma
|
|
24
|
+
* separators are consumed into the atomic tags so they are not visible.
|
|
25
|
+
*/
|
|
26
|
+
export type RefEditorMode = 'ref' | 'email';
|
|
27
|
+
|
|
28
|
+
export type RefEditorExtensionOptions = {
|
|
29
|
+
mode?: RefEditorMode;
|
|
30
|
+
/** Resolve an object id referenced as `@<id>` to display info. */
|
|
31
|
+
getRef?: (id: string) => RefInfo | undefined;
|
|
32
|
+
/** Pluggable raw-token matcher (e.g. {@link EMAIL_REGEX}); matching tokens render as neutral tags. */
|
|
33
|
+
match?: RegExp;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Matches an object reference token (`@<object id>`). */
|
|
37
|
+
export const REF_REGEX = /^@([A-Za-z0-9]+)$/;
|
|
38
|
+
|
|
39
|
+
/** Matches a well-formed email address token. */
|
|
40
|
+
export const EMAIL_REGEX = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/;
|
|
41
|
+
|
|
42
|
+
/** Matches an RFC 5322 name-addr (`Name <email>`); group 1 = display name, group 2 = addr-spec. */
|
|
43
|
+
export const NAME_ADDR_REGEX = /^(.*?)\s*<([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})>$/;
|
|
44
|
+
|
|
45
|
+
/** Formats an RFC 5322 mailbox (`Name <email>`). */
|
|
46
|
+
export const formatMailbox = (name: string | undefined, email: string): string => (name ? `${name} <${email}>` : email);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Effect dispatched when reference data changes out-of-band (e.g. objects finish loading) so that
|
|
50
|
+
* already-rendered reference decorations re-resolve without a document change.
|
|
51
|
+
*/
|
|
52
|
+
export const refEditorRedecorate = StateEffect.define<null>();
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* CodeMirror extension rendering reference-editor tokens as tags. Tags are atomic: the cursor
|
|
56
|
+
* cannot enter them and deletion removes the whole token.
|
|
57
|
+
* - `'ref'` mode: `@<id>` references resolve to the object's label (via
|
|
58
|
+
* {@link RefEditorExtensionOptions.getRef}); tokens accepted by the pluggable `match` regexp
|
|
59
|
+
* render as neutral tags.
|
|
60
|
+
* - `'email'` mode: RFC 5322 mailboxes render as tags labelled with the display name (falling
|
|
61
|
+
* back to the address); the trailing comma separator is hidden inside the tag's atomic range.
|
|
62
|
+
*/
|
|
63
|
+
export const refEditor = ({ mode = 'ref', getRef, match }: RefEditorExtensionOptions = {}): Extension => {
|
|
64
|
+
const buildRefDecorations = (state: EditorState) => {
|
|
65
|
+
const deco = new RangeSetBuilder<Decoration>();
|
|
66
|
+
const text = state.doc.toString();
|
|
67
|
+
const tokenRegex = /\S+/g;
|
|
68
|
+
let tokenMatch: RegExpExecArray | null;
|
|
69
|
+
while ((tokenMatch = tokenRegex.exec(text))) {
|
|
70
|
+
const token = tokenMatch[0];
|
|
71
|
+
const from = tokenMatch.index;
|
|
72
|
+
const to = from + token.length;
|
|
73
|
+
|
|
74
|
+
const refMatch = token.match(REF_REGEX);
|
|
75
|
+
if (refMatch) {
|
|
76
|
+
const ref = getRef?.(refMatch[1]);
|
|
77
|
+
if (ref) {
|
|
78
|
+
deco.add(
|
|
79
|
+
from,
|
|
80
|
+
to,
|
|
81
|
+
Decoration.widget({
|
|
82
|
+
widget: new RefWidget(ref.label, ref.hue ?? getHashHue(refMatch[1])),
|
|
83
|
+
atomic: true,
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (match?.test(token)) {
|
|
91
|
+
deco.add(
|
|
92
|
+
from,
|
|
93
|
+
to,
|
|
94
|
+
Decoration.widget({
|
|
95
|
+
widget: new TokenWidget(token),
|
|
96
|
+
atomic: true,
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return deco.finish();
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const buildEmailDecorations = (state: EditorState) => {
|
|
106
|
+
const addrRegex = match ?? EMAIL_REGEX;
|
|
107
|
+
const deco = new RangeSetBuilder<Decoration>();
|
|
108
|
+
const text = state.doc.toString();
|
|
109
|
+
// Segments are comma-separated; a segment's atomic range includes its trailing comma (and
|
|
110
|
+
// following whitespace) so the separator never renders.
|
|
111
|
+
const segmentRegex = /[^,]+(?:,\s*)?|,\s*/g;
|
|
112
|
+
let segmentMatch: RegExpExecArray | null;
|
|
113
|
+
while ((segmentMatch = segmentRegex.exec(text))) {
|
|
114
|
+
const segment = segmentMatch[0];
|
|
115
|
+
const commaIndex = segment.indexOf(',');
|
|
116
|
+
const content = (commaIndex === -1 ? segment : segment.slice(0, commaIndex)).trim();
|
|
117
|
+
if (!content) {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const leading = segment.length - segment.trimStart().length;
|
|
121
|
+
const from = segmentMatch.index + leading;
|
|
122
|
+
const to = segmentMatch.index + segment.length;
|
|
123
|
+
|
|
124
|
+
const nameAddr = content.match(NAME_ADDR_REGEX);
|
|
125
|
+
if (nameAddr) {
|
|
126
|
+
deco.add(
|
|
127
|
+
from,
|
|
128
|
+
to,
|
|
129
|
+
Decoration.widget({
|
|
130
|
+
widget: new RefWidget(nameAddr[1] || nameAddr[2], getHashHue(nameAddr[2])),
|
|
131
|
+
atomic: true,
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
} else if (addrRegex.test(content)) {
|
|
135
|
+
deco.add(
|
|
136
|
+
from,
|
|
137
|
+
to,
|
|
138
|
+
Decoration.widget({
|
|
139
|
+
widget: new TokenWidget(content),
|
|
140
|
+
atomic: true,
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return deco.finish();
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const buildDecorations = mode === 'email' ? buildEmailDecorations : buildRefDecorations;
|
|
150
|
+
|
|
151
|
+
return [
|
|
152
|
+
styles,
|
|
153
|
+
StateField.define<DecorationSet>({
|
|
154
|
+
create: (state) => buildDecorations(state),
|
|
155
|
+
update: (deco, tr) => {
|
|
156
|
+
if (tr.docChanged || tr.effects.some((effect) => effect.is(refEditorRedecorate))) {
|
|
157
|
+
return buildDecorations(tr.state);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return deco;
|
|
161
|
+
},
|
|
162
|
+
provide: (field) => [
|
|
163
|
+
EditorView.decorations.from(field),
|
|
164
|
+
EditorView.atomicRanges.of((view) => {
|
|
165
|
+
const builder = new RangeSetBuilder<Decoration>();
|
|
166
|
+
const cursor = view.state.field(field).iter();
|
|
167
|
+
while (cursor.value) {
|
|
168
|
+
if (cursor.value.spec.atomic) {
|
|
169
|
+
builder.add(cursor.from, cursor.to, cursor.value);
|
|
170
|
+
}
|
|
171
|
+
cursor.next();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return builder.finish();
|
|
175
|
+
}),
|
|
176
|
+
],
|
|
177
|
+
}),
|
|
178
|
+
];
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const lineHeight = '30px';
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* NOTE: The outer container vertically aligns the inner text with content in the outer div.
|
|
185
|
+
*/
|
|
186
|
+
const container = (classNames: string, ...children: Domino<HTMLElement>[]) => {
|
|
187
|
+
const inner = Domino.of('span')
|
|
188
|
+
.classNames(mx('inline-flex h-[26px] border rounded-xs', classNames))
|
|
189
|
+
.append(...children);
|
|
190
|
+
return Domino.of('span').classNames('inline-flex h-[28px] align-middle').append(inner).root;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Resolved reference (object reference or RFC 5322 mailbox), rendered as the object's label.
|
|
195
|
+
*/
|
|
196
|
+
class RefWidget extends WidgetType {
|
|
197
|
+
constructor(
|
|
198
|
+
private readonly _label: string,
|
|
199
|
+
private readonly _hue: string,
|
|
200
|
+
) {
|
|
201
|
+
super();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
override eq(other: this) {
|
|
205
|
+
return this._label === other._label && this._hue === other._hue;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
override toDOM() {
|
|
209
|
+
const { bg: fill, border, surface } = getStyles(this._hue);
|
|
210
|
+
return container(
|
|
211
|
+
border,
|
|
212
|
+
Domino.of('span').classNames(mx('flex items-center px-1 text-black text-xs', fill)).text('@'),
|
|
213
|
+
Domino.of('span')
|
|
214
|
+
.classNames(mx('flex items-center px-1 text-subdued text-sm rounded-r-[3px]', surface))
|
|
215
|
+
.text(this._label),
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Raw token accepted by the pluggable matcher (e.g. an email address).
|
|
222
|
+
*/
|
|
223
|
+
class TokenWidget extends WidgetType {
|
|
224
|
+
constructor(private readonly _token: string) {
|
|
225
|
+
super();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
override eq(other: this) {
|
|
229
|
+
return this._token === other._token;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
override toDOM() {
|
|
233
|
+
return container(
|
|
234
|
+
'border-separator',
|
|
235
|
+
Domino.of('span').classNames('flex items-center px-1 text-subdued text-sm').text(this._token),
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const styles = EditorView.theme({
|
|
241
|
+
'.cm-line': {
|
|
242
|
+
lineHeight,
|
|
243
|
+
},
|
|
244
|
+
// Match the standard Input block size (md density): 2.5rem, 2rem on pointer-fine devices.
|
|
245
|
+
'.cm-scroller': {
|
|
246
|
+
alignItems: 'center',
|
|
247
|
+
minHeight: '2.5rem',
|
|
248
|
+
},
|
|
249
|
+
'@media (pointer: fine)': {
|
|
250
|
+
'.cm-scroller': {
|
|
251
|
+
minHeight: '2rem',
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
});
|