From e93cc73d51dfcf75f98bcfebe5e632dbb17f6f4c Mon Sep 17 00:00:00 2001 From: Yangshun Tay Date: Tue, 4 Oct 2022 09:30:15 +0800 Subject: [PATCH] ui: add components --- apps/portal/package.json | 2 +- apps/storybook/stories/badge.stories.tsx | 41 ++++++ apps/storybook/stories/button.stories.tsx | 2 +- apps/storybook/stories/dialog.stories.tsx | 56 ++++++++ .../stories/dropdown-menu.stories.tsx | 124 +++++++++++++++++ apps/storybook/stories/select.stories.tsx | 88 ++++++++++++ apps/storybook/stories/slide-out.stories.tsx | 49 +++++++ apps/storybook/stories/spinner.stories.tsx | 2 +- apps/storybook/stories/tabs.stories.tsx | 43 ++++++ apps/storybook/stories/text-input.stories.tsx | 129 ++++++++++++++++++ packages/ui/package.json | 2 + packages/ui/src/Badge/Badge.tsx | 57 ++++++++ packages/ui/src/Button/Button.tsx | 7 +- packages/ui/src/Dialog/Dialog.tsx | 89 ++++++++++++ packages/ui/src/DropdownMenu/DropdownMenu.tsx | 64 +++++++++ .../ui/src/DropdownMenu/DropdownMenuItem.tsx | 41 ++++++ packages/ui/src/Select/Select.tsx | 62 +++++++++ packages/ui/src/SlideOut/SlideOut.tsx | 96 +++++++++++++ packages/ui/src/Tabs/Tabs.tsx | 65 +++++++++ packages/ui/src/TextInput/TextInput.tsx | 109 +++++++++++++++ packages/ui/src/index.tsx | 24 ++++ yarn.lock | 2 +- 22 files changed, 1146 insertions(+), 8 deletions(-) create mode 100644 apps/storybook/stories/badge.stories.tsx create mode 100644 apps/storybook/stories/dialog.stories.tsx create mode 100644 apps/storybook/stories/dropdown-menu.stories.tsx create mode 100644 apps/storybook/stories/select.stories.tsx create mode 100644 apps/storybook/stories/slide-out.stories.tsx create mode 100644 apps/storybook/stories/tabs.stories.tsx create mode 100644 apps/storybook/stories/text-input.stories.tsx create mode 100644 packages/ui/src/Badge/Badge.tsx create mode 100644 packages/ui/src/Dialog/Dialog.tsx create mode 100644 packages/ui/src/DropdownMenu/DropdownMenu.tsx create mode 100644 packages/ui/src/DropdownMenu/DropdownMenuItem.tsx create mode 100644 packages/ui/src/Select/Select.tsx create mode 100644 packages/ui/src/SlideOut/SlideOut.tsx create mode 100644 packages/ui/src/Tabs/Tabs.tsx create mode 100644 packages/ui/src/TextInput/TextInput.tsx diff --git a/apps/portal/package.json b/apps/portal/package.json index db44a820..3889db21 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -11,7 +11,7 @@ "postinstall": "prisma generate" }, "dependencies": { - "@headlessui/react": "^1.7.2", + "@headlessui/react": "^1.7.3", "@heroicons/react": "^2.0.11", "@next-auth/prisma-adapter": "^1.0.4", "@prisma/client": "^4.4.0", diff --git a/apps/storybook/stories/badge.stories.tsx b/apps/storybook/stories/badge.stories.tsx new file mode 100644 index 00000000..45c85639 --- /dev/null +++ b/apps/storybook/stories/badge.stories.tsx @@ -0,0 +1,41 @@ +import { ComponentMeta } from '@storybook/react'; +import { Badge, BadgeVariant } from '@tih/ui'; +import React from 'react'; + +const badgeVariants: ReadonlyArray = [ + 'primary', + 'info', + 'danger', + 'success', + 'warning', +]; + +export default { + title: 'Badge', + component: Badge, + argTypes: { + variant: { + options: badgeVariants, + control: { type: 'select' }, + }, + }, +} as ComponentMeta; + +export const Basic = { + args: { + label: 'Hello', + variant: 'primary', + }, +}; + +export function Variants() { + return ( +
+ + + + + +
+ ); +} diff --git a/apps/storybook/stories/button.stories.tsx b/apps/storybook/stories/button.stories.tsx index 8f1c6239..2aef9296 100644 --- a/apps/storybook/stories/button.stories.tsx +++ b/apps/storybook/stories/button.stories.tsx @@ -45,7 +45,7 @@ export default { control: 'boolean', }, label: { - control: 'string', + control: 'text', }, size: { options: buttonSizes, diff --git a/apps/storybook/stories/dialog.stories.tsx b/apps/storybook/stories/dialog.stories.tsx new file mode 100644 index 00000000..e1e15639 --- /dev/null +++ b/apps/storybook/stories/dialog.stories.tsx @@ -0,0 +1,56 @@ +import { ComponentMeta } from '@storybook/react'; +import { Button, Dialog } from '@tih/ui'; +import React, { useState } from 'react'; + +export default { + title: 'Dialog', + component: Dialog, + argTypes: { + title: { + control: 'text', + }, + }, +} as ComponentMeta; + +export function Basic({ children, title }) { + const [isShown, setIsShown] = useState(false); + + return ( +
+
+ ); +} + +Basic.args = { + title: 'Lorem ipsum, dolor sit amet', + children: ( +
+ Lorem ipsum, dolor sit amet consectetur adipisicing elit. Eius aliquam + laudantium explicabo pariatur iste dolorem animi vitae error totam. At + sapiente aliquam accusamus facere veritatis. +
+ ), +}; diff --git a/apps/storybook/stories/dropdown-menu.stories.tsx b/apps/storybook/stories/dropdown-menu.stories.tsx new file mode 100644 index 00000000..d35dff44 --- /dev/null +++ b/apps/storybook/stories/dropdown-menu.stories.tsx @@ -0,0 +1,124 @@ +import { ComponentMeta } from '@storybook/react'; +import { DropdownMenu, DropdownMenuAlignment, DropdownMenuSize } from '@tih/ui'; +import React, { useState } from 'react'; + +const DropdownMenuAlignments: ReadonlyArray = [ + 'start', + 'end', +]; +const DropdownMenuSizes: ReadonlyArray = [ + 'inherit', + 'regular', +]; + +export default { + title: 'DropdownMenu', + component: DropdownMenu, + parameters: { + docs: { + inlineStories: false, + iframeHeight: 300, + }, + }, + argTypes: { + align: { + options: DropdownMenuAlignments, + control: { type: 'select' }, + }, + label: { + control: 'text', + }, + size: { + options: DropdownMenuSizes, + control: { type: 'select' }, + }, + }, +} as ComponentMeta; + +export function Basic({ align, label, size }) { + const menuItems = [ + { + label: 'Apple', + value: 'apple', + }, + { + label: 'Banana', + value: 'banana', + }, + { + label: 'Orange', + value: 'orange', + }, + ]; + + const [selectedValue, setSelectedValue] = useState('apple'); + + return ( + + {menuItems.map(({ label, value }) => ( + { + setSelectedValue(value); + }} + /> + ))} + + ); +} + +Basic.args = { + align: 'start', + label: 'Select fruitzz', + size: 'regular', +}; + +export function Align() { + const menuItems = [ + { + label: 'Apple', + value: 'apple', + }, + { + label: 'Banana', + value: 'banana', + }, + { + label: 'Orange', + value: 'orange', + }, + ]; + + const [selectedValue, setSelectedValue] = useState('apple'); + + return ( +
+ + {menuItems.map(({ label, value }) => ( + { + setSelectedValue(value); + }} + /> + ))} + + + {menuItems.map(({ label, value }) => ( + { + setSelectedValue(value); + }} + /> + ))} + +
+ ); +} diff --git a/apps/storybook/stories/select.stories.tsx b/apps/storybook/stories/select.stories.tsx new file mode 100644 index 00000000..eaa40b90 --- /dev/null +++ b/apps/storybook/stories/select.stories.tsx @@ -0,0 +1,88 @@ +import { ComponentMeta } from '@storybook/react'; +import { Select, SelectDisplay } from '@tih/ui'; +import React, { useState } from 'react'; + +const SelectDisplays: ReadonlyArray = ['inline', 'block']; + +export default { + title: 'Select', + component: Select, + argTypes: { + display: { + options: SelectDisplays, + control: { type: 'select' }, + }, + isLabelHidden: { + control: 'boolean', + }, + label: { + control: 'text', + }, + name: { + control: 'text', + }, + }, +} as ComponentMeta; + +export function Basic({ display, isLabelHidden, label, name }) { + const [value, setValue] = useState('apple'); + + return ( + + + ); +} diff --git a/apps/storybook/stories/slide-out.stories.tsx b/apps/storybook/stories/slide-out.stories.tsx new file mode 100644 index 00000000..0131917a --- /dev/null +++ b/apps/storybook/stories/slide-out.stories.tsx @@ -0,0 +1,49 @@ +import { ComponentMeta } from '@storybook/react'; +import { Button, SlideOut, SlideOutEnterFrom, SlideOutSize } from '@tih/ui'; +import React, { useState } from 'react'; + +const slideOutEnterFrom: ReadonlyArray = ['start', 'end']; +const slideOutSize: ReadonlyArray = ['sm', 'md', 'lg', 'xl']; + +export default { + title: 'SlideOut', + component: SlideOut, + argTypes: { + title: { + control: 'text', + }, + enterFrom: { + options: slideOutEnterFrom, + control: { type: 'select' }, + }, + size: { + options: slideOutSize, + control: { type: 'select' }, + }, + }, +} as ComponentMeta; + +export function Basic({ children, enterFrom, size, title }) { + const [isShown, setIsShown] = useState(false); + + return ( +
+
+ ); +} + +Basic.args = { + title: 'Navigation', + children:
Hello World
, + enterFrom: 'end', + size: 'md', +}; diff --git a/apps/storybook/stories/spinner.stories.tsx b/apps/storybook/stories/spinner.stories.tsx index 609aefa6..17756eb1 100644 --- a/apps/storybook/stories/spinner.stories.tsx +++ b/apps/storybook/stories/spinner.stories.tsx @@ -20,7 +20,7 @@ export default { control: { type: 'select' }, }, label: { - control: 'string', + control: 'text', }, size: { options: spinnerSizes, diff --git a/apps/storybook/stories/tabs.stories.tsx b/apps/storybook/stories/tabs.stories.tsx new file mode 100644 index 00000000..8641719e --- /dev/null +++ b/apps/storybook/stories/tabs.stories.tsx @@ -0,0 +1,43 @@ +import { ComponentMeta } from '@storybook/react'; +import { Tabs } from '@tih/ui'; +import React, { useState } from 'react'; + +export default { + title: 'Tabs', + component: Tabs, + argTypes: { + label: { + control: 'text', + }, + }, +} as ComponentMeta; + +export function Basic({ label }) { + const [value, setValue] = useState('apple'); + + return ( + + ); +} + +Basic.args = { + label: 'Fruits Navigation', +}; diff --git a/apps/storybook/stories/text-input.stories.tsx b/apps/storybook/stories/text-input.stories.tsx new file mode 100644 index 00000000..d607ddce --- /dev/null +++ b/apps/storybook/stories/text-input.stories.tsx @@ -0,0 +1,129 @@ +import { + EnvelopeIcon, + KeyIcon, + QuestionMarkCircleIcon, +} from '@heroicons/react/24/solid'; +import { ComponentMeta } from '@storybook/react'; +import { TextInput } from '@tih/ui'; +import React, { useState } from 'react'; + +export default { + title: 'TextInput', + component: TextInput, + argTypes: { + autoComplete: { + control: 'text', + }, + errorMessage: { + control: 'text', + }, + isDisabled: { + control: 'boolean', + }, + isLabelHidden: { + control: 'boolean', + }, + label: { + control: 'text', + }, + name: { + control: 'text', + }, + placeholder: { + control: 'text', + }, + type: { + control: 'text', + }, + }, +} as ComponentMeta; + +export const Basic = { + args: { + label: 'Name', + placeholder: 'John Doe', + }, +}; + +export function HiddenLabel() { + const [value, setValue] = useState(''); + + return ( + + ); +} + +export function Email() { + const [value, setValue] = useState(''); + + return ( + + ); +} + +export function Icon() { + const [value, setValue] = useState(''); + + return ( +
+ + +
+ ); +} + +export function Disabled() { + return ( + + ); +} + +export function Error() { + const [value, setValue] = useState('1234'); + + return ( + + ); +} diff --git a/packages/ui/package.json b/packages/ui/package.json index 5e92810e..cc0a857c 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -22,6 +22,8 @@ "lint": "eslint src/**/*.ts* --fix" }, "dependencies": { + "@headlessui/react": "^1.7.3", + "@heroicons/react": "^2.0.11", "clsx": "^1.2.1", "next": "^12.3.1" }, diff --git a/packages/ui/src/Badge/Badge.tsx b/packages/ui/src/Badge/Badge.tsx new file mode 100644 index 00000000..305ee90f --- /dev/null +++ b/packages/ui/src/Badge/Badge.tsx @@ -0,0 +1,57 @@ +import clsx from 'clsx'; + +export type BadgeVariant = + | 'danger' + | 'info' + | 'primary' + | 'success' + | 'warning'; + +type Props = Readonly<{ + label: string; + variant: BadgeVariant; +}>; + +const classes: Record< + BadgeVariant, + Readonly<{ + backgroundClass: string; + textClass: string; + }> +> = { + danger: { + backgroundClass: 'bg-danger-100', + textClass: 'text-danger-800', + }, + info: { + backgroundClass: 'bg-info-100', + textClass: 'text-info-800', + }, + primary: { + backgroundClass: 'bg-primary-100', + textClass: 'text-primary-800', + }, + success: { + backgroundClass: 'bg-success-100', + textClass: 'text-success-800', + }, + warning: { + backgroundClass: 'bg-warning-100', + textClass: 'text-warning-800', + }, +}; + +export default function Badge({ label, variant }: Props) { + const { backgroundClass, textClass } = classes[variant]; + + return ( + + {label} + + ); +} diff --git a/packages/ui/src/Button/Button.tsx b/packages/ui/src/Button/Button.tsx index d957721b..010cf88b 100644 --- a/packages/ui/src/Button/Button.tsx +++ b/packages/ui/src/Button/Button.tsx @@ -73,7 +73,7 @@ const variantClasses: Record = { secondary: 'border-transparent text-primary-700 bg-primary-100 hover:bg-primary-200', special: 'border-slate-900 text-white bg-slate-900 hover:bg-slate-700', - success: 'border-transparent text-white bg-emerald-600 hover:bg-emerald-500', + success: 'border-transparent text-white bg-success-600 hover:bg-success-500', tertiary: 'border-slate-300 text-slate-700 bg-white hover:bg-slate-50', }; @@ -150,8 +150,7 @@ export default function Button({ } return ( - - - + // TODO: Allow passing in of Link component. + ); } diff --git a/packages/ui/src/Dialog/Dialog.tsx b/packages/ui/src/Dialog/Dialog.tsx new file mode 100644 index 00000000..242b05af --- /dev/null +++ b/packages/ui/src/Dialog/Dialog.tsx @@ -0,0 +1,89 @@ +import clsx from 'clsx'; +import { Fragment, useRef } from 'react'; +import { Dialog as HeadlessDialog, Transition } from '@headlessui/react'; + +type Props = Readonly<{ + children: React.ReactNode; + isShown?: boolean; + onClose: () => void; + primaryButton: React.ReactNode; + secondaryButton?: React.ReactNode; + title: string; + topIcon?: (props: React.ComponentProps<'svg'>) => JSX.Element; +}>; + +export default function Dialog({ + children, + isShown, + primaryButton, + title, + topIcon: TopIcon, + secondaryButton, + onClose, +}: Props) { + const cancelButtonRef = useRef(null); + + return ( + + onClose()}> + +
+ +
+
+ + +
+ {TopIcon != null && ( +
+
+ )} +
+ + {title} + +
+
{children}
+
+
+
+
+ {secondaryButton} + {primaryButton} +
+
+
+
+
+ + + ); +} diff --git a/packages/ui/src/DropdownMenu/DropdownMenu.tsx b/packages/ui/src/DropdownMenu/DropdownMenu.tsx new file mode 100644 index 00000000..e3ccd4f2 --- /dev/null +++ b/packages/ui/src/DropdownMenu/DropdownMenu.tsx @@ -0,0 +1,64 @@ +import clsx from 'clsx'; +import React, { Fragment } from 'react'; +import { Menu, Transition } from '@headlessui/react'; +import { ChevronDownIcon } from '@heroicons/react/24/solid'; + +import DropdownMenuItem from './DropdownMenuItem'; + +export type DropdownMenuAlignment = 'end' | 'start'; +export type DropdownMenuSize = 'inherit' | 'regular'; + +type Props = Readonly<{ + align?: DropdownMenuAlignment; + children: React.ReactNode; // TODO: Change to strict children. + label: React.ReactNode; + size?: DropdownMenuSize; +}>; + +DropdownMenu.Item = DropdownMenuItem; + +const alignmentClasses: Record = { + end: 'origin-top-right right-0', + start: 'origin-top-left left-0', +}; + +export default function DropdownMenu({ + align = 'start', + children, + label, + size = 'regular', +}: Props) { + return ( + +
+ +
{label}
+
+
+ + +
{children}
+
+
+
+ ); +} diff --git a/packages/ui/src/DropdownMenu/DropdownMenuItem.tsx b/packages/ui/src/DropdownMenu/DropdownMenuItem.tsx new file mode 100644 index 00000000..655ef460 --- /dev/null +++ b/packages/ui/src/DropdownMenu/DropdownMenuItem.tsx @@ -0,0 +1,41 @@ +import clsx from 'clsx'; +import React from 'react'; + +import { Menu } from '@headlessui/react'; + +type Props = Readonly<{ + href?: string; + isSelected?: boolean; + label: React.ReactNode; + onClick: () => void; +}>; + +export default function DropdownMenuItem({ + href, + isSelected = false, + label, + onClick, +}: Props) { + return ( + + {({ 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
+
+ {children} + + + + + + ); +} diff --git a/packages/ui/src/Tabs/Tabs.tsx b/packages/ui/src/Tabs/Tabs.tsx new file mode 100644 index 00000000..e872da7e --- /dev/null +++ b/packages/ui/src/Tabs/Tabs.tsx @@ -0,0 +1,65 @@ +import clsx from 'clsx'; +import Link from 'next/link'; +import type { UrlObject } from 'url'; + +export type TabItem = Readonly<{ + href?: UrlObject | string; + label: string; + value: T; +}>; + +type Props = Readonly<{ + label: string; + onChange?: (value: T) => void; + tabs: ReadonlyArray>; + value: T; +}>; + +export default function Tabs({ label, tabs, value, onChange }: Props) { + return ( +
+
+
+ +
+
+
+ ); +} diff --git a/packages/ui/src/TextInput/TextInput.tsx b/packages/ui/src/TextInput/TextInput.tsx new file mode 100644 index 00000000..1894c2a5 --- /dev/null +++ b/packages/ui/src/TextInput/TextInput.tsx @@ -0,0 +1,109 @@ +import clsx from 'clsx'; +import type { ChangeEvent } from 'react'; +import React, { useId } from 'react'; + +type Props = Readonly<{ + autoComplete?: string; + defaultValue?: string; + endIcon?: React.ComponentType>; + errorMessage?: React.ReactNode; + id?: string; + isDisabled?: boolean; + isLabelHidden?: boolean; + label: string; + name?: string; + onChange?: (value: string, event: ChangeEvent) => void; + placeholder?: string; + startIcon?: React.ComponentType>; + type?: 'email' | 'password' | 'text'; + value?: string; +}>; + +type State = 'error' | 'normal'; + +const stateClasses: Record = { + error: + 'border-danger-300 text-danger-900 placeholder-danger-300 focus:outline-none focus:ring-danger-500 focus:border-danger-500', + normal: + 'placeholder:text-slate-400 focus:ring-primary-500 focus:border-primary-500 border-slate-300', +}; + +export default function TextInput({ + autoComplete, + defaultValue, + endIcon: EndIcon, + errorMessage, + id: idParam, + isDisabled, + isLabelHidden = false, + label, + name, + placeholder, + startIcon: StartIcon, + type = 'text', + value, + onChange, +}: Props) { + const hasError = errorMessage != null; + const generatedId = useId(); + const id = idParam ?? generatedId; + const errorId = useId(); + const state: State = hasError ? 'error' : 'normal'; + + return ( +
+ +
+ {StartIcon && ( +
+
+ )} + { + if (!onChange) { + return; + } + + onChange(event.target.value, event); + }} + /> + {EndIcon && ( +
+
+ )} +
+ {errorMessage && ( +

+ {errorMessage} +

+ )} +
+ ); +} diff --git a/packages/ui/src/index.tsx b/packages/ui/src/index.tsx index e165928b..f7818ae8 100644 --- a/packages/ui/src/index.tsx +++ b/packages/ui/src/index.tsx @@ -1,6 +1,30 @@ +// Alert export * from './Alert/Alert'; export { default as Alert } from './Alert/Alert'; +// Badge +export * from './Badge/Badge'; +export { default as Badge } from './Badge/Badge'; +// Button export * from './Button/Button'; export { default as Button } from './Button/Button'; +// Dialog +export * from './Dialog/Dialog'; +export { default as Dialog } from './Dialog/Dialog'; +// DropdownMenu +export * from './DropdownMenu/DropdownMenu'; +export { default as DropdownMenu } from './DropdownMenu/DropdownMenu'; +// Select +export * from './Select/Select'; +export { default as Select } from './Select/Select'; +// SlideOut +export * from './SlideOut/SlideOut'; +export { default as SlideOut } from './SlideOut/SlideOut'; +// Spinner export * from './Spinner/Spinner'; export { default as Spinner } from './Spinner/Spinner'; +// Tabs +export * from './Tabs/Tabs'; +export { default as Tabs } from './Tabs/Tabs'; +// TextInput +export * from './TextInput/TextInput'; +export { default as TextInput } from './TextInput/TextInput'; diff --git a/yarn.lock b/yarn.lock index 999d5e87..c5877431 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1776,7 +1776,7 @@ dependencies: "@hapi/hoek" "^9.0.0" -"@headlessui/react@^1.7.2": +"@headlessui/react@^1.7.3": version "1.7.3" resolved "https://registry.yarnpkg.com/@headlessui/react/-/react-1.7.3.tgz#853c598ff47b37cdd192c5cbee890d9b610c3ec0" integrity sha512-LGp06SrGv7BMaIQlTs8s2G06moqkI0cb0b8stgq7KZ3xcHdH3qMP+cRyV7qe5x4XEW/IGY48BW4fLesD6NQLng==