mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 02:02:42 +00:00
fix(app): keep the composer focus hint off the placeholder
The web composer draws its focus shortcut hint over the input's top-right corner. When the placeholder and the hint did not fit on one line, as in French at a phone width or with the longer desktop placeholder, the hint was drawn on top of the end of the placeholder. The input now reserves the hint's width as right padding while the hint shows, so the placeholder wraps before it. Closes #6191
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
async function useLanguage(page: Page, language: string): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ key, language: value }) => {
|
||||
localStorage.setItem(key, JSON.stringify({ language: value }));
|
||||
},
|
||||
{ key: APP_SETTINGS_KEY, language },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lays the placeholder out the way the textarea does, in a mirror element with
|
||||
* the same box and font, and reports whether any of its lines reach the hint.
|
||||
*/
|
||||
async function placeholderOverlaps(input: Locator, hint: Locator): Promise<boolean> {
|
||||
const hintBox = await hint.boundingBox();
|
||||
if (!hintBox) throw new Error("Focus hint has no layout box");
|
||||
return input.evaluate((element, box) => {
|
||||
const textarea = element as HTMLTextAreaElement;
|
||||
const style = getComputedStyle(textarea);
|
||||
const rect = textarea.getBoundingClientRect();
|
||||
const mirror = document.createElement("div");
|
||||
for (const property of [
|
||||
"boxSizing",
|
||||
"paddingTop",
|
||||
"paddingRight",
|
||||
"paddingBottom",
|
||||
"paddingLeft",
|
||||
"borderTopWidth",
|
||||
"borderRightWidth",
|
||||
"borderBottomWidth",
|
||||
"borderLeftWidth",
|
||||
"fontFamily",
|
||||
"fontSize",
|
||||
"fontWeight",
|
||||
"letterSpacing",
|
||||
"lineHeight",
|
||||
"textIndent",
|
||||
"wordSpacing",
|
||||
] as const) {
|
||||
mirror.style[property] = style[property];
|
||||
}
|
||||
Object.assign(mirror.style, {
|
||||
position: "fixed",
|
||||
left: `${rect.left}px`,
|
||||
top: `${rect.top}px`,
|
||||
width: `${rect.width}px`,
|
||||
borderStyle: "solid",
|
||||
borderColor: "transparent",
|
||||
whiteSpace: "pre-wrap",
|
||||
overflowWrap: "break-word",
|
||||
visibility: "hidden",
|
||||
});
|
||||
const text = document.createTextNode(textarea.placeholder);
|
||||
mirror.appendChild(text);
|
||||
document.body.appendChild(mirror);
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(text);
|
||||
const lines = Array.from(range.getClientRects());
|
||||
mirror.remove();
|
||||
return lines.some(
|
||||
(line) =>
|
||||
line.right > box.x &&
|
||||
line.left < box.x + box.width &&
|
||||
line.bottom > box.y &&
|
||||
line.top < box.y + box.height,
|
||||
);
|
||||
}, hintBox);
|
||||
}
|
||||
|
||||
const FRENCH_LAYOUTS = [
|
||||
{ name: "phone width", width: 390, placeholder: "Message, @fichiers, /commandes" },
|
||||
{
|
||||
name: "desktop width",
|
||||
width: 1024,
|
||||
placeholder:
|
||||
"Écrivez à l’agent, mentionnez des @fichiers ou utilisez les /commandes et /skills",
|
||||
},
|
||||
];
|
||||
|
||||
for (const layout of FRENCH_LAYOUTS) {
|
||||
test.describe(`French composer at ${layout.name}`, () => {
|
||||
test.use({ viewport: { width: layout.width, height: 844 } });
|
||||
|
||||
test("the focus hint does not cover the placeholder", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-focus-hint-",
|
||||
title: "Composer focus hint",
|
||||
});
|
||||
|
||||
try {
|
||||
await useLanguage(page, "fr");
|
||||
await openAgentRoute(page, agent);
|
||||
const input = page.getByRole("textbox", { name: "Écrire à l’agent…" }).first();
|
||||
await expect(input).toBeVisible();
|
||||
await expect(input).toHaveAttribute("placeholder", layout.placeholder);
|
||||
await input.evaluate((element) => (element as HTMLTextAreaElement).blur());
|
||||
|
||||
const hint = page.getByText(/pour saisir$/).first();
|
||||
await expect(hint).toBeVisible();
|
||||
expect(await placeholderOverlaps(input, hint)).toBe(false);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -608,18 +608,14 @@ function MessageInputOverlay({
|
||||
}
|
||||
|
||||
function FocusHint({
|
||||
visible,
|
||||
focusInputKeys,
|
||||
label,
|
||||
onLayout,
|
||||
}: {
|
||||
visible: boolean;
|
||||
focusInputKeys: ShortcutChord | null | undefined;
|
||||
label: string;
|
||||
onLayout: (event: LayoutChangeEvent) => void;
|
||||
}) {
|
||||
const { isActiveComposer } = useComposerKeyboardScope();
|
||||
if (!isActiveComposer || !visible || !focusInputKeys || !label.trim()) return null;
|
||||
return (
|
||||
<Text style={styles.focusHintText} pointerEvents="none">
|
||||
<Text style={styles.focusHintText} pointerEvents="none" onLayout={onLayout}>
|
||||
{label}
|
||||
</Text>
|
||||
);
|
||||
@@ -654,6 +650,25 @@ interface ComposerTextSurfaceProps {
|
||||
* state of this composer rather than a second one.
|
||||
*/
|
||||
function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElement {
|
||||
const { isActiveComposer } = useComposerKeyboardScope();
|
||||
const [focusHintWidth, setFocusHintWidth] = useState(0);
|
||||
const handleFocusHintLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
setFocusHintWidth(event.nativeEvent.layout.width);
|
||||
}, []);
|
||||
const isFocusHintShown =
|
||||
isActiveComposer &&
|
||||
props.focusHintVisible &&
|
||||
Boolean(props.focusInputKeys) &&
|
||||
props.focusHintLabel.trim().length > 0;
|
||||
// The hint sits over the input's top-right corner, so the placeholder wraps
|
||||
// before it instead of running underneath.
|
||||
const textInputStyle = useMemo(
|
||||
() =>
|
||||
isFocusHintShown
|
||||
? [props.textInputStyle, { paddingRight: focusHintWidth }]
|
||||
: props.textInputStyle,
|
||||
[focusHintWidth, isFocusHintShown, props.textInputStyle],
|
||||
);
|
||||
if (props.readOnly) {
|
||||
return (
|
||||
<View style={styles.textInputScrollWrapper}>
|
||||
@@ -674,7 +689,7 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen
|
||||
accessibilityLabel={props.accessibilityLabel}
|
||||
onFocus={props.onFocus}
|
||||
onBlur={props.onBlur}
|
||||
style={props.textInputStyle}
|
||||
style={textInputStyle}
|
||||
multiline
|
||||
scrollEnabled={props.scrollEnabled}
|
||||
editable={props.editable}
|
||||
@@ -684,11 +699,9 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen
|
||||
onPasteError={props.onPasteError}
|
||||
autoFocus={props.autoFocus}
|
||||
/>
|
||||
<FocusHint
|
||||
visible={props.focusHintVisible}
|
||||
focusInputKeys={props.focusInputKeys}
|
||||
label={props.focusHintLabel}
|
||||
/>
|
||||
{isFocusHintShown ? (
|
||||
<FocusHint label={props.focusHintLabel} onLayout={handleFocusHintLayout} />
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1955,6 +1968,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
paddingLeft: theme.spacing[2],
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
opacity: 0.5,
|
||||
|
||||
Reference in New Issue
Block a user