Absent Value
StabledataAbsentReasonPrimitive · Clinical
The smallest component in the library and one of the most consequential. A blank cell is indistinguishable from a rendering failure, a dash flattens fifteen FHIR reasons into one, and a zero is a value — reading “not measured” as 0 is a clinical error. This renders the reason instead, and keeps “the system does not have this” separate from “you are not allowed to see this”, which look identical in most products and mean opposite things at the bedside.
pnpm dlx shadcn@latest add @oxygenui/absent-valueFirst install? Add "@oxygenui": "https://oxygenui.design/r/{name}.json" to the registries block of your components.json first — or skip the config and pass https://oxygenui.design/r/absent-value.json directly.
Preview
Every state, switchable.
- Magnesium
- Not asked
- Smoking status
- Declined to answer
- Bicarbonate
- Result pending
- Toxicology screen
- Hidden — restricted
- Albumin
- Unavailable — system error
- Last menstrual period
- Not applicable
Fifteen FHIR codes collapsed to the groups a reader must act on differently. None of them is a dash.
Usage
Props are the FHIR resource.
import { AbsentValue } from "@/components/oxygen/absent-value";
// From a FHIR payload — the reason is read and categorised for you.
<AbsentValue field="Potassium" reason={observation.dataAbsentReason} />
// Or state it directly.
<AbsentValue reason="masked" onRequestAccess={requestAccess} />
// Absence with no stated reason still renders text, never a blank.
<AbsentValue />Dependencies
- @oxygenui-design/fhir@^0.1.0
- lucide-react
- clsx
- tailwind-merge
| Prop | Type | Default |
|---|---|---|
| detail Free-text explanation from the source system. Shown alongside the label. Ignored for restricted reasons: source text on a masked value can itself describe what was masked, which defeats the masking. Failing closed here costs a little detail and prevents a disclosure. | string | undefined | — |
| field The field this absence belongs to, e.g. "Potassium". Rendered as a screen-reader-only prefix so the announcement is "Potassium, not asked" rather than a bare "not asked". Omit it when the absence already sits in a cell associated with a row or column header, or the field name will be announced twice. | string | undefined | — |
| hideIcon Hide the icon. Only when an adjacent icon already carries the meaning. | boolean | undefined | false |
| onRequestAccess Offer a way to request access. Rendered only for restricted reasons, since there is nothing to request when the data genuinely does not exist. | (() => void) | undefined | — |
| reason The FHIR `dataAbsentReason`, or a normalized reason directly. Omit it entirely and the component renders "Not recorded" — absence with no stated reason is itself a state, not a case to fall through. | CodeableConcept | AbsentReason | undefined | — |
| requestAccessLabel | string | undefined | "Request access" |
| variant inline — flows with body text and table cells (default) block — a bordered region for a whole empty section | 'inline' | 'block' | undefined | "inline" |
Guidance
When to use it, and when not to.
DECISION SURFACE / 6 RULES
Recommended context
Use it
- Everywhere a clinical value could be missing — cells, fields, and whole sections.
- With field set when the absence sits somewhere with no associated label.
- In place of any “—”, “N/A”, or empty string standing in for a clinical value.
Guardrails
Don't
- As a permission check. It renders an outcome; the application decides who may see what.
- For a zero or a genuinely negative result. Those are values and belong in the value slot.
- Passing source text through detail on masked content — the component ignores it by design.
Quality
What was tested, and what is still missing.
- Never silent
- Always renders text. A screen-reader user landing in the cell hears a reason rather than skipping an empty element.
- Field prefix
- field adds a screen-reader-only prefix so the announcement is “Potassium, not asked” rather than a context-free “not asked”.
- Not colour alone
- Every reason carries an icon and a full-sentence label. The three tones are reinforcement, and the wording survives grayscale and forced-colors.
Open gaps
Known limitations
- Presentational only. It does not evaluate consent, security labels, or role — pass it the resolved reason.
- Restricted reasons deliberately drop source text, so a genuinely safe explanation is lost along with an unsafe one.
- The request-access affordance emits a callback; the access workflow itself is yours to build.
Source
Exactly what lands in your repository.
Read directly from the published registry, so this can never drift from what the CLI installs.
Show sourceHide source240 lines
"use client";
/**
* AbsentValue — renders the absence of a clinical value as a statement.
*
* This is the smallest component in the library and one of the most important,
* because the alternatives are all worse than they look:
*
* - A blank cell is indistinguishable from a rendering failure.
* - A dash flattens fifteen different reasons into one.
* - A zero is a value, and reading "not measured" as 0 is a clinical error.
*
* So absence gets a component. It always renders text, it never renders an
* empty string, and it distinguishes the reasons that a reader must act on
* differently — in particular it separates "the system does not have this"
* from "you are not allowed to see this", which look identical in most
* products and mean opposite things at the bedside.
*
* The component is presentational and takes the reason as data. It does not
* decide whether a viewer is permitted to see something; that is the
* application's job, and this renders the outcome.
*/
import * as React from "react";
import {
CircleHelp,
CircleOff,
CircleSlash,
Clock,
FileText,
Lock,
MessageCircleOff,
TriangleAlert,
} from "lucide-react";
import {
ABSENT_REASON_LABEL,
isRestrictedAbsence,
resolveAbsentReason,
type AbsentReason,
type CodeableConcept,
} from "@oxygenui-design/fhir";
import { cn } from "@/lib/utils";
/**
* Three tones, not eleven. The reason taxonomy is fine-grained because the
* words matter; the styling is coarse because a reader can only act on a few
* distinctions at a glance.
*
* muted — genuinely absent. The chart does not have this.
* restricted — present but withheld. Do not read the chart as empty.
* warning — something is broken. This SHOULD be here.
*/
type AbsentTone = "muted" | "restricted" | "warning";
const REASON_TONE: Record<AbsentReason, AbsentTone> = {
unknown: "muted",
pending: "muted",
"not-collected": "muted",
declined: "muted",
masked: "restricted",
"not-permitted": "restricted",
"not-applicable": "muted",
"not-performed": "muted",
"as-text": "muted",
error: "warning",
unstated: "muted",
};
/**
* Class names written out in full. Tailwind resolves classes by scanning source
* text, so a template literal built from `tone` produces no CSS and the absence
* renders as unstyled body text — which reads as a real value.
*/
const TONE_CLASS: Record<AbsentTone, string> = {
muted: "text-[var(--ox-text-subtle)]",
restricted: "text-[var(--ox-flag-restricted)]",
warning: "text-[var(--ox-status-high)]",
};
const BLOCK_CLASS: Record<AbsentTone, string> = {
muted: "border-[var(--ox-border)] bg-[var(--ox-bg-subtle)] text-[var(--ox-text-muted)]",
restricted:
"border-[var(--ox-flag-restricted)]/30 bg-[var(--ox-flag-restricted-bg)] text-[var(--ox-flag-restricted)]",
warning:
"border-[var(--ox-status-high-border)] bg-[var(--ox-status-high-bg)] text-[var(--ox-status-high)]",
};
const REASON_ICON: Record<AbsentReason, React.ComponentType<{ className?: string }>> = {
unknown: CircleHelp,
pending: Clock,
"not-collected": CircleSlash,
declined: MessageCircleOff,
masked: Lock,
"not-permitted": Lock,
"not-applicable": CircleOff,
"not-performed": CircleSlash,
"as-text": FileText,
error: TriangleAlert,
unstated: CircleSlash,
};
export interface AbsentValueProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "children"> {
/**
* The FHIR `dataAbsentReason`, or a normalized reason directly.
* Omit it entirely and the component renders "Not recorded" — absence with
* no stated reason is itself a state, not a case to fall through.
*/
reason?: CodeableConcept | AbsentReason;
/**
* The field this absence belongs to, e.g. "Potassium".
*
* Rendered as a screen-reader-only prefix so the announcement is "Potassium,
* not asked" rather than a bare "not asked". Omit it when the absence already
* sits in a cell associated with a row or column header, or the field name
* will be announced twice.
*/
field?: string;
/**
* Free-text explanation from the source system. Shown alongside the label.
*
* Ignored for restricted reasons: source text on a masked value can itself
* describe what was masked, which defeats the masking. Failing closed here
* costs a little detail and prevents a disclosure.
*/
detail?: string;
/**
* inline — flows with body text and table cells (default)
* block — a bordered region for a whole empty section
*/
variant?: "inline" | "block";
/** Hide the icon. Only when an adjacent icon already carries the meaning. */
hideIcon?: boolean;
/**
* Offer a way to request access. Rendered only for restricted reasons, since
* there is nothing to request when the data genuinely does not exist.
*/
onRequestAccess?: () => void;
requestAccessLabel?: string;
}
/** Resolve either input shape to a normalized reason. */
function normalize(reason: AbsentValueProps["reason"]): AbsentReason {
if (!reason) return "unstated";
if (typeof reason === "string") return reason;
return resolveAbsentReason(reason);
}
/** Source text, where it is safe to show and adds something over the label. */
function sourceDetail(
reason: AbsentValueProps["reason"],
detail: string | undefined,
resolved: AbsentReason,
): string | undefined {
if (isRestrictedAbsence(resolved)) return undefined;
if (detail) return detail;
if (reason && typeof reason !== "string" && reason.text) return reason.text;
return undefined;
}
export function AbsentValue({
reason,
field,
detail,
variant = "inline",
hideIcon = false,
onRequestAccess,
requestAccessLabel = "Request access",
className,
...props
}: AbsentValueProps) {
const resolved = normalize(reason);
const tone = REASON_TONE[resolved];
const Icon = REASON_ICON[resolved];
const label = ABSENT_REASON_LABEL[resolved];
const explanation = sourceDetail(reason, detail, resolved);
const canRequest = Boolean(onRequestAccess) && isRestrictedAbsence(resolved);
const body = (
<>
{/*
Not aria-hidden on the field: this is the prefix that turns a bare
"Not asked" into "Potassium, not asked" for a screen-reader user
landing in a cell with no other context.
*/}
{field && <span className="sr-only">{field}, </span>}
{!hideIcon && <Icon aria-hidden="true" className="size-3.5 shrink-0" />}
<span>{label}</span>
{explanation && <span className="text-[var(--ox-text-subtle)]">— {explanation}</span>}
</>
);
if (variant === "block") {
return (
<div
data-absent-reason={resolved}
className={cn(
"flex flex-col items-start gap-2 rounded-[var(--ox-radius)] border border-dashed px-[var(--ox-density-pad-x)] py-[var(--ox-density-pad-y)] text-[length:var(--ox-text-sm)]",
BLOCK_CLASS[tone],
className,
)}
{...(props as React.HTMLAttributes<HTMLDivElement>)}
>
<span className="flex items-center gap-1.5 font-medium">{body}</span>
{canRequest && (
<button
type="button"
onClick={onRequestAccess}
className="rounded-[var(--ox-radius-sm)] border border-current px-2 py-1 text-[length:var(--ox-text-xs)] font-semibold hover:bg-[var(--ox-bg)]"
>
{requestAccessLabel}
</button>
)}
</div>
);
}
return (
<span
data-absent-reason={resolved}
className={cn(
"inline-flex items-center gap-1.5 text-[length:var(--ox-text-sm)]",
TONE_CLASS[tone],
className,
)}
{...props}
>
{body}
{canRequest && (
<button
type="button"
onClick={onRequestAccess}
className="underline underline-offset-2 hover:no-underline"
>
{requestAccessLabel}
</button>
)}
</span>
);
}