Replace native selects with custom dropdown for mobile usability
Native <select> popups ignore CSS on iOS/Android. CustomSelect renders a styled div-based dropdown with full-width touch-friendly option buttons (py-3, text-base). Used in BeoSelector and ObjektSelector. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import type { BeoOption } from '@/types/logbuch';
|
import type { BeoOption } from '@/types/logbuch';
|
||||||
|
import CustomSelect from './CustomSelect';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
selected: BeoOption[];
|
selected: BeoOption[];
|
||||||
@@ -21,9 +22,8 @@ export default function BeoSelector({ selected, onChange }: Props) {
|
|||||||
const selectedIds = new Set(selected.map((b) => b.ID));
|
const selectedIds = new Set(selected.map((b) => b.ID));
|
||||||
const available = all.filter((b) => !selectedIds.has(b.ID));
|
const available = all.filter((b) => !selectedIds.has(b.ID));
|
||||||
|
|
||||||
function add(id: string) {
|
function add(value: string) {
|
||||||
if (!id) return;
|
const beo = all.find((b) => b.ID === parseInt(value));
|
||||||
const beo = all.find((b) => b.ID === parseInt(id));
|
|
||||||
if (beo) onChange([...selected, beo]);
|
if (beo) onChange([...selected, beo]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,18 +32,18 @@ export default function BeoSelector({ selected, onChange }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{selected.map((b) => (
|
{selected.map((b) => (
|
||||||
<span
|
<span
|
||||||
key={b.ID}
|
key={b.ID}
|
||||||
className="inline-flex items-center gap-1 bg-blue-100 text-blue-800 text-sm px-2 py-1 rounded-full"
|
className="inline-flex items-center gap-2 bg-blue-100 text-blue-800 text-base px-3 py-2 rounded-full"
|
||||||
>
|
>
|
||||||
{b.Kuerzel} — {b.Name}
|
{b.Kuerzel} — {b.Name}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => remove(b.ID)}
|
onClick={() => remove(b.ID)}
|
||||||
className="ml-1 text-blue-600 hover:text-red-600 font-bold leading-none"
|
className="flex items-center justify-center w-7 h-7 rounded-full text-blue-600 hover:bg-red-100 hover:text-red-600 font-bold text-xl leading-none"
|
||||||
aria-label={`${b.Kuerzel} entfernen`}
|
aria-label={`${b.Kuerzel} entfernen`}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
@@ -51,19 +51,13 @@ export default function BeoSelector({ selected, onChange }: Props) {
|
|||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{available.length > 0 && (
|
{available.length > 0 && (
|
||||||
<select
|
<CustomSelect
|
||||||
className="px-3 py-1.5 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none"
|
placeholder="+ BEO hinzufügen"
|
||||||
value=""
|
options={available.map((b) => ({ value: String(b.ID), label: `${b.Kuerzel} — ${b.Name}` }))}
|
||||||
onChange={(e) => add(e.target.value)}
|
onChange={add}
|
||||||
>
|
/>
|
||||||
<option value="">+ BEO hinzufügen</option>
|
|
||||||
{available.map((b) => (
|
|
||||||
<option key={b.ID} value={b.ID}>
|
|
||||||
{b.Kuerzel} — {b.Name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
69
components/CustomSelect.tsx
Normal file
69
components/CustomSelect.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
export interface SelectOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
options: SelectOption[];
|
||||||
|
placeholder: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CustomSelect({ options, placeholder, onChange }: Props) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleOutside(e: MouseEvent) {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (open) document.addEventListener('mousedown', handleOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleOutside);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
function select(value: string) {
|
||||||
|
setOpen(false);
|
||||||
|
onChange(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="w-full flex items-center justify-between px-4 py-3 border-2 border-gray-400 rounded-lg bg-white text-base text-gray-700 focus:border-blue-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
<span>{placeholder}</span>
|
||||||
|
<svg
|
||||||
|
className={`w-5 h-5 text-gray-500 transition-transform ${open ? 'rotate-180' : ''}`}
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="absolute z-50 left-0 right-0 mt-1 bg-white border-2 border-gray-400 rounded-lg shadow-lg max-h-72 overflow-y-auto">
|
||||||
|
{options.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
disabled={opt.disabled}
|
||||||
|
onClick={() => select(opt.value)}
|
||||||
|
className="w-full text-left px-4 py-3 text-base hover:bg-blue-50 active:bg-blue-100 border-b border-gray-100 last:border-0 disabled:text-gray-400 disabled:bg-gray-50"
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import type { ObjektOption, SelectedObjekt } from '@/types/logbuch';
|
import type { ObjektOption, SelectedObjekt } from '@/types/logbuch';
|
||||||
|
import CustomSelect from './CustomSelect';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
selected: SelectedObjekt[];
|
selected: SelectedObjekt[];
|
||||||
@@ -46,19 +47,24 @@ export default function ObjektSelector({ selected, onChange }: Props) {
|
|||||||
onChange(selected.filter((o) => o.Name !== name));
|
onChange(selected.filter((o) => o.Name !== name));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{ value: 'neu', label: '— Neues Objekt eingeben —' },
|
||||||
|
...available.map((o) => ({ value: String(o.ID), label: o.Name })),
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{selected.map((o) => (
|
{selected.map((o) => (
|
||||||
<span
|
<span
|
||||||
key={o.Name}
|
key={o.Name}
|
||||||
className="inline-flex items-center gap-1 bg-green-100 text-green-800 text-sm px-2 py-1 rounded-full"
|
className="inline-flex items-center gap-2 bg-green-100 text-green-800 text-base px-3 py-2 rounded-full"
|
||||||
>
|
>
|
||||||
{o.Name}
|
{o.Name}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => remove(o.Name)}
|
onClick={() => remove(o.Name)}
|
||||||
className="ml-1 text-green-600 hover:text-red-600 font-bold leading-none"
|
className="flex items-center justify-center w-7 h-7 rounded-full text-green-600 hover:bg-red-100 hover:text-red-600 font-bold text-xl leading-none"
|
||||||
aria-label={`${o.Name} entfernen`}
|
aria-label={`${o.Name} entfernen`}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
@@ -67,19 +73,11 @@ export default function ObjektSelector({ selected, onChange }: Props) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<select
|
<CustomSelect
|
||||||
className="px-3 py-1.5 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none"
|
placeholder="+ Objekt hinzufügen"
|
||||||
value=""
|
options={options}
|
||||||
onChange={(e) => add(e.target.value)}
|
onChange={add}
|
||||||
>
|
/>
|
||||||
<option value="">+ Objekt hinzufügen</option>
|
|
||||||
<option value="neu">— Neues Objekt eingeben —</option>
|
|
||||||
{available.map((o) => (
|
|
||||||
<option key={o.ID} value={o.ID}>
|
|
||||||
{o.Name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{showNewInput && (
|
{showNewInput && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -89,19 +87,19 @@ export default function ObjektSelector({ selected, onChange }: Props) {
|
|||||||
onChange={(e) => setNewName(e.target.value)}
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addNew(); } }}
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addNew(); } }}
|
||||||
placeholder="Objektname eingeben"
|
placeholder="Objektname eingeben"
|
||||||
className="flex-1 px-3 py-1.5 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none"
|
className="flex-1 px-3 py-3 border-2 border-gray-400 rounded-lg bg-white text-base focus:border-blue-500 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={addNew}
|
onClick={addNew}
|
||||||
className="px-3 py-1.5 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700"
|
className="px-4 py-3 bg-green-600 text-white text-base rounded-lg hover:bg-green-700"
|
||||||
>
|
>
|
||||||
OK
|
OK
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setShowNewInput(false); setNewName(''); }}
|
onClick={() => { setShowNewInput(false); setNewName(''); }}
|
||||||
className="px-3 py-1.5 bg-gray-200 text-gray-700 text-sm rounded-lg hover:bg-gray-300"
|
className="px-4 py-3 bg-gray-200 text-gray-700 text-base rounded-lg hover:bg-gray-300"
|
||||||
>
|
>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user