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
115 lines
5.4 KiB
PHP
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>
|