mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-05 11:13:52 +10:00
feat(ui): make overlays and controls move from where they come from
Menus, popovers and combobox lists now slide in from their trigger on whichever side they open (data-side aware, RTL-correct), and submenus open in 120ms and close in 84ms. The combobox list exits at 0.7x like every other popup, and tooltips scale from their trigger. Toasts keep travelling in the swipe direction when dismissed, ease back on a cancelled swipe, and a replaced toast drops out before the new one has finished rising. The tabs indicator uses the movement curve and now also draws the line variant's underline, so it slides instead of teleporting. Hotkey mode switches in the resume and letter builders land instantly. Radio dots and checkbox ticks grow in instead of popping or only fading.
This commit is contained in:
@@ -19,3 +19,14 @@ export const EASE_OUT_STRONG = [0.23, 1, 0.32, 1] as const;
|
||||
|
||||
/** dnd-kit timing for things settling into place (sortable shuffles, drop animations): on-screen movement. */
|
||||
export const DRAG_SETTLE = { duration: D2 * 1000, easing: `cubic-bezier(${EASE_MOVE.join(", ")})` };
|
||||
|
||||
/**
|
||||
* Hotkey mode switches land at once: the editor's mode-switch indicator skips its slide for this one change
|
||||
* (keyboard shortcuts are used too often to animate). The flag stays two frames, until the new position has painted.
|
||||
*/
|
||||
export function switchModeInstantly(change: () => void) {
|
||||
const list = document.querySelector<HTMLElement>('[data-slot="tabs-list"][data-mode-switch]');
|
||||
list?.setAttribute("data-instant", "");
|
||||
change();
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => list?.removeAttribute("data-instant")));
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ function ModeTabs() {
|
||||
const labels: Record<EditorMode, string> = { write: t`Write`, design: t`Design`, check: t`Check` };
|
||||
|
||||
return (
|
||||
<TabsList aria-label={t`Editor mode`} className="h-9">
|
||||
<TabsList aria-label={t`Editor mode`} className="h-9" data-mode-switch="">
|
||||
{(Object.keys(MODE_ICONS) as EditorMode[]).map((mode) => (
|
||||
<TabsTrigger key={mode} value={mode} className="px-4">
|
||||
<Icon name={MODE_ICONS[mode]} size={18} />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { toast } from "@reactive-resume/ui/components/toast";
|
||||
import { isEditableElementFocused, useCurrentResume, useResumeStore } from "@/features/resume/builder/draft";
|
||||
import { useEditorStore } from "@/features/resume/editor/store";
|
||||
import { useResumeExport } from "@/features/resume/export/use-resume-export";
|
||||
import { switchModeInstantly } from "@/libs/motion";
|
||||
|
||||
/**
|
||||
* The editor's keyboard map: 1/2/3 switch modes outside fields, ⌘Z and ⇧⌘Z undo and redo outside fields
|
||||
@@ -20,9 +21,9 @@ export function useEditorHotkeys(setMode: (mode: EditorMode) => void) {
|
||||
const { select, setShareTab, setAssistantOpen } = useEditorStore.getState();
|
||||
const isOnline = () => useResumeStore.getState().saveStatus !== "offline";
|
||||
|
||||
useHotkey("1", () => setMode("write"));
|
||||
useHotkey("2", () => setMode("design"));
|
||||
useHotkey("3", () => setMode("check"));
|
||||
useHotkey("1", () => switchModeInstantly(() => setMode("write")));
|
||||
useHotkey("2", () => switchModeInstantly(() => setMode("design")));
|
||||
useHotkey("3", () => switchModeInstantly(() => setMode("check")));
|
||||
|
||||
useHotkey("Mod+Z", () => {
|
||||
if (!isEditableElementFocused()) undo();
|
||||
|
||||
@@ -57,7 +57,7 @@ export function LetterBar({ layout, pinnable }: LetterBarProps) {
|
||||
{layout === "mobile" ? (
|
||||
<span />
|
||||
) : (
|
||||
<TabsList aria-label={t`Editor mode`} className="h-9">
|
||||
<TabsList aria-label={t`Editor mode`} className="h-9" data-mode-switch="">
|
||||
<TabsTrigger value="write" className="px-4">
|
||||
<Icon name="edit" size={18} />
|
||||
<Trans>Write</Trans>
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import { usePreviewPausedStore } from "@/features/resume/builder/draft";
|
||||
import { useIsLandscape } from "@/features/resume/editor/chrome";
|
||||
import { useEditorStore } from "@/features/resume/editor/store";
|
||||
import { switchModeInstantly } from "@/libs/motion";
|
||||
import { LetterDesignPanel } from "./design-panel";
|
||||
import { LetterBar, useDownloadLetter } from "./letter-bar";
|
||||
import { LetterPage } from "./letter-page";
|
||||
@@ -228,8 +229,8 @@ function LetterHotkeys({ onModeChange }: { onModeChange: (mode: LetterMode) => v
|
||||
const download = useDownloadLetter();
|
||||
const setShareTab = useEditorStore((state) => state.setShareTab);
|
||||
|
||||
useHotkey("1", () => onModeChange("write"));
|
||||
useHotkey("2", () => onModeChange("design"));
|
||||
useHotkey("1", () => switchModeInstantly(() => onModeChange("write")));
|
||||
useHotkey("2", () => switchModeInstantly(() => onModeChange("design")));
|
||||
useHotkey("Mod+P", () => void download.run());
|
||||
useHotkey("Mod+Shift+E", () => setShareTab("download"));
|
||||
useHotkey("Mod+J", () => useEditorStore.getState().setAssistantOpen(!useEditorStore.getState().assistantOpen, true));
|
||||
|
||||
@@ -14,7 +14,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="flex items-center justify-center text-current transition-opacity duration-quick data-ending-style:opacity-0 data-starting-style:opacity-0"
|
||||
className="flex items-center justify-center text-current transition-[opacity,scale] duration-quick ease-enter data-ending-style:scale-[0.8] data-starting-style:scale-[0.8] data-ending-style:opacity-0 data-starting-style:opacity-0 data-ending-style:duration-[calc(var(--d1)*0.7)]"
|
||||
>
|
||||
<Icon name={props.indeterminate ? "remove" : "check"} size={16} />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from "@reactive-resume/ui/components/input-group";
|
||||
import { menuSeparatorClassName } from "@reactive-resume/ui/components/menu-styles";
|
||||
import { menuSeparatorClassName, popupSlideClassName } from "@reactive-resume/ui/components/menu-styles";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
|
||||
const ComboboxRoot = ComboboxPrimitive.Root;
|
||||
@@ -102,7 +102,8 @@ function ComboboxContent({
|
||||
data-slot="combobox-content"
|
||||
data-chips={!!anchor}
|
||||
className={cn(
|
||||
"group/combobox-content relative max-h-(--available-height) w-fit min-w-[calc(var(--anchor-width)+--spacing(7))] max-w-(--available-width) origin-(--transform-origin) overflow-hidden rounded-xl bg-raised text-ink shadow-e2 transition-[opacity,scale,translate] duration-standard ease-enter data-[chips=true]:min-w-(--anchor-width) data-ending-style:-translate-y-1 data-starting-style:-translate-y-1 data-ending-style:scale-[0.98] data-starting-style:scale-[0.98] data-ending-style:opacity-0 data-starting-style:opacity-0 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-9 *:data-[slot=input-group]:bg-bg *:data-[slot=input-group]:shadow-none",
|
||||
"group/combobox-content relative max-h-(--available-height) w-fit min-w-[calc(var(--anchor-width)+--spacing(7))] max-w-(--available-width) origin-(--transform-origin) overflow-hidden rounded-xl bg-raised text-ink shadow-e2 transition-[opacity,scale,translate] duration-standard ease-enter data-[chips=true]:min-w-(--anchor-width) data-ending-style:scale-[0.98] data-starting-style:scale-[0.98] data-ending-style:opacity-0 data-starting-style:opacity-0 data-ending-style:duration-[calc(var(--d2)*0.7)] *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-9 *:data-[slot=input-group]:bg-bg *:data-[slot=input-group]:shadow-none",
|
||||
popupSlideClassName,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
/**
|
||||
* Popups start 4px back toward their trigger and settle outward, on whichever side they open. Base UI sets
|
||||
* `data-side` to top | bottom | left | right, or inline-start | inline-end when the side was given logically.
|
||||
*/
|
||||
export const popupSlideClassName =
|
||||
"data-[side=bottom]:data-starting-style:-translate-y-1 data-[side=bottom]:data-ending-style:-translate-y-1 data-[side=top]:data-starting-style:translate-y-1 data-[side=top]:data-ending-style:translate-y-1 data-[side=right]:data-starting-style:-translate-x-1 data-[side=right]:data-ending-style:-translate-x-1 data-[side=left]:data-starting-style:translate-x-1 data-[side=left]:data-ending-style:translate-x-1 ltr:data-[side=inline-end]:data-starting-style:-translate-x-1 ltr:data-[side=inline-end]:data-ending-style:-translate-x-1 rtl:data-[side=inline-end]:data-starting-style:translate-x-1 rtl:data-[side=inline-end]:data-ending-style:translate-x-1 ltr:data-[side=inline-start]:data-starting-style:translate-x-1 ltr:data-[side=inline-start]:data-ending-style:translate-x-1 rtl:data-[side=inline-start]:data-starting-style:-translate-x-1 rtl:data-[side=inline-start]:data-ending-style:-translate-x-1";
|
||||
|
||||
/**
|
||||
* Shared look for dropdown menus, context menus and combobox lists: 12px radius, e2, 4px padding,
|
||||
* 36px items with an 8px radius. Destructive items go last, after a separator, in danger text.
|
||||
* Submenus (`data-nested`) open in 120ms and close in 84ms: they open and close on every hover pass.
|
||||
*/
|
||||
export const menuPopupClassName =
|
||||
"relative z-50 max-h-(--available-height) min-w-[220px] origin-(--transform-origin) overflow-y-auto overflow-x-hidden rounded-xl bg-raised p-1 text-ink shadow-e2 outline-none transition-[opacity,scale,translate] duration-standard ease-enter data-ending-style:-translate-y-1 data-starting-style:-translate-y-1 data-ending-style:scale-[0.98] data-starting-style:scale-[0.98] data-closed:overflow-hidden data-ending-style:opacity-0 data-starting-style:opacity-0 data-instant:transition-none data-ending-style:duration-[calc(var(--d2)*0.7)]";
|
||||
export const menuPopupClassName = `relative z-50 max-h-(--available-height) min-w-[220px] origin-(--transform-origin) overflow-y-auto overflow-x-hidden rounded-xl bg-raised p-1 text-ink shadow-e2 outline-none transition-[opacity,scale,translate] duration-standard ease-enter data-ending-style:scale-[0.98] data-starting-style:scale-[0.98] data-closed:overflow-hidden data-ending-style:opacity-0 data-starting-style:opacity-0 data-instant:transition-none data-ending-style:duration-[calc(var(--d2)*0.7)] data-nested:duration-quick data-nested:data-ending-style:duration-[calc(var(--d1)*0.7)] ${popupSlideClassName}`;
|
||||
|
||||
export const menuItemClassName =
|
||||
"relative flex min-h-9 cursor-default select-none items-center gap-2.5 rounded-md px-2.5 text-ink text-sm outline-hidden data-disabled:pointer-events-none data-highlighted:bg-hover data-inset:ps-9 data-disabled:text-ink-3 data-[variant=destructive]:text-danger-text [&_[data-slot=icon]]:text-ink-2 data-[variant=destructive]:[&_[data-slot=icon]]:text-danger-text [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type * as React from "react";
|
||||
import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
|
||||
import { popupSlideClassName } from "@reactive-resume/ui/components/menu-styles";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
|
||||
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
|
||||
@@ -31,7 +32,8 @@ function PopoverContent({
|
||||
<PopoverPrimitive.Popup
|
||||
data-slot="popover-content"
|
||||
className={cn(
|
||||
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-xl bg-raised p-3 text-ink text-sm shadow-e2 outline-hidden transition-[opacity,scale,translate] duration-standard ease-enter data-ending-style:-translate-y-1 data-starting-style:-translate-y-1 data-ending-style:scale-[0.98] data-starting-style:scale-[0.98] data-ending-style:opacity-0 data-starting-style:opacity-0 data-instant:transition-none data-ending-style:duration-[calc(var(--d2)*0.7)]",
|
||||
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-xl bg-raised p-3 text-ink text-sm shadow-e2 outline-hidden transition-[opacity,scale,translate] duration-standard ease-enter data-ending-style:scale-[0.98] data-starting-style:scale-[0.98] data-ending-style:opacity-0 data-starting-style:opacity-0 data-instant:transition-none data-ending-style:duration-[calc(var(--d2)*0.7)]",
|
||||
popupSlideClassName,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -6,7 +6,7 @@ function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props) {
|
||||
return <RadioGroupPrimitive data-slot="radio-group" className={cn("grid gap-2", className)} {...props} />;
|
||||
}
|
||||
|
||||
/** An 18px circle with a 1.5px border; the chosen option shows an 8px accent dot. */
|
||||
/** An 18px circle with a 1.5px border; the chosen option's 8px accent dot grows in from half size. */
|
||||
function RadioGroupItem({ className, ...props }: Radio.Root.Props) {
|
||||
return (
|
||||
<Radio.Root
|
||||
@@ -17,7 +17,10 @@ function RadioGroupItem({ className, ...props }: Radio.Root.Props) {
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Radio.Indicator data-slot="radio-group-indicator" className="size-2 rounded-full bg-accent" />
|
||||
<Radio.Indicator
|
||||
data-slot="radio-group-indicator"
|
||||
className="size-2 rounded-full bg-accent transition-[opacity,scale] duration-quick ease-enter data-ending-style:scale-50 data-starting-style:scale-50 data-ending-style:opacity-0 data-starting-style:opacity-0 data-ending-style:duration-[calc(var(--d1)*0.7)]"
|
||||
/>
|
||||
</Radio.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ function Tabs({ className, orientation = "horizontal", ...props }: TabsPrimitive
|
||||
|
||||
/**
|
||||
* `default` is the segmented control used for modes (a sunken track with a raised selected
|
||||
* segment). `line` is the underline tab strip used for filters and sheet sections.
|
||||
* segment). `line` is the underline tab strip used for filters and sheet sections; its underline slides like the pill.
|
||||
*/
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list relative inline-flex w-fit items-center group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col",
|
||||
@@ -46,18 +46,24 @@ function TabsList({
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
>
|
||||
{variant === "default" && <TabsIndicator />}
|
||||
<TabsIndicator />
|
||||
{children}
|
||||
</TabsPrimitive.List>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Slides to the active tab: the raised pill on `default`, a 2px ink underline on `line`. Movement uses the
|
||||
* on-screen curve; a list with `data-instant` (keyboard mode switches) jumps instead.
|
||||
*/
|
||||
function TabsIndicator({ className, ...props }: TabsPrimitive.Indicator.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Indicator
|
||||
data-slot="tabs-indicator"
|
||||
className={cn(
|
||||
"absolute top-0 left-0 h-(--active-tab-height) w-(--active-tab-width) translate-x-(--active-tab-left) translate-y-(--active-tab-top) rounded-sm bg-raised shadow-e1 transition-[translate,width,height] duration-standard ease-enter",
|
||||
"absolute left-0 w-(--active-tab-width) translate-x-(--active-tab-left) transition-[translate,width,height] duration-standard ease-in-out-strong group-data-instant/tabs-list:transition-none",
|
||||
"group-data-[variant=default]/tabs-list:top-0 group-data-[variant=default]/tabs-list:h-(--active-tab-height) group-data-[variant=default]/tabs-list:translate-y-(--active-tab-top) group-data-[variant=default]/tabs-list:rounded-sm group-data-[variant=default]/tabs-list:bg-raised group-data-[variant=default]/tabs-list:shadow-e1",
|
||||
"group-data-[variant=line]/tabs-list:bottom-0 group-data-[variant=line]/tabs-list:h-0.5 group-data-[variant=line]/tabs-list:bg-ink",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -72,7 +78,7 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||
className={cn(
|
||||
"touch-target relative inline-flex min-w-fit items-center justify-center gap-1.5 whitespace-nowrap font-medium transition-colors duration-quick hover:text-ink disabled:pointer-events-none disabled:text-ink-3 aria-disabled:pointer-events-none aria-disabled:text-ink-3 data-active:text-ink group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
"group-data-[variant=default]/tabs-list:h-full group-data-[variant=default]/tabs-list:flex-1 group-data-[variant=default]/tabs-list:rounded-sm group-data-[variant=default]/tabs-list:px-3 group-data-[variant=default]/tabs-list:text-[13px]",
|
||||
"group-data-[variant=line]/tabs-list:h-full group-data-[variant=line]/tabs-list:text-sm group-data-[variant=line]/tabs-list:data-active:shadow-[inset_0_-2px_0_var(--ink)]",
|
||||
"group-data-[variant=line]/tabs-list:h-full group-data-[variant=line]/tabs-list:text-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -35,7 +35,7 @@ function Toast({ className, ...props }: ToastPrimitive.Root.Props) {
|
||||
<ToastPrimitive.Root
|
||||
data-slot="toast"
|
||||
className={cn(
|
||||
"group/toast pointer-events-auto flex w-fit max-w-[min(560px,100%)] select-none items-center rounded-lg bg-ink text-bg shadow-e3 outline-none transition-[opacity,translate] duration-emphasized ease-enter [grid-area:1/1] focus-visible:outline-accent data-ending-style:translate-y-2.5 data-starting-style:translate-y-2.5 data-ending-style:opacity-0 data-limited:opacity-0 data-starting-style:opacity-0 data-ending-style:duration-[calc(var(--d3)*0.7)]",
|
||||
"group/toast pointer-events-auto flex w-fit max-w-[min(560px,100%)] select-none items-center rounded-lg bg-ink text-bg shadow-e3 outline-none transition-[opacity,translate,transform] duration-emphasized ease-enter [grid-area:1/1] focus-visible:outline-accent data-[swipe-direction=right]:data-ending-style:translate-x-[calc(var(--toast-swipe-movement-x)+100%)] data-[swipe-direction=down]:data-ending-style:translate-y-[calc(var(--toast-swipe-movement-y)+100%)] data-[swipe-direction=right]:data-ending-style:translate-y-0 data-ending-style:translate-y-2.5 data-limited:translate-y-2.5 data-starting-style:translate-y-2.5 data-ending-style:opacity-0 data-limited:opacity-0 data-starting-style:opacity-0 data-ending-style:duration-[calc(var(--d3)*0.7)] data-limited:duration-[calc(var(--d3)*0.7)]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -35,7 +35,7 @@ function TooltipContent({
|
||||
<TooltipPrimitive.Popup
|
||||
data-slot="tooltip-content"
|
||||
className={cn(
|
||||
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-ink px-2.5 py-1.5 font-medium text-bg text-xs shadow-e2 transition-opacity duration-quick ease-enter has-data-[slot=kbd]:pe-1.5 data-ending-style:opacity-0 data-starting-style:opacity-0 data-instant:transition-none",
|
||||
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-ink px-2.5 py-1.5 font-medium text-bg text-xs shadow-e2 transition-[opacity,scale] duration-quick ease-enter has-data-[slot=kbd]:pe-1.5 data-ending-style:scale-[0.97] data-starting-style:scale-[0.97] data-ending-style:opacity-0 data-starting-style:opacity-0 data-instant:transition-none data-ending-style:duration-[calc(var(--d1)*0.7)]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
Reference in New Issue
Block a user