@dxos/react-ui-query 0.12.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.
Files changed (57) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +5 -0
  3. package/dist/lib/index.mjs +637 -0
  4. package/dist/lib/index.mjs.map +1 -0
  5. package/dist/types/src/components/QueryEditor/QueryEditor.d.ts +37 -0
  6. package/dist/types/src/components/QueryEditor/QueryEditor.d.ts.map +1 -0
  7. package/dist/types/src/components/QueryEditor/QueryEditor.stories.d.ts +42 -0
  8. package/dist/types/src/components/QueryEditor/QueryEditor.stories.d.ts.map +1 -0
  9. package/dist/types/src/components/QueryEditor/autocomplete.d.ts +8 -0
  10. package/dist/types/src/components/QueryEditor/autocomplete.d.ts.map +1 -0
  11. package/dist/types/src/components/QueryEditor/autocomplete.test.d.ts +2 -0
  12. package/dist/types/src/components/QueryEditor/autocomplete.test.d.ts.map +1 -0
  13. package/dist/types/src/components/QueryEditor/index.d.ts +3 -0
  14. package/dist/types/src/components/QueryEditor/index.d.ts.map +1 -0
  15. package/dist/types/src/components/QueryEditor/query-extension.d.ts +28 -0
  16. package/dist/types/src/components/QueryEditor/query-extension.d.ts.map +1 -0
  17. package/dist/types/src/components/QueryEditor/query-extension.test.d.ts +2 -0
  18. package/dist/types/src/components/QueryEditor/query-extension.test.d.ts.map +1 -0
  19. package/dist/types/src/components/QueryForm/Picker.d.ts +15 -0
  20. package/dist/types/src/components/QueryForm/Picker.d.ts.map +1 -0
  21. package/dist/types/src/components/QueryForm/QueryForm.d.ts +15 -0
  22. package/dist/types/src/components/QueryForm/QueryForm.d.ts.map +1 -0
  23. package/dist/types/src/components/QueryForm/QueryForm.stories.d.ts +26 -0
  24. package/dist/types/src/components/QueryForm/QueryForm.stories.d.ts.map +1 -0
  25. package/dist/types/src/components/QueryForm/index.d.ts +2 -0
  26. package/dist/types/src/components/QueryForm/index.d.ts.map +1 -0
  27. package/dist/types/src/components/QueryForm/query.d.ts +6 -0
  28. package/dist/types/src/components/QueryForm/query.d.ts.map +1 -0
  29. package/dist/types/src/components/index.d.ts +3 -0
  30. package/dist/types/src/components/index.d.ts.map +1 -0
  31. package/dist/types/src/hooks/index.d.ts +2 -0
  32. package/dist/types/src/hooks/index.d.ts.map +1 -0
  33. package/dist/types/src/hooks/useQueryBuilder.d.ts +6 -0
  34. package/dist/types/src/hooks/useQueryBuilder.d.ts.map +1 -0
  35. package/dist/types/src/index.d.ts +3 -0
  36. package/dist/types/src/index.d.ts.map +1 -0
  37. package/dist/types/src/translations.d.ts +13 -0
  38. package/dist/types/src/translations.d.ts.map +1 -0
  39. package/dist/types/tsconfig.tsbuildinfo +1 -0
  40. package/package.json +78 -0
  41. package/src/components/QueryEditor/QueryEditor.stories.tsx +165 -0
  42. package/src/components/QueryEditor/QueryEditor.tsx +136 -0
  43. package/src/components/QueryEditor/autocomplete.test.ts +46 -0
  44. package/src/components/QueryEditor/autocomplete.ts +76 -0
  45. package/src/components/QueryEditor/index.ts +7 -0
  46. package/src/components/QueryEditor/query-extension.test.ts +176 -0
  47. package/src/components/QueryEditor/query-extension.ts +532 -0
  48. package/src/components/QueryForm/Picker.tsx +47 -0
  49. package/src/components/QueryForm/QueryForm.stories.tsx +72 -0
  50. package/src/components/QueryForm/QueryForm.tsx +77 -0
  51. package/src/components/QueryForm/index.ts +5 -0
  52. package/src/components/QueryForm/query.ts +91 -0
  53. package/src/components/index.ts +6 -0
  54. package/src/hooks/index.ts +5 -0
  55. package/src/hooks/useQueryBuilder.ts +25 -0
  56. package/src/index.ts +6 -0
  57. package/src/translations.ts +22 -0
