fix(app): float the pane Find widget and tighten its chrome

Find pushed the first lines of the file down, ran 480px wide with 32px
ghost buttons, and let the replacement input start left of the query
input. Float the CodeMirror top panel out of flow so the widget overlays
the top right of the pane, move the disclosure toggle into a gutter that
spans both rows so the two fields share a leading rail, put the match
count inside the query field, and drop the icon controls to the shared
pane-toolbar chrome the file bar above already uses.
This commit is contained in:
Mohamed Boudra
2026-09-09 20:09:55 +02:00
parent d014cfdb2f
commit 7b813f7f58
3 changed files with 248 additions and 126 deletions
+17 -11
View File
@@ -10,9 +10,11 @@ import { createPortal } from "react-dom";
import { EditorView } from "@codemirror/view";
import { Search } from "lucide-react-native";
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { mutedIconColorMapping } from "@/components/ui/icon-button-chrome";
import { paneContentToolbarIconSize, ToolbarButton } from "@/components/ui/pane-content-toolbar";
import { useIsCompactFormFactor } from "@/constants/layout";
import { PaneFind, type PaneFindHandle } from "@/pane-find";
import { usePaneFocus } from "@/panels/pane-context";
import { hasActiveWebOverlay } from "@/lib/overlay-root";
@@ -22,6 +24,8 @@ import { FileFindModel } from "./model.web";
export { FileFindModel } from "./model.web";
const SearchIcon = withUnistyles(Search, mutedIconColorMapping);
export function FileFind({
model,
editor,
@@ -31,6 +35,7 @@ export function FileFind({
}) {
const { t } = useTranslation();
const state = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
const isCompact = useIsCompactFormFactor();
const widget = useRef<PaneFindHandle>(null);
const { isInteractive, focusPane } = usePaneFocus();
const active = useRetainedPanelActive();
@@ -75,13 +80,9 @@ export function FileFind({
if (!state.panel)
return (
<View style={styles.trigger}>
<Button
variant="ghost"
size="sm"
leftIcon={Search}
accessibilityLabel={t("paneFind.title")}
onPress={open}
/>
<ToolbarButton label={t("paneFind.title")} compact={isCompact} onPress={open}>
<SearchIcon size={paneContentToolbarIconSize(isCompact)} />
</ToolbarButton>
</View>
);
const total = `${state.total}${state.limited ? "+" : ""}`;
@@ -110,13 +111,18 @@ export function FileFind({
}
const styles = StyleSheet.create((theme) => ({
panel: { alignItems: "flex-end", padding: theme.spacing[2] },
panel: { alignItems: "flex-end", maxWidth: "100%", padding: theme.spacing[2] },
// Carries the open widget's chrome so the entry point reads as the same object.
trigger: {
position: "absolute",
top: theme.spacing[2],
right: theme.spacing[2],
zIndex: 1,
padding: theme.spacing[1],
backgroundColor: theme.colors.surface1,
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
...theme.shadow.md,
},
}));
+14 -1
View File
@@ -39,8 +39,21 @@ export class FileFindModel {
readonly getSnapshot = () => this.snapshot;
readonly extension: Extension = [
// The custom panel lives inside the editor's monospace subtree.
EditorView.theme({
// CodeMirror lays panels out as flex siblings of the scroller, which pushes
// the first lines down every time Find opens. Take the panel out of flow so
// the widget floats over the top-right of the content instead.
".cm-panels.cm-panels-top": {
position: "absolute",
top: "0",
right: "0",
left: "auto",
maxWidth: "100%",
zIndex: "4",
border: "none",
backgroundColor: "transparent",
},
// The custom panel lives inside the editor's monospace subtree.
".paseo-file-find, .paseo-file-find *": { fontFamily: "var(--paseo-ui-font)" },
}),
search({ literal: true, top: true, createPanel: (view) => this.createPanel(view) }),
+217 -114
View File
@@ -6,6 +6,7 @@ import {
useImperativeHandle,
useRef,
useState,
type ReactNode,
type KeyboardEvent,
} from "react";
import {
@@ -13,19 +14,34 @@ import {
View,
type NativeSyntheticEvent,
type TextInputKeyPressEventData,
type TextInputProps,
} from "react-native";
import { ArrowDown, ArrowUp, ChevronDown, ChevronRight, X } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { EditingTextInput, type EditingTextInputHandle } from "@/components/ui/text-input";
import { Button } from "@/components/ui/button";
import { CONTROL_HEIGHTS } from "@/components/ui/control-geometry";
import { EditingTextInput, type EditingTextInputHandle } from "@/components/ui/text-input";
import {
createControlGeometry,
resolveControlInteractionStyles,
} from "@/components/ui/control-geometry";
import {
mutedIconColorMapping,
smallIconButtonChromeFrameSize,
} from "@/components/ui/icon-button-chrome";
import { paneContentToolbarIconSize, ToolbarButton } from "@/components/ui/pane-content-toolbar";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
const TextInput = withUnistyles(EditingTextInput, (theme) => ({
placeholderTextColor: theme.colors.foregroundMuted,
}));
const ArrowUpIcon = withUnistyles(ArrowUp, mutedIconColorMapping);
const ArrowDownIcon = withUnistyles(ArrowDown, mutedIconColorMapping);
const CloseIcon = withUnistyles(X, mutedIconColorMapping);
const ChevronDownIcon = withUnistyles(ChevronDown, mutedIconColorMapping);
const ChevronRightIcon = withUnistyles(ChevronRight, mutedIconColorMapping);
export interface PaneFindHandle {
focus(): void;
@@ -47,12 +63,81 @@ export interface PaneFindProps {
};
}
/**
* The bordered box around one Find input. It owns the field chrome so the query
* row and the replacement row land on the same rails, and so the match count can
* sit inside the query box instead of widening the widget.
*/
const FindField = forwardRef<
EditingTextInputHandle,
{
label: string;
initialValue: string;
height: number;
trailing?: ReactNode;
onChangeText(value: string): void;
onKeyPress(event: NativeSyntheticEvent<TextInputKeyPressEventData>): void;
autoFocus?: boolean;
returnKeyType?: TextInputProps["returnKeyType"];
}
>(function FindField(
{ label, initialValue, height, trailing, onChangeText, onKeyPress, autoFocus, returnKeyType },
ref,
) {
const [focused, setFocused] = useState(false);
const onFocus = useCallback(() => setFocused(true), []);
const onBlur = useCallback(() => setFocused(false), []);
const fieldStyle = useMemo(
() => [
styles.field,
{ minHeight: height },
resolveControlInteractionStyles(
{
controlRest: styles.controlRest,
controlHover: styles.controlHover,
controlActive: styles.controlActive,
},
{ focused },
),
],
[focused, height],
);
return (
<View style={fieldStyle}>
<TextInput
ref={ref}
autoFocus={autoFocus}
selectTextOnFocus
initialValue={initialValue}
onChangeText={onChangeText}
onKeyPress={onKeyPress}
onFocus={onFocus}
onBlur={onBlur}
accessibilityLabel={label}
placeholder={label}
autoCapitalize="none"
autoCorrect={false}
blurOnSubmit={false}
returnKeyType={returnKeyType}
style={styles.input}
/>
{trailing}
</View>
);
});
/** Pane-local chrome. The content owner supplies search state and commands. */
export const PaneFind = forwardRef<PaneFindHandle, PaneFindProps>(function PaneFind(
{ query, status, canNavigate, onQueryChange, onNext, onPrevious, onClose, replace },
ref,
) {
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
const controlSize = smallIconButtonChromeFrameSize(isCompact);
const glyphSize = paneContentToolbarIconSize(isCompact);
const rowHeight = Math.max(COLLAPSED_FIELD_HEIGHT, controlSize);
// Keep the replace actions on the same row height as the icon controls beside them.
const actionSize = isCompact ? "sm" : "xs";
const input = useRef<EditingTextInputHandle>(null);
const replacementInput = useRef<EditingTextInputHandle>(null);
useLayoutEffect(() => {
@@ -102,9 +187,22 @@ export const PaneFind = forwardRef<PaneFindHandle, PaneFindProps>(function PaneF
[focus, onClose, onNext, onPrevious],
);
const toggleReplace = useCallback(() => setReplaceExpanded((expanded) => !expanded), []);
const replaceAccessibilityState = useMemo(
() => ({ expanded: replaceExpanded }),
[replaceExpanded],
const gutterStyle = useMemo(
() => [styles.gutter, { width: controlSize, height: rowHeight }],
[controlSize, rowHeight],
);
const matchCount = useMemo(
() => (
<Text
style={styles.status}
role="status"
accessibilityLabel={t("paneFind.matches")}
accessibilityLiveRegion="polite"
>
{status}
</Text>
),
[status, t],
);
return (
@@ -123,138 +221,143 @@ export const PaneFind = forwardRef<PaneFindHandle, PaneFindProps>(function PaneF
}
: {})}
>
<View style={styles.findRow}>
<View style={styles.queryRow}>
{replace ? (
<Button
variant="ghost"
size="sm"
leftIcon={replaceExpanded ? ChevronDown : ChevronRight}
accessibilityLabel={t("paneFind.toggleReplace")}
accessibilityState={replaceAccessibilityState}
onPress={toggleReplace}
/>
) : null}
<TextInput
{replace ? (
<View style={gutterStyle}>
<ToolbarButton
label={t("paneFind.toggleReplace")}
aria-expanded={replaceExpanded}
compact={isCompact}
onPress={toggleReplace}
>
{replaceExpanded ? (
<ChevronDownIcon size={glyphSize} />
) : (
<ChevronRightIcon size={glyphSize} />
)}
</ToolbarButton>
</View>
) : null}
<View style={styles.rows}>
<View style={styles.row}>
<FindField
ref={input}
autoFocus
selectTextOnFocus
label={t("paneFind.placeholder")}
initialValue={query}
height={rowHeight}
returnKeyType="search"
trailing={matchCount}
onChangeText={onQueryChange}
onKeyPress={onKeyPress}
accessibilityLabel={t("paneFind.placeholder")}
placeholder={t("paneFind.placeholder")}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="search"
blurOnSubmit={false}
style={styles.input}
/>
</View>
<View style={styles.row}>
<Text
style={styles.status}
role="status"
accessibilityLabel={t("paneFind.matches")}
accessibilityLiveRegion="polite"
>
{status}
</Text>
<Button
variant="ghost"
size="sm"
leftIcon={ArrowUp}
accessibilityLabel={t("paneFind.previous")}
<ToolbarButton
label={t("paneFind.previous")}
compact={isCompact}
disabled={!canNavigate}
onPress={onPrevious}
/>
<Button
variant="ghost"
size="sm"
leftIcon={ArrowDown}
accessibilityLabel={t("paneFind.next")}
>
<ArrowUpIcon size={glyphSize} />
</ToolbarButton>
<ToolbarButton
label={t("paneFind.next")}
compact={isCompact}
disabled={!canNavigate}
onPress={onNext}
/>
>
<ArrowDownIcon size={glyphSize} />
</ToolbarButton>
<ToolbarButton label={t("paneFind.close")} compact={isCompact} onPress={onClose}>
<CloseIcon size={glyphSize} />
</ToolbarButton>
</View>
<Button
variant="ghost"
size="sm"
leftIcon={X}
accessibilityLabel={t("paneFind.close")}
onPress={onClose}
/>
</View>
{replace && replaceExpanded ? (
<View style={styles.replacement}>
<TextInput
ref={replacementInput}
initialValue={replace.value}
onChangeText={replace.onChange}
onKeyPress={onKeyPress}
accessibilityLabel={t("paneFind.replaceWith")}
placeholder={t("paneFind.replaceWith")}
autoCapitalize="none"
autoCorrect={false}
style={styles.input}
/>
{replace && replaceExpanded ? (
<View style={styles.row}>
<Button variant="ghost" size="sm" disabled={!canNavigate} onPress={replace.onReplace}>
<FindField
ref={replacementInput}
label={t("paneFind.replaceWith")}
initialValue={replace.value}
height={rowHeight}
onChangeText={replace.onChange}
onKeyPress={onKeyPress}
/>
<Button
variant="ghost"
size={actionSize}
style={styles.replaceAction}
disabled={!canNavigate}
onPress={replace.onReplace}
>
{t("paneFind.replace")}
</Button>
<Button
variant="ghost"
size="sm"
size={actionSize}
style={styles.replaceAction}
disabled={!canNavigate}
onPress={replace.onReplaceAll}
>
{t("paneFind.replaceAll")}
</Button>
</View>
</View>
) : null}
) : null}
</View>
</View>
);
});
const styles = StyleSheet.create((theme) => ({
widget: {
width: 480,
maxWidth: "100%",
padding: theme.spacing[2],
gap: theme.spacing[1],
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.md,
},
findRow: { flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: theme.spacing[1] },
queryRow: {
flex: 1,
minWidth: 140,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
row: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1] },
replacement: { gap: theme.spacing[1] },
input: {
flex: 1,
minWidth: 0,
minHeight: CONTROL_HEIGHTS.compact,
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
fontSize: theme.fontSize.base,
color: theme.colors.foreground,
backgroundColor: theme.colors.surface0,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.sm,
},
status: {
flex: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
paddingHorizontal: theme.spacing[2],
},
}));
/** Desktop find rows run one step tighter than a form field — this is pane chrome. */
const COLLAPSED_FIELD_HEIGHT = 28;
const FIND_WIDGET_WIDTH = 340;
const styles = StyleSheet.create((theme) => {
const geometry = createControlGeometry(theme);
return {
widget: {
width: FIND_WIDGET_WIDTH,
maxWidth: "100%",
flexDirection: "row",
padding: theme.spacing[1.5],
gap: theme.spacing[1],
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
...theme.shadow.md,
},
// The disclosure column spans both rows so the two fields share a leading rail.
gutter: { alignItems: "center", justifyContent: "center", flexShrink: 0 },
rows: { flex: 1, minWidth: 0, gap: theme.spacing[1] },
row: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1] },
field: {
flex: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.md,
},
controlRest: { ...geometry.controlRest },
controlHover: { ...geometry.controlHover },
controlActive: { ...geometry.controlActive },
input: {
flex: 1,
minWidth: 0,
paddingHorizontal: 0,
paddingVertical: 0,
color: theme.colors.foreground,
outlineWidth: 0,
outlineColor: "transparent",
...geometry.fieldTextSm,
},
status: {
flexShrink: 0,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
// Ghost actions sit on the field's rail, so they carry the field's padding.
replaceAction: { paddingHorizontal: theme.spacing[2] },
};
});