Files
multica/packages/views/issues/components/pickers/property-picker.tsx
Jiayuan Zhang 7803a5b9ea feat(ui): establish a role-named type scale and migrate ad-hoc font sizes (MUL-5451) (#6136)
tokens.css defined colours, radii and font families but not a single --text-*
step, so font sizes had no baseline to align to and grew wherever they were
needed: 51 distinct sizes across web + desktop, 370 written as arbitrary
values, six at half a pixel (10.5 / 11.5 / 12.5 / 13.5 / 14.5 / 15.5px).
text-xs and text-sm carried nearly all UI text while the range between them —
11, 13, 15px — could only be reached with arbitrary values. Hierarchy does not
come from having more sizes; past a handful, each extra size makes the
hierarchy blurrier.

Add ten role-named steps, each with its own line-height so leading cannot
fragment the way size did, and move every product-UI call site onto them.
Steps are named for what the text is for, not for a t-shirt size, because
that is what keeps the scale from drifting again.

Six steps deliberately keep the exact size/line-height pairs of the Tailwind
defaults they replace, so the ~1,900-call-site rename moves nothing on screen.
The visible changes are confined to former arbitrary values snapping to a step:
8/9/10px -> micro (11px) on badges and overlines; 17 -> 18; 22 -> 24; 30
(text-3xl) -> 36 on headings and stat numbers; 12.8px -> label (13px) on small
buttons and toggles. Half-pixel sizes are gone.

This supersedes #6108, which was reverted by #6116 because the sidebar group
labels rendered at the inherited 16px. The cause was not the scale but cn():
`text-<x>` is ambiguous in Tailwind, and tailwind-merge resolves it against a
table listing only the default sizes, so it filed every role step under
text-colour and dropped whichever of `text-caption` /
`text-sidebar-foreground/70` came first. Registering the steps as a font-size
class group restores the real conflict groups — size beats size, colour beats
colour, the two coexist — and a test pins the list against the scale, since
the failure is silent in source.

Hand-written CSS is covered too. The transcript kept a 12.5px body long after
every Tailwind call site was on the scale, so the "no half-pixel sizes" claim
was true of the classes and false of the product; the editor's prose, code and
mermaid ramps had the same blind spot, and seven of their eight values already
equalled a step exactly. All now reference var(--text-*). The guard test reads
raw `font-size:` declarations as well as class names, exempting only the 16px
iOS input-zoom workaround in base.css and the landing pages' marketing ramp.

apps/mobile (own NativeWind config) and apps/docs (fumadocs' own type system)
keep Tailwind's default scale and are untouched. Landing display type
(rem/clamp, 2.2-6.4rem) stays on its separate ramp, as do four decorative
emoji / serif-hero sizes.

Verified on a running local stack: pinned sidebar rows and group labels
measure 12px/16px, nav items 14px/20px — identical to pre-migration. An audit
of every rendered font size across the product surfaces finds nothing off the
scale; the only exceptions are avatar initials and emoji, which
actor-avatar.tsx sizes proportionally to the avatar diameter by design.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-30 13:42:33 +08:00

291 lines
9.7 KiB
TypeScript

"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import { Check } from "lucide-react";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@multica/ui/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@multica/ui/components/ui/tooltip";
import { isImeComposing } from "@multica/core/utils";
import { useT } from "../../../i18n";
const HIGHLIGHT_CLASS = "bg-accent";
const ITEM_SELECTOR = "button[data-picker-item]:not(:disabled)";
/**
* Default class of the picker popover trigger. Shared with the deferred
* (pre-mount) lookalike trigger in `DeferredPopup` call sites so the swap on
* first interaction is pixel-identical.
*/
export const PICKER_TRIGGER_CLASS =
"flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden";
// ---------------------------------------------------------------------------
// PropertyPicker — generic Popover shell with optional search
// ---------------------------------------------------------------------------
export function PropertyPicker({
open,
onOpenChange,
trigger,
triggerRender,
width = "w-48",
align = "end",
side = "bottom",
searchable = false,
searchPlaceholder,
onSearchChange,
header,
tooltip,
children,
footer,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
trigger: React.ReactNode;
triggerRender?: React.ReactElement;
width?: string;
align?: "start" | "center" | "end";
side?: React.ComponentProps<typeof PopoverContent>["side"];
searchable?: boolean;
searchPlaceholder?: string | undefined;
onSearchChange?: (query: string) => void;
/** Custom sticky header rendered above the scrollable list. Use for
* filter toggles, search inputs, or any UI that must stay visible while
* the list scrolls. The built-in `searchable` input renders just above
* this header when both are present. */
header?: React.ReactNode;
/** Optional design-system tooltip shown when the trigger is hovered while
* the popover is closed. Suppressed automatically when the popover is
* open (otherwise tooltip + popover would stack on the same anchor). */
tooltip?: React.ReactNode;
children: React.ReactNode;
/**
* Optional footer rendered below the listbox. Unlike items rendered as
* children, the footer is *not* included in arrow-key navigation — use it
* for actions like "Create new…" or "Manage…" that shouldn't be treated as
* selectable listbox options.
*/
footer?: React.ReactNode;
}) {
const { t } = useT("issues");
const placeholder = searchPlaceholder ?? t(($) => $.filters.placeholder);
const filterAria = t(($) => $.pickers.filter_options_aria);
const [query, setQuery] = useState("");
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const [tooltipHover, setTooltipHover] = useState(false);
const listRef = useRef<HTMLDivElement>(null);
// Show the tooltip only while the trigger is hovered AND the popover is
// closed — avoids the awkward state where the tooltip floats next to (or
// on top of) the popover that just opened on click.
const tooltipOpen = !!tooltip && tooltipHover && !open;
const getItems = useCallback(() => {
if (!listRef.current) return [];
return Array.from(
listRef.current.querySelectorAll<HTMLButtonElement>(ITEM_SELECTOR),
);
}, []);
// Apply/remove highlight class via DOM when index changes
useEffect(() => {
const items = getItems();
for (const item of items) {
item.classList.remove(HIGHLIGHT_CLASS);
}
if (highlightedIndex >= 0 && highlightedIndex < items.length) {
items[highlightedIndex]?.classList.add(HIGHLIGHT_CLASS);
}
}, [highlightedIndex, getItems, children]); // re-run when children change (filtered list updates)
// Reset the search state on the open -> closed transition rather than inside
// an open-change handler. Every picker closes itself after a selection by
// calling its own `setOpen(false)`, which flips this `open` prop directly and
// never routes through the popover's `onOpenChange` — so a handler-only reset
// left the stale query (and the filtered list) in place on the next open.
const wasOpen = useRef(open);
useEffect(() => {
if (wasOpen.current && !open) {
setQuery("");
setHighlightedIndex(-1);
onSearchChange?.("");
}
wasOpen.current = open;
}, [open, onSearchChange]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
// IME is composing — Enter/Arrow belong to the IME (Enter commits
// composition; Arrow rotates candidates). Don't hijack them.
if (isImeComposing(e)) return;
const items = getItems();
if (items.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setHighlightedIndex((prev) => {
const next = prev < items.length - 1 ? prev + 1 : 0;
items[next]?.scrollIntoView({ block: "nearest" });
return next;
});
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHighlightedIndex((prev) => {
const next = prev > 0 ? prev - 1 : items.length - 1;
items[next]?.scrollIntoView({ block: "nearest" });
return next;
});
} else if (e.key === "Enter") {
e.preventDefault();
if (highlightedIndex >= 0 && highlightedIndex < items.length) {
items[highlightedIndex]?.click();
} else if (items.length === 1) {
// Auto-select when only one result
items[0]?.click();
}
}
},
[getItems, highlightedIndex],
);
const popoverTrigger = (
<PopoverTrigger
className={triggerRender ? undefined : PICKER_TRIGGER_CLASS}
render={triggerRender}
>
{trigger}
</PopoverTrigger>
);
return (
<Popover open={open} onOpenChange={onOpenChange}>
{tooltip ? (
<Tooltip open={tooltipOpen} onOpenChange={setTooltipHover}>
<TooltipTrigger render={popoverTrigger} />
<TooltipContent side="top">{tooltip}</TooltipContent>
</Tooltip>
) : (
popoverTrigger
)}
<PopoverContent align={align} side={side} className={`${width} gap-0 p-0`}>
{searchable && (
<div className="px-2 py-1.5 border-b">
<input
type="text"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setHighlightedIndex(0);
onSearchChange?.(e.target.value);
}}
onKeyDown={handleKeyDown}
placeholder={placeholder}
aria-label={filterAria}
className="w-full bg-transparent text-body placeholder:text-muted-foreground outline-none"
/>
</div>
)}
{header && <div className="border-b">{header}</div>}
<div ref={listRef} className="p-1 max-h-72 overflow-y-auto">{children}</div>
{footer && <div className="border-t p-1">{footer}</div>}
</PopoverContent>
</Popover>
);
}
// ---------------------------------------------------------------------------
// PickerItem — single selectable row
// ---------------------------------------------------------------------------
export function PickerItem({
selected,
disabled,
onClick,
hoverClassName,
tooltip,
children,
}: {
selected: boolean;
disabled?: boolean;
onClick: () => void;
hoverClassName?: string;
/** Design-system tooltip for the row — useful when truncated content needs
* the full string, or when the row carries metadata that doesn't fit on
* a single line. Wrapped in a real Tooltip component (200ms delay,
* styled), not a native `title` attribute. */
tooltip?: React.ReactNode;
children: React.ReactNode;
}) {
const button = (
<button
type="button"
data-picker-item
disabled={disabled}
onClick={onClick}
className={`flex w-full items-center gap-3 rounded-md px-2 py-1.5 text-left text-body ${disabled ? "opacity-50 cursor-not-allowed" : hoverClassName ?? "hover:bg-accent"} transition-colors`}
>
{/* min-w-0 lets long children (like truncated label names) shrink
inside the flex row instead of pushing the selected checkmark off
the right edge. The check column always reserves its 14px slot
(visible when selected, invisible otherwise) so unselected rows
align with selected rows and the eye doesn't chase a jittery
right edge. */}
<span className="flex min-w-0 flex-1 items-center gap-2">{children}</span>
<Check
className={`h-3.5 w-3.5 shrink-0 text-muted-foreground ${
selected ? "" : "invisible"
}`}
/>
</button>
);
if (!tooltip) return button;
return (
<Tooltip>
<TooltipTrigger render={button} />
<TooltipContent side="top">{tooltip}</TooltipContent>
</Tooltip>
);
}
// ---------------------------------------------------------------------------
// PickerSection — group header
// ---------------------------------------------------------------------------
export function PickerSection({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div>
<div className="px-2 pt-2 pb-1 text-caption font-medium text-muted-foreground uppercase tracking-wider">
{label}
</div>
{children}
</div>
);
}
// ---------------------------------------------------------------------------
// PickerEmpty — no results state
// ---------------------------------------------------------------------------
export function PickerEmpty() {
const { t } = useT("issues");
return (
<div className="px-2 py-3 text-center text-body text-muted-foreground">
{t(($) => $.pickers.no_results)}
</div>
);
}