runeforge 0.0.36 → 0.0.38

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.
@@ -21,7 +21,7 @@
21
21
  </div>
22
22
 
23
23
  {#if buttons}
24
- <div class="flex shrink-0 items-center gap-2 sm:pt-1">
24
+ <div class="flex min-w-0 items-center gap-2 sm:pt-1">
25
25
  {@render buttons()}
26
26
  </div>
27
27
  {/if}
@@ -21,7 +21,8 @@
21
21
  CustomAction,
22
22
  CustomBulkAction,
23
23
  FieldDefinition,
24
- SearchConfiguration
24
+ SearchConfiguration,
25
+ ViewBasedCustomBulkAction
25
26
  } from '../../types/crud.js';
26
27
  import type {
27
28
  FilterSnapshot,
@@ -121,6 +122,9 @@
121
122
  );
122
123
 
123
124
  let activeAction = $state<{ action: CustomAction<T>; item: T } | null>(null);
125
+ let activeBulkAction = $state<{ action: ViewBasedCustomBulkAction<T>; items: T[] } | null>(
126
+ null
127
+ );
124
128
 
125
129
  async function runAction(action: CustomAction<T>, item: T) {
126
130
  if (!(action.condition?.(item) ?? true)) return;
@@ -131,6 +135,10 @@
131
135
  activeAction = { action, item };
132
136
  }
133
137
 
138
+ function runBulkAction(action: ViewBasedCustomBulkAction<T>, items: T[]) {
139
+ activeBulkAction = { action, items };
140
+ }
141
+
134
142
  async function navList() {
135
143
  await goto('?');
136
144
  }
@@ -293,7 +301,9 @@
293
301
  );
294
302
 
295
303
  const serverError = $derived(
296
- creating || reading || editing || activeAction !== null ? (form?.error ?? '') : ''
304
+ creating || reading || editing || activeAction !== null || activeBulkAction !== null
305
+ ? (form?.error ?? '')
306
+ : ''
297
307
  );
298
308
  </script>
299
309
 
@@ -359,6 +369,7 @@
359
369
  onEdit={navEdit}
360
370
  onView={navRead}
361
371
  onAction={runAction}
372
+ onBulkAction={runBulkAction}
362
373
  />
363
374
  {/if}
364
375
 
@@ -373,3 +384,14 @@
373
384
  onSuccess={() => (activeAction = null)}
374
385
  />
375
386
  {/if}
387
+
388
+ {#if activeBulkAction !== null}
389
+ {@const BulkActionView = activeBulkAction.action.view}
390
+ <BulkActionView
391
+ items={activeBulkAction.items}
392
+ label={activeBulkAction.action.label}
393
+ {serverError}
394
+ onCancel={() => (activeBulkAction = null)}
395
+ onSuccess={() => (activeBulkAction = null)}
396
+ />
397
+ {/if}
@@ -15,7 +15,8 @@
15
15
  CustomAction,
16
16
  CustomBulkAction,
17
17
  RowAction,
18
- SearchConfiguration
18
+ SearchConfiguration,
19
+ ViewBasedCustomBulkAction
19
20
  } from '../../../types/crud.js';
