ubiquitous-invention/apps/web/components/views/config/sort-config.tsx

199 lines
7.4 KiB
TypeScript
Raw Permalink Normal View History

"use client";
import * as Popover from "@radix-ui/react-popover";
import * as Select from "@radix-ui/react-select";
import { ArrowDown, ArrowDownAZ, ArrowUpAZ, Plus, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import type { ViewSort } from "@/lib/hooks/use-view-data";
import { useViewStore } from "@/lib/stores/view-store";
import { cn } from "@/lib/utils";
const SORT_FIELDS = [
{ value: "title", label: "Title" },
{ value: "status", label: "Status" },
{ value: "sortOrder", label: "Order" },
{ value: "createdAt", label: "Created" },
{ value: "updatedAt", label: "Updated" },
] as const;
const selectTriggerClass = cn(
"flex h-8 min-w-[5.5rem] shrink-0 items-center justify-between gap-1 rounded-md border border-input bg-background px-2 text-xs",
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background",
);
const selectContentClass = cn(
"z-[60] max-h-[min(60vh,20rem)] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
);
const selectItemClass =
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-xs outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50";
function SortSelect({
value,
options,
onChange,
placeholder,
className,
}: {
value: string;
options: { value: string; label: string }[];
onChange: (v: string) => void;
placeholder?: string;
className?: string;
}) {
return (
<Select.Root value={value} onValueChange={onChange}>
<Select.Trigger className={cn(selectTriggerClass, className)} aria-label={placeholder}>
<Select.Value placeholder={placeholder} />
<Select.Icon className="opacity-50">
<svg width="12" height="12" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M4.93179 5.43179C4.75605 5.60753 4.75605 5.89245 4.93179 6.06819C5.10753 6.24392 5.39245 6.24392 5.56819 6.06819L7.49999 4.13638L9.43179 6.06819C9.60753 6.24392 9.89245 6.24392 10.0682 6.06819C10.2439 5.89245 10.2439 5.60753 10.0682 5.43179L7.81819 3.18179C7.73379 3.0974 7.61933 3.04999 7.49999 3.04999C7.38064 3.04999 7.26618 3.0974 7.18179 3.18179L4.93179 5.43179ZM10.0682 9.56819C10.2439 9.39245 10.2439 9.10753 10.0682 8.93179C9.89245 8.75606 9.60753 8.75606 9.43179 8.93179L7.49999 10.8636L5.56819 8.93179C5.39245 8.75606 5.10753 8.75606 4.93179 8.93179C4.75605 9.10753 4.75605 9.39245 4.93179 9.56819L7.18179 11.8182C7.35753 11.9939 7.64245 11.9939 7.81819 11.8182L10.0682 9.56819Z"
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
/>
</svg>
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Content position="popper" className={selectContentClass} sideOffset={4}>
<Select.Viewport className="p-1">
{options.map((o) => (
<Select.Item key={o.value} value={o.value} className={selectItemClass}>
<Select.ItemText>{o.label}</Select.ItemText>
</Select.Item>
))}
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select.Root>
);
}
function defaultSort(): ViewSort {
return { field: "sortOrder", direction: "asc" };
}
function SortRow({
sort,
index,
onChange,
onRemove,
}: {
sort: ViewSort;
index: number;
onChange: (next: ViewSort) => void;
onRemove: () => void;
}) {
const field = SORT_FIELDS.some((f) => f.value === sort.field) ? sort.field : "sortOrder";
const direction = sort.direction === "desc" ? "desc" : "asc";
return (
<div className="flex flex-col gap-2 rounded-md border border-border/80 bg-muted/30 p-2 sm:flex-row sm:items-center">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
<SortSelect
value={field}
onChange={(v) => onChange({ ...sort, field: v })}
options={SORT_FIELDS.map((f) => ({ value: f.value, label: f.label }))}
placeholder="Field"
className="min-w-[7rem] flex-1"
/>
<div className="flex items-center gap-0.5 rounded-md border border-input bg-background p-0.5">
<Button
type="button"
variant={direction === "asc" ? "secondary" : "ghost"}
size="sm"
className="h-7 gap-1 px-2 text-[11px]"
onClick={() => onChange({ ...sort, direction: "asc" })}
aria-pressed={direction === "asc"}
aria-label={`Sort ${index + 1} ascending`}
>
<ArrowUpAZ className="size-3.5" />
Asc
</Button>
<Button
type="button"
variant={direction === "desc" ? "secondary" : "ghost"}
size="sm"
className="h-7 gap-1 px-2 text-[11px]"
onClick={() => onChange({ ...sort, direction: "desc" })}
aria-pressed={direction === "desc"}
aria-label={`Sort ${index + 1} descending`}
>
<ArrowDownAZ className="size-3.5" />
Desc
</Button>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 self-end sm:self-center"
onClick={onRemove}
aria-label={`Remove sort ${index + 1}`}
>
<X className="size-4" />
</Button>
</div>
);
}
export function SortConfig() {
const sorts = useViewStore((s) => s.config.sorts);
const addSort = useViewStore((s) => s.addSort);
const removeSort = useViewStore((s) => s.removeSort);
const updateSort = useViewStore((s) => s.updateSort);
return (
<Popover.Root>
<Popover.Trigger asChild>
<Button type="button" variant="ghost" size="sm" className="h-8 gap-1.5 px-2 text-xs">
<ArrowDown className="size-3.5" />
Sort
{sorts.length > 0 ? (
<span className="rounded-full bg-muted px-1.5 text-[10px] font-semibold text-muted-foreground">
{sorts.length}
</span>
) : null}
</Button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
className="z-[60] w-[min(calc(100vw-2rem),22rem)] rounded-md border bg-popover p-3 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
sideOffset={6}
align="start"
>
<p className="mb-2 text-xs font-semibold">Sort by</p>
<div className="flex max-h-[min(50vh,18rem)] flex-col gap-2 overflow-y-auto pr-0.5">
{sorts.map((s, i) => (
<SortRow
key={i}
sort={s}
index={i}
onChange={(next) => updateSort(i, next)}
onRemove={() => removeSort(i)}
/>
))}
</div>
<Separator className="my-3" />
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-full justify-start gap-2 text-xs"
onClick={() => addSort(defaultSort())}
>
<Plus className="size-3.5" />
Add sort
</Button>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}