From 798180a5c5f43f861fc28cc98a8178f877c2d36d Mon Sep 17 00:00:00 2001 From: Kazunari Hara Date: Thu, 13 Jun 2024 15:43:16 +0900 Subject: [PATCH 1/3] feat(spindle-ui): use Popover API, Anchor positioning API --- .../src/DropdownMenu/DropdownMenu.css | 154 +++++++------ .../src/DropdownMenu/DropdownMenu.test.tsx | 10 + .../src/DropdownMenu/DropdownMenu.tsx | 206 +++--------------- 3 files changed, 121 insertions(+), 249 deletions(-) diff --git a/packages/spindle-ui/src/DropdownMenu/DropdownMenu.css b/packages/spindle-ui/src/DropdownMenu/DropdownMenu.css index 017babcf2..28aa5fbce 100644 --- a/packages/spindle-ui/src/DropdownMenu/DropdownMenu.css +++ b/packages/spindle-ui/src/DropdownMenu/DropdownMenu.css @@ -3,8 +3,8 @@ * NOTE: Styles can be overridden with "--DropdownMenu-*" variables */ :root { - --DropdownMenu-z-index: 1; --DropdownMenu-onFocus-outlineColor: var(--color-focus-clarity); + --DropdownMenu-menu-gap: 8px; } .spui-DropdownMenu { @@ -15,67 +15,107 @@ .spui-DropdownMenu-menu { animation: 0.3s spui-DropdownMenu-fade-in; background-color: var(--color-surface-primary); + border: none; border-radius: 12px; box-shadow: 0px 11px 28px rgba(8, 18, 26, 0.12); box-sizing: border-box; + display: none; list-style: none; margin: 0; + opacity: 0; padding: 12px 0; position: absolute; + transition: + opacity 300ms, + display 300ms allow-discrete, + overlay 300ms allow-discrete; width: 256px; - z-index: var(--DropdownMenu-z-index); } -/* stylelint-disable-next-line plugin/selector-bem-pattern */ -.spui-DropdownMenu-menu.is-fade-out { - animation: 0.3s spui-DropdownMenu-fade-out; - opacity: 0; +.spui-DropdownMenu-menu:popover-open { + display: block; + opacity: 1; + + @starting-style { + opacity: 0; + } } -.spui-DropdownMenu-menu--topLeft, -.spui-DropdownMenu-menu--topCenter, .spui-DropdownMenu-menu--topRight { - margin-bottom: 8px; + inset-area: top span-left; + margin: 0 0 var(--DropdownMenu-menu-gap); } -.spui-DropdownMenu-menu--bottomLeft, -.spui-DropdownMenu-menu--bottomCenter, -.spui-DropdownMenu-menu--bottomRight { - margin-top: 8px; +.spui-DropdownMenu-menu--topCenter { + inset-area: top; + margin: 0 0 var(--DropdownMenu-menu-gap); } -.spui-DropdownMenu-menu--topLeft, -.spui-DropdownMenu-menu--bottomLeft { - left: 0; +.spui-DropdownMenu-menu--topLeft { + inset-area: top span-right; + margin: 0 0 var(--DropdownMenu-menu-gap); } -.spui-DropdownMenu-menu--topRight, -.spui-DropdownMenu-menu--bottomRight { - right: 0; +.spui-DropdownMenu-menu--rightTop { + inset-area: right span-bottom; + margin: 0 0 0 var(--DropdownMenu-menu-gap); +} + +.spui-DropdownMenu-menu--rightCenter { + inset-area: right center; + margin: 0 0 0 var(--DropdownMenu-menu-gap); } -.spui-DropdownMenu-menu--rightTop, -.spui-DropdownMenu-menu--rightCenter, .spui-DropdownMenu-menu--rightBottom { - /* Menuの横幅256px + margin8px */ - right: -264px; + inset-area: right span-top; + margin: 0 0 0 var(--DropdownMenu-menu-gap); } -.spui-DropdownMenu-menu--leftTop, -.spui-DropdownMenu-menu--leftCenter, -.spui-DropdownMenu-menu--leftBottom { - /* Menuの横幅256px + margin8px */ - left: -264px; +.spui-DropdownMenu-menu--bottomRight { + inset-area: bottom span-left; + margin: var(--DropdownMenu-menu-gap) 0 0; +} + +.spui-DropdownMenu-menu--bottomCenter { + inset-area: bottom; + margin: var(--DropdownMenu-menu-gap) 0 0; +} + +.spui-DropdownMenu-menu--bottomLeft { + inset-area: bottom span-right; + margin: var(--DropdownMenu-menu-gap) 0 0; } -.spui-DropdownMenu-menu--rightTop, .spui-DropdownMenu-menu--leftTop { - top: 0; + inset-area: left span-bottom; + margin: 0 var(--DropdownMenu-menu-gap) 0 0; +} + +.spui-DropdownMenu-menu--leftCenter { + inset-area: left center; + margin: 0 var(--DropdownMenu-menu-gap) 0 0; } -.spui-DropdownMenu-menu--rightBottom, .spui-DropdownMenu-menu--leftBottom { - bottom: 0; + inset-area: left span-top; + margin: 0 var(--DropdownMenu-menu-gap) 0 0; +} + +/* TODO: use position-try */ +@media screen and (max-width: 768px) { + .spui-DropdownMenu-menu--rightTop, + .spui-DropdownMenu-menu--rightCenter, + .spui-DropdownMenu-menu--rightBottom { + inset-area: bottom span-right; + margin: var(--DropdownMenu-menu-gap) 0 0; + } + + .spui-DropdownMenu-menu--leftTop, + .spui-DropdownMenu-menu--leftCenter, + .spui-DropdownMenu-menu--leftBottom { + inset-area: bottom span-left; + margin: var(--DropdownMenu-menu-gap) 0 0; + } } .spui-DropdownMenu-menuButton { @@ -107,8 +147,8 @@ } .spui-DropdownMenu-menuButton:focus-visible { - outline: 2px solid var(--DropdownMenu-onFocus-outlineColor); - z-index: var(--DropdownMenu-z-index); + outline-color: var(--DropdownMenu-onFocus-outlineColor); + outline-width: 2px; } .spui-DropdownMenu-menuItem .spui-DropdownMenu-menuButton::before { @@ -149,52 +189,8 @@ margin: 4px 0 0; } -@keyframes spui-DropdownMenu-fade-in { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -@keyframes spui-DropdownMenu-fade-out { - from { - opacity: 1; - } - to { - opacity: 0; - } -} - -@media screen and (max-width: 768px) { - .spui-DropdownMenu-menu--rightTop, - .spui-DropdownMenu-menu--rightCenter, - .spui-DropdownMenu-menu--rightBottom { - bottom: auto; - left: 0; - margin-top: 8px; - top: auto !important; - } - - .spui-DropdownMenu-menu--leftTop, - .spui-DropdownMenu-menu--leftCenter, - .spui-DropdownMenu-menu--leftBottom { - bottom: auto; - left: auto; - margin-top: 8px; - right: 0; - top: auto !important; - } -} - @media (prefers-reduced-motion: reduce) { .spui-DropdownMenu-menu { - animation: 0s spui-DropdownMenu-fade-in; - } - - /* stylelint-disable-next-line plugin/selector-bem-pattern */ - .spui-DropdownMenu-menu.is-fade-out { - animation: 0s spui-DropdownMenu-fade-out; + transition-duration: 0.1s; } } diff --git a/packages/spindle-ui/src/DropdownMenu/DropdownMenu.test.tsx b/packages/spindle-ui/src/DropdownMenu/DropdownMenu.test.tsx index ef28618b7..519922d96 100644 --- a/packages/spindle-ui/src/DropdownMenu/DropdownMenu.test.tsx +++ b/packages/spindle-ui/src/DropdownMenu/DropdownMenu.test.tsx @@ -50,6 +50,7 @@ describe('', () => { triggerButton ', () => { triggerButton ', () => { triggerButton ', () => { triggerButton ', () => { triggerButton ', () => { triggerButton ', () => { triggerButton ', () => { triggerButton ', () => { triggerButton ', () => { triggerButton + +import React, { useCallback, useEffect, useRef, useState } from 'react'; + +declare module 'react' { + interface CSSProperties { + anchorName?: string; + positionAnchor?: string; + } +} type Variant = | 'text' @@ -30,18 +39,16 @@ interface ListItemProps extends DefaultProps { } interface ListProps extends DefaultProps { - id?: string; - onClose: () => void; - open: boolean; + id: string; + onClose?: (state: 'open' | 'closed') => void; + open?: boolean; + popover?: 'auto' | 'manual'; position?: Position; triggerRef: React.RefObject; variant?: Variant; } export const BLOCK_NAME = 'spui-DropdownMenu'; -const FADE_IN_ANIMATION = 'spui-DropdownMenu-fade-in'; -const CLOSE_KEY_LIST = ['ESCAPE', 'ESC']; -const MENU_WIDTH = 256; const Caption = ({ children }: DefaultProps) => { return

{children}

; @@ -56,131 +63,53 @@ const List = ({ id, onClose, open, + popover = 'auto', position = 'leftTop', - triggerRef, variant = 'text', + triggerRef, }: ListProps) => { - const menuEl = useRef(null); - const [fadeOut, setFadeOut] = useState(false); - const [triggerHeight, setTriggerHeight] = useState(0); - const [triggerWidth, setTriggerWidth] = useState(0); - const [menuHeight, setMenuHeight] = useState(0); - - const onClickBody = useCallback( - (e: MouseEvent) => { - if (!open) return; - if (triggerRef.current && e.composedPath().includes(triggerRef.current)) - return; - const menuEl = document.querySelector(`.${BLOCK_NAME}-menu`); - if (menuEl && e.composedPath().includes(menuEl)) return; - - setFadeOut(true); - }, - [open, setFadeOut, triggerRef], - ); - - const onClickCloser = useCallback(() => { - setFadeOut(true); - triggerRef.current?.focus(); - }, [setFadeOut, triggerRef]); - - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - if (CLOSE_KEY_LIST.includes(e.key.toUpperCase())) { - onClickCloser(); - } + const menuEl = useRef void }>(null); + const [anchorName, setAnchorName] = useState(''); + + // TODO: close with menu item click + // NOTE: esc, backdrop click do not fire close event + const handleClose = useCallback( + (event: React.ToggleEvent) => { + onClose?.(event.newState); }, - [onClickCloser], + [onClose], ); - const handleAnimationEnd = useCallback( - (event: AnimationEvent) => { - if (event.animationName === FADE_IN_ANIMATION) return; - - onClose(); - setFadeOut(false); - }, - [onClose, setFadeOut], - ); - - // Triggerボタンの縦横幅を取得 useEffect(() => { - if (!triggerRef.current) return; - - const { height, width } = triggerRef.current.getBoundingClientRect(); - setTriggerHeight(height); - setTriggerWidth(width); - }, [triggerRef]); - - // Menuの縦幅を取得 - useEffect(() => { - if (!open) return; - if (!menuEl.current) return; - - const { height } = menuEl.current.getBoundingClientRect(); - setMenuHeight(height); - }, [open]); - - useEffect(() => { - const menu = menuEl.current; - if (open) { - menu?.addEventListener('animationend', handleAnimationEnd, false); + // triggerRef is for backword compatibility, id string is more reliable + if (triggerRef.current) { + setAnchorName(triggerRef.current.id); } - - return () => - menu?.removeEventListener('animationend', handleAnimationEnd, false); - }, [menuEl, handleAnimationEnd, open]); - - useEffect(() => { - if (open) { - window.addEventListener('click', onClickBody, false); - } - - return () => window.removeEventListener('click', onClickBody, false); - }, [onClickBody, open]); + }, [triggerRef]); useEffect(() => { + // initial state if (open) { - window.addEventListener('keydown', handleKeyDown); + menuEl.current?.showPopover(); } - return () => window.removeEventListener('keydown', handleKeyDown); - }, [handleKeyDown, open]); - - if (!open) { - return <>; - } - - let top; - let bottom; - let left; - if (['topLeft', 'topCenter', 'topRight'].includes(position)) { - bottom = `${triggerHeight}px`; - } - if (['topCenter', 'bottomCenter'].includes(position)) { - left = `-${(MENU_WIDTH - triggerWidth) / 2}px`; - } - if (['rightCenter', 'leftCenter'].includes(position)) { - top = `-${(menuHeight - triggerHeight) / 2}px`; - } - if (['bottomLeft', 'bottomCenter', 'bottomRight'].includes(position)) { - top = `${triggerHeight}px`; - } + }, [menuEl, open]); return ( @@ -198,68 +127,6 @@ const ListItem = ({ children, icon, onClick }: ListItemProps) => { ); }; -// Storybookでのpositionプロパティのバリエーション確認用 -const Position = ({ - children, - position = 'leftTop', - triggerRef, -}: Omit) => { - const menuEl = useRef(null); - const [triggerHeight, setTriggerHeight] = useState(0); - const [triggerWidth, setTriggerWidth] = useState(0); - const [menuHeight, setMenuHeight] = useState(0); - - // Triggerボタンの縦横幅を取得 - useEffect(() => { - if (!triggerRef.current) return; - - const { height, width } = triggerRef.current.getBoundingClientRect(); - setTriggerHeight(height); - setTriggerWidth(width); - }, [triggerRef]); - - // Menuの縦幅を取得 - useEffect(() => { - if (!menuEl.current) return; - - const { height } = menuEl.current.getBoundingClientRect(); - setMenuHeight(height); - }, []); - - let top; - let bottom; - let left; - if (['topLeft', 'topCenter', 'topRight'].includes(position)) { - bottom = `${triggerHeight}px`; - } - if (['topCenter', 'bottomCenter'].includes(position)) { - left = `-${(MENU_WIDTH - triggerWidth) / 2}px`; - } - if (['rightCenter', 'leftCenter'].includes(position)) { - top = `-${(menuHeight - triggerHeight) / 2}px`; - } - if (['bottomLeft', 'bottomCenter', 'bottomRight'].includes(position)) { - top = `${triggerHeight}px`; - } - - return ( -
    - {children} -
- ); -}; - const Title = ({ children }: DefaultProps) => { return

{children}

; }; @@ -269,6 +136,5 @@ export const DropdownMenu = { Frame, List, ListItem, - Position, Title, }; From c5e618ea7b1ad0d819f63e743d2f32a517973b38 Mon Sep 17 00:00:00 2001 From: Kazunari Hara Date: Thu, 13 Jun 2024 15:43:43 +0900 Subject: [PATCH 2/3] test(spindle-ui): update story --- .../DropdownMenu.stories.example.tsx | 345 ++++++++++++------ .../src/DropdownMenu/DropdownMenu.stories.mdx | 3 + 2 files changed, 245 insertions(+), 103 deletions(-) diff --git a/packages/spindle-ui/src/DropdownMenu/DropdownMenu.stories.example.tsx b/packages/spindle-ui/src/DropdownMenu/DropdownMenu.stories.example.tsx index 7e3793be9..5e6c3ffb1 100644 --- a/packages/spindle-ui/src/DropdownMenu/DropdownMenu.stories.example.tsx +++ b/packages/spindle-ui/src/DropdownMenu/DropdownMenu.stories.example.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef } from 'react'; +import React, { useRef } from 'react'; import { actions } from '@storybook/addon-actions'; import { Button } from '../Button'; @@ -6,32 +6,23 @@ import { AllFill } from '../Icon'; import { DropdownMenu } from './DropdownMenu'; export function Text() { - const [open, setOpen] = useState(false); const triggerRef = useRef(null); - const onClick = () => { - setOpen((prevOpen) => !prevOpen); - }; - const onClose = () => { - setOpen(false); - }; return ( -
+
(null); - const onClick = () => { - setOpen((prevOpen) => !prevOpen); - }; - const onClose = () => { - setOpen(false); - }; return ( -
+
(null); - const onClick = () => { - setOpen((prevOpen) => !prevOpen); - }; - const onClose = () => { - setOpen(false); - }; return ( -
+
(null); - const onClick = () => { - setOpen((prevOpen) => !prevOpen); - }; - const onClose = () => { - setOpen(false); - }; return ( -
+
(null); + const triggerRefTopRight = useRef(null); + const triggerRefTopCenter = useRef(null); + const triggerRefTopLeft = useRef(null); + const triggerRefRightTop = useRef(null); + const triggerRefRightCenter = useRef(null); + const triggerRefRightBottom = useRef(null); + const triggerRefBottomRight = useRef(null); + const triggerRefBottomCenter = useRef(null); + const triggerRefBottomLeft = useRef(null); + const triggerRefLeftTop = useRef(null); + const triggerRefLeftCenter = useRef(null); + const triggerRefLeftBottom = useRef(null); return ( <>
- - + position: topRightを指定 - +
- - + position: topCenterを指定 - +
-
+
- - + position: topLeftを指定 - +
-
+
- - + position: rightTopを指定 - +
-
+
- - + position: rightCenterを指定 - +
-
+
- - + position: rightBottomを指定 - +
- - + position: bottomRightを指定 - +
- - position: bottomCenterを指定 - +
-
+
- - + position: bottomLeftを指定 - +
- - + position: leftTopを指定 - +
- - + position: leftCenterを指定 - +
- - + position: leftBottomを指定 - +
diff --git a/packages/spindle-ui/src/DropdownMenu/DropdownMenu.stories.mdx b/packages/spindle-ui/src/DropdownMenu/DropdownMenu.stories.mdx index 063782c86..384afea9a 100644 --- a/packages/spindle-ui/src/DropdownMenu/DropdownMenu.stories.mdx +++ b/packages/spindle-ui/src/DropdownMenu/DropdownMenu.stories.mdx @@ -27,6 +27,9 @@ import { code={``} /> +## Polyfill +DropdownMenuは[Popover API](https://developer.mozilla.org/docs/Web/API/Popover_API)と[Anchor Positioning API](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_anchor_positioning)を利用して作られています。非対応ブラウザで表示したい場合には[Popover Polyfill](https://github.com/oddbird/popover-polyfill)、[Anchor Positionin Polyfill](https://github.com/oddbird/css-anchor-positioning)を併用してください。 + ## 指定できるプロパティ ### DropdownMenu.List From 258dc65887e8c748832a91cb8a2d056ddd922d66 Mon Sep 17 00:00:00 2001 From: Kazunari Hara Date: Thu, 19 Sep 2024 09:59:55 +0900 Subject: [PATCH 3/3] chore(spindle-ui): load polyfull --- packages/spindle-ui/.storybook/preview-head.html | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/spindle-ui/.storybook/preview-head.html b/packages/spindle-ui/.storybook/preview-head.html index cea073813..2e346eca7 100644 --- a/packages/spindle-ui/.storybook/preview-head.html +++ b/packages/spindle-ui/.storybook/preview-head.html @@ -12,4 +12,10 @@ width: 1px; } +