fix: refine responsive dashboard filters

This commit is contained in:
2026-08-20 22:54:20 +03:30
parent 458ab69a3c
commit b599cdfba4
12 changed files with 299 additions and 284 deletions

View File

@@ -2,6 +2,7 @@
import SlidersHorizontalIcon from '@lucide/svelte/icons/sliders-horizontal';
import * as Popover from '$lib/components/ui/popover';
import { Slider } from '$lib/components/ui/slider';
import { formatValue } from '$lib/format';
const LIMIT = 10_000_000_000;
@@ -20,21 +21,20 @@
let open = $state(false);
let localMin = $state(0);
let localMax = $state(LIMIT);
const startPercent = $derived((localMin / LIMIT) * 100);
const endPercent = $derived((localMax / LIMIT) * 100);
const hasFilter = $derived(minAmount !== null || maxAmount !== null);
let sliderValue = $state([0, LIMIT]);
$effect(() => {
localMin = Math.max(0, Math.min(minAmount ?? 0, LIMIT));
localMax = Math.max(localMin, Math.min(maxAmount ?? LIMIT, LIMIT));
sliderValue = [localMin, localMax];
});
function updateMin(value: number) {
localMin = Math.min(value, localMax - STEP);
}
function updateMax(value: number) {
localMax = Math.max(value, localMin + STEP);
function updateRange(value: number[]) {
const [nextMin = 0, nextMax = LIMIT] = [...value].sort((left, right) => left - right);
localMin = nextMin;
localMax = nextMax;
sliderValue = [nextMin, nextMax];
}
function apply() {
@@ -45,7 +45,7 @@
<Popover.Root bind:open>
<Popover.Trigger
class="border-input bg-background hover:bg-muted focus-visible:ring-ring inline-flex min-h-11 cursor-pointer items-center gap-1.5 rounded-md border px-3 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none sm:min-h-9"
class="border-input bg-background hover:bg-muted focus-visible:border-ring focus-visible:ring-ring/30 inline-flex min-h-11 cursor-pointer items-center gap-1.5 rounded-md border px-3 text-xs font-medium transition-colors focus-visible:ring-1 focus-visible:outline-none sm:min-h-9"
aria-label="فیلتر بازه مبلغ"
aria-pressed={hasFilter}
>
@@ -64,29 +64,16 @@
<output class="bg-muted rounded-md px-2 py-1">تا {formatValue(localMax)} ریال</output>
</div>
<div class="range-shell" style={`--range-start:${startPercent}%; --range-end:${endPercent}%`} dir="rtl">
<div class="range-track" aria-hidden="true"></div>
<input
class="range-input"
type="range"
min="0"
max={LIMIT}
step={STEP}
value={localMin}
aria-label="حداقل مبلغ"
oninput={(event) => updateMin(Number(event.currentTarget.value))}
/>
<input
class="range-input"
type="range"
min="0"
max={LIMIT}
step={STEP}
value={localMax}
aria-label="حداکثر مبلغ"
oninput={(event) => updateMax(Number(event.currentTarget.value))}
/>
</div>
<Slider
value={sliderValue}
onValueChange={updateRange}
min={0}
max={LIMIT}
step={STEP}
dir="rtl"
class="my-2 h-8"
aria-label="بازه مبلغ تراکنش"
/>
<div class="flex gap-2">
<button
@@ -107,67 +94,3 @@
</div>
</Popover.Content>
</Popover.Root>
<style>
.range-shell {
position: relative;
height: 2.75rem;
}
.range-track {
position: absolute;
inset: 1.25rem 0 auto;
height: 0.25rem;
border-radius: 999px;
background: linear-gradient(
to left,
var(--muted) 0 var(--range-start),
var(--primary) var(--range-start) var(--range-end),
var(--muted) var(--range-end) 100%
);
}
.range-input {
pointer-events: none;
position: absolute;
inset: 0;
width: 100%;
appearance: none;
background: transparent;
}
.range-input:first-of-type {
z-index: 3;
}
.range-input:last-of-type {
z-index: 2;
}
.range-input::-webkit-slider-thumb {
pointer-events: auto;
width: 1.25rem;
height: 1.25rem;
cursor: grab;
appearance: none;
border: 3px solid var(--background);
border-radius: 999px;
background: var(--primary);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--primary) 45%, transparent);
}
.range-input::-moz-range-thumb {
pointer-events: auto;
width: 1.25rem;
height: 1.25rem;
cursor: grab;
border: 3px solid var(--background);
border-radius: 999px;
background: var(--primary);
}
.range-input:focus-visible::-webkit-slider-thumb {
outline: 3px solid color-mix(in srgb, var(--primary) 28%, transparent);
outline-offset: 2px;
}
</style>

