@campminder/components
v0.3.2
Published
> Published on public npm. Install and render: [`docs/consuming-the-shell.md`](../../docs/consuming-the-shell.md). > Working in here: [`CONTRIBUTING.md`](../../CONTRIBUTING.md).
Maintainers
Keywords
Readme
@campminder/components
Published on public npm. Install and render:
docs/consuming-the-shell.md. Working in here:CONTRIBUTING.md.
The Campminder chrome: left nav, top bar, super-user banner. Lit web components, composition-agnostic, rendering only what they are handed.
Why one package and not three
The lineage verdict on #25854 is explicit that side-nav and top-bar are "one shell, split across two packages by history, not by design." Porting them into three separate packages here would have carried that accident forward permanently — three version numbers, three release decisions, and a consumer installing three things to get one shell. Merging published packages later is a breaking change for everyone; merging them now, before anything publishes, costs nothing.
Subpath entries keep the granularity that actually mattered:
import '@campminder/components/side-nav'
import '@campminder/components/top-bar'A consumer that only wants the left nav does not pull the top bar's code.
Dependencies are added by the floor that imports them
This package declares lit and nothing else. axios, @carbon/web-components and
@material/web were carried over from the core-js manifests during the scaffold and
removed 2026-09-02: nothing in this package imported them — and nothing in core-js's
apps/side-nav, apps/top-bar or apps/super-user-banner imports them either, so they
were dead weight upstream before they were dead weight here. They sat in dependencies,
not devDependencies, so every consumer would have installed two whole design systems to
render a menu.
If the Floor 7 top-bar port or the Floor 8 banner port needs one, the floor that writes
the import adds it back. Do not pre-declare a dependency for a port that has not
happened — an unused dependency in a published package is a cost paid by every consumer
for nothing.
Port sources
| Entry | Port from | ADO floor |
| ------------------- | ------------------------------------------------------ | ---------- |
| side-nav | core-js/apps/side-nav (@campminder/side-nav 1.5.0) | Floors 3–4 |
| top-bar | core-js/apps/top-bar (@campminder/top-bar 0.0.111) | Floor 7 |
| super-user-banner | core-js/apps/super-user-banner (0.8.6) | Floor 8 |
core-js keeps building its copies for its existing consumers. This is a copy, not a
move — Rule 2 of the verdict.
Strip on the way in
The port is the cheapest moment: there are no consumers here yet, so nothing needs to stay backwards-compatible. This is choosing what not to copy, not refactoring live code.
side-nav
BodyPaddingController— injectshtml body { padding-left: 168px !important; }intodocument.head. The shell's grid owns that space now.initSearchIntegration()and itssetTimeout(setupSearchBarListener, 100)re-arm — polls the DOM forever in any composition without search.document.querySelector('search-bar')atSideNav.ts:129and:477.SIDENAV_STORAGE_KEY = 'isSideNavCollapsed'— a global key two shells on one origin would share. The shell namespaces it per portal.src/data/data.ts— the base64 fixture. The tree is a property.
top-bar — ported Floor 7, 2026-09-08. This list is now a record, not a forecast.
The entire CDN script loader.
loadWebComponents(),getCdnUrl(),WEB_COMPONENTS_PATH,BASE_DOMAIN,FALLBACK_CDN_HOST, thesearchReadystate and thewebComponentsUrlproperty. It appended a<script type="module">todocument.headpointed at a hardcodedcdn.*.campminder.comhost, purely so thatsearch-barwould register itself. The plan did not know about this — it named the registry probe and the render, not the script injection, which is the more serious breach of the one rule. Making search a slot removed the whole apparatus.customElements.get('search-bar')atTopBar.ts:58, the<search-bar>render at:333, and theenableSearchWidgetproperty. Search is a named<slot name="search">, rendered unconditionally; a shell that wants it fills it, and nothing polls for it.search-baris therefore not ported in Phase 1.All search-expansion state —
searchExpanded,handleSearchExpanded,handleSearchClose,renderSearchCloseButton(), and twodocument.dispatchEvent(new CustomEvent('search-expanded'))calls that existed so side-nav could hear them. Phase 1 ships no search, so the state could never become true. Owed when search is slotted in: upstream collapsed the page-info block and the user dropdown while search was expanded on mobile. Reintroduce that as something the slotted element drives, not as a document-level broadcast.All positioning.
topOffsetPxandleftOffsetPx, theisSideNavCollapsedstate, thecalculatedLeftOffsetgetter,syncWithSideNavState(),initSideNavIntegration(),handleSideNavToggle, and the import ofshared/navigation-constants.ts(SIDENAV_ANIMATION,SIDENAV_DIMENSIONS,SIDENAV_STORAGE_KEY,SIDENAV_TOGGLE_EVENT).LAYOUTin@campminder/campminder-shellis the single source and the shell's grid applies it. The rendered<section>carries nostyleattribute at all. Reading side-nav'slocalStoragekey was querying for a sibling.All JS device detection.
Utils/device-css-classes.tsandUtils/mobile-detection.tsare not here. Responsiveness is the media queries that were already instyles.tsalongside the class-driven duplicates. SeeTopBar.styles.ts's comment on the page-title breakpoint for the one behaviour-neutral simplification this exposed.data/data.ts— the base64 fixture, andindex.css— page-level:rootandbodystyling, which is a host concern.assets/Icons/CloseIcon.ts— copied with the other five, then removed: its only consumer was the search close button. side-nav carries its ownCloseIcon, so nothing lost it. The remaining five live inassets/TopBarIcons/behind a barrel, namedXSvgIcon, matching side-nav'sassets/SideNavIcons/rather than upstream's unprefixedassets/Icons/with one lowercase export.Carbon and Material: nothing to strip. Verified 2026-09-08 that no file under
core-js/apps/top-bar/srcimports@carbon/web-componentsor@material/web. The standing note that retiring them is separate-and-optional still holds for the estate, but this port added neither, so this package still declareslitand nothing else.@import url(fonts.googleapis.com/...)— missed by this floor and stripped on Floor 8, matching what side-nav had already done on Floor 4. See the banner section below for the measurement.Seven upstream security patterns, refused rather than copied —
link.urlinhrefand inwindow.open();popup_propertiespassed towindow.open()verbatim, and then its UI-suppressing features;target="_blank"with norel, and the popup path with nonoopener;feature.valuethrough lit'sunsafeHTML; andicon_url/profile_url/logo_urlreaching<img src>unguarded. Why each one is refused, and the trust boundary that makes them live rather than theoretical, is stated once — inUtils/safe-url.ts, with 46 unit tests beside it. Floor 8 was expected to inherit the question and did not — see below.
Old → new names, since this element is consumed by tag name:
| Upstream | Here |
| ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| <top-bar> | <cm-top-bar> |
| data (base64 attribute) | unchanged — still data, still base64-decoded |
| user | gone — its only consumer was <search-bar>'s config, which the host now supplies to whatever it slots |
| top-offset-px, left-offset-px, enable-search-widget, web-components-url | gone |
| — | <slot name="search"> |
super-user-banner
Renaming the element and its attributes is allowed. It is emitted as raw markup from
ReactAppController.cs:163, so WebForms consumes it with no package edge — but that
constrains the Phase 2 cutover, not this port: per Rule 2 this is a copy-forward and
core-js keeps building the copy that page actually loads. Ruled 2026-09-02, reversing
the ban that used to sit here.
position: fixed; top: 0; left: 0; width: 100%, and theResizeObserverthat comparedscrollWidthagainstwindow.innerWidthto toggle anoverflowedclass (plus the shadow-rootidthat existed only for itsgetElementById). Both went because the shell owns layout. Why each one is refused, and the latent defect the observer was carrying, is stated once — inSuperUserBanner.styles.ts, which is where the replacement lives. Worth knowing at this level: the fixed positioning is whyReactAppControllerhas to emit ahasSuperUserBannerclass and prepend container markup at all, so deleting four declarations here deletes the reason for two host-side workarounds in Phase 2.@import url(fonts.googleapis.com/…)andindex.css(page-level:rootandbodystyling, a host concern). The@importwas also still inTopBar.styles.ts— side-nav dropped it on Floor 4, Floor 7 missed it, Floor 8 caught it. That file carries the reasoning and the measurement.
No URL guard was needed, and that is a finding. top-bar's
Utils/safe-url.ts was left under top-bar/ on the
argument that Floor 8 would be the second caller and would show the right shape. It is
not: all three of the banner's values are database-sourced and cross the same untrusted
boundary, but none is a URL and none is markup, so lit escaping text is the entire
defence. A test asserts the component renders no a, img, iframe, source or
script at all. Promoting the guards out of top-bar/ therefore still has exactly one
caller and still is not justified.
It did need two fixes of a different kind, both found in review. A banner whose job is to make impersonation and environment unmistakable can fail at that job without any injection being involved:
- Upstream's badge failed open — anything that was not exactly
staginggot the calmlocalbadge, including the literalproductionandundefinedthat the host'sGetEnvironmentName()really returns. A super-user impersonating in production saw the same badge as a laptop. There are now three badges and the unrecognised case is loud;environmentBadgeClassinSuperUserBanner.tscarries the reasoning. - The port had introduced a scroll container (
overflow-x: auto) and hidden its scrollbar, with the badge as the last flex child — so a merely long client name pushed the badge out of view with no affordance, on a section with notabindex. Upstream had nooverflowrule at all, so this one was a regression, not an inheritance. The names now truncate and the scrollbar is no longer suppressed.
Old → new names, since this element is consumed by tag name:
| Upstream | Here |
| ---------------------------------------------------- | ------------------------------------------------ |
| <super-user-banner> | <cm-super-user-banner> |
| superUser= (parsed as superuser) | super-user |
| activeEnvironment= (parsed as activeenvironment) | active-environment |
| client= | unchanged — client |
| id="super-user-banner" in the shadow root | gone — existed only for the ResizeObserver |
The old spellings are ignored, not aliased, and a test asserts that — so the cutover fails loudly rather than silently rendering an empty banner.
The rule
Components render themselves; the shell owns layout. No component may touch
document.body,document.head, or query for a sibling.
Dismissing the side-nav flyout
<cm-side-nav> exposes exactly one dismissal entry point:
dismissFlyout(restoreFocus: boolean): voidIt closes the flyout when one is open and is a no-op when none is. Every trigger
routes through it — the flyout's own Escape and shift-Tab paths, the navigation's
mouseleave, the cm-mega-menu-close event (which now carries
detail.restoreFocus), and the host shell's page-level Escape and outside pointer
press — so the focus rule below is applied in one place and no trigger can bypass
it.
The focus rule. Focus moves only when restoreFocus is true and focus is
currently inside the flyout; then it goes to the category control that opened the
flyout, falling back to the navigation's collapse control when that category
control no longer exists. Focus is never left on the document body, and is never
pulled into the navigation from somewhere else on the page — a person typing in a
form field, or pressing a pointer outside the flyout, keeps the focus they have.
Two consequences worth keeping in mind when changing this code:
- The focus target is read before the open-category state is cleared. The
target is found via the
category-hoveredclass, which is derived from that state; clearing first leaves nothing to find and silently reintroduces the dropped-focus bug. - Page-level Escape and outside-pointer listening belongs to the shell, which
calls
dismissFlyouton each slotted navigation —truefor Escape,falsefor an outside pointer press. This component registers nothing ondocumentorwindow, per the rule above, andmega-menu-isolation.test.tsobserves that.
