Skip to content

Commit

Permalink
Merge pull request #567 from LifeSG/inline-popover
Browse files Browse the repository at this point in the history
Accessibility improvements to PopoverTrigger and new inline popover
  • Loading branch information
weili-govtech authored Sep 26, 2024
2 parents cc1520f + 308fab4 commit f72968c
Show file tree
Hide file tree
Showing 12 changed files with 535 additions and 167 deletions.
3 changes: 2 additions & 1 deletion src/popover-v2/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./popover-trigger";
export * from "./popover";
export * from "./popover-inline";
export * from "./popover-trigger";
export * from "./types";
1 change: 1 addition & 0 deletions src/popover-v2/popover-inline/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./popover-inline";
52 changes: 52 additions & 0 deletions src/popover-v2/popover-inline/popover-inline.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Color } from "../../color";
import styled from "styled-components";
import { PopoverInlineStyle } from "../types";

// =============================================================================
// STYLE INTERFACE
// =============================================================================

interface StyledTextProps {
$defaultStyle: PopoverInlineStyle;
$hoverStyle: PopoverInlineStyle;
}

interface StyledIconProps {
$standalone: boolean;
}

// =============================================================================
// STYLING
// =============================================================================
const getTextStyle = (style: PopoverInlineStyle) => {
switch (style) {
case "underline":
return "text-decoration: underline 1px;";
case "underline-dashed":
return "text-decoration: underline dashed 1px;";
}
};

export const StyledText = styled.span<StyledTextProps>`
color: ${Color.Primary};
font-weight: 600;
text-underline-position: under;
${({ $defaultStyle }) => getTextStyle($defaultStyle)}
&:hover,
&:focus-visible {
color: ${Color.Secondary};
${({ $hoverStyle }) => getTextStyle($hoverStyle)}
}
svg {
height: 1lh; // align vertically
width: 1em; // scale icon with font size
vertical-align: top;
}
`;

export const StyledIcon = styled.span<StyledIconProps>`
${(props) => !props.$standalone && "margin-left: 0.25rem;"}
`;
29 changes: 29 additions & 0 deletions src/popover-v2/popover-inline/popover-inline.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { PopoverTrigger } from "../popover-trigger";
import { PopoverInlineProps } from "../types";
import { StyledIcon, StyledText } from "./popover-inline.styles";

export const PopoverInline = ({
content,
icon,
underlineStyle = "default",
underlineHoverStyle = "default",
...otherProps
}: PopoverInlineProps) => {
// =========================================================================
// RENDER FUNCTIONS
// =========================================================================
return (
<PopoverTrigger {...otherProps}>
<StyledText
role="button"
aria-haspopup="dialog"
tabIndex={0}
$defaultStyle={underlineStyle}
$hoverStyle={underlineHoverStyle}
>
{content}
{icon && <StyledIcon $standalone={!content}>{icon}</StyledIcon>}
</StyledText>
</PopoverTrigger>
);
};
135 changes: 65 additions & 70 deletions src/popover-v2/popover-trigger.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,29 @@
import {
FloatingFocusManager,
FloatingPortal,
autoUpdate,
flip,
limitShift,
offset,
shift,
useClick,
useDismiss,
useFloating,
useHover,
useInteractions,
} from "@floating-ui/react";
import { useEffect, useRef, useState } from "react";
import { useRef, useState } from "react";
import { useMediaQuery } from "react-responsive";
import { MediaWidths } from "../media";
import { useFloatingChild } from "../overlay/use-floating-context";
import { PopoverV2 } from "./popover";
import { TriggerContainer } from "./popover-trigger.styles";
import { PopoverV2TriggerProps } from "./types";
import { PopoverV2TriggerProps, PopoverV2TriggerType } from "./types";

