diff --git a/src/components/button/Button.style.scss b/src/components/button/Button.style.scss index a21fb4e7b..319b7dcd1 100644 --- a/src/components/button/Button.style.scss +++ b/src/components/button/Button.style.scss @@ -13,8 +13,23 @@ justify-content: center; font-size: variables.$sm; + //feedback lives on the press, not on the release, so it has to be instant + transition: transform 100ms ease-out, background-color 150ms ease-out, box-shadow 150ms ease-out; + @include helpers.borderRadius(); @include helpers.disabled; + + &:active:not([aria-disabled="true"]) { + transform: scale(0.97); + } + + @media (prefers-reduced-motion: reduce) { + transition: background-color 150ms ease-out, box-shadow 150ms ease-out; + + &:active:not([aria-disabled="true"]) { + transform: none; + } + } } diff --git a/src/components/command/Command.style.scss b/src/components/command/Command.style.scss index a2f154ca9..94c77b49b 100644 --- a/src/components/command/Command.style.scss +++ b/src/components/command/Command.style.scss @@ -98,4 +98,16 @@ color: helpers.color(variables.$white, variables.$hierarchyTertiary); background: helpers.backgroundColor(variables.$primary); } -} \ No newline at end of file +} + +//the highlight follows the pointer and the arrow keys continuously, so it gets +//a short cross fade instead of a hard cut +.command__item { + transition: background-color 120ms ease-out, color 120ms ease-out; +} + +@media (prefers-reduced-motion: reduce) { + .command__item { + transition: none; + } +} diff --git a/src/components/context-menu/ContextMenu.style.scss b/src/components/context-menu/ContextMenu.style.scss index 1e7a92bb8..0474122d9 100644 --- a/src/components/context-menu/ContextMenu.style.scss +++ b/src/components/context-menu/ContextMenu.style.scss @@ -19,6 +19,12 @@ & { @include helpers.borderRadius(); } + + //while the surface animates out it is no longer interactive, but still on + //screen - clicks have to reach what is behind it again + &[data-state="closed"] { + pointer-events: none; + } } &__content-inner { @@ -77,4 +83,16 @@ .context-menu__content--#{$name}, .context-menu__sub-content--#{$name} { @include box.box($color); } -} \ No newline at end of file +} + +//the highlight follows the pointer and the arrow keys continuously, so it gets +//a short cross fade instead of a hard cut +.context-menu__item, .context-menu__sub-trigger { + transition: background-color 120ms ease-out, color 120ms ease-out; +} + +@media (prefers-reduced-motion: reduce) { + .context-menu__item, .context-menu__sub-trigger { + transition: none; + } +} diff --git a/src/components/context-menu/ContextMenu.tsx b/src/components/context-menu/ContextMenu.tsx index eb60e848a..4f2a12338 100644 --- a/src/components/context-menu/ContextMenu.tsx +++ b/src/components/context-menu/ContextMenu.tsx @@ -1,10 +1,18 @@ "use client" import React from "react"; -import {ComponentProps, Color, mergeComponentProps} from "../../utils"; +import { + Color, + ComponentProps, + mergeComponentProps, + popperTransformOrigin, + useObservedOpen, + usePopperMotion +} from "../../utils"; import * as Radix from "@radix-ui/react-context-menu"; import "./ContextMenu.style.scss" import {AutoScrollArea} from "../scroll-area/ScrollArea"; +import {AnimatePresence, motion} from "motion/react"; export type ContextMenuProps = ComponentProps & Radix.ContextMenuProps export type ContextMenuTriggerProps = ComponentProps & Radix.ContextMenuTriggerProps @@ -23,8 +31,18 @@ export type ContextMenuSubContentProps = ComponentProps & Radix.ContextMenuSubCo export type ContextMenuSeparatorProps = ComponentProps & Radix.ContextMenuSeparatorProps export type ContextMenuArrowProps = ComponentProps & Radix.ContextMenuArrowProps +//the content is force mounted and kept alive by AnimatePresence while it +//animates out, so the open state has to be known one level above it. A context +//menu is only ever opened by the user, so its state can only be observed +const ContextMenuOpenContext = React.createContext(false) +const ContextMenuSubOpenContext = React.createContext(false) + export const ContextMenu: React.FC = (props) => { - return + const [open, onOpenChange] = useObservedOpen(undefined, undefined, props.onOpenChange) + + return + + } export const ContextMenuTrigger: React.FC = (props) => { @@ -32,19 +50,32 @@ export const ContextMenuTrigger: React.FC = (props) => } export const ContextMenuPortal: React.FC = (props) => { - return + //force mounted so the portal outlives the menu's exit animation + return } export const ContextMenuContent: React.FC = (props) => { const {children, ...rest} = props - return - -
- {children} -
-
-
+ const open = React.useContext(ContextMenuOpenContext) + //already open on mount means the user did not open it, so it does not animate in + const openOnMount = React.useRef(open) + const motionProps = usePopperMotion() + + return + {open && + + +
+ {children} +
+
+
+
} +
} export const ContextMenuLabel: React.FC = (props) => { @@ -60,7 +91,11 @@ export const ContextMenuGroup: React.FC = (props) => { } export const ContextMenuSub: React.FC = (props) => { - return + const [open, onOpenChange] = useObservedOpen(props.open, props.defaultOpen, props.onOpenChange) + + return + + } export const ContextMenuSubTrigger: React.FC = (props) => { @@ -69,13 +104,26 @@ export const ContextMenuSubTrigger: React.FC = (prop export const ContextMenuSubContent: React.FC = (props) => { const {children, ...rest} = props - return - -
- {children} -
-
-
+ const open = React.useContext(ContextMenuSubOpenContext) + //already open on mount means the user did not open it, so it does not animate in + const openOnMount = React.useRef(open) + const motionProps = usePopperMotion() + + return + {open && + + +
+ {children} +
+
+
+
} +
} export const ContextMenuSeparator: React.FC = (props) => { diff --git a/src/components/dialog/Dialog.style.scss b/src/components/dialog/Dialog.style.scss index 537c42a26..4dc6c0fdf 100644 --- a/src/components/dialog/Dialog.style.scss +++ b/src/components/dialog/Dialog.style.scss @@ -24,23 +24,35 @@ left: 0; z-index: 50; background: rgba(variables.$primary, 0.75); + + //while the overlay animates out it is no longer interactive, but still on + //screen - clicks have to reach what is behind it again + &[data-state="closed"] { + pointer-events: none; + } } &__content { @include box.box(variables.$secondary); @include helpers.borderRadius(); + //centred without a transform, so the enter and exit animation owns the + //element's transform on its own position: fixed; - top: 50%; - left: 50%; + inset: 0; + margin: auto; + height: fit-content; z-index: 50; width: 100%; max-width: calc(100% - 2rem); gap: 1rem; - transform: translate(-50%, -50%); padding: 1rem; overflow: hidden; + &[data-state="closed"] { + pointer-events: none; + } + @include media.respond-above(md) { width: 490px; } @@ -82,4 +94,14 @@ justify-content: flex-end; } } -} \ No newline at end of file +} + +//the scrim's blur is animated inline, so it has to be overridden here; a +//frostier, near solid scrim replaces it +@media (prefers-reduced-transparency: reduce) { + .dialog__overlay { + background: rgba(variables.$primary, 0.95); + backdrop-filter: none !important; + -webkit-backdrop-filter: none !important; + } +} diff --git a/src/components/dialog/Dialog.tsx b/src/components/dialog/Dialog.tsx index adc5a0990..93463b673 100644 --- a/src/components/dialog/Dialog.tsx +++ b/src/components/dialog/Dialog.tsx @@ -1,5 +1,13 @@ import * as DialogPrimitive from "@radix-ui/react-dialog" -import {Component, ComponentProps, mergeComponentProps} from "../../utils"; +import { + Component, + ComponentProps, + mergeComponentProps, + useObservedOpen, + useScrimMotion, + useSurfaceMotion +} from "../../utils"; +import {AnimatePresence, motion} from "motion/react"; import React from "react"; import {IconX} from "@tabler/icons-react" import "./Dialog.style.scss" @@ -25,8 +33,17 @@ export type DialogStickyContentProps = Component & { children: React.ReactNode | React.ReactNode[] } +//the overlay and the content are force mounted and kept alive by +//AnimatePresence while they animate out, so the open state has to be known one +//level above them +const DialogOpenContext = React.createContext(false) + export const Dialog: React.FC = (props) => { - return + const [open, onOpenChange] = useObservedOpen(props.open, props.defaultOpen, props.onOpenChange) + + return + + } export const DialogTrigger: React.FC = (props) => { @@ -34,7 +51,8 @@ export const DialogTrigger: React.FC = (props) => { } export const DialogPortal: React.FC = (props) => { - return + //force mounted so the portal outlives the dialog's exit animation + return } export const DialogClose: React.FC = (props) => { @@ -42,24 +60,50 @@ export const DialogClose: React.FC = (props) => { } export const DialogOverlay: React.FC = (props) => { - return + const open = React.useContext(DialogOpenContext) + //already open on mount means the user did not open it, so it does not animate in + const openOnMount = React.useRef(open) + const motionProps = useScrimMotion() + + return + {open && + + } + } export const DialogContent: React.FC = (props) => { + const open = React.useContext(DialogOpenContext) + //already open on mount means the user did not open it, so it does not animate in + const openOnMount = React.useRef(open) + const motionProps = useSurfaceMotion() + return ( - - {props.showCloseButton && ( - - {props.title} - - - - - )} - {props.children} - + + {open && + + {props.showCloseButton && ( + + {props.title} + + + + + )} + {props.children} + + } + ) } diff --git a/src/components/form/Input.style.scss b/src/components/form/Input.style.scss index c432be9ae..6ff068064 100644 --- a/src/components/form/Input.style.scss +++ b/src/components/form/Input.style.scss @@ -366,8 +366,6 @@ -webkit-transform: translateX(22px); -ms-transform: translateX(22px); transform: translateX(22px); - -webkit-transition: .4s; - transition: .4s; } &:before { @@ -387,16 +385,21 @@ border-radius: 50%; position: absolute; content: ""; - -webkit-transition: .4s; - transition: .4s; + //the thumb is thrown to the other end, so it may overshoot a little; + //a linear transition over the same distance reads as dragged, not flicked + transition: transform 320ms cubic-bezier(0.34, 1.3, 0.64, 1); + + @media (prefers-reduced-motion: reduce) { + transition: none; + } } &:before { content: ""; z-index: -1; - -webkit-transition: .4s; - transition: .4s; + //the track only changes colour, which must not lag behind the thumb + transition: background-color 200ms ease-out, box-shadow 200ms ease-out; top: calc(-1 * $padding / 2); left: calc(-1 * $padding / 2); diff --git a/src/components/form/NumberInput.tsx b/src/components/form/NumberInput.tsx index ee6861ae5..1e7960a54 100644 --- a/src/components/form/NumberInput.tsx +++ b/src/components/form/NumberInput.tsx @@ -35,10 +35,10 @@ export const NumberInput: React.ForwardRefExoticComponent = Re return - + } left={ - + } leftType={"action"} type={"number"} diff --git a/src/components/form/PasswordInput.tsx b/src/components/form/PasswordInput.tsx index 81fcec9ce..0aaf4af6b 100644 --- a/src/components/form/PasswordInput.tsx +++ b/src/components/form/PasswordInput.tsx @@ -44,9 +44,9 @@ export const PasswordInput: React.ForwardRefExoticComponent } const rightAction: ReactElement[] = [right as ReactElement] - visible && rightAction.push() - clearable && rightAction.push() const rightActions = rightAction.filter(Boolean) diff --git a/src/components/form/SelectInput.style.scss b/src/components/form/SelectInput.style.scss index e7d2e510b..de55b9aa7 100644 --- a/src/components/form/SelectInput.style.scss +++ b/src/components/form/SelectInput.style.scss @@ -9,6 +9,11 @@ position: relative; box-sizing: border-box; z-index: 999; + //the surface materialises out of its trigger - blur and scale together, so + //it reads as a material arriving instead of a plain fade. Radix unmounts the + //content on close, so there is no exit animation to mirror here + transform-origin: var(--radix-select-content-transform-origin, center); + animation: select-input-materialize 300ms cubic-bezier(0.22, 1, 0.36, 1) both; & { @include helpers.borderRadius(); @@ -53,4 +58,45 @@ @include box.box($color); border: helpers.backgroundColor(variables.$secondary) 2px solid; } -} \ No newline at end of file +} + +//the highlight follows the pointer and the arrow keys continuously, so it gets +//a short cross fade instead of a hard cut +.select-input__item { + transition: background-color 120ms ease-out, color 120ms ease-out; +} + +@media (prefers-reduced-motion: reduce) { + .select-input__item { + transition: none; + } +} + +@keyframes select-input-materialize { + from { + opacity: 0; + transform: scale(0.96); + filter: blur(4px); + } + to { + opacity: 1; + transform: scale(1); + filter: blur(0); + } +} + +//reduced motion keeps the feedback, but without the travel +@media (prefers-reduced-motion: reduce) { + .select-input__content { + animation: select-input-fade 150ms ease-out both; + } +} + +@keyframes select-input-fade { + from { + opacity: 0; + } + to { + opacity: 1; + } +} diff --git a/src/components/form/TextInput.tsx b/src/components/form/TextInput.tsx index 7f4a107f5..c3e04f43d 100644 --- a/src/components/form/TextInput.tsx +++ b/src/components/form/TextInput.tsx @@ -28,7 +28,7 @@ export const TextInput: React.ForwardRefExoticComponent = React. } const rightAction: ReactElement[] = [right as ReactElement] - clearable && rightAction.push() + clearable && rightAction.push() const rightActions = rightAction.filter(Boolean) diff --git a/src/components/menu/Menu.style.scss b/src/components/menu/Menu.style.scss index f38ca831d..fb8b09327 100644 --- a/src/components/menu/Menu.style.scss +++ b/src/components/menu/Menu.style.scss @@ -19,6 +19,12 @@ & { @include helpers.borderRadius(); } + + //while the surface animates out it is no longer interactive, but still on + //screen - clicks have to reach what is behind it again + &[data-state="closed"] { + pointer-events: none; + } } &__content-inner { @@ -78,4 +84,16 @@ @include box.box($color); border: helpers.backgroundColor(variables.$secondary) 1px solid; } -} \ No newline at end of file +} + +//the highlight follows the pointer and the arrow keys continuously, so it gets +//a short cross fade instead of a hard cut +.menu__item, .menu__sub-trigger, .menu__checkbox-item { + transition: background-color 120ms ease-out, color 120ms ease-out; +} + +@media (prefers-reduced-motion: reduce) { + .menu__item, .menu__sub-trigger, .menu__checkbox-item { + transition: none; + } +} diff --git a/src/components/menu/Menu.tsx b/src/components/menu/Menu.tsx index f9e73f779..931adadef 100644 --- a/src/components/menu/Menu.tsx +++ b/src/components/menu/Menu.tsx @@ -1,7 +1,15 @@ import React from "react"; import "./Menu.style.scss" -import {Color, ComponentProps, mergeComponentProps} from "../../utils"; +import { + Color, + ComponentProps, + mergeComponentProps, + popperTransformOrigin, + useObservedOpen, + usePopperMotion +} from "../../utils"; +import {AnimatePresence, motion} from "motion/react"; import { DropdownMenu, DropdownMenuArrow, @@ -53,8 +61,17 @@ export type MenuSubContentProps = ComponentProps & DropdownMenuSubContentProps & export type MenuSeparatorProps = ComponentProps & DropdownMenuSeparatorProps export type MenuArrowProps = ComponentProps & DropdownMenuArrowProps +//the content is force mounted and kept alive by AnimatePresence while it +//animates out, so the open state has to be known one level above it +const MenuOpenContext = React.createContext(false) +const MenuSubOpenContext = React.createContext(false) + export const Menu: React.FC = (props) => { - return + const [open, onOpenChange] = useObservedOpen(props.open, props.defaultOpen, props.onOpenChange) + + return + + } export const MenuTrigger: React.FC = (props) => { @@ -62,19 +79,32 @@ export const MenuTrigger: React.FC = (props) => { } export const MenuPortal: React.FC = (props) => { - return + //force mounted so the portal outlives the menu's exit animation + return } export const MenuContent: React.FC = (props) => { const {children, ...rest} = props - return - -
- {children} -
-
-
+ const open = React.useContext(MenuOpenContext) + //already open on mount means the user did not open it, so it does not animate in + const openOnMount = React.useRef(open) + const motionProps = usePopperMotion() + + return + {open && + + +
+ {children} +
+
+
+
} +
} export const MenuLabel: React.FC = (props) => { @@ -90,7 +120,11 @@ export const MenuGroup: React.FC = (props) => { } export const MenuSub: React.FC = (props) => { - return + const [open, onOpenChange] = useObservedOpen(props.open, props.defaultOpen, props.onOpenChange) + + return + + } export const MenuSubTrigger = React.forwardRef((props, ref) => { @@ -99,13 +133,26 @@ export const MenuSubTrigger = React.forwardRef = (props) => { const {children, ...rest} = props - return - -
- {children} -
-
-
+ const open = React.useContext(MenuSubOpenContext) + //already open on mount means the user did not open it, so it does not animate in + const openOnMount = React.useRef(open) + const motionProps = usePopperMotion() + + return + {open && + + +
+ {children} +
+
+
+
} +
} export const MenuSeparator: React.FC = (props) => { diff --git a/src/components/segmented-control/SegmentedControl.style.scss b/src/components/segmented-control/SegmentedControl.style.scss index 51fe8fa3a..4e3b21eb1 100644 --- a/src/components/segmented-control/SegmentedControl.style.scss +++ b/src/components/segmented-control/SegmentedControl.style.scss @@ -19,26 +19,46 @@ cursor: pointer; padding: variables.$xxs variables.$xs; + //the selected background lives on the indicator, which slides between the + //items, so the item itself stays transparent and is painted above it + position: relative; + z-index: 1; + transition: opacity 150ms ease-out; &[data-state="off"] { - background: transparent; opacity: .6666666666; } & { @include box.box(variables.$secondary); @include helpers.disabled(); - background: helpers.backgroundColor(variables.$secondary, 2); + background: transparent; border-radius: variables.$borderRadius - (variables.$xxs / 2); box-shadow: none; } - &:disabled { - background: transparent; + @media (prefers-reduced-motion: reduce) { + transition: none; } } + &__item-wrapper { + position: relative; + display: inline-flex; + } + + //the indicator is a single shared element that slides from the previously + //selected item to the new one; the item itself is lifted above it + &__indicator { + position: absolute; + inset: 0; + z-index: 0; + border-radius: variables.$borderRadius - (variables.$xxs / 2); + background: helpers.backgroundColor(variables.$secondary, 2); + pointer-events: none; + } + } @each $name, $color in variables.$colors { diff --git a/src/components/segmented-control/SegmentedControl.tsx b/src/components/segmented-control/SegmentedControl.tsx index 80c13cae4..224cfbc90 100644 --- a/src/components/segmented-control/SegmentedControl.tsx +++ b/src/components/segmented-control/SegmentedControl.tsx @@ -1,16 +1,40 @@ import React from "react"; -import {ComponentProps, Color} from "../../utils"; -import {ToggleGroupSingleProps, ToggleGroupItemProps, Root, Item} from "@radix-ui/react-toggle-group"; +import {Color, ComponentProps, springs, useObservedValue, useReducedMotionSafe} from "../../utils"; +import {Item, Root, ToggleGroupItemProps, ToggleGroupSingleProps} from "@radix-ui/react-toggle-group"; import {mergeComponentProps} from "../../utils"; import "./SegmentedControl.style.scss" +import {motion} from "motion/react"; type SegmentedControlProps = ComponentProps & ToggleGroupSingleProps & {color?: Color} type SegmentedControlItemProps = ComponentProps & ToggleGroupItemProps +//the selected background is one shared element that slides between the items, +//so the selected value and the layout id both have to come from the root +const SegmentedControlContext = React.createContext<{ value?: string, layoutId: string }>({layoutId: "segmented-control__indicator"}) + export const SegmentedControl: React.FC = (props) => { - return + const [value, onValueChange] = useObservedValue(props.value, props.defaultValue, props.onValueChange) + const layoutId = React.useId() + + return + + } export const SegmentedControlItem: React.FC = (props) => { - return -} \ No newline at end of file + const {value, layoutId} = React.useContext(SegmentedControlContext) + const reduced = useReducedMotionSafe() + + const selected = value === props.value + + //the indicator is a sibling of the item instead of a child, so an item + //rendered with asChild keeps its single child + return + {selected && } + + +} diff --git a/src/components/tab/Tab.style.scss b/src/components/tab/Tab.style.scss index 8cda5b338..3a144890e 100644 --- a/src/components/tab/Tab.style.scss +++ b/src/components/tab/Tab.style.scss @@ -30,50 +30,67 @@ position: relative; box-sizing: border-box; opacity: 0.5; - transition: all ease-in-out 0.2s; + transition: opacity 150ms ease-out, color 150ms ease-out; - &[data-orientation="horizontal"] { - &:after { - content: ""; - position: absolute; - background: transparent; - - border-top-left-radius: 50rem; - border-top-right-radius: 50rem; - width: 35%; - min-width: 28px; - height: 2px; - bottom: -0.475rem; - left: 50%; - transform: translateX(-50%); - } + &[aria-selected=true] { + opacity: 1; } - &[data-orientation="vertical"] { - &:after { - content: ""; - position: absolute; - background: transparent; - - border-bottom-right-radius: 50rem; - border-top-right-radius: 50rem; - width: 2px; - height: 35%; - top: 50%; - left: -0.7rem; - transform: translateY(-50%); - } + @media (prefers-reduced-motion: reduce) { + transition: none; } + } - &[aria-selected=true] { - &[data-orientation="vertical"], &[data-orientation="horizontal"] { - opacity: 1; - &:after { - background: variables.$info; - } - } + //the indicator is a single shared element that springs from the previously + //selected trigger to the new one, so it must not carry a transform of its + //own - the layout animation owns that. It therefore spans the trigger and + //centers the bar with flex instead of translating it + &__indicator { + position: absolute; + display: flex; + pointer-events: none; + + &-bar { + background: variables.$info; } + } + &__trigger-wrapper { + position: relative; + display: inline-flex; + } + + &__trigger-wrapper[data-orientation="horizontal"] .tab__indicator { + left: 0; + right: 0; + bottom: -0.475rem; + height: 2px; + justify-content: center; + + .tab__indicator-bar { + width: 35%; + min-width: 28px; + height: 100%; + border-top-left-radius: 50rem; + border-top-right-radius: 50rem; + } + } + + &__trigger-wrapper[data-orientation="vertical"] .tab__indicator { + top: 0; + bottom: 0; + left: -0.7rem; + width: 2px; + flex-direction: column; + justify-content: center; + + .tab__indicator-bar { + height: 35%; + min-height: 28px; + width: 100%; + border-bottom-right-radius: 50rem; + border-top-right-radius: 50rem; + } } &__content { diff --git a/src/components/tab/Tab.tsx b/src/components/tab/Tab.tsx index 836728600..59da8d21d 100644 --- a/src/components/tab/Tab.tsx +++ b/src/components/tab/Tab.tsx @@ -1,5 +1,5 @@ import React from "react"; -import {ComponentProps, mergeComponentProps} from "../../utils"; +import {ComponentProps, mergeComponentProps, springs, useObservedValue, useReducedMotionSafe} from "../../utils"; import { Content, List, @@ -11,14 +11,28 @@ import { Trigger } from "@radix-ui/react-tabs"; import "./Tab.style.scss" +import {motion} from "motion/react"; export type TabProps = ComponentProps & TabsProps export type TabListProps = ComponentProps & TabsListProps; export type TabTriggerProps = ComponentProps & TabsTriggerProps export type TabContentProps = ComponentProps & TabsContentProps +//the indicator is one shared element that travels between the triggers, so the +//selected value and the layout id both have to come from the root +const TabValueContext = React.createContext<{ + value?: string, + layoutId: string, + orientation: "horizontal" | "vertical" +}>({layoutId: "tab__indicator", orientation: "horizontal"}) + export const Tab: React.FC = (props) => { - return + const [value, onValueChange] = useObservedValue(props.value, props.defaultValue, props.onValueChange) + const layoutId = React.useId() + + return + + } export const TabList: React.FC = (props) => { @@ -26,12 +40,27 @@ export const TabList: React.FC = (props) => { } export const TabTrigger: React.FC = (props) => { - return + const {value, layoutId, orientation} = React.useContext(TabValueContext) + const reduced = useReducedMotionSafe() + + const selected = value !== undefined && value === props.value + + //the indicator is a sibling of the trigger instead of a child, so a trigger + //rendered with asChild keeps its single child + return + + {selected && + + } + } export const TabContent: React.FC = (props) => { return -} \ No newline at end of file +} diff --git a/src/components/toast/Toast.tsx b/src/components/toast/Toast.tsx index 3867aa891..d3ff44344 100644 --- a/src/components/toast/Toast.tsx +++ b/src/components/toast/Toast.tsx @@ -3,8 +3,9 @@ import React from 'react' import { toast as sonnerToast } from 'sonner' import "./Toast.style.scss" -import {Component, Color} from "../../utils" -import {mergeComponentProps} from "../../utils" +import {Color, Component} from "../../utils" +import {mergeComponentProps, usePopperMotion} from "../../utils" +import {motion} from "motion/react" import { IconAlertCircle, IconCircleCheck, IconCircleDot, @@ -53,10 +54,17 @@ export function Toast(props: ToastProps) { //error toasts are critical and interrupt the screen reader (assertive); everything else is announced politely const isCritical = color === "error" + //the toast materialises on mount; sonner owns the exit, so only the enter + //states are handed to motion here + const {initial, animate, transition} = usePopperMotion() + return ( -
@@ -69,7 +77,7 @@ export function Toast(props: ToastProps) { {children}
} - + ) } diff --git a/src/components/tooltip/Tooltip.style.scss b/src/components/tooltip/Tooltip.style.scss index 340ea4cc2..1351213a2 100644 --- a/src/components/tooltip/Tooltip.style.scss +++ b/src/components/tooltip/Tooltip.style.scss @@ -21,6 +21,12 @@ @include helpers.fontStyle(); @include helpers.borderRadius(); } + + //while the surface animates out it is no longer interactive, but still on + //screen - clicks have to reach what is behind it again + &[data-state="closed"] { + pointer-events: none; + } } &__content-inner { diff --git a/src/components/tooltip/Tooltip.tsx b/src/components/tooltip/Tooltip.tsx index be4498d47..972284d96 100644 --- a/src/components/tooltip/Tooltip.tsx +++ b/src/components/tooltip/Tooltip.tsx @@ -1,8 +1,16 @@ import React from "react"; -import {Color, ComponentProps, mergeComponentProps} from "../../utils"; +import { + Color, + ComponentProps, + mergeComponentProps, + popperTransformOrigin, + useObservedOpen, + usePopperMotion +} from "../../utils"; import * as RadixTooltip from "@radix-ui/react-tooltip"; import "./Tooltip.style.scss" import {AutoScrollArea} from "../scroll-area/ScrollArea"; +import {AnimatePresence, motion} from "motion/react"; export type TooltipProps = ComponentProps & RadixTooltip.TooltipProps export type TooltipTriggerProps = ComponentProps & RadixTooltip.TooltipTriggerProps @@ -10,9 +18,17 @@ export type TooltipPortalProps = ComponentProps & RadixTooltip.TooltipPortalProp export type TooltipContentProps = ComponentProps & RadixTooltip.TooltipContentProps & { color?: Color } export type TooltipArrowProps = ComponentProps & RadixTooltip.TooltipArrowProps & { color?: Color } +//the content is force mounted and kept alive by AnimatePresence while it +//animates out, so the open state has to be known one level above it +const TooltipOpenContext = React.createContext(false) + export const Tooltip: React.FC = (props) => { + const [open, onOpenChange] = useObservedOpen(props.open, props.defaultOpen, props.onOpenChange) + return - + + + } @@ -21,19 +37,32 @@ export const TooltipTrigger: React.FC = (props) => { } export const TooltipPortal: React.FC = (props) => { - return + //force mounted so the portal outlives the tooltip's exit animation + return } export const TooltipContent: React.FC = (props) => { const {children, ...rest} = props - return - -
- {children} -
-
-
+ const open = React.useContext(TooltipOpenContext) + //already open on mount means the user did not open it, so it does not animate in + const openOnMount = React.useRef(open) + const motionProps = usePopperMotion() + + return + {open && + + +
+ {children} +
+
+
+
} +
} export const TooltipArrow: React.FC = (props) => { diff --git a/src/utils/index.ts b/src/utils/index.ts index 83058b8b6..3972bf50a 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -2,5 +2,6 @@ export * from "./arrayService" export * from "./contextStore" export * from "./reactiveArrayService" export * from "./component" +export * from "./motion" export * from "./color" export * from "./size" \ No newline at end of file diff --git a/src/utils/motion.ts b/src/utils/motion.ts new file mode 100644 index 000000000..9ccc8cfbc --- /dev/null +++ b/src/utils/motion.ts @@ -0,0 +1,140 @@ +import React from "react"; +import {TargetAndTransition, Transition, useReducedMotion} from "motion/react"; + +/** + * Apple describes springs with two designer facing parameters instead of the + * physics triplet: damping (how much it overshoots, 1 = no bounce) and response + * (how quickly the value reaches its target, in seconds). Motion's spring takes + * bounce + duration, which maps onto those directly. + */ +export const spring = (damping: number, response: number): Transition => ({ + type: "spring", + bounce: Math.max(0, 1 - damping), + duration: response +}) + +export const springs = { + //critically damped - the default for anything that just appears on screen + ui: spring(1, 0.3), + //larger surfaces carry more visual weight, so they settle a little slower + surface: spring(1, 0.4), + //only for motion a gesture handed off to (flick, throw, drag release) + momentum: spring(0.8, 0.4), + //drawers and sheets + sheet: spring(0.8, 0.3) +} satisfies Record + +/** + * Reduced motion does not mean no feedback - it means a short, non vestibular + * cross fade instead of a spring. + */ +export const crossFade: Transition = {duration: 0.15, ease: "easeOut"} + +export const useReducedMotionSafe = (): boolean => useReducedMotion() ?? false + +export interface PresenceMotion { + initial: TargetAndTransition + animate: TargetAndTransition + exit: TargetAndTransition + transition: Transition +} + +/** + * Enter and exit share one path (a surface leaves the way it arrived) and the + * surface materialises - blur and scale animate together - so it reads as a + * real material arriving instead of a plain opacity fade. + */ +const materialize = (scale: number, blur: number, transition: Transition): PresenceMotion => ({ + initial: {opacity: 0, scale, filter: `blur(${blur}px)`}, + animate: {opacity: 1, scale: 1, filter: "blur(0px)"}, + exit: {opacity: 0, scale, filter: `blur(${blur}px)`}, + transition +}) + +const fade: PresenceMotion = { + initial: {opacity: 0}, + animate: {opacity: 1}, + exit: {opacity: 0}, + transition: crossFade +} + +/** + * Motion for popper surfaces (menus, tooltips, selects). Pair it with + * {@link popperTransformOrigin} so the surface grows out of its trigger rather + * than out of its own centre. + */ +export const usePopperMotion = (): PresenceMotion => { + const reduced = useReducedMotionSafe() + return reduced ? fade : materialize(0.96, 4, springs.ui) +} + +/** Motion for modal surfaces, which are bigger and therefore settle slower. */ +export const useSurfaceMotion = (): PresenceMotion => { + const reduced = useReducedMotionSafe() + return reduced ? fade : materialize(0.97, 6, springs.surface) +} + +/** Motion for the scrim behind a modal surface: dim and blur to focus. */ +export const useScrimMotion = (blur = 6): PresenceMotion => { + const reduced = useReducedMotionSafe() + + if (reduced) return fade + + return { + initial: {opacity: 0, backdropFilter: "blur(0px)"}, + animate: {opacity: 1, backdropFilter: `blur(${blur}px)`}, + exit: {opacity: 0, backdropFilter: "blur(0px)"}, + transition: springs.surface + } +} + +/** + * Radix exposes the anchor it computed as a custom property on the popper + * wrapper. Custom properties inherit, so the content element can use it to + * scale out of the element that opened it. + */ +export const popperTransformOrigin = (name: string): React.CSSProperties => ({ + transformOrigin: `var(--radix-${name}-content-transform-origin, center)` +}) + +/** + * Keeps track of an overlay's open state without taking control of it away + * from the consumer: a passed `open` prop always wins, an uncontrolled overlay + * is observed through `onOpenChange`. + */ +export const useObservedOpen = ( + open: boolean | undefined, + defaultOpen: boolean | undefined, + onOpenChange: ((open: boolean) => void) | undefined +): [boolean, (open: boolean) => void] => { + + const [observed, setObserved] = React.useState(open ?? defaultOpen ?? false) + + const handleOpenChange = React.useCallback((next: boolean) => { + setObserved(next) + onOpenChange?.(next) + }, [onOpenChange]) + + return [open ?? observed, handleOpenChange] +} + +/** + * The same idea as {@link useObservedOpen} for the single selected value of a + * tab list or a segmented control, which a sliding indicator has to know about + * one level above the items. + */ +export const useObservedValue = ( + value: string | undefined, + defaultValue: string | undefined, + onValueChange: ((value: string) => void) | undefined +): [string | undefined, (value: string) => void] => { + + const [observed, setObserved] = React.useState(value ?? defaultValue) + + const handleValueChange = React.useCallback((next: string) => { + setObserved(next) + onValueChange?.(next) + }, [onValueChange]) + + return [value ?? observed, handleValueChange] +}