View File

@@ -257,28 +257,30 @@
</Card>
</div>
{:else if kind === 'fees' && fees.state === 'READY'}
<div class="grid gap-4 min-[600px]:grid-cols-2 xl:grid-cols-4">
<KpiCard
label="کارمزد پرداخت‌شده"
value={formatMoney(fees.data.paidFeeAmountRial.value)}
note="مجموع کارمزد ثبت‌شده برای پرداخت‌های این بازه"
icon={HandCoinsIcon}
/>
<FeeShareDonut value={fees.data.paidFeeRate.value} />
<KpiCard
label="مبلغ پرداخت موفق"
value={formatMoney(fees.data.bankPaidAmountRial.value)}
note="مبنای محاسبه سهم کارمزد"
icon={BanknoteIcon}
tone="good"
/>
<KpiCard
label="نرخ مؤثر کارمزد"
value={formatPercent(fees.data.paidFeeRate.value)}
note="سهم کارمزد از مبلغ پرداخت‌های موفق"
icon={GaugeIcon}
formula={formulas.feeRate}
/>
<div class="grid gap-4 lg:grid-cols-[1.1fr_1fr]" dir="rtl">
<FeeShareDonut value={fees.data.paidFeeRate.value} class="min-h-[28rem]" />
<div class="grid gap-4">
<KpiCard
label="کارمزد پرداخت‌شده"
value={formatMoney(fees.data.paidFeeAmountRial.value)}
note="مجموع کارمزد ثبت‌شده برای پرداخت‌های این بازه"
icon={HandCoinsIcon}
/>
<KpiCard
label="مبلغ پرداخت موفق"
value={formatMoney(fees.data.bankPaidAmountRial.value)}
note="مبنای محاسبه سهم کارمزد"
icon={BanknoteIcon}
tone="good"
/>
<KpiCard
label="نرخ مؤثر کارمزد"
value={formatPercent(fees.data.paidFeeRate.value)}
note="سهم کارمزد از مبلغ پرداخت‌های موفق"
icon={GaugeIcon}
formula={formulas.feeRate}
/>
</div>
</div>
<Card class="mt-4 rounded-lg shadow-xs"
><CardContent class="flex items-start gap-4 p-6"

View File

