Skip to content

Commit

Permalink
Merge pull request #1642 from silx-kit/floating-ui
Browse files Browse the repository at this point in the history
Refactor Export menu with Floating UI
  • Loading branch information
axelboc committed May 16, 2024
2 parents 3c4240f + 465d0e0 commit 77025c4
Show file tree
Hide file tree
Showing 13 changed files with 255 additions and 102 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/approve-snapshots.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ jobs:
CYPRESS_TAKE_SNAPSHOTS: true

- name: Upload debug screenshots and diffs on failure 🖼️
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: failure()
with:
name: cypress
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/lint-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ jobs:
CYPRESS_TAKE_SNAPSHOTS: true

- name: Upload debug screenshots and diffs on failure 🖼️
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: failure()
with:
name: cypress
Expand Down
Binary file modified cypress/snapshots/app.cy.ts/heatmap_2D_inverted_cmap.snap.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified cypress/snapshots/app.cy.ts/heatmap_flip.snap.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 0 additions & 1 deletion packages/app/src/vis-packs/core/matrix/MatrixToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ function MatrixToolbar(props: Props) {
format,
url: getExportURL(format),
}))}
align="right"
/>
</>
)}
Expand Down
1 change: 0 additions & 1 deletion packages/app/src/vis-packs/core/raw/RawToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ function RawToolbar(props: Props) {
<Separator />
<ExportMenu
entries={[{ format: 'json', url: getExportURL('json') }]}
align="right"
/>
</>
)}
Expand Down
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.

133 changes: 110 additions & 23 deletions packages/lib/src/toolbar/controls/ExportMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,45 +1,132 @@
import ram from 'react-aria-menubutton'; // CJS
import {
autoUpdate,
offset,
shift,
useClick,
useFloating,
useInteractions,
useListNavigation,
} from '@floating-ui/react';
import { assertDefined } from '@h5web/shared/guards';
import { useToggle } from '@react-hookz/web';
import { useId, 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, floatingMinWidth } 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 referenceId = useId();
const listRef = useRef<(HTMLButtonElement | null)[]>([]);

const { refs, floatingStyles, context } = useFloating<HTMLButtonElement>({
open: isOpen,
placement: PLACEMENTS[align],
middleware: [floatingMinWidth, offset(6), shift({ padding: 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}
id={referenceId}
className={styles.btn}
tag="button"
disabled={!entries.some(({ url }) => !!url)}
type="button"
disabled={availableEntries.length === 0}
aria-haspopup="menu"
aria-expanded={isOpen || undefined}
aria-controls={context.floatingId}
{...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}
id={context.floatingId}
className={styles.exportMenu}
style={floatingStyles}
role="menu"
aria-labelledby={referenceId}
{...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],
);
}
27 changes: 27 additions & 0 deletions packages/lib/src/toolbar/controls/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { MiddlewareState } from '@floating-ui/react';

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();
}

export const floatingMinWidth = {
name: 'minWidth',
fn({ x, y, elements, rects }: MiddlewareState) {
elements.floating.style.minWidth = `${rects.reference.width.toString()}px`;
return { x, y };
},
};

0 comments on commit 77025c4

Please sign in to comment.