@lavalogic/scoria 0.37.17 → 0.37.19

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.
@@ -727,6 +727,8 @@ div.input-cell {
727
727
  overflow: visible;
728
728
  padding-left: 0;
729
729
  padding-right: 0;
730
+ border-right: solid 1px #cbd4da !important;
731
+ box-sizing: border-box;
730
732
  }
731
733
  div.input-cell div.stick-right {
732
734
  position: absolute;
@@ -368,59 +368,71 @@
368
368
  ondragleave={disableDragVisual}
369
369
  ondrop={disableDragVisual}
370
370
  >
371
- <div class="visibility-wrapper">
372
- <Checkbox
373
- icon={{ name: 'assessment', size: Size.Medium }}
374
- size={Size.FillWidth}
375
- id={`is-visible-${tableContext.tableName}-${columnDef.id}`}
376
- value={tableContext.columnVisibility[columnDef.id] !== false}
377
- onchange={onVisibilityChange}
378
- />
379
- </div>
380
-
381
- {#if tableContext.allowColumnReordering}
382
- <!--
383
- Tri-state pin selector rendered via `ThreeStateRadio`,
384
- matching the visual segmented-control used elsewhere in
385
- the table (filter Active state, etc.). The three
386
- `Ternary` slots map to:
387
- False → pinned LEFT (pin-left icon, leftmost)
388
- Neutral → unpinned (circle-cross icon, middle)
389
- True → pinned RIGHT (pin-right icon, rightmost)
390
- Expand columns can never be pinned, so the group goes
391
- fully disabled for them.
392
- -->
393
- <div
394
- class="pin-selector"
395
- class:disabled={isExpandColumn}
371
+ <!--
372
+ `.row-main` stacks the column label on top and the
373
+ visibility / pin controls underneath, so the label gets
374
+ the full card width to wrap into (instead of competing
375
+ horizontally with the controls). `.reorder-buttons` stays
376
+ as a sibling outside `.row-main` so the up / down arrows
377
+ remain vertically centred on the right edge of the card.
378
+ -->
379
+ <div class="row-main">
380
+ <span
381
+ class="column-label"
382
+ title={typeof columnDef.header === 'string' ? columnDef.header : columnDef.id}
396
383
  >
397
- <ThreeStateRadio
398
- value={pinSide}
399
- disabled={isExpandColumn}
400
- noLabel="Pin to left"
401
- neutralLabel="Unpinned"
402
- yesLabel="Pin to right"
403
- noIcon={{ name: 'pin-left', size: Size.Medium }}
404
- neutralIcon={{ name: 'circle-cross', size: Size.Medium }}
405
- yesIcon={{ name: 'pin-right', size: Size.Medium }}
406
- onchange={(next: Ternary) => {
407
- setPin(next);
408
- }}
409
- />
384
+ {#if typeof columnDef.header === 'function'}
385
+ <!-- FIXME this needs testing -->
386
+ {@render columnDef.header()}
387
+ {:else}
388
+ {columnDef.header}
389
+ {/if}
390
+ </span>
391
+
392
+ <div class="row-controls">
393
+ <div class="visibility-wrapper">
394
+ <Checkbox
395
+ icon={{ name: 'assessment', size: Size.MediumSmall }}
396
+ size={Size.FillWidth}
397
+ id={`is-visible-${tableContext.tableName}-${columnDef.id}`}
398
+ value={tableContext.columnVisibility[columnDef.id] !== false}
399
+ onchange={onVisibilityChange}
400
+ />
401
+ </div>
402
+
403
+ {#if tableContext.allowColumnReordering}
404
+ <!--
405
+ Tri-state pin selector rendered via `ThreeStateRadio`,
406
+ matching the visual segmented-control used elsewhere in
407
+ the table (filter Active state, etc.). The three
408
+ `Ternary` slots map to:
409
+ False → pinned LEFT (pin-left icon, leftmost)
410
+ Neutral → unpinned (circle-cross icon, middle)
411
+ True → pinned RIGHT (pin-right icon, rightmost)
412
+ Expand columns can never be pinned, so the group goes
413
+ fully disabled for them.
414
+ -->
415
+ <div
416
+ class="pin-selector"
417
+ class:disabled={isExpandColumn}
418
+ >
419
+ <ThreeStateRadio
420
+ value={pinSide}
421
+ disabled={isExpandColumn}
422
+ noLabel="Pin to left"
423
+ neutralLabel="Unpinned"
424
+ yesLabel="Pin to right"
425
+ noIcon={{ name: 'pin-left', size: Size.MediumSmall }}
426
+ neutralIcon={{ name: 'circle-cross', size: Size.MediumSmall }}
427
+ yesIcon={{ name: 'pin-right', size: Size.MediumSmall }}
428
+ onchange={(next: Ternary) => {
429
+ setPin(next);
430
+ }}
431
+ />
432
+ </div>
433
+ {/if}
410
434
  </div>
411
- {/if}
412
-
413
- <span
414
- class="column-label"
415
- title={typeof columnDef.header === 'string' ? columnDef.header : columnDef.id}
416
- >
417
- {#if typeof columnDef.header === 'function'}
418
- <!-- FIXME this needs testing -->
419
- {@render columnDef.header()}
420
- {:else}
421
- {columnDef.header}
422
- {/if}
423
- </span>
435
+ </div>
424
436
 
425
437
  {#if tableContext.allowColumnReordering}
426
438
  <!--
@@ -481,10 +493,11 @@ li.targeted {
481
493
 
482
494
  li.single-column:not(.anchor) {
483
495
  display: flex;
484
- gap: 0.75rem;
485
- padding: 0.5rem 0.75rem;
486
- margin-bottom: 0.5rem;
496
+ flex-flow: row nowrap;
487
497
  align-items: center;
498
+ gap: 0.5rem;
499
+ padding: 0.5rem 0.625rem;
500
+ margin-bottom: 0.375rem;
488
501
  background-color: #ffffff;
489
502
  border: solid 1px #cbd4da;
490
503
  border-radius: 4px;
@@ -502,15 +515,30 @@ li.single-column:not(.anchor):last-of-type {
502
515
  margin-bottom: 0;
503
516
  }
504
517
 
518
+ .row-main {
519
+ flex: 1 1 auto;
520
+ min-width: 0;
521
+ display: flex;
522
+ flex-flow: column nowrap;
523
+ gap: 0.375rem;
524
+ }
525
+
526
+ .row-controls {
527
+ display: flex;
528
+ flex-flow: row nowrap;
529
+ align-items: center;
530
+ gap: 0.5rem;
531
+ }
532
+
505
533
  .visibility-wrapper {
506
- width: 3rem;
507
- height: 3rem;
534
+ width: 2.25rem;
535
+ height: 2.25rem;
508
536
  display: flex;
509
537
  flex-shrink: 0;
510
538
  }
511
539
 
512
540
  .pin-selector {
513
- --input-height: 3rem;
541
+ --input-height: 2.25rem;
514
542
  --input-width: auto;
515
543
  flex-shrink: 0;
516
544
  display: flex;
@@ -522,10 +550,12 @@ li.single-column:not(.anchor):last-of-type {
522
550
  }
523
551
 
524
552
  .column-label {
525
- flex: 1 1 auto;
553
+ width: 100%;
526
554
  min-width: 0;
527
555
  font-size: 1.125rem;
528
- line-height: 1.2;
556
+ font-weight: 500;
557
+ line-height: 1.25;
558
+ color: #3a4952;
529
559
  display: -webkit-box;
530
560
  -webkit-line-clamp: 2;
531
561
  -webkit-box-orient: vertical;
@@ -188,6 +188,7 @@ div.bubble-parent {
188
188
  display: flex;
189
189
  flex-flow: row wrap;
190
190
  max-width: 100%;
191
+ height: 100%;
191
192
  overflow-x: clip;
192
193
  justify-content: flex-start;
193
194
  align-items: center;
@@ -183,6 +183,11 @@
183
183
  font-size: 0.875rem;
184
184
  }
185
185
 
186
+ .datatable-row.quick-filter-row > .cell {
187
+ border-right: solid 1px #cbd4da;
188
+ box-sizing: border-box;
189
+ }
190
+
186
191
  .datatable-row.quick-filter-row {
187
192
  transition: line-height 0.2s ease, height 0.2s ease;
188
193
  height: 61px;
@@ -28,16 +28,4 @@ export interface ColumnSpecInternals<TRow extends object> {
28
28
  * spec was not produced by `createColumnFactory(...)` (defensive; the
29
29
  * public type's `_scoriaColumnSpec` brand prevents this at compile time). */
30
30
  export declare function unwrapColumnSpec<TRow extends object>(spec: ColumnSpec<TRow>): ColumnSpecInternals<TRow>;
31
- /**
32
- * Construct a `ColumnFactory<TRow>` for use inside the `columns:` closure of
33
- * `createTable({ ... })`. Each method on the returned object is a typed
34
- * builder that wraps the matching internal `<Name>Def` constructor and
35
- * returns a public `ColumnSpec` whose internal payload is hidden behind a
36
- * symbol-keyed property.
37
- *
38
- * Consumers do not call this directly. It is exported so the `createTable`
39
- * implementation can instantiate it inside the user-supplied closure and so
40
- * tests can exercise individual factory methods without round-tripping
41
- * through a full `createTable(...)` call.
42
- */
43
31
  export declare function createColumnFactory<TRow extends object>(): ColumnFactory<TRow>;
@@ -178,6 +178,30 @@ function normaliseOptionsLoader(options) {
178
178
  * tests can exercise individual factory methods without round-tripping
179
179
  * through a full `createTable(...)` call.
180
180
  */
181
+ /** Format a Date or ISO-8601 date string for display using the browser
182
+ * locale. Returns null / empty input unchanged. Used by `col.display` when
183
+ * the column declares `filterValueType: 'Date'` so backend timestamps
184
+ * surface as a properly localised date+time string instead of the raw
185
+ * ISO payload (`2026-04-16T09:44:11+01:00`). */
186
+ function formatLocaleDate(value) {
187
+ if (value == null || value === '') {
188
+ return value;
189
+ }
190
+ let d;
191
+ if (value instanceof Date) {
192
+ d = value;
193
+ }
194
+ else if (typeof value === 'string' || typeof value === 'number') {
195
+ const parsed = new Date(value);
196
+ if (!Number.isNaN(parsed.getTime())) {
197
+ d = parsed;
198
+ }
199
+ }
200
+ if (d == null) {
201
+ return value;
202
+ }
203
+ return d.toLocaleString();
204
+ }
181
205
  export function createColumnFactory() {
182
206
  const factory = {
183
207
  // ─── Accessor kinds ─────────────────────────────────────────
@@ -301,10 +325,26 @@ export function createColumnFactory() {
301
325
  : opts.filterValueType === 'String'
302
326
  ? 'text'
303
327
  : 'display';
328
+ // When the consumer flagged the column as a Date filter, auto-
329
+ // format the displayed value via `toLocaleString` so timestamps
330
+ // surface in the user's locale rather than the raw ISO 8601
331
+ // payload returned by the server. Promise / scalar / null
332
+ // inputs are all handled. Filter / sort logic still operates
333
+ // on the raw accessor value below.
334
+ const rawValue = opts.value;
335
+ const valueAccessor = opts.filterValueType === 'Date'
336
+ ? (row, index) => {
337
+ const v = rawValue(row, index);
338
+ if (v instanceof Promise) {
339
+ return v.then(formatLocaleDate);
340
+ }
341
+ return formatLocaleDate(v);
342
+ }
343
+ : rawValue;
304
344
  const def = new DisplayDef({
305
345
  id,
306
346
  ...commonProps(opts, inferredKind),
307
- accessorFn: opts.value,
347
+ accessorFn: valueAccessor,
308
348
  modal: opts.modal,
309
349
  // Display columns have no implicit filter type (unlike the
310
350
  // typed kinds where the factory pins one based on the
@@ -35,6 +35,24 @@
35
35
 
36
36
  const customFilter = $derived(customFilters?.get(def.id));
37
37
 
38
+ /** Human-readable label used by every inner input (QuerySelect /
39
+ * MultiSelect / SingleSelect / etc.). Priority:
40
+ * 1. The column's `header` (when it is a plain string, which is the
41
+ * usual case in flowms migrations).
42
+ * 2. The customFilter's `label` (set on the remoteFilters entry).
43
+ * 3. The raw `customFilter.id` (last-ditch fallback).
44
+ * Avoids surfacing dotted-path / camelCase IDs like
45
+ * `orderLines.allowSplitFulfilment` as the visible label. */
46
+ const displayLabel: string = $derived.by(() => {
47
+ if (typeof def.header === 'string' && def.header.length > 0) {
48
+ return def.header;
49
+ }
50
+ if (customFilter?.label) {
51
+ return customFilter.label;
52
+ }
53
+ return customFilter?.id ?? def.id;
54
+ });
55
+
38
56
  const isRemoteSelect = $derived(customFilter?.type === CustomFilterType.Select);
39
57
 
40
58
  let value: unknown = $state(null);
@@ -263,7 +281,7 @@
263
281
  {#if isSearchable}
264
282
  <!-- TODO remove this check once datePicker is supported -->
265
283
  {#if def instanceof DateInputDef || def instanceof CheckboxDef}
266
- <label for={inputId}>{def.header}</label>
284
+ <label for={inputId}>{displayLabel}</label>
267
285
  {/if}
268
286
  <div class="row">
269
287
  {#if def instanceof DateInputDef}
@@ -353,7 +371,7 @@
353
371
  {#await options then options}
354
372
  {#if hasQuery(customFilter)}
355
373
  {#if customFilter.valueAsObject !== true}
356
- {@const label = customFilter.id}
374
+ {@const label = displayLabel}
357
375
 
358
376
  <!-- TODO: multiselect -->
359
377
 
@@ -370,7 +388,7 @@
370
388
  onchange={generateOnChangePrimitive(label)}
371
389
  />
372
390
  {:else}
373
- {@const label = customFilter.id}
391
+ {@const label = displayLabel}
374
392
 
375
393
  <!-- TODO: multiselect -->
376
394
 
@@ -389,7 +407,7 @@
389
407
  {/if}
390
408
  {:else}
391
409
  <MultiSelect
392
- label={customFilter.id}
410
+ label={displayLabel}
393
411
  labelProp="label"
394
412
  valueProp="value"
395
413
  name={customFilter.objectName ?? 'Item'}
@@ -28,7 +28,12 @@
28
28
  > = Object.freeze({
29
29
  allowCopy: false,
30
30
  enableResize: false,
31
- allowColumnReordering: false,
31
+ // Was `false`; flipped to `true` so nested-table columns can be
32
+ // reordered and pinned by default like the outer table. The
33
+ // `inner.allowReorder` option on `col.expand` (and the matching
34
+ // `allowReorder` on top-level `createTable`) still lets callers
35
+ // opt out per inner table when they want to lock the layout.
36
+ allowColumnReordering: true,
32
37
  allowQuickFiltering: true,
33
38
  allowAdvancedFiltering: true,
34
39
  toolbar: ToolbarPosition.None,
@@ -129,10 +129,21 @@
129
129
  }
130
130
 
131
131
  element?.focus({ preventScroll: true });
132
+ // Same pinned-cell horizontal-scroll skip as the focusEnd
133
+ // effect below. Sticky-pinned cells are always visually
134
+ // in view, but their underlying layout position is at
135
+ // their real column track, so an unconditional
136
+ // `inline: 'nearest'` here scrolls the table sideways
137
+ // every time focus lands inside a pinned column (e.g.
138
+ // every click on a right-pinned Actions button).
139
+ const startCell = element?.closest('[role="cell"]');
140
+ const isStartPinned =
141
+ startCell?.classList.contains('pinned-left') ||
142
+ startCell?.classList.contains('pinned-right');
132
143
  element?.scrollIntoView({
133
144
  behavior: 'instant',
134
145
  block: 'nearest',
135
- inline: 'nearest',
146
+ inline: isStartPinned ? undefined : 'nearest',
136
147
  });
137
148
  }
138
149
  });
@@ -156,10 +167,25 @@
156
167
  `#${tableContext.tableName}-cell-${row}-${def.id}`
157
168
  ) as HTMLElement | undefined;
158
169
 
170
+ // Skip horizontal scroll-into-view for pinned (sticky)
171
+ // cells. Sticky positioning makes the cell visually
172
+ // "always on screen", but its true layout position is at
173
+ // its real column track - which for a right-pinned column
174
+ // in a wider-than-viewport table is offscreen. Asking the
175
+ // browser to `scrollIntoView({inline: 'nearest'})` then
176
+ // scrolls the table horizontally to bring the cell's
177
+ // underlying position into view, which is the user-facing
178
+ // "table jumps right when I click the Actions button"
179
+ // bug. The vertical block axis still scrolls because the
180
+ // row's vertical position is the user's actual scroll
181
+ // target.
182
+ const isPinned =
183
+ cell?.classList.contains('pinned-left') ||
184
+ cell?.classList.contains('pinned-right');
159
185
  cell?.scrollIntoView({
160
186
  behavior: 'instant',
161
187
  block: 'nearest',
162
- inline: 'nearest',
188
+ inline: isPinned ? undefined : 'nearest',
163
189
  });
164
190
  }
165
191
  });
@@ -1869,6 +1869,17 @@
1869
1869
  overflow: visible;
1870
1870
  padding-left: 0;
1871
1871
  padding-right: 0;
1872
+ // Reassert the column-separator border-right here so it survives
1873
+ // even when a narrow select-filter cell's internal widget (the
1874
+ // SingleSelect / MultiSelect dropdown trigger) paints flush to
1875
+ // the right edge. Without this rule, two adjacent select filters
1876
+ // visually merge because the inner widget's background covers
1877
+ // the cell's table-cell-basic border. `!important` is here to
1878
+ // override the more-specific .focusable rule from
1879
+ // table-cell-basic that occasionally re-paints the right edge
1880
+ // transparent during focus.
1881
+ border-right: solid 1px colours.$ui-blue-4 !important;
1882
+ box-sizing: border-box;
1872
1883
 
1873
1884
  div.stick-right {
1874
1885
  position: absolute;
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.37.17",
4
+ "version": "0.37.19",
5
5
  "publishConfig": {
6
6
  "access": "restricted"
7
7
  },