fix(ui): improve tooltip a11y and settings select layout
Make button and helper tooltips keyboard-focusable with ARIA roles, close helper popups on outside pointer/focus leave, reserve select chevron padding, rotate DNS chevron when open, and re-key the service domain list after row changes.
This commit is contained in:
parent
4f966e3759
commit
232b999cc9
8 changed files with 138 additions and 26 deletions
|
|
@ -1174,6 +1174,20 @@ .application-settings-form .select {
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Reserve the trailing area used by the native select chevron. */
|
||||||
|
.application-settings-workspace .select,
|
||||||
|
.application-settings-form .select {
|
||||||
|
padding-right: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.application-settings-workspace .select,
|
||||||
|
.application-settings-form .select {
|
||||||
|
font-size: 0.75rem !important;
|
||||||
|
line-height: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Keep password toggle / copy button clear of the value inside denser settings inputs */
|
/* Keep password toggle / copy button clear of the value inside denser settings inputs */
|
||||||
.application-settings-workspace .input.input-with-password-toggle,
|
.application-settings-workspace .input.input-with-password-toggle,
|
||||||
.application-settings-workspace .input[type="password"],
|
.application-settings-workspace .input[type="password"],
|
||||||
|
|
|
||||||
|
|
@ -26,26 +26,45 @@
|
||||||
@endphp
|
@endphp
|
||||||
@if ($authDisabled || filled($tooltip))
|
@if ($authDisabled || filled($tooltip))
|
||||||
<span class="relative inline-flex"
|
<span class="relative inline-flex"
|
||||||
x-data="{ visible: false, _t: null }"
|
x-data="{
|
||||||
@mouseenter="_t = setTimeout(() => {
|
visible: false,
|
||||||
visible = true;
|
_t: null,
|
||||||
$nextTick(() => requestAnimationFrame(() => {
|
showTooltip(delay = 0) {
|
||||||
const tip = $refs.tip;
|
clearTimeout(this._t);
|
||||||
if (!tip) return;
|
this._t = setTimeout(() => {
|
||||||
const r = $el.getBoundingClientRect();
|
this.visible = true;
|
||||||
const t = tip.getBoundingClientRect();
|
this.positionTooltip();
|
||||||
let top = r.top - t.height - 6;
|
}, delay);
|
||||||
let left = r.left;
|
},
|
||||||
if (top < 4) top = r.bottom + 6;
|
hideTooltip() {
|
||||||
if (left + t.width > innerWidth - 8) left = innerWidth - 8 - t.width;
|
clearTimeout(this._t);
|
||||||
if (left < 4) left = 4;
|
this.visible = false;
|
||||||
tip.style.top = top + 'px';
|
},
|
||||||
tip.style.left = left + 'px';
|
positionTooltip() {
|
||||||
}));
|
this.$nextTick(() => requestAnimationFrame(() => {
|
||||||
}, 300)"
|
const tip = this.$refs.tip;
|
||||||
@mouseleave="clearTimeout(_t); visible = false">
|
if (!tip) return;
|
||||||
|
const r = this.$el.getBoundingClientRect();
|
||||||
|
const t = tip.getBoundingClientRect();
|
||||||
|
let top = r.top - t.height - 6;
|
||||||
|
let left = r.left;
|
||||||
|
if (top < 4) top = r.bottom + 6;
|
||||||
|
if (left + t.width > innerWidth - 8) left = innerWidth - 8 - t.width;
|
||||||
|
if (left < 4) left = 4;
|
||||||
|
tip.style.top = top + 'px';
|
||||||
|
tip.style.left = left + 'px';
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
@if ($disabled) tabindex="0" :aria-describedby="visible ? $id('button-tooltip') : null" @endif
|
||||||
|
@mouseenter="showTooltip(300)"
|
||||||
|
@mouseleave="hideTooltip()"
|
||||||
|
@focusin="showTooltip()"
|
||||||
|
@focusout="hideTooltip()"
|
||||||
|
@click.outside="hideTooltip()">
|
||||||
@endif
|
@endif
|
||||||
<button @disabled($disabled) @if ($isHighlighted) isHighlighted @endif
|
<button @disabled($disabled) @if ($isHighlighted) isHighlighted @endif
|
||||||
|
@if ($authDisabled || filled($tooltip)) :aria-describedby="visible ? $id('button-tooltip') : null" @endif
|
||||||
{{ $attributes->merge(['class' => $defaultClass, 'type' => 'button'])->merge($loadingAttributes) }}
|
{{ $attributes->merge(['class' => $defaultClass, 'type' => 'button'])->merge($loadingAttributes) }}
|
||||||
@isset($confirm)
|
@isset($confirm)
|
||||||
x-on:click="toggleConfirmModal('{{ $confirm }}', '{{ explode('(', $confirmAction)[0] }}')"
|
x-on:click="toggleConfirmModal('{{ $confirm }}', '{{ explode('(', $confirmAction)[0] }}')"
|
||||||
|
|
@ -60,7 +79,8 @@
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
</button>
|
</button>
|
||||||
@if ($authDisabled || filled($tooltip))
|
@if ($authDisabled || filled($tooltip))
|
||||||
<div x-ref="tip" x-show="visible" x-cloak class="auth-tooltip">
|
<div x-ref="tip" x-show="visible" x-cloak :id="$id('button-tooltip')" role="tooltip"
|
||||||
|
class="auth-tooltip">
|
||||||
{{ $tooltip ?: 'You do not have permission to perform this action.' }}
|
{{ $tooltip ?: 'You do not have permission to perform this action.' }}
|
||||||
</div>
|
</div>
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,21 @@
|
||||||
this.pinned = false;
|
this.pinned = false;
|
||||||
this.open = false;
|
this.open = false;
|
||||||
},
|
},
|
||||||
|
closeWhenFocusLeaves() {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
const activeElement = document.activeElement;
|
||||||
|
if (!this.$refs.trigger?.contains(activeElement) && !this.$refs.popup?.contains(activeElement)) {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
closeWhenPointerIsOutside(event) {
|
||||||
|
if (!this.open || this.$refs.trigger?.contains(event.target) || this.$refs.popup?.contains(event.target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.close();
|
||||||
|
},
|
||||||
position() {
|
position() {
|
||||||
const trigger = this.$refs.trigger;
|
const trigger = this.$refs.trigger;
|
||||||
const popup = this.$refs.popup;
|
const popup = this.$refs.popup;
|
||||||
|
|
@ -56,7 +71,8 @@
|
||||||
|
|
||||||
this.style = `top: ${top}px; left: ${left}px;`;
|
this.style = `top: ${top}px; left: ${left}px;`;
|
||||||
}
|
}
|
||||||
}" @click.outside="close" @keydown.window.escape="close" @resize.window="open && position()" @scroll.window="open && position()"
|
}" @pointerdown.window="closeWhenPointerIsOutside($event)" @keydown.window.escape="close"
|
||||||
|
@resize.window="open && position()" @scroll.window="open && position()"
|
||||||
{{ $attributes->merge(['class' => 'relative z-10 inline-block align-middle']) }}>
|
{{ $attributes->merge(['class' => 'relative z-10 inline-block align-middle']) }}>
|
||||||
{{-- button (not div) so label-for associations do not steal the click on mobile --}}
|
{{-- button (not div) so label-for associations do not steal the click on mobile --}}
|
||||||
<button type="button" x-ref="trigger"
|
<button type="button" x-ref="trigger"
|
||||||
|
|
@ -64,7 +80,8 @@
|
||||||
'info-helper relative z-10 inline-flex shrink-0 items-center justify-center border-0 bg-transparent p-0 leading-none',
|
'info-helper relative z-10 inline-flex shrink-0 items-center justify-center border-0 bg-transparent p-0 leading-none',
|
||||||
'size-3.5' => ! isset($trigger),
|
'size-3.5' => ! isset($trigger),
|
||||||
])
|
])
|
||||||
aria-label="{{ $label }}" @mouseenter="show(false)" @mouseleave="hide"
|
aria-label="{{ $label }}" :aria-describedby="open ? $id('helper-popup') : null"
|
||||||
|
@mouseenter="show(false)" @mouseleave="hide" @focus="show(false)" @blur="closeWhenFocusLeaves()"
|
||||||
@click.prevent.stop="open && pinned ? close() : show(true)">
|
@click.prevent.stop="open && pinned ? close() : show(true)">
|
||||||
@isset($trigger)
|
@isset($trigger)
|
||||||
{{ $trigger }}
|
{{ $trigger }}
|
||||||
|
|
@ -77,7 +94,7 @@ class="size-3.5 text-neutral-400 transition-colors hover:text-neutral-600 dark:t
|
||||||
@endisset
|
@endisset
|
||||||
</button>
|
</button>
|
||||||
<template x-teleport="body">
|
<template x-teleport="body">
|
||||||
<div x-ref="popup" x-show="open" x-cloak
|
<div x-ref="popup" x-show="open" x-cloak :id="$id('helper-popup')" role="tooltip"
|
||||||
x-transition:enter="transition ease-out duration-100"
|
x-transition:enter="transition ease-out duration-100"
|
||||||
x-transition:enter-start="opacity-0 translate-y-0.5"
|
x-transition:enter-start="opacity-0 translate-y-0.5"
|
||||||
x-transition:enter-end="opacity-100 translate-y-0"
|
x-transition:enter-end="opacity-100 translate-y-0"
|
||||||
|
|
@ -86,7 +103,7 @@ class="size-3.5 text-neutral-400 transition-colors hover:text-neutral-600 dark:t
|
||||||
x-transition:leave-end="opacity-0"
|
x-transition:leave-end="opacity-0"
|
||||||
:style="style"
|
:style="style"
|
||||||
class="info-helper-popup fixed z-[9999] w-max max-w-[min(20rem,calc(100vw-2rem))]"
|
class="info-helper-popup fixed z-[9999] w-max max-w-[min(20rem,calc(100vw-2rem))]"
|
||||||
@mouseenter="cancelHide()" @mouseleave="hide()" @click.stop>
|
@mouseenter="cancelHide()" @mouseleave="hide()" @focusout="closeWhenFocusLeaves()" @click.stop>
|
||||||
<div class="px-3 py-2.5 text-[13px] leading-5">
|
<div class="px-3 py-2.5 text-[13px] leading-5">
|
||||||
{!! $helper !!}
|
{!! $helper !!}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -176,7 +176,8 @@ class="button button-highlighted">
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
{{-- Flat table with Service column so assignment is always visible --}}
|
{{-- Flat table with Service column so assignment is always visible --}}
|
||||||
<div class="application-settings-section-body is-flush mt-1 w-full scroll-mt-28">
|
<div wire:key="service-domains-list-{{ md5(serialize($domainRows)) }}"
|
||||||
|
class="application-settings-section-body is-flush mt-1 w-full scroll-mt-28">
|
||||||
@include('livewire.project.service.partials.domain-table', [
|
@include('livewire.project.service.partials.domain-table', [
|
||||||
'rows' => collect($domainRows),
|
'rows' => collect($domainRows),
|
||||||
'domainRows' => $domainRows,
|
'domainRows' => $domainRows,
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,9 @@
|
||||||
x-bind:aria-expanded="dnsEntriesOpen" title="DNS entries for this server">
|
x-bind:aria-expanded="dnsEntriesOpen" title="DNS entries for this server">
|
||||||
<x-reicon name="globe" class="size-3.5" />
|
<x-reicon name="globe" class="size-3.5" />
|
||||||
DNS entries
|
DNS entries
|
||||||
<x-reicon name="chevron-down" class="size-3 opacity-55" />
|
<span class="inline-flex transition-transform" :class="dnsEntriesOpen && 'rotate-180'">
|
||||||
|
<x-reicon name="chevron-down" class="size-3 opacity-55" />
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div x-show="dnsEntriesOpen" x-cloak role="menu" x-transition.origin.top.right
|
<div x-show="dnsEntriesOpen" x-cloak role="menu" x-transition.origin.top.right
|
||||||
class="listbox-panel left-auto! right-0! z-[90]! w-56! min-w-56!">
|
class="listbox-panel left-auto! right-0! z-[90]! w-56! min-w-56!">
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,39 @@
|
||||||
->toContain('@mouseleave="hide()"');
|
->toContain('@mouseleave="hide()"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('helper popup supports focus dismissal and tooltip aria relationships', function () {
|
||||||
|
$helper = file_get_contents(resource_path('views/components/helper.blade.php'));
|
||||||
|
|
||||||
|
expect($helper)
|
||||||
|
->toContain('closeWhenFocusLeaves()')
|
||||||
|
->toContain('@focus="show(false)"')
|
||||||
|
->toContain('@blur="closeWhenFocusLeaves()"')
|
||||||
|
->toContain('role="tooltip"')
|
||||||
|
->toContain(':aria-describedby="open ? $id(\'helper-popup\') : null"')
|
||||||
|
->toContain(':id="$id(\'helper-popup\')"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('teleported helper popup closes from taps outside on mobile', function () {
|
||||||
|
$helper = file_get_contents(resource_path('views/components/helper.blade.php'));
|
||||||
|
|
||||||
|
expect($helper)
|
||||||
|
->toContain('@pointerdown.window="closeWhenPointerIsOutside($event)"')
|
||||||
|
->toContain('closeWhenPointerIsOutside(event)')
|
||||||
|
->toContain('this.$refs.popup?.contains(event.target)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('button tooltips are available to keyboard and assistive technology users', function () {
|
||||||
|
$button = file_get_contents(resource_path('views/components/forms/button.blade.php'));
|
||||||
|
|
||||||
|
expect($button)
|
||||||
|
->toContain('@focusin="showTooltip()"')
|
||||||
|
->toContain('@focusout="hideTooltip()"')
|
||||||
|
->toContain('@click.outside="hideTooltip()"')
|
||||||
|
->toContain('role="tooltip"')
|
||||||
|
->toContain(':aria-describedby="visible ? $id(\'button-tooltip\') : null"')
|
||||||
|
->toContain(':id="$id(\'button-tooltip\')"');
|
||||||
|
});
|
||||||
|
|
||||||
test('listbox keeps the helper outside the label association', function () {
|
test('listbox keeps the helper outside the label association', function () {
|
||||||
$path = resource_path('views/components/forms/listbox.blade.php');
|
$path = resource_path('views/components/forms/listbox.blade.php');
|
||||||
$contents = file_get_contents($path);
|
$contents = file_get_contents($path);
|
||||||
|
|
|
||||||
9
tests/Feature/SelectControlStyleTest.php
Normal file
9
tests/Feature/SelectControlStyleTest.php
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
test('native select values stay clear of the dropdown icon on mobile', function () {
|
||||||
|
$styles = file_get_contents(resource_path('css/app.css'));
|
||||||
|
|
||||||
|
expect($styles)
|
||||||
|
->toMatch('/\.application-settings-workspace \.select,\s*\.application-settings-form \.select \{\s*padding-right: 2\.5rem;/')
|
||||||
|
->toMatch('/@media \(max-width: 767px\) \{\s*\.application-settings-workspace \.select,\s*\.application-settings-form \.select \{\s*font-size: 0\.75rem !important;/');
|
||||||
|
});
|
||||||
|
|
@ -105,6 +105,14 @@
|
||||||
->assertSee('Manual records');
|
->assertSee('Manual records');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rotates the dns entries chevron while its dropdown is open', function () {
|
||||||
|
$view = file_get_contents(resource_path('views/livewire/project/shared/cloudflare-autoconfigure.blade.php'));
|
||||||
|
|
||||||
|
expect($view)
|
||||||
|
->toContain('class="inline-flex transition-transform"')
|
||||||
|
->toContain(':class="dnsEntriesOpen && \'rotate-180\'"');
|
||||||
|
});
|
||||||
|
|
||||||
it('lists dns entries for service hosts that still need dns', function () {
|
it('lists dns entries for service hosts that still need dns', function () {
|
||||||
$this->webApp->update([
|
$this->webApp->update([
|
||||||
'fqdn' => 'https://web.example.com',
|
'fqdn' => 'https://web.example.com',
|
||||||
|
|
@ -199,7 +207,9 @@
|
||||||
->set('newDomain', 'https://web.example.com')
|
->set('newDomain', 'https://web.example.com')
|
||||||
->call('addDomain')
|
->call('addDomain')
|
||||||
->assertHasNoErrors()
|
->assertHasNoErrors()
|
||||||
->assertDispatched('success');
|
->assertDispatched('success')
|
||||||
|
->assertSet('domainRows', fn (array $rows): bool => collect($rows)->pluck('url')->contains('https://web.example.com'))
|
||||||
|
->assertSee('https://web.example.com');
|
||||||
|
|
||||||
$this->webApp->refresh();
|
$this->webApp->refresh();
|
||||||
expect(explode(',', (string) $this->webApp->fqdn))
|
expect(explode(',', (string) $this->webApp->fqdn))
|
||||||
|
|
@ -216,6 +226,12 @@
|
||||||
->not->toBeNull();
|
->not->toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('replaces the rendered domain list when its rows change', function () {
|
||||||
|
$view = file_get_contents(resource_path('views/livewire/project/service/domains.blade.php'));
|
||||||
|
|
||||||
|
expect($view)->toContain('wire:key="service-domains-list-{{ md5(serialize($domainRows)) }}"');
|
||||||
|
});
|
||||||
|
|
||||||
it('does not duplicate the service name as a badge in the domain cell', function () {
|
it('does not duplicate the service name as a badge in the domain cell', function () {
|
||||||
$view = file_get_contents(resource_path('views/livewire/project/service/partials/domain-table.blade.php'));
|
$view = file_get_contents(resource_path('views/livewire/project/service/partials/domain-table.blade.php'));
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue