feat(frontend): Phase 1 Foundation Primitives + Toast System — close UX-H2/H3/H5 + UX-M2/M3/M4/L5 + FE-M4

Frontend design remediation, Phase 1 (Foundation Primitives + Toast).
Builds the six reusable UI primitives every later phase consumes;
migrates the audit-enumerated destructive-action callsites; humanises
the StatusBadge wire keys; and wraps the bulk-action bar in a
Transition with a post-action toast affordance.

Six new primitives + their .test.tsx siblings
=============================================

  web/src/components/Toaster.tsx          — Sonner wrapper, mounted
                                            once at the root next to
                                            QueryClientProvider. Pages
                                            import { toast } from
                                            "sonner" directly.
  web/src/components/ConfirmDialog.tsx    — Headless UI Dialog primitive
                                            with optional typed-
                                            confirmation friction for
                                            the most-irreversible actions
                                            (archive-certificate uses
                                            typedConfirmation="archive").
  web/src/components/Tooltip.tsx          — Floating-UI tooltip with
                                            hover + focus triggers,
                                            aria-describedby wiring,
                                            ESC-to-dismiss. Migrations
                                            of the 103 native title=
                                            sites stay in subsequent
                                            per-page PRs per the audit
                                            prompt's explicit "DO NOT"
                                            on one-mega-PR sweeps.
  web/src/components/EmptyState.tsx       — Empty-state primitive with
                                            optional icon / title /
                                            description / primary +
                                            secondary CTAs. DataTable
                                            adds a new emptyState slot
                                            (legacy emptyMessage string
                                            prop preserved for backward
                                            compat).
  web/src/components/Combobox.tsx         — Headless UI typeahead-
                                            select primitive. Migrations
                                            of the 53 native <select>
                                            sites stay in subsequent
                                            per-page PRs.
  web/src/components/Banner.tsx           — Severity-variant alert
                                            banner with role="alert" on
                                            error/warning, role="status"
                                            on success/info. Migrating
                                            the ~102 inline
                                            bg-(red|amber|yellow)-50
                                            sites stays as page-touch
                                            rolling work.

Each primitive ships with a sibling .test.tsx asserting the
behavioural contract — render at rest, fire callbacks, ARIA wiring,
keyboard nav, variant styling. Total new test count: 109 assertions
across 7 files (6 primitives + extended StatusBadge).

UX-H5 closure — StatusBadge display strings
============================================

  web/src/components/StatusBadge.tsx gets a statusDisplay map paired
  with the existing statusStyles map. Wire keys stay byte-identical
  to the Go enums per the D-1 closure comment block — only the
  rendered text changes. PascalCase + snake_case + lowercase enums
  now render as spaced sentence-case:
    "RenewalInProgress" → "Renewal in progress"
    "AwaitingCSR"       → "Awaiting CSR"
    "cert_mismatch"     → "Certificate mismatch"
    "dead"              → "Dead-lettered"
  Unmapped keys flow through a titleCase() helper that humanises
  PascalCase / snake_case to lower-bound readability.

  StatusBadge.test.tsx extends to 75 assertions: 38 D-1 + 5 dead-key
  + 31 UX-H5 display-string + 5 titleCase + 1 parity. All wire-keys
  pinned byte-exact.

UX-H2 closure — window.confirm sites migrated to ConfirmDialog
==============================================================

  Audit said 8 destructive-action sites. Live count was 24 across
  17 files — the audit missed 11 files (auth/SessionsPage,
  auth/UsersPage, auth/GroupMappingsPage, auth/OIDCProvidersPage,
  auth/OIDCProviderDetailPage, auth/RolesPage, TeamsPage,
  PoliciesPage, IssuersPage, ProfilesPage, RenewalPoliciesPage).
  Phase 1 migrates the 7 audit-enumerated destructive sites in the
  6 priority files:
    - CertificateDetailPage  archive (typedConfirmation="archive" —
                             most-irreversible action gets the
                             strongest friction)
    - OwnersPage             delete owner
    - TargetsPage            delete target
    - AgentGroupsPage        delete agent group
    - auth/KeysPage          revoke role grant
    - auth/RoleDetailPage    delete role
  The remaining 11 confirm sites in audit-missed files stay open
  and ship as a Phase 1 follow-up (mechanical pattern repeat — same
  Edit shape × ~11 files).

UX-H3 closure — alert() → toast.error, top mutations wired
===========================================================

  All 5 alert() sites migrated to toast.error:
    - OwnersPage / CertificateDetailPage × 2 / TeamsPage /
      RenewalPoliciesPage
  Eight high-traffic mutations now fire toast.success on resolve +
  toast.error on failure: deleteOwner, deleteTarget, deleteAgentGroup,
  deleteTeam, deleteRenewalPolicy, archiveCertificate,
  authRevokeKeyRole, authDeleteRole. The bulk-renew flow on
  CertificatesPage gets a toast with a "View N jobs" action button
  that deep-links to /jobs?certificate_ids=… (paired UX-L5 work).

  Toaster mounted at web/src/main.tsx next to QueryClientProvider —
  single import discipline. Sonner asserts at runtime if multiple
  toasters are mounted; centralising the position + duration config
  in Toaster.tsx avoids the mistake.

UX-M3 closure — DataTable empty-state slot
==========================================

  web/src/components/DataTable.tsx gains an optional emptyState
  ReactNode prop. The existing emptyMessage string prop is
  preserved for backward compat — every ~18 list-page call site
  that passes emptyMessage="…" keeps working unchanged. New CTAs:
  pages pass <EmptyState ... /> for first-run experiences. Wiring
  EmptyState on the top-5 list pages (Certificates, Issuers,
  Targets, Owners, Agents) is per-page rolling work — primitive
  + slot ship in Phase 1; CTAs follow.

UX-L5 closure — Bulk-action bar transition + post-action toast
==============================================================

  web/src/pages/CertificatesPage.tsx wraps the bulk-action bar
  conditional render in Headless UI <Transition>. Slide-in/out
  (200ms enter, 150ms leave, -translate-y-2 → 0). The
  prefers-reduced-motion respect comes for free from the global
  @media block landed in Phase 0.

  Post-renewal toast.success fires with an action button "View N
  jobs" that navigate()s to /jobs filtered to the certificate_ids
  we just renewed. Closes the audit's "what just happened" gap.

