chore(ui,icons,ui-preset,toolbox): Move design system packages to monorepo (#5470)

This commit is contained in:
Kasper Fabricius Kristensen
2023-11-07 22:17:44 +01:00
committed by GitHub
parent 71853eafdd
commit e4ce2f4e07
722 changed files with 30300 additions and 186 deletions

View File

@@ -0,0 +1,112 @@
"use client"
import * as React from "react"
import { Input } from "@/components/input"
import { Label } from "@/components/label"
import { Prompt } from "@/components/prompt"
export type DialogProps = {
open: boolean
title: string
description: string
verificationText?: string
cancelText?: string
confirmText?: string
onConfirm: () => void
onCancel: () => void
}
const Dialog = ({
open,
title,
description,
verificationText,
cancelText = "Cancel",
confirmText = "Confirm",
onConfirm,
onCancel,
}: DialogProps) => {
const [userInput, setUserInput] = React.useState("")
const handleUserInput = (event: React.ChangeEvent<HTMLInputElement>) => {
setUserInput(event.target.value)
}
const validInput = React.useMemo(() => {
if (!verificationText) {
return true
}
return userInput === verificationText
}, [userInput, verificationText])
const handleFormSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!verificationText) {
return
}
if (validInput) {
onConfirm()
}
}
React.useEffect(() => {
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape" && open) {
onCancel()
}
}
document.addEventListener("keydown", handleEscape)
return () => {
document.removeEventListener("keydown", handleEscape)
}
}, [onCancel, open])
return (
<Prompt open={open}>
<Prompt.Content>
<form onSubmit={handleFormSubmit}>
<Prompt.Header>
<Prompt.Title>{title}</Prompt.Title>
<Prompt.Description>{description}</Prompt.Description>
</Prompt.Header>
{verificationText && (
<div className="border-ui-border-base mt-6 flex flex-col gap-y-4 border-y p-6">
<Label htmlFor="verificationText" className="text-ui-fg-subtle">
Please type{" "}
<span className="text-ui-fg-base txt-compact-medium-plus">
{verificationText}
</span>{" "}
to confirm.
</Label>
<Input
autoFocus
autoComplete="off"
id="verificationText"
placeholder={verificationText}
onChange={handleUserInput}
/>
</div>
)}
<Prompt.Footer>
<Prompt.Cancel onClick={onCancel}>{cancelText}</Prompt.Cancel>
<Prompt.Action
disabled={!validInput}
type={verificationText ? "submit" : "button"}
onClick={verificationText ? undefined : onConfirm}
>
{confirmText}
</Prompt.Action>
</Prompt.Footer>
</form>
</Prompt.Content>
</Prompt>
)
}
export default Dialog

View File

@@ -0,0 +1 @@
export { usePrompt } from "./use-prompt"

View File

