Skip to content
Brok UI

Loading…

No results

Save Blockers

Open source

A side rail (docked to the viewport bottom on mobile) listing what currently blocks saving, where each blocker moves focus to the field responsible.

Version
v1.0.1
Stability
stable
License
MIT
Related
Record Header
Button
Badge

Preview

2 fields need attention Cancel
previews.components.admin-save-blockers.default.blade.php Blade
<div class="w-full">
    <x-ui.admin.save-blockers />
</div>

Installation

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

  • blade resources/views/components/ui/admin/save-blockers.blade.php
Registry dependencies
button badge
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-save-blockers.md
# 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

manifest knowledge + registry-derived coverage

Props

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

save-blockers save-blockers-dock save-blockers-group

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

Persistent message and notification

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
save-blockers-group save-blockers save-blockers-dock
Theming hooks
save-blockers

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-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

Safe

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.

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