feat(GatewayAutocomplete): replace dropdown with card-based selection for gateways, enhancing user experience and accessibility
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m6s
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m6s
This commit is contained in:
@@ -330,7 +330,7 @@ function CommunityAutocomplete({ label, value, onChange, communities = [], requi
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Компонент автокомплита для выбора Gateway
|
// Компонент выбора Gateway в виде карточек (вместо выпадающего списка)
|
||||||
function GatewayAutocomplete({ label, value, onChange, gateways = [], required = false, placeholder = 'Введите или выберите gateway...' }) {
|
function GatewayAutocomplete({ label, value, onChange, gateways = [], required = false, placeholder = 'Введите или выберите gateway...' }) {
|
||||||
const [inputValue, setInputValue] = useState(value || '');
|
const [inputValue, setInputValue] = useState(value || '');
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
@@ -369,7 +369,7 @@ function GatewayAutocomplete({ label, value, onChange, gateways = [], required =
|
|||||||
const val = e.target.value;
|
const val = e.target.value;
|
||||||
setInputValue(val);
|
setInputValue(val);
|
||||||
onChange(val);
|
onChange(val);
|
||||||
setIsOpen(true);
|
setIsOpen(true); // показываем список карточек при вводе
|
||||||
setHighlightedIndex(-1);
|
setHighlightedIndex(-1);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -392,6 +392,7 @@ function GatewayAutocomplete({ label, value, onChange, gateways = [], required =
|
|||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
setIsOpen(true);
|
||||||
setHighlightedIndex(prev => Math.min(prev + 1, filteredGateways.length - 1));
|
setHighlightedIndex(prev => Math.min(prev + 1, filteredGateways.length - 1));
|
||||||
break;
|
break;
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
@@ -402,12 +403,17 @@ function GatewayAutocomplete({ label, value, onChange, gateways = [], required =
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (highlightedIndex >= 0 && filteredGateways[highlightedIndex]) {
|
if (highlightedIndex >= 0 && filteredGateways[highlightedIndex]) {
|
||||||
handleSelect(filteredGateways[highlightedIndex]);
|
handleSelect(filteredGateways[highlightedIndex]);
|
||||||
|
} else if (filteredGateways.length === 1) {
|
||||||
|
// Удобный выбор единственного варианта
|
||||||
|
handleSelect(filteredGateways[0]);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'Escape':
|
case 'Escape':
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setHighlightedIndex(-1);
|
setHighlightedIndex(-1);
|
||||||
break;
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -430,7 +436,7 @@ function GatewayAutocomplete({ label, value, onChange, gateways = [], required =
|
|||||||
onFocus={() => setIsOpen(true)}
|
onFocus={() => setIsOpen(true)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
required={required}
|
required={required && !value}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
@@ -441,66 +447,78 @@ function GatewayAutocomplete({ label, value, onChange, gateways = [], required =
|
|||||||
<IconChevronDown size={16} className={isOpen ? 'rotate-180' : ''} style={{ transition: 'transform 0.2s' }} />
|
<IconChevronDown size={16} className={isOpen ? 'rotate-180' : ''} style={{ transition: 'transform 0.2s' }} />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dropdown */}
|
|
||||||
{isOpen && (
|
|
||||||
<div
|
|
||||||
className="dropdown-menu show w-100 overflow-auto"
|
|
||||||
style={{
|
|
||||||
maxHeight: '280px',
|
|
||||||
position: 'absolute',
|
|
||||||
top: '100%',
|
|
||||||
left: 0,
|
|
||||||
zIndex: 1050,
|
|
||||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{gateways.length === 0 ? (
|
|
||||||
<div className="dropdown-item text-muted">
|
|
||||||
<IconAlertTriangle size={14} className="me-2 text-warning" />
|
|
||||||
Нет доступных gateway
|
|
||||||
</div>
|
|
||||||
) : filteredGateways.length === 0 ? (
|
|
||||||
<div className="dropdown-item text-muted">
|
|
||||||
Ничего не найдено. Нажмите Enter чтобы использовать "{inputValue}"
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{inputValue && !gateways.some(g => g.name === inputValue) && (
|
|
||||||
<div className="dropdown-header small text-muted">Без привязки</div>
|
|
||||||
)}
|
|
||||||
{filteredGateways.map((gw, idx) => (
|
|
||||||
<div
|
|
||||||
key={gw.name}
|
|
||||||
className={`dropdown-item cursor-pointer ${highlightedIndex === idx ? 'active' : ''} ${gw.name === value ? 'bg-primary-lt' : ''}`}
|
|
||||||
onClick={() => handleSelect(gw)}
|
|
||||||
onMouseEnter={() => setHighlightedIndex(idx)}
|
|
||||||
>
|
|
||||||
<div className="d-flex align-items-start gap-2">
|
|
||||||
<span className="avatar avatar-xs bg-primary-lt mt-1">
|
|
||||||
{gw.country ? countryToFlag(gw.country) : <IconServer size={14} />}
|
|
||||||
</span>
|
|
||||||
<div className="flex-grow-1 min-w-0">
|
|
||||||
<div className="d-flex align-items-center gap-2">
|
|
||||||
<code className="fw-bold">{gw.name}</code>
|
|
||||||
{gw.comment && <span className="text-muted">({gw.comment})</span>}
|
|
||||||
{gw.serverDns && <span className="text-muted">— {gw.serverDns}</span>}
|
|
||||||
</div>
|
|
||||||
<div className="text-muted small">
|
|
||||||
{[gw.comment, gw.country].filter(Boolean).join(' · ') || '—'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{gw.primary && <span className="badge bg-green-lt text-green">Основной</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Показываем выбранный gateway */}
|
{/* Карточки gateway вместо выпадающего списка */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="mt-3">
|
||||||
|
{gateways.length === 0 ? (
|
||||||
|
<div className="text-muted small d-flex align-items-center">
|
||||||
|
<IconAlertTriangle size={14} className="me-2 text-warning" />
|
||||||
|
<span>Нет доступных gateway для выбранного сервера</span>
|
||||||
|
</div>
|
||||||
|
) : filteredGateways.length === 0 ? (
|
||||||
|
<div className="text-muted small">
|
||||||
|
Ничего не найдено. Уточните запрос по IP, имени или описанию.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="row row-cols-1 row-cols-md-2 g-2">
|
||||||
|
{filteredGateways.map((gw, idx) => {
|
||||||
|
const isSelected = gw.name === value;
|
||||||
|
const isHighlighted = highlightedIndex === idx;
|
||||||
|
return (
|
||||||
|
<div className="col" key={gw.name || idx}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-100 border-0 bg-transparent p-0 text-start"
|
||||||
|
onClick={() => handleSelect(gw)}
|
||||||
|
onMouseEnter={() => setHighlightedIndex(idx)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'card h-100 gateway-card ' +
|
||||||
|
(isSelected ? 'border-primary shadow-sm ' : '') +
|
||||||
|
(isHighlighted ? 'border-2 ' : '')
|
||||||
|
}
|
||||||
|
style={{ borderRadius: '10px', transition: 'all 0.12s ease' }}
|
||||||
|
>
|
||||||
|
<div className="card-body py-2 px-3 d-flex align-items-start gap-2">
|
||||||
|
<span className="avatar avatar-sm bg-primary-lt">
|
||||||
|
{gw.country ? countryToFlag(gw.country) : <IconServer size={16} />}
|
||||||
|
</span>
|
||||||
|
<div className="flex-grow-1 min-w-0">
|
||||||
|
<div className="d-flex align-items-center gap-2 mb-1">
|
||||||
|
<code className="fw-bold">{gw.name}</code>
|
||||||
|
{gw.comment && (
|
||||||
|
<span className="text-muted small text-truncate" style={{ maxWidth: '180px' }}>
|
||||||
|
{gw.comment}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted small">
|
||||||
|
{gw.ip && <span>IP: {gw.ip}</span>}
|
||||||
|
{gw.ip && gw.serverDns && <span className="mx-1">•</span>}
|
||||||
|
{gw.serverDns && <span>{gw.serverDns}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{gw.primary && (
|
||||||
|
<span className="badge bg-green-lt text-green align-self-start">
|
||||||
|
Основной
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Показываем выбранный gateway отдельной карточкой под полем */}
|
||||||
{value && selectedGateway && !isOpen && (
|
{value && selectedGateway && !isOpen && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<div className="d-flex align-items-center gap-2 p-2 bg-light rounded">
|
<div className="d-flex align-items-center gap-2 p-2 bg-light rounded">
|
||||||
|
|||||||
Reference in New Issue
Block a user