Linchpin's admin component library for WordPress plugins — the chrome and the brand, on top of @wordpress/admin-ui.
See CHANGELOG.md for release history.
| Workflow | Status |
|---|---|
| Release | |
| JavaScript |
Every Linchpin plugin was building the same admin screen: a branded top bar with the plugin's logo, its version and the Linchpin mark; a page header; tabs; a two-column body with a help sidebar; an About Linchpin card. Mantle, Psst and linchpin-blocks each arrived at that arrangement separately, and each carried its own copy of it.
This package is that arrangement, once. It is a thin layer on top of core — components
come from @wordpress/components, @wordpress/ui and @wordpress/admin-ui, and what lives
here is the Linchpin-specific chrome and the brand contract that makes a plugin look like
ours.
It is not a design system. It does not re-implement buttons, cards, form controls or tables,
and it does not rebuild the page header: <LinchpinAdminPage> wraps core's Page, so the
full header
— breadcrumbs, title, subtitle, badges, actions and section navigation — is the one WordPress
is standardising on.
npm install @linchpinagency/uiEvery @wordpress/* package is a peer dependency. The consuming plugin already has them,
and @wordpress/dependency-extraction-webpack-plugin bundles rather than externalises
admin-ui, ui and icons — so a nested copy would double the bytes and fork the
ThemeProvider context. Never add them as dependencies of this package.
import {
AboutLinchpinCard,
currentSection,
defineBrand,
HelpCard,
LinchpinAdminFooter,
LinchpinAdminFrame,
LinchpinAdminLayout,
LinchpinAdminPage,
LinchpinAdminTopBar,
LinchpinNotices,
sectionNavigation,
SettingsCard,
} from '@linchpinagency/ui';
import '@linchpinagency/ui/style.css';
const BRAND = defineBrand( {
primary: '#318873',
deep: '#082318',
deepEnd: '#164a3b',
} );
const PLUGIN = { name: 'Psst', slug: 'psst', version: window.psstAdmin.version };
const SECTIONS = [
{ name: 'settings', label: 'Settings' },
{ name: 'secrets', label: 'Secrets' },
{ name: 'health', label: 'Health' },
];
function App() {
return (
<LinchpinAdminFrame
plugin={ PLUGIN }
brand={ BRAND }
topBar={ <LinchpinAdminTopBar logo={ <PsstLogo /> } /> }
>
<LinchpinAdminPage
subTitle="One-time secrets, encrypted in the browser."
navigation={ sectionNavigation( { sections: SECTIONS } ) }
actions={
<Button variant="primary" href={ createUrl }>
Share a secret
</Button>
}
>
<LinchpinNotices />
<LinchpinAdminLayout
label="About Psst"
sidebar={
<>
<HelpCard />
<AboutLinchpinCard />
</>
}
>
<View section={ currentSection( { sections: SECTIONS } ) } />
</LinchpinAdminLayout>
</LinchpinAdminPage>
<LinchpinAdminFooter />
</LinchpinAdminFrame>
);
}The page header belongs to the page, but what goes in it belongs to the screen inside: its primary action, its state, its sections. A screen reaches it from anywhere in its own body, and whatever the page was given directly comes first.
function Declutter() {
const [ section, setSection ] = useState( 'inventory' );
usePageSections( { sections: SECTIONS, current: section, onSelect: setSection } );
return (
<>
<PageHeaderBadges><Badge>Not recording</Badge></PageHeaderBadges>
<PageHeaderActions>
<Button variant="secondary" onClick={ startRecording }>Start recording</Button>
</PageHeaderActions>
<InventoryTable />
</>
);
}PageHeaderActionsgoes after the page's ownactions.PageHeaderBadgesgoes after the page'sbadges. Passbadges={ null }to show only the screen's.PageHeaderSubTitlereplaces the page'ssubTitle.- The fills render through a portal, so they keep the screen's state and context. Outside a
LinchpinAdminPagethey render nothing. usePageSectionsdraws the sections as tabs. A plain click goes toonSelectand switches in place. With noonSelect, the tab is anAdminLink, so it stays on the screen underAdminRouterand is an ordinary link otherwise. An explicitnavigationprop on the page wins.
A data view on a page goes in PageFullBleed. It spans the page body instead of sitting inside its padding, and DataViews' toolbar, first and last columns and footer are padded to the page gutter. The table then starts where the page title does, rather than 24px further in. Notices, empty states and sections beside it stay padded as usual.
<PageFullBleed>
<DataViews data={ rows } fields={ fields } view={ view } onChangeView={ setView } />
</PageFullBleed>A settings screen sits on a page panel that is already a surface, so its sections are flat: SettingsSection draws a real heading (h2 by default), a sentence on what the section is for, and its controls in a stacked column with the design system's large gap. Sections are set apart by space and a rule, not boxed. Pass surface="card" for the rare section that has to read as an object of its own. SettingsCard is now an alias of that card form, kept for screens written against it.
PageSaveAction is the screen's one Save, in the page header. It is disabled until something changes and reports the result as a snackbar (render LinchpinNotices once). It asks before the person leaves with unsaved changes. It holds no form state, so it works with react-hook-form or plain state alike. Pass isDirty, and an onSave that returns a promise that rejects when the save fails; with saveEntityRecord, that means throwOnError: true.
<LinchpinAdminPage title="Communication" subTitle="How this site's team reaches Linchpin.">
<PageSaveAction isDirty={ isDirty } onSave={ handleSubmit( save ) } />
<SettingsSection title="Team chat" description="Chat with the Linchpin team from the admin.">
<SwitchControl label="Enable team chat" checked={ chat } onCheckedChange={ setChat } />
</SettingsSection>
<SettingsSection title="Chat window" description="Where people open the chat.">
<RadioGroupControl label="Open the chat from" items={ PLACEMENTS } value={ placement } onValueChange={ setPlacement } />
</SettingsSection>
</LinchpinAdminPage>MenuButton is for a decision with a few answers: record for 15 minutes, an hour or until stopped; clear every cache or one. As sibling buttons those read as separate actions; as one button, they read as the one choice they are. Put what the person needs to know to choose in description. It sits at the top of the menu, and the menu is described by it.
<PageHeaderActions>
<MenuButton
label="Start recording"
description="Recording watches every admin page load, which costs a little each time."
options={ [
{ value: 15, label: 'For 15 minutes' },
{ value: 60, label: 'For 1 hour' },
{ type: 'separator' },
{ value: 0, label: 'Until I stop it' },
] }
onSelect={ startRecording }
/>
</PageHeaderActions>MenuButton is built on Menu and Button from @wordpress/ui, so the plugin needs @wordpress/ui 0.25 or later. LinchpinAdminFrame opts into @wordpress/ui's compat overlay slot, so menus and popovers stack above core's Page even where wp-components isn't enqueued.
WordPress names the screen in the browser title and highlights it in its own admin menu, but only for the screen the page loaded on. A plugin that switches screens in place calls useAdminChrome( screenTitle ) from whatever renders the current screen. The browser title and the WordPress menu then follow, under AdminRouter or the plugin's own routing. The menu item marked current is the submenu link whose query arguments the address carries, all of them and the most of them. So ?page=mantle&view=declutter marks the Declutter link, and a screen no link names falls back to the bare ?page=mantle one.
Below 782px, LinchpinAdminNav folds behind one control naming the open screen ("Menu: Security"), so the screen leads instead of a full-height menu above it. Choosing a screen or pressing Escape folds it again.
The stylesheet must load after wp-components. Declare it as a dependency when enqueuing:
wp_enqueue_style(
'psst-admin',
PSST_URL . 'build/admin.css',
[ 'wp-components' ],
PSST_VERSION
);Every screen built on this package follows eight rules. The components carry most of them, so a new screen gets them without anyone having to remember them.
- The page panel is the surface. Settings sections sit straight on the page, set apart by space and a rule (
SettingsSection). A card is for peers in a grid, and nothing bordered goes inside a card. - One left edge. Title, tabs, section headings, fields and a table's toolbar all start on the page gutter. Data views run full bleed.
- The header owns the verbs. The screen's actions go in the header, right-aligned. Its status is a header badge, and its intro sentence is the subtitle (
PageHeaderActions,PageHeaderBadges,PageHeaderSubTitle, or the page's own props). The body never starts with a toolbar row. - One Save. A settings screen saves from the header with one label, disabled until something changes, and reports the result as a snackbar (
PageSaveAction). There is no save button per section and no Back button. - Gaps, not margins. Every group of siblings is a
Stackwith a token gap. Components never set their own outer margin. - One choice, one control. Mutually exclusive answers are a
MenuButton, aRadioGroupControlor aSelectControl, never a row of buttons. - Headings follow the page. The page title is the
h1, each section anh2and a group inside one anh3.SettingsSectionrenders the heading, so a screen cannot skip a level. - One notice.
LinchpinNoticewherever a notice is shown, with the actions in the right-hand column beside the dismiss. A fact about the whole install goes inLinchpinGlobalNoticesonce, never on each screen that depends on it. The result of something the person just did is a snackbar.
The spacing between things, all existing --wpds-* tokens:
| Between | Token | |
|---|---|---|
| A heading and its description | --wpds-dimension-gap-xs |
4px |
| Buttons in a row, header actions | --wpds-dimension-gap-sm |
8px |
| The page edge and everything on it | --lp-admin-gutter |
16px |
| One field and the next | --wpds-dimension-gap-lg |
16px |
| One section and the next, with a rule | --wpds-dimension-gap-2xl |
32px |
No hex in a component. Colour comes from a --wpds-* design token, from the agency
palette the frame publishes as --lp-color-*, or from the --lp-brand-* properties it sets
out of defineBrand(). A plugin recolours itself by changing its brand object, nothing else;
Linchpin blue is the default when it names nothing.
The About Linchpin copy is fixed. <AboutLinchpinPage> and <AboutLinchpinCard> take no
copy props. The wording is marketing, it gets revised, and the point of the component is that
a revision reaches every plugin through a version bump instead of one pull request per
repository. <HelpCard> is the exception and takes props — support routing genuinely differs
per plugin.
1.0 moves the library onto @wordpress/ui and gives screens a way into their header. Most of it is additive. The changes a plugin can notice:
@wordpress/ui0.25 or later is a peer dependency. Install it alongside the other@wordpress/*packages. With@wordpress/admin-ui3,@wordpress/components43 and@wordpress/theme3, npm resolves one copy of it.SettingsCardis drawn with@wordpress/ui'sCard. Its padding, radius and stroke now match the cards around it, and itssizeprop is ignored. It is deprecated in favour ofSettingsSection, flat or withsurface="card".DangerZone,HelpCard,FeatureListCard,AboutLinchpinCard,AboutLinchpinPageandLinchpinNotice's dismiss button are drawn with@wordpress/ui, and keep the same props. Links that open a new tab now say so to screen readers.LinchpinAdminFrameturns on@wordpress/ui's compat overlay slot, so menus, popovers and tooltips stack above the page. WordPress already does this whereverwp-componentsis enqueued.- Section tabs size to their label. They keep the 44px height and drop the 44px minimum width.
- Below 782px,
LinchpinAdminNavfolds behind one control naming the open screen. - New:
PageHeaderActions,PageHeaderBadges,PageHeaderSubTitle,usePageSections,SettingsSection,PageSaveAction,useUnsavedChangesWarning,MenuButton,LinchpinGlobalNotices,useSessionDismissals,useAdminChrome,retitleandmarkCurrentMenuItem.
npm install
npm run build # babel to build-module/, sass to build-style/
npm test # jest + @testing-library/react
npm run lint:js
npm run lint:css
npm run storybook # the components, renderedDocs live in docs/ui and publish to docs.linchpin.com. Storybook shows what a
component looks like; the docs say what we have decided about it. Neither restates the other.
GPL-2.0-or-later. © Linchpin.
