@qoretechnologies/reqraft 0.10.27-pr.74.gc5f6ca3 → 0.10.27

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 (64) hide show
  1. package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
  2. package/dist/components/form/engine/CompactRow.js +8 -1
  3. package/dist/components/form/engine/CompactRow.js.map +1 -1
  4. package/dist/components/searchFilterBar/SearchFilterBar.d.ts +33 -0
  5. package/dist/components/searchFilterBar/SearchFilterBar.d.ts.map +1 -0
  6. package/dist/components/searchFilterBar/SearchFilterBar.js +76 -0
  7. package/dist/components/searchFilterBar/SearchFilterBar.js.map +1 -0
  8. package/dist/components/searchFilterBar/index.d.ts +2 -0
  9. package/dist/components/searchFilterBar/index.d.ts.map +1 -0
  10. package/dist/components/searchFilterBar/index.js +18 -0
  11. package/dist/components/searchFilterBar/index.js.map +1 -0
  12. package/dist/components/supportTicket/InterfaceReferenceTags.d.ts +9 -1
  13. package/dist/components/supportTicket/InterfaceReferenceTags.d.ts.map +1 -1
  14. package/dist/components/supportTicket/InterfaceReferenceTags.js +2 -2
  15. package/dist/components/supportTicket/InterfaceReferenceTags.js.map +1 -1
  16. package/dist/components/supportTicket/ReferencePicker.d.ts +52 -6
  17. package/dist/components/supportTicket/ReferencePicker.d.ts.map +1 -1
  18. package/dist/components/supportTicket/ReferencePicker.js +240 -31
  19. package/dist/components/supportTicket/ReferencePicker.js.map +1 -1
  20. package/dist/components/supportTicket/attachmentAccess.d.ts +37 -0
  21. package/dist/components/supportTicket/attachmentAccess.d.ts.map +1 -0
  22. package/dist/components/supportTicket/attachmentAccess.js +132 -0
  23. package/dist/components/supportTicket/attachmentAccess.js.map +1 -0
  24. package/dist/components/supportTicket/index.d.ts +2 -0
  25. package/dist/components/supportTicket/index.d.ts.map +1 -1
  26. package/dist/components/supportTicket/index.js +2 -0
  27. package/dist/components/supportTicket/index.js.map +1 -1
  28. package/dist/components/supportTicket/meta.d.ts +8 -0
  29. package/dist/components/supportTicket/meta.d.ts.map +1 -1
  30. package/dist/components/supportTicket/meta.js +37 -1
  31. package/dist/components/supportTicket/meta.js.map +1 -1
  32. package/dist/components/supportTicket/ticketModel.d.ts +102 -0
  33. package/dist/components/supportTicket/ticketModel.d.ts.map +1 -0
  34. package/dist/components/supportTicket/ticketModel.js +3 -0
  35. package/dist/components/supportTicket/ticketModel.js.map +1 -0
  36. package/dist/components/ticketReferences/TicketReferences.d.ts.map +1 -1
  37. package/dist/components/ticketReferences/TicketReferences.js +109 -78
  38. package/dist/components/ticketReferences/TicketReferences.js.map +1 -1
  39. package/dist/components/ticketThread/TicketThread.d.ts.map +1 -1
  40. package/dist/components/ticketThread/TicketThread.js +19 -2
  41. package/dist/components/ticketThread/TicketThread.js.map +1 -1
  42. package/dist/index.d.ts +3 -1
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +4 -1
  45. package/dist/index.js.map +1 -1
  46. package/package.json +1 -1
  47. package/src/components/form/engine/CompactRow.tsx +7 -0
  48. package/src/components/form/engine/FormEngine.stories.tsx +90 -0
  49. package/src/components/searchFilterBar/SearchFilterBar.tsx +131 -0
  50. package/src/components/searchFilterBar/index.ts +1 -0
  51. package/src/components/supportTicket/InterfaceReferenceTags.stories.tsx +37 -0
  52. package/src/components/supportTicket/InterfaceReferenceTags.tsx +11 -0
  53. package/src/components/supportTicket/ReferencePicker.stories.tsx +257 -34
  54. package/src/components/supportTicket/ReferencePicker.tsx +421 -95
  55. package/src/components/supportTicket/TicketConversation.stories.tsx +0 -1
  56. package/src/components/supportTicket/TicketReplyBox.stories.tsx +0 -4
  57. package/src/components/supportTicket/attachmentAccess.ts +97 -0
  58. package/src/components/supportTicket/index.ts +2 -0
  59. package/src/components/supportTicket/meta.ts +40 -0
  60. package/src/components/supportTicket/ticketModel.ts +131 -0
  61. package/src/components/ticketReferences/TicketReferences.tsx +124 -145
  62. package/src/components/ticketThread/TicketThread.stories.tsx +24 -2
  63. package/src/components/ticketThread/TicketThread.tsx +23 -3
  64. package/src/index.tsx +3 -1
