@lavalogic/scoria 0.38.0 → 0.38.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,7 @@
4
4
  import ColumnList from '../Body/Rows/ColumnList.svelte';
5
5
  import TextInput from '../../TextInput.svelte';
6
6
  import { Size } from '../../../Types/Internal/Size.js';
7
+ import TableViewDropdown from './TableViewDropdown.svelte';
7
8
 
8
9
  let searchValue = $state('');
9
10
  </script>
@@ -14,6 +15,10 @@
14
15
  </header>
15
16
 
16
17
  <div class="toolbar">
18
+ <!-- Quick-switch dropdown for saved column layouts. Sits above the
19
+ search box; saving a layout is done from the Table
20
+ Configuration modal's "Save a new Layout" tab. -->
21
+ <TableViewDropdown kind="layout" />
17
22
  <TextInput
18
23
  bind:value={searchValue}
19
24
  leftIcon={{ name: 'search', size: Size.Medium }}
@@ -83,6 +88,10 @@
83
88
  box-sizing: border-box;
84
89
  }
85
90
 
91
+ /* Top toolbar (saved-layout dropdown + Search input) sits below the
92
+ header but above the scrollable list. It is intentionally not
93
+ inside `.side-panel-body` because the list takes the remaining
94
+ vertical space and scrolls on its own. */
86
95
  .toolbar {
87
96
  flex-shrink: 0;
88
97
  padding: 1rem 1.25rem;
@@ -100,6 +109,13 @@
100
109
  display: flex;
101
110
  flex-flow: column nowrap;
102
111
  background-color: #ffffff;
112
+ /* Horizontal padding aligned with FilterPanel's `.side-panel-body`
113
+ so the column-list cards leave the same gutter before the panel's
114
+ right border as the filter chips do, and the SidePanel's
115
+ `border-right` stays visible between the content and the toolbar
116
+ icons. Vertical padding is 0 because `.column-list` provides
117
+ its own 0.5rem padding (which doubles up to a 1.5rem gap from
118
+ the toolbar above and the footer below). */
103
119
  padding: 0 1.25rem;
104
120
  box-sizing: border-box;
105
121
  }</style>
@@ -5,17 +5,16 @@
5
5
  generics="T extends object, IdType extends Primitive"
6
6
  >
7
7
  import Action from '../../Action.svelte';
8
- import Button from '../../Button.svelte';
9
8
  import TextInput from '../../TextInput.svelte';
10
9
  import { Colour, ColourSet } from '../../../scss/colours.js';
11
10
  import { Size } from '../../../Types/Internal/Size.js';
12
- import { Variant } from '../../../Types/Internal/Variant.js';
13
11
  import { SvelteSet } from 'svelte/reactivity';
14
12
  import { getContext } from 'svelte';
15
13
  import type { Primitive } from '../Types/Context/TableInitOptions.js';
16
14
  import type { ColumnDef } from '../Types/Columns/Definitions/ColumnDef.svelte.js';
17
15
  import { TableContext } from '../Types/Context/TableContext.svelte.js';
18
16
  import FilterInput from './FilterInput.svelte';
17
+ import TableViewDropdown from './TableViewDropdown.svelte';
19
18
 
20
19
  const tableContext = getContext<TableContext<T, IdType>>(TableContext.identifier);
21
20
 
@@ -31,11 +30,6 @@
31
30
  * string is the "no filter" state. */
32
31
  let quickFindValue: string = $state('');
33
32
 
34
- /** Whether the Save Filter overflow menu is open. The chevron
35
- * toggles it; clicking anywhere else (via the window-level
36
- * listener below) closes it. */
37
- let saveMenuOpen: boolean = $state(false);
38
-
39
33
  /** Names of currently-collapsed groups. A name absent from this
40
34
  * set is rendered expanded. Reactive via `SvelteSet` so the panel
41
35
  * re-renders on toggle without manually re-assigning. */
@@ -106,39 +100,6 @@
106
100
  }
107
101
  }
108
102
 
