runeforge 0.0.31 → 0.0.33

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.
@@ -208,6 +208,7 @@
208
208
  label: action.label,
209
209
  icon: action.icon,
210
210
  toggle: action.toggle,
211
+ class: action.class,
211
212
  condition: action.condition,
212
213
  run: (item: T) => onAction?.(action, item)
213
214
  })),
@@ -234,10 +235,12 @@
234
235
  <div class="flex justify-end items-center gap-1">
235
236
  {#each rowActions as action (action.label)}
236
237
  {#if action.condition?.(item) ?? true}
238
+ {@const resolvedClass =
239
+ typeof action.class === 'function' ? action.class(item) : action.class}
237
240
  {#if action.toggle}
238
241
  <input
239
242
  type="checkbox"
240
- class={['toggle toggle-sm', action.class]}
243
+ class={['toggle toggle-sm', resolvedClass]}
241
244
  title={action.label}
242
245
  aria-label={action.label}
243
246
  checked={action.toggle(item)}
@@ -251,7 +254,7 @@
251
254
  {@const Icon = action.icon}
252
255
  <Button
253
256
  variant="ghost"
254
- class={['btn-xs', action.class]}
257
+ class={['btn-xs', resolvedClass]}
255
258
  title={action.label}
256
259
  aria-label={action.label}
257
260
  onclick={(e) => {
@@ -1,160 +1,168 @@
1
1
  <script lang="ts" generics="T extends object">
2
- import { onMount } from 'svelte';
3
- import Label from '../form/Label.svelte';
4
- import Button from '../form/Button.svelte';
5
- import { getIconSet } from '../../icons/context.js';
6
- import { defaultIconSet } from '../../icons/sets/default.js';
7
- import { getStrings } from '../../i18n/context.js';
2
+ import { onMount } from 'svelte';
3
+ import Label from '../form/Label.svelte';
4
+ import Button from '../form/Button.svelte';
5
+ import { getIconSet } from '../../icons/context.js';
6
+ import { defaultIconSet } from '../../icons/sets/default.js';
7
+ import { getStrings } from '../../i18n/context.js';
8
8
 
9
- const strings = getStrings();
9
+ const strings = getStrings();
10
10
 
11
- onMount(() => {
12
- import('cally');
13
- });
14
- import type { DistinctEntry } from '../../types/table.js';
15
- import type { FilterState } from './state.svelte.js';
16
- import type { ColumnDefinition } from '../../types/crud.js';
11
+ onMount(() => {
12
+ import('cally');
13
+ });
14
+ import type { DistinctEntry } from '../../types/table.js';
15
+ import type { FilterState } from './state.svelte.js';
16
+ import type { ColumnDefinition } from '../../types/crud.js';
17
17
 
18
- let {
19
- column,
20
- entries = [],
21
- filter,
22
- maxCheckboxValues = 20,
23
- onchange,
24
- }: {
25
- column: ColumnDefinition<T>;
26
- entries?: DistinctEntry<T>[];
27
- filter: FilterState;
28
- maxCheckboxValues?: number;
29
- onchange?: () => void;
30
- } = $props();
18
+ let {
19
+ column,
20
+ entries = [],
21
+ filter,
22
+ maxCheckboxValues = 20,
23
+ onchange
24
+ }: {
25
+ column: ColumnDefinition<T>;
26
+ entries?: DistinctEntry<T>[];
27
+ filter: FilterState;
28
+ maxCheckboxValues?: number;
29
+ onchange?: () => void;
30
+ } = $props();
31
31
 
32
- const icons = $derived(getIconSet() ?? defaultIconSet);
33
- const popId = $derived(`filter-pop-${column.attribute}`);
34
- const anchor = $derived(`--filter-anchor-${column.attribute}`);
35
- const isDatetime = $derived(column.type === 'datetime');
32
+ const icons = $derived(getIconSet() ?? defaultIconSet);
33
+ const popId = $derived(`filter-pop-${column.attribute}`);
34
+ const anchor = $derived(`--filter-anchor-${column.attribute}`);
35
+ const isDatetime = $derived(column.type === 'datetime');
36
36
 
37
- const dateRange = $derived(filter.dateRangeFor(column.attribute));
38
- const calendarValue = $derived(
39
- dateRange.from || dateRange.to ? `${dateRange.from}/${dateRange.to}` : ''
40
- );
37
+ const dateRange = $derived(filter.dateRangeFor(column.attribute));
38
+ const calendarValue = $derived(
39
+ dateRange.from || dateRange.to ? `${dateRange.from}/${dateRange.to}` : ''
40
+ );
41
41
 
42
- let calendarEl: HTMLElement | undefined = $state();
42
+ let calendarEl: HTMLElement | undefined = $state();
43
43
 
44
- $effect(() => {
45
- if (!calendarEl) return;
46
- function handler() {
47
- const value = (calendarEl as HTMLElement & { value: string }).value ?? '';
48
- const [from = '', to = ''] = value.split('/');
49
- filter.setDateRange(column.attribute, from, to);
50
- onchange?.();
51
- }
52
- calendarEl.addEventListener('change', handler);
53
- return () => calendarEl?.removeEventListener('change', handler);
54
- });
44
+ $effect(() => {
45
+ if (!calendarEl) return;
46
+ function handler() {
47
+ const value = (calendarEl as HTMLElement & { value: string }).value ?? '';
48
+ const [from = '', to = ''] = value.split('/');
49
+ filter.setDateRange(column.attribute, from, to);
50
+ onchange?.();
51
+ }
52
+ calendarEl.addEventListener('change', handler);
53
+ return () => calendarEl?.removeEventListener('change', handler);
54
+ });
55
55
 
56
- let debounceTimer: ReturnType<typeof setTimeout>;
57
- function setText(value: string) {
58
- filter.setText(column.attribute, value);
59
- clearTimeout(debounceTimer);
60
- debounceTimer = setTimeout(() => onchange?.(), 300);
61
- }
62
- function toggle(value: string) {
63
- filter.toggleValue(column.attribute, value);
64
- onchange?.();
65
- }
66
- function clear() {
67
- filter.clear(column.attribute);
68
- onchange?.();
69
- }
56
+ let debounceTimer: ReturnType<typeof setTimeout>;
57
+ function setText(value: string) {
58
+ filter.setText(column.attribute, value);
59
+ clearTimeout(debounceTimer);
60
+ debounceTimer = setTimeout(() => onchange?.(), 300);
61
+ }
62
+ function toggle(value: string) {
63
+ filter.toggleValue(column.attribute, value);
64
+ onchange?.();
65
+ }
66
+ function clear() {
67
+ filter.clear(column.attribute);
68
+ onchange?.();
69
+ }
70
70
  </script>
71
71
 
72
72
  <Button
73
- variant="ghost"
74
- class={[
75
- 'btn-xs btn-square',
76
- filter.hasActive(column.attribute) && 'text-primary'
77
- ]}
78
- popovertarget={popId}
79
- style="anchor-name:{anchor}"
80
- aria-label={strings.filterColumn(column.title ?? column.attribute)}
81
- title={strings.filter}
73
+ variant="ghost"
74
+ class={['btn-xs btn-square', filter.hasActive(column.attribute) && 'text-primary']}
75
+ popovertarget={popId}
76
+ style="anchor-name:{anchor}"
77
+ aria-label={strings.filterColumn(column.title ?? column.attribute)}
78
+ title={strings.filter}
82
79
  >
83
- {#if filter.hasActive(column.attribute)}
84
- {@const Icon = icons.filterActive}
85
- <Icon class="size-3.5" />
86
- {:else}
87
- {@const Icon = icons.filter}
88
- <Icon class="size-3.5" />
89
- {/if}
80
+ {#if filter.hasActive(column.attribute)}
81
+ {@const Icon = icons.filterActive}
82
+ <Icon class="size-3.5" />
83
+ {:else}
84
+ {@const Icon = icons.filter}
85
+ <Icon class="size-3.5" />
86
+ {/if}
90
87
  </Button>
91
88
 
92
89
  <div
93
- popover="auto"
94
- id={popId}
95
- style="position-anchor:{anchor}; position-try-fallbacks:flip-block;"
96
- class="dropdown dropdown-end rounded-box border border-base-content/10 bg-base-100 p-3 shadow-lg"
97
- class:w-56={!isDatetime}
90
+ popover="auto"
91
+ id={popId}
92
+ style="position-anchor:{anchor}; position-try-fallbacks:flip-block;"
93
+ class="dropdown dropdown-end rounded-box border border-base-content/10 bg-base-100 p-3 shadow-lg"
94
+ class:w-56={!isDatetime}
98
95
  >
99
- {#if isDatetime}
100
- <calendar-range
101
- class="cally bg-base-100"
102
- value={calendarValue}
103
- bind:this={calendarEl}
104
- >
105
- <svg aria-label={strings.previous} class="fill-current size-4" {...{"slot": "previous"}} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M15.75 19.5 8.25 12l7.5-7.5"/></svg>
106
- <svg aria-label={strings.next} class="fill-current size-4" {...{"slot": "next"}} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="m8.25 4.5 7.5 7.5-7.5 7.5"/></svg>
107
- <calendar-month></calendar-month>
108
- </calendar-range>
109
- {#if dateRange.from || dateRange.to}
110
- <p class="mt-2 text-center text-xs text-base-content/60">
111
- {dateRange.from || ''} → {dateRange.to || '…'}
112
- </p>
113
- {/if}
114
- {#if filter.hasActive(column.attribute)}
115
- <Button variant="ghost" class="btn-sm mt-2 w-full" onclick={clear}>
116
- {strings.clearFilter}
117
- </Button>
118
- {/if}
119
- {:else}
120
- <div class="relative">
121
- <input
122
- type="text"
123
- class={['input input-bordered input-sm w-full', filter.hasActive(column.attribute) && 'pr-7']}
124
- placeholder={strings.filterPlaceholder}
125
- value={filter.textFor(column.attribute)}
126
- oninput={(e) => setText(e.currentTarget.value)}
127
- />
128
- {#if filter.hasActive(column.attribute)}
129
- {@const Icon = icons.clear}
130
- <Button
131
- variant="ghost"
132
- class="btn-xs btn-square btn-circle absolute top-1/2 right-1 -translate-y-1/2"
133
- aria-label={strings.clearFilter}
134
- title={strings.clearFilter}
135
- onclick={clear}
136
- >
137
- <Icon class="size-3" />
138
- </Button>
139
- {/if}
140
- </div>
96
+ {#if isDatetime}
97
+ <calendar-range class="cally bg-base-100" value={calendarValue} bind:this={calendarEl}>
98
+ <svg
99
+ aria-label={strings.previous}
100
+ class="fill-current size-4"
101
+ {...{ slot: 'previous' }}
102
+ xmlns="http://www.w3.org/2000/svg"
103
+ viewBox="0 0 24 24"><path fill="currentColor" d="M15.75 19.5 8.25 12l7.5-7.5" /></svg
104
+ >
105
+ <svg
106
+ aria-label={strings.next}
107
+ class="fill-current size-4"
108
+ {...{ slot: 'next' }}
109
+ xmlns="http://www.w3.org/2000/svg"
110
+ viewBox="0 0 24 24"><path fill="currentColor" d="m8.25 4.5 7.5 7.5-7.5 7.5" /></svg
111
+ >
112
+ <calendar-month></calendar-month>
113
+ </calendar-range>
114
+ {#if dateRange.from || dateRange.to}
115
+ <p class="mt-2 text-center text-xs text-base-content/60">
116
+ {dateRange.from || '…'} → {dateRange.to || '…'}
117
+ </p>
118
+ {/if}
119
+ {#if filter.hasActive(column.attribute)}
120
+ <Button variant="ghost" class="btn-sm mt-2 w-full" onclick={clear}>
121
+ {strings.clearFilter}
122
+ </Button>
123
+ {/if}
124
+ {:else}
125
+ <div class="relative">
126
+ <input
127
+ type="text"
128
+ class={[
129
+ 'input input-bordered input-sm w-full',
130
+ filter.hasActive(column.attribute) && 'pr-7'
131
+ ]}
132
+ placeholder={strings.filterPlaceholder}
133
+ value={filter.textFor(column.attribute)}
134
+ oninput={(e) => setText(e.currentTarget.value)}
135
+ />
136
+ {#if filter.hasActive(column.attribute)}
137
+ {@const Icon = icons.clear}
138
+ <Button
139
+ variant="ghost"
140
+ class="btn-xs btn-square btn-circle absolute top-1/2 right-1 -translate-y-1/2"
141
+ aria-label={strings.clearFilter}
142
+ title={strings.clearFilter}
143
+ onclick={clear}
144
+ >
145
+ <Icon class="size-3" />
146
+ </Button>
147
+ {/if}
148
+ </div>
141
149
 
142
- {#if entries.length > 0 && entries.length < maxCheckboxValues}
143
- <div class="mt-2 flex max-h-60 flex-col gap-1 overflow-y-auto">
144
- {#each entries as entry (entry.key)}
145
- <Label class="flex cursor-pointer items-center gap-2 text-sm font-normal normal-case">
146
- <input
147
- type="checkbox"
148
- class="checkbox checkbox-xs shrink-0"
149
- checked={filter.isChecked(column.attribute, entry.key)}
150
- onchange={() => toggle(entry.key)}
151
- />
152
- <span class="truncate">
153
- {entry.key === '' ? strings.emptyValue : entry.key}
154
- </span>
155
- </Label>
156
- {/each}
157
- </div>
158
- {/if}
159
- {/if}
150
+ {#if entries.length > 0 && entries.length < maxCheckboxValues}
151
+ <div class="mt-2 flex max-h-60 flex-col gap-1 overflow-y-auto">
152
+ {#each entries as entry (entry.key)}
153
+ <Label class="flex cursor-pointer items-center gap-2 text-sm font-normal normal-case">
154
+ <input
155
+ type="checkbox"
156
+ class="checkbox checkbox-xs shrink-0"
157
+ checked={filter.isChecked(column.attribute, entry.key)}
158
+ onchange={() => toggle(entry.key)}
159
+ />
160
+ <span class="truncate">
161
+ {entry.key === '' ? strings.emptyValue : (entry.label ?? entry.key)}
162
+ </span>
163
+ </Label>
164
+ {/each}
165
+ </div>
166
+ {/if}
167
+ {/if}
160
168
  </div>
@@ -1,189 +1,197 @@
1
1
  <script lang="ts" generics="T extends object = Record<string, unknown>">
2
- import { SvelteSet } from 'svelte/reactivity';
3
- import TableBody from './TableBody.svelte';
4
- import Paginator from './Paginator.svelte';
5
- import TableHeader from './TableHeader.svelte';
6
- import { SortState, FilterState, snapshotFilter } from './state.svelte.js';
7
- import { distinctEntries, isFilterable } from './utils.js';
8
- import type {
9
- FilterSnapshot,
10
- IndexedRow,
11
- ServerPagination,
12
- SortDirection,
13
- TableQuery,
14
- } from '../../types/table.js';
15
- import type { Snippet } from 'svelte';
16
- import type { ColumnDefinition } from '../../types/crud.js';
17
- import { getStrings } from '../../i18n/context.js';
18
-
19
- const strings = getStrings();
20
-
21
- let {
22
- data = [] as T[],
23
- columns = [] as ColumnDefinition<T>[],
24
- pageSize = 10,
25
- selectable = true,
26
- selected = $bindable(new SvelteSet<number>()),
27
- rowActions = undefined as Snippet<[T]> | undefined,
28
- actionsLabel = strings.actions,
29
- pagination = undefined as ServerPagination | undefined,
30
- initialSort = undefined as { column: string; direction: SortDirection } | undefined,
31
- initialFilters = undefined as Partial<FilterSnapshot> | undefined,
32
- onPaginationChange = undefined as ((query: TableQuery) => void) | undefined,
33
- visibleRows = $bindable<T[]>([]),
34
- query = $bindable<TableQuery | undefined>(undefined),
35
- }: {
36
- data?: T[];
37
- columns?: ColumnDefinition<T>[];
38
- pageSize?: number;
39
- selectable?: boolean;
40
- selected?: SvelteSet<number>;
41
- rowActions?: Snippet<[T]>;
42
- actionsLabel?: string;
43
- /** When provided, the table trusts `data` is already the requested page and
44
- * defers pagination/sort/filter to `onPaginationChange` instead of computing
45
- * them locally. Omit for the original fully-client-side behavior. */
46
- pagination?: ServerPagination;
47
- initialSort?: { column: string; direction: SortDirection };
48
- initialFilters?: Partial<FilterSnapshot>;
49
- onPaginationChange?: (query: TableQuery) => void;
50
- /** Filtered + sorted rows before page slicing (client mode), or the
51
- * current page's rows as-is (server mode). Read-only for callers. */
52
- visibleRows?: T[];
53
- /** Current ordering + filters snapshot, kept in sync for callers that
54
- * need to replicate the active query (e.g. exporting server-side). */
55
- query?: TableQuery;
56
- } = $props();
57
-
58
- // Intentional one-time hydration of local state from the initial prop
59
- // values (not a live binding) — `svelte-check`'s state_referenced_locally
60
- // warning is a false positive here.
61
- const sort = new SortState(initialSort ?? null);
62
- const filter = new FilterState(initialFilters ?? null);
63
-
64
- let currentPage = $state(pagination?.page ?? 1);
65
- let lastKnownPage = pagination?.page ?? 1;
66
-
67
- const distinctValues = $derived(
68
- pagination
69
- ? Object.fromEntries(
70
- columns
71
- .filter((c) => isFilterable(c) && c.type === 'boolean')
72
- .map((c) => [
73
- c.attribute,
74
- [
75
- { key: 'true', row: {} as T },
76
- { key: 'false', row: {} as T },
77
- ],
78
- ]),
79
- )
80
- : distinctEntries(data, columns)
81
- );
82
-
83
- const indexed = $derived(data.map((row, index): IndexedRow<T> => ({ row, index })));
84
- const filtered = $derived(pagination ? indexed : indexed.filter(({ row }) => filter.matches(row, columns)));
85
- const sorted = $derived(pagination ? filtered : sort.apply(filtered, columns));
86
-
87
- const effectivePageSize = $derived(pagination?.pageSize ?? pageSize);
88
- const totalPages = $derived(pagination?.totalPages ?? Math.ceil(sorted.length / effectivePageSize));
89
- const displayPage = $derived(pagination?.page ?? currentPage);
90
- const pageStart = $derived((displayPage - 1) * effectivePageSize);
91
- const pageData = $derived(pagination ? sorted : sorted.slice(pageStart, pageStart + effectivePageSize));
92
- const totalCount = $derived(pagination?.total ?? sorted.length);
93
- const allChecked = $derived(pageData.length > 0 && pageData.every((e) => selected.has(e.index)));
94
- const someChecked = $derived(pageData.some((e) => selected.has(e.index)));
95
-
96
- // Client mode only: server mode's totalPages is externally owned, clamping
97
- // here would fight with URL-driven navigation while a page reload is pending.
98
- $effect(() => {
99
- if (!pagination && currentPage > totalPages && totalPages > 0) currentPage = totalPages;
100
- });
101
-
102
- // Server mode: external (URL/reload) page changes -> sync local state.
103
- $effect(() => {
104
- if (pagination && pagination.page !== lastKnownPage) {
105
- currentPage = pagination.page;
106
- lastKnownPage = pagination.page;
107
- }
108
- });
109
-
110
- // Server mode: local (Paginator click) page changes -> notify caller.
111
- $effect(() => {
112
- if (pagination && currentPage !== lastKnownPage) {
113
- lastKnownPage = currentPage;
114
- onPaginationChange?.(currentQuery(currentPage));
115
- }
116
- });
117
-
118
- // Surface the filtered+sorted rows and current query for callers (e.g. export).
119
- $effect(() => {
120
- visibleRows = sorted.map((e) => e.row);
121
- });
122
- $effect(() => {
123
- query = currentQuery(displayPage);
124
- });
125
-
126
- function currentQuery(page: number): TableQuery {
127
- return {
128
- page,
129
- ordering: sort.column ? (sort.direction === 'asc' ? sort.column : `-${sort.column}`) : null,
130
- filters: snapshotFilter(filter),
131
- };
132
- }
133
-
134
- function handleHeaderChange() {
135
- currentPage = 1;
136
- if (!pagination) return;
137
- lastKnownPage = 1;
138
- onPaginationChange?.(currentQuery(1));
139
- }
140
-
141
- function toggleAll() {
142
- if (allChecked) pageData.forEach((e) => selected.delete(e.index));
143
- else pageData.forEach((e) => selected.add(e.index));
144
- }
145
-
146
- function toggleItem(index: number) {
147
- if (selected.has(index)) selected.delete(index);
148
- else selected.add(index);
149
- }
150
-
151
- const colCount = $derived(columns.length + (selectable ? 1 : 0) + (rowActions ? 1 : 0));
2
+ import { SvelteSet } from 'svelte/reactivity';
3
+ import TableBody from './TableBody.svelte';
4
+ import Paginator from './Paginator.svelte';
5
+ import TableHeader from './TableHeader.svelte';
6
+ import { SortState, FilterState, snapshotFilter } from './state.svelte.js';
7
+ import { distinctEntries, isFilterable } from './utils.js';
8
+ import type {
9
+ FilterSnapshot,
10
+ IndexedRow,
11
+ ServerPagination,
12
+ SortDirection,
13
+ TableQuery
14
+ } from '../../types/table.js';
15
+ import type { Snippet } from 'svelte';
16
+ import type { ColumnDefinition } from '../../types/crud.js';
17
+ import { getStrings } from '../../i18n/context.js';
18
+
19
+ const strings = getStrings();
20
+
21
+ let {
22
+ data = [] as T[],
23
+ columns = [] as ColumnDefinition<T>[],
24
+ pageSize = 10,
25
+ selectable = true,
26
+ selected = $bindable(new SvelteSet<number>()),
27
+ rowActions = undefined as Snippet<[T]> | undefined,
28
+ actionsLabel = strings.actions,
29
+ pagination = undefined as ServerPagination | undefined,
30
+ initialSort = undefined as { column: string; direction: SortDirection } | undefined,
31
+ initialFilters = undefined as Partial<FilterSnapshot> | undefined,
32
+ onPaginationChange = undefined as ((query: TableQuery) => void) | undefined,
33
+ visibleRows = $bindable<T[]>([]),
34
+ query = $bindable<TableQuery | undefined>(undefined)
35
+ }: {
36
+ data?: T[];
37
+ columns?: ColumnDefinition<T>[];
38
+ pageSize?: number;
39
+ selectable?: boolean;
40
+ selected?: SvelteSet<number>;
41
+ rowActions?: Snippet<[T]>;
42
+ actionsLabel?: string;
43
+ /** When provided, the table trusts `data` is already the requested page and
44
+ * defers pagination/sort/filter to `onPaginationChange` instead of computing
45
+ * them locally. Omit for the original fully-client-side behavior. */
46
+ pagination?: ServerPagination;
47
+ initialSort?: { column: string; direction: SortDirection };
48
+ initialFilters?: Partial<FilterSnapshot>;
49
+ onPaginationChange?: (query: TableQuery) => void;
50
+ /** Filtered + sorted rows before page slicing (client mode), or the
51
+ * current page's rows as-is (server mode). Read-only for callers. */
52
+ visibleRows?: T[];
53
+ /** Current ordering + filters snapshot, kept in sync for callers that
54
+ * need to replicate the active query (e.g. exporting server-side). */
55
+ query?: TableQuery;
56
+ } = $props();
57
+
58
+ // Intentional one-time hydration of local state from the initial prop
59
+ // values (not a live binding) — `svelte-check`'s state_referenced_locally
60
+ // warning is a false positive here.
61
+ const sort = new SortState(initialSort ?? null);
62
+ const filter = new FilterState(initialFilters ?? null);
63
+
64
+ let currentPage = $state(pagination?.page ?? 1);
65
+ let lastKnownPage = pagination?.page ?? 1;
66
+
67
+ const distinctValues = $derived(
68
+ pagination
69
+ ? Object.fromEntries(
70
+ columns
71
+ .filter((c) => isFilterable(c) && c.type === 'boolean')
72
+ .map((c) => [
73
+ c.attribute,
74
+ [
75
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
76
+ { key: 'true', label: c.formatter?.(true as any, {} as T), row: {} as T },
77
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
78
+ { key: 'false', label: c.formatter?.(false as any, {} as T), row: {} as T }
79
+ ]
80
+ ])
81
+ )
82
+ : distinctEntries(data, columns)
83
+ );
84
+
85
+ const indexed = $derived(data.map((row, index): IndexedRow<T> => ({ row, index })));
86
+ const filtered = $derived(
87
+ pagination ? indexed : indexed.filter(({ row }) => filter.matches(row, columns))
88
+ );
89
+ const sorted = $derived(pagination ? filtered : sort.apply(filtered, columns));
90
+
91
+ const effectivePageSize = $derived(pagination?.pageSize ?? pageSize);
92
+ const totalPages = $derived(
93
+ pagination?.totalPages ?? Math.ceil(sorted.length / effectivePageSize)
94
+ );
95
+ const displayPage = $derived(pagination?.page ?? currentPage);
96
+ const pageStart = $derived((displayPage - 1) * effectivePageSize);
97
+ const pageData = $derived(
98
+ pagination ? sorted : sorted.slice(pageStart, pageStart + effectivePageSize)
99
+ );
100
+ const totalCount = $derived(pagination?.total ?? sorted.length);
101
+ const allChecked = $derived(pageData.length > 0 && pageData.every((e) => selected.has(e.index)));
102
+ const someChecked = $derived(pageData.some((e) => selected.has(e.index)));
103
+
104
+ // Client mode only: server mode's totalPages is externally owned, clamping
105
+ // here would fight with URL-driven navigation while a page reload is pending.
106
+ $effect(() => {
107
+ if (!pagination && currentPage > totalPages && totalPages > 0) currentPage = totalPages;
108
+ });
109
+
110
+ // Server mode: external (URL/reload) page changes -> sync local state.
111
+ $effect(() => {
112
+ if (pagination && pagination.page !== lastKnownPage) {
113
+ currentPage = pagination.page;
114
+ lastKnownPage = pagination.page;
115
+ }
116
+ });
117
+
118
+ // Server mode: local (Paginator click) page changes -> notify caller.
119
+ $effect(() => {
120
+ if (pagination && currentPage !== lastKnownPage) {
121
+ lastKnownPage = currentPage;
122
+ onPaginationChange?.(currentQuery(currentPage));
123
+ }
124
+ });
125
+
126
+ // Surface the filtered+sorted rows and current query for callers (e.g. export).
127
+ $effect(() => {
128
+ visibleRows = sorted.map((e) => e.row);
129
+ });
130
+ $effect(() => {
131
+ query = currentQuery(displayPage);
132
+ });
133
+
134
+ function currentQuery(page: number): TableQuery {
135
+ return {
136
+ page,
137
+ ordering: sort.column ? (sort.direction === 'asc' ? sort.column : `-${sort.column}`) : null,
138
+ filters: snapshotFilter(filter)
139
+ };
140
+ }
141
+
142
+ function handleHeaderChange() {
143
+ currentPage = 1;
144
+ if (!pagination) return;
145
+ lastKnownPage = 1;
146
+ onPaginationChange?.(currentQuery(1));
147
+ }
148
+
149
+ function toggleAll() {
150
+ if (allChecked) pageData.forEach((e) => selected.delete(e.index));
151
+ else pageData.forEach((e) => selected.add(e.index));
152
+ }
153
+
154
+ function toggleItem(index: number) {
155
+ if (selected.has(index)) selected.delete(index);
156
+ else selected.add(index);
157
+ }
158
+
159
+ const colCount = $derived(columns.length + (selectable ? 1 : 0) + (rowActions ? 1 : 0));
152
160
  </script>
153
161
 
154
162
  <div class="flex flex-col gap-6">
155
- <div class="min-w-0 overflow-x-auto rounded-box border border-base-content/10">
156
- <table class="table table-zebra table-xs w-full text-xs sm:table-md sm:text-base">
157
- <TableHeader
158
- {columns}
159
- {selectable}
160
- {allChecked}
161
- {someChecked}
162
- onToggleAll={toggleAll}
163
- {sort}
164
- {filter}
165
- {distinctValues}
166
- hasRowActions={!!rowActions}
167
- {actionsLabel}
168
- onchange={handleHeaderChange}
169
- />
170
- <TableBody
171
- {columns}
172
- rows={pageData}
173
- {selectable}
174
- {selected}
175
- onToggle={toggleItem}
176
- {colCount}
177
- {rowActions}
178
- />
179
- </table>
180
- </div>
181
-
182
- <Paginator
183
- bind:page={currentPage}
184
- {totalPages}
185
- {pageStart}
186
- pageSize={effectivePageSize}
187
- total={totalCount}
188
- />
163
+ <div class="min-w-0 overflow-x-auto rounded-box border border-base-content/10">
164
+ <table class="table table-zebra table-xs w-full text-xs sm:table-md sm:text-base">
165
+ <TableHeader
166
+ {columns}
167
+ {selectable}
168
+ {allChecked}
169
+ {someChecked}
170
+ onToggleAll={toggleAll}
171
+ {sort}
172
+ {filter}
173
+ {distinctValues}
174
+ hasRowActions={!!rowActions}
175
+ {actionsLabel}
176
+ onchange={handleHeaderChange}
177
+ />
178
+ <TableBody
179
+ {columns}
180
+ rows={pageData}
181
+ {selectable}
182
+ {selected}
183
+ onToggle={toggleItem}
184
+ {colCount}
185
+ {rowActions}
186
+ />
187
+ </table>
188
+ </div>
189
+
190
+ <Paginator
191
+ bind:page={currentPage}
192
+ {totalPages}
193
+ {pageStart}
194
+ pageSize={effectivePageSize}
195
+ total={totalCount}
196
+ />
189
197
  </div>
@@ -75,6 +75,11 @@ export interface CustomAction<T extends object = Record<string, unknown>> {
75
75
  * flips once the underlying item's data actually changes — e.g. after
76
76
  * the action's modal confirms and the list reloads. */
77
77
  toggle?: (item: T) => boolean;
78
+ /** Extra class(es) on the row action's button/switch — e.g. to color a
79
+ * `toggle` by the item's own state (`toggle-success`/`toggle-error`).
80
+ * Pass a function to resolve it per item; a plain string applies to
81
+ * every row alike. */
82
+ class?: string | ((item: T) => string);
78
83
  endpoint?: string;
79
84
  condition?: (item: T) => boolean;
80
85
  /** Renders as a modal-like panel when the action runs. Mutually exclusive
@@ -89,7 +94,8 @@ export interface RowAction<T extends object = Record<string, unknown>> {
89
94
  icon?: any;
90
95
  /** See `CustomAction.toggle` — same controlled-switch rendering. */
91
96
  toggle?: (item: T) => boolean;
92
- class?: string;
97
+ /** See `CustomAction.class`. */
98
+ class?: string | ((item: T) => string);
93
99
  condition?: (item: T) => boolean;
94
100
  run: (item: T) => void;
95
101
  }
@@ -4,8 +4,14 @@ export type IndexedRow<T> = {
4
4
  row: T;
5
5
  index: number;
6
6
  };
7
+ /** `key` is the actual filter value (matched against row data, and sent
8
+ * server-side as-is) — `label` is only what's displayed for it, falling back
9
+ * to `key` when absent. They diverge for a formatted value whose match token
10
+ * isn't human text, e.g. a boolean column's `key: 'true'` paired with
11
+ * `label: 'Sí'` from the column's formatter. */
7
12
  export type DistinctEntry<T> = {
8
13
  key: string;
14
+ label?: string;
9
15
  row: T;
10
16
  };
11
17
  export interface CellProps<T extends object = Record<string, unknown>, V = unknown> {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runeforge",
3
- "version": "0.0.31",
3
+ "version": "0.0.33",
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",