runeforge 0.0.18 → 0.0.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.
@@ -12,6 +12,7 @@
12
12
  inferType
13
13
  } from './utils/resolution.js';
14
14
  import { isFilterable } from '../table/utils.js';
15
+ import type { XlsxModule } from '../table/export.js';
15
16
  import type { AttributeMetadata } from '../../types/attribute.js';
16
17
  import type {
17
18
  ActionConfiguration,
@@ -47,7 +48,10 @@
47
48
  columns = undefined as ColumnDefinition<T>[] | undefined,
48
49
  fields = undefined as FieldDefinition<T>[] | undefined,
49
50
  meta = undefined as Partial<Record<string, AttributeMetadata>> | undefined,
50
- form = null as { error?: string } | null
51
+ form = null as { error?: string } | null,
52
+ enableExport = false,
53
+ onExport = undefined as ((query: TableQuery) => Promise<T[]>) | undefined,
54
+ xlsx = undefined as XlsxModule | undefined
51
55
  }: {
52
56
  data?: Record<string, unknown>;
53
57
  dataKey?: string;
@@ -68,6 +72,14 @@
68
72
  fields?: FieldDefinition<T>[];
69
73
  meta?: Partial<Record<string, AttributeMetadata>>;
70
74
  form?: { error?: string } | null;
75
+ /** Shows an icon-only export button (CSV, and Excel if `xlsx` is provided). */
76
+ enableExport?: boolean;
77
+ /** Server-pagination mode only: fetch all rows matching the current query
78
+ * (unpaginated) for export. Without it, export falls back to the loaded page. */
79
+ onExport?: (query: TableQuery) => Promise<T[]>;
80
+ /** Resolved `xlsx` (SheetJS) module, e.g. `import * as xlsx from 'xlsx'`.
81
+ * Enables the "Export as Excel" option; omit to only offer CSV. */
82
+ xlsx?: XlsxModule;
71
83
  } = $props();
72
84
 
