fix(providers): use shared selects in advanced editors

This commit is contained in:
Supra4E8C
2026-09-30 21:41:05 +08:00
parent 6275636285
commit dab7cbc9da
8 changed files with 144 additions and 72 deletions
+9
View File
@@ -7,6 +7,7 @@ import {
useRef,
useState,
type CSSProperties,
type AriaAttributes,
} from 'react';
import { createPortal } from 'react-dom';
import { IconChevronDown } from './icons';
@@ -27,6 +28,7 @@ interface SelectProps {
ariaLabel?: string;
ariaLabelledBy?: string;
ariaDescribedBy?: string;
ariaInvalid?: AriaAttributes['aria-invalid'];
fullWidth?: boolean;
size?: 'sm' | 'md';
id?: string;
@@ -86,6 +88,7 @@ export function Select({
ariaLabel,
ariaLabelledBy,
ariaDescribedBy,
ariaInvalid,
fullWidth = true,
size = 'md',
id,
@@ -96,6 +99,7 @@ export function Select({
const [open, setOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const wrapRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const dropdownRef = useRef<HTMLDivElement | null>(null);
const rafRef = useRef<number | null>(null);
const [dropdownStyle, setDropdownStyle] = useState<CSSProperties | null>(null);
@@ -191,6 +195,7 @@ export function Select({
onChange(nextOption.value);
setOpen(false);
setHighlightedIndex(nextIndex);
triggerRef.current?.focus({ preventScroll: true });
},
[onChange, options]
);
@@ -250,7 +255,9 @@ export function Select({
case 'Escape':
if (!isOpen) return;
event.preventDefault();
event.stopPropagation();
setOpen(false);
triggerRef.current?.focus({ preventScroll: true });
return;
case 'Tab':
if (isOpen) setOpen(false);
@@ -310,6 +317,7 @@ export function Select({
>
<button
id={selectId}
ref={triggerRef}
type="button"
className={`${styles.trigger} ${size === 'sm' ? styles.triggerSm : ''}`.trim()}
onClick={disabled ? undefined : () => setOpen((prev) => !prev)}
@@ -325,6 +333,7 @@ export function Select({
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
aria-describedby={ariaDescribedBy}
aria-invalid={ariaInvalid}
disabled={disabled}
>
<span className={`${styles.triggerText} ${isPlaceholder ? styles.placeholder : ''}`}>
@@ -1,6 +1,7 @@
import { useEffect, useId, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { IconChevronDown, IconChevronUp, IconPlus, IconTrash2 } from '@/components/ui/icons';
import { Select } from '@/components/ui/Select';
import {
ERROR_RULE_ACTIONS,
createErrorMatch,
@@ -121,25 +122,25 @@ export function ErrorRulesEditor({ rules, onChange, disabled }: ErrorRulesEditor
<label htmlFor={`${id}-${rule.id}-action`} className={form.label}>
{t(`${key}.action`)}
</label>
<select
<Select
id={`${id}-${rule.id}-action`}
className={form.input}
ariaLabel={t(`${key}.action`)}
value={rule.action}
disabled={disabled}
aria-invalid={actionError || undefined}
aria-describedby={actionError ? errorId : undefined}
onChange={(event) => update({ ...rule, action: event.target.value })}
>
<option value="">{t(`${key}.selectAction`)}</option>
{rule.action && !actions.includes(rule.action) && (
<option value={rule.action}>{rule.action}</option>
)}
{actions.map((action) => (
<option key={action} value={action}>
{t(`${key}.actions.${action}`)}
</option>
))}
</select>
ariaInvalid={actionError || undefined}
ariaDescribedBy={actionError ? errorId : undefined}
options={[
{ value: '', label: t(`${key}.selectAction`) },
...(rule.action && !actions.includes(rule.action)
? [{ value: rule.action, label: rule.action }]
: []),
...actions.map((action) => ({
value: action,
label: t(`${key}.actions.${action}`),
})),
]}
onChange={(action) => update({ ...rule, action })}
/>
</div>
</div>
{rule.matches.map((match, matchIndex) => (
@@ -148,25 +149,24 @@ export function ErrorRulesEditor({ rules, onChange, disabled }: ErrorRulesEditor
<label htmlFor={`${id}-${match.id}-kind`} className={form.label}>
{t(`${key}.matchType`)}
</label>
<select
<Select
id={`${id}-${match.id}-kind`}
className={form.input}
ariaLabel={t(`${key}.matchType`)}
value={match.kind}
disabled={disabled}
onChange={(event) =>
options={[
{ value: 'text', label: t(`${key}.text`) },
{ value: 'regex', label: t(`${key}.regex`) },
]}
onChange={(kind) =>
update({
...rule,
matches: rule.matches.map((item) =>
item.id === match.id
? { ...item, kind: event.target.value as 'text' | 'regex' }
: item
item.id === match.id ? { ...item, kind: kind as 'text' | 'regex' } : item
),
})
}
>
<option value="text">{t(`${key}.text`)}</option>
<option value="regex">{t(`${key}.regex`)}</option>
</select>
/>
</div>
<div className={form.field}>
<label htmlFor={matchId(match.id)} className={form.label}>
@@ -1,4 +1,6 @@
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import { Select } from '@/components/ui/Select';
import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
import { getProviderModelCapabilities } from '../../descriptors';
import { THINKING_LEVELS } from '../../thinkingLevels';
@@ -21,6 +23,7 @@ export function ModelAdvancedFields({
onUpdate,
}: ModelAdvancedFieldsProps) {
const { t } = useTranslation();
const id = useId();
const capabilities = getProviderModelCapabilities(providerBrand);
const enabled = entry.thinkingEnabled ?? Boolean(entry.thinkingJson?.trim());
const levels = entry.thinkingLevels ?? [];
@@ -102,18 +105,22 @@ export function ModelAdvancedFields({
{supportsThinking ? (
<fieldset className={styles.thinkingFieldset}>
<legend className={styles.label}>{t('providersPage.form.thinkingConfig')}</legend>
<label className={styles.field}>
<span className={styles.label}>{t('providersPage.modelOptions.thinkingMode')}</span>
<select
className={styles.input}
<div className={styles.field}>
<label htmlFor={`${id}-thinking-mode`} className={styles.label}>
{t('providersPage.modelOptions.thinkingMode')}
</label>
<Select
id={`${id}-thinking-mode`}
ariaLabel={t('providersPage.modelOptions.thinkingMode')}
value={enabled ? 'custom' : 'default'}
disabled={disabled}
onChange={(event) => onUpdate({ thinkingEnabled: event.target.value === 'custom' })}
>
<option value="default">{t('providersPage.modelOptions.useDefault')}</option>
<option value="custom">{t('providersPage.modelOptions.custom')}</option>
</select>
</label>
options={[
{ value: 'default', label: t('providersPage.modelOptions.useDefault') },
{ value: 'custom', label: t('providersPage.modelOptions.custom') },
]}
onChange={(mode) => onUpdate({ thinkingEnabled: mode === 'custom' })}
/>
</div>
<p className={styles.thinkingExistingHint}>
{t('providersPage.modelOptions.thinkingHint')}
</p>
@@ -1,6 +1,7 @@
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import { Collapsible } from '@/components/ui/Collapsible';
import { Select } from '@/components/ui/Select';
import type { ProviderBehaviorOptions } from '@/types/provider';
import { getProviderBehaviorCapabilities } from '../../descriptors';
import type { ProviderBrand } from '../../types';
@@ -48,9 +49,9 @@ export function ProviderBehaviorEditor({
<label htmlFor={`${id}-cloak`} className={styles.label}>
{t('providersPage.behavior.codexCloaking')}
</label>
<select
<Select
id={`${id}-cloak`}
className={styles.input}
ariaLabel={t('providersPage.behavior.codexCloaking')}
value={
value.disableCodexCloaking === undefined
? 'default'
@@ -59,20 +60,18 @@ export function ProviderBehaviorEditor({
: 'enabled'
}
disabled={disabled}
aria-describedby={`${id}-cloak-hint`}
onChange={(event) =>
ariaDescribedBy={`${id}-cloak-hint`}
options={[
{ value: 'default', label: t('providersPage.behavior.useDefault') },
{ value: 'enabled', label: t('providersPage.behavior.cloakingEnabled') },
{ value: 'disabled', label: t('providersPage.behavior.cloakingDisabled') },
]}
onChange={(mode) =>
onChange({
disableCodexCloaking:
event.target.value === 'default'
? undefined
: event.target.value === 'disabled',
disableCodexCloaking: mode === 'default' ? undefined : mode === 'disabled',
})
}
>
<option value="default">{t('providersPage.behavior.useDefault')}</option>
<option value="enabled">{t('providersPage.behavior.cloakingEnabled')}</option>
<option value="disabled">{t('providersPage.behavior.cloakingDisabled')}</option>
</select>
/>
<small id={`${id}-cloak-hint`} className={styles.labelHint}>
{t('providersPage.behavior.codexCloakingHint')}
</small>
@@ -1,6 +1,7 @@
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import { Collapsible } from '@/components/ui/Collapsible';
import { Select } from '@/components/ui/Select';
import type { RuntimePolicyDraft } from '../../runtimePolicy';
import { ErrorRulesEditor } from './ErrorRulesEditor';
import styles from './sharedForm.module.scss';
@@ -32,19 +33,20 @@ export function RuntimePolicyEditor({
<label htmlFor={`${id}-cooling`} className={styles.label}>
{t(`${key}.cooling`)}
</label>
<select
<Select
id={`${id}-cooling`}
className={styles.input}
ariaLabel={t(`${key}.cooling`)}
value={value.cooling}
disabled={disabled}
onChange={(event) =>
onChange({ ...value, cooling: event.target.value as RuntimePolicyDraft['cooling'] })
options={[
{ value: 'inherit', label: t(`${key}.inherit`) },
{ value: 'enabled', label: t(`${key}.coolingEnabled`) },
{ value: 'disabled', label: t(`${key}.coolingDisabled`) },
]}
onChange={(cooling) =>
onChange({ ...value, cooling: cooling as RuntimePolicyDraft['cooling'] })
}
>
<option value="inherit">{t(`${key}.inherit`)}</option>
<option value="enabled">{t(`${key}.coolingEnabled`)}</option>
<option value="disabled">{t(`${key}.coolingDisabled`)}</option>
</select>
/>
</div>
<div className={styles.field}>
<label htmlFor={`${id}-retry`} className={styles.label}>
@@ -70,22 +72,20 @@ export function RuntimePolicyEditor({
<label htmlFor={`${id}-errors-mode`} className={styles.label}>
{t(`${key}.errorsMode`)}
</label>
<select
<Select
id={`${id}-errors-mode`}
className={styles.input}
ariaLabel={t(`${key}.errorsMode`)}
value={value.errorsMode}
disabled={disabled}
aria-describedby={`${id}-errors-hint`}
onChange={(event) =>
onChange({
...value,
errorsMode: event.target.value as RuntimePolicyDraft['errorsMode'],
})
ariaDescribedBy={`${id}-errors-hint`}
options={[
{ value: 'inherit', label: t(`${key}.inherit`) },
{ value: 'override', label: t(`${key}.override`) },
]}
onChange={(errorsMode) =>
onChange({ ...value, errorsMode: errorsMode as RuntimePolicyDraft['errorsMode'] })
}
>
<option value="inherit">{t(`${key}.inherit`)}</option>
<option value="override">{t(`${key}.override`)}</option>
</select>
/>
<span id={`${id}-errors-hint`} className={styles.labelHint}>
{t(`${key}.errorsHint`)}
</span>
+4 -1
View File
@@ -37,6 +37,8 @@ describe('error-rule editor UI', () => {
expect(html).toContain('Add rule');
expect(html).toContain('Add condition');
expect(html).not.toContain('JSON');
expect(html).not.toContain('<select');
expect(html).toContain('aria-haspopup="listbox"');
const ids = [...html.matchAll(/\bid="([^"]+)"/g)].map((match) => match[1]);
expect(new Set(ids).size).toBe(ids.length);
for (const label of html.matchAll(/\bfor="([^"]+)"/g)) expect(ids).toContain(label[1]);
@@ -90,7 +92,8 @@ describe('error-rule editor UI', () => {
const html = renderRows([{ ...rows[0], action: '<unknown>' }]);
expect(html).toContain('&lt;unknown&gt;');
expect(html).not.toContain('<unknown>');
expect(html).toContain('value="&lt;unknown&gt;" selected=""');
expect(html).toContain('>&lt;unknown&gt;</span>');
expect(html).toContain('aria-label="Action"');
});
test('every locale contains complete row labels, errors, actions and no stale JSON guidance', () => {
for (const messages of [en, zhCN, zhTW, ru]) {
+6 -1
View File
@@ -213,6 +213,11 @@ describe('provider model options', () => {
const defaultThinking = render('gemini', false, false).split('<fieldset')[1];
const inputs = defaultThinking.match(/<(?:input|button)\b[^>]*>/g) ?? [];
expect(inputs.length).toBeGreaterThan(4);
for (const input of inputs) expect(input).toContain('disabled=""');
const mode = inputs.filter((input) => input.includes('aria-haspopup="listbox"'));
expect(mode).toHaveLength(1);
expect(mode[0]).not.toContain('disabled=""');
for (const input of inputs.filter((input) => !input.includes('aria-haspopup="listbox"'))) {
expect(input).toContain('disabled=""');
}
});
});
+49
View File
@@ -0,0 +1,49 @@
import { expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { Select } from '@/components/ui/Select';
test('Provider advanced editors consistently use the shared Select instead of native menus', () => {
for (const file of [
'RuntimePolicyEditor',
'ProviderBehaviorEditor',
'ModelAdvancedFields',
'ErrorRulesEditor',
]) {
const source = readFileSync(
new URL(`../src/features/providers/sheets/forms/${file}.tsx`, import.meta.url),
'utf8'
);
expect(source).toContain("import { Select } from '@/components/ui/Select'");
expect(source).toContain('<Select');
expect(source).not.toMatch(/<select[\s>]/);
expect(source).not.toMatch(/<option[\s>]/);
}
});
test('shared Select forwards validation, labels and disabled state to its trigger', () => {
const markup = renderToStaticMarkup(
createElement(Select, {
id: 'rule-action',
value: 'stop',
options: [{ value: 'stop', label: 'Stop' }],
onChange: () => {},
ariaLabel: 'Action',
ariaLabelledBy: 'action-label',
ariaDescribedBy: 'action-error',
ariaInvalid: true,
disabled: true,
})
);
expect(markup).not.toContain('<select');
expect(markup).toContain('id="rule-action"');
expect(markup).toContain('aria-haspopup="listbox"');
expect(markup).toContain('aria-expanded="false"');
expect(markup).toContain('aria-label="Action"');
expect(markup).toContain('aria-labelledby="action-label"');
expect(markup).toContain('aria-describedby="action-error"');
expect(markup).toContain('aria-invalid="true"');
expect(markup).toContain('disabled=""');
expect(markup).toContain('>Stop</span>');
});