mirror of
https://github.com/open-webui/open-webui.git
synced 2026-08-13 01:02:25 -06:00
fix: make admin user table sortable by keyboard (WCAG 2.1.1, 4.1.2) (#27501)
On latest `dev`, the five sortable column headers in the admin Users table are click handling `<th>` elements:
```svelte
<th scope="col" class="px-2.5 py-1.5 font-normal cursor-pointer select-none" on:click={() => setSortKey('name')}>
```
A `<th>` is not interactive. There is no `<button>`, no `tabindex`, no `role` and no key handler, so **sorting the user list is impossible without a mouse**. The sort direction is also conveyed only by an 8×8 pixel chevron, with no programmatic state, so assistive technology cannot report which column is sorted or in which direction.
Breaks WCAG 2.1.1 Keyboard (Level A) and 4.1.2 Name, Role, Value (Level A).
Fix: move the click handler onto a real `<button>` inside the header, which brings native focus, Enter and Space activation and the correct role, and add `aria-sort` to the `<th>`, which already carries `scope="col"` and therefore the implicit `columnheader` role. Only the active column reports a direction, since `orderBy` is a single value; the non sortable actions column deliberately gets no `aria-sort` at all rather than `none`, so it is not advertised as sortable.
The cell padding moves from the `<th>` onto the button so the whole header stays clickable. Left on the `<th>`, the padding ring would have become a dead zone, shrinking the hit target and flipping the cursor at an invisible boundary inside the header.
`cursor-pointer` is dropped from the `<th>` because `src/tailwind.css` already applies it to every `button`.
The repeated `aria-sort` ternary is extracted to a small `sortState` helper rather than pasted five times.
The same mouse only `<th on:click>` pattern still exists in the Analytics, Evaluations and Groups tables and is not touched here.
Severity: Serious. A core admin function is unreachable without a pointing device.
### Contributor License Agreement
<!--
🚨 DO NOT DELETE THE TEXT BELOW 🚨
Keep the "Contributor License Agreement" confirmation text intact.
Deleting it will trigger the CLA-Bot to INVALIDATE your PR.
Your PR will NOT be reviewed or merged until you check the box below confirming that you have read and agree to the terms of the CLA.
-->
- [x] By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
> [!NOTE]
> Deleting the CLA section will lead to immediate closure of your PR and it will not be merged in.
This commit is contained in:
@@ -72,6 +72,9 @@
|
||||
}
|
||||
};
|
||||
|
||||
const sortState = (key) =>
|
||||
orderBy === key ? (direction === 'asc' ? 'ascending' : 'descending') : 'none';
|
||||
|
||||
const setSortKey = (key) => {
|
||||
if (orderBy === key) {
|
||||
direction = direction === 'asc' ? 'desc' : 'asc';
|
||||
@@ -221,12 +224,12 @@
|
||||
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full">
|
||||
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
|
||||
<tr class=" border-b-[1.5px] border-gray-50 dark:border-gray-850/30">
|
||||
<th
|
||||
scope="col"
|
||||
class="px-2.5 py-1.5 font-normal cursor-pointer select-none"
|
||||
on:click={() => setSortKey('name')}
|
||||
>
|
||||
<div class="flex gap-1.5 items-center">
|
||||
<th scope="col" class="font-normal select-none" aria-sort={sortState('name')}>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full gap-1.5 items-center px-2.5 py-1.5"
|
||||
on:click={() => setSortKey('name')}
|
||||
>
|
||||
{$i18n.t('Name')}
|
||||
|
||||
{#if orderBy === 'name'}
|
||||
@@ -242,14 +245,14 @@
|
||||
<ChevronUp className="size-2" />
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-2.5 py-1.5 font-normal cursor-pointer select-none"
|
||||
on:click={() => setSortKey('role')}
|
||||
>
|
||||
<div class="flex gap-1.5 items-center">
|
||||
<th scope="col" class="font-normal select-none" aria-sort={sortState('role')}>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full gap-1.5 items-center px-2.5 py-1.5"
|
||||
on:click={() => setSortKey('role')}
|
||||
>
|
||||
{$i18n.t('Role')}
|
||||
|
||||
{#if orderBy === 'role'}
|
||||
@@ -265,14 +268,14 @@
|
||||
<ChevronUp className="size-2" />
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-2.5 py-1.5 font-normal cursor-pointer select-none"
|
||||
on:click={() => setSortKey('email')}
|
||||
>
|
||||
<div class="flex gap-1.5 items-center">
|
||||
<th scope="col" class="font-normal select-none" aria-sort={sortState('email')}>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full gap-1.5 items-center px-2.5 py-1.5"
|
||||
on:click={() => setSortKey('email')}
|
||||
>
|
||||
{$i18n.t('Email')}
|
||||
|
||||
{#if orderBy === 'email'}
|
||||
@@ -288,15 +291,15 @@
|
||||
<ChevronUp className="size-2" />
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
</th>
|
||||
|
||||
<th
|
||||
scope="col"
|
||||
class="px-2.5 py-1.5 font-normal cursor-pointer select-none"
|
||||
on:click={() => setSortKey('last_active_at')}
|
||||
>
|
||||
<div class="flex gap-1.5 items-center">
|
||||
<th scope="col" class="font-normal select-none" aria-sort={sortState('last_active_at')}>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full gap-1.5 items-center px-2.5 py-1.5"
|
||||
on:click={() => setSortKey('last_active_at')}
|
||||
>
|
||||
{$i18n.t('Last Active')}
|
||||
<!-- {$i18n.t('Last Modified')} -->
|
||||
|
||||
@@ -313,14 +316,14 @@
|
||||
<ChevronUp className="size-2" />
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-2.5 py-1.5 font-normal cursor-pointer select-none"
|
||||
on:click={() => setSortKey('created_at')}
|
||||
>
|
||||
<div class="flex gap-1.5 items-center">
|
||||
<th scope="col" class="font-normal select-none" aria-sort={sortState('created_at')}>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full gap-1.5 items-center px-2.5 py-1.5"
|
||||
on:click={() => setSortKey('created_at')}
|
||||
>
|
||||
{$i18n.t('Created at')}
|
||||
{#if orderBy === 'created_at'}
|
||||
<span class="font-normal"
|
||||
@@ -335,7 +338,7 @@
|
||||
<ChevronUp className="size-2" />
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
</th>
|
||||
|
||||
<th scope="col" class="px-2.5 py-1.5 font-normal text-right"></th>
|
||||
|
||||
Reference in New Issue
Block a user