Skip to content
Merged
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
15 changes: 15 additions & 0 deletions src/components/button/Button.style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}


Expand Down
14 changes: 13 additions & 1 deletion src/components/command/Command.style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,16 @@
color: helpers.color(variables.$white, variables.$hierarchyTertiary);
background: helpers.backgroundColor(variables.$primary);
}
}
}

//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;
}
}
20 changes: 19 additions & 1 deletion src/components/context-menu/ContextMenu.style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -77,4 +83,16 @@
.context-menu__content--#{$name}, .context-menu__sub-content--#{$name} {
@include box.box($color);
}
}
}

//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;
}
}
86 changes: 67 additions & 19 deletions src/components/context-menu/ContextMenu.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -23,28 +31,51 @@ 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<ContextMenuProps> = (props) => {
return <Radix.ContextMenu {...mergeComponentProps(`context-menu`, props) as ContextMenuProps}/>
const [open, onOpenChange] = useObservedOpen(undefined, undefined, props.onOpenChange)

return <ContextMenuOpenContext.Provider value={open}>
<Radix.ContextMenu {...mergeComponentProps(`context-menu`, props) as ContextMenuProps} onOpenChange={onOpenChange}/>
</ContextMenuOpenContext.Provider>
}

export const ContextMenuTrigger: React.FC<ContextMenuTriggerProps> = (props) => {
return <Radix.ContextMenuTrigger {...mergeComponentProps("context-menu__trigger", props) as ContextMenuTriggerProps}/>
}

export const ContextMenuPortal: React.FC<ContextMenuPortalProps> = (props) => {
return <Radix.ContextMenuPortal {...mergeComponentProps("context-menu__portal", props) as ContextMenuPortalProps}/>
//force mounted so the portal outlives the menu's exit animation
return <Radix.ContextMenuPortal forceMount {...mergeComponentProps("context-menu__portal", props) as ContextMenuPortalProps}/>
}

export const ContextMenuContent: React.FC<ContextMenuContentProps> = (props) => {
const {children, ...rest} = props
return <Radix.ContextMenuContent
align={props.align} {...mergeComponentProps(`context-menu__content context-menu__content--${props.color ?? "primary"}`, rest) as ContextMenuContentProps}>
<AutoScrollArea>
<div className={"context-menu__content-inner"}>
{children}
</div>
</AutoScrollArea>
</Radix.ContextMenuContent>
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 <AnimatePresence initial={!openOnMount.current}>
{open && <Radix.ContextMenuContent
key={"context-menu__content"}
forceMount
asChild
{...mergeComponentProps(`context-menu__content context-menu__content--${props.color ?? "primary"}`, rest) as ContextMenuContentProps}>
<motion.div {...motionProps} style={popperTransformOrigin("context-menu")}>
<AutoScrollArea>
<div className={"context-menu__content-inner"}>
{children}
</div>
</AutoScrollArea>
</motion.div>
</Radix.ContextMenuContent>}
</AnimatePresence>
}

export const ContextMenuLabel: React.FC<ContextMenuLabelProps> = (props) => {
Expand All @@ -60,7 +91,11 @@ export const ContextMenuGroup: React.FC<ContextMenuGroupProps> = (props) => {
}

export const ContextMenuSub: React.FC<ContextMenuSubProps> = (props) => {
return <Radix.ContextMenuSub {...mergeComponentProps("context-menu__sub", props) as ContextMenuSubProps}/>
const [open, onOpenChange] = useObservedOpen(props.open, props.defaultOpen, props.onOpenChange)

return <ContextMenuSubOpenContext.Provider value={open}>
<Radix.ContextMenuSub {...mergeComponentProps("context-menu__sub", props) as ContextMenuSubProps} onOpenChange={onOpenChange}/>
</ContextMenuSubOpenContext.Provider>
}

export const ContextMenuSubTrigger: React.FC<ContextMenuSubTriggerProps> = (props) => {
Expand All @@ -69,13 +104,26 @@ export const ContextMenuSubTrigger: React.FC<ContextMenuSubTriggerProps> = (prop

export const ContextMenuSubContent: React.FC<ContextMenuSubContentProps> = (props) => {
const {children, ...rest} = props
return <Radix.ContextMenuSubContent align={props.align} {...mergeComponentProps(`context-menu__sub-content context-menu__sub-content--${props.color ?? "primary"}`, rest) as ContextMenuSubContentProps}>
<AutoScrollArea>
<div className={"context-menu__content-inner"}>
{children}
</div>
</AutoScrollArea>
</Radix.ContextMenuSubContent>
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 <AnimatePresence initial={!openOnMount.current}>
{open && <Radix.ContextMenuSubContent
key={"context-menu__sub-content"}
forceMount
asChild
{...mergeComponentProps(`context-menu__sub-content context-menu__sub-content--${props.color ?? "primary"}`, rest) as ContextMenuSubContentProps}>
<motion.div {...motionProps} style={popperTransformOrigin("context-menu")}>
<AutoScrollArea>
<div className={"context-menu__content-inner"}>
{children}
</div>
</AutoScrollArea>
</motion.div>
</Radix.ContextMenuSubContent>}
</AnimatePresence>
}

export const ContextMenuSeparator: React.FC<ContextMenuSeparatorProps> = (props) => {
Expand Down
30 changes: 26 additions & 4 deletions src/components/dialog/Dialog.style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -82,4 +94,14 @@
justify-content: flex-end;
}
}
}
}

