Changes
9 changed files (+292/-3)
-
-
-
@@ -25,6 +25,7 @@ "dependencies": {"@bufbuild/protobuf": "^2.2.2", "@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-toast": "^1.2.4", "@radix-ui/themes": "^3.1.6", "@tanstack/react-query": "^5.62.8", "@yamori/idb_backend": "workspace:*",
-
-
-
@@ -4,6 +4,8 @@import { Theme } from "@radix-ui/themes"; import { type FC, type ReactNode, useEffect, useState } from "react"; import { ToastProvider } from "../contexts/Toast.tsx"; export interface ThemeProviderProps { className?: string | undefined;
-
@@ -29,7 +31,7 @@ }, [isDark]);return ( <Theme className={className} appearance={isDark.matches ? "dark" : "light"}> {children} <ToastProvider>{children}</ToastProvider> </Theme> ); };
-
-
-
@@ -0,0 +1,31 @@/** * SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> * SPDX-License-Identifier: AGPL-3.0-only */ .toast { align-items: center; background-color: var(--accent-3); box-shadow: var(--shadow-3); } @keyframes slide-in { from { transform: translateX(-20px); opacity: 0; } to { transform: translateX(0); opacity: 1; } } .toast[data-state="open"] { animation: 0.3s 0s ease-out 1 both slide-in; } .toast[data-swipe="move"] { transform: translateX(var(--radix-toast-swipe-move-x)); }
-
-
-
@@ -0,0 +1,57 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { CursorArrowIcon } from "@radix-ui/react-icons"; import { Provider, Viewport } from "@radix-ui/react-toast"; import { Flex, Button } from "@radix-ui/themes"; import type { Meta, StoryObj } from "@storybook/react"; import { Toast } from "./Toast.tsx"; export default { component: Toast, args: { title: "Title", description: "Description", icon: <CursorArrowIcon />, action: <Button size="1">やり直す</Button>, dismissible: true, open: true, }, render(args) { return ( <Provider> <Toast {...args} /> <Viewport asChild> <Flex /> </Viewport> </Provider> ); }, } satisfies Meta<typeof Toast>; type Story = StoryObj<typeof Toast>; export const Info: Story = { args: { severity: "info", }, }; export const Success: Story = { args: { severity: "success", }, }; export const Warn: Story = { args: { severity: "warn", }, }; export const Danger: Story = { args: { severity: "danger", }, };
-
-
-
@@ -0,0 +1,90 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as RadixToast from "@radix-ui/react-toast"; import { Button, Callout, Flex, Text } from "@radix-ui/themes"; import { type ComponentProps, type FC, type ReactElement, type ReactNode } from "react"; import css from "./Toast.module.css"; export type Severity = "info" | "success" | "warn" | "danger"; function severityToColor( severity: Severity, ): ComponentProps<(typeof Callout)["Root"]>["color"] { switch (severity) { case "info": return "blue"; case "success": return "grass"; case "warn": return "amber"; case "danger": return "red"; } } export interface ToastProps extends Omit<RadixToast.ToastProps, "asChild" | "children" | "title"> { icon?: ReactElement; title: ReactNode; description?: ReactNode; action?: ReactElement; severity?: Severity; dismissible?: boolean; } export const Toast: FC<ToastProps> = ({ icon, title, description, action, dismissible, severity = "info", ...rest }) => { return ( <RadixToast.Root {...rest} asChild> <Callout.Root className={css.toast} size="1" color={severityToColor(severity)} style={{ alignItems: "center", backgroundColor: "var(--accent-3)" }} > {icon && <Callout.Icon>{icon}</Callout.Icon>} <Flex asChild align="center" gap="4" width="100%"> <Callout.Text> <Flex as="span" flexGrow="1" flexShrink="1" direction="column"> <RadixToast.Title asChild> <Text weight="bold">{title}</Text> </RadixToast.Title> {description && ( <RadixToast.Description asChild> <Text size="2">{description}</Text> </RadixToast.Description> )} </Flex> {(action || dismissible) && ( <Flex as="span" direction="column" gap="1"> { // <RadixToast.Action> は altText がないとエラーになる不具合が // あるため利用していない。 action } {dismissible && ( <RadixToast.Close asChild> <Button type="button" size="1" variant="surface"> 閉じる </Button> </RadixToast.Close> )} </Flex> )} </Callout.Text> </Flex> </Callout.Root> </RadixToast.Root> ); };
-
-
-
@@ -0,0 +1,80 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as RadixToast from "@radix-ui/react-toast"; import { Flex } from "@radix-ui/themes"; import { createContext, type FC, type ReactNode, use, useMemo, useState } from "react"; import { Toast, type ToastProps } from "../components/Toast.tsx"; interface ToastController { open(props: ToastProps): () => void; } const ToastContext = createContext<ToastController>({ open() { return () => {}; }, }); export interface ToastProviderProps { children: ReactNode; } export const ToastProvider: FC<ToastProviderProps> = ({ children }) => { const [toasts, setToasts] = useState<readonly (ToastProps & { key: string })[]>([]); const controller = useMemo<ToastController>(() => { return { open(props) { const queue: ToastProps & { key: string } = { ...props, key: crypto.randomUUID(), onOpenChange(open) { props.onOpenChange?.(open); if (!open) { setToasts((prev) => prev.filter((p) => p !== queue)); } }, }; setToasts((prev) => [...prev, queue]); return () => void setToasts((prev) => prev.filter((p) => p !== queue)); }, }; }, []); return ( <RadixToast.Provider duration={3_000}> <ToastContext.Provider value={controller}>{children}</ToastContext.Provider> {toasts.map(({ key, ...toast }) => ( <Toast key={key} {...toast} style={{ pointerEvents: "auto", viewTransitionName: `toast-${key}` }} /> ))} <RadixToast.Viewport asChild> <Flex position="absolute" top={{ initial: "0", md: "unset" }} right="0" bottom={{ md: "0" }} left={{ initial: "0", md: "unset" }} p="2" direction={{ initial: "column", md: "column-reverse" }} align="center" gap="2" overflowX="hidden" style={{ pointerEvents: "none", zIndex: 999 }} /> </RadixToast.Viewport> </RadixToast.Provider> ); }; export function useToast() { return use(ToastContext); }
-
-
-
@@ -4,6 +4,7 @@import type { Meta, StoryObj } from "@storybook/react"; import { withMockedBackend } from "../../../.storybook/decorators/withMockedBackend.tsx"; import { withInmemoryRouter } from "../../../.storybook/decorators/withInmemoryRouter.tsx"; import { Create } from "../../mocks/yamori/workspace/v1/workspace_service.ts";
-
@@ -11,6 +12,7 @@ import { CreationForm } from "./CreationForm.tsx";export default { component: CreationForm, decorators: [withInmemoryRouter()], } satisfies Meta<typeof CreationForm>; type Story = StoryObj<typeof CreationForm>;
-
-
-
@@ -1,6 +1,7 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { CheckCircledIcon } from "@radix-ui/react-icons"; import { Button, Flex, TextField } from "@radix-ui/themes"; import { CreateRequestSchema } from "@yamori/proto/yamori/workspace/v1/create_request_pb.js"; import { CreateResponseSchema } from "@yamori/proto/yamori/workspace/v1/create_response_pb.js";
-
@@ -10,6 +11,7 @@import { ManagedErrorCallout } from "../../components/ErrorCallout.ts"; import * as FormField from "../../components/FormField.ts"; import { useMethodMutation } from "../../contexts/Service.tsx"; import { useToast } from "../../contexts/Toast.tsx"; import { IllegalMessageError } from "../../errors/IllegalMessageError.ts"; export interface CreationFormProps {
-
@@ -19,6 +21,8 @@ onCreated?(): void;} export const CreationForm: FC<CreationFormProps> = ({ className, onCreated }) => { const toast = useToast(); const form = useForm<{ displayName: string; }>({
-
@@ -39,7 +43,11 @@ schema: CreateResponseSchema,}, mapResponse(resp) { if (resp.result.case === "ok") { return resp.result.value; if (!resp.result.value.workspace) { throw new IllegalMessageError(resp); } return resp.result.value.workspace; } if (typeof resp.result.case === "string") {
-
@@ -49,8 +57,26 @@throw new IllegalMessageError(resp); }, options: { onSuccess() { onSuccess(workspace) { onCreated?.(); const dismiss = toast.open({ icon: <CheckCircledIcon />, severity: "success", title: `「${workspace.displayName}」を作成しました`, action: ( <Button asChild size="1" onClick={() => { dismiss(); }} > <a href={`/${workspace.id?.value}/workers`}>開く</a> </Button> ), type: "foreground", }); }, }, });
-