@svadmin/ui 0.50.0 → 0.51.0

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.
Files changed (29) hide show
  1. package/dist/components/AutoTable.svelte +223 -106
  2. package/dist/components/AutoTable.svelte.d.ts +5 -0
  3. package/dist/components/ListPage.svelte +16 -2
  4. package/dist/components/ListPage.svelte.d.ts +7 -1
  5. package/dist/components/ShowPage.svelte +65 -23
  6. package/dist/components/ShowPage.svelte.d.ts +3 -0
  7. package/dist/components/buttons/CloneButton.svelte +23 -2
  8. package/dist/components/buttons/CloneButton.svelte.d.ts +3 -0
  9. package/dist/components/buttons/CreateButton.svelte +22 -2
  10. package/dist/components/buttons/CreateButton.svelte.d.ts +3 -0
  11. package/dist/components/buttons/DeleteButton.svelte +23 -3
  12. package/dist/components/buttons/DeleteButton.svelte.d.ts +3 -0
  13. package/dist/components/buttons/EditButton.svelte +23 -2
  14. package/dist/components/buttons/EditButton.svelte.d.ts +3 -0
  15. package/dist/components/buttons/ExportButton.svelte +22 -3
  16. package/dist/components/buttons/ExportButton.svelte.d.ts +3 -0
  17. package/dist/components/buttons/ImportButton.svelte +39 -17
  18. package/dist/components/buttons/ImportButton.svelte.d.ts +3 -0
  19. package/dist/components/buttons/ListButton.svelte +13 -2
  20. package/dist/components/buttons/ListButton.svelte.d.ts +2 -0
  21. package/dist/components/buttons/RefreshButton.svelte +22 -2
  22. package/dist/components/buttons/RefreshButton.svelte.d.ts +3 -0
  23. package/dist/components/buttons/SaveButton.svelte +23 -2
  24. package/dist/components/buttons/SaveButton.svelte.d.ts +3 -0
  25. package/dist/components/buttons/ShowButton.svelte +24 -3
  26. package/dist/components/buttons/ShowButton.svelte.d.ts +3 -0
  27. package/dist/components/content/DescriptionList.svelte +125 -15
  28. package/dist/components/content/DescriptionList.svelte.d.ts +6 -2
  29. package/package.json +2 -2
@@ -61,7 +61,7 @@
61
61
  import {
62
62
  Plus, Pencil, Trash2,
63
63
  Search, Download, ChevronDown, ChevronUp, SlidersHorizontal, Filter as FilterIcon,
64
- Eye, Copy
64
+ Eye, Copy, RefreshCw, Rows, X
65
65
  } from '@lucide/svelte';
66
66
  import ConfirmDialog from './ConfirmDialog.svelte';
67
67
  import TooltipButton from './TooltipButton.svelte';
@@ -76,6 +76,11 @@
76
76
  resourceName: string;
77
77
  selectable?: boolean;
78
78
  density?: 'compact' | 'comfortable';
79
+ showHeader?: boolean;
80
+ title?: string;
81
+ showFilterTags?: boolean;
82
+ showDensitySwitcher?: boolean;
83
+ showRefresh?: boolean;
79
84
  /** Custom header actions (right side) */
80
85
  headerActions?: Snippet;
81
86
  /** Custom cell renderers by field key */
@@ -100,6 +105,11 @@
100
105
  resourceName,
101
106
  selectable = true,
102
107
  density = 'comfortable',
108
+ showHeader = true,
109
+ title,
110
+ showFilterTags = true,
111
+ showDensitySwitcher = true,
112
+ showRefresh = true,
103
113
  headerActions,
104
114
  columns: customColumns,
105
115
  defaultCellRenderer,
@@ -110,6 +120,9 @@
110
120
  pagination: externalPagination,
111
121
  sorters: externalSorters,
112
122
  }: Props = $props();
123
+
124
+ let densityOverride = $state<'compact' | 'comfortable' | undefined>(undefined);
125
+ const currentDensity = $derived(densityOverride ?? density);
113
126
  const adminContext = captureAdminContext();