export const PopoverTrigger = ({
children,
popoverContent,
trigger = "click",
trigger: _trigger = "click",
position = "top",
zIndex,
rootNode,
Expand All @@ -35,7 +40,7 @@ export const PopoverTrigger = ({
const isMobile = useMediaQuery({
maxWidth: MediaWidths.mobileL,
});
const { refs, floatingStyles } = useFloating({
const { refs, floatingStyles, context } = useFloating({
open: visible,
placement: position,
whileElementsMounted: autoUpdate,
Expand All @@ -46,63 +51,46 @@ export const PopoverTrigger = ({
limiter: limitShift(),
}),
],
onOpenChange: (isOpen) => {
setVisible(isOpen);

// this callback is triggering twice on hover, the check here prevents extra calls
if (visible !== isOpen) {
handleVisibilityChange(isOpen);
}
},
});
const parentZIndex = useFloatingChild();

// =========================================================================
// EFFECTS
// =========================================================================
useEffect(() => {
// NOTE: Do not add mouse down event if it's mobile
if (isMobile || !visible) {
return;
}
document.addEventListener("mousedown", handleMouseDownEvent);
const trigger: PopoverV2TriggerType = isMobile ? "click" : _trigger;
const click = useClick(context, {
// allow trigger by Space/Enter, but disable mouse click in hover mode
ignoreMouse: trigger === "hover",
});
const dismiss = useDismiss(context);
const hover = useHover(context, {
enabled: trigger === "hover",
// short window to enter the floating element without it closing
delay: { close: 500 },
});

return () => {
document.removeEventListener("mousedown", handleMouseDownEvent);
};
}, [visible]);
const { getReferenceProps, getFloatingProps } = useInteractions([
click,
dismiss,
hover,
]);

// =========================================================================
// EVENT HANDLERS
// =========================================================================
const handleMouseDownEvent = (event: MouseEvent) => {
if (
!nodeRef.current?.contains(event.target as Node) &&
!popoverRef.current?.contains(event.target as Node)
) {
// outside click
setVisible(false);

if (onPopoverDismiss) onPopoverDismiss();
}
};

const handleClick = (event: React.MouseEvent) => {
event.preventDefault();
if (trigger === "click" || isMobile) {
setVisible(!visible);

if (!visible && onPopoverAppear) onPopoverAppear();
if (visible && onPopoverDismiss) onPopoverDismiss();
}
};

const handleOnMouseEnter = () => {
if (trigger === "hover" && !isMobile) {
setVisible(true);
}
};

const handleOnMouseLeave = () => {
if (trigger === "hover" && visible && !isMobile) {
setVisible(false);
}
};

const handlePopoverMobileClose = () => {
setVisible(false);
handleVisibilityChange(false);
};

const handleVisibilityChange = (nextVisible: boolean) => {
if (nextVisible && onPopoverAppear) onPopoverAppear();
if (!nextVisible && onPopoverDismiss) onPopoverDismiss();
};

// =========================================================================
Expand All @@ -122,34 +110,41 @@ export const PopoverTrigger = ({

return (
<>
{visible && (
<FloatingPortal root={rootNode}>
<div
ref={(node) => {
popoverRef.current = node;
refs.setFloating(node);
}}
style={{
...floatingStyles,
zIndex: zIndex ?? parentZIndex,
}}
>
{renderPopover()}
</div>
</FloatingPortal>
)}
<TriggerContainer
ref={(node) => {
nodeRef.current = node;
refs.setReference(node);
}}
onClick={handleClick}
onMouseEnter={handleOnMouseEnter}
onMouseLeave={handleOnMouseLeave}
{...getReferenceProps({
onClick: (event) => {
// prevent popover interaction from triggering click events on parents
event.stopPropagation();
event.preventDefault();
},
})}
{...otherProps}
>
{children}
</TriggerContainer>
{visible && (
<FloatingPortal root={rootNode}>
<FloatingFocusManager context={context}>
<div
ref={(node) => {
popoverRef.current = node;
refs.setFloating(node);
}}
style={{
...floatingStyles,
zIndex: zIndex ?? parentZIndex,
}}
{...getFloatingProps()}
>
{renderPopover()}
</div>
</FloatingFocusManager>
</FloatingPortal>
)}
</>
);
};
10 changes: 10 additions & 0 deletions src/popover-v2/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,13 @@ export interface PopoverV2TriggerProps {
onPopoverAppear?: (() => void) | undefined;
onPopoverDismiss?: (() => void) | undefined;
}

export type PopoverInlineStyle = "default" | "underline" | "underline-dashed";

export interface PopoverInlineProps
extends Omit<PopoverV2TriggerProps, "children"> {
content?: React.ReactNode | undefined;
icon?: JSX.Element | undefined;
underlineStyle?: PopoverInlineStyle | undefined;
underlineHoverStyle?: PopoverInlineStyle | undefined;
}
30 changes: 30 additions & 0 deletions stories/popover-v2/popover-inline/popover-inline.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Canvas, Meta } from "@storybook/blocks";
import { Heading3, Heading4, Secondary, Title } from "../../storybook-common";
import * as PopoverInlineStories from "./popover-inline.stories";
import { PropsTable } from "./props-table";

<Meta of={PopoverInlineStories} />

<Title>PopoverInline</Title>

<Secondary>Overview</Secondary>

Provides additional information for content within a text block.

<Canvas of={PopoverInlineStories.InlineTextAndIcon} />

<Heading3>Text style</Heading3>

The text and icon inherit the font size of the surrounding text.

<Canvas of={PopoverInlineStories.InlineText} />

<Canvas of={PopoverInlineStories.InlineIcon} />

<Heading3>Underline style</Heading3>

<Canvas of={PopoverInlineStories.UnderlineStyle} />

<Secondary>Component API</Secondary>

<PropsTable />
Loading

0 comments on commit f72968c

Please sign in to comment.