@industream/flowmaker-flowbox-ui-components 1.1.1 → 1.2.2

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.
@@ -0,0 +1,197 @@
1
+ <script>
2
+ /**
3
+ * AssetTree - loads an asset dictionary as a tree and lets the user pick a node.
4
+ * Emits the selected node's own entryIds to the container, which fetches and
5
+ * filters the entries. This component holds no selection or entry state.
6
+ */
7
+ import { firstDictionaryId, collectEntryIds } from './picker-tree';
8
+
9
+ let {
10
+ client = null,
11
+ onSelectNode = null
12
+ } = $props();
13
+
14
+ let dictionaries = $state([]);
15
+ let activeDictionaryId = $state('');
16
+ let rootNodes = $state([]);
17
+ let expanded = $state(new Set());
18
+ let selectedNodeId = $state('');
19
+ let loading = $state(false);
20
+ let error = $state('');
21
+
22
+ /** Cancels in-flight dictionary/tree fetches so rapid switching can't render a stale tree. */
23
+ let abort = null;
24
+ function newAbort() {
25
+ abort?.abort();
26
+ abort = new AbortController();
27
+ return abort.signal;
28
+ }
29
+
30
+ /** Load the dictionary list when a client becomes available. */
31
+ $effect(() => {
32
+ if (client) loadDictionaries(client);
33
+ });
34
+
35
+ async function loadDictionaries(dc) {
36
+ const signal = newAbort();
37
+ loading = true;
38
+ error = '';
39
+ try {
40
+ const result = await dc.assetDictionaries.get({ asTree: true }, signal);
41
+ if (signal.aborted) return;
42
+ dictionaries = result.items ?? [];
43
+ const first = firstDictionaryId(dictionaries);
44
+ if (first) {
45
+ activeDictionaryId = first;
46
+ await loadTreeInner(dc, first, signal);
47
+ } else {
48
+ rootNodes = [];
49
+ }
50
+ } catch (err) {
51
+ if (signal.aborted) return;
52
+ error = err?.message ?? 'Failed to load asset dictionaries';
53
+ dictionaries = [];
54
+ rootNodes = [];
55
+ } finally {
56
+ if (!signal.aborted) loading = false;
57
+ }
58
+ }
59
+
60
+ async function loadTree(dc, id) {
61
+ await loadTreeInner(dc, id, newAbort());
62
+ }
63
+
64
+ async function loadTreeInner(dc, id, signal) {
65
+ loading = true;
66
+ error = '';
67
+ try {
68
+ const dict = await dc.assetDictionaries.getById(id, { asTree: true }, signal);
69
+ if (signal.aborted) return;
70
+ rootNodes = dict.nodes ?? [];
71
+ expanded = new Set(rootNodes.map(n => n.id));
72
+ } catch (err) {
73
+ if (signal.aborted) return;
74
+ error = err?.message ?? 'Failed to load the asset tree';
75
+ rootNodes = [];
76
+ } finally {
77
+ if (!signal.aborted) loading = false;
78
+ }
79
+ }
80
+
81
+ function onDictionaryChange(e) {
82
+ activeDictionaryId = e.target.value;
83
+ selectedNodeId = '';
84
+ if (client && activeDictionaryId) loadTree(client, activeDictionaryId);
85
+ }
86
+
87
+ function toggleExpand(id) {
88
+ const next = new Set(expanded);
89
+ next.has(id) ? next.delete(id) : next.add(id);
90
+ expanded = next;
91
+ }
92
+
93
+ function selectNode(node) {
94
+ selectedNodeId = node.id;
95
+ onSelectNode?.(collectEntryIds(node), node.id);
96
+ }
97
+
98
+ function retry() {
99
+ if (client) loadDictionaries(client);
100
+ }
101
+ </script>
102
+
103
+ <div class="asset-tree">
104
+ {#if dictionaries.length > 1}
105
+ <select class="dict-select" value={activeDictionaryId} onchange={onDictionaryChange}>
106
+ {#each dictionaries as dict (dict.id)}
107
+ <option value={dict.id}>{dict.name}</option>
108
+ {/each}
109
+ </select>
110
+ {/if}
111
+
112
+ {#if loading}
113
+ <div class="tree-state"><div class="spinner"></div> <span>Loading tree...</span></div>
114
+ {:else if error}
115
+ <div class="tree-state error">{error} - <button class="link-button" onclick={retry}>retry</button></div>
116
+ {:else if rootNodes.length === 0}
117
+ <div class="tree-state muted">No asset dictionary available.</div>
118
+ {:else}
119
+ <ul class="tree-root">
120
+ {#each rootNodes as node (node.id)}
121
+ {@render treeNode(node, 0)}
122
+ {/each}
123
+ </ul>
124
+ {/if}
125
+ </div>
126
+
127
+ {#snippet treeNode(node, depth)}
128
+ {@const hasChildren = node.children && node.children.length > 0}
129
+ {@const isOpen = expanded.has(node.id)}
130
+ <li class="tree-item">
131
+ <div
132
+ class="node-row"
133
+ class:selected={selectedNodeId === node.id}
134
+ style="padding-left: {depth * 1}rem"
135
+ >
136
+ {#if hasChildren}
137
+ <button type="button" class="twisty" class:open={isOpen} onclick={() => toggleExpand(node.id)} aria-label="Toggle">
138
+ <svg viewBox="0 0 16 16" width="12" height="12"><path d="M11 8L6 13V3z" fill="currentColor"></path></svg>
139
+ </button>
140
+ {:else}
141
+ <span class="twisty-spacer"></span>
142
+ {/if}
143
+ <button type="button" class="node-label" onclick={() => selectNode(node)}>
144
+ {node.name}
145
+ {#if node.entryIds && node.entryIds.length > 0}<span class="node-count">{node.entryIds.length}</span>{/if}
146
+ </button>
147
+ </div>
148
+ {#if hasChildren && isOpen}
149
+ <ul>
150
+ {#each node.children as child (child.id)}
151
+ {@render treeNode(child, depth + 1)}
152
+ {/each}
153
+ </ul>
154
+ {/if}
155
+ </li>
156
+ {/snippet}
157
+
158
+ <style>
159
+ .asset-tree { font-size: 0.8125rem; }
160
+ .dict-select {
161
+ width: 100%; margin-bottom: 0.5rem; padding: 0.375rem 0.5rem;
162
+ background: var(--cds-field-01, #353535); color: var(--cds-text-primary, #f4f4f4);
163
+ border: none; border-bottom: 1px solid var(--cds-border-strong-01, #6f6f6f); font-size: 0.8125rem;
164
+ }
165
+ .tree-state { display: flex; align-items: center; gap: 0.5rem; padding: 1rem; color: var(--cds-text-secondary, #c6c6c6); }
166
+ .tree-state.error { color: var(--cds-support-error, #fa4d56); }
167
+ .tree-state.muted { color: var(--cds-text-helper, #8d8d8d); }
168
+ .tree-root, .tree-item ul { list-style: none; margin: 0; padding: 0; }
169
+ .tree-root { max-height: 400px; overflow-y: auto; border: 1px solid var(--cds-border-subtle-01, #393939); }
170
+ .node-row { display: flex; align-items: center; gap: 0.25rem; cursor: pointer; }
171
+ .node-row:hover { background: var(--cds-layer-hover-01, #4c4c4c); }
172
+ .node-row.selected { background: var(--cds-layer-selected-01, #3d3d3d); }
173
+ .twisty, .twisty-spacer { width: 1.25rem; height: 1.5rem; flex-shrink: 0; }
174
+ .twisty { background: none; border: none; color: var(--cds-text-secondary, #c6c6c6); cursor: pointer; padding: 0; }
175
+ .twisty svg { transition: transform 0.15s ease; }
176
+ .twisty.open svg { transform: rotate(90deg); }
177
+ .node-label {
178
+ flex: 1; display: flex; align-items: center; gap: 0.375rem;
179
+ background: none; border: none; color: var(--cds-text-primary, #f4f4f4);
180
+ text-align: left; padding: 0.25rem 0.25rem; cursor: pointer; font-size: 0.8125rem;
181
+ }
182
+ .node-count {
183
+ font-size: 0.625rem; padding: 0 0.375rem; border-radius: 1rem;
184
+ background: var(--cds-tag-background-gray, #393939); color: var(--cds-tag-color-gray, #c6c6c6);
185
+ }
186
+ .spinner {
187
+ width: 1rem; height: 1rem;
188
+ border: 2px solid var(--cds-border-subtle-01, #e0e0e0);
189
+ border-top-color: var(--cds-interactive, #052FAD);
190
+ border-radius: 50%; animation: spin 0.8s linear infinite;
191
+ }
192
+ @keyframes spin { to { transform: rotate(360deg); } }
193
+ .link-button {
194
+ background: none; border: none; padding: 0; cursor: pointer;
195
+ color: var(--cds-link-primary, #78a9ff); text-decoration: underline; font-size: inherit;
196
+ }
197
+ </style>
@@ -0,0 +1,13 @@
1
+ export default AssetTree;
2
+ type AssetTree = {
3
+ $on?(type: string, callback: (e: any) => void): () => void;
4
+ $set?(props: Partial<$$ComponentProps>): void;
5
+ };
6
+ declare const AssetTree: import("svelte").Component<{
7
+ client?: any;
8
+ onSelectNode?: any;
9
+ }, {}, "">;
10
+ type $$ComponentProps = {
11
+ client?: any;
12
+ onSelectNode?: any;
13
+ };
@@ -6,16 +6,15 @@
6
6
  * SelectedTags (collapsible summary with remove). Owns the DataCatalog fetch
7
7
  * lifecycle, pagination state, and the selected-entry metadata cache.
8
8
  *
9
- * Strategy (see docs/superpowers/specs/2026-06-30-tag-picker-lazy-pagination-design.md):
10
- * - Always load page 1 on open never a blank table.
11
- * - Lazy "load more" pagination via offset; totalCount drives the counter and end.
12
- * - Search: client-side instant when the whole catalog is loaded (small catalogs),
13
- * debounced server search otherwise.
14
- * - Selected entries are cached independently of the loaded window so their
15
- * metadata always renders.
9
+ * Two-tab layout (Lot B):
10
+ * - Search tab: LabelFacet (label OR filter) + TagBrowser (paginated, server search).
11
+ * - Tree tab: AssetTree (dictionary node picker) + TagBrowser (client-side, no load-more).
12
+ * - selectedIds and selectedCache are shared across both tabs.
13
+ * - sourceTypes scopes every fetch for type-correct entries.
16
14
  *
17
15
  * @prop dcApiUrl — DataCatalog API base URL
18
16
  * @prop sourceConnectionId — Filters catalog entries by source connection
17
+ * @prop sourceTypes — Optional type scope (e.g. ['OPC-UA']); empty = all types
19
18
  * @prop selectedIds — (bindable) Array of selected CatalogEntry IDs
20
19
  * @prop entries — (bindable) Loaded catalog entries (exposed for parent access)
21
20
  * @prop columns — Column keys for the browse table
@@ -39,12 +38,16 @@
39
38
  upsertSelectedCache,
40
39
  resolveSelected
41
40
  } from './picker-pagination';
41
+ import { filterEntriesByConnection } from './picker-tree';
42
42
  import TagBrowser from './TagBrowser.svelte';
43
43
  import SelectedTags from './SelectedTags.svelte';
44
+ import LabelFacet from './LabelFacet.svelte';
45
+ import AssetTree from './AssetTree.svelte';
44
46
 
45
47
  interface Props {
46
48
  dcApiUrl?: string;
47
49
  sourceConnectionId?: string;
50
+ sourceTypes?: string[];
48
51
  selectedIds?: string[];
49
52
  entries?: CatalogEntry[];
50
53
  columns?: string[];
@@ -57,6 +60,7 @@
57
60
  let {
58
61
  dcApiUrl = '',
59
62
  sourceConnectionId = '',
63
+ sourceTypes = [],
60
64
  selectedIds = $bindable([]),
61
65
  entries = $bindable([]),
62
66
  columns = DEFAULT_COLUMNS,
@@ -75,6 +79,14 @@
75
79
  let currentQuery = $state(MATCH_ALL);
76
80
  let offset = $state(0);
77
81
 
82
+ let activeTab = $state<'search' | 'tree'>('search');
83
+ let activeLabelIds = $state<string[]>([]);
84
+ let treeNodeSelected = $state(false);
85
+ /** Tree-tab empty message: distinguishes "pick a node" from "node has no entries here". */
86
+ const treeEmptyMessage = $derived(treeNodeSelected
87
+ ? 'No entries for this node on this source connection.'
88
+ : 'Select a node in the tree to see its entries.');
89
+
78
90
  /**
79
91
  * Metadata for selected tags, kept across reloads/searches so SelectedTags always
80
92
  * renders. Invariant: every mutation of this Map is paired with an `entries`
@@ -102,7 +114,13 @@
102
114
  let clientUrl = '';
103
115
 
104
116
  /** Whether search runs server-side (large catalog) or client-side (everything loaded). */
105
- const serverSearch = $derived(!isAllLoaded(MATCH_ALL, entries.length, totalCount) || currentQuery !== MATCH_ALL);
117
+ const searchModeServer = $derived(!isAllLoaded(MATCH_ALL, entries.length, totalCount) || currentQuery !== MATCH_ALL);
118
+
119
+ /**
120
+ * Force client-side filtering in the Tree tab (node entries are fully in memory).
121
+ * In the Search tab, use the derived server/client decision.
122
+ */
123
+ const serverSearch = $derived(activeTab === 'tree' ? false : searchModeServer);
106
124
 
107
125
  /** Resolved entries for the selected-tags panel: window first, cache fallback. */
108
126
  const selectedEntries = $derived(resolveSelected(entries, selectedCache, selectedIds));
@@ -114,14 +132,45 @@
114
132
  return client;
115
133
  }
116
134
 
135
+ /**
136
+ * Search options shared by page-1, server-search and load-more, so sourceTypes
137
+ * (mandatory scoping) and labelIds (OR facet) are threaded in exactly one place.
138
+ * `labelIds` is honoured by SDK >= 1.10.0; older runtimes ignore it (unfiltered).
139
+ */
140
+ function buildSearchOptions(offsetValue: number) {
141
+ return {
142
+ sourceConnectionIds: sourceConnectionId ? [sourceConnectionId] : [],
143
+ sourceTypes: sourceTypes,
144
+ labelIds: activeLabelIds,
145
+ limit: PAGE_SIZE,
146
+ offset: offsetValue
147
+ };
148
+ }
149
+
150
+ // Single source of truth for url/connection/tab changes. Runs when any is read here.
117
151
  $effect(() => {
118
- if (dcApiUrl) {
119
- loadInitial(dcApiUrl, sourceConnectionId);
152
+ const url = dcApiUrl;
153
+ const conn = sourceConnectionId;
154
+ const tab = activeTab;
155
+ if (!url) {
156
+ fetchAbort?.abort();
157
+ entries = [];
158
+ totalCount = 0;
159
+ error = '';
160
+ return;
161
+ }
162
+ if (tab === 'search') {
163
+ loadInitial(url, conn);
120
164
  } else {
165
+ // Tree tab: a url/connection change invalidates the resolved node entries
166
+ // (they were scoped to the previous connection). Clear and await a re-pick.
121
167
  fetchAbort?.abort();
122
168
  entries = [];
123
169
  totalCount = 0;
170
+ offset = 0;
171
+ currentQuery = MATCH_ALL;
124
172
  error = '';
173
+ treeNodeSelected = false;
125
174
  }
126
175
  });
127
176
 
@@ -141,11 +190,7 @@
141
190
 
142
191
  try {
143
192
  const dc = getClient(apiUrl);
144
- const result = await dc.catalogEntries.search(MATCH_ALL, {
145
- sourceConnectionIds: connId ? [connId] : [],
146
- limit: PAGE_SIZE,
147
- offset: 0
148
- }, abort.signal);
193
+ const result = await dc.catalogEntries.search(MATCH_ALL, buildSearchOptions(0), abort.signal);
149
194
  if (abort.signal.aborted) return;
150
195
  const items = deduplicateEntries(result.items ?? []);
151
196
  entries = items;
@@ -178,11 +223,7 @@
178
223
  searching = true;
179
224
 
180
225
  const dc = getClient(dcApiUrl);
181
- dc.catalogEntries.search(query, {
182
- sourceConnectionIds: sourceConnectionId ? [sourceConnectionId] : [],
183
- limit: PAGE_SIZE,
184
- offset: 0
185
- }, abort.signal)
226
+ dc.catalogEntries.search(query, buildSearchOptions(0), abort.signal)
186
227
  .then((result) => {
187
228
  if (abort.signal.aborted) return;
188
229
  const items = deduplicateEntries(result.items ?? []);
@@ -214,11 +255,7 @@
214
255
  const startOffset = offset;
215
256
 
216
257
  const dc = getClient(dcApiUrl);
217
- dc.catalogEntries.search(currentQuery, {
218
- sourceConnectionIds: sourceConnectionId ? [sourceConnectionId] : [],
219
- limit: PAGE_SIZE,
220
- offset: startOffset
221
- }, abort.signal)
258
+ dc.catalogEntries.search(currentQuery, buildSearchOptions(startOffset), abort.signal)
222
259
  .then((result) => {
223
260
  if (abort.signal.aborted) return;
224
261
  const items = result.items ?? [];
@@ -235,26 +272,120 @@
235
272
  });
236
273
  }
237
274
 
275
+ // Just flip the tab; the $effect above reacts (search → loadInitial, tree → clear),
276
+ // so page-1 is fetched exactly once (no double fetch).
277
+ function selectTab(tab: 'search' | 'tree') {
278
+ if (activeTab === tab) return;
279
+ loadMoreError = '';
280
+ activeTab = tab;
281
+ }
282
+
283
+ /** Label chips changed — reset pagination and re-query server-side (Search tab). */
284
+ function handleLabelChange(ids: string[]) {
285
+ activeLabelIds = ids;
286
+ handleServerSearch(currentQuery === MATCH_ALL ? '' : currentQuery);
287
+ }
288
+
289
+ /** A tree node was picked — resolve its entries for the current connection/type. */
290
+ function handleSelectNode(entryIds: string[]) {
291
+ if (!dcApiUrl) return;
292
+ treeNodeSelected = true;
293
+ fetchAbort?.abort();
294
+ const abort = new AbortController();
295
+ fetchAbort = abort;
296
+
297
+ loading = true;
298
+ error = '';
299
+ loadMoreError = '';
300
+ currentQuery = MATCH_ALL;
301
+ entries = [];
302
+ totalCount = 0;
303
+ offset = 0;
304
+
305
+ if (entryIds.length === 0) {
306
+ entries = [];
307
+ totalCount = 0;
308
+ loading = false;
309
+ return;
310
+ }
311
+
312
+ const dc = getClient(dcApiUrl);
313
+ dc.catalogEntries.query({ entryIds, sourceTypes }, abort.signal)
314
+ .then((result) => {
315
+ if (abort.signal.aborted) return;
316
+ const scoped = filterEntriesByConnection(result.items ?? [], sourceConnectionId);
317
+ const items = deduplicateEntries(scoped);
318
+ entries = items;
319
+ totalCount = items.length;
320
+ offset = items.length;
321
+ upsertSelectedCache(selectedCache, items);
322
+ loading = false;
323
+ })
324
+ .catch((err: any) => {
325
+ if (abort.signal.aborted) return;
326
+ error = err?.message ?? 'Failed to load node entries';
327
+ entries = [];
328
+ loading = false;
329
+ });
330
+ }
331
+
238
332
  const remaining = $derived(remainingCount(entries.length, totalCount));
239
333
  </script>
240
334
 
241
- <TagBrowser
242
- {entries}
243
- bind:selectedIds
244
- {columns}
245
- {loading}
246
- {loadingMore}
247
- {error}
248
- {loadMoreError}
249
- {emptyMessage}
250
- {totalCount}
251
- {remaining}
252
- serverSearch={serverSearch}
253
- {searching}
254
- {onSelectionChange}
255
- onSearch={handleServerSearch}
256
- onLoadMore={handleLoadMore}
257
- />
335
+ <div class="picker-tabs" role="tablist">
336
+ <button type="button" class="tab" class:active={activeTab === 'search'} role="tab" aria-selected={activeTab === 'search'} onclick={() => selectTab('search')}>Search</button>
337
+ <button type="button" class="tab" class:active={activeTab === 'tree'} role="tab" aria-selected={activeTab === 'tree'} onclick={() => selectTab('tree')}>Asset</button>
338
+ </div>
339
+
340
+ {#if activeTab === 'search'}
341
+ {#if dcApiUrl}
342
+ <LabelFacet client={getClient(dcApiUrl)} bind:selectedLabelIds={activeLabelIds} onChange={handleLabelChange} />
343
+ {/if}
344
+ <TagBrowser
345
+ {entries}
346
+ bind:selectedIds
347
+ {columns}
348
+ {loading}
349
+ {loadingMore}
350
+ {error}
351
+ {loadMoreError}
352
+ {emptyMessage}
353
+ {totalCount}
354
+ {remaining}
355
+ {serverSearch}
356
+ {searching}
357
+ {onSelectionChange}
358
+ onSearch={handleServerSearch}
359
+ onLoadMore={handleLoadMore}
360
+ />
361
+ {:else}
362
+ <div class="tree-layout">
363
+ <div class="tree-pane">
364
+ {#if dcApiUrl}
365
+ <AssetTree client={getClient(dcApiUrl)} onSelectNode={handleSelectNode} />
366
+ {/if}
367
+ </div>
368
+ <div class="tree-entries">
369
+ <TagBrowser
370
+ {entries}
371
+ bind:selectedIds
372
+ {columns}
373
+ {loading}
374
+ loadingMore={false}
375
+ {error}
376
+ loadMoreError={''}
377
+ emptyMessage={treeEmptyMessage}
378
+ {totalCount}
379
+ remaining={0}
380
+ serverSearch={false}
381
+ searching={false}
382
+ {onSelectionChange}
383
+ onSearch={null}
384
+ onLoadMore={null}
385
+ />
386
+ </div>
387
+ </div>
388
+ {/if}
258
389
 
259
390
  {#if selectedIds.length > 0}
260
391
  <SelectedTags
@@ -268,12 +399,17 @@
268
399
  {/if}
269
400
 
270
401
  <style>
271
- .helper-text {
272
- color: var(--cds-text-secondary, #525252);
273
- font-size: 0.75rem;
274
- margin-top: 0.25rem;
275
- }
276
- .helper-text.warning {
277
- color: var(--cds-support-warning, #f1c21b);
402
+ .picker-tabs { display: flex; gap: 0; border-bottom: 1px solid var(--cds-border-subtle-01, #393939); margin-bottom: 0.5rem; }
403
+ .tab {
404
+ padding: 0.5rem 1rem; background: transparent; border: none;
405
+ border-bottom: 2px solid transparent; color: var(--cds-text-secondary, #c6c6c6);
406
+ font-size: 0.8125rem; cursor: pointer; transition: color 0.15s, border-color 0.15s;
278
407
  }
408
+ .tab:hover { color: var(--cds-text-primary, #f4f4f4); }
409
+ .tab.active { color: var(--cds-text-primary, #f4f4f4); border-bottom-color: var(--cds-border-interactive, #052FAD); }
410
+ .tree-layout { display: grid; grid-template-columns: minmax(180px, 40%) 1fr; gap: 0.75rem; align-items: start; }
411
+ .tree-pane { min-width: 0; }
412
+ .tree-entries { min-width: 0; }
413
+ .helper-text { color: var(--cds-text-secondary, #525252); font-size: 0.75rem; margin-top: 0.25rem; }
414
+ .helper-text.warning { color: var(--cds-support-warning, #f1c21b); }
279
415
  </style>
@@ -2,6 +2,7 @@ import type { CatalogEntry } from '@industream/datacatalog-client/dto';
2
2
  interface Props {
3
3
  dcApiUrl?: string;
4
4
  sourceConnectionId?: string;
5
+ sourceTypes?: string[];
5
6
  selectedIds?: string[];
6
7
  entries?: CatalogEntry[];
7
8
  columns?: string[];
@@ -0,0 +1,97 @@
1
+ <script>
2
+ /**
3
+ * LabelFacet - loads catalog labels and renders them as toggle chips.
4
+ * Selecting chips emits the active labelIds (OR filter) to the container,
5
+ * which owns the fetch. This component never fetches catalog entries.
6
+ */
7
+ let {
8
+ client = null,
9
+ selectedLabelIds = $bindable([]),
10
+ onChange = null
11
+ } = $props();
12
+
13
+ let labels = $state([]);
14
+ let loading = $state(false);
15
+ let error = $state('');
16
+
17
+ /** Cancels an in-flight labels fetch (e.g. client/url changes before it resolves). */
18
+ let abort = null;
19
+
20
+ /** Load labels once a client is available. */
21
+ $effect(() => {
22
+ if (client) loadLabels(client);
23
+ });
24
+
25
+ async function loadLabels(dc) {
26
+ abort?.abort();
27
+ const a = new AbortController();
28
+ abort = a;
29
+ loading = true;
30
+ error = '';
31
+ try {
32
+ const result = await dc.labels.get(undefined, a.signal);
33
+ if (a.signal.aborted) return;
34
+ labels = result.items ?? [];
35
+ } catch (err) {
36
+ if (a.signal.aborted) return;
37
+ error = err?.message ?? 'Failed to load labels';
38
+ labels = [];
39
+ } finally {
40
+ if (!a.signal.aborted) loading = false;
41
+ }
42
+ }
43
+
44
+ function toggle(id) {
45
+ selectedLabelIds = selectedLabelIds.indexOf(id) >= 0
46
+ ? selectedLabelIds.filter(x => x !== id)
47
+ : [...selectedLabelIds, id];
48
+ onChange?.(selectedLabelIds);
49
+ }
50
+
51
+ function clearAll() {
52
+ if (selectedLabelIds.length === 0) return;
53
+ selectedLabelIds = [];
54
+ onChange?.(selectedLabelIds);
55
+ }
56
+ </script>
57
+
58
+ {#if loading}
59
+ <div class="label-facet muted">Loading labels...</div>
60
+ {:else if error}
61
+ <div class="label-facet notice">Labels unavailable - search works without label filtering.</div>
62
+ {:else if labels.length > 0}
63
+ <div class="label-facet">
64
+ <span class="facet-label">Labels:</span>
65
+ {#each labels as label (label.id)}
66
+ {@const active = selectedLabelIds.includes(label.id)}
67
+ <button type="button" class="chip" class:active onclick={() => toggle(label.id)}>{label.name}</button>
68
+ {/each}
69
+ {#if selectedLabelIds.length > 0}
70
+ <button type="button" class="chip clear" onclick={clearAll}>Clear</button>
71
+ {/if}
72
+ </div>
73
+ {/if}
74
+
75
+ <style>
76
+ .label-facet {
77
+ display: flex; flex-wrap: wrap; align-items: center; gap: 0.375rem;
78
+ padding: 0.5rem 0; font-size: 0.75rem;
79
+ }
80
+ .facet-label { color: var(--cds-text-secondary, #c6c6c6); margin-right: 0.25rem; }
81
+ .muted { color: var(--cds-text-helper, #8d8d8d); }
82
+ .notice { color: var(--cds-support-warning, #f1c21b); }
83
+ .chip {
84
+ padding: 0.125rem 0.5rem;
85
+ background: var(--cds-tag-background-gray, #393939);
86
+ color: var(--cds-tag-color-gray, #c6c6c6);
87
+ border: 1px solid transparent; border-radius: 1rem;
88
+ font-size: 0.6875rem; cursor: pointer; transition: background-color 0.15s, border-color 0.15s;
89
+ }
90
+ .chip:hover { background: var(--cds-layer-hover-01, #4c4c4c); }
91
+ .chip.active {
92
+ background: var(--cds-layer-selected-01, #052FAD);
93
+ color: var(--cds-text-on-color, #ffffff);
94
+ border-color: var(--cds-focus, #052FAD);
95
+ }
96
+ .chip.clear { background: transparent; border-color: var(--cds-border-strong-01, #6f6f6f); }
97
+ </style>
@@ -0,0 +1,15 @@
1
+ export default LabelFacet;
2
+ type LabelFacet = {
3
+ $on?(type: string, callback: (e: any) => void): () => void;
4
+ $set?(props: Partial<$$ComponentProps>): void;
5
+ };
6
+ declare const LabelFacet: import("svelte").Component<{
7
+ client?: any;
8
+ selectedLabelIds?: any[];
9
+ onChange?: any;
10
+ }, {}, "selectedLabelIds">;
11
+ type $$ComponentProps = {
12
+ client?: any;
13
+ selectedLabelIds?: any[];
14
+ onChange?: any;
15
+ };
@@ -183,7 +183,7 @@
183
183
  {#if showLoadMore}
184
184
  <div class="load-more" use:autoLoad>
185
185
  {#if loadingMore}
186
- <div class="spinner"></div> <span>Loading more…</span>
186
+ <div class="spinner"></div> <span>Loading more...</span>
187
187
  {:else}
188
188
  <button class="action-button" onclick={() => onLoadMore?.()}>
189
189
  Load more ({remaining} remaining)
@@ -191,7 +191,7 @@
191
191
  {/if}
192
192
  </div>
193
193
  {#if loadMoreError}
194
- <div class="load-more-error">{loadMoreError} <button class="link-button" onclick={() => onLoadMore?.()}>retry</button></div>
194
+ <div class="load-more-error">{loadMoreError} - <button class="link-button" onclick={() => onLoadMore?.()}>retry</button></div>
195
195
  {/if}
196
196
  {/if}
197
197
  </div>