@@ -3,7 +3,6 @@
import { page } from '$app/state';
import ActivityIcon from '@lucide/svelte/icons/activity';
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down';
import BookOpenIcon from '@lucide/svelte/icons/book-open';
import HandCoinsIcon from '@lucide/svelte/icons/hand-coins';
import LogOutIcon from '@lucide/svelte/icons/log-out';
import MenuIcon from '@lucide/svelte/icons/menu';
@@ -71,8 +70,10 @@
<Sidebar.Provider class="dashboard-shell" style="--sidebar-width: 16rem; --sidebar-width-icon: 4.5rem;">
<Sidebar.Root side="right" collapsible="icon" class="top-14 h-[calc(100svh-3.5rem)] border-l">
<Sidebar.Header class="border-sidebar-border gap-2 border-b p-2.5">
<div class="terminal-card">
<WalletCardsIcon class="size-5 shrink-0" />
<div
class="terminal-card group-data-[collapsible=icon]:size-11 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:p-0"
>
<WalletCardsIcon class="size-5 shrink-0 group-data-[collapsible=icon]:size-[22px]" />
<div class="min-w-0 group-data-[collapsible=icon]:hidden">
<strong class="block truncate text-sm">{dashboard.session?.merchant.displayName}</strong>
<span class="block truncate text-[11px] text-white/75">حساب تحلیل پذیرنده</span>
@@ -99,7 +100,7 @@
<Sidebar.MenuButton
isActive={isActive(item.href)}
tooltipContent={item.label}
class="data-[active=true]:text-primary h-11 justify-start px-3 text-[13px]"
class="data-[active=true]:text-primary h-11 justify-start px-3 text-[13px] group-data-[collapsible=icon]:size-11! group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:p-0! group-data-[collapsible=icon]:[&_svg]:size-[21px]!"
>
{#snippet child({ props })}
<a
@@ -117,36 +118,15 @@
</Sidebar.Menu>
</Sidebar.GroupContent>
</Sidebar.Group>
<Sidebar.Separator class="my-2" />
<Sidebar.Group>
<Sidebar.GroupContent>
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton
isActive={isActive('/methodology')}
tooltipContent="روش محاسبه"
class="data-[active=true]:text-primary h-11 justify-start px-3 text-[13px]"
>
{#snippet child({ props })}
<a
{...props}
href={navUrl('/methodology')}
aria-current={isActive('/methodology') ? 'page' : undefined}
>
<BookOpenIcon class="size-[19px]" strokeWidth={1.8} />
<span>روش محاسبه</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
</Sidebar.Menu>
</Sidebar.GroupContent>
</Sidebar.Group>
</Sidebar.Content>
<Sidebar.Footer class="border-sidebar-border border-t p-2">
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton tooltipContent="خروج" class="h-11 px-3" onclick={logout}>
<Sidebar.MenuButton
tooltipContent="خروج"
class="h-11 px-3 group-data-[collapsible=icon]:size-11! group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:p-0! group-data-[collapsible=icon]:[&_svg]:size-5!"
onclick={logout}
>
<LogOutIcon class="size-[18px]" />
<span>خروج از حساب</span>
</Sidebar.MenuButton>

View File

@@ -3,9 +3,11 @@
import { page } from '$app/state';
import CalendarRangeIcon from '@lucide/svelte/icons/calendar-range';
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
import SlidersHorizontalIcon from '@lucide/svelte/icons/sliders-horizontal';
import { Button } from '$lib/components/ui/button';
import * as Select from '$lib/components/ui/select';
import * as Sheet from '$lib/components/ui/sheet';
import { useDashboardContext } from '$lib/dashboard-context.svelte';
import {
parseDashboardFilters,
@@ -20,102 +22,185 @@
const terminalIds = $derived(dashboard.terminals.map((terminal) => terminal.terminalId));
const filters = $derived(parseDashboardFilters(page.url.searchParams, terminalIds));
const isTransactionsPage = $derived(page.url.pathname === '/transactions');
const currentPeriod = $derived(dashboard.periodFor(filters.range, filters.period));
let pickerRange = $state<DashboardRange>('month');
let desktopPickerOpen = $state(false);
let mobilePickerOpen = $state(false);
let mobileFiltersOpen = $state(false);
$effect(() => {
if (!desktopPickerOpen && !mobilePickerOpen) pickerRange = filters.range;
});
function update(updates: Record<string, string | null>) {
const next = setDashboardQuery(page.url, updates);
void goto(`${next.pathname}${next.search}`, { keepFocus: true, noScroll: true });
}
function selectRange(range: DashboardRange) {
const offeredPeriod = dashboard.periodFor(range);
update({ range, period: offeredPeriod?.periodId ?? null });
function openPeriodPicker(range: DashboardRange, mobile: boolean) {
pickerRange = range;
if (mobile) mobilePickerOpen = true;
else desktopPickerOpen = true;
}
function applyPeriod(period: string) {
update({ range: pickerRange, period });
desktopPickerOpen = false;
mobilePickerOpen = false;
mobileFiltersOpen = false;
}
</script>
<div
class="bg-card flex flex-wrap items-center justify-end gap-2 rounded-lg border p-2 shadow-xs sm:gap-3 sm:p-2.5"
>
<div class="flex max-w-full items-center gap-1 overflow-x-auto" aria-label="بازه گزارش">
<CalendarRangeIcon class="text-muted-foreground mx-1 hidden size-4 sm:block" />
{#each Object.entries(rangeLabels) as [range, label]}
<Button
variant={filters.range === range ? 'default' : 'ghost'}
size="sm"
class="min-h-11 rounded-md px-2.5 text-xs sm:min-h-9"
aria-pressed={filters.range === range}
onclick={() => selectRange(range as DashboardRange)}>{label}</Button
>
{/each}
</div>
<PeriodPicker
periods={dashboard.periods}
range={filters.range}
periodId={filters.period}
onselect={(period) => update({ period })}
/>
{#if isTransactionsPage}
<span class="border-border hidden h-8 border-s sm:block" aria-hidden="true"></span>
<div class="flex max-w-full flex-1 flex-wrap items-center justify-end gap-2 sm:flex-none">
<Select.Root
type="single"
value={filters.status}
onValueChange={(value) => update({ status: value })}
items={[
{ value: 'all', label: 'همه وضعیت‌ها' },
{ value: 'Verified', label: 'تأییدشده' },
{ value: 'Paid', label: 'پرداخت‌شده' },
{ value: 'Failed', label: 'ناموفق' },
{ value: 'Reversed', label: 'برگشت‌خورده' },
]}
>
<Select.Trigger
id="transaction-status-filter"
aria-label="وضعیت تراکنش"
class="min-h-11 cursor-pointer rounded-md px-3 text-xs font-medium sm:min-h-9"
{#snippet periodControls(mobile: boolean)}
<div class={mobile ? 'grid gap-4' : 'flex min-w-0 items-center gap-3'}>
<div
class={mobile ? 'grid grid-cols-2 gap-2' : 'flex max-w-full items-center gap-1 overflow-x-auto'}
aria-label="بازه گزارش"
>
{#if !mobile}<CalendarRangeIcon class="text-muted-foreground mx-1 hidden size-4 lg:block" />{/if}
{#each Object.entries(rangeLabels) as [range, label]}
<Button
variant={(desktopPickerOpen || mobilePickerOpen) && pickerRange === range
? 'default'
: filters.range === range
? 'secondary'
: 'ghost'}
size="sm"
class={mobile ? 'min-h-11 rounded-md px-3 text-xs' : 'min-h-9 rounded-md px-2.5 text-xs'}
aria-pressed={filters.range === range}
onclick={() => openPeriodPicker(range as DashboardRange, mobile)}>{label}</Button
>
<span data-slot="select-value">
{filters.status === 'Verified'
? 'تأییدشده'
: filters.status === 'Paid'
? 'پرداخت‌شده'
: filters.status === 'Failed'
? 'ناموفق'
: filters.status === 'Reversed'
? 'برگشت‌خورده'
: 'همه وضعیت‌ها'}
</span>
</Select.Trigger>
<Select.Content align="end" dir="rtl" class="min-w-40 rounded-lg p-1 text-right">
{/each}
</div>
{#if mobile}
<PeriodPicker
periods={dashboard.periods}
range={pickerRange}
periodId={pickerRange === filters.range ? filters.period : ''}
bind:open={mobilePickerOpen}
onselect={applyPeriod}
/>
{:else}
<PeriodPicker
periods={dashboard.periods}
range={pickerRange}
periodId={pickerRange === filters.range ? filters.period : ''}
bind:open={desktopPickerOpen}
onselect={applyPeriod}
/>
{/if}
</div>
{/snippet}
{#snippet transactionFilters()}
<div class="flex flex-wrap items-center gap-2">
<Select.Root
type="single"
value={filters.status}
onValueChange={(value) => update({ status: value })}
items={[
{ value: 'all', label: 'همه وضعیت‌ها' },
{ value: 'Verified', label: 'تأییدشده' },
{ value: 'Paid', label: 'پرداخت‌شده' },
{ value: 'Failed', label: 'ناموفق' },
{ value: 'Reversed', label: 'برگشت‌خورده' },
]}
>
<Select.Trigger
id="transaction-status-filter"
aria-label="وضعیت تراکنش"
class="min-h-11 cursor-pointer rounded-md px-3 text-xs font-medium md:min-h-9"
>
<span data-slot="select-value">
{filters.status === 'Verified'
? 'تأییدشده'
: filters.status === 'Paid'
? 'پرداخت‌شده'
: filters.status === 'Failed'
? 'ناموفق'
: filters.status === 'Reversed'
? 'برگشت‌خورده'
: 'همه وضعیت‌ها'}
</span>
</Select.Trigger>
<Select.Content align="end" dir="rtl" class="min-w-40 rounded-lg p-1 text-right">
<Select.Group>
<Select.Item value="all" label="همه وضعیت‌ها" class="min-h-11 cursor-pointer text-right" />
<Select.Item value="Verified" label="تأییدشده" class="min-h-11 cursor-pointer text-right" />
<Select.Item value="Paid" label="پرداخت‌شده" class="min-h-11 cursor-pointer text-right" />
<Select.Item value="Failed" label="ناموفق" class="min-h-11 cursor-pointer text-right" />
<Select.Item value="Reversed" label="برگشت‌خورده" class="min-h-11 cursor-pointer text-right" />
</Select.Content>
</Select.Root>
</Select.Group>
</Select.Content>
</Select.Root>
<AmountRangeFilter
minAmount={filters.minAmount}
maxAmount={filters.maxAmount}
onchange={(minAmount, maxAmount) =>
update({
minAmount: minAmount === null ? null : String(minAmount),
maxAmount: maxAmount === null ? null : String(maxAmount),
})}
/>
<AmountRangeFilter
minAmount={filters.minAmount}
maxAmount={filters.maxAmount}
onchange={(minAmount, maxAmount) =>
update({
minAmount: minAmount === null ? null : String(minAmount),
maxAmount: maxAmount === null ? null : String(maxAmount),
})}
/>
{#if filters.status !== 'all' || filters.minAmount !== null || filters.maxAmount !== null}
<Button
variant="ghost"
size="sm"
class="min-h-11 cursor-pointer rounded-md px-2.5 text-xs sm:min-h-9"
aria-label="پاک‌کردن فیلترهای تراکنش"
onclick={() => update({ status: null, minAmount: null, maxAmount: null })}
><RotateCcwIcon /> پاک‌کردن</Button
>
{/if}
</div>
{#if filters.status !== 'all' || filters.minAmount !== null || filters.maxAmount !== null}
<Button
variant="ghost"
size="sm"
class="min-h-11 cursor-pointer rounded-md px-2.5 text-xs md:min-h-9"
aria-label="پاک‌کردن فیلترهای تراکنش"
onclick={() => update({ status: null, minAmount: null, maxAmount: null })}
><RotateCcwIcon /> پاک‌کردن</Button
>
{/if}
</div>
{/snippet}
<div class="bg-card hidden min-h-14 items-center justify-end gap-3 rounded-lg border p-2.5 shadow-xs md:flex">
{#if isTransactionsPage}
{@render transactionFilters()}
<span class="border-border h-8 border-s" aria-hidden="true"></span>
{/if}
{@render periodControls(false)}
</div>
<div class="bg-card flex items-center justify-between gap-3 rounded-lg border p-2.5 shadow-xs md:hidden">
<div class="min-w-0">
<span class="text-muted-foreground block text-[11px]">بازه گزارش</span>
<strong class="block truncate text-xs">{currentPeriod?.jalaliLabelFa ?? 'انتخاب بازه'}</strong>
</div>
<Sheet.Root bind:open={mobileFiltersOpen}>
<Sheet.Trigger
class="border-input bg-background hover:bg-muted inline-flex min-h-11 cursor-pointer items-center gap-2 rounded-md border px-3 text-xs font-semibold"
>
<SlidersHorizontalIcon class="size-4" />
فیلترها
</Sheet.Trigger>
<Sheet.Content
side="bottom"
dir="rtl"
class="max-h-[88svh] overflow-y-auto rounded-t-xl px-4 pt-5 pb-[max(1rem,env(safe-area-inset-bottom))]"
>
<Sheet.Header class="text-right">
<Sheet.Title>فیلتر گزارش</Sheet.Title>
<Sheet.Description>بازه دقیق و فیلترهای این صفحه را انتخاب کنید.</Sheet.Description>
</Sheet.Header>
{#if isTransactionsPage}
<section class="grid gap-3">
<h2 class="text-sm font-semibold">فیلتر تراکنش‌ها</h2>
{@render transactionFilters()}
</section>
<div class="border-border border-t"></div>
{/if}
<section class="grid gap-3">
<h2 class="text-sm font-semibold">بازه زمانی</h2>
{@render periodControls(true)}
</section>
</Sheet.Content>
</Sheet.Root>
</div>

View File

@@ -4,15 +4,16 @@
import { Card, CardContent } from '$lib/components/ui/card';
import * as Tooltip from '$lib/components/ui/tooltip';
import { formatPercent } from '$lib/format';
import { cn } from '$lib/utils';
let { value }: { value: string | null } = $props();
let { value, class: className }: { value: string | null; class?: string } = $props();
const percent = $derived(value === null ? 0 : Math.max(0, Math.min(100, Number(value))));
</script>
<Card class="rounded-lg shadow-xs">
<CardContent class="flex h-full items-center gap-4 p-4 sm:p-5">
<Card class={cn('rounded-lg shadow-xs', className)}>
<CardContent class="flex h-full flex-col items-center justify-center gap-5 p-6 text-center sm:p-8">
<div
class="relative grid size-24 shrink-0 place-items-center"
class="relative grid size-40 shrink-0 place-items-center sm:size-48"
role="img"
aria-label={`سهم کارمزد ${formatPercent(value)}`}
>
@@ -31,10 +32,10 @@
class="transition-[stroke-dasharray] duration-700 motion-reduce:transition-none"
/>
</svg>
<strong class="absolute text-sm tabular-nums">{formatPercent(value)}</strong>
<strong class="absolute text-xl font-extrabold tabular-nums">{formatPercent(value)}</strong>
</div>
<div class="min-w-0">
<div class="flex items-center gap-1.5">
<div class="max-w-sm min-w-0">
<div class="flex items-center justify-center gap-1.5">
<h2 class="text-sm font-semibold">سهم کارمزد از پرداخت موفق</h2>
<Tooltip.Root>
<Tooltip.Trigger
@@ -47,7 +48,7 @@
>
</Tooltip.Root>
</div>
<p class="text-muted-foreground mt-2 text-xs leading-6">
<p class="text-muted-foreground mt-2 text-sm leading-7">
بخش آبی، سهم واقعی کارمزد را از کل مبلغ موفق این بازه نشان می‌دهد.
</p>
</div>

View File

@@ -33,15 +33,16 @@
periods,
range,
periodId,
open = $bindable(false),
onselect,
}: {
periods: readonly ReportingPeriod[];
range: DashboardRange;
periodId: string;
open?: boolean;
onselect: (periodId: string) => void;
} = $props();
let open = $state(false);
let selectedYear = $state(0);
let yearInput = $state('');
let calendarMonth = $state(1);
@@ -114,7 +115,7 @@
<Popover.Root bind:open>
<Popover.Trigger
class="border-input bg-background hover:bg-muted focus-visible:ring-ring inline-flex min-h-11 max-w-64 cursor-pointer items-center gap-2 rounded-md border px-3 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none sm:min-h-9"
class="border-input bg-background hover:bg-muted focus-visible:border-ring focus-visible:ring-ring/30 inline-flex min-h-11 max-w-64 cursor-pointer items-center gap-2 rounded-md border px-3 text-xs font-medium transition-colors focus-visible:ring-1 focus-visible:outline-none sm:min-h-9"
aria-label="انتخاب تاریخ دقیق گزارش"
>
<CalendarDaysIcon class="text-primary size-4 shrink-0" />
@@ -132,7 +133,7 @@
سال گزارش
<input
id="reporting-year"
class="border-input bg-background focus-visible:ring-ring min-h-11 rounded-md border px-3 text-sm outline-none focus-visible:ring-2 sm:min-h-9"
class="border-input bg-background focus-visible:border-ring focus-visible:ring-ring/30 min-h-11 rounded-md border px-3 text-sm outline-none focus-visible:ring-1 sm:min-h-9"
type="text"
inputmode="numeric"
maxlength="4"
@@ -148,7 +149,7 @@
ماه
<select
id="reporting-month"
class="border-input bg-background focus-visible:ring-ring min-h-11 cursor-pointer rounded-md border px-3 text-sm outline-none focus-visible:ring-2 sm:min-h-9"
class="border-input bg-background focus-visible:border-ring focus-visible:ring-ring/30 min-h-11 cursor-pointer rounded-md border px-3 text-sm outline-none focus-visible:ring-1 sm:min-h-9"
value={calendarMonth}
onchange={(event) => (calendarMonth = Number(event.currentTarget.value))}
>
@@ -226,7 +227,7 @@
class:text-primary-foreground={day.periodId === selectedPeriod?.periodId ||
(range === 'week' && day.periodId === hoveredWeek)}
class:opacity-25={!day.inCurrentMonth || !day.periodId}
class="hover:bg-primary/10 focus-visible:ring-ring aspect-square min-h-10 rounded-md text-xs transition-colors focus-visible:ring-2 focus-visible:outline-none enabled:cursor-pointer disabled:cursor-not-allowed"
class="hover:bg-primary/10 focus-visible:ring-ring/40 aspect-square min-h-10 rounded-md text-xs transition-colors focus-visible:ring-1 focus-visible:outline-none enabled:cursor-pointer disabled:cursor-not-allowed"
onmouseenter={() => {
if (range === 'week' && day.periodId) hoveredWeek = day.periodId;
}}

View File

@@ -25,7 +25,7 @@
bind:this={ref}
data-slot={dataSlot}
class={cn(
'border-input focus-visible:border-ring focus-visible:ring-ring/50 disabled:bg-input/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 file:text-foreground placeholder:text-muted-foreground h-8 w-full min-w-0 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-3 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-3 md:text-sm',
'border-input focus-visible:border-ring focus-visible:ring-ring/30 disabled:bg-input/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 file:text-foreground placeholder:text-muted-foreground h-8 w-full min-w-0 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-1 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-1 md:text-sm',
className,
)}
type="file"
@@ -38,7 +38,7 @@
bind:this={ref}
data-slot={dataSlot}
class={cn(
'border-input focus-visible:border-ring focus-visible:ring-ring/50 disabled:bg-input/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 file:text-foreground placeholder:text-muted-foreground h-8 w-full min-w-0 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-3 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-3 md:text-sm',
'border-input focus-visible:border-ring focus-visible:ring-ring/30 disabled:bg-input/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 file:text-foreground placeholder:text-muted-foreground h-8 w-full min-w-0 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-1 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-1 md:text-sm',
className,
)}
{type}

View File

@@ -19,7 +19,7 @@
data-slot="select-trigger"
data-size={size}
class={cn(
"border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 flex w-fit items-center justify-between gap-1.5 rounded-lg border bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-3 data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"border-input focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 flex w-fit items-center justify-between gap-1.5 rounded-lg border bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-1 data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...restProps}

View File

@@ -0,0 +1,7 @@
import Root from './slider.svelte';
export {
Root,
//
Root as Slider,
};

View File

@@ -0,0 +1,50 @@
<script lang="ts">
import { Slider as SliderPrimitive } from 'bits-ui';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
let {
ref = $bindable(null),
value = $bindable(),
orientation = 'horizontal',
class: className,
...restProps
}: WithoutChildrenOrChild<SliderPrimitive.RootProps> = $props();
</script>
<!--
Discriminated Unions + Destructing (required for bindable) do not
get along, so we shut typescript up by casting `value` to `never`.
-->
<SliderPrimitive.Root
bind:ref
bind:value={value as never}
data-slot="slider"
{orientation}
class={cn(
'relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:min-h-40 data-vertical:w-auto data-vertical:flex-col',
className,
)}
{...restProps}
>
{#snippet children({ thumbItems })}
<span
data-slot="slider-track"
data-orientation={orientation}
class={cn(
'bg-muted bg-muted relative grow overflow-hidden rounded-full data-horizontal:h-1 data-horizontal:w-full data-horizontal:w-full data-vertical:h-full data-vertical:h-full data-vertical:w-1',
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
class={cn('bg-primary absolute select-none data-horizontal:h-full data-vertical:w-full')}
/>
</span>
{#each thumbItems as thumb (thumb.index)}
<SliderPrimitive.Thumb
data-slot="slider-thumb"
index={thumb.index}
class="border-background bg-primary ring-ring/30 relative block size-4 shrink-0 cursor-grab rounded-full border-2 shadow-sm transition-[color,box-shadow] select-none after:absolute after:-inset-2 hover:ring-1 focus-visible:ring-1 focus-visible:outline-hidden active:cursor-grabbing active:ring-1 disabled:pointer-events-none disabled:opacity-50"
/>
{/each}
{/snippet}
</SliderPrimitive.Root>