runeforge 0.0.37 → 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,7 +122,9 @@
121
122
  );
122
123
 
123
124
  let activeAction = $state<{ action: CustomAction<T>; item: T } | null>(null);
124
- let activeBulkAction = $state<{ action: CustomBulkAction<T>; items: T[] } | null>(null);
125
+ let activeBulkAction = $state<{ action: ViewBasedCustomBulkAction<T>; items: T[] } | null>(
126
+ null
127
+ );
125
128
 
126
129
  async function runAction(action: CustomAction<T>, item: T) {
127
130
  if (!(action.condition?.(item) ?? true)) return;
@@ -132,7 +135,7 @@
132
135
  activeAction = { action, item };
133
136
  }
134
137
 
135
- function runBulkAction(action: CustomBulkAction<T>, items: T[]) {
138
+ function runBulkAction(action: ViewBasedCustomBulkAction<T>, items: T[]) {
136
139
  activeBulkAction = { action, items };
137
140
  }
138
141
 
@@ -387,7 +390,6 @@
387
390
  <BulkActionView
388
391
  items={activeBulkAction.items}
389
392
  label={activeBulkAction.action.label}
390
- endpoint={activeBulkAction.action.endpoint}
391
393
  {serverError}
392
394
  onCancel={() => (activeBulkAction = null)}
393
395
  onSuccess={() => (activeBulkAction = null)}
@@ -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 = '',
@@ -86,7 +93,7 @@
86
93
  onEdit?: (item: T) => void;
87
94
  onView?: (item: T) => void;
88
95
  onAction?: (action: CustomAction<T>, item: T) => void;
89
- onBulkAction?: (action: CustomBulkAction<T>, items: T[]) => void;
96
+ onBulkAction?: (action: ViewBasedCustomBulkAction<T>, items: T[]) => void;
90
97
  } = $props();
91
98
 
92
99
  const icons = $derived(getIconSet() ?? defaultIconSet);
@@ -99,7 +106,12 @@
99
106
  const updateLabel = $derived(update.label ?? strings.edit);
100
107
  const readLabel = $derived(read.label ?? strings.view);
101
108
  const showRowActions = $derived(allowRead || allowUpdate || allowDelete || actions.length > 0);
102
- const allowSelection = $derived(allowDelete || customBulkActions.some((action) => !action.view));
109
+ const allowSelection = $derived(
110
+ allowDelete || customBulkActions.some((action) => !isViewBasedBulkAction(action))
111
+ );
112
+ const hasCollapsibleActions = $derived(
113
+ enableExport || customBulkActions.length > 0 || allowDelete
114
+ );
103
115
 
104
116
  let selected = new SvelteSet<number>();
105
117
  let pendingDeletion = $state<T[] | null>(null);
@@ -109,7 +121,33 @@
109
121
  let visibleRows = $state<T[]>([]);
110
122
  let exportQuery = $state<TableQuery | undefined>(undefined);
111
123
  let exportPopoverEl: HTMLElement | undefined = $state();
124
+ let actionsPopoverEl: HTMLElement | undefined = $state();
112
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
+ });
113
151
 
114
152
  async function resolveExportRows(): Promise<T[]> {
115
153
  if (!pagination) return visibleRows;
@@ -181,12 +219,12 @@
181
219
  }
182
220
 
183
221
  async function runBulkAction(action: CustomBulkAction<T>, items: T[]) {
184
- if (!action.endpoint) return;
222
+ if (isViewBasedBulkAction(action)) return;
185
223
  await runEndpointAction(action.endpoint, items);
186
224
  }
187
225
 
