Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,8 @@ 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.
Native browser scrollbars are hidden. Long 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.
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
Expand Down
9 changes: 7 additions & 2 deletions src/components/primitives/avatar-group.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -80,7 +81,11 @@ export function AvatarGroup({ people, max = 7, listLabel = "Members", className
<p className="border-b border-(--pn-line) px-3 py-2 text-xs text-(--pn-fg-muted) tabular-nums">
{pluralize(rest.length, "more member")}
</p>
<ul className="max-h-[min(20rem,var(--available-height))] overflow-y-auto overscroll-contain py-1">
<CustomScrollbar
as="ul"
className="max-h-[min(20rem,var(--available-height))]"
viewportClassName="overscroll-contain py-1"
>
{rest.map((person) => (
<li key={person.id} className="flex h-9 items-center gap-2.5 px-3">
<PersonAvatar person={person} className="size-6 shrink-0" />
Expand All @@ -89,7 +94,7 @@ export function AvatarGroup({ people, max = 7, listLabel = "Members", className
</Hint>
</li>
))}
</ul>
</CustomScrollbar>
</PopoverContent>
</Popover>
)}
Expand Down
5 changes: 3 additions & 2 deletions src/components/primitives/chip-overflow.tsx
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -99,14 +100,14 @@ export function ChipOverflow<T>({
)}
>
<PopoverTitle className="sr-only">Additional items</PopoverTitle>
<ul className="flex max-h-72 flex-col gap-2 overflow-y-auto text-[13px] leading-5">
<CustomScrollbar as="ul" className="max-h-72" viewportClassName="flex flex-col gap-2 text-[13px] leading-5">
{rest.map((label, index) => (
<li key={index} className="flex items-start gap-2 [overflow-wrap:anywhere]">
<span aria-hidden className="mt-2 size-1.5 shrink-0 rounded-full bg-(--pn-accent)" />
{label}
</li>
))}
</ul>
</CustomScrollbar>
</PopoverContent>
</Popover>
)}
Expand Down
5 changes: 3 additions & 2 deletions src/components/primitives/confirm-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -120,7 +121,7 @@ export function ConfirmDialog({
finalFocus={finalFocus ? () => finalFocus() ?? true : undefined}
className={cn(dialogPanel, dialogMotion, "max-w-[460px]")}
>
<div className="flex min-h-0 items-start gap-3.5 overflow-y-auto px-5.5 pt-5 pb-5">
<CustomScrollbar className="min-h-0 flex-1" viewportClassName="flex items-start gap-3.5 px-5.5 pt-5 pb-5">
{Icon ? (
<span
aria-hidden
Expand All @@ -138,7 +139,7 @@ export function ConfirmDialog({
<AlertDialogDescription className={dialogDescriptionText}>{description}</AlertDialogDescription>
{error && <InlineAlert className="mt-3">{error}</InlineAlert>}
</div>
</div>
</CustomScrollbar>
<div className={dialogFooter}>
<AlertDialogCancel ref={cancelRef} disabled={pending} className={dialogFooterButton}>
{cancelLabel}
Expand Down
217 changes: 217 additions & 0 deletions src/components/primitives/custom-scrollbar.tsx
Original file line number Diff line number Diff line change
@@ -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<E extends ElementType> = {
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<HTMLElement>
/** Accessible label for the scrollbar control. */
scrollLabel?: string
children?: ReactNode
} & Omit<ComponentPropsWithoutRef<E>, "as" | "className" | "children" | "ref">

/** A scroll area with a themed, draggable scrollbar instead of the browser's scrollbar. */
export function CustomScrollbar<E extends ElementType = "div">({
as,
className,
viewportClassName,
scrollRef,
scrollLabel,
children,
...props
}: CustomScrollbarProps<E>) {
// SAFETY: `as` accepts only React element types, and the fallback is a native div.
const ScrollElement = (as ?? "div") as ElementType
const localScrollRef = useRef<HTMLElement | null>(null)
const trackRef = useRef<HTMLDivElement>(null)
const dragRef = useRef<{ pointerId: number; startY: number; startScroll: number } | null>(null)
const generatedId = useId()
const [metrics, setMetrics] = useState<ScrollMetrics>({ 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<HTMLDivElement>) {
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<HTMLDivElement>) {
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<HTMLDivElement>) {
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<HTMLDivElement>) {
if (dragRef.current?.pointerId === event.pointerId) dragRef.current = null
}

function onScrollbarKeyDown(event: KeyboardEvent<HTMLDivElement>) {
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 (
<div className={cn("custom-scroll-area", className)}>
<ScrollElement
{...props}
id={generatedId}
ref={localScrollRef}
className={cn("custom-scroll-area__viewport", viewportClassName)}
>
{children}
</ScrollElement>
<div
ref={trackRef}
role="scrollbar"
aria-label={scrollLabel ?? "Scroll content"}
aria-controls={generatedId}
aria-orientation="vertical"
aria-valuemin={0}
aria-valuemax={metrics.maxScroll}
aria-valuenow={localScrollRef.current?.scrollTop ?? 0}
tabIndex={metrics.maxScroll > 0 ? 0 : -1}
onKeyDown={onScrollbarKeyDown}
onPointerDown={onTrackPointerDown}
className="custom-scroll-area__track"
data-visible={metrics.maxScroll > 0 ? "" : undefined}
>
<div
className="custom-scroll-area__thumb"
style={{
height: `${metrics.thumbSize}px`,
transform: `translateY(${metrics.thumbOffset}px)`,
}}
onPointerDown={onThumbPointerDown}
onPointerMove={onThumbPointerMove}
onPointerUp={onThumbPointerEnd}
onPointerCancel={onThumbPointerEnd}
onLostPointerCapture={onThumbPointerEnd}
/>
</div>
</div>
)
}
11 changes: 8 additions & 3 deletions src/components/primitives/form-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -103,7 +104,7 @@ export function FormDialog({
const [submitError, setSubmitError] = useState<string | null>(null)
const [confirmingDiscard, setConfirmingDiscard] = useState(false)
const popupRef = useRef<HTMLDivElement>(null)
const bodyRef = useRef<HTMLDivElement>(null)
const bodyRef = useRef<HTMLElement>(null)
const shownError = error ?? submitError

function requestClose() {
Expand Down Expand Up @@ -181,9 +182,13 @@ export function FormDialog({
onKeyDown={onFormKeyDown}
className="flex min-h-0 flex-1 flex-col"
>
<div ref={bodyRef} className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-5.5 py-4.5">
<CustomScrollbar
scrollRef={bodyRef}
className="min-h-0 flex-1"
viewportClassName="flex flex-col gap-4 px-5.5 py-4.5"
>
{children}
</div>
</CustomScrollbar>
{(shownError || notice) && (
<div className="flex shrink-0 flex-col gap-2 px-5.5 pb-4">
{notice}
Expand Down
9 changes: 5 additions & 4 deletions src/components/primitives/label-tree-selector.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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.
<div className="shrink-0 border-b border-(--pn-line) px-2 pt-2 pb-2">
<div className="flex max-h-[52px] flex-wrap content-start gap-1 overflow-y-auto">
<CustomScrollbar className="max-h-[52px]" viewportClassName="flex max-h-[52px] flex-wrap content-start gap-1">
{selectedLabels.map((label) => (
<button
key={label.label}
Expand All @@ -218,11 +219,11 @@ export function LabelTreeSelector({
<X aria-hidden className="size-3 text-(--pn-fg-muted)" />
</button>
))}
</div>
</CustomScrollbar>
</div>
)}

<div className="min-h-0 flex-1 overflow-y-auto p-1">
<CustomScrollbar className="min-h-0 flex-1" viewportClassName="p-1">
{showTree && (
<section>
<SectionTitle>Categories</SectionTitle>
Expand Down Expand Up @@ -266,7 +267,7 @@ export function LabelTreeSelector({
className="px-2 py-6"
/>
)}
</div>
</CustomScrollbar>
</div>
)
}
Loading
Loading