@@ -1,24 +1,67 @@
1
- import { ReqoreInput, ReqoreMenu, ReqoreMenuItem, ReqoreSpinner } from '@qoretechnologies/reqore';
1
+ import {
2
+ ReqoreInput,
3
+ ReqoreMenu,
4
+ ReqoreMenuItem,
5
+ ReqoreModal,
6
+ ReqoreSpinner,
7
+ } from '@qoretechnologies/reqore';
8
+ import { TReqoreBadge } from '@qoretechnologies/reqore/dist/components/Button';
2
9
  import { IReqoreIconName } from '@qoretechnologies/reqore/dist/types/icons';
3
- import { ReactNode, useMemo, useState } from 'react';
10
+ import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
4
11
  import styled from 'styled-components';
5
12
  import { thinScrollbar } from '../../helpers/scrollbar';
6
- import { defaultInterfaceIcon, IInterfaceReference } from './meta';
13
+ import {
14
+ defaultInterfaceIcon,
15
+ defaultInterfaceKindLabel,
16
+ IInterfaceReference,
17
+ } from './meta';
7
18
 
8
19
  /*
9
- * The two-pane interface browser: kinds down the left, the selected kind's interfaces
10
- * on the right, and one search field that scopes both. Sits above a ticket composer,
11
- * toggled by the composer's "Reference interfaces" action.
20
+ * The interface browser: kinds on one side, the selected kind's interfaces on the
21
+ * other, and one search field that scopes both. Sits above a ticket composer, toggled
22
+ * by the composer's "Reference interfaces" action.
12
23
  *
13
24
  * The host owns the data. Which kinds exist and what lives under one are questions only
14
25
  * the surrounding app can answer — the IDE lists them off the customer's live instance,
15
26
  * a story hands over fixtures — so this component takes `kinds` and the selected kind's
16
27
  * `items` and does the rest: selection, search, and turning a click into a reference.
28
+ * Nothing here fetches; `kind` is the only contract, and the host maps it onto whatever
29
+ * transport it has.
30
+ *
31
+ * Three rules the layout follows, in order of how often they bite:
32
+ *
33
+ * - Search filters the kind list down to what matches, and keeps ONE extra row: the
34
+ * selected kind, dimmed, when the query doesn't match it. That row is what stops the
35
+ * right-hand results outliving the kind they belong to — without it you'd see three
36
+ * interfaces and no way to tell what they were. Everything else goes, because a match
37
+ * that survives only by scrolling past nine dimmed rows isn't a match the user found.
38
+ * - A picked interface STAYS in the list, selected. Dropping it on pick was a
39
+ * disappearing act with no undo inside the picker; now the row toggles.
40
+ * - Below `STACK_BREAKPOINT` of its OWN width — not the viewport's, since this thing
41
+ * lives inside composers and drawers — the two panes collapse into one column and the
42
+ * selected kind's interfaces render directly beneath it. On a phone it goes further
43
+ * and lifts into a modal, because inline it would own the whole screen.
17
44
  */
18
45
 
19
- /** How many interfaces the right pane shows before it scrolls. */
20
- const PANE_MAX_HEIGHT = 180;
46
+ /** How tall the panes get before they scroll: side by side, stacked, and in the modal. */
47
+ const COLUMNS_MAX_HEIGHT = '220px';
48
+ const STACKED_MAX_HEIGHT = '280px';
49
+ const MODAL_MAX_HEIGHT = '60vh';
21
50
  const KIND_PANE_WIDTH = 150;
51
+ /** Container width (not viewport) below which the panes stack into one column. */
52
+ const STACK_BREAKPOINT = 420;
53
+ const BORDER = 'rgba(123, 104, 238, 0.25)';
54
+ /*
55
+ * Marks the selected kind when the search doesn't match it — it's only still on the
56
+ * list to say what the interfaces on the right are. Quiet, but pickable.
57
+ */
58
+ const DIMMED_CLASS = 'reqore-reference-picker-dimmed';
59
+ /*
60
+ * Marks an already-referenced row. Reqore paints `selected` purely through styled
61
+ * props — no class, no aria state — so "is this row picked" is unobservable from the
62
+ * outside without a marker of our own. Stable, so a host can style on it too.
63
+ */
64
+ const PICKED_CLASS = 'reqore-reference-picker-picked';
22
65
 
