mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-03 15:12:48 +00:00
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:
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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) }),
|
||||
|
||||
@@ -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] },
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user