From 7c0df6f05bebcf69530419605d28642615631cb2 Mon Sep 17 00:00:00 2001 From: Harry Randall Date: Sun, 20 Sep 2026 20:07:19 +1000 Subject: [PATCH 1/2] refactor: size the year picker to the year it holds A year is four characters, but the picker was the same 224px menu of generously spaced rows that a labelled choice needs, so a single number read as the heaviest control on the directory toolbar. OptionPicker gains a compact mode that sizes the menu to its content, and the year picker takes it along with the small trigger and tabular figures. The years were also listed oldest first, which put the year being worked on at the bottom of the list. Newest first. --- apps/web/ui/common/option-picker.tsx | 12 +++++++++++- apps/web/ui/common/year-picker.tsx | 10 +++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/apps/web/ui/common/option-picker.tsx b/apps/web/ui/common/option-picker.tsx index ce616403..a4dbc612 100644 --- a/apps/web/ui/common/option-picker.tsx +++ b/apps/web/ui/common/option-picker.tsx @@ -21,6 +21,7 @@ export function OptionPicker({ searchable = "auto", placeholder = "Select...", className, + compact = false, disabled, ...triggerProps }: Omit, "value" | "onChange" | "children"> & { @@ -32,6 +33,12 @@ export function OptionPicker({ /** Auto enables search for more than eight options. Years pass false. */ searchable?: boolean | "auto"; placeholder?: string; + /** + * The values are single tokens, such as a year, so the menu is sized to + * them. The default width suits labels that need the room; over four + * characters it reads as a heavy component for a very small choice. + */ + compact?: boolean; }) { const [localOpen, setLocalOpen] = useState(false); const open = controlledOpen ?? localOpen; @@ -61,7 +68,10 @@ export function OptionPicker({ left - right); + const ordered = [...new Set(years)].sort((left, right) => right - left); return ( ({ From 4d53aa8ea8f90acd6999986acb6330376b99915b Mon Sep 17 00:00:00 2001 From: Harry Randall Date: Mon, 21 Sep 2026 19:47:09 +1000 Subject: [PATCH 2/2] feat: vendor the ReUI select and choose a year with it The year picker was our own composition: OptionPicker, a popover holding a searchable option menu, sized for labelled choices. For a handful of four-digit years it drew a heavy list below the trigger. The repository had never vendored ReUI's select, so there was no native-feeling control to reach for and the option picker was stretched to fit. select.tsx is taken from the pinned revision 8a2c701eaf95729f238274d5ce2555a5a8bd23e7 at registry/bases/radix/ui, with the same two import rewrites as the other primitives and nothing else changed. Its MARK: Select section is copied from the upstream Nova stylesheet, as packages/ui/README.md requires, having been pruned with the components Coursemap had not taken. Its three icons resolve to Lucide. The year picker now opens with the current year aligned over the trigger. OptionPicker loses the compact mode added for it, which nothing else used. --- apps/web/ui/common/option-picker.tsx | 12 +- apps/web/ui/common/year-picker.tsx | 45 +++--- packages/ui/primitives/select.tsx | 215 +++++++++++++++++++++++++++ packages/ui/style-nova.css | 65 ++++++++ 4 files changed, 308 insertions(+), 29 deletions(-) create mode 100644 packages/ui/primitives/select.tsx diff --git a/apps/web/ui/common/option-picker.tsx b/apps/web/ui/common/option-picker.tsx index a4dbc612..ce616403 100644 --- a/apps/web/ui/common/option-picker.tsx +++ b/apps/web/ui/common/option-picker.tsx @@ -21,7 +21,6 @@ export function OptionPicker({ searchable = "auto", placeholder = "Select...", className, - compact = false, disabled, ...triggerProps }: Omit, "value" | "onChange" | "children"> & { @@ -33,12 +32,6 @@ export function OptionPicker({ /** Auto enables search for more than eight options. Years pass false. */ searchable?: boolean | "auto"; placeholder?: string; - /** - * The values are single tokens, such as a year, so the menu is sized to - * them. The default width suits labels that need the room; over four - * characters it reads as a heavy component for a very small choice. - */ - compact?: boolean; }) { const [localOpen, setLocalOpen] = useState(false); const open = controlledOpen ?? localOpen; @@ -68,10 +61,7 @@ export function OptionPicker({ 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;