@lavalogic/scoria 0.37.18 → 0.37.20

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.
@@ -724,10 +724,11 @@
724
724
  div.input-cell {
725
725
  background-color: #f4f7f9;
726
726
  height: var(--height);
727
- overflow: visible;
727
+ overflow-x: clip;
728
+ overflow-y: visible;
728
729
  padding-left: 0;
729
730
  padding-right: 0;
730
- border-right: solid 1px #cbd4da !important;
731
+ border-right: none;
731
732
  box-sizing: border-box;
732
733
  }
733
734
  div.input-cell div.stick-right {
@@ -7,7 +7,6 @@
7
7
  import Action from '../../../Action.svelte';
8
8
  import Checkbox from '../../../Checkbox.svelte';
9
9
  import ThreeStateRadio from '../../../ThreeStateRadio.svelte';
10
- import { ColumnType } from '../../Types/Columns/ColumnType.js';
11
10
  import { TableContext } from '../../Types/Context/TableContext.svelte.js';
12
11
  import type { Primitive } from '../../Types/Context/TableInitOptions.js';
13
12
  import { Size } from '../../../../Types/Internal/Size.js';
@@ -18,7 +17,6 @@
18
17
 
19
18
  const { columnDef }: ColumnListRowProps<T> = $props();
20
19
 
21
- const isExpandColumn = $derived(columnDef?.columnType === ColumnType.Expand);
22
20
 
23
21
  /**
24
22
  * Position of this column inside the sorted column list, used to
@@ -412,13 +410,9 @@
412
410
  Expand columns can never be pinned, so the group goes
413
411
  fully disabled for them.
414
412
  -->
415
- <div
416
- class="pin-selector"
417
- class:disabled={isExpandColumn}
418
- >
413
+ <div class="pin-selector">
419
414
  <ThreeStateRadio
420
415
  value={pinSide}
421
- disabled={isExpandColumn}
422
416
  noLabel="Pin to left"
423
417
  neutralLabel="Unpinned"
424
418
  yesLabel="Pin to right"
@@ -83,11 +83,17 @@
83
83
  >
84
84
  <div class="bubble-parent">
85
85
  {#await values then values}
86
- <!-- The label string itself is not guaranteed unique so the key combines
87
- index and value; both pieces are required because duplicate labels
88
- would otherwise collapse and reordering with identical labels
89
- would force unrelated bubbles to swap. -->
90
- {#each values as value, index (`${index}:${value}`)}
86
+ <!-- `maxBubbles` (default 3) caps how many pills render inline;
87
+ anything beyond gets a single `"+N more"` overflow chip with
88
+ the hidden labels as a hover tooltip. Key combines index and
89
+ value because duplicate labels would otherwise collapse and
90
+ reordering with identical labels would force unrelated
91
+ bubbles to swap. -->
92
+ {@const maxBubbles = (cell.column.columnDef as BubbleDef<T>).maxBubbles ?? 3}
93
+ {@const visibleBubbles = values.slice(0, maxBubbles)}
94
+ {@const overflowCount = Math.max(0, values.length - maxBubbles)}
95
+ {@const overflowTitle = values.slice(maxBubbles).join(', ')}
96
+ {#each visibleBubbles as value, index (`${index}:${value}`)}
91
97
  {@const colourPromise = (cell.column.columnDef as BubbleDef<T>).getColour?.(
92
98
  cell.row.original,
93
99
  cell.row.originalIndex,
@@ -105,6 +111,12 @@
105
111
  {/await}
106
112
  {/if}
107
113
  {/each}
114
+ {#if overflowCount > 0}
115
+ <span
116
+ class="bubble-overflow"
117
+ title={overflowTitle}
118
+ >+{overflowCount} more</span>
119
+ {/if}
108
120
  {/await}
109
121
  </div>
110
122
  </div>
@@ -186,7 +198,7 @@
186
198
 
187
199
  div.bubble-parent {
188
200
  display: flex;
189
- flex-flow: row wrap;
201
+ flex-flow: row nowrap;
190
202
  max-width: 100%;
191
203
  height: 100%;
192
204
  overflow-x: clip;
@@ -194,4 +206,16 @@ div.bubble-parent {
194
206
  align-items: center;
195
207
  gap: 0.75rem;
196
208
  --line-height: 1;
209
+ }
210
+
211
+ .bubble-overflow {
212
+ flex-shrink: 0;
213
+ padding: 0.125rem 0.5rem;
214
+ border-radius: 9999px;
215
+ background-color: #e9ecf1;
216
+ color: #647d8e;
217
+ font-size: 0.75rem;
218
+ font-weight: 500;
219
+ white-space: nowrap;
220
+ cursor: default;
197
221
  }</style>
@@ -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
@@ -330,6 +370,7 @@ export function createColumnFactory() {
330
370
  id,
331
371
  ...commonProps(opts, 'bubble'),
332
372
  accessorFn: opts.value,
373
+ maxBubbles: opts.maxBubbles,
333
374
  getColour: opts.colour,
334
375
  // Default to a string-typed filter so bubble columns appear in
335
376
  // the quick-filter row and advanced filter panel by default
@@ -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
  });
@@ -14,6 +14,10 @@ import { AbstractDisplayDef, type AbstractDisplayDefProps } from './AbstractDisp
14
14
  export interface BubbleDefProps<T extends object, in FilterValueType, out FilterOptionsType> extends AbstractDisplayDefProps<T, FilterValueType, FilterOptionsType> {
15
15
  /** Per-bubble colour mapper. Invoked per bubble value per render. */
16
16
  getColour?: (item: T, index: number, value: string) => BubbleColour | Promise<BubbleColour | null | undefined>;
17
+ /** Maximum number of pills shown per cell before the overflow chip
18
+ * kicks in. Pills beyond this cap are hidden and a single
19
+ * `"+N more"` chip is rendered in their place. Defaults to 3. */
20
+ maxBubbles?: number;
17
21
  }
