Skip to content

Commit

Permalink
feat: add Alert component
Browse files Browse the repository at this point in the history
  • Loading branch information
piotrkulpinski committed Mar 7, 2024
1 parent 567aa8a commit 3cb9899
Show file tree
Hide file tree
Showing 7 changed files with 347 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"editor.defaultFormatter": "biomejs.biome"
}
53 changes: 53 additions & 0 deletions biome.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
{
"$schema": "https://biomejs.dev/schemas/1.5.3/schema.json",
"organizeImports": {
"enabled": true
},
"formatter": {
"enabled": true,
"lineWidth": 100,
"indentStyle": "space",
"indentWidth": 2
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"semicolons": "asNeeded",
"arrowParentheses": "asNeeded",
"bracketSpacing": true,
"trailingComma": "all"
}
},
"json": {
"parser": {
"allowComments": false,
"allowTrailingCommas": true
}
},
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noExplicitAny": "off",
"noArrayIndexKey": "off",
"noConfusingLabels": "off",
"noShadowRestrictedNames": "off"
},
"security": {
"noDangerouslySetInnerHtml": "off"
},
"a11y": {
"useAnchorContent": "off"
},
"nursery": {
"useGroupedTypeImport": "error"
}
}
}
}
Binary file modified bun.lockb
Binary file not shown.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"tailwind-merge": "^2.2.1"
},
"devDependencies": {
"@biomejs/biome": "^1.5.3",
"@storybook/addon-essentials": "^7.6.17",
"@storybook/addon-interactions": "^7.6.17",
"@storybook/addon-links": "^7.6.17",
Expand Down
229 changes: 229 additions & 0 deletions src/ui/Alert/Alert.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
"use client"

import { Slot } from "@radix-ui/react-slot"
import { type VariantProps, cx } from "cva"

import {
ComponentPropsWithoutRef,
ElementRef,
HTMLAttributes,
MouseEventHandler,
ReactElement,
ReactNode,
forwardRef,
useCallback,
useState,
} from "react"
import { IconClose } from "../../icons/IconClose"
import { isReactElement } from "../../shared"
import { Affixable } from "../../utils/Affixable"
import { Action } from "../Action"
import { Button } from "../Button"
import {
alertAffixVariants,
alertRootVariants,
alertTitleVariants,
alertVariants,
} from "./Alert.variants"

/* ---------------------------------- Types --------------------------------- */
type ClosableProps = {
/**
* Is the alert closable? If true, a close icon will be displayed.
* @default true
*/
closable: true

/**
* An optional callback function to be called when the close icon is clicked.
* This can be used to handle the removal of the tag.
* If provided, the close icon will be displayed.
*/
onClose?: MouseEventHandler<HTMLButtonElement>
}

type NotClosableProps = {
/**
* Is the alert closable? If true, a close button will be displayed and
* when clicked on it will hide the alert element
* @default true
*/
closable?: false

/**
* An optional callback function to be called when the close button is clicked.
* Requires the `closable` prop to be set to `true`.
*/
onClose?: never
}

export type AlertProps = Omit<HTMLAttributes<HTMLDivElement>, "title" | "prefix"> &
VariantProps<typeof alertVariants> & {
/**
* The slot to be rendered prefix the description.
* This can be used to render an icon
* or any other element prefix the description. Also accepts a string,
* number, or any valid React element.
* If the `prefix` prop is omitted, the default icon will be displayed.
*
* @example
* // Display an alert with icon
* <Alert prefix={<SuccessIcon />} />
*/
prefix?: ReactNode

/**
* The slot to be rendered suffix the description.
* This can be a string, number or any valid React element.
* If omitted, it will not be displayed.
*
* @example
* // Display an alert with button
* <Alert suffix={<Button size="sm">Save</Button>} />
*/
suffix?: ReactNode

/**
* The title to display within the Alert component.
* This can be a string, number or any valid React element.
* If omitted, no title will be displayed.
* If a string is provided, it will be wrapped in an <AlertTitle /> component.
* If a React element is provided, it will be rendered as-is.
*/
title?: ReactNode
} & (ClosableProps | NotClosableProps)

/* ------------------------------- Components ------------------------------- */
export const AlertBase = forwardRef<HTMLDivElement, AlertProps>(
(
{
className,
suffix,
prefix,
closable,
theme,
variant = "inline",
children,
title,
onClose,
...props
},
ref,
) => {
const [visible, setVisible] = useState(true)

/**
* Handle the close event.
* @param event - The event object
*/
const handleClose = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
// Do not close if the event is prevented by the onClose callback
if (!event.defaultPrevented) {
setVisible(false)
}

if (onClose) {
onClose(event)
}
},
[onClose],
)

if (!visible) {
return null
}

return (
<AlertRoot ref={ref} className={cx(alertVariants({ variant, theme }), className)} {...props}>
<Affixable variants={alertAffixVariants}>{prefix}</Affixable>

<div
className={cx(
"flex grow flex-col items-start",
variant === "expanded" && "items-start gap-3 px-2",
variant === "inline" && "px-3 sm:flex-row sm:items-center sm:gap-2",
variant === "inline" && closable && "pr-1",
)}
>
<div
className={cx(
"flex grow flex-col items-start",
variant === "expanded" && "items-start",
variant === "inline" && "sm:flex-row sm:items-center sm:gap-2",
)}
>
{title && <AlertTitle theme={theme}>{title}</AlertTitle>}
{children && <AlertDescription>{children}</AlertDescription>}
</div>

<Affixable variants={alertAffixVariants} className="mt-3 sm:ml-auto sm:mt-0">
{suffix}
</Affixable>
</div>

{closable && (
<AlertCloseButton className={cx(variant === "inline" && "mr-1")} onClick={handleClose} />
)}
</AlertRoot>
)
},
)