23
66
  /*
24
67
  * A scroll pane with the thin, unobtrusive scrollbar the Qonsole surfaces use
@@ -26,22 +69,48 @@ const KIND_PANE_WIDTH = 150;
26
69
  * webkit-only, like those surfaces: setting `scrollbar-width` as well would let
27
70
  * Chromium override this custom 6px with its own, wider, "thin".
28
71
  */
29
- const Pane = styled.div<{ $right?: boolean }>`
72
+ const Pane = styled.div<{ $right?: boolean; $maxHeight: string }>`
30
73
  ${({ $right }) =>
31
74
  $right
32
75
  ? 'flex: 1; min-width: 0;'
33
- : `width: ${KIND_PANE_WIDTH}px; flex: none; border-right: 1px solid rgba(123, 104, 238, 0.25);`}
34
- max-height: ${PANE_MAX_HEIGHT}px;
76
+ : `width: ${KIND_PANE_WIDTH}px; flex: none; border-right: 1px solid ${BORDER};`}
77
+ max-height: ${({ $maxHeight }) => $maxHeight};
78
+ overflow-y: auto;
79
+ padding: 4px;
80
+
81
+ ${thinScrollbar}
82
+ `;
83
+
84
+ /** The stacked counterpart of `Pane` — one column, so one scroll region. */
85
+ const Stack = styled.div<{ $maxHeight: string }>`
86
+ max-height: ${({ $maxHeight }) => $maxHeight};
35
87
  overflow-y: auto;
36
88
  padding: 4px;
37
89
 
38
90
  ${thinScrollbar}
39
91
  `;
40
92
 
41
- const Frame = styled.div`
42
- border: 1px solid rgba(123, 104, 238, 0.25);
43
- border-radius: 8px;
93
+ /*
94
+ * The selected kind's interfaces once the panes have collapsed. The indent and the
95
+ * rule are the only things left saying "these belong to the row above" — in the
96
+ * two-pane layout the divider between the panes says it.
97
+ */
98
+ const Nested = styled.div`
99
+ margin: 2px 0 4px 10px;
100
+ padding-left: 8px;
101
+ border-left: 1px solid ${BORDER};
102
+ `;
103
+
104
+ const Frame = styled.div<{ $bare?: boolean }>`
105
+ ${({ $bare }) => ($bare ? '' : `border: 1px solid ${BORDER}; border-radius: 8px;`)}
44
106
  overflow: hidden;
107
+
108
+ /* Rows the search doesn't match. Set here rather than per row so the rule is
109
+ stated once, and on the row itself (not a wrapper) so it can't disturb the
110
+ menu's own flex layout. */
111
+ .${DIMMED_CLASS} {
112
+ opacity: 0.35;
113
+ }
45
114
  `;
46
115
 
47
116
  const Panes = styled.div`
@@ -55,10 +124,15 @@ const Panes = styled.div`
55
124
  * around the whole control. Suppressing that outline and brightening the underline
56
125
  * instead keeps a focus cue without the box. The selector is scoped under this wrapper,
57
126
  * so it outranks Reqore's without `!important`.
127
+ *
128
+ * `$active` (the field has a query) holds the underline at full accent, so a filtered
129
+ * list is legible as filtered at a glance — the same signal `intent='info'` carries on
130
+ * a plain filter control, spoken in this surface's own accent.
58
131
  */
59
- const Search = styled.div<{ $accent: string }>`
132
+ const Search = styled.div<{ $accent: string; $active?: boolean }>`
60
133
  padding: 6px 4px 4px;
61
- border-bottom: 1px solid ${({ $accent }) => `${$accent}80`};
134
+ border-bottom: 1px solid
135
+ ${({ $accent, $active }) => ($active ? $accent : `${$accent}80`)};
62
136
  transition: border-color 0.15s ease-out;
63
137
 
