@campminder/components

中文文档
0.8.0发布于 2026/11/10TypeScriptES Module近 30 天下载 1842

Published on public npm. Install and render: docs/consuming-the-shell.md. > Working in here: CONTRIBUTING.md.

原始使用文档
文档目录6 节
  1. Why one package and not three
  2. Dependencies are added by the floor that imports them
  3. Port sources
  4. Strip on the way in
  5. The rule
  6. Dismissing the side-nav flyout

@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

EntryPort fromADO floor
side-navcore-js/apps/side-nav (@campminder/side-nav 1.5.0)Floors 3–4
top-barcore-js/apps/top-bar (@campminder/top-bar 0.0.111)Floor 7
super-user-bannercore-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 — injects html body { padding-left: 168px !important; } into document.head. The shell's grid owns that space now.
  • initSearchIntegration() and its setTimeout(setupSearchBarListener, 100) re-arm — polls the DOM forever in any composition without search.
  • document.querySelector('search-bar') at SideNav.ts:129 and :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, the searchReady state and the webComponentsUrl property. It appended a <script type="module"> to document.head pointed at a hardcoded cdn.*.campminder.com host, purely so that search-bar would 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') at TopBar.ts:58, the <search-bar> render at :333, and the enableSearchWidget property. Search is a named <slot name="search">, rendered unconditionally; a shell that wants it fills it, and nothing polls for it. search-bar is therefore not ported in Phase 1.

  • All search-expansion state — searchExpanded, handleSearchExpanded, handleSearchClose, renderSearchCloseButton(), and two document.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. topOffsetPx and leftOffsetPx, the isSideNavCollapsed state, the calculatedLeftOffset getter, syncWithSideNavState(), initSideNavIntegration(), handleSideNavToggle, and the import of shared/navigation-constants.ts (SIDENAV_ANIMATION, SIDENAV_DIMENSIONS, SIDENAV_STORAGE_KEY, SIDENAV_TOGGLE_EVENT). LAYOUT in @campminder/campminder-shell is the single source and the shell's grid applies it. The rendered <section> carries no style attribute at all. Reading side-nav's localStorage key was querying for a sibling.

  • All JS device detection. Utils/device-css-classes.ts and Utils/mobile-detection.ts are not here. Responsiveness is the media queries that were already in styles.ts alongside the class-driven duplicates. See TopBar.styles.ts's comment on the page-title breakpoint for the one behaviour-neutral simplification this exposed.

  • data/data.ts — the base64 fixture, and index.css — page-level :root and body styling, 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 own CloseIcon, so nothing lost it. The remaining five live in assets/TopBarIcons/ behind a barrel, named XSvgIcon, matching side-nav's assets/SideNavIcons/ rather than upstream's unprefixed assets/Icons/ with one lowercase export.

  • Carbon and Material: nothing to strip. Verified 2026-09-08 that no file under core-js/apps/top-bar/src imports @carbon/web-components or @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 declares lit and 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.url in href and in window.open(); popup_properties passed to window.open() verbatim, and then its UI-suppressing features; target="_blank" with no rel, and the popup path with no noopener; feature.value through lit's unsafeHTML; and icon_url / profile_url / logo_url reaching <img src> unguarded. Why each one is refused, and the trust boundary that makes them live rather than theoretical, is stated once — in Utils/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:

UpstreamHere
<top-bar><cm-top-bar>
data (base64 attribute)unchanged — still data, still base64-decoded
usergone — 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-urlgone
—<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 the ResizeObserver that compared scrollWidth against window.innerWidth to toggle an overflowed class (plus the shadow-root id that existed only for its getElementById). Both went because the shell owns layout. Why each one is refused, and the latent defect the observer was carrying, is stated once — in SuperUserBanner.styles.ts, which is where the replacement lives. Worth knowing at this level: the fixed positioning is why ReactAppController has to emit a hasSuperUserBanner class 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/…) and index.css (page-level :root and body styling, a host concern). The @import was also still in TopBar.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 staging got the calm local badge, including the literal production and undefined that the host's GetEnvironmentName() 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; environmentBadgeClass in SuperUserBanner.ts carries 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 no tabindex. Upstream had no overflow rule 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:

UpstreamHere
<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 rootgone — 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): void

It 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-hovered class, 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 dismissFlyout on each slotted navigation — true for Escape, false for an outside pointer press. This component registers nothing on document or window, per the rule above, and mega-menu-isolation.test.ts observes that.

版本与发布记录

@campminder/components 共发布 14 个版本,首次发布于 2026/09/14;过去 12 个月发布了 14 个版本。

版本发布日期距今
0.8.0latest2026/11/10今天
0.7.12026/11/073 天前
0.7.02026/11/073 天前
0.6.12026/11/036 天前
0.6.02026/10/2614 天前
0.5.22026/10/2614 天前

关于 @campminder/components 的常见问题

@campminder/components 是什么?

Published on public npm. Install and render: docs/consuming-the-shell.md. > Working in here: CONTRIBUTING.md.

如何安装 @campminder/components?

在项目目录运行 npm install @campminder/components,或使用 pnpm add / yarn add / bun add。

@campminder/components 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@campminder/components 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@campminder/components 可以商用吗?

@campminder/components 使用 未声明 许可。package.json 没有声明许可证。没有许可证的代码默认保留全部权利,商用前请联系作者或查看仓库中的 LICENSE 文件。以上为概括,不构成法律意见,请以许可原文为准。

@campminder/components 的下载量有多少?

根据 npm 官方下载统计,@campminder/components 在 2026/10/09 – 2026/11/07 这 30 天内被下载 1,842 次,日均约 61 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@campminder/components 还在维护吗?

最新版本 0.8.0 发布于 2026/11/10(今天);至今共发布 14 个版本,其中过去 12 个月发布了 14 个;首次发布于 2026/09/14。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。