Skip to content

Commit

Permalink
feat: add toggletip component
Browse files Browse the repository at this point in the history
  • Loading branch information
maxinteger committed Jun 11, 2024
1 parent 2bfe2b2 commit 53b6e67
Show file tree
Hide file tree
Showing 24 changed files with 2,030 additions and 129 deletions.
3 changes: 2 additions & 1 deletion src/components/Popover/Popover.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const BOUNDARIES = {
VIEWPORT: 'viewport',
WINDOW: 'window',
PARENT: 'scrollParent',
};
} as const;

const CLOSE_BUTTON_PLACEMENTS = {
TOP_LEFT: 'top-left',
Expand All @@ -35,6 +35,7 @@ const DEFAULTS = {
STRATEGY: 'absolute' as const,
ADD_BACKDROP: true,
ROLE: MODAL_CONTAINER_CONSTANTS.DEFAULTS.ROLE,
APPEND_TO: 'parent' as const,
};

const STYLE = {
Expand Down
4 changes: 2 additions & 2 deletions src/components/Popover/Popover.stories.args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,14 +142,14 @@ const popoverArgTypes = {
},
},
focusBackOnTrigger: {
description: `Determines wether the focus should return to the trigger element when the popover is closed`,
description: `Determines whether the focus should return to the trigger element when the popover is closed`,
control: { type: 'boolean' },
table: {
type: {
summary: 'boolean',
},
defaultValue: {
summary: DEFAULTS.FOCUS_BACK_ON_TRIGGER_COMPONENT,
summary: DEFAULTS.FOCUS_BACK_ON_TRIGGER_COMPONENT_NON_INTERACTIVE,
},
},
},
Expand Down
15 changes: 11 additions & 4 deletions src/components/Popover/Popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,18 +63,25 @@ const Popover = forwardRef((props: Props, ref: ForwardedRef<HTMLElement>) => {
onClickOutside,
firstFocusElement,
autoFocus = DEFAULTS.AUTO_FOCUS,
appendTo = DEFAULTS.APPEND_TO,
...rest
} = props;

const focusBackOnTrigger = focusBackOnTriggerFromProps ??
(interactive ? DEFAULTS.FOCUS_BACK_ON_TRIGGER_COMPONENT_INTERACTIVE : DEFAULTS.FOCUS_BACK_ON_TRIGGER_COMPONENT_NON_INTERACTIVE);
const focusBackOnTrigger =
focusBackOnTriggerFromProps ??
(interactive
? DEFAULTS.FOCUS_BACK_ON_TRIGGER_COMPONENT_INTERACTIVE
: DEFAULTS.FOCUS_BACK_ON_TRIGGER_COMPONENT_NON_INTERACTIVE);

const popoverInstance = React.useRef<PopoverInstance>(undefined);

const triggerComponentId = triggerComponent.props?.id || uuidV4();

const modalConditionalProps = {
...(interactive && { 'aria-labelledby': triggerComponentId , focusLockProps: { restoreFocus: focusBackOnTrigger, autoFocus } }),
...(interactive && {
'aria-labelledby': triggerComponentId,
focusLockProps: { restoreFocus: focusBackOnTrigger, autoFocus },
}),
};

// memoize arrow id to avoid memory leak (arrow will be different, but JS still tries to find old ones):
Expand Down Expand Up @@ -163,7 +170,7 @@ const Popover = forwardRef((props: Props, ref: ForwardedRef<HTMLElement>) => {
/* add focusin automatically if only mouseenter is passed in as a trigger - this is for accessibility reasons */
trigger={trigger === 'mouseenter' ? 'mouseenter focusin' : trigger}
interactive={interactive}
appendTo="parent"
appendTo={appendTo}
popperOptions={{
modifiers: [
{
Expand Down
7 changes: 6 additions & 1 deletion src/components/Popover/Popover.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export type CloseButtonPlacement = 'top-left' | 'top-right' | 'none';
export type PlacementType = TippyProps['placement'];
export type TriggerType = TippyProps['trigger'];
export type PositioningStrategy = TippyProps['popperOptions']['strategy'];
export type PopoverAria = TippyProps['aria'];
export type AppendToType = TippyProps['appendTo'];

/**
* Popover instance interface abstracted from Tippy.js
Expand Down Expand Up @@ -192,4 +192,9 @@ export interface Props extends PopoverCommonStyleProps, Partial<LifecycleHooks>
* Role of the popover content
*/
role?: string;

/**
* The element to append the popover to.
*/
appendTo?: AppendToType;
}
13 changes: 13 additions & 0 deletions src/components/Toggletip/Toggletip.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { PLACEMENTS } from '../ModalArrow/ModalArrow.constants';
import { COLORS } from '../ModalContainer/ModalContainer.constants';
import { BOUNDARIES } from '../Popover/Popover.constants';

export const DEFAULTS = {
BOUNDARY: BOUNDARIES.PARENT,
COLOR: COLORS.PRIMARY,
OFFSET_DISTANCE: 5,
OFFSET_SKIDDING: 0,
PLACEMENT: PLACEMENTS.AUTO as string,
STRATEGY: 'absolute' as const,
VARIANT: 'small',
} as const;
40 changes: 40 additions & 0 deletions src/components/Toggletip/Toggletip.stories.args.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { commonStyles } from '../../storybook/helper.stories.argtypes';
import { popoverArgTypes } from '../Popover/Popover.stories.args';

const toggletipArgTypes = {
placement: {
description: `Placement of the Toggletip relative to the trigger component`,
...popoverArgTypes['placement'],
},
offsetSkidding: {
description: `The offset skidding (in px) along the reference.`,
...popoverArgTypes['offsetSkidding'],
},
offsetDistance: {
description: `The offset distance (in px) from the reference.`,
...popoverArgTypes['offsetDistance'],
},
variant: {
description: `Variant of the Toggletip - can be either small or medium`,
...popoverArgTypes['variant'],
},
children: {
description: 'Provides the child nodes for this element.',
...popoverArgTypes['children'],
},
color: {
description: 'What color to render this `<Toggletip />` as.',
...popoverArgTypes['color'],
},
boundary: {
description: 'The overflow boundary of the toggletip element.',
...popoverArgTypes['boundary'],
},
};

export { toggletipArgTypes };

export default {
...commonStyles,
...toggletipArgTypes,
};
2 changes: 2 additions & 0 deletions src/components/Toggletip/Toggletip.stories.docs.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
The `<Toggletip />` component allows adding a Toggletip to whatever provided as `triggerComponent`. It
will show the Tooltip after a click event. It utilise live area to notify Screen Reader or other assistive technology.
134 changes: 134 additions & 0 deletions src/components/Toggletip/Toggletip.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import React from 'react';
import { MultiTemplate, Template } from '../../storybook/helper.stories.templates';
import { DocumentationPage } from '../../storybook/helper.stories.docs';
import StyleDocs from '../../storybook/docs.stories.style.mdx';
import Documentation from './Toggletip.stories.docs.mdx';
import Toggletip, { ToggletipProps } from './';
import Text from '../Text';
import ButtonPill from '../ButtonPill';
import ButtonSimple from '../ButtonSimple';
import { COLORS } from '../ModalContainer/ModalContainer.constants';
import argTypes from './Toggletip.stories.args';
import { PLACEMENTS } from '../ModalArrow/ModalArrow.constants';
import Icon from '../Icon';
import Flex from '../Flex';
import Popover from '../Popover';
import ButtonCircle from '../ButtonCircle';

export default {
title: 'Momentum UI/Toggletip',
component: Toggletip,
parameters: {
expanded: true,
docs: {
page: DocumentationPage(Documentation, StyleDocs),
},
},
};

const Example = Template<ToggletipProps>(Toggletip).bind({});

Example.argTypes = { ...argTypes };

Example.args = {
placement: PLACEMENTS.AUTO,
variant: 'small',
color: COLORS.PRIMARY,
delay: [0, 0],
children: <p>Toggletip</p>,
triggerComponent: (
<ButtonCircle
ghost
size={64}
aria-label="About toggletip"
style={{ margin: '10rem auto', display: 'flex' }}
>
<Icon name="info-badge" weight="filled" scale={32} />
</ButtonCircle>
),
};

const Common = MultiTemplate<ToggletipProps>(Toggletip).bind({});

Common.argTypes = { ...argTypes };

Common.args = {};
Common.parameters = {
variants: [
{
children: <p>Label toggletip TERTIARY color, variant medium</p>,
triggerComponent: (
<ButtonSimple style={{ margin: '10rem auto', display: 'flex' }}>Click me!</ButtonSimple>
),
placement: PLACEMENTS.RIGHT,
variant: 'medium',
color: COLORS.TERTIARY,
},
{
children: <p>Toggletip, PRIMARY color, variant small</p>,
triggerComponent: (
<ButtonSimple style={{ margin: '10rem auto', display: 'flex' }}>Click me!</ButtonSimple>
),
placement: PLACEMENTS.BOTTOM_START,
variant: 'small',
color: COLORS.PRIMARY,
},
{
children: <p>Toggletip, SECONDARY color, variant medium, showDelay 500ms</p>,
triggerComponent: (
<ButtonSimple>
Click me! <br /> Open with delay
</ButtonSimple>
),
placement: PLACEMENTS.LEFT_START,
delay: [500],
variant: 'medium',
color: COLORS.SECONDARY,
},
],
};

const Offset = Template<ToggletipProps>(Toggletip).bind({});

Offset.argTypes = { ...argTypes };

Offset.args = {
placement: PLACEMENTS.RIGHT,
variant: 'small',
color: COLORS.TERTIARY,
delay: [0, 0],
offsetDistance: -150,
triggerComponent: (
<ButtonPill style={{ margin: '10rem auto', display: 'flex', width: '30rem' }}>
Click me!
</ButtonPill>
),
children: (
<Flex style={{ width: '10rem', height: '10rem' }} justifyContent="center" alignItems="center">
<Text type="display">🏖</Text>
</Flex>
),
};

const MultiplePopovers = Template<ToggletipProps>((args: ToggletipProps) => {
const triggerComponent = (
<Toggletip
placement={PLACEMENTS.BOTTOM}
triggerComponent={
<ButtonSimple style={{ margin: '10rem auto', display: 'flex' }}>Click me!</ButtonSimple>
}
>
Description toggletip on click
</Toggletip>
);
return <Popover {...args} triggerComponent={triggerComponent} />;
}).bind({});

MultiplePopovers.argTypes = { ...argTypes };

MultiplePopovers.args = {
placement: PLACEMENTS.TOP,
children: 'Popover content on click',
};

export { Example, Common, Offset, MultiplePopovers };
95 changes: 95 additions & 0 deletions src/components/Toggletip/Toggletip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import React, { ForwardedRef, forwardRef, useCallback, useEffect, useRef } from 'react';
import Popover, { PopoverInstance } from '../Popover';
import { Props } from './Toggletip.types';
import { DEFAULTS } from './Toggletip.constants';
import { BoundaryType, PlacementType } from '../Popover/Popover.types';

/**
* Toggletip component
*
* Shows a non-interactable popover component with inside a live aria.
*
* @see [WCAG - Tooltip pattern]{@link https://www.w3.org/WAI/ARIA/apg/patterns/tooltip/}
*
* Toggletip content must be rendered in a live aria because Screen reader can not pick up changes
* on the active element. This is why the
* - we can not use the aria-describedby and
* - the "status" div have to be rendered upfront
*
* @see [Tooltips & Toggletips]{@link https://inclusive-components.design/tooltips-toggletips/}
*/
const Toggletip = forwardRef(
(
{
boundary = DEFAULTS.BOUNDARY as BoundaryType,
color = DEFAULTS.COLOR,
offsetDistance = DEFAULTS.OFFSET_DISTANCE,
offsetSkidding = DEFAULTS.OFFSET_SKIDDING,
placement = DEFAULTS.PLACEMENT as PlacementType,
strategy = DEFAULTS.STRATEGY,
variant = DEFAULTS.VARIANT,
triggerComponent,
children,
...otherProps
}: Props,
ref: ForwardedRef<HTMLElement>
) => {
const tippyRef = useRef<PopoverInstance>(null);
const liveAriaRef = useRef<HTMLDivElement>(null);
const triggerComponentRef = useRef<HTMLElement>(null);

// Update aria props manually, because "The `aria` attribute is reserved for future use in React."
// see https://atomiks.github.io/tippyjs/v6/all-props/#aria
const setInstance = useCallback((popoverInstance: PopoverInstance | undefined) => {
popoverInstance?.setProps?.({ aria: { expanded: false, content: null } });
tippyRef.current = popoverInstance;
otherProps?.setInstance?.(popoverInstance);
}, []);

// Hide popover on when the trigger component loose focus
// User must re-open the toggletip otherwise SR will not announce the content when user
// focus on the trigger component because of the live area
const hidePopoverOnBlur = useCallback(() => {
tippyRef.current?.hide();
}, [tippyRef.current]);

useEffect(() => {
const triggerRef = triggerComponentRef.current;
if (triggerRef) {
triggerRef.addEventListener('blur', hidePopoverOnBlur);
return () => triggerRef.removeEventListener('blur', hidePopoverOnBlur);
}
}, [triggerComponentRef.current]);

return (
<>
<Popover
ref={ref}
appendTo={() => liveAriaRef.current}
trigger="click"
triggerComponent={React.cloneElement(triggerComponent, { ref: triggerComponentRef })}
showArrow
interactive={false}
addBackdrop={false}
role="generic"
boundary={boundary}
color={color}
offsetDistance={offsetDistance}
offsetSkidding={offsetSkidding}
placement={placement}
strategy={strategy}
variant={variant}
{...otherProps}
setInstance={setInstance}
>
{children}
</Popover>
<div role="status" ref={liveAriaRef} />
</>
);
}
);

Toggletip.displayName = 'Toggletip';

export default Toggletip;
Loading

0 comments on commit 53b6e67

Please sign in to comment.