@shipfox/react-ui 0.32.1 → 0.33.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 (26) hide show
  1. package/dist/components/code-block/code-block-footer.js +7 -2
  2. package/dist/components/shipql-editor/index.d.ts +1 -1
  3. package/dist/components/shipql-editor/lexical/on-blur-plugin.d.ts +2 -1
  4. package/dist/components/shipql-editor/lexical/on-blur-plugin.js +8 -4
  5. package/dist/components/shipql-editor/lexical/shipql-leaf-node.d.ts +5 -2
  6. package/dist/components/shipql-editor/lexical/shipql-leaf-node.js +18 -8
  7. package/dist/components/shipql-editor/lexical/shipql-plugin.d.ts +2 -1
  8. package/dist/components/shipql-editor/lexical/shipql-plugin.js +188 -34
  9. package/dist/components/shipql-editor/shipql-editor-inner.d.ts +1 -1
  10. package/dist/components/shipql-editor/shipql-editor-inner.js +53 -7
  11. package/dist/components/shipql-editor/shipql-editor.d.ts +2 -1
  12. package/dist/components/shipql-editor/suggestions/generate-suggestions.d.ts +8 -2
  13. package/dist/components/shipql-editor/suggestions/generate-suggestions.js +94 -20
  14. package/dist/components/shipql-editor/suggestions/shipql-range-facet-panel.js +2 -2
  15. package/dist/components/shipql-editor/suggestions/shipql-suggestion-item.d.ts +1 -1
  16. package/dist/components/shipql-editor/suggestions/shipql-suggestion-item.js +74 -10
  17. package/dist/components/shipql-editor/suggestions/shipql-suggestions-dropdown.d.ts +2 -1
  18. package/dist/components/shipql-editor/suggestions/shipql-suggestions-dropdown.js +43 -11
  19. package/dist/components/shipql-editor/suggestions/shipql-suggestions-plugin.js +7 -5
  20. package/dist/components/shipql-editor/suggestions/types.d.ts +25 -3
  21. package/dist/styles.css +2 -1
  22. package/dist/utils/format/bytes.d.ts +2 -0
  23. package/dist/utils/format/bytes.js +14 -0
  24. package/dist/utils/format/index.d.ts +1 -0
  25. package/dist/utils/format/index.js +1 -0
  26. package/package.json +11 -11
@@ -6,6 +6,9 @@ import { Text } from '../../components/typography/index.js';
6
6
  import { useResolvedTheme } from '../../hooks/useResolvedTheme.js';
7
7
  import { ShipfoxLoader } from 'shipfox-loader-react';
8
8
  import { cn } from '../../utils/cn.js';
