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
15 changes: 15 additions & 0 deletions docs/frontend-ui-audit-2026-09-03/PropertyDropdownAlignment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# PropertyDropdownAlignment UI audit

| Line | Element | Verdict | Reason | Suggested change |
| --- | --- | --- | --- | --- |
| `src/components/PropertyField/PropertyFieldEditable.tsx:182` | `getPropertyDropdownAlign` | keep with reason | This is the shared seam for pill-field placement; its right-edge policy prevents each consumer from choosing a transient alignment. | None. |
| `src/components/PropertyField/PropertyFieldEditable.tsx:189` | Auto-alignment measurement | keep with reason | Auto placement remains supported for callers that require viewport-aware fallback, but the panel stays non-interactive and invisible until its measured side is resolved. | None. |
| `src/components/PropertyField/PropertyFieldEditable.tsx:249` | Inline dropdown surface | keep with reason | The custom relative surface is the shared primitive for field rows; it now uses the standard positioned-overlay visibility helper. | None. |
| `src/components/PropertyField/PropertyFieldEditable.tsx:367` | Portaled searchable dropdown surface | keep with reason | The portal is necessary to escape overflow-clipping property panels and now shares the positioned-overlay visibility contract. | None. |
| `src/modules/ProjectManager/WorkItems/components/WorkItemProperties/LabelsSection.tsx:102` | Labels picker | keep with reason | Delegates pill/right versus row/left placement to the shared helper instead of reimplementing the policy. | None. |
| `src/modules/ProjectManager/WorkItems/components/WorkItemProperties/DateQuickAssignDropdown.tsx:153` | Date picker | keep with reason | Delegates placement to the shared helper; row behavior remains left-aligned. | None. |
| `src/modules/ProjectManager/WorkItems/components/WorkItemProperties/PlanningSection.tsx:175` | Milestone picker | keep with reason | Delegates placement to the shared helper; no local popup shell is introduced. | None. |
| `src/modules/ProjectManager/shared/components/PropertiesPanel/PropertyFieldSections/PeopleTeamsLabelsFields.tsx:114` | People, teams, labels, and repos pickers | keep with reason | Five consumers use the same shared policy, preventing a future per-picker drift. | None. |
| `src/modules/ProjectManager/shared/components/PropertiesPanel/PropertyFieldSections/StatusHealthPriorityFields.tsx:88` | Status, health, and priority pickers | keep with reason | Three consumers use the same shared policy, preventing a future per-picker drift. | None. |

Verdict totals: **0 fix**, **9 keep with reason**, **0 abstract**.
63 changes: 63 additions & 0 deletions src/components/PropertyField/PropertyFieldEditable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
SearchableDropdown,
type SearchableDropdownProps,
getPropertyDropdownAlign,
} from "./PropertyFieldEditable";

vi.mock("@src/components/Dropdown/DropdownSearch", () => ({
Expand Down Expand Up @@ -70,4 +71,66 @@ describe("SearchableDropdown", () => {
expect(dropdown?.style.top).toBe("80px");
expect(dropdown?.style.width).toBe("240px");
});

it("waits to reveal an auto-aligned menu until its right edge is resolved", () => {
Object.defineProperty(window, "innerWidth", {
configurable: true,
value: 1_000,
});
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
function getBoundingClientRect(this: HTMLElement) {
if (this.dataset.propertyDropdown !== undefined) {
return {
bottom: 300,
height: 220,
left: 800,
right: 1_100,
top: 80,
width: 300,
x: 800,
y: 80,
toJSON: () => ({}),
};
}

return {
bottom: 80,
height: 0,
left: 800,
right: 800,
top: 80,
width: 0,
x: 800,
y: 80,
toJSON: () => ({}),
};
}
);
const dropdownProps: SearchableDropdownProps = {
align: "auto",
children: () => createElement("span", null, "Option"),
widthMode: "menu",
};

act(() => {
root.render(createElement(SearchableDropdown, dropdownProps));
});

const dropdown = document.body.querySelector<HTMLElement>(
"[data-property-dropdown]"
);
expect(dropdown).not.toBeNull();
expect(dropdown?.style.left).toBe("");
expect(dropdown?.style.right).toBe("200px");
expect(dropdown?.style.visibility).toBe("visible");
expect(dropdown?.style.pointerEvents).toBe("auto");
});
});