64
138
  .reqore-control-wrapper:focus-within {
@@ -77,6 +151,49 @@ const Note = styled.div`
77
151
 
78
152
  const DEFAULT_ACCENT = '#762f7e';
79
153
 
154
+ /*
155
+ * How a picked row reads: `info`, tinted rather than filled.
156
+ *
157
+ * Two Reqore details make this less obvious than `selected` + `intent`:
158
+ *
159
+ * - `selected` becomes `active` on the button, and the active branch paints a solid
160
+ * background from the intent while ignoring `minimal` and `transparent` outright.
161
+ * So `selected` can't be softened — it has to go, and `minimal` carries the state.
162
+ * - `ReqoreMenuItem` sets `transparent` on every row, and `transparent` short-circuits
163
+ * `minimal`'s tint to nothing. Turning it off for picked rows is what lets the tint
164
+ * land, and it's only spread when the row IS picked — a `transparent: undefined` on
165
+ * the others would override the menu item's own default and flatten them all.
166
+ *
167
+ * The point of the tint: a solid blue bar is too much weight for "this one's already
168
+ * in your list". The rows still up for choosing are what the user is reading, and they
169
+ * should stay the loudest thing in the pane.
170
+ */
171
+ const pickedItemProps = (isPicked: boolean) =>
172
+ isPicked ? ({ intent: 'info', minimal: true, transparent: false } as const) : {};
173
+
174
+ /**
175
+ * One interface in the list. A bare string is the name and nothing else — the common
176
+ * case, and what a host that has only names can keep passing. The object form adds the
177
+ * two things a name alone can't disambiguate: a `description` second line (what the
178
+ * thing is for, an owning group, a path) and a `badge` (a version, a run count, a
179
+ * status tag). Search reads both the name and the description, since both are on screen.
180
+ */
181
+ export interface IReferencePickerItem {
182
+ /** the interface name — this is what gets stored on the reference */
183
+ name: string;
184
+ /** a muted second line under the name */
185
+ description?: string;
186
+ /** trailing badge, before the add/remove icon */
187
+ badge?: TReqoreBadge | TReqoreBadge[];
188
+ }
189
+
190
+ export type TReferencePickerItem = string | IReferencePickerItem;
191
+
192
+ /** `auto` picks `stacked` from the picker's own measured width. */
193
+ export type TReferencePickerLayout = 'auto' | 'columns' | 'stacked';
194
+ /** `auto` lifts into a modal on a phone-sized viewport, when `onClose` is given. */
195
+ export type TReferencePickerPresentation = 'auto' | 'inline' | 'modal';
196
+
80
197
  export interface IReferencePickerProps {
81
198
  /** the interface kinds listed down the left pane */
82
199
  kinds: string[];
@@ -84,10 +201,11 @@ export interface IReferencePickerProps {
84
201
  kind: string;
85
202
  onKindChange: (kind: string) => void;
86
203
  /**
87
- * The selected kind's interfaces, by name. Fetching is the host's job — it knows
88
- * where they come from and how to cache them.
204
+ * The selected kind's interfaces — bare names, or objects carrying a description
205
+ * and a badge. Fetching is the host's job; it knows where they come from and how
206
+ * to cache them.
89
207
  */
90
- items: string[];
208
+ items: TReferencePickerItem[];
91
209
  /** `items` is still being fetched, so the right pane shows a spinner rather than
92
210
  * claiming the kind is empty */
93
211
  loading?: boolean;
@@ -99,21 +217,95 @@ export interface IReferencePickerProps {
99
217
  error?: string;
100
218
  /** Retry the failed fetch. The error state is pressable when given. */
101
219
  onRetry?: () => void;
102
- /** Already-picked references. Anything of the current kind that is already picked is
103
- * dropped from the right pane, so a reference can't be added twice. */
220
+ /** Already-picked references. Anything of the current kind that is already picked
221
+ * renders selected in place — never removed from the list. */
104
222
  picked?: IInterfaceReference[];
105
223
  onAdd: (reference: IInterfaceReference) => void;
224
+ /** Un-pick a reference from inside the picker. Without it a picked row still shows
225
+ * as picked, it just can't be toggled back off here. */
226
+ onRemove?: (reference: IInterfaceReference) => void;
106
227
  /** the whole picker is inert (e.g. a reply is in flight) */
107
228
  disabled?: boolean;
108
229
  /** Per-kind icon. Consumers with their own icon vocabulary (the IDE) pass one; the
109
230
  * built-in per-kind default is used when omitted. */
110
231
  resolveInterfaceIcon?: (kind: string) => IReqoreIconName;
232
+ /**
233
+ * Per-kind reading name — "Value maps" for `value-map`. The kind id is a wire value
234
+ * and never reaches the user; this is what does. Search matches both, so typing
235
+ * either the id or the label finds the kind. Defaults to the built-in Qorus
236
+ * vocabulary.
237
+ */
238
+ resolveKindLabel?: (kind: string) => string;
111
239
  /** the underline / selected-kind colour. Defaults to the support surfaces' violet. */
112
240
  accent?: string;
113
241
  /** shown above the panes — the composer's picked-reference chips, typically */
114
242
  header?: ReactNode;
243
+ /**
244
+ * Dismiss the picker. Required for the modal presentation (a modal with no way out
245
+ * is a trap); the inline presentation ignores it, since the host's own toggle is
246
+ * what closes the picker there.
247
+ */
248
+ onClose?: () => void;
249
+ /** Modal heading. */
250
+ title?: string;
251
+ /** How the panes arrange. `auto` (default) stacks below `STACK_BREAKPOINT` of the
252
+ * picker's own width; the explicit values are what stories pin. */
253
+ layout?: TReferencePickerLayout;
254
+ /** Where the picker renders. `auto` (default) goes to a modal on a phone-sized
255
+ * viewport when `onClose` is given, and stays inline otherwise. */
256
+ presentation?: TReferencePickerPresentation;
115
257
  }
116
258
 
259
+ /**
260
+ * Watches an element's own width, because the viewport's is the wrong question for
261
+ * anything that lives inside a composer, a drawer or a side panel — those stay narrow
262
+ * on a 2560px screen. A callback ref rather than `useRef` so the observer attaches the
263
+ * moment the node mounts; a ref that never gets attached silently reports "wide"
264
+ * forever, which is exactly the bug this is meant to catch.
265
+ */
266
+ const useIsNarrow = (enabled: boolean): [(node: HTMLDivElement | null) => void, boolean] => {
267
+ const [node, setNode] = useState<HTMLDivElement | null>(null);
268
+ const [narrow, setNarrow] = useState<boolean>(false);
269
+
270
+ useEffect(() => {
271
+ if (!enabled || !node || typeof ResizeObserver === 'undefined') {
272
+ return undefined;
273
+ }
274
+
275
+ const observer = new ResizeObserver(([entry]) => {
276
+ setNarrow(entry.contentRect.width < STACK_BREAKPOINT);
277
+ });
278
+
279
+ observer.observe(node);
280
+
281
+ return () => observer.disconnect();
282
+ }, [enabled, node]);
283
+
284
+ return [setNode, narrow];
285
+ };
286
+
287
+ /** Phone-sized viewport. Its own media query rather than reqore's `isMobile`, which
288
+ * is pinned to `false` under NODE_ENV=test and so never fires in a story run. */
289
+ const usePhoneViewport = (enabled: boolean): boolean => {
290
+ const [phone, setPhone] = useState<boolean>(false);
291
+
292
+ useEffect(() => {
293
+ if (!enabled || typeof window === 'undefined' || !window.matchMedia) {
294
+ return undefined;
295
+ }
296
+
297
+ const query = window.matchMedia('(max-width: 480px)');
298
+ const sync = () => setPhone(query.matches);
299
+
300
+ sync();
301
+ query.addEventListener('change', sync);
302
+
303
+ return () => query.removeEventListener('change', sync);
304
+ }, [enabled]);
305
+
306
+ return phone;
307
+ };
308
+
117
309
  export const ReferencePicker = ({
118
310
  kinds,
119
311
  kind,
@@ -124,45 +316,205 @@ export const ReferencePicker = ({
124
316
  onRetry,
125
317
  picked,
126
318
  onAdd,
319
+ onRemove,
127
320
  disabled,
128
321
  resolveInterfaceIcon = defaultInterfaceIcon,
322
+ resolveKindLabel = defaultInterfaceKindLabel,
129
323
  accent = DEFAULT_ACCENT,
130
324
  header,
325
+ onClose,
326
+ title = 'Reference interfaces',
327
+ layout = 'auto',
328
+ presentation = 'auto',
131
329
  }: IReferencePickerProps) => {
132
330
  const [query, setQuery] = useState<string>('');
133
331
  const needle = query.trim().toLowerCase();
134
332
 
333
+ const [frameRef, narrow] = useIsNarrow(layout === 'auto');
334
+ const phone = usePhoneViewport(presentation === 'auto');
335
+
336
+ const asModal = presentation === 'modal' || (presentation === 'auto' && phone && !!onClose);
337
+ const stacked = layout === 'stacked' || (layout === 'auto' && narrow);
338
+ const label = resolveKindLabel(kind);
339
+
340
+ /* A kind matches on its id or its reading name, so "value map" and `value-map`
341
+ * both find it. */
342
+ const matchesKind = useCallback(
343
+ (candidate: string) =>
344
+ !needle ||
345
+ candidate.toLowerCase().includes(needle) ||
346
+ resolveKindLabel(candidate).toLowerCase().includes(needle),
347
+ [needle, resolveKindLabel]
348
+ );
349
+
350
+ /* The matches, plus the selected kind whether or not it matched — it keeps its
351
+ * natural position rather than jumping to the top, so the list a user is reading
352
+ * doesn't reorder under them mid-keystroke. */
135
353
  const visibleKinds = useMemo(
136
- () => (needle ? kinds.filter((k) => k.toLowerCase().includes(needle)) : kinds),
137
- [kinds, needle]
354
+ () => kinds.filter((candidate) => candidate === kind || matchesKind(candidate)),
355
+ [kinds, kind, matchesKind]
356
+ );
357
+
358
+ const pickedNames = useMemo(
359
+ () =>
360
+ new Set(
361
+ (picked ?? [])
362
+ .filter((reference) => reference.interface_kind === kind)
363
+ .map((reference) => reference.interface_name)
364
+ ),
365
+ [picked, kind]
366
+ );
367
+
368
+ /** One shape from here down, whichever of the two the host passed. */
369
+ const normalizedItems = useMemo(
370
+ () => items.map((item) => (typeof item === 'string' ? { name: item } : item)),
371
+ [items]
138
372
  );
139
373
 
140
374
  /*
141
- * The right pane drops what's already been picked and then applies the search. When
142
- * the KIND itself matches the search ("workflow"), its interfaces all stay — you
143
- * searched for the kind, so you want to see what's in it.
375
+ * The interfaces the search leaves standing. When the KIND itself matches
376
+ * ("workflow"), its interfaces all stay — you searched for the kind, so you want to
377
+ * see what's in it. Otherwise the name and the description are both fair game,
378
+ * because both are text the user can see.
144
379
  */
145
380
  const visibleItems = useMemo(() => {
146
- const already = new Set(
147
- (picked ?? [])
148
- .filter((reference) => reference.interface_kind === kind)
149
- .map((reference) => reference.interface_name)
381
+ const kindMatches = matchesKind(kind);
382
+
383
+ return normalizedItems.filter(
384
+ (item) =>
385
+ !needle ||
386
+ kindMatches ||
387
+ item.name.toLowerCase().includes(needle) ||
388
+ !!item.description?.toLowerCase().includes(needle)
150
389
  );
151
- const kindMatches = kind.toLowerCase().includes(needle);
152
- return items.filter(
153
- (name) =>
154
- !already.has(name) && (!needle || kindMatches || name.toLowerCase().includes(needle))
390
+ }, [normalizedItems, kind, needle, matchesKind]);
391
+
392
+ /* Drives the "…but other types do" hint: with the kind list now always on screen,
393
+ * an empty result is only puzzling when the answer is sitting in another row. */
394
+ const otherKindsMatch = useMemo(
395
+ () => !!needle && kinds.some((candidate) => candidate !== kind && matchesKind(candidate)),
396
+ [kinds, kind, needle, matchesKind]
397
+ );
398
+
399
+ const toggle = (name: string) => {
400
+ const reference: IInterfaceReference = { interface_kind: kind, interface_name: name };
401
+
402
+ if (pickedNames.has(name)) {
403
+ onRemove?.(reference);
404
+ } else {
405
+ onAdd(reference);
406
+ }
407
+ };
408
+
409
+ const emptyNote = () => {
410
+ if (!needle) {
411
+ return `No ${label.toLowerCase()} on this instance`;
412
+ }
413
+
414
+ return otherKindsMatch
415
+ ? `No ${label.toLowerCase()} match “${query.trim()}” — other types do`
416
+ : `Nothing matches “${query.trim()}”`;
417
+ };
418
+
419
+ /* Both panes are `ReqoreMenu`s of `ReqoreMenuItem`s — a vertical selectable list is
420
+ exactly what Menu is, so the selection (`selected`), the left-icon + right-icon,
421
+ and the item chrome come from reqore rather than hand-styled buttons. The wrappers
422
+ still own the scroll and the layout; the menus render transparent inside them.
423
+ Only menu items go inside a `ReqoreMenu`: it clones every child with its own props,
424
+ so a spinner or a note nested in one would be handed props it can't take. */
425
+ const kindItems = visibleKinds.map((option) => (
426
+ <ReqoreMenuItem
427
+ key={option}
428
+ className={matchesKind(option) ? undefined : DIMMED_CLASS}
429
+ icon={resolveInterfaceIcon(option)}
430
+ label={resolveKindLabel(option)}
431
+ selected={option === kind}
432
+ disabled={disabled}
433
+ onClick={() => onKindChange(option)}
434
+ />
435
+ ));
436
+
437
+ const interfaces = (() => {
438
+ if (error) {
439
+ return (
440
+ <ReqoreMenu transparent flat padded={false} width='100%' size='small'>
441
+ <ReqoreMenuItem
442
+ icon='ErrorWarningLine'
443
+ rightIcon={onRetry ? 'RefreshLine' : undefined}
444
+ intent='danger'
445
+ label={`Couldn't load ${label.toLowerCase()}`}
446
+ /* the specific failure is the tooltip — the pane states the outcome */
447
+ tooltip={error}
448
+ onClick={onRetry}
449
+ />
450
+ </ReqoreMenu>
451
+ );
452
+ }
453
+
454
+ if (loading) {
455
+ return (
456
+ <ReqoreSpinner iconColor='info' size='small' centered>
457
+ Loading {label.toLowerCase()}…
458
+ </ReqoreSpinner>
459
+ );
460
+ }
461
+
462
+ if (!visibleItems.length) {
463
+ return <Note>{emptyNote()}</Note>;
464
+ }
465
+
466
+ return (
467
+ <ReqoreMenu transparent flat padded={false} width='100%' size='small'>
468
+ {visibleItems.map(({ name, description, badge }) => {
469
+ const isPicked = pickedNames.has(name);
470
+ const removable = isPicked && !!onRemove;
471
+
472
+ return (
473
+ <ReqoreMenuItem
474
+ key={name}
475
+ className={isPicked ? PICKED_CLASS : undefined}
476
+ icon={resolveInterfaceIcon(kind)}
477
+ rightIcon={isPicked ? 'CheckLine' : 'AddLine'}
478
+ label={name}
479
+ description={description}
480
+ badge={badge}
481
+ disabled={disabled}
482
+ tooltip={
483
+ isPicked
484
+ ? removable
485
+ ? `Remove “${name}”`
486
+ : `“${name}” is already referenced`
487
+ : `Reference “${name}”`
488
+ }
489
+ onClick={!isPicked || removable ? () => toggle(name) : undefined}
490
+ {...pickedItemProps(isPicked)}
491
+ />
492
+ );
493
+ })}
494
+ </ReqoreMenu>
155
495
  );
