@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.
- package/dist/components/AutoTable.svelte +223 -106
- package/dist/components/AutoTable.svelte.d.ts +5 -0
- package/dist/components/ListPage.svelte +16 -2
- package/dist/components/ListPage.svelte.d.ts +7 -1
- package/dist/components/ShowPage.svelte +65 -23
- package/dist/components/ShowPage.svelte.d.ts +3 -0
- package/dist/components/buttons/CloneButton.svelte +23 -2
- package/dist/components/buttons/CloneButton.svelte.d.ts +3 -0
- package/dist/components/buttons/CreateButton.svelte +22 -2
- package/dist/components/buttons/CreateButton.svelte.d.ts +3 -0
- package/dist/components/buttons/DeleteButton.svelte +23 -3
- package/dist/components/buttons/DeleteButton.svelte.d.ts +3 -0
- package/dist/components/buttons/EditButton.svelte +23 -2
- package/dist/components/buttons/EditButton.svelte.d.ts +3 -0
- package/dist/components/buttons/ExportButton.svelte +22 -3
- package/dist/components/buttons/ExportButton.svelte.d.ts +3 -0
- package/dist/components/buttons/ImportButton.svelte +39 -17
- package/dist/components/buttons/ImportButton.svelte.d.ts +3 -0
- package/dist/components/buttons/ListButton.svelte +13 -2
- package/dist/components/buttons/ListButton.svelte.d.ts +2 -0
- package/dist/components/buttons/RefreshButton.svelte +22 -2
- package/dist/components/buttons/RefreshButton.svelte.d.ts +3 -0
- package/dist/components/buttons/SaveButton.svelte +23 -2
- package/dist/components/buttons/SaveButton.svelte.d.ts +3 -0
- package/dist/components/buttons/ShowButton.svelte +24 -3
- package/dist/components/buttons/ShowButton.svelte.d.ts +3 -0
- package/dist/components/content/DescriptionList.svelte +125 -15
- package/dist/components/content/DescriptionList.svelte.d.ts +6 -2
- 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-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
<
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
<
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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="
|
|
618
|
+
variant="ghost"
|
|
568
619
|
size="sm"
|
|
569
|
-
class="h-
|
|
570
|
-
onclick={
|
|
620
|
+
class="h-8 text-xs text-muted-foreground hover:text-foreground"
|
|
621
|
+
onclick={() => rowSelectionAtom.set({})}
|
|
571
622
|
>
|
|
572
|
-
|
|
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
|
|
577
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
585
|
-
<SlidersHorizontal class="h-4 w-4"
|
|
586
|
-
</
|
|
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
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
-
<!--
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
<
|
|
615
|
-
|
|
616
|
-
<
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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={
|
|
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 {
|
|
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
|
|
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-
|
|
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
|
|
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-
|
|
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
|
-
|
|
81
|
-
<Card.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
<
|
|
90
|
-
{value}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
</Card.
|
|
101
|
-
|
|
140
|
+
{/each}
|
|
141
|
+
</Card.Content>
|
|
142
|
+
</Card.Root>
|
|
143
|
+
{/if}
|
|
102
144
|
{#if children}
|
|
103
145
|
{@render children()}
|
|
104
146
|
{/if}
|