diff --git a/docs/design.md b/docs/design.md index 6ad423d..d83212f 100644 --- a/docs/design.md +++ b/docs/design.md @@ -248,7 +248,9 @@ control blurs, which rings it on `:focus`: a click or ⌘↵ doesn't count as ke Every hover is the same faint brand tint: fills use `--pn-hover`, borders `--pn-hover-line`; never `--pn-muted`, the solid accent or a status color. Selected and active states keep their own fills (`--pn-accent-soft`, `--pn-nav-active`). Destructive colors identify actions at rest and validation errors; their hover is the same blue. -Scrollable surfaces use thin, rounded scrollbars with the theme's line color on a transparent track, including portals. +Converted scrollable surfaces use `CustomScrollbar`: a narrow, rounded thumb in the theme's line color on a transparent +track, with pointer dragging, track clicks and keyboard navigation. Only those viewports hide native scrollbars; +other scroll regions retain their native controls, including horizontal controls in zoomed PDF viewers. Chip overflow counts open a themed popover on hover or click, listing full names on separate lines without truncation; keyboard users open it with Enter or Space and dismiss it with Escape. Tooltips use the same raised surface, foreground, border and corner radius as overflow popovers. Both open below diff --git a/src/components/primitives/avatar-group.tsx b/src/components/primitives/avatar-group.tsx index 63d537c..4ac541c 100644 --- a/src/components/primitives/avatar-group.tsx +++ b/src/components/primitives/avatar-group.tsx @@ -1,3 +1,4 @@ +import { CustomScrollbar } from "@/components/primitives/custom-scrollbar" import { Avatar, AvatarFallback } from "@/components/ui/avatar" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { pluralize } from "@/lib/format" @@ -80,7 +81,11 @@ export function AvatarGroup({ people, max = 7, listLabel = "Members", className

{pluralize(rest.length, "more member")}

