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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user