Changes
14 changed files (+438/-0)
-
-
-
@@ -23,6 +23,7 @@ "react-dom": "19.x.x"}, "dependencies": { "@bufbuild/protobuf": "^2.2.2", "@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/themes": "^3.1.6", "@tanstack/react-query": "^5.62.8",
-
-
-
@@ -0,0 +1,38 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import type { Meta, StoryObj } from "@storybook/react"; import * as NavigationMenu from "./NavigationMenu.ts"; export default { component: NavigationMenu.Root, args: { children: ( <> <NavigationMenu.Group title="Foo"> <NavigationMenu.Item> <a href="#">Link</a> </NavigationMenu.Item> </NavigationMenu.Group> <NavigationMenu.Group title="Bar"> <NavigationMenu.Item> <button>Button</button> </NavigationMenu.Item> </NavigationMenu.Group> <NavigationMenu.Separator /> <NavigationMenu.Group title="Root"> <NavigationMenu.Group title="Nested"> <NavigationMenu.Item current> <button>Item</button> </NavigationMenu.Item> </NavigationMenu.Group> </NavigationMenu.Group> </> ), }, } satisfies Meta<(typeof NavigationMenu)["Root"]>; type Story = StoryObj<(typeof NavigationMenu)["Root"]>; export const Defaults: Story = {};
-
-
-
@@ -0,0 +1,7 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only export * from "./NavigationMenu/Root.tsx"; export * from "./NavigationMenu/Group.tsx"; export * from "./NavigationMenu/Item.tsx"; export * from "./NavigationMenu/Separator.tsx";
-
-
-
@@ -0,0 +1,15 @@/* * SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> * SPDX-License-Identifier: AGPL-3.0-only */ .caret { transition: transform 0.1s ease-out; } [data-state="closed"] > .caret { transform: rotate(-90deg); } .content[data-state="closed"] { display: none; }
-
-
-
@@ -0,0 +1,65 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as Collapsible from "@radix-ui/react-collapsible"; import { CaretDownIcon } from "@radix-ui/react-icons"; import { type BoxProps, Flex, Reset, Text } from "@radix-ui/themes"; import { type ContextType, type FC, type ReactNode, use, useCallback, useMemo, useState, } from "react"; import { GroupOpenContext } from "./GroupOpenContext.ts"; import css from "./Group.module.css"; import itemCss from "./Item.module.css"; export interface GroupProps extends Pick<BoxProps, "className" | "children" | "style"> { defaultOpen?: boolean; title: ReactNode; } export const Group: FC<GroupProps> = ({ defaultOpen, children, title, ...rest }) => { const ctx = use(GroupOpenContext); const [isOpen, setIsOpen] = useState(() => defaultOpen ?? false); const open = useCallback(() => { setIsOpen(true); ctx?.open(); }, [ctx?.open]); const ctxValue = useMemo<ContextType<typeof GroupOpenContext>>(() => { return { open }; }, [open]); return ( <Flex {...rest} asChild direction="column" gap="1"> <Collapsible.Root open={isOpen} onOpenChange={setIsOpen}> <Flex asChild className={itemCss.item} p="2" justify="between" align="center"> <Reset> <Collapsible.Trigger> <Text size="2" weight="bold"> {title} </Text> <CaretDownIcon className={css.caret} /> </Collapsible.Trigger> </Reset> </Flex> <GroupOpenContext.Provider value={ctxValue}> <Collapsible.Content asChild forceMount> <Flex className={css.content} direction="column" pl="3" gap="1"> {children} </Flex> </Collapsible.Content> </GroupOpenContext.Provider> </Collapsible.Root> </Flex> ); };
-
-
-
@@ -0,0 +1,8 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { createContext } from "react"; export const GroupOpenContext = createContext<{ open(): void; } | null>(null);
-
-
-
@@ -0,0 +1,28 @@/* * SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> * SPDX-License-Identifier: AGPL-3.0-only */ .item { border: 1px solid transparent; border-radius: var(--radius-4); color: var(--gray-11); } .item:hover { background-color: var(--gray-3); color: var(--gray-12); } @media (hover: hover) { .item:focus-visible { outline: 2px solid var(--focus-8); outline-offset: -1px; } } .item[aria-current="true"] { background-color: var(--accent-2); border-color: var(--accent-8); color: var(--accent-11); }
-
-
-
@@ -0,0 +1,43 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { type BoxProps, Box, Reset, Text } from "@radix-ui/themes"; import { type FC, type ReactElement, use, useEffect } from "react"; import { GroupOpenContext } from "./GroupOpenContext.ts"; import css from "./Item.module.css"; export interface ItemProps extends Pick<BoxProps, "className" | "style"> { /** * プラットフォームによってリンクなのかボタンなのか、など変わってくるため * 要素を受けるようにしている。 */ children: ReactElement; current?: boolean; } export const Item: FC<ItemProps> = ({ children, className = "", current, ...rest }) => { const ctx = use(GroupOpenContext); useEffect(() => { if (current) { ctx?.open(); } }, [current, ctx?.open]); return ( <Box {...rest} asChild className={`${css.item} ${className}`} p="2" aria-current={current} > <Text asChild size="2"> <Reset>{children}</Reset> </Text> </Box> ); };
-
-
-
@@ -0,0 +1,15 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { type BoxProps, Flex } from "@radix-ui/themes"; import { type FC } from "react"; export type RootProps = Pick<BoxProps, "className" | "children" | "style">; export const Root: FC<RootProps> = ({ children, ...rest }) => { return ( <Flex {...rest} direction="column" gap="1"> {children} </Flex> ); };
-
-
-
@@ -0,0 +1,14 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { Separator as RadixSeparator, type SeparatorProps as RadixSeparatorProps, } from "@radix-ui/themes"; import { type FC } from "react"; export type SeparatorProps = Omit<RadixSeparatorProps, "my" | "mt" | "mb" | "size">; export const Separator: FC<SeparatorProps> = (props) => { return <RadixSeparator {...props} size="4" my="2" />; };
-
-
-
@@ -0,0 +1,38 @@/* * SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> * SPDX-License-Identifier: AGPL-3.0-only */ .appbar { border-block-end: 1px solid var(--gray-a6); background-color: var(--color-panel-solid); z-index: 50; } .menu { background-color: var(--color-background); z-index: 30; view-transition-name: menu; } @keyframes slidein { from { transform: translateX(-100%); opacity: 0; } to { transform: translateX(0px); opacity: 1; } } ::view-transition-new(menu) { animation-name: slidein; } ::view-transition-old(menu) { animation-name: slidein; animation-direction: reverse; }
-
-
-
@@ -0,0 +1,66 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { Pencil2Icon, TrashIcon } from "@radix-ui/react-icons"; import { Button, IconButton } from "@radix-ui/themes"; import type { Meta, StoryObj } from "@storybook/react"; import * as NavigationMenu from "./NavigationMenu.ts"; import { WorkspacePagesLayout } from "./WorkspacePagesLayout.tsx"; export default { component: WorkspacePagesLayout, args: { title: "TITLE", menu: ( <NavigationMenu.Root> <NavigationMenu.Group title="グループ"> <NavigationMenu.Item current> <button>アイテム1</button> </NavigationMenu.Item> <NavigationMenu.Item> <button>アイテム2</button> </NavigationMenu.Item> <NavigationMenu.Item> <button>アイテム3</button> </NavigationMenu.Item> </NavigationMenu.Group> </NavigationMenu.Root> ), children: "CHILDREN", }, parameters: { layout: "fullscreen", }, } satisfies Meta<typeof WorkspacePagesLayout>; type Story = StoryObj<typeof WorkspacePagesLayout>; export const Defaults: Story = {}; export const MenuOpened: Story = { args: { defaultMenuOpened: true, }, }; export const WithTextAction: Story = { args: { actions: <Button>保存</Button>, }, }; export const WithIconActions: Story = { args: { actions: ( <> <IconButton variant="surface"> <TrashIcon /> </IconButton> <IconButton variant="surface"> <Pencil2Icon /> </IconButton> </> ), }, };
-
-
-
@@ -0,0 +1,100 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { HamburgerMenuIcon } from "@radix-ui/react-icons"; import { Box, type BoxProps, Container, Flex, Grid, Heading, IconButton, } from "@radix-ui/themes"; import { type FC, type ReactNode, useCallback, useState } from "react"; import { useViewTransition } from "../hooks/useViewTransition.ts"; import css from "./WorkspacePagesLayout.module.css"; export interface WorkspacePagesLayoutProps extends Pick<BoxProps, "className" | "children" | "style"> { menu: ReactNode; title: ReactNode; /** * この画面 (シーン) にグローバルな操作・アクション。 * Flex コンテナ内となるため、 Fragment で渡すとレイアウトされる。 */ actions?: ReactNode; defaultMenuOpened?: boolean; } export const WorkspacePagesLayout: FC<WorkspacePagesLayoutProps> = ({ menu, children, title, actions, defaultMenuOpened = false, ...rest }) => { const viewTransition = useViewTransition(); const [isMenuOpened, setIsMenuOpened] = useState(defaultMenuOpened); const toggleMenu = useCallback(() => { viewTransition(() => { setIsMenuOpened((prev) => !prev); }); }, [viewTransition]); return ( <Grid {...rest} inset="0" height="100%" columns={{ initial: "1", md: "minmax(15rem, max-content) minmax(0, 1fr)" }} rows="max-content minmax(0, 1fr)" gapY="2" > <Box className={css.appbar} display={{ initial: "none", md: "block" }} /> <Flex className={css.appbar} p="2" align="center" gapX="3"> <Box display={{ md: "none" }}> <IconButton variant="soft" onClick={toggleMenu}> <HamburgerMenuIcon /> </IconButton> </Box> <Container size={{ initial: "4", md: "2", lg: "3" }}> <Flex align="center" minHeight="var(--space-6)"> <Box flexGrow="1" flexShrink="1"> <Heading size="3">{title}</Heading> </Box> {actions && ( <Flex justify="end" gapX="1"> {actions} </Flex> )} </Flex> </Container> </Flex> <Box className={css.menu} p="2" gridRowStart="2" gridColumnStart="1" display={{ initial: isMenuOpened ? "block" : "none", md: "block" }} > {menu} </Box> <Container size={{ initial: "2", lg: "3" }} p="2" gridRowStart="2" gridColumnStart={{ initial: "1", md: "2" }} > {children} </Container> </Grid> ); };
-