Skip to content

Commit

Permalink
Refactor Export menu with Floating UI
Browse files Browse the repository at this point in the history
  • Loading branch information
axelboc committed May 16, 2024
1 parent 3c4240f commit 96f684e
Show file tree
Hide file tree
Showing 7 changed files with 234 additions and 98 deletions.
1 change: 1 addition & 0 deletions packages/lib/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
}
},
"dependencies": {
"@floating-ui/react": "0.26.14",
"@react-hookz/web": "24.0.4",
"@visx/axis": "3.10.1",
"@visx/drag": "3.3.0",
Expand Down
66 changes: 0 additions & 66 deletions packages/lib/src/toolbar/controls/ExportEntry.tsx

This file was deleted.

124 changes: 101 additions & 23 deletions packages/lib/src/toolbar/controls/ExportMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,45 +1,123 @@
import ram from 'react-aria-menubutton'; // CJS
import {
autoUpdate,
offset,
useClick,
useFloating,
useInteractions,
useListNavigation,
} from '@floating-ui/react';
import { assertDefined } from '@h5web/shared/guards';
import { useToggle } from '@react-hookz/web';
import { useRef, useState } from 'react';
import { FiDownload } from 'react-icons/fi';
import { MdArrowDropDown } from 'react-icons/md';

import type { ExportEntryProps } from './ExportEntry';
import ExportEntry from './ExportEntry';
import { useFloatingDismiss } from './hooks';
import styles from './Selector/Selector.module.css';
import { download } from './utils';

const { Button, Menu, Wrapper } = ram;
const PLACEMENTS = {
center: 'bottom',
left: 'bottom-start',
right: 'bottom-end',
} as const;

interface ExportEntry {
format: string;
url: URL | (() => Promise<URL | Blob>) | undefined;
}

interface Props {
entries: ExportEntryProps[];
entries: ExportEntry[];
isSlice?: boolean;
align?: 'center' | 'left' | 'right';
align?: keyof typeof PLACEMENTS;
}

function ExportMenu(props: Props) {
const { entries, isSlice, align = 'center' } = props;
const availableEntries = entries.filter(({ url }) => !!url);

const [isOpen, toggle] = useToggle();
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const listRef = useRef<(HTMLButtonElement | null)[]>([]);

const { refs, floatingStyles, context } = useFloating<HTMLButtonElement>({
open: isOpen,
placement: PLACEMENTS[align],
middleware: [offset(6)],
onOpenChange: toggle,
whileElementsMounted: autoUpdate,
});

const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions(
[
useClick(context),
useFloatingDismiss(context),
useListNavigation(context, {
listRef,
activeIndex,
loop: true,
focusItemOnHover: false,
onNavigate: setActiveIndex,
}),
],
);

return (
<Wrapper className={styles.wrapper}>
<Button
<>
<button
ref={refs.setReference}
className={styles.btn}
tag="button"
disabled={!entries.some(({ url }) => !!url)}
type="button"
disabled={availableEntries.length === 0}
{...getReferenceProps()}
>
<div className={styles.btnLike}>
<span className={styles.btnLike}>
<FiDownload className={styles.icon} />
<span className={styles.selectedOption}>
Export{isSlice && ' slice'}
</span>
<span className={styles.label}>Export{isSlice && ' slice'}</span>
<MdArrowDropDown className={styles.arrowIcon} />
</span>
</button>

{isOpen && (
<div
ref={refs.setFloating}
className={styles.exportMenu}
style={floatingStyles}
role="menu"
{...getFloatingProps()}
>
{availableEntries.map((entry, index) => {
const { format, url } = entry;
const isActive = activeIndex === index;
assertDefined(url);

return (
<button
key={format}
ref={(node) => {
listRef.current[index] = node;
}}
className={styles.btnOption}
type="button"
tabIndex={isActive ? 0 : -1}
data-active={isActive || undefined}
{...getItemProps({
onClick: () => {
toggle(false);
void download(url, `data.${format}`);
},
})}
>
<span className={styles.label}>
Export to {format.toUpperCase()}
</span>
</button>
);
})}
</div>
</Button>
<Menu className={styles.menu} data-align={align}>
<div className={styles.list}>
{entries.map((entry) => (
<ExportEntry key={entry.format} {...entry} />
))}
</div>
</Menu>
</Wrapper>
)}
</>
);
}

Expand Down
26 changes: 17 additions & 9 deletions packages/lib/src/toolbar/controls/Selector/Selector.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,16 @@
transform: rotate(180deg);
}

