runeforge 0.0.37 → 0.0.39

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)}
@@ -49,5 +49,5 @@
49
49
  bind:value
50
50
  oninput={onInput}
51
51
  placeholder={config.placeholder ?? strings.searchPlaceholder}
52
- class="input input-bordered"
52
+ class="input input-bordered w-64 shrink-0"
53
53
  />
@@ -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,9 @@
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
+ );
103
112
 
104
113
  let selected = new SvelteSet<number>();
105
114
  let pendingDeletion = $state<T[] | null>(null);
@@ -109,7 +118,33 @@
109
118
  let visibleRows = $state<T[]>([]);
110
119
  let exportQuery = $state<TableQuery | undefined>(undefined);
111
120
  let exportPopoverEl: HTMLElement | undefined = $state();
121
+ let actionsPopoverEl: HTMLElement | undefined = $state();
112
122
  const exportId = $props.id();
123
+ const actionsMenuId = `${exportId}-actions`;
124
+
125
+ // Collapses export/bulk-actions/delete into a single "Acciones" dropdown
126
+ // once the toolbar no longer fits its available width (small monitor, a
127
+ // browser window not maximized, many customBulkActions, ...) — measured
128
+ // against an identical but off-flow, always-uncollapsed clone rather than
129
+ // the visible row itself, since the visible row's own width changes
130
+ // depending on whether it's currently collapsed.
131
+ let toolbarEl: HTMLElement | undefined = $state();
132
+ let toolbarMeasureEl: HTMLElement | undefined = $state();
133
+ let toolbarCollapsed = $state(false);
134
+
135
+ $effect(() => {
136
+ if (!toolbarEl || !toolbarMeasureEl) return;
137
+ const el = toolbarEl;
138
+ const measureEl = toolbarMeasureEl;
139
+ const check = () => {
140
+ toolbarCollapsed = measureEl.scrollWidth > el.clientWidth + 1;
141
+ };
142
+ const observer = new ResizeObserver(check);
143
+ observer.observe(el);
144
+ observer.observe(measureEl);
145
+ check();
146
+ return () => observer.disconnect();
147
+ });
113
148
 
114
149
  async function resolveExportRows(): Promise<T[]> {
115
150
  if (!pagination) return visibleRows;
@@ -181,12 +216,12 @@
181
216
  }
182
217
 
183
218
  async function runBulkAction(action: CustomBulkAction<T>, items: T[]) {
184
- if (!action.endpoint) return;
219
+ if (isViewBasedBulkAction(action)) return;
185
220
  await runEndpointAction(action.endpoint, items);
186
221
  }
187
222
 
