runeforge 0.0.54 → 0.0.56

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.
package/README.md CHANGED
@@ -379,6 +379,7 @@ Every entry in an `InterfaceMetadata<T>` object is an `AttributeMetadata` — a
379
379
  | `formatter` | `(data) => (value, row) => string` | all | Custom cell text — see [Formatters](#formatters) |
380
380
  | `excludedFromList/Create/Read/Update` | `boolean` | all | Hides the field from that specific view |
381
381
  | `sortable` / `filterable` | `boolean` | all | Table column controls |
382
+ | `filterOptions` | `SelectOption[]` | all | Static, exhaustive column filter choices, replacing the sampled-from-loaded-rows checkbox list — see [Server-side pagination, sorting & filtering](#server-side-pagination-sorting--filtering) |
382
383
 
383
384
  ### Validation
384
385
 
@@ -963,6 +964,23 @@ export const load: PageServerLoad = ({ url }) => {
963
964
 
964
965
  No other prop changes are needed — column sorting/filtering UI, the paginator, and (with `config.export.callback`) export all keep working the same way, just backed by the server instead of the in-memory array. Boolean-column filters send comma-separated values (`?active=true,false`); date-range filters send `<attribute>_from`/`<attribute>_to`.
965
966
 
967
+ A text column's filter checkbox list is populated from values seen on the currently loaded page — a cosmetic hint in server mode, not an exhaustive list, since the full set of values lives server-side. For a column whose possible values are a known, bounded set (an enum-like field, a small lookup table), give it `filterOptions` instead so every choice always shows up, regardless of what the current page contains:
968
+
969
+ ```ts
970
+ status: {
971
+ label: 'Status',
972
+ type: AttributeType.text,
973
+ filterable: true,
974
+ filterOptions: [
975
+ { value: 'DRAFT', label: 'Draft' },
976
+ { value: 'PUBLISHED', label: 'Published' },
977
+ { value: 'ARCHIVED', label: 'Archived' },
978
+ ],
979
+ },
980
+ ```
981
+
982
+ `value` is matched against the column's rendered cell text and sent server-side as-is (same as any other checkbox filter value); `label` is only what's displayed, falling back to `value`.
983
+
966
984
  ### PaginatedTable
967
985
 
968
986
  A standalone table component with built-in sort, filter, and pagination — the same engine `GenericCRUD` uses internally.
@@ -93,6 +93,43 @@
93
93
  return String(n).padStart(2, '0');
94
94
  }
95
95
 
96
+ // ISO (yyyy-mm-dd) <-> display (dd/mm/yyyy) conversion and typing mask.
97
+ function isoDateToDisplay(iso: string): string {
98
+ const [year, month, day] = iso.split('-');
99
+ return `${day}/${month}/${year}`;
100
+ }
101
+
102
+ function displayToIsoDate(display: string): string | null {
103
+ const match = display.trim().match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
104
+ if (!match) return null;
105
+ const [, dd, mm, yyyy] = match;
106
+ const day = Number(dd);
107
+ const month = Number(mm);
108
+ const year = Number(yyyy);
109
+ // Rejects invalid dates (e.g. 31/02).
110
+ const date = new Date(Date.UTC(year, month - 1, day));
111
+ if (
112
+ date.getUTCFullYear() !== year ||
113
+ date.getUTCMonth() !== month - 1 ||
114
+ date.getUTCDate() !== day
115
+ ) {
116
+ return null;
117
+ }
118
+ return `${yyyy}-${mm.padStart(2, '0')}-${dd.padStart(2, '0')}`;
119
+ }
120
+
121
+ // Inserts "/" as digits are typed: "21" -> "21/", "211" -> "21/1".
122
+ function maskDateDigits(digits: string): string {
123
+ const day = digits.slice(0, 2);
124
+ const month = digits.slice(2, 4);
125
+ const year = digits.slice(4, 8);
126
+ let out = day;
127
+ if (digits.length >= 2) out += '/';
128
+ out += month;
129
+ if (digits.length >= 4) out += '/';
130
+ return out + year;
131
+ }
132
+
96
133
  // `record[field.attribute]` may be empty, a bare "YYYY-MM-DD" (freshly
97
134
  // picked, before any time is set) or a full ISO datetime (loaded from an
98
135
  // existing instance) — parsed once here so the date button, the time
@@ -112,16 +149,47 @@
112
149
  const timePart = $derived(
113
150
  parsedDateTime ? `${pad(parsedDateTime.getHours())}:${pad(parsedDateTime.getMinutes())}` : ''
114
151
  );
115
- const formattedDatePart = $derived(
116
- parsedDateTime
117
- ? `${pad(parsedDateTime.getDate())}/${pad(parsedDateTime.getMonth() + 1)}/${parsedDateTime.getFullYear()}`
118
- : ''
119
- );
120
152
 
121
153
  function setDateTime(nextDatePart: string, nextTimePart: string) {
122
154
  record[field.attribute] = nextDatePart ? `${nextDatePart}T${nextTimePart || '00:00'}` : '';
123
155
  }
124
156
 
157
+ // Typed display text for the date part, synced from `datePart`.
158
+ // eslint-disable-next-line svelte/prefer-writable-derived
159
+ let dateTextValue = $state('');
160
+ $effect(() => {
161
+ dateTextValue = datePart ? isoDateToDisplay(datePart) : '';
162
+ });
163
+
164
+ function onDateTextInput(event: Event) {
165
+ const raw = (event.currentTarget as HTMLInputElement).value;
166
+ const prevDigits = dateTextValue.replace(/\D/g, '');
167
+ let digits = raw.replace(/\D/g, '');
168
+ // Drops one extra digit when backspacing over an inserted "/".
169
+ if (raw.length < dateTextValue.length && digits.length === prevDigits.length) {
170
+ digits = digits.slice(0, -1);
171
+ }
172
+ digits = digits.slice(0, 8);
173
+ dateTextValue = maskDateDigits(digits);
174
+ if (!digits) {
175
+ setDateTime('', timePart);
176
+ return;
177
+ }
178
+ const parsed = displayToIsoDate(dateTextValue);
179
+ if (parsed) setDateTime(parsed, timePart);
180
+ }
181
+
182
+ function onDateTextBlur() {
183
+ if (!dateTextValue.trim()) {
184
+ setDateTime('', timePart);
185
+ return;
186
+ }
187
+ if (!displayToIsoDate(dateTextValue)) {
188
+ // Discards invalid input.
189
+ dateTextValue = datePart ? isoDateToDisplay(datePart) : '';
190
+ }
191
+ }
192
+
125
193
  $effect(() => {
126
194
  if (!field.dependentOptions || isMultiValued) return;
127
195
  const current = record[field.attribute];
@@ -226,16 +294,41 @@
226
294
  disabled={fieldDisabled}
227
295
  />
228
296
  <div class="flex gap-2">
229
- <button
230
- type="button"
231
- class="input input-bordered w-full text-left font-normal"
232
- class:opacity-40={!record[field.attribute]}
233
- disabled={fieldDisabled}
234
- popovertarget={datePopId}
235
- style="anchor-name:{dateAnchorName}"
236
- >
237
- {record[field.attribute] ? formattedDatePart : (field.placeholder ?? '')}
238
- </button>
297
+ <div class="relative flex-1" style="anchor-name:{dateAnchorName}">
298
+ <input
299
+ type="text"
300
+ id={field.attribute}
301
+ placeholder={field.placeholder ?? 'dd/mm/aaaa'}
302
+ value={dateTextValue}
303
+ oninput={onDateTextInput}
304
+ onblur={onDateTextBlur}
305
+ autocomplete="off"
306
+ disabled={fieldDisabled}
307
+ class="input input-bordered w-full pr-9"
308
+ class:input-error={!!error}
309
+ />
310
+ <button
311
+ type="button"
312
+ popovertarget={datePopId}
313
+ aria-label={strings.chooseDate}
314
+ disabled={fieldDisabled}
315
+ class="absolute inset-y-0 right-0 flex items-center px-2.5 text-base-content/50 hover:text-base-content disabled:pointer-events-none disabled:opacity-40"
316
+ >
317
+ <svg
318
+ class="size-4"
319
+ xmlns="http://www.w3.org/2000/svg"
320
+ viewBox="0 0 24 24"
321
+ fill="none"
322
+ stroke="currentColor"
323
+ stroke-width="2"
324
+ stroke-linecap="round"
325
+ stroke-linejoin="round"
326
+ >
327
+ <rect x="3" y="4" width="18" height="18" rx="2" />
328
+ <path d="M16 2v4M8 2v4M3 10h18" />
329
+ </svg>
330
+ </button>
331
+ </div>
239
332
  <input
240
333
  type="time"
241
334
  class="input input-bordered w-32 shrink-0"
@@ -219,6 +219,7 @@
219
219
  component: m.component,
220
220
  sortable: m.sortable,
221
221
  filterable: m.filterable,
222
+ filterOptions: m.filterOptions,
222
223
  fields: embeddedFields,
223
224
  itemLabel: m.itemLabel
224
225
  };
@@ -5,8 +5,7 @@
5
5
  import TableHeader from './TableHeader.svelte';
6
6
  import { SortState, FilterState, snapshotFilter } from './state.svelte.js';
7
7
  import {
8
- distinctEntries,
9
- isFilterable,
8
+ resolveDistinctValues,
10
9
  resolveReorderComparator
11
10
  } from './utils.js';
12
11
  import type {
@@ -83,37 +82,14 @@
83
82
  const serverFilterSampleSize = 5;
84
83
 
85
84
  // Server mode: there's no way to know every value a column can take without
86
- // querying the whole (server-owned) dataset, so this is deliberately just a
87
- // cosmetic hint — up to `serverFilterSampleSize` distinct values found on
88
- // the current page, not an exhaustive list. Boolean is the one exception:
89
- // its two states are always known, so both show up regardless of what the
90
- // current page happens to contain.
85
+ // querying the whole (server-owned) dataset, so a plain text column's
86
+ // entries are deliberately just a cosmetic hint — up to
87
+ // `serverFilterSampleSize` distinct values found on the current page, not
88
+ // an exhaustive list. `boolean` columns and columns with `filterOptions`
89
+ // are the exceptions: their full set of choices is always known upfront,
90
+ // so it shows up regardless of what the current page happens to contain.
91
91
  const distinctValues = $derived(
92
- pagination
93
- ? {
94
- ...Object.fromEntries(
95
- Object.entries(
96
- distinctEntries(
97
- data,
98
- columns.filter((c) => isFilterable(c) && c.type !== 'boolean')
99
- )
100
- ).map(([attribute, entries]) => [attribute, entries.slice(0, serverFilterSampleSize)])
101
- ),
102
- ...Object.fromEntries(
103
- columns
104
- .filter((c) => isFilterable(c) && c.type === 'boolean')
105
- .map((c) => [
106
- c.attribute,
107
- [
108
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
109
- { key: 'true', label: c.formatter?.(true as any, {} as T), row: {} as T },
110
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
111
- { key: 'false', label: c.formatter?.(false as any, {} as T), row: {} as T }
112
- ]
113
- ])
114
- )
115
- }
116
- : distinctEntries(data, columns)
92
+ resolveDistinctValues(data, columns, pagination ? serverFilterSampleSize : undefined)
117
93
  );
118
94
 
119
95
  // Server-pagination mode owns its own full row set server-side, where
@@ -5,6 +5,15 @@ export declare function isSortable<T extends object>(col: ColumnDefinition<T>):
5
5
  export declare function isFilterable<T extends object>(col: ColumnDefinition<T>): boolean;
6
6
  export declare function compare(a: unknown, b: unknown): number;
7
7
  export declare function distinctEntries<T extends object>(data: T[], columns: ColumnDefinition<T>[]): Record<string, DistinctEntry<T>[]>;
8
+ /** Resolves the checkbox-list entries for every filterable column: a
9
+ * column's own `filterOptions` wins when present (a static, exhaustive
10
+ * list — see its doc comment); a `boolean` column always gets its two known
11
+ * states; everything else falls back to values actually seen in `data`,
12
+ * truncated to `sampleSize` when given. Sampling off `data` is only ever a
13
+ * hint under server-side pagination, where `data` is just the current
14
+ * page — pass `sampleSize` there; omit it in client mode, where `data` is
15
+ * the complete, already-filtered row set. */
16
+ export declare function resolveDistinctValues<T extends object>(data: T[], columns: ColumnDefinition<T>[], sampleSize?: number): Record<string, DistinctEntry<T>[]>;
8
17
  /** Returns a copy of `rows` with the item at `from` moved to `to` — pure
9
18
  * array surgery, handy for building custom reorder UIs on `PaginatedTable`.
10
19
  * Out-of-range indices are a no-op. */
@@ -42,6 +42,41 @@ export function distinctEntries(data, columns) {
42
42
  }
43
43
  return result;
44
44
  }
45
+ /** Resolves the checkbox-list entries for every filterable column: a
46
+ * column's own `filterOptions` wins when present (a static, exhaustive
47
+ * list — see its doc comment); a `boolean` column always gets its two known
48
+ * states; everything else falls back to values actually seen in `data`,
49
+ * truncated to `sampleSize` when given. Sampling off `data` is only ever a
50
+ * hint under server-side pagination, where `data` is just the current
51
+ * page — pass `sampleSize` there; omit it in client mode, where `data` is
52
+ * the complete, already-filtered row set. */
53
+ export function resolveDistinctValues(data, columns, sampleSize) {
54
+ const result = {};
55
+ for (const col of columns) {
56
+ if (!isFilterable(col))
57
+ continue;
58
+ if (col.filterOptions) {
59
+ result[col.attribute] = col.filterOptions.map((o) => ({
60
+ key: o.value,
61
+ label: o.label,
62
+ row: {},
63
+ }));
64
+ continue;
65
+ }
66
+ if (col.type === 'boolean') {
67
+ result[col.attribute] = [
68
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
69
+ { key: 'true', label: col.formatter?.(true, {}), row: {} },
70
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
71
+ { key: 'false', label: col.formatter?.(false, {}), row: {} },
72
+ ];
73
+ continue;
74
+ }
75
+ const entries = distinctEntries(data, [col])[col.attribute] ?? [];
76
+ result[col.attribute] = sampleSize != null ? entries.slice(0, sampleSize) : entries;
77
+ }
78
+ return result;
79
+ }
45
80
  /** Returns a copy of `rows` with the item at `from` moved to `to` — pure
46
81
  * array surgery, handy for building custom reorder UIs on `PaginatedTable`.
47
82
  * Out-of-range indices are a no-op. */
package/dist/i18n/en.js CHANGED
@@ -8,6 +8,7 @@ export const en = {
8
8
  emptyValue: '(empty)',
9
9
  previous: 'Previous',
10
10
  next: 'Next',
11
+ chooseDate: 'Choose date from calendar',
11
12
  selectPlaceholder: 'Select an option',
12
13
  selectSearch: 'Search...',
13
14
  selectSearching: 'Searching...',
package/dist/i18n/es.js CHANGED
@@ -8,6 +8,7 @@ export const es = {
8
8
  emptyValue: '(vacío)',
9
9
  previous: 'Anterior',
10
10
  next: 'Siguiente',
11
+ chooseDate: 'Elegir fecha en el calendario',
11
12
  selectPlaceholder: 'Seleccioná una opción',
12
13
  selectSearch: 'Buscar...',
13
14
  selectSearching: 'Buscando...',
@@ -8,6 +8,7 @@ export interface RuneforgeStrings {
8
8
  emptyValue: string;
9
9
  previous: string;
10
10
  next: string;
11
+ chooseDate: string;
11
12
  selectPlaceholder: string;
12
13
  selectSearch: string;
13
14
  selectSearching: string;
@@ -75,6 +75,15 @@ export type AttributeMetadata = {
75
75
  excludedFromRead?: boolean;
76
76
  sortable?: boolean;
77
77
  filterable?: boolean;
78
+ /** List column filter only: a static, exhaustive set of choices shown in
79
+ * the column filter's checkbox list, instead of the values sampled off
80
+ * currently loaded rows. For a bounded, known set (e.g. an enum-like text
81
+ * column) that the loaded page might not fully represent — especially
82
+ * under [server-side pagination](#server-side-pagination-sorting--filtering),
83
+ * where the sample is only a handful of values off the current page. `value`
84
+ * is matched against the rendered cell text (and sent server-side as-is);
85
+ * `label` is only what's displayed, falling back to `value`. */
86
+ filterOptions?: SelectOption[];
78
87
  groupedAs?: string;
79
88
  min?: number;
80
89
  max?: number;
@@ -12,6 +12,8 @@ export type ColumnDefinition<T extends object = Record<string, unknown>> = {
12
12
  formatter?: CellFormatter<T, T[K]>;
13
13
  sortable?: boolean;
14
14
  filterable?: boolean;
15
+ /** Static, exhaustive filter choices — see `AttributeMetadata.filterOptions`. */
16
+ filterOptions?: SelectOption[];
15
17
  /** Embedded columns only: sub-field definitions for each item, used to
16
18
  * render a default cell summary and to expand the column into one
17
19
  * sub-column per field on CSV/XLSX export. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runeforge",
3
- "version": "0.0.54",
3
+ "version": "0.0.56",
4
4
  "description": "SvelteKit toolkit for building metadata-driven CRUD interfaces with tables, forms, and actions",
5
5
  "license": "MIT",
6
6
  "author": "Ezequiel Puerta",