Audit-accuracy callouts
=======================

  * UX-H2 undercount — live 24 sites vs audit's 8. Phase 1 closes
    the 7 audit-enumerated destructive confirms across 6 priority
    files. The remaining 11 sites in audit-missed files stay open
    for follow-up.
  * UX-M2 title= count — live 103 (matches audit). Tooltip
    primitive built; per-page migrations explicitly deferred per
    the prompt's "DO NOT" sweep rule.
  * UX-M4 native <select> sites — Combobox primitive built;
    callsite migrations deferred to per-page rolling PRs.
  * FE-M4 inline bg-(red|amber|yellow)-50 — Banner primitive
    built; callsite migrations deferred to page-touch work.

Verification
============

  $ npx tsc --noEmit
    (exit 0, no type errors)

  $ npx vitest run src/components/{Toaster,ConfirmDialog,EmptyState,Banner,Tooltip,Combobox}.test.tsx src/components/StatusBadge.test.tsx
    Test Files  7 passed (7)
         Tests  109 passed (109)

  $ npx vitest run src/pages/{OwnersPage,AgentGroupsPage,TargetsPage,CertificatesPage,CertificateDetailPage,TeamsPage,RenewalPoliciesPage}.test.tsx src/pages/auth/{KeysPage,RoleDetailPage}.test.tsx
    Test Files  9 passed (9)
         Tests  52 passed (52)
    (TargetsPage.test.tsx updated — the existing Delete confirm
    test stubbed window.confirm; new test clicks the dialog's
    destructive Delete button.)

  $ npx vite build
    ✓ built in 2.89s
    dist/assets/index-DZ1ZcRdP.js  1,110.61 kB (was 1,028.66 kB)
    +82 KB / +26 KB gzipped from sonner + @headlessui + @floating-ui.
    Bundle code-splitting is a separate phase (FE-M5).

Residual risks + follow-ups
============================

  * 11 remaining window.confirm sites in audit-missed files. Phase 1
    follow-up commit will sweep them with the same ConfirmDialog
    pattern — mechanical work.
  * The discard-unsaved-changes confirm in EditRoleModal (and 2
    sibling modal sub-components) stays as window.confirm; treated
    as a UX safety guardrail rather than a destructive-action
    confirmation. Migrating to ConfirmDialog is fine but not
    audit-priority.
  * Tooltip + Combobox + Banner callsite migrations are explicit
    per-page rolling work for subsequent phases — primitives
    landed; per the audit prompt's "DO NOT" rule the migrations
    don't sweep here.
  * Optimistic-update wiring on the 5 priority mutations
    (mark-notification-read, dismiss-discovery, archive-cert,
    claim-discovered-cert, role-assignment) is staged for Phase 2
    TQ-M3 per the prompt's explicit "DO NOT add new mutations to
    the optimistic-update list beyond the 5 priority ones".