@@ -0,0 +1,121 @@
import { RenderResult, fireEvent, render } from "@testing-library/react"
import * as React from "react"
import { usePrompt } from "./use-prompt"
const OPEN_TEXT = "Open dialog"
const TITLE_TEXT = "Delete something"
const DESCRIPTION_TEXT = "Are you sure? This cannot be undone."
const CANCEL_TEXT = "Cancel"
const CONFIRM_TEXT = "Confirm"
const VERIFICATION_TEXT = "medusa-design-system"
const DialogTest = ({ verificationText }: { verificationText?: string }) => {
const dialog = usePrompt()
const handleAction = async () => {
await dialog({
title: TITLE_TEXT,
description: DESCRIPTION_TEXT,
cancelText: CANCEL_TEXT,
confirmText: CONFIRM_TEXT,
verificationText,
variant: "danger",
})
}
return (
<div>
<button onClick={handleAction}>{OPEN_TEXT}</button>
</div>
)
}
describe("usePrompt", () => {
let rendered: RenderResult
let trigger: HTMLElement
beforeEach(() => {
rendered = render(<DialogTest />)
trigger = rendered.getByText(OPEN_TEXT)
})
afterEach(() => {
// Try to find the cancel button and click it to close the dialog
// We need to do this a we are appending a div to the body and it will not be removed
// automatically by the cleanup
const cancelButton = rendered.queryByText(CANCEL_TEXT)
if (cancelButton) {
fireEvent.click(cancelButton)
}
})
it("renders a basic alert dialog when the trigger is clicked", async () => {
fireEvent.click(trigger)
const title = await rendered.findByText(TITLE_TEXT)
const description = await rendered.findByText(DESCRIPTION_TEXT)
expect(title).toBeInTheDocument()
expect(description).toBeInTheDocument()
})
it("unmounts the dialog when the cancel button is clicked", async () => {
fireEvent.click(trigger)
const cancelButton = await rendered.findByText(CANCEL_TEXT)
fireEvent.click(cancelButton)
const title = rendered.queryByText(TITLE_TEXT)
const description = rendered.queryByText(DESCRIPTION_TEXT)
expect(title).not.toBeInTheDocument()
expect(description).not.toBeInTheDocument()
})
it("unmounts the dialog when the confirm button is clicked", async () => {
fireEvent.click(trigger)
const confirmButton = await rendered.findByText(CONFIRM_TEXT)
fireEvent.click(confirmButton)
const title = rendered.queryByText(TITLE_TEXT)
const description = rendered.queryByText(DESCRIPTION_TEXT)
expect(title).not.toBeInTheDocument()
expect(description).not.toBeInTheDocument()
})
it("renders a verification input when verificationText is provided", async () => {
rendered.rerender(<DialogTest verificationText="delete" />)
fireEvent.click(trigger)
const input = await rendered.findByRole("textbox")
expect(input).toBeInTheDocument()
})
it("renders a disabled confirm button when verificationText is provided", async () => {
rendered.rerender(<DialogTest verificationText={VERIFICATION_TEXT} />)
fireEvent.click(trigger)
const button = await rendered.findByText(CONFIRM_TEXT)
expect(button).toBeDisabled()
})
it("renders an enabled confirm button when verificationText is provided and the input matches", async () => {
rendered.rerender(<DialogTest verificationText={VERIFICATION_TEXT} />)
fireEvent.click(trigger)
const input = await rendered.findByRole("textbox")
const button = await rendered.findByText(CONFIRM_TEXT)
fireEvent.change(input, { target: { value: VERIFICATION_TEXT } })
expect(button).toBeEnabled()
})
})

View File

@@ -0,0 +1,59 @@
import type { Meta, StoryObj } from "@storybook/react"
import * as React from "react"
import { Button } from "@/components/button"
import { Badge } from "../../components/badge"
import { Text } from "../../components/text"
import { usePrompt } from "./use-prompt"
type DemoProps = {
verificationText?: string
}
const Demo = ({ verificationText }: DemoProps) => {
const [status, setStatus] = React.useState(false)
const dialog = usePrompt()
const handleDangerousAction = async () => {
const confirmed = await dialog({
title: "Delete Product",
description:
"Are you sure you want to delete this product? This action cannot be undone.",
verificationText,
variant: "danger",
})
setStatus(confirmed)
}
return (
<div className="flex flex-col items-center gap-y-2">
<Button variant="danger" onClick={handleDangerousAction}>
Delete Product
</Button>
<Text>
Status: <Badge>{status ? "Confirmed" : "Unconfirmed"}</Badge>
</Text>
</div>
)
}
const meta: Meta<typeof usePrompt> = {
title: "Hooks/usePrompt",
component: Demo,
parameters: {
layout: "centered",
},
}
export default meta
type Story = StoryObj<typeof Demo>
export const Default: Story = {}
export const WithVerificationText: Story = {
args: {
verificationText: "product",
},
}

View File

@@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import { createRoot } from "react-dom/client"
import { DialogProps } from "./dialog"
import Dialog from "./dialog"
type PromptProps = Omit<DialogProps, "onConfirm" | "onCancel" | "open">
const usePrompt = () => {
const dialog = async (props: PromptProps): Promise<boolean> => {
return new Promise((resolve) => {
let open = true
const onCancel = () => {
open = false
render()
resolve(false)
}
const onConfirm = () => {
open = false
resolve(true)
render()
}
const mountRoot = createRoot(document.createElement("div"))
const render = () => {
mountRoot.render(
<Dialog
open={open}
onConfirm={onConfirm}
onCancel={onCancel}
{...props}
/>
)
}
render()
})
}
return dialog
}
export { usePrompt }

View File

@@ -0,0 +1 @@
export * from "./use-toast"

View File

