mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
feat(mobile): native iOS assignee picker — search bar + pin selected + checkmark accessory
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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. */}
|
||||
<Stack.Screen
|
||||
name="issue/[id]/picker/assignee"
|
||||
options={SHEET_OPTIONS}
|
||||
options={{
|
||||
...SHEET_OPTIONS,
|
||||
headerShown: true,
|
||||
title: "Assignee",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="issue/[id]/picker/label"
|
||||
@@ -238,7 +249,11 @@ export default function WorkspaceLayout() {
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="new-issue-picker/assignee"
|
||||
options={SHEET_OPTIONS}
|
||||
options={{
|
||||
...SHEET_OPTIONS,
|
||||
headerShown: true,
|
||||
title: "Assignee",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="new-issue-picker/project"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* Assignee picker route for an existing issue. See ./status.tsx for the
|
||||
* self-contained-route rationale.
|
||||
* Assignee picker route for an existing issue. Uses the native iOS Stack
|
||||
* header + UISearchController (registered in ../_layout.tsx with
|
||||
* `headerShown: true` + title); the search bar wiring is encapsulated in
|
||||
* `useNativeSearchBar`.
|
||||
*/
|
||||
import { useLocalSearchParams, router } from "expo-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -8,12 +10,14 @@ import { AssigneePickerBody } from "@/components/issue/pickers/assignee-picker-b
|
||||
import { issueDetailOptions } from "@/data/queries/issues";
|
||||
import { useUpdateIssue } from "@/data/mutations/issues";
|
||||
import { useWorkspaceStore } from "@/data/workspace-store";
|
||||
import { useNativeSearchBar } from "@/lib/use-native-search-bar";
|
||||
|
||||
export default function IssueAssigneePickerRoute() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
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 (
|
||||
<AssigneePickerBody
|
||||
value={value}
|
||||
query={query}
|
||||
onChange={(next) => {
|
||||
if (next === null) {
|
||||
updateIssue.mutate({ assignee_type: null, assignee_id: null });
|
||||
|
||||
@@ -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 (
|
||||
<AssigneePickerBody
|
||||
value={assignee}
|
||||
query={query}
|
||||
onChange={(next) => {
|
||||
setAssignee(next);
|
||||
router.back();
|
||||
|
||||
@@ -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<Row[]>(() => {
|
||||
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 <View> hides the list
|
||||
// from the native search and the rows render at y=0, overlapping the header.
|
||||
return (
|
||||
<View className="flex-1">
|
||||
<View className="px-4 pt-3 pb-2">
|
||||
<Text className="text-lg font-semibold text-foreground">Assignee</Text>
|
||||
</View>
|
||||
<View className="px-4 pb-2 border-b border-border">
|
||||
<TextField
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
placeholder="Search people"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</View>
|
||||
<FlatList
|
||||
data={rows}
|
||||
className="flex-1"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
automaticallyAdjustKeyboardInsets
|
||||
keyExtractor={(row) => {
|
||||
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 }) => (
|
||||
<Pressable
|
||||
onPress={() => 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" ? (
|
||||
<View className="size-7 rounded-full border border-dashed border-muted-foreground/40 items-center justify-center">
|
||||
<Text className="text-xs text-muted-foreground">∅</Text>
|
||||
</View>
|
||||
) : item.kind === "member" ? (
|
||||
<ActorAvatar
|
||||
type="member"
|
||||
id={item.member.user_id}
|
||||
size={28}
|
||||
/>
|
||||
) : item.kind === "agent" ? (
|
||||
<ActorAvatar type="agent" id={item.agent.id} size={28} />
|
||||
) : (
|
||||
<ActorAvatar type="squad" id={item.squad.id} size={28} />
|
||||
)}
|
||||
<Text className="flex-1 text-sm text-foreground">
|
||||
{item.kind === "unassigned"
|
||||
? "Unassigned"
|
||||
: item.kind === "member"
|
||||
? item.member.name
|
||||
: item.kind === "agent"
|
||||
? item.agent.name
|
||||
: item.squad.name}
|
||||
</Text>
|
||||
{isSelected(item) ? (
|
||||
<Text className="text-xs text-muted-foreground">✓</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<View className="px-3 py-8 items-center">
|
||||
<Text className="text-xs text-muted-foreground">No matches.</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
data={rows}
|
||||
className="flex-1"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
automaticallyAdjustKeyboardInsets
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
keyExtractor={(row) => {
|
||||
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 }) => (
|
||||
<Pressable
|
||||
onPress={() => select(item)}
|
||||
className="flex-row items-center gap-3 px-4 py-3 active:bg-secondary"
|
||||
>
|
||||
{item.kind === "unassigned" ? (
|
||||
<View
|
||||
className="rounded-full border border-dashed border-muted-foreground/40 items-center justify-center"
|
||||
style={{ width: AVATAR_SIZE, height: AVATAR_SIZE }}
|
||||
>
|
||||
<Text className="text-sm text-muted-foreground">∅</Text>
|
||||
</View>
|
||||
) : item.kind === "member" ? (
|
||||
<ActorAvatar
|
||||
type="member"
|
||||
id={item.member.user_id}
|
||||
size={AVATAR_SIZE}
|
||||
/>
|
||||
) : item.kind === "agent" ? (
|
||||
<ActorAvatar type="agent" id={item.agent.id} size={AVATAR_SIZE} />
|
||||
) : (
|
||||
<ActorAvatar type="squad" id={item.squad.id} size={AVATAR_SIZE} />
|
||||
)}
|
||||
<Text className="flex-1 text-base text-foreground">
|
||||
{item.kind === "unassigned"
|
||||
? "Unassigned"
|
||||
: item.kind === "member"
|
||||
? item.member.name
|
||||
: item.kind === "agent"
|
||||
? item.agent.name
|
||||
: item.squad.name}
|
||||
</Text>
|
||||
{/* 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" ? (
|
||||
<Text className="text-sm text-muted-foreground">Agent</Text>
|
||||
) : item.kind === "squad" ? (
|
||||
<Text className="text-sm text-muted-foreground">Squad</Text>
|
||||
) : null}
|
||||
{isSelected(item) ? (
|
||||
<Ionicons name="checkmark" size={20} color={checkColor} />
|
||||
) : null}
|
||||
</Pressable>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<View className="px-3 py-8 items-center">
|
||||
<Text className="text-sm text-muted-foreground">No matches.</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
51
apps/mobile/lib/use-native-search-bar.ts
Normal file
51
apps/mobile/lib/use-native-search-bar.ts
Normal file
@@ -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<TextInputFocusEventData>) =>
|
||||
setQuery(e.nativeEvent.text),
|
||||
onCancelButtonPress: () => setQuery(""),
|
||||
},
|
||||
});
|
||||
}, [navigation, placeholder, autoFocus]);
|
||||
|
||||
return query;
|
||||
}
|
||||
27
apps/mobile/lib/use-scroll-to-top-on-change.ts
Normal file
27
apps/mobile/lib/use-scroll-to-top-on-change.ts
Normal file
@@ -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<T>(value: T) {
|
||||
const ref = useRef<FlatList<any>>(null);
|
||||
|
||||
useEffect(() => {
|
||||
ref.current?.scrollToOffset({ offset: 0, animated: false });
|
||||
}, [value]);
|
||||
|
||||
return ref;
|
||||
}
|
||||
Reference in New Issue
Block a user