PresetPicker.tsx 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  1. import { useEffect, useRef, useState } from 'react';
  2. import { ChevronDown, Search, X } from 'lucide-react';
  3. import { useTranslation } from 'react-i18next';
  4. import type { FilamentOption, FilamentOptionSource } from './types';
  5. /**
  6. * Pick one filament preset, showing where each preset came from.
  7. *
  8. * A native `<select>` cannot carry the origin badge, and the origin is not
  9. * decoration: the same filament exists as a Bambu Cloud preset, an Orca Cloud
  10. * profile, a locally imported one and a built-in, and which one you pick
  11. * decides what actually reaches the printer. The Configure AMS Slot modal has
  12. * shown these badges for that reason since #1623; this is the same wording and
  13. * the same colours, so the two screens read alike.
  14. *
  15. * Deliberately not a shared "Select" component: it is a listbox of buttons
  16. * because each row is a name plus a badge, and it filters as you type because
  17. * a cloud account with a few hundred presets is ordinary.
  18. */
  19. const BADGE_CLASSES: Record<FilamentOptionSource, string> = {
  20. local: 'bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400',
  21. orca_cloud: 'bg-purple-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-400',
  22. cloud: 'bg-bambu-blue/20 text-bambu-blue',
  23. builtin: 'bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400',
  24. };
  25. export function PresetSourceBadge({ source }: { source: FilamentOptionSource }) {
  26. const { t } = useTranslation();
  27. // The same four labels the Configure AMS Slot modal uses, by the same keys --
  28. // one wording per source across the app rather than a second set to keep in
  29. // step.
  30. const label = {
  31. local: t('profiles.localProfiles.badge'),
  32. orca_cloud: t('configureAmsSlot.orcaCloud'),
  33. cloud: t('configureAmsSlot.bambuCloud'),
  34. builtin: t('configureAmsSlot.builtin'),
  35. }[source];
  36. return (
  37. <span className={`text-[10px] px-1.5 py-0.5 rounded whitespace-nowrap ${BADGE_CLASSES[source]}`}>
  38. {label}
  39. </span>
  40. );
  41. }
  42. interface PresetPickerProps {
  43. /** Currently chosen preset code, or '' for "inherit". */
  44. value: string;
  45. options: FilamentOption[];
  46. /** What the empty choice reads as, e.g. "Use the spool's preset". */
  47. inheritLabel: string;
  48. onChange: (option: FilamentOption | null) => void;
  49. disabled?: boolean;
  50. ariaLabel: string;
  51. }
  52. export function PresetPicker({
  53. value,
  54. options,
  55. inheritLabel,
  56. onChange,
  57. disabled = false,
  58. ariaLabel,
  59. }: PresetPickerProps) {
  60. const { t } = useTranslation();
  61. const [open, setOpen] = useState(false);
  62. const [query, setQuery] = useState('');
  63. const rootRef = useRef<HTMLDivElement>(null);
  64. const selected = options.find(o => o.code === value);
  65. useEffect(() => {
  66. if (!open) return;
  67. const onPointerDown = (e: MouseEvent) => {
  68. if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
  69. };
  70. // Escape closes this control WITHOUT closing the modal around it, which is
  71. // what a bare keydown listener on the document would otherwise let happen.
  72. const onKeyDown = (e: KeyboardEvent) => {
  73. if (e.key !== 'Escape') return;
  74. e.stopPropagation();
  75. setOpen(false);
  76. };
  77. document.addEventListener('mousedown', onPointerDown);
  78. document.addEventListener('keydown', onKeyDown, true);
  79. return () => {
  80. document.removeEventListener('mousedown', onPointerDown);
  81. document.removeEventListener('keydown', onKeyDown, true);
  82. };
  83. }, [open]);
  84. const needle = query.trim().toLowerCase();
  85. const shown = needle
  86. ? options.filter(o => o.displayName.toLowerCase().includes(needle))
  87. : options;
  88. const choose = (option: FilamentOption | null) => {
  89. onChange(option);
  90. setOpen(false);
  91. setQuery('');
  92. };
  93. return (
  94. <div className="relative" ref={rootRef}>
  95. <button
  96. type="button"
  97. aria-label={ariaLabel}
  98. aria-haspopup="listbox"
  99. aria-expanded={open}
  100. disabled={disabled}
  101. onClick={() => setOpen(o => !o)}
  102. className="w-full flex items-center gap-2 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-sm text-left focus:outline-none focus:border-bambu-green disabled:opacity-50"
  103. >
  104. <span className={`flex-1 min-w-0 truncate ${selected ? 'text-white' : 'text-bambu-gray italic'}`}>
  105. {selected ? selected.displayName : inheritLabel}
  106. </span>
  107. {selected && <PresetSourceBadge source={selected.source} />}
  108. <ChevronDown className="w-3.5 h-3.5 text-bambu-gray shrink-0" />
  109. </button>
  110. {open && (
  111. <div className="absolute z-50 mt-1 w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl">
  112. <div className="p-2 border-b border-bambu-dark-tertiary">
  113. <div className="relative">
  114. <Search className="w-3.5 h-3.5 text-bambu-gray absolute left-2 top-1/2 -translate-y-1/2" />
  115. <input
  116. autoFocus
  117. type="text"
  118. value={query}
  119. onChange={e => setQuery(e.target.value)}
  120. placeholder={t('inventory.searchPresets')}
  121. className="w-full pl-7 pr-7 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded-md text-sm text-white placeholder:text-bambu-gray/60 focus:outline-none focus:border-bambu-green"
  122. />
  123. {query && (
  124. <button
  125. type="button"
  126. aria-label={t('common.clear')}
  127. onClick={() => setQuery('')}
  128. className="absolute right-2 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white"
  129. >
  130. <X className="w-3.5 h-3.5" />
  131. </button>
  132. )}
  133. </div>
  134. </div>
  135. <div className="max-h-56 overflow-y-auto p-1" role="listbox" aria-label={ariaLabel}>
  136. <button
  137. type="button"
  138. role="option"
  139. aria-selected={!selected}
  140. onClick={() => choose(null)}
  141. className={`w-full text-left px-2 py-1.5 rounded-md text-sm italic ${
  142. selected ? 'text-bambu-gray hover:bg-bambu-dark' : 'bg-bambu-green/15 text-bambu-green'
  143. }`}
  144. >
  145. {inheritLabel}
  146. </button>
  147. {shown.length === 0 ? (
  148. <p className="px-2 py-2 text-sm text-bambu-gray">{t('inventory.noResults')}</p>
  149. ) : (
  150. shown.map(option => (
  151. <button
  152. key={option.code}
  153. type="button"
  154. role="option"
  155. aria-selected={option.code === value}
  156. onClick={() => choose(option)}
  157. className={`w-full flex items-center gap-2 text-left px-2 py-1.5 rounded-md text-sm ${
  158. option.code === value
  159. ? 'bg-bambu-green/15 text-bambu-green'
  160. : 'text-white hover:bg-bambu-dark'
  161. }`}
  162. >
  163. <span className="flex-1 min-w-0 truncate" title={option.displayName}>
  164. {option.displayName}
  165. </span>
  166. <PresetSourceBadge source={option.source} />
  167. </button>
  168. ))
  169. )}
  170. </div>
  171. </div>
  172. )}
  173. </div>
  174. );
  175. }