diff --git a/apps/web/ui/common/year-picker.tsx b/apps/web/ui/common/year-picker.tsx index 7ad0dceb..f591a28c 100644 --- a/apps/web/ui/common/year-picker.tsx +++ b/apps/web/ui/common/year-picker.tsx @@ -1,9 +1,23 @@ "use client"; -import { OptionPicker } from "@/ui/common/option-picker"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@coursemap/ui/primitives/select"; export type YearSelection = number | "all"; +/** + * One academic year. This is a native-feeling select rather than the option + * picker: the values are a handful of four-digit years, and a searchable + * popover list drawn below the trigger read as a heavy component for choosing + * a single number. The select opens with the current year aligned over the + * trigger, so a change is one short movement. Newest first, because that is + * the year being worked on. + */ export function YearPicker({ allLabel = "All", allowAll = false, @@ -22,21 +36,24 @@ export function YearPicker({ value: YearSelection; years: number[]; }) { - const ordered = [...new Set(years)].sort((left, right) => left - right); + const ordered = [...new Set(years)].sort((left, right) => right - left); return ( - ({ - value: String(year), - label: String(year), - })), - ...(allowAll ? [{ value: "all", label: allLabel }] : []), - ]} onValueChange={(next) => onChange(next === "all" ? "all" : Number(next))} - /> + > + + + + + {ordered.map((year) => ( + + {year} + + ))} + {allowAll ? {allLabel} : null} + + ); } diff --git a/packages/ui/primitives/select.tsx b/packages/ui/primitives/select.tsx new file mode 100644 index 00000000..235766b0 --- /dev/null +++ b/packages/ui/primitives/select.tsx @@ -0,0 +1,215 @@ +"use client" + +import * as React from "react" +import { Select as SelectPrimitive } from "radix-ui" + +import { cn } from "@coursemap/ui/lib/utils" +import { IconPlaceholder } from "@coursemap/ui/icon-placeholder" + +function Select({ + ...props +}: React.ComponentProps) { + return +} + +function SelectGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectValue({ + ...props +}: React.ComponentProps) { + return +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: React.ComponentProps & { + size?: "sm" | "default" +}) { + return ( + + {children} + + + + + ) +} + +function SelectContent({ + className, + children, + position = "item-aligned", + align = "center", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ) +} + +function SelectLabel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} diff --git a/packages/ui/style-nova.css b/packages/ui/style-nova.css index c6a486c5..19eace56 100644 --- a/packages/ui/style-nova.css +++ b/packages/ui/style-nova.css @@ -477,6 +477,71 @@ @apply rounded-full; } + /* MARK: Select */ + .cn-select-trigger { + @apply border-input data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 gap-1.5 rounded-lg border bg-transparent py-2 pr-2 pl-2.5 text-sm transition-colors select-none focus-visible:ring-3 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]:flex *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-4; + } + + .cn-select-value { + @apply flex flex-1 text-left; + } + + .cn-select-value-aria { + @apply data-placeholder:text-muted-foreground; + } + + .cn-select-trigger-icon { + @apply text-muted-foreground size-4; + } + + .cn-select-content { + @apply bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-36 rounded-lg shadow-md ring-1 duration-100; + } + + .cn-select-content-logical { + @apply data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2; + } + + .cn-select-label { + @apply text-muted-foreground px-1.5 py-1 text-xs; + } + + .cn-select-item { + @apply focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2; + } + + .cn-select-item-aria { + @apply data-focused:bg-accent data-focused:text-accent-foreground; + } + + .cn-select-item-indicator { + @apply pointer-events-none absolute right-2 flex size-4 items-center justify-center; + } + + .cn-select-group { + @apply scroll-my-1 p-1; + } + + .cn-select-item-text { + @apply flex flex-1 gap-2; + } + + .cn-select-separator { + @apply bg-border -mx-1 my-1 h-px; + } + + .cn-select-scroll-up-button { + @apply bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4; + } + + .cn-select-scroll-down-button { + @apply bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4; + } + + .cn-select-empty-aria { + @apply text-muted-foreground hidden w-full justify-center py-2 text-center text-sm group-data-empty/select-list:flex; + } + /* MARK: Sheet */ .cn-sheet-overlay { @apply bg-black/10 supports-backdrop-filter:backdrop-blur-xs;