chore: merge ui package into portal

This commit is contained in:
Yangshun
2024-06-25 09:39:16 +08:00
parent a9cd10afe4
commit a6359e337b
135 changed files with 795 additions and 10450 deletions

View File

@@ -1,8 +0,0 @@
module.exports = {
root: true,
extends: ['tih'],
parserOptions: {
tsconfigRootDir: __dirname,
project: ['./tsconfig.json'],
},
};

View File

@@ -1,45 +0,0 @@
{
"name": "@tih/ui",
"version": "0.0.0",
"private": true,
"license": "MIT",
"sideEffects": false,
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": "./dist",
"./styles.css": "./dist/styles.css"
},
"files": [
"dist/**"
],
"scripts": {
"build": "tsup src/index.tsx --format esm,cjs --dts --external react && tailwindcss -i ./src/styles.css -o ./dist/styles.css",
"dev": "concurrently \"tsup src/index.tsx --format esm,cjs --dts --external react --watch\" \"tailwindcss -i ./src/styles.css -o ./dist/styles.css --watch\"",
"clean": "rm -rf dist",
"tsc": "tsc",
"lint": "eslint src/**/*.ts* --fix"
},
"dependencies": {
"@headlessui/react": "^1.7.3",
"@heroicons/react": "2.0.11",
"clsx": "^1.2.1",
"next": "^12.3.1",
"tailwindcss": "^3.1.8"
},
"devDependencies": {
"@tih/tailwind-config": "workspace:0.0.0",
"@tih/tsconfig": "workspace:0.0.0",
"@types/react": "^18.0.21",
"@types/react-dom": "^18.0.6",
"autoprefixer": "^10.4.12",
"concurrently": "^7.4.0",
"eslint": "^8.24.0",
"eslint-config-tih": "workspace:0.0.0",
"prettier-plugin-tailwindcss": "^0.1.13",
"react": "^18.2.0",
"tsup": "^6.2.3",
"typescript": "^4.8.3"
}
}

View File

