mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-19 05:47:48 +00:00
fix(webui): ARIA-valid hover text, alias metadata guard, context-chip polish
CompactNumber.vue and ProviderRateBadge.vue both put aria-label on a
plain <span> — axe's aria-prohibited-attr rule: a <span>'s implicit
ARIA role ("generic") does not support accessible-name computation at
all, so the attribute was simply invalid there, not just discouraged.
Both now use the visible-text-hidden-plus-sr-only-full-text pattern
instead: the compact/label text is marked aria-hidden="true", and a
sr-only sibling span carries the exact value/detail for assistive tech.
title stays on the outer element for mouse hover, unchanged. Folded
review minor.
ProvidersView.vue: the alias table's ModelChip cell read
row.original.modelMeta.contextTokens without a guard, unlike the
provider path's own modelMetaFor fallback — a response from a server
build older than this field (version skew) would throw instead of just
omitting the hover detail. Now optional-chained to match. The visible
context chip beside each model gains title="context window" and
text-xs (was an arbitrary text-[10px]), matching the rest of the panel's
muted-label sizing convention. Folded review minors.
This commit is contained in:
@@ -5,13 +5,22 @@ import { formatCompactCount, formatExactInt } from '@/lib/format'
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* CompactNumber renders a large raw token/request count in its compact
|
* CompactNumber renders a large raw token/request count in its compact
|
||||||
* SI-style form ("1.23M") while carrying the exact value via both
|
* SI-style form ("1.23M") while carrying the exact value via `title`
|
||||||
* `title` (mouse hover) and `aria-label` (keyboard/screen-reader parity
|
* (mouse hover) and a visually-hidden sibling span (keyboard/screen-
|
||||||
* — the same established pattern ProviderRateBadge.vue and ModelChip.vue
|
* reader parity). Used anywhere a usage count renders: DataTable numeric
|
||||||
* already apply to their own hover detail). Used anywhere a usage count
|
* columns (usage-columns.ts, target-columns.ts, via
|
||||||
* renders: DataTable numeric columns (usage-columns.ts, target-
|
* `h(CompactNumber, { value })`), the Usage tab's Total card, and a
|
||||||
* columns.ts, via `h(CompactNumber, { value })`), the Usage tab's Total
|
* group accordion's trigger/detail stat grid (UsageView.vue).
|
||||||
* card, and a group accordion's trigger/detail stat grid (UsageView.vue).
|
*
|
||||||
|
* Review fix (folded minor, ARIA): `aria-label` on a plain `<span>` is
|
||||||
|
* an axe `aria-prohibited-attr` violation — a `<span>`'s implicit ARIA
|
||||||
|
* role ("generic") does not support name computation at all, so
|
||||||
|
* `aria-label`/`aria-labelledby` there is simply invalid, not merely
|
||||||
|
* discouraged. The visible, compact text is marked `aria-hidden="true"`
|
||||||
|
* so assistive tech skips the abbreviated form, and a `sr-only` sibling
|
||||||
|
* span (visually hidden, still in the accessibility tree) carries the
|
||||||
|
* exact value instead — the same visible-abbreviation-plus-hidden-full-
|
||||||
|
* text pattern ProviderRateBadge.vue now applies too.
|
||||||
*
|
*
|
||||||
* Cost figures are NOT this component's concern — formatCost already
|
* Cost figures are NOT this component's concern — formatCost already
|
||||||
* shows full 4-decimal precision, so there is nothing to compact or
|
* shows full 4-decimal precision, so there is nothing to compact or
|
||||||
@@ -24,5 +33,8 @@ const exact = computed(() => formatExactInt(props.value))
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<span :title="exact" :aria-label="exact">{{ compact }}</span>
|
<span :title="exact">
|
||||||
|
<span aria-hidden="true">{{ compact }}</span>
|
||||||
|
<span class="sr-only">{{ exact }}</span>
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -37,10 +37,16 @@ const label = computed(() => (status.value === 'no-traffic' ? 'no traffic' : for
|
|||||||
* detail carries the "right now" (provider) or "today" (model) counts —
|
* detail carries the "right now" (provider) or "today" (model) counts —
|
||||||
* the spec's minute-window requirement where that data exists at all
|
* the spec's minute-window requirement where that data exists at all
|
||||||
* (minuteRateTitle), falling back to dayRateTitle when it does not. Used
|
* (minuteRateTitle), falling back to dayRateTitle when it does not. Used
|
||||||
* as BOTH the badge's title (mouse hover) and its aria-label (folded
|
* as BOTH the badge's title (mouse hover) and, via the sr-only span in
|
||||||
* review minor, v0.22 review round: title alone is mouse-only — a
|
* the template, its accessible name for keyboard/screen-reader users
|
||||||
* keyboard or screen-reader user gets none of it without an aria-label
|
* (folded review minor, v0.22 review round: title alone is mouse-only).
|
||||||
* carrying the same sentence).
|
*
|
||||||
|
* Review fix (v0.23 round): this used to sit directly on `:aria-label`
|
||||||
|
* on the Badge/`<span>` element, which axe flags as `aria-prohibited-
|
||||||
|
* attr` — a `<span>`'s implicit role ("generic") does not support name
|
||||||
|
* computation. See CompactNumber.vue's own doc comment for the identical
|
||||||
|
* fix and the shared visible-abbreviation-plus-sr-only-full-text
|
||||||
|
* pattern this component now uses too.
|
||||||
*/
|
*/
|
||||||
const detail = computed(() =>
|
const detail = computed(() =>
|
||||||
props.attemptsMinute === undefined || props.failuresMinute === undefined
|
props.attemptsMinute === undefined || props.failuresMinute === undefined
|
||||||
@@ -68,7 +74,8 @@ const extraClass = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Badge as="span" :variant="variant" :class="extraClass" class="font-normal tabular-nums" :title="detail" :aria-label="detail">
|
<Badge as="span" :variant="variant" :class="extraClass" class="font-normal tabular-nums" :title="detail">
|
||||||
{{ label }}
|
<span aria-hidden="true">{{ label }}</span>
|
||||||
|
<span class="sr-only">{{ detail }}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -179,12 +179,18 @@ const aliasColumns: ColumnDef<AdminAliasView, unknown>[] = [
|
|||||||
id: 'alias',
|
id: 'alias',
|
||||||
header: 'Alias',
|
header: 'Alias',
|
||||||
accessorFn: (a) => a.alias,
|
accessorFn: (a) => a.alias,
|
||||||
|
// Optional-chained (review fix, folded minor) the same way
|
||||||
|
// modelMetaFor guards the provider path below: AdminAliasView.
|
||||||
|
// modelMeta is typed as always-present, but a real response from a
|
||||||
|
// server build older than this field (version skew) would otherwise
|
||||||
|
// throw on `.modelMeta.contextTokens` here instead of just omitting
|
||||||
|
// the hover detail.
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
h(ModelChip, {
|
h(ModelChip, {
|
||||||
id: row.original.alias,
|
id: row.original.alias,
|
||||||
contextTokens: row.original.modelMeta.contextTokens,
|
contextTokens: row.original.modelMeta?.contextTokens,
|
||||||
inputPerMTokUsd: row.original.modelMeta.inputPerMTokUsd,
|
inputPerMTokUsd: row.original.modelMeta?.inputPerMTokUsd,
|
||||||
outputPerMTokUsd: row.original.modelMeta.outputPerMTokUsd,
|
outputPerMTokUsd: row.original.modelMeta?.outputPerMTokUsd,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -331,7 +337,8 @@ const aliasEmptyMessage = computed(() =>
|
|||||||
-->
|
-->
|
||||||
<span
|
<span
|
||||||
v-if="modelMetaFor(p, m).contextTokens !== undefined"
|
v-if="modelMetaFor(p, m).contextTokens !== undefined"
|
||||||
class="text-[10px] text-muted-foreground tabular-nums"
|
title="context window"
|
||||||
|
class="text-xs text-muted-foreground tabular-nums"
|
||||||
>
|
>
|
||||||
{{ formatContextWindow(modelMetaFor(p, m).contextTokens as number) }}
|
{{ formatContextWindow(modelMetaFor(p, m).contextTokens as number) }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user