From c19bf6612aa2d0dda9a70062bf8b91bac2f93680 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Wed, 20 May 2026 19:13:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(mobile):=20native=20iOS=20assignee=20picke?= =?UTF-8?q?r=20=E2=80=94=20search=20bar=20+=20pin=20selected=20+=20checkma?= =?UTF-8?q?rk=20accessory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Switch assignee picker (issue + new-issue) from body-rendered header to native Stack header + UISearchController via headerSearchBarOptions. - Body becomes pure FlatList — fixes react-native-screens#3634 overlap (FlatList now route's direct child, no intermediate wrapper view). - Pin currently-selected actor + Unassigned to the top when no query; search results stay in member → agent → squad order. - Inline right-aligned "Agent" / "Squad" tag mirrors Apple's Value-1 cell style (UIListContentConfiguration.valueCell) used throughout Settings. - Selection indicator: Ionicons checkmark in primary tint only, no row bg highlight (Apple HIG: never use selection to indicate state). - Avatar 28pt → 36pt. - autoFocus on search bar for search-first pickers — keyboard appears on mount, opt-in via hook option. - Extract useNativeSearchBar + useScrollToTopOnChange hooks under apps/mobile/lib/ for phase-2 rollout to label / project / lead pickers. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/mobile/app/(app)/[workspace]/_layout.tsx | 19 +- .../issue/[id]/picker/assignee.tsx | 9 +- .../[workspace]/new-issue-picker/assignee.tsx | 6 + .../issue/pickers/assignee-picker-body.tsx | 202 ++++++++++-------- apps/mobile/lib/use-native-search-bar.ts | 51 +++++ .../mobile/lib/use-scroll-to-top-on-change.ts | 27 +++ 6 files changed, 224 insertions(+), 90 deletions(-) create mode 100644 apps/mobile/lib/use-native-search-bar.ts create mode 100644 apps/mobile/lib/use-scroll-to-top-on-change.ts diff --git a/apps/mobile/app/(app)/[workspace]/_layout.tsx b/apps/mobile/app/(app)/[workspace]/_layout.tsx index a1794b0c9..f2a3a543e 100644 --- a/apps/mobile/app/(app)/[workspace]/_layout.tsx +++ b/apps/mobile/app/(app)/[workspace]/_layout.tsx @@ -182,9 +182,20 @@ export default function WorkspaceLayout() { name="issue/[id]/picker/priority" options={SHEET_OPTIONS} /> + {/* Experiment: assignee uses iOS-native nav header + UISearchController + instead of the body-rendered header pattern in SHEET_OPTIONS. + Eliminates the #3634 overlap class of bugs and the focus-loss + footgun of a custom TextInput inside ListHeaderComponent. The + route file wires `headerSearchBarOptions` via setOptions. If this + proves out, propagate to label / project / other search pickers + and update CLAUDE.md Lesson 6 with a carve-out. */} (); const wsId = useWorkspaceStore((s) => s.currentWorkspaceId); const { data: issue } = useQuery(issueDetailOptions(wsId, id)); const updateIssue = useUpdateIssue(id); + const query = useNativeSearchBar("Search people", { autoFocus: true }); const value = issue?.assignee_type && issue?.assignee_id @@ -23,6 +27,7 @@ export default function IssueAssigneePickerRoute() { return ( { if (next === null) { updateIssue.mutate({ assignee_type: null, assignee_id: null }); diff --git a/apps/mobile/app/(app)/[workspace]/new-issue-picker/assignee.tsx b/apps/mobile/app/(app)/[workspace]/new-issue-picker/assignee.tsx index 6ac43ce18..2684e8abd 100644 --- a/apps/mobile/app/(app)/[workspace]/new-issue-picker/assignee.tsx +++ b/apps/mobile/app/(app)/[workspace]/new-issue-picker/assignee.tsx @@ -1,17 +1,23 @@ /** * Assignee picker route for the in-progress new-issue draft. See ./status.tsx. + * Uses the same iOS-native nav header + UISearchController pattern as + * `issue/[id]/picker/assignee.tsx`, with the search bar wiring encapsulated + * in `useNativeSearchBar`. */ import { router } from "expo-router"; import { AssigneePickerBody } from "@/components/issue/pickers/assignee-picker-body"; import { useNewIssueDraftStore } from "@/data/stores/new-issue-draft-store"; +import { useNativeSearchBar } from "@/lib/use-native-search-bar"; export default function NewIssueAssigneePickerRoute() { const assignee = useNewIssueDraftStore((s) => s.assignee); const setAssignee = useNewIssueDraftStore((s) => s.setAssignee); + const query = useNativeSearchBar("Search people", { autoFocus: true }); return ( { setAssignee(next); router.back(); diff --git a/apps/mobile/components/issue/pickers/assignee-picker-body.tsx b/apps/mobile/components/issue/pickers/assignee-picker-body.tsx index 25526ef96..c327ca8c4 100644 --- a/apps/mobile/components/issue/pickers/assignee-picker-body.tsx +++ b/apps/mobile/components/issue/pickers/assignee-picker-body.tsx @@ -5,10 +5,18 @@ * * Mirrors web `packages/views/issues/components/pickers/assignee-picker.tsx` * (mobile skips frequency-sort; alphabetical instead). + * + * Header + search bar are owned by the iOS native nav header registered in + * `app/(app)/[workspace]/_layout.tsx` (assignee Stack.Screen sets + * `headerShown: true` + `title`); the route file wires + * `headerSearchBarOptions.onChangeText` to a local `query` state and passes + * it in as the `query` prop. This body is just a FlatList — no chrome. */ -import { useMemo, useState } from "react"; +import { useMemo } from "react"; import { FlatList, Pressable, View } from "react-native"; import { useQuery } from "@tanstack/react-query"; +import { Ionicons } from "@expo/vector-icons"; +import { useColorScheme } from "nativewind"; import type { Agent, IssueAssigneeType, @@ -17,12 +25,14 @@ import type { } from "@multica/core/types"; import { Text } from "@/components/ui/text"; import { ActorAvatar } from "@/components/ui/actor-avatar"; -import { TextField } from "@/components/ui/text-field"; import { memberListOptions } from "@/data/queries/members"; import { agentListOptions } from "@/data/queries/agents"; import { squadListOptions } from "@/data/queries/squads"; import { useWorkspaceStore } from "@/data/workspace-store"; -import { cn } from "@/lib/utils"; +import { useScrollToTopOnChange } from "@/lib/use-scroll-to-top-on-change"; +import { THEME } from "@/lib/theme"; + +const AVATAR_SIZE = 36; export type AssigneeValue = { type: IssueAssigneeType; @@ -31,6 +41,7 @@ export type AssigneeValue = { interface Props { value: AssigneeValue; + query: string; onChange: (next: AssigneeValue) => void; } @@ -40,12 +51,29 @@ type Row = | { kind: "agent"; agent: Agent } | { kind: "squad"; squad: Squad }; -export function AssigneePickerBody({ value, onChange }: Props) { +function isRowSelected(value: AssigneeValue, row: Row): boolean { + if (row.kind === "unassigned") return value === null; + if (value === null) return false; + if (row.kind === "member") + return value.type === "member" && value.id === row.member.user_id; + if (row.kind === "agent") + return value.type === "agent" && value.id === row.agent.id; + return value.type === "squad" && value.id === row.squad.id; +} + +export function AssigneePickerBody({ value, query, onChange }: Props) { const wsId = useWorkspaceStore((s) => s.currentWorkspaceId); const { data: members = [] } = useQuery(memberListOptions(wsId)); const { data: agents = [] } = useQuery(agentListOptions(wsId)); const { data: squads = [] } = useQuery(squadListOptions(wsId)); - const [query, setQuery] = useState(""); + const listRef = useScrollToTopOnChange(query); + const { colorScheme } = useColorScheme(); + // Tint color for the checkmark accessory. Project uses a monochrome + // shadcn palette where `primary` is the canonical tint (near-black light / + // near-white dark); matches Apple HIG's "tintColor" semantics for + // selection accessories. + const checkColor = + colorScheme === "dark" ? THEME.dark.primary : THEME.light.primary; const rows = useMemo(() => { const q = query.trim().toLowerCase(); @@ -65,23 +93,24 @@ export function AssigneePickerBody({ value, onChange }: Props) { .map((s) => ({ kind: "squad" as const, squad: s })); if (q) return [...memberRows, ...agentRows, ...squadRows]; + + // Pin the currently-selected actor right below Unassigned and remove it + // from its own section so it doesn't render twice. Apple HIG doesn't + // require this — it's a product UX choice that speeds up the common + // "see who's assigned + reassign nearby" path. Skipped when query is + // active because search-result order should reflect matches, not state. + const all = [...memberRows, ...agentRows, ...squadRows]; + const selectedRow = all.find((r) => isRowSelected(value, r)); return [ { kind: "unassigned" }, - ...memberRows, - ...agentRows, - ...squadRows, + ...(selectedRow ? [selectedRow] : []), + ...memberRows.filter((r) => !isRowSelected(value, r)), + ...agentRows.filter((r) => !isRowSelected(value, r)), + ...squadRows.filter((r) => !isRowSelected(value, r)), ]; - }, [members, agents, squads, query]); + }, [members, agents, squads, query, value]); - const isSelected = (row: Row): boolean => { - if (row.kind === "unassigned") return value === null; - if (value === null) return false; - if (row.kind === "member") - return value.type === "member" && value.id === row.member.user_id; - if (row.kind === "agent") - return value.type === "agent" && value.id === row.agent.id; - return value.type === "squad" && value.id === row.squad.id; - }; + const isSelected = (row: Row) => isRowSelected(value, row); const select = (row: Row) => { if (row.kind === "unassigned") onChange(null); @@ -92,74 +121,75 @@ export function AssigneePickerBody({ value, onChange }: Props) { else onChange({ type: "squad", id: row.squad.id }); }; + // FlatList is returned as the route's direct child so RNSScreenContentWrapper + // can find it as a direct subview and apply the iOS formSheet header offset. + // See react-native-screens#3634 — wrapping in a parent hides the list + // from the native search and the rows render at y=0, overlapping the header. return ( - - - Assignee - - - - - { - if (row.kind === "unassigned") return "unassigned"; - if (row.kind === "member") return `m:${row.member.user_id}`; - if (row.kind === "agent") return `a:${row.agent.id}`; - return `s:${row.squad.id}`; - }} - renderItem={({ item }) => ( - select(item)} - className={cn( - "flex-row items-center gap-3 px-3 py-2.5 active:bg-secondary", - isSelected(item) && "bg-secondary", - )} - > - {item.kind === "unassigned" ? ( - - - - ) : item.kind === "member" ? ( - - ) : item.kind === "agent" ? ( - - ) : ( - - )} - - {item.kind === "unassigned" - ? "Unassigned" - : item.kind === "member" - ? item.member.name - : item.kind === "agent" - ? item.agent.name - : item.squad.name} - - {isSelected(item) ? ( - - ) : null} - - )} - ListEmptyComponent={ - - No matches. - - } - /> - + { + if (row.kind === "unassigned") return "unassigned"; + if (row.kind === "member") return `m:${row.member.user_id}`; + if (row.kind === "agent") return `a:${row.agent.id}`; + return `s:${row.squad.id}`; + }} + renderItem={({ item }) => ( + select(item)} + className="flex-row items-center gap-3 px-4 py-3 active:bg-secondary" + > + {item.kind === "unassigned" ? ( + + + + ) : item.kind === "member" ? ( + + ) : item.kind === "agent" ? ( + + ) : ( + + )} + + {item.kind === "unassigned" + ? "Unassigned" + : item.kind === "member" + ? item.member.name + : item.kind === "agent" + ? item.agent.name + : item.squad.name} + + {/* Right-aligned secondary label. Mirrors Apple's + UITableViewCellStyleValue1 / UIListContentConfiguration.valueCell + pattern used throughout iOS Settings — type tag in lighter font on + the same row. Members carry no tag (they're the default actor). */} + {item.kind === "agent" ? ( + Agent + ) : item.kind === "squad" ? ( + Squad + ) : null} + {isSelected(item) ? ( + + ) : null} + + )} + ListEmptyComponent={ + + No matches. + + } + /> ); } diff --git a/apps/mobile/lib/use-native-search-bar.ts b/apps/mobile/lib/use-native-search-bar.ts new file mode 100644 index 000000000..5b6d109cd --- /dev/null +++ b/apps/mobile/lib/use-native-search-bar.ts @@ -0,0 +1,51 @@ +/** + * Hook for wiring an iOS native `UISearchController` (via react-native-screens + * `headerSearchBarOptions`) into a route. Returns the current query string. + * + * Used by every search-enabled picker route on mobile (issue/project/label/ + * lead). Pair with `useScrollToTopOnChange` in the body to reset the list + * scroll position when the filter changes. + * + * Why this exists rather than inlining `setOptions` in every route: + * - Cancel button contract: `cancelSearch()` clears the native text but + * does NOT fire `onChangeText`, so the route MUST reset query state in + * `onCancelButtonPress`. Easy to forget when copy-pasted. + * - Sensible defaults (autoCapitalize: "none", hideWhenScrolling: false) + * match the standard iOS picker pattern; one place to revise. + * + * Requires the Stack.Screen to register `headerShown: true` + a `title` in + * the layout. See `apps/mobile/app/(app)/[workspace]/_layout.tsx` for the + * pattern. + */ +import { useLayoutEffect, useState } from "react"; +import { useNavigation } from "expo-router"; +import type { NativeSyntheticEvent, TextInputFocusEventData } from "react-native"; + +export function useNativeSearchBar( + placeholder: string, + options?: { autoFocus?: boolean }, +): string { + const navigation = useNavigation(); + const [query, setQuery] = useState(""); + const autoFocus = options?.autoFocus; + + useLayoutEffect(() => { + navigation.setOptions({ + headerSearchBarOptions: { + placeholder, + autoCapitalize: "none", + hideWhenScrolling: false, + // Opt-in: pickers whose primary action is typing (assignee, label, + // project, lead) set this so the keyboard appears on mount. Apple + // HIG cautions against auto-keyboard for browse-first lists; pass + // `autoFocus: true` only when the picker is search-first. + autoFocus, + onChangeText: (e: NativeSyntheticEvent) => + setQuery(e.nativeEvent.text), + onCancelButtonPress: () => setQuery(""), + }, + }); + }, [navigation, placeholder, autoFocus]); + + return query; +} diff --git a/apps/mobile/lib/use-scroll-to-top-on-change.ts b/apps/mobile/lib/use-scroll-to-top-on-change.ts new file mode 100644 index 000000000..14ebea0ef --- /dev/null +++ b/apps/mobile/lib/use-scroll-to-top-on-change.ts @@ -0,0 +1,27 @@ +/** + * Hook that returns a FlatList ref and scrolls the list to offset 0 whenever + * the watched value changes. + * + * Used by every search-enabled picker body to reset scroll when the filter + * query changes. UISearchController does NOT do this for us — system iOS + * apps work because they swap in a separate `searchResultsController`; the + * RN pattern reuses the same FlatList for browse and filter, so without + * this reset the filtered list sits below the previously-scrolled viewport + * and looks blank. + * + * `animated: false` is deliberate: `animated: true` produces competing + * scroll tweens during fast typing (RN does not cancel in-flight scroll + * animations before starting a new one). + */ +import { useEffect, useRef } from "react"; +import type { FlatList } from "react-native"; + +export function useScrollToTopOnChange(value: T) { + const ref = useRef>(null); + + useEffect(() => { + ref.current?.scrollToOffset({ offset: 0, animated: false }); + }, [value]); + + return ref; +}