fix(docs) Improve a11y for Admonitions with file refactor (#48112)

Closes FE-3914


## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## Problem

On screenreader, I found that the Admonition was not behaving as it
should:
- There was no way on screenreader to tell what type of note I was
seeing
- I could not tell when a note began or ended.
- The screenreader also read aloud an 'image' icon without knowing what
it was.
- Notes with titles were an `h5`, breaking header hierarchy structures.

## Solution

This PR does several things to resolve the issue:
- Adds `aria-hidden` to all icons. Instead of duplicating code, I
refactored the icons into a Base Icon and moved Admonitions into its own
folder.
- ~Adds a text label for each of the notes. For example, "**Note:**".
This is a standard practice in other documentation. If there is a title,
it is added there. Otherwise, it's added to the description.~ Change
reverted from design feedback.
- ~Adds `role='note'` and `aria-label` to the Admonition. While
`<aside>` is recommended semantic HTML, the base UI element does not
allow for that change.~ This will be done in a follow-up for docs only.
- Refactors Admonition into a folder with files so that it is more
readable
- Removes `h5` by default with a new prop to declare a header

Additionally adjusts the icon so that it aligns with text better.

## Testing

1. Open documentation preview
2. Navigate to any guide and see its admonition. Compare to live. You
can also see the Design System:
https://design-system-git-a11y-docs-admonition-supabase.vercel.app/design-system/docs/fragments/admonition
3. See the icon position is in line with the text.
4. See the text label.
5. Use a screenreader like Voiceover on the admonition. Hear that it is
clearly defined.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## Summary by CodeRabbit

- **New Features**
- Added the Admonition UI pattern with support for `type`, `layout`,
`title`/`description`, optional actions, and configurable icons.
- Expanded Admonition’s public export surface with dedicated subpath
entry points and icon/type exports.
- **Bug Fixes**
- Standardized Admonition import path casing across related components.
- **Documentation**
- Updated design system examples to use `type="warning"` instead of
`variant="warning"`.
- **Tests**
- Added/updated the Admonition test coverage and removed the legacy test
file.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Danny White <3104761+dnywh@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-07-24 15:51:19 -07:00
1 parent d46cc88f09
commit 2b27ed0ab1
16 files changed
+436 -326

No files matched your search

@@ -13,7 +13,7 @@ source:
<ComponentPreview name="label-demo" peekCode wide />
<Admonition
variant="warning"
type="warning"
title="Do not use this Label component in a Form"
>
@@ -157,7 +157,7 @@ export const Policies = ({
{!isSchemaExposedAPI && (
<Admonition
variant="warning"
type="warning"
title="No data from any table in this schema will be selectable via Supabase APIs"
>
This schema is not exposed via the Supabase APIs. You may configure this in your
+20
View File
@@ -14,6 +14,26 @@
},
"exports": {
"./package.json": "./package.json",
"./Admonition/Admonition.constants": {
"import": "./src/Admonition/Admonition.constants.ts",
"types": "./src/Admonition/Admonition.constants.ts"
},
"./Admonition/Admonition": {
"import": "./src/Admonition/Admonition.tsx",
"types": "./src/Admonition/Admonition.tsx"
},
"./Admonition/Admonition.types": {
"import": "./src/Admonition/Admonition.types.ts",
"types": "./src/Admonition/Admonition.types.ts"
},
"./Admonition/AdmonitionIcons": {
"import": "./src/Admonition/AdmonitionIcons.tsx",
"types": "./src/Admonition/AdmonitionIcons.tsx"
},
"./Admonition": {
"import": "./src/Admonition/index.tsx",
"types": "./src/Admonition/index.tsx"
},
"./AssistantChat/AssistantChatForm": {
"import": "./src/AssistantChat/AssistantChatForm.tsx",
"types": "./src/AssistantChat/AssistantChatForm.tsx"
@@ -0,0 +1,25 @@
import type { AdmonitionType } from './Admonition.types'
export const TYPE_TO_VARIANT = {
note: 'default',
tip: 'default',
caution: 'warning',
danger: 'destructive',
deprecation: 'warning',
default: 'default',
warning: 'warning',
destructive: 'destructive',
success: 'default',
} as const satisfies Record<AdmonitionType, 'default' | 'warning' | 'destructive'>
export const TYPE_LABEL = {
note: 'Note',
tip: 'Tip',
caution: 'Caution',
danger: 'Danger',
deprecation: 'Deprecated',
default: 'Note',
warning: 'Warning',
destructive: 'Danger',
success: 'Success',
} as const satisfies Record<AdmonitionType, string>
@@ -0,0 +1,141 @@
import { render, screen, within } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { Admonition, type AdmonitionProps } from './index'
const stringDescriptionProps = {
description: 'Description-only copy.',
} satisfies AdmonitionProps
const invalidLabelProps = {
// @ts-expect-error label was removed; use title instead.
label: 'Legacy heading',
description: 'Body copy.',
} satisfies AdmonitionProps
void stringDescriptionProps
void invalidLabelProps
describe('Admonition', () => {
it('renders description-only content without a visible type label', () => {
render(<Admonition type="default" description="Changes can take a few minutes to apply." />)
const note = screen.getByRole('alert', { name: 'Note' })
expect(within(note).queryByText('Note:')).not.toBeInTheDocument()
expect(note).toHaveTextContent('Changes can take a few minutes to apply.')
})
it('renders children-only rich MDX-like content', () => {
render(
<Admonition type="note">
<p>
This is a Postgres{' '}
<a href="/docs/guides/database/postgres/row-level-security">SECURITY DEFINER</a> function.
</p>
<ul>
<li>Keep privileges scoped.</li>
</ul>
</Admonition>
)
const note = screen.getByRole('alert', { name: 'Note' })
expect(within(note).queryByText('Note:')).not.toBeInTheDocument()
expect(within(note).getByText('SECURITY DEFINER')).toHaveAttribute(
'href',
'/docs/guides/database/postgres/row-level-security'
)
expect(within(note).getByText('Keep privileges scoped.')).toBeVisible()
})
it('renders a title and description without a type label', () => {
render(
<Admonition
type="warning"
title="Manual approval required"
description="Review the pending changes before continuing."
/>
)
const note = screen.getByRole('alert', { name: 'Warning' })
expect(within(note).queryByText('Warning:')).not.toBeInTheDocument()
expect(note).toHaveTextContent('Manual approval required')
expect(note.querySelector('h1, h2, h3, h4, h5, h6')).not.toBeInTheDocument()
expect(note).toHaveTextContent('Review the pending changes before continuing.')
})
it('renders a title with children', () => {
render(
<Admonition type="caution" title="Security definer function">
<p>Review ownership before exposing this function.</p>
</Admonition>
)
const note = screen.getByRole('alert', { name: 'Caution' })
expect(within(note).queryByText('Caution:')).not.toBeInTheDocument()
expect(note).toHaveTextContent('Security definer function')
expect(note.querySelector('h1, h2, h3, h4, h5, h6')).not.toBeInTheDocument()
expect(note).toHaveTextContent('Review ownership before exposing this function.')
})
it('renders success styling', () => {
render(
<Admonition
type="success"
title="Connection confirmed"
description="You can now close this tab."
/>
)
const note = screen.getByRole('alert', { name: 'Success' })
expect(note).toHaveTextContent('Connection confirmed')
expect(note).toHaveTextContent('You can now close this tab.')
expect(note).toHaveClass('bg-brand-400/15')
expect(note).toHaveClass('border-brand-400')
expect(note.querySelector('svg path')?.getAttribute('d')).toContain('M10.5 19.5')
})
it('omits the icon when showIcon is false', () => {
render(
<Admonition
type="destructive"
showIcon={false}
title="Deletion blocked"
description="Resolve dependent resources before retrying."
/>
)
const note = screen.getByRole('alert', { name: 'Danger' })
expect(note).toHaveTextContent('Deletion blocked')
expect(note.querySelector('svg')).not.toBeInTheDocument()
})
it.each([
['tip', 'Tip'],
['danger', 'Danger'],
['deprecation', 'Deprecated'],
] as const)('exposes %s via aria-label as %s', (type, name) => {
render(<Admonition type={type} description="Body copy." />)
expect(screen.getByRole('alert', { name })).toBeVisible()
expect(within(screen.getByRole('alert')).queryByText(`${name}:`)).not.toBeInTheDocument()
})
it('renders the title as a paragraph with its own margin', () => {
render(<Admonition type="note" title="Manual approval required" description="Body copy." />)
const note = screen.getByRole('alert', { name: 'Note' })
const title = within(note).getByText('Manual approval required')
expect(note.querySelector('h1, h2, h3, h4, h5, h6')).not.toBeInTheDocument()
expect(title.tagName).toBe('P')
expect(title).toHaveAttribute('data-admonition-title')
expect(title).toHaveClass('mb-0.5')
})
it('wraps a string description in a paragraph', () => {
render(<Admonition type="note" description="Body copy." />)
const note = screen.getByRole('alert', { name: 'Note' })
expect(within(note).getByText('Body copy.').tagName).toBe('P')
})
})
@@ -0,0 +1,109 @@
import { forwardRef } from 'react'
import { Alert, AlertDescription, cn } from 'ui'
import { TYPE_LABEL, TYPE_TO_VARIANT } from './Admonition.constants'
import type { AdmonitionLayout, AdmonitionProps, AdmonitionType } from './Admonition.types'
import { AdmonitionTypeIcon } from './AdmonitionIcons'
export type { AdmonitionLayout, AdmonitionProps, AdmonitionType }
export const Admonition = forwardRef<
React.ComponentRef<typeof Alert>,
Omit<
React.ComponentPropsWithoutRef<typeof Alert>,
keyof AdmonitionProps | 'children' | 'variant'
> &
AdmonitionProps
>(
(
{
type = 'note',
showIcon = true,
title,
description,
children,
layout = 'vertical',
actions,
childProps,
icon,
className,
...props
},
ref
) => {
const label = TYPE_LABEL[type]
return (
<Alert
ref={ref}
{...props}
aria-label={label}
variant={TYPE_TO_VARIANT[type]}
className={cn(
'overflow-hidden',
layout === 'responsive' && '@container',
type === 'success' && [
'bg-brand-400/15 dark:bg-brand/10',
'border-brand-400 dark:border-brand-500',
],
className
)}
>
<div className="flex items-start gap-3">
{showIcon && (icon ?? <AdmonitionTypeIcon type={type} />)}
<div
className={cn(
'min-w-0 flex-1',
layout === 'vertical' && 'flex flex-col',
layout === 'horizontal' && [
'flex flex-row items-center justify-between',
'gap-x-6 lg:gap-x-8',
],
layout === 'responsive' && [
'flex flex-col',
'@md:flex-row @md:items-center @md:justify-between',
'@md:gap-x-6 @lg:gap-x-8',
]
)}
>
<div
{...childProps?.description}
className={cn(
'text-foreground-light',
// Exclude the title <p> so these MDX body resets don't override its mb-0.5
// ([&_p]:!mb-1.5 beats !mb-0.5 on specificity: class+element vs class).
'[&_p:not([data-admonition-title])]:!mt-0 [&_p:not([data-admonition-title])]:!mb-1.5 [&_p:not([data-admonition-title]):last-child]:!mb-0',
'[&_ul]:!my-1.5 [&_ol]:!my-1.5 [&_li]:!my-0.5',
childProps?.description?.className
)}
>
{title && (
<p
{...childProps?.title}
data-admonition-title
className={cn('mb-0.5 font-medium text-foreground', childProps?.title?.className)}
>
{title}
</p>
)}
{description && <AlertDescription>{description}</AlertDescription>}
{children}
</div>
{actions && (
<div
className={cn(
'flex flex-row gap-3',
layout === 'vertical' && 'mt-3 items-start',
layout === 'horizontal' && 'items-center',
layout === 'responsive' && 'mt-3 items-start @md:mt-0 @md:items-center'
)}
>
{actions}
</div>
)}
</div>
</div>
</Alert>
)
}
)
@@ -0,0 +1,30 @@
import type { HTMLAttributes, ReactNode } from 'react'
export type AdmonitionType =
| 'note'
| 'tip'
| 'caution'
| 'danger'
| 'deprecation'
| 'default'
| 'destructive'
| 'success'
| 'warning'
export type AdmonitionLayout = 'horizontal' | 'vertical' | 'responsive'
export interface AdmonitionProps {
type?: AdmonitionType
title?: string
description?: ReactNode
children?: ReactNode
showIcon?: boolean
childProps?: {
title?: HTMLAttributes<HTMLParagraphElement>
description?: HTMLAttributes<HTMLDivElement>
}
layout?: AdmonitionLayout
actions?: ReactNode
icon?: ReactNode
className?: string
}
@@ -0,0 +1,94 @@
import type { ReactNode, SVGProps } from 'react'
import { cn } from 'ui'
import type { AdmonitionType } from './Admonition.types'
export type AdmonitionIconProps = SVGProps<SVGSVGElement>
type AdmonitionIconBaseProps = AdmonitionIconProps & {
viewBox: string
children: ReactNode
}
type IconVisual = 'default' | 'warning' | 'destructive' | 'success'
const ICON_BADGE_CLASS: Record<IconVisual, string> = {
default: 'text-background bg-foreground-muted',
success: 'text-white dark:text-brand-link bg-brand dark:bg-brand-500/50',
warning: 'text-warning-200 bg-warning-600',
destructive: 'text-destructive-200 bg-destructive-600',
}
function getIconVisual(type: AdmonitionType): IconVisual {
if (type === 'success') return 'success'
if (type === 'danger' || type === 'destructive') return 'destructive'
if (type === 'caution' || type === 'warning' || type === 'deprecation') return 'warning'
return 'default'
}
function AdmonitionIcon({ className, viewBox, children, ...props }: AdmonitionIconBaseProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox={viewBox}
className={cn('w-6 h-6', className)}
fill="currentColor"
aria-hidden="true"
{...props}
>
{children}
</svg>
)
}
export const InfoIcon = (props: AdmonitionIconProps) => (
<AdmonitionIcon viewBox="0 0 21 20" {...props}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0.625 9.8252C0.625 4.44043 4.99023 0.0751953 10.375 0.0751953C15.7598 0.0751953 20.125 4.44043 20.125 9.8252C20.125 15.21 15.7598 19.5752 10.375 19.5752C4.99023 19.5752 0.625 15.21 0.625 9.8252ZM9.3584 4.38135C9.45117 4.28857 9.55518 4.20996 9.66699 4.14648C9.88086 4.02539 10.1245 3.96045 10.375 3.96045C10.5845 3.96045 10.7896 4.00586 10.9766 4.09229C11.1294 4.1626 11.2705 4.26025 11.3916 4.38135C11.6611 4.65088 11.8125 5.0166 11.8125 5.39795C11.8125 5.5249 11.7959 5.6499 11.7637 5.77002C11.6987 6.01172 11.5718 6.23438 11.3916 6.41455C11.1221 6.68408 10.7563 6.83545 10.375 6.83545C9.99365 6.83545 9.62793 6.68408 9.3584 6.41455C9.08887 6.14502 8.9375 5.7793 8.9375 5.39795C8.9375 5.29492 8.94873 5.19287 8.97021 5.09375C9.02783 4.82568 9.16162 4.57812 9.3584 4.38135ZM10.375 15.6899C10.0933 15.6899 9.82275 15.5781 9.62354 15.3789C9.42432 15.1797 9.3125 14.9092 9.3125 14.6274V9.31494C9.3125 9.0332 9.42432 8.7627 9.62354 8.56348C9.82275 8.36426 10.0933 8.25244 10.375 8.25244C10.6567 8.25244 10.9272 8.36426 11.1265 8.56348C11.3257 8.7627 11.4375 9.0332 11.4375 9.31494V14.6274C11.4375 14.7944 11.3979 14.9575 11.3242 15.104C11.2739 15.2046 11.2075 15.2979 11.1265 15.3789C10.9272 15.5781 10.6567 15.6899 10.375 15.6899Z"
/>
</AdmonitionIcon>
)
export const SuccessIcon = (props: AdmonitionIconProps) => (
<AdmonitionIcon viewBox="0 0 21 20" {...props}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10.5 19.5C5.25329 19.5 1 15.2467 1 10C1 4.75329 5.25329 0.5 10.5 0.5C15.7467 0.5 20 4.75329 20 10C20 15.2467 15.7467 19.5 10.5 19.5ZM14.7803 7.78033C15.0732 7.48744 15.0732 7.01256 14.7803 6.71967C14.4874 6.42678 14.0126 6.42678 13.7197 6.71967L9.25 11.1893L7.28033 9.21967C6.98744 8.92678 6.51256 8.92678 6.21967 9.21967C5.92678 9.51256 5.92678 9.98744 6.21967 10.2803L8.71967 12.7803C9.01256 13.0732 9.48744 13.0732 9.78033 12.7803L14.7803 7.78033Z"
/>
</AdmonitionIcon>
)
export const WarningIcon = (props: AdmonitionIconProps) => (
<AdmonitionIcon viewBox="0 0 22 20" {...props}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8.15137 1.95117C9.30615 -0.0488281 12.1943 -0.0488281 13.3481 1.95117L20.7031 14.6992C21.8574 16.6992 20.4131 19.1992 18.104 19.1992H3.39502C1.08594 19.1992 -0.356933 16.6992 0.797364 14.6992L8.15137 1.95117ZM11.7666 16.0083C11.4971 16.2778 11.1313 16.4292 10.75 16.4292C10.3687 16.4292 10.0029 16.2778 9.7334 16.0083C9.46387 15.7388 9.3125 15.373 9.3125 14.9917C9.3125 14.9307 9.31641 14.8706 9.32373 14.811C9.33545 14.7197 9.35547 14.6304 9.38379 14.5439L9.41406 14.4609C9.48584 14.2803 9.59375 14.1147 9.7334 13.9751C10.0029 13.7056 10.3687 13.5542 10.75 13.5542C11.1313 13.5542 11.4971 13.7056 11.7666 13.9751C12.0361 14.2446 12.1875 14.6104 12.1875 14.9917C12.1875 15.373 12.0361 15.7388 11.7666 16.0083ZM10.75 4.69971C11.0317 4.69971 11.3022 4.81152 11.5015 5.01074C11.7007 5.20996 11.8125 5.48047 11.8125 5.76221V11.0747C11.8125 11.3564 11.7007 11.627 11.5015 11.8262C11.3022 12.0254 11.0317 12.1372 10.75 12.1372C10.4683 12.1372 10.1978 12.0254 9.99854 11.8262C9.79932 11.627 9.6875 11.3564 9.6875 11.0747V5.76221C9.6875 5.48047 9.79932 5.20996 9.99854 5.01074C10.1978 4.81152 10.4683 4.69971 10.75 4.69971Z"
/>
</AdmonitionIcon>
)
function getGlyph(type: AdmonitionType) {
const visual = getIconVisual(type)
if (visual === 'success') return <SuccessIcon />
if (visual === 'warning' || visual === 'destructive') return <WarningIcon />
return <InfoIcon />
}
export function AdmonitionTypeIcon({ type }: { type: AdmonitionType }) {
const visual = getIconVisual(type)
return (
<span
className={cn(
'inline-flex shrink-0 items-center justify-center size-[23px] p-1 rounded-sm [&>svg]:size-full',
ICON_BADGE_CLASS[visual]
)}
>
{getGlyph(type)}
</span>
)
}
@@ -0,0 +1,9 @@
export { Admonition } from './Admonition'
export type { AdmonitionLayout, AdmonitionProps, AdmonitionType } from './Admonition.types'
export {
AdmonitionTypeIcon,
InfoIcon,
SuccessIcon,
WarningIcon,
type AdmonitionIconProps,
} from './AdmonitionIcons'
@@ -16,7 +16,7 @@ import {
} from 'ui'
import { DialogDescription, DialogHeader } from 'ui/src/components/shadcn/ui/dialog'
import { Admonition } from './../admonition'
import { Admonition } from '../Admonition'
export interface ConfirmationModalProps {
loading?: boolean
@@ -29,7 +29,7 @@ import {
import { DialogHeader } from 'ui/src/components/shadcn/ui/dialog'
import { z } from 'zod'
import { Admonition } from './../admonition'
import { Admonition } from '../Admonition'
export interface TextConfirmModalProps {
loading: boolean
@@ -4,7 +4,7 @@ import { HelpCircle } from 'lucide-react'
import { forwardRef, useEffect, useRef } from 'react'
import { Card, CardHeader, cn } from 'ui'
import { WarningIcon } from '../admonition'
import { WarningIcon } from '../Admonition'
import type { ErrorDisplayProps, SupportFormParams } from './ErrorDisplay.types'
export type { SupportFormParams } from './ErrorDisplay.types'
@@ -17,7 +17,7 @@ import {
Switch,
} from 'ui'
import { Admonition } from '../admonition'
import { Admonition } from '../Admonition'
interface PrivacySettingsProps {
className?: string
+1 -1
View File
@@ -3,7 +3,7 @@
import dynamic from 'next/dynamic.js'
import { ErrorBoundary, FallbackProps } from 'react-error-boundary'
import { Admonition } from '../admonition'
import { Admonition } from '../Admonition'
import { SqlToRestProps } from './sql-to-rest'
function FallbackComponent({ error }: FallbackProps) {
@@ -1,104 +0,0 @@
import { render, screen, within } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { Admonition, type AdmonitionProps } from './admonition'
const stringDescriptionProps = {
description: 'Description-only copy.',
} satisfies AdmonitionProps
const invalidLabelProps = {
// @ts-expect-error label was removed; use title instead.
label: 'Legacy heading',
description: 'Body copy.',
} satisfies AdmonitionProps
void stringDescriptionProps
void invalidLabelProps
describe('Admonition', () => {
it('renders description-only content', () => {
render(<Admonition type="default" description="Changes can take a few minutes to apply." />)
expect(screen.getByRole('alert')).toHaveTextContent('Changes can take a few minutes to apply.')
})
it('renders children-only rich MDX-like content', () => {
render(
<Admonition type="note">
<p>
This is a Postgres{' '}
<a href="/docs/guides/database/postgres/row-level-security">SECURITY DEFINER</a> function.
</p>
<ul>
<li>Keep privileges scoped.</li>
</ul>
</Admonition>
)
const alert = screen.getByRole('alert')
expect(within(alert).getByText('SECURITY DEFINER')).toHaveAttribute(
'href',
'/docs/guides/database/postgres/row-level-security'
)
expect(within(alert).getByText('Keep privileges scoped.')).toBeVisible()
})
it('renders title and description together', () => {
render(
<Admonition
type="warning"
title="Manual approval required"
description="Review the pending changes before continuing."
/>
)
const alert = screen.getByRole('alert')
expect(alert).toHaveTextContent('Manual approval required')
expect(alert).toHaveTextContent('Review the pending changes before continuing.')
})
it('renders title and children together', () => {
render(
<Admonition type="caution" title="Security definer function">
<p>Review ownership before exposing this function.</p>
</Admonition>
)
const alert = screen.getByRole('alert')
expect(alert).toHaveTextContent('Security definer function')
expect(alert).toHaveTextContent('Review ownership before exposing this function.')
})
it('renders success state with success styling', () => {
render(
<Admonition
type="success"
title="Connection confirmed"
description="You can now close this tab."
/>
)
const alert = screen.getByRole('alert')
expect(alert).toHaveTextContent('Connection confirmed')
expect(alert).toHaveTextContent('You can now close this tab.')
expect(alert).toHaveClass('bg-brand-400/15')
expect(alert).toHaveClass('border-brand-400')
expect(alert.querySelector('svg path')?.getAttribute('d')).toContain('M10.5 19.5')
})
it('does not render the destructive icon when showIcon is false', () => {
render(
<Admonition
type="destructive"
showIcon={false}
title="Deletion blocked"
description="Resolve dependent resources before retrying."
/>
)
const alert = screen.getByRole('alert')
expect(alert).toHaveTextContent('Deletion blocked')
expect(alert.querySelector('svg')).not.toBeInTheDocument()
})
})
+1 -215
View File
@@ -1,215 +1 @@
import { cva } from 'class-variance-authority'
import { ComponentProps, forwardRef, ReactNode } from 'react'
import { Alert, AlertDescription, AlertTitle, cn } from 'ui'
export type AdmonitionType =
| 'note'
| 'tip'
| 'caution'
| 'danger'
| 'deprecation'
| 'default'
| 'destructive'
| 'success'
| 'warning'
export interface AdmonitionProps {
type?: AdmonitionType
title?: string
description?: ReactNode
children?: ReactNode
showIcon?: boolean
childProps?: {
title?: ComponentProps<typeof AlertTitle>
description?: ComponentProps<typeof AlertDescription>
}
layout?: 'horizontal' | 'vertical' | 'responsive'
actions?: ReactNode
icon?: ReactNode
className?: string
}
const admonitionToAlertMapping: Record<AdmonitionType, 'default' | 'destructive' | 'warning'> = {
note: 'default',
tip: 'default',
caution: 'warning',
danger: 'destructive',
deprecation: 'warning',
default: 'default',
warning: 'warning',
destructive: 'destructive',
success: 'default',
}
const InfoIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 21 20"
className="w-6 h-6"
fill="currentColor"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0.625 9.8252C0.625 4.44043 4.99023 0.0751953 10.375 0.0751953C15.7598 0.0751953 20.125 4.44043 20.125 9.8252C20.125 15.21 15.7598 19.5752 10.375 19.5752C4.99023 19.5752 0.625 15.21 0.625 9.8252ZM9.3584 4.38135C9.45117 4.28857 9.55518 4.20996 9.66699 4.14648C9.88086 4.02539 10.1245 3.96045 10.375 3.96045C10.5845 3.96045 10.7896 4.00586 10.9766 4.09229C11.1294 4.1626 11.2705 4.26025 11.3916 4.38135C11.6611 4.65088 11.8125 5.0166 11.8125 5.39795C11.8125 5.5249 11.7959 5.6499 11.7637 5.77002C11.6987 6.01172 11.5718 6.23438 11.3916 6.41455C11.1221 6.68408 10.7563 6.83545 10.375 6.83545C9.99365 6.83545 9.62793 6.68408 9.3584 6.41455C9.08887 6.14502 8.9375 5.7793 8.9375 5.39795C8.9375 5.29492 8.94873 5.19287 8.97021 5.09375C9.02783 4.82568 9.16162 4.57812 9.3584 4.38135ZM10.375 15.6899C10.0933 15.6899 9.82275 15.5781 9.62354 15.3789C9.42432 15.1797 9.3125 14.9092 9.3125 14.6274V9.31494C9.3125 9.0332 9.42432 8.7627 9.62354 8.56348C9.82275 8.36426 10.0933 8.25244 10.375 8.25244C10.6567 8.25244 10.9272 8.36426 11.1265 8.56348C11.3257 8.7627 11.4375 9.0332 11.4375 9.31494V14.6274C11.4375 14.7944 11.3979 14.9575 11.3242 15.104C11.2739 15.2046 11.2075 15.2979 11.1265 15.3789C10.9272 15.5781 10.6567 15.6899 10.375 15.6899Z"
/>
</svg>
)
const SuccessIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 21 20"
className="w-6 h-6"
fill="currentColor"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10.5 19.5C5.25329 19.5 1 15.2467 1 10C1 4.75329 5.25329 0.5 10.5 0.5C15.7467 0.5 20 4.75329 20 10C20 15.2467 15.7467 19.5 10.5 19.5ZM14.7803 7.78033C15.0732 7.48744 15.0732 7.01256 14.7803 6.71967C14.4874 6.42678 14.0126 6.42678 13.7197 6.71967L9.25 11.1893L7.28033 9.21967C6.98744 8.92678 6.51256 8.92678 6.21967 9.21967C5.92678 9.51256 5.92678 9.98744 6.21967 10.2803L8.71967 12.7803C9.01256 13.0732 9.48744 13.0732 9.78033 12.7803L14.7803 7.78033Z"
/>
</svg>
)
export const WarningIcon = ({ className }: { className?: string }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 22 20"
className={cn('w-6 h-6', className)}
fill="currentColor"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8.15137 1.95117C9.30615 -0.0488281 12.1943 -0.0488281 13.3481 1.95117L20.7031 14.6992C21.8574 16.6992 20.4131 19.1992 18.104 19.1992H3.39502C1.08594 19.1992 -0.356933 16.6992 0.797364 14.6992L8.15137 1.95117ZM11.7666 16.0083C11.4971 16.2778 11.1313 16.4292 10.75 16.4292C10.3687 16.4292 10.0029 16.2778 9.7334 16.0083C9.46387 15.7388 9.3125 15.373 9.3125 14.9917C9.3125 14.9307 9.31641 14.8706 9.32373 14.811C9.33545 14.7197 9.35547 14.6304 9.38379 14.5439L9.41406 14.4609C9.48584 14.2803 9.59375 14.1147 9.7334 13.9751C10.0029 13.7056 10.3687 13.5542 10.75 13.5542C11.1313 13.5542 11.4971 13.7056 11.7666 13.9751C12.0361 14.2446 12.1875 14.6104 12.1875 14.9917C12.1875 15.373 12.0361 15.7388 11.7666 16.0083ZM10.75 4.69971C11.0317 4.69971 11.3022 4.81152 11.5015 5.01074C11.7007 5.20996 11.8125 5.48047 11.8125 5.76221V11.0747C11.8125 11.3564 11.7007 11.627 11.5015 11.8262C11.3022 12.0254 11.0317 12.1372 10.75 12.1372C10.4683 12.1372 10.1978 12.0254 9.99854 11.8262C9.79932 11.627 9.6875 11.3564 9.6875 11.0747V5.76221C9.6875 5.48047 9.79932 5.20996 9.99854 5.01074C10.1978 4.81152 10.4683 4.69971 10.75 4.69971Z"
/>
</svg>
)
const admonitionSVG = cva('', {
variants: {
type: {
default: `[&>svg]:bg-foreground-muted`,
success: `bg-brand-400/15 dark:bg-brand/10 border-brand-400 dark:border-brand-500 [&>svg]:text-white dark:[&>svg]:text-brand-link [&>svg]:bg-brand dark:[&>svg]:bg-brand-500/50`,
warning: ``,
destructive: ``,
},
},
})
const admonitionBodyClassName =
'[&_p]:!mt-0 [&_p]:!mb-1.5 [&_p:last-child]:!mb-0 [&_p:only-child]:!mb-0 [&_ul]:!my-1.5 [&_ol]:!my-1.5 [&_li]:!my-0.5'
export const Admonition = forwardRef<
React.ElementRef<typeof Alert>,
Omit<React.ComponentPropsWithoutRef<typeof Alert>, keyof AdmonitionProps | 'children'> &
AdmonitionProps
>(
(
{
type = 'note',
variant,
showIcon = true,
title,
description,
children,
layout = 'vertical',
actions,
childProps = {},
icon,
...props
},
ref
) => {
const typeMapped = variant ? admonitionToAlertMapping[variant] : admonitionToAlertMapping[type]
const typeStyle = type === 'success' ? 'success' : typeMapped
const heading = title
return (
<Alert
ref={ref}
variant={typeMapped}
{...props}
className={cn(
// Handle occasional background elements
'overflow-hidden',
// Container query context for responsive layout
layout === 'responsive' && '@container',
// SVG icon
admonitionSVG({ type: typeStyle }),
props.className
)}
>
{!!icon ? (
icon
) : showIcon && typeStyle === 'success' ? (
<SuccessIcon />
) : showIcon && (typeMapped === 'warning' || typeMapped === 'destructive') ? (
<WarningIcon />
) : showIcon ? (
<InfoIcon />
) : null}
<div
className={cn(
'flex',
layout === 'vertical' && 'flex-col',
layout === 'horizontal' && 'flex-row items-center justify-between gap-x-6 lg:gap-x-8',
layout === 'responsive' &&
'flex-col @md:flex-row @md:items-center @md:justify-between @md:gap-x-6 @lg:gap-x-8'
)}
>
<div>
{heading && (
<AlertTitle
{...childProps.title}
className={cn(
'text mt-0.5 flex flex-col gap-3 text-sm',
childProps.title?.className
)}
>
{heading}
</AlertTitle>
)}
{description && (
<AlertDescription
{...childProps.description}
className={cn(
admonitionBodyClassName,
!heading && 'my-0.5',
childProps.description?.className
)}
>
{description}
</AlertDescription>
)}
{/* // children is to handle Docs and MDX issues with children and <p> elements */}
{children && (
<AlertDescription
{...childProps.description}
className={cn(
admonitionBodyClassName,
!heading && !description && 'my-0.5',
childProps?.description?.className
)}
>
{children}
</AlertDescription>
)}
</div>
{actions && (
<div
className={cn(
'flex flex-row gap-3',
layout === 'vertical' && 'mt-3 items-start',
layout === 'horizontal' && 'items-center',
layout === 'responsive' && 'mt-3 items-start @md:mt-0 @md:items-center'
)}
>
{actions}
</div>
)}
</div>
</Alert>
)
}
)
export * from './Admonition'