73
85
  function isEnvelope(value: unknown): value is PaginatedEnvelope<T> {
@@ -342,6 +354,9 @@
342
354
  {initialSort}
343
355
  {initialFilters}
344
356
  onPaginationChange={handlePaginationChange}
357
+ {enableExport}
358
+ {onExport}
359
+ {xlsx}
345
360
  onCreate={navCreate}
346
361
  onEdit={navEdit}
347
362
  onView={navRead}
@@ -1,5 +1,7 @@
1
+ import type { XlsxModule } from '../table/export.js';
1
2
  import type { AttributeMetadata } from '../../types/attribute.js';
2
3
  import type { ActionConfiguration, ColumnDefinition, CustomAction, CustomBulkAction, FieldDefinition, SearchConfiguration } from '../../types/crud.js';
4
+ import type { TableQuery } from '../../types/table.js';
3
5
  declare function $$render<T extends object = Record<string, unknown>>(): {
4
6
  props: {
5
7
  data?: Record<string, unknown>;
@@ -22,6 +24,14 @@ declare function $$render<T extends object = Record<string, unknown>>(): {
22
24
  form?: {
23
25
  error?: string;
24
26
  } | null;
27
+ /** Shows an icon-only export button (CSV, and Excel if `xlsx` is provided). */
28
+ enableExport?: boolean;
29
+ /** Server-pagination mode only: fetch all rows matching the current query
30
+ * (unpaginated) for export. Without it, export falls back to the loaded page. */
31
+ onExport?: (query: TableQuery) => Promise<T[]>;
32
+ /** Resolved `xlsx` (SheetJS) module, e.g. `import * as xlsx from 'xlsx'`.
33
+ * Enables the "Export as Excel" option; omit to only offer CSV. */
34
+ xlsx?: XlsxModule;
25
35
  };
26
36
  exports: {};
27
37
  bindings: "";
@@ -6,6 +6,7 @@
6
6
  import Modal from '../../Modal.svelte';
7
7
  import PaginatedTable from '../../table/PaginatedTable.svelte';
8
8
  import SearchInput from '../SearchInput.svelte';
9
+ import { downloadCsv, downloadXlsx, type XlsxModule } from '../../table/export.js';
9
10
  import { getIconSet } from '../../../icons/context.js';
10
11
  import { defaultIconSet } from '../../../icons/sets/default.js';
11
12
  import type {
@@ -45,6 +46,9 @@
45
46
  initialSort = undefined as { column: string; direction: SortDirection } | undefined,
46
47
  initialFilters = undefined as Partial<FilterSnapshot> | undefined,
47
48
  onPaginationChange = undefined as ((query: TableQuery) => void) | undefined,
49
+ enableExport = false,
50
+ onExport = undefined as ((query: TableQuery) => Promise<T[]>) | undefined,
51
+ xlsx = undefined as XlsxModule | undefined,
48
52
  onCreate,
49
53
  onEdit,
50
54
  onView,
@@ -69,6 +73,14 @@
69
73
  initialSort?: { column: string; direction: SortDirection };
70
74
  initialFilters?: Partial<FilterSnapshot>;
71
75
  onPaginationChange?: (query: TableQuery) => void;
76
+ /** Shows an icon-only export button (CSV, and Excel if `xlsx` is provided). */
77
+ enableExport?: boolean;
78
+ /** Server-pagination mode only: fetch all rows matching the current query
79
+ * (unpaginated) for export. Without it, export falls back to the loaded page. */
80
+ onExport?: (query: TableQuery) => Promise<T[]>;
81
+ /** Resolved `xlsx` (SheetJS) module, e.g. `import * as xlsx from 'xlsx'`.
82
+ * Enables the "Export as Excel" option; omit to only offer CSV. */
83
+ xlsx?: XlsxModule;
72
84
  onCreate?: () => void;
73
85
  onEdit?: (item: T) => void;
74
86
  onView?: (item: T) => void;
@@ -92,6 +104,25 @@
92
104
  let pendingBulkAction = $state<{ action: CustomBulkAction<T>; items: T[] } | null>(null);
93
105
  const selectedItems = $derived([...selected].map((i) => data[i]));
94
106
 
107
+ let visibleRows = $state<T[]>([]);
108
+ let exportQuery = $state<TableQuery | undefined>(undefined);
109
+ let exportPopoverEl: HTMLElement | undefined = $state();
110
+ const exportId = $props.id();
111
+
112
+ async function resolveExportRows(): Promise<T[]> {
113
+ if (!pagination) return visibleRows;
114
+ if (!onExport || !exportQuery) return data;
115
+ return onExport(exportQuery);
116
+ }
117
+
118
+ async function handleExport(format: 'csv' | 'xlsx') {
119
+ exportPopoverEl?.hidePopover();
120
+ const rows = await resolveExportRows();
121
+ const filename = `${labelMany || 'export'}-${new Date().toISOString().slice(0, 10)}`;
122
+ if (format === 'csv') downloadCsv(rows, columns, filename);
123
+ else if (xlsx) downloadXlsx(rows, columns, filename, xlsx);
124
+ }
125
+
95
126
  // `selected` holds indices into `data`; if `data` is swapped for a different
96
127
  // slice (page/sort/filter change in server mode) stale indices could point
97
128
  // at unrelated rows, so clear on any data reference change.
@@ -230,6 +261,44 @@
230
261
  <SearchInput config={search} />
231
262
  {/if}
232
263
 
264
+ {#if enableExport}
265
+ {@const ExportIcon = icons.download}
266
+ <Button
267
+ variant="ghost"
268
+ class="btn-square"
269
+ popovertarget="export-menu-{exportId}"
270
+ style="anchor-name:--export-anchor-{exportId}"
271
+ aria-label={strings.export}
272
+ title={strings.export}
273
+ >
274
+ <ExportIcon class="size-4" />
275
+ </Button>
276
+ <div
277
+ popover="auto"
278
+ id="export-menu-{exportId}"
279
+ style="position-anchor:--export-anchor-{exportId}"
280
+ class="dropdown dropdown-end w-40 rounded-box border border-base-content/10 bg-base-100 p-1 shadow-lg"
281
+ bind:this={exportPopoverEl}
282
+ >
283
+ <Button
284
+ variant="ghost"
285
+ class="btn-sm w-full justify-start"
286
+ onclick={() => handleExport('csv')}
287
+ >
288
+ {strings.exportCsv}
289
+ </Button>
290
+ {#if xlsx}
291
+ <Button
292
+ variant="ghost"
293
+ class="btn-sm w-full justify-start"
294
+ onclick={() => handleExport('xlsx')}
295
+ >
296
+ {strings.exportExcel}
297
+ </Button>
298
+ {/if}
299
+ </div>
300
+ {/if}
301
+
233
302
  {#each customBulkActions as bulkAction (bulkAction.label)}
234
303
  {#if bulkAction.condition?.(selectedItems) ?? true}
235
304
  {@const BulkIcon = bulkAction.icon}
@@ -282,6 +351,8 @@
282
351
  {initialSort}
283
352
  {initialFilters}
284
353
  {onPaginationChange}
354
+ bind:visibleRows
355
+ bind:query={exportQuery}
285
356
  />
286
357
  </div>
287
358
  </div>
@@ -1,3 +1,4 @@
1
+ import { type XlsxModule } from '../../table/export.js';
1
2
  import type { ActionConfiguration, ColumnDefinition, CustomAction, CustomBulkAction, SearchConfiguration } from '../../../types/crud.js';
2
3
  import type { FilterSnapshot, ServerPagination, SortDirection, TableQuery } from '../../../types/table.js';
3
4
  declare function $$render<T extends object = Record<string, unknown>>(): {
@@ -23,6 +24,14 @@ declare function $$render<T extends object = Record<string, unknown>>(): {
23
24
  };
24
25
  initialFilters?: Partial<FilterSnapshot>;
25
26
  onPaginationChange?: (query: TableQuery) => void;
27
+ /** Shows an icon-only export button (CSV, and Excel if `xlsx` is provided). */
28
+ enableExport?: boolean;
29
+ /** Server-pagination mode only: fetch all rows matching the current query
30
+ * (unpaginated) for export. Without it, export falls back to the loaded page. */
31
+ onExport?: (query: TableQuery) => Promise<T[]>;
32
+ /** Resolved `xlsx` (SheetJS) module, e.g. `import * as xlsx from 'xlsx'`.
33
+ * Enables the "Export as Excel" option; omit to only offer CSV. */
34
+ xlsx?: XlsxModule;
26
35
  onCreate?: () => void;
27
36
  onEdit?: (item: T) => void;
28
37
  onView?: (item: T) => void;
@@ -30,6 +30,8 @@
30
30
  initialSort = undefined as { column: string; direction: SortDirection } | undefined,
31
31
  initialFilters = undefined as Partial<FilterSnapshot> | undefined,
32
32
  onPaginationChange = undefined as ((query: TableQuery) => void) | undefined,
33
+ visibleRows = $bindable<T[]>([]),
34
+ query = $bindable<TableQuery | undefined>(undefined),
33
35
  }: {
34
36
  data?: T[];
35
37
  columns?: ColumnDefinition<T>[];
@@ -45,6 +47,12 @@
45
47
  initialSort?: { column: string; direction: SortDirection };
46
48
  initialFilters?: Partial<FilterSnapshot>;
47
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;
48
56
  } = $props();
49
57
 
50
58
  // Intentional one-time hydration of local state from the initial prop
@@ -107,6 +115,14 @@
107
115
  }
108
116
  });
109
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
+
110
126
  function currentQuery(page: number): TableQuery {
111
127
  return {
112
128
  page,
@@ -21,9 +21,15 @@ declare function $$render<T extends object = Record<string, unknown>>(): {
21
21
  };
22
22
  initialFilters?: Partial<FilterSnapshot>;
23
23
  onPaginationChange?: (query: TableQuery) => void;
24
+ /** Filtered + sorted rows before page slicing (client mode), or the
25
+ * current page's rows as-is (server mode). Read-only for callers. */
26
+ visibleRows?: T[];
27
+ /** Current ordering + filters snapshot, kept in sync for callers that
28
+ * need to replicate the active query (e.g. exporting server-side). */
29
+ query?: TableQuery;
24
30
  };
25
31
  exports: {};
26
- bindings: "selected";
32
+ bindings: "selected" | "visibleRows" | "query";
27
33
  slots: {};
28
34
  events: {};
29
35
  };
@@ -31,7 +37,7 @@ declare class __sveltets_Render<T extends object = Record<string, unknown>> {
31
37
  props(): ReturnType<typeof $$render<T>>['props'];
32
38
  events(): ReturnType<typeof $$render<T>>['events'];
33
39
  slots(): ReturnType<typeof $$render<T>>['slots'];
34
- bindings(): "selected";
40
+ bindings(): "selected" | "visibleRows" | "query";
35
41
  exports(): {};
36
42
  }
37
43
  interface $$IsomorphicComponent {
@@ -0,0 +1,17 @@
1
+ import type { ColumnDefinition } from '../../types/crud.js';
2
+ /**
3
+ * Minimal duck-typed subset of the SheetJS (`xlsx`) module API used for
4
+ * building a workbook. Runeforge never imports `xlsx` itself — the consumer
5
+ * installs it and passes the resolved module in, so the dependency stays
6
+ * fully optional and never affects consumers who don't use Excel export.
7
+ */
8
+ export interface XlsxModule {
9
+ utils: {
10
+ aoa_to_sheet: (data: unknown[][]) => unknown;
11
+ book_new: () => unknown;
12
+ book_append_sheet: (workbook: unknown, worksheet: unknown, name?: string) => void;
13
+ };
14
+ writeFile: (workbook: unknown, filename: string) => void;
15
+ }
16
+ export declare function downloadCsv<T extends object>(rows: T[], columns: ColumnDefinition<T>[], filename: string): void;
17
+ export declare function downloadXlsx<T extends object>(rows: T[], columns: ColumnDefinition<T>[], filename: string, xlsx: XlsxModule): void;
@@ -0,0 +1,31 @@
1
+ import { cellRenderedText } from './utils.js';
2
+ function buildTable(rows, columns) {
3
+ const headers = columns.map((col) => col.title ?? col.attribute);
4
+ const body = rows.map((row) => columns.map((col) => cellRenderedText(row, col)));
5
+ return { headers, body };
6
+ }
7
+ function escapeCsvCell(value) {
8
+ return /[",\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
9
+ }
10
+ function triggerDownload(blob, filename) {
11
+ const url = URL.createObjectURL(blob);
12
+ const link = document.createElement('a');
13
+ link.href = url;
14
+ link.download = filename;
15
+ link.click();
16
+ URL.revokeObjectURL(url);
17
+ }
18
+ export function downloadCsv(rows, columns, filename) {
19
+ const { headers, body } = buildTable(rows, columns);
20
+ const csv = [headers, ...body]
21
+ .map((line) => line.map(escapeCsvCell).join(','))
22
+ .join('\r\n');
23
+ triggerDownload(new Blob([csv], { type: 'text/csv;charset=utf-8;' }), `${filename}.csv`);
24
+ }
25
+ export function downloadXlsx(rows, columns, filename, xlsx) {
26
+ const { headers, body } = buildTable(rows, columns);
27
+ const worksheet = xlsx.utils.aoa_to_sheet([headers, ...body]);
28
+ const workbook = xlsx.utils.book_new();
29
+ xlsx.utils.book_append_sheet(workbook, worksheet);
30
+ xlsx.writeFile(workbook, `${filename}.xlsx`);
31
+ }
package/dist/i18n/en.js CHANGED
@@ -16,6 +16,9 @@ export const en = {
16
16
  delete: 'Delete',
17
17
  create: 'Create',
18
18
  searchPlaceholder: 'Search...',
19
+ export: 'Export',
20
+ exportCsv: 'Export as CSV',
21
+ exportExcel: 'Export as Excel',
19
22
  save: 'Save',
20
23
  saveAndContinue: 'Save and continue',
21
24
  cancel: 'Cancel',
package/dist/i18n/es.js CHANGED
@@ -16,6 +16,9 @@ export const es = {
16
16
  delete: 'Eliminar',
17
17
  create: 'Crear',
18
18
  searchPlaceholder: 'Buscar...',
19
+ export: 'Exportar',
20
+ exportCsv: 'Exportar a CSV',
21
+ exportExcel: 'Exportar a Excel',
19
22
  save: 'Guardar',
20
23
  saveAndContinue: 'Guardar y continuar',
21
24
  cancel: 'Cancelar',
@@ -16,6 +16,9 @@ export interface RuneforgeStrings {
16
16
  delete: string;
17
17
  create: string;
18
18
  searchPlaceholder: string;
19
+ export: string;
20
+ exportCsv: string;
21
+ exportExcel: string;
19
22
  save: string;
20
23
  saveAndContinue: string;
21
24
  cancel: string;
@@ -0,0 +1,7 @@
1
+ <script lang="ts">
2
+ let { size = '1em', class: cls = '' }: { size?: string | number; class?: string } = $props();
3
+ </script>
4
+ <svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 16 16" fill="currentColor" class={cls}>
5
+ <path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5"/>
6
+ <path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708z"/>
7
+ </svg>
@@ -0,0 +1,7 @@
1
+ type $$ComponentProps = {
2
+ size?: string | number;
3
+ class?: string;
4
+ };
5
+ declare const Download: import("svelte").Component<$$ComponentProps, {}, "">;
6
+ type Download = ReturnType<typeof Download>;
7
+ export default Download;
@@ -10,7 +10,7 @@
10
10
  * setIconSet(bootstrapIconSet);
11
11
  */
12
12
  import * as Icons from 'svelte-bootstrap-icons';
13
- const { ChevronExpand, CaretUpFill, CaretDownFill, Funnel, FunnelFill, Plus, Eye, PencilSquare, Trash3, HouseDoor, Folder, EyeSlash, X, } = Icons;
13
+ const { ChevronExpand, CaretUpFill, CaretDownFill, Funnel, FunnelFill, Plus, Eye, PencilSquare, Trash3, HouseDoor, Folder, EyeSlash, X, Download, } = Icons;
14
14
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
15
15
  function asIcon(c) { return c; }
16
16
  export const bootstrapIconSet = {
@@ -28,6 +28,7 @@ export const bootstrapIconSet = {
28
28
  folder: asIcon(Folder),
29
29
  passwordShow: asIcon(Eye),
30
30
  passwordHide: asIcon(EyeSlash),
31
+ download: asIcon(Download),
31
32
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
32
33
  getByName: (name) => asIcon(Icons[name]) ?? null,
33
34
  };
@@ -12,6 +12,7 @@ import Home from '../defaults/Home.svelte';
12
12
  import Folder from '../defaults/Folder.svelte';
13
13
  import PasswordShow from '../defaults/PasswordShow.svelte';
14
14
  import PasswordHide from '../defaults/PasswordHide.svelte';
15
+ import Download from '../defaults/Download.svelte';
15
16
  export const defaultIconSet = {
16
17
  sortNone: SortNone,
17
18
  sortAsc: SortAsc,
@@ -27,4 +28,5 @@ export const defaultIconSet = {
27
28
  folder: Folder,
28
29
  passwordShow: PasswordShow,
29
30
  passwordHide: PasswordHide,
31
+ download: Download,
30
32
  };
@@ -18,5 +18,6 @@ export interface CRUDIconSet {
18
18
  folder: IconComponent;
19
19
  passwordShow: IconComponent;
20
20
  passwordHide: IconComponent;
21
+ download: IconComponent;
21
22
  getByName?: (name: string) => IconComponent | null;
22
23
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runeforge",
3
- "version": "0.0.18",
3
+ "version": "0.0.19",
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",