20
21
  import type {
21
22
  FilterSnapshot,
@@ -27,6 +28,12 @@
27
28
 
28
29
  const strings = getStrings();
29
30
 
31
+ function isViewBasedBulkAction(
32
+ action: CustomBulkAction<T>
33
+ ): action is ViewBasedCustomBulkAction<T> {
34
+ return action.kind === 'view';
35
+ }
36
+
30
37
  let {
31
38
  data = [] as T[],
32
39
  labelOne = '',
@@ -52,7 +59,8 @@
52
59
  onCreate,
53
60
  onEdit,
54
61
  onView,
55
- onAction
62
+ onAction,
63
+ onBulkAction
56
64
  }: {
57
65
  data?: T[];
58
66
  labelOne?: string;
@@ -85,6 +93,7 @@
85
93
  onEdit?: (item: T) => void;
86
94
  onView?: (item: T) => void;
87
95
  onAction?: (action: CustomAction<T>, item: T) => void;
96
+ onBulkAction?: (action: ViewBasedCustomBulkAction<T>, items: T[]) => void;
88
97
  } = $props();
89
98
 
90
99
  const icons = $derived(getIconSet() ?? defaultIconSet);
@@ -97,7 +106,12 @@
97
106
  const updateLabel = $derived(update.label ?? strings.edit);
98
107
  const readLabel = $derived(read.label ?? strings.view);
99
108
  const showRowActions = $derived(allowRead || allowUpdate || allowDelete || actions.length > 0);
100
- const allowSelection = $derived(allowDelete || customBulkActions.length > 0);
109
+ const allowSelection = $derived(
110
+ allowDelete || customBulkActions.some((action) => !isViewBasedBulkAction(action))
111
+ );
112
+ const hasCollapsibleActions = $derived(
113
+ enableExport || customBulkActions.length > 0 || allowDelete
114
+ );
101
115
 
102
116
  let selected = new SvelteSet<number>();
103
117
  let pendingDeletion = $state<T[] | null>(null);
@@ -107,7 +121,33 @@
107
121
  let visibleRows = $state<T[]>([]);
108
122
  let exportQuery = $state<TableQuery | undefined>(undefined);
109
123
  let exportPopoverEl: HTMLElement | undefined = $state();
124
+ let actionsPopoverEl: HTMLElement | undefined = $state();
110
125
  const exportId = $props.id();
126
+ const actionsMenuId = `${exportId}-actions`;
127
+
128
+ // Collapses export/bulk-actions/delete into a single "Acciones" dropdown
129
+ // once the toolbar no longer fits its available width (small monitor, a
130
+ // browser window not maximized, many customBulkActions, ...) — measured
131
+ // against an identical but off-flow, always-uncollapsed clone rather than
132
+ // the visible row itself, since the visible row's own width changes
133
+ // depending on whether it's currently collapsed.
134
+ let toolbarEl: HTMLElement | undefined = $state();
135
+ let toolbarMeasureEl: HTMLElement | undefined = $state();
136
+ let toolbarCollapsed = $state(false);
137
+
138
+ $effect(() => {
139
+ if (!toolbarEl || !toolbarMeasureEl) return;
140
+ const el = toolbarEl;
141
+ const measureEl = toolbarMeasureEl;
142
+ const check = () => {
143
+ toolbarCollapsed = measureEl.scrollWidth > el.clientWidth + 1;
144
+ };
145
+ const observer = new ResizeObserver(check);
146
+ observer.observe(el);
147
+ observer.observe(measureEl);
148
+ check();
149
+ return () => observer.disconnect();
150
+ });
111
151
 
112
152
  async function resolveExportRows(): Promise<T[]> {
113
153
  if (!pagination) return visibleRows;
@@ -179,11 +219,12 @@
179
219
  }
180
220
 
181
221
  async function runBulkAction(action: CustomBulkAction<T>, items: T[]) {
222
+ if (isViewBasedBulkAction(action)) return;
182
223
  await runEndpointAction(action.endpoint, items);
183
224
  }
184
225
 
185
226
  function requestBulkAction(action: CustomBulkAction<T>, items: T[]) {
186
- if (action.confirm) {
227
+ if (!isViewBasedBulkAction(action) && action.confirm) {
187
228
  pendingBulkAction = { action, items };
188
229
  } else {
189
230
  runBulkAction(action, items);
@@ -192,6 +233,10 @@
192
233
 
193
234
  function handleBulkAction(action: CustomBulkAction<T>) {
194
235
  const items = selectedItems;
236
+ if (isViewBasedBulkAction(action)) {
237
+ onBulkAction?.(action, items);
238
+ return;
239
+ }
195
240
  selected.clear();
196
241
  requestBulkAction(action, items);
197
242
  }
@@ -231,6 +276,97 @@
231
276
  ]);
232
277
  </script>
233
278
 
279
+ {#snippet toolbarButtons(measuring: boolean)}
280
+ {#if search}
281
+ <SearchInput config={search} />
282
+ {/if}
283
+
284
+ {#if enableExport}
285
+ {@const ExportIcon = icons.download}
286
+ <Button
287
+ variant="ghost"
288
+ class="btn-square"
289
+ popovertarget={measuring ? undefined : `export-menu-${exportId}`}
290
+ style={measuring ? undefined : `anchor-name:--export-anchor-${exportId}`}
291
+ aria-label={strings.export}
292
+ title={strings.export}
293
+ >
294
+ <ExportIcon class="size-4" />
295
+ </Button>
296
+ {#if !measuring}
297
+ <div
298
+ popover="auto"
299
+ id="export-menu-{exportId}"
300
+ style="position-anchor:--export-anchor-{exportId}"
301
+ class="dropdown dropdown-end w-40 rounded-box border border-base-content/10 bg-base-100 p-1 shadow-lg"
302
+ bind:this={exportPopoverEl}
303
+ >
304
+ <Button
305
+ variant="ghost"
306
+ class="btn-sm w-full justify-start"
307
+ onclick={() => handleExport('csv')}
308
+ >
309
+ {strings.exportCsv}
310
+ </Button>
311
+ {#if xlsx}
312
+ <Button
313
+ variant="ghost"
314
+ class="btn-sm w-full justify-start"
315
+ onclick={() => handleExport('xlsx')}
316
+ >
317
+ {strings.exportExcel}
318
+ </Button>
319
+ {/if}
320
+ </div>
321
+ {/if}
322
+ {/if}
323
+
324
+ {#each customBulkActions as bulkAction, i (i)}
325
+ {#if bulkAction.condition?.(selectedItems) ?? true}
326
+ {@const BulkIcon = bulkAction.icon}
327
+ {@const isView = isViewBasedBulkAction(bulkAction)}
328
+ <Button
329
+ variant={bulkAction.variant ?? 'ghost'}
330
+ class="btn-outline"
331
+ disabled={!isView && selected.size === 0}
332
+ title={bulkAction.tooltip ?? bulkAction.label}
333
+ aria-label={bulkAction.tooltip ?? bulkAction.label}
334
+ onclick={measuring ? undefined : () => handleBulkAction(bulkAction)}
335
+ >
336
+ <BulkIcon class="size-4" />
337
+ {#if bulkAction.label}
338
+ {bulkAction.label}{#if !isView}
339
+ ({selected.size})
340
+ {/if}
341
+ {/if}
342
+ </Button>
343
+ {/if}
344
+ {/each}
345
+
346
+ {#if allowDelete}
347
+ {@const DeleteIcon = icons.delete}
348
+ <Button
349
+ variant="error"
350
+ class="btn-outline"
351
+ disabled={selected.size === 0}
352
+ onclick={measuring ? undefined : handleDelete}
353
+ >
354
+ <DeleteIcon class="size-4" />
355
+ {deleteLabel} ({selected.size})
356
+ </Button>
357
+ {/if}
358
+
359
+ {@const CreateIcon = icons.create}
360
+ <Button variant="primary" onclick={measuring ? undefined : handleCreate}>
361
+ <CreateIcon class="size-5" />
362
+ {#if creation.label}
363
+ <span>{creation.label}</span>
364
+ {:else}
365
+ <span>{strings.create}<span class="hidden sm:inline">&nbsp;{labelOne}</span></span>
366
+ {/if}
367
+ </Button>
368
+ {/snippet}
369
+
234
370
  {#snippet actionsCell(item: T)}
235
371
  <div class="flex justify-end items-center gap-1">
236
372
  {#each rowActions as action (action.label)}
@@ -276,85 +412,115 @@
276
412
  breadcrumbs={[{ label: labelMany, icon: entityIcon, link: { href: '#' }, prominent: true }]}
277
413
  >
278
414
  {#snippet buttons()}
279
- {#if search}
280
- <SearchInput config={search} />
281
- {/if}
415
+ <div bind:this={toolbarEl} class="flex flex-wrap items-center gap-2 overflow-hidden">
416
+ {#if toolbarCollapsed}
417
+ {#if search}
418
+ <SearchInput config={search} />
419
+ {/if}
282
420
 
283
- {#if enableExport}
284
- {@const ExportIcon = icons.download}
285
- <Button
286
- variant="ghost"
287
- class="btn-square"
288
- popovertarget="export-menu-{exportId}"
289
- style="anchor-name:--export-anchor-{exportId}"
290
- aria-label={strings.export}
291
- title={strings.export}
292
- >
293
- <ExportIcon class="size-4" />
294
- </Button>
295
- <div
296
- popover="auto"
297
- id="export-menu-{exportId}"
298
- style="position-anchor:--export-anchor-{exportId}"
299
- class="dropdown dropdown-end w-40 rounded-box border border-base-content/10 bg-base-100 p-1 shadow-lg"
300
- bind:this={exportPopoverEl}
301
- >
302
- <Button
303
- variant="ghost"
304
- class="btn-sm w-full justify-start"
305
- onclick={() => handleExport('csv')}
306
- >
307
- {strings.exportCsv}
308
- </Button>
309
- {#if xlsx}
421
+ {#if hasCollapsibleActions}
310
422
  <Button
311
423
  variant="ghost"
312
- class="btn-sm w-full justify-start"
313
- onclick={() => handleExport('xlsx')}
424
+ class="btn-square"
425
+ popovertarget="actions-menu-{actionsMenuId}"
426
+ style="anchor-name:--actions-anchor-{actionsMenuId}"
427
+ aria-label={strings.actions}
428
+ title={strings.actions}
314
429
  >
315
- {strings.exportExcel}
430
+ <span class="text-lg leading-none" aria-hidden="true">⋯</span>
316
431
  </Button>
317
- {/if}
318
- </div>
319
- {/if}
432
+ <div
433
+ popover="auto"
434
+ id="actions-menu-{actionsMenuId}"
435
+ style="position-anchor:--actions-anchor-{actionsMenuId}"
436
+ class="dropdown dropdown-end w-56 rounded-box border border-base-content/10 bg-base-100 p-1 shadow-lg"
437
+ bind:this={actionsPopoverEl}
438
+ >
439
+ {#if enableExport}
440
+ <Button
441
+ variant="ghost"
442
+ class="btn-sm w-full justify-start"
443
+ onclick={() => {
444
+ actionsPopoverEl?.hidePopover();
445
+ handleExport('csv');
446
+ }}
447
+ >
448
+ {strings.exportCsv}
449
+ </Button>
450
+ {#if xlsx}
451
+ <Button
452
+ variant="ghost"
453
+ class="btn-sm w-full justify-start"
454
+ onclick={() => {
455
+ actionsPopoverEl?.hidePopover();
456
+ handleExport('xlsx');
457
+ }}
458
+ >
459
+ {strings.exportExcel}
460
+ </Button>
461
+ {/if}
462
+ {/if}
320
463
 
321
- {#each customBulkActions as bulkAction (bulkAction.label)}
322
- {#if bulkAction.condition?.(selectedItems) ?? true}
323
- {@const BulkIcon = bulkAction.icon}
324
- <Button
325
- variant={bulkAction.variant ?? 'ghost'}
326
- class="btn-outline"
327
- disabled={selected.size === 0}
328
- onclick={() => handleBulkAction(bulkAction)}
329
- >
330
- <BulkIcon class="size-4" />
331
- {bulkAction.label} ({selected.size})
332
- </Button>
333
- {/if}
334
- {/each}
464
+ {#each customBulkActions as bulkAction, i (i)}
465
+ {#if bulkAction.condition?.(selectedItems) ?? true}
466
+ {@const BulkIcon = bulkAction.icon}
467
+ {@const isView = isViewBasedBulkAction(bulkAction)}
468
+ <Button
469
+ variant="ghost"
470
+ class="btn-sm w-full justify-start"
471
+ disabled={!isView && selected.size === 0}
472
+ title={bulkAction.tooltip}
473
+ onclick={() => {
474
+ actionsPopoverEl?.hidePopover();
475
+ handleBulkAction(bulkAction);
476
+ }}
477
+ >
478
+ <BulkIcon class="size-4" />
479
+ {bulkAction.label ?? bulkAction.tooltip}{#if !isView}
480
+ ({selected.size})
481
+ {/if}
482
+ </Button>
483
+ {/if}
484
+ {/each}
335
485
 
336
- {#if allowDelete}
337
- {@const DeleteIcon = icons.delete}
338
- <Button
339
- variant="error"
340
- class="btn-outline"
341
- disabled={selected.size === 0}
342
- onclick={handleDelete}
343
- >
344
- <DeleteIcon class="size-4" />
345
- {deleteLabel} ({selected.size})
346
- </Button>
347
- {/if}
486
+ {#if allowDelete}
487
+ <Button
488
+ variant="ghost"
489
+ class="btn-sm w-full justify-start text-error"
490
+ disabled={selected.size === 0}
491
+ onclick={() => {
492
+ actionsPopoverEl?.hidePopover();
493
+ handleDelete();
494
+ }}
495
+ >
496
+ {deleteLabel} ({selected.size})
497
+ </Button>
498
+ {/if}
499
+ </div>
500
+ {/if}
348
501
 
349
- {@const CreateIcon = icons.create}
350
- <Button variant="primary" onclick={handleCreate}>
351
- <CreateIcon class="size-5" />
352
- {#if creation.label}
353
- <span>{creation.label}</span>
502
+ {@const CreateIcon = icons.create}
503
+ <Button variant="primary" onclick={handleCreate}>
504
+ <CreateIcon class="size-5" />
505
+ {#if creation.label}
506
+ <span>{creation.label}</span>
507
+ {:else}
508
+ <span>{strings.create}<span class="hidden sm:inline">&nbsp;{labelOne}</span></span>
509
+ {/if}
510
+ </Button>
354
511
  {:else}
355
- <span>{strings.create}<span class="hidden sm:inline">&nbsp;{labelOne}</span></span>
512
+ {@render toolbarButtons(false)}
356
513
  {/if}
357
- </Button>
514
+ </div>
515
+
516
+ <div
517
+ bind:this={toolbarMeasureEl}
518
+ class="pointer-events-none invisible absolute flex items-center gap-2 whitespace-nowrap"
519
+ style="left:-9999px; top:-9999px;"
520
+ aria-hidden="true"
521
+ >
522
+ {@render toolbarButtons(true)}
523
+ </div>
358
524
  {/snippet}
359
525
  </Header>
360
526
 
@@ -391,8 +557,10 @@
391
557
  {/if}
392
558
 
393
559
  {#if pendingBulkAction !== null}
394
- <Modal title={pendingBulkAction.action.label} onClose={() => (pendingBulkAction = null)}>
395
- <p>{strings.deleteConfirm(pendingBulkAction.items.length, pendingBulkAction.action.label)}</p>
560
+ {@const pendingActionLabel =
561
+ pendingBulkAction.action.label ?? pendingBulkAction.action.tooltip ?? strings.actions}
562
+ <Modal title={pendingActionLabel} onClose={() => (pendingBulkAction = null)}>
563
+ <p>{strings.deleteConfirm(pendingBulkAction.items.length, pendingActionLabel)}</p>
396
564
  <div class="flex justify-end gap-2 mt-4">
397
565
  <Button variant="ghost" onclick={() => (pendingBulkAction = null)}>
398
566
  {strings.cancel}
@@ -1,5 +1,5 @@
1
1
  import { type XlsxModule } from '../../table/export.js';
2
- import type { ActionConfiguration, ColumnDefinition, CustomAction, CustomBulkAction, SearchConfiguration } from '../../../types/crud.js';
2
+ import type { ActionConfiguration, ColumnDefinition, CustomAction, CustomBulkAction, SearchConfiguration, ViewBasedCustomBulkAction } from '../../../types/crud.js';
3
3
  import type { FilterSnapshot, ServerPagination, SortDirection, TableQuery } from '../../../types/table.js';
4
4
  declare function $$render<T extends object = Record<string, unknown>>(): {
5
5
  props: {
@@ -36,6 +36,7 @@ declare function $$render<T extends object = Record<string, unknown>>(): {
36
36
  onEdit?: (item: T) => void;
37
37
  onView?: (item: T) => void;
38
38
  onAction?: (action: CustomAction<T>, item: T) => void;
39
+ onBulkAction?: (action: ViewBasedCustomBulkAction<T>, items: T[]) => void;
39
40
  };
40
41
  exports: {};
41
42
  bindings: "";
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export type { BreadcrumbItem } from './types/breadcrumb.js';
2
2
  export { AttributeType } from './types/attribute.js';
3
3
  export type { AttributeMetadata, InterfaceMetadata, SelectOption, OptionsResolver, FormatterResolver, EmbeddedItemLabelResolver } from './types/attribute.js';
4
4
  export type { CellProps, CellComponent, CellFormatter, SortDirection, IndexedRow, DistinctEntry, PaginatedEnvelope, ServerPagination, FilterSnapshot, TableQuery } from './types/table.js';
5
- export type { ColumnDefinition, FieldDefinition, ActionConfiguration, CustomAction, CustomBulkAction, RowAction, SearchConfiguration } from './types/crud.js';
5
+ export type { ColumnDefinition, FieldDefinition, ActionConfiguration, CustomAction, BaseCustomBulkAction, ViewBasedCustomBulkAction, EndpointBasedCustomBulkAction, CustomBulkAction, RowAction, SearchConfiguration } from './types/crud.js';
6
6
  export type { RuneforgeConfig } from './config/context.js';
7
7
  export { setConfig, getConfig } from './config/context.js';
8
8
  export type { CRUDIconSet, IconComponent } from './icons/types.js';
@@ -99,14 +99,44 @@ export interface RowAction<T extends object = Record<string, unknown>> {
99
99
  condition?: (item: T) => boolean;
100
100
  run: (item: T) => void;
101
101
  }
102
- export interface CustomBulkAction<T extends object = Record<string, unknown>> {
103
- label: string;
102
+ export interface BaseCustomBulkAction<T extends object = Record<string, unknown>> {
103
+ /** Visible text on the button. Omit for an icon-only button — pass
104
+ * `tooltip` in that case so the action still has an accessible name and a
105
+ * hover hint, since `label` is what a collapsed "Acciones" menu item and
106
+ * the button's default `title`/`aria-label` fall back to otherwise. */
107
+ label?: string;
104
108
  icon: any;
105
- endpoint: string;
109
+ /** Hover title shown on the button. Defaults to `label`. Set this
110
+ * explicitly when `label` is omitted (icon-only button) so there's still
111
+ * a hint on hover and an accessible name. */
112
+ tooltip?: string;
106
113
  variant?: string;
107
- confirm?: boolean;
108
114
  condition?: (items: T[]) => boolean;
109
115
  }
116
+ export interface ViewBasedCustomBulkAction<T extends object = Record<string, unknown>> extends BaseCustomBulkAction<T> {
117
+ kind: 'view';
118
+ /** Renders as a modal-like panel when the action runs, instead of POSTing
119
+ * to an endpoint once per selected item, mirroring `CustomAction.view`.
120
+ *
121
+ * A `view` action never requires a pre-existing selection: the button is
122
+ * enabled even with nothing selected. The component receives the current
123
+ * selection as `items` (possibly empty). */
124
+ view: Component<any>;
125
+ }
126
+ export interface EndpointBasedCustomBulkAction<T extends object = Record<string, unknown>> extends BaseCustomBulkAction<T> {
127
+ kind: 'endpoint';
128
+ /** POSTed once per selected item (`id` set in the FormData body), then
129
+ * `invalidateAll()`s. */
130
+ endpoint: string;
131
+ confirm?: boolean;
132
+ }
133
+ /** A toolbar action operating on the current selection — either a `view`
134
+ * (opens a modal-like panel, e.g. an import wizard) or an `endpoint`
135
+ * (POSTed once per selected item). The required `kind` discriminant picks
136
+ * which one: `'view'` brings `view` and forbids `endpoint`/`confirm`;
137
+ * `'endpoint'` brings `endpoint`/`confirm` and forbids `view` — see
138
+ * `ViewBasedCustomBulkAction` / `EndpointBasedCustomBulkAction`. */
139
+ export type CustomBulkAction<T extends object = Record<string, unknown>> = ViewBasedCustomBulkAction<T> | EndpointBasedCustomBulkAction<T>;
110
140
  export interface SearchConfiguration {
111
141
  param?: string;
112
142
  placeholder?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runeforge",
3
- "version": "0.0.36",
3
+ "version": "0.0.38",
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",