This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@devrunbook/ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"lint": "eslint src --max-warnings=0",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "19.2.8",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "4.1.10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export const productName = 'DevRunbook'
|
||||
|
||||
export * from './playbooks/playbook-card'
|
||||
export * from './playbooks/playbook-dense-row'
|
||||
export * from './playbooks/playbook-types'
|
||||
export * from './primitives/button'
|
||||
export * from './primitives/icon-button'
|
||||
export * from './primitives/segmented-control'
|
||||
export * from './primitives/skeleton'
|
||||
export * from './primitives/surface'
|
||||
export * from './status/badges'
|
||||
export * from './status/state-panel'
|
||||
@@ -0,0 +1,5 @@
|
||||
export function classNames(
|
||||
...values: readonly (string | false | null | undefined)[]
|
||||
): string {
|
||||
return values.filter((value): value is string => Boolean(value)).join(' ')
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { classNames } from '../lib/class-names'
|
||||
import { LifecycleBadge, RiskBadge, SourceBadge } from '../status/badges'
|
||||
import type {
|
||||
PlaybookActions,
|
||||
PlaybookLabels,
|
||||
PlaybookPresentation,
|
||||
} from './playbook-types'
|
||||
|
||||
export interface PlaybookCardProps
|
||||
extends PlaybookPresentation, PlaybookActions {
|
||||
readonly className?: string
|
||||
readonly labels?: PlaybookLabels
|
||||
}
|
||||
|
||||
export function PlaybookCard({
|
||||
id,
|
||||
slug,
|
||||
title,
|
||||
outcome,
|
||||
category,
|
||||
type,
|
||||
riskTier,
|
||||
autonomyMinimum,
|
||||
autonomyMaximum,
|
||||
defaultAutonomy,
|
||||
tags,
|
||||
lifecycle,
|
||||
source,
|
||||
version,
|
||||
updatedAt,
|
||||
favorite,
|
||||
matchReasons,
|
||||
detailHref,
|
||||
composeHref,
|
||||
favoriteBusy = false,
|
||||
onFavoriteChange,
|
||||
className,
|
||||
labels,
|
||||
}: PlaybookCardProps) {
|
||||
const copy = labels ?? defaultLabels
|
||||
const titleId = `playbook-${id}-title`
|
||||
return (
|
||||
<article
|
||||
className={classNames('drb-playbook-card', className)}
|
||||
aria-labelledby={titleId}
|
||||
data-playbook-slug={slug}
|
||||
>
|
||||
<header className="drb-playbook-card__header">
|
||||
<div className="drb-playbook-card__badges">
|
||||
<LifecycleBadge value={lifecycle} />
|
||||
<SourceBadge value={source} />
|
||||
<RiskBadge value={riskTier} />
|
||||
</div>
|
||||
{onFavoriteChange ? (
|
||||
<button
|
||||
type="button"
|
||||
className="drb-playbook-card__favorite"
|
||||
aria-label={
|
||||
favorite ? copy.removeFavorite(title) : copy.addFavorite(title)
|
||||
}
|
||||
aria-pressed={favorite}
|
||||
aria-busy={favoriteBusy || undefined}
|
||||
disabled={favoriteBusy}
|
||||
onClick={() => onFavoriteChange(!favorite)}
|
||||
>
|
||||
<span aria-hidden="true">{favorite ? copy.saved : copy.save}</span>
|
||||
</button>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<div className="drb-playbook-card__identity">
|
||||
<p className="drb-playbook-card__eyebrow">
|
||||
{category} / {type}
|
||||
</p>
|
||||
<h2 className="drb-playbook-card__title" id={titleId}>
|
||||
<a href={detailHref}>{title}</a>
|
||||
</h2>
|
||||
<p className="drb-playbook-card__outcome">{outcome}</p>
|
||||
</div>
|
||||
|
||||
<dl className="drb-playbook-card__facts">
|
||||
<div>
|
||||
<dt>{copy.defaultAutonomy}</dt>
|
||||
<dd>{defaultAutonomy}</dd>
|
||||
</div>
|
||||
{autonomyMinimum && autonomyMaximum ? (
|
||||
<div>
|
||||
<dt>{copy.supportedRange}</dt>
|
||||
<dd>
|
||||
{autonomyMinimum} {copy.rangeConnector} {autonomyMaximum}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
|
||||
{tags.length > 0 ? (
|
||||
<ul className="drb-playbook-card__tags" aria-label={copy.supportedTags}>
|
||||
{tags.slice(0, 4).map((tag) => (
|
||||
<li key={tag}>{tag}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{matchReasons && matchReasons.length > 0 ? (
|
||||
<div className="drb-playbook-card__match">
|
||||
<p>{copy.whyMatch}</p>
|
||||
<ul>
|
||||
{matchReasons.map((reason) => (
|
||||
<li key={reason}>{reason}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<footer className="drb-playbook-card__footer">
|
||||
<p className="drb-playbook-card__version">
|
||||
<span>
|
||||
{copy.version} {version}
|
||||
</span>
|
||||
{updatedAt ? <time dateTime={updatedAt}>{updatedAt}</time> : null}
|
||||
</p>
|
||||
<div className="drb-playbook-card__actions">
|
||||
<a
|
||||
className="drb-link-button drb-link-button--secondary"
|
||||
href={detailHref}
|
||||
>
|
||||
{copy.review}
|
||||
</a>
|
||||
{composeHref ? (
|
||||
<a
|
||||
className="drb-link-button drb-link-button--primary"
|
||||
href={composeHref}
|
||||
>
|
||||
{copy.compose}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
const defaultLabels: PlaybookLabels = {
|
||||
save: 'Save',
|
||||
saved: 'Saved',
|
||||
addFavorite: (title) => `Add ${title} to favorites`,
|
||||
removeFavorite: (title) => `Remove ${title} from favorites`,
|
||||
defaultAutonomy: 'Default autonomy',
|
||||
supportedRange: 'Supported range',
|
||||
rangeConnector: 'to',
|
||||
supportedTags: 'Supported tags',
|
||||
whyMatch: 'Why this matches',
|
||||
version: 'Version',
|
||||
autonomy: 'Autonomy',
|
||||
updated: 'Updated',
|
||||
status: 'Playbook status',
|
||||
matches: 'Matches',
|
||||
review: 'Review',
|
||||
compose: 'Compose',
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { classNames } from '../lib/class-names'
|
||||
import { LifecycleBadge, RiskBadge, SourceBadge } from '../status/badges'
|
||||
import type {
|
||||
PlaybookActions,
|
||||
PlaybookLabels,
|
||||
PlaybookPresentation,
|
||||
} from './playbook-types'
|
||||
|
||||
export interface PlaybookDenseRowProps
|
||||
extends PlaybookPresentation, PlaybookActions {
|
||||
readonly className?: string
|
||||
readonly labels?: PlaybookLabels
|
||||
}
|
||||
|
||||
export function PlaybookDenseRow({
|
||||
id,
|
||||
slug,
|
||||
title,
|
||||
outcome,
|
||||
category,
|
||||
type,
|
||||
riskTier,
|
||||
defaultAutonomy,
|
||||
lifecycle,
|
||||
source,
|
||||
version,
|
||||
updatedAt,
|
||||
favorite,
|
||||
matchReasons,
|
||||
detailHref,
|
||||
composeHref,
|
||||
favoriteBusy = false,
|
||||
onFavoriteChange,
|
||||
className,
|
||||
labels,
|
||||
}: PlaybookDenseRowProps) {
|
||||
const copy = labels ?? defaultLabels
|
||||
const titleId = `playbook-${id}-dense-title`
|
||||
return (
|
||||
<article
|
||||
className={classNames('drb-playbook-dense-row', className)}
|
||||
aria-labelledby={titleId}
|
||||
data-playbook-slug={slug}
|
||||
>
|
||||
<div className="drb-playbook-dense-row__identity">
|
||||
<p className="drb-playbook-dense-row__eyebrow">
|
||||
{category} / {type}
|
||||
</p>
|
||||
<h2 id={titleId}>
|
||||
<a href={detailHref}>{title}</a>
|
||||
</h2>
|
||||
<p>{outcome}</p>
|
||||
{matchReasons && matchReasons.length > 0 ? (
|
||||
<p className="drb-playbook-dense-row__match">
|
||||
{copy.matches}: {matchReasons.join('; ')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="drb-playbook-dense-row__badges" aria-label={copy.status}>
|
||||
<LifecycleBadge value={lifecycle} />
|
||||
<SourceBadge value={source} />
|
||||
<RiskBadge value={riskTier} />
|
||||
</div>
|
||||
|
||||
<dl className="drb-playbook-dense-row__facts">
|
||||
<div>
|
||||
<dt>{copy.autonomy}</dt>
|
||||
<dd>{defaultAutonomy}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{copy.version}</dt>
|
||||
<dd>{version}</dd>
|
||||
</div>
|
||||
{updatedAt ? (
|
||||
<div>
|
||||
<dt>{copy.updated}</dt>
|
||||
<dd>
|
||||
<time dateTime={updatedAt}>{updatedAt}</time>
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
|
||||
<div className="drb-playbook-dense-row__actions">
|
||||
{onFavoriteChange ? (
|
||||
<button
|
||||
type="button"
|
||||
className="drb-playbook-dense-row__favorite"
|
||||
aria-label={
|
||||
favorite ? copy.removeFavorite(title) : copy.addFavorite(title)
|
||||
}
|
||||
aria-pressed={favorite}
|
||||
aria-busy={favoriteBusy || undefined}
|
||||
disabled={favoriteBusy}
|
||||
onClick={() => onFavoriteChange(!favorite)}
|
||||
>
|
||||
<span aria-hidden="true">{favorite ? copy.saved : copy.save}</span>
|
||||
</button>
|
||||
) : null}
|
||||
<a
|
||||
className="drb-link-button drb-link-button--secondary"
|
||||
href={detailHref}
|
||||
>
|
||||
{copy.review}
|
||||
</a>
|
||||
{composeHref ? (
|
||||
<a
|
||||
className="drb-link-button drb-link-button--primary"
|
||||
href={composeHref}
|
||||
>
|
||||
{copy.compose}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
const defaultLabels: PlaybookLabels = {
|
||||
save: 'Save',
|
||||
saved: 'Saved',
|
||||
addFavorite: (title) => `Add ${title} to favorites`,
|
||||
removeFavorite: (title) => `Remove ${title} from favorites`,
|
||||
defaultAutonomy: 'Default autonomy',
|
||||
supportedRange: 'Supported range',
|
||||
rangeConnector: 'to',
|
||||
supportedTags: 'Supported tags',
|
||||
whyMatch: 'Why this matches',
|
||||
version: 'Version',
|
||||
autonomy: 'Autonomy',
|
||||
updated: 'Updated',
|
||||
status: 'Playbook status',
|
||||
matches: 'Matches',
|
||||
review: 'Review',
|
||||
compose: 'Compose',
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Lifecycle, PlaybookSource, RiskTier } from '../status/badges'
|
||||
|
||||
export interface PlaybookPresentation {
|
||||
readonly id: string
|
||||
readonly slug: string
|
||||
readonly title: string
|
||||
readonly outcome: string
|
||||
readonly category: string
|
||||
readonly type: string
|
||||
readonly riskTier: RiskTier
|
||||
readonly autonomyMinimum?: string
|
||||
readonly autonomyMaximum?: string
|
||||
readonly defaultAutonomy: string
|
||||
readonly tags: readonly string[]
|
||||
readonly lifecycle: Lifecycle
|
||||
readonly source: PlaybookSource
|
||||
readonly version: string
|
||||
readonly updatedAt?: string
|
||||
readonly favorite: boolean
|
||||
readonly matchReasons?: readonly string[]
|
||||
}
|
||||
|
||||
export interface PlaybookActions {
|
||||
readonly detailHref: string
|
||||
readonly composeHref?: string
|
||||
readonly favoriteBusy?: boolean
|
||||
readonly onFavoriteChange?: (favorite: boolean) => void
|
||||
}
|
||||
|
||||
export interface PlaybookLabels {
|
||||
readonly save: string
|
||||
readonly saved: string
|
||||
readonly addFavorite: (title: string) => string
|
||||
readonly removeFavorite: (title: string) => string
|
||||
readonly defaultAutonomy: string
|
||||
readonly supportedRange: string
|
||||
readonly rangeConnector: string
|
||||
readonly supportedTags: string
|
||||
readonly whyMatch: string
|
||||
readonly version: string
|
||||
readonly autonomy: string
|
||||
readonly updated: string
|
||||
readonly status: string
|
||||
readonly matches: string
|
||||
readonly review: string
|
||||
readonly compose: string
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||
|
||||
import { classNames } from '../lib/class-names'
|
||||
|
||||
export type ButtonVariant = 'primary' | 'secondary' | 'quiet' | 'danger'
|
||||
export type ButtonSize = 'small' | 'medium' | 'large'
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
readonly variant?: ButtonVariant
|
||||
readonly size?: ButtonSize
|
||||
readonly leadingIcon?: ReactNode
|
||||
readonly trailingIcon?: ReactNode
|
||||
readonly busy?: boolean
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = 'primary',
|
||||
size = 'medium',
|
||||
leadingIcon,
|
||||
trailingIcon,
|
||||
busy = false,
|
||||
disabled,
|
||||
className,
|
||||
children,
|
||||
type = 'button',
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
className={classNames(
|
||||
'drb-button',
|
||||
`drb-button--${variant}`,
|
||||
`drb-button--${size}`,
|
||||
className,
|
||||
)}
|
||||
disabled={disabled || busy}
|
||||
aria-busy={busy || undefined}
|
||||
{...props}
|
||||
>
|
||||
{leadingIcon ? (
|
||||
<span className="drb-button__icon" aria-hidden="true">
|
||||
{leadingIcon}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="drb-button__label">{children}</span>
|
||||
{trailingIcon ? (
|
||||
<span className="drb-button__icon" aria-hidden="true">
|
||||
{trailingIcon}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||
|
||||
import { classNames } from '../lib/class-names'
|
||||
import type { ButtonSize, ButtonVariant } from './button'
|
||||
|
||||
export interface IconButtonProps extends Omit<
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
'children'
|
||||
> {
|
||||
readonly label: string
|
||||
readonly icon: ReactNode
|
||||
readonly variant?: ButtonVariant
|
||||
readonly size?: ButtonSize
|
||||
readonly busy?: boolean
|
||||
}
|
||||
|
||||
export function IconButton({
|
||||
label,
|
||||
icon,
|
||||
variant = 'quiet',
|
||||
size = 'medium',
|
||||
busy = false,
|
||||
disabled,
|
||||
className,
|
||||
type = 'button',
|
||||
...props
|
||||
}: IconButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
className={classNames(
|
||||
'drb-icon-button',
|
||||
`drb-icon-button--${variant}`,
|
||||
`drb-icon-button--${size}`,
|
||||
className,
|
||||
)}
|
||||
aria-label={label}
|
||||
aria-busy={busy || undefined}
|
||||
disabled={disabled || busy}
|
||||
{...props}
|
||||
>
|
||||
<span aria-hidden="true">{icon}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { classNames } from '../lib/class-names'
|
||||
|
||||
export interface SegmentedControlOption<Value extends string> {
|
||||
readonly value: Value
|
||||
readonly label: string
|
||||
readonly disabled?: boolean
|
||||
}
|
||||
|
||||
export interface SegmentedControlProps<Value extends string> {
|
||||
readonly legend: string
|
||||
readonly name: string
|
||||
readonly value: Value
|
||||
readonly options: readonly SegmentedControlOption<Value>[]
|
||||
readonly onValueChange?: (value: Value) => void
|
||||
readonly className?: string
|
||||
readonly disabled?: boolean
|
||||
}
|
||||
|
||||
export function SegmentedControl<Value extends string>({
|
||||
legend,
|
||||
name,
|
||||
value,
|
||||
options,
|
||||
onValueChange,
|
||||
className,
|
||||
disabled = false,
|
||||
}: SegmentedControlProps<Value>) {
|
||||
return (
|
||||
<fieldset
|
||||
className={classNames('drb-segmented-control', className)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<legend className="drb-visually-hidden">{legend}</legend>
|
||||
{options.map((option) => (
|
||||
<label className="drb-segmented-control__option" key={option.value}>
|
||||
<input
|
||||
className="drb-segmented-control__input"
|
||||
type="radio"
|
||||
name={name}
|
||||
value={option.value}
|
||||
checked={option.value === value}
|
||||
disabled={option.disabled}
|
||||
onChange={() => onValueChange?.(option.value)}
|
||||
/>
|
||||
<span className="drb-segmented-control__label">{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { HTMLAttributes } from 'react'
|
||||
|
||||
import { classNames } from '../lib/class-names'
|
||||
|
||||
export interface SkeletonProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
readonly shape?: 'text' | 'rectangle' | 'circle'
|
||||
}
|
||||
|
||||
export function Skeleton({
|
||||
shape = 'text',
|
||||
className,
|
||||
...props
|
||||
}: SkeletonProps) {
|
||||
return (
|
||||
<span
|
||||
className={classNames(
|
||||
'drb-skeleton',
|
||||
`drb-skeleton--${shape}`,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { HTMLAttributes, ReactNode } from 'react'
|
||||
|
||||
import { classNames } from '../lib/class-names'
|
||||
|
||||
export type SurfaceElement = 'div' | 'section' | 'article' | 'aside'
|
||||
export type SurfaceElevation = 'flat' | 'raised' | 'overlay'
|
||||
|
||||
export interface SurfaceProps extends HTMLAttributes<HTMLElement> {
|
||||
readonly as?: SurfaceElement
|
||||
readonly elevation?: SurfaceElevation
|
||||
readonly children: ReactNode
|
||||
}
|
||||
|
||||
export function Surface({
|
||||
as: Component = 'div',
|
||||
elevation = 'flat',
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SurfaceProps) {
|
||||
return (
|
||||
<Component
|
||||
className={classNames(
|
||||
'drb-surface',
|
||||
`drb-surface--${elevation}`,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getLifecycleLabel, getRiskLabel, getSourceLabel } from './badges'
|
||||
|
||||
describe('badge labels', () => {
|
||||
it('provides complete human-readable labels for terse catalog values', () => {
|
||||
expect(getRiskLabel('moderate')).toBe('Moderate risk')
|
||||
expect(getLifecycleLabel('battle-tested')).toBe('Battle-tested')
|
||||
expect(getSourceLabel('built_in')).toBe('Built-in')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { HTMLAttributes } from 'react'
|
||||
|
||||
import { classNames } from '../lib/class-names'
|
||||
|
||||
export type RiskTier = 'low' | 'moderate' | 'high' | 'critical'
|
||||
export type Lifecycle =
|
||||
'draft' | 'reviewed' | 'validated' | 'battle-tested' | 'deprecated'
|
||||
export type PlaybookSource = 'built_in' | 'private' | 'imported'
|
||||
|
||||
const riskLabels: Readonly<Record<RiskTier, string>> = {
|
||||
low: 'Low risk',
|
||||
moderate: 'Moderate risk',
|
||||
high: 'High risk',
|
||||
critical: 'Critical risk',
|
||||
}
|
||||
|
||||
const lifecycleLabels: Readonly<Record<Lifecycle, string>> = {
|
||||
draft: 'Draft',
|
||||
reviewed: 'Reviewed',
|
||||
validated: 'Validated',
|
||||
'battle-tested': 'Battle-tested',
|
||||
deprecated: 'Deprecated',
|
||||
}
|
||||
|
||||
const sourceLabels: Readonly<Record<PlaybookSource, string>> = {
|
||||
built_in: 'Built-in',
|
||||
private: 'Private',
|
||||
imported: 'Imported',
|
||||
}
|
||||
|
||||
export function getRiskLabel(value: RiskTier): string {
|
||||
return riskLabels[value]
|
||||
}
|
||||
|
||||
export function getLifecycleLabel(value: Lifecycle): string {
|
||||
return lifecycleLabels[value]
|
||||
}
|
||||
|
||||
export function getSourceLabel(value: PlaybookSource): string {
|
||||
return sourceLabels[value]
|
||||
}
|
||||
|
||||
interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
readonly value: string
|
||||
readonly label: string
|
||||
readonly kind: 'risk' | 'lifecycle' | 'source'
|
||||
}
|
||||
|
||||
function Badge({ value, label, kind, className, ...props }: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={classNames(
|
||||
'drb-badge',
|
||||
`drb-badge--${kind}`,
|
||||
`drb-badge--${value.replaceAll('_', '-')}`,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function RiskBadge({
|
||||
value,
|
||||
...props
|
||||
}: { readonly value: RiskTier } & Omit<BadgeProps, 'kind' | 'label'>) {
|
||||
return (
|
||||
<Badge {...props} kind="risk" value={value} label={riskLabels[value]} />
|
||||
)
|
||||
}
|
||||
|
||||
export function LifecycleBadge({
|
||||
value,
|
||||
...props
|
||||
}: { readonly value: Lifecycle } & Omit<BadgeProps, 'kind' | 'label'>) {
|
||||
return (
|
||||
<Badge
|
||||
{...props}
|
||||
kind="lifecycle"
|
||||
value={value}
|
||||
label={lifecycleLabels[value]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function SourceBadge({
|
||||
value,
|
||||
...props
|
||||
}: { readonly value: PlaybookSource } & Omit<BadgeProps, 'kind' | 'label'>) {
|
||||
return (
|
||||
<Badge {...props} kind="source" value={value} label={sourceLabels[value]} />
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { classNames } from '../lib/class-names'
|
||||
import { Surface } from '../primitives/surface'
|
||||
|
||||
export type StatePanelTone = 'neutral' | 'info' | 'warning' | 'danger'
|
||||
|
||||
export interface StatePanelProps {
|
||||
readonly title: string
|
||||
readonly description: ReactNode
|
||||
readonly tone?: StatePanelTone
|
||||
readonly icon?: ReactNode
|
||||
readonly actions?: ReactNode
|
||||
readonly className?: string
|
||||
readonly live?: boolean
|
||||
}
|
||||
|
||||
export function StatePanel({
|
||||
title,
|
||||
description,
|
||||
tone = 'neutral',
|
||||
icon,
|
||||
actions,
|
||||
className,
|
||||
live = false,
|
||||
}: StatePanelProps) {
|
||||
return (
|
||||
<Surface
|
||||
as="section"
|
||||
className={classNames(
|
||||
'drb-state-panel',
|
||||
`drb-state-panel--${tone}`,
|
||||
className,
|
||||
)}
|
||||
role={tone === 'danger' ? 'alert' : live ? 'status' : undefined}
|
||||
aria-live={live && tone !== 'danger' ? 'polite' : undefined}
|
||||
>
|
||||
{icon ? (
|
||||
<div className="drb-state-panel__icon" aria-hidden="true">
|
||||
{icon}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="drb-state-panel__body">
|
||||
<h2 className="drb-state-panel__title">{title}</h2>
|
||||
<div className="drb-state-panel__description">{description}</div>
|
||||
{actions ? (
|
||||
<div className="drb-state-panel__actions">{actions}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Surface>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"jsx": "react-jsx",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
||||
}
|
||||
Reference in New Issue
Block a user