mirror of
https://github.com/yangshun/tech-interview-handbook.git
synced 2026-04-27 03:52:34 +08:00
chore: merge ui package into portal
This commit is contained in:
@@ -1,8 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ['tih'],
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
project: ['./tsconfig.json'],
|
||||
},
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -1,3 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -1,3 +0,0 @@
|
||||
const config = require('@tih/tailwind-config/tailwind.config.js');
|
||||
|
||||
module.exports = config;
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "ES2015"]
|
||||
},
|
||||
"extends": "@tih/tsconfig/react-library.json",
|
||||
"include": ["."],
|
||||
"exclude": ["dist", "build", "node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user