18
22
  /**
19
23
  * `BubbleDef<T, FilterValueType, FilterOptionsType>` renders the cell
@@ -29,4 +33,6 @@ export declare class BubbleDef<T extends object, in FilterValueType = StringFilt
29
33
  set filterFn(v: FilterFn<T, FilterValueType> | undefined);
30
34
  /** Per-bubble colour mapper. */
31
35
  readonly getColour: ((item: T, index: number, value: string) => BubbleColour | Promise<BubbleColour | null | undefined>) | undefined;
36
+ /** Cap on rendered pills before the overflow chip. */
37
+ readonly maxBubbles: number;
32
38
  }
@@ -15,6 +15,7 @@ export class BubbleDef extends AbstractDisplayDef {
15
15
  // props.getColour re-derives the wrapper and discards the stale cache.
16
16
  this.getColour = $derived(memoiseRowValueAccessor(props.getColour));
17
17
  this._filterFn = $state(props.filterFn);
18
+ this.maxBubbles = $derived(props.maxBubbles ?? 3);
18
19
  }
19
20
  _filterFn;
20
21
  /** Filter predicate. Reassignable. */
@@ -26,4 +27,6 @@ export class BubbleDef extends AbstractDisplayDef {
26
27
  }
27
28
  /** Per-bubble colour mapper. */
28
29
  getColour;
30
+ /** Cap on rendered pills before the overflow chip. */
31
+ maxBubbles;
29
32
  }
@@ -208,6 +208,12 @@ export interface BubbleColumnOptions<TRow extends object, TValue = unknown> exte
208
208
  * supported (scoria internally wraps it in `Promise.resolve`).
209
209
  */
210
210
  getOptions?: () => ReadonlyArray<SelectOption<TValue>> | Promise<ReadonlyArray<SelectOption<TValue>>>;
211
+ /**
212
+ * Maximum number of pills rendered before they collapse into an
213
+ * overflow chip (`"+N more"`). Defaults to 3. Set a larger number
214
+ * to show more pills inline; set `Infinity` to disable the cap.
215
+ */
216
+ maxBubbles?: number;
211
217
  }
212
218
  /** Options for `col.progress({ header, value, ... })`. Renders a horizontal
213
219
  * progress bar from a numeric ratio. */
@@ -1866,19 +1866,22 @@
1866
1866
  div.input-cell {
1867
1867
  background-color: colours.$ui-blue-1;
1868
1868
  height: var(--height);
1869
- overflow: visible;
1869
+ // `overflow-x: clip` so a narrow filter cell's internal widget
1870
+ // (select chevron, filter-mode funnel, etc.) cannot paint past
1871
+ // the column boundary. The column-separator border now lives on
1872
+ // the outer grid item (`.quick-filter-row > .cell` in
1873
+ // `TableBody.svelte`), so clipping here keeps both the widget
1874
+ // AND the cell's own background inside the track and the border
1875
+ // stays visible. `overflow-y: visible` preserves the inline
1876
+ // dropdown spillage for select / date popups.
1877
+ overflow-x: clip;
1878
+ overflow-y: visible;
1870
1879
  padding-left: 0;
1871
1880
  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;
1881
+ // Inner widget no longer paints a border-right - the outer cell
1882
+ // wrapper owns the column divider so adjacent filters cannot
1883
+ // visually merge regardless of what the widget paints inside.
1884
+ border-right: none;
1882
1885
  box-sizing: border-box;
1883
1886
 
1884
1887
  div.stick-right {
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.18",
4
+ "version": "0.37.20",
5
5
  "publishConfig": {
6
6
  "access": "restricted"
7
7
  },