/* Root */
export const AlertRoot = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, children, ...props }, ref) => {
return (
<div ref={ref} className={cx(alertRootVariants({ className }))} role="alert" {...props}>
{children}
</div>
)
},
)

/* Title */
export const AlertTitle = forwardRef<
HTMLParagraphElement,
HTMLAttributes<HTMLParagraphElement> & VariantProps<typeof alertTitleVariants>
>(({ className, theme, children, ...props }, ref) => {
const Component = isReactElement(children) ? Slot : "p"

return (
<Component ref={ref} className={cx(alertTitleVariants({ theme }), className)} {...props}>
{children}
</Component>
)
})

/* Description */
export const AlertDescription = forwardRef<
HTMLParagraphElement,
HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const Component = isReactElement(children) ? Slot : "p"

return (
<Component ref={ref} className={cx("text-start", className)} {...props}>
{children}
</Component>
)
})

/* CloseButton */
export const AlertCloseButton = forwardRef<
ElementRef<typeof Button>,
ComponentPropsWithoutRef<typeof Button>
>(({ children, ...props }, ref) => {
const renderCloseIcon = (children: ReactNode): ReactElement<HTMLElement> => {
return isReactElement(children) ? children : <IconClose aria-label="Close" />
}

return <Action ref={ref} prefix={renderCloseIcon(children)} {...props} />
})

export const Alert = Object.assign(AlertBase, {
Root: AlertRoot,
CloseButton: AlertCloseButton,
Description: AlertDescription,
Title: AlertTitle,
})
60 changes: 60 additions & 0 deletions src/ui/Alert/Alert.variants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { cva } from "../../shared"

export const alertRootVariants = cva({
base: "flex items-start bg-gray-100 text-sm/relaxed",
})

export const alertVariants = cva({
variants: {
variant: {
inline: "rounded-lg px-3 py-3 sm:items-center",
expanded: "gap-1 rounded-r-lg border-l-2 p-4 pl-4",
},
theme: {
default: "border-gray-200 text-gray-600",
info: "border-blue bg-blue-lighter text-blue-darker",
success: "border-green bg-green-lighter text-green-darker",
error: "border-red bg-red-lighter text-red-darker",
warning: "border-yellow bg-yellow-lighter text-yellow-darker",
},
},
defaultVariants: {
variant: "inline",
theme: "default",
},
})

export const alertTitleVariants = cva({
base: "text-start font-medium",
variants: {
theme: {
default: "text-gray-900",
info: "text-blue-darker",
success: "text-green-darker",
error: "text-red-darker",
warning: "text-yellow-darker",
},
},
defaultVariants: {
theme: "default",
},
})

export const alertIconVariants = cva({
variants: {
theme: {
default: "text-gray-400",
info: "text-blue",
success: "text-green",
error: "text-red",
warning: "text-yellow",
},
},
defaultVariants: {
theme: "default",
},
})

export const alertAffixVariants = cva({
base: "shrink-0",
})
1 change: 1 addition & 0 deletions src/ui/Alert/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./Alert"

0 comments on commit 3cb9899

Please sign in to comment.