@pagix/series-nav
v0.1.0
Published
Anvilkit Puck-native PLP series navigation — 6-card horizontal grid with image + title + link, no carousel/popup.
Readme
@pagix/series-nav
Puck-native PLP series navigation — a strip of six product-series cards (square image + series name), each one clickable. No carousel, no popup, no title: the whole card is the link.
Replica of the Huawei PLP top series nav (#mate / #pura / #pocket /
#nova / #changxiang / accessories).
Install
pnpm add @pagix/series-navPeer deps: @anvilkit/ui, @puckeditor/core, lucide-react, react,
react-dom.
Usage
import { createSeriesNavConfig } from "@pagix/series-nav";
import "@pagix/series-nav/styles.css";
const config = createSeriesNavConfig();
// config.render({ id: "node-1", ...config.defaultProps })The static componentConfig export is the English default;
createSeriesNavConfig({ locale, messages }) returns a localized copy for
the Puck inspector.
Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| cards | SeriesCard[] | six Huawei series | One card per series. |
| navAriaLabel | string | "Series navigation" | Accessible name for the <nav>; localized by the factory. |
| containerWidth | "design" \| "sm" \| "md" \| "lg" \| "xl" \| "full" | "design" | Overall width — see Layout. |
| containerAlign | "left" \| "center" \| "right" | "left" | Horizontal placement of the width-capped box. |
| gap | "design" \| "sm" \| "md" \| "lg" \| "xl" | "design" | Space between cards. |
| classNames | Record<string, string> | — | Tailwind passthrough per style target. |
| animation | AnimationProps | — | Entrance animation (preset / durationMs / delayMs / easing). |
animation: { preset: "none" } (and an unset value) emits no attributes at
all. Keyframes live in this package's styles.css behind
prefers-reduced-motion: no-preference.
SeriesCard
interface SeriesCard {
image: { src: string; alt: string };
title: string;
link?: string;
openInNewPage?: boolean;
}image.src empty renders a muted placeholder square rather than a broken
<img>. image.alt empty falls back to title.
Layout
All three layout props default to the source design, so an untouched component renders the SPEC's own ladder exactly. Each is an inspector control in the Properties panel.
containerWidth — "design" keeps the SPEC ladder (full-bleed with
container padding, then max-width: 1760px centred from 1920 up). Every
other tier pins an inline max-width at all breakpoints, which also
replaces the 1920 rule:
| Value | max-width |
| --- | --- |
| design | the ladder above (no inline style) |
| sm / md / lg / xl | 640 / 768 / 1024 / 1200px |
| full | 100% |
The sm–xl tiers are the same px values as centered-hero's
containerWidth, so the two components share one width vocabulary.
containerAlign — mr-auto / mx-auto / ml-auto. With the default
design width the box is already full-bleed, so alignment only becomes
visible once a width tier frees up space; at 1920+ the design ladder
applies its own mx-auto, which stays in charge.
gap — "design" keeps the SPEC ladder (8 → 12 → 16 → 20 → 24px);
the named tiers flatten it to one step at every breakpoint (1→2 / 3→4 /
5→6 / 8→10). The gap belongs to the row, so one value spaces all six
cards equally.
Responsive breakpoints
Mobile-first; the six fixed-width cards cannot fit one viewport below md,
so the track scrolls horizontally there and becomes six equal columns from
md up.
| Breakpoint | Card width | Text | Gap | Container padding |
| --- | --- | --- | --- | --- |
| < 480 | 110px fixed | 12px | 8px | 16px |
| 480–767 | 130px fixed | 14px | 12px | 16px |
| 768–1079 | six equal columns | 14px | 16px | 40px |
| 1080–1440 | six equal columns | 16px | 20px | 80px |
| 1441+ | six equal columns | 16px | 24px | 80px |
| 1920+ | six equal columns | 16px | 24px | 0 (centred, max-width 1760) |
The last two columns are what the design values of gap and
containerWidth resolve to; see Layout for the overrides.
Links
#anchor→ in-page jump. Never getstarget="_blank", even ifopenInNewPageis left on — a new tab for an anchor is always a bug./path→ page link. Opens in a new tab only whenopenInNewPageis explicitlytrue(then withrel="noopener noreferrer").- No
linkat all → the card renders without anhref.
Image field
image is an object field containing a custom src control (URL input +
device upload + thumbnail preview) and a text alt control. Uploads are
stored as data URLs in the document payload — fine for authoring; the
production upload pipeline is @anvilkit/plugin-asset-manager.
The custom sub-field renders its own title through the injected Puck
Label when the host provides one, and the array-item panel wraps its own
FieldLabel instead — so no duplicate title appears in either position.
Style targets
Five targets, all responsive, stamped in edit and production so the shared style compiler can materialize authored CSS:
root (the <nav>) · track (the flex container) · card (one stamp per
card) · card-img (the square wrapper) · card-text (the series name).
Each target grants a slice of the authorable-style vocabulary. Two consumers read that one declaration: the style inspector builds its controls from the grant, and the compiler's allowlist filter decides what survives into CSS — so nothing granted can go unrendered.
In the studio app in this repo those controls are not reachable yet.
The chrome mounts only the Properties panel (StudioLayout.tsx renders
<Puck.Fields /> in #ak-inspector-panel, with no tabs); the tabbed
StylePanel / StudioPuckLayout are exported from @anvilkit/core but
no app here mounts them. The grants remain the contract for hosts that
do, and the containerWidth / containerAlign / gap fields above cover
the same ground from the Properties panel today.
| Target | Use it to set |
| --- | --- |
| root | The module's own box — position, inset, width/height (+ min/max), margin, padding, background, radius. |
| track | The row: gap is the spacing between cards; rowGap/columnGap space a wrapped row's two axes. |
| card | One card's box and spacing — margin is each card's outer spacing, gap is the image↔title spacing inside it. The stamp is identical on all six, so a change applies to every card at once. |
| card-img | The square wrapper — size, radius, overflow. |
| card-text | The series name — typography. |
Two rules worth knowing:
width/heightonrootalso unlock the canvas resize handles. The handle layer answers its eligibility question from the root target's grant (CanvasHandles.tsx), so granting them is what puts a resize grip on the block. Drag-to-move is not part of it: the repo ships no move handle forposition: absolutenodes yet, so placement is set from theposition/insetcontrols in the Style tab.rootwithholdszIndex. This target is the component root, so its stacking would escape into the page and change its relationship to elements the author never selected (ADR 0007 decision 5 — the same rulesliderandtooltipfollow).
Accessibility
- The root is a
<nav>landmark with anaria-label. - There is no heading — this module has no title, per the source design.
- Cards are real
<a>elements, so they are keyboard reachable and show a visible focus ring.
editMode
All interactions are disabled: each card renders as a <span> instead of an
<a>, so no link is clickable while editing. Style-target stamps are still
emitted, and an empty cards array shows an editor-only "add cards" hint
(production renders nothing there).
i18n
Ships en / zh / ja / ko catalogs at i18n/messages/*.json with flat
series-nav.* keys. Only editor chrome is translated (field labels, option
labels, the aria name, the empty hint); defaultProps page content stays as
authored.
createSeriesNavConfig({ locale: "zh" });
createSeriesNavConfig({ messages: { "series-nav.label": "OVERRIDE" } });Unknown locales and missing keys fall back to English.
Independence
This package shares no field factories with its four sibling replicas
(plp-product-gallery, focused-product, feature-image-text,
feature-highlights-gallery). custom-fields.tsx and authoring.ts are
private copies by design — see the family README §0.
