@shipfox/react-ui 0.32.2 → 0.33.1

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 (125) hide show
  1. package/dist/components/alert/index.d.ts +1 -1
  2. package/dist/components/avatar/avatar-group.d.ts +1 -1
  3. package/dist/components/avatar/avatar.d.ts +1 -1
  4. package/dist/components/avatar/index.d.ts +2 -2
  5. package/dist/components/badge/badge.d.ts +1 -1
  6. package/dist/components/badge/icon-badge.d.ts +1 -1
  7. package/dist/components/badge/index.d.ts +4 -4
  8. package/dist/components/button/button-link.d.ts +1 -1
  9. package/dist/components/button/button.d.ts +1 -1
  10. package/dist/components/button/icon-button.d.ts +1 -1
  11. package/dist/components/button/index.d.ts +3 -3
  12. package/dist/components/button-group/index.d.ts +1 -1
  13. package/dist/components/calendar/index.d.ts +1 -1
  14. package/dist/components/card/card.d.ts +1 -1
  15. package/dist/components/card/index.d.ts +1 -1
  16. package/dist/components/checkbox/checkbox-label.d.ts +1 -1
  17. package/dist/components/checkbox/checkbox-links.d.ts +1 -1
  18. package/dist/components/checkbox/index.d.ts +3 -3
  19. package/dist/components/code-block/code-block-footer.js +7 -2
  20. package/dist/components/code-block/index.d.ts +3 -3
  21. package/dist/components/combobox/combobox.d.ts +1 -1
  22. package/dist/components/combobox/index.d.ts +1 -1
  23. package/dist/components/command/command.d.ts +1 -1
  24. package/dist/components/command/index.d.ts +1 -1
  25. package/dist/components/confetti/index.d.ts +1 -1
  26. package/dist/components/count-up/index.d.ts +1 -1
  27. package/dist/components/dashboard/components/charts/bar-chart.d.ts +1 -1
  28. package/dist/components/dashboard/components/charts/index.d.ts +5 -5
  29. package/dist/components/dashboard/components/charts/line-chart.d.ts +1 -1
  30. package/dist/components/dashboard/components/charts/utils.d.ts +2 -2
  31. package/dist/components/dashboard/components/dashboard-alert.d.ts +1 -1
  32. package/dist/components/dashboard/components/mobile-sidebar.d.ts +1 -1
  33. package/dist/components/dashboard/components/sidebar.d.ts +1 -1
  34. package/dist/components/dashboard/components/topbar-button.d.ts +1 -1
  35. package/dist/components/dashboard/context/dashboard-context.d.ts +2 -2
  36. package/dist/components/dashboard/context/index.d.ts +4 -4
  37. package/dist/components/dashboard/context/types.d.ts +1 -1
  38. package/dist/components/dashboard/context/utils.d.ts +1 -1
  39. package/dist/components/dashboard/index.d.ts +18 -18
  40. package/dist/components/dashboard/pages/index.d.ts +2 -2
  41. package/dist/components/dashboard/table/index.d.ts +2 -2
  42. package/dist/components/dashboard/toolbar/filter-button.d.ts +1 -1
  43. package/dist/components/dashboard/toolbar/index.d.ts +9 -9
  44. package/dist/components/date-picker/index.d.ts +1 -1
  45. package/dist/components/date-time-range-picker/index.d.ts +1 -1
  46. package/dist/components/dot-grid/index.d.ts +1 -1
  47. package/dist/components/dropdown-menu/dropdown-menu.d.ts +1 -1
  48. package/dist/components/dropdown-menu/index.d.ts +1 -1
  49. package/dist/components/dynamic-item/dynamic-item.d.ts +1 -1
  50. package/dist/components/dynamic-item/index.d.ts +1 -1
  51. package/dist/components/empty-state/empty-state.d.ts +1 -1
  52. package/dist/components/empty-state/index.d.ts +1 -1
  53. package/dist/components/form/index.d.ts +1 -1
  54. package/dist/components/icon/custom/index.d.ts +14 -14
  55. package/dist/components/icon/icon.d.ts +1 -1
  56. package/dist/components/icon/index.d.ts +1 -1
  57. package/dist/components/index.d.ts +47 -47
  58. package/dist/components/inline-tips/index.d.ts +1 -1
  59. package/dist/components/input/index.d.ts +1 -1
  60. package/dist/components/interval-selector/hooks/index.d.ts +3 -3
  61. package/dist/components/interval-selector/hooks/use-interval-selector-input.d.ts +1 -1
  62. package/dist/components/interval-selector/hooks/use-interval-selector-navigation.d.ts +1 -1
  63. package/dist/components/interval-selector/hooks/use-interval-selector.d.ts +2 -2
  64. package/dist/components/interval-selector/index.d.ts +2 -2
  65. package/dist/components/interval-selector/interval-selector-calendar.d.ts +1 -1
  66. package/dist/components/interval-selector/interval-selector-input.d.ts +1 -1
  67. package/dist/components/interval-selector/interval-selector-suggestions.d.ts +1 -1
  68. package/dist/components/interval-selector/interval-selector.d.ts +1 -1
  69. package/dist/components/interval-selector/utils/format.d.ts +1 -1
  70. package/dist/components/interval-selector/utils/index.d.ts +2 -2
  71. package/dist/components/item/index.d.ts +1 -1
  72. package/dist/components/kbd/index.d.ts +1 -1
  73. package/dist/components/label/index.d.ts +1 -1
  74. package/dist/components/modal/index.d.ts +1 -1
  75. package/dist/components/moving-border/index.d.ts +1 -1
  76. package/dist/components/popover/index.d.ts +1 -1
  77. package/dist/components/scroll-area/index.d.ts +1 -1
  78. package/dist/components/search/index.d.ts +6 -6
  79. package/dist/components/search/search-inline.d.ts +1 -1
  80. package/dist/components/search/search-modal.d.ts +1 -1
  81. package/dist/components/search/search-trigger.d.ts +1 -1
  82. package/dist/components/select/index.d.ts +1 -1
  83. package/dist/components/select/select.d.ts +1 -1
  84. package/dist/components/sheet/index.d.ts +1 -1
  85. package/dist/components/shiny-text/index.d.ts +1 -1
  86. package/dist/components/shipql-editor/index.d.ts +2 -2
  87. package/dist/components/shipql-editor/lexical/on-blur-plugin.d.ts +2 -1
  88. package/dist/components/shipql-editor/lexical/on-blur-plugin.js +8 -4
  89. package/dist/components/shipql-editor/lexical/shipql-leaf-node.d.ts +5 -2
  90. package/dist/components/shipql-editor/lexical/shipql-leaf-node.js +18 -8
  91. package/dist/components/shipql-editor/lexical/shipql-plugin.d.ts +3 -2
  92. package/dist/components/shipql-editor/lexical/shipql-plugin.js +188 -34
  93. package/dist/components/shipql-editor/shipql-editor-inner.d.ts +2 -2
  94. package/dist/components/shipql-editor/shipql-editor-inner.js +52 -7
  95. package/dist/components/shipql-editor/shipql-editor.d.ts +4 -3
  96. package/dist/components/shipql-editor/suggestions/generate-suggestions.d.ts +9 -3
  97. package/dist/components/shipql-editor/suggestions/generate-suggestions.js +94 -20
  98. package/dist/components/shipql-editor/suggestions/shipql-range-facet-panel.d.ts +1 -1
  99. package/dist/components/shipql-editor/suggestions/shipql-range-facet-panel.js +2 -2
  100. package/dist/components/shipql-editor/suggestions/shipql-suggestion-item.d.ts +2 -2
  101. package/dist/components/shipql-editor/suggestions/shipql-suggestion-item.js +74 -10
  102. package/dist/components/shipql-editor/suggestions/shipql-suggestions-dropdown.d.ts +4 -3
  103. package/dist/components/shipql-editor/suggestions/shipql-suggestions-dropdown.js +56 -11
  104. package/dist/components/shipql-editor/suggestions/shipql-suggestions-plugin.d.ts +2 -2
  105. package/dist/components/shipql-editor/suggestions/shipql-suggestions-plugin.js +7 -5
  106. package/dist/components/shipql-editor/suggestions/types.d.ts +25 -3
  107. package/dist/components/skeleton/index.d.ts +1 -1
  108. package/dist/components/slider/index.d.ts +1 -1
  109. package/dist/components/switch/index.d.ts +1 -1
  110. package/dist/components/table/index.d.ts +4 -4
  111. package/dist/components/table/table.stories.columns.d.ts +1 -1
  112. package/dist/components/tabs/index.d.ts +1 -1
  113. package/dist/components/textarea/index.d.ts +1 -1
  114. package/dist/components/theme/index.d.ts +1 -1
  115. package/dist/components/theme/theme-provider.d.ts +1 -1
  116. package/dist/components/toast/index.d.ts +2 -2
  117. package/dist/components/tooltip/index.d.ts +1 -1
  118. package/dist/components/typography/index.d.ts +3 -3
  119. package/dist/hooks/index.d.ts +6 -6
  120. package/dist/hooks/useTheme.d.ts +1 -1
  121. package/dist/index.d.ts +4 -4
  122. package/dist/styles.css +2 -1
  123. package/dist/utils/format/index.d.ts +5 -5
  124. package/dist/utils/index.d.ts +6 -6
  125. package/package.json +10 -10
