parent
db672a2beb
commit
e93cc73d51
@ -0,0 +1,41 @@
|
||||
import { ComponentMeta } from '@storybook/react';
|
||||
import { Badge, BadgeVariant } from '@tih/ui';
|
||||
import React from 'react';
|
||||
|
||||
const badgeVariants: ReadonlyArray<BadgeVariant> = [
|
||||
'primary',
|
||||
'info',
|
||||
'danger',
|
||||
'success',
|
||||
'warning',
|
||||
];
|
||||
|
||||
export default {
|
||||
title: 'Badge',
|
||||
component: Badge,
|
||||
argTypes: {
|
||||
variant: {
|
||||
options: badgeVariants,
|
||||
control: { type: 'select' },
|
||||
},
|
||||
},
|
||||
} as ComponentMeta<typeof Badge>;
|
||||
|
||||
export const Basic = {
|
||||
args: {
|
||||
label: 'Hello',
|
||||
variant: 'primary',
|
||||
},
|
||||
};
|
||||
|
||||
export function Variants() {
|
||||
return (
|
||||
<div className="space-x-4">
|
||||
<Badge label="Primary" variant="primary" />
|
||||
<Badge label="Success" variant="success" />
|
||||
<Badge label="Information" variant="info" />
|
||||
<Badge label="Warning" variant="warning" />
|
||||
<Badge label="Danger" variant="danger" />
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,56 @@
|
||||
import { ComponentMeta } from '@storybook/react';
|
||||
import { Button, Dialog } from '@tih/ui';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
export default {
|
||||
title: 'Dialog',
|
||||
component: Dialog,
|
||||
argTypes: {
|
||||
title: {
|
||||
control: 'text',
|
||||
},
|
||||
},
|
||||
} as ComponentMeta<typeof Dialog>;
|
||||
|
||||
export function Basic({ children, title }) {
|
||||
const [isShown, setIsShown] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button label="Open" variant="primary" onClick={() => setIsShown(true)} />
|
||||
<Dialog
|
||||
isShown={isShown}
|
||||
primaryButton={
|
||||
<Button
|
||||
display="block"
|
||||
label="OK"
|
||||
variant="primary"
|
||||
onClick={() => setIsShown(false)}
|
||||
/>
|
||||
}
|
||||
secondaryButton={
|
||||
<Button
|
||||
display="block"
|
||||
label="Cancel"
|
||||
variant="tertiary"
|
||||
onClick={() => setIsShown(false)}
|
||||
/>
|
||||
}
|
||||
title={title}
|
||||
onClose={() => setIsShown(false)}>
|
||||
{children}
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Basic.args = {
|
||||
title: 'Lorem ipsum, dolor sit amet',
|
||||
children: (
|
||||
<div>
|
||||
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Eius aliquam
|
||||
laudantium explicabo pariatur iste dolorem animi vitae error totam. At
|
||||
sapiente aliquam accusamus facere veritatis.
|
||||
</div>
|
||||
),
|
||||
};
|
@ -0,0 +1,124 @@
|
||||
import { ComponentMeta } from '@storybook/react';
|
||||
import { DropdownMenu, DropdownMenuAlignment, DropdownMenuSize } from '@tih/ui';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
const DropdownMenuAlignments: ReadonlyArray<DropdownMenuAlignment> = [
|
||||
'start',
|
||||
'end',
|
||||
];
|
||||
const DropdownMenuSizes: ReadonlyArray<DropdownMenuSize> = [
|
||||
'inherit',
|
||||
'regular',
|
||||
];
|
||||
|
||||
export default {
|
||||
title: 'DropdownMenu',
|
||||
component: DropdownMenu,
|
||||
parameters: {
|
||||
docs: {
|
||||
inlineStories: false,
|
||||
iframeHeight: 300,
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
align: {
|
||||
options: DropdownMenuAlignments,
|
||||
control: { type: 'select' },
|
||||
},
|
||||
label: {
|
||||
control: 'text',
|
||||
},
|
||||
size: {
|
||||
options: DropdownMenuSizes,
|
||||
control: { type: 'select' },
|
||||
},
|
||||
},
|
||||
} as ComponentMeta<typeof DropdownMenu>;
|
||||
|
||||
export function Basic({ align, label, size }) {
|
||||
const menuItems = [
|
||||
{
|
||||
label: 'Apple',
|
||||
value: 'apple',
|
||||
},
|
||||
{
|
||||
label: 'Banana',
|
||||
value: 'banana',
|
||||
},
|
||||
{
|
||||
label: 'Orange',
|
||||
value: 'orange',
|
||||
},
|
||||
];
|
||||
|
||||
const [selectedValue, setSelectedValue] = useState('apple');
|
||||
|
||||
return (
|
||||
<DropdownMenu align={align} label={label} size={size}>
|
||||
{menuItems.map(({ label, value }) => (
|
||||
<DropdownMenu.Item
|
||||
key={value}
|
||||
isSelected={value === selectedValue}
|
||||
label={label}
|
||||
onClick={() => {
|
||||
setSelectedValue(value);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
Basic.args = {
|
||||
align: 'start',
|
||||
label: 'Select fruitzz',
|
||||
size: 'regular',
|
||||
};
|
||||
|
||||
export function Align() {
|
||||
const menuItems = [
|
||||
{
|
||||
label: 'Apple',
|
||||
value: 'apple',
|
||||
},
|
||||
{
|
||||
label: 'Banana',
|
||||
value: 'banana',
|
||||
},
|
||||
{
|
||||
label: 'Orange',
|
||||
value: 'orange',
|
||||
},
|
||||
];
|
||||
|
||||
const [selectedValue, setSelectedValue] = useState('apple');
|
||||
|
||||
return (
|
||||
<div className="flex justify-between">
|
||||
<DropdownMenu align="start" label="Select fruit" size="regular">
|
||||
{menuItems.map(({ label, value }) => (
|
||||
<DropdownMenu.Item
|
||||
key={value}
|
||||
isSelected={value === selectedValue}
|
||||
label={label}
|
||||
onClick={() => {
|
||||
setSelectedValue(value);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
<DropdownMenu align="end" label="Select fruit" size="regular">
|
||||
{menuItems.map(({ label, value }) => (
|
||||
<DropdownMenu.Item
|
||||
key={value}
|
||||
isSelected={value === selectedValue}
|
||||
label={label}
|
||||
onClick={() => {
|
||||
setSelectedValue(value);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,88 @@
|
||||
import { ComponentMeta } from '@storybook/react';
|
||||
import { Select, SelectDisplay } from '@tih/ui';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
const SelectDisplays: ReadonlyArray<SelectDisplay> = ['inline', 'block'];
|
||||
|
||||
export default {
|
||||
title: 'Select',
|
||||
component: Select,
|
||||
argTypes: {
|
||||
display: {
|
||||
options: SelectDisplays,
|
||||
control: { type: 'select' },
|
||||
},
|
||||
isLabelHidden: {
|
||||
control: 'boolean',
|
||||
},
|
||||
label: {
|
||||
control: 'text',
|
||||
},
|
||||
name: {
|
||||
control: 'text',
|
||||
},
|
||||
},
|
||||
} as ComponentMeta<typeof Select>;
|
||||
|
||||
export function Basic({ display, isLabelHidden, label, name }) {
|
||||
const [value, setValue] = useState('apple');
|
||||
|
||||
return (
|
||||
<Select
|
||||
display={display}
|
||||
isLabelHidden={isLabelHidden}
|
||||
label={label}
|
||||
onChange={setValue}
|
||||
name={name}
|
||||
options={[
|
||||
{
|
||||
label: 'Apple',
|
||||
value: 'apple',
|
||||
},
|
||||
{
|
||||
label: 'Banana',
|
||||
value: 'banana',
|
||||
},
|
||||
{
|
||||
label: 'Orange',
|
||||
value: 'orange',
|
||||
},
|
||||
]}
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Basic.args = {
|
||||
label: 'Select fruit',
|
||||
display: 'inline',
|
||||
isLabelHidden: false,
|
||||
};
|
||||
|
||||
export function Display() {
|
||||
const [value, setValue] = useState('apple');
|
||||
|
||||
return (
|
||||
<div className="space-x-4">
|
||||
<Select
|
||||
label="Select a fruit"
|
||||
onChange={setValue}
|
||||
options={[
|
||||
{
|
||||
label: 'Apple',
|
||||
value: 'apple',
|
||||
},
|
||||
{
|
||||
label: 'Banana',
|
||||
value: 'banana',
|
||||
},
|
||||
{
|
||||
label: 'Orange',
|
||||
value: 'orange',
|
||||
},
|
||||
]}
|
||||
value={value}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,49 @@
|
||||
import { ComponentMeta } from '@storybook/react';
|
||||
import { Button, SlideOut, SlideOutEnterFrom, SlideOutSize } from '@tih/ui';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
const slideOutEnterFrom: ReadonlyArray<SlideOutEnterFrom> = ['start', 'end'];
|
||||
const slideOutSize: ReadonlyArray<SlideOutSize> = ['sm', 'md', 'lg', 'xl'];
|
||||
|
||||
export default {
|
||||
title: 'SlideOut',
|
||||
component: SlideOut,
|
||||
argTypes: {
|
||||
title: {
|
||||
control: 'text',
|
||||
},
|
||||
enterFrom: {
|
||||
options: slideOutEnterFrom,
|
||||
control: { type: 'select' },
|
||||
},
|
||||
size: {
|
||||
options: slideOutSize,
|
||||
control: { type: 'select' },
|
||||
},
|
||||
},
|
||||
} as ComponentMeta<typeof SlideOut>;
|
||||
|
||||
export function Basic({ children, enterFrom, size, title }) {
|
||||
const [isShown, setIsShown] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button label="Open" variant="primary" onClick={() => setIsShown(true)} />
|
||||
<SlideOut
|
||||
enterFrom={enterFrom}
|
||||
isShown={isShown}
|
||||
size={size}
|
||||
title={title}
|
||||
onClose={() => setIsShown(false)}>
|
||||
{children}
|
||||
</SlideOut>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Basic.args = {
|
||||
title: 'Navigation',
|
||||
children: <div className="p-4">Hello World</div>,
|
||||
enterFrom: 'end',
|
||||
size: 'md',
|
||||
};
|
@ -0,0 +1,43 @@
|
||||
import { ComponentMeta } from '@storybook/react';
|
||||
import { Tabs } from '@tih/ui';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
export default {
|
||||
title: 'Tabs',
|
||||
component: Tabs,
|
||||
argTypes: {
|
||||
label: {
|
||||
control: 'text',
|
||||
},
|
||||
},
|
||||
} as ComponentMeta<typeof Tabs>;
|
||||
|
||||
export function Basic({ label }) {
|
||||
const [value, setValue] = useState('apple');
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
label={label}
|
||||
onChange={setValue}
|
||||
tabs={[
|
||||
{
|
||||
label: 'Apple',
|
||||
value: 'apple',
|
||||
},
|
||||
{
|
||||
label: 'Banana',
|
||||
value: 'banana',
|
||||
},
|
||||
{
|
||||
label: 'Orange',
|
||||
value: 'orange',
|
||||
},
|
||||
]}
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Basic.args = {
|
||||
label: 'Fruits Navigation',
|
||||
};
|
@ -0,0 +1,129 @@
|
||||
import {
|
||||
EnvelopeIcon,
|
||||
KeyIcon,
|
||||
QuestionMarkCircleIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
import { ComponentMeta } from '@storybook/react';
|
||||
import { TextInput } from '@tih/ui';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
export default {
|
||||
title: 'TextInput',
|
||||
component: TextInput,
|
||||
argTypes: {
|
||||
autoComplete: {
|
||||
control: 'text',
|
||||
},
|
||||
errorMessage: {
|
||||
control: 'text',
|
||||
},
|
||||
isDisabled: {
|
||||
control: 'boolean',
|
||||
},
|
||||
isLabelHidden: {
|
||||
control: 'boolean',
|
||||
},
|
||||
label: {
|
||||
control: 'text',
|
||||
},
|
||||
name: {
|
||||
control: 'text',
|
||||
},
|
||||
placeholder: {
|
||||
control: 'text',
|
||||
},
|
||||
type: {
|
||||
control: 'text',
|
||||
},
|
||||
},
|
||||
} as ComponentMeta<typeof TextInput>;
|
||||
|
||||
export const Basic = {
|
||||
args: {
|
||||
label: 'Name',
|
||||
placeholder: 'John Doe',
|
||||
},
|
||||
};
|
||||
|
||||
export function HiddenLabel() {
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
isLabelHidden={true}
|
||||
label="Name"
|
||||
placeholder="John Doe"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Email() {
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
label="Email"
|
||||
placeholder="john.doe@email.com"
|
||||
startIcon={EnvelopeIcon}
|
||||
type="email"
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Icon() {
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<TextInput
|
||||
endIcon={QuestionMarkCircleIcon}
|
||||
label="Account number"
|
||||
placeholder="000-00-0000"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
<TextInput
|
||||
startIcon={QuestionMarkCircleIcon}
|
||||
label="Account number"
|
||||
placeholder="000-00-0000"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Disabled() {
|
||||
return (
|
||||
<TextInput
|
||||
isDisabled={true}
|
||||
label="Disabled input"
|
||||
placeholder="John Doe"
|
||||
type="text"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Error() {
|
||||
const [value, setValue] = useState('1234');
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
label="Email"
|
||||
errorMessage={
|
||||
value.length < 6 ? 'Password must be at least 6 characters' : undefined
|
||||
}
|
||||
startIcon={KeyIcon}
|
||||
type="password"
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
);
|
||||
}
|
@ -0,0 +1,57 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
export type BadgeVariant =
|
||||
| 'danger'
|
||||
| 'info'
|
||||
| 'primary'
|
||||
| 'success'
|
||||
| 'warning';
|
||||
|
||||
type Props = Readonly<{
|
||||
label: string;
|
||||
variant: BadgeVariant;
|
||||
}>;
|
||||
|
||||
const classes: Record<
|
||||
BadgeVariant,
|
||||
Readonly<{
|
||||
backgroundClass: string;
|
||||
textClass: string;
|
||||
}>
|
||||
> = {
|
||||
danger: {
|
||||
backgroundClass: 'bg-danger-100',
|
||||
textClass: 'text-danger-800',
|
||||
},
|
||||
info: {
|
||||
backgroundClass: 'bg-info-100',
|
||||
textClass: 'text-info-800',
|
||||
},
|
||||
primary: {
|
||||
backgroundClass: 'bg-primary-100',
|
||||
textClass: 'text-primary-800',
|
||||
},
|
||||
success: {
|
||||
backgroundClass: 'bg-success-100',
|
||||
textClass: 'text-success-800',
|
||||
},
|
||||
warning: {
|
||||
backgroundClass: 'bg-warning-100',
|
||||
textClass: 'text-warning-800',
|
||||
},
|
||||
};
|
||||
|
||||
export default function Badge({ label, variant }: Props) {
|
||||
const { backgroundClass, textClass } = classes[variant];
|
||||
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center rounded-full px-3 py-1 text-xs font-medium',
|
||||
backgroundClass,
|
||||
textClass,
|
||||
)}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
@ -0,0 +1,89 @@
|
||||
import clsx from 'clsx';
|
||||
import { Fragment, useRef } from 'react';
|
||||
import { Dialog as HeadlessDialog, Transition } from '@headlessui/react';
|
||||
|
||||
type Props = Readonly<{
|
||||
children: React.ReactNode;
|
||||
isShown?: boolean;
|
||||
onClose: () => void;
|
||||
primaryButton: React.ReactNode;
|
||||
secondaryButton?: React.ReactNode;
|
||||
title: string;
|
||||
topIcon?: (props: React.ComponentProps<'svg'>) => JSX.Element;
|
||||
}>;
|
||||
|
||||
export default function Dialog({
|
||||
children,
|
||||
isShown,
|
||||
primaryButton,
|
||||
title,
|
||||
topIcon: TopIcon,
|
||||
secondaryButton,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const cancelButtonRef = useRef(null);
|
||||
|
||||
return (
|
||||
<Transition.Root as={Fragment} show={isShown}>
|
||||
<HeadlessDialog
|
||||
as="div"
|
||||
className="relative z-10"
|
||||
initialFocus={cancelButtonRef}
|
||||
onClose={() => onClose()}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0">
|
||||
<div className="fixed inset-0 bg-slate-500 bg-opacity-75 transition-opacity" />
|
||||
</Transition.Child>
|
||||
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div className="flex min-h-full items-end justify-center p-4 sm:items-center sm:p-0">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
|
||||
<HeadlessDialog.Panel className="relative transform overflow-hidden rounded-lg bg-white px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6">
|
||||
<div>
|
||||
{TopIcon != null && (
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
|
||||
<TopIcon
|
||||
aria-hidden="true"
|
||||
className="h-6 w-6 text-green-600"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<HeadlessDialog.Title
|
||||
as="h2"
|
||||
className="text-2xl font-bold leading-6 text-slate-900">
|
||||
{title}
|
||||
</HeadlessDialog.Title>
|
||||
<div className="my-4">
|
||||
<div className="text-sm">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
'mt-5 grid gap-3 sm:mt-6 sm:grid-flow-row-dense',
|
||||
secondaryButton != null && 'sm:grid-cols-2',
|
||||
)}>
|
||||
{secondaryButton}
|
||||
{primaryButton}
|
||||
</div>
|
||||
</HeadlessDialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</HeadlessDialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
}
|
@ -0,0 +1,64 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { Fragment } from 'react';
|
||||
import { Menu, Transition } from '@headlessui/react';
|
||||
import { ChevronDownIcon } from '@heroicons/react/24/solid';
|
||||
|
||||
import DropdownMenuItem from './DropdownMenuItem';
|
||||
|
||||
export type DropdownMenuAlignment = 'end' | 'start';
|
||||
export type DropdownMenuSize = 'inherit' | 'regular';
|
||||
|
||||
type Props = Readonly<{
|
||||
align?: DropdownMenuAlignment;
|
||||
children: React.ReactNode; // TODO: Change to strict children.
|
||||
label: React.ReactNode;
|
||||
size?: DropdownMenuSize;
|
||||
}>;
|
||||
|
||||
DropdownMenu.Item = DropdownMenuItem;
|
||||
|
||||
const alignmentClasses: Record<DropdownMenuAlignment, string> = {
|
||||
end: 'origin-top-right right-0',
|
||||
start: 'origin-top-left left-0',
|
||||
};
|
||||
|
||||
export default function DropdownMenu({
|
||||
align = 'start',
|
||||
children,
|
||||
label,
|
||||
size = 'regular',
|
||||
}: Props) {
|
||||
return (
|
||||
<Menu as="div" className="relative inline-block">
|
||||
<div className="flex">
|
||||
<Menu.Button
|
||||
className={clsx(
|
||||
'group inline-flex justify-center font-medium text-slate-700 hover:text-slate-900',
|
||||
size === 'regular' && 'text-sm',
|
||||
)}>
|
||||
<div>{label}</div>
|
||||
<ChevronDownIcon
|
||||
aria-hidden="true"
|
||||
className="-mr-1 ml-1 h-5 w-5 flex-shrink-0 text-slate-400 group-hover:text-slate-500"
|
||||
/>
|
||||
</Menu.Button>
|
||||
</div>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95">
|
||||
<Menu.Items
|
||||
className={clsx(
|
||||
alignmentClasses[align],
|
||||
'ring-primary-500 absolute z-10 mt-2 w-48 rounded-md bg-white shadow-lg ring-1 ring-opacity-5 focus:outline-none',
|
||||
)}>
|
||||
<div className="py-1">{children}</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
);
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
import { Menu } from '@headlessui/react';
|
||||
|
||||
type Props = Readonly<{
|
||||
href?: string;
|
||||
isSelected?: boolean;
|
||||
label: React.ReactNode;
|
||||
onClick: () => void;
|
||||
}>;
|
||||
|
||||
export default function DropdownMenuItem({
|
||||
href,
|
||||
isSelected = false,
|
||||
label,
|
||||
onClick,
|
||||
}: Props) {
|
||||
return (
|
||||
<Menu.Item>
|
||||
{({ active }) => {
|
||||
const props = {
|
||||
children: label,
|
||||
className: clsx(
|
||||
isSelected ? 'font-medium text-slate-900' : 'text-slate-500',
|
||||
active && 'bg-slate-100',
|
||||
'block px-4 py-2 text-sm w-full text-left',
|
||||
),
|
||||
onClick,
|
||||
};
|
||||
|
||||
if (href == null) {
|
||||
return <button type="button" {...props} />;
|
||||
}
|
||||
|
||||
// TODO: Change to <Link> when there's a need for client-side navigation.
|
||||
return <a href={href} {...props} />;
|
||||
}}
|
||||
</Menu.Item>
|
||||
);
|
||||
}
|
@ -0,0 +1,62 @@
|
||||
import clsx from 'clsx';
|
||||
import { useId } from 'react';
|
||||
|
||||
export type SelectItem<T> = Readonly<{
|
||||
label: string;
|
||||
value: T;
|
||||
}>;
|
||||
|
||||
export type SelectDisplay = 'block' | 'inline';
|
||||
|
||||
type Props<T> = Readonly<{
|
||||
display?: SelectDisplay;
|
||||
isLabelHidden?: boolean;
|
||||
label: string;
|
||||
name?: string;
|
||||
onChange: (value: string) => void;
|
||||
options: ReadonlyArray<SelectItem<T>>;
|
||||
value: T;
|
||||
}>;
|
||||
|
||||
export default function Select<T>({
|
||||
display,
|
||||
label,
|
||||
isLabelHidden,
|
||||
name,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
}: Props<T>) {
|
||||
const id = useId();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label
|
||||
className={clsx(
|
||||
'mb-1 block text-sm font-medium text-slate-700',
|
||||
isLabelHidden && 'sr-only',
|
||||
)}
|
||||
htmlFor={id ?? undefined}>
|
||||
{label}
|
||||
</label>
|
||||
<select
|
||||
aria-label={isLabelHidden ? label : undefined}
|
||||
className={clsx(
|
||||
display === 'block' && 'block w-full',
|
||||
'focus:border-primary-500 focus:ring-primary-500 rounded-md border-slate-300 py-2 pl-3 pr-10 text-base focus:outline-none sm:text-sm',
|
||||
)}
|
||||
id={id}
|
||||
name={name ?? undefined}
|
||||
value={String(value)}
|
||||
onChange={(event) => {
|
||||
onChange(event.target.value);
|
||||
}}>
|
||||
{options.map(({ label: optionLabel, value: optionValue }) => (
|
||||
<option key={String(optionValue)} value={String(optionValue)}>
|
||||
{optionLabel}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,96 @@
|
||||
import clsx from 'clsx';
|
||||
import { Fragment } from 'react';
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
export type SlideOutSize = 'lg' | 'md' | 'sm' | 'xl';
|
||||
export type SlideOutEnterFrom = 'end' | 'start';
|
||||
|
||||
type Props = Readonly<{
|
||||
children: React.ReactNode;
|
||||
enterFrom?: SlideOutEnterFrom;
|
||||
isShown?: boolean;
|
||||
onClose?: () => void;
|
||||
size: SlideOutSize;
|
||||
title?: string;
|
||||
}>;
|
||||
|
||||
const sizeClasses: Record<SlideOutSize, string> = {
|
||||
lg: 'max-w-lg',
|
||||
md: 'max-w-md',
|
||||
sm: 'max-w-sm',
|
||||
xl: 'max-w-xl',
|
||||
};
|
||||
|
||||
const enterFromClasses: Record<
|
||||
SlideOutEnterFrom,
|
||||
Readonly<{ hidden: string; position: string; shown: string }>
|
||||
> = {
|
||||
end: {
|
||||
hidden: 'translate-x-full',
|
||||
position: 'ml-auto',
|
||||
shown: 'translate-x-0',
|
||||
},
|
||||
start: {
|
||||
hidden: '-translate-x-full',
|
||||
position: 'mr-auto',
|
||||
shown: 'translate-x-0',
|
||||
},
|
||||
};
|
||||
|
||||
export default function SlideOut({
|
||||
children,
|
||||
enterFrom = 'end',
|
||||
isShown = false,
|
||||
size,
|
||||
title,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const enterFromClass = enterFromClasses[enterFrom];
|
||||
|
||||
return (
|
||||
<Transition.Root as={Fragment} show={isShown}>
|
||||
<Dialog as="div" className="relative z-40" onClose={() => onClose?.()}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="transition-opacity ease-linear duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-opacity ease-linear duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0">
|
||||
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
||||
</Transition.Child>
|
||||
<div className="fixed inset-0 z-40 flex">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="transition ease-in-out duration-300 transform"
|
||||
enterFrom={enterFromClass.hidden}
|
||||
enterTo={enterFromClass.shown}
|
||||
leave="transition ease-in-out duration-300 transform"
|
||||
leaveFrom={enterFromClass.shown}
|
||||
leaveTo={enterFromClass.hidden}>
|
||||
<Dialog.Panel
|
||||
className={clsx(
|
||||
'relative flex h-full w-full max-w-lg flex-col overflow-y-auto bg-white py-4 pb-6 shadow-xl',
|
||||
enterFromClass.position,
|
||||
sizeClasses[size],
|
||||
)}>
|
||||
<div className="flex items-center justify-between px-4">
|
||||
<h2 className="text-lg font-medium text-slate-900">{title}</h2>
|
||||
<button
|
||||
className="focus:ring-primary-500 -mr-2 flex h-10 w-10 items-center justify-center rounded-full p-2 text-slate-400 hover:text-slate-500 focus:outline-none focus:ring-2 focus:ring-inset"
|
||||
type="button"
|
||||
onClick={() => onClose?.()}>
|
||||
<span className="sr-only">Close menu</span>
|
||||
<XMarkIcon aria-hidden="true" className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
}
|
@ -0,0 +1,65 @@
|
||||
import clsx from 'clsx';
|
||||
import Link from 'next/link';
|
||||
import type { UrlObject } from 'url';
|
||||
|
||||
export type TabItem<T> = Readonly<{
|
||||
href?: UrlObject | string;
|
||||
label: string;
|
||||
value: T;
|
||||
}>;
|
||||
|
||||
type Props<T> = Readonly<{
|
||||
label: string;
|
||||
onChange?: (value: T) => void;
|
||||
tabs: ReadonlyArray<TabItem<T>>;
|
||||
value: T;
|
||||
}>;
|
||||
|
||||
export default function Tabs<T>({ label, tabs, value, onChange }: Props<T>) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div role="tablist">
|
||||
<div className="border-b border-slate-200">
|
||||
<nav aria-label={label} className="-mb-px flex space-x-4">
|
||||
{tabs.map((tab) => {
|
||||
const isSelected = tab.value === value;
|
||||
const commonProps = {
|
||||
'aria-label': tab.label,
|
||||
'aria-selected': isSelected,
|
||||
children: tab.label,
|
||||
className: clsx(
|
||||
isSelected
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300',
|
||||
'whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm',
|
||||
),
|
||||
onClick:
|
||||
onChange != null ? () => onChange(tab.value) : undefined,
|
||||
role: 'tab',
|
||||
};
|
||||
|
||||
if (tab.href != null) {
|
||||
// TODO: Allow passing in of Link component.
|
||||
return (
|
||||
<Link
|
||||
key={String(tab.value)}
|
||||
href={tab.href}
|
||||
{...commonProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={String(tab.value)}
|
||||
type="button"
|
||||
{...commonProps}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,109 @@
|
||||
import clsx from 'clsx';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import React, { useId } from 'react';
|
||||
|
||||
type Props = Readonly<{
|
||||
autoComplete?: string;
|
||||
defaultValue?: string;
|
||||
endIcon?: React.ComponentType<React.ComponentProps<'svg'>>;
|
||||
errorMessage?: React.ReactNode;
|
||||
id?: string;
|
||||
isDisabled?: boolean;
|
||||
isLabelHidden?: boolean;
|
||||
label: string;
|
||||
name?: string;
|
||||
onChange?: (value: string, event: ChangeEvent<HTMLInputElement>) => void;
|
||||
placeholder?: string;
|
||||
startIcon?: React.ComponentType<React.ComponentProps<'svg'>>;
|
||||
type?: 'email' | 'password' | 'text';
|
||||
value?: string;
|
||||
}>;
|
||||
|
||||
type State = 'error' | 'normal';
|
||||
|
||||
const stateClasses: Record<State, string> = {
|
||||
error:
|
||||
'border-danger-300 text-danger-900 placeholder-danger-300 focus:outline-none focus:ring-danger-500 focus:border-danger-500',
|
||||
normal:
|
||||
'placeholder:text-slate-400 focus:ring-primary-500 focus:border-primary-500 border-slate-300',
|
||||
};
|
||||
|
||||
export default function TextInput({
|
||||
autoComplete,
|
||||
defaultValue,
|
||||
endIcon: EndIcon,
|
||||
errorMessage,
|
||||
id: idParam,
|
||||
isDisabled,
|
||||
isLabelHidden = false,
|
||||
label,
|
||||
name,
|
||||
placeholder,
|
||||
startIcon: StartIcon,
|
||||
type = 'text',
|
||||
value,
|
||||
onChange,
|
||||
}: Props) {
|
||||
const hasError = errorMessage != null;
|
||||
const generatedId = useId();
|
||||
const id = idParam ?? generatedId;
|
||||
const errorId = useId();
|
||||
const state: State = hasError ? 'error' : 'normal';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label
|
||||
className={clsx(
|
||||
isLabelHidden
|
||||
? 'sr-only'
|
||||
: 'block text-sm font-medium text-slate-700',
|
||||
)}
|
||||
htmlFor={id}>
|
||||
{label}
|
||||
</label>
|
||||
<div className="relative mt-1">
|
||||
{StartIcon && (
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<StartIcon aria-hidden="true" className="h-5 w-5 text-slate-400" />
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
aria-describedby={hasError ? errorId : undefined}
|
||||
aria-invalid={hasError ? true : undefined}
|
||||
autoComplete={autoComplete}
|
||||
className={clsx(
|
||||
'block w-full rounded-md sm:text-sm',
|
||||
StartIcon && 'pl-10',
|
||||
EndIcon && 'pr-10',
|
||||
stateClasses[state],
|
||||
isDisabled && 'bg-slate-100',
|
||||
)}
|
||||
defaultValue={defaultValue}
|
||||
disabled={isDisabled}
|
||||
id={id}
|
||||
name={name}
|
||||
placeholder={placeholder}
|
||||
type={type}
|
||||
value={value != null ? value : undefined}
|
||||
onChange={(event) => {
|
||||
if (!onChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(event.target.value, event);
|
||||
}}
|
||||
/>
|
||||
{EndIcon && (
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<EndIcon aria-hidden="true" className="h-5 w-5 text-slate-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{errorMessage && (
|
||||
<p className="text-danger-600 mt-2 text-sm" id={errorId}>
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,6 +1,30 @@
|
||||
// Alert
|
||||
export * from './Alert/Alert';
|
||||
export { default as Alert } from './Alert/Alert';
|
||||
// Badge
|
||||
export * from './Badge/Badge';
|
||||
export { default as Badge } from './Badge/Badge';
|
||||
// Button
|
||||
export * from './Button/Button';
|
||||
export { default as Button } from './Button/Button';
|
||||
// Dialog
|
||||
export * from './Dialog/Dialog';
|
||||
export { default as Dialog } from './Dialog/Dialog';
|
||||
// DropdownMenu
|
||||
export * from './DropdownMenu/DropdownMenu';
|
||||
export { default as DropdownMenu } from './DropdownMenu/DropdownMenu';
|
||||
// Select
|
||||
export * from './Select/Select';
|
||||
export { default as Select } from './Select/Select';
|
||||
// SlideOut
|
||||
export * from './SlideOut/SlideOut';
|
||||
export { default as SlideOut } from './SlideOut/SlideOut';
|
||||
// Spinner
|
||||
export * from './Spinner/Spinner';
|
||||
export { default as Spinner } from './Spinner/Spinner';
|
||||
// Tabs
|
||||
export * from './Tabs/Tabs';
|
||||
export { default as Tabs } from './Tabs/Tabs';
|
||||
// TextInput
|
||||
export * from './TextInput/TextInput';
|
||||
export { default as TextInput } from './TextInput/TextInput';
|
||||
|
Loading…
Reference in new issue