Save Blockers
A side rail (docked to the viewport bottom on mobile) listing what currently blocks saving, where each blocker moves focus to the field responsible.
Preview
<div class="w-full">
<x-ui.admin.save-blockers />
</div>
Installation
php artisan ui:add admin-save-blockers
Registry contract
php artisan ui:add admin-save-blockers
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/save-blockers.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: Save Blockers (`admin-save-blockers`)
A side rail (docked to the viewport bottom on mobile) listing what currently blocks saving, where each blocker moves focus to the field responsible.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add admin-save-blockers
```
## Usage
```blade
<div class="w-full">
<x-ui.admin.save-blockers />
</div>
```
## Props
- `blockers` (array, default `[]`) — [['field','text','target']]. `target` is the id of the field the blocker's link jumps to and focuses. Empty falls back to a sample SKU/price pair.
- `actions` (array, default `[]`) — [['label','variant' => 'default'|'outline','href' => null,'primary' => false,'requiresValid' => true]]. requiresValid:false lets an action (e.g. 'Save as draft') bypass the blocker gate. Empty falls back to a sample Save changes/Save as draft pair.
- `cancelHref` (string|null, default `#`) — Href for the always-enabled Cancel action, shown in both the rail and the mobile dock.
- `note` (string|null, default `null`) — Small print under the actions. Defaults to a localized placeholder.
- `dockLabel` (string|null, default `null`) — Accessible group label for the mobile dock.
## Use when
- Use for important messages that should stay visible long enough to read and act on.
- Save can be blocked by more than one field and 'why is save disabled' needs to be answerable without guessing.
- A record form is long enough on mobile that the save action deserves a bottom dock instead of scrolling back up to it.
## Avoid when
- Do not downgrade important guidance into transient messaging users can miss.
- There's at most one validation rule for the whole form — an inline field error is simpler than a rail.
- The host already surfaces field errors inline and doesn't want a second, competing list of the same problems.
## Anti-patterns
- Downgrading important outcomes to transient feedback
## Rules
- Use the `<brok:admin-save-blockers>` tag (or `<x-ui.admin-save-blockers>`) 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-save-blockers
- Registry JSON (files, props, contract): https://brokui.dev/r/open/admin-save-blockers.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 |
|---|---|---|---|
| blockers | array | [] | [['field','text','target']]. `target` is the id of the field the blocker's link jumps to and focuses. Empty falls back to a sample SKU/price pair. |
| actions | array | [] | [['label','variant' => 'default'|'outline','href' => null,'primary' => false,'requiresValid' => true]]. requiresValid:false lets an action (e.g. 'Save as draft') bypass the blocker gate. Empty falls back to a sample Save changes/Save as draft pair. |
| cancelHref | string | null | # | Href for the always-enabled Cancel action, shown in both the rail and the mobile dock. |
| note | string | null | null | Small print under the actions. Defaults to a localized placeholder. |
| dockLabel | string | null | null | Accessible group label for the mobile dock. |
Slots
Default Blade slot only.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Each blocker is a plain anchor to '#'+target: landing on a focusable field via a fragment link focuses it in every evergreen browser, so this works without JavaScript.
- Actions never disappear while blocked — they render disabled with a title naming the reason, matching the availability doctrine of admin/record-header.
- Below lg the rail is hidden and a bottom-docked bar carries the same actions (plus a compact blocker count) instead, generalised from the mobile action dock pattern.
- An action with requiresValid => false stays enabled even while other blockers exist (e.g. an explicit 'Save as draft' escape hatch).
- Declares registry capability flags: a11y, responsive, rtl, darkMode, localized.
Guidance
Keep an important message visible near its context.
Use when
- Use for important messages that should stay visible long enough to read and act on.
- Save can be blocked by more than one field and 'why is save disabled' needs to be answerable without guessing.
- A record form is long enough on mobile that the save action deserves a bottom dock instead of scrolling back up to it.
Avoid when
- Do not downgrade important guidance into transient messaging users can miss.
- There's at most one validation rule for the whole form — an inline field error is simpler than a rail.
- The host already surfaces field errors inline and doesn't want a second, competing list of the same problems.
Use instead
- Toast for low-priority confirmation
Anti-patterns
- Downgrading important outcomes to transient feedback
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- No component-owned keyboard interaction; native element behavior applies.
- Focus
none
- Meet the WCAG 2.2 AA target declared in meta.a11y.
- The mobile dock carries role="group" and an aria-label naming it as the record's actions.
- Each blocker's accessible name is the full sentence naming the field, not a bare field key.
- 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
Livewire can update this component through forwarded wire:* attributes.
Source
The exact, editable file ui:add writes
into your app. Previews render this same code; there are no preview-only components.
{{--
Save Blockers — what currently prevents saving, then the terminal
actions. Each blocker is a real anchor to the field's id (`#<target>`):
landing on a focusable form control via a fragment link focuses it in
every evergreen browser, so "why is save disabled" becomes navigation
rather than a guessing game, and it survives without JavaScript.
The actions never disappear behind a blocked state. They stay on screen,
disabled, with the reason in their `title` — the same availability
doctrine `admin-record-header` follows: a workflow must not be findable
on one surface and missing on another.
Below `lg` the rail is replaced by a dock pinned to the viewport bottom,
carrying the same actions (generalised from
`customers/profile/dock.blade.php`): a record with several tabs of form
work otherwise puts Save a full scroll away from the field that made it
necessary.
Generalises `bundles/form/action-rail.blade.php`. Left out as domain
logic: the bundle-specific blocker vocabulary and shortcut copy —
callers pass their own `blockers`/`actions`/`note`.
--}}
@props([
'blockers' => [],
'actions' => [],
'cancelHref' => '#',
'note' => null,
'dockLabel' => null,
])
@php
// Shapes documented in item.json knowledge.props (kept single-line in
// @props so the registry contract builder can parse each default verbatim):
// blockers: [['field' => 'sku', 'text' => 'Add a SKU before publishing.', 'target' => 'field-sku']]
// actions: [['label', 'variant' => 'default'|'outline', 'href' => null, 'primary' => false, 'requiresValid' => true]]
$blockers = $blockers !== [] ? $blockers : [
['field' => 'sku', 'text' => 'Add a SKU before publishing.', 'target' => 'field-sku'],
['field' => 'price', 'text' => 'Set a price greater than €0.00.', 'target' => 'field-price'],
];
$blockers = array_values(array_filter((array) $blockers, 'is_array'));
$blocked = $blockers !== [];
$actions = $actions !== [] ? $actions : [
['label' => __('Save changes'), 'primary' => true],
['label' => __('Save as draft'), 'variant' => 'outline', 'requiresValid' => false],
];
$actions = array_values(array_filter((array) $actions, 'is_array'));
$note ??= __('Blockers clear automatically as each field is fixed.');
$dockLabel ??= __('Record actions');
$primary = collect($actions)->firstWhere('primary', true) ?? ($actions[0] ?? null);
@endphp
<div data-slot="save-blockers-group" data-surface="admin" {{ $attributes->merge(['class' => 'block']) }}>
<aside data-slot="save-blockers" class="hidden w-full max-w-xs shrink-0 space-y-4 lg:block">
<div class="rounded-lg border border-border bg-card p-6">
<div class="flex items-center justify-between gap-2">
<p class="text-xs font-semibold tracking-wide text-muted-foreground uppercase">
{{ __('Before you can save') }}
</p>
@if ($blocked)
<x-ui.badge variant="soft-warning" size="sm">{{ count($blockers) }}</x-ui.badge>
@endif
</div>
@if ($blocked)
<ul class="mt-2 space-y-2">
@foreach ($blockers as $blocker)
<li class="flex items-start gap-2">
<span class="mt-2 size-2 shrink-0 rounded-full bg-warning" aria-hidden="true"></span>
<x-ui.button
variant="link"
wrap
href="#{{ $blocker['target'] ?? '' }}"
class="!text-warning-text justify-start text-start"
>
{{ $blocker['text'] ?? '' }}
</x-ui.button>
</li>
@endforeach
</ul>
@else
<p class="mt-2 text-sm text-muted-foreground">{{ __('Nothing is blocking save right now.') }}</p>
@endif
</div>
<div class="space-y-2">
@foreach ($actions as $action)
@php $gate = ($action['requiresValid'] ?? true) && $blocked; @endphp
<x-ui.button
variant="{{ $action['variant'] ?? (($action['primary'] ?? false) ? 'default' : 'outline') }}"
class="w-full"
:href="$gate ? null : ($action['href'] ?? null)"
:disabled="$gate"
:title="$gate ? __('Resolve the items above first.') : null"
>
{{ $action['label'] ?? '' }}
</x-ui.button>
@endforeach
@if ($cancelHref)
<x-ui.button variant="ghost" class="w-full text-muted-foreground" href="{{ $cancelHref }}">
{{ __('Cancel') }}
</x-ui.button>
@endif
<p class="px-2 text-xs leading-4 text-pretty text-muted-foreground">{{ $note }}</p>
</div>
</aside>
<div
data-slot="save-blockers-dock"
role="group"
aria-label="{{ $dockLabel }}"
class="fixed inset-x-0 bottom-0 z-30 border-t border-border bg-card/95 px-4 py-2 backdrop-blur lg:hidden"
>
<div class="flex items-center gap-2">
@if ($blocked)
<span class="shrink-0 text-xs text-warning-text">
{{ trans_choice('1 field needs attention|:count fields need attention', count($blockers), ['count' => count($blockers)]) }}
</span>
@endif
@if ($cancelHref)
<x-ui.button variant="outline" size="sm" class="flex-1" href="{{ $cancelHref }}">
{{ __('Cancel') }}
</x-ui.button>
@endif
@if ($primary)
@php $primaryGate = ($primary['requiresValid'] ?? true) && $blocked; @endphp
<x-ui.button size="sm" class="flex-1" :disabled="$primaryGate" :title="$primaryGate ? __('Resolve the items above first.') : null">
{{ $primary['label'] ?? '' }}
</x-ui.button>
@endif
</div>
</div>
</div>
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.0.1 - 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