fix(ui): simplify credential aliases and usage filters
Use plain model name and alias inputs for credential aliases, discard unsaved credential settings on close, and replace usage filters with select-only menus.
This commit is contained in:
1 parent
88d58529f1
commit
072d771b3b
13 files changed
+414
-201
No files matched your search
@@ -46,8 +46,6 @@
|
||||
.credential-settings-actions { display: flex; gap: 10px; justify-content: flex-end; }
|
||||
.credential-settings-error { color: var(--failure-badge-text); margin: 0 0 12px; font-size: var(--font-size-caption); overflow-wrap: anywhere; max-height: 90px; overflow-y: auto; }
|
||||
.credential-settings-warning { color: var(--amber-text); }
|
||||
.credential-settings-discard { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; font-size: var(--font-size-caption); }
|
||||
.credential-settings-discard span { flex: 1 1 100%; }
|
||||
.credential-settings-loading { display: flex; align-items: center; justify-content: center; gap: 10px; min-height: 150px; }
|
||||
@media (max-width: 560px) {
|
||||
.credential-settings-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
@@ -58,41 +56,13 @@
|
||||
.credential-settings-span { grid-column: 1 / -1; }
|
||||
.credential-settings-inline-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
||||
.credential-alias-list, .credential-header-list { display: grid; gap: 12px; }
|
||||
.credential-alias-card { display: grid; gap: 10px; padding: 12px; border: 1px solid var(--border-color); border-radius: 10px; background: var(--bg-primary); }
|
||||
.credential-alias-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 12px; }
|
||||
.credential-alias-fields .credential-settings-field { margin: 0; }
|
||||
.credential-alias-fields .credential-settings-field:last-child { grid-column: 1 / -1; }
|
||||
.credential-settings-dialog .agent-model-trigger { box-sizing: border-box; min-height: 41px; padding: 0 4px 0 11px; border-color: var(--border-color); border-radius: 7px; background: var(--bg-primary); color: var(--text-primary); }
|
||||
.credential-settings-dialog .agent-model-trigger strong { color: var(--text-primary); }
|
||||
.credential-settings-dialog .agent-model-editable-trigger input { box-sizing: border-box; width: 100%; height: 39px; margin: 0; padding: 0; border: 0; border-radius: 0; outline: 0; background: transparent; box-shadow: none; color: inherit; font: inherit; font-size: inherit; line-height: 1.5; }
|
||||
.credential-settings-dialog .credential-model-menu {
|
||||
position: fixed;
|
||||
z-index: 2;
|
||||
margin: 0;
|
||||
max-height: none;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 16px 40px #0003;
|
||||
}
|
||||
.credential-settings-dialog .credential-model-menu .agent-model-option {
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.credential-settings-dialog .credential-model-menu .agent-model-option:hover,
|
||||
.credential-settings-dialog .credential-model-menu .agent-model-option.active {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
.credential-settings-dialog .credential-model-menu .agent-model-option.selected {
|
||||
background: color-mix(in srgb, var(--primary-color) 12%, var(--bg-card));
|
||||
}
|
||||
.credential-settings-dialog .credential-model-menu .agent-model-option small,
|
||||
.credential-settings-dialog .credential-model-menu .agent-model-dropdown-footer {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.credential-alias-card-heading, .credential-header-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.credential-alias-card-heading strong { font-size: var(--font-size-meta); }
|
||||
.credential-alias-card { display: grid; gap: 8px; }
|
||||
.credential-alias-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; gap: 8px; align-items: center; }
|
||||
.credential-alias-row input { min-width: 0; }
|
||||
.credential-alias-row-actions { display: flex; align-items: center; gap: 4px; }
|
||||
.credential-alias-row-actions .is-expanded { transform: rotate(180deg); }
|
||||
.credential-alias-details { display: grid; gap: 10px; padding: 12px; border: 1px solid var(--border-color); border-radius: 10px; background: var(--bg-secondary); }
|
||||
.credential-alias-details .credential-settings-field { margin: 0; }
|
||||
.credential-alias-checks { display: flex; flex-wrap: wrap; gap: 8px 16px; }
|
||||
.credential-alias-checks label { display: flex; align-items: center; gap: 8px; font-size: var(--font-size-meta); }
|
||||
.credential-header-row { display: grid; grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr) auto; }
|
||||
@@ -102,6 +72,7 @@
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.credential-alias-fields { grid-template-columns: minmax(0, 1fr); }
|
||||
.credential-alias-row { grid-template-columns: minmax(0, 1fr) auto; }
|
||||
.credential-alias-row input:first-child { grid-column: 1 / -1; }
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { loadAuthFileSettings, saveAuthFileSettings, type AuthFileSettingsDraft,
|
||||
import { modelMatchesRule, normalizeOAuthExcludedRules, oauthModelCandidates, oauthModelsFromPayload, setOAuthModelsExcluded, type OAuthModelDefinition } from '../services/oauthModels';
|
||||
import './AuthFileSettingsDialog.css';
|
||||
import { CredentialAdvancedFields, CredentialHeadersEditor, credentialProviderKey } from './CredentialAdvancedFields';
|
||||
import { modelSearchText, type ModelOption } from '../services/modelService';
|
||||
import { modelSearchText } from '../services/modelService';
|
||||
|
||||
export function AuthFileSettingsDialog({ name, provider = '', onClose, onSaved }: {
|
||||
name: string;
|
||||
@@ -22,14 +22,12 @@ export function AuthFileSettingsDialog({ name, provider = '', onClose, onSaved }
|
||||
const [original, setOriginal] = useState<AuthFileSettingsDraft | null>(null);
|
||||
const [draft, setDraft] = useState<AuthFileSettingsDraft | null>(null);
|
||||
const [models, setModels] = useState<OAuthModelDefinition[]>([]);
|
||||
const [pickerModels, setPickerModels] = useState<ModelOption[]>([]);
|
||||
const [catalogError, setCatalogError] = useState('');
|
||||
const [catalogLoading, setCatalogLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [discard, setDiscard] = useState(false);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const dirty = draft !== null && (Boolean(draft.normalizeCloakMetadata?.length) || JSON.stringify(draft) !== JSON.stringify(original));
|
||||
|
||||
@@ -61,7 +59,6 @@ export function AuthFileSettingsDialog({ name, provider = '', onClose, onSaved }
|
||||
const catalog = oauthModelsFromPayload(payload);
|
||||
if (!active) return;
|
||||
setModels(catalog);
|
||||
setPickerModels(catalog.map((model) => ({ name: model.id, displayName: model.displayName })));
|
||||
}).catch((reason: unknown) => {
|
||||
if (active) setCatalogError(reason instanceof Error ? reason.message : String(reason));
|
||||
}).finally(() => { if (active) setCatalogLoading(false); });
|
||||
@@ -70,20 +67,17 @@ export function AuthFileSettingsDialog({ name, provider = '', onClose, onSaved }
|
||||
|
||||
const close = () => {
|
||||
if (savingRef.current) return;
|
||||
if (dirty) setDiscard(true);
|
||||
else onClose();
|
||||
onClose();
|
||||
};
|
||||
const update = <K extends keyof AuthFileSettingsDraft>(key: K, value: AuthFileSettingsDraft[K]) => {
|
||||
setDraft((current) => current ? { ...current, [key]: value } : current);
|
||||
setError('');
|
||||
setDiscard(false);
|
||||
};
|
||||
const save = async () => {
|
||||
if (!draft || !original || savingRef.current) return;
|
||||
savingRef.current = true;
|
||||
setSaving(true);
|
||||
setError('');
|
||||
setDiscard(false);
|
||||
try {
|
||||
const changed = await saveAuthFileSettings(name, original, draft);
|
||||
if (changed) onSaved();
|
||||
@@ -175,7 +169,7 @@ export function AuthFileSettingsDialog({ name, provider = '', onClose, onSaved }
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
<CredentialAdvancedFields name={name} provider={provider} advanced={draft.advanced} models={pickerModels} modelsLoading={catalogLoading} modelsError={catalogError} disabled={saving} onChange={(advanced) => update('advanced', advanced)} />
|
||||
<CredentialAdvancedFields name={name} provider={provider} advanced={draft.advanced} disabled={saving} onChange={(advanced) => update('advanced', advanced)} />
|
||||
<section className="credential-settings-section">
|
||||
<h3>{t('authFiles.settings.additional')}</h3>
|
||||
<CredentialHeadersEditor value={draft.headers} disabled={saving} onChange={(headers) => update('headers', headers)} />
|
||||
@@ -189,7 +183,6 @@ export function AuthFileSettingsDialog({ name, provider = '', onClose, onSaved }
|
||||
</div>
|
||||
<footer className="credential-settings-footer">
|
||||
{error ? <p className="credential-settings-error" role="alert">{error}</p> : null}
|
||||
{discard ? <div className="credential-settings-discard" role="alert"><span>{t('authFiles.settings.unsaved')}</span><button type="button" className="secondary-button compact-button" onClick={() => setDiscard(false)}>{t('authFiles.settings.keepEditing')}</button><button type="button" className="danger-button compact-button" onClick={onClose}>{t('authFiles.settings.discard')}</button></div> : null}
|
||||
<div className="credential-settings-actions">
|
||||
<button type="button" className="secondary-button" disabled={saving} onClick={close}>{t('common.cancel')}</button>
|
||||
{!loading && !draft ? <button type="button" className="primary-button" onClick={() => setAttempt((value) => value + 1)}>{t('common.refresh')}</button> : <button type="submit" className="primary-button" disabled={!draft || loading || saving || !dirty}>{saving ? <LoaderCircle size={16} className="spin" /> : <Check size={16} />}{t(saving ? 'common.saving' : 'common.save')}</button>}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Undo2, X } from 'lucide-react';
|
||||
import { AgentModelPicker } from './AgentModelPicker';
|
||||
import type { ModelOption } from '../services/modelService';
|
||||
import { ChevronDown, Plus, Undo2, X } from 'lucide-react';
|
||||
import { useI18n } from '../i18n';
|
||||
import type { MessageKey } from '../i18n/resources';
|
||||
import { isRecord } from '../services/managementApi';
|
||||
@@ -44,14 +42,11 @@ function aliasRows(value: unknown): AliasRow[] {
|
||||
}
|
||||
|
||||
export function CredentialAdvancedFields({
|
||||
name, provider = '', advanced, models = [], modelsLoading = false, modelsError = '', onChange, disabled = false,
|
||||
name, provider = '', advanced, onChange, disabled = false,
|
||||
}: {
|
||||
name: string;
|
||||
provider?: string;
|
||||
advanced: Advanced;
|
||||
models?: ModelOption[];
|
||||
modelsLoading?: boolean;
|
||||
modelsError?: string;
|
||||
onChange: (advanced: Advanced) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
@@ -61,6 +56,7 @@ export function CredentialAdvancedFields({
|
||||
const aliases = aliasRows(advanced.model_aliases);
|
||||
const aliasesSet = Array.isArray(advanced.model_aliases);
|
||||
const [wordsDraft, setWordsDraft] = useState<string | null>(null);
|
||||
const [expandedAlias, setExpandedAlias] = useState<number | null>(null);
|
||||
const setField = (key: string, value: unknown) => onChange(withoutKey(advanced, key, value));
|
||||
const tri = (value: unknown) => value === true ? 'true' : value === false ? 'false' : '';
|
||||
const choice = (label: MessageKey, key: string, options: ReadonlyArray<{ value: string; label: MessageKey }>) => {
|
||||
@@ -101,34 +97,33 @@ export function CredentialAdvancedFields({
|
||||
<h3>{t('authFiles.settings.aliases')}</h3>
|
||||
<p className="credential-settings-lead">{t('authFiles.settings.aliasesHint')}</p>
|
||||
{aliases.length ? <div className="credential-alias-list">
|
||||
{aliases.map((row, index) => <div className="credential-alias-card" key={index}>
|
||||
<div className="credential-alias-card-heading">
|
||||
<strong>{t('authFiles.settings.aliasItem', { index: index + 1 })}</strong>
|
||||
<button type="button" className="icon-button quiet" disabled={disabled} aria-label={t('authFiles.settings.aliasRemove', { index: index + 1 })} onClick={() => setField('model_aliases', aliases.filter((_, rowIndex) => rowIndex !== index))}><X size={15} /></button>
|
||||
{aliases.map((row, index) => {
|
||||
const expanded = expandedAlias === index;
|
||||
return <div className="credential-alias-card" key={index}>
|
||||
<div className="credential-alias-row">
|
||||
<input aria-label={t('authFiles.settings.aliasUpstream')} placeholder={t('authFiles.settings.aliasUpstream')} value={textValue(row.name)} disabled={disabled} autoComplete="off" spellCheck={false} maxLength={240}
|
||||
onChange={(event) => updateAlias(index, { name: event.currentTarget.value })} />
|
||||
<input aria-label={t('authFiles.settings.aliasClient')} placeholder={t('authFiles.settings.aliasClient')} value={textValue(row.alias)} disabled={disabled} autoComplete="off" spellCheck={false} maxLength={240}
|
||||
onChange={(event) => updateAlias(index, { alias: event.currentTarget.value })} />
|
||||
<div className="credential-alias-row-actions">
|
||||
<button type="button" className="icon-button quiet" disabled={disabled} aria-expanded={expanded} aria-label={expanded ? t('appUpdate.notes.collapse') : t('appUpdate.notes.expand')}
|
||||
onClick={() => setExpandedAlias(expanded ? null : index)}><ChevronDown size={15} className={expanded ? 'is-expanded' : ''} /></button>
|
||||
<button type="button" className="icon-button quiet" disabled={disabled} aria-label={t('authFiles.settings.aliasRemove', { index: index + 1 })}
|
||||
onClick={() => { setExpandedAlias((current) => current === index ? null : current !== null && current > index ? current - 1 : current); setField('model_aliases', aliases.filter((_, rowIndex) => rowIndex !== index)); }}><X size={15} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="credential-alias-fields">
|
||||
<label className="credential-settings-field">
|
||||
<span>{t('authFiles.settings.aliasUpstream')}</span>
|
||||
<AgentModelPicker models={models} value={textValue(row.name)} loading={modelsLoading} error={modelsError} disabled={disabled}
|
||||
editable={{ label: t('authFiles.settings.aliasUpstream'), placeholder: t('authFiles.settings.aliasModelPlaceholder'), maxLength: 240 }}
|
||||
menuClassName="credential-model-menu" onChange={(value) => updateAlias(index, { name: value })} />
|
||||
</label>
|
||||
<label className="credential-settings-field">
|
||||
<span>{t('authFiles.settings.aliasClient')}</span>
|
||||
<AgentModelPicker models={models} value={textValue(row.alias)} loading={modelsLoading} error={modelsError} disabled={disabled}
|
||||
editable={{ label: t('authFiles.settings.aliasClient'), placeholder: t('authFiles.settings.aliasModelPlaceholder'), maxLength: 240 }}
|
||||
menuClassName="credential-model-menu" onChange={(value) => updateAlias(index, { alias: value })} />
|
||||
</label>
|
||||
{expanded ? <div className="credential-alias-details">
|
||||
<label className="credential-settings-field">
|
||||
<span>{t('authFiles.settings.aliasDisplay')}</span>
|
||||
<input value={textValue(row['display-name'])} disabled={disabled} autoComplete="off" spellCheck={false} onChange={(event) => updateAlias(index, { 'display-name': event.currentTarget.value.trim() ? event.currentTarget.value : undefined })} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="credential-alias-checks">
|
||||
<label><input type="checkbox" checked={row.fork === true} disabled={disabled} onChange={(event) => updateAlias(index, { fork: event.currentTarget.checked })} />{t('authFiles.settings.aliasFork')}</label>
|
||||
<label><input type="checkbox" checked={row['force-mapping'] === true} disabled={disabled} onChange={(event) => updateAlias(index, { 'force-mapping': event.currentTarget.checked })} />{t('authFiles.settings.aliasForce')}</label>
|
||||
</div>
|
||||
</div>)}
|
||||
<div className="credential-alias-checks">
|
||||
<label><input type="checkbox" checked={row.fork === true} disabled={disabled} onChange={(event) => updateAlias(index, { fork: event.currentTarget.checked })} />{t('authFiles.settings.aliasFork')}</label>
|
||||
<label><input type="checkbox" checked={row['force-mapping'] === true} disabled={disabled} onChange={(event) => updateAlias(index, { 'force-mapping': event.currentTarget.checked })} />{t('authFiles.settings.aliasForce')}</label>
|
||||
</div>
|
||||
</div> : null}
|
||||
</div>;
|
||||
})}
|
||||
</div> : aliasesSet ? <p className="credential-settings-lead">{t('authFiles.settings.aliasCleared')}</p> : null}
|
||||
<div className="credential-settings-inline-actions">
|
||||
<button type="button" className="secondary-button compact-button" disabled={disabled} onClick={(event) => { const scroller = event.currentTarget.closest('.credential-settings-body'); const top = scroller?.scrollTop ?? 0; setField('model_aliases', [...aliases, { name: '', alias: '' }]); requestAnimationFrame(() => { if (scroller) scroller.scrollTop = top; }); }}><Plus size={14} />{t('authFiles.settings.aliasAdd')}</button>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
.select-menu {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.select-menu-trigger {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, max-content) 16px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
min-height: 34px;
|
||||
padding: 0 10px 0 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.select-menu-trigger:hover:not(:disabled),
|
||||
.select-menu.open .select-menu-trigger {
|
||||
border-color: var(--text-tertiary);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.select-menu-trigger:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.select-menu-trigger strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: inherit;
|
||||
font-size: var(--font-size-meta);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.select-menu-trigger > svg {
|
||||
color: var(--text-tertiary);
|
||||
transition: transform 0.16s ease;
|
||||
}
|
||||
|
||||
.select-menu.open .select-menu-trigger > svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.select-menu-dropdown {
|
||||
position: fixed;
|
||||
z-index: 45;
|
||||
display: grid;
|
||||
min-width: 180px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-card);
|
||||
box-shadow: 0 14px 34px rgba(45, 42, 38, 0.18);
|
||||
}
|
||||
|
||||
.select-menu-list {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.select-menu-option {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 16px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
padding: 6px 10px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.select-menu-option span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-size-meta);
|
||||
}
|
||||
|
||||
.select-menu-option:hover,
|
||||
.select-menu-option.active {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.select-menu-option.selected {
|
||||
background: color-mix(in srgb, var(--primary-color) 12%, var(--bg-card));
|
||||
}
|
||||
|
||||
.select-menu-option > svg {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
} from 'react';
|
||||
import { Check, ChevronDown } from 'lucide-react';
|
||||
|
||||
export type SelectMenuOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type MenuLayout = {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export function SelectMenu({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
disabled = false,
|
||||
}: {
|
||||
value: string;
|
||||
options: SelectMenuOption[];
|
||||
onChange: (value: string) => void;
|
||||
ariaLabel: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [layout, setLayout] = useState<MenuLayout | null>(null);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const listboxId = useId();
|
||||
const selected = options.find((option) => option.value === value) ?? options[0];
|
||||
|
||||
const updateLayout = useCallback(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root) return;
|
||||
const rect = root.getBoundingClientRect();
|
||||
const edgeGap = 12;
|
||||
const triggerGap = 6;
|
||||
const preferredHeight = Math.min(282, Math.max(options.length, 1) * 42 + 18);
|
||||
const minimumHeight = 88;
|
||||
const spaceBelow = Math.max(0, window.innerHeight - edgeGap - rect.bottom - triggerGap);
|
||||
const spaceAbove = Math.max(0, rect.top - triggerGap - edgeGap);
|
||||
const placeAbove = spaceBelow < minimumHeight && spaceAbove > spaceBelow;
|
||||
const available = placeAbove ? spaceAbove : spaceBelow;
|
||||
const height = Math.min(preferredHeight, Math.max(Math.min(minimumHeight, available), available));
|
||||
const width = Math.max(rect.width, 180);
|
||||
const maxLeft = Math.max(edgeGap, window.innerWidth - edgeGap - width);
|
||||
const left = Math.min(Math.max(edgeGap, rect.left), maxLeft);
|
||||
const desiredTop = placeAbove ? rect.top - triggerGap - height : rect.bottom + triggerGap;
|
||||
const top = Math.min(Math.max(edgeGap, desiredTop), Math.max(edgeGap, window.innerHeight - edgeGap - height));
|
||||
setLayout({ top, left, width, height });
|
||||
}, [options.length]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) {
|
||||
setLayout(null);
|
||||
return undefined;
|
||||
}
|
||||
updateLayout();
|
||||
window.addEventListener('resize', updateLayout);
|
||||
window.addEventListener('scroll', updateLayout, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateLayout);
|
||||
window.removeEventListener('scroll', updateLayout, true);
|
||||
};
|
||||
}, [open, updateLayout]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
const close = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (!rootRef.current?.contains(target) && !menuRef.current?.contains(target)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', close);
|
||||
return () => document.removeEventListener('mousedown', close);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const selectedIndex = options.findIndex((option) => option.value === value);
|
||||
setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0);
|
||||
}, [open, options, value]);
|
||||
|
||||
const choose = (next: string) => {
|
||||
onChange(next);
|
||||
setOpen(false);
|
||||
rootRef.current?.querySelector('button')?.focus();
|
||||
};
|
||||
|
||||
const moveActive = (offset: number) => {
|
||||
if (!options.length) return;
|
||||
setActiveIndex((current) => (current + offset + options.length) % options.length);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (!open && ['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
moveActive(1);
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
moveActive(-1);
|
||||
} else if (event.key === 'Enter' && options[activeIndex]) {
|
||||
event.preventDefault();
|
||||
choose(options[activeIndex].value);
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`select-menu ${open ? 'open' : ''}`} ref={rootRef}
|
||||
onBlur={(event) => {
|
||||
const next = event.relatedTarget as Node | null;
|
||||
if (!event.currentTarget.contains(next) && !menuRef.current?.contains(next)) setOpen(false);
|
||||
}}>
|
||||
<button
|
||||
type="button"
|
||||
className="select-menu-trigger"
|
||||
aria-label={ariaLabel}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-controls={listboxId}
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<strong title={selected?.label}>{selected?.label ?? ''}</strong>
|
||||
<ChevronDown size={16} aria-hidden />
|
||||
</button>
|
||||
{open ? (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="select-menu-dropdown"
|
||||
style={layout
|
||||
? { top: layout.top, left: layout.left, width: layout.width, height: layout.height }
|
||||
: { top: 0, left: 0, width: 0, height: 0, visibility: 'hidden' }}
|
||||
>
|
||||
<div className="select-menu-list" id={listboxId} role="listbox" aria-label={ariaLabel}>
|
||||
{options.map((option, index) => {
|
||||
const isSelected = option.value === value;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
className={`select-menu-option${isSelected ? ' selected' : ''}${index === activeIndex ? ' active' : ''}`}
|
||||
key={option.value || option.label}
|
||||
onMouseEnter={() => setActiveIndex(index)}
|
||||
onClick={() => choose(option.value)}
|
||||
>
|
||||
<span title={option.label}>{option.label}</span>
|
||||
{isSelected ? <Check size={15} aria-hidden /> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user