Skip to content
Brok UI

Loading…

No results

Record Header

Open source

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.

Version
v1.0.2
Stability
stable
License
MIT
Related
Status Select
Save Blockers
Stat

Preview

Aurora Wireless Earbuds

#4821

Audio Updated 12 minutes ago by Priya Shah

Unsaved changes Preview Duplicate
previews.components.admin-record-header.default.blade.php Blade
<div class="w-full">
    <x-ui.admin.record-header />
</div>

Installation

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

  • blade resources/views/components/ui/admin/record-header.blade.php
Registry dependencies
button badge status-select
Packages
composer: jml/brok:^0.2

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.

admin-record-header.md
# 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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

record-header record-header-dirty-note record-header-title

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

Layout and content structure

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
record-header record-header-title record-header-dirty-note
Theming hooks
record-header

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:ignore

Add wire:ignore to the component root because its behavior owns rendered DOM.

livewire-component.blade.php Blade
<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.

livewire-form.blade.php Blade
<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.

resources/views/components/ui/admin/record-header.blade.php Blade
{{--
    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