fix(hosts): separate host and settings controls

The host picker nested its settings button inside the selection button, producing invalid interactive markup. Keep both controls independently reachable.
This commit is contained in:
Mohamed Boudra
2026-11-02 07:51:18 +01:00
parent 16621b4845
commit d121ac93a2
2 changed files with 77 additions and 27 deletions
@@ -0,0 +1,42 @@
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, it } from "vitest";
import { HostPickerOption } from "./host-picker";
const actions: string[] = [];
function select() {
actions.push("select");
}
function openSettings() {
actions.push("settings");
}
it("keeps host selection and settings as separate accessible buttons", () => {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
actions.length = 0;
act(() =>
root.render(
<HostPickerOption
serverId="host-a"
label="Host A"
showActiveConnection={false}
active={false}
onPress={select}
onOpenHostSettings={openSettings}
/>,
),
);
const host = container.querySelector('button[aria-label="Host A"]');
const settings = container.querySelector('button[aria-label="Open Host A settings"]');
expect(host).not.toBeNull();
expect(settings).not.toBeNull();
expect(host?.contains(settings!)).toBe(false);
act(() => (settings as HTMLElement).click());
expect(actions).toEqual(["settings"]);
act(() => (host as HTMLElement).click());
expect(actions).toEqual(["settings", "select"]);
act(() => root.unmount());
container.remove();
});
@@ -8,6 +8,7 @@ import { Combobox, ComboboxItem, type ComboboxProps } from "@/components/ui/comb
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import { useHostRuntimeSnapshot, type ActiveConnection } from "@/runtime/host-runtime";
import { orderHostsLocalFirst } from "@/types/host-connection";
import { isWeb } from "@/constants/platform";
import {
ADD_HOST_OPTION_ID,
ALL_HOSTS_OPTION_ID,
@@ -89,35 +90,33 @@ export function HostPickerOption({
);
const trailingSlot = useMemo(() => {
if (!onOpenHostSettings) return undefined;
return (
<Pressable
onPress={handleSettingsPress}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={`Open ${label} settings`}
>
<Settings size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
);
}, [
handleSettingsPress,
label,
onOpenHostSettings,
theme.colors.foregroundMuted,
theme.iconSize.sm,
]);
return <View style={styles.settingsSlot} />;
}, [onOpenHostSettings]);
return (
<ComboboxItem
label={label}
description={connectionLabel}
leadingSlot={leadingSlot}
trailingSlot={trailingSlot}
selected={selected}
active={active}
onPress={onPress}
testID={testID}
/>
<View style={styles.optionContainer}>
<ComboboxItem
label={label}
description={connectionLabel}
leadingSlot={leadingSlot}
trailingSlot={trailingSlot}
selected={selected}
active={active}
onPress={onPress}
testID={testID}
/>
{onOpenHostSettings ? (
<Pressable
style={styles.settingsAction}
onPress={handleSettingsPress}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={`Open ${label} settings`}
>
<Settings size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
) : null}
</View>
);
}
@@ -319,6 +318,15 @@ export function HostPicker({
}
const styles = StyleSheet.create((theme) => ({
optionContainer: { position: "relative" },
settingsSlot: { width: theme.iconSize.sm, height: theme.iconSize.sm },
settingsAction: {
position: "absolute",
right: theme.spacing[3] + (isWeb ? 0 : theme.spacing[1]),
top: 0,
bottom: 0,
justifyContent: "center",
},
statusDotSlot: {
width: theme.iconSize.sm,
height: theme.iconSize.sm,