PrinterSearchPicker.tsx 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
  1. import { useEffect, useRef, useState } from 'react';
  2. import { Plus, Search } from 'lucide-react';
  3. import { useTranslation } from 'react-i18next';
  4. export interface PickablePrinter {
  5. id: number;
  6. name: string;
  7. location?: string | null;
  8. }
  9. interface PrinterSearchPickerProps {
  10. /** Printers that can still be picked. */
  11. printers: PickablePrinter[];
  12. onPick: (printer: PickablePrinter) => void;
  13. /** Text of the button that opens the picker. */
  14. label: string;
  15. disabled?: boolean;
  16. }
  17. /**
  18. * A button that opens a searchable list of printers and hands back the one
  19. * clicked. Made for farms: with 120 printers a plain select is a long scroll,
  20. * so the list filters by name and location as you type.
  21. */
  22. export function PrinterSearchPicker({ printers, onPick, label, disabled }: PrinterSearchPickerProps) {
  23. const { t } = useTranslation();
  24. const [open, setOpen] = useState(false);
  25. const [query, setQuery] = useState('');
  26. const rootRef = useRef<HTMLDivElement>(null);
  27. useEffect(() => {
  28. if (!open) return;
  29. const onPointerDown = (e: PointerEvent) => {
  30. if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
  31. };
  32. document.addEventListener('pointerdown', onPointerDown);
  33. return () => document.removeEventListener('pointerdown', onPointerDown);
  34. }, [open]);
  35. const needle = query.trim().toLowerCase();
  36. const matches = printers.filter(p =>
  37. !needle || p.name.toLowerCase().includes(needle) || (p.location ?? '').toLowerCase().includes(needle),
  38. );
  39. const close = () => {
  40. setOpen(false);
  41. setQuery('');
  42. };
  43. return (
  44. <div ref={rootRef} className="relative inline-block">
  45. <button
  46. type="button"
  47. onClick={() => (open ? close() : setOpen(true))}
  48. disabled={disabled || printers.length === 0}
  49. aria-expanded={open}
  50. className="inline-flex items-center gap-1 px-2 py-1 rounded border border-dashed border-bambu-dark-tertiary text-xs text-bambu-gray hover:text-white hover:border-bambu-green disabled:opacity-50 disabled:cursor-not-allowed"
  51. >
  52. <Plus className="w-3.5 h-3.5" />
  53. {label}
  54. </button>
  55. {open && (
  56. <div
  57. className="absolute left-0 z-20 mt-1 w-64 max-w-[calc(100vw-2rem)] rounded-lg border border-bambu-dark-tertiary bg-bambu-dark-secondary shadow-lg"
  58. onKeyDown={(e) => {
  59. if (e.key === 'Escape') close();
  60. }}
  61. >
  62. <div className="relative p-2 border-b border-bambu-dark-tertiary">
  63. <Search className="w-3.5 h-3.5 text-bambu-gray absolute left-4 top-1/2 -translate-y-1/2" />
  64. <input
  65. type="search"
  66. autoFocus
  67. value={query}
  68. onChange={(e) => setQuery(e.target.value)}
  69. placeholder={t('settings.printerPickerSearch')}
  70. aria-label={t('settings.printerPickerSearch')}
  71. className="w-full pl-7 pr-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
  72. />
  73. </div>
  74. <ul className="max-h-60 overflow-y-auto py-1" role="listbox" aria-label={label}>
  75. {matches.length === 0 && (
  76. <li className="px-3 py-2 text-xs text-bambu-gray italic">{t('settings.printerPickerNoMatch')}</li>
  77. )}
  78. {matches.map(p => (
  79. <li key={p.id} role="option" aria-selected={false}>
  80. <button
  81. type="button"
  82. onClick={() => {
  83. onPick(p);
  84. close();
  85. }}
  86. className="w-full text-left px-3 py-1.5 text-sm text-white hover:bg-bambu-dark-tertiary"
  87. >
  88. <span className="block truncate">{p.name}</span>
  89. {p.location && <span className="block truncate text-[11px] text-bambu-gray">{p.location}</span>}
  90. </button>
  91. </li>
  92. ))}
  93. </ul>
  94. </div>
  95. )}
  96. </div>
  97. );
  98. }