156
- }, [items, picked, kind, needle]);
496
+ })();
157
497
 
158
- return (
159
- <Frame>
498
+ const maxHeight = asModal
499
+ ? MODAL_MAX_HEIGHT
500
+ : stacked
501
+ ? STACKED_MAX_HEIGHT
502
+ : COLUMNS_MAX_HEIGHT;
503
+
504
+ /* Stacked: one column, with the selected kind's interfaces spliced in directly
505
+ under its row. A kind the host hasn't listed puts them at the end rather than
506
+ dropping them. */
507
+ const selectedIndex = visibleKinds.indexOf(kind);
508
+ const splitAt = selectedIndex === -1 ? visibleKinds.length : selectedIndex + 1;
509
+
510
+ const body = (
511
+ <Frame ref={frameRef} $bare={asModal}>
160
512
  {header}
161
- <Search $accent={accent}>
513
+ <Search $accent={accent} $active={!!needle}>
162
514
  <ReqoreInput
163
515
  value={query}
164
516
  onChange={(event) => setQuery((event.target as HTMLInputElement).value)}
165
- placeholder='Search kinds and interfaces…'
517
+ placeholder='Search types and interfaces…'
166
518
  icon='SearchLine'
167
519
  minimal
168
520
  flat
@@ -172,66 +524,40 @@ export const ReferencePicker = ({
172
524
  onClearClick={query ? () => setQuery('') : undefined}
173
525
  />
174
526
  </Search>
175
- <Panes>
176
- {/* Both panes are `ReqoreMenu`s of `ReqoreMenuItem`s — a vertical selectable
177
- list is exactly what Menu is, so the selection (`selected`), the left-icon +
178
- right-icon, and the item chrome come from reqore rather than hand-styled
179
- buttons. The Pane wrapper still owns the scroll (fixed/flex width +
180
- thinScrollbar); the menus render transparent inside it. */}
181
- <Pane>
182
- {visibleKinds.length ? (
183
- <ReqoreMenu transparent flat padded={false} width='100%' size='small'>
184
- {visibleKinds.map((option) => (
185
- <ReqoreMenuItem
186
- key={option}
187
- icon={resolveInterfaceIcon(option)}
188
- label={option}
189
- selected={option === kind}
190
- disabled={disabled}
191
- onClick={() => onKindChange(option)}
192
- />
193
- ))}
194
- </ReqoreMenu>
195
- ) : (
196
- <Note>No kinds</Note>
197
- )}
198
- </Pane>
199
- <Pane $right>
200
- {error ? (
527
+ {stacked ? (
528
+ <Stack $maxHeight={maxHeight}>
529
+ <ReqoreMenu transparent flat padded={false} width='100%' size='small'>
530
+ {kindItems.slice(0, splitAt)}
531
+ </ReqoreMenu>
532
+ <Nested>{interfaces}</Nested>
533
+ {splitAt < kindItems.length ? (
201
534
  <ReqoreMenu transparent flat padded={false} width='100%' size='small'>
202
- <ReqoreMenuItem
203
- icon='ErrorWarningLine'
204
- rightIcon={onRetry ? 'RefreshLine' : undefined}
205
- intent='danger'
206
- label={`Couldn't load ${kind}s`}
207
- /* the specific failure is the tooltip — the pane states the outcome */
208
- tooltip={error}
209
- onClick={onRetry}
210
- />
535
+ {kindItems.slice(splitAt)}
211
536
  </ReqoreMenu>
212
- ) : loading ? (
213
- <ReqoreSpinner iconColor='info' size='small' centered>
214
- Loading {kind}s…
215
- </ReqoreSpinner>
216
- ) : visibleItems.length ? (
537
+ ) : null}
538
+ </Stack>
539
+ ) : (
540
+ <Panes>
541
+ <Pane $maxHeight={maxHeight}>
217
542
  <ReqoreMenu transparent flat padded={false} width='100%' size='small'>
218
- {visibleItems.map((name) => (
219
- <ReqoreMenuItem
220
- key={name}
221
- icon={resolveInterfaceIcon(kind)}
222
- rightIcon='AddLine'
223
- label={name}
224
- disabled={disabled}
225
- tooltip={`Reference ${kind} ${name}`}
226
- onClick={() => onAdd({ interface_kind: kind, interface_name: name })}
227
- />
228
- ))}
543
+ {kindItems}
229
544
  </ReqoreMenu>
230
- ) : (
231
- <Note>No matching {kind}</Note>
232
- )}
233
- </Pane>
234
- </Panes>
545
+ </Pane>
546
+ <Pane $right $maxHeight={maxHeight}>
547
+ {interfaces}
548
+ </Pane>
549
+ </Panes>
550
+ )}
235
551
  </Frame>
236
552
  );
553
+
554
+ if (!asModal) {
555
+ return body;
556
+ }
557
+
558
+ return (
559
+ <ReqoreModal isOpen icon='LinksLine' label={title} width='100%' blur={3} onClose={onClose}>
560
+ {body}
561
+ </ReqoreModal>
562
+ );
237
563
  };
@@ -53,7 +53,6 @@ const themed = (node: ReactNode) => (
53
53
 
54
54
  const refs = (
55
55
  <InterfaceReferenceTags
56
- size='tiny'
57
56
  customTheme={{ main: 'custom1' }}
58
57
  references={[{ interface_kind: 'connection', interface_name: 'sftp-partner' }]}
59
58
  />
@@ -156,7 +156,6 @@ export const RichHelpdeskReply: Story = {
156
156
  // "References" label, the chips read as the referenced interfaces on their own
157
157
  aboveInput: (
158
158
  <InterfaceReferenceTags
159
- size='tiny'
160
159
  references={[{ interface_kind: 'connection', interface_name: 'sftp-partner' }]}
161
160
  customTheme={{ main: 'custom1' }}
162
161
  />
@@ -203,7 +202,6 @@ export const AllReferenceTypes: Story = {
203
202
  defaultText: 'This one references every interface kind.',
204
203
  aboveInput: (
205
204
  <InterfaceReferenceTags
206
- size='tiny'
207
205
  customTheme={{ main: 'custom1' }}
208
206
  references={[
209
207
  { interface_kind: 'workflow', interface_name: 'order-sync:1.2' },
@@ -269,7 +267,6 @@ export const QogTriggerAppLogo: Story = {
269
267
  defaultText: 'These qogs are referenced by their trigger apps.',
270
268
  aboveInput: (
271
269
  <InterfaceReferenceTags
272
- size='tiny'
273
270
  customTheme={{ main: 'custom1' }}
274
271
  references={[
275
272
  // resolved live from trigger_app (the customer/IDE path)
@@ -337,7 +334,6 @@ export const WithScreenshotAndReferences: Story = {
337
334
  defaultFiles: [screenshotFile()],
338
335
  aboveInput: (
339
336
  <InterfaceReferenceTags
340
- size='tiny'
341
337
  customTheme={{ main: 'custom1' }}
342
338
  references={[
343
339
  { interface_kind: 'connection', interface_name: 'sftp-partner' },