- + )} diff --git a/src/components/primitives/chip-overflow.tsx b/src/components/primitives/chip-overflow.tsx index b2ebd3c..589c01f 100644 --- a/src/components/primitives/chip-overflow.tsx +++ b/src/components/primitives/chip-overflow.tsx @@ -1,6 +1,7 @@ import type * as React from "react" import { type ReactNode, useLayoutEffect, useRef, useState } from "react" +import { CustomScrollbar } from "@/components/primitives/custom-scrollbar" import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "@/components/ui/popover" import { cn } from "@/lib/utils" @@ -121,14 +122,14 @@ export function ChipOverflow({ )} > Additional items - + )} diff --git a/src/components/primitives/confirm-dialog.tsx b/src/components/primitives/confirm-dialog.tsx index 381f80c..bd4cad9 100644 --- a/src/components/primitives/confirm-dialog.tsx +++ b/src/components/primitives/confirm-dialog.tsx @@ -2,6 +2,7 @@ import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog import { type LucideIcon, TriangleAlert } from "lucide-react" import { type ReactElement, type ReactNode, useRef, useState } from "react" +import { CustomScrollbar } from "@/components/primitives/custom-scrollbar" import { AlertDialog, AlertDialogCancel, @@ -120,7 +121,7 @@ export function ConfirmDialog({ finalFocus={finalFocus ? () => finalFocus() ?? true : undefined} className={cn(dialogPanel, dialogMotion, "max-w-[460px]")} > -
+ {Icon ? ( {description} {error && {error}}
- +
{cancelLabel} diff --git a/src/components/primitives/custom-scrollbar.tsx b/src/components/primitives/custom-scrollbar.tsx new file mode 100644 index 0000000..da238a2 --- /dev/null +++ b/src/components/primitives/custom-scrollbar.tsx @@ -0,0 +1,217 @@ +import { + type ComponentPropsWithoutRef, + type ElementType, + type KeyboardEvent, + type PointerEvent, + type ReactNode, + type Ref, + useCallback, + useEffect, + useImperativeHandle, + useId, + useRef, + useState, +} from "react" + +import { cn } from "@/lib/utils" + +type ScrollMetrics = { + maxScroll: number + thumbSize: number + thumbOffset: number + trackSize: number +} + +type CustomScrollbarProps = { + as?: E + /** Sizing and layout classes for the outer grid. */ + className?: string + /** Classes for the actual scrollable viewport. */ + viewportClassName?: string + /** Ref to the scrollable element. */ + scrollRef?: Ref + /** Accessible label for the scrollbar control. */ + scrollLabel?: string + children?: ReactNode +} & Omit, "as" | "className" | "children" | "ref"> + +/** A scroll area with a themed, draggable scrollbar instead of the browser's scrollbar. */ +export function CustomScrollbar({ + as, + className, + viewportClassName, + scrollRef, + scrollLabel, + children, + ...props +}: CustomScrollbarProps) { + // SAFETY: `as` accepts only React element types, and the fallback is a native div. + const ScrollElement = (as ?? "div") as ElementType + const localScrollRef = useRef(null) + const trackRef = useRef(null) + const dragRef = useRef<{ pointerId: number; startY: number; startScroll: number } | null>(null) + const generatedId = useId() + const [metrics, setMetrics] = useState({ maxScroll: 0, thumbSize: 0, thumbOffset: 0, trackSize: 0 }) + useImperativeHandle(scrollRef, () => { + const element = localScrollRef.current + if (!element) throw new Error("Custom scrollbar viewport did not mount.") + return element + }, [scrollRef]) + + const measure = useCallback(() => { + const element = localScrollRef.current + const track = trackRef.current + if (!element || !track) return + + const maxScroll = Math.max(0, element.scrollHeight - element.clientHeight) + const trackSize = track.clientHeight + const thumbSize = + maxScroll > 0 + ? Math.min(trackSize, Math.max(24, (trackSize * element.clientHeight) / element.scrollHeight)) + : trackSize + const thumbOffset = maxScroll > 0 ? ((trackSize - thumbSize) * element.scrollTop) / maxScroll : 0 + setMetrics((current) => + current.maxScroll === maxScroll && + current.thumbSize === thumbSize && + current.thumbOffset === thumbOffset && + current.trackSize === trackSize + ? current + : { maxScroll, thumbSize, thumbOffset, trackSize } + ) + }, []) + + useEffect(() => { + const element = localScrollRef.current + if (!element) return + + let frame = 0 + const scheduleMeasure = () => { + cancelAnimationFrame(frame) + frame = requestAnimationFrame(measure) + } + const resizeObserver = new ResizeObserver(scheduleMeasure) + resizeObserver.observe(element) + if (element.firstElementChild) resizeObserver.observe(element.firstElementChild) + const mutationObserver = new MutationObserver(scheduleMeasure) + mutationObserver.observe(element, { childList: true, subtree: true, characterData: true, attributes: true }) + element.addEventListener("scroll", scheduleMeasure, { passive: true }) + window.addEventListener("resize", scheduleMeasure) + scheduleMeasure() + + return () => { + cancelAnimationFrame(frame) + resizeObserver.disconnect() + mutationObserver.disconnect() + element.removeEventListener("scroll", scheduleMeasure) + window.removeEventListener("resize", scheduleMeasure) + } + }, [measure]) + + function onTrackPointerDown(event: PointerEvent) { + if (event.target !== event.currentTarget || metrics.maxScroll === 0) return + const element = localScrollRef.current + if (!element) return + const rect = event.currentTarget.getBoundingClientRect() + const clickPosition = event.clientY - rect.top + const targetPosition = Math.max(0, Math.min(metrics.trackSize, clickPosition - metrics.thumbSize / 2)) + const travel = Math.max(1, metrics.trackSize - metrics.thumbSize) + element.scrollTop = (targetPosition / travel) * metrics.maxScroll + } + + function onThumbPointerDown(event: PointerEvent) { + if (metrics.maxScroll === 0) return + event.preventDefault() + event.stopPropagation() + event.currentTarget.setPointerCapture(event.pointerId) + dragRef.current = { + pointerId: event.pointerId, + startY: event.clientY, + startScroll: localScrollRef.current?.scrollTop ?? 0, + } + } + + function onThumbPointerMove(event: PointerEvent) { + const drag = dragRef.current + const element = localScrollRef.current + if (!drag || drag.pointerId !== event.pointerId || !element) return + const travel = Math.max(1, metrics.trackSize - metrics.thumbSize) + element.scrollTop = drag.startScroll + ((event.clientY - drag.startY) / travel) * metrics.maxScroll + } + + function onThumbPointerEnd(event: PointerEvent) { + if (dragRef.current?.pointerId === event.pointerId) dragRef.current = null + } + + function onScrollbarKeyDown(event: KeyboardEvent) { + const element = localScrollRef.current + if (!element) return + const page = Math.max(40, element.clientHeight * 0.9) + const amount = Math.max(24, element.clientHeight * 0.1) + let nextPosition: number + switch (event.key) { + case "ArrowDown": + nextPosition = element.scrollTop + amount + break + case "ArrowUp": + nextPosition = element.scrollTop - amount + break + case "PageDown": + nextPosition = element.scrollTop + page + break + case "PageUp": + nextPosition = element.scrollTop - page + break + case "Home": + nextPosition = 0 + break + case "End": + nextPosition = metrics.maxScroll + break + default: + return + } + event.preventDefault() + element.scrollTop = nextPosition + } + + return ( +
+ + {children} + +
0 ? 0 : -1} + onKeyDown={onScrollbarKeyDown} + onPointerDown={onTrackPointerDown} + className="custom-scroll-area__track" + data-visible={metrics.maxScroll > 0 ? "" : undefined} + > +
+
+
+ ) +} diff --git a/src/components/primitives/form-dialog.tsx b/src/components/primitives/form-dialog.tsx index 1654875..08a2cb2 100644 --- a/src/components/primitives/form-dialog.tsx +++ b/src/components/primitives/form-dialog.tsx @@ -2,6 +2,7 @@ import { Dialog as DialogPrimitive } from "@base-ui/react/dialog" import { type LucideIcon, TriangleAlert, X } from "lucide-react" import { type KeyboardEvent, type ReactElement, type ReactNode, useRef, useState } from "react" +import { CustomScrollbar } from "@/components/primitives/custom-scrollbar" import { Button } from "@/components/ui/button" import { Dialog, @@ -103,7 +104,7 @@ export function FormDialog({ const [submitError, setSubmitError] = useState(null) const [confirmingDiscard, setConfirmingDiscard] = useState(false) const popupRef = useRef(null) - const bodyRef = useRef(null) + const bodyRef = useRef(null) const shownError = error ?? submitError function requestClose() { @@ -181,9 +182,13 @@ export function FormDialog({ onKeyDown={onFormKeyDown} className="flex min-h-0 flex-1 flex-col" > -
+ {children} -
+ {(shownError || notice) && (
{notice} diff --git a/src/components/primitives/label-tree-selector.tsx b/src/components/primitives/label-tree-selector.tsx index 320e243..407be63 100644 --- a/src/components/primitives/label-tree-selector.tsx +++ b/src/components/primitives/label-tree-selector.tsx @@ -1,6 +1,7 @@ import { Check, ChevronRight, Minus, Search, X } from "lucide-react" import { useMemo, useState } from "react" +import { CustomScrollbar } from "@/components/primitives/custom-scrollbar" import { buildLabelTree, collectSubtreeLabels, @@ -204,7 +205,7 @@ export function LabelTreeSelector({ {selectedLabels.length > 0 && ( // Own block under the search: two 24px lines (plus the 4px gap) then it scrolls; 8px below the chips.
-
+ {selectedLabels.map((label) => ( ))} -
+
)} -
+ {showTree && (
Categories @@ -266,7 +267,7 @@ export function LabelTreeSelector({ className="px-2 py-6" /> )} -
+
) } diff --git a/src/components/shell/command-palette.tsx b/src/components/shell/command-palette.tsx index a3ec4fe..ab57d62 100644 --- a/src/components/shell/command-palette.tsx +++ b/src/components/shell/command-palette.tsx @@ -4,6 +4,7 @@ import { Command } from "cmdk" import { LogOut, type LucideIcon, Moon, Search, Sun, UserRound } from "lucide-react" import { type ReactNode, useEffect, useMemo, useRef, useState } from "react" +import { CustomScrollbar } from "@/components/primitives/custom-scrollbar" import { Dialog, DialogOverlay, DialogPortal } from "@/components/ui/dialog" import { suppressNextFocusTooltip } from "@/components/ui/tooltip" @@ -159,30 +160,36 @@ export function CommandPalette({ open, onOpenChange, onSignOut }: CommandPalette className="h-full min-w-0 flex-1 bg-transparent text-[14px] text-(--pn-fg) outline-none placeholder:text-(--pn-fg-subtle)" />
- - - Nothing matches “{query}”. - - {groups.map((group) => ( - - {group.entries.map((entry) => ( - run(entry)} - className="flex h-10 cursor-default items-center gap-3 rounded-(--pn-r-2) px-2 text-[14px] select-none data-[selected=true]:bg-(--pn-muted)" - > - {entry.glyph} - {entry.label} - - ))} - - ))} - + + + + Nothing matches “{query}”. + + {groups.map((group) => ( + + {group.entries.map((entry) => ( + run(entry)} + className="flex h-10 cursor-default items-center gap-3 rounded-(--pn-r-2) px-2 text-[14px] select-none data-[selected=true]:bg-(--pn-muted)" + > + {entry.glyph} + {entry.label} + + ))} + + ))} + + diff --git a/src/components/shell/panel.tsx b/src/components/shell/panel.tsx index bbd3487..ae75518 100644 --- a/src/components/shell/panel.tsx +++ b/src/components/shell/panel.tsx @@ -1,6 +1,7 @@ import { Link } from "@tanstack/react-router" import { type KeyboardEvent, Suspense, use, useDeferredValue, useLayoutEffect, useRef } from "react" +import { CustomScrollbar } from "@/components/primitives/custom-scrollbar" import { formatNumber } from "@/lib/format" import { cn } from "@/lib/utils" @@ -125,13 +126,16 @@ export function Panel({ service, match, pending, className }: PanelProps) { {service.title}
- + ) } diff --git a/src/components/shell/shell.tsx b/src/components/shell/shell.tsx index b6a219e..d8c3b19 100644 --- a/src/components/shell/shell.tsx +++ b/src/components/shell/shell.tsx @@ -2,6 +2,7 @@ import { Outlet } from "@tanstack/react-router" import { animate, AnimatePresence, motion, type Transition, useReducedMotion } from "motion/react" import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react" +import { CustomScrollbar } from "@/components/primitives/custom-scrollbar" import { TOOLTIP_DELAY } from "@/components/primitives/hint" import { TooltipProvider } from "@/components/ui/tooltip" import type { DashboardUser } from "@/lib/auth" @@ -194,11 +195,13 @@ function DashboardFrame({ user: dashboardUser, pending }: DashboardShellProps) {
{/* One scroll container per page: keyed so each page starts at the top, and back/forward restore it. */} -
{hasPanel ? ( @@ -206,7 +209,7 @@ function DashboardFrame({ user: dashboardUser, pending }: DashboardShellProps) {
-
+
diff --git a/src/features/groups-by-label/add-group-to-label-dialog.tsx b/src/features/groups-by-label/add-group-to-label-dialog.tsx index 8f27d8c..26b8337 100644 --- a/src/features/groups-by-label/add-group-to-label-dialog.tsx +++ b/src/features/groups-by-label/add-group-to-label-dialog.tsx @@ -16,6 +16,7 @@ import { SectionEmpty, SegmentedControl, } from "@/components/primitives" +import { CustomScrollbar } from "@/components/primitives/custom-scrollbar" import { Hint } from "@/components/primitives/hint" import { appToast, useCan } from "@/components/shell" import { Button } from "@/components/ui/button" @@ -356,9 +357,11 @@ export function AddGroupToLabelDialog({ {selected.length > 0 && ( -
{selected.length} selected @@ -380,13 +383,14 @@ export function AddGroupToLabelDialog({ ))} -
+ )} -
{pickable.length > 0 ? ( pickable.map((group) => { @@ -414,7 +418,7 @@ export function AddGroupToLabelDialog({ ) : ( )} -
+ )} diff --git a/src/features/groups/labels-filter-popover.tsx b/src/features/groups/labels-filter-popover.tsx index 25d48f3..1776efb 100644 --- a/src/features/groups/labels-filter-popover.tsx +++ b/src/features/groups/labels-filter-popover.tsx @@ -17,6 +17,7 @@ import { type TablePagination, type TableSort, } from "@/components/primitives" +import { CustomScrollbar } from "@/components/primitives/custom-scrollbar" import { Button } from "@/components/ui/button" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import type { GroupLabel } from "@/features/group-labels/types" @@ -326,10 +327,12 @@ export function LabelsFilterPopover({ labels, value, onChange, groupCount }: Lab className={cn( raisedSurface, floatingMotion, - "max-h-(--available-height) w-80 gap-3 overflow-y-auto rounded-(--pn-r-4) p-3 max-lg:max-w-[calc(100vw-2rem)]" + "max-h-(--available-height) w-80 overflow-hidden rounded-(--pn-r-4) p-0 max-lg:max-w-[calc(100vw-2rem)]" )} > - {content} + + {content} + ) diff --git a/src/styles.css b/src/styles.css index 97b0bdb..85ab7be 100644 --- a/src/styles.css +++ b/src/styles.css @@ -5,34 +5,72 @@ @custom-variant dark (&:is(.dark *)); -/* Scrolling surfaces and portals share the dashboard palette in both themes. */ -* { - scrollbar-width: thin; - scrollbar-color: var(--pn-line-strong) transparent; +.custom-scroll-area { + position: relative; + display: grid; + min-width: 0; + min-height: 0; + grid-template: minmax(0, 1fr) / minmax(0, 1fr); } -::-webkit-scrollbar { - width: 8px; - height: 8px; +.custom-scroll-area__viewport { + grid-area: 1 / 1; + min-width: 0; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + scrollbar-width: none; } -::-webkit-scrollbar-track { - background: transparent; +.custom-scroll-area__viewport::-webkit-scrollbar { + display: none; + width: 0; + height: 0; +} + +.custom-scroll-area__track { + z-index: 1; + grid-area: 1 / 1; + justify-self: end; + align-self: stretch; + width: 12px; + margin: 2px 1px; + padding: 0; + overflow: hidden; + cursor: pointer; + touch-action: none; + user-select: none; + opacity: 0; + pointer-events: none; + transition: opacity 120ms var(--pn-ease-out); +} + +.custom-scroll-area__track[data-visible] { + opacity: 1; + pointer-events: auto; +} + +.custom-scroll-area__track:focus-visible { + outline: 2px solid var(--pn-focus); + outline-offset: -1px; + border-radius: 999px; } -::-webkit-scrollbar-thumb { - border: 2px solid transparent; +.custom-scroll-area__thumb { + width: 4px; + margin-inline: auto; border-radius: 999px; background: var(--pn-line-strong); - background-clip: padding-box; + cursor: grab; } -::-webkit-scrollbar-thumb:hover { - background-color: var(--pn-fg-subtle); +.custom-scroll-area__track:hover .custom-scroll-area__thumb { + width: 6px; + background: var(--pn-fg-subtle); } -::-webkit-scrollbar-corner { - background: transparent; +.custom-scroll-area__thumb:active { + cursor: grabbing; } /* The shell breakpoint (760px): `shell:` gets the rail and panel, `max-shell:` the top bar, pills and bottom nav. */