188
223
  function requestBulkAction(action: CustomBulkAction<T>, items: T[]) {
189
- if (action.confirm) {
224
+ if (!isViewBasedBulkAction(action) && action.confirm) {
190
225
  pendingBulkAction = { action, items };
191
226
  } else {
192
227
  runBulkAction(action, items);
@@ -195,7 +230,7 @@
195
230
 
196
231
  function handleBulkAction(action: CustomBulkAction<T>) {
197
232
  const items = selectedItems;
198
- if (action.view) {
233
+ if (isViewBasedBulkAction(action)) {
199
234
  onBulkAction?.(action, items);
200
235
  return;
201
236
  }
@@ -238,6 +273,97 @@
238
273
  ]);
239
274
  </script>
240
275
 
276
+ {#snippet toolbarButtons(measuring: boolean)}
277
+ {#if search}
278
+ <SearchInput config={search} />
279
+ {/if}
280
+
281
+ {#if enableExport}
282
+ {@const ExportIcon = icons.download}
283
+ <Button
284
+ variant="ghost"
285
+ class="btn-square"
286
+ popovertarget={measuring ? undefined : `export-menu-${exportId}`}
287
+ style={measuring ? undefined : `anchor-name:--export-anchor-${exportId}`}
288
+ aria-label={strings.export}
289
+ title={strings.export}
290
+ >
291
+ <ExportIcon class="size-4" />
292
+ </Button>
293
+ {#if !measuring}
294
+ <div
295
+ popover="auto"
296
+ id="export-menu-{exportId}"
297
+ style="position-anchor:--export-anchor-{exportId}"
298
+ class="dropdown dropdown-end w-40 rounded-box border border-base-content/10 bg-base-100 p-1 shadow-lg"
299
+ bind:this={exportPopoverEl}
300
+ >
301
+ <Button
302
+ variant="ghost"
303
+ class="btn-sm w-full justify-start"
304
+ onclick={() => handleExport('csv')}
305
+ >
306
+ {strings.exportCsv}
307
+ </Button>
308
+ {#if xlsx}
309
+ <Button
310
+ variant="ghost"
311
+ class="btn-sm w-full justify-start"
312
+ onclick={() => handleExport('xlsx')}
313
+ >
314
+ {strings.exportExcel}
315
+ </Button>
316
+ {/if}
317
+ </div>
318
+ {/if}
319
+ {/if}
320
+
321
+ {#each customBulkActions as bulkAction, i (i)}
322
+ {#if bulkAction.condition?.(selectedItems) ?? true}
323
+ {@const BulkIcon = bulkAction.icon}
324
+ {@const isView = isViewBasedBulkAction(bulkAction)}
325
+ <Button
326
+ variant={bulkAction.variant ?? 'ghost'}
327
+ class="btn-outline"
328
+ disabled={!isView && selected.size === 0}
329
+ title={bulkAction.tooltip ?? bulkAction.label}
330
+ aria-label={bulkAction.tooltip ?? bulkAction.label}
331
+ onclick={measuring ? undefined : () => handleBulkAction(bulkAction)}
332
+ >
333
+ <BulkIcon class="size-4" />
334
+ {#if bulkAction.label}
335
+ {bulkAction.label}{#if !isView}
336
+ ({selected.size})
337
+ {/if}
338
+ {/if}
339
+ </Button>
340
+ {/if}
341
+ {/each}
342
+
343
+ {#if allowDelete}
344
+ {@const DeleteIcon = icons.delete}
345
+ <Button
346
+ variant="error"
347
+ class="btn-outline"
348
+ disabled={selected.size === 0}
349
+ onclick={measuring ? undefined : handleDelete}
350
+ >
351
+ <DeleteIcon class="size-4" />
352
+ {deleteLabel} ({selected.size})
353
+ </Button>
354
+ {/if}
355
+
356
+ {@const CreateIcon = icons.create}
357
+ <Button variant="primary" onclick={measuring ? undefined : handleCreate}>
358
+ <CreateIcon class="size-5" />
359
+ {#if creation.label}
360
+ <span>{creation.label}</span>
361
+ {:else}
362
+ <span>{strings.create}<span class="hidden sm:inline">&nbsp;{labelOne}</span></span>
363
+ {/if}
364
+ </Button>
365
+ {/snippet}
366
+
241
367
  {#snippet actionsCell(item: T)}
242
368
  <div class="flex justify-end items-center gap-1">
243
369
  {#each rowActions as action (action.label)}
@@ -283,87 +409,116 @@
283
409
  breadcrumbs={[{ label: labelMany, icon: entityIcon, link: { href: '#' }, prominent: true }]}
284
410
  >
285
411
  {#snippet buttons()}
286
- {#if search}
287
- <SearchInput config={search} />
288
- {/if}
412
+ <div bind:this={toolbarEl} class="flex min-w-0 flex-wrap items-center gap-2 overflow-hidden">
413
+ {#if toolbarCollapsed}
414
+ {#if search}
415
+ <SearchInput config={search} />
416
+ {/if}
289
417
 
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
- >
418
+ {@const CreateIcon = icons.create}
309
419
  <Button
310
420
  variant="ghost"
311
- class="btn-sm w-full justify-start"
312
- onclick={() => handleExport('csv')}
421
+ class="btn-square"
422
+ popovertarget="actions-menu-{actionsMenuId}"
423
+ style="anchor-name:--actions-anchor-{actionsMenuId}"
424
+ aria-label={strings.actions}
425
+ title={strings.actions}
313
426
  >
314
- {strings.exportCsv}
427
+ <span class="text-lg leading-none" aria-hidden="true">⋯</span>
315
428
  </Button>
316
- {#if xlsx}
429
+ <div
430
+ popover="auto"
431
+ id="actions-menu-{actionsMenuId}"
432
+ style="position-anchor:--actions-anchor-{actionsMenuId}"
433
+ class="dropdown dropdown-end w-56 rounded-box border border-base-content/10 bg-base-100 p-1 shadow-lg"
434
+ bind:this={actionsPopoverEl}
435
+ >
317
436
  <Button
318
437
  variant="ghost"
319
438
  class="btn-sm w-full justify-start"
320
- onclick={() => handleExport('xlsx')}
439
+ onclick={() => {
440
+ actionsPopoverEl?.hidePopover();
441
+ handleCreate();
442
+ }}
321
443
  >
322
- {strings.exportExcel}
444
+ <CreateIcon class="size-4" />
445
+ {creation.label || `${strings.create} ${labelOne}`}
323
446
  </Button>
324
- {/if}
325
- </div>
326
- {/if}
327
447
 
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})
448
+ {#if allowDelete}
449
+ <Button
450
+ variant="ghost"
451
+ class="btn-sm w-full justify-start text-error"
452
+ disabled={selected.size === 0}
453
+ onclick={() => {
454
+ actionsPopoverEl?.hidePopover();
455
+ handleDelete();
456
+ }}
457
+ >
458
+ {deleteLabel} ({selected.size})
459
+ </Button>
340
460
  {/if}
341
- </Button>
342
- {/if}
343
- {/each}
344
461
 
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}
462
+ {#if enableExport}
463
+ <Button
464
+ variant="ghost"
465
+ class="btn-sm w-full justify-start"
466
+ onclick={() => {
467
+ actionsPopoverEl?.hidePopover();
468
+ handleExport('csv');
469
+ }}
470
+ >
471
+ {strings.exportCsv}
472
+ </Button>
473
+ {#if xlsx}
474
+ <Button
475
+ variant="ghost"
476
+ class="btn-sm w-full justify-start"
477
+ onclick={() => {
478
+ actionsPopoverEl?.hidePopover();
479
+ handleExport('xlsx');
480
+ }}
481
+ >
482
+ {strings.exportExcel}
483
+ </Button>
484
+ {/if}
485
+ {/if}
357
486
 
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>
487
+ {#each customBulkActions as bulkAction, i (i)}
488
+ {#if bulkAction.condition?.(selectedItems) ?? true}
489
+ {@const BulkIcon = bulkAction.icon}
490
+ {@const isView = isViewBasedBulkAction(bulkAction)}
491
+ <Button
492
+ variant="ghost"
493
+ class="btn-sm w-full justify-start"
494
+ disabled={!isView && selected.size === 0}
495
+ title={bulkAction.tooltip}
496
+ onclick={() => {
497
+ actionsPopoverEl?.hidePopover();
498
+ handleBulkAction(bulkAction);
499
+ }}
500
+ >
501
+ <BulkIcon class="size-4" />
502
+ {bulkAction.label ?? bulkAction.tooltip}{#if !isView}
503
+ ({selected.size})
504
+ {/if}
505
+ </Button>
506
+ {/if}
507
+ {/each}
508
+ </div>
363
509
  {:else}
364
- <span>{strings.create}<span class="hidden sm:inline">&nbsp;{labelOne}</span></span>
510
+ {@render toolbarButtons(false)}
365
511
  {/if}
366
- </Button>
512
+ </div>
513
+
514
+ <div
515
+ bind:this={toolbarMeasureEl}
516
+ class="pointer-events-none invisible fixed flex items-center gap-2 whitespace-nowrap"
517
+ style="left:-9999px; top:-9999px; width:max-content;"
518
+ aria-hidden="true"
519
+ >
520
+ {@render toolbarButtons(true)}
521
+ </div>
367
522
  {/snippet}
368
523
  </Header>
369
524
 
@@ -400,8 +555,10 @@
400
555
  {/if}
401
556
 
402
557
  {#if pendingBulkAction !== null}
403
- <Modal title={pendingBulkAction.action.label} onClose={() => (pendingBulkAction = null)}>
404
- <p>{strings.deleteConfirm(pendingBulkAction.items.length, pendingBulkAction.action.label)}</p>
558
+ {@const pendingActionLabel =
559
+ pendingBulkAction.action.label ?? pendingBulkAction.action.tooltip ?? strings.actions}
560
+ <Modal title={pendingActionLabel} onClose={() => (pendingBulkAction = null)}>
561
+ <p>{strings.deleteConfirm(pendingBulkAction.items.length, pendingActionLabel)}</p>
405
562
  <div class="flex justify-end gap-2 mt-4">
406
563
  <Button variant="ghost" onclick={() => (pendingBulkAction = null)}>
407
564
  {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.39",
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",