Documentation menu
HomeDocsCustomizationBuilt-in components

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 sizes
  • defaultPerPage() — Initial page size
  • defaultSort() / defaultSortDirection() — Initial sort
  • tableStriped() / 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 / colSpanLg support

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 in toggle. SSO, Google, Forgot password, and "Create an account" render only when the matching config.auth.*.enabled flag is set. With auth.password_login.enabled off (v2.21.0) it shows the SSO buttons only, and the form again on ?password_login=1 when a break-glass address is configured (v2.23.0).
  • Register — /register route gated by config.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.tsx drives 404 / 403 / 500 with a faded watermark code, accent icon, optional incidentId chip with copy button, and primary + secondary CTAs. ForbiddenPage and NotFoundPage are 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:

PresetComponentDescription
sidebarSidebarLayoutLeft sidebar + top bar + main content (default)
topnavTopnavLayoutTop navigation bar + main content
minimalMinimalLayoutMinimal header + main content

SidebarLayout is an inner function inside resources/js/components/Layout.tsx, not a standalone file under components/layouts/. TopnavLayout and MinimalLayout each live in their own file there. To replace the whole shell, register a component under layout:shell (componentRegistry.register('layout:shell', MyShell)); to replace one piece, under layout:sidebar, layout:topbar or layout:footer. See Overrides → Shell piece-by-piece overrides. To add a component to the top bar without replacing it, register it under topbar:start or topbar: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.

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:

SectionSourceWhen it shows
Resources/api/command-palette → resourcesAlways (filtered by query). Each row is the resource label + its navigation group as a hint.
Tools/api/command-palette → toolsEvery Tool the user may see.
Commands/api/command-palette → commandsThe entries the app registers with Martis::commandPalette() (v2.1.0), with their group as a hint.
Actions/api/command-palette → actionsWhen 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 → recentOnly 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's authorizedToSee() 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 an InvalidArgumentException naming its type, and so does an entry other than a MenuItem or a MenuGroup.
  • 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+).

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).

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 FieldDisplay components
  • Sortable column headers with visual indicators
  • Row selection with checkboxes (prepared for bulk actions)
  • Configurable striping, gridlines, size, and hover effects
  • Overridable via registry
  • emptyMessage prop (v1.32.4): undefined renders the localised "No records found."; null renders 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 pass null until 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, large
public 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

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:

ClassRole
.martis-modal-scrimFixed-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-surface480 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-headTitle 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-body18/20 px padding, muted text, overflow-y: auto, grows to fill the surface.
.martis-modal-footFooter row on --martis-surface-alt with a 1 px divider on top. Right-aligned buttons.
.martis-modal-close28 × 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-accent or 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.

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 Resource
public 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:

  1. Custom registry — consumer-registered icons (synchronous).
  2. Curated built-ins — 130 most-used Phosphor icons bundled eagerly with the main app chunk (synchronous, no network roundtrip).
  3. 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.ts
import { 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)" />
PropDefaultDescription
valuesrequiredNumeric series. Renders nothing when fewer than 2 points.
colorvar(--martis-accent)Stroke + gradient colour.
variantblockblock (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 in en / 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 compact variant 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):

OptionTypeDescription
link{ href, label }A link under the message, opened in a new tab. Only a safe href renders.
lifenumber | nullLifetime 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.
dismissOnNavigatebooleanDefault 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, from config('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 is 100dvh tall 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 null
  • isLoading — Whether the session check is in progress
  • login(email, password) — Authenticate
  • logout() — End session and reload

ThemeContext

Manages dark/light theme state.

const { theme, toggle, setTheme } = useTheme()
  • theme — 'light' or 'dark'
  • toggle() — Switch between themes
  • setTheme(t) — Set theme explicitly
  • Persists to localStorage (martis-theme)
  • Toggles .dark class 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 keys
await update((prev) => ({ accent: prev.accent === 'teal' ? 'martis' : 'teal' }))
await reset() // back to package defaults
PropertyTypeDescription
prefsPreferencesCurrently-applied preference set.
metaPreferencesMeta | nullAllowed 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).
enabledbooleanfalse 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 uploads

Features:

  • 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 an ApiError (status 400): the browser would resolve it to another endpoint. Build every path that holds a route param or a record key with apiPath (or pathSegment for one value), exported by @martis/runtime, so a value such as ../users/5 or 5/force stays one segment: encodeURIComponent() is not enough, since Laravel decodes %2F before it routes. A value holding a literal %2F throws an ApiError (status 400) (v2.4.0)
  • File detection and FormData conversion (hasFileValues() / buildFormData()): on the multipart path booleans travel as 1 / 0 and 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
  • ApiError class with errorsByField() 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 // boolean
config.footer?.text // string
config.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 toggles

Use 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 string
t('delete_confirm') // Fallback to key if not found

Namespaces: resources, messages, actions, navigation

Translations are fetched dynamically from /api/translations/{locale} and cached by react-i18next.

resolveRedirect.ts

Resolves post-CRUD navigation targets:

