(null);
- const selected = options.find((option) => option.value === value) ?? options[0];
- const ariaLabel = ariaLabelAttribute ?? ariaLabelProp ?? "select option";
-
- const updatePosition = useCallback(() => {
- const trigger = triggerRef.current;
- if (!trigger) return;
-
- const rect = trigger.getBoundingClientRect();
- const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
- const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
- const popupWidth = Math.max(rect.width, 160);
- const spaceBelow = viewportHeight - rect.bottom - VIEWPORT_MARGIN;
- const spaceAbove = rect.top - VIEWPORT_MARGIN;
- const placement =
- spaceBelow < MIN_POPUP_HEIGHT && spaceAbove > spaceBelow ? "top" : "bottom";
- const available = Math.max(
- MIN_POPUP_HEIGHT,
- placement === "top" ? spaceAbove : spaceBelow,
- );
- const maxHeight = Math.min(MAX_POPUP_HEIGHT, available);
- const left = Math.min(
- Math.max(VIEWPORT_MARGIN, rect.left),
- Math.max(VIEWPORT_MARGIN, viewportWidth - popupWidth - VIEWPORT_MARGIN),
- );
- const top =
- placement === "top"
- ? Math.max(VIEWPORT_MARGIN, rect.top - maxHeight - 4)
- : Math.min(viewportHeight - VIEWPORT_MARGIN, rect.bottom + 4);
-
- setPosition({ top, left, width: popupWidth, maxHeight, placement });
- }, []);
-
- useLayoutEffect(() => {
- if (!open) return;
- updatePosition();
- }, [open, updatePosition]);
-
- useEffect(() => {
- if (!open) return;
-
- function onPointerDown(event: PointerEvent) {
- const target = event.target as Node | null;
- if (
- target &&
- (triggerRef.current?.contains(target) || popupRef.current?.contains(target))
- ) {
- return;
- }
- setOpen(false);
- }
-
- function onKeyDown(event: KeyboardEvent) {
- if (event.key === "Escape") {
- event.preventDefault();
- setOpen(false);
- triggerRef.current?.focus();
- }
- }
-
- const onViewportChange = () => {
- updatePosition();
- };
-
- document.addEventListener("pointerdown", onPointerDown);
- document.addEventListener("keydown", onKeyDown);
- window.addEventListener("resize", onViewportChange);
- window.addEventListener("scroll", onViewportChange, true);
- window.visualViewport?.addEventListener("resize", onViewportChange);
- window.visualViewport?.addEventListener("scroll", onViewportChange);
-
- return () => {
- document.removeEventListener("pointerdown", onPointerDown);
- document.removeEventListener("keydown", onKeyDown);
- window.removeEventListener("resize", onViewportChange);
- window.removeEventListener("scroll", onViewportChange, true);
- window.visualViewport?.removeEventListener("resize", onViewportChange);
- window.visualViewport?.removeEventListener("scroll", onViewportChange);
- };
- }, [open, updatePosition]);
-
return (
- <>
-
- {open &&
- position &&
- createPortal(
-
- {options.map((option) => (
-
- ))}
-
,
- document.body,
- )}
- >
+
);
}
diff --git a/frontend/src/shared/index.ts b/frontend/src/shared/index.ts
index 3e95840..25e3788 100644
--- a/frontend/src/shared/index.ts
+++ b/frontend/src/shared/index.ts
@@ -43,3 +43,6 @@ export type { MenuBarProps, MenuBarMenu, MenuBarItem } from "./ui/MenuBar";
export { DockRegion } from "./ui/DockRegion";
export type { DockRegionProps, DockSide } from "./ui/DockRegion";
+
+export { SmallDropdown } from "./ui/SmallDropdown";
+export type { SmallDropdownProps, SmallDropdownOption } from "./ui/SmallDropdown";
diff --git a/frontend/src/shared/ui/SmallDropdown.tsx b/frontend/src/shared/ui/SmallDropdown.tsx
new file mode 100644
index 0000000..25a2943
--- /dev/null
+++ b/frontend/src/shared/ui/SmallDropdown.tsx
@@ -0,0 +1,296 @@
+import {
+ useCallback,
+ useEffect,
+ useId,
+ useLayoutEffect,
+ useRef,
+ useState,
+} from "react";
+import { createPortal } from "react-dom";
+
+import { cn } from "@/shared/lib/cn";
+import { zIndex } from "./zIndex";
+
+export interface SmallDropdownOption {
+ value: string;
+ label: string;
+ disabled?: boolean;
+}
+
+export interface SmallDropdownProps {
+ ariaLabel?: string;
+ "aria-label"?: string;
+ value: string;
+ options: SmallDropdownOption[];
+ onChange: (value: string) => void;
+ id?: string;
+ className?: string;
+ disabled?: boolean;
+ size?: "sm" | "md";
+ invalid?: boolean;
+}
+
+interface PopupPosition {
+ top: number;
+ left: number;
+ width: number;
+ maxHeight: number;
+ placement: "top" | "bottom";
+}
+
+const VIEWPORT_MARGIN = 8;
+const MIN_POPUP_HEIGHT = 96;
+const MAX_POPUP_HEIGHT = 280;
+
+function firstEnabledIndex(options: SmallDropdownOption[]): number {
+ return options.findIndex((option) => !option.disabled);
+}
+
+function nextEnabledIndex(
+ options: SmallDropdownOption[],
+ from: number,
+ direction: 1 | -1,
+): number {
+ if (options.length === 0) return -1;
+ for (let offset = 1; offset <= options.length; offset += 1) {
+ const index = (from + offset * direction + options.length) % options.length;
+ if (!options[index]?.disabled) return index;
+ }
+ return -1;
+}
+
+export function SmallDropdown({
+ ariaLabel: ariaLabelProp,
+ "aria-label": ariaLabelAttribute,
+ value,
+ options,
+ onChange,
+ id,
+ className,
+ disabled = false,
+ size = "sm",
+ invalid = false,
+}: SmallDropdownProps) {
+ const idBase = useId();
+ const triggerRef = useRef(null);
+ const popupRef = useRef(null);
+ const [open, setOpen] = useState(false);
+ const [position, setPosition] = useState(null);
+ const selectedIndex = options.findIndex((option) => option.value === value);
+ const selected = selectedIndex >= 0 ? options[selectedIndex] : options[0];
+ const [activeIndex, setActiveIndex] = useState(
+ selectedIndex >= 0 ? selectedIndex : firstEnabledIndex(options),
+ );
+ const ariaLabel = ariaLabelAttribute ?? ariaLabelProp ?? "select option";
+
+ const updatePosition = useCallback(() => {
+ const trigger = triggerRef.current;
+ if (!trigger) return;
+
+ const rect = trigger.getBoundingClientRect();
+ const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
+ const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
+ const popupWidth = Math.max(rect.width, 160);
+ const spaceBelow = viewportHeight - rect.bottom - VIEWPORT_MARGIN;
+ const spaceAbove = rect.top - VIEWPORT_MARGIN;
+ const placement =
+ spaceBelow < MIN_POPUP_HEIGHT && spaceAbove > spaceBelow ? "top" : "bottom";
+ const available = Math.max(
+ MIN_POPUP_HEIGHT,
+ placement === "top" ? spaceAbove : spaceBelow,
+ );
+ const maxHeight = Math.min(MAX_POPUP_HEIGHT, available);
+ const left = Math.min(
+ Math.max(VIEWPORT_MARGIN, rect.left),
+ Math.max(VIEWPORT_MARGIN, viewportWidth - popupWidth - VIEWPORT_MARGIN),
+ );
+ const top =
+ placement === "top"
+ ? Math.max(VIEWPORT_MARGIN, rect.top - maxHeight - 4)
+ : Math.min(viewportHeight - VIEWPORT_MARGIN, rect.bottom + 4);
+
+ setPosition({ top, left, width: popupWidth, maxHeight, placement });
+ }, []);
+
+ const openPopup = useCallback(() => {
+ if (disabled) return;
+ const nextActive = selectedIndex >= 0 ? selectedIndex : firstEnabledIndex(options);
+ setActiveIndex(nextActive);
+ setOpen(true);
+ }, [disabled, options, selectedIndex]);
+
+ const closePopup = useCallback(() => {
+ setOpen(false);
+ }, []);
+
+ const selectOption = useCallback(
+ (option: SmallDropdownOption | undefined) => {
+ if (!option || option.disabled) return;
+ onChange(option.value);
+ closePopup();
+ triggerRef.current?.focus();
+ },
+ [closePopup, onChange],
+ );
+
+ useLayoutEffect(() => {
+ if (!open) return;
+ updatePosition();
+ }, [open, updatePosition]);
+
+ useEffect(() => {
+ if (!open) return;
+
+ function onPointerDown(event: PointerEvent) {
+ const target = event.target as Node | null;
+ if (
+ target &&
+ (triggerRef.current?.contains(target) || popupRef.current?.contains(target))
+ ) {
+ return;
+ }
+ closePopup();
+ }
+
+ function onKeyDown(event: KeyboardEvent) {
+ if (event.key === "Escape") {
+ event.preventDefault();
+ closePopup();
+ triggerRef.current?.focus();
+ return;
+ }
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
+ event.preventDefault();
+ setActiveIndex((current) =>
+ nextEnabledIndex(options, current, event.key === "ArrowDown" ? 1 : -1),
+ );
+ return;
+ }
+ if (event.key === "Home") {
+ event.preventDefault();
+ setActiveIndex(firstEnabledIndex(options));
+ return;
+ }
+ if (event.key === "End") {
+ event.preventDefault();
+ setActiveIndex(nextEnabledIndex(options, 0, -1));
+ return;
+ }
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ selectOption(options[activeIndex]);
+ }
+ }
+
+ const onViewportChange = () => {
+ updatePosition();
+ };
+
+ document.addEventListener("pointerdown", onPointerDown);
+ document.addEventListener("keydown", onKeyDown);
+ window.addEventListener("resize", onViewportChange);
+ window.addEventListener("scroll", onViewportChange, true);
+ window.visualViewport?.addEventListener("resize", onViewportChange);
+ window.visualViewport?.addEventListener("scroll", onViewportChange);
+
+ return () => {
+ document.removeEventListener("pointerdown", onPointerDown);
+ document.removeEventListener("keydown", onKeyDown);
+ window.removeEventListener("resize", onViewportChange);
+ window.removeEventListener("scroll", onViewportChange, true);
+ window.visualViewport?.removeEventListener("resize", onViewportChange);
+ window.visualViewport?.removeEventListener("scroll", onViewportChange);
+ };
+ }, [activeIndex, closePopup, open, options, selectOption, updatePosition]);
+
+ const popupId = `${idBase}-listbox`;
+ const activeOptionId =
+ open && activeIndex >= 0 ? `${idBase}-option-${activeIndex}` : undefined;
+
+ return (
+ <>
+
+ {open &&
+ position &&
+ createPortal(
+ ,
+ document.body,
+ )}
+ >
+ );
+}
diff --git a/frontend/src/shared/ui/ui.test.tsx b/frontend/src/shared/ui/ui.test.tsx
index bc1472b..03735fb 100644
--- a/frontend/src/shared/ui/ui.test.tsx
+++ b/frontend/src/shared/ui/ui.test.tsx
@@ -11,6 +11,7 @@ import { render, screen, fireEvent } from "@testing-library/react";
import { Button } from "./Button";
import { Input } from "./Input";
import { Field } from "./Field";
+import { SmallDropdown } from "./SmallDropdown";
import { Tabs } from "./Tabs";
describe("Button", () => {
@@ -115,3 +116,48 @@ describe("Tabs", () => {
expect(onClose).toHaveBeenCalledWith("b");
});
});
+
+describe("SmallDropdown", () => {
+ const options = [
+ { value: "", label: "None" },
+ { value: "a", label: "Alpha" },
+ { value: "b", label: "Beta", disabled: true },
+ ];
+
+ it("opens a listbox and reports the selected value", () => {
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByLabelText("test dropdown"));
+ fireEvent.click(screen.getByRole("option", { name: "Alpha" }));
+
+ expect(onChange).toHaveBeenCalledWith("a");
+ expect(screen.queryByRole("listbox")).toBeNull();
+ });
+
+ it("supports keyboard selection and ignores disabled options", () => {
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+
+ const trigger = screen.getByLabelText("test dropdown");
+ fireEvent.keyDown(trigger, { key: "ArrowDown" });
+ fireEvent.keyDown(document, { key: "ArrowDown" });
+ fireEvent.keyDown(document, { key: "Enter" });
+
+ expect(onChange).toHaveBeenCalledWith("a");
+ });
+});