From 45add6584392ca997c2d7d62bf38c1fb3eca149d Mon Sep 17 00:00:00 2001 From: Micah Snyder Date: Tue, 14 Jan 2025 14:08:15 -0800 Subject: [PATCH 01/13] Temp commit: Initial floating checklist struts --- .../stories/Checklist/Floating.stories.tsx | 21 +++ .../src/components/Checklist/Floating.tsx | 173 ++++++++++++++++++ .../react/src/components/Checklist/index.tsx | 1 + 3 files changed, 195 insertions(+) create mode 100644 apps/smithy/src/stories/Checklist/Floating.stories.tsx create mode 100644 packages/react/src/components/Checklist/Floating.tsx diff --git a/apps/smithy/src/stories/Checklist/Floating.stories.tsx b/apps/smithy/src/stories/Checklist/Floating.stories.tsx new file mode 100644 index 00000000..4355942c --- /dev/null +++ b/apps/smithy/src/stories/Checklist/Floating.stories.tsx @@ -0,0 +1,21 @@ +import { Box, Button, Checklist } from "@frigade/react"; +import { type Meta } from "@storybook/react"; + +export default { + title: "Components/Checklist/Floating", + component: Checklist.Floating, + decorators: [ + (Story) => ( + + + + ), + ], +} as Meta; + +export const Default = { + args: { + children: , + flowId: "flow_K2dmIlteW8eGbGoo", + }, +}; diff --git a/packages/react/src/components/Checklist/Floating.tsx b/packages/react/src/components/Checklist/Floating.tsx new file mode 100644 index 00000000..1e4f2911 --- /dev/null +++ b/packages/react/src/components/Checklist/Floating.tsx @@ -0,0 +1,173 @@ +import { + autoUpdate, + flip, + offset, + type Placement, + shift, + useClick, + useDismiss, + useFloating, + type UseFloatingOptions, + type UseFloatingReturn, + useInteractions, + type UseInteractionsReturn, + useRole, + useTransitionStatus, +} from '@floating-ui/react' +import { createContext, type Dispatch, type SetStateAction, useContext, useState } from 'react' + +import { Box } from '@/components/Box' +import { Flow } from '@/components/Flow' + +export interface FloatingChecklistContextValue { + isOpen: boolean + setIsOpen: Dispatch> +} + +const FloatingChecklistContext = createContext({ + isOpen: false, + setIsOpen: () => {}, +}) + +export type AlignValue = 'after' | 'before' | 'center' | 'end' | 'start' + +function getOriginalAlign(align: AlignValue) { + switch (align) { + case 'after': + return 'end' + break + case 'before': + return 'start' + break + default: + return align + } +} + +export function Floating({ + align = 'center', + alignOffset = 0, + children, + css, + defaultOpen = false, + flowId, + onOpenChange = () => {}, + open, + part, + side = 'bottom', + sideOffset = 0, + style, + ...props +}) { + const [internalOpen, setInternalOpen] = useState(defaultOpen) + + const canonicalOpen = open ?? internalOpen + + const placement = `${side}-${getOriginalAlign(align)}` as Placement + + function offsetMiddleware({ rects }) { + const offsets = { + alignmentAxis: alignOffset, + mainAxis: sideOffset, + } + + // if align is before or after + if (['after', 'before'].includes(align)) { + // if side is bottom or top + if (['bottom', 'top'].includes(side)) { + // hOffset + offsets.alignmentAxis = alignOffset - rects.floating.width + } else { + // vOffset + offsets.alignmentAxis = alignOffset - rects.floating.height + } + } + + return offsets + } + + const { + context, + floatingStyles, + // placement: computedPlacement, + refs, + } = useFloating({ + middleware: [offset(offsetMiddleware, [align, alignOffset, side, sideOffset]), flip(), shift()], + onOpenChange, + open: canonicalOpen, + placement, + whileElementsMounted: autoUpdate, + }) + + const click = useClick(context) + const dismiss = useDismiss(context, { + outsidePress: false, + }) + const role = useRole(context) + const status = useTransitionStatus(context) + + // Merge all the interactions into prop getters + const { getFloatingProps, getReferenceProps } = useInteractions([click, dismiss, role]) + + return ( + + {({ flow }) => { + return ( + + + {children} + + + {Array.from(flow.steps.values()).map((step) => ( + + {step.title} + + ))} + + + ) + }} + + ) +} + +function FloatingTrigger({ children, triggerRef }) { + const { setIsOpen } = useContext(FloatingChecklistContext) + return ( + setIsOpen((prev) => !prev)}> + {children} + + ) +} diff --git a/packages/react/src/components/Checklist/index.tsx b/packages/react/src/components/Checklist/index.tsx index 31f4fd3f..bdb192a4 100644 --- a/packages/react/src/components/Checklist/index.tsx +++ b/packages/react/src/components/Checklist/index.tsx @@ -2,3 +2,4 @@ export { Carousel } from './Carousel' export { Collapsible, type CollapsibleProps, type CollapsibleStepProps } from './Collapsible' // eslint-disable-next-line react-refresh/only-export-components export * as CollapsibleStep from './CollapsibleStep' +export { Floating } from './Floating' From 2863d00173bd3c4b8955ded76400e7eefcfc1111 Mon Sep 17 00:00:00 2001 From: Micah Snyder Date: Thu, 16 Jan 2025 15:14:53 -0800 Subject: [PATCH 02/13] Return full useFloating object --- packages/react/src/components/Hint/useFloatingHint.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/react/src/components/Hint/useFloatingHint.ts b/packages/react/src/components/Hint/useFloatingHint.ts index ee6621dc..775cbd04 100644 --- a/packages/react/src/components/Hint/useFloatingHint.ts +++ b/packages/react/src/components/Hint/useFloatingHint.ts @@ -26,7 +26,7 @@ export interface FloatingHintProps extends HintProps { open: boolean } -export interface FloatingHintReturn extends Partial> { +export interface FloatingHintReturn extends Omit { placement: ExtendedPlacement getFloatingProps: UseInteractionsReturn['getFloatingProps'] getReferenceProps: UseInteractionsReturn['getReferenceProps'] @@ -83,6 +83,7 @@ export function useFloatingHint({ floatingStyles, placement: computedPlacement, refs, + ...floatingReturn } = useFloating({ middleware: [offset(offsetMiddleware, [align, alignOffset, side, sideOffset]), flip(), shift()], onOpenChange, @@ -127,5 +128,6 @@ export function useFloatingHint({ placement: finalPlacement.join('-') as ExtendedPlacement, refs, status, + ...floatingReturn, } } From 6bb96123091d5ba83ea31008201b9e77af144459 Mon Sep 17 00:00:00 2001 From: Micah Snyder Date: Mon, 20 Jan 2025 17:25:48 -0800 Subject: [PATCH 03/13] Rework useFloating to allow for nested Popovers, also make a Popover component, also build out Floating Checklist with nested Popovers. --- .../src/components/Checklist/Floating.tsx | 199 ++++------------ .../src/components/Checklist/FloatingStep.tsx | 84 +++++++ packages/react/src/components/Hint/index.tsx | 4 +- .../react/src/components/Popover/Popover.tsx | 216 ++++++++++++++++++ .../react/src/components/Popover/index.tsx | 11 + .../useFloating.ts} | 28 ++- 6 files changed, 374 insertions(+), 168 deletions(-) create mode 100644 packages/react/src/components/Checklist/FloatingStep.tsx create mode 100644 packages/react/src/components/Popover/Popover.tsx create mode 100644 packages/react/src/components/Popover/index.tsx rename packages/react/src/{components/Hint/useFloatingHint.ts => hooks/useFloating.ts} (85%) diff --git a/packages/react/src/components/Checklist/Floating.tsx b/packages/react/src/components/Checklist/Floating.tsx index 1e4f2911..eefec6fb 100644 --- a/packages/react/src/components/Checklist/Floating.tsx +++ b/packages/react/src/components/Checklist/Floating.tsx @@ -1,173 +1,60 @@ -import { - autoUpdate, - flip, - offset, - type Placement, - shift, - useClick, - useDismiss, - useFloating, - type UseFloatingOptions, - type UseFloatingReturn, - useInteractions, - type UseInteractionsReturn, - useRole, - useTransitionStatus, -} from '@floating-ui/react' -import { createContext, type Dispatch, type SetStateAction, useContext, useState } from 'react' +import { useState } from 'react' +import { FloatingTree } from '@floating-ui/react' -import { Box } from '@/components/Box' -import { Flow } from '@/components/Flow' +import { Flow, type FlowPropsWithoutChildren } from '@/components/Flow' +import * as Popover from '@/components/Popover' -export interface FloatingChecklistContextValue { - isOpen: boolean - setIsOpen: Dispatch> -} - -const FloatingChecklistContext = createContext({ - isOpen: false, - setIsOpen: () => {}, -}) - -export type AlignValue = 'after' | 'before' | 'center' | 'end' | 'start' +import { FloatingStep } from '@/components/Checklist/FloatingStep' -function getOriginalAlign(align: AlignValue) { - switch (align) { - case 'after': - return 'end' - break - case 'before': - return 'start' - break - default: - return align - } -} +export interface FloatingChecklistProps + extends Popover.PopoverRootProps, + FlowPropsWithoutChildren {} -export function Floating({ - align = 'center', - alignOffset = 0, - children, - css, - defaultOpen = false, - flowId, - onOpenChange = () => {}, - open, - part, - side = 'bottom', - sideOffset = 0, - style, - ...props -}) { - const [internalOpen, setInternalOpen] = useState(defaultOpen) - - const canonicalOpen = open ?? internalOpen - - const placement = `${side}-${getOriginalAlign(align)}` as Placement - - function offsetMiddleware({ rects }) { - const offsets = { - alignmentAxis: alignOffset, - mainAxis: sideOffset, - } +// TODO: Fix props here (split popover and flow props and pass them to Flow / Popover.Root) +export function Floating({ children, onPrimary, onSecondary, ...props }: FloatingChecklistProps) { + const [openStepId, setOpenStepId] = useState(null) - // if align is before or after - if (['after', 'before'].includes(align)) { - // if side is bottom or top - if (['bottom', 'top'].includes(side)) { - // hOffset - offsets.alignmentAxis = alignOffset - rects.floating.width - } else { - // vOffset - offsets.alignmentAxis = alignOffset - rects.floating.height - } + function resetOpenStepOnClose(isOpen: boolean) { + if (!isOpen && openStepId != null) { + setOpenStepId(null) } - - return offsets } - const { - context, - floatingStyles, - // placement: computedPlacement, - refs, - } = useFloating({ - middleware: [offset(offsetMiddleware, [align, alignOffset, side, sideOffset]), flip(), shift()], - onOpenChange, - open: canonicalOpen, - placement, - whileElementsMounted: autoUpdate, - }) - - const click = useClick(context) - const dismiss = useDismiss(context, { - outsidePress: false, - }) - const role = useRole(context) - const status = useTransitionStatus(context) - - // Merge all the interactions into prop getters - const { getFloatingProps, getReferenceProps } = useInteractions([click, dismiss, role]) - return ( - + {({ flow }) => { return ( - - - {children} - - + - {Array.from(flow.steps.values()).map((step) => ( - - {step.title} - - ))} - - + + {children} + + + + {Array.from(flow.steps.values()).map((step) => ( + + ))} + + + ) }} ) } - -function FloatingTrigger({ children, triggerRef }) { - const { setIsOpen } = useContext(FloatingChecklistContext) - return ( - setIsOpen((prev) => !prev)}> - {children} - - ) -} diff --git a/packages/react/src/components/Checklist/FloatingStep.tsx b/packages/react/src/components/Checklist/FloatingStep.tsx new file mode 100644 index 00000000..675298b9 --- /dev/null +++ b/packages/react/src/components/Checklist/FloatingStep.tsx @@ -0,0 +1,84 @@ +import { useMemo, useRef } from 'react' +import * as Popover from '@/components/Popover' + +import { Box } from '@/components/Box' +import { Card } from '@/components/Card' +import { Flex } from '@/components/Flex' +import { getVideoProps } from '@/components/Media/videoProps' + +import { useStepHandlers } from '@/hooks/useStepHandlers' + +// TODO: Type props +export function FloatingStep({ onPrimary, onSecondary, openStepId, setOpenStepId, step }) { + const anchorId = useMemo(() => `floating-checklist-step-${step.id}`, [step.id]) + const anchorPointerEnterTimeout = useRef>() + + const { handlePrimary, handleSecondary } = useStepHandlers(step, { onPrimary, onSecondary }) + + const isStepOpen = openStepId === step.id + + // TODO: Handle tap while open on mobile to close step + function handlePointerEnter() { + clearTimeout(anchorPointerEnterTimeout.current) + + if (!isStepOpen) { + anchorPointerEnterTimeout.current = setTimeout(() => setOpenStepId(step.id), 300) + } + } + + function handlePointerLeave() { + clearTimeout(anchorPointerEnterTimeout.current) + } + + // TODO: set a timeout on pointer leave trigger and cancel it when pointer enters content + + const primaryButtonTitle = step.primaryButton?.title ?? step.primaryButtonTitle + const secondaryButtonTitle = step.secondaryButton?.title ?? step.secondaryButtonTitle + + const { videoProps } = getVideoProps(step.props ?? {}) + + // TODO: Steps have no visual state indicator (and button aren't disabled when completed) + + return ( + <> + + {step.title} + + + + + + + + + + + + + + + ) +} diff --git a/packages/react/src/components/Hint/index.tsx b/packages/react/src/components/Hint/index.tsx index d0897e9f..56c6fe34 100644 --- a/packages/react/src/components/Hint/index.tsx +++ b/packages/react/src/components/Hint/index.tsx @@ -6,7 +6,7 @@ import { Ping } from '@/components/Ping' import { Spotlight } from '@/components/Spotlight' import { getPingPosition } from '@/components/Hint/getPingPosition' -import { useFloatingHint } from '@/components/Hint/useFloatingHint' +import { useFloating } from '@/hooks/useFloating' import { useVisibility } from '@/hooks/useVisibility' export type AlignValue = 'after' | 'before' | 'center' | 'end' | 'start' @@ -57,7 +57,7 @@ export function Hint({ const canonicalOpen = open ?? internalOpen const { getFloatingProps, getReferenceProps, floatingStyles, placement, refs, status } = - useFloatingHint({ + useFloating({ align, alignOffset, anchor, diff --git a/packages/react/src/components/Popover/Popover.tsx b/packages/react/src/components/Popover/Popover.tsx new file mode 100644 index 00000000..3157981f --- /dev/null +++ b/packages/react/src/components/Popover/Popover.tsx @@ -0,0 +1,216 @@ +import { + createContext, + type Dispatch, + type SetStateAction, + useContext, + useEffect, + useState, +} from 'react' + +import { FloatingNode, useFloatingNodeId } from '@floating-ui/react' + +import { Box, type BoxProps } from '@/components/Box' +import { Overlay } from '@/components/Overlay' +import { Spotlight } from '@/components/Spotlight' + +import { type FloatingReturn, useFloating } from '@/hooks/useFloating' +import { useVisibility } from '@/hooks/useVisibility' + +export type AlignValue = 'after' | 'before' | 'center' | 'end' | 'start' +export type SideValue = 'bottom' | 'left' | 'right' | 'top' +export type ExtendedPlacement = `${SideValue}-${AlignValue}` + +export interface PopoverContextValue { + floating?: FloatingReturn + floatingNodeId: string | null + isOpen: boolean + setIsOpen: Dispatch> +} + +const PopoverContext = createContext({ + floatingNodeId: null, + isOpen: false, + setIsOpen: () => {}, +}) + +// TODO: Extend this off of useFloating +export interface PopoverRootProps { + align?: AlignValue + alignOffset?: number + anchor: string + autoScroll?: ScrollIntoViewOptions | boolean + children?: React.ReactNode + defaultOpen?: boolean + modal?: boolean + onOpenChange?: (open: boolean) => void + open?: boolean + side?: SideValue + sideOffset?: number + spotlight?: boolean +} + +export interface PopoverContentProps extends BoxProps {} + +export interface PopoverTriggerProps extends BoxProps {} + +export function Root({ + align = 'center', + alignOffset = 0, + anchor, + autoScroll = false, + children, + defaultOpen = false, + modal = false, + onOpenChange = () => {}, + open, + side = 'bottom', + sideOffset = 0, + spotlight = false, +}: PopoverRootProps) { + const [internalOpen, setInternalOpen] = useState(defaultOpen) + const [scrollComplete, setScrollComplete] = useState(false) + + // Defer to controlled open prop, otherwise manage open state internally + const canonicalOpen = open ?? internalOpen + + const floatingNodeId = useFloatingNodeId() + + const floating = useFloating({ + align, + alignOffset, + anchor, + nodeId: floatingNodeId, + onOpenChange: (newOpen) => { + onOpenChange(newOpen) + + if (open == null) { + setInternalOpen(newOpen) + } + }, + open: canonicalOpen, + side, + sideOffset, + }) + + const { refs } = floating + + // const [finalSide, finalAlign] = placement.split('-') + // const referenceProps = getReferenceProps() + + // TODO: Split this out to useAutoScroll hook + useEffect(() => { + if (!scrollComplete && autoScroll && refs.reference.current instanceof Element) { + const scrollOptions: ScrollIntoViewOptions = + typeof autoScroll !== 'boolean' ? autoScroll : { behavior: 'smooth', block: 'center' } + + /* + * NOTE: "scrollend" event isn't supported widely enough yet :( + * + * We'll listen to a capture-phase "scroll" instead, and when it stops + * bouncing, we can infer that the scroll we initiated is over. + */ + let scrollTimeout: ReturnType + window.addEventListener( + 'scroll', + function scrollHandler() { + clearTimeout(scrollTimeout) + + scrollTimeout = setTimeout(() => { + window.removeEventListener('scroll', scrollHandler) + setScrollComplete(true) + }, 100) + }, + true + ) + + refs.reference.current.scrollIntoView(scrollOptions) + } else if (!autoScroll) { + setScrollComplete(true) + } + }, [autoScroll, refs.reference, scrollComplete]) + + // TODO: Generalize / dedupe Spotlight and Overlay throughout SDK + return ( + + {spotlight && canonicalOpen && } + {modal && !spotlight && canonicalOpen && } + + {children} + + ) +} + +export function Content({ children, css, part, style, ...props }: BoxProps) { + const { floating, floatingNodeId, isOpen } = useContext(PopoverContext) + + const { isVisible: isAnchorVisible } = useVisibility( + floating?.refs.reference.current as Element | null + ) + + if (floating == null) { + return null + } + + const { floatingStyles, getFloatingProps, placement, refs, status } = floating + + if (refs.reference.current == null || !isAnchorVisible) { + return null + } + + return ( + + {isOpen && ( + + {children} + + )} + + ) +} + +export function Trigger({ children, ...props }: BoxProps) { + const { + floating: { getReferenceProps, refs }, + setIsOpen, + } = useContext(PopoverContext) + + return ( + setIsOpen((prev) => !prev)} + {...props} + {...(getReferenceProps?.() ?? {})} + > + {children} + + ) +} diff --git a/packages/react/src/components/Popover/index.tsx b/packages/react/src/components/Popover/index.tsx new file mode 100644 index 00000000..a1c5b74a --- /dev/null +++ b/packages/react/src/components/Popover/index.tsx @@ -0,0 +1,11 @@ +export { + type AlignValue, + Content, + type ExtendedPlacement, + Root, + type PopoverContentProps, + type PopoverRootProps, + type PopoverTriggerProps, + type SideValue, + Trigger, +} from './Popover' diff --git a/packages/react/src/components/Hint/useFloatingHint.ts b/packages/react/src/hooks/useFloating.ts similarity index 85% rename from packages/react/src/components/Hint/useFloatingHint.ts rename to packages/react/src/hooks/useFloating.ts index 775cbd04..7e0590bf 100644 --- a/packages/react/src/components/Hint/useFloatingHint.ts +++ b/packages/react/src/hooks/useFloating.ts @@ -8,7 +8,7 @@ import { shift, useClick, useDismiss, - useFloating, + useFloating as useFloatingUI, type UseFloatingOptions, type UseFloatingReturn, useInteractions, @@ -21,12 +21,14 @@ import type { AlignValue, ExtendedPlacement, HintProps } from '@/components/Hint import { useMutationAwareAnchor } from '@/components/Hint/useMutationAwareAnchor' -export interface FloatingHintProps extends HintProps { +// TODO: Fix these props +export interface FloatingProps extends HintProps { + hover?: boolean onOpenChange?: UseFloatingOptions['onOpenChange'] open: boolean } -export interface FloatingHintReturn extends Omit { +export interface FloatingReturn extends Omit { placement: ExtendedPlacement getFloatingProps: UseInteractionsReturn['getFloatingProps'] getReferenceProps: UseInteractionsReturn['getReferenceProps'] @@ -46,15 +48,16 @@ function getOriginalAlign(align: AlignValue) { } } -export function useFloatingHint({ +export function useFloating({ align, alignOffset, anchor, + nodeId, onOpenChange = () => {}, open, side, sideOffset, -}: FloatingHintProps): FloatingHintReturn { +}: FloatingProps): FloatingReturn { const placement = `${side}-${getOriginalAlign(align)}` as Placement function offsetMiddleware({ rects }) { @@ -84,23 +87,28 @@ export function useFloatingHint({ placement: computedPlacement, refs, ...floatingReturn - } = useFloating({ + } = useFloatingUI({ middleware: [offset(offsetMiddleware, [align, alignOffset, side, sideOffset]), flip(), shift()], + nodeId, onOpenChange, open, placement, whileElementsMounted: autoUpdate, }) - const click = useClick(context) - const dismiss = useDismiss(context, { + const clickHandler = useClick(context) + const dismissHandler = useDismiss(context, { outsidePress: false, }) - const role = useRole(context) + const roleProps = useRole(context) const status = useTransitionStatus(context) // Merge all the interactions into prop getters - const { getFloatingProps, getReferenceProps } = useInteractions([click, dismiss, role]) + const { getFloatingProps, getReferenceProps } = useInteractions([ + clickHandler, + dismissHandler, + roleProps, + ]) const { anchorElement } = useMutationAwareAnchor(anchor) From d0caed2674ac255ce3809790cdc601eca9e609dc Mon Sep 17 00:00:00 2001 From: Micah Snyder Date: Tue, 21 Jan 2025 12:28:48 -0800 Subject: [PATCH 04/13] Temp commit: Mucking around with animation containers --- .../react/src/components/Popover/Popover.tsx | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/react/src/components/Popover/Popover.tsx b/packages/react/src/components/Popover/Popover.tsx index 3157981f..25dc9f82 100644 --- a/packages/react/src/components/Popover/Popover.tsx +++ b/packages/react/src/components/Popover/Popover.tsx @@ -164,6 +164,9 @@ export function Content({ children, css, part, style, ...props }: BoxProps) { return null } + console.log('FLOATIES: ', floatingStyles) + + // TODO: Should Popover animate on its own? Should it detect side/align and set transform-origin accordingly? return ( {isOpen && ( @@ -171,11 +174,22 @@ export function Content({ children, css, part, style, ...props }: BoxProps) { css={{ opacity: 1, transitionProperty: 'opacity', - '&[data-status="initial"],&[data-status="close"]': { - opacity: 0, + transformOrigin: 'left', + '&[data-status="initial"], &[data-status="close"]': { + opacity: 0.6, + }, + '&[data-status="open"], &[data-status="close"]': { + transition: 'transform 0.2s ease-out, opacity 0.2s ease-out', + }, + '&[data-status="initial"] .fr-popover-transition-container, &[data-status="close"] .fr-popover-transition-container': + { + transform: 'scale(0.1)', + }, + '&[data-status="open"] .fr-popover-transition-container': { + transform: 'scale(1)', }, - '&[data-status="open"],&[data-status="close"]': { - transition: 'transform 0.2s ease-out', + '& .fr-popover-transition-container': { + transition: 'transform 1s ease-out', }, ...css, }} @@ -190,7 +204,7 @@ export function Content({ children, css, part, style, ...props }: BoxProps) { {...getFloatingProps()} {...props} > - {children} + {children} )} From 574b6c23ac01a4fe8729edac3a525376ac9489d4 Mon Sep 17 00:00:00 2001 From: Micah Snyder Date: Thu, 23 Jan 2025 15:16:57 -0800 Subject: [PATCH 05/13] Some Floating style fixes --- .../src/components/Checklist/Floating.tsx | 13 ++- .../src/components/Checklist/FloatingStep.tsx | 50 ++++++----- .../react/src/components/Popover/Popover.tsx | 89 +++++++++++-------- 3 files changed, 91 insertions(+), 61 deletions(-) diff --git a/packages/react/src/components/Checklist/Floating.tsx b/packages/react/src/components/Checklist/Floating.tsx index eefec6fb..f3c05189 100644 --- a/packages/react/src/components/Checklist/Floating.tsx +++ b/packages/react/src/components/Checklist/Floating.tsx @@ -1,8 +1,12 @@ import { useState } from 'react' import { FloatingTree } from '@floating-ui/react' +import { Box } from '@/components/Box' +import { Flex } from '@/components/Flex' import { Flow, type FlowPropsWithoutChildren } from '@/components/Flow' import * as Popover from '@/components/Popover' +import * as Progress from '@/components/Progress' +import { Text } from '@/components/Text' import { FloatingStep } from '@/components/Checklist/FloatingStep' @@ -23,6 +27,9 @@ export function Floating({ children, onPrimary, onSecondary, ...props }: Floatin return ( {({ flow }) => { + const currentSteps = flow.getNumberOfCompletedSteps() + const availableSteps = flow.getNumberOfAvailableSteps() + return ( + + {flow.title} + + {Array.from(flow.steps.values()).map((step) => ( - {step.title} - + - - + + + - + - - - - + + + + + diff --git a/packages/react/src/components/Popover/Popover.tsx b/packages/react/src/components/Popover/Popover.tsx index 25dc9f82..d727b0c5 100644 --- a/packages/react/src/components/Popover/Popover.tsx +++ b/packages/react/src/components/Popover/Popover.tsx @@ -148,7 +148,7 @@ export function Root({ } export function Content({ children, css, part, style, ...props }: BoxProps) { - const { floating, floatingNodeId, isOpen } = useContext(PopoverContext) + const { floating, floatingNodeId } = useContext(PopoverContext) const { isVisible: isAnchorVisible } = useVisibility( floating?.refs.reference.current as Element | null @@ -164,49 +164,60 @@ export function Content({ children, css, part, style, ...props }: BoxProps) { return null } - console.log('FLOATIES: ', floatingStyles) - // TODO: Should Popover animate on its own? Should it detect side/align and set transform-origin accordingly? return ( - {isOpen && ( - - {children} - - )} + }, + '&[data-status="unmounted"]': { + display: 'none', + }, + '&[data-status="initial"]': { + opacity: 0.8, + }, + '&[data-status="open"], &[data-status="close"]': { + transition: 'transform 0.2s ease-out, opacity 0.2s ease-out', + }, + '&[data-status="initial"] .fr-popover-transition-container': { + transform: 'scale(0.8)', + }, + '&[data-status="close"] .fr-popover-transition-container': { + transform: 'scale(0.3)', + }, + '&[data-status="open"] .fr-popover-transition-container': { + transform: 'scale(1)', + }, + '& .fr-popover-transition-container': { + transformOrigin: 'left', + transition: 'transform 0.2s ease-out', + }, + ...css, + }} + data-placement={placement} + data-status={status.status} + part={['popover-content', part]} + ref={refs.setFloating} + style={{ + ...floatingStyles, + ...style, + }} + {...getFloatingProps()} + {...props} + > + {children} + ) } From 04b04ede5106b18d6672ea94c52e78da0b65be5a Mon Sep 17 00:00:00 2001 From: Micah Snyder Date: Mon, 27 Jan 2025 11:07:50 -0800 Subject: [PATCH 06/13] Handle closing interactions, add checkmark --- .../src/components/CheckIndicator/index.tsx | 7 +-- .../src/components/Checklist/Floating.tsx | 26 +++++++---- .../src/components/Checklist/FloatingStep.tsx | 46 ++++++++++++++----- .../react/src/components/Popover/Popover.tsx | 1 + packages/react/src/hooks/useFloating.ts | 3 ++ 5 files changed, 60 insertions(+), 23 deletions(-) diff --git a/packages/react/src/components/CheckIndicator/index.tsx b/packages/react/src/components/CheckIndicator/index.tsx index a30145da..b6e8b0d4 100644 --- a/packages/react/src/components/CheckIndicator/index.tsx +++ b/packages/react/src/components/CheckIndicator/index.tsx @@ -24,9 +24,10 @@ function CheckIcon() { interface CheckIndicatorProps extends BoxProps { checked?: boolean + size?: string } -export function CheckIndicator({ checked = false, ...props }: CheckIndicatorProps) { +export function CheckIndicator({ checked = false, size = '22px', ...props }: CheckIndicatorProps) { return ( {checked && ( diff --git a/packages/react/src/components/Checklist/Floating.tsx b/packages/react/src/components/Checklist/Floating.tsx index f3c05189..fdf53c3b 100644 --- a/packages/react/src/components/Checklist/Floating.tsx +++ b/packages/react/src/components/Checklist/Floating.tsx @@ -1,7 +1,6 @@ -import { useState } from 'react' +import { useRef, useState } from 'react' import { FloatingTree } from '@floating-ui/react' -import { Box } from '@/components/Box' import { Flex } from '@/components/Flex' import { Flow, type FlowPropsWithoutChildren } from '@/components/Flow' import * as Popover from '@/components/Popover' @@ -17,8 +16,21 @@ export interface FloatingChecklistProps // TODO: Fix props here (split popover and flow props and pass them to Flow / Popover.Root) export function Floating({ children, onPrimary, onSecondary, ...props }: FloatingChecklistProps) { const [openStepId, setOpenStepId] = useState(null) + const pointerLeaveTimeout = useRef>() - function resetOpenStepOnClose(isOpen: boolean) { + function handlePointerEnter() { + clearTimeout(pointerLeaveTimeout.current) + } + + function handlePointerLeave() { + clearTimeout(pointerLeaveTimeout.current) + + if (openStepId != null) { + pointerLeaveTimeout.current = setTimeout(() => setOpenStepId(null), 300) + } + } + + function resetOpenStep(isOpen: boolean) { if (!isOpen && openStepId != null) { setOpenStepId(null) } @@ -32,11 +44,7 @@ export function Floating({ children, onPrimary, onSecondary, ...props }: Floatin return ( - + {children} @@ -45,6 +53,8 @@ export function Floating({ children, onPrimary, onSecondary, ...props }: Floatin backgroundColor="neutral.background" border="md solid neutral.border" borderRadius="md" + onPointerEnter={handlePointerEnter} + onPointerLeave={handlePointerLeave} padding="1" > diff --git a/packages/react/src/components/Checklist/FloatingStep.tsx b/packages/react/src/components/Checklist/FloatingStep.tsx index c6f31352..d802b773 100644 --- a/packages/react/src/components/Checklist/FloatingStep.tsx +++ b/packages/react/src/components/Checklist/FloatingStep.tsx @@ -1,8 +1,8 @@ import { useMemo, useRef } from 'react' import * as Popover from '@/components/Popover' -import { Box } from '@/components/Box' import { Card } from '@/components/Card' +import { CheckIndicator } from '@/components/CheckIndicator' import { Flex } from '@/components/Flex' import { getVideoProps } from '@/components/Media/videoProps' import { Text } from '@/components/Text' @@ -13,11 +13,26 @@ import { useStepHandlers } from '@/hooks/useStepHandlers' export function FloatingStep({ onPrimary, onSecondary, openStepId, setOpenStepId, step }) { const anchorId = useMemo(() => `floating-checklist-step-${step.id}`, [step.id]) const anchorPointerEnterTimeout = useRef>() - const { handlePrimary, handleSecondary } = useStepHandlers(step, { onPrimary, onSecondary }) const isStepOpen = openStepId === step.id + async function wrappedHandlePrimary(...args: Parameters) { + const primaryReturnValue = await handlePrimary(...args) + + if (primaryReturnValue) { + setOpenStepId(null) + } + } + + async function wrappedHandleSecondary(...args: Parameters) { + const secondaryReturnValue = await handleSecondary(...args) + + if (secondaryReturnValue) { + setOpenStepId(null) + } + } + // TODO: Handle tap while open on mobile to close step function handlePointerEnter() { clearTimeout(anchorPointerEnterTimeout.current) @@ -31,20 +46,18 @@ export function FloatingStep({ onPrimary, onSecondary, openStepId, setOpenStepId clearTimeout(anchorPointerEnterTimeout.current) } - // TODO: set a timeout on pointer leave trigger and cancel it when pointer enters content - const primaryButtonTitle = step.primaryButton?.title ?? step.primaryButtonTitle const secondaryButtonTitle = step.secondaryButton?.title ?? step.secondaryButtonTitle const { videoProps } = getVideoProps(step.props ?? {}) - // TODO: Steps have no visual state indicator (and button aren't disabled when completed) - return ( <> - - {step.title} - + {step.title} + + - - + + diff --git a/packages/react/src/components/Popover/Popover.tsx b/packages/react/src/components/Popover/Popover.tsx index d727b0c5..394a83ce 100644 --- a/packages/react/src/components/Popover/Popover.tsx +++ b/packages/react/src/components/Popover/Popover.tsx @@ -168,6 +168,7 @@ export function Content({ children, css, part, style, ...props }: BoxProps) { return ( Date: Mon, 27 Jan 2025 11:47:36 -0800 Subject: [PATCH 07/13] Fix Floating animation, move animation from Popover to Floating --- .../components/Checklist/Floating.styles.ts | 33 ++++++++++++ .../src/components/Checklist/Floating.tsx | 53 ++++++++++++------- .../src/components/Checklist/FloatingStep.tsx | 9 ++-- .../react/src/components/Popover/Popover.tsx | 31 ----------- 4 files changed, 71 insertions(+), 55 deletions(-) create mode 100644 packages/react/src/components/Checklist/Floating.styles.ts diff --git a/packages/react/src/components/Checklist/Floating.styles.ts b/packages/react/src/components/Checklist/Floating.styles.ts new file mode 100644 index 00000000..677a310b --- /dev/null +++ b/packages/react/src/components/Checklist/Floating.styles.ts @@ -0,0 +1,33 @@ +export const floatingTransitionCSS = { + '&[data-status="open"]': { + opacity: 1, + zIndex: 1, + }, + '&[data-status="close"]': { + opacity: 0, + zIndex: 0, + + '& [data-status="close"]': { + display: 'none', + }, + }, + '&[data-status="initial"]': { + opacity: 0.8, + }, + '&[data-status="open"], &[data-status="close"]': { + transition: 'transform 0.2s ease-out, opacity 0.2s ease-out', + }, + '&[data-status="initial"] .fr-popover-transition-container': { + transform: 'scale(0.8)', + }, + '&[data-status="close"] .fr-popover-transition-container': { + transform: 'scale(0.3)', + }, + '&[data-status="open"] .fr-popover-transition-container': { + transform: 'scale(1)', + }, + '& .fr-popover-transition-container': { + transformOrigin: 'left', + transition: 'transform 0.2s ease-out', + }, +} diff --git a/packages/react/src/components/Checklist/Floating.tsx b/packages/react/src/components/Checklist/Floating.tsx index fdf53c3b..dee04828 100644 --- a/packages/react/src/components/Checklist/Floating.tsx +++ b/packages/react/src/components/Checklist/Floating.tsx @@ -1,6 +1,7 @@ import { useRef, useState } from 'react' import { FloatingTree } from '@floating-ui/react' +import { Card } from '@/components/Card' import { Flex } from '@/components/Flex' import { Flow, type FlowPropsWithoutChildren } from '@/components/Flow' import * as Popover from '@/components/Popover' @@ -8,6 +9,7 @@ import * as Progress from '@/components/Progress' import { Text } from '@/components/Text' import { FloatingStep } from '@/components/Checklist/FloatingStep' +import { floatingTransitionCSS } from '@/components/Checklist/Floating.styles' export interface FloatingChecklistProps extends Popover.PopoverRootProps, @@ -50,27 +52,38 @@ export function Floating({ children, onPrimary, onSecondary, ...props }: Floatin - - {flow.title} - - - {Array.from(flow.steps.values()).map((step) => ( - - ))} + + + {flow.title} + + + {Array.from(flow.steps.values()).map((step) => ( + + ))} + diff --git a/packages/react/src/components/Checklist/FloatingStep.tsx b/packages/react/src/components/Checklist/FloatingStep.tsx index d802b773..8f8739b7 100644 --- a/packages/react/src/components/Checklist/FloatingStep.tsx +++ b/packages/react/src/components/Checklist/FloatingStep.tsx @@ -9,6 +9,8 @@ import { Text } from '@/components/Text' import { useStepHandlers } from '@/hooks/useStepHandlers' +import { floatingTransitionCSS } from '@/components/Checklist/Floating.styles' + // TODO: Type props export function FloatingStep({ onPrimary, onSecondary, openStepId, setOpenStepId, step }) { const anchorId = useMemo(() => `floating-checklist-step-${step.id}`, [step.id]) @@ -77,22 +79,21 @@ export function FloatingStep({ onPrimary, onSecondary, openStepId, setOpenStepId side="right" sideOffset={4} > - + - - + Date: Wed, 29 Jan 2025 15:41:31 -0800 Subject: [PATCH 08/13] Progress.Ring component, add default anchor for Floating --- .../stories/Checklist/Floating.stories.tsx | 4 +- .../src/stories/Progress/Progress.stories.tsx | 29 +++++- .../src/components/Checklist/Floating.tsx | 34 ++++++- .../src/components/Checklist/FloatingStep.tsx | 12 ++- .../react/src/components/Progress/Ring.tsx | 88 +++++++++++++++++++ .../react/src/components/Progress/index.tsx | 1 + 6 files changed, 158 insertions(+), 10 deletions(-) create mode 100644 packages/react/src/components/Progress/Ring.tsx diff --git a/apps/smithy/src/stories/Checklist/Floating.stories.tsx b/apps/smithy/src/stories/Checklist/Floating.stories.tsx index 4355942c..bcdbf073 100644 --- a/apps/smithy/src/stories/Checklist/Floating.stories.tsx +++ b/apps/smithy/src/stories/Checklist/Floating.stories.tsx @@ -6,7 +6,7 @@ export default { component: Checklist.Floating, decorators: [ (Story) => ( - + ), @@ -15,7 +15,7 @@ export default { export const Default = { args: { - children: , + //children: , flowId: "flow_K2dmIlteW8eGbGoo", }, }; diff --git a/apps/smithy/src/stories/Progress/Progress.stories.tsx b/apps/smithy/src/stories/Progress/Progress.stories.tsx index ff75c678..a269ba6f 100644 --- a/apps/smithy/src/stories/Progress/Progress.stories.tsx +++ b/apps/smithy/src/stories/Progress/Progress.stories.tsx @@ -1,4 +1,4 @@ -import { Flex, Progress } from "@frigade/react"; +import { Flex, Progress, Text } from "@frigade/react"; export default { title: "Design System/Progress", @@ -13,9 +13,30 @@ export const Default = { decorators: [ (_, { args }) => ( - - - + + Progress.Bar + + + + Progress.Dots + + + + Progress.Segments + + + + Progress.Ring + + + + + ), ], diff --git a/packages/react/src/components/Checklist/Floating.tsx b/packages/react/src/components/Checklist/Floating.tsx index dee04828..97fe44e5 100644 --- a/packages/react/src/components/Checklist/Floating.tsx +++ b/packages/react/src/components/Checklist/Floating.tsx @@ -44,16 +44,46 @@ export function Floating({ children, onPrimary, onSecondary, ...props }: Floatin const currentSteps = flow.getNumberOfCompletedSteps() const availableSteps = flow.getNumberOfAvailableSteps() + const anchorContent = children ?? ( + + {flow.title} + {' '} + + ) + return ( - + - {children} + {anchorContent} @@ -93,17 +94,24 @@ export function FloatingStep({ onPrimary, onSecondary, openStepId, setOpenStepId css={{ objectFit: 'contain', width: '100%' }} {...videoProps} /> - + diff --git a/packages/react/src/components/Progress/Ring.tsx b/packages/react/src/components/Progress/Ring.tsx new file mode 100644 index 00000000..fb88a319 --- /dev/null +++ b/packages/react/src/components/Progress/Ring.tsx @@ -0,0 +1,88 @@ +import { Box } from '@/components/Box' +import { Text } from '@/components/Text' + +import { theme } from '@/shared/theme' + +import type { ProgressProps } from './ProgressProps' + +export function Ring({ + css, + current, + height = '48px', + showLabel = false, + strokeWidth = '8px', + total, + width = '48px', + ...props +}: ProgressProps) { + if (total == 1) { + return null + } + + const progressPercent = total > 0 ? Math.min(Math.round((current / total) * 100) / 100, 1) : 0 + + // TODO: Configurable size + return ( + + + + {showLabel && ( + + {progressPercent * 100} + + )} + + ) +} diff --git a/packages/react/src/components/Progress/index.tsx b/packages/react/src/components/Progress/index.tsx index 57d3722a..1805da16 100644 --- a/packages/react/src/components/Progress/index.tsx +++ b/packages/react/src/components/Progress/index.tsx @@ -1,5 +1,6 @@ export { Bar } from './Bar' export { Dots } from './Dots' export { Fraction } from './Fraction' +export { Ring } from './Ring' export { Segments } from './Segments' export type { ProgressProps } from './ProgressProps' From 6f86d4f3adae16f895c58578c57f1e3dd6e1bf62 Mon Sep 17 00:00:00 2001 From: Micah Snyder Date: Wed, 29 Jan 2025 18:18:38 -0800 Subject: [PATCH 09/13] Add part props to Floating, fix jank title/progress --- .../stories/Checklist/Floating.stories.tsx | 2 +- .../src/components/Checklist/Floating.tsx | 40 ++++++++++++++----- .../src/components/Checklist/FloatingStep.tsx | 12 ++---- .../react/src/components/Popover/Popover.tsx | 3 +- 4 files changed, 38 insertions(+), 19 deletions(-) diff --git a/apps/smithy/src/stories/Checklist/Floating.stories.tsx b/apps/smithy/src/stories/Checklist/Floating.stories.tsx index bcdbf073..7610dcc7 100644 --- a/apps/smithy/src/stories/Checklist/Floating.stories.tsx +++ b/apps/smithy/src/stories/Checklist/Floating.stories.tsx @@ -1,4 +1,4 @@ -import { Box, Button, Checklist } from "@frigade/react"; +import { Box, Checklist } from "@frigade/react"; import { type Meta } from "@storybook/react"; export default { diff --git a/packages/react/src/components/Checklist/Floating.tsx b/packages/react/src/components/Checklist/Floating.tsx index 97fe44e5..d685542b 100644 --- a/packages/react/src/components/Checklist/Floating.tsx +++ b/packages/react/src/components/Checklist/Floating.tsx @@ -16,7 +16,14 @@ export interface FloatingChecklistProps FlowPropsWithoutChildren {} // TODO: Fix props here (split popover and flow props and pass them to Flow / Popover.Root) -export function Floating({ children, onPrimary, onSecondary, ...props }: FloatingChecklistProps) { +export function Floating({ + children, + flowId, + onPrimary, + onSecondary, + part, + ...props +}: FloatingChecklistProps) { const [openStepId, setOpenStepId] = useState(null) const pointerLeaveTimeout = useRef>() @@ -39,7 +46,7 @@ export function Floating({ children, onPrimary, onSecondary, ...props }: Floatin } return ( - + {({ flow }) => { const currentSteps = flow.getNumberOfCompletedSteps() const availableSteps = flow.getNumberOfAvailableSteps() @@ -53,16 +60,19 @@ export function Floating({ children, onPrimary, onSecondary, ...props }: Floatin cursor="pointer" gap="2" padding="1 2" + part="floating-checklist-anchor" userSelect="none" > - {flow.title} + + {flow.title} + {' '} + /> ) @@ -79,6 +89,7 @@ export function Floating({ children, onPrimary, onSecondary, ...props }: Floatin - - {flow.title} - - + {Array.from(flow.steps.values()).map((step) => ( - {step.title} + {step.title} - + - + setIsOpen((prev) => !prev)} + part={['popover-trigger', part]} {...props} {...(getReferenceProps?.() ?? {})} > From 8c4bdb7f87735e4b0af0b51385fd8fb3d2c4e394 Mon Sep 17 00:00:00 2001 From: Micah Snyder Date: Wed, 29 Jan 2025 18:23:39 -0800 Subject: [PATCH 10/13] Fix transform origin when side is top --- packages/react/src/components/Checklist/Floating.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/react/src/components/Checklist/Floating.tsx b/packages/react/src/components/Checklist/Floating.tsx index d685542b..0041909a 100644 --- a/packages/react/src/components/Checklist/Floating.tsx +++ b/packages/react/src/components/Checklist/Floating.tsx @@ -99,6 +99,9 @@ export function Floating({ transformOrigin: 'top left', transition: 'transform 0.2s ease-out', }, + '&[data-placement^="top"] .fr-popover-transition-container': { + transformOrigin: 'bottom left', + }, }} > Date: Fri, 31 Jan 2025 10:02:05 -0800 Subject: [PATCH 11/13] Remove explicit anchors in Popover, fix hella type issues --- .../src/components/Checklist/Floating.tsx | 11 +-- .../src/components/Checklist/FloatingStep.tsx | 85 +++++++++---------- .../components/Hint/useMutationAwareAnchor.ts | 8 ++ .../react/src/components/Popover/Popover.tsx | 22 +---- packages/react/src/hooks/useFloating.ts | 30 ++++--- 5 files changed, 70 insertions(+), 86 deletions(-) diff --git a/packages/react/src/components/Checklist/Floating.tsx b/packages/react/src/components/Checklist/Floating.tsx index 0041909a..216091f5 100644 --- a/packages/react/src/components/Checklist/Floating.tsx +++ b/packages/react/src/components/Checklist/Floating.tsx @@ -78,15 +78,8 @@ export function Floating({ return ( - - - {anchorContent} - + + {anchorContent} `floating-checklist-step-${step.id}`, [step.id]) const anchorPointerEnterTimeout = useRef>() const { handlePrimary, handleSecondary } = useStepHandlers(step, { onPrimary, onSecondary }) @@ -54,15 +53,15 @@ export function FloatingStep({ onPrimary, onSecondary, openStepId, setOpenStepId const { videoProps } = getVideoProps(step.props ?? {}) return ( - <> - + {step.title} - - - - - + + + + + + + + - - - - - - - - - - + + + + ) } diff --git a/packages/react/src/components/Hint/useMutationAwareAnchor.ts b/packages/react/src/components/Hint/useMutationAwareAnchor.ts index 66ff4bf6..02a2e036 100644 --- a/packages/react/src/components/Hint/useMutationAwareAnchor.ts +++ b/packages/react/src/components/Hint/useMutationAwareAnchor.ts @@ -28,6 +28,10 @@ export function useMutationAwareAnchor(anchor: string) { const [anchorElement, setAnchorElement] = useState(null) useEffect(() => { + if (typeof anchor !== 'string') { + return + } + try { const element = document.querySelector(anchor) @@ -43,6 +47,10 @@ export function useMutationAwareAnchor(anchor: string) { }, [anchor]) useEffect(() => { + if (typeof anchor !== 'string') { + return + } + const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { if (mutation.type !== 'childList') { diff --git a/packages/react/src/components/Popover/Popover.tsx b/packages/react/src/components/Popover/Popover.tsx index 5f005be6..bd635309 100644 --- a/packages/react/src/components/Popover/Popover.tsx +++ b/packages/react/src/components/Popover/Popover.tsx @@ -13,13 +13,9 @@ import { Box, type BoxProps } from '@/components/Box' import { Overlay } from '@/components/Overlay' import { Spotlight } from '@/components/Spotlight' -import { type FloatingReturn, useFloating } from '@/hooks/useFloating' +import { type FloatingProps, type FloatingReturn, useFloating } from '@/hooks/useFloating' import { useVisibility } from '@/hooks/useVisibility' -export type AlignValue = 'after' | 'before' | 'center' | 'end' | 'start' -export type SideValue = 'bottom' | 'left' | 'right' | 'top' -export type ExtendedPlacement = `${SideValue}-${AlignValue}` - export interface PopoverContextValue { floating?: FloatingReturn floatingNodeId: string | null @@ -33,19 +29,11 @@ const PopoverContext = createContext({ setIsOpen: () => {}, }) -// TODO: Extend this off of useFloating -export interface PopoverRootProps { - align?: AlignValue - alignOffset?: number - anchor: string +export interface PopoverRootProps extends FloatingProps { autoScroll?: ScrollIntoViewOptions | boolean children?: React.ReactNode defaultOpen?: boolean modal?: boolean - onOpenChange?: (open: boolean) => void - open?: boolean - side?: SideValue - sideOffset?: number spotlight?: boolean } @@ -66,6 +54,7 @@ export function Root({ side = 'bottom', sideOffset = 0, spotlight = false, + ...floatingProps }: PopoverRootProps) { const [internalOpen, setInternalOpen] = useState(defaultOpen) const [scrollComplete, setScrollComplete] = useState(false) @@ -90,13 +79,11 @@ export function Root({ open: canonicalOpen, side, sideOffset, + ...floatingProps, }) const { refs } = floating - // const [finalSide, finalAlign] = placement.split('-') - // const referenceProps = getReferenceProps() - // TODO: Split this out to useAutoScroll hook useEffect(() => { if (!scrollComplete && autoScroll && refs.reference.current instanceof Element) { @@ -164,7 +151,6 @@ export function Content({ children, css, part, style, ...props }: BoxProps) { return null } - // TODO: Should Popover animate on its own? Should it detect side/align and set transform-origin accordingly? return ( { @@ -61,20 +64,19 @@ export function useFloating({ }: FloatingProps): FloatingReturn { const placement = `${side}-${getOriginalAlign(align)}` as Placement + // Handle our added "after" and "before" alignments function offsetMiddleware({ rects }) { const offsets = { alignmentAxis: alignOffset, mainAxis: sideOffset, } - // if align is before or after if (['after', 'before'].includes(align)) { - // if side is bottom or top if (['bottom', 'top'].includes(side)) { - // hOffset + // Offset horizontally offsets.alignmentAxis = alignOffset - rects.floating.width } else { - // vOffset + // Offset vertically offsets.alignmentAxis = alignOffset - rects.floating.height } } @@ -105,7 +107,6 @@ export function useFloating({ const roleProps = useRole(context) const status = useTransitionStatus(context) - // Merge all the interactions into prop getters const { getFloatingProps, getReferenceProps } = useInteractions([ clickHandler, dismissHandler, @@ -113,6 +114,11 @@ export function useFloating({ roleProps, ]) + /* + * Note: If anchor is passed in as a selector, we'll automatically pass it + * through to refs.setReference If not, we assume that the floating reference + * element is being set manually elsewhere (e.g. Popover.Trigger) + */ const { anchorElement } = useMutationAwareAnchor(anchor) useEffect(() => { From 8f6d5a19935cc474042288da596de0c204a13e91 Mon Sep 17 00:00:00 2001 From: Micah Snyder Date: Fri, 31 Jan 2025 14:35:35 -0800 Subject: [PATCH 12/13] useAutoScroll, Split Popover out into individual files, added Popover.Stories --- .../stories/Checklist/Floating.stories.tsx | 2 +- .../src/stories/Popover/Popover.stories.tsx | 58 +++++ .../src/components/Checklist/Floating.tsx | 1 - .../react/src/components/Popover/Content.tsx | 51 +++++ .../react/src/components/Popover/Popover.tsx | 198 ------------------ .../react/src/components/Popover/Root.tsx | 97 +++++++++ .../react/src/components/Popover/Trigger.tsx | 24 +++ .../react/src/components/Popover/index.tsx | 17 +- packages/react/src/hooks/useAutoScroll.ts | 35 ++++ packages/react/src/index.ts | 5 +- 10 files changed, 276 insertions(+), 212 deletions(-) create mode 100644 apps/smithy/src/stories/Popover/Popover.stories.tsx create mode 100644 packages/react/src/components/Popover/Content.tsx delete mode 100644 packages/react/src/components/Popover/Popover.tsx create mode 100644 packages/react/src/components/Popover/Root.tsx create mode 100644 packages/react/src/components/Popover/Trigger.tsx create mode 100644 packages/react/src/hooks/useAutoScroll.ts diff --git a/apps/smithy/src/stories/Checklist/Floating.stories.tsx b/apps/smithy/src/stories/Checklist/Floating.stories.tsx index 7610dcc7..fc42d3fe 100644 --- a/apps/smithy/src/stories/Checklist/Floating.stories.tsx +++ b/apps/smithy/src/stories/Checklist/Floating.stories.tsx @@ -6,7 +6,7 @@ export default { component: Checklist.Floating, decorators: [ (Story) => ( - + ), diff --git a/apps/smithy/src/stories/Popover/Popover.stories.tsx b/apps/smithy/src/stories/Popover/Popover.stories.tsx new file mode 100644 index 00000000..e9a402df --- /dev/null +++ b/apps/smithy/src/stories/Popover/Popover.stories.tsx @@ -0,0 +1,58 @@ +import { Meta, StoryObj } from "@storybook/react"; +import { Box, Button, Card, FloatingUI, Popover } from "@frigade/react"; + +const meta: Meta = { + title: "Design System/Popover", + parameters: { + layout: "centered", + }, +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + + + Popover.Trigger + + + + Popover.Content + + + + + ), +}; + +export const Nested: Story = { + render: () => ( + + + + + Popover.Trigger + + + + Popover.Content + + + Nested Popover.Trigger + + + + Nested Popover.Content + + + + + + + + + ), +}; diff --git a/packages/react/src/components/Checklist/Floating.tsx b/packages/react/src/components/Checklist/Floating.tsx index 216091f5..6372e08e 100644 --- a/packages/react/src/components/Checklist/Floating.tsx +++ b/packages/react/src/components/Checklist/Floating.tsx @@ -82,7 +82,6 @@ export function Floating({ {anchorContent} + + {children} + + + ) +} diff --git a/packages/react/src/components/Popover/Popover.tsx b/packages/react/src/components/Popover/Popover.tsx deleted file mode 100644 index bd635309..00000000 --- a/packages/react/src/components/Popover/Popover.tsx +++ /dev/null @@ -1,198 +0,0 @@ -import { - createContext, - type Dispatch, - type SetStateAction, - useContext, - useEffect, - useState, -} from 'react' - -import { FloatingNode, useFloatingNodeId } from '@floating-ui/react' - -import { Box, type BoxProps } from '@/components/Box' -import { Overlay } from '@/components/Overlay' -import { Spotlight } from '@/components/Spotlight' - -import { type FloatingProps, type FloatingReturn, useFloating } from '@/hooks/useFloating' -import { useVisibility } from '@/hooks/useVisibility' - -export interface PopoverContextValue { - floating?: FloatingReturn - floatingNodeId: string | null - isOpen: boolean - setIsOpen: Dispatch> -} - -const PopoverContext = createContext({ - floatingNodeId: null, - isOpen: false, - setIsOpen: () => {}, -}) - -export interface PopoverRootProps extends FloatingProps { - autoScroll?: ScrollIntoViewOptions | boolean - children?: React.ReactNode - defaultOpen?: boolean - modal?: boolean - spotlight?: boolean -} - -export interface PopoverContentProps extends BoxProps {} - -export interface PopoverTriggerProps extends BoxProps {} - -export function Root({ - align = 'center', - alignOffset = 0, - anchor, - autoScroll = false, - children, - defaultOpen = false, - modal = false, - onOpenChange = () => {}, - open, - side = 'bottom', - sideOffset = 0, - spotlight = false, - ...floatingProps -}: PopoverRootProps) { - const [internalOpen, setInternalOpen] = useState(defaultOpen) - const [scrollComplete, setScrollComplete] = useState(false) - - // Defer to controlled open prop, otherwise manage open state internally - const canonicalOpen = open ?? internalOpen - - const floatingNodeId = useFloatingNodeId() - - const floating = useFloating({ - align, - alignOffset, - anchor, - nodeId: floatingNodeId, - onOpenChange: (newOpen) => { - onOpenChange(newOpen) - - if (open == null) { - setInternalOpen(newOpen) - } - }, - open: canonicalOpen, - side, - sideOffset, - ...floatingProps, - }) - - const { refs } = floating - - // TODO: Split this out to useAutoScroll hook - useEffect(() => { - if (!scrollComplete && autoScroll && refs.reference.current instanceof Element) { - const scrollOptions: ScrollIntoViewOptions = - typeof autoScroll !== 'boolean' ? autoScroll : { behavior: 'smooth', block: 'center' } - - /* - * NOTE: "scrollend" event isn't supported widely enough yet :( - * - * We'll listen to a capture-phase "scroll" instead, and when it stops - * bouncing, we can infer that the scroll we initiated is over. - */ - let scrollTimeout: ReturnType - window.addEventListener( - 'scroll', - function scrollHandler() { - clearTimeout(scrollTimeout) - - scrollTimeout = setTimeout(() => { - window.removeEventListener('scroll', scrollHandler) - setScrollComplete(true) - }, 100) - }, - true - ) - - refs.reference.current.scrollIntoView(scrollOptions) - } else if (!autoScroll) { - setScrollComplete(true) - } - }, [autoScroll, refs.reference, scrollComplete]) - - // TODO: Generalize / dedupe Spotlight and Overlay throughout SDK - return ( - - {spotlight && canonicalOpen && } - {modal && !spotlight && canonicalOpen && } - - {children} - - ) -} - -export function Content({ children, css, part, style, ...props }: BoxProps) { - const { floating, floatingNodeId } = useContext(PopoverContext) - - const { isVisible: isAnchorVisible } = useVisibility( - floating?.refs.reference.current as Element | null - ) - - if (floating == null) { - return null - } - - const { floatingStyles, getFloatingProps, placement, refs, status } = floating - - if (refs.reference.current == null || !isAnchorVisible) { - return null - } - - return ( - - - {children} - - - ) -} - -export function Trigger({ children, part, ...props }: BoxProps) { - const { - floating: { getReferenceProps, refs }, - setIsOpen, - } = useContext(PopoverContext) - - return ( - setIsOpen((prev) => !prev)} - part={['popover-trigger', part]} - {...props} - {...(getReferenceProps?.() ?? {})} - > - {children} - - ) -} diff --git a/packages/react/src/components/Popover/Root.tsx b/packages/react/src/components/Popover/Root.tsx new file mode 100644 index 00000000..cd07060a --- /dev/null +++ b/packages/react/src/components/Popover/Root.tsx @@ -0,0 +1,97 @@ +import { createContext, type Dispatch, type SetStateAction, useState } from 'react' +import { useFloatingNodeId } from '@floating-ui/react' + +import { Spotlight } from '@/components/Spotlight' +import { Overlay } from '@/components/Overlay' + +import { useAutoScroll } from '@/hooks/useAutoScroll' +import { type FloatingProps, type FloatingReturn, useFloating } from '@/hooks/useFloating' + +export interface PopoverContextValue { + floating?: FloatingReturn + floatingNodeId: string | null + isOpen: boolean + setIsOpen: Dispatch> +} + +export const PopoverContext = createContext({ + floatingNodeId: null, + isOpen: false, + setIsOpen: () => {}, +}) + +export interface PopoverRootProps extends FloatingProps { + autoScroll?: ScrollIntoViewOptions | boolean + children?: React.ReactNode + defaultOpen?: boolean + modal?: boolean + spotlight?: boolean +} + +export function Root({ + align = 'center', + alignOffset = 0, + anchor, + autoScroll = false, + children, + defaultOpen = false, + modal = false, + onOpenChange = () => {}, + open, + side = 'bottom', + sideOffset = 0, + spotlight = false, + ...floatingProps +}: PopoverRootProps) { + const [internalOpen, setInternalOpen] = useState(defaultOpen) + + // Defer to controlled open prop, otherwise manage open state internally + const canonicalOpen = open ?? internalOpen + const floatingNodeId = useFloatingNodeId() + + const floating = useFloating({ + align, + alignOffset, + anchor, + nodeId: floatingNodeId, + onOpenChange: (newOpen) => { + onOpenChange(newOpen) + if (open == null) { + setInternalOpen(newOpen) + } + }, + open: canonicalOpen, + side, + sideOffset, + ...floatingProps, + }) + + const { refs } = floating + + console.log( + 'Popover.Root status: ', + canonicalOpen, + floating.context.floatingId, + floating.status.status + ) + + useAutoScroll({ + enabled: autoScroll, + ref: refs.reference.current, + }) + + return ( + + {spotlight && canonicalOpen && } + {modal && !spotlight && canonicalOpen && } + {children} + + ) +} diff --git a/packages/react/src/components/Popover/Trigger.tsx b/packages/react/src/components/Popover/Trigger.tsx new file mode 100644 index 00000000..bd7e9500 --- /dev/null +++ b/packages/react/src/components/Popover/Trigger.tsx @@ -0,0 +1,24 @@ +import { useContext } from 'react' +import { Box, type BoxProps } from '@/components/Box' +import { PopoverContext } from './Root' + +export interface PopoverTriggerProps extends BoxProps {} + +export function Trigger({ children, part, ...props }: BoxProps) { + const { + floating: { getReferenceProps, refs }, + setIsOpen, + } = useContext(PopoverContext) + + return ( + setIsOpen((prev) => !prev)} + part={['popover-trigger', part]} + {...props} + {...(getReferenceProps?.() ?? {})} + > + {children} + + ) +} diff --git a/packages/react/src/components/Popover/index.tsx b/packages/react/src/components/Popover/index.tsx index a1c5b74a..1c7efc85 100644 --- a/packages/react/src/components/Popover/index.tsx +++ b/packages/react/src/components/Popover/index.tsx @@ -1,11 +1,6 @@ -export { - type AlignValue, - Content, - type ExtendedPlacement, - Root, - type PopoverContentProps, - type PopoverRootProps, - type PopoverTriggerProps, - type SideValue, - Trigger, -} from './Popover' +export { Root } from './Root' +export { Content } from './Content' +export { Trigger } from './Trigger' +export type { PopoverRootProps } from './Root' +export type { PopoverContentProps } from './Content' +export type { PopoverTriggerProps } from './Trigger' diff --git a/packages/react/src/hooks/useAutoScroll.ts b/packages/react/src/hooks/useAutoScroll.ts new file mode 100644 index 00000000..8dd4c919 --- /dev/null +++ b/packages/react/src/hooks/useAutoScroll.ts @@ -0,0 +1,35 @@ +import { useEffect, useState } from 'react' + +export function useAutoScroll({ enabled, ref }) { + const [scrollComplete, setScrollComplete] = useState(false) + + useEffect(() => { + if (!scrollComplete && enabled && ref instanceof Element) { + const scrollOptions: ScrollIntoViewOptions = + typeof enabled !== 'boolean' ? enabled : { behavior: 'smooth', block: 'center' } + + /* + * NOTE: "scrollend" event isn't supported widely enough yet :( + * + * We'll listen to a capture-phase "scroll" instead, and when it stops + * bouncing, we can infer that the scroll we initiated is over. + */ + let scrollTimeout: ReturnType + window.addEventListener( + 'scroll', + function scrollHandler() { + clearTimeout(scrollTimeout) + scrollTimeout = setTimeout(() => { + window.removeEventListener('scroll', scrollHandler) + setScrollComplete(true) + }, 100) + }, + true + ) + + ref.scrollIntoView(scrollOptions) + } else if (!enabled) { + setScrollComplete(true) + } + }, [enabled, ref, scrollComplete]) +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 2f704cc2..4865de5b 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -26,7 +26,7 @@ export { Label } from './components/Form/fields/Label' export { BaseField } from './components/Form/fields/BaseField' export { Media, Image, Video } from './components/Media' export { type NPSProps } from './components/Survey/NPS' - +export * as Popover from './components/Popover' export * as Progress from './components/Progress' export { ProgressBadge, type ProgressBadgeProps } from './components/ProgressBadge' export { Provider, type ProviderProps } from './components/Provider' @@ -62,3 +62,6 @@ export { export { useFrigade } from './hooks/useFrigade' export { useUser } from './hooks/useUser' export { useGroup } from './hooks/useGroup' + +// TEMP: Remove this, used for testing Storybook +export * as FloatingUI from '@floating-ui/react' From f288a030a0b5044e73aa02af72befc5950772ea8 Mon Sep 17 00:00:00 2001 From: Micah Snyder Date: Tue, 4 Feb 2025 15:15:18 -0800 Subject: [PATCH 13/13] autoScroll stories, backport useAutoScroll to Hint, fix refs --- .../src/stories/Popover/Popover.stories.tsx | 17 ++++++++ apps/smithy/src/stories/Tour/Tour.stories.tsx | 1 + .../stories/hooks/useAutoScroll.stories.tsx | 26 ++++++++++++ packages/react/src/components/Hint/index.tsx | 41 ++++--------------- .../react/src/components/Popover/Root.tsx | 12 +----- packages/react/src/hooks/useAutoScroll.ts | 11 +++-- packages/react/src/index.ts | 1 + 7 files changed, 60 insertions(+), 49 deletions(-) create mode 100644 apps/smithy/src/stories/hooks/useAutoScroll.stories.tsx diff --git a/apps/smithy/src/stories/Popover/Popover.stories.tsx b/apps/smithy/src/stories/Popover/Popover.stories.tsx index e9a402df..055f5f38 100644 --- a/apps/smithy/src/stories/Popover/Popover.stories.tsx +++ b/apps/smithy/src/stories/Popover/Popover.stories.tsx @@ -56,3 +56,20 @@ export const Nested: Story = { ), }; + +export const AutoScroll: Story = { + render: () => ( + + + + Popover.Trigger + + + + Popover.Content + + + + + ), +}; diff --git a/apps/smithy/src/stories/Tour/Tour.stories.tsx b/apps/smithy/src/stories/Tour/Tour.stories.tsx index 11d11170..a1c1ed00 100644 --- a/apps/smithy/src/stories/Tour/Tour.stories.tsx +++ b/apps/smithy/src/stories/Tour/Tour.stories.tsx @@ -10,6 +10,7 @@ export default { export const Default = { args: { align: "after", + autoScroll: false, dismissible: true, defaultOpen: true, flowId: "flow_U63A5pndRrvCwxNs", diff --git a/apps/smithy/src/stories/hooks/useAutoScroll.stories.tsx b/apps/smithy/src/stories/hooks/useAutoScroll.stories.tsx new file mode 100644 index 00000000..3d3b55c4 --- /dev/null +++ b/apps/smithy/src/stories/hooks/useAutoScroll.stories.tsx @@ -0,0 +1,26 @@ +import { useState } from "react"; +import { Box, Button, Flex, useAutoScroll } from "@frigade/react"; + +export default { + title: "Hooks/useAutoScroll", +}; + +export const Default = { + args: { + scrollOptions: true, + }, + + decorators: [ + () => { + const [scrollRef, setScrollRef] = useState(); + + useAutoScroll(scrollRef); + + return ( + + Scroll to this element + + ); + }, + ], +}; diff --git a/packages/react/src/components/Hint/index.tsx b/packages/react/src/components/Hint/index.tsx index 56c6fe34..4f9c557a 100644 --- a/packages/react/src/components/Hint/index.tsx +++ b/packages/react/src/components/Hint/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useRef, useState } from 'react' import { Box, type BoxProps } from '@/components/Box' import { Overlay } from '@/components/Overlay' @@ -6,6 +6,7 @@ import { Ping } from '@/components/Ping' import { Spotlight } from '@/components/Spotlight' import { getPingPosition } from '@/components/Hint/getPingPosition' +import { useAutoScroll } from '@/hooks/useAutoScroll' import { useFloating } from '@/hooks/useFloating' import { useVisibility } from '@/hooks/useVisibility' @@ -51,7 +52,6 @@ export function Hint({ ...props }: HintProps) { const [internalOpen, setInteralOpen] = useState(defaultOpen) - const [scrollComplete, setScrollComplete] = useState(false) // Defer to controlled open prop, otherwise manage open state internally const canonicalOpen = open ?? internalOpen @@ -79,43 +79,16 @@ export function Hint({ const { isVisible } = useVisibility(refs.reference.current as Element | null) const isMounted = useRef(false) - useEffect(() => { - if (!scrollComplete && autoScroll && refs.reference.current instanceof Element) { - const scrollOptions: ScrollIntoViewOptions = - typeof autoScroll !== 'boolean' ? autoScroll : { behavior: 'smooth', block: 'center' } - - /* - * NOTE: "scrollend" event isn't supported widely enough yet :( - * - * We'll listen to a capture-phase "scroll" instead, and when it stops - * bouncing, we can infer that the scroll we initiated is over. - */ - let scrollTimeout: ReturnType - window.addEventListener( - 'scroll', - function scrollHandler() { - clearTimeout(scrollTimeout) - - scrollTimeout = setTimeout(() => { - window.removeEventListener('scroll', scrollHandler) - setScrollComplete(true) - }, 100) - }, - true - ) - - refs.reference.current.scrollIntoView(scrollOptions) - } else if (!autoScroll) { - setScrollComplete(true) - } - }, [autoScroll, refs.reference, scrollComplete]) - - const shouldMount = refs.reference.current !== null && scrollComplete && isVisible + useAutoScroll(refs.reference.current as Element, autoScroll) + + const shouldMount = refs.reference.current !== null && isVisible if (!shouldMount) { + console.log('HINT: !shouldMount') isMounted.current = false return null } else if (isMounted.current === false) { + console.log('HINT: shouldMount') isMounted.current = true onMount?.() } diff --git a/packages/react/src/components/Popover/Root.tsx b/packages/react/src/components/Popover/Root.tsx index cd07060a..0067c65b 100644 --- a/packages/react/src/components/Popover/Root.tsx +++ b/packages/react/src/components/Popover/Root.tsx @@ -68,17 +68,7 @@ export function Root({ const { refs } = floating - console.log( - 'Popover.Root status: ', - canonicalOpen, - floating.context.floatingId, - floating.status.status - ) - - useAutoScroll({ - enabled: autoScroll, - ref: refs.reference.current, - }) + useAutoScroll(refs.reference.current as Element, autoScroll) return ( { - if (!scrollComplete && enabled && ref instanceof Element) { + if (!scrollComplete && enabled && element instanceof Element) { const scrollOptions: ScrollIntoViewOptions = typeof enabled !== 'boolean' ? enabled : { behavior: 'smooth', block: 'center' } @@ -27,9 +30,9 @@ export function useAutoScroll({ enabled, ref }) { true ) - ref.scrollIntoView(scrollOptions) + element.scrollIntoView(scrollOptions) } else if (!enabled) { setScrollComplete(true) } - }, [enabled, ref, scrollComplete]) + }, [enabled, element, scrollComplete]) } diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 4865de5b..c482bf8e 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -45,6 +45,7 @@ export * as FrigadeJS from '@frigade/js' export { themeVariables, type Theme } from './shared/theme' export { tokens, type Tokens } from './shared/tokens' +export { useAutoScroll } from './hooks/useAutoScroll' export { useBoundingClientRect } from './hooks/useBoundingClientRect' export { useFlow, type FlowConfig } from './hooks/useFlow' export {