package/package.json ADDED
@@ -0,0 +1,78 @@
1
+ {
2
+ "name": "@dxos/react-ui-query",
3
+ "version": "0.12.0",
4
+ "description": "Editors for ECHO queries: a query-language input and a type/tag form.",
5
+ "homepage": "https://dxos.org",
6
+ "bugs": "https://github.com/dxos/dxos/issues",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/dxos/dxos"
10
+ },
11
+ "license": "FSL-1.1-Apache-2.0",
12
+ "author": "DXOS.org",
13
+ "sideEffects": false,
14
+ "type": "module",
15
+ "imports": {
16
+ "#translations": {
17
+ "source": "./src/translations.ts",
18
+ "types": "./dist/types/src/translations.d.ts",
19
+ "default": "./dist/lib/translations.mjs"
20
+ }
21
+ },
22
+ "exports": {
23
+ ".": {
24
+ "source": "./src/index.ts",
25
+ "types": "./dist/types/src/index.d.ts",
26
+ "import": "./dist/lib/index.mjs"
27
+ },
28
+ "./translations": {
29
+ "source": "./src/translations.ts",
30
+ "types": "./dist/types/src/translations.d.ts",
31
+ "import": "./dist/lib/translations.mjs"
32
+ }
33
+ },
34
+ "types": "dist/types/src/index.d.ts",
35
+ "files": [
36
+ "dist",
37
+ "src"
38
+ ],
39
+ "dependencies": {
40
+ "@codemirror/language": "^6.12.4",
41
+ "@codemirror/state": "^6.7.4",
42
+ "@codemirror/view": "^6.43.11",
43
+ "@lezer/common": "^1.5.2",
44
+ "@lezer/highlight": "^1.2.3",
45
+ "json5": "^2.2.3",
46
+ "@dxos/echo": "0.12.0",
47
+ "@dxos/keys": "0.12.0",
48
+ "@dxos/echo-query": "0.12.0",
49
+ "@dxos/ui-editor": "0.12.0",
50
+ "@dxos/ui": "0.12.0",
51
+ "@dxos/react-ui-editor": "0.12.0"
52
+ },
53
+ "devDependencies": {
54
+ "@storybook/react-vite": "^10.6.0",
55
+ "@types/react": "~19.2.17",
56
+ "@types/react-dom": "~19.2.3",
57
+ "effect": "4.0.0",
58
+ "jsdom": "^29.1.1",
59
+ "react": "~19.2.7",
60
+ "react-dom": "~19.2.7",
61
+ "vite": "^8.2.2",
62
+ "@dxos/react-client": "0.12.0",
63
+ "@dxos/react-ui-syntax-highlighter": "0.12.0",
64
+ "@dxos/react-ui": "0.12.0",
65
+ "@dxos/ui-theme": "0.12.0",
66
+ "@dxos/types": "0.12.0"
67
+ },
68
+ "peerDependencies": {
69
+ "effect": "4.0.0",
70
+ "react": "~19.2.7",
71
+ "react-dom": "~19.2.7",
72
+ "@dxos/react-ui": "^0.12.0",
73
+ "@dxos/ui-theme": "^0.12.0"
74
+ },
75
+ "publishConfig": {
76
+ "access": "public"
77
+ }
78
+ }
@@ -0,0 +1,165 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { EditorView } from '@codemirror/view';
6
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
7
+ import React, { useCallback, useMemo, useState } from 'react';
8
+ import { expect, userEvent, waitFor } from 'storybook/test';
9
+
10
+ import { type Filter, Tag } from '@dxos/echo';
11
+ import { useClientStory, withClientProvider } from '@dxos/react-client/testing';
12
+ import { Toolbar } from '@dxos/react-ui';
13
+ import { JsonHighlighter } from '@dxos/react-ui-syntax-highlighter';
14
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
15
+ import { Employer, Organization, Person, Pipeline } from '@dxos/types';
16
+
17
+ import { translations } from '#translations';
18
+
19
+ import { QueryEditor, type QueryEditorProps } from './QueryEditor.tsx';
20
+
21
+ // Create tags at render time to avoid Storybook serialization issues with ECHO objects.
22
+ const createTags = (): Tag.Map => ({
23
+ tag_1: Tag.make({ label: 'Important' }),
24
+ tag_2: Tag.make({ label: 'Investor' }),
25
+ tag_3: Tag.make({ label: 'New' }),
26
+ });
27
+
28
+ const DefaultStory = (args: QueryEditorProps) => {
29
+ const { space } = useClientStory();
30
+ const [filter, setFilter] = useState<Filter.Any>();
31
+ // Create tags and builder at render time to avoid Storybook serialization issues.
32
+ const tags = useMemo(() => args.tags ?? createTags(), [args.tags]);
33
+
34
+ // The editor parses the DSL itself; a story that rebuilt the filter here would be exercising its
35
+ // own `QueryBuilder` rather than the component's.
36
+ const handleFilterChange = useCallback<NonNullable<QueryEditorProps['onFilterChange']>>(
37
+ ({ filter }) => setFilter(filter),
38
+ [],
39
+ );
40
+
41
+ return (
42
+ <div className='flex flex-col gap-2'>
43
+ <Toolbar.Root>
44
+ <QueryEditor {...args} db={space?.db} tags={tags} onFilterChange={handleFilterChange} />
45
+ </Toolbar.Root>
46
+
47
+ <JsonHighlighter data={filter} classNames='text-xs' />
48
+ </div>
49
+ );
50
+ };
51
+
52
+ const meta = {
53
+ title: 'ui/react-ui-query/QueryEditor',
54
+ component: QueryEditor,
55
+ render: (args: QueryEditorProps) => <DefaultStory {...args} />,
56
+ decorators: [
57
+ withTheme(),
58
+ withLayout({ layout: 'column' }),
59
+ withClientProvider({
60
+ types: [Organization.Organization, Person.Person, Pipeline.Pipeline, Employer.Employer],
61
+ createIdentity: true,
62
+ }),
63
+ ],
64
+ parameters: {
65
+ translations,
66
+ },
67
+ } satisfies Meta<typeof QueryEditor>;
68
+
69
+ export default meta;
70
+
71
+ type Story = StoryObj<typeof meta>;
72
+
73
+ export const Default: Story = {};
74
+
75
+ export const Complex: Story = {
76
+ args: {
77
+ autoFocus: true,
78
+ value: '#important OR type:org.dxos.type.person AND { title: "DXOS", value: true }',
79
+ },
80
+ };
81
+
82
+ export const Relation: Story = {
83
+ args: {
84
+ autoFocus: true,
85
+ value: '(type:org.dxos.type.person -> type:org.dxos.type.organization)',
86
+ },
87
+ };
88
+
89
+ export const Tags: Story = {
90
+ args: {
91
+ autoFocus: true,
92
+ value: 'type:org.dxos.type.person #investor #new',
93
+ },
94
+ play: async ({ canvasElement }) => {
95
+ const content = await waitFor(() => {
96
+ const element = canvasElement.querySelector<HTMLElement>('.cm-content');
97
+ if (!element) {
98
+ throw new Error('Query editor content not found.');
99
+ }
100
+ return element;
101
+ });
102
+
103
+ // The document, not `textContent`: a type filter is drawn as a widget, so the rendered text is
104
+ // the chip's label rather than what the document holds.
105
+ const view = EditorView.findFromDOM(content)!;
106
+
107
+ // `selectionEnd` puts the caret at the end of the document on the first render.
108
+ await waitFor(() => expect(view.state.selection.main.head).toEqual(view.state.doc.length));
109
+
110
+ // Which is what makes the first keystroke append rather than land in front of the query.
111
+ await userEvent.keyboard('X');
112
+ await waitFor(() => expect(view.state.doc.toString()).toEqual('type:org.dxos.type.person #investor #newX '));
113
+ },
114
+ };
115
+
116
+ /**
117
+ * A tag behaves as a single object: one Backspace or Delete removes the whole chip, and text typed
118
+ * against either edge is separated from it by a space, with a trailing space always left to type
119
+ * into. Driven through real keystrokes, since atomicity is enforced by CodeMirror's own cursor and
120
+ * delete commands rather than by the decorations the unit tests inspect.
121
+ */
122
+ export const Atomic: Story = {
123
+ args: {
124
+ autoFocus: true,
125
+ value: '#test',
126
+ },
127
+ play: async ({ canvasElement }) => {
128
+ const content = await waitFor(() => {
129
+ const element = canvasElement.querySelector<HTMLElement>('.cm-content');
130
+ if (!element) {
131
+ throw new Error('Query editor content not found.');
132
+ }
133
+ return element;
134
+ });
135
+
136
+ // The placeholder renders inside the content element, so an empty document is not empty text.
137
+ const doc = () => (content.querySelector('.cm-placeholder') ? '' : content.textContent);
138
+
139
+ await waitFor(() => expect(doc()).toEqual('#test'));
140
+
141
+ // No click and no `{End}`: `selectionEnd` is what puts the caret at the end of the document on
142
+ // the first render, and the caret at the tag's own edge is still in the tag — so this single
143
+ // Backspace both proves that and takes the whole chip.
144
+ // `keyboard`, not `type`: the latter clicks the element first, which would move the caret.
145
+ await userEvent.keyboard('{Backspace}');
146
+ await waitFor(() => expect(doc()).toEqual(''));
147
+
148
+ // Atomic does not mean uneditable: each character lands outside the replaced range and grows it,
149
+ // so the tag is still typed a character at a time. The chip renders the label the whole way.
150
+ await userEvent.keyboard('#te');
151
+ await waitFor(() => expect(doc()).toEqual('#te '));
152
+ await userEvent.keyboard('st');
153
+ await waitFor(() => expect(doc()).toEqual('#test '));
154
+
155
+ // Typed text is never glued to a chip, in either direction.
156
+ await userEvent.keyboard('{Home}X');
157
+ await waitFor(() => expect(doc()).toEqual('X #test '));
158
+ await userEvent.keyboard('{End}Y');
159
+ await waitFor(() => expect(doc()).toEqual('X #test Y '));
160
+
161
+ // Two deletes for `X `, then a single one for the whole five-character tag.
162
+ await userEvent.keyboard('{Home}{Delete}{Delete}{Delete}');
163
+ await waitFor(() => expect(doc()).toEqual(' Y '));
164
+ },
165
+ };
@@ -0,0 +1,136 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { type Extension, Prec } from '@codemirror/state';
6
+ import React, { forwardRef, useCallback, useMemo, useRef } from 'react';
7
+
8
+ import { type BuildResult, QueryBuilder } from '@dxos/echo-query';
9
+ import { type ThemedClassName, useThemeMode, useTranslation } from '@dxos/react-ui';
10
+ import {
11
+ Editor,
12
+ type EditorController,
13
+ type EditorMenuProviderProps,
14
+ type EditorViewProps,
15
+ type UseEditorMenuProps,
16
+ createMenuGroup,
17
+ } from '@dxos/react-ui-editor';
18
+ import { createBasicExtensions, createThemeExtensions, keymap } from '@dxos/ui-editor';
19
+
20
+ import { translationKey } from '#translations';
21
+
22
+ import { type CompletionOptions, completions } from './autocomplete.ts';
23
+ import { query } from './query-extension.ts';
24
+
25
+ export type QueryEditorProps = ThemedClassName<
26
+ {
27
+ value?: string;
28
+ readonly?: boolean;
29
+ /**
30
+ * The parsed query, rebuilt on every edit. `filter` is undefined while the text does not parse,
31
+ * which is what a caller gates a "save this view" affordance on.
32
+ *
33
+ * Supplying this is what opts the editor into building a filter at all — the DSL is otherwise
34
+ * parsed only for decoration, and a caller that wants the raw text pays nothing for a parse it
35
+ * will not read.
36
+ */
37
+ onFilterChange?: (result: BuildResult) => void;
38
+ } & (CompletionOptions & Omit<EditorViewProps, 'initialValue'> & Pick<EditorMenuProviderProps, 'numItems'>)
39
+ >;
40
+
41
+ /**
42
+ * Query editor with decorations and autocomplete.
43
+ */
44
+ export const QueryEditor = forwardRef<EditorController, QueryEditorProps>(
45
+ ({ db, tags, value, readonly, numItems = 8, onChange, onFilterChange, ...props }, forwardedRef) => {
46
+ const { t } = useTranslation(translationKey);
47
+
48
+ const getOptions = useMemo(() => completions({ db, tags }), [db, tags]);
49
+ const getMenu = useCallback<NonNullable<UseEditorMenuProps['getMenu']>>(
50
+ async (context) => [createMenuGroup({ items: getOptions(context) })],
51
+ [getOptions],
52
+ );
53
+
54
+ // The editor owns the parse that used to be repeated by every consumer: the DSL was already being
55
+ // parsed here for decorations, so a caller building its own `QueryBuilder` parsed each keystroke
56
+ // twice.
57
+ const builder = useMemo(() => (onFilterChange ? new QueryBuilder(tags) : undefined), [onFilterChange, tags]);
58
+ const handleChange = useCallback(
59
+ (doc: string) => {
60
+ onChange?.(doc);
61
+ if (builder) {
62
+ onFilterChange?.(builder.build(doc));
63
+ }
64
+ },
65
+ [onChange, onFilterChange, builder],
66
+ );
67
+
68
+ // Keyed on the tag map's CONTENTS, not its identity: callers rebuild the map each render, and
69
+ // `useTextEditor` destroys and recreates the view whenever `extensions` changes — so depending on
70
+ // the object blurred the editor on every keystroke.
71
+ const tagsKey = useMemo(
72
+ () =>
73
+ Object.values(tags ?? {})
74
+ .map((tag) => `${tag.id}:${tag.label}:${tag.hue ?? ''}`)
75
+ .join(),
76
+ [tags],
77
+ );
78
+ const tagsRef = useRef(tags);
79
+ tagsRef.current = tags;
80
+
81
+ const themeMode = useThemeMode();
82
+ const extensions = useMemo<Extension[]>(
83
+ () => [
84
+ createBasicExtensions({
85
+ readOnly: readonly,
86
+ lineWrapping: false,
87
+ placeholder: t('query-editor.placeholder'),
88
+ // A single-line query gains nothing from bracket matching, and CodeMirror's default paints
89
+ // `.cm-matchingBracket` with a background while focused — a coloured wash over the braces of
90
+ // an object literal the moment the caret reaches it.
91
+ bracketMatching: false,
92
+ }),
93
+ createThemeExtensions({ themeMode, slots: { scroller: { className: 'scrollbar-none' } } }),
94
+ query({ tags: tagsRef.current }),
95
+ Prec.highest(
96
+ keymap.of([
97
+ {
98
+ key: 'Enter',
99
+ run: () => {
100
+ // Prevent newline.
101
+ return true;
102
+ },
103
+ },
104
+ ]),
105
+ ),
106
+ ],
107
+ // eslint-disable-next-line react-hooks/exhaustive-deps
108
+ [readonly, themeMode, tagsKey],
109
+ );
110
+
111
+ return (
112
+ <Editor.Root
113
+ ref={forwardedRef}
114
+ extensions={extensions}
115
+ numItems={numItems}
116
+ // `#` opens the tag list. `:` cannot be a trigger — `popoverKeymap` only fires one at a line
117
+ // start or after a space, and a typename's colon follows `type`; `activateOnTyping` covers it
118
+ // instead, since `:` is a completion delimiter so the word under the caret is the typename.
119
+ // The explicit key stays, for re-opening a dismissed menu.
120
+ trigger={['#']}
121
+ // NOT `activateOnTyping`: that opens the menu on every word character, and the popover takes
122
+ // focus each keystroke while returning nothing for most positions. `:` is the only delimiter
123
+ // with a list behind it, so the typename menu opens there and nowhere else.
124
+ activateOnDelimiters={[':']}
125
+ triggerKey='Ctrl-Space'
126
+ getMenu={getMenu}
127
+ >
128
+ {/* `initialValue`, never the controlled `value`: the sync effect behind `value` compares the
129
+ prop against the document a frame later, and a caller whose state trails fast typing then
130
+ rewrites the document back to the older text, dropping characters. A caller rewriting the
131
+ query itself does it through `EditorController.setText`. */}
132
+ <Editor.View {...props} initialValue={value} onChange={handleChange} selectionEnd />
133
+ </Editor.Root>
134
+ );
135
+ },
136
+ );
@@ -0,0 +1,46 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { EditorState } from '@codemirror/state';
6
+ import { describe, expect, test } from 'vitest';
7
+
8
+ import { Tag } from '@dxos/echo';
9
+
10
+ import { completions } from './autocomplete.ts';
11
+
12
+ const tags: Tag.Map = {
13
+ tag_1: Tag.make({ label: 'important' }),
14
+ tag_2: Tag.make({ label: 'investor' }),
15
+ };
16
+
17
+ /**
18
+ * Selecting a completion replaces the WHOLE trigger range — the `#` included — and inserts the item
19
+ * verbatim, so an unprefixed label would drop the `#` and leave text that no longer parses as a tag.
20
+ */
21
+ describe('tag completions', () => {
22
+ test('carry the `#`, so the inserted text is still a tag', () => {
23
+ expect(optionsAt('#')).toEqual(['#important', '#investor']);
24
+ });
25
+
26
+ test('are offered while the label is being typed', () => {
27
+ expect(optionsAt('#imp')).toEqual(['#important', '#investor']);
28
+ });
29
+
30
+ test('are offered for a tag later in the query', () => {
31
+ expect(optionsAt('type:org.dxos.Person AND #inv')).toEqual(['#important', '#investor']);
32
+ });
33
+
34
+ test('are not offered away from a tag', () => {
35
+ expect(optionsAt('type:')).not.toContain('#important');
36
+ });
37
+
38
+ // Only once the string is closed: until then the parser recovers by reading the `"` as an error and
39
+ // `#imp` as a real tag, so the tree has no string to resolve into.
40
+ test('are not offered inside a closed string, where `#` is content', () => {
41
+ expect(optionsAt('{ title: "#imp" }', 14)).toEqual([]);
42
+ });
43
+ });
44
+
45
+ const optionsAt = (doc: string, pos = doc.length) =>
46
+ completions({ tags })({ state: EditorState.create({ doc }), pos, text: doc });
@@ -0,0 +1,76 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { type Database, type Tag, Type } from '@dxos/echo';
6
+ import { QueryDSL, formatTag } from '@dxos/echo-query';
7
+ import { type GetMenuContext } from '@dxos/react-ui-editor';
8
+
9
+ export type CompletionOptions = {
10
+ db?: Database.Database;
11
+ tags?: Tag.Map;
12
+ };
13
+
14
+ /**
15
+ * Tag labels as they must appear in the document. Selecting a completion REPLACES the whole trigger
16
+ * range, `#` included, and inserts the item verbatim — so an unprefixed label would silently drop the
17
+ * `#` and leave text that no longer parses as a tag.
18
+ */
19
+ const tagCompletions = (tags: Tag.Map): string[] => Object.values(tags).map((tag) => formatTag(tag.label));
20
+
21
+ /**
22
+ * Whether the caret sits in a tag the user is still typing: a `#` at the start of the current word,
23
+ * with only label characters between it and `pos`.
24
+ */
25
+ const isTypingTag = (text: string, pos: number): boolean => {
26
+ let index = pos;
27
+ while (index > 0 && /[a-zA-Z0-9_-]/.test(text[index - 1])) {
28
+ index--;
29
+ }
30
+
31
+ return index > 0 && text[index - 1] === '#';
32
+ };
33
+
34
+ export const completions = ({ db, tags }: CompletionOptions) => {
35
+ const parser = QueryDSL.Parser.configure({ strict: false });
36
+ return ({ state, pos }: GetMenuContext): string[] => {
37
+ const text = state.sliceDoc();
38
+ const tree = parser.parse(text);
39
+ const { node } = tree.cursorAt(pos, -1);
40
+
41
+ // A `#` with no label yet is an error node, not a `TagFilter` — the grammar needs a label
42
+ // character before it accepts one — so the tree cannot answer for the keystroke that opens a tag.
43
+ // Inside a string it is content, so the tree is still what settles that.
44
+ if (tags && isTypingTag(text, pos) && node.type.id !== QueryDSL.Node.String) {
45
+ return tagCompletions(tags);
46
+ }
47
+
48
+ switch (node.parent?.type.id) {
49
+ case QueryDSL.Node.TypeFilter: {
50
+ let range: { from: number; to: number } | undefined;
51
+ if (node?.type.id === QueryDSL.Node.Identifier) {
52
+ range = { from: node.from, to: node.to };
53
+ } else if (node?.type.name === ':') {
54
+ range = { from: node.from + 1, to: node.to };
55
+ }
56
+
57
+ if (range) {
58
+ const schema = db ? [...db.graph.registry.list().filter(Type.isType)] : [];
59
+ return schema.map((schema) => Type.getTypename(schema));
60
+ }
61
+
62
+ break;
63
+ }
64
+
65
+ case QueryDSL.Node.TagFilter: {
66
+ if (tags) {
67
+ return tagCompletions(tags);
68
+ }
69
+
70
+ break;
71
+ }
72
+ }
73
+
74
+ return [];
75
+ };
76
+ };
@@ -0,0 +1,7 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export type { EditorController } from '@dxos/react-ui-editor';
6
+
7
+ export * from './QueryEditor.tsx';
@@ -0,0 +1,176 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { EditorSelection, EditorState } from '@codemirror/state';
6
+ import { EditorView } from '@codemirror/view';
7
+ import { describe, expect, test } from 'vitest';
8
+
9
+ import { Tag } from '@dxos/echo';
10
+ import { getHashHue } from '@dxos/ui-theme';
11
+
12
+ import { buildQueryDecorations, query } from './query-extension.ts';
13
+
14
+ const tag = Tag.make({ label: 'important' });
15
+ const tags: Tag.Map = { [tag.id]: tag };
16
+ const hue = tag.hue ?? getHashHue(tag.id);
17
+
18
+ /**
19
+ * A tag is one object: a widget REPLACES its text, atomically, wherever the caret is — including at
20
+ * the tag's own edges, so Backspace against it removes the whole chip rather than a character of the
21
+ * label. It is still typed a character at a time, since an insertion at the range's boundary lands
22
+ * outside it and grows the node.
23
+ */
24
+ describe('tag rendering', () => {
25
+ test('is an atomic chip when the caret is elsewhere', async () => {
26
+ const view = await viewOf('#important AND #other', 0);
27
+ expect(decorationsOf(view)).toMatchObject([
28
+ { from: 0, to: 10, atomic: true },
29
+ { from: 11, to: 14, atomic: true },
30
+ { from: 15, to: 21, atomic: true },
31
+ ]);
32
+ view.destroy();
33
+ });
34
+
35
+ test('stays an atomic chip with the caret inside it', async () => {
36
+ const view = await viewOf('#important', 5);
37
+ expect(decorationsOf(view)).toMatchObject([{ from: 0, to: 10, atomic: true }]);
38
+ view.destroy();
39
+ });
40
+
41
+ test('stays an atomic chip with the caret at its trailing edge', async () => {
42
+ // Regression: the caret lands here both while the tag is being typed and after `End`, and an
43
+ // exception for it left Backspace deleting a character of the label instead of the whole tag.
44
+ const view = await viewOf('#important', 10);
45
+ expect(decorationsOf(view)).toMatchObject([{ from: 0, to: 10, atomic: true }]);
46
+ view.destroy();
47
+ });
48
+
49
+ test('a bare `#` is marked from the keystroke that opens it', async () => {
50
+ // The grammar needs a label character before it accepts a `Tag`, so this comes from a document
51
+ // scan rather than the tree — the affordance cannot wait for the parse to succeed.
52
+ const view = await viewOf('#', 1);
53
+ expect(decorationsOf(view)).toMatchObject([{ from: 0, to: 1, atomic: false }]);
54
+ view.destroy();
55
+ });
56
+
57
+ test('every tag is a chip, wherever the caret is', async () => {
58
+ const view = await viewOf('#alpha #beta', 12);
59
+ const ranges = decorationsOf(view);
60
+ expect(ranges.filter((range) => range.atomic).map(({ from, to }) => [from, to])).toEqual([
61
+ [0, 6],
62
+ [7, 12],
63
+ ]);
64
+ view.destroy();
65
+ });
66
+
67
+ test('the label is rendered once', async () => {
68
+ const view = await viewOf('#important', 10);
69
+ expect(view.contentDOM.textContent).toBe('#important');
70
+ view.destroy();
71
+ });
72
+ });
73
+
74
+ /**
75
+ * Decorations are fed to a `RangeSetBuilder`, which throws on an out-of-order range. The bare-`#`
76
+ * ranges come from a document scan rather than the syntax tree, so they interleave with the tree's
77
+ * own and have to be sorted before being added.
78
+ */
79
+ describe('buildQueryDecorations', () => {
80
+ test.each([
81
+ ['', 'empty'],
82
+ ['#', 'a bare hash'],
83
+ ['#important', 'a tag'],
84
+ ['#a #b #c', 'several tags'],
85
+ ['type:org.dxos.Person AND #important', 'a tag following another filter'],
86
+ ['{ name: "x" } #tag', 'an object literal before a tag'],
87
+ ['"# not a tag" #real', 'a hash inside a string'],
88
+ ])('builds over %j (%s)', async (doc) => {
89
+ const view = await viewOf(doc);
90
+ expect(() => decorationsOf(view)).not.toThrow();
91
+ view.destroy();
92
+ });
93
+
94
+ test('a hash inside a string is not treated as a tag', async () => {
95
+ const view = await viewOf('"# not a tag"', 0);
96
+ expect(decorationsOf(view).filter((range) => range.from === 1)).toHaveLength(0);
97
+ view.destroy();
98
+ });
99
+
100
+ test('the chip is drawn in the tag hue', async () => {
101
+ const view = await viewOf('#important', 10);
102
+ // The hue lives in the widget's own DOM, so it is read off the rendered chip rather than a spec.
103
+ const classes = [...view.contentDOM.querySelectorAll('span')].map((el) => el.className);
104
+ expect(classes.some((className) => className.includes(hue))).toBe(true);
105
+ view.destroy();
106
+ });
107
+ });
108
+
109
+ /**
110
+ * Tags are atomic, so text must never end up glued to one — and there has to be a position at the end
111
+ * of the document that is not adjacent to a chip.
112
+ */
113
+ describe('spacing', () => {
114
+ test('typing immediately before a tag is separated from it', async () => {
115
+ const view = await viewOf('#important', 0);
116
+ view.dispatch({ changes: { from: 0, insert: 'a' }, selection: EditorSelection.cursor(1) });
117
+ expect(view.state.doc.toString()).toBe('a #important ');
118
+ expect(view.state.selection.main.head).toBe(1);
119
+ view.destroy();
120
+ });
121
+
122
+ test('an insertion leaves a trailing space', async () => {
123
+ const view = await viewOf('#a', 2);
124
+ view.dispatch({ changes: { from: 2, insert: 'b' }, selection: EditorSelection.cursor(3) });
125
+ expect(view.state.doc.toString()).toBe('#ab ');
126
+ // The caret stays before the trailing space, so typing continues to extend the tag.
127
+ expect(view.state.selection.main.head).toBe(3);
128
+ view.destroy();
129
+ });
130
+
131
+ test('the trailing space is not re-added after a deletion', async () => {
132
+ const view = await viewOf('#a ', 3);
133
+ view.dispatch({ changes: { from: 2, to: 3 } });
134
+ expect(view.state.doc.toString()).toBe('#a');
135
+ view.destroy();
136
+ });
137
+
138
+ test('an empty document is left empty, so the placeholder shows', async () => {
139
+ const view = await viewOf('x', 1);
140
+ view.dispatch({ changes: { from: 0, to: 1 } });
141
+ expect(view.state.doc.toString()).toBe('');
142
+ view.destroy();
143
+ });
144
+ });
145
+
146
+ /**
147
+ * A mounted, focused view. Focus matters: the tag under the caret is only drawn as live text while the
148
+ * editor has it, so a blurred editor shows chips throughout.
149
+ */
150
+ const viewOf = async (doc: string, head = doc.length) => {
151
+ const parent = document.createElement('div');
152
+ document.body.appendChild(parent);
153
+ const view = new EditorView({
154
+ state: EditorState.create({ doc, selection: EditorSelection.cursor(head), extensions: [query({ tags })] }),
155
+ parent,
156
+ });
157
+ view.contentDOM.dispatchEvent(new FocusEvent('focus'));
158
+ await new Promise((resolve) => requestAnimationFrame(resolve));
159
+ return view;
160
+ };
161
+
162
+ const decorationsOf = (view: EditorView) => {
163
+ const ranges: { from: number; to: number; atomic: boolean; class?: string }[] = [];
164
+ const cursor = buildQueryDecorations(view.state, { tags }).iter();
165
+ while (cursor.value) {
166
+ ranges.push({
167
+ from: cursor.from,
168
+ to: cursor.to,
169
+ atomic: !!cursor.value.spec.atomic,
170
+ class: cursor.value.spec.class,
171
+ });
172
+ cursor.next();
173
+ }
174
+
175
+ return ranges;
176
+ };