From 9a7710c070b7b80bdfd8d3bd31abdf5a5b8c970c Mon Sep 17 00:00:00 2001 From: thislight Date: Wed, 20 Nov 2024 00:27:52 +0800 Subject: [PATCH] Menu: fix #47, skip animateOpen if animating --- src/material/Menu.css | 1 + src/material/Menu.tsx | 182 ++++++++++++++++++---------- src/timelines/ProfileMenuButton.tsx | 14 +-- 3 files changed, 124 insertions(+), 73 deletions(-) diff --git a/src/material/Menu.css b/src/material/Menu.css index 1837db3..e3e11e5 100644 --- a/src/material/Menu.css +++ b/src/material/Menu.css @@ -7,6 +7,7 @@ width: max-content; box-shadow: var(--tutu-shadow-e8); contain: content; + overscroll-behavior: contain; &.e1 { box-shadow: var(--tutu-shadow-e9); diff --git a/src/material/Menu.tsx b/src/material/Menu.tsx index 0ca0f69..c0fe72b 100644 --- a/src/material/Menu.tsx +++ b/src/material/Menu.tsx @@ -1,6 +1,7 @@ import { useWindowSize } from "@solid-primitives/resize-observer"; import { MenuList } from "@suid/material"; import { + batch, createEffect, createSignal, splitProps, @@ -65,11 +66,39 @@ export function createManagedMenuState() { return !!anchor(); }, anchor: anchor as () => Anchor, - onClose: () => setAnchor(), + onClose: (event: Event) => { + event.preventDefault(); + return setAnchor(); + }, }, ] as const; } +function animateGrowFromTopLeft( + element: HTMLElement, + opts?: Omit, +) { + 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 * 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 MenuItem} from SUID as children. */ -const Menu: Component = (props) => { +const Menu: Component = (oprops) => { let root: HTMLDialogElement; const windowSize = useWindowSize(); - const [, rest] = splitProps(props, ["open", "onClose", "anchor"]); + const [props, rest] = splitProps(oprops, [ + "open", + "onClose", + "anchor", + "MenuListProps", + "children", + ]); const [anchorPos, setAnchorPos] = createSignal<{ left?: number; @@ -106,51 +141,57 @@ const Menu: Component = (props) => { let openAnimationOrigin: "lt" | "rt" = "lt"; + const animateOpen = () => { + 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; + } + + root.showModal(); + const rend = root.getBoundingClientRect(); + + if (left > width / 2) { + openAnimationOrigin = "rt"; + setAnchorPos({ + left: right - rend.width, + top, + e, + }); + } else { + openAnimationOrigin = "lt"; + setAnchorPos({ left, top, e }); + } + + if (!isOpened) { + switch (openAnimationOrigin) { + case "lt": + animateGrowFromTopLeft(root, { easing: ANIM_CURVE_STD }); + break; + case "rt": + animateGrowFromTopRight(root, { easing: ANIM_CURVE_STD }); + break; + } + } + }; + createEffect(() => { if (props.open) { - const a = props.anchor(); - - if (!root.open) { - root.showModal(); - const rend = root.getBoundingClientRect(); - - const { width } = windowSize; - const { left, top, right, e } = a; - if (left > width / 2) { - openAnimationOrigin = "rt"; - setAnchorPos({ - left: right - rend.width, - top, - e, - }); - - animateGrowFromTopRight(root, { easing: ANIM_CURVE_STD }); - } else { - openAnimationOrigin = "lt"; - setAnchorPos({ left, top, e }); - - const overflow = root.style.overflow; - root.style.overflow = "hidden"; - const duration = (rend.height / 1600) * 1000; - const easing = ANIM_CURVE_STD; - const animation = root.animate( - { - height: [`${rend.height / 2}px`, `${rend.height}px`], - width: [`${(rend.width / 4) * 3}px`, `${rend.width}px`], - }, - { - duration, - easing, - }, - ); - animation.addEventListener( - "finish", - () => (root.style.overflow = overflow), - ); - } - } else { - // TODO: update the pos - } + animateOpen(); } else { animateClose(); } @@ -185,27 +226,42 @@ const Menu: Component = (props) => { } }; + const onDialogClick = ( + event: MouseEvent & { currentTarget: HTMLDialogElement }, + ) => { + event.stopPropagation(); + if (event.currentTarget !== event.target) return; + 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 (Array.isArray(props.onClose)) { + props.onClose[0](props.onClose[1], event); + } else { + ( + props.onClose as ( + event: Event & { currentTarget: HTMLDialogElement }, + ) => void + )(event); + } + } + } + }; + return ( { - if (e.target === root) { - if (props.onClose) { - if (Array.isArray(props.onClose)) { - props.onClose[0](props.onClose[1], e); - } else { - ( - props.onClose as ( - event: Event & { currentTarget: HTMLDialogElement }, - ) => void - )(e); - } - } - e.stopPropagation(); - } - }} - class={`Menu e${anchorPos().e || 0}`} + onCancel={props.onClose} + onClick={onDialogClick} + class={`Menu e${anchorPos().e || "0"}`} style={{ left: px(anchorPos().left), top: px(anchorPos().top), diff --git a/src/timelines/ProfileMenuButton.tsx b/src/timelines/ProfileMenuButton.tsx index 25ff2c9..3202ae8 100644 --- a/src/timelines/ProfileMenuButton.tsx +++ b/src/timelines/ProfileMenuButton.tsx @@ -7,11 +7,7 @@ import { ListItemText, MenuItem, } from "@suid/material"; -import { - Show, - createUniqueId, - type ParentComponent, -} from "solid-js"; +import { Show, createUniqueId, type ParentComponent } from "solid-js"; import { Settings as SettingsIcon, Bookmark as BookmarkIcon, @@ -39,9 +35,7 @@ const ProfileMenuButton: ParentComponent<{ const [open, state] = createManagedMenuState(); - const onClick = ( - event: MouseEvent & { currentTarget: HTMLButtonElement }, - ) => { + const onClick = (event: { currentTarget: HTMLElement }) => { open(event.currentTarget.getBoundingClientRect()); }; @@ -54,8 +48,8 @@ const ProfileMenuButton: ParentComponent<{ sx={{ borderRadius: "50%" }} id={buttonId} onClick={onClick} - aria-controls={open() ? menuId : undefined} - aria-expanded={open() ? "true" : undefined} + aria-controls={state.open ? menuId : undefined} + aria-expanded={state.open ? "true" : "false"} >