188
226
  function requestBulkAction(action: CustomBulkAction<T>, items: T[]) {
189
- if (action.confirm) {
227
+ if (!isViewBasedBulkAction(action) && action.confirm) {
190
228
  pendingBulkAction = { action, items };
191
229
  } else {
192
230
  runBulkAction(action, items);
@@ -195,7 +233,7 @@
195
233
 
196
234
  function handleBulkAction(action: CustomBulkAction<T>) {
197
235
  const items = selectedItems;
198
- if (action.view) {
236
+ if (isViewBasedBulkAction(action)) {
199
237
  onBulkAction?.(action, items);
200
238
  return;
201
239
  }
@@ -238,6 +276,97 @@
238
276
  ]);
239
277
  </script>
240
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
+
241
370
  {#snippet actionsCell(item: T)}
242
371
  <div class="flex justify-end items-center gap-1">
243
372
  {#each rowActions as action (action.label)}
@@ -283,87 +412,115 @@
283
412
  breadcrumbs={[{ label: labelMany, icon: entityIcon, link: { href: '#' }, prominent: true }]}
284
413
  >
285
414
  {#snippet buttons()}
286
- {#if search}
287
- <SearchInput config={search} />
288
- {/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}
289
420
 
290
- {#if enableExport}
291
- {@const ExportIcon = icons.download}
292
- <Button
293
- variant="ghost"
294
- class="btn-square"
295
- popovertarget="export-menu-{exportId}"
296
- style="anchor-name:--export-anchor-{exportId}"
297
- aria-label={strings.export}
298
- title={strings.export}
299
- >
300
- <ExportIcon class="size-4" />
301
- </Button>
302
- <div
303
- popover="auto"
304
- id="export-menu-{exportId}"
305
- style="position-anchor:--export-anchor-{exportId}"
306
- class="dropdown dropdown-end w-40 rounded-box border border-base-content/10 bg-base-100 p-1 shadow-lg"
307
- bind:this={exportPopoverEl}
308
- >
309
- <Button
310
- variant="ghost"
311
- class="btn-sm w-full justify-start"
312
- onclick={() => handleExport('csv')}
313
- >
314
- {strings.exportCsv}
315
- </Button>
316
- {#if xlsx}
421
+ {#if hasCollapsibleActions}
317
422
  <Button
318
423
  variant="ghost"
319
- class="btn-sm w-full justify-start"
320
- 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}
321
429
  >
322
- {strings.exportExcel}
430
+ <span class="text-lg leading-none" aria-hidden="true">⋯</span>
323
431
  </Button>
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}
463
+
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}
485
+
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>
324
500
  {/if}
325
- </div>
326
- {/if}
327
501
 
328
- {#each customBulkActions as bulkAction (bulkAction.label)}
329
- {#if bulkAction.condition?.(selectedItems) ?? true}
330
- {@const BulkIcon = bulkAction.icon}
331
- <Button
332
- variant={bulkAction.variant ?? 'ghost'}
333
- class="btn-outline"
334
- disabled={!bulkAction.view && selected.size === 0}
335
- onclick={() => handleBulkAction(bulkAction)}
336
- >
337
- <BulkIcon class="size-4" />
338
- {bulkAction.label}{#if !bulkAction.view}
339
- ({selected.size})
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>
340
509
  {/if}
341
510
  </Button>
342
- {/if}
343
- {/each}
344
-
345
- {#if allowDelete}
346
- {@const DeleteIcon = icons.delete}
347
- <Button
348
- variant="error"
349
- class="btn-outline"
350
- disabled={selected.size === 0}
351
- onclick={handleDelete}
352
- >
353
- <DeleteIcon class="size-4" />
354
- {deleteLabel} ({selected.size})
355
- </Button>
356
- {/if}
357
-
358
- {@const CreateIcon = icons.create}
359
- <Button variant="primary" onclick={handleCreate}>
360
- <CreateIcon class="size-5" />
361
- {#if creation.label}
362
- <span>{creation.label}</span>
363
511
  {:else}
364
- <span>{strings.create}<span class="hidden sm:inline">&nbsp;{labelOne}</span></span>
512
+ {@render toolbarButtons(false)}
365
513
  {/if}
366
- </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>
367
524
  {/snippet}
368
525
  </Header>
369
526
 
@@ -400,8 +557,10 @@
400
557
  {/if}
401
558
 
402
559
  {#if pendingBulkAction !== null}
403
- <Modal title={pendingBulkAction.action.label} onClose={() => (pendingBulkAction = null)}>
404
- <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>
405
564
  <div class="flex justify-end gap-2 mt-4">
406
565
  <Button variant="ghost" onclick={() => (pendingBulkAction = null)}>
407
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,7 +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: CustomBulkAction<T>, items: T[]) => void;
39
+ onBulkAction?: (action: ViewBasedCustomBulkAction<T>, items: T[]) => void;
40
40
  };
41
41
  exports: {};
42
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,24 +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
- /** Required unless `view` is set. */
106
- 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;
107
113
  variant?: string;
108
- confirm?: boolean;
109
114
  condition?: (items: T[]) => boolean;
115
+ }
116
+ export interface ViewBasedCustomBulkAction<T extends object = Record<string, unknown>> extends BaseCustomBulkAction<T> {
117
+ kind: 'view';
110
118
  /** Renders as a modal-like panel when the action runs, instead of POSTing
111
- * to `endpoint` once per selected item. Mutually exclusive with
112
- * `endpoint` — provide exactly one of the two, mirroring `CustomAction.view`.
119
+ * to an endpoint once per selected item, mirroring `CustomAction.view`.
113
120
  *
114
- * Unlike the endpoint-loop behavior, a `view` action never requires a
115
- * pre-existing selection: the button is enabled even with nothing
116
- * selected, and `confirm` is ignored (the view owns its own flow). The
117
- * component receives the current selection as `items` (possibly empty). */
118
- view?: Component<any>;
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;
119
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>;
120
140
  export interface SearchConfiguration {
121
141
  param?: string;
122
142
  placeholder?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runeforge",
3
- "version": "0.0.37",
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",