From 69efda527099cd9aa2765f3f3df856c9d5d7b4c1 Mon Sep 17 00:00:00 2001 From: Maggie921 <268191359+sudomaggie@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:05:21 +0800 Subject: [PATCH 1/2] fix(property-dropdown): align pill pickers before reveal --- .../PropertyDropdownAlignment.md | 15 +++++ .../PropertyFieldEditable.test.ts | 63 +++++++++++++++++++ .../PropertyField/PropertyFieldEditable.tsx | 44 +++++++++---- .../DateQuickAssignDropdown.tsx | 3 +- .../WorkItemProperties/LabelsSection.tsx | 3 +- .../WorkItemProperties/PlanningSection.tsx | 3 +- .../PeopleTeamsLabelsFields.tsx | 11 ++-- .../StatusHealthPriorityFields.tsx | 7 ++- 8 files changed, 126 insertions(+), 23 deletions(-) create mode 100644 docs/frontend-ui-audit-2026-09-03/PropertyDropdownAlignment.md diff --git a/docs/frontend-ui-audit-2026-09-03/PropertyDropdownAlignment.md b/docs/frontend-ui-audit-2026-09-03/PropertyDropdownAlignment.md new file mode 100644 index 0000000000..6ea0a30041 --- /dev/null +++ b/docs/frontend-ui-audit-2026-09-03/PropertyDropdownAlignment.md @@ -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**. diff --git a/src/components/PropertyField/PropertyFieldEditable.test.ts b/src/components/PropertyField/PropertyFieldEditable.test.ts index 2de1af6e6c..812b547965 100644 --- a/src/components/PropertyField/PropertyFieldEditable.test.ts +++ b/src/components/PropertyField/PropertyFieldEditable.test.ts @@ -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", () => ({ @@ -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( + "[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"); + }); }); diff --git a/src/components/PropertyField/PropertyFieldEditable.tsx b/src/components/PropertyField/PropertyFieldEditable.tsx index aff94d2f51..0facc3ae6c 100644 --- a/src/components/PropertyField/PropertyFieldEditable.tsx +++ b/src/components/PropertyField/PropertyFieldEditable.tsx @@ -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, @@ -173,18 +174,28 @@ export const FieldRow: React.FC = ({ 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 { + 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; @@ -192,12 +203,17 @@ function useResolvedDropdownAlign(align: DropdownAlign) { 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 { @@ -213,7 +229,8 @@ export const Dropdown: React.FC = ({ align = "left", widthMode = "match-parent", }) => { - const { dropdownRef, resolvedAlign } = useResolvedDropdownAlign(align); + const { dropdownRef, resolvedAlign, isPositioned } = + useResolvedDropdownAlign(align); const positionClass = widthMode === "menu" ? resolvedAlign === "right" @@ -229,6 +246,7 @@ export const Dropdown: React.FC = ({ ref={dropdownRef} data-property-dropdown className={`absolute ${positionClass} top-full mt-1 flex flex-col ${widthClass} ${DROPDOWN_CLASSES.panelAnimated} ${className}`} + style={getPositionedOverlayVisibilityStyle(isPositioned)} > {children} @@ -265,7 +283,8 @@ export const SearchableDropdown: React.FC = ({ width?: number; } | null>(null); const anchorRef = useRef(null); - const { dropdownRef, resolvedAlign } = useResolvedDropdownAlign(align); + const { dropdownRef, resolvedAlign, isPositioned } = + useResolvedDropdownAlign(align); const positionClass = widthMode === "menu" ? resolvedAlign === "right" @@ -345,6 +364,7 @@ export const SearchableDropdown: React.FC = ({ 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, diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemProperties/DateQuickAssignDropdown.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemProperties/DateQuickAssignDropdown.tsx index 6358f59676..b5bfbc2424 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemProperties/DateQuickAssignDropdown.tsx +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemProperties/DateQuickAssignDropdown.tsx @@ -11,6 +11,7 @@ import { type FieldRowVariant, Option, SearchableDropdown, + getPropertyDropdownAlign, } from "@src/components/PropertyField/PropertyFieldEditable"; import type { DropdownEnginePosition } from "@src/hooks/dropdown"; import { @@ -152,7 +153,7 @@ export function DateQuickAssignDropdown({ {(query) => renderOptions({ searchQuery: query, value, onChange, t, emptyLabel }) diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemProperties/LabelsSection.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemProperties/LabelsSection.tsx index 4f86ce22dc..9cb57ada7d 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemProperties/LabelsSection.tsx +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemProperties/LabelsSection.tsx @@ -4,6 +4,7 @@ import { type FieldRowVariant, Option, SearchableDropdown, + getPropertyDropdownAlign, } from "@src/components/PropertyField/PropertyFieldEditable"; import { HugeiconsIcon, Tag01Icon } from "@src/icons"; import type { @@ -101,7 +102,7 @@ export function LabelsSection({ {(searchQuery) => { const filtered = searchQuery diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemProperties/PlanningSection.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemProperties/PlanningSection.tsx index bc0bcded0d..77cf303fa0 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemProperties/PlanningSection.tsx +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemProperties/PlanningSection.tsx @@ -8,6 +8,7 @@ import { type FieldRowVariant, Option, SearchableDropdown, + getPropertyDropdownAlign, } from "@src/components/PropertyField/PropertyFieldEditable"; import { Book02Icon, @@ -174,7 +175,7 @@ export function PlanningSection({ {(searchQuery) => { const filtered = searchQuery diff --git a/src/modules/ProjectManager/shared/components/PropertiesPanel/PropertyFieldSections/PeopleTeamsLabelsFields.tsx b/src/modules/ProjectManager/shared/components/PropertiesPanel/PropertyFieldSections/PeopleTeamsLabelsFields.tsx index f199bfa016..73a36f54f9 100644 --- a/src/modules/ProjectManager/shared/components/PropertiesPanel/PropertyFieldSections/PeopleTeamsLabelsFields.tsx +++ b/src/modules/ProjectManager/shared/components/PropertiesPanel/PropertyFieldSections/PeopleTeamsLabelsFields.tsx @@ -14,6 +14,7 @@ import { type FieldRowVariant, Option, SearchableDropdown, + getPropertyDropdownAlign, } from "@src/components/PropertyField/PropertyFieldEditable"; import { Airplane01Icon, @@ -113,7 +114,7 @@ const PeopleTeamsLabelsFields: React.FC = ({ {(searchQuery) => { const filtered = searchQuery @@ -195,7 +196,7 @@ const PeopleTeamsLabelsFields: React.FC = ({ {(searchQuery) => { const filtered = searchQuery @@ -264,7 +265,7 @@ const PeopleTeamsLabelsFields: React.FC = ({ {(searchQuery) => { const filtered = searchQuery @@ -338,7 +339,7 @@ const PeopleTeamsLabelsFields: React.FC = ({ {(searchQuery) => { const filtered = searchQuery @@ -401,7 +402,7 @@ const PeopleTeamsLabelsFields: React.FC = ({ {(searchQuery) => { const filtered = searchQuery diff --git a/src/modules/ProjectManager/shared/components/PropertiesPanel/PropertyFieldSections/StatusHealthPriorityFields.tsx b/src/modules/ProjectManager/shared/components/PropertiesPanel/PropertyFieldSections/StatusHealthPriorityFields.tsx index dee7d4561a..a1bf146c73 100644 --- a/src/modules/ProjectManager/shared/components/PropertiesPanel/PropertyFieldSections/StatusHealthPriorityFields.tsx +++ b/src/modules/ProjectManager/shared/components/PropertiesPanel/PropertyFieldSections/StatusHealthPriorityFields.tsx @@ -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"; @@ -87,7 +88,7 @@ const StatusHealthPriorityFields: React.FC = ({ {(searchQuery) => { const filtered = searchQuery @@ -152,7 +153,7 @@ const StatusHealthPriorityFields: React.FC = ({ {(searchQuery) => { const filtered = searchQuery @@ -217,7 +218,7 @@ const StatusHealthPriorityFields: React.FC = ({ {(searchQuery) => { const filtered = searchQuery From bb775b50d294926e5f2d90694d8ba7fcb0a0b7e7 Mon Sep 17 00:00:00 2001 From: Harry19081 <20519290+Harry19081@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:59:45 +0800 Subject: [PATCH 2/2] test(team-inbox): preserve git remote module exports --- .../MainApp/TeamInbox/__tests__/AssignedWorkItemDetail.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/modules/MainApp/TeamInbox/__tests__/AssignedWorkItemDetail.test.ts b/src/modules/MainApp/TeamInbox/__tests__/AssignedWorkItemDetail.test.ts index 7a1b2a8ce0..524ad3f005 100644 --- a/src/modules/MainApp/TeamInbox/__tests__/AssignedWorkItemDetail.test.ts +++ b/src/modules/MainApp/TeamInbox/__tests__/AssignedWorkItemDetail.test.ts @@ -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()), getGitRemotes: mocks.getGitRemotes, }));