.exportMenu {
display: flex;
flex-direction: column;
padding: 0.25rem 0;
background-color: var(--h5w-selector-menu--bgColor, white);
box-shadow:
rgba(0, 0, 0, 0.1) 0px 0px 0px 1px,
rgba(0, 0, 0, 0.1) 0px 4px 11px;
}

.menu {
position: absolute;
top: calc(100% + 0.75rem);
Expand Down Expand Up @@ -95,28 +105,26 @@
}

.option {
flex: 1;
padding: 0.5rem 0.75rem;
cursor: pointer;
white-space: nowrap;
}

.linkOption {
composes: btn from '../../utils.module.css';
composes: option;
padding: 0.5rem 0.75rem;
}

.option:hover,
.option:focus {
.option[data-active] {
background-color: var(--h5w-selector-option-hover--bgColor, whitesmoke);
}

.option:focus {
.option:focus-visible {
outline: 1px solid var(--h5w-selector-option-focus--outlineColor, gray);
outline-offset: -1px;
}

.option[data-selected] {
background-color: var(--h5w-selector-option-selected--bgColor, #eee);
}

.btnOption {
composes: btn from '../../utils.module.css';
composes: option;
}
44 changes: 44 additions & 0 deletions packages/lib/src/toolbar/controls/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { ElementProps, FloatingContext } from '@floating-ui/react';
import { useClickOutside, useKeyboardEvent } from '@react-hookz/web';
import type { FocusEvent } from 'react';
import { useCallback, useMemo } from 'react';

/* Custom dismiss interaction hook for Floating UI widgets.
* Reduces bundle size (~16 kB) by replacing:
* - `useDismiss` (Escape key, click outside)
* - `FloatingFocusManager` (focus out) */
export function useFloatingDismiss(context: FloatingContext): ElementProps {
const { refs, onOpenChange } = context;
const { domReference, floating } = refs;

const handleBlur = useCallback(
(evt: FocusEvent) => {
const { relatedTarget } = evt;
if (
relatedTarget &&
!domReference.current?.contains(relatedTarget) &&
!floating.current?.contains(relatedTarget)
) {
onOpenChange(false);
}
},
[domReference, floating, onOpenChange],
);

const referenceProps = useMemo(() => ({ onBlur: handleBlur }), [handleBlur]);
const floatingProps = useMemo(() => ({ onBlur: handleBlur }), [handleBlur]);

useKeyboardEvent('Escape', () => onOpenChange(false));
useClickOutside(refs.floating, (evt) => {
const triggerRef = refs.domReference.current;
if (evt.target instanceof Element && triggerRef?.contains(evt.target)) {
return; // skip
}
onOpenChange(false);
});

return useMemo(
() => ({ reference: referenceProps, floating: floatingProps }),
[referenceProps, floatingProps],
);
}
17 changes: 17 additions & 0 deletions packages/lib/src/toolbar/controls/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export async function download(
url: URL | (() => Promise<URL | Blob>),
filename: string,
) {
const urlOrBlob = url instanceof URL ? url : await url();

const anchor = document.createElement('a');
anchor.download = filename;
document.body.append(anchor);

anchor.href =
urlOrBlob instanceof Blob ? URL.createObjectURL(urlOrBlob) : urlOrBlob.href;
anchor.click();

URL.revokeObjectURL(anchor.href);
anchor.remove();
}

0 comments on commit 96f684e

Please sign in to comment.