Built-in Components Reference
Martis ships with a set of React components that make up the admin panel UI. All components are designed to be replaceable through the Override System.
Page Components
ResourceIndex
The main listing page for a resource. Displays records in a paginated, sortable, searchable table.
Route: /resources/:resource
Features:
- Paginated data table with configurable per-page options
- Column sorting (click headers to toggle asc/desc)
- Global search with 300ms debounce
- Soft delete support (archive/restore)
- Custom create override detection (drawer or page)
- Row click navigation to detail page
- Three explicit states for the records query (v1.32.4): while the first fetch is pending only the loader shows; a failed fetch renders the inline
QueryErrorState(status, server message, Retry) in place of the table body and fires an error toast; "No records found." renders only for an authoritative empty result. Before v1.32.4 a failed fetch (any 5xx) was rendered as the empty state.
Configurable via Resource PHP:
perPageOptions()— Available page sizesdefaultPerPage()— Initial page sizedefaultSort()/defaultSortDirection()— Initial sorttableStriped()/tableShowGridlines()/tableSize()/tableRowHover()— Table appearance
ResourceDetail
Displays a single record with all its field values in read-only mode.
Route: /resources/:resource/:id
Features:
- Parallel fetch of schema and record data
- Scalar fields displayed in a definition list layout
- HasMany relationships displayed as inline DataTables with full CRUD
- Edit and Delete action buttons
- Soft delete detection with Restore button
- Custom detail override support (drawer mode)
ResourceCreate
Form for creating a new record.
Route: /resources/:resource/create
Features:
- Schema-driven form with all
fieldsForCreate - File upload support via FormData (automatic detection)
- Inline validation errors per field
- Via-relationship support (create child from parent's HasMany)
- Custom create override support (drawer mode)
- Responsive field grids (Section, Panel, Tab) with
colSpan/colSpanMd/colSpanLgsupport
ResourceUpdate
Form for editing an existing record.
Route: /resources/:resource/:id/edit
Features:
- Parallel schema and record fetch
- Pre-populated form with current values
- Smart value filtering (skips unchanged file/BelongsTo objects)
- File upload support
- Via-relationship support for inline editing
Dashboard
Landing page after login. Configurable via config/martis.php:
'dashboard' => [ 'showGreeting' => true, 'showWelcome' => true, 'showMetrics' => true, 'showResourceCards' => true,],Login / Register / 2FA challenge / Error screens
All six unauthenticated surfaces (Login, Register, 2FA challenge, 404, 403, 500) share the components/auth/AuthFrame.tsx shell — dot-grid background, brand-only logo row, centered Shell footer, and a guest-mode theme + language picker in the top-right via AuthControls.tsx.
- Login — email + password +
Keep me signed intoggle. SSO, Google, Forgot password, and "Create an account" render only when the matchingconfig.auth.*.enabledflag is set. Withauth.password_login.enabledoff (v2.21.0) it shows the SSO buttons only, and the form again on?password_login=1when a break-glass address is configured (v2.23.0). - Register —
/registerroute gated byconfig.auth.registration.enabled. Posts to/{martis-path}/api/auth/register(consumer-provided endpoint). - 2FA challenge — 6-cell OTP row with auto-advance + paste-to-fill, a 30 s visual countdown, and a backup-code toggle that swaps the OTP grid for a plain recovery-code input.
- Error pages —
ErrorScreen.tsxdrives 404 / 403 / 500 with a faded watermark code, accent icon, optionalincidentIdchip with copy button, and primary + secondary CTAs.ForbiddenPageandNotFoundPageare on@martis/runtime(v2.2.0+), so a custom page shows the 403 or 404 screen in place.
Challenge widget (v2.21.0). While martis.auth.challenge is enabled, the Login, Forgot password, Reset password, Register and invitation-accept forms render the component registered under the registry key auth:challenge (props { siteKey, surface, onToken, resetSignal }), send its token as challenge_token, and bump resetSignal after a failed submit. A form with the challenge on and no component registered shows a configuration error and does not submit. useAuthChallenge and AuthChallenge are exported by @martis/runtime for an auth:* override that keeps the wiring. See Authentication → Challenge (captcha) hook for the contract and a Cloudflare Turnstile example.
See Authentication for the full config surface (config.auth), backend recipes for SSO / Google, and the registration contract.
Layout Components
Layout (Shell)
The main application shell that wraps all pages. Resolves layout preset from config and checks authentication.
Presets:
| Preset | Component | Description |
|---|---|---|
sidebar | SidebarLayout | Left sidebar + top bar + main content (default) |
topnav | TopnavLayout | Top navigation bar + main content |
minimal | MinimalLayout | Minimal header + main content |
SidebarLayoutis an inner function insideresources/js/components/Layout.tsx, not a standalone file undercomponents/layouts/.TopnavLayoutandMinimalLayouteach live in their own file there. To replace the whole shell, register a component underlayout:shell(componentRegistry.register('layout:shell', MyShell)); to replace one piece, underlayout:sidebar,layout:topbarorlayout:footer. See Overrides → Shell piece-by-piece overrides. To add a component to the top bar without replacing it, register it undertopbar:startortopbar:end(v2.7.0, Top-bar slots).
Give the pages of one resource a layout of their own with layoutRegistry (v1.38.0+):
import { layoutRegistry } from '@martis/runtime'layoutRegistry.register('users', CustomUserLayout)The layout wraps every page of that resource (index, lens, create, detail, update) inside the shell and receives the page as children. See Overrides → Layout Overrides.
Sidebar
Collapsible left navigation panel.
Features:
- Declarative menu sections and items from
/api/navigation - Automatic resource sections generated from
group()when no custom main menu is defined - Expandable group sections
- Dashboard quick link
- Phosphor icons per item/resource
- Collapse state persisted to localStorage
- Responsive: shows icons only when collapsed
Topbar
Top navigation bar with:
- Brand logo/text
- Global search input (configurable: bar, icon, or disabled)
- User menu with theme toggle, profile, and logout
- Mobile-responsive search mode
Command palette (GlobalSearch.tsx)
Global overlay that replaces the old "search sheet". Triggered by ⌘K (macOS) / Ctrl+K (Windows / Linux) anywhere in the app, or by / when focus is outside an input. Mirrors the design-system Catalog spec via the .martis-cmdk-* CSS family.
Ordered sections:
| Section | Source | When it shows |
|---|---|---|
| Resources | /api/command-palette → resources | Always (filtered by query). Each row is the resource label + its navigation group as a hint. |
| Tools | /api/command-palette → tools | Every Tool the user may see. |
| Commands | /api/command-palette → commands | The entries the app registers with Martis::commandPalette() (v2.1.0), with their group as a hint. |
| Actions | /api/command-palette → actions | When any registered resource exposes a standalone action (Action::standalone()) shown on its index that the user may see. The hint column shows the owning resource. |
| Recent activity | /api/command-palette → recent | Only when the query is empty. The user's latest 5 martis_action_events rows, when the user may read the log. Clicking jumps to the affected record when model_id is set. |
| Records | /api/search?q=… | When the query has 2+ characters. Uses the existing unified record-search endpoint. |
Keyboard:
↑/↓— navigate.↵— run the active item.esc— close.⌘K/Ctrl+K— toggle open / closed even while typing in a text field.
Backend route: GET /api/command-palette (CommandPaletteController@index), behind the protected Martis API stack: authentication, the 2FA challenge, the locale, email verification and the viewMartis panel gate when the app defines it. The aggregate is short-cached client-side for 30 s; the record search is debounced 300 ms and re-queries for every distinct query string.
App commands (v2.1.0)
Register commands and deep links with Martis::commandPalette() in app/Providers/MartisServiceProvider.php. The closure returns MenuItems and MenuGroups, the classes the menu builder uses:
use Illuminate\Http\Request;use Martis\Facades\Martis;use Martis\Menu\MenuGroup;use Martis\Menu\MenuItem; Martis::commandPalette(fn (Request $request) => [ MenuItem::link('Open analyses', '/tools/analyses')->icon('chart-line'), MenuItem::tool(ReportsTool::class), MenuGroup::make('Reports', [ MenuItem::externalLink('Status page', 'https://status.example.com') ->canSee(fn (Request $request) => $request->user()?->is_admin), ]),]);- The entries resolve per request as in the menu:
canSee()and a Tool'sauthorizedToSee()apply, so a user never sees a command either of them hides. Soft-gate locks do not hide a command: a locked Tool or Dashboard command stays listed and opens its lock page, as its menu item does. - A
MenuGroup's label becomes the hint of its items. link()takes a path inside the panel (/tools/analyses, not/martis/tools/analyses).externalLink()opens in a new tab.- Calls accumulate, so a Tool or a package can register its own.
- The closure may return an array or a Collection. Any other return value (a single
MenuItem,null) throws anInvalidArgumentExceptionnaming its type, and so does an entry other than aMenuItemor aMenuGroup. - Resolving a resource or Tool item may compute its menu count, as the menu does.
A standalone action shown on its resource's index (the shortcut opens the index) also appears in the palette, under Actions, with the gates the sidebar and the action endpoints apply: the user must pass the resource's viewAny, the action's canSee() (an action it hides is not named in the payload), and the resource must be one the sidebar lists, that is displayInNavigation() is true and no lockedFor() soft gate locks it for the user. A locked resource shows its lock in the sidebar and opens the lock modal on click; the palette does not offer a shortcut that skips it. An action kept off the index (exceptOnIndex(), onlyOnDetail(), onlyInline(), or an action run only from your own components) is not offered (v2.16.0+).
Skip links
The sidebar and topnav layouts start with a "Skip to main content" link (v2.1.0, WCAG 2.2 success criterion 2.4.1). It stays off-screen until it takes keyboard focus, and Enter moves focus to the page content, the <main id="martis-main"> landmark, without changing the URL. The minimal layout has no navigation to skip, so it has no link, but its <main> carries the same id.
A second link, "Skip to navigation" (v2.7.0, translation key navigation.skip_to_navigation), comes right after it. It moves focus to the menu's <nav id="martis-navigation"> landmark, labelled "Main navigation" (navigation.main_navigation), which in the sidebar layout wraps the whole sidebar. On mobile, where the menu is a closed drawer, it moves focus to the menu button (#martis-sidebar-toggle) instead; Enter on that button opens the drawer as a modal dialog. The ids are a contract a replacement sidebar or top bar keeps (see Overrides → Keyboard and screen-reader contract).
Footer
Configurable page footer. Enable/disable and set custom text via config:
'footer' => [ 'enabled' => true, 'text' => 'Powered by Martis',],Override with a custom component:
componentRegistry.register('layout:footer', MyCustomFooter)Data Display Components
Table
The data table used on index pages and HasMany relationship views.
Features:
- Renders field values via
FieldDisplaycomponents - Sortable column headers with visual indicators
- Row selection with checkboxes (prepared for bulk actions)
- Configurable striping, gridlines, size, and hover effects
- Overridable via registry
emptyMessageprop (v1.32.4):undefinedrenders the localised "No records found.";nullrenders a blank spacer (the rows are not authoritative yet: first fetch pending or failed); any node renders as custom copy. The index and lens pages passnulluntil the records query succeeds so the empty state can never describe a failed request.
Configuration (from Resource):
public function tableStriped(): bool { return true; }public function tableShowGridlines(): bool { return false; }public function tableSize(): string { return 'normal'; } // small, normal, largepublic function tableRowHover(): bool { return true; }Pagination
Page navigation controls showing current page, total records, and per-page selector.
The per-page and soft-delete ("trashed") selects of the index, lenses and relationship tables (BelongsToMany, MorphToMany included) are PrimeReact Dropdowns since v2.23.0 (before, native <select> elements), so the control and its option panel follow the theme and dark mode like the filters do. The root keeps the martis-perpage-select class and the compact 2.25rem height; host CSS that targeted select.martis-perpage-select must target .p-dropdown.martis-perpage-select instead. The other native selects (repeater, morph-to, metric range, preferences, the sort chip) are unchanged.
DeleteModal
Confirmation dialog for delete/archive operations.
Behavior:
- Soft delete models: Shows orange "Archive" button
- Hard delete models: Shows red "Delete Permanently" button
- Portal renders to
document.body - Close on ESC or backdrop click
Modal shell (.martis-modal-*)
Every dialog in Martis renders through the same CSS shell so consumer-built overrides line up with the shipped modals. The classes live in resources/css/martis.css and mirror the design-system Catalog spec:
| Class | Role |
|---|---|
.martis-modal-scrim | Fixed-position overlay, centers a single surface. An onClick on it closes the modal on a backdrop click; the modals that hold a form (action, pivot action, inline create) leave it out so a stray click does not lose the form. |
.martis-modal-surface | 480 px default. Size variants: .is-lg (640), .is-xl (800), .is-2xl (960). Always flex-column with max-height: 85vh and body-scroll. |
.martis-modal-head | Title row with a bare icon (no circular disc) + the close button on the far right. |
.martis-modal-head-title | <h3> token — 16 / 600 / -0.01em. |
.martis-modal-body | 18/20 px padding, muted text, overflow-y: auto, grows to fill the surface. |
.martis-modal-foot | Footer row on --martis-surface-alt with a 1 px divider on top. Right-aligned buttons. |
.martis-modal-close | 28 × 28 square close button (X icon). |
Consumer recipe for a custom confirmation dialog:
import type { ReactNode } from 'react'import { XIcon, WarningIcon } from '@phosphor-icons/react'import { createPortal, useModalHistoryLock } from '@martis/runtime' interface Props { open: boolean onCancel: () => void onConfirm: () => void title: ReactNode body: ReactNode} export function DangerConfirm({ open, onCancel, onConfirm, title, body }: Props) { useModalHistoryLock(open) if (!open) return null return createPortal( <div className="martis-modal-scrim" onClick={onCancel}> <div role="dialog" aria-modal="true" className="martis-modal-surface" onClick={(e) => e.stopPropagation()}> <div className="martis-modal-head"> <div className="flex items-center gap-3"> <WarningIcon size={18} weight="fill" style={{ color: 'var(--martis-danger)' }} /> <h3 className="martis-modal-head-title">{title}</h3> </div> <button type="button" className="martis-modal-close" onClick={onCancel}><XIcon size={16} /></button> </div> <div className="martis-modal-body">{body}</div> <div className="martis-modal-foot"> <button type="button" className="martis-btn-secondary" onClick={onCancel}>Cancel</button> <button type="button" className="martis-btn-danger" onClick={onConfirm}>Delete</button> </div> </div> </div>, document.body, )}The useModalHistoryLock(open) hook intercepts the browser back button while the dialog is visible and cooperates with the DrawerShell so closing the dialog, by a button, the back button or Escape (v2.0.0), does not also close the drawer underneath: while a locked dialog is open the drawer ignores Escape, so your dialog handles its own. Its Escape handler also calls e.preventDefault(), which tells any later listener (a drawer's, the app's) that the key was taken. Required whenever a modal nests inside a drawer or the unsaved-changes guard. It is on @martis/runtime since v1.38.0 and has to come from there: it shares a lock count with the drawers, which a copy of the hook would not see. createPortal comes from the runtime too: it is the host's, so the dialog renders with the host's React DOM. Since v1.38.0 import { createPortal } from 'react-dom' reaches the same function, since the extension build sends react-dom to a shim that serves the host's public React DOM API (flushSync since v1.38.2, unstable_batchedUpdates, version and react-dom/client's createRoot and hydrateRoot since v2.10.0; not the legacy render and findDOMNode); on a scaffold published earlier, react-dom resolves to the React shim, which exports React core only (see Refreshing the extension scaffold).
Escape closes the top layer only (v2.0.0)
In a drawer, Escape closes whatever is open on top of it first: a modal, a menu (the actions menu, a row's inline action menu, the lens menu, a Repeater's add menu), a picker (BelongsTo, MorphTo, Tag, the multi-select, the icon picker) or a PrimeReact overlay (Dropdown, MultiSelect, Calendar, AutoComplete, SplitButton, ColorPicker, OverlayPanel). The drawer and the form in it stay; the next Escape closes the drawer, through its unsaved-changes prompt when the form is dirty. Before v2.0, an Escape meant for a menu or a picker in a Create or Update drawer also closed the drawer and lost the form.
The modals that hold a form follow the same rule (v2.16.1): the action modal, the pivot action modal and the inline create modal leave an Escape to a picker, menu or PrimeReact overlay open in their form, and close on the next Escape. Like the DrawerShell they decide in the capture phase, before the form's own handlers (a PrimeReact Dropdown marks every Escape it receives handled, its list open or not, so a later check would never close the modal while one has focus). A modal opened over another one (an inline create modal opened from a picker in an action modal) takes the Escape alone. Up to v2.16.0 one Escape closed the picker and the modal together, or both modals, and the form was lost.
The DrawerShell closes on an Escape nothing else took. It decides in the capture phase, before the layers' own listeners: a key press runs the microtasks between listeners, where React applies a layer's close, so a later check would find nothing open. It leaves the Escape alone when it arrives already handled (defaultPrevented, as every Martis modal and popup marks the Escape it takes), while a modal holds its history lock (useModalHistoryLock()), while a Martis popup is open, or while a PrimeReact overlay is mounted. A dialog of your own calls e.preventDefault() on the Escape it takes; a popup of your own (one that closes on an outside click) joins the same rule with useEscapeLayer(open, close) from @martis/runtime: Escape calls close when it is the top layer, and the drawer underneath stays open.
import { useState } from 'react'import { useEscapeLayer } from '@martis/runtime' export function StatusMenu() { const [open, setOpen] = useState(false) useEscapeLayer(open, () => setOpen(false)) return ( <div> <button type="button" onClick={() => setOpen(true)}>Status</button> {open && <div role="menu">…</div>} </div> )}Picker lists float above modals and drawers (v2.16.1)
The lists of the bundled pickers (BelongsTo, MorphTo, Tag, MultiSelect, the icon picker), the Repeater's add menu and the pivot action menus of a BelongsToMany or MorphToMany panel render in document.body with position: fixed, as PrimeReact's overlays do. A container that scrolls or hides its overflow (an action modal's body, a drawer, a card) no longer cuts them, and opening one never scrolls the container. The list:
- opens under its trigger, and above it when there is not enough room below and more room above; its height is capped by the room on that side;
- follows its trigger while any container scrolls or the window resizes, and hides while a scrolling container has moved the trigger out of view (it shows again when the trigger scrolls back);
- sits over the modals and drawers (
.martis-floating-panel,z-index: 10001; the drawers use 50, the action and inline create modals 9990 to 9999, the unsaved-changes dialog and PrimeReact's overlays 10000). The language switch overlay (10050) and the tooltips (99999) stay above it; - keeps the per-resource accent of the page it opens on (
data-resource-accentor an inline--martis-accent), which it would otherwise lose outside the page's wrapper.
A click on an option keeps the list open where the picker allows several values, and a click anywhere else closes the list only. Escape closes the list first (see above). Up to v2.16.0 the lists were absolute children of their picker, so near the bottom of an action modal only part of the options showed and the user had to scroll the modal to reach them. A theme that restyles .martis-belongs-to-dropdown keeps it free of position, top, left, width and max-height: the panel writes them from its trigger's rectangle. The class is now a flex column (display: flex; flex-direction: column) whose .martis-belongs-to-options list shrinks (flex: 1 1 auto; min-height: 0) when the room left caps the panel.
Index toolbar (.martis-index-toolbar)
The resource index and lens pages share a single card surface (.martis-index-surface) that holds the filter row, the search/per-page/trashed row, the DataTable, and the paginator as one continuous visual block. Toolbar rows render inside .martis-index-toolbar — pad, gap, and bottom border adjust automatically under [data-density="dense"]. Override the paginator or table chrome via the usual override hooks; override the toolbar by writing directly to the same classes in a consumer stylesheet.
Breadcrumbs
Navigation breadcrumbs showing the current path (Dashboard > Resource > Record). A package route names its crumb with an i18n key; a page your app registered shows its crumb as given. A page replaces the last crumb with useDynamicCrumb(label) from @martis/runtime (v2.2.0+), as a Tool page shows the tool's name. The trail is nav.martis-breadcrumbs, each crumb .martis-breadcrumb and its text .martis-breadcrumb-label (v2.14.0): when the bar has no room, the labels shrink and end in an ellipsis, the home link and the chevrons keep their size.
ResourceIcon
Renders Phosphor icons by name. Backed by iconRegistry, a three-tier resolver that gives consumers access to all 1500+ Phosphor icons without the up-front bundle cost.
// In Resourcepublic function icon(): string{ return 'users'; // kebab-case // return 'Users'; // PascalCase also works}Accepts kebab-case (shopping-cart), PascalCase (ShoppingCart), snake_case (shopping_cart), or any of those suffixed with Icon.
In an extension (v2.10.0)
ResourceIcon is on @martis/runtime, with its props type, so an extension renders an icon by name without bundling its own @phosphor-icons/react copy and without bypassing the lazy registry:
import { ResourceIcon } from '@martis/runtime'import type { ResourceIconProps } from '@martis/runtime' export function StatusIcon({ name }: { name: ResourceIconProps['iconName'] }) { return <ResourceIcon iconName={name} size={16} weight="bold" />}The props are iconName, size, className, weight and color. An extension published before v2.10.0 republishes its shims to import it by name (php artisan vendor:publish --tag=martis-extension-shims --force), or reads runtime.ResourceIcon off the default export.
Resolution strategy
iconRegistry.resolve(name) walks three tiers, fastest first:
- Custom registry — consumer-registered icons (synchronous).
- Curated built-ins — 130 most-used Phosphor icons bundled eagerly with the main app chunk (synchronous, no network roundtrip).
- Dynamic CSR import — every other Phosphor icon (1380+) loads on demand from
@phosphor-icons/react/dist/csr/<Name>.es.js. Each becomes its own ~1-3 KB chunk wrapped in<Suspense fallback={null}>. Only icons actually rendered ship to the browser.
Names that don't match any Phosphor export fall back silently to DatabaseIcon so the page never crashes on a typo. Only the registry's own entries resolve: a stored name that is spelled like something every JavaScript object inherits (constructor, __proto__) falls back too instead of crashing the page that renders it (v2.4.0).
Registering custom icons
For icons outside Phosphor (custom SVGs) or to skip the dynamic-import roundtrip on a hot path, register synchronously from the extension entry (iconRegistry is on @martis/runtime since v1.38.0):
// resources/js/martis-extensions/index.tsimport { iconRegistry } from '@martis/runtime'import { CrownIcon } from '@phosphor-icons/react' iconRegistry.register('crown', CrownIcon)Custom registrations win over the built-in curated set (use this to override a built-in too).
Bundle impact
Pre-v0.8 the package imported the entire @phosphor-icons/react namespace inside ResourceIcon, which defeated tree-shaking and produced a ~5 MB minified phosphor-icons-*.js chunk on every build (1 MB gzip).
After v0.8 the curated set bundles eagerly with the main app chunk and the remaining 1380+ icons each become a ~1-3 KB lazy chunk fetched on first render. Total saving on first paint: ~3 MB raw per page load while preserving access to all 1500+ Phosphor icons.
LoadingSkeleton
Skeleton loading placeholders with pulse animation. Displayed while data is being fetched.
Sparkline (components/metrics/Sparkline.tsx)
Tiny SVG area sparkline used by TrendCard when the backend opts into sparkline mode (TrendResult::sparkline()). A custom framed card reuses it from @martis/runtime (v1.38.0+).
import { Sparkline } from '@martis/runtime' <Sparkline values={[32, 38, 41, 44, 52, 60, 70]} variant="inline" color="var(--martis-chart-2)" />| Prop | Default | Description |
|---|---|---|
values | required | Numeric series. Renders nothing when fewer than 2 points. |
color | var(--martis-accent) | Stroke + gradient colour. |
variant | block | block (36px, fills width) or inline (96×28, fits next to a KPI value). |
label | — | Optional aria-label for the SVG. |
ErrorBoundary
Catches and displays React rendering errors gracefully instead of crashing the entire application.
QueryErrorState
Inline error state for a failed listing fetch, rendered in place of the table body on the resource index, lens pages and relationship panels (v1.32.4). Sibling of the full-page ResourceErrorPage, but scoped to the table so the toolbar (search, filters, per-page, trashed) stays usable: the schema loaded fine and the user may just need to fix a filter or retry.
- Triage mirrors the error page: network / transport failure, 403, 404, 5xx, other 4xx, each with its own copy (
messages.query_error_*keys, translated inen/pt_PT/pt_BR). - A detail line shows
HTTP {status} · {server message}(production Laravel returns the generic "Server Error"; the stack trace belongs in the logs). The status is what lets support tell a failure from an empty list on a screenshot. - Retry calls the query's
refetch(); the button is disabled while the retry is in flight and the page loader overlay covers the state. - The index and lens pages also fire an error toast on the transition to the error state (again on each failed retry), so the failure is visible when the user is scrolled away from the table. Relationship panels render the
compactvariant without a toast. - A failed refetch on top of data already held (polling, focus revalidation) keeps the last good rows on screen; the toast is the signal.
How the package's pages render it:
// Package-internal: resources/js/components/QueryErrorState.tsx, not on @martis/runtime.import { QueryErrorState } from '@/components/QueryErrorState' <QueryErrorState error={query.error} onRetry={() => void query.refetch()} retrying={query.isFetching} /><QueryErrorState compact error={query.error} onRetry={() => void query.refetch()} />CSS hooks: .martis-query-error (+ -compact), .martis-query-error-icon, -body, -title, -desc, -detail, -retry. The root carries role="alert" and data-status (500, 403, network, ...).
GlobalSearch
Cross-resource search from the top bar. Configurable via:
'search' => [ 'enabled' => true, 'mode' => 'bar', // 'bar', 'icon', or 'disabled' 'mobileMode' => 'icon', // mobile-specific mode 'placeholder' => 'Search...',],Toast
Notification popup system. Supports 4 types: success, error, warning, info.
Configuration:
'toast' => [ 'position' => 'bottom-right', // top-right, top-left, bottom-right, bottom-left, top-center, bottom-center],Usage in components:
const { addToast } = useToast()addToast('success', 'Record saved successfully')addToast('error', 'Failed to save record')addToast(type, message, options?) (options since v2.21.0):
| Option | Type | Description |
|---|---|---|
link | { href, label } | A link under the message, opened in a new tab. Only a safe href renders. |
life | number | null | Lifetime in ms; null keeps the toast until closed. Beats martis.toast.durations. A value that is neither null nor a finite positive number (0, a negative, NaN, Infinity, a string) is ignored and the configured duration applies; one above 2147483000 is clamped, since a browser timer cannot hold more. |
dismissOnNavigate | boolean | Default false. A sticky toast with it set is removed on route change. |
Without life, the lifetime is the severity's martis.toast.durations value (5000 ms by default); a toast with a link stays at least 10000 ms unless it is sticky. Sticky toasts stack: nothing caps their number or merges repeats, so do not raise one from a loop. A sticky toast the person closes, or that expires or is removed, also leaves the set dismissOnNavigate clears.
// A sticky error that goes away when the person leaves the page.addToast('error', 'Sync failed', { life: null, dismissOnNavigate: true })Drawer Components
Slide-in panels for inline CRUD operations. See Override System for configuration details.
DrawerShell
Reusable container for all drawer overrides.
Features:
- Slide-in animation (configurable left/right)
- Expand/collapse button (toggle width)
- Fullscreen button (100vw)
- Close button + ESC key
- Backdrop click to close
- Portal renders to
document.body - Configurable width (default:
720px, fromconfig('martis.drawer.width')), never wider than the viewport (min(<width>, 100vw)) - At phone width (768px or less, the layout's mobile breakpoint) the drawer is the full viewport and the expand and fullscreen buttons are hidden (v2.18.2)
- While a drawer is open the page behind it does not scroll: the document is pinned (on iOS Safari too) and restored at the same scroll position on close; a drawer opened from a drawer shares one lock (v2.18.2)
- The body is the only vertical scroller (
overscroll-behavior: contain); the header and footer stay pinned, and the panel is100dvhtall so the iOS address bar does not hide the footer (v2.18.2)
DrawerCreate
Wraps the create form inside a DrawerShell. Sections, Panels and Tabs lay their fields out on the responsive field grid (colSpan / colSpanMd / colSpanLg, see Grid Layout); a field outside any layout container is a full-width row.
DrawerUpdate
Pre-populated edit form inside a DrawerShell.
DrawerDetail
Read-only detail display inside a DrawerShell.
Contexts (Global State)
AuthContext
Manages authentication state.
const { user, isLoading, login, logout } = useAuth()user— Current logged-in user or nullisLoading— Whether the session check is in progresslogin(email, password)— Authenticatelogout()— End session and reload
ThemeContext
Manages dark/light theme state.
const { theme, toggle, setTheme } = useTheme()theme—'light'or'dark'toggle()— Switch between themessetTheme(t)— Set theme explicitly- Persists to localStorage (
martis-theme) - Toggles
.darkclass on<html>
useTheme() is package-internal (not on @martis/runtime). An extension reads and sets the theme through usePreferences(): prefs.theme and update({ theme }).
PreferencesContext
Single source of truth for user-tunable preferences (theme, accent, density, locale, reduced motion). Drives the Preferences menu, the per-resource accent override, and the density / reduced-motion CSS hooks (data-density, data-reduced-motion).
import { usePreferences, usePreferencesOptional } from '@martis/runtime' // v1.38.0+ const { prefs, meta, update, reset, enabled } = usePreferences() prefs.theme // 'dark' | 'light' | 'system'prefs.accent // accent token name (e.g. 'martis', 'teal')prefs.density // 'comfortable' | 'dense'prefs.locale // 'en' | 'pt_PT' | 'pt_BR' | …prefs.reducedMotion // boolean await update({ theme: 'light' }) // patch one or many keysawait update((prev) => ({ accent: prev.accent === 'teal' ? 'martis' : 'teal' }))await reset() // back to package defaults| Property | Type | Description |
|---|---|---|
prefs | Preferences | Currently-applied preference set. |
meta | PreferencesMeta | null | Allowed values per axis (themes, accents, densities, locales, presetsAvailable) plus source (default, user, or preset). null while loading. |
update(patch) | (Partial<Preferences> | (prev) => Partial<Preferences>) => Promise<void> | Optimistic patch; persists to the server when authenticated and to localStorage when not. |
reset() | () => Promise<void> | Clears the user override and falls back to the active preset (or package defaults). |
enabled | boolean | false when the preferences subsystem is disabled via config; UI surfaces should hide their controls. |
usePreferencesOptional() is the safe variant that returns null outside the provider — use it from surfaces that may render before the shell mounts.
ToastContext
Centralized notification system.
const { addToast } = useToast()addToast('success', 'Changes saved')Utilities
api.ts
Unified API client with CSRF handling, JSON and multipart support.
import { api } from '@martis/runtime' const data = await api.get<Post[]>('/api/posts')await api.post('/api/posts', { title: 'New Post' })await api.upload('POST', '/api/posts', formValues) // handles file uploadsFeatures:
- Automatic CSRF token injection (cookie or meta tag)
- Same-origin credentials
- Refuses a path with a dot segment (
.or.., also spelt%2e) before it sends anything, with anApiError(status 400): the browser would resolve it to another endpoint. Build every path that holds a route param or a record key withapiPath(orpathSegmentfor one value), exported by@martis/runtime, so a value such as../users/5or5/forcestays one segment:encodeURIComponent()is not enough, since Laravel decodes%2Fbefore it routes. A value holding a literal%2Fthrows anApiError(status 400) (v2.4.0) - File detection and FormData conversion (
hasFileValues()/buildFormData()): on the multipart path booleans travel as1/0and arrays or plain objects as JSON strings, which the resource controllers decode back for structured fields, so a Repeater, MultiSelect, BooleanGroup, KeyValue, Tag, MorphTo or Sparkline value saved alongside a file keeps its shape (see Fields → Structured values and file uploads) - Laravel validation error normalization
ApiErrorclass witherrorsByField()for inline display
config.ts
Reads configuration from window.MartisConfig (set by Laravel's Blade template).
import { config } from '@martis/runtime' config.theme?.default // 'dark' or 'light'config.layout?.preset // 'sidebar', 'topnav', 'minimal'config.search?.enabled // booleanconfig.footer?.text // stringconfig.basePath // e.g. '/martis'The package's own modules also import two constants from @/lib/config, which are not on the runtime: BASE_PATH (config.basePath ?? '/martis') and API_BASE_URL (window.location.origin followed by BASE_PATH, e.g. 'http://app.test/martis'). An extension rarely needs them: api prefixes every request path with API_BASE_URL.
usePrefersReducedMotion (lib/usePrefersReducedMotion.ts)
Reactive React hook that returns true when motion should be paused. Combines the OS-level signal (@media (prefers-reduced-motion: reduce)) with the per-user Martis preference (html[data-reduced-motion="true"] written by PreferencesContext).
import { useEffect } from 'react'import { usePrefersReducedMotion } from '@martis/runtime' // v1.38.0+ const reducedMotion = usePrefersReducedMotion() useEffect(() => { if (reducedMotion) return // skip JS-driven motion el.addEventListener('mousemove', onTilt) return () => el.removeEventListener('mousemove', onTilt)}, [reducedMotion]) // re-runs when the user togglesUse it whenever you write a mousemove-driven parallax, a requestAnimationFrame loop, or any other motion that CSS cannot clamp. CSS-driven animations (Tailwind animate-*, the martis- keyframes) already get clamped to 1ms via the global * selector under both [data-reduced-motion="true"] and prefers-reduced-motion: reduce, so simple CSS animations don't need the hook.
The hook listens to both signals and re-renders on any change, so toggling the Preferences panel switch pauses motion immediately without remount.
i18n.ts
Internationalization setup using react-i18next.
import { useTranslation } from 'react-i18next' const { t } = useTranslation('messages')t('record_deleted') // Translated stringt('delete_confirm') // Fallback to key if not foundNamespaces: resources, messages, actions, navigation
Translations are fetched dynamically from /api/translations/{locale} and cached by react-i18next.
resolveRedirect.ts
Resolves post-CRUD navigation targets:
| Value | Destination |
|---|---|
'detail' | /resources/{resource}/{id} |
'index' | /resources/{resource} |
'edit' | /resources/{resource}/{id}/edit |
'create' | /resources/{resource}/create |
'dashboard' | / |
'stay' | No navigation |
| Custom URL | Replace {id} and {resource} placeholders |
Event Bus
The Martis Event Bus enables decoupled communication between components without prop drilling. An extension reaches it through the martisEventBus singleton on @martis/runtime:
import { useEffect } from 'react'import { martisEventBus, type EventBusEvents } from '@martis/runtime' // Subscribe, and unsubscribe on unmount:useEffect(() => { const onCreated = ({ resourceKey, id }: EventBusEvents['martis:record-created']) => console.log('New record', id, 'in', resourceKey) martisEventBus.on('martis:record-created', onCreated) return () => martisEventBus.off('martis:record-created', onCreated)}, []) // Emit:martisEventBus.emit('martis:record-created', { resourceKey: 'posts', id: '1' })A built-in event types its payload in on, once, off and emit (EventBusEvents, v1.38.0): a handler written inline, martisEventBus.on('martis:record-created', ({ resourceKey, id }) => …), gets both typed.
The package's own components use the useEventBus() hook, which wraps the same singleton and drops every handler it registered when the component unmounts:
// Package-internal: resources/js/lib/useEventBus.ts, not on @martis/runtime.import { useEventBus } from '@/lib/useEventBus' const { on, emit } = useEventBus() useEffect(() => { return on('martis:record-created', ({ resourceKey, id }) => { console.log('New record', id, 'in', resourceKey) })}, [on])Built-in events:
| Event | Payload | Fired when |
|---|---|---|
martis:record-created | { resourceKey, id } | After a record is created, by the page, drawer, panel or inline create that wrote it (v2.3.0) |
martis:record-updated | { resourceKey, id } | After a record is updated, by the page, drawer, panel or inline create that wrote it (v2.3.0) |
martis:record-deleted | { resourceKey, id } | After a record is deleted, by the page, drawer, panel or inline create that wrote it (v2.3.0) |
martis:record-restored | { resourceKey, id } | After a soft-deleted record is restored, by the page, drawer, panel or inline create that wrote it (v2.3.0) |
martis:action-executed | { resourceKey, action, ids } | After an action run that is not a dry run (v2.3.0); dashboard metrics refetch on it |
martis:refresh-index | { resourceKey? } | Request a Resource index to revalidate. Any transport (a consumer's own ws-gateway, SSE, or an Echo listener) can emit this to tell a ResourceIndexPage "another session mutated this resource — refetch." The index invalidates its ['resources', <uriKey>] query when the payload's resourceKey matches its own resource, or on every index when resourceKey is omitted. |
martis:notification-received | { id?, title?, message? } | Pluggable real-time feed for the notification bell — emit this from any transport (a consumer's own ws-gateway, SSE, or an Echo listener) to push a notification into the bell instantly. See docs/notifications.md "Real-time delivery". |
martis:notifications-changed | {} (no payload) | Ask the notification bell to re-fetch its unread count + open list (reconcile after a read / read-all / delete happened elsewhere — the down-direction mirror of martis:notification-received). See docs/notifications.md "Reconciling reads across sessions". |
The id of a record event, and each of the ids of martis:action-executed, is always a string (v2.3.0), as the route param of a record page is: an integer key arrives as '42', and a UUID or ULID key as it is. Compare it with String(record.id).
An override that writes a record through its own API call emits the event itself, with the id as a string: martisEventBus.emit('martis:record-created', { resourceKey: 'reports', id: String(id) }).
Custom events can use any string key. Martis prefixes built-in events with martis:.
An extension emits into native Martis UI the same way, for example a notification pushed into the bell:
import { martisEventBus } from '@martis/runtime' martisEventBus.emit('martis:notification-received', { id: 42, title: 'New order' })useRevalidateOnFocus Hook
Resources and Tools built on useQuery already get refetchOnWindowFocus from the react-query default: the data revalidates automatically when the operator returns to a backgrounded Martis tab. Custom Tools that fetch data manually (no react-query) don't get this for free — useRevalidateOnFocus closes that gap.
import { useCallback, useEffect, useState } from 'react'import { useRevalidateOnFocus } from '@martis/runtime' function MyManualFetchTool() { const [data, setData] = useState(null) const refetch = useCallback(() => { fetch('/api/my-tool-data').then(res => res.json()).then(setData) }, []) useEffect(() => { refetch() }, [refetch]) // Re-fetch whenever the tab becomes visible again or the window regains focus. useRevalidateOnFocus(refetch) return <div>{/* ... */}</div>}The hook takes a single onRevalidate: () => void callback, subscribes to document's visibilitychange (firing only when the page becomes visible) and window's focus, and cleans both listeners up on unmount. It has no dependencies of its own and does not touch react-query — it is purely a DOM-event seam for manual-fetch Tools.
Data freshness across sessions — the mental model
Reason about staleness explicitly rather than discovering it via a duplicate action:
- On focus (built-in). Resource lists/details and any Tool query built on
useQueryrevalidate when the tab regains focus or the network reconnects — the react-query defaults are on. This is gated bystaleTime(30 s): within 30 s of the last fetch the cached data is treated as fresh and no refetch fires; after that, returning to the tab refetches. So a second tab converges on its own, but only after the data has gone stale — not instantly. - Push (opt-in, no broadcaster). For immediate cross-session convergence, bridge your own transport (ws-gateway, SSE, or an Echo listener you write) to
martisEventBus: emitmartis:refresh-index({ resourceKey }) when another session mutates a resource, and useuseRevalidateOnFocusfor manual-fetch Tools. Martis ships no broadcaster — it exposes the seams, you feed them (the same pattern as the notification bell'smartis:notification-received/martis:notifications-changed). - No polling for lists. Unlike the notification bell (which polls), Resource/Tool data is not polled — freshness comes from focus-revalidation + whatever push you wire. If you need tighter guarantees on a specific mutation, pair a backend conflict guard (e.g. a 409 on a stale write) with the push seam above.
useUnsavedChangesGuard Hook
Wraps a form with the package-wide unsaved-changes guard. Reads the resource's confirmUnsavedChanges flag from the schema, snapshots initial values, and intercepts navigation when the form is dirty. The full-page create and update forms use it; a custom create or update override takes it from @martis/runtime (v1.38.0+).
import { useMemo, useState } from 'react'import { useUnsavedChangesGuard } from '@martis/runtime' function MyForm({ schema, initialValues }) { const [values, setValues] = useState(initialValues) const initialSnapshot = useMemo(() => JSON.stringify(initialValues), [initialValues]) const { dialog, markSaved } = useUnsavedChangesGuard({ values, initialSnapshot, schema, }) const onSubmit = async () => { await save(values) markSaved() // suppress the guard for the post-save redirect } return ( <> {dialog} {/* render the confirm dialog inside the tree */} <form onSubmit={onSubmit}>...</form> </> )}| Option | Type | Effect |
|---|---|---|
values | Record<string, unknown> | Current form values; the hook re-snapshots on every render. |
initialSnapshot | string | null | JSON snapshot of the loaded values. Pass null to disable the guard while the record loads. |
schema | ResourceSchema | undefined | Resource schema. The hook reads schema.confirmUnsavedChanges to decide whether to engage. |
bypass | boolean | When true, suppresses the guard for the next navigation (used after a successful submit). |
The hook integrates with React Router's useBlocker, so navigation via <Link> or useNavigate() triggers the dialog. It holds the browser back button only while the form is dirty (v1.38.0+): the first render where values differ from initialSnapshot pushes a copy of the page's history entry, and the Back press that removes it opens the dialog. A clean form leaves the history alone, so Back and Forward work as on any other page. It also integrates with the modal-history lock primitives in resources/js/lib/historyLock.ts: a modal open over the form keeps the back button, and a form that turns dirty while a modal holds the top history entry is guarded once the modal closes. Before v1.38.0 the hook pushed its history entry on mount, which erased the Forward history and could make Back skip a page.
If the form stays on the page after a save, pass the values the save sent as the new initialSnapshot, not the values the form holds when the request returns: the inputs stay editable while it runs, and what the user typed meanwhile is still unsaved.
useError Hook
Centralised error state management for the forms and pages of an extension (on @martis/runtime since v1.38.0).
import { api, useError } from '@martis/runtime' const { errors, setError, clearErrors, clearFieldError, hasErrors } = useError() try { await api.post('/api/posts', data)} catch (err) { setError(err) // Handles ApiError, Error, or string} // Render errors:{errors.message && <p className="text-destructive">{errors.message}</p>}{errors.fieldErrors.title && <p className="text-destructive">{errors.fieldErrors.title}</p>}| Property / Method | Type | Description |
|---|---|---|
errors | { message: string | null; fieldErrors: Record<string, string>; apiError: ApiError | null } | Current error state. fieldErrors keeps the first message of each field; apiError is the caught ApiError, if any. |
setError(err) | (err: unknown) => void | Parse and set errors from a caught exception: an ApiError fills message, fieldErrors and apiError, another Error or a string fills message, anything else sets a generic message. |
clearErrors() | () => void | Reset all error state |
clearFieldError(field) | (field: string) => void | Drop one field's error, e.g. when the user edits that field |
hasErrors | boolean | Whether a message or any field error is set |
useOverrideProps Hook
React context primitive that carries the live OverrideProps payload (the schema, record, recordId, params, navigation callbacks, etc.) every drawer or page override receives. Wrap children with the provider and any deeply-nested component reads the same payload without prop-drilling.
import { OverridePropsProvider, useOverrideProps, useOverridePropsOptional, type OverrideProps } from '@martis/runtime' // v1.38.0+ export function MyDrawerCreate(props: OverrideProps) { return ( <OverridePropsProvider value={props}> <MyHeader /> <MyForm /> </OverridePropsProvider> )} function MyHeader() { const { schema, onClose } = useOverrideProps() // throws outside provider return <h2>{schema.singularLabel}</h2>} function MyOptionalConsumer() { const ctx = useOverridePropsOptional() // returns null outside provider if (!ctx) return null return <span>{ctx.recordId}</span>}Opt-in. Overrides that prefer manual prop passing don't need to wrap. The provider is the override's own: the package mounts none around it.
usePageTitle Hook
Sets document.title for the currently-mounted page and restores the previous title on unmount, so stacked drawers and modals do not leave stale segments after they close. The package's pages call it; a custom page or override takes it from @martis/runtime (v1.38.0+).
import { usePageTitle } from '@martis/runtime' function MyCustomPage({ resource }) { usePageTitle(resource.label) // → "Clients · Brand" // usePageTitle(null) // → translated default ("Brand — Admin Control") return <div>…</div>}| Argument | Effect |
|---|---|
string | Renders "{segment} · {brand}". |
null / undefined / "" | Falls back to the localized default (navigation.page_title_default). |
Brand resolves from config.brand; the translation namespace is navigation.
useIsMobile Hook
Reactive viewport-width hook. Returns true when window.innerWidth <= breakpoint (default 768) and re-renders on every resize. Used by the topbar to switch the search input between bar and icon modes; consumers can reuse it from any override that needs a JS-side mobile gate without re-implementing the matchMedia listener.
import { useIsMobile } from '@martis/runtime' const isMobile = useIsMobile() // default 768pxconst isNarrow = useIsMobile(540) // custom breakpoint return isMobile ? <CompactToolbar /> : <FullToolbar />Prefer CSS media queries when the layout swap is purely visual; reach for this hook when the difference is structural (different React tree, different data fetch, etc.).
Tooltip Standard (PrimeReact)
All tooltips in Martis must go through the global data-pr-tooltip pattern (the PrimeReact attribute convention), rendered by the bundled MartisTooltip provider, or through the ref-based Tooltip export for JSX content. Native HTML title= attributes and ad-hoc tooltip implementations are prohibited. Extensions cannot import primereact/tooltip directly (the extension build does not alias primereact): use data-pr-tooltip, or Tooltip from @martis/runtime.
A global tooltip provider (MartisTooltip) is registered in the layout targeting [data-pr-tooltip], so any element with data-pr-tooltip automatically gets a tooltip.
Simple tooltip (recommended)
<button data-pr-tooltip="Delete this record" data-pr-position="top"> <Trash size={16} /></button>Rich content: markup or React
The global provider renders data-pr-tooltip as plain text, so markup in it
shows literally. Rich content takes one of two routes, depending on what it
is made of:
-
Markup you write: spread
htmlTooltip()onto the trigger. The global provider then renders the text as HTML (line breaks, bold, lists, links) in the same bubble, with the same placement and delay as a plain tooltip and a roomier layout for paragraphs. The field label tooltips (->tooltip()), the metric help and the cache page use it. The markup is sanitised before it is shown (scripts, handlers, unsafe URLs,data-*attributes,style,id,name, forms and form controls are removed), but it is still markup you author: use your own strings and translations, never text that comes from users or records.import \{ htmlTooltip } from '@martis/runtime'<span \{...htmlTooltip('<strong>Re-index</strong><br/>Rebuilds the search index.', 'top')}><InfoIcon size=\{14} /></span>htmlTooltip(markup, position?)returns thedata-pr-tooltip,data-pr-tooltip-htmlanddata-pr-positionprops plus arefthat registers the element. An element that already has a ref of its own sets the three attributes itself and registers itself withtrustHtmlTooltip(also from@martis/runtime) inside its ref callback.The
data-pr-tooltip-html="true"attribute alone no longer renders HTML (v2.4.0). Record content rendered as HTML (a Markdown or Trix value) can write that attribute, so the provider showsdata-pr-tooltipas plain text for every element that was not registered by the code that rendered it, and such an element can only be registered by code, never by markup. An extension that set the attribute by hand switches tohtmlTooltip(); an extension scaffolded before v2.4.0 refreshes its runtime shim first, since the shim it was published with does not export the two helpers (see Refreshing the extension scaffold after an upgrade). -
React content: the ref-based
Tooltipwith JSXcontent. For content made of components (an icon, a formatted value, a small table), content built from data (JSX escapes it), or PrimeReact options such asautoHide={false}for a tooltip the pointer can move into. PrimeReact'sTooltiphas noescapeprop, and a stringcontentis plain text:// Package-internal: an extension imports Tooltip from '@martis/runtime' (see below).import \{ Tooltip } from 'primereact/tooltip'import \{ useRef } from 'react'const btnRef = useRef(null)<button ref=\{btnRef}>Re-index</button><Tooltip target=\{btnRef} position="top" content=\{<div><b>Re-index</b><br/>Rebuilds the index.</div>} />
From a consumer Tool / extension
Extension bundles can't import { Tooltip } from 'primereact/tooltip' — the
extension build doesn't alias primereact. Reach the same component off the
runtime surface (window.Martis.runtime, since v1.19.0), exactly like
FieldInput / DrawerShell:
import { Tooltip } from '@martis/runtime' // shim → window.Martis.runtime.Tooltip <button ref={ref}>Re-index</button><Tooltip target={ref} position="top" content={<div><b>Re-index</b><br/>Rebuilds the index.</div>} />Long text and viewport edges
The global [data-pr-tooltip] provider (MartisTooltip) lays the bubble out
before placing it, so any trigger can carry a sentence (v1.38.0+):
- The text wraps inside a shrink-to-fit bubble of at most 360 px (or the viewport width minus 16 px on a narrow screen); a one-word label keeps its single-line pill, and a long unbroken token breaks inside the bubble.
- The bubble stays inside the viewport with an 8 px margin. A trigger near an
edge keeps the full-width bubble, shifted inward, with the arrow still on
the trigger; when the requested side (
data-pr-position) has no room the bubble flips to the opposite side, and aleft/rightbubble with room on neither side goes above or below the trigger. - While open it follows its trigger through page and container scrolls and closes when the trigger scrolls out of view.
No escaping workaround is needed for a long plain-text tooltip: keep it on
data-pr-tooltip and reserve htmlTooltip() for real markup.
Rules
| Rule | Detail |
|---|---|
❌ Never use title= | Native browser tooltips are inconsistent across themes |
| ❌ Never build custom tooltip divs | Breaks dark/light mode consistency |
✅ Always use data-pr-tooltip for simple text | Covered by global provider |
✅ Spread htmlTooltip(markup) for markup you write | Same bubble and placement; sanitised, but never for user or record data. The bare data-pr-tooltip-html="true" attribute renders text since v2.4.0 |
✅ Use the ref-based <Tooltip> for React content | JSX content (escaped), full PrimeReact API |
✅ Use data-pr-position to control placement | "top" | "bottom" | "left" | "right" |
Theming
Martis uses CSS custom properties for theming. Override these variables to customize colors:
:root { --martis-bg: #1b2332; /* Page background */ --martis-surface: #1e293b; /* Card/panel background */ --martis-sidebar: #111827; /* Sidebar background */ --martis-border: #334155; /* Border color */ --martis-text: #e2e8f0; /* Primary text */ --martis-text-muted: #94a3b8; /* Secondary text */ --martis-accent: #6366f1; /* Primary action color (indigo) */ --martis-accent-hover: #4f46e5; /* Hover state */ --martis-card: #1e293b; /* Card background */ --martis-input-bg: #111827; /* Form input background */}Light theme variables are set via html:not(.dark) selector and automatically applied when theme is toggled.
Loading Indicator
MartisLoader
The built-in loading indicator used across all resource pages, the profile page, and action drawers.
Props:
| Prop | Type | Default | Description |
|---|---|---|---|
loading | boolean | true | Controls visibility |
message | string | null | config or i18n | Text shown next to spinner |
overlay | boolean | false | Covers child content with a semi-transparent overlay |
disabled | boolean | false | Hides this instance regardless of state |
size | 'sm' | 'md' | 'lg' | 'md' | Spinner and text size |
children | ReactNode | — | Content to wrap in overlay mode |
Usage (from an extension, on @martis/runtime since v1.38.0; it renders the loader registered under loader when there is one):
import { MartisLoader } from '@martis/runtime' // Simple spinner<MartisLoader loading={isLoading} /> // Overlay mode — wraps content with a semi-transparent loading state<MartisLoader loading={isFetching} overlay> <DataTable rows={rows} /></MartisLoader>Configuration: All visual options (message, icon, logo, colors, opacity) are controlled via config/martis.php under the loader key. See the Loader documentation for the full configuration reference and customization guide.
Custom loader component: Replace the built-in loader entirely via the component registry:
import { componentRegistry } from '@martis/runtime'componentRegistry.register('loader', MyCustomLoader)CSS Utility Classes
Every component inside Martis is themed via design tokens (see theming.md), but a small set of standalone utility classes is also exposed so consumer overrides, custom actions and bespoke pages can drop in chips, badges and avatars without reinventing them.
Buttons
Compose a variant with an optional size and the martis-btn-icon helper for icon-only chips.
| Class | Effect |
|---|---|
martis-btn-primary | Accent-filled primary CTA. |
martis-btn-secondary | Neutral chip with a 1px border. |
martis-btn-danger / martis-btn-success / martis-btn-warning | Semantic filled variants. |
martis-btn-ghost | Transparent chip that washes on hover — ideal for toolbars and row actions. |
martis-btn-sm / martis-btn-lg | Height + typography tokens. Default (no size class) = 36px. |
martis-btn-icon | Square icon-only shape. Compose with any variant and any size. |
<button class="martis-btn-ghost martis-btn-icon" aria-label="Open"><svg/></button><button class="martis-btn-primary martis-btn-lg">Save changes</button>Badges
<span class="martis-badge martis-badge-success">Active</span><span class="martis-badge martis-badge-danger martis-badge-dot">Failed</span><span class="martis-badge martis-badge-neutral">Draft</span>| Class | Effect |
|---|---|
martis-badge | Base chip shape (pill, 22px, 11px font, weight 500, tabular numerals). Always required. |
martis-badge-info / success / warning / danger | Semantic colour tokens (background + text + border). |
martis-badge-neutral | Muted chip for "draft" / "unassigned" states. |
martis-badge-dot | Prepends a small dot for "live" / connection states. |
Avatars
Size tokens compose with any shape class (martis-avatar-circle, -rounded, -squared). Default avatar size stays at 36px so existing call sites are unaffected.
| Class | Size |
|---|---|
martis-avatar-xs | 20px |
martis-avatar-sm | 24px |
martis-avatar-md | 28px |
| (no class) | 36px |
martis-avatar-lg | 40px |
martis-avatar-xl | 56px |
Use .martis-avatar-stack on a wrapper to overlap several avatars with a subtle ring:
<div class="martis-avatar-stack"> <span class="martis-avatar martis-avatar-sm martis-avatar-circle">JA</span> <span class="martis-avatar martis-avatar-sm martis-avatar-circle">MW</span> <span class="martis-avatar martis-avatar-sm martis-avatar-circle">+3</span></div>.martis-avatar-fallback paints a muted user glyph slot for records with no image and no initials seed, keeping row layouts aligned.
The avatarColorForSeed helper (on @martis/runtime since v1.38.0) returns a deterministic colour for any seed string, picking one of the 16 --martis-avatar-1..16 token hues. Two users with the same name always get the same colour, and the colour stays stable across light/dark themes. The server picks the avatar slots with the same hash, so for a user with a name it is the colour of their Topbar avatar:
import { avatarColorForSeed } from '@martis/runtime' <span className="martis-avatar martis-avatar-md martis-avatar-circle" style={{ backgroundColor: avatarColorForSeed(user.name) }}> {user.avatar_initials}</span>The signed-in user (useAuth().user) already carries both: avatar_initials, and avatar_palette, the slot to paint as var(--martis-avatar-${user.avatar_palette}).
KPI typography
KPI cards (Value, Trend, Progress, framed custom cards) share three typography classes:
| Class | Effect |
|---|---|
martis-kpi-label | 12px uppercase muted text with a 0.04em tracking. Wraps an icon (martis-kpi-label-icon, 14px) and the label text (martis-kpi-label-text). |
martis-kpi-value | 28px semibold, tabular numerals. Collapses to 24px under [data-density="dense"]. |
martis-kpi-delta | Inline pill rendered next to the value; is-up / is-down colour variants. .martis-kpi-delta-sub styles the "vs previous" suffix. |
<h3 class="martis-kpi-label"> <span class="martis-kpi-label-icon">{icon}</span> <span class="martis-kpi-label-text">Total revenue</span></h3><p class="martis-kpi-value">€389,785</p><span class="martis-kpi-delta is-up">↗ +12.4% <span class="martis-kpi-delta-sub">vs €346,521</span></span>Status dot (Live indicator)
The shell's pulsating green dot is exposed as a public utility so any surface that signals "this value auto-refreshes" uses the same visual:
<span class="martis-status-dot"> <span class="martis-status-pulse"></span> Live</span>The pulse halo respects the user's reduced-motion preference (both [data-reduced-motion="true"] and prefers-reduced-motion).
Notification dot
A reusable danger-coloured dot for unread / notification indicators on icon buttons:
<button class="martis-tb-icon-btn" aria-label="Notifications"> <svg/> <span class="martis-notif-dot"></span></button>The selector .martis-tb-icon-btn .martis-notif-dot keeps the topbar's exact spec geometry (6×6 + 2px topbar border). Using .martis-notif-dot outside the topbar produces the same hue without the border.
Detail panel rows
Stacked label/value rows used by the resource detail page right rail and the drawer detail surface:
<dl class="martis-detail-panel"> <div class="martis-detail-row"> <dt class="martis-detail-label">Status</dt> <dd class="martis-detail-value">Active</dd> </div></dl>Add .martis-detail-panel.is-drawer on the wrapper for drawer-tighter spacing. .martis-detail-kicker is the eyebrow text rendered above the panel title (its text comes from Resource::detailKicker(), see resources.md).
Rows the package renders carry hooks for host CSS (v2.23.0): data-field (the field attribute) and data-type (the field component), plus .is-full-width for a fullWidth() field (one column, label above the value) and .is-label-hidden for hideLabelOnDetail() (the dt gets sr-only). Rows inside a Section use .martis-section-row with the same modifiers.
Form density helpers
Wrap form bodies in these classes so the create / update pages and drawer forms react to the active density token:
| Class | Effect |
|---|---|
martis-form-body | Padded form container. Tightens on [data-density="dense"]. |
martis-form-stack | Vertical flex stack of fields with token-driven gap. |
martis-form-grid | Grid gap of a form or detail field grid (16px, 10px dense); pair with martis-input-wrap per field. |
martis-field-grid | Responsive field grid: tracks from --martis-field-columns (12 by default), each child placed from its --martis-field-span / -md / -lg custom properties, full row below 768px. See Grid Layout. |
Tabs / Segmented / Skeleton
Generalised primitives previously living inline only inside specific surfaces:
| Class | Effect |
|---|---|
martis-tabs + martis-tab | Underline-active tab strip (2px --martis-accent border-bottom). |
martis-segmented | Equal-width segmented control with focus-visible ring. |
martis-skeleton | 1.6s linear shimmer gradient. Replaces ad-hoc animate-pulse usage. |
Dashboard layout helpers
Two grid classes mirror the Dashboard.html spec so a custom dashboard renders the same as the built-in one:
| Class | Effect |
|---|---|
martis-dash-kpis | 4-column row of KPI cards (collapses to 2 cols below 1100px). |
martis-dash-grid | 3-column body grid; supports .span-2 / .span-3 cell helpers. Collapses to 1 col below 1100px. |
Filter chip
Public 24px chip used by <FilterPanel> for active-filter pills. Drop the class on any <span> carrying a label / value plus a dismiss button to inherit the look.
<span class="martis-filter-chip"> <span class="martis-filter-chip-label">Status:</span> <span class="martis-filter-chip-value">Active</span> <button class="martis-filter-chip-x" aria-label="Clear">×</button></span>| Class | Effect |
|---|---|
martis-filter-chip | 24px height, --martis-hover bg, 1px --martis-border, 12px font, --martis-radius-sm. |
martis-filter-chip-label | Muted text for the field name prefix. |
martis-filter-chip-value | Default text for the value. |
martis-filter-chip-x | Round dismiss button with hover state. |
Dropzone
Public file-upload surface used by FileField, ImageField, and any consumer that wants the canonical Martis upload look.
<div class="martis-dropzone is-drag-over"> <!-- file row content --></div> <!-- big-zone variant: centred icon + CTA, dashed border --><div class="martis-dropzone is-zone"> <span class="martis-dropzone-icon"><svg/></span> <span class="martis-dropzone-title">No file attached</span> <span class="martis-dropzone-hint">Drop a file or click to browse.</span> <span class="martis-dropzone-cta">Choose file</span></div>| Class | Effect |
|---|---|
martis-dropzone | Compact inline row. --martis-input-bg + 1px --martis-border. |
martis-dropzone.is-zone | Dashed border, centred icon + CTA. Use for empty states. |
martis-dropzone.is-drag-over | Accent border + tinted bg while a file is hovering. |
martis-dropzone.has-error | Danger border. |
martis-dropzone.is-readonly | 0.6 opacity, not-allowed cursor, no accent on hover. FileField / ImageField set it on a readonly field, which ignores dropped files. |
martis-dropzone-icon / -title / -hint / -cta | Children of the is-zone variant. |
Card chrome
Public wrapper used by martis:card scaffolds and by any custom dashboard panel that wants the canonical Martis card look without reaching for the internal .martis-metric-card selectors:
<article class="martis-card"> <header class="martis-card-head"> <h3 class="martis-kpi-label"> <span class="martis-kpi-label-text">My custom card</span> </h3> </header> <div class="martis-card-body"> <p>Card content…</p> </div></article>| Class | Effect |
|---|---|
martis-card | Surface + border + 16/18 padding + --martis-radius-lg. |
martis-card-head | Flex row, space-between alignment, baseline gap. Pair with .martis-kpi-label for the title. |
martis-card-body | Vertical stack with token-driven gap. |