ui: add components

pull/305/head
Yangshun Tay 2 years ago
parent db672a2beb
commit e93cc73d51

@ -11,7 +11,7 @@
"postinstall": "prisma generate"
},
"dependencies": {
"@headlessui/react": "^1.7.2",
"@headlessui/react": "^1.7.3",
"@heroicons/react": "^2.0.11",
"@next-auth/prisma-adapter": "^1.0.4",
"@prisma/client": "^4.4.0",

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

@ -45,7 +45,7 @@ export default {
control: 'boolean',
},
label: {
control: 'string',
control: 'text',
},
size: {
options: buttonSizes,

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

@ -20,7 +20,7 @@ export default {
control: { type: 'select' },
},
label: {
control: 'string',
control: 'text',
},
size: {
options: spinnerSizes,

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

@ -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"
},

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

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

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

@ -1776,7 +1776,7 @@
dependencies:
"@hapi/hoek" "^9.0.0"
"@headlessui/react@^1.7.2":
"@headlessui/react@^1.7.3":
version "1.7.3"
resolved "https://registry.yarnpkg.com/@headlessui/react/-/react-1.7.3.tgz#853c598ff47b37cdd192c5cbee890d9b610c3ec0"
integrity sha512-LGp06SrGv7BMaIQlTs8s2G06moqkI0cb0b8stgq7KZ3xcHdH3qMP+cRyV7qe5x4XEW/IGY48BW4fLesD6NQLng==

Loading…
Cancel
Save