Unify all dropdowns on the custom Select component.

Replace every native <select> across the dashboard, admin pages, and
shared components with the custom Select used by the optional-service
version pickers, for consistent styling and mobile-safe anchoring. Add
a disabled prop to Select to cover the former read-only native cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
keyhan
2026-06-12 17:37:04 +03:30
parent aa7ff3d26d
commit 97cd5e989a
16 changed files with 664 additions and 494 deletions
@@ -2,6 +2,7 @@
import { Database } from 'lucide-react';
import { useT } from '@/i18n/I18nProvider';
import { Select } from '@/components/ui/select';
export interface DatabaseWorkloadValues {
cpuRequest: string;
@@ -27,55 +28,63 @@ export function DatabaseWorkloadResources({
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs text-gray-500 mb-1">{r.cpuRequest}</label>
<select
className="input-field text-sm"
<Select
size="md"
ariaLabel={r.cpuRequest}
value={values.cpuRequest}
onChange={(e) => onChange({ cpuRequest: e.target.value })}
>
<option value="50m">50m</option>
<option value="100m">100m</option>
<option value="250m">250m</option>
<option value="500m">500m</option>
</select>
onChange={(v) => onChange({ cpuRequest: v })}
options={[
{ value: '50m', label: '50m' },
{ value: '100m', label: '100m' },
{ value: '250m', label: '250m' },
{ value: '500m', label: '500m' },
]}
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">{r.cpuLimit}</label>
<select
className="input-field text-sm"
<Select
size="md"
ariaLabel={r.cpuLimit}
value={values.cpuLimit}
onChange={(e) => onChange({ cpuLimit: e.target.value })}
>
<option value="250m">250m</option>
<option value="500m">500m</option>
<option value="1">{r.oneCore}</option>
<option value="2">{r.twoCores}</option>
</select>
onChange={(v) => onChange({ cpuLimit: v })}
options={[
{ value: '250m', label: '250m' },
{ value: '500m', label: '500m' },
{ value: '1', label: r.oneCore },
{ value: '2', label: r.twoCores },
]}
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">{r.memoryRequest}</label>
<select
className="input-field text-sm"
<Select
size="md"
ariaLabel={r.memoryRequest}
value={values.memoryRequest}
onChange={(e) => onChange({ memoryRequest: e.target.value })}
>
<option value="64Mi">64 Mi</option>
<option value="128Mi">128 Mi</option>
<option value="256Mi">256 Mi</option>
<option value="512Mi">512 Mi</option>
</select>
onChange={(v) => onChange({ memoryRequest: v })}
options={[
{ value: '64Mi', label: '64 Mi' },
{ value: '128Mi', label: '128 Mi' },
{ value: '256Mi', label: '256 Mi' },
{ value: '512Mi', label: '512 Mi' },
]}
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">{r.memoryLimit}</label>
<select
className="input-field text-sm"
<Select
size="md"
ariaLabel={r.memoryLimit}
value={values.memoryLimit}
onChange={(e) => onChange({ memoryLimit: e.target.value })}
>
<option value="256Mi">256 Mi</option>
<option value="512Mi">512 Mi</option>
<option value="1Gi">1 Gi</option>
<option value="2Gi">2 Gi</option>
</select>
onChange={(v) => onChange({ memoryLimit: v })}
options={[
{ value: '256Mi', label: '256 Mi' },
{ value: '512Mi', label: '512 Mi' },
{ value: '1Gi', label: '1 Gi' },
{ value: '2Gi', label: '2 Gi' },
]}
/>
</div>
</div>
</div>
@@ -4,6 +4,7 @@ import { useRef, useState } from 'react';
import { Database, Eye, EyeOff, RefreshCw, AlertCircle } from 'lucide-react';
import { toast } from 'react-toastify';
import { useT } from '@/i18n/I18nProvider';
import { Select } from '@/components/ui/select';
import { ONE_GIB, minGiToFitFileBytes } from '@/lib/storage-utils';
export type DatabaseEngine = 'postgresql' | 'mysql' | 'mariadb' | 'mongodb';
@@ -101,17 +102,17 @@ export function ManagedDatabaseConfig({
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{c.version}</label>
<select
className="input-field max-w-xs"
<Select
size="md"
className="max-w-xs"
ariaLabel={c.version}
value={form.dbVersion}
onChange={(e) => onChange({ dbVersion: e.target.value })}
>
{versionOptions(form.databaseType).map((v) => (
<option key={v} value={v}>
{versionLabel(form.databaseType, v)}
</option>
))}
</select>
onChange={(v) => onChange({ dbVersion: v })}
options={versionOptions(form.databaseType).map((v) => ({
value: v,
label: versionLabel(form.databaseType, v),
}))}
/>
</div>
<div className="p-4 bg-blue-50/50 border border-blue-200 rounded-xl space-y-4">
@@ -2,6 +2,7 @@
import { Server } from 'lucide-react';
import { useT } from '@/i18n/I18nProvider';
import { Select } from '@/components/ui/select';
import type { OptionalServiceResourceConfig } from '@/types';
export function OptionalServiceResourceFields({
@@ -34,31 +35,35 @@ export function OptionalServiceResourceFields({
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs text-gray-500 mb-1">{r.cpuLimit}</label>
<select
className="input-field text-sm"
<Select
size="md"
ariaLabel={r.cpuLimit}
disabled={readOnly}
value={config.cpuLimit}
onChange={(e) => onChange({ cpuLimit: e.target.value })}
>
<option value="200m">200m</option>
<option value="500m">500m</option>
<option value="1">{r.oneCore}</option>
<option value="2">{r.twoCores}</option>
</select>
onChange={(v) => onChange({ cpuLimit: v })}
options={[
{ value: '200m', label: '200m' },
{ value: '500m', label: '500m' },
{ value: '1', label: r.oneCore },
{ value: '2', label: r.twoCores },
]}
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">{r.memoryLimit}</label>
<select
className="input-field text-sm"
<Select
size="md"
ariaLabel={r.memoryLimit}
disabled={readOnly}
value={config.memoryLimit}
onChange={(e) => onChange({ memoryLimit: e.target.value })}
>
<option value="256Mi">256 Mi</option>
<option value="512Mi">512 Mi</option>
<option value="1Gi">1 Gi</option>
<option value="2Gi">2 Gi</option>
</select>
onChange={(v) => onChange({ memoryLimit: v })}
options={[
{ value: '256Mi', label: '256 Mi' },
{ value: '512Mi', label: '512 Mi' },
{ value: '1Gi', label: '1 Gi' },
{ value: '2Gi', label: '2 Gi' },
]}
/>
</div>
<div className="sm:col-span-2">
<label className="block text-xs text-gray-500 mb-1">{r.storageGb}</label>
@@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api';
import { toast } from 'react-toastify';
import { useT } from '@/i18n/I18nProvider';
import { Select } from '@/components/ui/select';
import type { Application, ServiceAccessGrant, ServiceAccessTarget } from '@/types';
import { ExternalLink, ShieldAlert, Copy, Check, Eye, EyeOff } from 'lucide-react';
@@ -129,17 +130,13 @@ export function ServiceExternalAccessPanel({
<div className="flex flex-wrap gap-4 items-end">
<div className="flex-1 min-w-[160px]">
<label className="text-xs font-medium text-gray-600 block mb-1">{ea.service}</label>
<select
<Select
size="md"
ariaLabel={ea.service}
value={accessTarget}
onChange={(e) => setAccessTarget(e.target.value as ServiceAccessTarget)}
className="input-field w-full text-sm"
>
{accessTargetOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
onChange={(v) => setAccessTarget(v as ServiceAccessTarget)}
options={accessTargetOptions}
/>
</div>
<div className="flex-1 min-w-[200px]">
<label className="text-xs font-medium text-gray-600 block mb-1">{ea.accessMode}</label>
+127
View File
@@ -0,0 +1,127 @@
'use client';
import { useState, useRef, useEffect, useCallback } from 'react';
import { ChevronDown, Check } from 'lucide-react';
export type SelectOption = { value: string; label: string; disabled?: boolean };
/**
* Lightweight custom dropdown. Unlike a native <select>, its option list is
* absolutely anchored right below the trigger, so it can never render detached
* at the top of the screen (a common mobile/emulator quirk). Flips above the
* trigger automatically when there isn't enough room below.
*/
export function Select({
value,
options,
onChange,
className = '',
ariaLabel,
placeholder,
size = 'sm',
disabled = false,
}: {
value: string;
options: SelectOption[];
onChange: (value: string) => void;
className?: string;
ariaLabel?: string;
placeholder?: string;
/** sm: compact inline pickers; md: matches the .input-field form control. */
size?: 'sm' | 'md';
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
const [flipUp, setFlipUp] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const selected = options.find((o) => o.value === value);
const close = useCallback(() => setOpen(false), []);
useEffect(() => {
if (!open) return;
const onDocClick = (e: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) close();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') close();
};
document.addEventListener('mousedown', onDocClick);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDocClick);
document.removeEventListener('keydown', onKey);
};
}, [open, close]);
const toggle = (e: React.MouseEvent) => {
e.stopPropagation();
if (disabled) return;
if (!open && rootRef.current) {
const rect = rootRef.current.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
setFlipUp(spaceBelow < 240 && rect.top > spaceBelow);
}
setOpen((o) => !o);
};
return (
<div ref={rootRef} className={`relative ${className}`}>
<button
type="button"
onClick={toggle}
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={open}
aria-label={ariaLabel}
className={`w-full flex items-center justify-between gap-2 border border-gray-300 bg-white text-sm text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 transition-all duration-150 disabled:opacity-60 disabled:cursor-not-allowed disabled:bg-gray-50 ${
size === 'md' ? 'rounded-xl px-3.5 py-2.5' : 'rounded-lg px-2.5 py-1.5'
}`}
>
<span className={`truncate ${selected ? '' : 'text-gray-400'}`}>
{selected?.label ?? placeholder ?? value}
</span>
<ChevronDown
className={`w-4 h-4 text-gray-400 shrink-0 transition-transform duration-150 ${open ? 'rotate-180' : ''}`}
/>
</button>
{open && (
<ul
role="listbox"
className={`absolute z-50 left-0 right-0 max-h-60 overflow-auto rounded-xl border border-gray-200 bg-white py-1 shadow-lg ring-1 ring-black/5 ${
flipUp ? 'bottom-full mb-1' : 'top-full mt-1'
}`}
>
{options.map((opt) => {
const isSelected = opt.value === value;
return (
<li
key={opt.value}
role="option"
aria-selected={isSelected}
aria-disabled={opt.disabled}
onClick={(e) => {
e.stopPropagation();
if (opt.disabled) return;
onChange(opt.value);
close();
}}
className={`flex items-center justify-between gap-2 px-3 py-2 text-sm transition-colors ${
opt.disabled
? 'text-gray-300 cursor-not-allowed'
: isSelected
? 'bg-primary-50/70 text-primary-700 font-medium cursor-pointer'
: 'text-gray-700 hover:bg-gray-50 cursor-pointer'
}`}
>
<span className="truncate">{opt.label}</span>
{isSelected && <Check className="w-4 h-4 text-primary-600 shrink-0" />}
</li>
);
})}
</ul>
)}
</div>
);
}