@pagix/feature-highlights-gallery
v0.1.0
Published
Anvilkit Puck-native feature highlights gallery — a horizontally scrolling slide carousel with a title, square image cards, prev/next arrows, and a per-card full-screen popup; every card's and every button's colours, sizes, padding, radius and shape are P
Readme
@pagix/feature-highlights-gallery
Puck-native feature highlights gallery — a replica of the Huawei consumer-site "为什么选择华为手机?" module. A centered headline over a horizontally scrolling CSS scroll-snap track of square image cards, each with a kicker, a headline and a round ⊕ button that opens a full-width popup of sub-cards. Desktop gets a pair of prev/next arrows under the track.
Its reason for existing versus its three siblings (plp-product-gallery,
focused-product, feature-image-text): every visual property of every card
and every button is Puck-configurable — globally, and overridden per card.
RSC-safe by design: no "use client", no React hooks in the root component. The
prev/next arrows are wired by a per-instance inline <script> that calls
scrollBy and toggles disabled at the scroll ends (the plp-product-gallery /
variant-banner pattern). The ⊕ popup is the one interactive piece that needs
state, so it lives in a sibling "use client" file and reuses
@anvilkit/ui's dialog.
Install
pnpm add @pagix/feature-highlights-gallery @anvilkit/ui @puckeditor/coreThe package entry imports its own CSS side-effect (styles.css).
Usage
import {
FeatureHighlightsGallery,
type FeatureHighlightsGalleryProps,
} from "@pagix/feature-highlights-gallery";
const props: FeatureHighlightsGalleryProps = {
title: "为什么选择华为手机?",
cards: [
{
image: "/img/camera.png",
subtitle: "影像",
headline: "全焦段,出片真实有质感",
popup: {
subtitle: "影像",
headline: "全焦段,出片真实有质感",
subCards: [
{
image: "/img/macro.png",
title: "长焦微距",
description: "远距离也能拍出细节。",
layout: "half",
},
],
},
},
],
// Global defaults; every one is overridable per card.
cardBackground: "",
cardWidth: 0,
cardHeight: 0,
cardPadding: 40,
cardRadius: 8,
buttonBackground: "",
buttonIconColor: "",
buttonIconSize: 16,
buttonWidth: 36,
buttonHeight: 36,
buttonShape: "circle",
};
<FeatureHighlightsGallery {...props} />;In Puck, register the config factory:
import { createFeatureHighlightsGalleryConfig } from "@pagix/feature-highlights-gallery";
const config = {
components: {
FeatureHighlightsGallery: createFeatureHighlightsGalleryConfig({ locale: "zh" }),
},
};The factory accepts { locale?: "en" | "zh" | "ja" | "ko", messages? } and
translates the editor chrome (field labels, style-target labels, item summaries,
a11y labels). Per-key fallback order: messages → locale pack → en.
Props
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| title | string | "Why choose a Huawei phone?" | Centered section headline; empty → the catalog's defaultTitle |
| cards | HighlightCard[] | 4 sample cards | The slides |
| cardBackground | string | "" | Global card background; empty → bg-card |
| cardWidth | number | 0 | Global card width (px); 0 → the five-step responsive width |
| cardHeight | number | 0 | Global card height (px); 0 → square (1:1) |
| cardPadding | number | 40 | Global content-area padding (px) |
| cardRadius | number | 8 | Global card corner radius (px) |
| buttonBackground | string | "" | Global ⊕ background; empty → a muted foreground tint |
| buttonIconColor | string | "" | Global ⊕ icon color; empty → text-foreground |
| buttonIconSize | number | 16 | Global ⊕ glyph size (px) |
| buttonWidth | number | 36 | Global ⊕ width (px) |
| buttonHeight | number | 36 | Global ⊕ height (px) |
| buttonShape | "circle" \| "rounded" \| "square" | "circle" | Global ⊕ shape |
| classNames | Record<string, string> | — | Tailwind passthrough per style target (see below) |
| animation | AnimationProps | — | Entrance animation (none / fade-in / slide-up / slide-down / zoom-in) |
HighlightCard
| Field | Type | Description |
| --- | --- | --- |
| image | string | Image URL or data URL (the editor field takes a link or an upload); empty → a muted placeholder |
| imageAlt | string | Alt text; defaults to headline |
| subtitle | string | Kicker above the headline, e.g. 影像 |
| headline | string | Card headline |
| style | HighlightCardStyle? | Per-card overrides of the five global card properties |
| buttonStyle | HighlightCardButtonStyle? | Per-card overrides of the six global ⊕ properties |
| popup | HighlightPopup | Content of the popup this card's ⊕ opens |
Override semantics — "" and 0 mean "not authored"
HighlightCardStyle and HighlightCardButtonStyle declare every key as
required rather than optional. "" (colors, shape) and 0 (sizes) are the
"inherit" markers, so a card falls through to the matching global prop, which in
turn falls through to the design system.
Required-not-optional is also a type constraint: an optional key types as
Field<T | undefined>, which the shared colorField factory deliberately does
not satisfy — the same reason plp-product-gallery declares its per-card colors
as required strings.
buttonStyle.shape additionally offers an explicit Inherit global option
(value ""). Without it, defaultItemProps would pin "circle" on every new
card and the global buttonShape field would have no visible effect.
HighlightPopup / HighlightPopupSubCard
| Field | Type | Description |
| --- | --- | --- |
| subtitle | string | Popup kicker |
| headline | string | Popup headline (rendered as the dialog title) |
| subCards | HighlightPopupSubCard[] | Sub-cards; empty → the ⊕ renders but opens nothing |
| subCards[].layout | "full" \| "half" | full spans the popup width, half sits two-up on desktop |
| subCards[].background | string | Panel background; empty → bg-muted/40 |
| subCards[].textColor | string | Panel text color; empty → text-muted-foreground |
Style targets
Eight targets, each stamped with data-ak-style-node / data-ak-style-target
via anvilTargetAttrs in every render branch and every mode:
| Target | Element |
| --- | --- |
| root | The <section> (stamped by anvilRootAttrs) |
| title | The <h2> |
| track | The scroll container |
| card | Each <article> |
| cardImage | The image wrapper inside a card |
| cardContent | The kicker + headline block inside a card |
| cardBtn | The ⊕ trigger (a <button> live, an inert <span> in editMode) |
| nav | The arrow group |
card, cardImage, cardContent and cardBtn repeat per card.
A target is a class of element, so one generated rule styles every
occurrence — the same treatment plp-product-gallery gives item.
The ⊕ popup panel is deliberately not a target: @anvilkit/ui's
DialogContent renders its panel behind an open dialog, so it is absent from
the server-rendered markup that the authoring-parity suite inspects, and the
editMode branch renders an inert <span> rather than a dialog — no
declaration could hold in both branches. tooltip and select make the same
call for the same reason. Popup paint is authored through the
subCards[].background and subCards[].textColor props instead (see below).
Huawei original — exact parameters
Measured from the source module; this is the fidelity target the responsive classes implement.
| Property | Original value |
| --- | --- |
| Headline | Centered, bold, three tiers: 48 / 32 / 24px |
| Card background | White |
| Card width | 428 / 374 / 340 / 272 / 240px (five tiers, see below) |
| Card aspect | Square, 1:1 (width = height) |
| Card radius | 8px |
| Card padding | 40px 52px 60px 40px (top right bottom left) |
| Card kicker | 16px, gray #666 |
| Card headline | 28px, bold, black |
| ⊕ button | 36 × 36px, rounded-full, bottom-right of the card |
| Arrows | Right-aligned, desktop only, gap: 12px |
| Popup backdrop | Black translucent + backdrop-filter: blur(30px) |
| Popup container | Centered, white, max-width: 1560px, padding: 32px 80px 80px |
| Popup close | 36 × 36px circle, top-right |
Responsive breakpoints
| Breakpoint | Card | Card headline | Kicker | Container padding |
| --- | --- | --- | --- | --- |
| ≥ 1441 (pc-big) | 428px | 28px | 16px | 0 80px |
| 1440–1080 (pc) | 374px | 24px | 16px | 0 80px |
| 1079–768 (tablet) | 340px | 24px | 16px | 0 40px |
| 767–480 (mobile) | 272px | 20px | 14px | 0 16px |
| < 480 (xs) | 240px | 18px | 12px | 0 16px |
Known deviations from the original
Each is a deliberate trade, not an oversight:
- Card padding is a single px value, not a CSS shorthand. The original’s
40px 52px 60px 40pxwould need atextfield or a four-field group. This package uses a plain{ type: "number" }per the series convention (plp-product-gallery), which cannot express per-side values. Set the cardpaddingto40and use thecardContentstyle target orclassNames.cardContentfor asymmetric padding. - Card radius is one value for all four corners. The original only uses a
uniform
8px, so a single field covers the real case; per-corner radii are not offered. - The popup backdrop is the design-system one, not
blur(30px)over black.DialogContentrenders its ownDialogOverlayinternally and exposes no prop to reach it, and the repo forbids patching imported base components. The default isbg-black/10+backdrop-blur-xs. - The popup close button is the design-system one (
absolute top-2 right-2), not a 36 × 36 circle — same reason as (3). cardWidthis a single px value, so an authored width replaces the whole five-step responsive ladder. A fixed pixel width cannot also be responsive; leaving it0keeps the ladder.- Arrows are hidden below
md, matching the original, so the mobile experience is touch/trackpad scroll only. - Fidelity is limited by the utility scale for two tiers: the 32px headline
becomes
md:text-[32px]and 48px becomesxl:text-[48px]— arbitrary values, so exact — butmd:/xl:are used as the nearest tier boundaries rather than the original’s own breakpoints. - The popup panel carries no style target, so it is not restyleable from
the style panel. It renders only behind an open dialog, and the §6 contract
requires every declared target to be stampable in both the production and
edit-mode DOM. Use the
subCards[].background/subCards[].textColorprops instead.
Accessibility
- Card images carry
alt; the ⊕ trigger has a localizedaria-label. - The popup is a real dialog from
@anvilkit/ui: focus trap, Escape and outside-click dismissal, scroll lock andaria-modalcome from Base UI. - The track is a native scroll container — touch swipe, trackpad scroll and scrollbar-free keyboard scrolling work without JavaScript. The arrow script only adds stepping.
- Prev/next buttons expose a real
disabledstate at the scroll ends. - Entrance animations respect
prefers-reduced-motion.
editMode
The ⊕ renders as an inert aria-hidden <span> and the arrows' script is not
bound, so the editor canvas stays click-safe. The style-target stamps are
identical in both modes — edit mode may change affordances, never target
structure.
Authoring surface (PLAN-0025 §6 / PLAN-0027)
src/config.ts declares metadata.anvilkit.editor.styleTargets (no version
field — the contract is structural). Every declared target id is stamped on a
real element in every render branch, and every stamped element appears in the
map — the workspace authoring-parity suite enforces both directions, which is
why a portal'd panel cannot be declared. Extend the map only together with the
matching stamp and the feature-highlights-gallery.targets.<targetId> label in
all four catalogs.
Two authored props ride the same targets:
classNames?: Record<string, string>— Tailwind passthrough per target id, merged after the base classes so authored classes win. A class only takes effect if the host page's compiled CSS contains it.animation?: { preset, durationMs?, delayMs?, easing? }— entrance preset applied to the root viaanimationAttrs(); the keyframes live insrc/styles.cssbehind@media (prefers-reduced-motion: no-preference).
Neither is stamped into defaultProps, and neither are the hidden
appearance / interactions / bindings carriers from ...authoringFields.
src/authoring.ts is a verbatim per-package copy locked by the workspace
authoring-contract suite.
i18n
Four locale catalogs (en / zh / ja / ko) with identical key sets,
enforced by the workspace test suite. Keys are flat and slug-namespaced:
feature-highlights-gallery.label, .defaultTitle, .a11y.*,
.fields.<path>.label, .fields.<path>.placeholder,
.fields.<path>.itemSummary ({index} placeholder), .targets.<id>.
defaultProps page content stays English by convention; the Chinese copy from
the original module lives in the zh catalog and in the table above.
