@flarecart/admin-ui
v1.1.0
Published
The small vanilla-JS UI kit FlareCart's admin is built from: buttons, page headers, sortable table headers, switches, toasts, modals, flyouts, confirm and prompt dialogs, a searchable combobox, context and action menus, and an inline SVG icon set. Styled
Maintainers
Readme
@flarecart/admin-ui
The small vanilla-JS UI kit FlareCart's admin is built from: buttons, page headers, sortable table headers, switches, toasts, modals, flyouts, confirm and prompt dialogs, a searchable combobox, context and action menus, and an inline SVG icon set. Tailwind v4 classes, light and dark; markup built with DOM calls, never parsed; no dependencies.
Why
An admin page needs the same dozen pieces every time: a button, a dialog that asks "are you sure?", a list of 180 currencies you can type into, a "⋯" menu on each row, a toast that says it saved. Frameworks bring those along with a build, a runtime and an opinion about state. FlareCart's admin is plain modules and document.createElement, and these are the pieces it's made of, lifted out so another admin page can look and behave the same:
- Nothing a seller types becomes markup. Every element is made with DOM calls and text goes in as text, so a file called
<img onerror=…>.zipis just a long name. - Native where it counts. Modals and flyouts are
<dialog>(focus, Escape and the backdrop for free), the switch is a checkbox, the toast stack is a popover so it shows above an open dialog. - Keyboard and screen readers. Arrow keys through the combobox and menus, Escape closes the innermost thing only,
aria-sorton sortable headers,role="switch", labelled icon buttons. - The same classes as FlareCart. Same Tailwind classes, same theme, so a page built from this sits beside FlareCart's admin without a seam.
Features
- 🧱 Elements:
$andel, the two helpers everything is built with. - 🔘 Controls:
button,pageHeader,toggle,tile,emptyState,loading. - ↕️ Sortable tables:
sortHeader,nextSort,compareValues(natural, case-blind, empties last). - 💬 Feedback:
toast. - 🪟 Dialogs:
modal,flyout(stacks, guards unsaved changes),confirmDialog,promptDialog. - 🔎 Combobox: a searchable select for long lists.
- 📋 Menus:
contextMenuat the pointer,menubehind a "⋯" button. - ✏️ Icons: 45 Lucide-style icons,
addIcons()for your own. - 🌍 Labels: the five words the kit shows itself, in your language with
setLabels(). - 🚫 Zero dependencies.
Install
npm install @flarecart/admin-uiTailwind v4 setup
The components only set class names; Tailwind has to see them to generate the CSS. In your stylesheet:
@import "tailwindcss";
@import "@flarecart/admin-ui/theme.css";
@source "../node_modules/@flarecart/admin-ui/src";@sourcepoints Tailwind at the kit's scripts. The path is relative to the CSS file, so adjust the../to reach yournode_modules. Tailwind v4 skipsnode_moduleswhen it looks for classes, so without it utilities the kit uses and your page doesn't (max-w-[min(20rem,calc(100vw-2rem))],rotate-180,dark:…) are never generated.theme.csscarries the same line relative to itself, so with the import it's belt and braces; keep it if you don't importtheme.css.theme.cssis the part of FlareCart's admin stylesheet the kit depends on:- the look: neutral greys in place of zinc's (
--color-zinc-50to-950), Geist as--font-sansand--font-mono(named, not shipped: load the faces yourself, from thegeistpackage, or the system's fonts stand in),--radius-lgat 6px, and the accent--color-brand-50,-500,-600(blue; the combobox's check mark and the focus ring usebrand-500). Override any of them in your own@themeafter the import. - the component classes the markup uses:
.btn,.btn-sm,.btn-icon,.btn-primary,.btn-secondary,.btn-ghost,.btn-danger,.input,.switch,.label; and the admin's others for your own markup:.card,.badge(-zinc,-green,-amber,-red,-blue),.chip,.menu-item,.shell. dialog::backdrop(dimmed, slightly blurred), a brand:focus-visibleoutline, and the flyout's slide-in (.flyout[open], off underprefers-reduced-motion).
- the look: neutral greys in place of zinc's (
Dark mode is Tailwind's
dark:variant, which by default followsprefers-color-scheme. To switch it with a class instead, redefine it:@custom-variant dark (&:where(.dark, .dark *));.Colours are Tailwind's
zincgreys (remapped to neutral ones by the theme) withred,emerald,amberandskyfor tones; no other custom values.The page itself is yours. FlareCart's admin adds these, which the kit looks right on:
@layer base { html { color-scheme: light dark; scrollbar-gutter: stable; } body { @apply bg-zinc-50 text-zinc-900 antialiased dark:bg-zinc-950 dark:text-zinc-100; } }
Usage
import { button, confirmDialog, el, pageHeader, toast } from '@flarecart/admin-ui';
const remove = button({
label: 'Delete',
icon: 'trash',
variant: 'danger',
onClick: async () => {
if (!(await confirmDialog({ title: 'Delete Night Shift.zip?', message: 'It can’t be undone.', confirm: 'Delete', danger: true }))) return;
await fetch('/api/files/42', { method: 'DELETE' });
toast('Deleted', 'ok');
},
});
document.body.append(el('main', 'shell py-8', pageHeader('Files', '12 files, 1.4 GB', remove)));A sortable table
import { compareValues, el, nextSort, sortHeader } from '@flarecart/admin-ui';
let sort = { column: 'name', desc: false };
const descFirst = new Set(['size', 'created']); // newest and biggest first
function draw() {
const rows = files.slice().sort((a, b) => compareValues(a[sort.column], b[sort.column], sort.desc));
const onSort = (column) => ((sort = nextSort(sort, column, descFirst)), draw());
table.replaceChildren(
el('thead', '', el('tr', '', sortHeader({ label: 'Name', column: 'name', sort, onSort }), sortHeader({ label: 'Size', column: 'size', sort, onSort, end: true, className: 'text-right' }))),
el('tbody', '', ...rows.map((f) => el('tr', '', el('td', '', f.name), el('td', 'text-right', f.size)))),
);
}A flyout that asks before losing changes
import { button, confirmDialog, flyout } from '@flarecart/admin-ui';
let dirty = false;
const { done } = flyout({
title: 'Edit product',
body: [form],
beforeClose: () => !dirty || confirmDialog({ title: 'Discard changes?', message: 'Your edits will be lost.', confirm: 'Discard', danger: true }),
actions: (close) => [button({ label: 'Save', variant: 'primary', onClick: () => close(true) })],
});
if (await done) save();Another language
import { setLabels } from '@flarecart/admin-ui';
setLabels({ close: 'Fermer', cancel: 'Annuler', loading: 'Chargement…', search: 'Rechercher…', noMatches: 'Aucun résultat' });Components read the labels each time they draw, so call it whenever your page knows its language, before or after the import. Everything else they show (titles, button labels, messages) you pass in.
Your own icons
import { addIcons, icon } from '@flarecart/admin-ui';
addIcons({
star: 'M12 2l3 7h7l-5.5 4.5 2 7.5-6.5-4.5L5.5 21l2-7.5L2 9h7z', // one path's d
sun: ['M12 3v1', 'M12 20v1', 'M3 12h1', 'M20 12h1'], // several
dot: [['circle', { cx: 12, cy: 12, r: 3 }]], // any shapes
});
button({ icon: 'star', label: 'Feature' });
icon('star', 'size-5 text-amber-500');Icons are drawn on a 24×24 grid with a 1.75 stroke in currentColor, like Lucide, so a Lucide icon's paths paste straight in.
API
Elements
| | |
|---|---|
| $(id) | document.getElementById, typed. |
| el(tag, className = '', ...children) | An element; children are nodes or text, and null, undefined and false are skipped, so cond ? node : null works inline. |
Controls
| | |
|---|---|
| button({ label, icon, variant = 'secondary', size = '', title, onClick, className }) | variant: primary, secondary, ghost, danger; size: '', sm, icon. title is the tooltip and the accessible name; give one to an icon-only button. |
| pageHeader(title, subtitle, ...actions) | A page's <h1>, the line under it (text or a node), and its buttons on the right. |
| toggle({ checked, label }) | An on/off switch: a checkbox (.checked, change) with role="switch". |
| tile(iconName, colours, size = 'md') | An icon in a rounded tile; colours are its classes, e.g. bg-sky-50 text-sky-600. sm, md, lg. |
| emptyState(iconName, title, text, action?) | "Nothing here yet", with what to do and a button. |
| loading(text?) | A spinner and a line; text defaults to the loading label. |
Sorting
| | |
|---|---|
| sortHeader({ label, column, sort, onSort, className, end }) | A <th> whose button calls onSort(column); shows an arrow and sets aria-sort. end puts the arrow first, for right-aligned columns. |
| nextSort(sort, column, descFirst?) | The same column reversed, or a new one A to Z (biggest first if it's in descFirst). |
| compareValues(a, b, desc) | Numbers by size, text naturally (file2 before file10) and case-blind; null, undefined and '' last either way. |
Feedback and dialogs
| | |
|---|---|
| toast(text, tone = '') | Bottom centre, above any open dialog. tone: '', ok (a check), bad (an alert, kept 8 s instead of 4). |
| modal({ title, body, size = 'md', actions }) | { dialog, done, close }. actions(close) returns the footer's buttons; done resolves with what close() got (undefined for Escape, ✕ or the backdrop). Sizes sm to xl. |
| flyout({ title, subtitle, body, size = 'lg', headerActions, actions, beforeClose }) | A panel from the right, over the page or another flyout. beforeClose (sync or async) can keep it open; close() from code always closes. Also returns setTitle(text) and setBody(nodes). |
| confirmDialog({ title, message, confirm, danger }) | true when confirmed. message keeps its line breaks. |
| promptDialog({ title, label, value, placeholder, confirm, suggestions, select }) | The text, trimmed, or null when cancelled. suggestions become a <datalist>; select: [start, end] picks what's selected first (say, a filename without its extension). |
Combobox
combobox({ choices, value, placeholder, empty, label, className, display, onChange }) returns { node, input, value }: put node in the page, read or set value. choices are { value, label, hint?, keywords? } (or a function returning them, called when first needed); typing filters on every word across label, value and keywords. empty: 'None' adds a choice whose value is null. display(choice) is how the chosen one reads when closed. Escape inside it closes the list, not the dialog it's in.
Menus
| | |
|---|---|
| contextMenu(x, y, items) | A menu at a viewport point (a pointer's clientX, clientY), kept on screen and inside an open dialog. Items: { label, icon, danger, disabled, hint, onClick } or '-' for a divider. Closes on Escape, Tab, a click elsewhere, scroll, resize or blur, and gives focus back. |
| menu({ label, items }) | A "⋯" button and its menu; label names the button ("Price actions"). Items: { label, icon, danger, onClick }. |
Labels
setLabels(partial) changes some of the words and returns all of them; getLabels(), resetLabels(), DEFAULT_LABELS.
| Key | Default | Where |
|---|---|---|
| close | Close | The ✕ on a modal or flyout (its accessible name and tooltip). |
| cancel | Cancel | confirmDialog() and promptDialog(). |
| loading | Loading… | loading() with no text. |
| search | Search… | combobox()'s placeholder. |
| noMatches | No matches | combobox() when nothing matches. |
Icons
icon(name, className = 'size-4') (an unknown name draws file), icons (the set, by name), addIcons({ name: d | d[] | shapes }) (adds or replaces; returns the names).
The set: alert, archive, audio, ban, bucket, check, chevron-down, chevron-right, code, copy, document, download, external, file, flame, folder, folder-input, folder-plus, font, grip, image, key, laptop, link, loader, log-in, log-out, minus, more, package, pencil, phone, plus, refresh, search, shield, sliders, software, sort, tag, trash, upload, user, video, x.
Keys
splitKey('prism/v1.3/Prism.zip') → { folder: 'prism/v1.3/', name: 'Prism.zip' }.
Notes
- Browsers only. The components need a DOM (
<dialog>, popovers,:popover-open): every current browser. Importing the package in Node is fine; calling a component there isn't. - One toast stack per page, created on first use as
#toasts. - No formatting. Dates, money, sizes and "3 minutes ago" are your language's business; FlareCart keeps its formatters with its translations.
- Tests cover what doesn't need a DOM: sorting, labels, the icon set and
addIcons, plusicon(),button()andloading()against a few lines of fake document.
License
MIT