ValueDestination
'detail'/resources/{resource}/{id}
'index'/resources/{resource}
'edit'/resources/{resource}/{id}/edit
'create'/resources/{resource}/create
'dashboard'/
'stay'No navigation
Custom URLReplace {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:

EventPayloadFired 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 useQuery revalidate when the tab regains focus or the network reconnects — the react-query defaults are on. This is gated by staleTime (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: emit martis:refresh-index ({ resourceKey }) when another session mutates a resource, and use useRevalidateOnFocus for manual-fetch Tools. Martis ships no broadcaster — it exposes the seams, you feed them (the same pattern as the notification bell's martis: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>
</>
)
}
OptionTypeEffect
valuesRecord<string, unknown>Current form values; the hook re-snapshots on every render.
initialSnapshotstring | nullJSON snapshot of the loaded values. Pass null to disable the guard while the record loads.
schemaResourceSchema | undefinedResource schema. The hook reads schema.confirmUnsavedChanges to decide whether to engage.
bypassbooleanWhen 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 / MethodTypeDescription
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) => voidParse 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()() => voidReset all error state
clearFieldError(field)(field: string) => voidDrop one field's error, e.g. when the user edits that field
hasErrorsbooleanWhether 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>
}
ArgumentEffect
stringRenders "{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 768px
const 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.

<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 the data-pr-tooltip, data-pr-tooltip-html and data-pr-position props plus a ref that registers the element. An element that already has a ref of its own sets the three attributes itself and registers itself with trustHtmlTooltip (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 shows data-pr-tooltip as 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 to htmlTooltip(); 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 Tooltip with JSX content. For content made of components (an icon, a formatted value, a small table), content built from data (JSX escapes it), or PrimeReact options such as autoHide={false} for a tooltip the pointer can move into. PrimeReact's Tooltip has no escape prop, and a string content is 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 a left / right bubble 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

RuleDetail
❌ Never use title=Native browser tooltips are inconsistent across themes
❌ Never build custom tooltip divsBreaks dark/light mode consistency
✅ Always use data-pr-tooltip for simple textCovered by global provider
✅ Spread htmlTooltip(markup) for markup you writeSame 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 contentJSX 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:

PropTypeDefaultDescription
loadingbooleantrueControls visibility
messagestring | nullconfig or i18nText shown next to spinner
overlaybooleanfalseCovers child content with a semi-transparent overlay
disabledbooleanfalseHides this instance regardless of state
size'sm' | 'md' | 'lg''md'Spinner and text size
childrenReactNode—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.

ClassEffect
martis-btn-primaryAccent-filled primary CTA.
martis-btn-secondaryNeutral chip with a 1px border.
martis-btn-danger / martis-btn-success / martis-btn-warningSemantic filled variants.
martis-btn-ghostTransparent chip that washes on hover — ideal for toolbars and row actions.
martis-btn-sm / martis-btn-lgHeight + typography tokens. Default (no size class) = 36px.
martis-btn-iconSquare 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>
ClassEffect
martis-badgeBase chip shape (pill, 22px, 11px font, weight 500, tabular numerals). Always required.
martis-badge-info / success / warning / dangerSemantic colour tokens (background + text + border).
martis-badge-neutralMuted chip for "draft" / "unassigned" states.
martis-badge-dotPrepends 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.

ClassSize
martis-avatar-xs20px
martis-avatar-sm24px
martis-avatar-md28px
(no class)36px
martis-avatar-lg40px
martis-avatar-xl56px

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:

ClassEffect
martis-kpi-label12px 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-value28px semibold, tabular numerals. Collapses to 24px under [data-density="dense"].
martis-kpi-deltaInline 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:

ClassEffect
martis-form-bodyPadded form container. Tightens on [data-density="dense"].
martis-form-stackVertical flex stack of fields with token-driven gap.
martis-form-gridGrid gap of a form or detail field grid (16px, 10px dense); pair with martis-input-wrap per field.
martis-field-gridResponsive 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:

ClassEffect
martis-tabs + martis-tabUnderline-active tab strip (2px --martis-accent border-bottom).
martis-segmentedEqual-width segmented control with focus-visible ring.
martis-skeleton1.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:

ClassEffect
martis-dash-kpis4-column row of KPI cards (collapses to 2 cols below 1100px).
martis-dash-grid3-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>
ClassEffect
martis-filter-chip24px height, --martis-hover bg, 1px --martis-border, 12px font, --martis-radius-sm.
martis-filter-chip-labelMuted text for the field name prefix.
martis-filter-chip-valueDefault text for the value.
martis-filter-chip-xRound 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>
ClassEffect
martis-dropzoneCompact inline row. --martis-input-bg + 1px --martis-border.
martis-dropzone.is-zoneDashed border, centred icon + CTA. Use for empty states.
martis-dropzone.is-drag-overAccent border + tinted bg while a file is hovering.
martis-dropzone.has-errorDanger border.
martis-dropzone.is-readonly0.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 / -ctaChildren 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>
ClassEffect
martis-cardSurface + border + 16/18 padding + --martis-radius-lg.
martis-card-headFlex row, space-between alignment, baseline gap. Pair with .martis-kpi-label for the title.
martis-card-bodyVertical stack with token-driven gap.