diff --git a/apps/design-system/content/docs/components/label.mdx b/apps/design-system/content/docs/components/label.mdx index fe0afa71ae8..f8fec4bb3c8 100644 --- a/apps/design-system/content/docs/components/label.mdx +++ b/apps/design-system/content/docs/components/label.mdx @@ -13,7 +13,7 @@ source: diff --git a/apps/studio/components/interfaces/Database/Policies/Policies.tsx b/apps/studio/components/interfaces/Database/Policies/Policies.tsx index 6914e380755..a12159fdfe1 100644 --- a/apps/studio/components/interfaces/Database/Policies/Policies.tsx +++ b/apps/studio/components/interfaces/Database/Policies/Policies.tsx @@ -157,7 +157,7 @@ export const Policies = ({ {!isSchemaExposedAPI && ( This schema is not exposed via the Supabase APIs. You may configure this in your diff --git a/packages/ui-patterns/package.json b/packages/ui-patterns/package.json index a0df1ca5102..c274ba9bd31 100644 --- a/packages/ui-patterns/package.json +++ b/packages/ui-patterns/package.json @@ -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" diff --git a/packages/ui-patterns/src/Admonition/Admonition.constants.ts b/packages/ui-patterns/src/Admonition/Admonition.constants.ts new file mode 100644 index 00000000000..038f743ede8 --- /dev/null +++ b/packages/ui-patterns/src/Admonition/Admonition.constants.ts @@ -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 + +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 diff --git a/packages/ui-patterns/src/Admonition/Admonition.test.tsx b/packages/ui-patterns/src/Admonition/Admonition.test.tsx new file mode 100644 index 00000000000..205bf0be7fd --- /dev/null +++ b/packages/ui-patterns/src/Admonition/Admonition.test.tsx @@ -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() + + 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( + +

+ This is a Postgres{' '} + SECURITY DEFINER function. +

+
    +
  • Keep privileges scoped.
  • +
+
+ ) + + 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( + + ) + + 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( + +

Review ownership before exposing this function.

+
+ ) + + 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( + + ) + + 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( + + ) + + 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() + + 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() + + 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() + + const note = screen.getByRole('alert', { name: 'Note' }) + expect(within(note).getByText('Body copy.').tagName).toBe('P') + }) +}) diff --git a/packages/ui-patterns/src/Admonition/Admonition.tsx b/packages/ui-patterns/src/Admonition/Admonition.tsx new file mode 100644 index 00000000000..530f65658b5 --- /dev/null +++ b/packages/ui-patterns/src/Admonition/Admonition.tsx @@ -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, + Omit< + React.ComponentPropsWithoutRef, + 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 ( + +
+ {showIcon && (icon ?? )} +
+
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 && ( +

+ {title} +

+ )} + {description && {description}} + {children} +
+ {actions && ( +
+ {actions} +
+ )} +
+
+
+ ) + } +) diff --git a/packages/ui-patterns/src/Admonition/Admonition.types.ts b/packages/ui-patterns/src/Admonition/Admonition.types.ts new file mode 100644 index 00000000000..abece119d35 --- /dev/null +++ b/packages/ui-patterns/src/Admonition/Admonition.types.ts @@ -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 + description?: HTMLAttributes + } + layout?: AdmonitionLayout + actions?: ReactNode + icon?: ReactNode + className?: string +} diff --git a/packages/ui-patterns/src/Admonition/AdmonitionIcons.tsx b/packages/ui-patterns/src/Admonition/AdmonitionIcons.tsx new file mode 100644 index 00000000000..9fb01f60895 --- /dev/null +++ b/packages/ui-patterns/src/Admonition/AdmonitionIcons.tsx @@ -0,0 +1,94 @@ +import type { ReactNode, SVGProps } from 'react' +import { cn } from 'ui' + +import type { AdmonitionType } from './Admonition.types' + +export type AdmonitionIconProps = SVGProps + +type AdmonitionIconBaseProps = AdmonitionIconProps & { + viewBox: string + children: ReactNode +} + +type IconVisual = 'default' | 'warning' | 'destructive' | 'success' + +const ICON_BADGE_CLASS: Record = { + 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 ( + + ) +} + +export const InfoIcon = (props: AdmonitionIconProps) => ( + + + +) + +export const SuccessIcon = (props: AdmonitionIconProps) => ( + + + +) + +export const WarningIcon = (props: AdmonitionIconProps) => ( + + + +) + +function getGlyph(type: AdmonitionType) { + const visual = getIconVisual(type) + if (visual === 'success') return + if (visual === 'warning' || visual === 'destructive') return + return +} + +export function AdmonitionTypeIcon({ type }: { type: AdmonitionType }) { + const visual = getIconVisual(type) + + return ( + svg]:size-full', + ICON_BADGE_CLASS[visual] + )} + > + {getGlyph(type)} + + ) +} diff --git a/packages/ui-patterns/src/Admonition/index.tsx b/packages/ui-patterns/src/Admonition/index.tsx new file mode 100644 index 00000000000..c91e54bc024 --- /dev/null +++ b/packages/ui-patterns/src/Admonition/index.tsx @@ -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' diff --git a/packages/ui-patterns/src/Dialogs/ConfirmationModal.tsx b/packages/ui-patterns/src/Dialogs/ConfirmationModal.tsx index c7207f5d4cb..5b70b94d8c0 100644 --- a/packages/ui-patterns/src/Dialogs/ConfirmationModal.tsx +++ b/packages/ui-patterns/src/Dialogs/ConfirmationModal.tsx @@ -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 diff --git a/packages/ui-patterns/src/Dialogs/TextConfirmModal.tsx b/packages/ui-patterns/src/Dialogs/TextConfirmModal.tsx index ef34de98f7e..c3e156a071c 100644 --- a/packages/ui-patterns/src/Dialogs/TextConfirmModal.tsx +++ b/packages/ui-patterns/src/Dialogs/TextConfirmModal.tsx @@ -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 diff --git a/packages/ui-patterns/src/ErrorDisplay/ErrorDisplay.tsx b/packages/ui-patterns/src/ErrorDisplay/ErrorDisplay.tsx index e495a27dba1..5654c5827cd 100644 --- a/packages/ui-patterns/src/ErrorDisplay/ErrorDisplay.tsx +++ b/packages/ui-patterns/src/ErrorDisplay/ErrorDisplay.tsx @@ -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' diff --git a/packages/ui-patterns/src/PrivacySettings/index.tsx b/packages/ui-patterns/src/PrivacySettings/index.tsx index 09ae65a0250..b13fdd0c5c3 100644 --- a/packages/ui-patterns/src/PrivacySettings/index.tsx +++ b/packages/ui-patterns/src/PrivacySettings/index.tsx @@ -17,7 +17,7 @@ import { Switch, } from 'ui' -import { Admonition } from '../admonition' +import { Admonition } from '../Admonition' interface PrivacySettingsProps { className?: string diff --git a/packages/ui-patterns/src/SqlToRest/index.tsx b/packages/ui-patterns/src/SqlToRest/index.tsx index b9bafca284a..97bba488831 100644 --- a/packages/ui-patterns/src/SqlToRest/index.tsx +++ b/packages/ui-patterns/src/SqlToRest/index.tsx @@ -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) { diff --git a/packages/ui-patterns/src/admonition.test.tsx b/packages/ui-patterns/src/admonition.test.tsx deleted file mode 100644 index f81775a2085..00000000000 --- a/packages/ui-patterns/src/admonition.test.tsx +++ /dev/null @@ -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() - - expect(screen.getByRole('alert')).toHaveTextContent('Changes can take a few minutes to apply.') - }) - - it('renders children-only rich MDX-like content', () => { - render( - -

- This is a Postgres{' '} - SECURITY DEFINER function. -

-
    -
  • Keep privileges scoped.
  • -
-
- ) - - 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( - - ) - - 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( - -

Review ownership before exposing this function.

-
- ) - - 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( - - ) - - 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( - - ) - - const alert = screen.getByRole('alert') - expect(alert).toHaveTextContent('Deletion blocked') - expect(alert.querySelector('svg')).not.toBeInTheDocument() - }) -}) diff --git a/packages/ui-patterns/src/admonition.tsx b/packages/ui-patterns/src/admonition.tsx index 8319c872c8a..6163e4fb671 100644 --- a/packages/ui-patterns/src/admonition.tsx +++ b/packages/ui-patterns/src/admonition.tsx @@ -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 - description?: ComponentProps - } - layout?: 'horizontal' | 'vertical' | 'responsive' - actions?: ReactNode - icon?: ReactNode - className?: string -} - -const admonitionToAlertMapping: Record = { - note: 'default', - tip: 'default', - caution: 'warning', - danger: 'destructive', - deprecation: 'warning', - default: 'default', - warning: 'warning', - destructive: 'destructive', - success: 'default', -} - -const InfoIcon = () => ( - - - -) - -const SuccessIcon = () => ( - - - -) - -export const WarningIcon = ({ className }: { className?: string }) => ( - - - -) - -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, - Omit, 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 ( - - {!!icon ? ( - icon - ) : showIcon && typeStyle === 'success' ? ( - - ) : showIcon && (typeMapped === 'warning' || typeMapped === 'destructive') ? ( - - ) : showIcon ? ( - - ) : null} -
-
- {heading && ( - - {heading} - - )} - {description && ( - - {description} - - )} - {/* // children is to handle Docs and MDX issues with children and

elements */} - {children && ( - - {children} - - )} -

- {actions && ( -
- {actions} -
- )} -
-
- ) - } -) +export * from './Admonition'