@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
@@ -0,0 +1,532 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { HighlightStyle, LanguageSupport, LRLanguage, syntaxHighlighting, syntaxTree } from '@codemirror/language';
6
+ import { EditorState, type Extension, RangeSetBuilder, StateField, type TransactionSpec } from '@codemirror/state';
7
+ import { Decoration, type DecorationSet, EditorView, WidgetType } from '@codemirror/view';
8
+ import { type SyntaxNodeRef } from '@lezer/common';
9
+ import { styleTags, tags as t } from '@lezer/highlight';
10
+ import JSON5 from 'json5';
11
+
12
+ import { type Tag } from '@dxos/echo';
13
+ import { QueryDSL, findTagByToken } from '@dxos/echo-query';
14
+ import { Domino } from '@dxos/ui';
15
+ import { type CompletionContext, focus, focusField, staticCompletion, typeahead } from '@dxos/ui-editor';
16
+ import { getHashHue, getStyles, mx } from '@dxos/ui-theme';
17
+
18
+ export type QueryOptions = {
19
+ tags?: Tag.Map;
20
+ };
21
+
22
+ /**
23
+ * Create a CodeMirror extension for the query language with syntax highlighting.
24
+ */
25
+ export const query = ({ tags }: QueryOptions = {}): Extension => {
26
+ return [
27
+ new LanguageSupport(queryLanguage),
28
+ syntaxHighlighting(queryHighlightStyle),
29
+ decorations({ tags }),
30
+ spacing,
31
+ typeahead({
32
+ onComplete: ({ line }: CompletionContext) => {
33
+ const words = line.split(/\s+/).filter(Boolean);
34
+ if (words.length > 0) {
35
+ // TODO(burdon): Get suggestion from parser.
36
+ return staticCompletion(['type:', 'AND', 'OR', 'NOT'])({ line });
37
+ }
38
+ },
39
+ }),
40
+ focus,
41
+ styles,
42
+ ];
43
+ };
44
+
45
+ /**
46
+ * The extension's decorations for a given state.
47
+ *
48
+ * Exported as a pure function of `(state, options)` so the atomicity rules — which govern whether a
49
+ * tag can still be edited — are testable without mounting an `EditorView`.
50
+ */
51
+ export const buildQueryDecorations = (state: EditorState, { tags }: QueryOptions = {}): DecorationSet => {
52
+ {
53
+ const hasFocus = state.field(focusField);
54
+ const isInside = (node: SyntaxNodeRef) => {
55
+ const range = intersectRanges(state.selection.main, node);
56
+ return hasFocus && range && (state.selection.main.from > 0 || range.to - range.from > 0);
57
+ };
58
+
59
+ // Collected rather than fed straight to a `RangeSetBuilder`, which demands ascending `from`:
60
+ // the bare-`#` ranges below come from a document scan and interleave with the tree's own.
61
+ const collected: { from: number; to: number; deco: Decoration }[] = [];
62
+ const deco = {
63
+ add: (from: number, to: number, value: Decoration) => collected.push({ from, to, deco: value }),
64
+ };
65
+ const tagged: Range[] = [];
66
+ syntaxTree(state).iterate({
67
+ enter: (node) => {
68
+ if (node.type.id === QueryDSL.Node.Tag) {
69
+ tagged.push({ from: node.from, to: node.to });
70
+ }
71
+ switch (node.type.name) {
72
+ case '(':
73
+ case ')': {
74
+ deco.add(
75
+ node.from,
76
+ node.to,
77
+ Decoration.mark({
78
+ class: 'px-1',
79
+ }),
80
+ );
81
+ break;
82
+ }
83
+
84
+ case '=': {
85
+ deco.add(
86
+ node.from,
87
+ node.to,
88
+ Decoration.mark({
89
+ class: 'text-fg-subtle',
90
+ }),
91
+ );
92
+ break;
93
+ }
94
+ }
95
+
96
+ switch (node.type.id) {
97
+ case QueryDSL.Node.TypeFilter: {
98
+ if (isInside(node)) {
99
+ break;
100
+ }
101
+
102
+ const identifier = node.node.getChild(QueryDSL.Node.Identifier);
103
+ if (identifier) {
104
+ deco.add(
105
+ node.from,
106
+ node.to,
107
+ Decoration.widget({
108
+ widget: new TypeWidget(state.sliceDoc(identifier.from, identifier.to)),
109
+ }),
110
+ );
111
+ }
112
+ break;
113
+ }
114
+
115
+ case QueryDSL.Node.TagFilter: {
116
+ const tagNode = node.node.getChild(QueryDSL.Node.Tag);
117
+ if (tagNode) {
118
+ const token = state.sliceDoc(tagNode.from, tagNode.to);
119
+ const match = findTagByToken(tags, token);
120
+ // The chip names the tag as the user wrote it, not the token form it had to be typed in.
121
+ const label = match?.tag.label ?? token.slice(1);
122
+ const hue = match?.tag.hue ?? getHashHue(match?.key ?? label);
123
+ // Atomic at its own edges too, so Backspace against a tag takes the chip rather than a
124
+ // character of the label; typing still grows it, since an insertion at the range's
125
+ // boundary lands outside it.
126
+ //
127
+ // `replace`, not `widget`: a widget is a POINT decoration, so one covering a range that
128
+ // starts at offset 0 paints before that offset's coordinate and the caret draws to its
129
+ // right — pressing Home appeared to leave the caret after the tag.
130
+ deco.add(node.from, node.to, Decoration.replace({ widget: new TagWidget(label, hue), atomic: true }));
131
+ }
132
+ break;
133
+ }
134
+
135
+ case QueryDSL.Node.ObjectLiteral: {
136
+ if (isInside(node)) {
137
+ break;
138
+ }
139
+
140
+ try {
141
+ const props = JSON5.parse(state.sliceDoc(node.from, node.to));
142
+ if (props) {
143
+ deco.add(
144
+ node.from,
145
+ node.to,
146
+ Decoration.widget({
147
+ widget: new ObjectWidget(props),
148
+ }),
149
+ );
150
+ }
151
+ } catch {
152
+ // Ignore malformed JSON.
153
+ }
154
+ break;
155
+ }
156
+
157
+ case QueryDSL.Node.Not:
158
+ case QueryDSL.Node.And:
159
+ case QueryDSL.Node.Or: {
160
+ deco.add(
161
+ node.from,
162
+ node.to,
163
+ Decoration.mark({
164
+ class: 'pe-1 uppercase',
165
+ atomic: true,
166
+ }),
167
+ );
168
+ break;
169
+ }
170
+
171
+ case QueryDSL.Node.ArrowRight:
172
+ case QueryDSL.Node.ArrowLeft: {
173
+ deco.add(
174
+ node.from,
175
+ node.to,
176
+ Decoration.widget({
177
+ widget: new SymbolWidget(node.type.id === QueryDSL.Node.ArrowRight ? '\u2192' : '\u2190'),
178
+ atomic: true,
179
+ }),
180
+ );
181
+ break;
182
+ }
183
+
184
+ // default: {
185
+ // console.log(node.type.name);
186
+ // }
187
+ }
188
+ },
189
+ });
190
+
191
+ // A `#` with no label yet parses as an error, not a `Tag`, so the tree cannot decorate it — but
192
+ // the affordance has to appear on the keystroke that opens the tag, not once it is valid.
193
+ // A lone `#` is an error node, not a `Tag` — the grammar needs a label character — so it gets a
194
+ // plain highlight until the first one arrives and the chip takes over.
195
+ for (const range of bareTagRanges(state, tagged)) {
196
+ collected.push({
197
+ from: range.from,
198
+ to: range.to,
199
+ deco: Decoration.mark({ class: mx('rounded-xs px-0.5', getStyles(getHashHue('')).surface) }),
200
+ });
201
+ }
202
+
203
+ const builder = new RangeSetBuilder<Decoration>();
204
+ // Equal starts sort WIDEST first so an enclosing mark (the chip's border) nests the inner ones.
205
+ collected.sort((a, b) => a.from - b.from || b.to - a.to);
206
+ for (const { from, to, deco } of collected) {
207
+ builder.add(from, to, deco);
208
+ }
209
+
210
+ return builder.finish();
211
+ }
212
+ };
213
+
214
+ /**
215
+ * Decorations
216
+ */
217
+ const decorations = ({ tags }: QueryOptions): Extension => {
218
+ return [
219
+ StateField.define<DecorationSet>({
220
+ create: (state) => buildQueryDecorations(state, { tags }),
221
+ update: (deco, tr) => {
222
+ if (tr.docChanged || tr.newSelection) {
223
+ return buildQueryDecorations(tr.state, { tags });
224
+ }
225
+
226
+ return deco;
227
+ },
228
+ provide: (field) => [
229
+ EditorView.decorations.from(field),
230
+ EditorView.atomicRanges.of((view) => {
231
+ const builder = new RangeSetBuilder<Decoration>();
232
+ const cursor = view.state.field(field).iter();
233
+ while (cursor.value) {
234
+ if (cursor.value.spec.atomic) {
235
+ builder.add(cursor.from, cursor.to, cursor.value);
236
+ }
237
+ cursor.next();
238
+ }
239
+
240
+ return builder.finish();
241
+ }),
242
+ ],
243
+ }),
244
+ ];
245
+ };
246
+
247
+ /**
248
+ * Keeps typed text from being glued onto a tag chip.
249
+ *
250
+ * Two rules, both applied as follow-up changes so the user's own transaction is left intact:
251
+ * 1. Text typed immediately before a tag is separated from it by a space.
252
+ * 2. An insertion leaves a trailing space at the end of the document, so there is always somewhere to
253
+ * type that is not adjacent to a chip. Deletions are exempt, or backspace at the end of the
254
+ * document would only ever delete a space this rule puts straight back.
255
+ */
256
+ export const spacing: Extension = EditorState.transactionFilter.of((tr) => {
257
+ if (!tr.docChanged) {
258
+ return tr;
259
+ }
260
+
261
+ const tagStarts = new Set<number>();
262
+ syntaxTree(tr.startState).iterate({
263
+ enter: (node) => {
264
+ if (node.type.id === QueryDSL.Node.Tag) {
265
+ tagStarts.add(node.from);
266
+ }
267
+ },
268
+ });
269
+
270
+ let inserting = false;
271
+ let separator: number | undefined;
272
+ tr.changes.iterChanges((fromA, toA, _fromB, toB, inserted) => {
273
+ if (!inserted.length) {
274
+ return;
275
+ }
276
+
277
+ inserting = true;
278
+ // Keyed on `toA`, the offset the inserted text ends against: that covers a replacement whose
279
+ // range ends at the tag as well as a plain insertion, where `fromA` and `toA` are the same.
280
+ if (tagStarts.has(toA) && !/\s$/.test(inserted.toString())) {
281
+ separator = toB;
282
+ }
283
+ });
284
+
285
+ const specs: TransactionSpec[] = [tr];
286
+ let text = tr.newDoc.toString();
287
+ if (separator !== undefined) {
288
+ specs.push({ changes: { from: separator, insert: ' ' }, sequential: true });
289
+ text = text.slice(0, separator) + ' ' + text.slice(separator);
290
+ }
291
+ if (inserting && text.length > 0 && !/\s$/.test(text)) {
292
+ specs.push({ changes: { from: text.length, insert: ' ' }, sequential: true });
293
+ }
294
+
295
+ return specs;
296
+ });
297
+
298
+ const lineHeight = '30px';
299
+
300
+ /** The outer box vertically aligns the inner text with content in the outer div. */
301
+ const CHIP_OUTER = 'inline-flex h-[28px] align-middle';
302
+ const CHIP_INNER = 'inline-flex h-[26px] border rounded-xs';
303
+
304
+ const container = (classNames: string, ...children: Domino<HTMLElement>[]) => {
305
+ const inner = Domino.of('span')
306
+ .classNames(mx(CHIP_INNER, classNames))
307
+ .append(...children);
308
+ return Domino.of('span').classNames(CHIP_OUTER).append(inner).root;
309
+ };
310
+
311
+ /** A tag chip's parts, keyed by the nesting {@link container} produces. */
312
+ const chipClasses = (hue: string) => {
313
+ const { bg, fg, surface } = getStyles(hue);
314
+ return {
315
+ hash: mx('flex items-center px-1 text-black text-xs', bg),
316
+ label: mx('flex items-center px-1 text-sm', surface, fg),
317
+ };
318
+ };
319
+
320
+ /**
321
+ * TypeKeyword:Identifier
322
+ */
323
+ class TypeWidget extends WidgetType {
324
+ constructor(private readonly _identifier: string) {
325
+ super();
326
+ }
327
+
328
+ override ignoreEvent() {
329
+ return false;
330
+ }
331
+
332
+ override eq(other: this) {
333
+ return this._identifier === other._identifier;
334
+ }
335
+
336
+ override toDOM() {
337
+ const label: string = this._identifier.split(/\W/).at(-1)!;
338
+ return container(
339
+ 'border-sky-500',
340
+ Domino.of('span').classNames(mx('flex items-center px-1 text-black text-xs bg-sky-500')).text('type'),
341
+ Domino.of('span').classNames(mx('flex items-center px-1 text-fg-muted')).text(label),
342
+ );
343
+ }
344
+ }
345
+
346
+ /**
347
+ * Tag
348
+ */
349
+ class TagWidget extends WidgetType {
350
+ constructor(
351
+ private readonly _str: string,
352
+ private readonly _hue: string,
353
+ ) {
354
+ super();
355
+ }
356
+
357
+ override eq(other: this) {
358
+ return this._str === other._str;
359
+ }
360
+
361
+ override toDOM() {
362
+ const { hash, label } = chipClasses(this._hue);
363
+ return container(
364
+ getStyles(this._hue).border,
365
+ Domino.of('span').classNames(hash).text('#'),
366
+ Domino.of('span').classNames(label).text(this._str),
367
+ );
368
+ }
369
+ }
370
+
371
+ /**
372
+ * { type: "value" }
373
+ */
374
+ class ObjectWidget extends WidgetType {
375
+ private readonly _entries: [string, any][];
376
+ private readonly _json: string;
377
+
378
+ constructor(private readonly _props: any) {
379
+ super();
380
+ this._entries = Object.entries(this._props);
381
+ this._json = JSON.stringify(this._props);
382
+ }
383
+
384
+ override ignoreEvent() {
385
+ return false;
386
+ }
387
+
388
+ override eq(other: this) {
389
+ return this._json === other._json;
390
+ }
391
+
392
+ override toDOM() {
393
+ return container(
394
+ 'border-separator divide-x divide-separator',
395
+ ...this._entries.map(([key, value]) => {
396
+ const keyEl = Domino.of('span')
397
+ .classNames('flex items-center px-1 text-fg-muted text-xs bg-modal-surface first:rounded-l-xs')
398
+ .text(key);
399
+ const valueEl = Domino.of('span').classNames('flex items-center px-1 text-fg-muted').text(value);
400
+ return Domino.of('span').classNames('inline-flex items-stretch').append(keyEl, valueEl);
401
+ }),
402
+ );
403
+ }
404
+ }
405
+
406
+ /**
407
+ * Symbol
408
+ */
409
+ class SymbolWidget extends WidgetType {
410
+ constructor(private readonly _str: string) {
411
+ super();
412
+ }
413
+
414
+ override eq(other: this) {
415
+ return this._str === other._str;
416
+ }
417
+
418
+ override toDOM() {
419
+ return Domino.of('span').text(this._str).root;
420
+ }
421
+ }
422
+
423
+ const styles = EditorView.theme({
424
+ '.cm-line': {
425
+ lineHeight,
426
+ },
427
+ // Match the standard Input block size (md density): 2.5rem, 2rem on pointer-fine devices.
428
+ '.cm-scroller': {
429
+ alignItems: 'center',
430
+ minHeight: '2.5rem',
431
+ },
432
+ '@media (pointer: fine)': {
433
+ '.cm-scroller': {
434
+ minHeight: '2rem',
435
+ },
436
+ },
437
+ });
438
+
439
+ /**
440
+ * Define syntax highlighting tags for the query language.
441
+ */
442
+ const queryHighlighting = styleTags({
443
+ // Keywords
444
+ 'Not And Or': t.keyword,
445
+ 'TypeKeyword': t.attributeName,
446
+
447
+ // Literals
448
+ 'String': t.string,
449
+ 'Number': t.number,
450
+ 'Boolean': t.bool,
451
+ 'Null': t.null,
452
+
453
+ // Identifiers
454
+ // NOT `Tag`: it is drawn as a chip, whose own foreground would be fighting a highlight class here.
455
+ 'Identifier': t.variableName,
456
+ 'PropertyPath': t.propertyName,
457
+
458
+ // Punctuation
459
+ '{ }': t.brace,
460
+ '[ ]': t.squareBracket,
461
+ '( )': t.paren,
462
+ ':': t.definitionOperator,
463
+ ',': t.separator,
464
+ '.': t.derefOperator,
465
+ });
466
+
467
+ /**
468
+ * Create the query language with the parser and highlighting.
469
+ */
470
+ const queryLanguage = LRLanguage.define({
471
+ parser: QueryDSL.Parser.configure({
472
+ props: [queryHighlighting],
473
+ strict: false,
474
+ }),
475
+ languageData: {
476
+ commentTokens: { line: '//' },
477
+ },
478
+ });
479
+
480
+ /**
481
+ * Define a custom highlight style for the query language.
482
+ */
483
+ const queryHighlightStyle = HighlightStyle.define([
484
+ { tag: t.keyword, class: 'text-blue-text' },
485
+ { tag: t.string, class: 'text-orange-text' },
486
+ { tag: t.number, class: 'text-green-text' },
487
+ { tag: t.bool, class: 'text-green-text' },
488
+ { tag: t.null, class: 'text-neutral-text' },
489
+ { tag: t.attributeName, class: 'text-blue-text' },
490
+ { tag: t.variableName, class: 'text-teal-text' },
491
+ { tag: t.propertyName, class: 'text-teal-text' },
492
+ { tag: t.definitionOperator, class: 'text-fg-subtle' },
493
+ { tag: t.separator, class: 'text-fg-subtle' },
494
+ { tag: t.derefOperator, class: 'text-fg-subtle' },
495
+ { tag: t.brace, class: 'text-fg-subtle' },
496
+ { tag: t.squareBracket, class: 'text-fg-subtle' },
497
+ { tag: t.operator, class: 'text-fg-subtle' },
498
+ { tag: t.paren, class: 'text-amber-text' },
499
+ ]);
500
+
501
+ type Range = { from: number; to: number };
502
+ function intersectRanges(a: Range, b: Range): Range | null {
503
+ const start = Math.max(a.from, b.from);
504
+ const end = Math.min(a.to, b.to);
505
+ return start <= end ? { from: start, to: end } : null;
506
+ }
507
+
508
+ /** Label characters the grammar admits after `#` (`Tag { "#" $[a-zA-Z0-9_\-]+ }`). */
509
+ const TAG_LABEL_CHAR = /[a-zA-Z0-9_-]/;
510
+
511
+ /**
512
+ * Ranges of an in-progress tag — a `#` the parser has not yet accepted as a {@link QueryDSL.Node.Tag},
513
+ * because the grammar requires at least one label character after it. Skips offsets the tree already
514
+ * claimed, and any `#` inside a string, where it is content rather than a tag.
515
+ */
516
+ const bareTagRanges = (state: EditorState, tagged: readonly Range[]): Range[] => {
517
+ const ranges: Range[] = [];
518
+ const text = state.sliceDoc();
519
+ const covered = (index: number) => tagged.some(({ from, to }) => index >= from && index < to);
520
+ for (let index = text.indexOf('#'); index !== -1; index = text.indexOf('#', index + 1)) {
521
+ if (covered(index) || syntaxTree(state).resolveInner(index, 1).type.id === QueryDSL.Node.String) {
522
+ continue;
523
+ }
524
+ let end = index + 1;
525
+ while (end < text.length && TAG_LABEL_CHAR.test(text[end])) {
526
+ end++;
527
+ }
528
+ ranges.push({ from: index, to: end });
529
+ }
530
+
531
+ return ranges;
532
+ };
@@ -0,0 +1,47 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import React, { useMemo } from 'react';
6
+
7
+ import { Select, useTranslation } from '@dxos/react-ui';
8
+
9
+ import { translationKey } from '#translations';
10
+
11
+ const NULL = '__NULL__';
12
+
13
+ export type PickerProps<T extends { value: string; label: string }> = {
14
+ placeholder?: string;
15
+ values?: T[];
16
+ value?: string | null;
17
+ onChange?: (value: string | null) => void;
18
+ };
19
+
20
+ // TODO(wittjosiah): Should use `SelectInput` from `react-ui-form` instead.
21
+ export const Picker = <T extends { value: string; label: string }>({
22
+ placeholder,
23
+ values,
24
+ value,
25
+ onChange,
26
+ }: PickerProps<T>) => {
27
+ const { t } = useTranslation(translationKey);
28
+ const sorted = useMemo(() => values?.sort(({ label: a }, { label: b }) => a.localeCompare(b)) ?? [], [values]);
29
+
30
+ return (
31
+ <Select.Root
32
+ items={[{ value: NULL, label: t('picker-none.label') }, ...sorted]}
33
+ value={[value ?? NULL]}
34
+ onValueChange={({ value: [value] }) => onChange?.(value === NULL ? null : value)}
35
+ >
36
+ <Select.Trigger placeholder={placeholder ?? t('picker-select.label')} />
37
+ <Select.Content>
38
+ <Select.ItemGroup>
39
+ <Select.Item item={{ value: NULL, label: t('picker-none.label') }} />
40
+ {sorted.map(({ value, label }) => (
41
+ <Select.Item key={value} item={{ value: value, label: label }} />
42
+ ))}
43
+ </Select.ItemGroup>
44
+ </Select.Content>
45
+ </Select.Root>
46
+ );
47
+ };
@@ -0,0 +1,72 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
+ import React, { useState } from 'react';
7
+
8
+ import { Filter, Query, Tag, Type } from '@dxos/echo';
9
+ import { EntityId } from '@dxos/keys';
10
+ import { withClientProvider } from '@dxos/react-client/testing';
11
+ import { Toolbar } from '@dxos/react-ui';
12
+ import { JsonHighlighter } from '@dxos/react-ui-syntax-highlighter';
13
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
14
+ import { Employer, Organization, Person, Pipeline } from '@dxos/types';
15
+
16
+ import { translations } from '#translations';
17
+
18
+ import { QueryForm, type QueryFormProps } from './QueryForm.tsx';
19
+
20
+ const types = [
21
+ // TODO(burdon): Get label from annotation.
22
+ { value: Type.getURI(Organization.Organization), label: 'Organization' },
23
+ { value: Type.getURI(Person.Person), label: 'Person' },
24
+ { value: Type.getURI(Pipeline.Pipeline), label: 'Project' },
25
+ { value: Type.getURI(Employer.Employer), label: 'Employer' },
26
+ ];
27
+
28
+ const tags = Tag.createTagList({
29
+ [EntityId.random().toString()]: Tag.make({ label: 'Important' }),
30
+ [EntityId.random().toString()]: Tag.make({ label: 'Investor' }),
31
+ [EntityId.random().toString()]: Tag.make({ label: 'New' }),
32
+ });
33
+
34
+ const meta = {
35
+ title: 'ui/react-ui-query/QueryForm',
36
+ component: QueryForm,
37
+ render: (args: QueryFormProps) => {
38
+ const [query, setQuery] = useState<Query.Any>(Query.select(Filter.nothing()));
39
+
40
+ return (
41
+ <div>
42
+ <Toolbar.Root classNames='border-b border-separator-subtle'>
43
+ <QueryForm {...args} onChange={setQuery} />
44
+ </Toolbar.Root>
45
+
46
+ <JsonHighlighter data={query} classNames='p-2 text-xs' />
47
+ </div>
48
+ );
49
+ },
50
+ decorators: [
51
+ withTheme(),
52
+ withLayout({ layout: 'column' }),
53
+ withClientProvider({
54
+ types: [Organization.Organization, Person.Person, Pipeline.Pipeline, Employer.Employer],
55
+ createIdentity: true,
56
+ }),
57
+ ],
58
+ parameters: {
59
+ translations,
60
+ },
61
+ } satisfies Meta<typeof QueryForm>;
62
+
63
+ export default meta;
64
+
65
+ type Story = StoryObj<typeof meta>;
66
+
67
+ export const Default: Story = {
68
+ args: {
69
+ types,
70
+ tags,
71
+ },
72
+ };