@@ -1,84 +0,0 @@
import clsx from 'clsx';
import type { ReactNode } from 'react';
import {
CheckCircleIcon,
ExclamationTriangleIcon,
InformationCircleIcon,
XCircleIcon,
} from '@heroicons/react/20/solid';
export type AlertVariant = 'danger' | 'info' | 'success' | 'warning';
type Props = Readonly<{
children: ReactNode;
title?: string;
variant: AlertVariant;
}>;
const classes: Record<
AlertVariant,
Readonly<{
backgroundClass: string;
bodyClass: string;
icon: (props: React.ComponentProps<'svg'>) => JSX.Element;
iconClass: string;
titleClass: string;
}>
> = {
danger: {
backgroundClass: 'bg-danger-50',
bodyClass: 'text-danger-700',
icon: XCircleIcon,
iconClass: 'text-danger-400',
titleClass: 'text-danger-800',
},
info: {
backgroundClass: 'bg-info-50',
bodyClass: 'text-info-700',
icon: InformationCircleIcon,
iconClass: 'text-info-400',
titleClass: 'text-info-800',
},
success: {
backgroundClass: 'bg-success-50',
bodyClass: 'text-success-700',
icon: CheckCircleIcon,
iconClass: 'text-success-400',
titleClass: 'text-success-800',
},
warning: {
backgroundClass: 'bg-warning-50',
bodyClass: 'text-warning-700',
icon: ExclamationTriangleIcon,
iconClass: 'text-warning-400',
titleClass: 'text-warning-800',
},
};
export default function Alert({ children, title, variant }: Props) {
const {
backgroundClass,
iconClass,
titleClass,
bodyClass,
icon: Icon,
} = classes[variant];
return (
<div className={clsx('rounded-md p-4', backgroundClass)}>
<div className="flex">
<div className="flex-shrink-0">
<Icon aria-hidden="true" className={clsx('h-5 w-5', iconClass)} />
</div>
<div className="ml-3 space-y-2">
{title && (
<h3 className={clsx('text-sm font-medium', titleClass)}>{title}</h3>
)}
<div className={clsx('text-sm', bodyClass)}>
<p>{children}</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,66 +0,0 @@
import clsx from 'clsx';
export type BadgeVariant =
| 'danger'
| 'info'
| 'primary'
| 'success'
| 'warning';
type Props = Readonly<{
endAddOn?: React.ComponentType<React.ComponentProps<'svg'>>;
label: string;
startAddOn?: React.ComponentType<React.ComponentProps<'svg'>>;
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({
endAddOn: EndAddOn,
label,
startAddOn: StartAddOn,
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,
)}>
{StartAddOn && <StartAddOn aria-hidden="true" className="mr-1 h-4 w-4" />}
<span>{label}</span>
{EndAddOn && <EndAddOn aria-hidden="true" className="ml-1 h-4 w-4" />}
</span>
);
}

View File

@@ -1,51 +0,0 @@
import clsx from 'clsx';
import React from 'react';
import { XMarkIcon } from '@heroicons/react/24/outline';
export type BannerSize = 'md' | 'sm' | 'xs';
type Props = Readonly<{
children: React.ReactNode;
onHide?: () => void;
size?: BannerSize;
}>;
export default function Banner({ children, size = 'md', onHide }: Props) {
return (
<div
className={clsx(
'bg-primary-600 relative',
size === 'sm' && 'text-sm',
size === 'xs' && 'text-xs',
)}>
<div className="mx-auto max-w-7xl py-2 px-3 sm:px-6 lg:px-8">
<div
className={clsx('text-center sm:px-16', onHide != null && 'pr-16')}>
<p className="font-medium text-white">{children}</p>
</div>
{onHide != null && (
<div
className={clsx(
'absolute inset-y-0 right-0 flex items-start sm:items-start',
size === 'md' && 'pt-2 pr-2',
size === 'sm' && 'pt-2 pr-2',
size === 'xs' && 'pt-1.5 pr-2',
)}>
<button
className={clsx(
'hover:bg-primary-400 flex rounded-md focus:outline-none focus:ring-2 focus:ring-white',
size === 'md' && 'p-1',
size === 'sm' && 'p-0.5',
size === 'xs' && 'p-0.5',
)}
type="button"
onClick={onHide}>
<span className="sr-only">Dismiss</span>
<XMarkIcon aria-hidden="true" className="h-6 w-6 text-white" />
</button>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,178 +0,0 @@
import clsx from 'clsx';
import Link from 'next/link';
import type { HTMLAttributeAnchorTarget } from 'react';
import type { UrlObject } from 'url';
import { Spinner } from '../';
export type ButtonAddOnPosition = 'end' | 'start';
export type ButtonDisplay = 'block' | 'inline';
export type ButtonSize = 'lg' | 'md' | 'sm';
export type ButtonType = 'button' | 'reset' | 'submit';
export type ButtonVariant =
| 'danger'
| 'info'
| 'primary'
| 'secondary'
| 'special'
| 'success'
| 'tertiary'
| 'warning';
type Props = Readonly<{
addonPosition?: ButtonAddOnPosition;
'aria-controls'?: string;
'aria-label'?: string;
className?: string;
disabled?: boolean;
display?: ButtonDisplay;
href?: UrlObject | string;
icon?: (props: React.ComponentProps<'svg'>) => JSX.Element;
isLabelHidden?: boolean;
isLoading?: boolean;
label: string;
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
rel?: string;
size?: ButtonSize;
target?: HTMLAttributeAnchorTarget;
type?: ButtonType;
variant: ButtonVariant;
}>;
const sizeClasses: Record<ButtonSize, string> = {
lg: 'px-5 py-2.5',
md: 'px-4 py-2',
sm: 'px-2.5 py-1.5',
};
const iconOnlySizeClasses: Record<ButtonSize, string> = {
lg: 'p-3',
md: 'p-2',
sm: 'p-1.5',
};
const baseClasses: Record<ButtonSize, string> = {
lg: 'text-base rounded-xl',
md: 'text-sm rounded-lg',
sm: 'text-xs rounded-md',
};
const sizeIconSpacingEndClasses: Record<ButtonSize, string> = {
lg: 'ml-3 -mr-1',
md: 'ml-2 -mr-1',
sm: 'ml-2 -mr-0.5',
};
const sizeIconSpacingStartClasses: Record<ButtonSize, string> = {
lg: 'mr-3 -ml-1',
md: 'mr-2 -ml-1',
sm: 'mr-2 -ml-0.5',
};
const sizeIconClasses: Record<ButtonSize, string> = {
lg: '!h-5 !w-5',
md: '!h-5 !w-5',
sm: '!h-4 !w-4',
};
const variantClasses: Record<ButtonVariant, string> = {
danger:
'border-transparent text-white bg-danger-600 hover:bg-danger-500 focus:ring-danger-500',
info: 'border-transparent text-white bg-info-600 hover:bg-info-500 focus:ring-info-500',
primary:
'border-transparent text-white bg-primary-600 hover:bg-primary-500 focus:ring-primary-500',
secondary:
'border-transparent text-primary-700 bg-primary-100 hover:bg-primary-200 focus:ring-primary-500',
special:
'border-slate-900 text-white bg-slate-900 hover:bg-slate-700 focus:ring-slate-900',
success:
'border-transparent text-white bg-success-600 hover:bg-success-500 focus:ring-success-500',
tertiary:
'border-slate-300 text-slate-700 bg-white hover:bg-slate-50 focus:ring-slate-600',
warning:
'border-transparent text-white bg-warning-600 hover:bg-warning-500 focus:ring-warning-500',
};
const variantDisabledClasses: Record<ButtonVariant, string> = {
danger: 'border-transparent text-slate-500 bg-slate-300',
info: 'border-transparent text-slate-500 bg-slate-300',
primary: 'border-transparent text-slate-500 bg-slate-300',
secondary: 'border-transparent text-slate-400 bg-slate-200',
special: 'border-transparent text-slate-500 bg-slate-300',
success: 'border-transparent text-slate-500 bg-slate-300',
tertiary: 'border-slate-300 text-slate-400 bg-slate-100',
warning: 'border-transparent text-slate-500 bg-slate-300',
};
export default function Button({
addonPosition = 'end',
'aria-controls': ariaControls,
'aria-label': ariaLabel,
className,
display = 'inline',
href,
icon: Icon,
disabled = false,
isLabelHidden = false,
isLoading = false,
label,
size = 'md',
type = 'button',
variant,
onClick,
rel,
target,
}: Props) {
const iconSpacingClass = (() => {
if (!isLabelHidden && addonPosition === 'start') {
return sizeIconSpacingStartClasses[size];
}
if (!isLabelHidden && addonPosition === 'end') {
return sizeIconSpacingEndClasses[size];
}
})();
const addOnClass = clsx(iconSpacingClass, sizeIconClasses[size]);
const addOn = isLoading ? (
<Spinner className={addOnClass} color="inherit" size="xs" />
) : Icon != null ? (
<Icon aria-hidden="true" className={addOnClass} />
) : null;
const children = (
<>
{addonPosition === 'start' && addOn}
{!isLabelHidden && label}
{addonPosition === 'end' && addOn}
</>
);
const commonProps = {
'aria-controls': ariaControls ?? undefined,
'aria-label': isLabelHidden ? ariaLabel ?? label : undefined,
children,
className: clsx(
display === 'block' ? 'flex w-full justify-center' : 'inline-flex',
'whitespace-nowrap items-center border font-medium focus:outline-none focus:ring-2 focus:ring-offset-2',
disabled ? variantDisabledClasses[variant] : variantClasses[variant],
disabled && 'pointer-events-none',
isLabelHidden ? iconOnlySizeClasses[size] : sizeClasses[size],
baseClasses[size],
className,
),
disabled,
onClick,
};
if (href == null) {
return (
<button type={type === 'button' ? 'button' : 'submit'} {...commonProps} />
);
}
return (
// TODO: Allow passing in of Link component.
<Link href={href} rel={rel} target={target} {...commonProps} />
);
}

View File

@@ -1,100 +0,0 @@
import clsx from 'clsx';
import type { ChangeEvent } from 'react';
import type { ForwardedRef } from 'react';
import { forwardRef, useId } from 'react';
type Props = Readonly<{
defaultValue?: boolean;
description?: string;
disabled?: boolean;
errorMessage?: string;
label: string;
name?: string;
onChange?: (
value: boolean,
event: ChangeEvent<HTMLInputElement>,
) => undefined | void;
value?: boolean;
}>;
function CheckboxInput(
{
defaultValue,
description,
disabled = false,
errorMessage,
label,
name,
value,
onChange,
}: Props,
ref: ForwardedRef<HTMLInputElement>,
) {
const id = useId();
const descriptionId = useId();
const errorId = useId();
return (
<div>
<div
className={clsx(
'relative flex',
// Vertically center only when there's no description.
description == null && 'items-center',
)}>
<div className="flex h-5 items-center">
<input
ref={ref}
aria-describedby={description != null ? descriptionId : undefined}
checked={value}
className={clsx(
'h-4 w-4 rounded border-slate-300',
disabled
? 'bg-slate-50 text-slate-400'
: 'text-primary-600 focus:ring-primary-500',
)}
defaultChecked={defaultValue}
disabled={disabled}
id={id}
name={name}
type="checkbox"
onChange={(event) => {
if (!onChange) {
return;
}
onChange(event.target.checked, event);
}}
/>
</div>
<div className="ml-3 text-sm">
<label
className={clsx(
'block font-medium',
disabled ? 'text-slate-400' : 'text-slate-700',
)}
htmlFor={id}>
{label}
</label>
{description && (
<p
className={clsx(
'text-xs',
disabled ? 'text-slate-400' : 'text-slate-500',
)}
id={descriptionId}>
{description}
</p>
)}
</div>
</div>
{errorMessage && (
<p className="text-danger-600 mt-2 text-sm" id={errorId}>
{errorMessage}
</p>
)}
</div>
);
}
export default forwardRef(CheckboxInput);

View File

@@ -1,46 +0,0 @@
import clsx from 'clsx';
import { useId } from 'react';
import type CheckboxInput from '../CheckboxInput/CheckboxInput';
export type CheckboxListOrientation = 'horizontal' | 'vertical';
type Props = Readonly<{
children: ReadonlyArray<React.ReactElement<typeof CheckboxInput>>;
description?: string;
isLabelHidden?: boolean;
label: string;
orientation?: CheckboxListOrientation;
}>;
export default function CheckboxList({
children,
description,
isLabelHidden,
label,
orientation = 'vertical',
}: Props) {
const labelId = useId();
return (
<div>
<div className={clsx(isLabelHidden ? 'sr-only' : 'mb-2')}>
<label className="text-sm font-medium text-gray-900" id={labelId}>
{label}
</label>
{description && (
<p className="text-xs leading-5 text-gray-500">{description}</p>
)}
</div>
<div
aria-labelledby={labelId}
className={clsx(
'space-y-2',
orientation === 'horizontal' &&
'sm:flex sm:items-center sm:space-y-0 sm:space-x-10',
)}
role="group">
{children}
</div>
</div>
);
}

View File

@@ -1,33 +0,0 @@
import clsx from 'clsx';
import type { ReactNode } from 'react';
import { Disclosure } from '@headlessui/react';
import { ChevronDownIcon } from '@heroicons/react/20/solid';
type Props = Readonly<{
children: ReactNode;
defaultOpen?: boolean;
label: ReactNode;
}>;
export default function Collapsible({ children, defaultOpen, label }: Props) {
return (
<Disclosure defaultOpen={defaultOpen}>
{({ open }) => (
<>
<Disclosure.Button className="-mx-2.5 box-content flex w-full justify-between rounded-lg px-2.5 py-2 text-left text-sm font-medium text-slate-900 hover:bg-slate-100 focus:outline-none focus-visible:ring focus-visible:ring-slate-500 focus-visible:ring-opacity-75">
<ChevronDownIcon
className={clsx(
'mr-1 -ml-1 h-5 w-5 text-slate-500',
open && 'rotate-180 transform',
)}
/>
<span className="flex-1">{label}</span>
</Disclosure.Button>
<Disclosure.Panel className="w-full pt-1 pb-2 text-sm text-gray-500">
{children}
</Disclosure.Panel>
</>
)}
</Disclosure>
);
}

View File

@@ -1,89 +0,0 @@
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

@@ -1,93 +0,0 @@
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 = 'lg' | 'md' | 'sm';
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',
};
const sizeClasses: Record<DropdownMenuSize, string> = {
lg: 'px-5 py-2.5',
md: 'px-4 py-2',
sm: 'px-2.5 py-1.5',
};
const baseClasses: Record<DropdownMenuSize, string> = {
lg: 'text-base rounded-xl',
md: 'text-sm rounded-lg',
sm: 'text-xs rounded-md',
};
const sizeIconSpacingEndClasses: Record<DropdownMenuSize, string> = {
lg: 'ml-3 -mr-1',
md: 'ml-2 -mr-1',
sm: 'ml-2 -mr-0.5',
};
const sizeIconClasses: Record<DropdownMenuSize, string> = {
lg: '!h-5 !w-5',
md: '!h-5 !w-5',
sm: '!h-4 !w-4',
};
export default function DropdownMenu({
align = 'start',
children,
label,
size = 'md',
}: Props) {
return (
<Menu as="div" className="relative inline-block">
<div className="flex">
<Menu.Button
className={clsx(
'group inline-flex items-center justify-center whitespace-nowrap border border-slate-300 bg-white font-medium text-slate-700 hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-600 focus:ring-offset-2',
baseClasses[size],
sizeClasses[size],
)}>
<div>{label}</div>
<ChevronDownIcon
aria-hidden="true"
className={clsx(
'flex-shrink-0 text-slate-400 group-hover:text-slate-500',
sizeIconSpacingEndClasses[size],
sizeIconClasses[size],
)}
/>
</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

@@ -1,40 +0,0 @@
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

@@ -1,14 +0,0 @@
import clsx from 'clsx';
type Props = Readonly<{
className?: string;
}>;
export default function HorizontalDivider({ className }: Props) {
return (
<hr
aria-hidden={true}
className={clsx('my-2 h-0 border-t border-slate-200', className)}
/>
);
}

View File

@@ -1,143 +0,0 @@
import clsx from 'clsx';
import type { ReactElement } from 'react';
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/20/solid';
type Props = Readonly<{
current: number;
end: number;
label: string;
onSelect: (page: number, event: React.MouseEvent<HTMLElement>) => void;
pagePadding?: number;
start: number;
}>;
function PaginationPage({
isCurrent = false,
label,
onClick,
}: Readonly<{
isCurrent?: boolean;
label: number;
onClick: (event: React.MouseEvent<HTMLElement>) => void;
}>) {
return (
<button
aria-current={isCurrent}
className={clsx(
'focus:ring-primary-500 focus:border-primary-500 relative inline-flex items-center border px-4 py-2 text-sm font-medium focus:z-20 focus:outline-none focus:ring-1',
isCurrent
? 'border-primary-500 bg-primary-50 text-primary-600 z-10'
: 'border-slate-300 bg-white text-slate-500 hover:bg-slate-50',
)}
disabled={isCurrent}
type="button"
onClick={onClick}>
{label}
</button>
);
}
function PaginationEllipsis() {
return (
<span className="relative inline-flex items-center border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700">
...
</span>
);
}
export default function Pagination({
current,
end,
label,
onSelect,
pagePadding = 1,
start = 1,
}: Props) {
const pageNumberSet = new Set();
const pageNumberList: Array<number | string> = [];
const elements: Array<ReactElement> = [];
let lastAddedPage = 0;
function addPage(page: number) {
if (page < start || page > end) {
return;
}
if (!pageNumberSet.has(page)) {
lastAddedPage = page;
pageNumberList.push(page);
pageNumberSet.add(page);
elements.push(
<PaginationPage
key={page}
isCurrent={current === page}
label={page}
onClick={(event) => {
onSelect(page, event);
}}
/>,
);
}
}
for (let i = start; i <= start + pagePadding; i++) {
addPage(i);
}
if (lastAddedPage < current - pagePadding - 1) {
elements.push(<PaginationEllipsis key="ellipse-1" />);
}
for (let i = current - pagePadding; i <= current + pagePadding; i++) {
addPage(i);
}
if (lastAddedPage < end - pagePadding - 1) {
elements.push(<PaginationEllipsis key="ellipse-2" />);
}
for (let i = end - pagePadding; i <= end; i++) {
addPage(i);
}
const isPrevButtonDisabled = current === start;
const isNextButtonDisabled = current === end;
return (
<nav
aria-label={label}
className="isolate inline-flex -space-x-px rounded-md shadow-sm">
<button
aria-label="Previous"
className={clsx(
'relative inline-flex items-center rounded-l-md border border-slate-300 px-2 py-2 text-sm font-medium focus:z-20',
isPrevButtonDisabled
? 'text-slate-300'
: 'focus:ring-primary-500 focus:border-primary-500 bg-white text-slate-500 hover:bg-slate-50 focus:outline-none focus:ring-1',
)}
disabled={isPrevButtonDisabled}
type="button"
onClick={(event) => {
onSelect(current - 1, event);
}}>
<ChevronLeftIcon aria-hidden="true" className="h-5 w-5" />
</button>
{elements}
<button
aria-label="Next"
className={clsx(
'relative inline-flex items-center rounded-r-md border border-slate-300 px-2 py-2 text-sm font-medium focus:z-20',
isNextButtonDisabled
? 'text-slate-300'
: 'focus:ring-primary-500 focus:border-primary-500 bg-white text-slate-500 hover:bg-slate-50 focus:outline-none focus:ring-1',
)}
disabled={isNextButtonDisabled}
type="button"
onClick={(event) => {
onSelect(current + 1, event);
}}>
<ChevronRightIcon aria-hidden="true" className="h-5 w-5" />
</button>
</nav>
);
}

View File

@@ -1,72 +0,0 @@
import clsx from 'clsx';
import type { ChangeEvent } from 'react';
import { useId } from 'react';
import { RadioListContext } from './RadioListContext';
import RadioListItem from './RadioListItem';
export type RadioListOrientation = 'horizontal' | 'vertical';
type Props<T> = Readonly<{
children: ReadonlyArray<React.ReactElement<typeof RadioListItem>>;
defaultValue?: T;
description?: string;
isLabelHidden?: boolean;
label: string;
name?: string;
onChange?: (value: T, event: ChangeEvent<HTMLInputElement>) => void;
orientation?: RadioListOrientation;
required?: boolean;
value?: T;
}>;
RadioList.Item = RadioListItem;
export default function RadioList<T>({
children,
defaultValue,
description,
isLabelHidden,
name,
orientation = 'vertical',
label,
required,
value,
onChange,
}: Props<T>) {
const labelId = useId();
return (
<RadioListContext.Provider
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore TODO: Figure out how to type the onChange.
value={{ defaultValue, name, onChange, value }}>
<div>
<div className={clsx(isLabelHidden ? 'sr-only' : 'mb-2')}>
<label className="text-sm font-medium text-gray-900" id={labelId}>
{label}
{required && (
<span aria-hidden="true" className="text-danger-500">
{' '}
*
</span>
)}
</label>
{description && (
<p className="text-xs leading-5 text-gray-500">{description}</p>
)}
</div>
<div
aria-labelledby={labelId}
aria-required={required != null ? required : undefined}
className={clsx(
'space-y-2',
orientation === 'horizontal' &&
'sm:flex sm:items-center sm:space-y-0 sm:space-x-10',
)}
role="radiogroup">
{children}
</div>
</div>
</RadioListContext.Provider>
);
}

View File

@@ -1,20 +0,0 @@
import type { ChangeEvent } from 'react';
import { createContext, useContext } from 'react';
type RadioListContextValue<T = unknown> = {
defaultValue?: T;
name?: string;
onChange?: (
value: T,
event: ChangeEvent<HTMLInputElement>,
) => undefined | void;
value?: T;
};
export const RadioListContext =
createContext<RadioListContextValue<unknown> | null>(null);
export function useRadioListContext<T>(): RadioListContextValue<T> | null {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore TODO: Figure out how to type useContext with generics.
return useContext<T>(RadioListContext);
}

View File

@@ -1,80 +0,0 @@
import clsx from 'clsx';
import { useId } from 'react';
import { useRadioListContext } from './RadioListContext';
type Props<T> = Readonly<{
description?: string;
disabled?: boolean;
label: string;
value: T;
}>;
export default function RadioListItem<T>({
description,
disabled = false,
label,
value,
}: Props<T>) {
const id = useId();
const descriptionId = useId();
const context = useRadioListContext();
return (
<div
className={clsx(
'relative flex',
// Vertically center only when there's no description.
description == null && 'items-center',
)}>
<div className="flex h-5 items-center">
<input
aria-describedby={description != null ? descriptionId : undefined}
checked={
context?.value != null ? value === context?.value : undefined
}
className={clsx(
'text-primary-600 focus:ring-primary-500 h-4 w-4 border-slate-300',
disabled && 'bg-slate-100',
)}
defaultChecked={
context?.defaultValue != null
? value === context?.defaultValue
: undefined
}
disabled={disabled}
id={id}
name={context?.name}
type="radio"
onChange={
context?.onChange != null
? (event) => {
context?.onChange?.(value, event);
}
: undefined
}
/>
</div>
<div className="ml-3 text-sm">
<label
className={clsx(
'block font-medium',
disabled ? 'text-slate-400' : 'text-slate-700',
)}
htmlFor={id}>
{label}
</label>
{description && (
<p
className={clsx(
'text-xs',
disabled ? 'text-slate-400' : 'text-slate-500',
)}
id={descriptionId}>
{description}
</p>
)}
</div>
</div>
);
}

View File

@@ -1,124 +0,0 @@
import clsx from 'clsx';
import type { ForwardedRef, SelectHTMLAttributes } from 'react';
import { forwardRef } from 'react';
import { useId } from 'react';
type Attributes = Pick<
SelectHTMLAttributes<HTMLSelectElement>,
'disabled' | 'name' | 'onBlur' | 'onFocus' | 'required'
>;
export type SelectItem<T> = Readonly<{
label: string;
value: T;
}>;
export type SelectDisplay = 'block' | 'inline';
export type SelectBorderStyle = 'bordered' | 'borderless';
type Props<T> = Readonly<{
borderStyle?: SelectBorderStyle;
defaultValue?: T;
display?: SelectDisplay;
errorMessage?: string;
isLabelHidden?: boolean;
label: string;
name?: string;
onChange?: (value: string) => void;
options: ReadonlyArray<SelectItem<T>>;
placeholder?: string;
value?: T;
}> &
Readonly<Attributes>;
const borderClasses: Record<SelectBorderStyle, string> = {
bordered: 'border-slate-300',
borderless: 'border-transparent bg-transparent',
};
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: 'focus:border-primary-500 focus:ring-primary-500',
};
function Select<T>(
{
borderStyle = 'bordered',
defaultValue,
display,
disabled,
errorMessage,
label,
isLabelHidden,
options,
placeholder,
required,
value,
onChange,
...props
}: Props<T>,
ref: ForwardedRef<HTMLSelectElement>,
) {
const hasError = errorMessage != null;
const id = useId();
const errorId = useId();
const state: State = hasError ? 'error' : 'normal';
return (
<div>
{!isLabelHidden && (
<label
className={clsx('mb-1 block text-sm font-medium text-slate-700')}
htmlFor={id ?? undefined}>
{label}
{required && (
<span aria-hidden="true" className="text-danger-500">
{' '}
*
</span>
)}
</label>
)}
<select
ref={ref}
aria-describedby={hasError ? errorId : undefined}
aria-label={isLabelHidden ? label : undefined}
className={clsx(
display === 'block' && 'block w-full',
'rounded-md py-2 pl-3 pr-8 text-sm focus:outline-none disabled:bg-slate-50 disabled:text-slate-500',
stateClasses[state],
borderClasses[borderStyle],
)}
defaultValue={defaultValue != null ? String(defaultValue) : undefined}
disabled={disabled}
id={id}
required={required}
value={value != null ? String(value) : undefined}
onChange={(event) => {
onChange?.(event.target.value);
}}
{...props}>
{placeholder && (
<option disabled={true} hidden={true} selected={true} value="">
{placeholder}
</option>
)}
{options.map(({ label: optionLabel, value: optionValue }) => (
<option key={String(optionValue)} value={String(optionValue)}>
{optionLabel}
</option>
))}
</select>
{errorMessage && (
<p className="text-danger-600 mt-2 text-sm" id={errorId}>
{errorMessage}
</p>
)}
</div>
);
}
export default forwardRef(Select);

View File

@@ -1,101 +0,0 @@
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;
className?: string;
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,
className,
enterFrom = 'end',
isShown = false,
size,
title,
onClose,
}: Props) {
const enterFromClass = enterFromClasses[enterFrom];
return (
<Transition.Root as={Fragment} show={isShown}>
<Dialog
as="div"
className={clsx('relative z-40', className)}
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

@@ -1,52 +0,0 @@
import clsx from 'clsx';
export type SpinnerColor = 'default' | 'inherit';
export type SpinnerSize = 'lg' | 'md' | 'sm' | 'xs';
export type SpinnerDisplay = 'block' | 'inline';
type Props = Readonly<{
className?: string;
color?: SpinnerColor;
display?: SpinnerDisplay;
label?: string;
size: SpinnerSize;
}>;
const colorClasses: Record<SpinnerColor, string> = {
default: 'text-slate-400',
inherit: '',
};
const sizeClasses: Record<SpinnerSize, string> = {
lg: 'w-12 h-12 border-[6px]',
md: 'w-8 h-8 border-4',
sm: 'w-6 h-6 border-[3px]',
xs: 'w-4 h-4 border-2',
};
export default function Spinner({
className,
color = 'default',
display = 'inline',
label = 'Loading...',
size,
}: Props) {
const spinner = (
<div
className={clsx(
'inline-block animate-spin rounded-full border-current border-r-transparent',
colorClasses[color],
sizeClasses[size],
className,
)}
role="status">
<span className="sr-only">{label}</span>
</div>
);
if (display === 'block') {
return <div className="text-center">{spinner}</div>;
}
return spinner;
}

View File

@@ -1,58 +0,0 @@
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">
<nav aria-label={label} className="flex space-x-2">
{tabs.map((tab) => {
const isSelected = tab.value === value;
const commonProps = {
'aria-label': tab.label,
'aria-selected': isSelected,
children: tab.label,
className: clsx(
isSelected
? 'bg-primary-100 text-primary-700'
: 'hover:bg-slate-100 text-slate-500 hover:text-slate-700',
'px-3 py-2 font-medium text-sm rounded-md',
),
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>
);
}

View File

@@ -1,149 +0,0 @@
import clsx from 'clsx';
import type {
ChangeEvent,
FocusEvent,
ForwardedRef,
TextareaHTMLAttributes,
} from 'react';
import React, { forwardRef, useId } from 'react';
type Attributes = Pick<
TextareaHTMLAttributes<HTMLTextAreaElement>,
| 'autoComplete'
| 'autoFocus'
| 'disabled'
| 'maxLength'
| 'minLength'
| 'name'
| 'onBlur'
| 'onFocus'
| 'placeholder'
| 'readOnly'
| 'required'
| 'rows'
>;
export type TextAreaResize = 'both' | 'horizontal' | 'none' | 'vertical';
type Props = Readonly<{
defaultValue?: string;
description?: React.ReactNode;
errorMessage?: React.ReactNode;
id?: string;
isLabelHidden?: boolean;
label: string;
onBlur?: (event: FocusEvent<HTMLTextAreaElement>) => void;
onChange?: (value: string, event: ChangeEvent<HTMLTextAreaElement>) => void;
resize?: TextAreaResize;
value?: string;
}> &
Readonly<Attributes>;
type State = 'error' | 'normal';
const stateClasses: Record<
State,
Readonly<{
textArea: string;
}>
> = {
error: {
textArea:
'border-danger-300 focus:ring-danger-500 focus:border-danger-500 text-danger-900 placeholder-danger-300',
},
normal: {
textArea:
'border-slate-300 focus:border-primary-500 focus:ring-primary-500 placeholder:text-slate-400',
},
};
const resizeClasses: Record<TextAreaResize, string> = {
both: 'resize',
horizontal: 'resize-x',
none: 'resize-none',
vertical: 'resize-y',
};
function TextArea(
{
defaultValue,
description,
disabled,
errorMessage,
id: idParam,
isLabelHidden,
label,
resize = 'vertical',
required,
value,
onChange,
...props
}: Props,
ref: ForwardedRef<HTMLTextAreaElement>,
) {
const hasError = errorMessage != null;
const generatedId = useId();
const id = idParam ?? generatedId;
const messageId = useId();
const state: State = hasError ? 'error' : 'normal';
return (
<div>
<label
className={clsx(
isLabelHidden
? 'sr-only'
: 'mb-1 block text-sm font-medium text-gray-700',
)}
htmlFor={id}>
{label}
{required && (
<span aria-hidden="true" className="text-danger-500">
{' '}
*
</span>
)}
</label>
<div>
<textarea
ref={ref}
aria-describedby={
hasError || description != null ? messageId : undefined
}
aria-invalid={hasError ? true : undefined}
className={clsx(
'block w-full rounded-md text-sm disabled:bg-slate-50 disabled:text-slate-500',
stateClasses[state].textArea,
resizeClasses[resize],
)}
defaultValue={defaultValue}
disabled={disabled}
id={id}
name="comment"
required={required}
value={value != null ? value : undefined}
onChange={(event) => {
if (!onChange) {
return;
}
onChange(event.target.value, event);
}}
{...props}
/>
</div>
{(errorMessage ?? description) && (
<p
className={clsx(
'mt-2 text-sm',
errorMessage ? 'text-danger-600' : 'text-slate-500',
)}
id={messageId}>
{errorMessage ?? description}
</p>
)}
</div>
);
}
export default forwardRef(TextArea);

View File

@@ -1,247 +0,0 @@
import clsx from 'clsx';
import type {
ChangeEvent,
FocusEvent,
ForwardedRef,
InputHTMLAttributes,
} from 'react';
import React, { forwardRef, useId } from 'react';
type Attributes = Pick<
InputHTMLAttributes<HTMLInputElement>,
| 'autoComplete'
| 'disabled'
| 'max'
| 'maxLength'
| 'min'
| 'minLength'
| 'name'
| 'onBlur'
| 'onFocus'
| 'pattern'
| 'placeholder'
| 'required'
| 'type'
>;
type StartAddOnProps =
| Readonly<{
startAddOn: React.ComponentType<React.ComponentProps<'svg'>>;
startAddOnType: 'icon';
}>
| Readonly<{
startAddOn: React.ReactNode;
startAddOnType: 'element';
}>
| Readonly<{
startAddOn: string;
startAddOnType: 'label';
}>
| Readonly<{
startAddOn?: undefined;
startAddOnType?: undefined;
}>;
type EndAddOnProps =
| Readonly<{
endAddOn: React.ComponentType<React.ComponentProps<'svg'>>;
endAddOnType: 'icon';
}>
| Readonly<{
endAddOn: React.ReactNode;
endAddOnType: 'element';
}>
| Readonly<{
endAddOn: string;
endAddOnType: 'label';
}>
| Readonly<{
endAddOn?: undefined;
endAddOnType?: undefined;
}>;
type BaseProps = Readonly<{
defaultValue?: string;
description?: React.ReactNode;
endIcon?: React.ComponentType<React.ComponentProps<'svg'>>;
errorMessage?: React.ReactNode;
id?: string;
isLabelHidden?: boolean;
label: string;
onBlur?: (event: FocusEvent<HTMLInputElement>) => void;
onChange?: (value: string, event: ChangeEvent<HTMLInputElement>) => void;
value?: string;
}> &
Readonly<Attributes>;
type Props = BaseProps & EndAddOnProps & StartAddOnProps;
type State = 'error' | 'normal';
const stateClasses: Record<
State,
Readonly<{
container: string;
input: string;
}>
> = {
error: {
container:
'border-danger-300 focus-within:outline-none focus-within:ring-danger-500 focus-within:border-danger-500',
input: 'text-danger-900 placeholder-danger-300',
},
normal: {
container:
'focus-within:ring-primary-500 focus-within:border-primary-500 border-slate-300',
input: 'placeholder:text-slate-400',
},
};
function TextInput(
{
defaultValue,
description,
disabled,
endAddOn,
endAddOnType,
errorMessage,
id: idParam,
isLabelHidden = false,
label,
required,
startAddOn,
startAddOnType,
type = 'text',
value,
onChange,
...props
}: Props,
ref: ForwardedRef<HTMLInputElement>,
) {
const hasError = errorMessage != null;
const generatedId = useId();
const id = idParam ?? generatedId;
const messageId = useId();
const state: State = hasError ? 'error' : 'normal';
const { input: inputClass, container: containerClass } = stateClasses[state];
return (
<div>
<label
className={clsx(
isLabelHidden
? 'sr-only'
: 'mb-1 block text-sm font-medium text-slate-700',
)}
htmlFor={id}>
{label}
{required && (
<span aria-hidden="true" className="text-danger-500">
{' '}
*
</span>
)}
</label>
<div
className={clsx(
'flex w-full overflow-hidden rounded-md border text-sm focus-within:ring-1',
disabled ? 'pointer-events-none select-none bg-slate-50' : 'bg-white',
containerClass,
)}>
{(() => {
if (startAddOnType == null) {
return;
}
switch (startAddOnType) {
case 'label':
return (
<div className="pointer-events-none flex items-center pl-3 text-slate-500">
{startAddOn}
</div>
);
case 'icon': {
const StartAddOn = startAddOn;
return (
<div className="pointer-events-none flex items-center pl-3">
<StartAddOn
aria-hidden="true"
className="h-5 w-5 text-slate-400"
/>
</div>
);
}
case 'element':
return startAddOn;
}
})()}
<input
ref={ref}
aria-describedby={
hasError || description != null ? messageId : undefined
}
aria-invalid={hasError ? true : undefined}
className={clsx(
'w-0 flex-1 border-none text-sm focus:outline-none focus:ring-0 disabled:cursor-not-allowed disabled:bg-transparent disabled:text-slate-500',
inputClass,
)}
defaultValue={defaultValue}
disabled={disabled}
id={id}
required={required}
type={type}
value={value != null ? value : undefined}
onChange={(event) => {
if (!onChange) {
return;
}
onChange(event.target.value, event);
}}
// To prevent scrolling from changing number input value
onWheel={(event) => event.currentTarget.blur()}
{...props}
/>
{(() => {
if (endAddOnType == null) {
return;
}
switch (endAddOnType) {
case 'label':
return (
<div className="pointer-events-none flex items-center pr-3 text-slate-500">
{endAddOn}
</div>
);
case 'icon': {
const EndAddOn = endAddOn;
return (
<div className="pointer-events-none flex items-center pr-3">
<EndAddOn
aria-hidden="true"
className="h-5 w-5 text-slate-400"
/>
</div>
);
}
case 'element':
return endAddOn;
}
})()}
</div>
{(errorMessage ?? description) && (
<p
className={clsx(
'mt-2 text-sm',
errorMessage ? 'text-danger-600' : 'text-slate-500',
)}
id={messageId}>
{errorMessage ?? description}
</p>
)}
</div>
);
}
export default forwardRef(TextInput);

View File

@@ -1,108 +0,0 @@
import { Fragment, useEffect, useRef } from 'react';
import { Transition } from '@headlessui/react';
import { CheckIcon } from '@heroicons/react/24/outline';
import { XMarkIcon } from '@heroicons/react/24/solid';
type ToastVariant = 'failure' | 'success';
export type ToastMessage = {
duration?: number;
subtitle?: string;
title: string;
variant: ToastVariant;
};
type Props = Readonly<{
duration?: number;
onClose: () => void;
subtitle?: string;
title: string;
variant: ToastVariant;
}>;
const DEFAULT_DURATION = 5000;
function ToastIcon({ variant }: Readonly<{ variant: ToastVariant }>) {
switch (variant) {
case 'success':
return (
<CheckIcon aria-hidden="true" className="text-success-500 h-6 w-6" />
);
case 'failure':
return (
<XMarkIcon aria-hidden="true" className="text-error-500 h-6 w-6" />
);
}
}
export default function Toast({
duration = DEFAULT_DURATION,
title,
subtitle,
variant,
onClose,
}: Props) {
const timer = useRef<number | null>(null);
function clearTimer() {
if (timer.current == null) {
return;
}
window.clearTimeout(timer.current);
timer.current = null;
}
function close() {
onClose();
clearTimer();
}
useEffect(() => {
timer.current = window.setTimeout(() => {
close();
}, duration);
return () => {
clearTimer();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<Transition
as={Fragment}
enter="transform ease-out duration-300 transition"
enterFrom="translate-y-2 opacity-0 sm:translate-y-2 sm:translate-x-2"
enterTo="translate-y-0 opacity-100 sm:translate-x-0"
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={true}>
<div className="pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5">
<div className="p-4">
<div className="flex items-start">
<div className="flex-shrink-0">
<ToastIcon variant={variant} />
</div>
<div className="ml-3 w-0 flex-1 space-y-1 pt-0.5">
<p className="text-sm font-medium text-slate-900">{title}</p>
{subtitle && (
<p className="mt-1 text-sm text-slate-500">{subtitle}</p>
)}
</div>
<div className="ml-4 flex flex-shrink-0">
<button
className="focus:ring-brand-500 inline-flex rounded-md bg-white text-slate-400 hover:text-slate-500 focus:outline-none focus:ring-2 focus:ring-offset-2"
type="button"
onClick={close}>
<span className="sr-only">Close</span>
<XMarkIcon aria-hidden="true" className="h-5 w-5" />
</button>
</div>
</div>
</div>
</div>
</Transition>
);
}

View File

@@ -1,72 +0,0 @@
import React, { createContext, useContext, useState } from 'react';
import type { ToastMessage } from './Toast';
import Toast from './Toast';
type Context = Readonly<{
showToast: (message: ToastMessage) => void;
}>;
export const ToastContext = createContext<Context>({
// eslint-disable-next-line @typescript-eslint/no-empty-function
showToast: (_: ToastMessage) => {},
});
const getID = (() => {
let id = 0;
return () => {
return id++;
};
})();
type ToastData = ToastMessage & {
id: number;
};
type Props = Readonly<{
children: React.ReactNode;
}>;
export function useToast() {
return useContext(ToastContext);
}
export default function ToastsProvider({ children }: Props) {
const [toasts, setToasts] = useState<Array<ToastData>>([]);
function showToast({ title, subtitle, variant }: ToastMessage) {
setToasts([{ id: getID(), subtitle, title, variant }, ...toasts]);
}
function closeToast(id: number) {
setToasts((oldToasts) => {
const newToasts = oldToasts.filter((toast) => toast.id !== id);
return newToasts;
});
}
return (
<ToastContext.Provider value={{ showToast }}>
{children}
<div
aria-live="assertive"
className="pointer-events-none fixed inset-0 z-10 flex items-end px-4 py-6 sm:p-6">
<div className="flex w-full flex-col items-center space-y-4 sm:items-end">
{/* Notification panel, dynamically insert this into the live region when it needs to be displayed */}
{toasts.map(({ id, title, subtitle, variant }) => (
<Toast
key={id}
subtitle={subtitle}
title={title}
variant={variant}
onClose={() => {
closeToast(id);
}}
/>
))}
</div>
</div>
</ToastContext.Provider>
);
}

View File

@@ -1,247 +0,0 @@
import clsx from 'clsx';
import type { InputHTMLAttributes } from 'react';
import { useId } from 'react';
import { Fragment, useState } from 'react';
import { Combobox, Transition } from '@headlessui/react';
import { CheckIcon, ChevronDownIcon } from '@heroicons/react/20/solid';
import { Spinner } from '..';
export type TypeaheadOption = Readonly<{
// String value to uniquely identify the option.
id: string;
label: string;
value: string;
}>;
export type TypeaheadTextSize = 'default' | 'inherit';
type Attributes = Pick<
InputHTMLAttributes<HTMLInputElement>,
| 'disabled'
| 'name'
| 'onBlur'
| 'onFocus'
| 'pattern'
| 'placeholder'
| 'required'
>;
type Props = Readonly<{
errorMessage?: React.ReactNode;
isLabelHidden?: boolean;
isLoading?: boolean;
label: string;
// Minimum query length before any results will be shown.
minQueryLength?: number;
noResultsMessage?: string;
onQueryChange: (
value: string,
event: React.ChangeEvent<HTMLInputElement>,
) => void;
options: ReadonlyArray<TypeaheadOption>;
textSize?: TypeaheadTextSize;
value?: TypeaheadOption | null;
}> &
Readonly<Attributes> &
(
| {
nullable: true;
onSelect: (option: TypeaheadOption | null) => void;
}
| {
nullable?: false;
onSelect: (option: TypeaheadOption) => void;
}
);
type State = 'error' | 'normal';
const stateClasses: Record<
State,
Readonly<{
container: string;
input: string;
}>
> = {
error: {
container:
'border-danger-300 focus-within:outline-none focus-within:ring-danger-500 focus-within:border-danger-500',
input: 'text-danger-900 placeholder-danger-300',
},
normal: {
container:
'focus-within:ring-primary-500 focus-within:border-primary-500 border-slate-300',
input: 'placeholder:text-slate-400',
},
};
const textSizes: Record<TypeaheadTextSize, string> = {
default: 'text-sm',
inherit: '',
};
export default function Typeahead({
disabled = false,
errorMessage,
isLabelHidden,
isLoading = false,
label,
minQueryLength = 0,
noResultsMessage = 'No results',
nullable = false,
options,
onQueryChange,
required,
textSize = 'default',
value,
onSelect,
...props
}: Props) {
const hasError = errorMessage != null;
const errorId = useId();
const state: State = hasError ? 'error' : 'normal';
const [query, setQuery] = useState('');
return (
<div>
<Combobox
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
by="id"
disabled={disabled}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
multiple={false}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
nullable={nullable}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
value={value}
onChange={(newValue) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
onSelect(newValue as TypeaheadOption | null);
}}>
<Combobox.Label
className={clsx(
isLabelHidden
? 'sr-only'
: clsx(
'mb-1 block font-medium text-slate-700',
textSizes[textSize],
),
)}>
{label}
{required && (
<span aria-hidden="true" className="text-danger-500">
{' '}
*
</span>
)}
</Combobox.Label>
<div className="relative">
<div
className={clsx(
'relative w-full cursor-default overflow-hidden rounded-md border text-left focus-within:ring-1',
disabled && 'pointer-events-none select-none bg-slate-50',
stateClasses[state].container,
textSizes[textSize],
)}>
<Combobox.Input
aria-describedby={hasError ? errorId : undefined}
autoComplete="nope" // "off" doesn't work as intended sometimes, so we use a random string.
className={clsx(
'w-full border-none py-2 pl-3 pr-10 text-[length:inherit] leading-5 focus:ring-0',
stateClasses[state].input,
textSizes[textSize],
'disabled:cursor-not-allowed disabled:bg-transparent disabled:text-slate-500',
)}
displayValue={(option) =>
(option as unknown as TypeaheadOption)?.label
}
required={required}
onChange={(event) => {
setQuery(event.target.value);
onQueryChange(event.target.value, event);
}}
{...props}
/>
{isLoading ? (
<div className="absolute inset-y-0 right-0 flex items-center pr-2">
<Spinner size="xs" />
</div>
) : (
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronDownIcon
aria-hidden="true"
className="h-5 w-5 text-slate-400"
/>
</Combobox.Button>
)}
</div>
{query.length >= minQueryLength && !isLoading && (
<Transition
afterLeave={() => setQuery('')}
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<Combobox.Options
className={clsx(
'absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none',
textSizes[textSize],
)}>
{options.length === 0 && query !== '' ? (
<div className="relative cursor-default select-none py-2 px-4 text-slate-700">
{noResultsMessage}
</div>
) : (
options.map((option) => (
<Combobox.Option
key={option.id}
className={({ active }) =>
clsx(
'relative cursor-default select-none py-2 px-4 text-slate-500',
active && 'bg-slate-100',
)
}
value={option}>
{({ selected }) => (
<>
<span
className={clsx(
'block truncate',
selected && 'font-medium',
)}>
{option.label}
</span>
{selected && (
<span
className={clsx(
'absolute inset-y-0 right-0 flex items-center pr-4',
)}>
<CheckIcon
aria-hidden="true"
className="h-5 w-5"
/>
</span>
)}
</>
)}
</Combobox.Option>
))
)}
</Combobox.Options>
</Transition>
)}
</div>
</Combobox>
{errorMessage && (
<p className="text-danger-600 mt-2 text-sm" id={errorId}>
{errorMessage}
</p>
)}
</div>
);
}

View File

@@ -1,63 +0,0 @@
// Alert
export * from './Alert/Alert';
export { default as Alert } from './Alert/Alert';
// Badge
export * from './Badge/Badge';
export { default as Badge } from './Badge/Badge';
// Banner
export * from './Banner/Banner';
export { default as Banner } from './Banner/Banner';
// Button
export * from './Button/Button';
export { default as Button } from './Button/Button';
// CheckboxInput
export * from './CheckboxInput/CheckboxInput';
export { default as CheckboxInput } from './CheckboxInput/CheckboxInput';
// CheckboxList
export * from './CheckboxList/CheckboxList';
export { default as CheckboxList } from './CheckboxList/CheckboxList';
// Collapsible
export * from './Collapsible/Collapsible';
export { default as Collapsible } from './Collapsible/Collapsible';
// Dialog
export * from './Dialog/Dialog';
export { default as Dialog } from './Dialog/Dialog';
// DropdownMenu
export * from './DropdownMenu/DropdownMenu';
export { default as DropdownMenu } from './DropdownMenu/DropdownMenu';
// HorizontalDivider
export * from './HorizontalDivider/HorizontalDivider';
export { default as HorizontalDivider } from './HorizontalDivider/HorizontalDivider';
// Pagination
export * from './Pagination/Pagination';
export { default as Pagination } from './Pagination/Pagination';
// RadioList
export * from './RadioList/RadioList';
export { default as RadioList } from './RadioList/RadioList';
// 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';
// TextArea
export * from './TextArea/TextArea';
export { default as TextArea } from './TextArea/TextArea';
// TextInput
export * from './TextInput/TextInput';
export { default as TextInput } from './TextInput/TextInput';
// Toast
export * from './Toast/Toast';
export { default as Toast } from './Toast/Toast';
// ToastsProvider
export * from './Toast/ToastsProvider';
export { default as ToastsProvider } from './Toast/ToastsProvider';
// Typeahead
export * from './Typeahead/Typeahead';
export { default as Typeahead } from './Typeahead/Typeahead';

View File

@@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -1,3 +0,0 @@
const config = require('@tih/tailwind-config/tailwind.config.js');
module.exports = config;

View File

@@ -1,8 +0,0 @@
{
"compilerOptions": {
"lib": ["dom", "ES2015"]
},
"extends": "@tih/tsconfig/react-library.json",
"include": ["."],
"exclude": ["dist", "build", "node_modules"]
}