+ );
+}
+
+/**
+ * Assessment & Plan with orders nested under each problem via the durable
+ * concern link. Hover a problem and use the **+** action to add an order
+ * inline. Drag a problem block to re-sequence the assessment; drag an order
+ * to reorder it within its plan β or drop it onto another problem (or between
+ * its orders) to move it there. The TSH and chest X-ray orders arrive
+ * unlinked β drag them onto a problem or use the chips in the amber bucket.
+ */
+export const Interactive: Story = {
+ render: () => ,
+};
+
+/** Assessment only β plan hidden. */
+export const AssessmentOnly: Story = {
+ args: {
+ concerns,
+ items: sampleItems,
+ orders: sampleOrders,
+ showPlan: false,
+ readOnly: true,
+ title: 'Assessment',
+ },
+ render: (args) => (
+
+
+
+ ),
+};
+
+/** Display-only A&P, e.g. for a signed note. */
+export const ReadOnly: Story = {
+ args: {
+ concerns,
+ items: sampleItems,
+ orders: sampleOrders.filter((o) => o.concernId),
+ readOnly: true,
+ },
+ render: (args) => (
+
+
+
+ ),
+};
diff --git a/src/components/Assessment/Assessment.tsx b/src/components/Assessment/Assessment.tsx
new file mode 100644
index 00000000..8530ca7b
--- /dev/null
+++ b/src/components/Assessment/Assessment.tsx
@@ -0,0 +1,1162 @@
+'use client';
+
+import * as React from 'react';
+import { cn } from '../../utils/cn';
+import { Badge } from '../Badge/Badge';
+import { Button } from '../Button';
+import { Tooltip } from '../Tooltip';
+import { Card, CardHeader, CardContent } from '../Card/Card';
+import {
+ PencilIcon,
+ RefreshIcon,
+ LinkIcon,
+ PlusIcon,
+ PillIcon,
+ TestTubeIcon,
+ ScanIcon,
+ StethoscopeIcon,
+ SendIcon,
+ AlertCircleIcon,
+ GripVerticalIcon,
+ ChevronUpIcon,
+ ChevronDownIcon,
+ MoreVerticalIcon,
+} from '../Icons';
+import { Dropdown, DropdownItem, DropdownSeparator } from '../Dropdown';
+import {
+ CodingChips,
+ toolbarKeyNav,
+ type ConditionAssertion,
+ type ConditionConcern,
+} from '../ProblemList';
+import {
+ useDragReorder,
+ dragIndicatorClasses,
+ reorderIds,
+} from '../../hooks/useDragReorder';
+import { useLiveAnnouncement } from '../../hooks/useLiveAnnouncement';
+
+// =============================================================================
+// Types
+// =============================================================================
+
+/** Order categories that can nest under an assessed problem. */
+export type OrderType =
+ | 'medication'
+ | 'lab'
+ | 'imaging'
+ | 'procedure'
+ | 'referral';
+
+/**
+ * An order placed this visit. Links to a problem via concernId (durable
+ * IndicationLink) β orders without a concernId collect in the unlinked bucket.
+ */
+export interface AssessmentOrder {
+ orderId: string;
+ type: OrderType;
+ /** Display text, e.g. "insulin glargine 10 units qhs" */
+ display: string;
+ /** Durable link to the problem this order is for */
+ concernId?: string;
+ /** Secondary display text, e.g. status or sig */
+ detail?: string;
+ /** Coding from the code lookup, when the order was picked from it */
+ code?: { fullid: string; codetype: string; fullcode: string };
+}
+
+/** A code picked from a lookup (structurally matches CodifyResult). */
+export interface OrderCodePick {
+ fullid: string;
+ label: string;
+ codetype: string;
+ fullcode: string;
+}
+
+/** What the unified add row hands back: a coded pick or free text. */
+export interface AssessmentAddPick {
+ label: string;
+ /** Absent for free-text entries */
+ code?: { fullid: string; codetype: string; fullcode: string };
+}
+
+/** Infer the order type from the picked code's coding system. */
+export function orderTypeForCodetype(codetype: string): OrderType {
+ const ct = codetype.toUpperCase();
+ if (['RXNORM', 'FDB', 'FDB MEDNAME', 'NDC', 'CVX'].includes(ct)) {
+ return 'medication';
+ }
+ if (ct.startsWith('LOINC') || ct.endsWith('ORDER')) return 'lab';
+ return 'procedure'; // HCPCS, ICD10PCS, β¦ (imaging is a manual choice)
+}
+
+/** Coding systems that represent a diagnosis/problem rather than an order. */
+export function isConditionCodetype(codetype: string): boolean {
+ const ct = codetype.toUpperCase();
+ return ct === 'ICD10' || ct === 'ICD9' || ct === 'SNOMED US';
+}
+
+/** Code-lookup domains to search for an order-type filter. */
+export const ORDER_TYPE_SEARCH_DOMAINS: Record = {
+ medication: ['med', 'vaccine'],
+ lab: ['lab'],
+ imaging: ['procedure'],
+ procedure: ['procedure'],
+ referral: ['procedure'],
+};
+
+/** Context-specific search placeholder per order-type filter. */
+const ORDER_TYPE_PLACEHOLDERS: Record<'auto' | OrderType, string> = {
+ auto: 'Search medications, labs, imaging, proceduresβ¦',
+ medication: 'Search medications⦠(try "lasix")',
+ lab: 'Search labs⦠(try "a1c")',
+ imaging: 'Search imaging⦠(try "chest x")',
+ procedure: 'Search proceduresβ¦',
+ referral: 'Search referrals & consultsβ¦',
+};
+
+/** One assessed problem this visit. */
+export interface AssessmentItem {
+ concernId: string;
+ /** Today's assertion for the concern (even a no-change "addressed today" one) */
+ assertionId: string;
+ note?: string;
+}
+
+/** Row actions on an assessed problem. */
+export type AssessmentAction = 'refine' | 'revise' | 'add-order';
+
+export interface AssessmentProps extends Omit<
+ React.HTMLAttributes,
+ 'className' | 'title'
+> {
+ /** Concerns addressed this visit (with their assertion history) */
+ concerns: ConditionConcern[];
+ /** Assessment entries β one per assessed concern (controlled) */
+ items: AssessmentItem[];
+ /** Orders placed this visit */
+ orders?: AssessmentOrder[];
+ /** Header title (pass null to hide) */
+ title?: string | null;
+ /** Show the plan (orders nested under each problem). Default true. */
+ showPlan?: boolean;
+ /** Called when the plan toggle changes (omit to hide the toggle) */
+ onShowPlanChange?: (show: boolean) => void;
+ /** Called when a row action is clicked */
+ onAction?: (item: AssessmentItem, action: AssessmentAction) => void;
+ /**
+ * Called when a new order is created from the inline order form.
+ * Enables the "Add order" affordances β `item` is null when the order is
+ * added from the unlinked bucket. The new order should come back in via
+ * `orders` (with `concernId` set to the item's concern when linked).
+ */
+ onAddOrder?: (
+ item: AssessmentItem | null,
+ order: { type: OrderType; display: string; code?: AssessmentOrder['code'] }
+ ) => void;
+ /**
+ * Called when a diagnosis (coded or free text) is added as a new problem
+ * via the unified add row. Enables it together with renderOrderSearch.
+ */
+ onAddAssessment?: (pick: AssessmentAddPick) => void;
+ /**
+ * Render a code-lookup search for the add-order / add-problem forms
+ * (dependency-injected so the library build doesn't bundle the lookup's
+ * worker β pass e.g. a CodeLookup wired to your index). `domains` reflects
+ * the user's order-type filter ('auto' = undefined = search everything) and
+ * `placeholder` is context-specific; call `onPick` with the chosen code.
+ * Omit to fall back to free-text order entry.
+ */
+ renderOrderSearch?: (args: {
+ domains?: string[];
+ placeholder: string;
+ onPick: (pick: OrderCodePick) => void;
+ /** Present when free-text entry is supported in this context */
+ onFreeText?: (text: string) => void;
+ }) => React.ReactNode;
+ /** Called when an unlinked order is linked to a problem β also used when an
+ * order is dragged onto another problem (re-link = move) */
+ onLinkOrder?: (order: AssessmentOrder, concernId: string) => void;
+ /**
+ * Called with the full new order-id sequence after an order is dragged to a
+ * new position β enables drag & drop reordering of orders within a plan.
+ * Orders render in `orders` array order per problem; persist and feed back.
+ */
+ onReorderOrders?: (orderIds: string[]) => void;
+ /**
+ * Called with the new concernId order after a problem block is dragged β
+ * enables drag & drop reordering of the assessment. The list renders in
+ * `items` order; persist and feed the order back in.
+ */
+ onReorderItems?: (concernIds: string[]) => void;
+ /** Hide all controls (display only) */
+ readOnly?: boolean;
+ /** Additional CSS classes */
+ className?: string;
+ /** Test ID for testing */
+ 'data-testid'?: string;
+}
+
+// =============================================================================
+// Constants
+// =============================================================================
+
+export const ORDER_TYPE_META: Record<
+ OrderType,
+ { label: string; icon: React.ComponentType<{ size?: number | string }> }
+> = {
+ medication: { label: 'Medication', icon: PillIcon },
+ lab: { label: 'Lab', icon: TestTubeIcon },
+ imaging: { label: 'Imaging', icon: ScanIcon },
+ procedure: { label: 'Procedure', icon: StethoscopeIcon },
+ referral: { label: 'Referral', icon: SendIcon },
+};
+
+const ACTION_META: Record<
+ AssessmentAction,
+ { label: string; icon: React.ComponentType<{ size?: number | string }> }
+> = {
+ refine: { label: 'Refine (more specific)', icon: PencilIcon },
+ revise: { label: 'Revise (prior was wrong)', icon: RefreshIcon },
+ 'add-order': { label: 'Add order', icon: PlusIcon },
+};
+
+// =============================================================================
+// Sub-components
+// =============================================================================
+
+function OrderContent({ order }: { order: AssessmentOrder }) {
+ const meta = ORDER_TYPE_META[order.type];
+ const Icon = meta.icon;
+ return (
+
+
+
+
+ {order.display}
+ {order.detail && {order.detail}}
+
+ );
+}
+
+/** Drag state shared by the order rows and the problem-block drop targets. */
+interface OrderDrag {
+ draggingId: string | null;
+ over:
+ | { type: 'order'; id: string; after: boolean }
+ | { type: 'concern'; concernId: string }
+ | null;
+ rowProps: (order: AssessmentOrder) => React.HTMLAttributes;
+ enabled: boolean;
+}
+
+/** Keyboard + menu controls for an order row (the non-pointer path for DnD). */
+interface OrderControls {
+ /** Reorder within the current plan (Alt+β/β, menu) */
+ moveWithin?: (order: AssessmentOrder, dir: -1 | 1) => void;
+ /** Re-link to another problem (Alt+β/β, menu) */
+ moveToProblem?: (order: AssessmentOrder, concernId: string) => void;
+ /** The problem before/after the order's current one, if any */
+ adjacentProblem: (
+ order: AssessmentOrder,
+ dir: -1 | 1
+ ) => { concernId: string; label: string } | null;
+ /** All problems (for the Move to⦠menu) */
+ targets: { concernId: string; label: string }[];
+}
+
+function OrderRow({
+ order,
+ drag,
+ controls,
+}: {
+ order: AssessmentOrder;
+ drag: OrderDrag;
+ controls?: OrderControls;
+}) {
+ const interactive = Boolean(controls?.moveWithin || controls?.moveToProblem);
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.target !== e.currentTarget || !controls) return;
+ if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
+ e.preventDefault();
+ controls.moveWithin?.(order, e.key === 'ArrowUp' ? -1 : 1);
+ } else if (e.altKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
+ e.preventDefault();
+ const adj = controls.adjacentProblem(
+ order,
+ e.key === 'ArrowLeft' ? -1 : 1
+ );
+ if (adj) controls.moveToProblem?.(order, adj.concernId);
+ } else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
+ // Roving focus between order rows in the same plan
+ e.preventDefault();
+ const list = e.currentTarget.closest('ul');
+ if (!list) return;
+ const rows = Array.from(
+ list.querySelectorAll('li[data-order-id]')
+ );
+ const i = rows.indexOf(e.currentTarget as HTMLElement);
+ rows[
+ e.key === 'ArrowUp'
+ ? Math.max(0, i - 1)
+ : Math.min(rows.length - 1, i + 1)
+ ]?.focus();
+ }
+ };
+
+ return (
+ // Order rows are focus stops so the drag interactions have a full keyboard
+ // equivalent: Alt+β/β reorders, Alt+β/β moves between problems (508).
+ /* eslint-disable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/no-noninteractive-tabindex */
+
+ );
+ })}
+
+
+ {/* Unified add row: a dx pick adds a problem, anything else adds an
+ (unlinked) order β auto-detected from the coding system, or
+ forced via the mode dropdown. Free text asks (in auto mode). */}
+ {!readOnly &&
+ renderOrderSearch &&
+ (onAddAssessment || onAddOrder) && (
+
+ )}
+
+ {/* Unlinked orders bucket β only takes space once something is in it */}
+ {showPlan && unlinkedOrders.length > 0 && (
+
+
+
+ Orders not linked to a problem
+
+
+ {unlinkedOrders.map((order) => (
+ // Same row component as linked orders: focusable, Alt+arrow
+ // keyboard moves, and the Move menu (508 parity with drag)
+
+ ))}
+
+
+ );
+ }
+);
+
+Assessment.displayName = 'Assessment';
+
+export default Assessment;
diff --git a/src/components/Assessment/index.ts b/src/components/Assessment/index.ts
new file mode 100644
index 00000000..437d82ac
--- /dev/null
+++ b/src/components/Assessment/index.ts
@@ -0,0 +1,14 @@
+export {
+ Assessment,
+ ORDER_TYPE_META,
+ ORDER_TYPE_SEARCH_DOMAINS,
+ orderTypeForCodetype,
+ isConditionCodetype,
+ type AssessmentProps,
+ type AssessmentItem,
+ type AssessmentOrder,
+ type AssessmentAction,
+ type OrderType,
+ type OrderCodePick,
+ type AssessmentAddPick,
+} from './Assessment';
diff --git a/src/components/CodeLookup/CodeLookup.stories.tsx b/src/components/CodeLookup/CodeLookup.stories.tsx
new file mode 100644
index 00000000..8bb2cab0
--- /dev/null
+++ b/src/components/CodeLookup/CodeLookup.stories.tsx
@@ -0,0 +1,97 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { useState } from 'react';
+import { CodeLookup } from './CodeLookup';
+import type { CodifyResult } from './engine';
+
+const meta: Meta = {
+ title: 'Healthcare/CodeLookup',
+ component: CodeLookup,
+ parameters: {
+ layout: 'padded',
+ docs: {
+ description: {
+ component: `
+**Offline medical-code autocomplete (proof of concept)** over the full MedicalCodify_search
+dataset (~770K entries: ICD-10, SNOMED, RxNorm, FDB, LOINC, HCPCS, ICD-10-PCS, CVX,
+Quest/LabCorp orders).
+
+- Pre-built binary index shards are fetched once and searched **entirely in a Web Worker** β
+ no server round-trips per keystroke, works offline.
+- **Every token is a word prefix**: \`con hea fa\` β *Congestive heart failure*.
+- **Aliases** are indexed on the documents at build time: \`chf\`, \`lvhf\`, \`lasix\` β
+ \`furosemide\`, \`a1c\` β \`hba1c\`, \`tylenol\` β \`acetaminophen\`β¦
+- **Typo fallback**: a token that matches nothing retries with edit-distance-1 candidates
+ (\`congestve\`, \`furosemid\`).
+- **Usage priors**: frequently used codes (top-200 meds/diagnoses/procedures sample) rank
+ above rare ones with equal text relevance.
+- **Locales**: shards are built per locale under \`/codify/{locale}/\`; use the π Language
+ toolbar to switch. The \`es\` set is a curated sample (common diagnoses + med ingredients β
+ try *insuficiencia card*, *hta*, *paracetamol*).
+- **OPFS persistence**: shards are cached in the browser's origin-private file system and
+ refetched only when the served manifest changes.
+
+Shards are committed via git-lfs and served from \`.storybook/public/codify/{locale}/\`;
+rebuild with \`pnpm codify:build\` (pipeline lives in the \`packages/codify\` submodule).
+
+π Full architecture documentation β build pipeline, .mcdx binary format, scoring, priors,
+aliases, locales, drill-down β in [src/components/CodeLookup/README.md](https://github.com/mieweb/ui/blob/healthcare-clinical-components/src/components/CodeLookup/README.md).
+ `,
+ },
+ },
+ },
+ tags: ['autodocs'],
+};
+
+export default meta;
+type Story = StoryObj;
+
+function Template({
+ domains,
+ locale,
+}: {
+ domains?: ('condition' | 'med' | 'lab' | 'procedure' | 'vaccine')[];
+ locale?: string;
+}) {
+ const [selected, setSelected] = useState(null);
+ return (
+
+
+ {selected && (
+
+ {JSON.stringify(selected, null, 2)}
+
+ )}
+
+ );
+}
+
+/** All domains (~81 MB of shards β loads in the background, then searches in ms). */
+export const AllDomains: Story = {
+ render: (_args, { globals }) => ,
+};
+
+/** Conditions only (ICD-10 + SNOMED, ~14 MB). Try "con hea fa", "chf", "lvhf". */
+export const ConditionsOnly: Story = {
+ render: (_args, { globals }) => (
+
+ ),
+};
+
+/** Medications only. Try "lasix" (shows furosemide too) or "tylenol". */
+export const MedsOnly: Story = {
+ render: (_args, { globals }) => (
+
+ ),
+};
+
+/** Labs only. Try "a1c" or "cbc". */
+export const LabsOnly: Story = {
+ render: (_args, { globals }) => (
+
+ ),
+};
diff --git a/src/components/CodeLookup/CodeLookup.tsx b/src/components/CodeLookup/CodeLookup.tsx
new file mode 100644
index 00000000..7f4939b1
--- /dev/null
+++ b/src/components/CodeLookup/CodeLookup.tsx
@@ -0,0 +1,554 @@
+'use client';
+
+/**
+ * CodeLookup β offline medical-code autocomplete combobox.
+ *
+ * Full pipeline & design docs:
+ * https://github.com/mieweb/ui/blob/main/src/components/CodeLookup/README.md
+ * (local: ./README.md)
+ */
+
+import * as React from 'react';
+import { cn } from '../../utils/cn';
+import { Card, CardContent } from '../Card/Card';
+import {
+ SearchIcon,
+ LoaderIcon,
+ AlertCircleIcon,
+ ChevronRightIcon,
+ ChevronLeftIcon,
+} from '../Icons';
+import type { CodifyResult } from './engine';
+
+// =============================================================================
+// Types
+// =============================================================================
+
+export type CodifyDomain =
+ | 'condition'
+ | 'med'
+ | 'lab'
+ | 'procedure'
+ | 'vaccine';
+
+export interface CodeLookupProps extends Omit<
+ React.HTMLAttributes,
+ 'className' | 'onSelect'
+> {
+ /** Base URL where per-locale index directories are served */
+ indexUrl: string;
+ /**
+ * Locale of the shard set to load: shards are fetched from
+ * `{indexUrl}/{locale}/manifest.json`. Non-en locales may be sample
+ * subsets (see README). Default: 'en'.
+ */
+ locale?: string;
+ /** Domains to load & search (default: all in the manifest) */
+ domains?: CodifyDomain[];
+ /**
+ * Restrict searches to these domains at query time without reloading
+ * shards (e.g. an order-type filter). Default: all loaded domains.
+ */
+ searchDomains?: CodifyDomain[];
+ /** Called when a result is picked */
+ onSelect?: (result: CodifyResult) => void;
+ /**
+ * Allow free-text entry: called when the user presses Enter without a
+ * highlighted result, or clicks the "use as free text" footer row.
+ */
+ onFreeText?: (text: string) => void;
+ /** Max results to show */
+ limit?: number;
+ placeholder?: string;
+ /**
+ * Render just the search input + dropdown (no card, no status line) for
+ * embedding in forms. Loading/error state shows in the placeholder.
+ */
+ bare?: boolean;
+ /**
+ * Clear the input after a selection so the next entry can be typed
+ * immediately (defaults to true in bare mode, false otherwise).
+ */
+ clearOnSelect?: boolean;
+ /** Additional CSS classes */
+ className?: string;
+ /** Test ID for testing */
+ 'data-testid'?: string;
+}
+
+type Status =
+ | { state: 'loading'; pct: number }
+ | { state: 'ready'; docCount: number }
+ | { state: 'error'; message: string };
+
+/** Inverse of the domains-array β key encoding ('' = explicitly none). */
+function keyToDomains(key: string | null): string[] | undefined {
+ return key === null ? undefined : key === '' ? [] : key.split(',');
+}
+
+const DOMAIN_TEXT: Record = {
+ condition: 'text-primary-600 dark:text-primary-400',
+ med: 'text-emerald-700 dark:text-emerald-400',
+ lab: 'text-amber-700 dark:text-amber-400',
+ procedure: 'text-violet-700 dark:text-violet-400',
+ vaccine: 'text-muted-foreground',
+};
+
+// =============================================================================
+// CodeLookup
+// =============================================================================
+
+/**
+ * Offline medical-code autocomplete (proof of concept). Loads pre-built
+ * MedicalCodify index shards in a Web Worker and searches them locally:
+ * every typed token is a word prefix ("con hea fa" β Congestive heart
+ * failure), curated aliases are indexed on the documents (CHF, Lasix β
+ * furosemide, A1C β HbA1c), and single-token typos fall back to an
+ * edit-distance-1 dictionary scan.
+ */
+export const CodeLookup = React.forwardRef(
+ (
+ {
+ indexUrl,
+ locale = 'en',
+ domains,
+ searchDomains,
+ onSelect,
+ onFreeText,
+ limit = 15,
+ placeholder = 'Search conditions, meds, labs⦠(try "con hea fa", "chf", "lasix")',
+ bare = false,
+ clearOnSelect,
+ className,
+ 'data-testid': dataTestId,
+ ...props
+ },
+ ref
+ ) => {
+ const [status, setStatus] = React.useState({
+ state: 'loading',
+ pct: 0,
+ });
+ const [query, setQuery] = React.useState('');
+ const [results, setResults] = React.useState([]);
+ const [tookMs, setTookMs] = React.useState(null);
+ const [activeIndex, setActiveIndex] = React.useState(-1);
+ const [open, setOpen] = React.useState(false);
+ /** Drill-down into a medication's forms & strengths (β on a med row) */
+ const [drill, setDrill] = React.useState<{
+ parent: CodifyResult;
+ results: CodifyResult[] | null; // null = loading
+ } | null>(null);
+ const workerRef = React.useRef(null);
+ const searchIdRef = React.useRef(0);
+ const drillIdRef = React.useRef(0);
+ /** Set when a pick writes the query β suppresses the follow-up auto-search */
+ const skipSearchRef = React.useRef(false);
+ // Per-instance element ids so multiple CodeLookups on a page don't break
+ // aria-controls / aria-activedescendant relationships.
+ const baseId = React.useId();
+ const resultsId = `${baseId}-results`;
+ const optionId = (i: number) => `${baseId}-option-${i}`;
+
+ // domain keys keep effects stable without array identity; null means the
+ // prop wasn't provided (search/load everything) β distinct from an
+ // explicitly empty array, which means "none"
+ const domainsKey = domains ? domains.join(',') : null;
+ const searchDomainsKey = searchDomains ? searchDomains.join(',') : null;
+
+ React.useEffect(() => {
+ const worker = new Worker(
+ new URL('./codify.worker.ts', import.meta.url),
+ {
+ type: 'module',
+ }
+ );
+ workerRef.current = worker;
+ worker.onmessage = (e: MessageEvent) => {
+ const msg = e.data;
+ if (msg.type === 'progress') {
+ setStatus({
+ state: 'loading',
+ pct: Math.round((msg.loadedBytes / msg.totalBytes) * 100),
+ });
+ } else if (msg.type === 'ready') {
+ setStatus({ state: 'ready', docCount: msg.docCount });
+ } else if (msg.type === 'error') {
+ setStatus({ state: 'error', message: msg.message });
+ } else if (msg.type === 'results') {
+ if (msg.id === searchIdRef.current) {
+ setResults(msg.results);
+ setTookMs(msg.tookMs);
+ setActiveIndex(-1);
+ setOpen(true);
+ } else if (msg.id === drillIdRef.current) {
+ // forms & strengths: detailed med entries (contain a number),
+ // excluding the name row itself, alphabetical
+ const forms = (msg.results as CodifyResult[])
+ .filter(
+ (r) =>
+ r.domain === 'med' &&
+ /\d/.test(r.label) &&
+ (r.codetype === 'FDB' || r.codetype === 'RxNORM')
+ )
+ .sort((a, b) => a.label.localeCompare(b.label));
+ setDrill((prev) => (prev ? { ...prev, results: forms } : prev));
+ setActiveIndex(forms.length > 0 ? 0 : -1);
+ }
+ }
+ };
+ worker.postMessage({
+ type: 'load',
+ baseUrl: `${indexUrl.replace(/\/+$/, '')}/${locale}`,
+ domains: keyToDomains(domainsKey),
+ });
+ return () => worker.terminate();
+ }, [indexUrl, locale, domainsKey]);
+
+ // debounced search-as-you-type
+ React.useEffect(() => {
+ if (status.state !== 'ready') return;
+ if (skipSearchRef.current) {
+ skipSearchRef.current = false;
+ return;
+ }
+ const q = query.trim();
+ if (!q) {
+ setResults([]);
+ setTookMs(null);
+ setOpen(false);
+ setDrill(null);
+ return;
+ }
+ const t = setTimeout(() => {
+ const id = ++searchIdRef.current;
+ workerRef.current?.postMessage({
+ type: 'search',
+ id,
+ query: q,
+ limit,
+ domains: keyToDomains(searchDomainsKey),
+ });
+ }, 40);
+ return () => clearTimeout(t);
+ }, [query, status.state, limit, searchDomainsKey]);
+
+ /** A row that can be drilled into (β): medication names */
+ const isDrillable = (r: CodifyResult) => r.domain === 'med';
+
+ const openDrill = React.useCallback((parent: CodifyResult) => {
+ setDrill({ parent, results: null });
+ // Search the parent's name; aliases make Lasix β furosemide forms work.
+ const id = --drillIdRef.current; // negative ids: never collide with searches
+ workerRef.current?.postMessage({
+ type: 'search',
+ id,
+ query: parent.label,
+ limit: 200,
+ domains: ['med'],
+ });
+ }, []);
+
+ const closeDrill = () => {
+ setDrill(null);
+ setActiveIndex(-1);
+ };
+
+ const list: CodifyResult[] = drill ? (drill.results ?? []) : results;
+
+ const pick = (r: CodifyResult) => {
+ onSelect?.(r);
+ setOpen(false);
+ setDrill(null);
+ setResults([]);
+ setActiveIndex(-1);
+ if (clearOnSelect ?? bare) {
+ // ready for the next entry
+ setQuery('');
+ } else {
+ skipSearchRef.current = true;
+ setQuery(r.label);
+ }
+ };
+
+ const submitFreeText = () => {
+ const text = query.trim();
+ if (!text || !onFreeText) return;
+ onFreeText(text);
+ setOpen(false);
+ setDrill(null);
+ setResults([]);
+ setActiveIndex(-1);
+ if (clearOnSelect ?? bare) setQuery('');
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ setOpen(true);
+ setActiveIndex((i) => Math.min(list.length - 1, i + 1));
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ setActiveIndex((i) => Math.max(-1, i - 1));
+ } else if (
+ e.key === 'ArrowRight' &&
+ !drill &&
+ activeIndex >= 0 &&
+ list[activeIndex] &&
+ isDrillable(list[activeIndex])
+ ) {
+ e.preventDefault();
+ openDrill(list[activeIndex]);
+ } else if (e.key === 'ArrowLeft' && drill) {
+ e.preventDefault();
+ closeDrill();
+ } else if (e.key === 'Enter' && activeIndex >= 0 && list[activeIndex]) {
+ e.preventDefault();
+ pick(list[activeIndex]);
+ } else if (e.key === 'Enter' && onFreeText && query.trim()) {
+ e.preventDefault();
+ submitFreeText();
+ } else if (e.key === 'Escape') {
+ e.preventDefault();
+ if (drill) closeDrill();
+ else if (open) setOpen(false);
+ else setQuery('');
+ }
+ };
+
+ const effectivePlaceholder =
+ status.state === 'loading'
+ ? `Loading offline index⦠${status.pct}%`
+ : status.state === 'error'
+ ? 'Code index unavailable'
+ : placeholder;
+
+ // Single source of truth for dropdown visibility β also drives aria-expanded
+ const dropdownOpen =
+ open &&
+ (Boolean(drill) ||
+ list.length > 0 ||
+ Boolean(onFreeText && query.trim() !== ''));
+
+ const searchBox = (
+
+ ))}
+
+
+ );
+}
+
+// =============================================================================
+// ConditionEditor
+// =============================================================================
+
+/**
+ * Dialog editor for condition assertions β the condition analog of the
+ * planned MedicationEditor. Capture-first: only the problem name is required;
+ * coding, severity, and onset are progressive enrichment. Every optional
+ * field carries the Β§4.1 three-state affordance (confident / uncertain /
+ * explicitly unknown), written to the assertion's uncertainty block.
+ *
+ * Modes:
+ * - `add` β new concern, first assertion
+ * - `observe` β quick progress note on the concern; shows the full observation
+ * history over time
+ * - `refine` β new assertion, changeType 'refinement' (or 'progression')
+ * - `revise` β new assertion, changeType 'revision'; warns that the prior
+ * assertion will be refuted
+ * - `relate` β picks a relationship type + target concern
+ */
+export function ConditionEditor({
+ mode,
+ open,
+ onOpenChange,
+ concern,
+ relatableConcerns = [],
+ onSave,
+ onRelate,
+ onAddObservation,
+ className,
+ 'data-testid': dataTestId,
+}: ConditionEditorProps) {
+ const prior = concern ? currentAssertion(concern) : undefined;
+
+ const [text, setText] = React.useState('');
+ const [coding, setCoding] = React.useState([]);
+ const [verification, setVerification] =
+ React.useState('confirmed');
+ const [severity, setSeverity] =
+ React.useState();
+ const [onsetDate, setOnsetDate] = React.useState('');
+ const [onsetFuzzy, setOnsetFuzzy] = React.useState('');
+ const [note, setNote] = React.useState('');
+ const [progression, setProgression] = React.useState(false);
+ const [fields, setFields] = React.useState<
+ Partial>
+ >({});
+ const [relType, setRelType] =
+ React.useState('caused-by');
+ const [relTarget, setRelTarget] = React.useState('');
+ const [observation, setObservation] = React.useState('');
+
+ // Seed from the prior assertion whenever the dialog opens (or the mode /
+ // concern changes while open β stale values must not carry over)
+ React.useEffect(() => {
+ if (!open) return;
+ setText(prior?.text ?? '');
+ setCoding(prior?.coding ?? []);
+ setVerification(
+ mode === 'add'
+ ? 'unconfirmed'
+ : (prior?.verificationStatus ?? 'confirmed')
+ );
+ setSeverity(prior?.severity);
+ setOnsetDate(prior?.onset?.date ?? '');
+ setOnsetFuzzy(prior?.onset?.fuzzy ?? '');
+ setNote('');
+ setProgression(false);
+ setFields(prior?.uncertainty?.fields ?? {});
+ setRelType('caused-by');
+ setRelTarget('');
+ setObservation('');
+ }, [open, mode, prior]);
+
+ const setField =
+ (field: UncertainConditionField) => (f: FieldUncertainty | undefined) =>
+ setFields((prev) => {
+ const next = { ...prev };
+ if (f) next[field] = f;
+ else delete next[field];
+ return next;
+ });
+
+ const updateCoding = (i: number, patch: Partial) =>
+ setCoding((prev) => prev.map((c, j) => (j === i ? { ...c, ...patch } : c)));
+
+ const handleSave = () => {
+ if (mode === 'relate') {
+ if (!relTarget) return;
+ onRelate?.({ type: relType, concernId: relTarget });
+ onOpenChange(false);
+ return;
+ }
+ if (mode === 'observe') {
+ if (!observation.trim()) return;
+ onAddObservation?.(observation.trim());
+ onOpenChange(false);
+ return;
+ }
+ if (!text.trim()) return;
+ if (observation.trim()) onAddObservation?.(observation.trim());
+ const uncertainty: Uncertainty | undefined =
+ Object.keys(fields).length > 0 ? { fields } : undefined;
+ onSave?.({
+ text: text.trim(),
+ // write the trimmed values back β whitespace in codes breaks equality
+ // checks downstream. A field marked explicitly unknown must not carry a
+ // value (e.g. one seeded from the prior assertion): value + "unknown"
+ // would be contradictory, so unknown wins and the value is dropped.
+ coding:
+ fields.coding?.known === false
+ ? []
+ : coding
+ .filter((c) => c.code.trim())
+ .map((c) => ({
+ ...c,
+ code: c.code.trim(),
+ display: c.display?.trim() || undefined,
+ mappedFrom: c.mappedFrom?.trim() || undefined,
+ })),
+ verificationStatus: verification,
+ changeType:
+ mode === 'refine'
+ ? progression
+ ? 'progression'
+ : 'refinement'
+ : MODE_META[mode].changeType,
+ supersedes: prior?.id,
+ severity: fields.severity?.known === false ? undefined : severity,
+ onset:
+ fields.onset?.known === false
+ ? undefined
+ : onsetDate || onsetFuzzy
+ ? { date: onsetDate || undefined, fuzzy: onsetFuzzy || undefined }
+ : undefined,
+ uncertainty,
+ note: note.trim() || undefined,
+ });
+ onOpenChange(false);
+ };
+
+ const saveDisabled =
+ mode === 'relate'
+ ? !relTarget
+ : mode === 'observe'
+ ? !observation.trim()
+ : !text.trim();
+
+ return (
+
+
+
+ {MODE_META[mode].title}
+ {prior ? ` β ${prior.text}` : ''}
+
+
+
+
+ {mode === 'revise' && (
+
+
+
+ Revising marks the prior assertion refuted (it
+ was never true) β history is preserved on the timeline. Use{' '}
+ Refine instead if the prior was correct but less
+ specific.
+
+
+ )}
+
+ {mode === 'relate' ? (
+ <>
+
+
+
+
+
+ {mode !== 'relate' && mode !== 'observe' && !text.trim() && (
+
+ name required
+
+ )}
+
+
+ );
+}
+
+ConditionEditor.displayName = 'ConditionEditor';
+
+export default ConditionEditor;
diff --git a/src/components/ConditionEditor/index.ts b/src/components/ConditionEditor/index.ts
new file mode 100644
index 00000000..c2bfb714
--- /dev/null
+++ b/src/components/ConditionEditor/index.ts
@@ -0,0 +1,6 @@
+export {
+ ConditionEditor,
+ type ConditionEditorProps,
+ type ConditionEditorMode,
+ type ConditionAssertionDraft,
+} from './ConditionEditor';
diff --git a/src/components/Icons/index.ts b/src/components/Icons/index.ts
index 1f92055c..dca1397f 100644
--- a/src/components/Icons/index.ts
+++ b/src/components/Icons/index.ts
@@ -58,6 +58,12 @@ export {
Share as ShareIcon,
Save as SaveIcon,
RefreshCw as RefreshIcon,
+ ThumbsUp as ThumbsUpIcon,
+ ThumbsDown as ThumbsDownIcon,
+ Ban as BanIcon,
+ ArrowUpDown as ArrowUpDownIcon,
+ GripVertical as GripVerticalIcon,
+ StickyNote as StickyNoteIcon,
} from 'lucide-react';
// Status & Feedback
@@ -113,6 +119,7 @@ export {
// Time & Calendar
export {
Calendar as CalendarIcon,
+ CalendarCheck as CalendarCheckIcon,
Clock as ClockIcon,
Timer as TimerIcon,
History as HistoryIcon,
diff --git a/src/components/MedicationList/MedicationList.stories.tsx b/src/components/MedicationList/MedicationList.stories.tsx
new file mode 100644
index 00000000..e387a9d6
--- /dev/null
+++ b/src/components/MedicationList/MedicationList.stories.tsx
@@ -0,0 +1,220 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { useState } from 'react';
+import {
+ MedicationList,
+ MEDICATION_STATUS_LABELS,
+ type Medication,
+ type MedicationAction,
+ type MedicationStatus,
+} from './MedicationList';
+
+const meta: Meta = {
+ title: 'Healthcare/MedicationList',
+ component: MedicationList,
+ parameters: {
+ layout: 'padded',
+ docs: {
+ description: {
+ component: `
+Presenting-medications list with **medication reconciliation**.
+
+- Medications group themselves by reconciliation status (Unreconciled, Taking as Directed, Not Taking as Directed, Not Taking, Unknown).
+- Hover (or keyboard-focus) a row to reveal the status buttons and other row actions.
+- Fully controlled: pass \`medications\` and handle \`onStatusChange\` / \`onAction\`.
+ `,
+ },
+ },
+ },
+ tags: ['autodocs'],
+};
+
+export default meta;
+type Story = StoryObj;
+
+const sampleMedications: Medication[] = [
+ { id: '1', name: 'calcium 500 mg tablet', status: 'unreconciled' },
+ {
+ id: '2',
+ name: 'Lasix 20 mg tablet',
+ sig: '1 tablet by mouth weekly; take if weight greater than 149 pounds',
+ status: 'unreconciled',
+ },
+ {
+ id: '3',
+ name: 'lisinopril 10 mg tablet',
+ status: 'unreconciled',
+ expired: true,
+ },
+ { id: '4', name: 'Coumadin 5 mg tablet', status: 'taking' },
+ {
+ id: '5',
+ name: 'ibuprofen 600 mg tablet',
+ sig: 'As needed pain, not to exceed 4 tablets in 24 hours period',
+ status: 'taking-noncompliant',
+ expired: true,
+ },
+ {
+ id: '6',
+ name: 'aspirin 81 mg tablet,delayed release',
+ status: 'not-taking',
+ expired: true,
+ },
+];
+
+/**
+ * Fully interactive reconciliation flow. Click a status button on a row and
+ * watch the medication move to the matching group. Drag a row (or use the
+ * move actions) to reorder within a group.
+ */
+function InteractiveTemplate() {
+ const [meds, setMeds] = useState(sampleMedications);
+ const [log, setLog] = useState([]);
+
+ const handleStatusChange = (med: Medication, status: MedicationStatus) => {
+ setMeds((prev) =>
+ prev.map((m) => (m.id === med.id ? { ...m, status } : m))
+ );
+ setLog((prev) => [
+ `${med.name} β ${MEDICATION_STATUS_LABELS[status]}`,
+ ...prev.slice(0, 4),
+ ]);
+ };
+
+ const handleAction = (med: Medication, action: MedicationAction) => {
+ setMeds((prev) => {
+ switch (action) {
+ case 'remove':
+ return prev.filter((m) => m.id !== med.id);
+ case 'correct': {
+ const name = window.prompt('Correct medication name:', med.name);
+ return name
+ ? prev.map((m) => (m.id === med.id ? { ...m, name } : m))
+ : prev;
+ }
+ case 'note': {
+ const note = window.prompt('Note:', med.note ?? '');
+ return note !== null
+ ? prev.map((m) =>
+ m.id === med.id ? { ...m, note: note || undefined } : m
+ )
+ : prev;
+ }
+ case 'add-task': {
+ const task = window.prompt('Task:', med.task ?? '');
+ return task !== null
+ ? prev.map((m) =>
+ m.id === med.id ? { ...m, task: task || undefined } : m
+ )
+ : prev;
+ }
+ case 'move-up':
+ case 'move-down': {
+ const group = prev.filter((m) => m.status === med.status);
+ const gi = group.findIndex((m) => m.id === med.id);
+ const swap = group[gi + (action === 'move-up' ? -1 : 1)];
+ if (!swap) return prev;
+ const next = [...prev];
+ const i = next.findIndex((m) => m.id === med.id);
+ const j = next.findIndex((m) => m.id === swap.id);
+ [next[i], next[j]] = [next[j], next[i]];
+ return next;
+ }
+ default:
+ return prev;
+ }
+ });
+ setLog((prev) => [`${med.name}: ${action}`, ...prev.slice(0, 4)]);
+ };
+
+ return (
+
+ );
+}
+
+/**
+ * Interactive problem list wired to the ConditionEditor. Expand the diabetes
+ * row to see the concern-evolution timeline: prediabetes β T2DM (refuted) β
+ * T1DM β T1DM with neuropathy. Hover a row for refine / revise / relate /
+ * move actions (Alt+β/β also reorders; β/β moves focus between rows; β/β
+ * moves within a toolbar). Reordering reports the full id order via
+ * `onReorder` for the consumer to persist.
+ */
+export const Interactive: Story = {
+ render: () => ,
+};
+
+/** Display-only rendering with no action affordances. */
+export const ReadOnly: Story = {
+ args: {
+ concerns: sampleConcerns,
+ readOnly: true,
+ },
+ render: (args) => (
+
+
+ );
+ }
+);
+
+ProblemList.displayName = 'ProblemList';
+
+export default ProblemList;
diff --git a/src/components/ProblemList/index.ts b/src/components/ProblemList/index.ts
new file mode 100644
index 00000000..34693f73
--- /dev/null
+++ b/src/components/ProblemList/index.ts
@@ -0,0 +1,24 @@
+export {
+ ProblemList,
+ CodingChips,
+ UncertaintyBadge,
+ currentAssertion,
+ concernGroupKey,
+ concernHistoryContent,
+ toolbarKeyNav,
+ CONCERN_STATUS_LABELS,
+ CHANGE_TYPE_LABELS,
+ type ProblemListProps,
+ type ProblemListAction,
+ type ConditionConcern,
+ type ConditionAssertion,
+ type ConditionCoding,
+ type ConditionObservation,
+ type ConcernRelationship,
+ type ConcernStatus,
+ type VerificationStatus,
+ type AssertionChangeType,
+ type Uncertainty,
+ type FieldUncertainty,
+ type UncertainConditionField,
+} from './ProblemList';
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
index f7c16581..5fc42cef 100644
--- a/src/hooks/index.ts
+++ b/src/hooks/index.ts
@@ -22,3 +22,12 @@ export {
type UseScrollSpyOptions,
type UseScrollSpyReturn,
} from './useScrollSpy';
+export {
+ useDragReorder,
+ reorderIds,
+ dragIndicatorClasses,
+ type UseDragReorderOptions,
+ type UseDragReorderReturn,
+ type DragOverState,
+} from './useDragReorder';
+export { useLiveAnnouncement } from './useLiveAnnouncement';
diff --git a/src/hooks/useDragReorder.ts b/src/hooks/useDragReorder.ts
new file mode 100644
index 00000000..fd6e081a
--- /dev/null
+++ b/src/hooks/useDragReorder.ts
@@ -0,0 +1,151 @@
+'use client';
+
+import * as React from 'react';
+
+/** Insert `draggedId` before/after `targetId` in `ids`. */
+export function reorderIds(
+ ids: string[],
+ draggedId: string,
+ targetId: string,
+ after: boolean
+): string[] {
+ if (draggedId === targetId) return ids;
+ // Ignore drags that originate outside this list (stale payloads would
+ // otherwise be inserted as unknown ids).
+ if (!ids.includes(draggedId)) return ids;
+ const without = ids.filter((i) => i !== draggedId);
+ const idx = without.indexOf(targetId);
+ if (idx === -1) return ids;
+ without.splice(after ? idx + 1 : idx, 0, draggedId);
+ return without;
+}
+
+export interface UseDragReorderOptions {
+ /** Current flat order of item ids (props order) */
+ ids: string[];
+ /** Called with the full new id order after a drop. Omit to disable dragging. */
+ onReorder?: (ids: string[]) => void;
+ /** Restrict drops, e.g. to the same status group */
+ canDropOn?: (draggedId: string, targetId: string) => boolean;
+}
+
+export interface DragOverState {
+ id: string;
+ /** Drop indicator position relative to the hovered row */
+ after: boolean;
+}
+
+export interface UseDragReorderReturn {
+ /** Whether drag reordering is active (onReorder provided) */
+ enabled: boolean;
+ /** Id currently being dragged, if any */
+ draggingId: string | null;
+ /** Row currently hovered as a drop target */
+ over: DragOverState | null;
+ /** Spread onto each row element (must be keyed by a stable id) */
+ rowProps: (id: string) => React.HTMLAttributes;
+}
+
+/**
+ * HTML5 drag-and-drop list reordering. Rows spread `rowProps(id)`; on drop the
+ * hook computes the full new id order and reports it via `onReorder` β the
+ * consumer persists it (e.g. on the encounter/patient object) and feeds the
+ * list back in props order.
+ *
+ * Drag-and-drop is a pointer-only affordance: pair it with a keyboard
+ * alternative (e.g. move buttons / Alt+Arrow keys) for accessibility.
+ *
+ * @example
+ * ```tsx
+ * const drag = useDragReorder({ ids: items.map((i) => i.id), onReorder });
+ * //
+ * ```
+ */
+export function useDragReorder({
+ ids,
+ onReorder,
+ canDropOn,
+}: UseDragReorderOptions): UseDragReorderReturn {
+ const [draggingId, setDraggingId] = React.useState(null);
+ // dragover can fire before the dragstart state update is visible, so the
+ // authoritative dragged id lives in a ref (set synchronously)
+ const draggingRef = React.useRef(null);
+ const [over, setOver] = React.useState(null);
+ const enabled = Boolean(onReorder);
+
+ const reset = React.useCallback(() => {
+ draggingRef.current = null;
+ setDraggingId(null);
+ setOver(null);
+ }, []);
+
+ const rowProps = React.useCallback(
+ (id: string): React.HTMLAttributes => {
+ if (!enabled) return {};
+ return {
+ draggable: true,
+ onDragStart: (e) => {
+ e.dataTransfer.effectAllowed = 'move';
+ e.dataTransfer.setData('text/plain', id);
+ draggingRef.current = id;
+ setDraggingId(id);
+ },
+ onDragEnd: reset,
+ onDragOver: (e) => {
+ const dragged = draggingRef.current;
+ if (
+ !dragged ||
+ dragged === id ||
+ (canDropOn && !canDropOn(dragged, id))
+ ) {
+ // hovering an invalid target: clear any stale indicator
+ setOver((prev) => (prev ? null : prev));
+ return;
+ }
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'move';
+ const r = e.currentTarget.getBoundingClientRect();
+ const after = e.clientY > r.top + r.height / 2;
+ setOver((prev) =>
+ prev?.id === id && prev.after === after ? prev : { id, after }
+ );
+ },
+ onDragLeave: () => {
+ setOver((prev) => (prev?.id === id ? null : prev));
+ },
+ onDrop: (e) => {
+ e.preventDefault();
+ const dragged =
+ draggingRef.current ?? e.dataTransfer.getData('text/plain');
+ reset();
+ if (!dragged || dragged === id) return;
+ if (canDropOn && !canDropOn(dragged, id)) return;
+ const r = e.currentTarget.getBoundingClientRect();
+ const after = e.clientY > r.top + r.height / 2;
+ onReorder?.(reorderIds(ids, dragged, id, after));
+ },
+ };
+ },
+ [enabled, ids, onReorder, canDropOn, reset]
+ );
+
+ return { enabled, draggingId, over, rowProps };
+}
+
+/** Tailwind classes for the drag state of a row: drop indicator + dragging dim. */
+export function dragIndicatorClasses(
+ drag: Pick,
+ id: string
+): string {
+ return [
+ drag.draggingId === id ? 'opacity-40' : '',
+ drag.over?.id === id && !drag.over.after
+ ? 'shadow-[inset_0_2px_0_0_var(--color-primary-500,#3b82f6)]'
+ : '',
+ drag.over?.id === id && drag.over.after
+ ? 'shadow-[inset_0_-2px_0_0_var(--color-primary-500,#3b82f6)]'
+ : '',
+ ]
+ .filter(Boolean)
+ .join(' ');
+}
diff --git a/src/hooks/useLiveAnnouncement.ts b/src/hooks/useLiveAnnouncement.ts
new file mode 100644
index 00000000..643c167f
--- /dev/null
+++ b/src/hooks/useLiveAnnouncement.ts
@@ -0,0 +1,39 @@
+'use client';
+
+import * as React from 'react';
+
+/**
+ * State for an `aria-live` region that re-announces even when the same
+ * message is emitted twice in a row. Setting identical React state bails out
+ * (and identical DOM text isn't a mutation), so screen readers would stay
+ * silent for repeated actions like "moved down" β this clears the region
+ * first, then sets the message a tick later.
+ *
+ * @example
+ * ```tsx
+ * const [announcement, announce] = useLiveAnnouncement();
+ * // announce('Item moved down');
+ * //
{announcement}
+ * ```
+ */
+export function useLiveAnnouncement(): [string, (message: string) => void] {
+ const [message, setMessage] = React.useState('');
+ const timerRef = React.useRef | undefined>(
+ undefined
+ );
+
+ const announce = React.useCallback((next: string) => {
+ setMessage('');
+ if (timerRef.current !== undefined) clearTimeout(timerRef.current);
+ timerRef.current = setTimeout(() => setMessage(next), 50);
+ }, []);
+
+ React.useEffect(
+ () => () => {
+ if (timerRef.current !== undefined) clearTimeout(timerRef.current);
+ },
+ []
+ );
+
+ return [message, announce];
+}
diff --git a/src/index.ts b/src/index.ts
index 00b344d4..01b03aa2 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -12,6 +12,7 @@ export * from './components/AI';
export * from './components/Alert';
export * from './components/AlertDialog';
export * from './components/AppHeader';
+export * from './components/Assessment';
export * from './components/AudioPlayer';
export * from './components/AudioRecorder';
export * from './components/AuthDialog';
@@ -25,6 +26,7 @@ export * from './components/BusinessHoursEditor';
export * from './components/Button';
export * from './components/Card';
export * from './components/Checkbox';
+export * from './components/ConditionEditor';
export * from './components/CheckrIntegration';
export * from './components/Collapsible';
export * from './components/CommandPalette';
@@ -72,6 +74,7 @@ export { InvoiceView, type InvoiceViewProps } from './components/InvoiceView';
export * from './components/LanguageSelector';
export * from './components/LoadingPage';
export * from './components/Markdown';
+export * from './components/MedicationList';
export * from './components/Messaging';
export * from './components/Modal';
export * from './components/NotificationCenter';
@@ -95,6 +98,8 @@ export * from './components/PaymentMethod';
export * from './components/PendingClaimsTable';
export * from './components/PermissionsEditor';
export * from './components/PhoneInput';
+export * from './components/PresentingProblems';
+export * from './components/ProblemList';
export * from './components/ProductVersion';
export * from './components/Progress';
export * from './components/ProviderCard';
diff --git a/src/tailwind-preset.ts b/src/tailwind-preset.ts
index be1efffe..ea378b47 100644
--- a/src/tailwind-preset.ts
+++ b/src/tailwind-preset.ts
@@ -78,6 +78,12 @@ export const miewebUISafelist = [
'focus:ring-success/20',
'focus:ring-destructive/20',
'opacity-0',
+ // Drag & drop (useDragReorder / dragIndicatorClasses)
+ 'opacity-40',
+ 'cursor-grab',
+ 'active:cursor-grabbing',
+ 'shadow-[inset_0_2px_0_0_var(--color-primary-500,#3b82f6)]',
+ 'shadow-[inset_0_-2px_0_0_var(--color-primary-500,#3b82f6)]',
'group-hover:opacity-100',
'focus:opacity-100',
// Markdown SurveyBlock