109
- function resetFilters(): void {
110
- tableContext.paginationRepo?.resetFilters();
111
- }
112
-
113
- /**
114
- * Placeholder for the future Save-Filter / preset workflow. For
115
- * now this just closes the overflow menu; consumers wire the real
116
- * persistence layer in a follow-up workorder. The chevron menu is
117
- * still useful today as a home for the Reset action and as the
118
- * insertion point for "Load Filter" / "Manage Filters" entries
119
- * once those land.
120
- */
121
- function saveFilter(): void {
122
- saveMenuOpen = false;
123
- if (typeof window !== 'undefined') {
124
- window.dispatchEvent(
125
- new CustomEvent('scoria:save-filter', {
126
- detail: { tableName: tableContext.tableName },
127
- })
128
- );
129
- }
130
- }
131
-
132
- function handleWindowClick(e: MouseEvent): void {
133
- if (!saveMenuOpen) {
134
- return;
135
- }
136
- const target = e.target as HTMLElement | null;
137
- if (target?.closest('.save-filter-cluster') == null) {
138
- saveMenuOpen = false;
139
- }
140
- }
141
-
142
103
  /**
143
104
  * No-op keydown handler used to override `TextInput`'s default
144
105
  * `passthrough` keydown (defined in `Helpers.svelte.ts`), which
@@ -155,14 +116,16 @@
155
116
  }
156
117
  </script>
157
118
 
158
- <svelte:window onclick={handleWindowClick} />
159
-
160
119
  <section class="side-panel-section">
161
120
  <header class="side-panel-header">
162
121
  <h2>Advanced Filter</h2>
163
122
  </header>
164
123
 
165
124
  <div class="toolbar">
125
+ <!-- Quick-switch dropdown for saved filter sets. Sits above the
126
+ search box; saving a filter is done from the Table
127
+ Configuration modal's "Save a new Filter" tab. -->
128
+ <TableViewDropdown kind="filter" />
166
129
  <div class="quick-find">
167
130
  <TextInput
168
131
  labelTop="Quick Find a Filter"
@@ -172,36 +135,6 @@
172
135
  onkeydown={noopKeydown}
173
136
  />
174
137
  </div>
175
-
176
- <div class="save-filter-cluster">
177
- <Button
178
- variant={Variant.Secondary}
179
- nowrap
180
- disabled
181
- size={Size.MediumSmall}
182
- onclick={saveFilter}>Save Filter</Button
183
- >
184
- <Action
185
- label="More save options"
186
- colourSet={ColourSet.Auxiliary}
187
- icon={{ name: 'chevron-down', size: Size.Medium }}
188
- onclick={() => {
189
- saveMenuOpen = !saveMenuOpen;
190
- }}
191
- />
192
- {#if saveMenuOpen}
193
- <div class="save-menu">
194
- <button
195
- type="button"
196
- class="save-menu-item"
197
- onclick={() => {
198
- saveMenuOpen = false;
199
- resetFilters();
200
- }}>Reset all filters</button
201
- >
202
- </div>
203
- {/if}
204
- </div>
205
138
  </div>
206
139
 
207
140
  <div class="side-panel-body">
@@ -306,59 +239,24 @@
306
239
  box-sizing: border-box;
307
240
  }
308
241
 
242
+ /* Top toolbar (saved-filter dropdown + Quick Find) sits below the
243
+ header but above the scrollable body. Kept outside
244
+ `.side-panel-body` so it stays pinned while the groups list
245
+ scrolls underneath. The two rows stack vertically: the
246
+ quick-switch dropdown on top, the search field below it. */
309
247
  .toolbar {
310
248
  flex-shrink: 0;
311
249
  display: flex;
312
- flex-flow: row nowrap;
313
- align-items: center;
250
+ flex-flow: column nowrap;
314
251
  gap: 0.75rem;
315
252
  padding: 1rem 1.25rem;
316
253
  background-color: #ffffff;
317
254
  border-bottom: solid 1px #cbd4da;
318
255
  }
319
256
  .toolbar .quick-find {
320
- flex: 1;
321
257
  min-width: 0;
322
258
  --input-height: 3rem;
323
259
  }
324
- .toolbar .save-filter-cluster {
325
- position: relative;
326
- display: flex;
327
- flex-flow: row nowrap;
328
- align-items: center;
329
- gap: 0.25rem;
330
- }
331
- .toolbar .save-filter-cluster .save-menu {
332
- position: absolute;
333
- top: 100%;
334
- right: 0;
335
- margin-top: 0.25rem;
336
- background-color: #ffffff;
337
- border: solid 1px #cbd4da;
338
- border-radius: 4px;
339
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
340
- min-width: 12rem;
341
- z-index: 5;
342
- display: flex;
343
- flex-flow: column nowrap;
344
- padding: 0.25rem 0;
345
- }
346
- .toolbar .save-filter-cluster .save-menu-item {
347
- background: transparent;
348
- border: none;
349
- text-align: left;
350
- padding: 0.5rem 0.75rem;
351
- cursor: pointer;
352
- font: inherit;
353
- color: #3a4952;
354
- }
355
- .toolbar .save-filter-cluster .save-menu-item:hover {
356
- background-color: #f4f7f9;
357
- }
358
- .toolbar .save-filter-cluster .save-menu-item:focus-visible {
359
- outline: 2px solid #259fc7;
360
- outline-offset: -2px;
361
- }
362
260
 
