Files
livewire-material/resources/views/components/modal.blade.php
T
Andreas Reinhold / reiniandClaude Opus 5 53bb000425 Let an application replace the safe-area insets and dock on the bottom bar
Components read the device's safe-area insets straight from env(), which a
browser test cannot fake and an application drawing its own status strip
cannot extend. Every inset the package reads, in its CSS and its views, is
now var(--material-safe-top|bottom|left|right, env(safe-area-inset-…)):
unchanged while the variables are unset.

The app shell's --material-bottom-bar also adds
var(--material-bottom-extra, 0px), so an application that docks something on
the phone's navigation bar (an offline banner) sets its height once and the
snackbar, a fab button and the page's bottom padding clear it.

A guard test fails on any env(safe-area-inset-*) outside such a variable, and
AppShellTest's assertion on the bar height's class follows the new value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 18:03:20 +02:00

115 lines
5.4 KiB
PHP

{{-- An M3 dialog, on the native `<dialog>` opened with `showModal()`.
Native because it gets the hard parts right on its own: the top layer above everything, the
rest of the page inert, focus moved in and handed back, Escape. The open state is the Livewire
property in `wire:model` (entangled live) a flag (`$confirmingDelete`) or an id
(`$deletingShareId`) and closing writes back whichever "closed" means for it, `false` or
`null`. Without `wire:model` it reads and writes `open` in the Alpine scope around it:
<div x-data="{ open: false }">
<x-button label="Delete" x-on:click="open = true" />
<x-modal title="Delete this share?"></x-modal>
</div>
`wire:ignore.self`, because `showModal()` sets the `open` attribute, which the server's HTML
does not have: without it the next Livewire render morphs the attribute away and the dialog
shuts under the person using it. The contents still morph.
M3's basic dialog (DialogTokens, androidx Compose Material 3, Apache-2.0): surface-container-
high, extra-large corner, elevation 3, a headline-small `title`, body-medium `subtitle` in
on-surface-variant, and the `actions` slot at the end. `icon` puts a secondary-coloured icon
above a centred title, as M3 draws a dialog with a hero icon. `fullscreen` makes a dialog that
holds a form take the whole screen below `sm`, with a close button and the title in a top bar
clear of the notch. `persistent` ignores Escape and the scrim, for a dialog that must be
answered. It opens on the fast spatial spring and closes at once, as M3's do. --}}
@props([
'title' => null,
'subtitle' => null,
'icon' => null,
'separator' => false,
'persistent' => false,
'fullscreen' => false,
'boxClass' => null,
])
@php
$model = $attributes->wire('model')->value() ?: null;
$id = $attributes->get('id') ?? 'material-dialog-'.substr(md5($model.'|'.$title), 0, 10);
@endphp
<dialog
wire:ignore.self
@if ($model !== null)
x-data="{
open: @entangle($attributes->wire('model')).live,
close() { this.open = typeof this.open === 'boolean' ? false : null },
}"
@else
x-data="{ close() { this.open = false } }"
@endif
x-effect="open ? ($el.open || $el.showModal()) : ($el.open && $el.close())"
x-on:cancel.prevent="{{ $persistent ? '' : 'close()' }}"
x-on:close="if (open) close()"
@if (! $persistent) x-on:click.self="close()" @endif
@if (filled($title)) aria-labelledby="{{ $id }}-title" @endif
{{ $attributes->whereDoesntStartWith('wire:model')->except(['id', 'class'])->merge(['id' => $id]) }}
@class([
'm-auto max-h-[calc(100dvh-3rem)] w-[calc(100vw-3rem)] max-w-[35rem] min-w-70 overflow-visible bg-transparent p-0 text-on-surface',
'backdrop:bg-scrim/32',
'opacity-100 scale-100 starting:opacity-0 starting:scale-95 transition-[opacity,scale] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast',
'max-sm:m-0 max-sm:h-dvh max-sm:max-h-none max-sm:w-full max-sm:max-w-none max-sm:min-w-0' => $fullscreen,
$attributes->get('class'),
])
>
<div @class([
'flex max-h-[inherit] flex-col overflow-y-auto rounded-corner-xl bg-surface-container-high p-6 shadow-elevation-3',
'max-sm:h-full max-sm:rounded-none max-sm:p-0 max-sm:pt-[var(--material-safe-top,env(safe-area-inset-top))]' => $fullscreen,
$boxClass,
])>
@if ($fullscreen)
<div class="flex h-16 shrink-0 items-center gap-1 px-1 sm:hidden">
<x-livewire-material::button icon="close" :tooltip="__('Close')" x-on:click="close()" />
@if (filled($title))
<span class="truncate type-title-lg">{{ $title }}</span>
@endif
</div>
@endif
<div @class(['min-h-0 flex-1', 'max-sm:overflow-y-auto max-sm:px-6 max-sm:pb-6' => $fullscreen])>
@if (filled($title) || filled($subtitle) || $icon)
{{-- A full-screen dialog's bar names it on a phone, so only the subtitle stays there. --}}
<div @class(['mb-4', 'max-sm:hidden' => $fullscreen && ! $icon && blank($subtitle), 'text-center' => $icon])>
@if ($icon)
<x-livewire-material::icon :name="$icon" class="mx-auto mb-4 size-6 text-secondary" />
@endif
@if (filled($title))
<h2 id="{{ $id }}-title" @class(['type-headline-sm', 'max-sm:hidden' => $fullscreen && ! $icon])>{{ $title }}</h2>
@endif
@if (filled($subtitle))
<p @class(['type-body-md text-on-surface-variant', 'mt-4' => filled($title) || $icon, 'max-sm:mt-0' => $fullscreen && ! $icon])>{{ $subtitle }}</p>
@endif
@if ($separator)
<x-livewire-material::divider class="mt-4" />
@endif
</div>
@endif
<div class="type-body-md text-on-surface-variant">{{ $slot }}</div>
</div>
@isset($actions)
<div @class([
'flex shrink-0 flex-wrap items-center justify-end gap-2 pt-6',
'max-sm:border-t max-sm:border-outline-variant max-sm:px-6 max-sm:py-4' => $fullscreen,
])>
{{ $actions }}
</div>
@endisset
</div>
</dialog>