Feat: 프로젝트 생성 모달 추가 - S11P21S002-64
This commit is contained in:
parent
fe3a5bda9c
commit
ddbf0de8a0
5
frontend/src/assets/icons/x.svg
Normal file
5
frontend/src/assets/icons/x.svg
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="X">
|
||||||
|
<path id="Icon" d="M24 8L8 24M8 8L24 24" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 226 B |
@ -0,0 +1,93 @@
|
|||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../ui/form';
|
||||||
|
import { Input } from '../ui/input';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import { RadioGroup, RadioGroupItem } from '../ui/radio-group';
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
projectName: z.string().max(50).min(1, {
|
||||||
|
message: '이름을 입력해주세요.',
|
||||||
|
}),
|
||||||
|
labelType: z.enum(['Classification', 'Detection', 'Segmentation']),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ProjectCreateFormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
const defaultValues: Partial<ProjectCreateFormValues> = {
|
||||||
|
projectName: '',
|
||||||
|
labelType: 'Classification',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ProjectCreateForm({ onSubmit }: { onSubmit: (data: ProjectCreateFormValues) => void }) {
|
||||||
|
const form = useForm<ProjectCreateFormValues>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="flex flex-col gap-5"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
name="projectName"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="body-strong">이름</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="이름을 입력해주세요."
|
||||||
|
maxLength={50}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
name="labelType"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="body-strong">레이블 종류</FormLabel>
|
||||||
|
<RadioGroup
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
className="flex gap-5"
|
||||||
|
>
|
||||||
|
{['Classification', 'Detection', 'Segmentation'].map((labelType) => (
|
||||||
|
<FormItem
|
||||||
|
key={labelType}
|
||||||
|
className="flex w-full items-center justify-center"
|
||||||
|
>
|
||||||
|
<FormLabel className="flex w-full cursor-pointer items-center justify-center rounded-lg bg-gray-100 px-4 py-3 text-gray-500 transition-colors [&:has([data-state=checked])]:bg-primary [&:has([data-state=checked])]:text-gray-50">
|
||||||
|
<FormControl>
|
||||||
|
<RadioGroupItem
|
||||||
|
value={labelType}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<span>{labelType}</span>
|
||||||
|
</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="outlinePrimary"
|
||||||
|
disabled={!form.formState.isValid}
|
||||||
|
>
|
||||||
|
프로젝트 만들기
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
18
frontend/src/components/ProjectCreateModal/index.stories.tsx
Normal file
18
frontend/src/components/ProjectCreateModal/index.stories.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import '@/index.css';
|
||||||
|
import ProjectCreateModal from '.';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'Modal/ProjectCreateModal',
|
||||||
|
component: ProjectCreateModal,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Default = () => (
|
||||||
|
<ProjectCreateModal
|
||||||
|
onClose={() => {
|
||||||
|
console.log('close');
|
||||||
|
}}
|
||||||
|
onSubmit={(data) => {
|
||||||
|
console.log(data);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
25
frontend/src/components/ProjectCreateModal/index.tsx
Normal file
25
frontend/src/components/ProjectCreateModal/index.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import ProjectCreateForm, { ProjectCreateFormValues } from './ProjectCreateForm';
|
||||||
|
import XIcon from '@/assets/icons/x.svg?react';
|
||||||
|
|
||||||
|
export default function ProjectCreateModal({
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (data: ProjectCreateFormValues) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex w-[610px] flex-col gap-10 rounded-3xl border px-10 py-5 shadow-lg">
|
||||||
|
<header className="flex gap-5">
|
||||||
|
<h1 className="small-title w-full">새 프로젝트</h1>
|
||||||
|
<button
|
||||||
|
className="flex h-8 w-8 items-center justify-center"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<XIcon className="stroke-gray-900" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<ProjectCreateForm onSubmit={onSubmit} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
59
frontend/src/components/ui/button.tsx
Normal file
59
frontend/src/components/ui/button.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'body-strong inline-flex items-center justify-center whitespace-nowrap rounded-lg ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-950 focus-visible:ring-offset-2 disabled:pointer-events-none dark:ring-offset-gray-950 dark:focus-visible:ring-gray-300',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
'bg-gray-900 text-gray-50 hover:bg-gray-900/90 dark:bg-gray-50 dark:text-gray-900 dark:hover:bg-gray-50/90',
|
||||||
|
destructive:
|
||||||
|
'bg-red-500 text-gray-50 hover:bg-red-500/90 dark:bg-red-900 dark:text-gray-50 dark:hover:bg-red-900/90',
|
||||||
|
outline:
|
||||||
|
'border border-gray-200 bg-white hover:bg-gray-100 hover:text-gray-900 dark:border-gray-800 dark:bg-gray-950 dark:hover:bg-gray-800 dark:hover:text-gray-50',
|
||||||
|
outlinePrimary:
|
||||||
|
'border border-primary text-primary hover:bg-primary hover:text-white dark:border-primary dark:text-primary dark:hover:bg-primary dark:hover:text-white disabled:border-gray-200 disabled:bg-white disabled:text-gray-500 disabled:hover:bg-gray-100 disabled:hover:text-gray-300',
|
||||||
|
secondary:
|
||||||
|
'bg-gray-100 text-gray-900 hover:bg-gray-100/80 dark:bg-gray-800 dark:text-gray-50 dark:hover:bg-gray-800/80',
|
||||||
|
ghost: 'hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-gray-800 dark:hover:text-gray-50',
|
||||||
|
link: 'text-gray-900 underline-offset-4 hover:underline dark:text-gray-50',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-10 px-4 py-2',
|
||||||
|
sm: 'h-9 rounded-md px-3',
|
||||||
|
lg: 'h-11 rounded-md px-8',
|
||||||
|
icon: 'h-10 w-10',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : 'button';
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
176
frontend/src/components/ui/form.tsx
Normal file
176
frontend/src/components/ui/form.tsx
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
ControllerProps,
|
||||||
|
FieldPath,
|
||||||
|
FieldValues,
|
||||||
|
FormProvider,
|
||||||
|
useFormContext,
|
||||||
|
} from "react-hook-form"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
|
||||||
|
const Form = FormProvider
|
||||||
|
|
||||||
|
type FormFieldContextValue<
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||||
|
> = {
|
||||||
|
name: TName
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||||
|
{} as FormFieldContextValue
|
||||||
|
)
|
||||||
|
|
||||||
|
const FormField = <
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||||
|
>({
|
||||||
|
...props
|
||||||
|
}: ControllerProps<TFieldValues, TName>) => {
|
||||||
|
return (
|
||||||
|
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||||
|
<Controller {...props} />
|
||||||
|
</FormFieldContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const useFormField = () => {
|
||||||
|
const fieldContext = React.useContext(FormFieldContext)
|
||||||
|
const itemContext = React.useContext(FormItemContext)
|
||||||
|
const { getFieldState, formState } = useFormContext()
|
||||||
|
|
||||||
|
const fieldState = getFieldState(fieldContext.name, formState)
|
||||||
|
|
||||||
|
if (!fieldContext) {
|
||||||
|
throw new Error("useFormField should be used within <FormField>")
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = itemContext
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: fieldContext.name,
|
||||||
|
formItemId: `${id}-form-item`,
|
||||||
|
formDescriptionId: `${id}-form-item-description`,
|
||||||
|
formMessageId: `${id}-form-item-message`,
|
||||||
|
...fieldState,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormItemContextValue = {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||||
|
{} as FormItemContextValue
|
||||||
|
)
|
||||||
|
|
||||||
|
const FormItem = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const id = React.useId()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItemContext.Provider value={{ id }}>
|
||||||
|
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||||
|
</FormItemContext.Provider>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormItem.displayName = "FormItem"
|
||||||
|
|
||||||
|
const FormLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { error, formItemId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(error && "text-red-500 dark:text-red-900", className)}
|
||||||
|
htmlFor={formItemId}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormLabel.displayName = "FormLabel"
|
||||||
|
|
||||||
|
const FormControl = React.forwardRef<
|
||||||
|
React.ElementRef<typeof Slot>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof Slot>
|
||||||
|
>(({ ...props }, ref) => {
|
||||||
|
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Slot
|
||||||
|
ref={ref}
|
||||||
|
id={formItemId}
|
||||||
|
aria-describedby={
|
||||||
|
!error
|
||||||
|
? `${formDescriptionId}`
|
||||||
|
: `${formDescriptionId} ${formMessageId}`
|
||||||
|
}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormControl.displayName = "FormControl"
|
||||||
|
|
||||||
|
const FormDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { formDescriptionId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
id={formDescriptionId}
|
||||||
|
className={cn("text-sm text-gray-500 dark:text-gray-400", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormDescription.displayName = "FormDescription"
|
||||||
|
|
||||||
|
const FormMessage = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, children, ...props }, ref) => {
|
||||||
|
const { error, formMessageId } = useFormField()
|
||||||
|
const body = error ? String(error?.message) : children
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
id={formMessageId}
|
||||||
|
className={cn("text-sm font-medium text-red-500 dark:text-red-900", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormMessage.displayName = "FormMessage"
|
||||||
|
|
||||||
|
export {
|
||||||
|
useFormField,
|
||||||
|
Form,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormMessage,
|
||||||
|
FormField,
|
||||||
|
}
|
25
frontend/src/components/ui/input.tsx
Normal file
25
frontend/src/components/ui/input.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export interface InputProps
|
||||||
|
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-950 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-800 dark:bg-gray-950 dark:ring-offset-gray-950 dark:placeholder:text-gray-400 dark:focus-visible:ring-gray-300",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Input.displayName = "Input"
|
||||||
|
|
||||||
|
export { Input }
|
24
frontend/src/components/ui/label.tsx
Normal file
24
frontend/src/components/ui/label.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
)
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Label }
|
42
frontend/src/components/ui/radio-group.tsx
Normal file
42
frontend/src/components/ui/radio-group.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
|
||||||
|
import { Circle } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const RadioGroup = React.forwardRef<
|
||||||
|
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Root
|
||||||
|
className={cn('grid gap-2', className)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
const RadioGroupItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'aspect-square h-4 w-4 rounded-full border border-gray-900 text-gray-900 ring-offset-white focus:outline-none focus-visible:ring-2 focus-visible:ring-gray-950 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-800 dark:text-gray-50 dark:ring-offset-gray-950 dark:focus-visible:ring-gray-300',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||||
|
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||||
|
</RadioGroupPrimitive.Indicator>
|
||||||
|
</RadioGroupPrimitive.Item>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem };
|
@ -1,52 +0,0 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/react';
|
|
||||||
import { fn } from '@storybook/test';
|
|
||||||
import { Button } from './Button';
|
|
||||||
|
|
||||||
// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export
|
|
||||||
const meta = {
|
|
||||||
title: 'Example/Button',
|
|
||||||
component: Button,
|
|
||||||
parameters: {
|
|
||||||
// Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout
|
|
||||||
layout: 'centered',
|
|
||||||
},
|
|
||||||
// This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
|
|
||||||
tags: ['autodocs'],
|
|
||||||
// More on argTypes: https://storybook.js.org/docs/api/argtypes
|
|
||||||
argTypes: {
|
|
||||||
backgroundColor: { control: 'color' },
|
|
||||||
},
|
|
||||||
// Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#action-args
|
|
||||||
args: { onClick: fn() },
|
|
||||||
} satisfies Meta<typeof Button>;
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
type Story = StoryObj<typeof meta>;
|
|
||||||
|
|
||||||
// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args
|
|
||||||
export const Primary: Story = {
|
|
||||||
args: {
|
|
||||||
primary: true,
|
|
||||||
label: 'Button',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Secondary: Story = {
|
|
||||||
args: {
|
|
||||||
label: 'Button',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Large: Story = {
|
|
||||||
args: {
|
|
||||||
size: 'large',
|
|
||||||
label: 'Button',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Small: Story = {
|
|
||||||
args: {
|
|
||||||
size: 'small',
|
|
||||||
label: 'Button',
|
|
||||||
},
|
|
||||||
};
|
|
@ -1,48 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import './button.css';
|
|
||||||
|
|
||||||
export interface ButtonProps {
|
|
||||||
/**
|
|
||||||
* Is this the principal call to action on the page?
|
|
||||||
*/
|
|
||||||
primary?: boolean;
|
|
||||||
/**
|
|
||||||
* What background color to use
|
|
||||||
*/
|
|
||||||
backgroundColor?: string;
|
|
||||||
/**
|
|
||||||
* How large should the button be?
|
|
||||||
*/
|
|
||||||
size?: 'small' | 'medium' | 'large';
|
|
||||||
/**
|
|
||||||
* Button contents
|
|
||||||
*/
|
|
||||||
label: string;
|
|
||||||
/**
|
|
||||||
* Optional click handler
|
|
||||||
*/
|
|
||||||
onClick?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Primary UI component for user interaction
|
|
||||||
*/
|
|
||||||
export const Button = ({
|
|
||||||
primary = false,
|
|
||||||
size = 'medium',
|
|
||||||
backgroundColor,
|
|
||||||
label,
|
|
||||||
...props
|
|
||||||
}: ButtonProps) => {
|
|
||||||
const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary';
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={['storybook-button', `storybook-button--${size}`, mode].join(' ')}
|
|
||||||
style={{ backgroundColor }}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,33 +0,0 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/react';
|
|
||||||
import { fn } from '@storybook/test';
|
|
||||||
|
|
||||||
import { Header } from './Header';
|
|
||||||
|
|
||||||
const meta = {
|
|
||||||
title: 'Example/Header',
|
|
||||||
component: Header,
|
|
||||||
// This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
|
|
||||||
tags: ['autodocs'],
|
|
||||||
parameters: {
|
|
||||||
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
|
|
||||||
layout: 'fullscreen',
|
|
||||||
},
|
|
||||||
args: {
|
|
||||||
onLogin: fn(),
|
|
||||||
onLogout: fn(),
|
|
||||||
onCreateAccount: fn(),
|
|
||||||
},
|
|
||||||
} satisfies Meta<typeof Header>;
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
type Story = StoryObj<typeof meta>;
|
|
||||||
|
|
||||||
export const LoggedIn: Story = {
|
|
||||||
args: {
|
|
||||||
user: {
|
|
||||||
name: 'Jane Doe',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const LoggedOut: Story = {};
|
|
@ -1,56 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { Button } from './Button';
|
|
||||||
import './header.css';
|
|
||||||
|
|
||||||
type User = {
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface HeaderProps {
|
|
||||||
user?: User;
|
|
||||||
onLogin?: () => void;
|
|
||||||
onLogout?: () => void;
|
|
||||||
onCreateAccount?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Header = ({ user, onLogin, onLogout, onCreateAccount }: HeaderProps) => (
|
|
||||||
<header>
|
|
||||||
<div className="storybook-header">
|
|
||||||
<div>
|
|
||||||
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g fill="none" fillRule="evenodd">
|
|
||||||
<path
|
|
||||||
d="M10 0h12a10 10 0 0110 10v12a10 10 0 01-10 10H10A10 10 0 010 22V10A10 10 0 0110 0z"
|
|
||||||
fill="#FFF"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M5.3 10.6l10.4 6v11.1l-10.4-6v-11zm11.4-6.2l9.7 5.5-9.7 5.6V4.4z"
|
|
||||||
fill="#555AB9"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M27.2 10.6v11.2l-10.5 6V16.5l10.5-6zM15.7 4.4v11L6 10l9.7-5.5z"
|
|
||||||
fill="#91BAF8"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
<h1>Acme</h1>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{user ? (
|
|
||||||
<>
|
|
||||||
<span className="welcome">
|
|
||||||
Welcome, <b>{user.name}</b>!
|
|
||||||
</span>
|
|
||||||
<Button size="small" onClick={onLogout} label="Log out" />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Button size="small" onClick={onLogin} label="Log in" />
|
|
||||||
<Button primary size="small" onClick={onCreateAccount} label="Sign up" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
@ -1,32 +0,0 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/react';
|
|
||||||
import { within, userEvent, expect } from '@storybook/test';
|
|
||||||
|
|
||||||
import { Page } from './Page';
|
|
||||||
|
|
||||||
const meta = {
|
|
||||||
title: 'Example/Page',
|
|
||||||
component: Page,
|
|
||||||
parameters: {
|
|
||||||
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
|
|
||||||
layout: 'fullscreen',
|
|
||||||
},
|
|
||||||
} satisfies Meta<typeof Page>;
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
type Story = StoryObj<typeof meta>;
|
|
||||||
|
|
||||||
export const LoggedOut: Story = {};
|
|
||||||
|
|
||||||
// More on interaction testing: https://storybook.js.org/docs/writing-tests/interaction-testing
|
|
||||||
export const LoggedIn: Story = {
|
|
||||||
play: async ({ canvasElement }) => {
|
|
||||||
const canvas = within(canvasElement);
|
|
||||||
const loginButton = canvas.getByRole('button', { name: /Log in/i });
|
|
||||||
await expect(loginButton).toBeInTheDocument();
|
|
||||||
await userEvent.click(loginButton);
|
|
||||||
await expect(loginButton).not.toBeInTheDocument();
|
|
||||||
|
|
||||||
const logoutButton = canvas.getByRole('button', { name: /Log out/i });
|
|
||||||
await expect(logoutButton).toBeInTheDocument();
|
|
||||||
},
|
|
||||||
};
|
|
@ -1,73 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { Header } from './Header';
|
|
||||||
import './page.css';
|
|
||||||
|
|
||||||
type User = {
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Page: React.FC = () => {
|
|
||||||
const [user, setUser] = React.useState<User>();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<article>
|
|
||||||
<Header
|
|
||||||
user={user}
|
|
||||||
onLogin={() => setUser({ name: 'Jane Doe' })}
|
|
||||||
onLogout={() => setUser(undefined)}
|
|
||||||
onCreateAccount={() => setUser({ name: 'Jane Doe' })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section className="storybook-page">
|
|
||||||
<h2>Pages in Storybook</h2>
|
|
||||||
<p>
|
|
||||||
We recommend building UIs with a{' '}
|
|
||||||
<a href="https://componentdriven.org" target="_blank" rel="noopener noreferrer">
|
|
||||||
<strong>component-driven</strong>
|
|
||||||
</a>{' '}
|
|
||||||
process starting with atomic components and ending with pages.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Render pages with mock data. This makes it easy to build and review page states without
|
|
||||||
needing to navigate to them in your app. Here are some handy patterns for managing page
|
|
||||||
data in Storybook:
|
|
||||||
</p>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
Use a higher-level connected component. Storybook helps you compose such data from the
|
|
||||||
"args" of child component stories
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Assemble data in the page component from your services. You can mock these services out
|
|
||||||
using Storybook.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<p>
|
|
||||||
Get a guided tutorial on component-driven development at{' '}
|
|
||||||
<a href="https://storybook.js.org/tutorials/" target="_blank" rel="noopener noreferrer">
|
|
||||||
Storybook tutorials
|
|
||||||
</a>
|
|
||||||
. Read more in the{' '}
|
|
||||||
<a href="https://storybook.js.org/docs" target="_blank" rel="noopener noreferrer">
|
|
||||||
docs
|
|
||||||
</a>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
<div className="tip-wrapper">
|
|
||||||
<span className="tip">Tip</span> Adjust the width of the canvas with the{' '}
|
|
||||||
<svg width="10" height="10" viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g fill="none" fillRule="evenodd">
|
|
||||||
<path
|
|
||||||
d="M1.5 5.2h4.8c.3 0 .5.2.5.4v5.1c-.1.2-.3.3-.4.3H1.4a.5.5 0 01-.5-.4V5.7c0-.3.2-.5.5-.5zm0-2.1h6.9c.3 0 .5.2.5.4v7a.5.5 0 01-1 0V4H1.5a.5.5 0 010-1zm0-2.1h9c.3 0 .5.2.5.4v9.1a.5.5 0 01-1 0V2H1.5a.5.5 0 010-1zm4.3 5.2H2V10h3.8V6.2z"
|
|
||||||
id="a"
|
|
||||||
fill="#999"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
Viewports addon in the toolbar
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,30 +0,0 @@
|
|||||||
.storybook-button {
|
|
||||||
font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
|
||||||
font-weight: 700;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 3em;
|
|
||||||
cursor: pointer;
|
|
||||||
display: inline-block;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
.storybook-button--primary {
|
|
||||||
color: white;
|
|
||||||
background-color: #1ea7fd;
|
|
||||||
}
|
|
||||||
.storybook-button--secondary {
|
|
||||||
color: #333;
|
|
||||||
background-color: transparent;
|
|
||||||
box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset;
|
|
||||||
}
|
|
||||||
.storybook-button--small {
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 10px 16px;
|
|
||||||
}
|
|
||||||
.storybook-button--medium {
|
|
||||||
font-size: 14px;
|
|
||||||
padding: 11px 20px;
|
|
||||||
}
|
|
||||||
.storybook-button--large {
|
|
||||||
font-size: 16px;
|
|
||||||
padding: 12px 24px;
|
|
||||||
}
|
|
@ -1,32 +0,0 @@
|
|||||||
.storybook-header {
|
|
||||||
font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
|
||||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
|
||||||
padding: 15px 20px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.storybook-header svg {
|
|
||||||
display: inline-block;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
|
|
||||||
.storybook-header h1 {
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 20px;
|
|
||||||
line-height: 1;
|
|
||||||
margin: 6px 0 6px 10px;
|
|
||||||
display: inline-block;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
|
|
||||||
.storybook-header button + button {
|
|
||||||
margin-left: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.storybook-header .welcome {
|
|
||||||
color: #333;
|
|
||||||
font-size: 14px;
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
@ -1,69 +0,0 @@
|
|||||||
.storybook-page {
|
|
||||||
font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 24px;
|
|
||||||
padding: 48px 20px;
|
|
||||||
margin: 0 auto;
|
|
||||||
max-width: 600px;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.storybook-page h2 {
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 32px;
|
|
||||||
line-height: 1;
|
|
||||||
margin: 0 0 4px;
|
|
||||||
display: inline-block;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
|
|
||||||
.storybook-page p {
|
|
||||||
margin: 1em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.storybook-page a {
|
|
||||||
text-decoration: none;
|
|
||||||
color: #1ea7fd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.storybook-page ul {
|
|
||||||
padding-left: 30px;
|
|
||||||
margin: 1em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.storybook-page li {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.storybook-page .tip {
|
|
||||||
display: inline-block;
|
|
||||||
border-radius: 1em;
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
background: #e7fdd8;
|
|
||||||
color: #66bf3c;
|
|
||||||
padding: 4px 12px;
|
|
||||||
margin-right: 10px;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
|
|
||||||
.storybook-page .tip-wrapper {
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 20px;
|
|
||||||
margin-top: 40px;
|
|
||||||
margin-bottom: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.storybook-page .tip-wrapper svg {
|
|
||||||
display: inline-block;
|
|
||||||
height: 12px;
|
|
||||||
width: 12px;
|
|
||||||
margin-right: 4px;
|
|
||||||
vertical-align: top;
|
|
||||||
margin-top: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.storybook-page .tip-wrapper svg path {
|
|
||||||
fill: #1ea7fd;
|
|
||||||
}
|
|
Loading…
Reference in New Issue
Block a user