//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;
}
}
78 changes: 61 additions & 17 deletions src/components/dialog/Dialog.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -25,41 +33,77 @@ export type DialogStickyContentProps = Component<HTMLDivElement> & {
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<DialogProps> = (props) => {
return <DialogPrimitive.Root {...mergeComponentProps("dialog", props) as DialogProps}/>
const [open, onOpenChange] = useObservedOpen(props.open, props.defaultOpen, props.onOpenChange)

return <DialogOpenContext.Provider value={open}>
<DialogPrimitive.Root {...mergeComponentProps("dialog", props) as DialogProps} onOpenChange={onOpenChange}/>
</DialogOpenContext.Provider>
}

export const DialogTrigger: React.FC<DialogTriggerProps> = (props) => {
return <DialogPrimitive.Trigger {...mergeComponentProps("dialog__trigger", props) as DialogTriggerProps}/>
}

export const DialogPortal: React.FC<DialogPortalProps> = (props) => {
return <DialogPrimitive.Portal {...mergeComponentProps("dialog__portal", props) as DialogPortalProps}/>
//force mounted so the portal outlives the dialog's exit animation
return <DialogPrimitive.Portal forceMount {...mergeComponentProps("dialog__portal", props) as DialogPortalProps}/>
}

export const DialogClose: React.FC<DialogCloseProps> = (props) => {
return <DialogPrimitive.Close {...mergeComponentProps("dialog__close", props) as DialogCloseProps}/>
}

export const DialogOverlay: React.FC<DialogOverlayProps> = (props) => {
return <DialogPrimitive.Overlay {...mergeComponentProps("dialog__overlay", props) as DialogOverlayProps}/>
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 <AnimatePresence initial={!openOnMount.current}>
{open && <DialogPrimitive.Overlay
key={"dialog__overlay"}
forceMount
asChild
{...mergeComponentProps("dialog__overlay", props) as DialogOverlayProps}>
<motion.div {...motionProps}/>
</DialogPrimitive.Overlay>}
</AnimatePresence>
}

export const DialogContent: React.FC<DialogContentProps> = (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 (
<DialogPrimitive.Content {...mergeComponentProps("dialog__content", props) as DialogContentProps}>
{props.showCloseButton && (
<Flex align={"center"} justify={"space-between"}>
<Text hierarchy={"primary"} size={"xl"}>{props.title}</Text>
<DialogClose asChild>
<Button variant={"none"} color={"tertiary"}>
<IconX size={16}/>
</Button>
</DialogClose>
</Flex>
)}
{props.children}
</DialogPrimitive.Content>
<AnimatePresence initial={!openOnMount.current}>
{open && <DialogPrimitive.Content
key={"dialog__content"}
forceMount
asChild
{...mergeComponentProps("dialog__content", props) as DialogContentProps}>
<motion.div {...motionProps}>
{props.showCloseButton && (
<Flex align={"center"} justify={"space-between"}>
<Text hierarchy={"primary"} size={"xl"}>{props.title}</Text>
<DialogClose asChild>
<Button variant={"none"} color={"tertiary"}>
<IconX size={16}/>
</Button>
</DialogClose>
</Flex>
)}
{props.children}
</motion.div>
</DialogPrimitive.Content>}
</AnimatePresence>
)
}

Expand Down
Loading
Loading