Menu: fix #47, skip animateOpen if animating

This commit is contained in:
thislight 2024-11-20 00:27:52 +08:00
parent a69a5c31e5
commit 9a7710c070
No known key found for this signature in database
GPG key ID: FCFE5192241CCD4E
3 changed files with 124 additions and 73 deletions

View file

@ -7,6 +7,7 @@
width: max-content; width: max-content;
box-shadow: var(--tutu-shadow-e8); box-shadow: var(--tutu-shadow-e8);
contain: content; contain: content;
overscroll-behavior: contain;
&.e1 { &.e1 {
box-shadow: var(--tutu-shadow-e9); box-shadow: var(--tutu-shadow-e9);

View file

@ -1,6 +1,7 @@
import { useWindowSize } from "@solid-primitives/resize-observer"; import { useWindowSize } from "@solid-primitives/resize-observer";
import { MenuList } from "@suid/material"; import { MenuList } from "@suid/material";
import { import {
batch,
createEffect, createEffect,
createSignal, createSignal,
splitProps, splitProps,
@ -65,11 +66,39 @@ export function createManagedMenuState() {
return !!anchor(); return !!anchor();
}, },
anchor: anchor as () => Anchor, anchor: anchor as () => Anchor,
onClose: () => setAnchor(), onClose: (event: Event) => {
event.preventDefault();
return setAnchor();
},
}, },
] as const; ] as const;
} }
function animateGrowFromTopLeft(
element: HTMLElement,
opts?: Omit<KeyframeAnimationOptions, "duration">,
) {
const rend = element.getBoundingClientRect();
const overflow = element.style.overflow;
element.style.overflow = "hidden";
const duration = (rend.height / 1600) * 1000;
const animation = element.animate(
{
height: [`${rend.height / 2}px`, `${rend.height}px`],
width: [`${(rend.width / 4) * 3}px`, `${rend.width}px`],
},
{
duration,
...opts,
},
);
animation.addEventListener(
"finish",
() => (element.style.overflow = overflow),
);
return animation;
}
/** /**
* Material Menu Component. This component is * Material Menu Component. This component is
* implemented with dialog and {@link MenuList} from SUID. * implemented with dialog and {@link MenuList} from SUID.
@ -78,10 +107,16 @@ export function createManagedMenuState() {
* - Use {@link createManagedMenuState} and you don't need to manage the open and close. * - Use {@link createManagedMenuState} and you don't need to manage the open and close.
* - Use {@link MenuItem} from SUID as children. * - Use {@link MenuItem} from SUID as children.
*/ */
const Menu: Component<MenuProps> = (props) => { const Menu: Component<MenuProps> = (oprops) => {
let root: HTMLDialogElement; let root: HTMLDialogElement;
const windowSize = useWindowSize(); const windowSize = useWindowSize();
const [, rest] = splitProps(props, ["open", "onClose", "anchor"]); const [props, rest] = splitProps(oprops, [
"open",
"onClose",
"anchor",
"MenuListProps",
"children",
]);
const [anchorPos, setAnchorPos] = createSignal<{ const [anchorPos, setAnchorPos] = createSignal<{
left?: number; left?: number;
@ -106,16 +141,30 @@ const Menu: Component<MenuProps> = (props) => {
let openAnimationOrigin: "lt" | "rt" = "lt"; let openAnimationOrigin: "lt" | "rt" = "lt";
createEffect(() => { const animateOpen = () => {
if (props.open) {
const a = props.anchor(); const a = props.anchor();
const { width } = windowSize;
const { left, top, right, e } = a;
const isOpened = root.open;
// There are incomplete animations.
// For `getBoundingClientRect()`, WebKit reports the initial state
// of the element, whilst Firefox reports the final state.
//
// We skip if animations are still on the element
// to avoid the problem on WebKit.
// Here use the final state.
//
// This is a dirty workaround. It's here because the feature is still
// works with it.
// I am curious that why the ones on the other parts are works. (Rubicon)
if (root.getAnimations().length > 0) {
return;
}
if (!root.open) {
root.showModal(); root.showModal();
const rend = root.getBoundingClientRect(); const rend = root.getBoundingClientRect();
const { width } = windowSize;
const { left, top, right, e } = a;
if (left > width / 2) { if (left > width / 2) {
openAnimationOrigin = "rt"; openAnimationOrigin = "rt";
setAnchorPos({ setAnchorPos({
@ -123,34 +172,26 @@ const Menu: Component<MenuProps> = (props) => {
top, top,
e, e,
}); });
animateGrowFromTopRight(root, { easing: ANIM_CURVE_STD });
} else { } else {
openAnimationOrigin = "lt"; openAnimationOrigin = "lt";
setAnchorPos({ left, top, e }); setAnchorPos({ left, top, e });
}
const overflow = root.style.overflow; if (!isOpened) {
root.style.overflow = "hidden"; switch (openAnimationOrigin) {
const duration = (rend.height / 1600) * 1000; case "lt":
const easing = ANIM_CURVE_STD; animateGrowFromTopLeft(root, { easing: ANIM_CURVE_STD });
const animation = root.animate( break;
{ case "rt":
height: [`${rend.height / 2}px`, `${rend.height}px`], animateGrowFromTopRight(root, { easing: ANIM_CURVE_STD });
width: [`${(rend.width / 4) * 3}px`, `${rend.width}px`], break;
},
{
duration,
easing,
},
);
animation.addEventListener(
"finish",
() => (root.style.overflow = overflow),
);
} }
} else {
// TODO: update the pos
} }
};
createEffect(() => {
if (props.open) {
animateOpen();
} else { } else {
animateClose(); animateClose();
} }
@ -185,27 +226,42 @@ const Menu: Component<MenuProps> = (props) => {
} }
}; };
return ( const onDialogClick = (
<dialog event: MouseEvent & { currentTarget: HTMLDialogElement },
ref={root!} ) => {
onClose={props.onClose} event.stopPropagation();
onClick={(e) => { if (event.currentTarget !== event.target) return;
if (e.target === root) { if (!event.currentTarget.open) return;
const rect = event.currentTarget.getBoundingClientRect();
const isNotInDialog =
event.clientY < rect.top ||
event.clientY > rect.bottom ||
event.clientX < rect.left ||
event.clientX > rect.right;
if (isNotInDialog) {
if (props.onClose) { if (props.onClose) {
if (Array.isArray(props.onClose)) { if (Array.isArray(props.onClose)) {
props.onClose[0](props.onClose[1], e); props.onClose[0](props.onClose[1], event);
} else { } else {
( (
props.onClose as ( props.onClose as (
event: Event & { currentTarget: HTMLDialogElement }, event: Event & { currentTarget: HTMLDialogElement },
) => void ) => void
)(e); )(event);
} }
} }
e.stopPropagation();
} }
}} };
class={`Menu e${anchorPos().e || 0}`}
return (
<dialog
ref={root!}
onClose={props.onClose}
onCancel={props.onClose}
onClick={onDialogClick}
class={`Menu e${anchorPos().e || "0"}`}
style={{ style={{
left: px(anchorPos().left), left: px(anchorPos().left),
top: px(anchorPos().top), top: px(anchorPos().top),

View file

@ -7,11 +7,7 @@ import {
ListItemText, ListItemText,
MenuItem, MenuItem,
} from "@suid/material"; } from "@suid/material";
import { import { Show, createUniqueId, type ParentComponent } from "solid-js";
Show,
createUniqueId,
type ParentComponent,
} from "solid-js";
import { import {
Settings as SettingsIcon, Settings as SettingsIcon,
Bookmark as BookmarkIcon, Bookmark as BookmarkIcon,
@ -39,9 +35,7 @@ const ProfileMenuButton: ParentComponent<{
const [open, state] = createManagedMenuState(); const [open, state] = createManagedMenuState();
const onClick = ( const onClick = (event: { currentTarget: HTMLElement }) => {
event: MouseEvent & { currentTarget: HTMLButtonElement },
) => {
open(event.currentTarget.getBoundingClientRect()); open(event.currentTarget.getBoundingClientRect());
}; };
@ -54,8 +48,8 @@ const ProfileMenuButton: ParentComponent<{
sx={{ borderRadius: "50%" }} sx={{ borderRadius: "50%" }}
id={buttonId} id={buttonId}
onClick={onClick} onClick={onClick}
aria-controls={open() ? menuId : undefined} aria-controls={state.open ? menuId : undefined}
aria-expanded={open() ? "true" : undefined} aria-expanded={state.open ? "true" : "false"}
> >
<Avatar <Avatar
alt={`${inf()?.displayName}'s avatar`} alt={`${inf()?.displayName}'s avatar`}