@kwtv/admin-components
v0.17.1
Published
Astro components and React islands for the KWTV website admin panel, built on KWTV tokens.
Readme
@kwtv/admin-components
Admin panel components for the KWTV website, built from the KWTV admin design. The package follows the same split as @kwtv/components: presentational components are Astro; controls that manage interaction are React islands. The admin has its own tuned light and dark colour sets, defined in admin.css.
Install
npm install @kwtv/admin-components @kwtv/tokensImport both stylesheets once in your app root:
---
import "@kwtv/tokens/tokens.css";
import "@kwtv/admin-components/styles/admin.css";
import AdminButton from "@kwtv/admin-components/astro/AdminButton.astro";
import AdminStatusBadge from "@kwtv/admin-components/astro/AdminStatusBadge.astro";
---
<div class="admin-theme">
<AdminButton variant="primary" icon="world-upload">
Publiceren
</AdminButton>
<AdminStatusBadge status="draft" />
</div>Astro components render native controls and semantic markup without a React bundle. Inputs, selects, checkboxes, choices, and section cards use native browser behaviour. The switch is a button with role="switch"; its Astro version ships a small script that toggles it, and renders a hidden input when it has a name. For controls with app state, add Astro's React integration and hydrate the island:
---
import AdminCodeInput from "@kwtv/admin-components/react/AdminCodeInput.tsx";
---
<AdminCodeInput client:load />Import several React components from @kwtv/admin-components/react when composing a React island. The React entry also includes presentational counterparts so those islands can stay self contained.
A control that is off for a reason takes disabledReason (read out by screen readers) or disabledReasonId (the id of a visible note). It gets aria-disabled and aria-describedby instead of native disabled, so it stays focusable. Plain disabled stays native.
Every field control takes value and onChange, id, name, required, readOnly and the aria-* props, so a form library such as Payload's useField can drive it. Input, Textarea, Select, PrefixInput, Switch, Check and Choice forward a ref to the focusable element. AdminField gives its control the id and wires the hint, reason and error to it. Their ids come from the control id (adminFieldIds(id)), so an error summary can link to #id. Use group for a radio group, a segmented control or a media picker, which a label element cannot name. Pass a function as children for a control from another library, such as a date picker, and spread the props it gets.
Wrap the admin in .admin-theme. With no theme pinned it follows the system setting, like @kwtv/tokens. Set data-theme="dark" or data-theme="light" on <html> to choose one, or use .admin-theme--light and .admin-theme--dark to pin a theme on the element, for example to show both side by side in a gallery. is-hover, is-focus, and is-active classes pin interaction states for visual review.
Put .admin-theme on <body> when the whole page is the admin. The canvas around and behind the page then shows the admin background too: <html> becomes transparent, so the body background fills it. A rule of your own on html still wins.
Links take --link inside the package's own text containers: notes, field hints and the label row, meta lists, dialog bodies, empty states, the sign-in card and rich text. Anchors elsewhere under .admin-theme keep the host's colour. Add .admin-prose to any container of your own whose links should look the same.
Reduced motion stops every animation and transition under .admin-theme, ::before and ::after included. It follows prefers-reduced-motion, and data-motion="off" on the .admin-theme element forces it. .admin-rm does the same for a subtree.
Hosts with a different root font size
Some sizes come from the token scale, which is in rem. A host with a root font size other than 16px, such as Payload's admin, shrinks them and not the px sizes next to them. Set --kw-rem on :root to keep the scale where it was designed:
:root {
--kw-rem: 16px;
}It must be on :root. See the @kwtv/tokens README.
Brand
AdminMark is the KW mark, a sage-light tile in both themes. It is decorative unless it has a label. AdminBrand is the mark with the name and a second line, as the sidebar and the sign-in card show it. The mark is a placeholder until the final files are ready. Its SVG lives in src/brand/mark.ts.
Astro components
Icon, Mark, Brand, Button, IconButton, Badge, StatusBadge, Tag, LocaleMarks, Input, Select, PrefixInput, Switch, Checkbox, Check, Choice, ChoiceGroup, Avatar, Kbd, Thumb, Skeleton, Progress, Field, Alert, Note, SitePill, SaveState, Breadcrumbs, EmptyState, Meta, Steps, NavItem, Tooltip, Sidebar, AuthCard, StrengthMeter, PageHeader, Panel, Fields, RowList, Row, SiteStatus, SectionCard, SerpPreview.
React islands
Textarea (counter and meter), CodeInput (one field, so paste and autofill work), Segmented, FilterButton, Toast and Toasts (duration, pause on hover and focus), Tabs, LocaleSwitch, Pager, Search (keyboard shortcut), AccountMenu, TopBar, ListToolbar, DataTable, BulkBar, PublishPanel, SectionRow, InsertPoint, DropLine, Tree, TreeItem, RichText, MediaGrid, MediaTile, MediaField, RecoveryCodes, TotpSetup, PasswordField, Shell, Sidebar, MobileBar, AuthCard, Dialog, Drawer.
Icons are Tabler glyphs rendered as inline SVG with the admin-ic class. They size with font-size. The Astro icon component embeds only the glyphs used by the library; React islands use @tabler/icons-react. Import Hanken Grotesk in the application as on the public site.
Icons in React
The React entry ships no icon names. A bundle holds only the icons its app and the components it renders use. Every icon prop, such as icon on AdminButton, AdminBadge or AdminEmptyState, takes a Tabler component or a name.
A component works as is:
import { IconWorldUpload } from "@tabler/icons-react";
<AdminButton icon={IconWorldUpload}>Publiceren</AdminButton>
<AdminIcon icon={IconWorldUpload} />A name, such as "world-upload" or "ti-world-upload", has to be registered first. This is the way for names that come from data, such as a Payload collection config. Register them once, in a client module that loads before the admin renders. In Payload that is a module the admin providers import.
"use client";
import { registerAdminIcons } from "@kwtv/admin-components/react";
import { IconHome, IconWorldUpload } from "@tabler/icons-react";
registerAdminIcons({ home: IconHome, "world-upload": IconWorldUpload });registerAdminIcons adds to one registry for the whole page, so it needs no provider and can run more than once. <AdminIcon name="home" /> and icon="home" then render IconHome.
A name that is not registered renders a circle. In development it also logs one warning per name, which says what to add. Production logs nothing.
node internal/scripts/list-admin-icon-names.mjs <src dir> in this repository scans an app's source for icon names and prints the registerAdminIcons call to paste.
@kwtv/admin-components/react/icons-all registers every name the package knew before 0.7.0, about 140 icons and 49 KB of minified JavaScript. Importing it is enough. The explorer uses it, and it works as a stopgap while an app moves to its own list:
import "@kwtv/admin-components/react/icons-all";Performance
See the performance rules. An app gets long
immutable caching by letting its bundler hash the CSS and font files, and by
serving them with public, max-age=31536000, immutable. The App checklist in
that page has the details.
Stylesheets
styles/admin.css is every admin rule in one request. Keep importing it if your app uses most of the package. The same rules also ship as area files, in cascade order:
core, shell, layout, forms, sections, content, dialogs, lists, media, wireframes, atoms, molecules, patterns, panels, orders, bars.
Import them as @kwtv/admin-components/styles/admin/<area>.css. Always load core first, then the others in the order above. Each file declares the layer order and keeps its rules in the components layer, so the order only matters between areas that style the same element. The area files are the source. Run yarn admin-css:build in the design system repo after editing one, which rewrites admin.css. yarn admin-css:check fails when it is out of date.
Soft navigation
Internal links render as plain <a href>, so a click loads the whole page. Give the package your router's link component once and every internal link in the package uses it: nav items, breadcrumbs, the account menu, table cells, rows, media tiles, calendar events and link buttons. The package has no router dependency.
Wrap the admin in AdminLinkProvider from a client component. With Next:
"use client";
import Link from "next/link";
import { AdminLinkProvider } from "@kwtv/admin-components/react";
export function AdminLinks({ children }: { children: React.ReactNode }) {
return <AdminLinkProvider component={Link}>{children}</AdminLinkProvider>;
}In a Payload app, render AdminLinks in your admin.components.providers entry (or inside your shell glue component), above every component from this package. Run yarn generate:importmap after adding it. A function cannot cross from a server component to a client one, which is why the provider takes the component inside a client file.
External links, mailto:, #hash links, downloads and target="_blank" stay plain anchors. Tabs and the pager are buttons that call onChange, so call router.push there. AdminPager also takes pageHref={(page) => url}: the page buttons then become real links through the provider, and onChange still runs on a plain click. AdminCommandSearch follows item.href with a full load unless you pass onOpenItem, where you can call router.push.
Panel layouts
AdminStack, AdminColumns and AdminActions have React and Astro versions. They use the existing package layout styles, so hosts can arrange panels without local layout CSS.
<AdminStack>
<AdminPageHeader title="Mijn account" />
<AdminColumns>
<AdminStack>{profileAndPasswordPanels}</AdminStack>
<AdminStack>{securityAndSessionPanels}</AdminStack>
</AdminColumns>
</AdminStack>AdminStack separates its children by 24px. AdminColumns uses equal columns with a minimum content width of 360px; narrower hosts stack the children in one column. AdminActions wraps buttons with the package's action spacing. All three accept normal div attributes, including className in React and class in Astro. They add no landmark role. The host supplies headings, labels and behavior.
Shell status lines
AdminTestModeBar and AdminCheckoutPausedLine have React and Astro versions. Both render role="note", use decorative icons and have no close control. The host supplies translated copy. Select test state and settings access on the server. These components do not read payment keys or permissions.
<AdminShell
sidebar={<AdminSidebar groups={groups} />}
topBar={
<AdminTopBar
user={user}
siteUrl={siteUrl}
siteLabel="Bekijk webshop"
showSiteStatus={canReadSiteStatus}
/>
}
mobileBar={
<AdminMobileBar
user={user}
groups={groups}
siteState={canReadSiteStatus ? siteState : undefined}
/>
}
aboveBar={
isTestMode && (
<AdminTestModeBar label="Testmodus:">
betalingen zijn niet echt.
</AdminTestModeBar>
)
}
belowBar={
checkoutPaused && (
<AdminCheckoutPausedLine
action={
canManageSettings
? { href: settingsHref, label: "Heropenen in Instellingen" }
: undefined
}
>
Afrekenen staat op pauze sinds {pauseDate}. Klanten kunnen kijken, maar
niet bestellen.
</AdminCheckoutPausedLine>
)
}
>
{children}
</AdminShell>The copy above illustrates the Dutch defaults used by the shop. In an application pass these strings from its translation namespace, and format pauseDate there. AdminTestModeBar requires label and children. AdminCheckoutPausedLine takes children and an optional action with href and label. Both accept className in React and class in Astro. Astro messages use the default slot.
AdminShell.aboveBar appears above whichever bar is active. belowBar appears below it, before main content. On phones the bars and status lines stay together while scrolling. An open menu starts below them. Both slots are optional, so existing shell calls keep their appearance.
AdminTopBar.showSiteStatus defaults to true. Set it to false to omit the status pill from the rendered tree. siteLabel defaults to Bekijk website; pass translated copy for another public site. The site link keeps its external-link icon, target="_blank" and rel="noopener noreferrer". The mobile bar's existing optional siteState remains its status control.
Payload shell integration
Payload 3.90.1 renders admin.components.header before a host div containing its nav toggler and .template-default. The template contains its own .nav and .template-default__wrap, with AppHeader followed by the view. Use AdminPayloadShellHeader in that header mounting point rather than wrapping the view in AdminShell.
Import the opt-in stylesheet once, after the normal package styles:
import "@kwtv/tokens/tokens.css";
import "@kwtv/admin-components/styles/admin.css";
import "@kwtv/admin-components/styles/payload-shell.css";Keep the theme provider's .admin-theme wrapper. AdminPayloadShellHeader must be its direct child, followed immediately by Payload's host div. This is the DOM produced by Payload's default template when the provider wraps its children. The package adds no Payload or Next runtime dependency.
// The Payload header glue component receives server-filtered data.
return (
<AdminPayloadShellHeader
sidebar={<AdminSidebar groups={groups} onSignOut={signOut} />}
topBar={
<AdminTopBar
user={user}
siteUrl={siteUrl}
siteLabel={t("kwtv:shell:siteLink")}
showSiteStatus={canReadSiteStatus}
/>
}
mobileBar={
<AdminMobileBar
user={user}
groups={groups}
siteState={canReadSiteStatus ? siteState : undefined}
onSignOut={signOut}
/>
}
aboveBar={
isTestMode && (
<AdminTestModeBar label={t("kwtv:shell:testLabel")}>
{t("kwtv:shell:testMessage")}
</AdminTestModeBar>
)
}
belowBar={
checkoutPaused && (
<AdminCheckoutPausedLine
action={
canManageSettings
? { href: settingsHref, label: t("kwtv:shell:reopen") }
: undefined
}
>
{pauseMessage}
</AdminCheckoutPausedLine>
)
}
breadcrumbs={<AdminBreadcrumbs items={breadcrumbs} />}
skipLabel={t("kwtv:shell:skip")}
/>
);Register that glue component in admin.components.header. Keep default collection list and edit views and the theme provider. The package stylesheet hides Payload's own nav, header and toggler only in this opted-in structure. Other Payload templates, auth views and ordinary AdminShell consumers are unchanged. The application's bridge stylesheet can stay limited to token mappings.
The glue that renders AdminPayloadShellHeader must be a client component, since it manages skip-link focus and observes view changes. A server header can prepare authorized data and pass it to that client glue, as the club's Shell and ShellFrame do.
AdminPayloadShellHeader props:
| Prop | Default | Purpose |
| ---------------------- | ------------------------- | ------------------------------------------------------ |
| sidebar | Required | Package sidebar with authorized navigation |
| topBar, mobileBar | Omitted | Desktop or tablet bar, and phone bar |
| aboveBar, belowBar | Omitted | Status slots surrounding the active bar |
| breadcrumbs | Omitted | Content below the status lines |
| mainId | admin-main | Skip-link target on the real Payload content container |
| skipLabel | Naar de inhoud | Translated skip-link label |
| contentSelector | .template-default__wrap | Content selector relative to the theme wrapper |
| className | Omitted | Additional host class |
The skip link focuses the existing content container with tabIndex=-1. Payload's edit view already emits a main; the header preserves it. List views have no main, so the content container gets role="main". Client navigation updates this choice when the view changes. Unmounting restores the original attributes. It adds no second main around an edit screen.
Stable mounting markers are data-admin-payload-shell="header", "sidebar" and "bars". The package classes are .admin-payload-shell__side, __bars, __top, __mobile and __crumbs. The stylesheet opts in only when the theme wrapper directly contains the header. No separate class on body is needed for layout; retain the host's body theme tagging for portals.
For the club's existing ShellFrame, replace its skip-link, sidebar and bar wrappers with AdminPayloadShellHeader and pass the same children as props. Remove only its old shell layout rules from payload-overrides.css; retain unrelated Payload view and field styling. Keep search dialogs as siblings in the theme provider. After updating the glue component, regenerate Payload's import map as usual. Check this mounting structure when upgrading Payload, since its template classes are internal.
Payload list integration
AdminPayloadList is a React wrapper for a collection's beforeListTable slot.
Keep Payload's default list view. Its providers still own access, query, sorting,
pagination, selection and authorized data. Import the opt-in stylesheet after
the normal admin styles:
import "@kwtv/admin-components/styles/payload-list.css";
import { AdminPayloadList } from "@kwtv/admin-components/react";
return (
<AdminPayloadList>
<AdminPageHeader title={title} actions={createAction} />
<AdminListToolbar
query={query}
onQueryChange={setQuery}
filters={filters}
/>
<AdminDataTable columns={columns} rows={authorizedRows} caption={title} />
<AdminPager page={page} pageCount={pageCount} onChange={setPage} />
</AdminPayloadList>
);The host maps Payload's list state and callbacks to those package components.
The wrapper adds no data requests, providers or main landmark. It accepts standard
div attributes, children and className. Its stable marker is
data-admin-payload-list; its package class is .admin-payload-list.
Payload 3.90.1 places beforeListTable directly inside .collection-list__wrap.
Only a .collection-list with that direct mounting structure opts in. The stylesheet
hides its direct .list-header, .list-controls, .collection-list__tables,
.no-results and .page-controls siblings. It preserves other slots, nested and
unmarked lists, shell navigation and breadcrumbs. Lists inside .drawer or
.list-drawer never opt in, even if their slot contains a marker. Check this host
structure when upgrading Payload, since its classes are internal.
The /admin-payload-list/ explorer fixture contains marked, nested, unmarked and
drawer lists. After building the explorer, run its focused browser check:
yarn test:browser internal/tests/admin-payload-list.spec.tsPermission groups
AdminPermissionGroup has React and Astro versions. Import its stylesheet
with the normal admin styles:
import "@kwtv/admin-components/styles/permissions.css";The package renders a fieldset, its legend and checked count, then the labelled
checkboxes with their permission keys. The host supplies all translated copy.
countLabel is a translated template with {selected} and {total} placeholders.
The component replaces those placeholders with its own checked and total counts.
Permission keys must be unique within each group.
<AdminPermissionGroup
title={t("permissions.orders")}
icon={IconReceipt}
countLabel={t("permissions.count")}
items={[
{
key: "order.view",
label: t("permissions.viewOrders"),
checked: true,
disabledReason: t("permissions.requiredByEdit"),
},
{
key: "order.edit",
label: t("permissions.editOrders"),
checked: true,
},
{
key: "order.complete",
label: t("permissions.completeOrders"),
checked: false,
note: t("permissions.openQuestion"),
},
]}
onChange={(key, checked) => updatePermission(key, checked)}
/>React takes controlled items and calls onChange(key, checked) only for an
allowed checkbox change. The host updates items. A disabledReason keeps
the checkbox focusable, applies aria-disabled and prevents pointer and keyboard
changes. Its visible note is associated with that checkbox. reasonIcon can
replace the default link icon. The host decides which permissions depend on
others and enforces those rules on the server.
title and countLabel are required. icon, name, onChange and extra
fieldset attributes are optional in React. The component does not accept native
fieldset disabled, since that would remove locked controls from keyboard focus.
Each item takes key, label, checked, and optional disabledReason,
reasonIcon and note. React labels and notes can be nodes. String labels remain
the checkbox names while keys, reasons and notes become their descriptions.
Astro accepts the same content fields as strings, plus id, class and name.
It uses native checkboxes and updates the translated count after a change.
The Astro icon props use package icon names. Add React's onChange when app
state must compute dependent permissions.
Catalogue fields
Import @kwtv/admin-components/styles/catalogue.css after admin.css for
the catalogue components and reordered tables. The host supplies translated
labels, permissions, validation and persistence. None of these components
fetch data or change stock themselves.
AdminPrefixInput now accepts an optional suffix and an optional prefix.
Both are decoration outside the value. Use suffix={t("characters")} for a
maximum length. React and Astro retain the existing input attributes, reasons
and descriptions. React retains its input ref. Existing prefix calls work as
before. AdminSerpPreview accepts paper and a translated label in React
and Astro. paper keeps the Google preview white with its Google text colours
in both themes. Its default remains the existing theme behavior.
AdminColourInput is a controlled text input with a decorative swatch. It has
React and Astro versions. React accepts the same attributes and input ref as
AdminInput, except type, trailingIcon and defaultValue. value is a
required string. The swatch displays only three or six digit hex colours.
The host validates and stores the text. Wrap it in AdminField to associate
the translated label, error and read-only reason, as for an ordinary input.
AdminErrorSummary has React and Astro versions. It takes title, errors
and optional id, tabIndex (tabindex in Astro) and class. Each error has
an id, fieldId and label. Empty errors render nothing. Links target the
encoded field ID. React forwards its ref to the alert and accepts
onSelect(error, event). The default is a native fragment link. For a hidden
field, prevent that default, open its tab, then focus its input after that
tab has rendered. The package owns the heading and list, while the host owns
tab and field behavior.
AdminOptionGroup is a React island with a fieldset and an ordered list.
Pass title, items and optionally empty. Each item has a stable id,
label, optional hex swatch and optional surcharge node. Format the
surcharge from integer cents in the host. These optional action props add
package controls:
| Props | Behavior |
| --------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| addAction: { label, onClick } | Adds the value button |
| editLabel(item), onEdit(id) | Adds the edit button to each value |
| moveUpLabel(item), moveDownLabel(item), onMove(id, targetIndex) | Adds order buttons and emits a zero-based target |
| firstReason, lastReason | Explains the locked boundary button; without a reason it is natively disabled |
| readOnlyReason | Shows a note and keeps all supplied actions focusable and locked |
The host applies moves to items; the component keeps no separate order.
It also accepts ordinary fieldset attributes except native disabled, which
would remove locked actions from keyboard focus.
AdminVariantMatrix is a React island. It takes caption, cornerLabel,
rows, columns, cells, absentLabel, optional empty, className and
onChange(id, value). Axes have stable id, label and optional swatch.
Cells have id, rowId, columnId, value: number | null and a translated
input label. Optional cell props are stockLabel, error, readOnly,
readOnlyReason and min. Cell IDs become input IDs, so summaries can target
them. Input callbacks emit safe integers or null for an empty field. The
host decides whether those integers represent stock or cents. Without a
callback the cells are read-only. Missing cells show absentLabel. Row and
column headers remain associated with every cell, and the row header stays
visible while the region scrolls on phones. The region is keyboard focusable.
Reordered tables
AdminDataTable accepts an optional controlled reorder object. Import
catalogue.css for its grip placement in desktop rows and phone cards. Pass
stable getRowId values and retain the existing columns and rows:
<AdminDataTable
columns={columns}
rows={categories}
getRowId={(row) => row.id}
reorder={{
label: t("order"),
handleLabel: (row) => t("moveCategory", { name: row.name }),
instructions: t("reorderInstructions"),
onMove: (id, targetIndex) => updateOrder(id, targetIndex),
getDisabledReason: (row, index, target) => boundaryReason(index, target),
announcement: (row, index, total) =>
t("categoryPosition", { name: row.name, position: index + 1, total }),
}}
/>The grip accepts pointer dragging and ArrowUp, ArrowDown, Home and End.
Escape cancels a pointer move. The host applies the zero-based target to its
controlled rows. A rejected move leaves the table order unchanged. The table
announces an accepted move and restores focus to that stable row's grip.
onMove can return void, boolean or Promise<void | boolean>. A synchronous
callback must update the controlled rows in the same commit. An ignored move
clears after that commit. An asynchronous callback returns a promise that settles
after its controlled update. Returning false or rejecting cancels the request.
Returning void or true accepts it only if the controlled rows have reached
the target when the callback settles. Escape, a newer move and unmount cancel
pending requests. Later external row updates do not complete cancelled requests.
onAnnounce(text) optionally receives the same announcement. Supply
getDisabledReason(row, index, targetIndex) for boundaries and blocked moves,
and disabledReason(row, index) to lock a whole row while preserving focus.
Reasons are read with the grip and announced when a blocked move is tried.
Neither callback determines authorization; enforce permission and order rules
in the application's server.
Payload edit integration
AdminPayloadEdit is a React UI field at the start of Payload's native main
field list. Import @kwtv/admin-components/styles/payload-edit.css after the
normal package styles. Keep the default edit form and its providers. Put the
package header, locales, tabs and error summary inside the marker; put main
panels in the remaining main fields and publish panels in native sidebar fields.
The host controls tab visibility with the existing layout components' hidden
attribute. No second form or main landmark is added.
The adapter measures its header to span the native main and sidebar columns. It offsets sidebar panels below the header, keeps two columns at tablet widths and stacks them below 641px. It restores its layout variables on unmount. Only a direct marker in that native main field list installs its observer. Nested markers inside panels do not change the enclosing edit layout.
Payload 3.90.1 views/Edit/index.js in @payloadcms/ui mounts the native
controls as direct form children, then collection-edit__main-wrapper,
collection-edit__main and document-fields. DocumentFields/index.js
mounts the marker under document-fields__main, document-fields__edit and
render-fields. The main render-fields also has document-fields__fields.
Only this exact structure opts in. The stylesheet hides direct native
doc-header and doc-controls siblings, keeping their components mounted for
autosave. Nested markers, unmarked edits and edits inside .drawer or
.document-drawer retain native chrome and layout. Recheck the host structure
and cascade when upgrading Payload. The package adds no Payload dependency.
The /admin-catalogue/ fixture covers React catalogue controls, Astro field
counterparts, native edit scope and controlled reorder callbacks. After building
the explorer, run yarn test:browser internal/tests/admin-catalogue.spec.ts.