9
+ // In Playwright (Argos CI) navigator.webdriver is true. Disable canvas-based
10
+ // animations so screenshots are deterministic.
11
+ const isTest = typeof navigator !== 'undefined' && navigator.webdriver === true;
9
12
  export function CodeBlockFooter({ className, asChild = false, state = 'running', message, description, icon, children, ...props }) {
10
13
  const Comp = asChild ? Slot : 'div';
11
14
  const resolvedTheme = useResolvedTheme();
@@ -13,7 +16,8 @@ export function CodeBlockFooter({ className, asChild = false, state = 'running',
13
16
  size: 20,
14
17
  animation: "circular",
15
18
  color: resolvedTheme === 'dark' ? 'white' : 'orange',
16
- background: resolvedTheme === 'dark' ? 'dark' : 'light'
19
+ background: resolvedTheme === 'dark' ? 'dark' : 'light',
20
+ autoPlay: !isTest
17
21
  }) : /*#__PURE__*/ _jsx(Icon, {
18
22
  name: "checkCircleSolid",
19
23
  className: "size-20 text-foreground-neutral-base",
@@ -41,7 +45,8 @@ export function CodeBlockFooter({ className, asChild = false, state = 'running',
41
45
  message && /*#__PURE__*/ _jsx(CodeBlockFooterMessage, {
42
46
  children: state === 'running' && typeof message === 'string' ? /*#__PURE__*/ _jsx(ShinyText, {
43
47
  text: message,
44
- speed: 3
48
+ speed: 3,
49
+ disabled: isTest
45
50
  }) : message
46
51
  }),
47
52
  description && /*#__PURE__*/ _jsx(CodeBlockFooterDescription, {
@@ -1,3 +1,3 @@
1
1
  export type { LeafAstNode } from './lexical/shipql-leaf-node';
2
- export { type FacetDef, type FormatLeafDisplay, type LeafChangePayload, type RangeFacetConfig, ShipQLEditor, type ShipQLEditorProps, } from './shipql-editor';
2
+ export { type FacetDef, type FacetMetadata, type FormatLeafDisplay, type LeafChangePayload, type RangeFacetConfig, ShipQLEditor, type ShipQLEditorProps, } from './shipql-editor';
3
3
  //# sourceMappingURL=index.d.ts.map
@@ -1,7 +1,8 @@
1
1
  import type { AstNode } from '@shipfox/shipql-parser';
2
2
  interface OnBlurPluginProps {
3
3
  onChange?: (ast: AstNode) => void;
4
+ allowFreeText?: boolean;
4
5
  }
5
- export declare function OnBlurPlugin({ onChange }: OnBlurPluginProps): null;
6
+ export declare function OnBlurPlugin({ onChange, allowFreeText }: OnBlurPluginProps): null;
6
7
  export {};
7
8
  //# sourceMappingURL=on-blur-plugin.d.ts.map
@@ -1,18 +1,22 @@
1
1
  import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
2
- import { parse } from '@shipfox/shipql-parser';
2
+ import { hasTextNodes, parse } from '@shipfox/shipql-parser';
3
3
  import { $getRoot, BLUR_COMMAND, COMMAND_PRIORITY_LOW } from 'lexical';
4
4
  import { useEffect, useRef } from 'react';
5
- export function OnBlurPlugin({ onChange }) {
5
+ export function OnBlurPlugin({ onChange, allowFreeText = true }) {
6
6
  const [editor] = useLexicalComposerContext();
7
- // Keep latest callback accessible without re-registering the command.
8
7
  const onChangeRef = useRef(onChange);
9
8
  onChangeRef.current = onChange;
9
+ const allowFreeTextRef = useRef(allowFreeText);
10
+ allowFreeTextRef.current = allowFreeText;
10
11
  useEffect(()=>{
11
12
  return editor.registerCommand(BLUR_COMMAND, ()=>{
12
13
  const text = editor.getEditorState().read(()=>$getRoot().getTextContent());
13
14
  try {
14
15
  const ast = parse(text);
15
- if (ast) onChangeRef.current?.(ast);
16
+ if (ast) {
17
+ if (!allowFreeTextRef.current && hasTextNodes(ast)) return false;
18
+ onChangeRef.current?.(ast);
19
+ }
16
20
  } catch {
17
21
  // Invalid query — do not call onChange.
18
22
  }
@@ -11,20 +11,23 @@ type SerializedShipQLLeafNode = SerializedTextNode & {
11
11
  export declare class ShipQLLeafNode extends TextNode {
12
12
  __shipqlNode: LeafAstNode;
13
13
  __displayText: string | null;
14
- constructor(text: string, shipqlNode: LeafAstNode, key?: NodeKey, displayText?: string);
14
+ __freeTextError: boolean;
15
+ constructor(text: string, shipqlNode: LeafAstNode, key?: NodeKey, displayText?: string, freeTextError?: boolean);
15
16
  static getType(): string;
16
17
  static clone(node: ShipQLLeafNode): ShipQLLeafNode;
17
18
  static importJSON(serialized: SerializedShipQLLeafNode): ShipQLLeafNode;
18
19
  exportJSON(): SerializedShipQLLeafNode;
19
20
  createDOM(config: EditorConfig): HTMLElement;
20
21
  updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean;
22
+ _isLeafValid(): boolean;
21
23
  isSimpleText(): boolean;
22
24
  canInsertTextAfter(): boolean;
23
25
  canInsertTextBefore(): boolean;
24
26
  getShipQLNode(): LeafAstNode;
25
27
  }
26
28
  export declare function $isShipQLLeafNode(node: unknown): node is ShipQLLeafNode;
27
- export declare function $createShipQLLeafNode(text: string, shipqlNode: LeafAstNode, displayText?: string): ShipQLLeafNode;
29
+ export declare function $createShipQLLeafNode(text: string, shipqlNode: LeafAstNode, displayText?: string, freeTextError?: boolean): ShipQLLeafNode;
30
+ export declare function $setLeafFreeTextError(node: ShipQLLeafNode, value: boolean): void;
28
31
  /** Returns true if the AST node qualifies as a visual leaf chip in the editor. */
29
32
  export declare function isAstLeafNode(ast: AstNode): ast is LeafAstNode;
30
33
  export declare function leafSource(node: LeafAstNode): string;
@@ -30,16 +30,17 @@ function isValidLeafText(text) {
30
30
  }
31
31
  }
32
32
  export class ShipQLLeafNode extends TextNode {
33
- constructor(text, shipqlNode, key, displayText){
33
+ constructor(text, shipqlNode, key, displayText, freeTextError = false){
34
34
  super(text, key);
35
35
  this.__shipqlNode = shipqlNode;
36
36
  this.__displayText = displayText ?? null;
37
+ this.__freeTextError = freeTextError;
37
38
  }
38
39
  static getType() {
39
40
  return 'shipql-leaf';
40
41
  }
41
42
  static clone(node) {
42
- return new ShipQLLeafNode(node.__text, node.__shipqlNode, node.__key, node.__displayText ?? undefined);
43
+ return new ShipQLLeafNode(node.__text, node.__shipqlNode, node.__key, node.__displayText ?? undefined, node.__freeTextError);
43
44
  }
44
45
  static importJSON(serialized) {
45
46
  const text = serialized.text;
@@ -80,7 +81,7 @@ export class ShipQLLeafNode extends TextNode {
80
81
  if (this.__displayText) {
81
82
  element.textContent = this.__displayText;
82
83
  }
83
- const valid = isValidLeafText(this.__text);
84
+ const valid = this._isLeafValid();
84
85
  for (const cls of LEAF_BASE_CLASSES.split(' '))element.classList.add(cls);
85
86
  for (const cls of (valid ? LEAF_NORMAL_CLASSES : LEAF_ERROR_CLASSES).split(' '))element.classList.add(cls);
86
87
  element.setAttribute('data-shipql-leaf', 'true');
@@ -89,9 +90,9 @@ export class ShipQLLeafNode extends TextNode {
89
90
  }
90
91
  updateDOM(prevNode, dom, config) {
91
92
  const result = super.updateDOM(prevNode, dom, config);
92
- if (prevNode.__text !== this.__text) {
93
- const prevValid = isValidLeafText(prevNode.__text);
94
- const nextValid = isValidLeafText(this.__text);
93
+ if (prevNode.__text !== this.__text || prevNode.__freeTextError !== this.__freeTextError) {
94
+ const prevValid = prevNode._isLeafValid();
95
+ const nextValid = this._isLeafValid();
95
96
  if (prevValid !== nextValid) {
96
97
  for (const cls of (prevValid ? LEAF_NORMAL_CLASSES : LEAF_ERROR_CLASSES).split(' '))dom.classList.remove(cls);
97
98
  for (const cls of (nextValid ? LEAF_NORMAL_CLASSES : LEAF_ERROR_CLASSES).split(' '))dom.classList.add(cls);
@@ -99,6 +100,11 @@ export class ShipQLLeafNode extends TextNode {
99
100
  }
100
101
  return result;
101
102
  }
103
+ _isLeafValid() {
104
+ if (!isValidLeafText(this.__text)) return false;
105
+ if (this.__freeTextError) return false;
106
+ return true;
107
+ }
102
108
  isSimpleText() {
103
109
  return false;
104
110
  }
@@ -115,8 +121,12 @@ export class ShipQLLeafNode extends TextNode {
115
121
  export function $isShipQLLeafNode(node) {
116
122
  return node instanceof ShipQLLeafNode;
117
123
  }
118
- export function $createShipQLLeafNode(text, shipqlNode, displayText) {
119
- return new ShipQLLeafNode(text, shipqlNode, undefined, displayText);
124
+ export function $createShipQLLeafNode(text, shipqlNode, displayText, freeTextError = false) {
125
+ return new ShipQLLeafNode(text, shipqlNode, undefined, displayText, freeTextError);
126
+ }
127
+ export function $setLeafFreeTextError(node, value) {
128
+ const writable = node.getWritable();
129
+ writable.__freeTextError = value;
120
130
  }
121
131
  /** Returns true if the AST node qualifies as a visual leaf chip in the editor. */ export function isAstLeafNode(ast) {
122
132
  return isSimpleLeaf(ast) || isNotLeaf(ast) || isGroupedCompound(ast);
@@ -4,7 +4,8 @@ export declare const REMOVE_LEAF_COMMAND: import("lexical").LexicalCommand<strin
4
4
  interface ShipQLPluginProps {
5
5
  onLeafFocus?: (node: LeafAstNode | null) => void;
6
6
  formatLeafDisplay?: (source: string, node: LeafAstNode) => string;
7
+ allowFreeText?: boolean;
7
8
  }
8
- export declare function ShipQLPlugin({ onLeafFocus, formatLeafDisplay }: ShipQLPluginProps): null;
9
+ export declare function ShipQLPlugin({ onLeafFocus, formatLeafDisplay, allowFreeText, }: ShipQLPluginProps): null;
9
10
  export {};
10
11
  //# sourceMappingURL=shipql-plugin.d.ts.map
@@ -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
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;
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);
27
+ const [suggestionsSuppressed, setSuggestionsSuppressed] = useState(false);
26
28
  const [selectedIndex, setSelectedIndex] = useState(-1);
27
29
  const [items, setItems] = useState([]);
28
30
  const [focusedLeafNode, setFocusedLeafNode] = useState(null);
29
31
  const [isNegated, setIsNegated] = useState(false);
30
32
  const [showSyntaxHelp, setShowSyntaxHelp] = useState(false);
33
+ const [isTextModeBlurError, setIsTextModeBlurError] = useState(false);
31
34
  const isSelectingRef = useRef(false);
32
35
  const applyRef = useRef(null);
33
36
  const negationPrefixRef = useRef('');
37
+ const containerRef = useRef(null);
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,44 @@ export default function ShipQLEditorInner({ onChange, onLeafFocus, placeholder,
56
60
  const handleToggleSyntaxHelp = useCallback(()=>{
57
61
  setShowSyntaxHelp((prev)=>!prev);
58
62
  }, []);
63
+ const handleDismissSuggestions = useCallback(()=>{
64
+ setSuggestionsSuppressed(true);
65
+ setSuggestionsOpen(false);
66
+ }, []);
67
+ const handleSuggestionsOpenChange = useCallback((open)=>{
68
+ if (open && suggestionsSuppressed) return;
69
+ setSuggestionsOpen(open);
70
+ }, [
71
+ suggestionsSuppressed
72
+ ]);
73
+ const handleEditorMouseDownCapture = useCallback(()=>{
74
+ setSuggestionsSuppressed(false);
75
+ }, []);
76
+ useEffect(()=>{
77
+ if (!suggestionsOpen) return;
78
+ const handleMouseDown = (event)=>{
79
+ if (isSelectingRef.current) return;
80
+ const target = event.target;
81
+ if (target && containerRef.current?.contains(target)) return;
82
+ handleDismissSuggestions();
83
+ const activeElement = document.activeElement;
84
+ if (activeElement && containerRef.current?.contains(activeElement)) {
85
+ activeElement.blur();
86
+ }
87
+ };
88
+ document.addEventListener('mousedown', handleMouseDown, true);
89
+ return ()=>{
90
+ document.removeEventListener('mousedown', handleMouseDown, true);
91
+ };
92
+ }, [
93
+ suggestionsOpen,
94
+ handleDismissSuggestions
95
+ ]);
59
96
  return /*#__PURE__*/ _jsxs("div", {
97
+ ref: containerRef,
60
98
  "data-shipql-editor": true,
61
99
  className: cn('relative', className),
100
+ onMouseDownCapture: handleEditorMouseDownCapture,
62
101
  children: [
63
102
  mode === 'editor' ? /*#__PURE__*/ _jsxs(Popover, {
64
103
  open: hasSuggestions && suggestionsOpen,
@@ -108,10 +147,12 @@ export default function ShipQLEditorInner({ onChange, onLeafFocus, placeholder,
108
147
  }),
109
148
  /*#__PURE__*/ _jsx(ShipQLPlugin, {
110
149
  onLeafFocus: handleLeafFocus,
111
- formatLeafDisplay: formatLeafDisplay
150
+ formatLeafDisplay: formatLeafDisplay,
151
+ allowFreeText: allowFreeText
112
152
  }),
113
153
  /*#__PURE__*/ _jsx(OnBlurPlugin, {
114
- onChange: onChange
154
+ onChange: onChange,
155
+ allowFreeText: allowFreeText
115
156
  }),
116
157
  /*#__PURE__*/ _jsx(OnTextChangePlugin, {
117
158
  onTextChange: onTextChange
@@ -125,7 +166,7 @@ export default function ShipQLEditorInner({ onChange, onLeafFocus, placeholder,
125
166
  valueSuggestions: valueSuggestions ?? [],
126
167
  isLoadingValueSuggestions: isLoadingValueSuggestions ?? false,
127
168
  open: suggestionsOpen,
128
- setOpen: setSuggestionsOpen,
169
+ setOpen: handleSuggestionsOpenChange,
129
170
  selectedIndex: selectedIndex,
130
171
  setSelectedIndex: setSelectedIndex,
131
172
  items: items,
@@ -144,6 +185,7 @@ export default function ShipQLEditorInner({ onChange, onLeafFocus, placeholder,
144
185
  selectedIndex: selectedIndex,
145
186
  isSelectingRef: isSelectingRef,
146
187
  onSelect: handleSelect,
188
+ onClose: handleDismissSuggestions,
147
189
  isLoading: isLoadingValueSuggestions,
148
190
  isNegated: isNegated,
149
191
  onToggleNegate: handleToggleNegate,
@@ -163,10 +205,11 @@ export default function ShipQLEditorInner({ onChange, onLeafFocus, placeholder,
163
205
  className: "shrink-0 text-foreground-neutral-muted"
164
206
  }),
165
207
  className: cn(INPUT_CLASSES, disabled && 'pointer-events-none opacity-50'),
166
- "aria-invalid": isError,
208
+ "aria-invalid": isError || isTextModeBlurError,
167
209
  value: text,
168
210
  onChange: (e)=>{
169
211
  const newText = e.target.value;
212
+ setIsTextModeBlurError(false);
170
213
  onTextChange(newText);
171
214
  const facetNames = facets ? normalizeFacets(facets) : [];
172
215
  const facetCtx = detectFacetContext(newText, facetNames);
@@ -178,8 +221,11 @@ export default function ShipQLEditorInner({ onChange, onLeafFocus, placeholder,
178
221
  },
179
222
  onBlur: (e)=>{
180
223
  const ast = tryParse(e.target.value);
181
- if (ast) onChange?.(ast);
224
+ const hasDeferredFreeTextError = Boolean(ast && !allowFreeText && hasTextNodes(ast));
225
+ setIsTextModeBlurError(hasDeferredFreeTextError);
226
+ if (ast && !hasDeferredFreeTextError) onChange?.(ast);
182
227
  },
228
+ onFocus: ()=>setIsTextModeBlurError(false),
183
229
  placeholder: placeholder,
184
230
  disabled: disabled
185
231
  }),