fix(usage): make sorting and api expansion keyboard accessible

This commit is contained in:
Supra4E8C
2026-02-13 15:27:16 +08:00
parent 9ef7d439d2
commit c6d00e8b3f
3 changed files with 134 additions and 56 deletions

View File

@@ -74,6 +74,7 @@ export function ApiDetailsCard({ apiStats, loading, hasPrices }: ApiDetailsCardP
<button <button
key={key} key={key}
type="button" type="button"
aria-pressed={sortKey === key}
className={`${styles.apiSortBtn} ${sortKey === key ? styles.apiSortBtnActive : ''}`} className={`${styles.apiSortBtn} ${sortKey === key ? styles.apiSortBtnActive : ''}`}
onClick={() => handleSort(key)} onClick={() => handleSort(key)}
> >
@@ -82,9 +83,19 @@ export function ApiDetailsCard({ apiStats, loading, hasPrices }: ApiDetailsCardP
))} ))}
</div> </div>
<div className={styles.apiList}> <div className={styles.apiList}>
{sorted.map((api) => ( {sorted.map((api, index) => {
const isExpanded = expandedApis.has(api.endpoint);
const panelId = `api-models-${index}`;
return (
<div key={api.endpoint} className={styles.apiItem}> <div key={api.endpoint} className={styles.apiItem}>
<div className={styles.apiHeader} onClick={() => toggleExpand(api.endpoint)}> <button
type="button"
className={styles.apiHeader}
onClick={() => toggleExpand(api.endpoint)}
aria-expanded={isExpanded}
aria-controls={panelId}
>
<div className={styles.apiInfo}> <div className={styles.apiInfo}>
<span className={styles.apiEndpoint}>{api.endpoint}</span> <span className={styles.apiEndpoint}>{api.endpoint}</span>
<div className={styles.apiStats}> <div className={styles.apiStats}>
@@ -110,11 +121,11 @@ export function ApiDetailsCard({ apiStats, loading, hasPrices }: ApiDetailsCardP
</div> </div>
</div> </div>
<span className={styles.expandIcon}> <span className={styles.expandIcon}>
{expandedApis.has(api.endpoint) ? '▼' : '▶'} {isExpanded ? '▼' : '▶'}
</span> </span>
</div> </button>
{expandedApis.has(api.endpoint) && ( {isExpanded && (
<div className={styles.apiModels}> <div id={panelId} className={styles.apiModels}>
{Object.entries(api.models).map(([model, stats]) => ( {Object.entries(api.models).map(([model, stats]) => (
<div key={model} className={styles.modelRow}> <div key={model} className={styles.modelRow}>
<span className={styles.modelName}>{model}</span> <span className={styles.modelName}>{model}</span>
@@ -133,7 +144,8 @@ export function ApiDetailsCard({ apiStats, loading, hasPrices }: ApiDetailsCardP
</div> </div>
)} )}
</div> </div>
))} );
})}
</div> </div>
</> </>
) : ( ) : (

View File

@@ -55,6 +55,8 @@ export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCar
const arrow = (key: SortKey) => const arrow = (key: SortKey) =>
sortKey === key ? (sortDir === 'asc' ? ' ▲' : ' ▼') : ''; sortKey === key ? (sortDir === 'asc' ? ' ▲' : ' ▼') : '';
const ariaSort = (key: SortKey): 'none' | 'ascending' | 'descending' =>
sortKey === key ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none';
return ( return (
<Card title={t('usage_stats.models')}> <Card title={t('usage_stats.models')}>
@@ -65,21 +67,51 @@ export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCar
<table className={styles.table}> <table className={styles.table}>
<thead> <thead>
<tr> <tr>
<th className={styles.sortableHeader} onClick={() => handleSort('model')}> <th className={styles.sortableHeader} aria-sort={ariaSort('model')}>
<button
type="button"
className={styles.sortHeaderButton}
onClick={() => handleSort('model')}
>
{t('usage_stats.model_name')}{arrow('model')} {t('usage_stats.model_name')}{arrow('model')}
</button>
</th> </th>
<th className={styles.sortableHeader} onClick={() => handleSort('requests')}> <th className={styles.sortableHeader} aria-sort={ariaSort('requests')}>
<button
type="button"
className={styles.sortHeaderButton}
onClick={() => handleSort('requests')}
>
{t('usage_stats.requests_count')}{arrow('requests')} {t('usage_stats.requests_count')}{arrow('requests')}
</button>
</th> </th>
<th className={styles.sortableHeader} onClick={() => handleSort('tokens')}> <th className={styles.sortableHeader} aria-sort={ariaSort('tokens')}>
<button
type="button"
className={styles.sortHeaderButton}
onClick={() => handleSort('tokens')}
>
{t('usage_stats.tokens_count')}{arrow('tokens')} {t('usage_stats.tokens_count')}{arrow('tokens')}
</button>
</th> </th>
<th className={styles.sortableHeader} onClick={() => handleSort('successRate')}> <th className={styles.sortableHeader} aria-sort={ariaSort('successRate')}>
<button
type="button"
className={styles.sortHeaderButton}
onClick={() => handleSort('successRate')}
>
{t('usage_stats.success_rate')}{arrow('successRate')} {t('usage_stats.success_rate')}{arrow('successRate')}
</button>
</th> </th>
{hasPrices && ( {hasPrices && (
<th className={styles.sortableHeader} onClick={() => handleSort('cost')}> <th className={styles.sortableHeader} aria-sort={ariaSort('cost')}>
<button
type="button"
className={styles.sortHeaderButton}
onClick={() => handleSort('cost')}
>
{t('usage_stats.total_cost')}{arrow('cost')} {t('usage_stats.total_cost')}{arrow('cost')}
</button>
</th> </th>
)} )}
</tr> </tr>

View File

@@ -451,6 +451,11 @@
background: var(--bg-tertiary); background: var(--bg-tertiary);
color: var(--text-primary); color: var(--text-primary);
} }
&:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 1px;
}
} }
.apiSortBtnActive { .apiSortBtnActive {
@@ -474,16 +479,27 @@
} }
.apiHeader { .apiHeader {
width: 100%;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 10px; padding: 10px;
border: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer; cursor: pointer;
transition: background-color 0.15s ease; transition: background-color 0.15s ease;
&:hover { &:hover {
background-color: var(--bg-tertiary); background-color: var(--bg-tertiary);
} }
&:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: -2px;
}
} }
.apiInfo { .apiInfo {
@@ -588,7 +604,6 @@
} }
th.sortableHeader { th.sortableHeader {
cursor: pointer;
user-select: none; user-select: none;
transition: color 0.15s ease; transition: color 0.15s ease;
@@ -606,6 +621,25 @@
} }
} }
.sortHeaderButton {
display: inline-flex;
align-items: center;
width: 100%;
border: 0;
padding: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
&:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
border-radius: $radius-sm;
}
}
.modelCell { .modelCell {
font-weight: 500; font-weight: 500;
max-width: 240px; max-width: 240px;