mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 21:07:40 +01:00
fix(ui): use large mobile selector modals (#14250)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The new task dialog lets an operator assign work on a phone. > - The mobile picker used fixed coordinates inside the Radix wrapper. > - iOS Safari already makes fixed coordinates relative to the visual viewport. > - The old code added the visual viewport offset a second time. > - This pull request removes the second offset and gives the mobile wrapper full viewport geometry. > - The benefit is a visible picker and search field when the iOS keyboard opens. ## Linked Issues or Issue Description **What happened?** The assignee and project pickers could move outside the visible screen in the new task dialog on iOS. **What did you expect to happen?** The picker and its search field must stay visible above the bottom edge and the software keyboard. **Steps to reproduce** Open the new task dialog on an iPhone. Select Assignee or Project. The picker can move outside the visual viewport when Safari pans the page. **Paperclip version or commit** The problem exists after the change in PR #13343. **Deployment mode** The problem affects the board UI in local and authenticated modes. Refs #13343 ## What Changed - Use visual viewport local coordinates for the new task dialog. - Give the mobile Radix popper wrapper full viewport geometry. - Render both shared selector components as large, titled mobile modals. - Keep desktop selectors as anchored popovers. - Cover the new-task assignee, new-task project, composer assignee, and generic searchable selector in Storybook. - Add iPhone-sized Storybook states for the assignee and project pickers. - Update viewport tests for the corrected coordinate model. ## Verification - `pnpm --filter @paperclipai/ui exec vitest run src/components/NewIssueDialog.test.tsx src/components/InlineEntitySelector.test.tsx` - `pnpm check:token-gates` - Playwright used the iPhone 14 device profile. All mobile modals were inside the 390 by 664 CSS viewport. - Each mobile modal was `x=16, y=16, width=358, height=632`. - A desktop check kept the anchored popover at `width=320, height=211`. ## Risks - Low risk. The CSS only changes narrow mobile viewports. - The full-screen popper wrapper does not receive pointer events. The picker still receives pointer events. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. See `CONTRIBUTING.md`. ## Model Used - OpenAI GPT-5.6, Codex agent with tool use and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change and contains no internal task ID - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant Storybook documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
74508357fa
commit
d423fc4411
10 files changed
+226
-15
No files matched your search
@@ -91,7 +91,7 @@ describe("InlineEntitySelector", () => {
|
||||
|
||||
it("focuses the search input when opened on coarse pointers", async () => {
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
matches: query === "(pointer: coarse)",
|
||||
matches: query === "(pointer: coarse)" || query === "(max-width: 40rem)",
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
@@ -116,6 +116,7 @@ describe("InlineEntitySelector", () => {
|
||||
searchPlaceholder="Search responsible..."
|
||||
emptyMessage="No responsible found."
|
||||
onChange={vi.fn()}
|
||||
disablePortal
|
||||
/>,
|
||||
);
|
||||
});
|
||||
@@ -131,8 +132,17 @@ describe("InlineEntitySelector", () => {
|
||||
expect(searchInput).not.toBeNull();
|
||||
expect(searchInput?.className).toContain("text-base");
|
||||
expect(document.querySelector("[data-mobile-entity-picker]")).not.toBeNull();
|
||||
expect(container.contains(document.querySelector("[data-mobile-entity-picker]"))).toBe(false);
|
||||
expect(document.querySelector("[data-mobile-entity-picker-header]")?.textContent).toContain("Responsible");
|
||||
expect(document.querySelector('button[aria-label="Close selector"]')).not.toBeNull();
|
||||
expect(document.activeElement).toBe(searchInput);
|
||||
|
||||
await act(async () => {
|
||||
(document.querySelector('button[aria-label="Close selector"]') as HTMLButtonElement | null)?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(document.querySelector("[data-mobile-entity-picker]")).toBeNull();
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { Check } from "lucide-react";
|
||||
import { Check, X } from "lucide-react";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { orderItemsBySelectedAndRecent } from "../lib/recent-selections";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useMobileEntityPickerViewportStyle } from "../hooks/useMobileEntityPickerViewportStyle";
|
||||
|
||||
export interface InlineEntityOption {
|
||||
id: string;
|
||||
@@ -35,10 +36,31 @@ interface InlineEntitySelectorProps {
|
||||
triggerDataSlot?: string;
|
||||
/** Runtime geometry variables for the portalled mobile picker sheet. */
|
||||
contentStyle?: CSSProperties;
|
||||
/** Heading for the large mobile selector modal. Defaults to the placeholder. */
|
||||
mobileTitle?: string;
|
||||
}
|
||||
|
||||
const EMPTY_RECENT_OPTION_IDS: string[] = [];
|
||||
|
||||
function useMobileSelectorModal() {
|
||||
const [mobile, setMobile] = useState(() =>
|
||||
typeof window !== "undefined"
|
||||
&& typeof window.matchMedia === "function"
|
||||
&& window.matchMedia("(max-width: 40rem)").matches,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window.matchMedia !== "function") return;
|
||||
const media = window.matchMedia("(max-width: 40rem)");
|
||||
const update = () => setMobile(media.matches);
|
||||
update();
|
||||
media.addEventListener("change", update);
|
||||
return () => media.removeEventListener("change", update);
|
||||
}, []);
|
||||
|
||||
return mobile;
|
||||
}
|
||||
|
||||
export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySelectorProps>(
|
||||
function InlineEntitySelector(
|
||||
{
|
||||
@@ -60,12 +82,15 @@ export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySe
|
||||
triggerTestId,
|
||||
triggerDataSlot,
|
||||
contentStyle,
|
||||
mobileTitle,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
||||
const mobileSelectorModal = useMobileSelectorModal();
|
||||
const mobileViewportStyle = useMobileEntityPickerViewportStyle();
|
||||
const highlightedIndexRef = useRef(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const shouldPreventCloseAutoFocusRef = useRef(false);
|
||||
@@ -146,12 +171,13 @@ export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySe
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
data-mobile-entity-picker=""
|
||||
aria-label={mobileTitle ?? placeholder}
|
||||
align="start"
|
||||
side="bottom"
|
||||
collisionPadding={16}
|
||||
className="w-(--sz-calc-6) p-1"
|
||||
disablePortal={disablePortal}
|
||||
style={contentStyle}
|
||||
disablePortal={disablePortal && !mobileSelectorModal}
|
||||
style={{ ...mobileViewportStyle, ...contentStyle }}
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
@@ -162,6 +188,20 @@ export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySe
|
||||
shouldPreventCloseAutoFocusRef.current = false;
|
||||
}}
|
||||
>
|
||||
<div data-mobile-entity-picker-header="" className="hidden items-center justify-between border-b border-border px-4 py-3">
|
||||
<span className="text-base font-semibold text-foreground">{mobileTitle ?? placeholder}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex size-9 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
aria-label="Close selector"
|
||||
onClick={() => {
|
||||
shouldPreventCloseAutoFocusRef.current = true;
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="w-full border-b border-border bg-transparent px-2 py-1.5 text-base outline-none placeholder:text-muted-foreground/60 md:text-sm"
|
||||
|
||||
@@ -1513,6 +1513,7 @@ export function NewIssueDialog() {
|
||||
options={assigneeOptions}
|
||||
recentOptionIds={recentAssigneeOptionIds}
|
||||
placeholder="Assignee"
|
||||
mobileTitle="Select assignee"
|
||||
className="h-8 px-2.5 py-0 sm:h-auto sm:px-2 sm:py-1"
|
||||
triggerDataSlot="new-issue-compact-control"
|
||||
contentStyle={entityPickerViewportStyle}
|
||||
@@ -1574,6 +1575,7 @@ export function NewIssueDialog() {
|
||||
options={projectOptions}
|
||||
recentOptionIds={recentProjectIds}
|
||||
placeholder="Project"
|
||||
mobileTitle="Select project"
|
||||
className="h-8 px-2.5 py-0 sm:h-auto sm:px-2 sm:py-1"
|
||||
triggerDataSlot="new-issue-compact-control"
|
||||
contentStyle={entityPickerViewportStyle}
|
||||
|
||||
@@ -131,6 +131,8 @@ describe("SearchableSelect", () => {
|
||||
expect(container.querySelector("[data-option-key='recent:alpha']")).not.toBeNull();
|
||||
expect(container.querySelector("[data-option-key='all:alpha']")).not.toBeNull();
|
||||
expect(container.querySelector("[data-mobile-entity-picker]")).not.toBeNull();
|
||||
expect(container.querySelector("[data-mobile-entity-picker-header]")?.textContent).toContain("Pick one");
|
||||
expect(container.querySelector('button[aria-label="Close selector"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("filters options and returns the selected option object", async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { Check, ChevronsUpDown, X } from "lucide-react";
|
||||
import { useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { fuzzyTextMatchesQuery, normalizeSearchText, scoreFuzzyTextFields } from "@/lib/searchable-select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useMobileEntityPickerViewportStyle } from "@/hooks/useMobileEntityPickerViewportStyle";
|
||||
|
||||
export interface SearchableSelectOption<TValue extends string = string> {
|
||||
key: string;
|
||||
@@ -59,6 +60,8 @@ export interface SearchableSelectProps<
|
||||
filterOption?: (option: TOption, query: string) => boolean;
|
||||
scoreOption?: (option: TOption, query: string) => number | null;
|
||||
disablePortal?: boolean;
|
||||
/** Heading for the large mobile selector modal. Defaults to the placeholder. */
|
||||
mobileTitle?: string;
|
||||
/**
|
||||
* Optional pinned "creatable" item rendered at the bottom of the list,
|
||||
* regardless of the query (used e.g. by the secret picker's
|
||||
@@ -107,10 +110,12 @@ export function SearchableSelect<
|
||||
filterOption = defaultFilterOption,
|
||||
scoreOption,
|
||||
disablePortal,
|
||||
mobileTitle,
|
||||
createItem,
|
||||
}: SearchableSelectProps<TValue, TOption>) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const mobileViewportStyle = useMobileEntityPickerViewportStyle();
|
||||
const pointerFocusRef = useRef(false);
|
||||
const suppressNextTriggerFocusRef = useRef(false);
|
||||
|
||||
@@ -226,6 +231,8 @@ export function SearchableSelect<
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
data-mobile-entity-picker=""
|
||||
aria-label={mobileTitle ?? placeholder}
|
||||
style={mobileViewportStyle}
|
||||
align={align}
|
||||
collisionPadding={16}
|
||||
disablePortal={disablePortal}
|
||||
@@ -244,6 +251,17 @@ export function SearchableSelect<
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div data-mobile-entity-picker-header="" className="hidden items-center justify-between border-b border-border px-4 py-3">
|
||||
<span className="text-base font-semibold text-foreground">{mobileTitle ?? placeholder}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex size-9 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
aria-label="Close selector"
|
||||
onClick={() => closePopover({ suppressTriggerFocus: true })}
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
value={query}
|
||||
|
||||
@@ -1509,6 +1509,7 @@ export function TaskChatComposer({
|
||||
value={assigneeValue}
|
||||
options={reassignOptions ?? []}
|
||||
placeholder="Assignee"
|
||||
mobileTitle="Select assignee"
|
||||
noneLabel="No assignee"
|
||||
searchPlaceholder="Search assignees…"
|
||||
emptyMessage="No matches."
|
||||
|
||||
Reference in new issue
Block a user