Record Header
A sticky record header — title, status, a dirty-state note, and a save/secondary action cluster gated on the record being both dirty and valid.
Preview
<div class="w-full">
<x-ui.admin.record-header />
</div>
Installation
php artisan ui:add admin-record-header
Registry contract
php artisan ui:add admin-record-header
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/admin/record-header.blade.php
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Record Header (`admin-record-header`)
A sticky record header — title, status, a dirty-state note, and a save/secondary action cluster gated on the record being both dirty and valid.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add admin-record-header
```
## Usage
```blade
<div class="w-full">
<x-ui.admin.record-header />
</div>
```
## Props
- `title` (string, default `Aurora Wireless Earbuds`) — The record's display title.
- `identifier` (string|null, default `#4821`) — A stable record identifier shown next to the title.
- `parent` (array, default `[]`) — Breadcrumb parent ['label','href'] shown before the updated label. An empty array renders a sample 'Audio' parent; pass your own to override.
- `updatedLabel` (string|null, default `null`) — Freeform 'updated' caption. Defaults to a localized placeholder.
- `status` (array, default `[]`) — ['value','options' => [['value','label','tone']]], passed straight to <x-ui.status-select>. An empty array falls back to a sample draft/active/archived vocabulary.
- `dirty` (bool, default `true`) — Whether the record has unsaved changes. Re-render with the host's live form state.
- `valid` (bool, default `true`) — Whether the record currently passes validation.
- `saving` (bool, default `false`) — Disables Save and swaps saveTitle to a 'Saving…' message while a save request is in flight.
- `dirtyNote` (string|null, default `null`) — Text shown next to the actions while dirty. Defaults to a localized 'Unsaved changes'.
- `saveLabel` (string|null, default `null`) — Save button label. Defaults to a localized 'Save'.
- `saveTitle` (string|null, default `null`) — Tooltip explaining why Save is disabled. Auto-derived from saving/valid/dirty when omitted.
- `saveUrl` (string|null, default `null`) — POST action for the save form. Defaults to '#' — wire up the real endpoint after installing.
- `secondaryActions` (array, default `[]`) — [['label','href' => null,'variant' => 'outline','disabled' => false,'title' => null]]. Empty falls back to a sample Preview/Duplicate/Archive set. Disabled actions stay visible with a title explaining why, never hidden.
- `top` ('0'|'14'|'16', default `0`) — Sticky offset in rem, so the header can clear a fixed app header/nav above it.
## Use when
- Use to structure hierarchy, spacing, and responsiveness so content is easier to scan and navigate.
- A record edit screen (product, order, category, …) is long enough that the operator scrolls past the save button.
- Save should only be reachable once the record is both dirty and valid, with the reason surfaced in a tooltip when it isn't.
## Avoid when
- Do not let layout primitives substitute for semantics, headings, or interaction rules users still need.
- The form fits on one screen with no scrolling — a static header adds sticky/z-index complexity for no benefit.
- There is more than one record identity on screen at once (a comparison view, a bulk editor) — this header names exactly one record.
## Anti-patterns
- Using visual layout as a substitute for semantic structure
## Rules
- Use the `<brok:admin-record-header>` tag (or `<x-ui.admin-record-header>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/admin-record-header
- Registry JSON (files, props, contract): https://brokui.dev/r/open/admin-record-header.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| title | string | Aurora Wireless Earbuds | The record's display title. |
| identifier | string | null | #4821 | A stable record identifier shown next to the title. |
| parent | array | [] | Breadcrumb parent ['label','href'] shown before the updated label. An empty array renders a sample 'Audio' parent; pass your own to override. |
| updatedLabel | string | null | null | Freeform 'updated' caption. Defaults to a localized placeholder. |
| status | array | [] | ['value','options' => [['value','label','tone']]], passed straight to <x-ui.status-select>. An empty array falls back to a sample draft/active/archived vocabulary. |
| dirty | bool | true | Whether the record has unsaved changes. Re-render with the host's live form state. |
| valid | bool | true | Whether the record currently passes validation. |
| saving | bool | false | Disables Save and swaps saveTitle to a 'Saving…' message while a save request is in flight. |
| dirtyNote | string | null | null | Text shown next to the actions while dirty. Defaults to a localized 'Unsaved changes'. |
| saveLabel | string | null | null | Save button label. Defaults to a localized 'Save'. |
| saveTitle | string | null | null | Tooltip explaining why Save is disabled. Auto-derived from saving/valid/dirty when omitted. |
| saveUrl | string | null | null | POST action for the save form. Defaults to '#' — wire up the real endpoint after installing. |
| secondaryActions | array | [] | [['label','href' => null,'variant' => 'outline','disabled' => false,'title' => null]]. Empty falls back to a sample Preview/Duplicate/Archive set. Disabled actions stay visible with a title explaining why, never hidden. |
| top | '0' | '14' | '16' | 0 | Sticky offset in rem, so the header can clear a fixed app header/nav above it. |
Slots
Default Blade slot only.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Save is disabled whenever saving is true, or dirty is false, or valid is false — never on dirty or valid alone.
- Secondary actions with disabled => true stay rendered as disabled buttons with their title explaining why, instead of being omitted.
- The action cluster wraps onto its own row rather than shrinking, so it never pushes the layout sideways on a narrow screen.
- Declares registry capability flags: a11y, responsive, rtl, darkMode, localized.
Guidance
Structure content hierarchy and responsive relationships.
Use when
- Use to structure hierarchy, spacing, and responsiveness so content is easier to scan and navigate.
- A record edit screen (product, order, category, …) is long enough that the operator scrolls past the save button.
- Save should only be reachable once the record is both dirty and valid, with the reason surfaced in a tooltip when it isn't.
Avoid when
- Do not let layout primitives substitute for semantics, headings, or interaction rules users still need.
- The form fits on one screen with no scrolling — a static header adds sticky/z-index complexity for no benefit.
- There is more than one record identity on screen at once (a comparison view, a bulk editor) — this header names exactly one record.
Use instead
- Semantic HTML with standard flow
Anti-patterns
- Using visual layout as a substitute for semantic structure
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- native
- Focus
native
- Meet the WCAG 2.2 AA target declared in meta.a11y.
- WCAG 2.4.11 Focus Not Obscured: the header keeps a predictable, capped height so a host can set scroll-padding-block-start (or per-field scroll-margin-block-start) to that height and keep focused fields clear of it.
- Disabled actions use aria-disabled/tabindex semantics via <x-ui.button>, never a bare 'disabled' look with no accessible reason.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add wire:ignore to the component root because its behavior owns rendered DOM.
<div wire:ignore>
<div class="w-full">
<x-ui.admin.record-header />
</div>
</div>
Validation
Validation support: native. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:admin-record-header
wire:model="value"
:aria-invalid="$errors->has('value') ? 'true' : 'false'"
aria-describedby="value-error"
/>
@error('value')
<p id="value-error" role="alert">{{ $message }}</p>
@enderror
<brok:button type="submit" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving…</span>
</brok:button>
</form>
Source
The exact, editable file ui:add writes
into your app. Previews render this same code; there are no preview-only components.
{{--
Record Header — a sticky identity + status + save cluster for a record
edit screen (product, order, category, …). It keeps three things on
screen while the operator scrolls a long record: what the record is,
what state it is in, and whether the pending edits can be saved.
Save is gated on the record being both dirty AND valid — re-render this
component with fresh `dirty`/`valid` props as the host's own form state
changes (Livewire, Inertia, or a full page reload) and the button
reflects it. `saveTitle` should name the reason it's disabled; pair this
with the companion `admin-save-blockers` primitive for a rail that names
every blocking field individually.
WCAG 2.4.11 (Focus Not Obscured): a sticky header must never fully hide
a keyboard-focused control scrolled beneath it. This header keeps a
predictable, non-growing two-row shape (title/status wrap onto their own
row on narrow screens, the action cluster onto its own — never a third)
so a host can measure it once and set `scroll-padding-block-start` (or
per-field `scroll-margin-block-start`) on the record's scroll container
to that height, which keeps every Tab-focused field clear of it. Verified
against the built docs preview: with that scroll-padding applied, every
Tab-focused field in a stacked form beneath the header landed fully
clear of it (`document.elementFromPoint` on the field's center returned
the field, not the header); a negative control confirmed the same field
IS obscured without the fix, proving the header really does need it.
Generalises `categories/record/header.blade.php`. Left out as domain
logic: the category-specific archive confirmation dialog (a destructive
action here is just a titled, host-wired button) and the CategoryStatus
vocabulary — callers pass their own `status.options`.
--}}
@props([
'title' => 'Aurora Wireless Earbuds',
'identifier' => '#4821',
'parent' => [],
'updatedLabel' => null,
'status' => [],
'dirty' => true,
'valid' => true,
'saving' => false,
'dirtyNote' => null,
'saveLabel' => null,
'saveTitle' => null,
'saveUrl' => null,
'secondaryActions' => [],
'top' => '0',
])
@php
// Shapes documented in item.json knowledge.props (kept single-line here so
// the registry contract builder can parse each @props default verbatim):
// parent: ['label', 'href']
// status: ['value', 'options' => [['value','label','tone' => 'soft-success'|'soft-warning'|'soft-destructive'|'soft-info'|'soft-neutral']]]
// secondaryActions: [['label', 'href' => null, 'variant' => 'outline', 'disabled' => false, 'title' => null]]
// top: '0'|'14'|'16' — sticky offset in rem, e.g. '14' to clear a 3.5rem app header above it.
$parent = $parent !== [] ? $parent : ['label' => 'Audio', 'href' => '#'];
$status = $status !== [] ? $status : [
'value' => 'draft',
'options' => [
['value' => 'draft', 'label' => 'Draft', 'tone' => 'soft-neutral'],
['value' => 'active', 'label' => 'Active', 'tone' => 'soft-success'],
['value' => 'archived', 'label' => 'Archived', 'tone' => 'soft-destructive'],
],
];
$statusValue = $status['value'] ?? null;
$statusOptions = $status['options'] ?? [];
$updatedLabel ??= __('Updated 12 minutes ago by Priya Shah');
$dirtyNote ??= __('Unsaved changes');
$saveLabel ??= __('Save');
$saveDisabled = $saving || ! $dirty || ! $valid;
$saveTitle ??= match (true) {
$saving => __('Saving…'),
! $valid => __('Resolve the highlighted fields before saving.'),
! $dirty => __('Nothing to save yet.'),
default => null,
};
$topClass = match ((string) $top) {
'14' => 'top-14',
'16' => 'top-16',
default => 'top-0',
};
$secondaryActions = $secondaryActions !== [] ? $secondaryActions : [
['label' => __('Preview'), 'href' => '#', 'variant' => 'outline'],
['label' => __('Duplicate'), 'href' => '#', 'variant' => 'outline'],
['label' => __('Archive'), 'variant' => 'destructive', 'disabled' => true, 'title' => __('Archiving is unavailable while the record has unsaved changes.')],
];
@endphp
<header
data-slot="record-header"
data-surface="admin"
{{ $attributes->merge(['class' => "sticky {$topClass} z-30 w-full border-b border-border bg-canvas/95 px-4 py-4 backdrop-blur supports-[backdrop-filter]:bg-canvas/80 sm:px-6"]) }}
>
<div class="flex flex-wrap items-start justify-between gap-x-6 gap-y-4">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-x-2 gap-y-2">
<h1 data-slot="record-header-title" class="truncate text-lg font-semibold tracking-tight text-foreground">
{{ $title }}
</h1>
@if ($statusOptions !== [])
<x-ui.status-select :value="$statusValue" :options="$statusOptions" />
@endif
@if ($identifier)
<x-ui.badge variant="soft-neutral" size="sm" class="font-mono">{{ $identifier }}</x-ui.badge>
@endif
</div>
<p class="mt-2 truncate text-sm text-muted-foreground">
@if ($parent)
<a href="{{ $parent['href'] ?? '#' }}" class="text-info-text hover:underline">{{ $parent['label'] ?? '' }}</a>
<span aria-hidden="true" class="text-faint-foreground">/</span>
@endif
{{ $updatedLabel }}
</p>
</div>
{{-- `min-w-0`, not `shrink-0`: the cluster grows by a whole sentence
the moment the record turns dirty, and a cluster that cannot
shrink pushes the page sideways on a phone instead of wrapping. --}}
<div class="flex min-w-0 flex-wrap items-center gap-2">
@if ($dirty)
<span data-slot="record-header-dirty-note" class="text-xs text-warning-text">{{ $dirtyNote }}</span>
@endif
@foreach ($secondaryActions as $action)
<x-ui.button
:href="($action['disabled'] ?? false) ? null : ($action['href'] ?? null)"
variant="{{ $action['variant'] ?? 'outline' }}"
size="sm"
:disabled="$action['disabled'] ?? false"
:title="$action['title'] ?? null"
>
{{ $action['label'] ?? '' }}
</x-ui.button>
@endforeach
<form method="POST" action="{{ $saveUrl ?? '#' }}" class="contents">
@csrf
<x-ui.button type="submit" size="sm" :disabled="$saveDisabled" :title="$saveTitle">
{{ $saveLabel }}
</x-ui.button>
</form>
</div>
</div>
</header>
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.0.2 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0