mobile: let users dismiss the keyboard from a task card
Focusing a task field opened the keyboard with no way out — it covered the card and the stream's tap-to-advance zones. Now: - A "Done" pill appears at the card's top-right while editing (reusing the existing `editing` flag) and calls Keyboard.dismiss(); the title row reserves space so the pill never overlaps a long title. - The card ScrollView gains keyboardDismissMode (interactive on iOS, on-drag on Android) so dragging the card also dismisses the keyboard. Dismissing blurs the active field, which flips `editing` off and resumes the dwell timer and tap navigation automatically. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV
This commit is contained in:
@@ -1,5 +1,13 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Pressable, ScrollView, Text, TextInput, View } from 'react-native';
|
||||
import {
|
||||
Keyboard,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { deleteField } from 'firebase/firestore';
|
||||
import { Check, Plus, X } from 'lucide-react-native';
|
||||
import type { ChecklistItem, Particle } from '@/api/types';
|
||||
@@ -158,119 +166,146 @@ export function TaskParticleView({
|
||||
className="flex-1 items-center justify-center px-6"
|
||||
style={{ paddingTop: safe.top + 16, paddingBottom: safe.bottom + 16 }}
|
||||
>
|
||||
<ScrollView
|
||||
className="max-h-full w-full max-w-xl rounded-2xl bg-white/10"
|
||||
contentContainerClassName="px-5 py-5 gap-5"
|
||||
showsVerticalScrollIndicator
|
||||
indicatorStyle="white"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* Title + done */}
|
||||
<View className="flex-row items-start gap-3">
|
||||
<Pressable
|
||||
onPress={handleToggleDone}
|
||||
hitSlop={8}
|
||||
accessibilityLabel={done ? 'Mark not done' : 'Mark done'}
|
||||
className={cn(
|
||||
'mt-1 h-6 w-6 items-center justify-center rounded-full border',
|
||||
done ? 'bg-emerald-500 border-emerald-500' : 'border-white/40',
|
||||
)}
|
||||
<View className="w-full max-w-xl" style={{ maxHeight: '100%' }}>
|
||||
<ScrollView
|
||||
className="max-h-full rounded-2xl bg-white/10"
|
||||
contentContainerClassName="px-5 py-5 gap-5"
|
||||
showsVerticalScrollIndicator
|
||||
indicatorStyle="white"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
// Dragging the card dismisses the keyboard (interactive follow on
|
||||
// iOS; on-drag on Android, which lacks the interactive variant).
|
||||
keyboardDismissMode={
|
||||
Platform.OS === 'ios' ? 'interactive' : 'on-drag'
|
||||
}
|
||||
>
|
||||
{/* Title + done */}
|
||||
<View
|
||||
className={cn('flex-row items-start gap-3', editing && 'pr-12')}
|
||||
>
|
||||
{done ? <Check color="white" size={14} strokeWidth={3} /> : null}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={handleToggleDone}
|
||||
hitSlop={8}
|
||||
accessibilityLabel={done ? 'Mark not done' : 'Mark done'}
|
||||
className={cn(
|
||||
'mt-1 h-6 w-6 items-center justify-center rounded-full border',
|
||||
done ? 'bg-emerald-500 border-emerald-500' : 'border-white/40',
|
||||
)}
|
||||
>
|
||||
{done ? <Check color="white" size={14} strokeWidth={3} /> : null}
|
||||
</Pressable>
|
||||
<TextInput
|
||||
defaultValue={title}
|
||||
key={`title-${particle.id}`}
|
||||
onFocus={() => setEditing(true)}
|
||||
onBlur={() => setEditing(false)}
|
||||
onEndEditing={(e) => {
|
||||
const value = e.nativeEvent.text;
|
||||
if (value !== title) {
|
||||
void updateParticleProperties<'task'>(docPath, {
|
||||
title: value,
|
||||
});
|
||||
}
|
||||
}}
|
||||
placeholder="Task title"
|
||||
placeholderTextColor="rgba(255,255,255,0.3)"
|
||||
multiline
|
||||
className={cn(
|
||||
'flex-1 text-white text-2xl font-semibold',
|
||||
done && 'text-white/50 line-through',
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Notes */}
|
||||
<TextInput
|
||||
defaultValue={title}
|
||||
key={`title-${particle.id}`}
|
||||
defaultValue={notes ?? ''}
|
||||
key={`notes-${particle.id}`}
|
||||
onFocus={() => setEditing(true)}
|
||||
onBlur={() => setEditing(false)}
|
||||
onEndEditing={(e) => {
|
||||
const value = e.nativeEvent.text;
|
||||
if (value !== title) {
|
||||
if (value !== (notes ?? '')) {
|
||||
void updateParticleProperties<'task'>(docPath, {
|
||||
title: value,
|
||||
notes: value,
|
||||
});
|
||||
}
|
||||
}}
|
||||
placeholder="Task title"
|
||||
placeholder="Add notes…"
|
||||
placeholderTextColor="rgba(255,255,255,0.3)"
|
||||
multiline
|
||||
className={cn(
|
||||
'flex-1 text-white text-2xl font-semibold',
|
||||
done && 'text-white/50 line-through',
|
||||
)}
|
||||
className="text-white/80 text-base"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Notes */}
|
||||
<TextInput
|
||||
defaultValue={notes ?? ''}
|
||||
key={`notes-${particle.id}`}
|
||||
onFocus={() => setEditing(true)}
|
||||
onBlur={() => setEditing(false)}
|
||||
onEndEditing={(e) => {
|
||||
const value = e.nativeEvent.text;
|
||||
if (value !== (notes ?? '')) {
|
||||
void updateParticleProperties<'task'>(docPath, { notes: value });
|
||||
}
|
||||
}}
|
||||
placeholder="Add notes…"
|
||||
placeholderTextColor="rgba(255,255,255,0.3)"
|
||||
multiline
|
||||
className="text-white/80 text-base"
|
||||
/>
|
||||
|
||||
{/* Checklist */}
|
||||
<View className="gap-2">
|
||||
{checklist.length > 0 ? (
|
||||
<Text className="text-white/40 text-xs">
|
||||
{doneCount} / {checklist.length} done
|
||||
</Text>
|
||||
) : null}
|
||||
{checklist.map((item, index) => (
|
||||
<ChecklistItemRow
|
||||
key={`${particle.id}-item-${index}`}
|
||||
item={item}
|
||||
onToggle={() => handleToggleItem(index)}
|
||||
onCommitText={(text) => handleCommitItemText(index, text)}
|
||||
onRemove={() => handleRemoveItem(index)}
|
||||
{/* Checklist */}
|
||||
<View className="gap-2">
|
||||
{checklist.length > 0 ? (
|
||||
<Text className="text-white/40 text-xs">
|
||||
{doneCount} / {checklist.length} done
|
||||
</Text>
|
||||
) : null}
|
||||
{checklist.map((item, index) => (
|
||||
<ChecklistItemRow
|
||||
key={`${particle.id}-item-${index}`}
|
||||
item={item}
|
||||
onToggle={() => handleToggleItem(index)}
|
||||
onCommitText={(text) => handleCommitItemText(index, text)}
|
||||
onRemove={() => handleRemoveItem(index)}
|
||||
onFocusChange={setEditing}
|
||||
/>
|
||||
))}
|
||||
<AddChecklistItemRow
|
||||
onAdd={handleAddItem}
|
||||
onFocusChange={setEditing}
|
||||
/>
|
||||
))}
|
||||
<AddChecklistItemRow
|
||||
onAdd={handleAddItem}
|
||||
onFocusChange={setEditing}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Assignee */}
|
||||
<View className="gap-2">
|
||||
<Text className="text-white/40 text-xs">Assignee</Text>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerClassName="gap-2"
|
||||
>
|
||||
<AssigneeChip
|
||||
label="Unassigned"
|
||||
selected={!assigned_to}
|
||||
onPress={() => handleAssign(null)}
|
||||
/>
|
||||
{network?.humans?.map((human) => {
|
||||
const display = resolveHumanDisplay(human.id, network?.humans);
|
||||
return (
|
||||
<AssigneeChip
|
||||
key={human.id}
|
||||
label={display.displayName}
|
||||
selected={assigned_to === human.id}
|
||||
onPress={() => handleAssign(human.id)}
|
||||
avatarHumanId={human.id}
|
||||
humans={network?.humans}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</ScrollView>
|
||||
{/* Assignee */}
|
||||
<View className="gap-2">
|
||||
<Text className="text-white/40 text-xs">Assignee</Text>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerClassName="gap-2"
|
||||
>
|
||||
<AssigneeChip
|
||||
label="Unassigned"
|
||||
selected={!assigned_to}
|
||||
onPress={() => handleAssign(null)}
|
||||
/>
|
||||
{network?.humans?.map((human) => {
|
||||
const display = resolveHumanDisplay(human.id, network?.humans);
|
||||
return (
|
||||
<AssigneeChip
|
||||
key={human.id}
|
||||
label={display.displayName}
|
||||
selected={assigned_to === human.id}
|
||||
onPress={() => handleAssign(human.id)}
|
||||
avatarHumanId={human.id}
|
||||
humans={network?.humans}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* While a field is focused the keyboard hides the stream's tap-zones,
|
||||
so offer an explicit way out. Dismissing blurs the active field,
|
||||
which flips `editing` off and resumes the dwell + tap navigation. */}
|
||||
{editing ? (
|
||||
<View className="absolute right-2 top-2">
|
||||
<Pressable
|
||||
onPress={() => Keyboard.dismiss()}
|
||||
hitSlop={8}
|
||||
accessibilityLabel="Done editing"
|
||||
className="rounded-full bg-white/15 px-3 py-1.5"
|
||||
>
|
||||
<Text className="text-white text-sm font-medium">Done</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user