diff --git a/apps/web/src/libs/motion.ts b/apps/web/src/libs/motion.ts index 9684136da..2a284c715 100644 --- a/apps/web/src/libs/motion.ts +++ b/apps/web/src/libs/motion.ts @@ -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('[data-slot="tabs-list"][data-mode-switch]'); + list?.setAttribute("data-instant", ""); + change(); + requestAnimationFrame(() => requestAnimationFrame(() => list?.removeAttribute("data-instant"))); +} diff --git a/apps/web/src/routes/builder/$resumeId/-components/editor-bar.tsx b/apps/web/src/routes/builder/$resumeId/-components/editor-bar.tsx index c69ae6ea2..32e2042db 100644 --- a/apps/web/src/routes/builder/$resumeId/-components/editor-bar.tsx +++ b/apps/web/src/routes/builder/$resumeId/-components/editor-bar.tsx @@ -68,7 +68,7 @@ function ModeTabs() { const labels: Record = { write: t`Write`, design: t`Design`, check: t`Check` }; return ( - + {(Object.keys(MODE_ICONS) as EditorMode[]).map((mode) => ( diff --git a/apps/web/src/routes/builder/$resumeId/-components/use-editor-hotkeys.ts b/apps/web/src/routes/builder/$resumeId/-components/use-editor-hotkeys.ts index 1a6544f11..658a0f168 100644 --- a/apps/web/src/routes/builder/$resumeId/-components/use-editor-hotkeys.ts +++ b/apps/web/src/routes/builder/$resumeId/-components/use-editor-hotkeys.ts @@ -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(); diff --git a/apps/web/src/routes/builder/letter/-components/letter-bar.tsx b/apps/web/src/routes/builder/letter/-components/letter-bar.tsx index 29fd7b1f7..663a43a5c 100644 --- a/apps/web/src/routes/builder/letter/-components/letter-bar.tsx +++ b/apps/web/src/routes/builder/letter/-components/letter-bar.tsx @@ -57,7 +57,7 @@ export function LetterBar({ layout, pinnable }: LetterBarProps) { {layout === "mobile" ? ( ) : ( - + Write diff --git a/apps/web/src/routes/builder/letter/-components/letter-shell.tsx b/apps/web/src/routes/builder/letter/-components/letter-shell.tsx index 8b8df9c2e..587858d9d 100644 --- a/apps/web/src/routes/builder/letter/-components/letter-shell.tsx +++ b/apps/web/src/routes/builder/letter/-components/letter-shell.tsx @@ -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)); diff --git a/packages/ui/src/components/checkbox.tsx b/packages/ui/src/components/checkbox.tsx index bc3286e46..a042cbdf6 100644 --- a/packages/ui/src/components/checkbox.tsx +++ b/packages/ui/src/components/checkbox.tsx @@ -14,7 +14,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) { > diff --git a/packages/ui/src/components/combobox.tsx b/packages/ui/src/components/combobox.tsx index b46668873..227a46375 100644 --- a/packages/ui/src/components/combobox.tsx +++ b/packages/ui/src/components/combobox.tsx @@ -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} diff --git a/packages/ui/src/components/menu-styles.tsx b/packages/ui/src/components/menu-styles.tsx index 8fb17eaab..37cca7ded 100644 --- a/packages/ui/src/components/menu-styles.tsx +++ b/packages/ui/src/components/menu-styles.tsx @@ -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"; diff --git a/packages/ui/src/components/popover.tsx b/packages/ui/src/components/popover.tsx index 3776f1e20..877c0c1b2 100644 --- a/packages/ui/src/components/popover.tsx +++ b/packages/ui/src/components/popover.tsx @@ -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({ ; } -/** 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 ( - + ); } diff --git a/packages/ui/src/components/tabs.tsx b/packages/ui/src/components/tabs.tsx index bb296fbcb..a4de35a42 100644 --- a/packages/ui/src/components/tabs.tsx +++ b/packages/ui/src/components/tabs.tsx @@ -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" && } + {children} ); } +/** + * 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 (