diff --git a/package.json b/package.json index e36615a7..a61729fa 100644 --- a/package.json +++ b/package.json @@ -27,14 +27,15 @@ "type-check": "tsc --noEmit", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", - "test": "vitest", - "test:ui": "vitest --ui", - "coverage": "vitest run --coverage", + "test": "TZ=UTC vitest", + "test:ui": "TZ=UTC vitest --ui", + "coverage": "TZ=UTC vitest run --coverage", "test-storybook": "test-storybook", "test-storybook:ci": "pnpm dlx concurrently -k -s first -n \"SB,TEST\" -c \"magenta,blue\" \"pnpm run build-storybook --quiet && pnpm dlx http-server storybook-static --port 6006 --silent\" \"pnpm dlx wait-on tcp:6006 && pnpm run test-storybook --maxWorkers=2\"", "install-playwright": "playwright install --with-deps" }, "dependencies": { + "@floating-ui/react": "0.26.24", "@mdx-js/loader": "3.0.1", "@mdx-js/react": "3.0.1", "@next/env": "14.2.13", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 88096f50..5a3ac8ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@floating-ui/react': + specifier: 0.26.24 + version: 0.26.24(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@mdx-js/loader': specifier: 3.0.1 version: 3.0.1(webpack@5.93.0(@swc/core@1.7.3(@swc/helpers@0.5.5))(esbuild@0.21.5)) @@ -1321,6 +1324,27 @@ packages: resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@floating-ui/core@1.6.8': + resolution: {integrity: sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==} + + '@floating-ui/dom@1.6.11': + resolution: {integrity: sha512-qkMCxSR24v2vGkhYDo/UzxfJN3D4syqSjyuTFz6C7XcpU1pASPRieNI0Kj5VP3/503mOfYiGY891ugBX1GlABQ==} + + '@floating-ui/react-dom@2.1.2': + resolution: {integrity: sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/react@0.26.24': + resolution: {integrity: sha512-2ly0pCkZIGEQUq5H8bBK0XJmc1xIK/RM3tvVzY3GBER7IOD1UgmC2Y2tjj4AuS+TC+vTE1KJv2053290jua0Sw==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.8': + resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==} + '@hapi/hoek@9.3.0': resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==} @@ -6589,6 +6613,9 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tabbable@6.2.0: + resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==} + tailwind-merge@2.5.2: resolution: {integrity: sha512-kjEBm+pvD+6eAwzJL2Bi+02/9LFLal1Gs61+QB7HvTfQQ0aXwC5LGT8PEt1gS0CWKktKe6ysPTAy3cBC5MeiIg==} @@ -8361,6 +8388,31 @@ snapshots: '@eslint/js@8.57.1': {} + '@floating-ui/core@1.6.8': + dependencies: + '@floating-ui/utils': 0.2.8 + + '@floating-ui/dom@1.6.11': + dependencies: + '@floating-ui/core': 1.6.8 + '@floating-ui/utils': 0.2.8 + + '@floating-ui/react-dom@2.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@floating-ui/dom': 1.6.11 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@floating-ui/react@0.26.24(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@floating-ui/react-dom': 2.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@floating-ui/utils': 0.2.8 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tabbable: 6.2.0 + + '@floating-ui/utils@0.2.8': {} + '@hapi/hoek@9.3.0': {} '@hapi/topo@5.1.0': @@ -14916,6 +14968,8 @@ snapshots: symbol-tree@3.2.4: {} + tabbable@6.2.0: {} + tailwind-merge@2.5.2: {} tailwindcss@3.4.12: diff --git a/src/app/_styles/globals.css b/src/app/_styles/globals.css index 325196cb..d818559e 100644 --- a/src/app/_styles/globals.css +++ b/src/app/_styles/globals.css @@ -24,7 +24,7 @@ --bg-warning: 254 249 195; --bg-success: 220 252 231; --bg-info: 219 234 254; - --bg-hover: 243 244 246; + --bg-hover: 229 231 235; --bg-active: 209 213 219; --bg-transparent: transparent; --bg-back-drop: 0 0 0 0.5; diff --git a/src/components/dropdown-menu/dropdown-menu.stories.tsx b/src/components/dropdown-menu/dropdown-menu.stories.tsx new file mode 100644 index 00000000..8e0b0fca --- /dev/null +++ b/src/components/dropdown-menu/dropdown-menu.stories.tsx @@ -0,0 +1,58 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { DropdownMenu } from './dropdown-menu'; +import { MoonStar } from 'lucide-react'; + +const meta: Meta = { + title: 'components/dropdown-menu', + component: DropdownMenu.Root, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + + + console.log(1)} + /> + console.log(2)} + /> + console.log(3)} + /> + + + ), +}; + +export const TriggerByIcon: Story = { + render: () => ( + + } + label="Options" + /> + + console.log(1)} + /> + console.log(2)} + /> + console.log(3)} + /> + + + ), +}; diff --git a/src/components/dropdown-menu/dropdown-menu.tsx b/src/components/dropdown-menu/dropdown-menu.tsx new file mode 100644 index 00000000..064d55ba --- /dev/null +++ b/src/components/dropdown-menu/dropdown-menu.tsx @@ -0,0 +1,272 @@ +'use client'; + +import { + FC, + MouseEventHandler, + PropsWithChildren, + ReactNode, + useCallback, + useEffect, + useId, + useRef, + useState, +} from 'react'; +import { + autoUpdate, + flip, + FloatingFocusManager, + FloatingList, + FloatingPortal, + offset, + Placement, + useFloating, + useInteractions, + useListItem, + useListNavigation, +} from '@floating-ui/react'; +import { ChevronDown } from 'lucide-react'; +import { cn } from '@/utils/cn'; +import { + MenuContextProvider, + useMenuContent, + useMenuItem, + useMenuTrigger, +} from './hooks'; +import clsx from 'clsx'; +import { AnimatePresence, motion, Variants } from 'framer-motion'; + +const Root: FC> = ({ + children, + placement = 'bottom-start', +}) => { + const id = useId(); + const [isOpen, setIsOpen] = useState(false); + + const [activeIndex, setActiveIndex] = useState(null); + const itemElementsRef = useRef<(HTMLElement | null)[]>([]); + + const { refs, floatingStyles, context } = useFloating({ + strategy: 'fixed', + placement: placement, + open: isOpen, + whileElementsMounted: autoUpdate, + // 要素と8pxだけ離す + middleware: [ + offset(8), + flip({ + fallbackAxisSideDirection: 'end', + padding: 8, + }), + ], + transform: false, + }); + + const listNavigation = useListNavigation(context, { + listRef: itemElementsRef, + activeIndex, + onNavigate: setActiveIndex, + loop: true, + }); + const { getReferenceProps, getFloatingProps, getItemProps } = + useInteractions([listNavigation]); + + const toggleOpen = useCallback(() => { + setIsOpen((prev) => !prev); + }, []); + + const onOpen = useCallback(() => { + setIsOpen(true); + }, []); + + const onClose = useCallback(() => { + setIsOpen(false); + }, []); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [onClose]); + + return ( + + {children} + + ); +}; + +const contentMotionVariants = { + closed: { + scale: 0, + transition: { + delay: 0.15, + }, + }, + open: { + scale: 1, + transition: { + type: 'spring', + duration: 0.2, + }, + }, +} satisfies Variants; + +const Content: FC = ({ children }) => { + const { + id, + ref, + isOpen, + context, + setContentRef, + contentStyles, + contentProps, + itemElementsRef, + } = useMenuContent(); + + return ( + + + {isOpen && ( + + +
+ + + +
+
+
+ )} +
+
+ ); +}; + +const Item: FC<{ onClick: MouseEventHandler; label: string }> = ({ + label, + onClick, +}) => { + const { activeIndex, props } = useMenuItem({ onClick }); + + const item = useListItem({ label }); + return ( + + ); +}; + +const Trigger: FC<{ + text: string; +}> = ({ text }) => { + const { contentId, isOpen, setRef, props } = useMenuTrigger(); + + return ( + + ); +}; + +const IconTrigger: FC<{ + icon: ReactNode; + label: string; +}> = ({ icon, label }) => { + const { contentId, isOpen, setRef, props } = useMenuTrigger(); + + return ( + + ); +}; + +export const DropdownMenu = { + Root, + Content, + Item, + Trigger, + IconTrigger, +}; diff --git a/src/components/dropdown-menu/hooks.ts b/src/components/dropdown-menu/hooks.ts new file mode 100644 index 00000000..67aeb582 --- /dev/null +++ b/src/components/dropdown-menu/hooks.ts @@ -0,0 +1,133 @@ +'use client'; + +import { useClickAway } from '@/hooks/click-away'; +import { FloatingContext, ReferenceType } from '@floating-ui/react'; +import { + createContext, + CSSProperties, + HTMLProps, + MouseEventHandler, + MutableRefObject, + useContext, + useMemo, +} from 'react'; + +type MenuContext = { + rootId: string; + activeIndex: number | null; + isOpen: boolean; + toggleOpen: () => void; + onOpen: () => void; + onClose: () => void; + + context: FloatingContext; + triggerRef: MutableRefObject; + setTriggerRef: (node: ReferenceType | null) => void; + setContentRef: (node: HTMLElement | null) => void; + contentStyles: CSSProperties; + itemElementsRef: MutableRefObject<(HTMLElement | null)[]>; + getTriggerProps: ( + userProps?: HTMLProps, + ) => Record; + getContentProps: ( + userProps?: HTMLProps, + ) => Record; + getItemProps: ( + userProps?: Omit< + React.HTMLProps, + 'selected' | 'active' + >, + ) => Record; +}; + +const MenuContext = createContext(null); + +export const MenuContextProvider = MenuContext.Provider; + +const useMenuContext = (): MenuContext => { + const menu = useContext(MenuContext); + if (!menu) { + throw new Error( + 'useMenuContext must be used within a DropdownMenu.Root', + ); + } + + return menu; +}; + +export const useMenuContent = () => { + const menu = useMenuContext(); + const ref = useClickAway((event) => { + if (!open) { + return; + } + if ( + menu.triggerRef.current?.contains(event.target as HTMLElement) + ) { + return; + } + menu.onClose(); + }); + return useMemo( + () => ({ + id: `${menu.rootId}_list`, + ref: ref, + isOpen: menu.isOpen, + context: menu.context, + setContentRef: menu.setContentRef, + contentStyles: menu.contentStyles, + contentProps: menu.getContentProps(), + itemElementsRef: menu.itemElementsRef, + }), + [ref, menu], + ); +}; + +export const useMenuItem = ({ + onClick, +}: { + onClick: MouseEventHandler; +}) => { + const menu = useMenuContext(); + return useMemo( + () => ({ + activeIndex: menu.activeIndex, + props: menu.getItemProps({ + onClick: (e) => { + onClick(e); + menu.onClose(); + }, + }), + }), + [menu, onClick], + ); +}; + +export const useMenuTrigger = () => { + const menu = useMenuContext(); + return useMemo( + () => ({ + contentId: `${menu.rootId}_list`, + isOpen: menu.isOpen, + setRef: menu.setTriggerRef, + props: menu.getTriggerProps({ + onClick: menu.toggleOpen, + onKeyDown: (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + menu.toggleOpen(); + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + menu.onOpen(); + } + if (e.key === 'ArrowDown') { + e.preventDefault(); + menu.onOpen(); + } + }, + }), + }), + [menu], + ); +}; diff --git a/src/components/dropdown-menu/index.ts b/src/components/dropdown-menu/index.ts new file mode 100644 index 00000000..2759d3ce --- /dev/null +++ b/src/components/dropdown-menu/index.ts @@ -0,0 +1 @@ +export * from './dropdown-menu'; diff --git a/src/design/color.stories.tsx b/src/design/color.stories.tsx index e1b58470..bb0d3adb 100644 --- a/src/design/color.stories.tsx +++ b/src/design/color.stories.tsx @@ -129,7 +129,7 @@ const COLORS = { { name: 'Hover', Sample: , - lightCode: '#f3f4f6', + lightCode: '#e5e7eb', darkCode: '#d1d5db', }, { diff --git a/src/hooks/click-away/index.test.tsx b/src/hooks/click-away/index.test.tsx new file mode 100644 index 00000000..7fcfe98f --- /dev/null +++ b/src/hooks/click-away/index.test.tsx @@ -0,0 +1,44 @@ +import { fireEvent, render } from '@testing-library/react'; +import { useClickAway } from '.'; +import { FC } from 'react'; + +const OutsideClicker: FC<{ + callback: () => void; +}> = ({ callback }: { callback: () => void }) => { + const ref = useClickAway(callback); + return ( + <> +
Element
+
Outside
+ + ); +}; + +describe('useClickAway', () => { + it('領域外を触るとcallbackが呼び出される', () => { + const fn = vi.fn(); + + const { getByText } = render(); + const element = getByText('Element'); + const outsideElement = getByText('Outside'); + + const click = (el: Node) => { + fireEvent.mouseDown(el); + fireEvent.mouseUp(el); + }; + + expect(fn).not.toHaveBeenCalled(); + + click(element); + expect(fn).not.toHaveBeenCalled(); + + click(outsideElement); + expect(fn).toHaveBeenCalledOnce(); + + click(document.body); + expect(fn).toHaveBeenCalledTimes(2); + + click(document); + expect(fn).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/hooks/click-away/index.ts b/src/hooks/click-away/index.ts new file mode 100644 index 00000000..709c2776 --- /dev/null +++ b/src/hooks/click-away/index.ts @@ -0,0 +1,28 @@ +'use client'; + +import { MutableRefObject, useEffect, useRef } from 'react'; + +export const useClickAway = ( + callback: (e: Event) => void, +): MutableRefObject => { + const ref = useRef(null); + + useEffect(() => { + const handler: EventListener = (e) => { + const element = ref.current; + if (element && !element.contains(e.target as HTMLElement)) { + callback(e); + } + }; + + document.addEventListener('mousedown', handler); + document.addEventListener('touchstart', handler); + + return () => { + document.removeEventListener('mousedown', handler); + document.removeEventListener('touchstart', handler); + }; + }, [callback]); + + return ref; +}; diff --git a/src/hooks/clipboard/index.test.ts b/src/hooks/clipboard/index.test.ts index 7c190b25..f7241b07 100644 --- a/src/hooks/clipboard/index.test.ts +++ b/src/hooks/clipboard/index.test.ts @@ -46,7 +46,7 @@ describe('useClipboard', () => { }); const { result } = renderHook(() => useClipboard()); - act(async () => { + await act(async () => { await result.current.readClipboard(); }); diff --git a/src/hooks/window-size/index.test.ts b/src/hooks/window-size/index.test.ts new file mode 100644 index 00000000..24a66fdb --- /dev/null +++ b/src/hooks/window-size/index.test.ts @@ -0,0 +1,25 @@ +import { renderHook, act } from '@testing-library/react'; +import { useWindowSize } from '.'; + +describe('useWindowSize', () => { + it('windowサイズの変更に合わせて現在のwindowサイズを取得する', () => { + const initWindowSize = { width: 0, height: 0 }; + const resizedWindowSize = { width: 1000, height: 1000 }; + + window.innerWidth = initWindowSize.width; + window.innerHeight = initWindowSize.height; + + const { result } = renderHook(() => useWindowSize()); + + expect(result.current).toEqual(initWindowSize); + + window.innerWidth = resizedWindowSize.width; + window.innerHeight = resizedWindowSize.height; + + act(() => { + window.dispatchEvent(new Event('resize')); + }); + + expect(result.current).toEqual(resizedWindowSize); + }); +}); diff --git a/src/hooks/window-size/index.ts b/src/hooks/window-size/index.ts new file mode 100644 index 00000000..71eb2e18 --- /dev/null +++ b/src/hooks/window-size/index.ts @@ -0,0 +1,28 @@ +'use client'; + +import { useLayoutEffect, useState } from 'react'; + +type Size = { + width: number; + height: number; +}; + +export const useWindowSize = (): Size => { + const [size, setSize] = useState({ width: 0, height: 0 }); + + useLayoutEffect(() => { + const handleResize = () => { + setSize({ + width: window.innerWidth, + height: window.innerHeight, + }); + }; + + handleResize(); + window.addEventListener('resize', handleResize); + + return () => window.removeEventListener('resize', handleResize); + }, []); + + return size; +};