ui: add components

This commit is contained in:
Yangshun Tay
2022-10-04 09:30:15 +08:00
parent db672a2beb
commit e93cc73d51
22 changed files with 1146 additions and 8 deletions

View File

@ -22,6 +22,8 @@
"lint": "eslint src/**/*.ts* --fix"
},
"dependencies": {
"@headlessui/react": "^1.7.3",
"@heroicons/react": "^2.0.11",
"clsx": "^1.2.1",
"next": "^12.3.1"
},

View File

@ -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>
);
}

View File

@ -73,7 +73,7 @@ const variantClasses: Record<ButtonVariant, string> = {
secondary:
'border-transparent text-primary-700 bg-primary-100 hover:bg-primary-200',
special: 'border-slate-900 text-white bg-slate-900 hover:bg-slate-700',
success: 'border-transparent text-white bg-emerald-600 hover:bg-emerald-500',
success: 'border-transparent text-white bg-success-600 hover:bg-success-500',
tertiary: 'border-slate-300 text-slate-700 bg-white hover:bg-slate-50',
};
@ -150,8 +150,7 @@ export default function Button({
}
return (
<Link href={href}>
<a {...commonProps} />
</Link>
// TODO: Allow passing in of Link component.
<Link href={href} {...commonProps} />
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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';