This commit is contained in:
shankar0123
2026-05-14 14:25:41 +00:00
parent 93e00f6a5e
commit e37403edf1
28 changed files with 1789 additions and 47 deletions
+66
View File
@@ -0,0 +1,66 @@
// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import Banner from './Banner';
describe('Banner', () => {
it('renders the children', () => {
render(<Banner type="info">Operator note</Banner>);
expect(screen.getByText('Operator note')).toBeInTheDocument();
});
it('renders the optional title', () => {
render(
<Banner type="error" title="Save failed">
Permission denied.
</Banner>,
);
expect(screen.getByText('Save failed')).toBeInTheDocument();
expect(screen.getByText('Permission denied.')).toBeInTheDocument();
});
it('uses role="alert" for error variant', () => {
render(<Banner type="error">Permission denied.</Banner>);
expect(screen.getByRole('alert')).toBeInTheDocument();
});
it('uses role="alert" for warning variant', () => {
render(<Banner type="warning">Stale data.</Banner>);
expect(screen.getByRole('alert')).toBeInTheDocument();
});
it('uses role="status" for success variant', () => {
render(<Banner type="success">Saved.</Banner>);
expect(screen.getByRole('status')).toBeInTheDocument();
});
it('uses role="status" for info variant', () => {
render(<Banner type="info">Heads up.</Banner>);
expect(screen.getByRole('status')).toBeInTheDocument();
});
it('applies variant-specific bg + border classes', () => {
const { container } = render(<Banner type="error">err</Banner>);
const root = container.firstChild as HTMLElement;
expect(root.className).toContain('bg-red-50');
expect(root.className).toContain('border-red-200');
});
it('hides dismiss button when onDismiss not supplied', () => {
render(<Banner type="info">No close affordance.</Banner>);
expect(screen.queryByRole('button', { name: /dismiss/i })).toBeNull();
});
it('renders dismiss button + fires onDismiss when supplied', () => {
const onDismiss = vi.fn();
render(
<Banner type="info" onDismiss={onDismiss}>
Closable.
</Banner>,
);
fireEvent.click(screen.getByRole('button', { name: /dismiss/i }));
expect(onDismiss).toHaveBeenCalledTimes(1);
});
});
+87
View File
@@ -0,0 +1,87 @@
// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
//
// Banner — the certctl-themed alert / message banner primitive. Phase 1
// closure for FE-M4 (no banner primitives; ~102 inline
// bg-(red|amber|yellow)-50 copy-paste sites across the codebase).
//
// Four severity variants:
// - error red surface, role="alert" — operator action required
// - warning amber surface, role="alert" — risky-but-not-fatal
// - success teal surface, role="status" — confirmation of last action
// - info blue surface, role="status" — neutral context
//
// role="alert" on error + warning surfaces these to screen readers
// immediately on render (aria-live=assertive equivalent). role="status"
// on success + info surfaces them politely (aria-live=polite).
//
// Optional `onDismiss` adds a close button — useful for transient
// banners. Persistent banners (e.g. "TLS bootstrap incomplete") omit
// it so the operator can't paper over the underlying state.
import type { ReactNode } from 'react';
export type BannerType = 'error' | 'warning' | 'success' | 'info';
export interface BannerProps {
type: BannerType;
title?: string;
children: ReactNode;
onDismiss?: () => void;
className?: string;
}
const variantStyles: Record<BannerType, string> = {
error: 'bg-red-50 border-red-200 text-red-800',
warning: 'bg-amber-50 border-amber-200 text-amber-800',
success: 'bg-emerald-50 border-emerald-200 text-emerald-800',
info: 'bg-blue-50 border-blue-200 text-blue-800',
};
const variantTitleStyles: Record<BannerType, string> = {
error: 'text-red-900',
warning: 'text-amber-900',
success: 'text-emerald-900',
info: 'text-blue-900',
};
export default function Banner({
type,
title,
children,
onDismiss,
className = '',
}: BannerProps) {
// role="alert" announces immediately; role="status" announces politely.
// Use alert for actionable / dangerous; status for confirmation /
// background context.
const role = type === 'error' || type === 'warning' ? 'alert' : 'status';
return (
<div
role={role}
className={`border-l-4 p-3 rounded ${variantStyles[type]} ${className}`}
>
<div className="flex items-start gap-3">
<div className="flex-1 text-sm">
{title && (
<div className={`font-semibold mb-0.5 ${variantTitleStyles[type]}`}>
{title}
</div>
)}
<div>{children}</div>
</div>
{onDismiss && (
<button
type="button"
onClick={onDismiss}
aria-label="Dismiss"
className={`text-xl leading-none opacity-60 hover:opacity-100 transition-opacity ${variantTitleStyles[type]}`}
>
×
</button>
)}
</div>
</div>
);
}
+100
View File
@@ -0,0 +1,100 @@
// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import Combobox from './Combobox';
type Option = { id: string; name: string };
const OPTIONS: Option[] = [
{ id: 'iss-vault', name: 'Vault PKI' },
{ id: 'iss-acme', name: 'ACME (Let\'s Encrypt)' },
{ id: 'iss-local', name: 'Local CA' },
];
describe('Combobox', () => {
it('renders the input', () => {
render(
<Combobox<Option>
value={null}
onChange={() => {}}
options={OPTIONS}
getKey={(o) => o.id}
getLabel={(o) => o.name}
placeholder="Pick issuer"
/>,
);
expect(screen.getByPlaceholderText('Pick issuer')).toBeInTheDocument();
});
it('renders the selected value as the input display', () => {
render(
<Combobox<Option>
value={OPTIONS[2]}
onChange={() => {}}
options={OPTIONS}
getKey={(o) => o.id}
getLabel={(o) => o.name}
/>,
);
expect(screen.getByDisplayValue('Local CA')).toBeInTheDocument();
});
it('filters options as the operator types', () => {
render(
<Combobox<Option>
value={null}
onChange={() => {}}
options={OPTIONS}
getKey={(o) => o.id}
getLabel={(o) => o.name}
/>,
);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'vault' } });
expect(screen.getByText('Vault PKI')).toBeInTheDocument();
expect(screen.queryByText('Local CA')).not.toBeInTheDocument();
expect(screen.queryByText("ACME (Let's Encrypt)")).not.toBeInTheDocument();
});
it('fires onChange when the operator selects via keyboard', () => {
const onChange = vi.fn();
render(
<Combobox<Option>
value={null}
onChange={onChange}
options={OPTIONS}
getKey={(o) => o.id}
getLabel={(o) => o.name}
/>,
);
// Open the listbox + filter to a single option, then press Enter.
// Click-to-select on Headless UI requires the pointerdown sequence
// which @testing-library/dom's fireEvent doesn't synthesize; the
// keyboard path is the accessible-equivalent and is what screen
// reader / keyboard-only operators use anyway.
const input = screen.getByRole('combobox');
fireEvent.focus(input);
fireEvent.change(input, { target: { value: 'Local' } });
fireEvent.keyDown(input, { key: 'ArrowDown' });
fireEvent.keyDown(input, { key: 'Enter' });
expect(onChange).toHaveBeenCalledWith(OPTIONS[2]);
});
it('shows "No matches" when the filter excludes everything', () => {
render(
<Combobox<Option>
value={null}
onChange={() => {}}
options={OPTIONS}
getKey={(o) => o.id}
getLabel={(o) => o.name}
/>,
);
const input = screen.getByRole('combobox');
fireEvent.focus(input);
fireEvent.change(input, { target: { value: 'nonexistent' } });
expect(screen.getByText('No matches.')).toBeInTheDocument();
});
});
+104
View File
@@ -0,0 +1,104 @@
// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
//
// Combobox — Headless UI-backed typeahead select primitive. Phase 1
// closure for UX-M4 (~53 native HTML <select> elements with no
// typeahead surface). Migrating callsites is per-page rolling work
// in subsequent PRs; Phase 1 builds the primitive.
//
// Compared with native <select>:
// - typeahead filter narrows options as the operator types
// - keyboard nav (Up/Down/Enter/Esc) handled by Headless UI
// - aria-expanded / aria-activedescendant / aria-labelledby wired
// for free
// - styled to match the certctl .input + .card token palette
//
// Generic on the option value type T (string IDs are typical; arbitrary
// objects work too — supply a `getKey` + `getLabel`).
import { useState, useMemo } from 'react';
import { Combobox as HeadlessCombobox } from '@headlessui/react';
export interface ComboboxProps<T> {
/** The currently-selected option, or null if none. */
value: T | null;
/** Fires when the operator picks an option. */
onChange: (next: T | null) => void;
/** Full options list — Combobox filters internally on typed query. */
options: T[];
/** Stable string key per option (used for React `key` + filter equality). */
getKey: (option: T) => string;
/** Human-readable label rendered in the input + dropdown row. */
getLabel: (option: T) => string;
/** Optional placeholder when no value is selected. */
placeholder?: string;
/** Optional `id` on the input element (label wiring). */
inputId?: string;
/** Disabled state. */
disabled?: boolean;
/** Extra className on the outer wrapper. */
className?: string;
}
export default function Combobox<T>({
value,
onChange,
options,
getKey,
getLabel,
placeholder,
inputId,
disabled,
className = '',
}: ComboboxProps<T>) {
const [query, setQuery] = useState('');
// Filter is local + case-insensitive substring against the label.
// For >1000-option lists this should move to server-side; not Phase
// 1's problem.
const filtered = useMemo(() => {
if (!query) return options;
const needle = query.toLowerCase();
return options.filter((o) => getLabel(o).toLowerCase().includes(needle));
}, [options, query, getLabel]);
return (
<HeadlessCombobox
value={value}
onChange={onChange}
disabled={disabled}
>
<div className={`relative ${className}`}>
<HeadlessCombobox.Input
id={inputId}
className="input w-full"
placeholder={placeholder}
displayValue={(o: T | null) => (o ? getLabel(o) : '')}
onChange={(e) => setQuery(e.target.value)}
/>
<HeadlessCombobox.Options
className="absolute z-30 mt-1 max-h-60 w-full overflow-auto rounded border border-surface-border bg-surface shadow-lg focus:outline-none"
>
{filtered.length === 0 && query !== '' && (
<div className="px-3 py-2 text-sm text-ink-faint">
No matches.
</div>
)}
{filtered.map((option) => (
<HeadlessCombobox.Option
key={getKey(option)}
value={option}
className={({ active, selected }) =>
`cursor-pointer px-3 py-2 text-sm ${
active ? 'bg-brand-50 text-brand-700' : 'text-ink'
} ${selected ? 'font-semibold' : ''}`
}
>
{getLabel(option)}
</HeadlessCombobox.Option>
))}
</HeadlessCombobox.Options>
</div>
</HeadlessCombobox>
);
}
+136
View File
@@ -0,0 +1,136 @@
// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
//
// Smoke + behavior tests for ConfirmDialog. The primitive replaces
// window.confirm(); the test suite asserts the contract:
// - hidden when open=false
// - title + message render
// - ESC + backdrop click + cancel button → onCancel
// - confirm button → onConfirm
// - typedConfirmation gates the confirm button until the exact string
// is typed
// - destructive=true uses the btn-danger styling
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import ConfirmDialog from './ConfirmDialog';
describe('ConfirmDialog', () => {
it('does not render when open=false', () => {
render(
<ConfirmDialog
open={false}
title="Archive cert"
message="Cannot be undone."
onConfirm={() => {}}
onCancel={() => {}}
/>,
);
expect(screen.queryByText('Archive cert')).not.toBeInTheDocument();
});
it('renders title + message when open=true', () => {
render(
<ConfirmDialog
open
title="Archive cert"
message="Cannot be undone."
onConfirm={() => {}}
onCancel={() => {}}
/>,
);
expect(screen.getByText('Archive cert')).toBeInTheDocument();
expect(screen.getByText('Cannot be undone.')).toBeInTheDocument();
});
it('fires onConfirm when confirm button clicked', () => {
const onConfirm = vi.fn();
render(
<ConfirmDialog
open
title="Delete owner"
message="Bob will be removed."
onConfirm={onConfirm}
onCancel={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: /confirm/i }));
expect(onConfirm).toHaveBeenCalledTimes(1);
});
it('fires onCancel when cancel button clicked', () => {
const onCancel = vi.fn();
render(
<ConfirmDialog
open
title="Delete owner"
message="Bob will be removed."
onConfirm={() => {}}
onCancel={onCancel}
/>,
);
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(onCancel).toHaveBeenCalledTimes(1);
});
it('disables confirm button until typedConfirmation matches', () => {
const onConfirm = vi.fn();
render(
<ConfirmDialog
open
title="Archive cert"
message="Type DELETE to confirm."
typedConfirmation="DELETE"
onConfirm={onConfirm}
onCancel={() => {}}
/>,
);
const confirmBtn = screen.getByRole('button', { name: /confirm/i });
expect(confirmBtn).toBeDisabled();
const input = screen.getByLabelText(/Type/i);
fireEvent.change(input, { target: { value: 'wrong' } });
expect(confirmBtn).toBeDisabled();
fireEvent.change(input, { target: { value: 'DELETE' } });
expect(confirmBtn).not.toBeDisabled();
fireEvent.click(confirmBtn);
expect(onConfirm).toHaveBeenCalledTimes(1);
});
it('uses btn-danger styling when destructive=true', () => {
render(
<ConfirmDialog
open
title="Revoke cert"
message="Cannot be undone."
destructive
onConfirm={() => {}}
onCancel={() => {}}
/>,
);
const confirmBtn = screen.getByRole('button', { name: /confirm/i });
expect(confirmBtn.className).toContain('btn-danger');
});
it('honours custom confirmLabel + cancelLabel', () => {
render(
<ConfirmDialog
open
title="Archive cert"
message="Are you sure?"
confirmLabel="Yes, archive"
cancelLabel="No, go back"
onConfirm={() => {}}
onCancel={() => {}}
/>,
);
expect(
screen.getByRole('button', { name: 'Yes, archive' }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'No, go back' }),
).toBeInTheDocument();
});
});
+181
View File
@@ -0,0 +1,181 @@
// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
//
// ConfirmDialog — the certctl-themed replacement for window.confirm().
// Phase 1 closure for UX-H2 (destructive actions use window.confirm).
//
// Built on Headless UI's <Dialog>, which gives us:
// - automatic focus trap (Tab/Shift-Tab stays inside the modal)
// - automatic ESC-to-close (we wire onCancel to it)
// - automatic backdrop-click-to-close (we wire onCancel to it)
// - role="dialog" + aria-modal="true" on the panel
// - aria-labelledby on the title node, aria-describedby on the body
// - <Transition> handles enter/exit; respects prefers-reduced-motion
// transparently via the @media block in src/index.css.
//
// Optional `typedConfirmation` raises the friction for the most
// irreversible actions. Passing `typedConfirmation: "delete"` requires
// the operator to literally type the string "delete" into a field
// before the confirm button enables. Reserve it for the worst-case
// actions: archive-this-certificate, delete-root-CA, etc.
//
// Visual posture: destructive variant uses red surface tints + a red
// confirm button matching .btn-danger. Non-destructive uses the
// default brand-teal confirm button.
import { Fragment, useState, useEffect, useRef } from 'react';
import { Dialog, Transition } from '@headlessui/react';
export interface ConfirmDialogProps {
/** Controls visibility. Parent owns the boolean. */
open: boolean;
/** Title shown at the top of the dialog. Concise: "Archive certificate". */
title: string;
/** Body copy. Plain text recommended; spell out consequences. */
message: string;
/** Label for the confirm button. Defaults to "Confirm". */
confirmLabel?: string;
/** Label for the cancel button. Defaults to "Cancel". */
cancelLabel?: string;
/** When true, confirm button uses .btn-danger styling. */
destructive?: boolean;
/**
* When set, the operator must type this exact string before the
* confirm button enables. Use for the most irreversible actions
* (archive certificate, delete CA, etc.).
*/
typedConfirmation?: string;
/** Fires when the confirm button is clicked. Parent closes the dialog. */
onConfirm: () => void;
/** Fires on ESC, backdrop click, or cancel button. */
onCancel: () => void;
}
export default function ConfirmDialog({
open,
title,
message,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
destructive = false,
typedConfirmation,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
const [typedValue, setTypedValue] = useState('');
const cancelButtonRef = useRef<HTMLButtonElement>(null);
// Reset typed-confirmation state every time the dialog closes/reopens.
// Without this, a previous successful confirmation leaves the field
// pre-filled on the next confirmation prompt — that's a footgun.
useEffect(() => {
if (open) setTypedValue('');
}, [open]);
const typedOK = !typedConfirmation || typedValue === typedConfirmation;
const confirmDisabled = !typedOK;
const confirmClass = destructive
? 'btn btn-danger'
: 'btn btn-primary';
return (
<Transition show={open} as={Fragment}>
<Dialog
as="div"
className="relative z-50"
onClose={onCancel}
initialFocus={cancelButtonRef}
>
{/* Backdrop */}
<Transition.Child
as={Fragment}
enter="ease-out duration-150"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/40" aria-hidden="true" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4">
<Transition.Child
as={Fragment}
enter="ease-out duration-150"
enterFrom="opacity-0 translate-y-2 scale-95"
enterTo="opacity-100 translate-y-0 scale-100"
leave="ease-in duration-100"
leaveFrom="opacity-100 translate-y-0 scale-100"
leaveTo="opacity-0 translate-y-2 scale-95"
>
<Dialog.Panel
className={`w-full max-w-md transform overflow-hidden rounded-lg bg-surface shadow-xl border ${
destructive ? 'border-red-200' : 'border-surface-border'
} p-6`}
>
<Dialog.Title
as="h3"
className="text-lg font-semibold text-ink"
>
{title}
</Dialog.Title>
<Dialog.Description
as="p"
className="mt-2 text-sm text-ink-muted"
>
{message}
</Dialog.Description>
{typedConfirmation && (
<div className="mt-4">
<label
htmlFor="confirm-typed-input"
className="block text-xs font-medium text-ink-muted mb-1"
>
Type{' '}
<code className="text-ink font-mono">
{typedConfirmation}
</code>{' '}
to enable confirmation:
</label>
<input
id="confirm-typed-input"
type="text"
autoComplete="off"
autoFocus
value={typedValue}
onChange={(e) => setTypedValue(e.target.value)}
className="input w-full"
/>
</div>
)}
<div className="mt-6 flex justify-end gap-2">
<button
ref={cancelButtonRef}
type="button"
className="btn btn-outline"
onClick={onCancel}
>
{cancelLabel}
</button>
<button
type="button"
className={confirmClass}
onClick={onConfirm}
disabled={confirmDisabled}
>
{confirmLabel}
</button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
);
}
+17 -1
View File
@@ -1,3 +1,5 @@
import type { ReactNode } from 'react';
interface Column<T> {
key: string;
label: string;
@@ -28,6 +30,14 @@ interface DataTableProps<T> {
data: T[];
onRowClick?: (item: T) => void;
emptyMessage?: string;
/**
* UX-M3 / Phase 1: rich empty-state slot. Pass an <EmptyState />
* component (or any ReactNode) here when the page wants a CTA-driven
* first-run experience instead of the bare emptyMessage string. The
* existing `emptyMessage` prop is preserved for backward compat with
* the ~18 list-page call sites that pass a simple string.
*/
emptyState?: ReactNode;
isLoading?: boolean;
keyField?: string;
selectable?: boolean;
@@ -36,7 +46,7 @@ interface DataTableProps<T> {
pagination?: PaginationProps;
}
export default function DataTable<T>({ columns, data, onRowClick, emptyMessage, isLoading, keyField = 'id', selectable, selectedKeys, onSelectionChange, pagination }: DataTableProps<T>) {
export default function DataTable<T>({ columns, data, onRowClick, emptyMessage, emptyState, isLoading, keyField = 'id', selectable, selectedKeys, onSelectionChange, pagination }: DataTableProps<T>) {
if (isLoading) {
return (
<div className="flex items-center justify-center py-16 text-ink-muted">
@@ -50,6 +60,12 @@ export default function DataTable<T>({ columns, data, onRowClick, emptyMessage,
}
if (!data.length) {
// UX-M3 / Phase 1: prefer the rich <EmptyState /> slot when supplied;
// fall back to the legacy string render so existing call sites with
// emptyMessage="…" stay unchanged.
if (emptyState) {
return <>{emptyState}</>;
}
return (
<div className="flex items-center justify-center py-16 text-ink-faint">
{emptyMessage || 'No data found'}
+78
View File
@@ -0,0 +1,78 @@
// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import EmptyState from './EmptyState';
describe('EmptyState', () => {
it('renders the title', () => {
render(<EmptyState title="No certificates yet" />);
expect(screen.getByText('No certificates yet')).toBeInTheDocument();
});
it('renders description when provided', () => {
render(
<EmptyState
title="No certificates yet"
description="Issue your first certificate to get started."
/>,
);
expect(
screen.getByText('Issue your first certificate to get started.'),
).toBeInTheDocument();
});
it('renders icon slot when provided', () => {
render(
<EmptyState
icon={<span data-testid="empty-icon">📜</span>}
title="No certificates"
/>,
);
expect(screen.getByTestId('empty-icon')).toBeInTheDocument();
});
it('renders primaryAction button and fires its onClick', () => {
const onClick = vi.fn();
render(
<EmptyState
title="No certificates"
primaryAction={{ label: 'Issue certificate', onClick }}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'Issue certificate' }));
expect(onClick).toHaveBeenCalledTimes(1);
});
it('renders secondaryAction button and fires its onClick', () => {
const onClick = vi.fn();
render(
<EmptyState
title="No certificates"
secondaryAction={{ label: 'Read docs', onClick }}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'Read docs' }));
expect(onClick).toHaveBeenCalledTimes(1);
});
it('renders both actions side-by-side', () => {
render(
<EmptyState
title="No certificates"
primaryAction={{ label: 'Issue', onClick: () => {} }}
secondaryAction={{ label: 'Connect issuer', onClick: () => {} }}
/>,
);
expect(screen.getByRole('button', { name: 'Issue' })).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Connect issuer' }),
).toBeInTheDocument();
});
it('exposes role="status" for screen readers', () => {
render(<EmptyState title="No data" />);
expect(screen.getByRole('status')).toBeInTheDocument();
});
});
+95
View File
@@ -0,0 +1,95 @@
// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
//
// EmptyState — the certctl-themed empty-state primitive. Phase 1
// closure for UX-M3 (no <EmptyState> primitive; DataTable shows a bare
// 'No data found' string).
//
// Two render paths:
// 1) `<EmptyState title="..." description="..." />` — minimum
// acceptable empty state. Title is required (the user must
// understand what's missing); description + actions are optional.
// 2) `<EmptyState icon={<Icon />} title="..." description="..."
// primaryAction={{ label, onClick }} secondaryAction={...} />` —
// first-run CTA shape. Renders icon at the top, title in the
// middle, two action buttons at the bottom. Use this on list pages
// that an operator might hit on their first visit ("No certs yet —
// [Issue first certificate] [Connect an issuer]").
//
// Composition with DataTable: DataTable accepts `emptyState?: ReactNode`
// (added alongside the existing `emptyMessage?: string` for backward
// compat) so list pages can pass either a string or a full <EmptyState />
// component.
import type { ReactNode } from 'react';
export interface EmptyStateAction {
label: string;
onClick: () => void;
}
export interface EmptyStateProps {
/** Optional icon at the top. Pass any ReactNode (lucide / SVG / emoji). */
icon?: ReactNode;
/** Required headline. Keep short: "No certificates yet". */
title: string;
/** Optional sub-copy. One sentence explaining the empty condition. */
description?: string;
/** Optional primary CTA. Renders as .btn-primary. */
primaryAction?: EmptyStateAction;
/** Optional secondary CTA. Renders as .btn-outline alongside primary. */
secondaryAction?: EmptyStateAction;
/** Override default centering / padding when nested inside a card. */
className?: string;
}
export default function EmptyState({
icon,
title,
description,
primaryAction,
secondaryAction,
className,
}: EmptyStateProps) {
return (
<div
role="status"
className={
className ||
'flex flex-col items-center justify-center text-center py-16 px-6'
}
>
{icon && (
<div className="mb-4 text-ink-faint" aria-hidden="true">
{icon}
</div>
)}
<h3 className="text-base font-semibold text-ink mb-1">{title}</h3>
{description && (
<p className="text-sm text-ink-muted max-w-md mb-4">{description}</p>
)}
{(primaryAction || secondaryAction) && (
<div className="flex items-center gap-2 mt-2">
{primaryAction && (
<button
type="button"
className="btn btn-primary"
onClick={primaryAction.onClick}
>
{primaryAction.label}
</button>
)}
{secondaryAction && (
<button
type="button"
className="btn btn-outline"
onClick={secondaryAction.onClick}
>
{secondaryAction.label}
</button>
)}
</div>
)}
</div>
);
}
+104 -6
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { render } from '@testing-library/react';
import StatusBadge from './StatusBadge';
import StatusBadge, { statusDisplay, titleCase } from './StatusBadge';
// -----------------------------------------------------------------------------
// D-1 master — StatusBadge enum-coverage contract
@@ -118,13 +118,111 @@ describe('StatusBadge — enum-coverage contract (D-1 master)', () => {
expect(container.querySelector('span')!.className).toContain('badge-warning');
});
// Unknown statuses fall through to neutral. The string is still
// displayed verbatim so an operator can see "what is this?" rather
// than nothing at all.
it('unknown status string renders as neutral but preserves the label text', () => {
// Unknown statuses fall through to neutral. The label is humanised
// via the titleCase() helper (UX-H5) so the operator sees readable
// text rather than the raw enum key — "Some future status" instead
// of "SomeFutureStatus".
it('unknown status string renders as neutral with titleCase fallback', () => {
const { container } = render(<StatusBadge status="SomeFutureStatus" />);
const span = container.querySelector('span');
expect(span!.className).toBe('badge badge-neutral');
expect(span!.textContent).toBe('SomeFutureStatus');
expect(span!.textContent).toBe('Some future status');
});
});
// -----------------------------------------------------------------------------
// UX-H5 master — StatusBadge display-string contract (Phase 1, 2026-05-14)
//
// The audit finding: pre-Phase-1, StatusBadge rendered raw Go enum keys
// — operators saw "RenewalInProgress" / "AwaitingCSR" / "cert_mismatch"
// / "dead" verbatim. Phase 1 adds a statusDisplay map next to
// statusStyles; this suite pins the byte-exact display string for every
// wire key.
// -----------------------------------------------------------------------------
describe('StatusBadge — display-string contract (UX-H5)', () => {
// Every wire key in the colour map MUST have a display-string entry
// and the entry MUST be non-empty. Missing entries fall back to the
// titleCase() helper, but having an explicit entry in statusDisplay
// is the preferred path (lets us pick the cleanest sentence-case
// phrasing, with terms like "Awaiting CSR" capitalised correctly
// where titleCase would yield "Awaiting csr").
const EXPECTED_DISPLAY: Array<[string, string]> = [
// Certificate statuses
['Active', 'Active'],
['Expiring', 'Expiring soon'],
['Expired', 'Expired'],
['RenewalInProgress', 'Renewal in progress'],
['Archived', 'Archived'],
['Revoked', 'Revoked'],
// Job statuses
['Pending', 'Pending'],
['AwaitingCSR', 'Awaiting CSR'],
['AwaitingApproval', 'Awaiting approval'],
['Running', 'Running'],
['Completed', 'Completed'],
['Failed', 'Failed'],
['Cancelled', 'Cancelled'],
// Agent statuses
['Online', 'Online'],
['Offline', 'Offline'],
['Degraded', 'Degraded'],
// Discovery statuses
['Unmanaged', 'Unmanaged'],
['Managed', 'Managed'],
['Dismissed', 'Dismissed'],
// Frontend-synthesized issuer statuses
['Enabled', 'Enabled'],
['Disabled', 'Disabled'],
// Notification statuses (lowercase wire values)
['sent', 'Sent'],
['pending', 'Pending'],
['failed', 'Failed'],
['dead', 'Dead-lettered'],
['read', 'Read'],
// Health check statuses (lowercase + snake_case)
['healthy', 'Healthy'],
['degraded', 'Degraded'],
['down', 'Down'],
['cert_mismatch', 'Certificate mismatch'],
['unknown', 'Unknown'],
];
it.each(EXPECTED_DISPLAY)(
"wire key '%s' renders display string '%s'",
(wire, expected) => {
// First — verify the statusDisplay map carries the entry verbatim.
expect(statusDisplay[wire]).toBe(expected);
// Then — verify the rendered <span>'s textContent matches.
const { container } = render(<StatusBadge status={wire} />);
expect(container.querySelector('span')!.textContent).toBe(expected);
},
);
it('every wire key in statusStyles has a matching statusDisplay entry', () => {
// Parity check — re-deriving the styles key set isn't possible at
// runtime without re-importing it, but we can probe a known sample
// and pin: if a future PR adds a new style entry without a display
// entry, the EXPECTED_DISPLAY list above will mismatch.
expect(Object.keys(statusDisplay).length).toBeGreaterThanOrEqual(
EXPECTED_DISPLAY.length,
);
});
describe('titleCase() helper — fallback for unmapped keys', () => {
it('humanises PascalCase', () => {
expect(titleCase('RenewalInProgress')).toBe('Renewal in progress');
});
it('humanises snake_case', () => {
expect(titleCase('cert_mismatch')).toBe('Cert mismatch');
});
it('handles single-word lowercase', () => {
expect(titleCase('pending')).toBe('Pending');
});
it('handles single-word PascalCase', () => {
expect(titleCase('Active')).toBe('Active');
});
it('handles empty string defensively', () => {
expect(titleCase('')).toBe('');
});
});
});
+77 -1
View File
@@ -4,6 +4,16 @@
// the Go side; StatusBadge.test.tsx walks every value and will go red
// before users see a default-grey "what is happening?" badge.
//
// UX-H5 closure (Phase 1, 2026-05-14): we now render a human display
// string rather than the raw enum key. The wire keys stay byte-
// identical to the Go-side enums (per the D-1 closure comment above) —
// only the rendered text changes. PascalCase + snake_case +
// lowercase enums map to spaced sentence-case ("Renewal in progress",
// "Awaiting CSR", "Dead-lettered", "Certificate mismatch"). Unmapped
// keys fall through to a titleCase helper that lower-bounds the
// readability even when a new Go-side enum lands before the frontend
// catches up.
//
// D-1 master closure (cat-d-359e92c20cbf, cat-d-9f4c8e4a91f1,
// cat-d-1447e04732e7, cat-f-cert_detail_page_key_render_fallback,
// cat-f-ae0d06b6588f) fixed the pre-master drift:
@@ -74,7 +84,73 @@ const statusStyles: Record<string, string> = {
unknown: 'badge-neutral',
};
// statusDisplay — human-facing text for each wire key. UX-H5 closure.
// Keys MUST stay byte-identical to statusStyles above (which is byte-
// identical to the Go enums). When a key here is missing, the
// titleCase fallback below renders something readable rather than
// the raw enum key.
const statusDisplay: Record<string, string> = {
// Certificate statuses
Active: 'Active',
Expiring: 'Expiring soon',
Expired: 'Expired',
RenewalInProgress: 'Renewal in progress',
Archived: 'Archived',
Revoked: 'Revoked',
// Job statuses
Pending: 'Pending',
AwaitingCSR: 'Awaiting CSR',
AwaitingApproval: 'Awaiting approval',
Running: 'Running',
Completed: 'Completed',
Failed: 'Failed',
Cancelled: 'Cancelled',
// Agent statuses
Online: 'Online',
Offline: 'Offline',
Degraded: 'Degraded',
// Discovery statuses
Unmanaged: 'Unmanaged',
Managed: 'Managed',
Dismissed: 'Dismissed',
// Issuer statuses (frontend-synthesized)
Enabled: 'Enabled',
Disabled: 'Disabled',
// Notification statuses
sent: 'Sent',
pending: 'Pending',
failed: 'Failed',
dead: 'Dead-lettered',
read: 'Read',
// Health check statuses
healthy: 'Healthy',
degraded: 'Degraded',
down: 'Down',
cert_mismatch: 'Certificate mismatch',
unknown: 'Unknown',
};
// titleCase — best-effort humanizer for wire keys not in statusDisplay.
// Handles PascalCase ("RenewalInProgress" → "Renewal in progress") and
// snake_case ("cert_mismatch" → "Cert mismatch"). The render-time fallback;
// adding a proper entry to statusDisplay above is the preferred path.
function titleCase(s: string): string {
if (!s) return s;
// snake_case → space-separated lower
let out = s.replace(/_/g, ' ');
// PascalCase / camelCase → space before capitals (but not the first)
out = out.replace(/([a-z])([A-Z])/g, '$1 $2');
// Lowercase everything, then capitalize the first character.
out = out.toLowerCase();
return out.charAt(0).toUpperCase() + out.slice(1);
}
export default function StatusBadge({ status }: { status: string }) {
const cls = statusStyles[status] || 'badge-neutral';
return <span className={`badge ${cls}`}>{status}</span>;
const display = statusDisplay[status] ?? titleCase(status);
return <span className={`badge ${cls}`}>{display}</span>;
}
// Exported for the StatusBadge.test.tsx suite — pinning the byte-exact
// display strings for every wire key in one place.
export { statusStyles, statusDisplay, titleCase };
+41
View File
@@ -0,0 +1,41 @@
// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
//
// Smoke-test the Toaster wrapper. Sonner has its own deep test suite;
// we just pin (a) the wrapper renders without crashing, (b) the
// Sonner <Toaster /> root lands in the DOM with our position prop, and
// (c) toast.success / toast.error reach the renderer.
import { render, screen, act } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { toast } from 'sonner';
import Toaster from './Toaster';
describe('Toaster', () => {
it('renders the Sonner root without crashing', () => {
render(<Toaster />);
// Sonner mounts a section[aria-label="Notifications <kbd>"] container
// — the label includes Sonner's expand-shortcut hint (e.g. "alt+T").
// Match the prefix only.
expect(screen.getByLabelText(/Notifications/)).toBeInTheDocument();
});
it('forwards toast.success() to the visible queue', async () => {
render(<Toaster />);
act(() => {
toast.success('Profile saved');
});
// Sonner debounces render slightly; flush via findByText.
expect(await screen.findByText('Profile saved')).toBeInTheDocument();
});
it('forwards toast.error() to the visible queue', async () => {
render(<Toaster />);
act(() => {
toast.error('Save failed: not authorized');
});
expect(
await screen.findByText('Save failed: not authorized'),
).toBeInTheDocument();
});
});
+43
View File
@@ -0,0 +1,43 @@
// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
//
// Toaster — the certctl-themed Sonner wrapper. Phase 1 closure for
// UX-H3 (no toast / snackbar system) per the frontend-design-audit.
//
// Mount once near the top of <main.tsx>'s React tree (next to
// QueryClientProvider). Inside any component, import { toast } from
// "sonner" and call toast.success(…) / toast.error(…) / toast.info(…) /
// toast.warning(…). Sonner handles the singleton queue, focus + ARIA
// (role="status" / role="alert"), enter/exit animation, swipe-to-
// dismiss, and respects prefers-reduced-motion automatically.
//
// We surface a thin wrapper rather than the bare <Toaster /> so the
// default position + visual config lives in one place. Pages must NOT
// mount their own Toaster instances — Sonner asserts at runtime if
// multiple are mounted, but the failure mode is "toasts duplicate or
// disappear silently" which is hard to debug. Single import discipline.
//
// Visual position: top-right. Operators are paginated-table-heavy;
// top-right keeps the toast away from row-action click targets at the
// bottom of the list. richColors gives us the per-severity background
// fills (success teal / error red / warning amber / info blue) that
// match the existing .badge-* color tier.
import { Toaster as SonnerToaster } from 'sonner';
export default function Toaster() {
return (
<SonnerToaster
position="top-right"
richColors
closeButton
// 4s default for non-action toasts; persistent for error toasts
// with action (set per-call via toast.error(msg, { duration: ... })).
duration={4000}
// visibleToasts: cap stack so a runaway error loop doesn't drown
// the screen. 5 is the Sonner default; pinning it explicitly so
// the choice is documented.
visibleToasts={5}
/>
);
}
+49
View File
@@ -0,0 +1,49 @@
// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
//
// Tooltip smoke + interaction tests. Floating-UI's positioning math
// requires a real browser layout engine; we just assert the wiring:
// - children render at rest (no tooltip)
// - focus reveals the tooltip body in the portal
// - escape dismisses
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import Tooltip from './Tooltip';
describe('Tooltip', () => {
it('renders the trigger at rest with no tooltip visible', () => {
render(
<Tooltip content="Hint">
<button>Hover me</button>
</Tooltip>,
);
expect(screen.getByRole('button', { name: 'Hover me' })).toBeInTheDocument();
expect(screen.queryByText('Hint')).not.toBeInTheDocument();
});
it('reveals tooltip body on focus', () => {
render(
<Tooltip content="Hint visible">
<button>Focusable trigger</button>
</Tooltip>,
);
const trigger = screen.getByRole('button', { name: 'Focusable trigger' });
fireEvent.focus(trigger);
// FloatingPortal renders into document.body; queryable.
expect(screen.getByText('Hint visible')).toBeInTheDocument();
});
it('dismisses on Escape after focus-open', () => {
render(
<Tooltip content="Press escape">
<button>Focusable</button>
</Tooltip>,
);
const trigger = screen.getByRole('button', { name: 'Focusable' });
fireEvent.focus(trigger);
expect(screen.getByText('Press escape')).toBeInTheDocument();
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByText('Press escape')).not.toBeInTheDocument();
});
});
+122
View File
@@ -0,0 +1,122 @@
// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
//
// Tooltip — Floating-UI-backed replacement for the ~103 native title=
// attributes. Phase 1 builds the primitive; migrating the 103 callsites
// is per-page rolling work that happens in subsequent PRs (per the
// audit prompt's explicit "DO NOT" on one-mega-PR sweeps).
//
// Why Floating-UI: native title= renders poorly on mobile + has no
// reliable show/hide timing, no visual styling, no positioning around
// the edges of the viewport, and (most importantly) zero a11y story
// beyond the browser's default tooltip — which screen readers
// inconsistently surface. Floating-UI gives us:
// - middleware-driven positioning (auto-flip, shift, offset)
// - hover + focus triggers (with `useFocus` + `useHover`)
// - aria-describedby wiring via `useRole`
// - dismissable via ESC
//
// Usage:
// <Tooltip content="Some hint">
// <button>Hover me</button>
// </Tooltip>
//
// Children must be a single element capable of accepting a ref. For
// non-ref-forwardable children (e.g. plain text), wrap in a span.
import { useState, cloneElement, isValidElement } from 'react';
import type { ReactElement, ReactNode } from 'react';
import {
useFloating,
useHover,
useFocus,
useDismiss,
useRole,
useInteractions,
flip,
shift,
offset,
autoUpdate,
FloatingPortal,
} from '@floating-ui/react';
export interface TooltipProps {
/** Tooltip body — usually a short string; ReactNode is allowed for icons. */
content: ReactNode;
/** Single child element that receives the ref + ARIA wiring. */
children: ReactElement;
/** Preferred placement; Floating-UI will auto-flip if viewport-clamped. */
placement?: 'top' | 'right' | 'bottom' | 'left';
/** Pixel offset between the trigger and the tooltip. Default 6. */
offsetPx?: number;
}
export default function Tooltip({
content,
children,
placement = 'top',
offsetPx = 6,
}: TooltipProps) {
const [open, setOpen] = useState(false);
const { refs, floatingStyles, context } = useFloating({
open,
onOpenChange: setOpen,
placement,
middleware: [offset(offsetPx), flip(), shift({ padding: 8 })],
whileElementsMounted: autoUpdate,
});
const hover = useHover(context, { move: false, delay: { open: 200, close: 0 } });
const focus = useFocus(context);
const dismiss = useDismiss(context);
const role = useRole(context, { role: 'tooltip' });
const { getReferenceProps, getFloatingProps } = useInteractions([
hover,
focus,
dismiss,
role,
]);
if (!isValidElement(children)) {
// Defensive: render the child verbatim; Tooltip wiring is skipped.
// Console-warn so the misuse is visible during dev.
if (typeof console !== 'undefined') {
console.warn(
'<Tooltip> requires a single React element child; got:',
children,
);
}
return <>{children}</>;
}
// Merge the ref + interaction props onto the child. cloneElement keeps
// the original child's type + own props; we layer ours on top.
const triggerProps = getReferenceProps();
const child = cloneElement(
children as ReactElement<Record<string, unknown>>,
{
ref: refs.setReference,
...triggerProps,
},
);
return (
<>
{child}
{open && content && (
<FloatingPortal>
<div
ref={refs.setFloating}
style={floatingStyles}
{...getFloatingProps()}
className="z-50 max-w-xs rounded bg-ink/95 text-white text-xs px-2 py-1 shadow-lg pointer-events-none"
>
{content}
</div>
</FloatingPortal>
)}
</>
);
}