@zudello/ui-preset
v0.1.1
Published
Reusable UI preset components, design tokens, and theme engine for Zudello apps (Vue, React, iframe bridge)
Downloads
10
Readme
@zudello/ui-preset
Reusable UI components, design tokens, and theme engine for Zudello embedded applications. Ships Vue 3, React 18/19, and a framework-agnostic iframe bridge.
All visual styles are derived from the Zudello customer repo (feature-ui-improvements branch) so embedded apps look identical to the main platform.
Install
npm install @zudello/ui-presetPeer dependencies (install whichever you need):
# React apps
npm install react react-dom
# Vue apps
npm install vue floating-vue
# Dark mode support (any framework)
npm install darkreaderPackage Exports
| Import Path | Contents |
|---|---|
| @zudello/ui-preset | Vue 3 components |
| @zudello/ui-preset/react | React components + UI primitives |
| @zudello/ui-preset/iframe | Iframe bridge (IframeHost, IframeGuest) |
| @zudello/ui-preset/theme | Theme engine (applyTheme, color accents, high contrast) |
| @zudello/ui-preset/theme.css | Design tokens (140+ --zd-* CSS variables) |
| @zudello/ui-preset/global.css | Global component styles (buttons, alerts, dialogs, forms, etc.) |
| @zudello/ui-preset/react.css | React domain component runtime styles (ResourcePageShell, toolbar components) |
| @zudello/ui-preset/grid.css | AG Grid theme overlay |
| @zudello/ui-preset/style.css | Vue scoped component styles |
| @zudello/ui-preset/components.json | shadcn/ui config template |
Quick Start (React Embedded App)
1. Import styles
/* src/index.css */
@import '@zudello/ui-preset/theme.css';
@import '@zudello/ui-preset/global.css';
@import '@zudello/ui-preset/react.css';2. Wrap with EmbeddedAppProvider
import { EmbeddedAppProvider } from '@zudello/ui-preset/react'
function App() {
return (
<EmbeddedAppProvider autoResize>
<MyPage />
</EmbeddedAppProvider>
)
}3. Use components
import {
ResourcePageShell,
ToolbarSearch,
ToolbarStatusFilter,
ToolbarSortSelect,
ToolbarViewToggle,
ToolbarIconButton,
Button,
Dialog,
Alert,
Badge,
Spinner,
type StatusItem,
type SortOption,
ViewType
} from '@zudello/ui-preset/react'Components
Domain Components
These compose the resource list page pattern used across the platform.
ResourcePageShell
Main page container with header, toolbar slot, loading state, and empty state.
<ResourcePageShell
title="Invoices"
count={214}
showCount
loading={false}
empty={items.length === 0}
showClearFilters={hasActiveFilters}
onClearFilters={clearFilters}
toolbar={<>...</>}
>
{/* Your content: AG Grid, list, cards, etc. */}
</ResourcePageShell>| Prop | Type | Default | Description |
|---|---|---|---|
| title | string | '' | Page title |
| count | number | 0 | Item count shown in badge |
| showCount | boolean | false | Show the count badge |
| loading | boolean | false | Show loading spinner |
| empty | boolean | false | Show empty state |
| emptyMessage | string | 'No results for this search.' | Empty state text |
| showClearFilters | boolean | true | Show clear filters button in empty state |
| hideHeader | boolean | false | Hide the entire header row |
| onClearFilters | () => void | — | Clear filters callback |
| toolbar | ReactNode | — | Toolbar content (right side of header) |
| titleSlot | ReactNode | — | Override title with custom content |
| loadingSlot | ReactNode | — | Override loading state |
| emptySlot | ReactNode | — | Override empty state |
ToolbarSearch
Collapsible search input that expands on click.
<ToolbarSearch
value={search}
onChange={setSearch}
collapsible
placeholder="Search invoices..."
/>ToolbarStatusFilter
Popover with colored checkboxes for status filtering.
const [statuses, setStatuses] = useState<StatusItem[]>([
{ key: 'DRAFT', name: 'Draft', color: '#b4b4b4', isEnabled: true, isSelected: true },
{ key: 'REVIEW', name: 'User Review', color: '#fdae61', isEnabled: true, isSelected: true },
{ key: 'COMPLETE', name: 'Completed', color: '#5cb85c', isEnabled: true, isSelected: false },
])
<ToolbarStatusFilter
statuses={statuses}
label="Status"
onToggle={(status) => {
setStatuses(prev => prev.map(s =>
s.key === status.key ? { ...s, isSelected: !s.isSelected } : s
))
}}
onToggleAll={(selectAll) => {
setStatuses(prev => prev.map(s =>
s.isEnabled ? { ...s, isSelected: selectAll } : s
))
}}
/>ToolbarSortSelect
Popover for choosing sort field and direction.
const sortOptions: SortOption[] = [
{ key: 'date_issued', label: 'Date issued' },
{ key: 'company_name', label: 'Supplier name' },
{ key: 'total', label: 'Total' },
]
<ToolbarSortSelect
options={sortOptions}
value={sortField}
ascending={sortAsc}
onValueChange={setSortField}
onAscendingChange={setSortAsc}
/>ToolbarViewToggle
Popover for switching between List, Board, and Table views.
<ToolbarViewToggle
value={viewType}
onChange={setViewType}
/>ToolbarIconButton
Icon button with optional tooltip and notification badge.
<ToolbarIconButton icon="fal fa-filter" tooltip="Filter" badge={hasFilters} />
<ToolbarIconButton icon="fal fa-user-group" tooltip="Show mine" active={meFilter} onClick={toggleMe} />
<ToolbarIconButton icon="fal fa-columns" tooltip="Columns" />Default Resource Toolbar (Gold Standard)
Use DEFAULT_RESOURCE_TOOLBAR_ACTIONS as the package-owned toolbar source of truth.
This keeps icon count/order consistent across consuming apps by default:
import {
DEFAULT_RESOURCE_TOOLBAR_ACTIONS,
ToolbarIconButton,
ToolbarSearch,
ToolbarSortSelect,
ToolbarStatusFilter,
ToolbarViewToggle,
} from "@zudello/ui-preset/react"Default action order is:
search -> users -> filter -> status -> sort -> view -> columns -> automations
The defaults include:
- icon/tooltip for icon actions (including
fal fa-robotforautomations) search.collapsible: true(icon-first search that expands)filter.showBadgeWhen: "filters_applied"(badge hidden by default)
To override a subset without copy-pasting the whole config:
const toolbarActions = DEFAULT_RESOURCE_TOOLBAR_ACTIONS.map((action) =>
action.key === "filter"
? { ...action, tooltip: "Filters" }
: action
)Default-aware usage:
const hasFiltersApplied = !statuses.every((s) => s.isEnabled === s.isSelected)
const toolbar = DEFAULT_RESOURCE_TOOLBAR_ACTIONS.map((action) => {
if (action.key === "search") {
return <ToolbarSearch key={action.key} value={search} onChange={setSearch} collapsible={action.collapsible ?? true} />
}
if (action.key === "filter") {
return (
<ToolbarIconButton
key={action.key}
icon={action.icon}
tooltip={action.tooltip}
badge={action.showBadgeWhen === "filters_applied" ? hasFiltersApplied : false}
/>
)
}
return null
})Putting it together
const toolbar = (
<>
<ToolbarSearch value={search} onChange={setSearch} />
<ToolbarIconButton icon="fal fa-user-group" tooltip="Show mine" active={meFilter} onClick={toggleMe} />
<ToolbarStatusFilter statuses={statuses} onToggle={toggleStatus} onToggleAll={toggleAll} />
<ToolbarSortSelect options={sortOptions} value={sortField} ascending={sortAsc} onValueChange={setSortField} onAscendingChange={setSortAsc} />
<ToolbarViewToggle value={viewType} onChange={setViewType} />
<ToolbarIconButton icon="fal fa-columns" tooltip="Columns" />
</>
)
<ResourcePageShell title="Invoices" count={count} showCount toolbar={toolbar} empty={count === 0}>
{/* AG Grid or list content */}
</ResourcePageShell>UI Primitives
Pre-built components that use the --zd-* design tokens. Every embedded app gets the same visual output.
Button
<Button variant="filled" color="primary" size="md">Save</Button>
<Button variant="secondary">Cancel</Button>
<Button variant="outlined" color="danger">Delete</Button>
<Button variant="text" size="sm">Learn more</Button>
<Button loading>Saving...</Button>
<Button circle icon="fal fa-plus" />
<Button block>Full width</Button>| Prop | Type | Default | Values |
|---|---|---|---|
| variant | string | 'filled' | filled, secondary, text, outlined |
| color | string | 'primary' | primary, secondary, danger, success |
| size | string | 'md' | sm (24px), md (31px), lg (36px) |
| circle | boolean | false | Circular button |
| block | boolean | false | Full width |
| loading | boolean | false | Show spinner, disable clicks |
| icon | string | — | Icon class (e.g. 'fal fa-plus') |
Also accepts all native <button> HTML attributes.
Dialog
<Dialog open={isOpen} onClose={() => setIsOpen(false)} title="Confirm Delete">
<p>Are you sure you want to delete this item?</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 16 }}>
<Button variant="secondary" onClick={() => setIsOpen(false)}>Cancel</Button>
<Button color="danger" onClick={handleDelete}>Delete</Button>
</div>
</Dialog>| Prop | Type | Description |
|---|---|---|
| open | boolean | Show/hide the dialog |
| onClose | () => void | Called on backdrop click or Escape key |
| title | string | Dialog title |
| description | string | Subtitle text |
Alert
Includes default icons per variant matching the customer platform (Font Awesome Light).
<Alert variant="success" title="Saved">Your changes have been saved.</Alert>
<Alert variant="danger">Something went wrong.</Alert>
<Alert variant="warning" title="Warning">This action cannot be undone.</Alert>
<Alert variant="info">Processing may take a few minutes.</Alert>
<Alert variant="danger" icon={false}>No icon alert.</Alert>
<Alert variant="info" icon="fal fa-bell">Custom icon.</Alert>Default icons: info = fa-info-circle, warning = fa-lightbulb, success = fa-check-circle, danger = fa-exclamation-triangle.
Switch
<Switch checked={enabled} onChange={setEnabled} label="Enable notifications" />
<Switch checked={active} onChange={setActive} success />Input
<Input placeholder="Enter name..." />
<Input icon="fal fa-search" placeholder="Search..." />
<Input small placeholder="Compact input" />Select
<Select
options={[
{ value: 'draft', label: 'Draft' },
{ value: 'active', label: 'Active' },
]}
value={status}
onChange={e => setStatus(e.target.value)}
placeholder="Select status"
/>Badge & Tag
<Badge color="purple">New</Badge>
<Badge color="green">Active</Badge>
<Badge color="red">Overdue</Badge>
<Tag color="grey">Category</Tag>
<Tag color="yellow">Pending</Tag>Badge colors: grey, purple, red, green, yellow, highlight, team.
Tag colors: grey, purple, red, green, yellow.
RadioGroup
Card-style radio options (used in settings pages).
<RadioGroup
name="approval"
options={[
{ value: 'auto', label: 'Auto', description: 'System decides based on rules' },
{ value: 'on', label: 'On', description: 'Always require approval' },
{ value: 'off', label: 'Off', description: 'Skip approval' },
]}
value={approval}
onChange={setApproval}
/>Avatar
Auto-generates background color and initials from name (18 preset colors matching the customer platform).
<Avatar name="Nathan Chung" size="lg" />
<Avatar name="Bryson Camp" size="md" />
<Avatar src="/photo.jpg" alt="User" size="sm" />
<Avatar initials="ZD" color="#8b3694" />Other Primitives
<Spinner /> // Loading spinner (sm, md, lg)
<Tabs tabs={[{key: 'a', label: 'Tab A'}, {key: 'b', label: 'Tab B'}]} active="a" onChange={setTab} />
<Collapsible title="Advanced Settings" defaultOpen={false}>{children}</Collapsible>
<Tooltip content="More info">{children}</Tooltip>
<EmptyState icon="fal fa-inbox" title="No items" description="Try adjusting your filters" />
<FormSection label="Notifications" help="Choose how you want to be notified">{children}</FormSection>
<ContextMenu items={[...]} position={{x, y}} onSelect={handleSelect} onClose={close} />
<Dropdown items={[...]} onSelect={handleSelect} />Iframe Bridge
Type-safe postMessage wrapper for host-guest communication between the Zudello platform and embedded apps.
Message Flow
Host (Zudello platform) Guest (embedded app in iframe)
───────────────────── ─────────────────────────────
← ready
theme-change →
auth-token →
module-context →
← navigate
← resize
← open-document
← notificationMessage Types
| Direction | Message | Payload |
|---|---|---|
| Host → Guest | theme-change | { theme: string, highContrast?: boolean, colorAccent?: string } |
| Host → Guest | auth-token | { token: string } |
| Host → Guest | module-context | { module: string, submodule?: string, title?: string } |
| Guest → Host | ready | — |
| Guest → Host | navigate | { path: string, query?: Record<string, string> } |
| Guest → Host | resize | { height: number } |
| Guest → Host | open-document | { id: string, module?: string } |
| Guest → Host | notification | { type: 'success' \| 'error' \| 'warning' \| 'info', message: string } |
All messages are sent on the zudello-iframe channel and are ignored by anything not listening on that channel.
Guest Side (Embedded React App)
The easiest way is to use EmbeddedAppProvider which handles everything automatically:
import { EmbeddedAppProvider, useEmbeddedApp } from '@zudello/ui-preset/react'
import { applyTheme } from '@zudello/ui-preset/theme'
function App() {
return (
<EmbeddedAppProvider
autoResize
onThemeChange={(payload) => {
applyTheme({ theme: payload.theme, highContrast: payload.highContrast })
}}
>
<MyPage />
</EmbeddedAppProvider>
)
}
function MyPage() {
const { authToken, theme, moduleContext, guest } = useEmbeddedApp()
// authToken: string | null — JWT from host
// theme: ThemePayload | null — current theme
// moduleContext: ModuleContextPayload | null — which module is active
// guest: IframeGuest — send messages back to host
const handleRowClick = (id: string) => {
guest.openDocument(id, 'purchase-invoices')
}
const handleSave = () => {
guest.notify('success', 'Invoice saved successfully')
}
const handleNavigate = () => {
guest.navigate('/settings/team')
}
return <div>...</div>
}EmbeddedAppProvider and useEmbeddedApp() are React-only APIs. For Vue (or any non-React app), use the manual IframeGuest approach in the next section.
What EmbeddedAppProvider does automatically:
- Creates an
IframeGuestinstance - Sends
readysignal to the host - Listens for
theme-change,auth-token,module-contextmessages - Enables auto-resize (watches
document.body.scrollHeightviaResizeObserver) - Exposes everything via
useEmbeddedApp()hook - Cleans up listeners on unmount
Guest Side (Manual / Non-React)
import { IframeGuest } from '@zudello/ui-preset/iframe'
import { applyTheme } from '@zudello/ui-preset/theme'
const guest = new IframeGuest({ targetOrigin: 'https://app.zudello.com' })
guest.onThemeChange((payload) => {
applyTheme({ theme: payload.theme, highContrast: payload.highContrast })
})
guest.onAuthToken(({ token }) => {
// Store token for API calls
localStorage.setItem('auth_token', token)
})
guest.onModuleContext((ctx) => {
// ctx.module, ctx.submodule, ctx.title
})
guest.enableAutoResize()
guest.signalReady()
// Send messages to host
guest.navigate('/invoices/123')
guest.openDocument('inv-456', 'purchase-invoices')
guest.notify('success', 'Done!')
// Cleanup
guest.destroy()Host Side (Zudello Platform)
import { IframeHost } from '@zudello/ui-preset/iframe'
const iframe = document.querySelector('iframe')!
const host = new IframeHost(iframe, { targetOrigin: 'https://expenses.zudello.com' })
// Wait for guest to be ready, then send initial data
host.onReady(() => {
host.sendTheme({ theme: 'dark-navy', highContrast: false, colorAccent: 'lavender' })
host.sendAuth({ token: currentUserJwt })
host.sendModuleContext({ module: 'expenses', title: 'Expense Claims' })
})
// Listen for guest messages
host.onNavigate(({ path, query }) => {
router.push({ path, query })
})
host.onOpenDocument(({ id, module }) => {
openDocumentModal(id, module)
})
host.onNotification(({ type, message }) => {
showToast(type, message)
})
// Auto-resize iframe to match content height
const unsubResize = host.autoResize()
// When user changes theme in host
host.sendTheme({ theme: 'light-warm', highContrast: false })
// Cleanup
host.destroy()Theme Engine
7 themes + 10 color accents + high contrast mode, matching the Zudello platform exactly.
Themes
| ID | Name | Type |
|---|---|---|
| light | Light | Default, no filters |
| light-soft | Light Soft | brightness 92%, contrast 95% |
| light-warm | Light Warm | Warm sepia overlay |
| dark-blue-gray | Dark Blue Gray | Dark Reader, bg #1e2128 |
| dark-navy | Deep Navy | Dark Reader, bg #1a1b26 |
| dark-charcoal | Cool Charcoal | Dark Reader, bg #282c34 |
| dark-purple | Purple Tinted | Dark Reader, bg #1c1825 |
Usage
import {
applyTheme,
applyColorAccent,
applyHighContrast,
AppTheme,
ColorAccent
} from '@zudello/ui-preset/theme'
// Apply a theme
applyTheme({ theme: AppTheme.DARK_NAVY, highContrast: false })
// Apply a color accent (light themes only)
applyColorAccent(ColorAccent.BLUE, false)
// Toggle high contrast mode
applyHighContrast({ highContrast: true, theme: AppTheme.LIGHT })Typical iframe integration
<EmbeddedAppProvider
onThemeChange={(payload) => {
applyTheme({ theme: payload.theme, highContrast: payload.highContrast })
if (payload.colorAccent) {
const isDark = ['dark-blue-gray', 'dark-navy', 'dark-charcoal', 'dark-purple'].includes(payload.theme)
applyColorAccent(payload.colorAccent, isDark)
}
}}
>Design Tokens
Import @zudello/ui-preset/theme.css to get all 140+ CSS custom properties. These are the source of truth from customer/src/assets/variables/_design-tokens.scss.
Key tokens
/* Colors */
--zd-color-primary: #8b3694;
--zd-color-text-primary: rgb(21, 19, 27);
--zd-color-text-secondary: rgb(53, 44, 58);
--zd-color-text-muted: rgb(101, 105, 114);
--zd-color-surface: rgb(252, 251, 254);
--zd-color-surface-hover: rgb(244, 242, 248);
--zd-color-border: rgb(236, 234, 241);
--zd-color-border-input: #d1d5db;
/* Typography — base is 13px, NOT 16px */
--zd-font-size-xs: 11px;
--zd-font-size-sm: 12px;
--zd-font-size-base: 13px;
--zd-font-size-md: 14px;
--zd-font-size-lg: 16px;
/* Spacing */
--zd-space-1: 2px; --zd-space-2: 4px; --zd-space-3: 6px;
--zd-space-4: 8px; --zd-space-5: 10px; --zd-space-6: 12px;
--zd-space-7: 14px; --zd-space-8: 16px; --zd-space-9: 20px; --zd-space-10: 24px;
/* Radius — default is 6px (md), NOT browser default */
--zd-radius-sm: 4px;
--zd-radius-md: 6px;
--zd-radius-lg: 8px;
--zd-radius-xl: 12px;
/* Shadows */
--zd-shadow-focus-ring: 0 0 0 3px color-mix(in srgb, var(--zd-color-primary) 15%, transparent);
--zd-shadow-popover: 0 4px 16px rgba(0, 0, 0, 0.12), 0 1px 4px rgba(0, 0, 0, 0.06);
/* Button sizing */
--zd-button-height-sm: 24px;
--zd-button-height-md: 31px;
--zd-button-height-lg: 36px;
/* Form controls */
--zd-form-field-height: 36px;
--zd-form-field-height-sm: 31px;Tailwind v4
theme.css includes an @theme block that maps all tokens to Tailwind utilities. Import the CSS and Tailwind picks them up automatically — no JS config needed:
/* Your app CSS */
@import '@zudello/ui-preset/theme.css';
@import '@zudello/ui-preset/global.css';
@import '@zudello/ui-preset/react.css'; /* React apps using package primitives/domain components */Then use in templates: text-zd-sm, bg-zd-surface, rounded-zd, shadow-zd-popover, text-zd-text-muted, etc.
Visual Contract (Token Ownership)
Use this package as the visual source of truth. The contract is:
theme.cssowns foundational design tokens (--zd-*)global.cssowns primitive/component class styling that references those tokensreact.cssorstyle.cssowns framework runtime styles for domain componentsgrid.cssowns grid aliases (--zd-grid-*) that should map to--zd-*when possible- consuming apps own business logic and layout composition, but should not redefine the core
--zd-*token set
Allowed consumer overrides
- Add app-level spacing/layout utilities
- Add route-specific wrappers and composition styles
- Override brand accents through documented theme APIs
Avoid in consumers
- Re-declaring core semantic design tokens already provided by
theme.css(--zd-color-*,--zd-font-*,--zd-space-*, etc.) - Re-defining
--zd-grid-*in app code as a second token source of truth; prefer overriding--zd-*first, then grid aliases only for intentional grid divergence - Hardcoding semantic color hex values where
--zd-*tokens already exist - Re-implementing primitives with raw
zd-*class markup when a React/Vue primitive exists (unless intentionally demonstrating CSS-only recipes)
If you need to diverge, document the reason in the consuming app so future embedded apps do not copy accidental drift.
AG Grid
Import the grid theme overlay alongside AG Grid's own CSS:
@import 'ag-grid-community/styles/ag-grid.css';
@import 'ag-grid-community/styles/ag-theme-alpine.css';
@import '@zudello/ui-preset/grid.css';Wrap your grid with the .zd-grid class and use the canonical grid baseline:
import {
GRID_CHECKBOX_COLUMN,
DEFAULT_GRID_PROPS,
getRowGroupColorStyle,
} from '@zudello/ui-preset/react'
const columnDefs = [
{ ...GRID_CHECKBOX_COLUMN },
{ field: 'status', headerName: 'Status', width: 120, minWidth: 120 },
{ field: 'name', headerName: 'Name', flex: 1 },
]
<div className="zd-grid ag-theme-alpine">
<AgGridReact
{...DEFAULT_GRID_PROPS}
rowData={data}
columnDefs={columnDefs}
getRowStyle={(params) =>
getRowGroupColorStyle(groupStatuses, params.data?.status)
}
/>
</div>Grid Baseline
The package exports two constants that enforce deterministic grid rendering:
GRID_CHECKBOX_COLUMN
Canonical checkbox column definition. The CSS in grid.css targets [col-id="checkbox"] for padding, alignment, and status-rail positioning — consumers must use this colId for the grid to render correctly.
GRID_CHECKBOX_COLUMN = {
colId: 'checkbox', // ← CSS contract: grid.css targets [col-id="checkbox"]
headerName: '',
width: 40, // 40px fixed (matches customer platform)
minWidth: 40,
maxWidth: 40,
pinned: 'left', // Pinned so status rail (3px left border) is visible
lockPosition: true,
resizable: false,
sortable: false,
filter: false,
checkboxSelection: true,
headerCheckboxSelection: false,
}Spread it as the first column. Override individual props if needed (e.g., custom cellRenderer for colored checkboxes):
const columnDefs = [
{ ...GRID_CHECKBOX_COLUMN }, // checkbox column — CSS relies on colId: 'checkbox'
{ field: 'status', ... },
{ field: 'name', ... },
]DEFAULT_GRID_PROPS
Standard AG Grid component props matching the customer platform:
DEFAULT_GRID_PROPS = {
rowHeight: 40, // 40px rows (matches customer)
headerHeight: 36, // 36px header
suppressRowClickSelection: true, // Click opens, doesn't select
theme: 'legacy', // Required for AG Grid v33+ with Alpine CSS
}Spread onto <AgGridReact>:
<AgGridReact {...DEFAULT_GRID_PROPS} rowData={data} columnDefs={cols} />Key specs
| Property | Value | Source |
|---|---|---|
| Row height | 40px | --ag-row-height in grid.css, DEFAULT_GRID_PROPS.rowHeight |
| Header height | 36px | --ag-header-height in grid.css, DEFAULT_GRID_PROPS.headerHeight |
| Cell font | 12px | --ag-font-size in grid.css |
| Cell padding | 12px horizontal | --ag-cell-horizontal-padding in grid.css |
| Checkbox column | 40px, pinned left | GRID_CHECKBOX_COLUMN |
| Status rail | 3px left border | --group-color on .ag-pinned-left-cols-container .ag-row |
| Column separators | Hidden | --ag-header-column-separator-display: none |
| Wrapper radius | 8px | .ag-root-wrapper in grid.css |
--group-color contract
The --group-color CSS custom property drives two visual elements in grid.css:
- Status rail — 3px colored left border on pinned rows (
.ag-pinned-left-cols-container .ag-row) - Checkbox color — border and check/dash color on
.zd-group-checkboxelements
Set it per-row via getRowStyle + getRowGroupColorStyle():
<AgGridReact
getRowStyle={(params) =>
getRowGroupColorStyle(GROUP_STATUSES, params.data?.status)
}
/>Without --group-color, the rail is transparent and checkboxes fall back to the default border color.
Grid Visual Components
Presentation-only primitives for AG Grid status-grouped views:
| Component | Description |
|---|---|
| GridGroupHeader | Collapsible "Active / 2 items" heading bar with chevron, colored name, count pill, optional group checkbox |
| GridCheckbox | Native <input type="checkbox"> with colored border via --group-color and :indeterminate support |
| StatusChip | Colored status label/pill (text or filled variant) |
Grid Rail Helper (--group-color)
The --group-color CSS custom property drives row-level color styling throughout grid.css:
- Left border band — 3px colored left border on pinned rows
- Checkbox border/check color — on
.zd-group-checkboxelements
Set it per-row via AG Grid's getRowStyle, using the provided helper:
import {
getRowGroupColorStyle,
GridGroupHeader,
GridCheckbox,
StatusChip,
type GridGroupStatus,
type StatusItem,
} from '@zudello/ui-preset/react'
const statuses: GridGroupStatus[] = [
{ key: 'active', name: 'Active', color: '#4caf50' },
{ key: 'review', name: 'Review', color: '#ff9800' },
{ key: 'draft', name: 'Draft', color: '#9e9e9e' },
]
// AG Grid getRowStyle callback — sets --group-color per row
<AgGridReact
getRowStyle={(params) =>
getRowGroupColorStyle(statuses, params.data?.status)
}
/>Or use groupColorStyle(color) directly when you already have the hex color:
import { groupColorStyle } from '@zudello/ui-preset/react'
<AgGridReact getRowStyle={(params) => groupColorStyle(params.data?.statusColor)} />Canonical Status Color Palette
GRID_STATUS_COLORS provides the canonical hex values used across the platform for status-grouped grids. Use these when defining StatusItem[] or GridGroupStatus[] to ensure checkbox borders, left-rail bands, and group headers all render with the correct colors:
import { GRID_STATUS_COLORS } from '@zudello/ui-preset/react'
// GRID_STATUS_COLORS = {
// draft: '#9e9e9e', // grey
// review: '#ff9800', // amber
// active: '#4caf50', // green ← matches screenshot
// approved: '#4caf50', // green (alias)
// rejected: '#f44336', // red
// complete: '#2196f3', // blue
// archived: '#607d8b', // blue-grey
// inactive: '#9e9e9e', // grey
// }The --group-color CSS variable uses these same hex values. All visual elements in grid.css that read --group-color (left border, checkbox border, checkbox check/dash mark) will render in the matching color.
Page Configuration Helpers
Every embedded page (suppliers, invoices, purchase orders, etc.) needs its own statuses, columns, sort options, and text. The package provides factory helpers and shared defaults so you can set up any page in a few lines while keeping the UI consistent.
createStatuses(definitions)
Creates a StatusItem[] from minimal definitions. Colors auto-resolve from GRID_STATUS_COLORS when the key matches a known status. isEnabled and isSelected default to true.
import { createStatuses } from '@zudello/ui-preset/react'
// Minimal — colors auto-resolve from GRID_STATUS_COLORS
const INVOICE_STATUSES = createStatuses([
{ key: 'draft', name: 'Draft' }, // → #9e9e9e (grey)
{ key: 'review', name: 'Pending Review' }, // → #ff9800 (amber)
{ key: 'approved', name: 'Approved' }, // → #4caf50 (green)
{ key: 'rejected', name: 'Rejected' }, // → #f44336 (red)
])
// Custom color for non-standard statuses
const PO_STATUSES = createStatuses([
{ key: 'open', name: 'Open', color: '#2196f3' },
{ key: 'partial', name: 'Partially Received', color: '#ff9800' },
{ key: 'complete', name: 'Complete' }, // → #2196f3 (blue)
{ key: 'cancelled', name: 'Cancelled', color: '#9e9e9e' },
])
// Start with a status deselected
const STATUSES = createStatuses([
{ key: 'active', name: 'Active' },
{ key: 'archived', name: 'Archived', isSelected: false },
])Input type: StatusDefinition
| Field | Type | Default | Description |
|---|---|---|---|
| key | string | required | Status identifier |
| name | string | required | Display label |
| color | string | GRID_STATUS_COLORS[key] or #9e9e9e | Hex color |
| isEnabled | boolean | true | Whether it appears in the filter |
| isSelected | boolean | true | Whether it starts selected |
toGroupStatuses(statuses)
Derives GridGroupStatus[] from a StatusItem[]. Use this for getRowGroupColorStyle:
import { toGroupStatuses, getRowGroupColorStyle } from '@zudello/ui-preset/react'
const INVOICE_GROUP_STATUSES = toGroupStatuses(INVOICE_STATUSES)
// AG Grid
<AgGridReact
getRowStyle={(params) =>
getRowGroupColorStyle(INVOICE_GROUP_STATUSES, params.data?.status)
}
/>COMMON_COLUMN_WIDTHS
Shared column widths for columns that appear on every grid page:
import { COMMON_COLUMN_WIDTHS, type ColumnWidthConfig } from '@zudello/ui-preset/react'
// COMMON_COLUMN_WIDTHS = {
// select: { width: 40 }, // checkbox column
// status: { width: 120, minWidth: 120 }, // status column
// }
// Compose with page-specific columns
const INVOICE_COLUMNS: Record<string, ColumnWidthConfig> = {
select: COMMON_COLUMN_WIDTHS.select,
status: COMMON_COLUMN_WIDTHS.status,
invoiceNumber: { width: 150, minWidth: 120 },
vendor: { minWidth: 180, flex: 1 },
amount: { width: 120, minWidth: 100 },
dueDate: { width: 130, minWidth: 110 },
}ColumnWidthConfig type: { width?: number, minWidth?: number, maxWidth?: number, flex?: number }
DEFAULT_GRID_LAYOUT
Standard AG Grid sizing that matches the customer platform:
import { DEFAULT_GRID_LAYOUT } from '@zudello/ui-preset/react'
// DEFAULT_GRID_LAYOUT = { minHeight: 160, rowHeight: 40, headerHeight: 36 }
<AgGridReact
rowHeight={DEFAULT_GRID_LAYOUT.rowHeight}
headerHeight={DEFAULT_GRID_LAYOUT.headerHeight}
/>DEFAULT_SEARCH_PLACEHOLDER / DEFAULT_EMPTY_MESSAGE
Sensible fallbacks. Override per-page with specific text:
import { DEFAULT_SEARCH_PLACEHOLDER, DEFAULT_EMPTY_MESSAGE } from '@zudello/ui-preset/react'
// DEFAULT_SEARCH_PLACEHOLDER = "Search..."
// DEFAULT_EMPTY_MESSAGE = "No results found for this filter."Building Any Resource Page
Here's the generic pattern for creating a new embedded page. All you define is your data — statuses, columns, sort options, and placeholder text. The UI stays consistent automatically.
import { useState, useMemo } from 'react'
import { AgGridReact } from 'ag-grid-react'
import type { ColDef, ValueFormatterParams } from 'ag-grid-community'
import {
// Factories & defaults
createStatuses, toGroupStatuses, statusLabelMap,
COMMON_COLUMN_WIDTHS, GRID_CHECKBOX_COLUMN, DEFAULT_GRID_PROPS,
// Components
ResourcePageShell, ResourceEmptyState,
buildResourceToolbar, GridGroupHeader, IconTextCell,
getRowGroupColorStyle,
ViewType, type StatusItem, type ColumnWidthConfig,
} from '@zudello/ui-preset/react'
// ── 1. Define your row type ──
type InvoiceRow = {
id: string
status: 'draft' | 'review' | 'approved' | 'rejected'
invoiceNumber: string
vendor: string
amount: number
dueDate: string
}
// ── 2. Create statuses (colors auto-resolve) ──
const STATUSES = createStatuses([
{ key: 'draft', name: 'Draft' },
{ key: 'review', name: 'Pending Review' },
{ key: 'approved', name: 'Approved' },
{ key: 'rejected', name: 'Rejected' },
])
const GROUP_STATUSES = toGroupStatuses(STATUSES)
const LABELS = statusLabelMap(STATUSES)
// ── 3. Define columns (spread common widths) ──
const COLUMNS: Record<string, ColumnWidthConfig> = {
select: COMMON_COLUMN_WIDTHS.select,
status: COMMON_COLUMN_WIDTHS.status,
invoiceNumber: { width: 150, minWidth: 120 },
vendor: { minWidth: 180, flex: 1 },
amount: { width: 120, minWidth: 100 },
dueDate: { width: 130, minWidth: 110 },
}
const COLUMN_DEFS: ColDef<InvoiceRow>[] = [
{ ...GRID_CHECKBOX_COLUMN },
{ field: 'status', headerName: 'Status', ...COLUMNS.status, valueFormatter: (p: ValueFormatterParams) => LABELS[p.value] ?? p.value },
{ field: 'invoiceNumber', headerName: 'Invoice #', ...COLUMNS.invoiceNumber },
{ field: 'vendor', headerName: 'Vendor', ...COLUMNS.vendor },
{ field: 'amount', headerName: 'Amount', ...COLUMNS.amount },
{ field: 'dueDate', headerName: 'Due Date', ...COLUMNS.dueDate },
]
// ── 4. Define sort options ──
const SORT_OPTIONS = [
{ key: 'invoiceNumber', label: 'Invoice number' },
{ key: 'vendor', label: 'Vendor' },
{ key: 'amount', label: 'Amount' },
{ key: 'dueDate', label: 'Due date' },
]
// ── 5. Build the page ──
function InvoicesPage() {
const [search, setSearch] = useState('')
const [statuses, setStatuses] = useState<StatusItem[]>(STATUSES)
const [sortField, setSortField] = useState('invoiceNumber')
const [ascending, setAscending] = useState(true)
const [viewType, setViewType] = useState(ViewType.GRID)
const [showMine, setShowMine] = useState(false)
// ... filtering/sorting logic ...
const toolbar = buildResourceToolbar({
search: { value: search, onChange: setSearch, placeholder: 'Search invoices...' },
users: { active: showMine, onToggle: () => setShowMine(c => !c) },
status: {
statuses,
onToggle: (s) => setStatuses(prev => prev.map(x => x.key === s.key ? { ...x, isSelected: !x.isSelected } : x)),
onToggleAll: (all) => setStatuses(prev => prev.map(x => x.isEnabled ? { ...x, isSelected: all } : x)),
},
sort: { options: SORT_OPTIONS, value: sortField, ascending, onValueChange: setSortField, onAscendingChange: setAscending },
view: { value: viewType, onChange: setViewType },
})
return (
<ResourcePageShell title="Invoices" count={filtered.length} showCount toolbar={toolbar}
empty={filtered.length === 0}
emptySlot={<ResourceEmptyState message="No invoices found for this filter." showClearButton onClear={clearFilters} />}
>
<div className="zd-grid-group-layout">
<GridGroupHeader name="All Invoices" count={filtered.length} color="var(--zd-grid-text)" />
<div className="zd-grid ag-theme-alpine" style={{ height: 400 }}>
<AgGridReact
{...DEFAULT_GRID_PROPS}
rowData={filtered}
columnDefs={COLUMN_DEFS}
getRowStyle={(params) => getRowGroupColorStyle(GROUP_STATUSES, params.data?.status)}
/>
</div>
</div>
</ResourcePageShell>
)
}What changes per page: row type, status definitions, column definitions, sort options, placeholder text. What stays the same: all UI components, toolbar composition, grid styling, color palette, layout sizing.
Supplier Presets
Pre-built constants for the suppliers page, built on top of the generic helpers (createStatuses, toGroupStatuses, COMMON_COLUMN_WIDTHS, DEFAULT_GRID_LAYOUT). Use these as-is for the suppliers page, or as a reference for creating your own page presets:
import {
SUPPLIER_STATUSES, // StatusItem[] with canonical GRID_STATUS_COLORS
SUPPLIER_GROUP_STATUSES, // GridGroupStatus[] derived from SUPPLIER_STATUSES
SUPPLIER_SORT_OPTIONS, // SortOption[] — legalName, tradingAs, code
SUPPLIER_COLUMN_WIDTHS, // { select: {width:44}, status: {width:120, minWidth:120}, ... }
SUPPLIER_SEARCH_PLACEHOLDER,// "Search suppliers..."
SUPPLIER_EMPTY_MESSAGE, // "No suppliers found for this filter."
SUPPLIER_GRID_LAYOUT, // { minHeight: 160, rowHeight: 40, headerHeight: 36 }
statusLabelMap, // (statuses) => Record<string, string> — derives labels from StatusItem.name
IconTextCell, // <IconTextCell icon="fal fa-file-lines">{value}</IconTextCell>
} from '@zudello/ui-preset/react'Canonical Example: Suppliers Status Grid
Full toolbar + status-grouped grid using presets and buildResourceToolbar:
import {
EmbeddedAppProvider,
ResourcePageShell, ResourceEmptyState,
buildResourceToolbar, GridGroupHeader, IconTextCell,
getRowGroupColorStyle, statusLabelMap,
GRID_CHECKBOX_COLUMN, DEFAULT_GRID_PROPS,
SUPPLIER_STATUSES, SUPPLIER_GROUP_STATUSES,
SUPPLIER_SORT_OPTIONS, SUPPLIER_COLUMN_WIDTHS,
SUPPLIER_SEARCH_PLACEHOLDER, SUPPLIER_EMPTY_MESSAGE,
ViewType, type StatusItem,
} from '@zudello/ui-preset/react'
import { applyTheme } from '@zudello/ui-preset/theme'
import { AgGridReact } from 'ag-grid-react'
import type { ColDef } from 'ag-grid-community'
type SupplierRow = { id: string; status: string; type: string; legalName: string; tradingAs: string; code: string; taxNumber: string; assignee: string }
const STATUS_LABELS = statusLabelMap(SUPPLIER_STATUSES)
const COLUMN_DEFS: ColDef<SupplierRow>[] = [
{ ...GRID_CHECKBOX_COLUMN },
{ field: 'status', headerName: 'Status', ...SUPPLIER_COLUMN_WIDTHS.status, valueFormatter: (p) => STATUS_LABELS[p.value ?? ''] ?? p.value },
{ field: 'type', headerName: 'Type', ...SUPPLIER_COLUMN_WIDTHS.type, cellRenderer: (p: {value: string}) => <IconTextCell>{p.value}</IconTextCell> },
{ field: 'legalName', headerName: 'Legal Name', ...SUPPLIER_COLUMN_WIDTHS.legalName },
{ field: 'tradingAs', headerName: 'Trading As', ...SUPPLIER_COLUMN_WIDTHS.tradingAs },
{ field: 'code', headerName: 'Code', ...SUPPLIER_COLUMN_WIDTHS.code },
{ field: 'taxNumber', headerName: 'Tax Number', ...SUPPLIER_COLUMN_WIDTHS.taxNumber },
{ field: 'assignee', headerName: 'Assignee', ...SUPPLIER_COLUMN_WIDTHS.assignee },
]
function SuppliersPage() {
const [search, setSearch] = useState('')
const [statuses, setStatuses] = useState<StatusItem[]>(SUPPLIER_STATUSES)
const [sortField, setSortField] = useState('legalName')
const [sortAsc, setSortAsc] = useState(true)
const [viewType, setViewType] = useState(ViewType.GRID)
const [showMine, setShowMine] = useState(false)
// buildResourceToolbar: pass config, get composed toolbar — no hand-ordering needed
const toolbar = buildResourceToolbar({
search: { value: search, onChange: setSearch, placeholder: SUPPLIER_SEARCH_PLACEHOLDER },
users: { active: showMine, onToggle: () => setShowMine(!showMine) },
status: {
statuses,
onToggle: (s) => setStatuses(prev => prev.map(x => x.key === s.key ? {...x, isSelected: !x.isSelected} : x)),
onToggleAll: (all) => setStatuses(prev => prev.map(x => x.isEnabled ? {...x, isSelected: all} : x)),
},
sort: { options: SUPPLIER_SORT_OPTIONS, value: sortField, ascending: sortAsc, onValueChange: setSortField, onAscendingChange: setSortAsc },
view: { value: viewType, onChange: setViewType },
})
return (
<ResourcePageShell title="Suppliers" count={filteredData.length} showCount toolbar={toolbar}
empty={filteredData.length === 0}
emptySlot={<ResourceEmptyState message={SUPPLIER_EMPTY_MESSAGE} showClearButton onClear={clearFilters} />}
>
{/* .zd-grid-group-layout provides gap-8 + pb-24 + minHeight on nested .zd-grid */}
<div className="zd-grid-group-layout">
<GridGroupHeader name="Active" count={filteredData.length} color="var(--zd-grid-text)" />
<div className="zd-grid ag-theme-alpine" style={{ height: 240 }}>
<AgGridReact
{...DEFAULT_GRID_PROPS}
rowData={filteredData}
columnDefs={COLUMN_DEFS}
getRowStyle={(params) => getRowGroupColorStyle(SUPPLIER_GROUP_STATUSES, params.data?.status)}
/>
</div>
</div>
</ResourcePageShell>
)
}
function App() {
return (
<EmbeddedAppProvider autoResize onThemeChange={(p) => applyTheme({ theme: p.theme, highContrast: p.highContrast })}>
<SuppliersPage />
</EmbeddedAppProvider>
)
}shadcn/ui Integration
For apps that need additional primitives beyond what this package ships, a components.json template is included.
# Copy the template to your app root
cp node_modules/@zudello/ui-preset/components.json .
# Generate components
npx shadcn@latest add button dialog switch input radio-groupGenerated components will use Zudello tokens automatically because your CSS imports theme.css. See the shadcn setup doc for parity adjustments.
CSS Imports Cheatsheet
/* React minimum — tokens + primitives + domain runtime styles */
@import '@zudello/ui-preset/theme.css';
@import '@zudello/ui-preset/global.css';
@import '@zudello/ui-preset/react.css';
/* Vue minimum — tokens + global styles + Vue scoped component styles */
@import '@zudello/ui-preset/theme.css';
@import '@zudello/ui-preset/global.css';
@import '@zudello/ui-preset/style.css';
/* AG Grid (if using) */
@import 'ag-grid-community/styles/ag-grid.css';
@import 'ag-grid-community/styles/ag-theme-alpine.css';
@import '@zudello/ui-preset/grid.css';Order matters — theme.css first (defines tokens), then global.css, then framework runtime CSS (react.css or style.css), then app overrides.
Shared Types
interface StatusItem {
key: string
name: string
color: string // Hex color for the status dot
isEnabled: boolean // Whether this status appears in the filter
isSelected: boolean // Whether this status is currently selected
}
// Minimal input for createStatuses() — only key and name are required
interface StatusDefinition {
key: string
name: string
color?: string // Falls back to GRID_STATUS_COLORS[key]
isEnabled?: boolean // Default: true
isSelected?: boolean // Default: true
}
interface SortOption {
key: string
label: string
}
enum ViewType {
LIST = 'LIST',
BOARD = 'BOARD',
GRID = 'GRID'
}
type CheckboxState = 'none' | 'some' | 'all'
interface GridGroupStatus {
key: string
name: string
color: string
}
// Generic column width config — spread into AG Grid ColDef
interface ColumnWidthConfig {
width?: number
minWidth?: number
maxWidth?: number
flex?: number
}Development
# Vue dev server (port 5188)
npm run dev
# React dev server (port 5189)
npm run dev:react
# Build everything
npm run build
# Build individual targets
npm run build:vue
npm run build:react
npm run build:iframe
npm run build:themeArchitecture
src/
├── components/ # Vue 3 components (9)
├── react/ # React components (11 domain + 18 primitives)
│ └── primitives/ # Button, Dialog, Input, Switch, Alert, Badge, etc.
├── iframe/ # Framework-agnostic postMessage bridge
│ ├── bridge.ts # Low-level send/receive
│ ├── host.ts # Host-side API (Zudello platform)
│ ├── guest.ts # Guest-side API (embedded app)
│ └── types.ts # Message type definitions
├── presets/ # Page-specific preset configs (built on factories/defaults)
│ └── suppliers.ts # Supplier page presets (example for other pages)
├── theme/ # Theme engine
│ ├── themes.ts # 7 theme definitions
│ ├── applyTheme.ts # DarkReader integration
│ ├── colorAccents.ts # 10 color accent variants
│ └── highContrast.ts # Accessibility mode
├── utils/
│ ├── color.ts # pickContrastColor
│ ├── grid.ts # getRowGroupColorStyle, groupColorStyle
│ └── status.ts # statusLabelMap, selectedStatusCount
├── styles/
│ ├── theme.css # Design tokens + Tailwind @theme
│ ├── global.css # Component CSS classes
│ └── zd-grid.css # AG Grid overlay
├── factories.ts # createStatuses, toGroupStatuses — generic config builders
├── defaults.ts # COMMON_COLUMN_WIDTHS, DEFAULT_GRID_LAYOUT, ColumnWidthConfig
├── grid.ts # GRID_CHECKBOX_COLUMN, DEFAULT_GRID_PROPS — canonical grid baseline
└── types.ts # Shared interfaces (StatusItem, SortOption, ViewType, GRID_STATUS_COLORS)