components/shell/shell.react.tsx
240 lines and 20 functions in DrDustinEdwards/capsomer, at commit d3966cc on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| RailIcon | 62 to 66 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| MoreIcon | 68 to 74 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| plainLink | 76 to 76 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| groups | 79 to 87 | 4 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| useCollapsedPref | 90 to 102 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:92> | 92 to 92 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:94> | 94 to 98 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| Shell | 104 to 251 | 11 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:111> | 111 to 114 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:113> | 113 to 113 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| entry | 119 to 147 | 9 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:175> | 175 to 185 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:181> | 181 to 181 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:184> | 184 to 184 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:189> | 189 to 189 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:201> | 201 to 201 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:210> | 210 to 210 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:211> | 211 to 211 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:220> | 220 to 220 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:227> | 227 to 243 | 5 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
Imports
It imports components/dialog/dialog.react.tsx, components/shell/shell.ts. Imported by components/admin-shell/admin-shell.react.tsx, components/site-header/site-header-parts.react.tsx, components/site-header/site-header.react.tsx.
Source
import { Fragment, useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react";
import { Dialog, DialogBody, DialogHeader, DialogTitle } from "../dialog/dialog.react.tsx";
import { DEFAULT_PREF, readPref, writePref } from "./shell.ts";
export interface ShellEntry {
id: string;
label: string;
href: string;
icon?: ReactNode;
current?: boolean;
// A count beside the entry, and what it counts: "Queue, 2 blocked".
count?: number;
countNote?: string;
tone?: "crit" | "warn";
// A group label over the entries that share it (Sidebar's group). Consecutive entries with
// the same `group` form one group; entries with none sit ungrouped. The label is hidden
// when the rail is collapsed.
group?: string;
}
export interface LinkProps {
href: string;
className?: string;
"aria-current"?: "page" | "true";
"aria-label"?: string;
onClick?: () => void;
children: ReactNode;
}
export interface ShellProps {
// The name beside the logo. Leave it null when the logo is the whole brand, and give `brandLabel`.
brand: ReactNode;
brandHref?: string;
// The site's logo (an `<img alt="">` or an `<svg>`), before the name: held to the bar's height, in
// proportion, and hidden from a screen reader because the name or `brandLabel` is the link's name.
logo?: ReactNode;
// The brand link's name when `brand` is empty (the logo alone).
brandLabel?: string;
nav: ShellEntry[];
// At most four: the wrapper adds More as the fifth when `more` is given.
tabs?: ShellEntry[];
// The views the tab bar has no room for, listed in the More sheet. `current` on one of
// them marks the More tab as current. The sheet's count reads "1 paused" (count, then countNote).
more?: ShellEntry[];
moreLabel?: string;
moreIcon?: ReactNode;
navLabel?: string;
// The top bar, left to right after the brand: a status, then actions. Put the theme
// switch, then Settings, then Sign out (where there is one) last in `actions`.
status?: ReactNode;
actions?: ReactNode;
railFoot?: ReactNode;
prefKey?: string;
// A router's link component; a plain <a> by default.
renderLink?: (props: LinkProps) => ReactNode;
// Controlled collapse, for an app that binds a key to it.
collapsed?: boolean;
onCollapsedChange?: (collapsed: boolean) => void;
children: ReactNode;
}
const RailIcon = () => (
<svg className="cap-shell-icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" d="M2.5 2.5h11v11h-11zM6 2.5v11M10.5 6 8.5 8l2 2" />
</svg>
);
const MoreIcon = () => (
<svg className="cap-shell-icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<circle cx="3" cy="8" r="1.4" fill="currentColor" />
<circle cx="8" cy="8" r="1.4" fill="currentColor" />
<circle cx="13" cy="8" r="1.4" fill="currentColor" />
</svg>
);
export const plainLink = ({ children, ...props }: LinkProps) => <a {...props}>{children}</a>;
// Consecutive entries with the same `group` are one group.
export function groups<E extends { group?: string }>(nav: E[]): Array<{ label?: string; entries: E[] }> {
const out: Array<{ label?: string; entries: E[] }> = [];
for (const e of nav) {
const last = out[out.length - 1];
if (last && last.label === e.group) last.entries.push(e);
else out.push({ label: e.group, entries: [e] });
}
return out;
}
// The rail's collapsed state: the prop when the app controls it, else the stored preference.
export function useCollapsedPref(prefKey: string, controlled: boolean | undefined, onChange: ((next: boolean) => void) | undefined): [boolean, (next: boolean) => void] {
const [own, setOwn] = useState(false);
useEffect(() => setOwn(readPref(prefKey) === "collapsed"), [prefKey]);
const setCollapsed = useCallback(
(next: boolean) => {
writePref(prefKey, next ? "collapsed" : "expanded");
setOwn(next);
onChange?.(next);
},
[prefKey, onChange],
);
return [controlled ?? own, setCollapsed];
}
export function Shell(props: ShellProps) {
const { brand, brandHref = "./", logo, brandLabel, nav, tabs, more, moreLabel = "More", moreIcon, navLabel = "Sections", status, actions, railFoot, prefKey = DEFAULT_PREF, renderLink = plainLink, children } = props;
const moreButton = useRef<HTMLButtonElement>(null);
const groupId = useId();
const [moreOpen, setMoreOpen] = useState(false);
// The shared dialog closes by Esc, Close, the backdrop and Back; a link in the list closes it
// too. Focus goes back to More, which a browser that does not focus a pressed button needs.
const closeMore = useCallback(() => {
setMoreOpen(false);
requestAnimationFrame(() => moreButton.current?.focus());
}, []);
const [collapsed, setCollapsed] = useCollapsedPref(prefKey, props.collapsed, props.onCollapsedChange);
// A phone tab bar holds at most five entries (rule 9): with a More sheet, four and More.
const phoneTabs = tabs?.slice(0, more ? 4 : 5);
const entry = (e: ShellEntry, phone: boolean) => {
// The visible label and count, then what it counts: "Sites, 2 down or degraded" (WCAG 2.5.3).
const named = e.count ? `${e.label}, ${e.count}${e.countNote ? ` ${e.countNote}` : ""}` : undefined;
return (
<Fragment key={e.id}>
{renderLink({
href: e.href,
"aria-current": e.current ? "page" : undefined,
"aria-label": named,
children: phone ? (
<>
{e.icon}
{e.label}
{e.count ? " " : null}
{e.count ? <span className="cap-shell-badge">{e.count}</span> : null}
</>
) : (
<>
{e.icon ?? <span aria-hidden="true" />}
<span className="cap-shell-label">{e.label}</span>{" "}
<span className="cap-shell-count" data-tone={e.tone}>
{e.count ? e.count : ""}
</span>
</>
),
})}
</Fragment>
);
};
return (
<div className="cap-shell" data-cap="shell" data-rail={collapsed ? "collapsed" : undefined}>
<a className="cap-shell-skip" href="#cap-main">
Skip to content
</a>
<header className="cap-shell-top">
{renderLink({
href: brandHref,
className: "cap-shell-brand",
"aria-label": brandLabel,
children: (
<>
{logo ? (
<span className="cap-shell-logo" aria-hidden="true">
{logo}
</span>
) : null}
{brand}
</>
),
})}
<div className="cap-shell-spacer" />
{status}
<div className="cap-shell-actions">{actions}</div>
</header>
<nav className="cap-shell-rail" id="cap-rail" aria-label={navLabel}>
{groups(nav).map((g, i) =>
g.label ? (
<div key={`g${i}`} className="cap-shell-group" role="group" aria-labelledby={`${groupId}-${i}`}>
<div className="cap-shell-group-label" id={`${groupId}-${i}`}>
{g.label}
</div>
{g.entries.map((e) => entry(e, false))}
</div>
) : (
g.entries.map((e) => entry(e, false))
),
)}
<div className="cap-shell-foot">
{railFoot}
<button type="button" className="cap-shell-toggle" data-cap-part="rail-toggle" aria-expanded={!collapsed} aria-controls="cap-rail" onClick={() => setCollapsed(!collapsed)}>
<RailIcon />
<span className="cap-shell-label">{collapsed ? "Expand menu" : "Collapse menu"}</span>
<kbd aria-hidden="true">[</kbd>
</button>
</div>
</nav>
<main className="cap-shell-main" id="cap-main" tabIndex={-1}>
{children}
</main>
{phoneTabs && phoneTabs.length > 0 && (
<nav className="cap-shell-tabs" aria-label={navLabel}>
{phoneTabs.map((e) => entry(e, true))}
{more && (
<button
ref={moreButton}
type="button"
data-cap-part="more"
aria-haspopup="dialog"
aria-expanded={moreOpen}
aria-controls="cap-more"
aria-current={more.some((e) => e.current) ? "true" : undefined}
onClick={() => setMoreOpen(true)}
>
{moreIcon ?? <MoreIcon />}
{moreLabel}
</button>
)}
</nav>
)}
{more && (
<Dialog open={moreOpen} onOpenChange={(o) => !o && closeMore()} placement="bottom" id="cap-more" className="cap-shell-more" aria-labelledby="cap-more-title">
<DialogHeader>
<DialogTitle id="cap-more-title">More views</DialogTitle>
</DialogHeader>
<DialogBody>
<nav aria-labelledby="cap-more-title">
<ul className="cap-shell-more-list">
{more.map((e) => (
<li key={e.id}>
{renderLink({
href: e.href,
"aria-current": e.current ? "page" : undefined,
onClick: closeMore,
children: (
<>
{e.icon}
<span className="cap-shell-more-name">{e.label}</span>
{e.count ? " " : null}
{e.count ? <span className="cap-shell-count">{`${e.count}${e.countNote ? ` ${e.countNote}` : ""}`}</span> : null}
</>
),
})}
</li>
))}
</ul>
</nav>
</DialogBody>
</Dialog>
)}
</div>
);
}