363
261
  p.empty {
364
262
  color: #647d8e;
@@ -372,6 +270,11 @@ p.empty {
372
270
  gap: 1rem;
373
271
  }
374
272
 
273
+ /* `overflow: visible` (no longer `overflow: hidden`) so a filter
274
+ row's select dropdown can extend below the group's bottom edge
275
+ when there are not enough rows in the group to push the dropdown
276
+ past it. The header / body each round their own outer corners
277
+ instead so the rounded-card look survives without clipping. */
375
278
  .group {
376
279
  border: solid 1px #cbd4da;
377
280
  border-radius: 4px;
@@ -387,6 +290,11 @@ p.empty {
387
290
  padding: 0.5rem 0.75rem;
388
291
  background-color: #f4f7f9;
389
292
  border-bottom: solid 1px #cbd4da;
293
+ /* Round the header's top corners directly so the tinted strip
294
+ follows the parent's rounded outline. Without this rule
295
+ (combined with the `.group` `overflow: visible` change) the
296
+ header's blue band would leak past the parent's rounded
297
+ corners. */
390
298
  border-top-left-radius: 4px;
391
299
  border-top-right-radius: 4px;
392
300
  }
@@ -399,10 +307,14 @@ p.empty {
399
307
 
400
308
  .group.collapsed > .group-header {
401
309
  border-bottom: none;
310
+ /* When collapsed the header is the only visible row, so it
311
+ has to round all four corners to follow the parent outline. */
402
312
  border-bottom-left-radius: 4px;
403
313
  border-bottom-right-radius: 4px;
404
314
  }
405
315
 
316
+ /* Round the bottom of the body so its visual edge follows the
317
+ parent's rounded outline once `overflow: hidden` is gone. */
406
318
  .group:not(.collapsed) > .group-body {
407
319
  border-bottom-left-radius: 4px;
408
320
  border-bottom-right-radius: 4px;
@@ -6,9 +6,13 @@
6
6
  >
7
7
  import Button from '../../Button.svelte';
8
8
  import DesktopModal from '../../DesktopModal.svelte';
9
+ import HorizontalTabGroup from '../../HorizontalTabGroup.svelte';
9
10
  import Icon from '../../Icon.svelte';
10
11
  import TextInput from '../../TextInput.svelte';
12
+ import VerticalTabGroup from '../../VerticalTabGroup.svelte';
13
+ import { cssLength } from '../../../Helpers/Helpers.svelte.js';
11
14
  import { Size } from '../../../Types/Internal/Size.js';
15
+ import type { TabOption } from '../../../Types/Internal/TabOption.js';
12
16
  import { Variant } from '../../../Types/Internal/Variant.js';
13
17
  import { getContext } from 'svelte';
14
18
  import { TableContext } from '../Types/Context/TableContext.svelte.js';
@@ -24,7 +28,8 @@
24
28
  // ── Left-nav section + right-pane tab selection ──────────────────────
25
29
  // `section` picks the kind (layout vs filter); `tab` picks which of the
26
30
  // three horizontal panes is visible for that section. Both are plain
27
- // `$state` since they are pure local UI selection.
31
+ // `$state` since they are pure local UI selection. They drive the
32
+ // `selected` prop of the two scoria tab groups below.
28
33
  let section = $state<DatatableViewKind>('layout');
29
34
  type ConfigTab = 'mine' | 'shared' | 'save';
30
35
  let tab = $state<ConfigTab>('mine');
@@ -191,8 +196,272 @@
191
196
  publicViews = null;
192
197
  }
193
198
  }
199
+
200
+ // ── scoria tab-group wiring ──────────────────────────────────────────
201
+ // The left-hand vertical nav is a `HorizontalTabGroup` (tabs strip on
202
+ // the left, content on the right); the top horizontal tabs are a
203
+ // `VerticalTabGroup` (tabs strip on top, content below). Same two
204
+ // scoria primitives the flowms order-allocation modal uses, so the
205
+ // look is consistent across the app.
206
+
207
+ /** The three top tabs for the active section. Rebuilt when the section
208
+ * flips so the labels track "My Layouts" / "My Filters" etc. */
209
+ const innerTabs: Array<TabOption<{ tab: ConfigTab }>> = $derived([
210
+ {
211
+ label: `My ${sectionNounPlural}`,
212
+ content: tabBody,
213
+ args: { tab: 'mine' },
214
+ onclick: () => {
215
+ selectTab('mine');
216
+ },
217
+ },
218
+ {
219
+ // DESIGN-REVIEW: the mockups were internally inconsistent
220
+ // ("Subscribed to Filters" on one, "Shared Filters" on
221
+ // another). Resolved to "Shared" consistently for both the
222
+ // layout and filter sections.
223
+ label: `Shared ${sectionNounPlural}`,
224
+ content: tabBody,
225
+ args: { tab: 'shared' },
226
+ onclick: () => {
227
+ selectTab('shared');
228
+ },
229
+ },
230
+ {
231
+ label: `Save a new ${sectionNoun}`,
232
+ content: tabBody,
233
+ args: { tab: 'save' },
234
+ onclick: () => {
235
+ selectTab('save');
236
+ },
237
+ },
238
+ ]);
239
+
240
+ const selectedInnerTab = $derived(innerTabs.find((t) => t.args.tab === tab) ?? innerTabs[0]);
241
+
242
+ /** The two left-nav sections. Each renders the inner `VerticalTabGroup`
243
+ * via the shared `sectionPane` snippet. */
244
+ const sectionTabs: Array<TabOption<{ kind: DatatableViewKind }>> = [
245
+ {
246
+ label: 'Table Layouts',
247
+ content: sectionPane,
248
+ args: { kind: 'layout' },
249
+ onclick: () => {
250
+ selectSection('layout');
251
+ },
252
+ },
253
+ {
254
+ label: 'Table Filters',
255
+ content: sectionPane,
256
+ args: { kind: 'filter' },
257
+ onclick: () => {
258
+ selectSection('filter');
259
+ },
260
+ },
261
+ ];
262
+
263
+ const selectedSectionTab = $derived(
264
+ sectionTabs.find((t) => t.args.kind === section) ?? sectionTabs[0]
265
+ );
194
266
  </script>
195
267
 
268
+ <!-- Left-nav section pane: the top horizontal tabs for the active kind. -->
269
+ {#snippet sectionPane(_args: { kind: DatatableViewKind })}
270
+ <div class="pane">
271
+ <VerticalTabGroup
272
+ tabs={innerTabs}
273
+ selected={selectedInnerTab}
274
+ nohistory
275
+ />
276
+ </div>
277
+ {/snippet}
278
+
279
+ <!-- Body of a single top tab (My / Shared / Save). -->
280
+ {#snippet tabBody(args: { tab: ConfigTab })}
281
+ <div class="tab-body">
282
+ {#if errorMessage}
283
+ <div
284
+ class="error-banner"
285
+ role="alert"
286
+ >
287
+ <Icon
288
+ name="circle-exclamation"
289
+ size={Size.Small}
290
+ />
291
+ <span>{errorMessage}</span>
292
+ </div>
293
+ {/if}
294
+
295
+ {#if args.tab === 'mine'}
296
+ {#if myViews.length === 0}
297
+ <p class="muted">
298
+ You have not saved any {sectionNounPlural.toLowerCase()} yet. Use the "Save a new {sectionNoun}"
299
+ tab to create one.
300
+ </p>
301
+ {:else}
302
+ <ul class="card-list">
303
+ {#each myViews as view (view.id)}
304
+ <li class="view-card">
305
+ <div class="view-card-head">
306
+ <span class="view-name">{view.name}</span>
307
+ <div class="view-actions">
308
+ <Button
309
+ variant={Variant.Secondary}
310
+ size={Size.MediumSmall}
311
+ nowrap
312
+ onclick={() => {
313
+ applyView(view);
314
+ }}>Apply</Button
315
+ >
316
+ <Button
317
+ variant={Variant.Secondary}
318
+ size={Size.MediumSmall}
319
+ nowrap
320
+ iconLeft={{ name: 'external-link', size: Size.Small }}
321
+ onclick={() => {
322
+ selectTab('shared');
323
+ }}>Share</Button
324
+ >
325
+ <Button
326
+ variant={Variant.Secondary}
327
+ size={Size.MediumSmall}
328
+ nowrap
329
+ iconLeft={{ name: 'circle-cross', size: Size.Small }}
330
+ onclick={() => {
331
+ deleteView(view);
332
+ }}>Delete</Button
333
+ >
334
+ </div>
335
+ </div>
336
+ {#if view.subscribers && view.subscribers.length > 0}
337
+ <ul class="subscriber-list">
338
+ {#each view.subscribers as subscriber (subscriber.userId)}
339
+ <li class="subscriber">
340
+ <Icon
341
+ name="user-3"
342
+ size={Size.Small}
343
+ />
344
+ <span>{subscriber.displayName ?? subscriber.userId}</span>
345
+ <Button
346
+ variant={Variant.Secondary}
347
+ size={Size.Tiny}
348
+ nowrap
349
+ onclick={() => {
350
+ removeSubscriber(view, subscriber.userId);
351
+ }}>Remove</Button
352
+ >
353
+ </li>
354
+ {/each}
355
+ </ul>
356
+ {/if}
357
+ </li>
358
+ {/each}
359
+ </ul>
360
+ {/if}
361
+ {:else if args.tab === 'shared'}
362
+ <div class="shared-head">
363
+ <Button
364
+ variant={Variant.Secondary}
365
+ size={Size.MediumSmall}
366
+ nowrap
367
+ disabled={loadingPublicViews}
368
+ iconLeft={{ name: 'circle-plus', size: Size.Small }}
369
+ onclick={loadPublicViews}>Subscribe to Additional...</Button
370
+ >
371
+ </div>
372
+
373
+ {#if publicViews !== null}
374
+ {#if publicViews.length === 0}
375
+ <p class="muted">
376
+ There are no further {sectionNounPlural.toLowerCase()} available to subscribe to.
377
+ </p>
378
+ {:else}
379
+ <ul class="card-list">
380
+ {#each publicViews as view (view.id)}
381
+ <li class="view-card">
382
+ <div class="view-card-head">
383
+ <span class="view-name">{view.name}</span>
384
+ <div class="view-actions">
385
+ <Button
386
+ variant={Variant.Secondary}
387
+ size={Size.MediumSmall}
388
+ nowrap
389
+ disabled={currentUserId === undefined}
390
+ onclick={() => {
391
+ subscribe(view);
392
+ }}>Subscribe</Button
393
+ >
394
+ </div>
395
+ </div>
396
+ </li>
397
+ {/each}
398
+ </ul>
399
+ {/if}
400
+ {/if}
401
+
402
+ {#if sharedViews.length === 0}
403
+ <p class="muted">
404
+ No {sectionNounPlural.toLowerCase()} are currently shared with you.
405
+ </p>
406
+ {:else}
407
+ <ul class="card-list">
408
+ {#each sharedViews as view (view.id)}
409
+ <li class="view-card">
410
+ <div class="view-card-head">
411
+ <span class="view-name">{view.name}</span>
412
+ <div class="view-actions">
413
+ <Button
414
+ variant={Variant.Secondary}
415
+ size={Size.MediumSmall}
416
+ nowrap
417
+ onclick={() => {
418
+ applyView(view);
419
+ }}>Apply</Button
420
+ >
421
+ <Button
422
+ variant={Variant.Secondary}
423
+ size={Size.MediumSmall}
424
+ nowrap
425
+ disabled={currentUserId === undefined}
426
+ onclick={() => {
427
+ unsubscribe(view);
428
+ }}>Unsubscribe</Button
429
+ >
430
+ </div>
431
+ </div>
432
+ </li>
433
+ {/each}
434
+ </ul>
435
+ {/if}
436
+ {:else}
437
+ <div class="save-tab">
438
+ <p class="muted">
439
+ Save the table's current {sectionNoun.toLowerCase()} as a reusable named
440
+ {sectionNoun.toLowerCase()}.
441
+ </p>
442
+ <TextInput
443
+ placeholder={`${sectionNoun} name`}
444
+ bind:value={newViewName}
445
+ onkeydown={(e: KeyboardEvent) => {
446
+ if (e.key === 'Enter') {
447
+ e.preventDefault();
448
+ saveNewView();
449
+ }
450
+ }}
451
+ />
452
+ <Button
453
+ type="submit"
454
+ variant={Variant.Primary}
455
+ size={Size.MediumSmall}
456
+ nowrap
457
+ disabled={!newViewName.trim()}
458
+ onclick={saveNewView}>Save Current</Button
459
+ >
460
+ </div>
461
+ {/if}
462
+ </div>
463
+ {/snippet}
464
+
196
465
  <DesktopModal
197
466
  headerLabel="Table Configuration"
198
467
  headerSubtitle={tableContext.datatableUuid
@@ -200,6 +469,8 @@
200
469
  : undefined}
201
470
  onclose={() => onclose?.()}
202
471
  buttons={[{ label: 'Close', callback: () => onclose?.() }]}
472
+ width={cssLength('50vw')}
473
+ minWidth={cssLength('40rem')}
203
474
  >
204
475
  {#if !remoteAvailable}
205
476
  <div class="empty-state">
@@ -211,261 +482,11 @@
211
482
  </div>
212
483
  {:else}
213
484
  <div class="config">
214
- <!-- Left vertical nav: pick the kind being configured. -->
215
- <nav class="side-nav">
216
- <button
217
- type="button"
218
- class="nav-item"
219
- class:active={section === 'layout'}
220
- onclick={() => {
221
- selectSection('layout');
222
- }}
223
- >
224
- Table Layouts
225
- </button>
226
- <button
227
- type="button"
228
- class="nav-item"
229
- class:active={section === 'filter'}
230
- onclick={() => {
231
- selectSection('filter');
232
- }}
233
- >
234
- Table Filters
235
- </button>
236
- </nav>
237
-
238
- <div class="pane">
239
- <!-- Three horizontal tabs for the selected section. -->
240
- <div
241
- class="tabs"
242
- role="tablist"
243
- >
244
- <button
245
- type="button"
246
- role="tab"
247
- aria-selected={tab === 'mine'}
248
- class="tab"
249
- class:active={tab === 'mine'}
250
- onclick={() => {
251
- selectTab('mine');
252
- }}
253
- >
254
- My {sectionNounPlural}
255
- </button>
256
- <button
257
- type="button"
258
- role="tab"
259
- aria-selected={tab === 'shared'}
260
- class="tab"
261
- class:active={tab === 'shared'}
262
- onclick={() => {
263
- selectTab('shared');
264
- }}
265
- >
266
- <!-- DESIGN-REVIEW: the mockups were internally inconsistent
267
- ("Subscribed to Filters" on one, "Shared Filters" on
268
- another). Resolved to "Shared" consistently for both
269
- the layout and filter sections. -->
270
- Shared {sectionNounPlural}
271
- </button>
272
- <button
273
- type="button"
274
- role="tab"
275
- aria-selected={tab === 'save'}
276
- class="tab"
277
- class:active={tab === 'save'}
278
- onclick={() => {
279
- selectTab('save');
280
- }}
281
- >
282
- Save a new {sectionNoun}
283
- </button>
284
- </div>
285
-
286
- {#if errorMessage}
287
- <div
288
- class="error-banner"
289
- role="alert"
290
- >
291
- <Icon
292
- name="circle-exclamation"
293
- size={Size.Small}
294
- />
295
- <span>{errorMessage}</span>
296
- </div>
297
- {/if}
298
-
299
- <div class="tab-body">
300
- {#if tab === 'mine'}
301
- {#if myViews.length === 0}
302
- <p class="muted">
303
- You have not saved any {sectionNounPlural.toLowerCase()} yet. Use the "Save a new {sectionNoun}"
304
- tab to create one.
305
- </p>
306
- {:else}
307
- <ul class="card-list">
308
- {#each myViews as view (view.id)}
309
- <li class="view-card">
310
- <div class="view-card-head">
311
- <span class="view-name">{view.name}</span>
312
- <div class="view-actions">
313
- <Button
314
- variant={Variant.Secondary}
315
- size={Size.MediumSmall}
316
- nowrap
317
- onclick={() => {
318
- applyView(view);
319
- }}>Apply</Button
320
- >
321
- <Button
322
- variant={Variant.Secondary}
323
- size={Size.MediumSmall}
324
- nowrap
325
- iconLeft={{ name: 'external-link', size: Size.Small }}
326
- onclick={() => {
327
- selectTab('shared');
328
- }}>Share</Button
329
- >
330
- <Button
331
- variant={Variant.Secondary}
332
- size={Size.MediumSmall}
333
- nowrap
334
- iconLeft={{ name: 'circle-cross', size: Size.Small }}
335
- onclick={() => {
336
- deleteView(view);
337
- }}>Delete</Button
338
- >
339
- </div>
340
- </div>
341
- {#if view.subscribers && view.subscribers.length > 0}
342
- <ul class="subscriber-list">
343
- {#each view.subscribers as subscriber (subscriber.userId)}
344
- <li class="subscriber">
345
- <Icon
346
- name="user-3"
347
- size={Size.Small}
348
- />
349
- <span>{subscriber.displayName ?? subscriber.userId}</span>
350
- <Button
351
- variant={Variant.Secondary}
352
- size={Size.Tiny}
353
- nowrap
354
- onclick={() => {
355
- removeSubscriber(view, subscriber.userId);
356
- }}>Remove</Button
357
- >
358
- </li>
359
- {/each}
360
- </ul>
361
- {/if}
362
- </li>
363
- {/each}
364
- </ul>
365
- {/if}
366
- {:else if tab === 'shared'}
367
- <div class="shared-head">
368
- <Button
369
- variant={Variant.Secondary}
370
- size={Size.MediumSmall}
371
- nowrap
372
- disabled={loadingPublicViews}
373
- iconLeft={{ name: 'circle-plus', size: Size.Small }}
374
- onclick={loadPublicViews}>Subscribe to Additional...</Button
375
- >
376
- </div>
377
-
378
- {#if publicViews !== null}
379
- {#if publicViews.length === 0}
380
- <p class="muted">
381
- There are no further {sectionNounPlural.toLowerCase()} available to subscribe to.
382
- </p>
383
- {:else}
384
- <ul class="card-list">
385
- {#each publicViews as view (view.id)}
386
- <li class="view-card">
387
- <div class="view-card-head">
388
- <span class="view-name">{view.name}</span>
389
- <div class="view-actions">
390
- <Button
391
- variant={Variant.Secondary}
392
- size={Size.MediumSmall}
393
- nowrap
394
- disabled={currentUserId === undefined}
395
- onclick={() => {
396
- subscribe(view);
397
- }}>Subscribe</Button
398
- >
399
- </div>
400
- </div>
401
- </li>
402
- {/each}
403
- </ul>
404
- {/if}
405
- {/if}
406
-
407
- {#if sharedViews.length === 0}
408
- <p class="muted">
409
- No {sectionNounPlural.toLowerCase()} are currently shared with you.
410
- </p>
411
- {:else}
412
- <ul class="card-list">
413
- {#each sharedViews as view (view.id)}
414
- <li class="view-card">
415
- <div class="view-card-head">
416
- <span class="view-name">{view.name}</span>
417
- <div class="view-actions">
418
- <Button
419
- variant={Variant.Secondary}
420
- size={Size.MediumSmall}
421
- nowrap
422
- onclick={() => {
423
- applyView(view);
424
- }}>Apply</Button
425
- >
426
- <Button
427
- variant={Variant.Secondary}
428
- size={Size.MediumSmall}
429
- nowrap
430
- disabled={currentUserId === undefined}
431
- onclick={() => {
432
- unsubscribe(view);
433
- }}>Unsubscribe</Button
434
- >
435
- </div>
436
- </div>
437
- </li>
438
- {/each}
439
- </ul>
440
- {/if}
441
- {:else}
442
- <div class="save-tab">
443
- <p class="muted">
444
- Save the table's current {sectionNoun.toLowerCase()} as a reusable named
445
- {sectionNoun.toLowerCase()}.
446
- </p>
447
- <TextInput
448
- placeholder={`${sectionNoun} name`}
449
- bind:value={newViewName}
450
- onkeydown={(e: KeyboardEvent) => {
451
- if (e.key === 'Enter') {
452
- e.preventDefault();
453
- saveNewView();
454
- }
455
- }}
456
- />
457
- <Button
458
- type="submit"
459
- variant={Variant.Primary}
460
- size={Size.MediumSmall}
461
- nowrap
462
- disabled={!newViewName.trim()}
463
- onclick={saveNewView}>Save Current</Button
464
- >
465
- </div>
466
- {/if}
467
- </div>
468
- </div>
485
+ <HorizontalTabGroup
486
+ tabs={sectionTabs}
487
+ selected={selectedSectionTab}
488
+ grow
489
+ />
469
490
  </div>
470
491
  {/if}
471
492
  </DesktopModal>
@@ -481,72 +502,27 @@
481
502
  color: #3a4952;
482
503
  }
483
504
 
505
+ /* Fixed-frame wrapper. The modal is a fixed 50vw (see the `width`
506
+ prop on `DesktopModal`); this `.config` lets the tab group fill
507
+ that frame and the tab body scroll inside it, so switching nav
508
+ section or top tab never reflows the dialog. */
484
509
  .config {
485
510
  display: flex;
486
- flex-flow: row nowrap;
487
- gap: 1rem;
511
+ flex-flow: column nowrap;
488
512
  flex: 1;
489
513
  min-height: 0;
490
514
  width: 100%;
491
515
  }
492
516
 
493
- .side-nav {
494
- display: flex;
495
- flex-flow: column nowrap;
496
- gap: 0.25rem;
497
- flex-shrink: 0;
498
- border-right: solid 2px #cbd4da;
499
- padding-right: 1rem;
500
- }
501
- .side-nav .nav-item {
502
- text-align: left;
503
- white-space: nowrap;
504
- padding: 0.6rem 0.9rem;
505
- border: solid 1px #cbd4da;
506
- border-radius: 4px;
507
- background-color: #ffffff;
508
- color: #3a4952;
509
- font-weight: 600;
510
- cursor: pointer;
511
- }
512
- .side-nav .nav-item:hover {
513
- background-color: #e9ecf1;
514
- }
515
- .side-nav .nav-item.active {
516
- background-color: #cbd4da;
517
- color: #455661;
518
- }
519
-
517
+ /* The right-hand content pane that each left-nav section renders:
518
+ holds the top horizontal tabs and their scrolling body. */
520
519
  .pane {
521
520
  display: flex;
522
521
  flex-flow: column nowrap;
523
- gap: 0.75rem;
524
522
  flex: 1;
525
523
  min-width: 0;
526
- }
527
-
528
- .tabs {
529
- display: flex;
530
- flex-flow: row nowrap;
531
- gap: 0.25rem;
532
- border-bottom: solid 2px #cbd4da;
533
- }
534
- .tabs .tab {
535
- padding: 0.5rem 0.9rem;
536
- border: none;
537
- border-bottom: solid 3px transparent;
538
- background-color: transparent;
539
- color: #3a4952;
540
- font-weight: 600;
541
- white-space: nowrap;
542
- cursor: pointer;
543
- }
544
- .tabs .tab:hover {
545
- color: #259fc7;
546
- }
547
- .tabs .tab.active {
548
- border-bottom-color: #259fc7;
549
- color: #259fc7;
524
+ min-height: 0;
525
+ width: 100%;
550
526
  }
551
527
 
552
528
  .error-banner {
@@ -561,9 +537,15 @@
561
537
  color: #aa1414;
562
538
  }
563
539
 
540
+ /* The body of a single top tab. Scrolls within the fixed modal
541
+ frame rather than growing the dialog. */
564
542
  .tab-body {
565
- flex: 1;
543
+ display: flex;
544
+ flex-flow: column nowrap;
545
+ gap: 0.75rem;
546
+ padding: 1rem;
566
547
  min-height: 0;
548
+ max-height: 60vh;
567
549
  overflow-y: auto;
568
550
  }
569
551
 
@@ -16,7 +16,6 @@
16
16
  import ColumnPanelModal from './ColumnPanelModal.svelte';
17
17
  import FilterPanelModal from './FilterPanelModal.svelte';
18
18
  import TableConfigurationModal from './TableConfigurationModal.svelte';
19
- import TableViewDropdown from './TableViewDropdown.svelte';
20
19
  import ToolboxPanelModal from './ToolboxPanelModal.svelte';
21
20
 
22
21
  const tableContext = getContext<TableContext<T, IdType>>(TableContext.identifier);
@@ -42,9 +41,6 @@
42
41
  />
43
42
  {/if}
44
43
  <div class="sidebar">
45
- <div class="view-dropdown-slot">
46
- <TableViewDropdown />
47
- </div>
48
44
  <Button
49
45
  side={Side.Bottom}
50
46
  onclick={() => {
@@ -159,12 +155,4 @@
159
155
  .sidebar .filler {
160
156
  flex-grow: 2;
161
157
  background-color: #ffffff;
162
- }
163
- .sidebar .view-dropdown-slot {
164
- display: flex;
165
- flex-flow: row nowrap;
166
- align-items: center;
167
- background-color: #ffffff;
168
- padding: 0 0.5rem;
169
- flex-shrink: 0;
170
158
  }</style>
@@ -4,16 +4,24 @@
4
4
  lang="ts"
5
5
  generics="T extends object, IdType extends Primitive"
6
6
  >
7
- import Button from '../../Button.svelte';
8
7
  import SingleSelect from '../../SingleSelect.svelte';
9
- import TextInput from '../../TextInput.svelte';
10
- import { devCatch } from '../../../Helpers/Helpers.svelte.js';
11
8
  import type { SelectOption } from '../../../Types/Internal/SelectOption.js';
12
- import { Size } from '../../../Types/Internal/Size.js';
13
- import { Variant } from '../../../Types/Internal/Variant.js';
14
9
  import { getContext } from 'svelte';
15
10
  import { TableContext } from '../Types/Context/TableContext.svelte.js';
16
11
  import type { Primitive } from '../Types/Context/TableInitOptions.js';
12
+ import type { DatatableViewKind } from '../Types/Persistence/DatatableViewKind.js';
13
+
14
+ interface Props {
15
+ /**
16
+ * Which view-state slice this dropdown switches between. `'layout'`
17
+ * lists saved column layouts; `'filter'` lists saved filter sets.
18
+ * Drives every `_viewState` read (label / active view / saved
19
+ * views) and which kind `resetToDefault` is called with.
20
+ */
21
+ kind: DatatableViewKind;
22
+ }
23
+
24
+ const { kind }: Props = $props();
17
25
 
18
26
  const tableContext = getContext<TableContext<T, IdType>>(TableContext.identifier);
19
27
 
@@ -22,15 +30,14 @@
22
30
  // collides with one.
23
31
  const DEFAULT_OPTION = '';
24
32
 
25
- // The layout view-state slice, read live from the context.
26
- const activeView = $derived(tableContext._viewState.activeView('layout'));
27
- const isDirty = $derived(tableContext._viewState.isDirty('layout'));
28
- const savedViews = $derived(tableContext._viewState.savedViews('layout'));
33
+ // The view-state slice for `kind`, read live from the context.
34
+ const activeView = $derived(tableContext._viewState.activeView(kind));
35
+ const savedViews = $derived(tableContext._viewState.savedViews(kind));
29
36
 
30
- // Toolbar label: 'Default' / 'Custom (unsaved)' / '<name>' / '<name>
37
+ // Dropdown label: 'Default' / 'Custom (unsaved)' / '<name>' / '<name>
31
38
  // (unsaved)'. Used as the displayed label of the currently-selected
32
39
  // option so the dropdown reflects the dirty state exactly.
33
- const dropdownLabel = $derived(tableContext._viewState.dropdownLabel('layout'));
40
+ const dropdownLabel = $derived(tableContext._viewState.dropdownLabel(kind));
34
41
 
35
42
  // The value of the currently-active option: the sentinel for Default,
36
43
  // otherwise the saved view's id.
@@ -38,7 +45,8 @@
38
45
 
39
46
  // Options for the select. The currently-active option's label is the
40
47
  // `dropdownLabel` (so a dirty view reads "<name> (unsaved)"); every
41
- // other option shows its plain name. "Default" is always first.
48
+ // other option shows its plain name. "Default" is always first, even
49
+ // when remote saved views are disabled and `savedViews` is empty.
42
50
  const options: Array<SelectOption<string>> = $derived([
43
51
  {
44
52
  label: activeValue === DEFAULT_OPTION ? dropdownLabel : 'Default',
@@ -52,17 +60,13 @@
52
60
 
53
61
  const selectedOption = $derived(options.find((o) => o.value === activeValue) ?? options[0]);
54
62
 
55
- // Inline "name this layout" prompt state for the Save button.
56
- let promptOpen = $state(false);
57
- let pendingName = $state('');
58
-
59
63
  /** Apply the option the user picked from the dropdown. */
60
64
  function selectOption(option: SelectOption<string> | null): void {
61
65
  if (!option) {
62
66
  return;
63
67
  }
64
68
  if (option.value === DEFAULT_OPTION) {
65
- tableContext.resetToDefault('layout');
69
+ tableContext.resetToDefault(kind);
66
70
  return;
67
71
  }
68
72
  const view = savedViews.find((v) => v.id === option.value);
@@ -70,106 +74,24 @@
70
74
  tableContext.applyDatatableView(view);
71
75
  }
72
76
  }
73
-
74
- /**
75
- * Handle the Save button. When the active layout is a saved view, save
76
- * over it directly. When it is the Default (an unsaved custom layout),
77
- * open the inline name prompt so the user can name a brand-new view.
78
- */
79
- function onSaveClicked(): void {
80
- if (activeView.kind === 'saved') {
81
- tableContext.saveActiveView('layout').catch(devCatch);
82
- return;
83
- }
84
- promptOpen = true;
85
- pendingName = '';
86
- }
87
-
88
- /** Commit the inline name prompt as a brand-new saved layout. */
89
- function commitNewView(): void {
90
- const trimmed = pendingName.trim();
91
- if (!trimmed) {
92
- return;
93
- }
94
- tableContext.saveCurrentAsNewView(trimmed, 'layout').catch(devCatch);
95
- promptOpen = false;
96
- pendingName = '';
97
- }
98
77
  </script>
99
78
 
100
79
  <div class="view-dropdown">
101
- <div class="select">
102
- <SingleSelect
103
- label="Layout"
104
- name="Layout"
105
- {options}
106
- labelProp="label"
107
- valueProp="value"
108
- value={selectedOption}
109
- valueAsObject
110
- searchable={false}
111
- onchange={selectOption}
112
- />
113
- </div>
114
-
115
- {#if promptOpen}
116
- <div class="save-prompt">
117
- <TextInput
118
- placeholder="Layout name"
119
- bind:value={pendingName}
120
- onkeydown={(e: KeyboardEvent) => {
121
- if (e.key === 'Enter') {
122
- e.preventDefault();
123
- commitNewView();
124
- } else if (e.key === 'Escape') {
125
- promptOpen = false;
126
- pendingName = '';
127
- }
128
- }}
129
- />
130
- <Button
131
- type="submit"
132
- variant={Variant.Primary}
133
- size={Size.MediumSmall}
134
- nowrap
135
- disabled={!pendingName.trim()}
136
- onclick={commitNewView}>Save</Button
137
- >
138
- <Button
139
- variant={Variant.Secondary}
140
- size={Size.MediumSmall}
141
- nowrap
142
- onclick={() => {
143
- promptOpen = false;
144
- pendingName = '';
145
- }}>Cancel</Button
146
- >
147
- </div>
148
- {:else}
149
- <Button
150
- variant={Variant.Secondary}
151
- size={Size.MediumSmall}
152
- nowrap
153
- disabled={!isDirty}
154
- onclick={onSaveClicked}>Save</Button
155
- >
156
- {/if}
80
+ <SingleSelect
81
+ label={kind === 'filter' ? 'Filter' : 'Layout'}
82
+ name={kind === 'filter' ? 'Filter' : 'Layout'}
83
+ {options}
84
+ labelProp="label"
85
+ valueProp="value"
86
+ value={selectedOption}
87
+ valueAsObject
88
+ searchable={false}
89
+ onchange={selectOption}
90
+ />
157
91
  </div>
158
92
 
159
93
  <style>.view-dropdown {
160
94
  display: flex;
161
- flex-flow: row nowrap;
162
- align-items: end;
163
- gap: 0.5rem;
164
- }
165
-
166
- .select {
167
- min-width: 12rem;
168
- }
169
-
170
- .save-prompt {
171
- display: flex;
172
- flex-flow: row nowrap;
173
- align-items: end;
174
- gap: 0.375rem;
95
+ flex-flow: column nowrap;
96
+ width: 100%;
175
97
  }</style>
@@ -1,6 +1,16 @@
1
1
  import type { Primitive } from '../Types/Context/TableInitOptions.js';
2
+ import type { DatatableViewKind } from '../Types/Persistence/DatatableViewKind.js';
3
+ interface Props {
4
+ /**
5
+ * Which view-state slice this dropdown switches between. `'layout'`
6
+ * lists saved column layouts; `'filter'` lists saved filter sets.
7
+ * Drives every `_viewState` read (label / active view / saved
8
+ * views) and which kind `resetToDefault` is called with.
9
+ */
10
+ kind: DatatableViewKind;
11
+ }
2
12
  declare function $$render<T extends object, IdType extends Primitive>(): {
3
- props: Record<string, never>;
13
+ props: Props;
4
14
  exports: {};
5
15
  bindings: "";
6
16
  slots: {};
@@ -17,7 +27,7 @@ interface $$IsomorphicComponent {
17
27
  new <T extends object, IdType extends Primitive>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T, IdType>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T, IdType>['props']>, ReturnType<__sveltets_Render<T, IdType>['events']>, ReturnType<__sveltets_Render<T, IdType>['slots']>> & {
18
28
  $$bindings?: ReturnType<__sveltets_Render<T, IdType>['bindings']>;
19
29
  } & ReturnType<__sveltets_Render<T, IdType>['exports']>;
20
- <T extends object, IdType extends Primitive>(internal: unknown, props: {}): ReturnType<__sveltets_Render<T, IdType>['exports']>;
30
+ <T extends object, IdType extends Primitive>(internal: unknown, props: ReturnType<__sveltets_Render<T, IdType>['props']> & {}): ReturnType<__sveltets_Render<T, IdType>['exports']>;
21
31
  z_$$bindings?: ReturnType<__sveltets_Render<any, any>['bindings']>;
22
32
  }
23
33
  declare const TableViewDropdown: $$IsomorphicComponent;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lavalogic/scoria",
3
3
  "description": "Svelte components used for the FloWMS Web Frontend",
4
- "version": "0.38.0",
4
+ "version": "0.38.1",
5
5
  "publishConfig": {
6
6
  "access": "restricted"
7
7
  },