describe("getPropertyDropdownAlign", () => {
it("anchors pill picker menus by their right edge", () => {
expect(getPropertyDropdownAlign("pill")).toBe("right");
expect(getPropertyDropdownAlign("row")).toBe("left");
expect(getPropertyDropdownAlign("workstation-trail")).toBe("left");
});
});
44 changes: 32 additions & 12 deletions src/components/PropertyField/PropertyFieldEditable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from "@src/components/CompoundPill/config";
import DropdownSearch from "@src/components/Dropdown/DropdownSearch";
import DropdownSelectedCheck from "@src/components/Dropdown/DropdownSelectedCheck";
import { getPositionedOverlayVisibilityStyle } from "@src/components/Dropdown/positioning";
import {
DROPDOWN_CLASSES,
DROPDOWN_ITEM,
Expand Down Expand Up @@ -173,31 +174,46 @@ export const FieldRow: React.FC<FieldRowProps> = ({
export type DropdownWidthMode = "match-parent" | "menu";
export type DropdownAlign = "left" | "right" | "auto";

/**
* Property pills use their trailing edge as the menu anchor. This keeps wide
* pickers inside the detail panel and gives every pill field the same menu
* edge, rather than letting each caller choose an initial side independently.
*/
export function getPropertyDropdownAlign(
fieldVariant: FieldRowVariant
): Exclude<DropdownAlign, "auto"> {
return fieldVariant === "pill" ? "right" : "left";
}

function useResolvedDropdownAlign(align: DropdownAlign) {
const [resolvedAlign, setResolvedAlign] = useState<"left" | "right">(
align === "right" ? "right" : "left"
);
const [autoAlign, setAutoAlign] = useState<"left" | "right">("left");
// Auto alignment needs the rendered panel width. Keep the panel hidden
// until its callback ref has resolved that width; otherwise it paints
// left-aligned for one frame before moving to the right-aligned position.
const [isAutoPositioned, setIsAutoPositioned] = useState(false);

const dropdownRef = useCallback(
(dropdown: HTMLDivElement | null) => {
if (!dropdown) return;
if (align !== "auto") {
if (resolvedAlign !== align) setResolvedAlign(align);
return;
}
if (align !== "auto") return;

const rect = dropdown.getBoundingClientRect();
const viewportPadding = 12;
const nextAlign =
rect.right > getViewportSize().width - viewportPadding
? "right"
: "left";
if (resolvedAlign !== nextAlign) setResolvedAlign(nextAlign);
setAutoAlign(nextAlign);
setIsAutoPositioned(true);
},
[align, resolvedAlign]
[align]
);

return { dropdownRef, resolvedAlign };
return {
dropdownRef,
resolvedAlign: align === "auto" ? autoAlign : align,
isPositioned: align !== "auto" || isAutoPositioned,
};
}

export interface DropdownProps {
Expand All @@ -213,7 +229,8 @@ export const Dropdown: React.FC<DropdownProps> = ({
align = "left",
widthMode = "match-parent",
}) => {
const { dropdownRef, resolvedAlign } = useResolvedDropdownAlign(align);
const { dropdownRef, resolvedAlign, isPositioned } =
useResolvedDropdownAlign(align);
const positionClass =
widthMode === "menu"
? resolvedAlign === "right"
Expand All @@ -229,6 +246,7 @@ export const Dropdown: React.FC<DropdownProps> = ({
ref={dropdownRef}
data-property-dropdown
className={`absolute ${positionClass} top-full mt-1 flex flex-col ${widthClass} ${DROPDOWN_CLASSES.panelAnimated} ${className}`}
style={getPositionedOverlayVisibilityStyle(isPositioned)}
>
{children}
</div>
Expand Down Expand Up @@ -265,7 +283,8 @@ export const SearchableDropdown: React.FC<SearchableDropdownProps> = ({
width?: number;
} | null>(null);
const anchorRef = useRef<HTMLDivElement | null>(null);
const { dropdownRef, resolvedAlign } = useResolvedDropdownAlign(align);
const { dropdownRef, resolvedAlign, isPositioned } =
useResolvedDropdownAlign(align);
const positionClass =
widthMode === "menu"
? resolvedAlign === "right"
Expand Down Expand Up @@ -345,6 +364,7 @@ export const SearchableDropdown: React.FC<SearchableDropdownProps> = ({
data-property-dropdown
className={`fixed flex flex-col ${widthClass} ${DROPDOWN_CLASSES.panelAnimated} ${className}`}
style={{
...getPositionedOverlayVisibilityStyle(isPositioned),
top: portalPosition.top,
left: portalPosition.left,
right: portalPosition.right,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@ const mocks = vi.hoisted(() => ({
} as WorkItem,
}));

vi.mock("@src/api/http/git/remotes", () => ({
vi.mock("@src/api/http/git/remotes", async (importOriginal) => ({
...(await importOriginal<typeof import("@src/api/http/git/remotes")>()),
getGitRemotes: mocks.getGitRemotes,
}));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type FieldRowVariant,
Option,
SearchableDropdown,
getPropertyDropdownAlign,
} from "@src/components/PropertyField/PropertyFieldEditable";
import type { DropdownEnginePosition } from "@src/hooks/dropdown";
import {
Expand Down Expand Up @@ -152,7 +153,7 @@ export function DateQuickAssignDropdown({
<SearchableDropdown
placeholder={t("properties.addDate")}
widthMode={fieldVariant === "pill" ? "menu" : "match-parent"}
align={fieldVariant === "pill" ? "auto" : "left"}
align={getPropertyDropdownAlign(fieldVariant)}
>
{(query) =>
renderOptions({ searchQuery: query, value, onChange, t, emptyLabel })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type FieldRowVariant,
Option,
SearchableDropdown,
getPropertyDropdownAlign,
} from "@src/components/PropertyField/PropertyFieldEditable";
import { HugeiconsIcon, Tag01Icon } from "@src/icons";
import type {
Expand Down Expand Up @@ -101,7 +102,7 @@ export function LabelsSection({
<SearchableDropdown
placeholder={t("common:actions.search")}
widthMode={fieldVariant === "pill" ? "menu" : "match-parent"}
align={fieldVariant === "pill" ? "auto" : "left"}
align={getPropertyDropdownAlign(fieldVariant)}
>
{(searchQuery) => {
const filtered = searchQuery
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type FieldRowVariant,
Option,
SearchableDropdown,
getPropertyDropdownAlign,
} from "@src/components/PropertyField/PropertyFieldEditable";
import {
Book02Icon,
Expand Down Expand Up @@ -174,7 +175,7 @@ export function PlanningSection({
<SearchableDropdown
placeholder={t("common:actions.search")}
widthMode={fieldVariant === "pill" ? "menu" : "match-parent"}
align={fieldVariant === "pill" ? "auto" : "left"}
align={getPropertyDropdownAlign(fieldVariant)}
>
{(searchQuery) => {
const filtered = searchQuery
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
type FieldRowVariant,
Option,
SearchableDropdown,
getPropertyDropdownAlign,
} from "@src/components/PropertyField/PropertyFieldEditable";
import {
Airplane01Icon,
Expand Down Expand Up @@ -113,7 +114,7 @@ const PeopleTeamsLabelsFields: React.FC<PeopleTeamsLabelsFieldsProps> = ({
<SearchableDropdown
placeholder={t("common:actions.search")}
widthMode={fieldVariant === "pill" ? "menu" : "match-parent"}
align={fieldVariant === "pill" ? "auto" : "left"}
align={getPropertyDropdownAlign(fieldVariant)}
>
{(searchQuery) => {
const filtered = searchQuery
Expand Down Expand Up @@ -195,7 +196,7 @@ const PeopleTeamsLabelsFields: React.FC<PeopleTeamsLabelsFieldsProps> = ({
<SearchableDropdown
placeholder={t("common:actions.search")}
widthMode={fieldVariant === "pill" ? "menu" : "match-parent"}
align={fieldVariant === "pill" ? "auto" : "left"}
align={getPropertyDropdownAlign(fieldVariant)}
>
{(searchQuery) => {
const filtered = searchQuery
Expand Down Expand Up @@ -264,7 +265,7 @@ const PeopleTeamsLabelsFields: React.FC<PeopleTeamsLabelsFieldsProps> = ({
<SearchableDropdown
placeholder={t("common:actions.search")}
widthMode={fieldVariant === "pill" ? "menu" : "match-parent"}
align={fieldVariant === "pill" ? "auto" : "left"}
align={getPropertyDropdownAlign(fieldVariant)}
>
{(searchQuery) => {
const filtered = searchQuery
Expand Down Expand Up @@ -338,7 +339,7 @@ const PeopleTeamsLabelsFields: React.FC<PeopleTeamsLabelsFieldsProps> = ({
<SearchableDropdown
placeholder={t("common:actions.search")}
widthMode={fieldVariant === "pill" ? "menu" : "match-parent"}
align={fieldVariant === "pill" ? "auto" : "left"}
align={getPropertyDropdownAlign(fieldVariant)}
>
{(searchQuery) => {
const filtered = searchQuery
Expand Down Expand Up @@ -401,7 +402,7 @@ const PeopleTeamsLabelsFields: React.FC<PeopleTeamsLabelsFieldsProps> = ({
<SearchableDropdown
placeholder={t("common:actions.search")}
widthMode={fieldVariant === "pill" ? "menu" : "match-parent"}
align={fieldVariant === "pill" ? "auto" : "left"}
align={getPropertyDropdownAlign(fieldVariant)}
>
{(searchQuery) => {
const filtered = searchQuery
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type FieldRowVariant,
Option,
SearchableDropdown,
getPropertyDropdownAlign,
} from "@src/components/PropertyField/PropertyFieldEditable";
import { CircleIcon, Flag01Icon, HugeiconsIcon } from "@src/icons";
import { getProjectPriorityConfig } from "@src/modules/ProjectManager/config/manage";
Expand Down Expand Up @@ -87,7 +88,7 @@ const StatusHealthPriorityFields: React.FC<StatusHealthPriorityFieldsProps> = ({
<SearchableDropdown
placeholder={t("common:actions.search")}
widthMode={fieldVariant === "pill" ? "menu" : "match-parent"}
align={fieldVariant === "pill" ? "auto" : "left"}
align={getPropertyDropdownAlign(fieldVariant)}
>
{(searchQuery) => {
const filtered = searchQuery
Expand Down Expand Up @@ -152,7 +153,7 @@ const StatusHealthPriorityFields: React.FC<StatusHealthPriorityFieldsProps> = ({
<SearchableDropdown
placeholder={t("common:actions.search")}
widthMode={fieldVariant === "pill" ? "menu" : "match-parent"}
align={fieldVariant === "pill" ? "auto" : "left"}
align={getPropertyDropdownAlign(fieldVariant)}
>
{(searchQuery) => {
const filtered = searchQuery
Expand Down Expand Up @@ -217,7 +218,7 @@ const StatusHealthPriorityFields: React.FC<StatusHealthPriorityFieldsProps> = ({
<SearchableDropdown
placeholder={t("common:actions.search")}
widthMode={fieldVariant === "pill" ? "menu" : "match-parent"}
align={fieldVariant === "pill" ? "auto" : "left"}
align={getPropertyDropdownAlign(fieldVariant)}
>
{(searchQuery) => {
const filtered = searchQuery
Expand Down
Loading