Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -567,7 +567,7 @@ jobs:
needs: package-smoke
if: ${{ github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' || github.base_ref == 'main' }}
runs-on: ${{ matrix.os }}
timeout-minutes: 20
timeout-minutes: 30
strategy:
fail-fast: false
max-parallel: 3
Expand Down Expand Up @@ -625,7 +625,7 @@ jobs:
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install --no-install-recommends -y libopenjp2-tools
sudo apt-get install --no-install-recommends -y libopenjp2-tools xvfb

- name: Install dependencies
run: pnpm install --frozen-lockfile --ignore-scripts
Expand All @@ -648,6 +648,9 @@ jobs:
- name: Build unsigned desktop package
run: pnpm exec electron-builder --config electron-builder.config.cjs ${{ matrix.electron-target }} --publish never

- name: Install and start release candidate
run: pnpm run electron:smoke-installed

- name: Upload release candidate artifacts
uses: actions/upload-artifact@v4
with:
Expand Down
6 changes: 5 additions & 1 deletion .github/workflows/desktop-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ jobs:
build-desktop:
name: Build ${{ matrix.name }}
runs-on: ${{ matrix.os }}
timeout-minutes: 40

strategy:
fail-fast: false
Expand Down Expand Up @@ -81,7 +82,7 @@ jobs:
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install --no-install-recommends -y libopenjp2-tools
sudo apt-get install --no-install-recommends -y libopenjp2-tools xvfb

- name: Install dependencies
run: pnpm install --frozen-lockfile --ignore-scripts
Expand All @@ -95,6 +96,9 @@ jobs:
- name: Build desktop package
run: pnpm run ${{ matrix.script }}

- name: Install and start desktop package
run: pnpm run electron:smoke-installed

- name: Upload workflow artifact
uses: actions/upload-artifact@v4
with:
Expand Down
6 changes: 5 additions & 1 deletion .github/workflows/release-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ jobs:
release-checks:
name: Release checks (${{ matrix.name }})
runs-on: ${{ matrix.os }}
timeout-minutes: 45

strategy:
fail-fast: false
Expand Down Expand Up @@ -73,7 +74,7 @@ jobs:
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install --no-install-recommends -y libopenjp2-tools
sudo apt-get install --no-install-recommends -y libopenjp2-tools xvfb

- name: Install dependencies
run: pnpm install --frozen-lockfile --ignore-scripts
Expand All @@ -93,6 +94,9 @@ jobs:
- name: Build desktop package
run: pnpm run ${{ matrix.electron-script }} -- --publish never

- name: Install and start release candidate
run: pnpm run electron:smoke-installed

- name: Upload release-check artifacts
uses: actions/upload-artifact@v4
with:
Expand Down
7 changes: 5 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ jobs:
name: 03 Release / desktop package (${{ matrix.name }})
needs: release-preflight
runs-on: ${{ matrix.os }}
timeout-minutes: 25
timeout-minutes: 35
permissions:
contents: write
strategy:
Expand Down Expand Up @@ -178,7 +178,7 @@ jobs:
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install --no-install-recommends -y libopenjp2-tools
sudo apt-get install --no-install-recommends -y libopenjp2-tools xvfb

- name: Install dependencies
run: pnpm install --frozen-lockfile --ignore-scripts
Expand All @@ -192,6 +192,9 @@ jobs:
- name: Build desktop package
run: pnpm run build && pnpm run electron:prepare-deps && pnpm exec electron-builder --config electron-builder.config.cjs ${{ matrix.electron-target }} --publish never

- name: Install and start desktop package
run: pnpm run electron:smoke-installed

- name: Upload workflow artifact
uses: actions/upload-artifact@v4
with:
Expand Down
4 changes: 3 additions & 1 deletion dashboard/src/v2/components/search/SearchOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export interface SearchResults {

interface SearchOverlayProps {
anchorRef?: preact.RefObject<HTMLDivElement | null>;
restoreFocusRef?: preact.RefObject<HTMLElement | null>;
committedSearchQuery?: string;
isLoading?: boolean;
isOpen: boolean;
Expand All @@ -121,14 +122,15 @@ interface SearchOverlayProps {
hasProjectData?: boolean;
}

export const SearchOverlay: FunctionComponent<SearchOverlayProps> = ({ anchorRef, committedSearchQuery, isOpen, onClose, searchQuery, onSearchChange, results, isLoading, hasProjectData = true }) => {
export const SearchOverlay: FunctionComponent<SearchOverlayProps> = ({ anchorRef, restoreFocusRef, committedSearchQuery, isOpen, onClose, searchQuery, onSearchChange, results, isLoading, hasProjectData = true }) => {
const { translate, translatePlural } = useOptionalDashboardI18n();
const overlayRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const resultsRegionRef = useRef<HTMLDivElement>(null);
const containerRef = useFocusTrap(isOpen, {
onClose,
initialFocusRef: inputRef,
restoreFocusRef,
restoreFocus: true
}) as preact.RefObject<HTMLDivElement>;
const [focusedIndex, setFocusedIndex] = useState(-1);
Expand Down
1 change: 1 addition & 0 deletions dashboard/src/v2/components/top-nav/GlobalSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ export const GlobalSearch: FunctionComponent<GlobalSearchProps> = ({ projectId,

<SearchOverlay
anchorRef={searchBarContainerRef}
restoreFocusRef={searchBarRef}
committedSearchQuery={debouncedQuery}
isOpen={isSearchOpen}
onClose={() => setIsSearchOpen(false)}
Expand Down
89 changes: 68 additions & 21 deletions dashboard/src/v2/components/ui/DropdownMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ interface DropdownMenuProps {
menuAriaLabel?: string;
}

interface DropdownCoordinates {
top: number;
left: number;
maxHeight?: number;
}

const VIEWPORT_PADDING = 8;

type DropdownMenuItemProps = JSX.HTMLAttributes<HTMLButtonElement> & {
children?: ComponentChildren;
disabled?: boolean;
Expand Down Expand Up @@ -115,7 +123,7 @@ export const DropdownMenu = ({
const triggerRef = externalTriggerRef || localTriggerRef;
const menuRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
const [coords, setCoords] = useState({ top: 0, left: 0 });
const [coords, setCoords] = useState<DropdownCoordinates>({ top: 0, left: 0 });
const [transformOrigin, setTransformOrigin] = useState<string>("top center");

// Generate a unique ID for ARIA wiring if none exists
Expand Down Expand Up @@ -156,33 +164,62 @@ export const DropdownMenu = ({
if (!triggerRef.current || !menuRef.current) return;

const triggerRect = triggerRef.current.getBoundingClientRect();
const menuRect = menuRef.current.getBoundingClientRect();
const menuElement = menuRef.current;
const menuRect = menuElement.getBoundingClientRect();
// Transforms and a previous max-height can make the visual rectangle smaller
// than the menu's contents. Position against the intrinsic dimensions so a
// growing action list still flips before it becomes unreachable.
const intrinsicRect = {
...menuRect,
width: Math.max(menuRect.width, menuElement.scrollWidth),
height: Math.max(menuRect.height, menuElement.scrollHeight),
} as DOMRect;
const viewport = {
width: window.innerWidth,
height: window.innerHeight,
};
let top: number;
let left: number;
let nextTransformOrigin = "top center";

if (computePosition) {
const custom = computePosition({
triggerRect,
menuRect,
viewport: {
width: window.innerWidth,
height: window.innerHeight,
},
menuRect: intrinsicRect,
viewport,
defaultPosition: position,
defaultAlign: align,
gap,
});
setCoords({ top: custom.top, left: custom.left });
setTransformOrigin(custom.transformOrigin ?? "top center");
return;
top = custom.top;
left = custom.left;
nextTransformOrigin = custom.transformOrigin ?? "top center";
} else {
const calculated = calculatePosition({
triggerRect,
contentRect: intrinsicRect,
position,
align,
gap,
padding: VIEWPORT_PADDING,
viewportWidth: viewport.width,
viewportHeight: viewport.height,
});
top = calculated.top;
left = calculated.left;
}
const { top, left } = calculatePosition({
triggerRect,
contentRect: menuRect,
position,
align,
gap,
padding: 8,

const maxHeight = Math.max(
0,
viewport.height - Math.max(VIEWPORT_PADDING, top) - VIEWPORT_PADDING,
);
setCoords((current) => {
if (current.top === top && current.left === left && current.maxHeight === maxHeight) {
return current;
}
return { top, left, maxHeight };
});
setCoords({ top, left });
setTransformOrigin("top center");
setTransformOrigin(nextTransformOrigin);
}, [align, computePosition, gap, position, triggerRef]);

useEffect(() => {
Expand All @@ -198,6 +235,16 @@ export const DropdownMenu = ({
if (isOpen && isRendered) updatePosition();
}, [isOpen, isRendered, updatePosition]);

useLayoutEffect(() => {
if (!isOpen || !isRendered || typeof ResizeObserver === "undefined") return undefined;

const observer = new ResizeObserver(() => updatePosition());
if (triggerRef.current) observer.observe(triggerRef.current);
if (menuRef.current) observer.observe(menuRef.current);

return () => observer.disconnect();
}, [isOpen, isRendered, triggerRef, updatePosition]);

useEffect(() => {
if (!isOpen) return undefined;

Expand Down Expand Up @@ -426,8 +473,8 @@ export const DropdownMenu = ({
role="menu"
aria-label={menuAriaLabel}
aria-labelledby={menuAriaLabel ? undefined : (isValidElement(children) && (children.props as any).id ? (children.props as any).id : triggerId)}
className={`fixed z-[100] bg-white dark:bg-void-800 border border-black/[0.08] dark:border-white/[0.08] shadow-[0_16px_36px_rgba(15,23,42,0.14)] dark:shadow-[0_16px_36px_rgba(0,0,0,0.4)] rounded-2xl p-2 ${!isOpen ? "pointer-events-none" : ""} ${className}`}
style={{ top: coords.top, left: coords.left, transformOrigin }}
className={`fixed z-[100] max-h-[calc(100dvh-1rem)] overflow-y-auto bg-white dark:bg-void-800 border border-black/[0.08] dark:border-white/[0.08] shadow-[0_16px_36px_rgba(15,23,42,0.14)] dark:shadow-[0_16px_36px_rgba(0,0,0,0.4)] rounded-2xl p-2 ${!isOpen ? "pointer-events-none" : ""} ${className}`}
style={{ top: coords.top, left: coords.left, maxHeight: coords.maxHeight, transformOrigin }}
onClick={(e) => e.stopPropagation()}
>
{enhancedContent}
Expand Down
23 changes: 21 additions & 2 deletions dashboard/src/v2/hooks/__tests__/use-focus-trap.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ describe("useFocusTrap", () => {
cleanup();
});

const TestComponent = ({ active, onClose, empty = false, initialFocusRef, restoreFocus = true }: any) => {
const trapRef = useFocusTrap(active, { onClose, initialFocusRef, restoreFocus });
const TestComponent = ({ active, onClose, empty = false, initialFocusRef, restoreFocusRef, restoreFocus = true }: any) => {
const trapRef = useFocusTrap(active, { onClose, initialFocusRef, restoreFocusRef, restoreFocus });
return (
<div>
<button id="outside">Outside</button>
Expand Down Expand Up @@ -182,6 +182,25 @@ describe("useFocusTrap", () => {
expect(focusSpy).toHaveBeenLastCalledWith({ preventScroll: true });
});

test("prefers an explicit return target when the captured trigger is replaced", async () => {
const capturedTrigger = document.createElement("button");
const replacementTrigger = document.createElement("button");
replacementTrigger.id = "replacement-trigger";
document.body.append(capturedTrigger, replacementTrigger);
capturedTrigger.focus();
const restoreFocusRef = { current: replacementTrigger };

const { unmount } = render(
<TestComponent active={true} onClose={() => {}} restoreFocusRef={restoreFocusRef} />,
);
await waitFor(() => expect(document.activeElement?.id).toBe("inside1"));
capturedTrigger.remove();
unmount();

await waitFor(() => expect(document.activeElement).toBe(replacementTrigger));
replacementTrigger.remove();
});

test("keeps focus trapped when the focused element is removed dynamically", async () => {
const DynamicTrap = () => {
const [showFirst, setShowFirst] = useState(true);
Expand Down
6 changes: 4 additions & 2 deletions dashboard/src/v2/hooks/use-focus-trap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ export function restoreFocusSafely(...candidates: Array<HTMLElement | null | und
export interface FocusTrapOptions {
onClose?: () => void;
initialFocusRef?: { current: HTMLElement | null };
/** Preferred return target when reactive rendering may replace activeElement. */
restoreFocusRef?: { current: HTMLElement | null };
restoreFocus?: boolean;
/** Temporarily yield keyboard handling without losing the original opener. */
paused?: boolean;
Expand All @@ -79,7 +81,7 @@ export function useFocusTrap(
? { onClose: optionsOrOnClose }
: (optionsOrOnClose || {});

const { onClose, initialFocusRef, restoreFocus = true, paused = false } = options;
const { onClose, initialFocusRef, restoreFocusRef, restoreFocus = true, paused = false } = options;
const onCloseRef = useRef(onClose);
const pausedRef = useRef(paused);

Expand Down Expand Up @@ -168,7 +170,7 @@ export function useFocusTrap(
// Defer focus restoration to ensure element is re-enabled or DOM is updated
const trigger = triggerRef.current;
window.setTimeout(() => {
restoreFocusSafely(trigger);
restoreFocusSafely(restoreFocusRef?.current, trigger);
}, 0);
}
};
Expand Down
14 changes: 10 additions & 4 deletions dashboard/src/v2/lib/sprint-menu-positioning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,21 @@ export function computeSprintActionMenuPosition(
const left = Math.max(VIEWPORT_PADDING, Math.min(rightAlignedLeft, maxLeft));

const belowTop = triggerRect.bottom + MENU_GAP;
const canFitBelow = belowTop + height <= viewport.height - VIEWPORT_PADDING;
const top = canFitBelow
const spaceBelow = Math.max(0, viewport.height - VIEWPORT_PADDING - belowTop);
const spaceAbove = Math.max(0, triggerRect.top - MENU_GAP - VIEWPORT_PADDING);
const canFitBelow = height <= spaceBelow;
const canFitAbove = height <= spaceAbove;
// When both sides fit, prefer the larger region. This stays stable if the
// first layout pass underestimates a long menu while fonts or actions settle.
const placeBelow = canFitBelow && (!canFitAbove || spaceBelow >= spaceAbove);
const top = placeBelow
? belowTop
: Math.max(VIEWPORT_PADDING, triggerRect.top - height - MENU_GAP);

return {
top,
left,
placement: canFitBelow ? "bottom" : "top",
transformOrigin: canFitBelow ? "top right" : "bottom right",
placement: placeBelow ? "bottom" : "top",
transformOrigin: placeBelow ? "top right" : "bottom right",
};
}
Loading
Loading