114
127
 
115
128
  const resource = $derived(getResource(resourceName));
@@ -206,6 +219,12 @@
206
219
  return result;
207
220
  });
208
221
 
222
+ function removeFilterKey(key: string): void {
223
+ const { [key]: _removed, ...rest } = filterValues;
224
+ filterValues = rest;
225
+ pagination = { ...pagination, current: 1 };
226
+ }
227
+
209
228
  function clonePlainValue(value: unknown): unknown {
210
229
  if (Array.isArray(value)) return value.map(clonePlainValue);
211
230
  if (value && typeof value === 'object') {
@@ -552,38 +571,173 @@
552
571
  }
553
572
  </script>
554
573
 
555
- <div class="space-y-4">
556
- <!-- Header -->
557
- <div class="flex flex-wrap items-center justify-between gap-2">
558
- <h1 class="text-lg sm:text-xl font-semibold text-foreground">{resource.label}</h1>
559
- <div class="flex flex-wrap items-center gap-2">
560
- {#if canExport}
561
- <Button variant="outline" size="sm" onclick={exportCSV}>
562
- <Download class="h-4 w-4" data-icon="inline-start" /> {i18n.t('common.export')}
563
- </Button>
564
- {/if}
565
- {#if selectedCount > 0 && canDelete}
574
+ <div class="space-y-3">
575
+ {#if showHeader}
576
+ <!-- Header -->
577
+ <div class="flex flex-wrap items-center justify-between gap-2">
578
+ <h1 class="text-lg sm:text-xl font-semibold text-foreground">{title ?? resource.label}</h1>
579
+ <div class="flex flex-wrap items-center gap-2">
580
+ {#if canExport}
581
+ <Button variant="outline" size="sm" onclick={exportCSV}>
582
+ <Download class="h-4 w-4" data-icon="inline-start" /> {i18n.t('common.export')}
583
+ </Button>
584
+ {/if}
585
+ {#if headerActions}
586
+ {@render headerActions()}
587
+ {/if}
588
+ {#if canCreate}
589
+ <Button onclick={() => adminContext.navigate(`/${resourceName}/create`)}>
590
+ <Plus class="h-4 w-4" data-icon="inline-start" /> {i18n.t('common.create')}
591
+ </Button>
592
+ {/if}
593
+ </div>
594
+ </div>
595
+ {/if}
596
+
597
+ <!-- Selection Banner (Enterprise Batch Actions) -->
598
+ {#if selectedCount > 0}
599
+ <div class="flex flex-wrap items-center justify-between gap-3 px-3.5 py-2 bg-primary/10 border border-primary/20 rounded-lg text-sm text-foreground animate-in fade-in duration-200">
600
+ <div class="flex items-center gap-2">
601
+ <span class="font-medium text-xs sm:text-sm">{i18n.t('common.selectedItems', { count: selectedCount })}</span>
602
+ </div>
603
+ <div class="flex items-center gap-2">
604
+ {#if canDelete}
605
+ <Button
606
+ variant="destructive"
607
+ size="sm"
608
+ class="h-8 text-xs whitespace-nowrap"
609
+ onclick={confirmBatchDelete}
610
+ >
611
+ <Trash2 class="h-3.5 w-3.5" data-icon="inline-start" /> {i18n.t('common.batchDelete', { count: selectedCount })}
612
+ </Button>
613
+ {/if}
614
+ {#if batchActions}
615
+ {@render batchActions({ selectedIds: Object.keys(tableRowSelection.current) })}
616
+ {/if}
566
617
  <Button
567
- variant="destructive"
618
+ variant="ghost"
568
619
  size="sm"
569
- class="h-9 whitespace-nowrap"
570
- onclick={confirmBatchDelete}
620
+ class="h-8 text-xs text-muted-foreground hover:text-foreground"
621
+ onclick={() => rowSelectionAtom.set({})}
571
622
  >
572
- <Trash2 class="h-4 w-4" data-icon="inline-start" /> {i18n.t('common.batchDelete', { count: selectedCount })}
623
+ {i18n.t('common.clearSelection')}
573
624
  </Button>
625
+ </div>
626
+ </div>
627
+ {/if}
628
+
629
+ <!-- Search, Filter & Table Utility Toolbar -->
630
+ <div class="flex flex-wrap items-center justify-between gap-2">
631
+ <!-- Left: Search and Advanced Filters -->
632
+ <div class="flex flex-wrap items-center gap-2 flex-1 min-w-[200px]">
633
+ {#if searchableFields.length > 0}
634
+ <div class="relative max-w-sm flex-1 sm:min-w-[220px]">
635
+ <Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
636
+ <Input
637
+ type="text"
638
+ value={searchText}
639
+ oninput={scheduleSearch}
640
+ placeholder={i18n.t('common.search')}
641
+ class="pl-9 h-9 text-sm"
642
+ />
643
+ </div>
574
644
  {/if}
575
-
576
- {#if selectedCount > 0 && batchActions}
577
- {@render batchActions({ selectedIds: Object.keys(tableRowSelection.current) })}
645
+
646
+ {#if filterableFields.length > 0}
647
+ <Popover.Root>
648
+ <Popover.Trigger>
649
+ {#snippet child({ props })}
650
+ <Button variant="outline" size="sm" class="h-9 px-3" {...props}>
651
+ <FilterIcon class="h-4 w-4" data-icon="inline-start" />
652
+ {i18n.t('common.filter')}
653
+ {#if activeFilterCount > 0}
654
+ <Badge variant="secondary" class="ml-1 h-5 min-w-5 px-1">{activeFilterCount}</Badge>
655
+ {/if}
656
+ </Button>
657
+ {/snippet}
658
+ </Popover.Trigger>
659
+ <Popover.Content class="w-80">
660
+ <div class="space-y-3">
661
+ <h4 class="font-medium text-sm">{i18n.t('common.filter')}</h4>
662
+ {#each filterableFields as field, _i (_i)}
663
+ <div class="space-y-1">
664
+ <label class="text-xs text-muted-foreground" for="filter-{field.key}">{field.label}</label>
665
+ {#if field.type === 'select' && field.options}
666
+ <select
667
+ id="filter-{field.key}"
668
+ class="h-9 text-sm w-full font-normal flex w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
669
+ value={filterValues[field.key] ?? ''}
670
+ onchange={(e) => filterValues[field.key] = (e.currentTarget as HTMLSelectElement).value}
671
+ >
672
+ <option value="">{i18n.t('common.all')}</option>
673
+ {#each field.options as opt, _i (_i)}
674
+ <option value={opt.value}>{opt.label}</option>
675
+ {/each}
676
+ </select>
677
+ {:else}
678
+ <Input
679
+ id="filter-{field.key}"
680
+ type="text"
681
+ value={filterValues[field.key] ?? ''}
682
+ oninput={(e) => filterValues[field.key] = (e.currentTarget as HTMLInputElement).value}
683
+ placeholder={field.label}
684
+ class="h-9 text-sm"
685
+ />
686
+ {/if}
687
+ </div>
688
+ {/each}
689
+ <div class="flex gap-2 pt-2">
690
+ <Button size="sm" class="flex-1" onclick={() => { pagination = { ...pagination, current: 1 }; }}>
691
+ {i18n.t('common.confirm')}
692
+ </Button>
693
+ <Button variant="outline" size="sm" onclick={() => {
694
+ filterValues = {};
695
+ pagination = { ...pagination, current: 1 };
696
+ }}>
697
+ {i18n.t('common.reset')}
698
+ </Button>
699
+ </div>
700
+ </div>
701
+ </Popover.Content>
702
+ </Popover.Root>
578
703
  {/if}
704
+ </div>
579
705
 
580
- <!-- Column Visibility Picker (DropdownMenu) -->
706
+ <!-- Right: Table Controls (Density, Columns, Refresh, Export if !showHeader) -->
707
+ <div class="flex items-center gap-1.5 shrink-0">
708
+ {#if !showHeader && canExport}
709
+ <TooltipButton tooltip={i18n.t('common.export')} variant="outline" size="sm" class="h-9 px-2.5" onclick={exportCSV}>
710
+ <Download class="h-4 w-4" />
711
+ </TooltipButton>
712
+ {/if}
713
+
714
+ {#if showDensitySwitcher}
715
+ <DropdownMenu.Root>
716
+ <DropdownMenu.Trigger>
717
+ {#snippet child({ props })}
718
+ <TooltipButton tooltip={i18n.t('common.density')} variant="outline" size="sm" class="h-9 px-2.5" {...props}>
719
+ <Rows class="h-4 w-4" />
720
+ </TooltipButton>
721
+ {/snippet}
722
+ </DropdownMenu.Trigger>
723
+ <DropdownMenu.Content align="end" class="w-36">
724
+ <DropdownMenu.Item onclick={() => { densityOverride = 'comfortable'; }} class={currentDensity === 'comfortable' ? 'font-semibold text-primary' : ''}>
725
+ {i18n.t('common.densityComfortable')}
726
+ </DropdownMenu.Item>
727
+ <DropdownMenu.Item onclick={() => { densityOverride = 'compact'; }} class={currentDensity === 'compact' ? 'font-semibold text-primary' : ''}>
728
+ {i18n.t('common.densityCompact')}
729
+ </DropdownMenu.Item>
730
+ </DropdownMenu.Content>
731
+ </DropdownMenu.Root>
732
+ {/if}
733
+
734
+ <!-- Column Visibility Picker -->
581
735
  <DropdownMenu.Root>
582
736
  <DropdownMenu.Trigger>
583
737
  {#snippet child({ props })}
584
- <Button variant="outline" size="sm" {...props}>
585
- <SlidersHorizontal class="h-4 w-4" data-icon="inline-start" /> {i18n.t('common.columns')}
586
- </Button>
738
+ <TooltipButton tooltip={i18n.t('common.columns')} variant="outline" size="sm" class="h-9 px-2.5" {...props}>
739
+ <SlidersHorizontal class="h-4 w-4" />
740
+ </TooltipButton>
587
741
  {/snippet}
588
742
  </DropdownMenu.Trigger>
589
743
  <DropdownMenu.Content align="end" class="w-48">
@@ -597,94 +751,57 @@
597
751
  {/each}
598
752
  </DropdownMenu.Content>
599
753
  </DropdownMenu.Root>
600
- {#if headerActions}
601
- {@render headerActions()}
602
- {/if}
603
- {#if canCreate}
604
- <Button onclick={() => adminContext.navigate(`/${resourceName}/create`)}>
605
- <Plus class="h-4 w-4" data-icon="inline-start" /> {i18n.t('common.create')}
606
- </Button>
754
+
755
+ {#if showRefresh}
756
+ <TooltipButton
757
+ tooltip={i18n.t('common.refresh')}
758
+ variant="outline"
759
+ size="sm"
760
+ class="h-9 px-2.5"
761
+ onclick={() => listResult.refetch()}
762
+ >
763
+ <RefreshCw class="h-4 w-4 {query.isFetching ? 'animate-spin' : ''}" />
764
+ </TooltipButton>
607
765
  {/if}
608
766
  </div>
609
767
  </div>
610
768
 
611
- <!-- Search and Advanced Filters -->
612
- <div class="flex flex-wrap items-center gap-2">
613
- {#if searchableFields.length > 0}
614
- <div class="relative max-w-sm flex-1 sm:min-w-[250px]">
615
- <Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
616
- <Input
617
- type="text"
618
- value={searchText}
619
- oninput={scheduleSearch}
620
- placeholder={i18n.t('common.search')}
621
- class="pl-10 h-9"
622
- />
623
- </div>
624
- {/if}
625
-
626
- {#if filterableFields.length > 0}
627
- <Popover.Root>
628
- <Popover.Trigger>
629
- {#snippet child({ props })}
630
- <Button variant="outline" size="sm" class="h-9 px-3" {...props}>
631
- <FilterIcon class="h-4 w-4" data-icon="inline-start" />
632
- {i18n.t('common.filter')}
633
- {#if activeFilterCount > 0}
634
- <Badge variant="secondary" class="ml-1 h-5 min-w-5 px-1">{activeFilterCount}</Badge>
635
- {/if}
636
- </Button>
637
- {/snippet}
638
- </Popover.Trigger>
639
- <Popover.Content class="w-80">
640
- <div class="space-y-3">
641
- <h4 class="font-medium text-sm">{i18n.t('common.filter')}</h4>
642
- {#each filterableFields as field, _i (_i)}
643
- <div class="space-y-1">
644
- <label class="text-xs text-muted-foreground" for="filter-{field.key}">{field.label}</label>
645
- {#if field.type === 'select' && field.options}
646
- <select
647
- id="filter-{field.key}"
648
- class="h-9 text-sm w-full font-normal flex w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
649
- value={filterValues[field.key] ?? ''}
650
- onchange={(e) => filterValues[field.key] = (e.currentTarget as HTMLSelectElement).value}
651
- >
652
- <option value="">{i18n.t('common.all')}</option>
653
- {#each field.options as opt, _i (_i)}
654
- <option value={opt.value}>{opt.label}</option>
655
- {/each}
656
- </select>
657
- {:else}
658
- <Input
659
- id="filter-{field.key}"
660
- type="text"
661
- value={filterValues[field.key] ?? ''}
662
- oninput={(e) => filterValues[field.key] = (e.currentTarget as HTMLInputElement).value}
663
- placeholder={field.label}
664
- class="h-9 text-sm"
665
- />
666
- {/if}
667
- </div>
668
- {/each}
669
- <div class="flex gap-2 pt-2">
670
- <Button size="sm" class="flex-1" onclick={() => { pagination = { ...pagination, current: 1 }; }}>
671
- {i18n.t('common.confirm')}
672
- </Button>
673
- <Button variant="outline" size="sm" onclick={() => {
674
- filterValues = {};
675
- pagination = { ...pagination, current: 1 };
676
- }}>
677
- {i18n.t('common.reset')}
678
- </Button>
679
- </div>
680
- </div>
681
- </Popover.Content>
682
- </Popover.Root>
683
- {/if}
684
- </div>
769
+ <!-- Active Filter Tags -->
770
+ {#if showFilterTags && (appliedSearchText.trim() || activeFilterCount > 0)}
771
+ <div class="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground pt-0.5">
772
+ <span class="font-medium text-foreground">{i18n.t('common.filterTags')}:</span>
773
+ {#if appliedSearchText.trim()}
774
+ <Badge variant="secondary" class="gap-1 pr-1 font-normal text-xs">
775
+ <span>{i18n.t('common.search')}: "{appliedSearchText}"</span>
776
+ <button type="button" class="hover:text-foreground cursor-pointer" aria-label={`${i18n.t('common.clear')}: ${i18n.t('common.search')}`} onclick={() => { searchText = ''; appliedSearchText = ''; pagination = { ...pagination, current: 1 }; }}>
777
+ <X class="h-3 w-3" />
778
+ </button>
779
+ </Badge>
780
+ {/if}
781
+ {#each Object.entries(filterValues) as [key, val] (key)}
782
+ {#if val.trim()}
783
+ {@const field = filterableFields.find(f => f.key === key)}
784
+ <Badge variant="secondary" class="gap-1 pr-1 font-normal text-xs">
785
+ <span>{field?.label ?? key}: {val}</span>
786
+ <button type="button" class="hover:text-foreground cursor-pointer" aria-label={`${i18n.t('common.clear')}: ${field?.label ?? key}`} onclick={() => removeFilterKey(key)}>
787
+ <X class="h-3 w-3" />
788
+ </button>
789
+ </Badge>
790
+ {/if}
791
+ {/each}
792
+ <Button variant="ghost" size="sm" class="h-6 px-2 text-xs text-muted-foreground hover:text-foreground" onclick={() => {
793
+ searchText = '';
794
+ appliedSearchText = '';
795
+ filterValues = {};
796
+ pagination = { ...pagination, current: 1 };
797
+ }}>
798
+ {i18n.t('common.clearAllFilters')}
799
+ </Button>
800
+ </div>
801
+ {/if}
685
802
 
686
803
  <!-- Table (TanStack-powered) -->
687
- <div class="overflow-hidden rounded-lg border border-border bg-card shadow-sm" role="region" aria-label="{resource.label} {i18n.t('common.list')}" data-table-density={density}>
804
+ <div class="overflow-hidden rounded-lg border border-border bg-card shadow-sm" role="region" aria-label="{resource.label} {i18n.t('common.list')}" data-table-density={currentDensity}>
688
805
  {#if query.isLoading}
689
806
  <div class="p-4 space-y-3">
690
807
  <div class="flex gap-4 mb-2">
@@ -708,7 +825,7 @@
708
825
  <div in:fade={{ duration: 150 }}>
709
826
  <!-- Desktop Table (hidden on mobile) -->
710
827
  <div class="hidden md:block">
711
- <Table.Root {density}>
828
+ <Table.Root density={currentDensity}>
712
829
  <Table.Header>
713
830
  {#each tableView.headerGroups as headerGroup, _i (_i)}
714
831
  {@const visibleHeaders = headerGroup.headers.filter((header: Header<TableFeatures, BaseRecord, unknown>) => isColumnVisible(header.column.id))}
@@ -4,6 +4,11 @@ interface Props {
4
4
  resourceName: string;
5
5
  selectable?: boolean;
6
6
  density?: 'compact' | 'comfortable';
7
+ showHeader?: boolean;
8
+ title?: string;
9
+ showFilterTags?: boolean;
10
+ showDensitySwitcher?: boolean;
11
+ showRefresh?: boolean;
7
12
  /** Custom header actions (right side) */
8
13
  headerActions?: Snippet;
9
14
  /** Custom cell renderers by field key */
@@ -20,8 +20,12 @@
20
20
  selectable?: boolean;
21
21
  /** Passthrough: custom header actions */
22
22
  headerActions?: Snippet;
23
- /** Optional status tabs or filter slot above table */
23
+ /** Optional status tabs slot above filter/table */
24
24
  statusTabs?: Snippet;
25
+ /** Optional filter toolbar slot between status tabs and table */
26
+ filterToolbar?: Snippet;
27
+ /** Passthrough: custom batch actions to render when rows are selected */
28
+ batchActions?: Snippet<[{ selectedIds: string[] }]>;
25
29
  /** Passthrough: custom cell renderer per field */
26
30
  cellRenderer?: Snippet<[{ field: FieldDefinition; value: unknown; record: Record<string, unknown> }]>;
27
31
  /** Passthrough: custom row actions */
@@ -41,6 +45,8 @@
41
45
  selectable,
42
46
  headerActions,
43
47
  statusTabs,
48
+ filterToolbar,
49
+ batchActions,
44
50
  cellRenderer,
45
51
  rowActions,
46
52
  emptyState,
@@ -54,7 +60,7 @@
54
60
  const showCreate = $derived(canCreate ?? resource.canCreate !== false);
55
61
  </script>
56
62
 
57
- <div class="{density === 'compact' ? 'space-y-4' : 'space-y-6'} {className}">
63
+ <div class="{density === 'compact' ? 'space-y-3' : 'space-y-4'} {className}">
58
64
  <PageHeader title={pageTitle} {density}>
59
65
  {#snippet actions()}
60
66
  {#if showCreate}
@@ -74,10 +80,18 @@
74
80
  </div>
75
81
  {/if}
76
82
 
83
+ {#if filterToolbar}
84
+ <div>
85
+ {@render filterToolbar()}
86
+ </div>
87
+ {/if}
88
+
77
89
  <AutoTable
78
90
  {resourceName}
91
+ showHeader={false}
79
92
  {density}
80
93
  {selectable}
94
+ {batchActions}
81
95
  defaultCellRenderer={cellRenderer}
82
96
  {rowActions}
83
97
  {emptyState}
@@ -9,8 +9,14 @@ interface Props {
9
9
  selectable?: boolean;
10
10
  /** Passthrough: custom header actions */
11
11
  headerActions?: Snippet;
12
- /** Optional status tabs or filter slot above table */
12
+ /** Optional status tabs slot above filter/table */
13
13
  statusTabs?: Snippet;
14
+ /** Optional filter toolbar slot between status tabs and table */
15
+ filterToolbar?: Snippet;
16
+ /** Passthrough: custom batch actions to render when rows are selected */
17
+ batchActions?: Snippet<[{
18
+ selectedIds: string[];
19
+ }]>;
14
20
  /** Passthrough: custom cell renderer per field */
15
21
  cellRenderer?: Snippet<[{
16
22
  field: FieldDefinition;
@@ -5,7 +5,7 @@
5
5
  import * as Card from './ui/card/index.js';
6
6
  import { Skeleton } from './ui/skeleton/index.js';
7
7
  import PageHeader from './PageHeader.svelte';
8
- import { getDisplayComponent } from './fieldComponentMap';
8
+ import { getDisplayComponent } from './fieldComponentMap.js';
9
9
  import ListButton from './buttons/ListButton.svelte';
10
10
  import EditButton from './buttons/EditButton.svelte';
11
11
  import DeleteButton from './buttons/DeleteButton.svelte';
@@ -18,6 +18,9 @@
18
18
  resourceName: string;
19
19
  id: string | number;
20
20
  density?: 'compact' | 'comfortable';
21
+ layout?: 'list' | 'grid';
22
+ columns?: 1 | 2 | 3 | 4;
23
+ bordered?: boolean;
21
24
  headerActions?: Snippet;
22
25
  children?: Snippet;
23
26
  class?: string;
@@ -27,6 +30,9 @@
27
30
  resourceName,
28
31
  id,
29
32
  density = 'comfortable',
33
+ layout = 'list',
34
+ columns = 2,
35
+ bordered = false,
30
36
  headerActions,
31
37
  children,
32
38
  class: className = '',
@@ -39,9 +45,18 @@
39
45
  const showFields = $derived(resource.fields.filter(f => f.showInShow !== false));
40
46
 
41
47
  const query = useShow({ get resource() { return resourceName; }, get id() { return id; } });
48
+
49
+ const gridColumnClass = $derived.by(() => {
50
+ switch (columns) {
51
+ case 4: return 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4';
52
+ case 3: return 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3';
53
+ case 2: return 'grid-cols-1 sm:grid-cols-2';
54
+ default: return 'grid-cols-1';
55
+ }
56
+ });
42
57
  </script>
43
58
 
44
- <div class="{isCompact ? 'space-y-4' : 'space-y-6'} {className}">
59
+ <div class="{isCompact ? 'space-y-3' : 'space-y-4'} {className}">
45
60
  <PageHeader
46
61
  title="{resource.label} {i18n.t('common.detail')} #{id}"
47
62
  {density}
@@ -77,28 +92,55 @@
77
92
  </Card.Content>
78
93
  </Card.Root>
79
94
  {:else if query.data?.data}
80
- <Card.Root class="overflow-hidden border-border/40 shadow-sm">
81
- <Card.Content class="p-0 divide-y divide-border/20">
82
- {#each showFields as field, i (i)}
83
- {@const value = ((query.data as { data: Record<string, unknown> }).data as Record<string, unknown>)[field.key]}
84
- {@const DisplayComponent = getDisplayComponent(field.type)}
85
- <div class="flex flex-col sm:flex-row {isCompact ? 'px-4 py-2.5 sm:px-5 sm:py-2.5' : 'px-4 sm:px-6 py-3 sm:py-4'} {i % 2 === 1 ? 'bg-muted/10' : ''}">
86
- <div class="sm:w-1/3 {isCompact ? 'text-xs' : 'text-xs sm:text-sm'} font-medium text-muted-foreground mb-1 sm:mb-0">{field.label}</div>
87
- <div class="sm:w-2/3 {isCompact ? 'text-xs sm:text-sm' : 'text-sm'} text-foreground">
88
- {#if DisplayComponent && value != null}
89
- <DisplayComponent
90
- {value}
91
- options={field.options}
92
- resourceName={field.resource}
93
- />
94
- {:else}
95
- {value != null ? String(value) : '—'}
96
- {/if}
95
+ {#if layout === 'grid'}
96
+ <Card.Root class="overflow-hidden border-border/40 shadow-sm">
97
+ <Card.Content class="p-0">
98
+ <dl class="grid divide-y divide-border/20 sm:divide-y-0 {gridColumnClass}">
99
+ {#each showFields as field, i (i)}
100
+ {@const value = ((query.data as { data: Record<string, unknown> }).data as Record<string, unknown>)[field.key]}
101
+ {@const DisplayComponent = getDisplayComponent(field.type)}
102
+ <div class="flex flex-col {bordered ? 'border-b border-r border-border/30 last:border-b-0 p-3 sm:p-4' : 'p-3 sm:p-4 border-b border-border/10'}">
103
+ <dt class="text-xs font-medium text-muted-foreground mb-1">{field.label}</dt>
104
+ <dd class="text-xs sm:text-sm text-foreground font-medium break-words">
105
+ {#if DisplayComponent && value != null}
106
+ <DisplayComponent
107
+ {value}
108
+ options={field.options}
109
+ resourceName={field.resource}
110
+ />
111
+ {:else}
112
+ {value != null ? String(value) : '—'}
113
+ {/if}
114
+ </dd>
115
+ </div>
116
+ {/each}
117
+ </dl>
118
+ </Card.Content>
119
+ </Card.Root>
120
+ {:else}
121
+ <Card.Root class="overflow-hidden border-border/40 shadow-sm">
122
+ <Card.Content class="p-0 divide-y divide-border/20">
123
+ {#each showFields as field, i (i)}
124
+ {@const value = ((query.data as { data: Record<string, unknown> }).data as Record<string, unknown>)[field.key]}
125
+ {@const DisplayComponent = getDisplayComponent(field.type)}
126
+ <div class="flex flex-col sm:flex-row {isCompact ? 'px-4 py-2 sm:px-5 sm:py-2' : 'px-4 sm:px-6 py-3 sm:py-3.5'} {i % 2 === 1 ? 'bg-muted/10' : ''}">
127
+ <div class="sm:w-1/3 {isCompact ? 'text-xs' : 'text-xs sm:text-sm'} font-medium text-muted-foreground mb-1 sm:mb-0">{field.label}</div>
128
+ <div class="sm:w-2/3 {isCompact ? 'text-xs sm:text-sm' : 'text-sm'} text-foreground">
129
+ {#if DisplayComponent && value != null}
130
+ <DisplayComponent
131
+ {value}
132
+ options={field.options}
133
+ resourceName={field.resource}
134
+ />
135
+ {:else}
136
+ {value != null ? String(value) : '—'}
137
+ {/if}
138
+ </div>
97
139
  </div>
98
- </div>
99
- {/each}
100
- </Card.Content>
101
- </Card.Root>
140
+ {/each}
141
+ </Card.Content>
142
+ </Card.Root>
143
+ {/if}
102
144
  {#if children}
103
145
  {@render children()}
104
146
  {/if}
@@ -3,6 +3,9 @@ interface Props {
3
3
  resourceName: string;
4
4
  id: string | number;
5
5
  density?: 'compact' | 'comfortable';
6
+ layout?: 'list' | 'grid';
7
+ columns?: 1 | 2 | 3 | 4;
8
+ bordered?: boolean;
6
9
  headerActions?: Snippet;
7
10
  children?: Snippet;
8
11
  class?: string;