@@ -1,9 +1,9 @@
1
1
  import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
2
2
  import { parse, removeBySource, stringify } from '@shipfox/shipql-parser';
3
- import { $createRangeSelection, $createTextNode, $getRoot, $getSelection, $isRangeSelection, $isTextNode, $setSelection, COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_NORMAL, createCommand, INSERT_PARAGRAPH_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_ENTER_COMMAND } from 'lexical';
3
+ import { $createRangeSelection, $createTextNode, $getRoot, $getSelection, $isRangeSelection, $isTextNode, $setSelection, BLUR_COMMAND, COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_LOW, COMMAND_PRIORITY_NORMAL, createCommand, FOCUS_COMMAND, INSERT_PARAGRAPH_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_ENTER_COMMAND } from 'lexical';
4
4
  import { useEffect, useRef } from 'react';
5
5
  import { handleArrowRightFromLeaf } from './handle-arrow-right-from-leaf.js';
6
- import { $createShipQLLeafNode, $isShipQLLeafNode, isGroupedCompound, isSimpleLeaf, leafSource } from './shipql-leaf-node.js';
6
+ import { $createShipQLLeafNode, $isShipQLLeafNode, $setLeafFreeTextError, isGroupedCompound, isSimpleLeaf, leafSource } from './shipql-leaf-node.js';
7
7
  function collectLeaves(ast) {
8
8
  // Simple terminals are always a single leaf chip.
9
9
  if (isSimpleLeaf(ast)) {
@@ -86,7 +86,15 @@ function needsRebuild(children, segments) {
86
86
  }
87
87
  return false;
88
88
  }
89
+ function getOrderedLeafStateKey(text, counts) {
90
+ const index = (counts.get(text) ?? 0) + 1;
91
+ counts.set(text, index);
92
+ return `${text}::${index}`;
93
+ }
89
94
  function getAbsoluteOffset(para, point) {
95
+ if (point.type === 'element') {
96
+ return para.getChildren().slice(0, point.offset).reduce((offset, child)=>offset + ($isTextNode(child) ? child.getTextContentSize() : 0), 0);
97
+ }
90
98
  let offset = 0;
91
99
  for (const child of para.getChildren()){
92
100
  if (child.getKey() === point.key) return offset + point.offset;
@@ -94,19 +102,62 @@ function getAbsoluteOffset(para, point) {
94
102
  }
95
103
  return offset + point.offset;
96
104
  }
105
+ function getDesiredLeafErrorState(segments, selectionOffset, allowFreeText, isFocused) {
106
+ const counts = new Map();
107
+ const leafRanges = [];
108
+ let offset = 0;
109
+ for (const seg of segments){
110
+ if (seg.kind === 'leaf') {
111
+ leafRanges.push({
112
+ stateKey: getOrderedLeafStateKey(seg.text, counts),
113
+ node: seg.node,
114
+ start: offset,
115
+ end: offset + seg.text.length
116
+ });
117
+ }
118
+ offset += seg.text.length;
119
+ }
120
+ const activeLeafKey = selectionOffset >= 0 ? leafRanges.find((leaf)=>selectionOffset >= leaf.start && selectionOffset <= leaf.end)?.stateKey ?? null : null;
121
+ const errorState = new Map();
122
+ for (const leaf of leafRanges){
123
+ const shouldError = !allowFreeText && isTextLeaf(leaf.node) && (!isFocused || leaf.stateKey !== activeLeafKey);
124
+ errorState.set(leaf.stateKey, shouldError);
125
+ }
126
+ return errorState;
127
+ }
128
+ function leafErrorStateMatches(children, desiredErrorState) {
129
+ const counts = new Map();
130
+ for (const child of children){
131
+ if (!$isShipQLLeafNode(child)) continue;
132
+ const stateKey = getOrderedLeafStateKey(child.getTextContent(), counts);
133
+ if ((desiredErrorState.get(stateKey) ?? false) !== child.getLatest().__freeTextError) {
134
+ return false;
135
+ }
136
+ }
137
+ return true;
138
+ }
139
+ /** Returns true if a leaf segment represents a free-text node (bare word / quoted string). */ function isTextLeaf(node) {
140
+ if (node.type === 'text') return true;
141
+ if (node.type === 'not' && node.expr.type === 'text') return true;
142
+ return false;
143
+ }
97
144
  // ─── Commands ─────────────────────────────────────────────────────────────────
98
145
  /** Payload: the Lexical node key of the leaf to remove. */ export const REMOVE_LEAF_COMMAND = createCommand('REMOVE_LEAF_COMMAND');
99
146
  // ─── Plugin ───────────────────────────────────────────────────────────────────
100
147
  const REBUILD_TAG = 'shipql-rebuild';
101
- export function ShipQLPlugin({ onLeafFocus, formatLeafDisplay }) {
148
+ export function ShipQLPlugin({ onLeafFocus, formatLeafDisplay, allowFreeText = true }) {
102
149
  const [editor] = useLexicalComposerContext();
103
150
  // Keep latest callback accessible inside Lexical listeners without re-registering.
104
151
  const onLeafFocusRef = useRef(onLeafFocus);
105
152
  onLeafFocusRef.current = onLeafFocus;
106
153
  const formatLeafDisplayRef = useRef(formatLeafDisplay);
107
154
  formatLeafDisplayRef.current = formatLeafDisplay;
155
+ const allowFreeTextRef = useRef(allowFreeText);
156
+ allowFreeTextRef.current = allowFreeText;
108
157
  // Track the key of the last focused leaf to avoid redundant callbacks.
109
158
  const lastFocusedKeyRef = useRef(null);
159
+ // Track whether the editor is focused so we can show errors only when blurred.
160
+ const isFocusedRef = useRef(true);
110
161
  useEffect(()=>{
111
162
  // Block newlines — ShipQL is a single-line language.
112
163
  const unregisterEnter = editor.registerCommand(KEY_ENTER_COMMAND, (event)=>{
@@ -156,6 +207,85 @@ export function ShipQLPlugin({ onLeafFocus, formatLeafDisplay }) {
156
207
  });
157
208
  return true;
158
209
  }, COMMAND_PRIORITY_HIGH);
210
+ // ── Blur / Focus: toggle free-text error styling via rebuild ────────────
211
+ function syncLeafNodesWithCurrentState() {
212
+ editor.update(()=>{
213
+ const para = $getRoot().getFirstChild();
214
+ if (!para) return;
215
+ const text = para.getTextContent();
216
+ const children = para.getChildren();
217
+ const sel = $getSelection();
218
+ const selectionOffset = $isRangeSelection(sel) ? getAbsoluteOffset(para, sel.anchor) : -1;
219
+ if (!text) {
220
+ if (children.some($isShipQLLeafNode)) {
221
+ para.clear();
222
+ }
223
+ return;
224
+ }
225
+ let ast = null;
226
+ try {
227
+ ast = parse(text);
228
+ } catch {
229
+ return;
230
+ }
231
+ if (!ast) {
232
+ if (!children.some($isShipQLLeafNode)) return;
233
+ para.clear();
234
+ para.append($createTextNode(text));
235
+ return;
236
+ }
237
+ const segments = tokenize(text, collectLeaves(ast));
238
+ const fmt = formatLeafDisplayRef.current;
239
+ const desiredLeafErrors = getDesiredLeafErrorState(segments, selectionOffset, allowFreeTextRef.current, isFocusedRef.current);
240
+ if (!needsRebuild(children, segments) && leafErrorStateMatches(children, desiredLeafErrors)) {
241
+ return;
242
+ }
243
+ para.clear();
244
+ const newNodes = [];
245
+ const counts = new Map();
246
+ for (const seg of segments){
247
+ const node = seg.kind === 'leaf' ? $createShipQLLeafNode(seg.text, seg.node, fmt?.(seg.text, seg.node), desiredLeafErrors.get(getOrderedLeafStateKey(seg.text, counts)) ?? false) : $createTextNode(seg.text);
248
+ newNodes.push(node);
249
+ para.append(node);
250
+ }
251
+ if (selectionOffset >= 0 && newNodes.length > 0) {
252
+ let remaining = selectionOffset;
253
+ let lastTextNode = null;
254
+ for (const node of newNodes){
255
+ if (!$isTextNode(node)) continue;
256
+ lastTextNode = node;
257
+ const len = node.getTextContentSize();
258
+ if (remaining <= len) {
259
+ const nextSelection = $createRangeSelection();
260
+ nextSelection.anchor.set(node.getKey(), remaining, 'text');
261
+ nextSelection.focus.set(node.getKey(), remaining, 'text');
262
+ $setSelection(nextSelection);
263
+ return;
264
+ }
265
+ remaining -= len;
266
+ }
267
+ if (lastTextNode && $isTextNode(lastTextNode)) {
268
+ const endSelection = $createRangeSelection();
269
+ const end = lastTextNode.getTextContentSize();
270
+ endSelection.anchor.set(lastTextNode.getKey(), end, 'text');
271
+ endSelection.focus.set(lastTextNode.getKey(), end, 'text');
272
+ $setSelection(endSelection);
273
+ }
274
+ }
275
+ }, {
276
+ tag: REBUILD_TAG
277
+ });
278
+ }
279
+ const unregisterBlur = editor.registerCommand(BLUR_COMMAND, ()=>{
280
+ isFocusedRef.current = false;
281
+ if (!allowFreeTextRef.current) syncLeafNodesWithCurrentState();
282
+ return false;
283
+ }, COMMAND_PRIORITY_LOW);
284
+ const unregisterFocus = editor.registerCommand(FOCUS_COMMAND, ()=>{
285
+ isFocusedRef.current = true;
286
+ if (!allowFreeTextRef.current) syncLeafNodesWithCurrentState();
287
+ return false;
288
+ }, COMMAND_PRIORITY_LOW);
159
289
  const unregisterUpdate = editor.registerUpdateListener(({ editorState, tags })=>{
160
290
  // After our own rebuild we only need to refresh the leaf-focus callback.
161
291
  if (tags.has(REBUILD_TAG)) {
@@ -175,6 +305,8 @@ export function ShipQLPlugin({ onLeafFocus, formatLeafDisplay }) {
175
305
  let shouldRebuild = false;
176
306
  let nextSegments = [];
177
307
  let savedOffset = -1;
308
+ let desiredLeafErrors = new Map();
309
+ let needsErrorStateUpdate = false;
178
310
  editorState.read(()=>{
179
311
  const para = $getRoot().getFirstChild();
180
312
  if (!para) return;
@@ -197,7 +329,7 @@ export function ShipQLPlugin({ onLeafFocus, formatLeafDisplay }) {
197
329
  }
198
330
  ] : [];
199
331
  const sel = $getSelection();
200
- if ($isRangeSelection(sel) && sel.anchor.type === 'text') {
332
+ if ($isRangeSelection(sel)) {
201
333
  savedOffset = getAbsoluteOffset(para, sel.anchor);
202
334
  }
203
335
  }
@@ -209,16 +341,16 @@ export function ShipQLPlugin({ onLeafFocus, formatLeafDisplay }) {
209
341
  return;
210
342
  }
211
343
  const segments = tokenize(text, collectLeaves(ast));
344
+ const sel = $getSelection();
345
+ if ($isRangeSelection(sel)) {
346
+ savedOffset = getAbsoluteOffset(para, sel.anchor);
347
+ }
348
+ desiredLeafErrors = getDesiredLeafErrorState(segments, savedOffset, allowFreeTextRef.current, isFocusedRef.current);
212
349
  if (needsRebuild(children, segments)) {
213
350
  shouldRebuild = true;
214
351
  nextSegments = segments;
215
- const sel = $getSelection();
216
- if ($isRangeSelection(sel) && sel.anchor.type === 'text') {
217
- savedOffset = getAbsoluteOffset(para, sel.anchor);
218
- }
219
352
  } else {
220
- // No structural change — just update leaf-focus.
221
- const sel = $getSelection();
353
+ // No structural change — update leaf-focus and deferred text-node errors.
222
354
  if ($isRangeSelection(sel)) {
223
355
  const anchor = sel.anchor.getNode();
224
356
  const key = $isShipQLLeafNode(anchor) ? anchor.getKey() : null;
@@ -226,32 +358,72 @@ export function ShipQLPlugin({ onLeafFocus, formatLeafDisplay }) {
226
358
  lastFocusedKeyRef.current = key;
227
359
  onLeafFocusRef.current?.(key !== null && $isShipQLLeafNode(anchor) ? anchor.getShipQLNode() : null);
228
360
  }
361
+ } else if (lastFocusedKeyRef.current !== null) {
362
+ lastFocusedKeyRef.current = null;
363
+ onLeafFocusRef.current?.(null);
229
364
  }
365
+ needsErrorStateUpdate = !leafErrorStateMatches(children, desiredLeafErrors);
230
366
  }
231
367
  });
232
- if (!shouldRebuild) return;
368
+ if (!shouldRebuild) {
369
+ if (needsErrorStateUpdate) {
370
+ editor.update(()=>{
371
+ const para = $getRoot().getFirstChild();
372
+ if (!para) return;
373
+ const counts = new Map();
374
+ for (const child of para.getChildren()){
375
+ if (!$isShipQLLeafNode(child)) continue;
376
+ const stateKey = getOrderedLeafStateKey(child.getTextContent(), counts);
377
+ $setLeafFreeTextError(child, desiredLeafErrors.get(stateKey) ?? false);
378
+ }
379
+ }, {
380
+ tag: REBUILD_TAG
381
+ });
382
+ }
383
+ return;
384
+ }
233
385
  // ── Rebuild node structure ─────────────────────────────────────────────
234
386
  editor.update(()=>{
235
387
  const para = $getRoot().getFirstChild();
236
388
  if (!para) return;
237
389
  para.clear();
238
390
  const fmt = formatLeafDisplayRef.current;
239
- const newNodes = nextSegments.map((seg)=>seg.kind === 'leaf' ? $createShipQLLeafNode(seg.text, seg.node, fmt?.(seg.text, seg.node)) : $createTextNode(seg.text));
391
+ const occurrenceCounts = new Map();
392
+ const newNodes = nextSegments.map((seg)=>{
393
+ if (seg.kind === 'leaf') {
394
+ const stateKey = getOrderedLeafStateKey(seg.text, occurrenceCounts);
395
+ const freeTextError = desiredLeafErrors.get(stateKey) ?? false;
396
+ return $createShipQLLeafNode(seg.text, seg.node, fmt?.(seg.text, seg.node), freeTextError);
397
+ }
398
+ return $createTextNode(seg.text);
399
+ });
240
400
  for (const node of newNodes)para.append(node);
241
401
  // Restore cursor to the same absolute character position.
242
402
  if (savedOffset >= 0 && newNodes.length > 0) {
243
403
  let remaining = savedOffset;
404
+ let lastTextNode = null;
405
+ let restoredSelection = false;
244
406
  for (const node of newNodes){
245
- const len = $isTextNode(node) ? node.getTextContentSize() : 0;
407
+ if (!$isTextNode(node)) continue;
408
+ lastTextNode = node;
409
+ const len = node.getTextContentSize();
246
410
  if (remaining <= len) {
247
411
  const sel = $createRangeSelection();
248
412
  sel.anchor.set(node.getKey(), remaining, 'text');
249
413
  sel.focus.set(node.getKey(), remaining, 'text');
250
414
  $setSelection(sel);
415
+ restoredSelection = true;
251
416
  break;
252
417
  }
253
418
  remaining -= len;
254
419
  }
420
+ if (!restoredSelection && lastTextNode && $isTextNode(lastTextNode)) {
421
+ const sel = $createRangeSelection();
422
+ const end = lastTextNode.getTextContentSize();
423
+ sel.anchor.set(lastTextNode.getKey(), end, 'text');
424
+ sel.focus.set(lastTextNode.getKey(), end, 'text');
425
+ $setSelection(sel);
426
+ }
255
427
  }
256
428
  }, {
257
429
  tag: REBUILD_TAG
@@ -261,32 +433,14 @@ export function ShipQLPlugin({ onLeafFocus, formatLeafDisplay }) {
261
433
  // registered after Lexical has already committed its initialConfig editorState
262
434
  // (useEffect runs post-paint), so the listener never fires for the first render.
263
435
  // We do the full rebuild here instead of relying on the listener being triggered.
264
- editor.update(()=>{
265
- const para = $getRoot().getFirstChild();
266
- if (!para) return;
267
- const text = para.getTextContent();
268
- if (!text) return;
269
- const children = para.getChildren();
270
- let ast = null;
271
- try {
272
- ast = parse(text);
273
- } catch {
274
- return;
275
- }
276
- if (!ast) return;
277
- const segments = tokenize(text, collectLeaves(ast));
278
- if (!needsRebuild(children, segments)) return;
279
- const fmt = formatLeafDisplayRef.current;
280
- para.clear();
281
- for (const seg of segments){
282
- para.append(seg.kind === 'leaf' ? $createShipQLLeafNode(seg.text, seg.node, fmt?.(seg.text, seg.node)) : $createTextNode(seg.text));
283
- }
284
- });
436
+ syncLeafNodesWithCurrentState();
285
437
  return ()=>{
286
438
  unregisterEnter();
287
439
  unregisterParagraph();
288
440
  unregisterArrowRight();
289
441
  unregisterRemoveLeaf();
442
+ unregisterBlur();
443
+ unregisterFocus();
290
444
  unregisterUpdate();
291
445
  };
292
446
  }, [
@@ -1,3 +1,3 @@
1
- import type { ShipQLEditorInnerProps } from './shipql-editor';
2
- export default function ShipQLEditorInner({ onChange, onLeafFocus, placeholder, className, disabled, mode, text, editorKey, isError, onTextChange, onClear, onToggleMode, facets, currentFacet, setCurrentFacet, valueSuggestions, isLoadingValueSuggestions, onLeafChange, formatLeafDisplay, }: ShipQLEditorInnerProps): import("react/jsx-runtime").JSX.Element;
1
+ import type { ShipQLEditorInnerProps } from './shipql-editor.js';
2
+ export default function ShipQLEditorInner({ onChange, onLeafFocus, placeholder, className, disabled, mode, text, editorKey, isError, onTextChange, onClear, onToggleMode, facets, currentFacet, setCurrentFacet, valueSuggestions, isLoadingValueSuggestions, onLeafChange, formatLeafDisplay, allowFreeText, }: ShipQLEditorInnerProps): import("react/jsx-runtime").JSX.Element;
3
3
  //# sourceMappingURL=shipql-editor-inner.d.ts.map
@@ -4,10 +4,11 @@ import { ContentEditable } from '@lexical/react/LexicalContentEditable';
4
4
  import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
5
5
  import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
6
6
  import { PlainTextPlugin } from '@lexical/react/LexicalPlainTextPlugin';
7
+ import { hasTextNodes } from '@shipfox/shipql-parser';
7
8
  import { Input } from '../../components/input/index.js';
8
9
  import { Popover, PopoverAnchor } from '../../components/popover/index.js';
9
10
  import { $createParagraphNode, $createTextNode, $getRoot } from 'lexical';
10
- import { useCallback, useRef, useState } from 'react';
11
+ import { useCallback, useEffect, useRef, useState } from 'react';
11
12
  import { cn } from '../../utils/cn.js';
12
13
  import { Icon } from '../icon/icon.js';
13
14
  import { LeafCloseOverlay } from './lexical/leaf-close-overlay.js';
@@ -21,16 +22,19 @@ import { ShipQLSuggestionsPlugin } from './suggestions/shipql-suggestions-plugin
21
22
  const INPUT_CLASSES = 'block w-full rounded-6 bg-background-field-base py-2 pl-32 pr-58 sm:pr-64 text-md text-foreground-neutral-base caret-foreground-neutral-base outline-none focus:border-border-highlights-interactive shadow-button-neutral';
22
23
  const INPUT_ERROR_CLASSES = 'shadow-border-error';
23
24
  const BUTTON_CLASSES = 'shrink-0 text-foreground-neutral-subtle hover:text-foreground-neutral-base transition-all duration-150 flex justify-center items-center cursor-pointer w-28 sm:w-32 h-full';
24
- export default function ShipQLEditorInner({ onChange, onLeafFocus, placeholder, className, disabled, mode, text, editorKey, isError, onTextChange, onClear, onToggleMode, facets, currentFacet, setCurrentFacet, valueSuggestions, isLoadingValueSuggestions, onLeafChange, formatLeafDisplay }) {
25
+ export default function ShipQLEditorInner({ onChange, onLeafFocus, placeholder, className, disabled, mode, text, editorKey, isError, onTextChange, onClear, onToggleMode, facets, currentFacet, setCurrentFacet, valueSuggestions, isLoadingValueSuggestions, onLeafChange, formatLeafDisplay, allowFreeText }) {
25
26
  const [suggestionsOpen, setSuggestionsOpen] = useState(false);
26
27
  const [selectedIndex, setSelectedIndex] = useState(-1);
27
28
  const [items, setItems] = useState([]);
28
29
  const [focusedLeafNode, setFocusedLeafNode] = useState(null);
29
30
  const [isNegated, setIsNegated] = useState(false);
30
31
  const [showSyntaxHelp, setShowSyntaxHelp] = useState(false);
32
+ const [isTextModeBlurError, setIsTextModeBlurError] = useState(false);
31
33
  const isSelectingRef = useRef(false);
32
34
  const applyRef = useRef(null);
33
35
  const negationPrefixRef = useRef('');
36
+ const containerRef = useRef(null);
37
+ const suggestionsSuppressedRef = useRef(false);
34
38
  const hasSuggestions = Boolean(facets && facets.length > 0);
35
39
  const showValueActions = Boolean(currentFacet);
36
40
  const isRangeContext = items.length === 1 && items[0]?.type === 'range-slider';
@@ -56,9 +60,43 @@ export default function ShipQLEditorInner({ onChange, onLeafFocus, placeholder,
56
60
  const handleToggleSyntaxHelp = useCallback(()=>{
57
61
  setShowSyntaxHelp((prev)=>!prev);
58
62
  }, []);
63
+ const handleDismissSuggestions = useCallback(()=>{
64
+ suggestionsSuppressedRef.current = true;
65
+ setSuggestionsOpen(false);
66
+ }, []);
67
+ const handleSuggestionsOpenChange = useCallback((open)=>{
68
+ if (open && suggestionsSuppressedRef.current) return;
69
+ setSuggestionsOpen(open);
70
+ }, []);
71
+ const handleEditorMouseDownCapture = useCallback(()=>{
72
+ suggestionsSuppressedRef.current = false;
73
+ }, []);
74
+ useEffect(()=>{
75
+ if (!suggestionsOpen) return;
76
+ const handleMouseDown = (event)=>{
77
+ if (isSelectingRef.current) return;
78
+ const target = event.target;
79
+ if (target && containerRef.current?.contains(target)) return;
80
+ if (target?.closest?.('[data-shipql-suggestions]')) return;
81
+ handleDismissSuggestions();
82
+ const activeElement = document.activeElement;
83
+ if (activeElement && containerRef.current?.contains(activeElement)) {
84
+ activeElement.blur();
85
+ }
86
+ };
87
+ document.addEventListener('mousedown', handleMouseDown, true);
88
+ return ()=>{
89
+ document.removeEventListener('mousedown', handleMouseDown, true);
90
+ };
91
+ }, [
92
+ suggestionsOpen,
93
+ handleDismissSuggestions
94
+ ]);
59
95
  return /*#__PURE__*/ _jsxs("div", {
96
+ ref: containerRef,
60
97
  "data-shipql-editor": true,
61
98
  className: cn('relative', className),
99
+ onMouseDownCapture: handleEditorMouseDownCapture,
62
100
  children: [
63
101
  mode === 'editor' ? /*#__PURE__*/ _jsxs(Popover, {
64
102
  open: hasSuggestions && suggestionsOpen,
@@ -108,10 +146,12 @@ export default function ShipQLEditorInner({ onChange, onLeafFocus, placeholder,
108
146
  }),
109
147
  /*#__PURE__*/ _jsx(ShipQLPlugin, {
110
148
  onLeafFocus: handleLeafFocus,
111
- formatLeafDisplay: formatLeafDisplay
149
+ formatLeafDisplay: formatLeafDisplay,
150
+ allowFreeText: allowFreeText
112
151
  }),
113
152
  /*#__PURE__*/ _jsx(OnBlurPlugin, {
114
- onChange: onChange
153
+ onChange: onChange,
154
+ allowFreeText: allowFreeText
115
155
  }),
116
156
  /*#__PURE__*/ _jsx(OnTextChangePlugin, {
117
157
  onTextChange: onTextChange
@@ -125,7 +165,7 @@ export default function ShipQLEditorInner({ onChange, onLeafFocus, placeholder,
125
165
  valueSuggestions: valueSuggestions ?? [],
126
166
  isLoadingValueSuggestions: isLoadingValueSuggestions ?? false,
127
167
  open: suggestionsOpen,
128
- setOpen: setSuggestionsOpen,
168
+ setOpen: handleSuggestionsOpenChange,
129
169
  selectedIndex: selectedIndex,
130
170
  setSelectedIndex: setSelectedIndex,
131
171
  items: items,
@@ -144,6 +184,7 @@ export default function ShipQLEditorInner({ onChange, onLeafFocus, placeholder,
144
184
  selectedIndex: selectedIndex,
145
185
  isSelectingRef: isSelectingRef,
146
186
  onSelect: handleSelect,
187
+ onClose: handleDismissSuggestions,
147
188
  isLoading: isLoadingValueSuggestions,
148
189
  isNegated: isNegated,
149
190
  onToggleNegate: handleToggleNegate,
@@ -163,10 +204,11 @@ export default function ShipQLEditorInner({ onChange, onLeafFocus, placeholder,
163
204
  className: "shrink-0 text-foreground-neutral-muted"
164
205
  }),
165
206
  className: cn(INPUT_CLASSES, disabled && 'pointer-events-none opacity-50'),
166
- "aria-invalid": isError,
207
+ "aria-invalid": isError || isTextModeBlurError,
167
208
  value: text,
168
209
  onChange: (e)=>{
169
210
  const newText = e.target.value;
211
+ setIsTextModeBlurError(false);
170
212
  onTextChange(newText);
171
213
  const facetNames = facets ? normalizeFacets(facets) : [];
172
214
  const facetCtx = detectFacetContext(newText, facetNames);
@@ -178,8 +220,11 @@ export default function ShipQLEditorInner({ onChange, onLeafFocus, placeholder,
178
220
  },
179
221
  onBlur: (e)=>{
180
222
  const ast = tryParse(e.target.value);
181
- if (ast) onChange?.(ast);
223
+ const hasDeferredFreeTextError = Boolean(ast && !allowFreeText && hasTextNodes(ast));
224
+ setIsTextModeBlurError(hasDeferredFreeTextError);
225
+ if (ast && !hasDeferredFreeTextError) onChange?.(ast);
182
226
  },
227
+ onFocus: ()=>setIsTextModeBlurError(false),
183
228
  placeholder: placeholder,
184
229
  disabled: disabled
185
230
  }),
@@ -1,7 +1,7 @@
1
1
  import type { AstNode } from '@shipfox/shipql-parser';
2
- import type { LeafAstNode } from './lexical/shipql-leaf-node';
3
- import type { FacetDef, FormatLeafDisplay } from './suggestions/types';
4
- export type { FacetDef, FormatLeafDisplay, RangeFacetConfig } from './suggestions/types';
2
+ import type { LeafAstNode } from './lexical/shipql-leaf-node.js';
3
+ import type { FacetDef, FormatLeafDisplay } from './suggestions/types.js';
4
+ export type { FacetDef, FacetMetadata, FormatLeafDisplay, RangeFacetConfig, } from './suggestions/types.js';
5
5
  export type LeafChangePayload = {
6
6
  partialValue: string;
7
7
  ast: AstNode | null;
@@ -20,6 +20,7 @@ export interface ShipQLEditorProps {
20
20
  isLoadingValueSuggestions?: boolean;
21
21
  onLeafChange?: (payload: LeafChangePayload) => void;
22
22
  formatLeafDisplay?: FormatLeafDisplay;
23
+ allowFreeText?: boolean;
23
24
  }
24
25
  export interface ShipQLEditorInnerProps extends ShipQLEditorProps {
25
26
  mode: 'editor' | 'text';
@@ -1,9 +1,15 @@
1
1
  import { type AstNode } from '@shipfox/shipql-parser';
2
- import type { LeafAstNode } from '../lexical/shipql-leaf-node';
3
- import type { FacetDef, RangeFacetConfig, SuggestionItem } from './types';
2
+ import type { LeafAstNode } from '../lexical/shipql-leaf-node.js';
3
+ import type { FacetDef, FacetGroupInfo, RangeFacetConfig, SuggestionItem } from './types.js';
4
4
  export declare function tryParse(text: string): AstNode | null;
5
5
  export declare function normalizeFacets(facets: FacetDef[]): string[];
6
- export declare function getFacetConfig(facets: FacetDef[], name: string): RangeFacetConfig | undefined;
6
+ export declare function getFacetConfig(facets: FacetDef[], id: string): RangeFacetConfig | undefined;
7
+ /** Returns the human-readable display label, falling back to the raw id. */
8
+ export declare function getFacetLabel(facets: FacetDef[], id: string): string;
9
+ /** Returns the description for a facet, if any. */
10
+ export declare function getFacetDescription(facets: FacetDef[], id: string): string | undefined;
11
+ /** Returns grouping information for a facet, with defaults applied. */
12
+ export declare function getFacetGroupInfo(facets: FacetDef[], id: string): FacetGroupInfo;
7
13
  export declare function extractFacetFromLeaf(leaf: LeafAstNode): string | undefined;
8
14
  /** Extracts the negation prefix used in a `not` node's source (either 'NOT ' or '-'). */
9
15
  export declare function negationPrefixFromSource(source: string): string;