@@ -0,0 +1,85 @@
import type { Meta, StoryObj } from "@storybook/react"
import * as React from "react"
import { Button } from "@/components/button"
import { Toaster } from "@/components/toaster"
import { useToast } from "./use-toast"
const Demo = () => {
const { toast } = useToast()
const handleUndo = async () => {
// Wait 3 seconds then resolve with true
const confirmed = await new Promise((resolve) => {
setTimeout(() => {
resolve(true)
}, 3000)
})
return confirmed
}
const handleSchedule = () => {
const onAction = async (fn: (update: any) => void) => {
fn({
title: "Processing",
description: "Removing post from queue.",
variant: "loading",
disableDismiss: true,
action: undefined,
})
const confirmed = await handleUndo().then((confirmed) => {
if (confirmed) {
fn({
title: "Success",
description: "Your post was successfully removed from the queue.",
variant: "success",
disableDismiss: false,
})
} else {
fn({
title: "Error",
description:
"Something went wrong, and your post was not removed from the queue. Try again.",
variant: "error",
disableDismiss: false,
})
}
})
}
const t = toast({
title: "Scheduled",
description: "Your post has been scheduled for 12:00 PM",
variant: "success",
action: {
label: "Undo",
onClick: () => onAction(t.update),
altText:
"Alternatively, you can undo this action by navigating to the scheduled posts page, and clicking the unschedule button.",
},
})
}
return (
<div className="min-w-screen flex min-h-screen flex-col items-center justify-center">
<Button onClick={handleSchedule}>Schedule</Button>
<Toaster />
</div>
)
}
const meta: Meta<typeof Demo> = {
title: "Hooks/useToast",
component: Demo,
parameters: {
layout: "centered",
},
}
export default meta
type Story = StoryObj<typeof Demo>
export const Default: Story = {}

View File

@@ -0,0 +1,188 @@
"use client"
import * as React from "react"
import type { ToastActionElement, ToastProps } from "@/components/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_VALUE
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { toast, useToast }

View File

@@ -0,0 +1 @@
export { useToggleState } from "./use-toggle-state"

View File

@@ -0,0 +1,54 @@
import { act, renderHook } from "@testing-library/react"
import { useToggleState } from "./use-toggle-state"
describe("useToggleState", () => {
test("should return a state and a function to toggle it", () => {
const { result } = renderHook(() => useToggleState())
expect(result.current[0]).toBe(false)
expect(typeof result.current[1]).toBe("function")
})
test("should return a state and a function to toggle it with an initial value", () => {
const { result } = renderHook(() => useToggleState(true))
expect(result.current[0]).toBe(true)
})
test("should update the state when the toggle function is called", () => {
const { result } = renderHook(() => useToggleState())
expect(result.current[0]).toBe(false)
act(() => {
result.current[1]()
})
expect(result.current[0]).toBe(true)
})
test("should update the state when the close function is called", () => {
const { result } = renderHook(() => useToggleState(true))
expect(result.current[0]).toBe(true)
act(() => {
result.current[2]()
})
expect(result.current[0]).toBe(false)
})
test("should update the state when the open function is called", () => {
const { result } = renderHook(() => useToggleState())
expect(result.current[0]).toBe(false)
act(() => {
result.current[3]()
})
expect(result.current[0]).toBe(true)
})
})

View File

@@ -0,0 +1,35 @@
import type { Meta, StoryObj } from "@storybook/react"
import * as React from "react"
import { Button } from "@/components/button"
import { Text } from "@/components/text"
import { useToggleState } from "./use-toggle-state"
const Demo = () => {
const { state, open, close, toggle } = useToggleState()
return (
<div className="flex flex-col items-center gap-y-4">
<Text>State: {state ? "True" : "False"}</Text>
<div className="flex items-center gap-x-4">
<Button onClick={open}>Open</Button>
<Button onClick={close}>Close</Button>
<Button onClick={toggle}>Toggle</Button>
</div>
</div>
)
}
const meta: Meta<typeof Demo> = {
title: "Hooks/useToggleState",
component: Demo,
parameters: {
layout: "centered",
},
}
export default meta
type Story = StoryObj<typeof Demo>
export const Default: Story = {}

View File

@@ -0,0 +1,35 @@
"use client"
import * as React from "react"
type StateType = [boolean, () => void, () => void, () => void] & {
state: boolean
open: () => void
close: () => void
toggle: () => void
}
const useToggleState = (initial = false) => {
const [state, setState] = React.useState<boolean>(initial)
const close = () => {
setState(false)
}
const open = () => {
setState(true)
}
const toggle = () => {
setState((state) => !state)
}
const hookData = [state, open, close, toggle] as StateType
hookData.state = state
hookData.open = open
hookData.close = close
hookData.toggle = toggle
return hookData
}
export { useToggleState }