Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,39 @@ describe("Switchify PC shell", () => {
fireEvent.click(screen.getByRole("button", { name: "Add switch" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Next" })).toBeDisabled());
expect(screen.getByRole("button", { name: "Skip switch configuration" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Skip setup for now" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Next" })).toHaveAccessibleDescription("Save or cancel the new switch before continuing.");
fireEvent.click(screen.getByRole("button", { name: "Cancel new switch" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Next" })).toBeEnabled());
switches.unsaved = true;
switches.error = "Save failed";
rerender(<App />);
expect(screen.getByRole("button", { name: "Next" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Retry save" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Next" })).toHaveAccessibleDescription("Your switch changes have not saved. Retry saving before continuing.");
});

it.each([
["capture", "Finish capturing a switch or cancel capture before continuing."],
["save", "Wait for your switch changes to finish saving before continuing."],
])("explains and preserves navigation guards during %s", async (operation, message) => {
browserState.setup.autoOpenEligible = true;
const switches = localSwitches();
vi.spyOn(switchHooks, "useSwitches").mockReturnValue(switches);
const { rerender } = render(<App />);
fireEvent.click(await screen.findByRole("button", { name: "Next" }));
if (operation === "capture") switches.capturing = true;
else switches.pending = 1;
rerender(<App />);
expect(screen.getByRole("button", { name: "Next" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Next" })).toHaveAccessibleDescription(message);
expect(screen.getByRole("button", { name: "Skip setup for now" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Skip switch configuration" })).toBeDisabled();
switches.capturing = false;
switches.pending = 0;
rerender(<App />);
expect(screen.getByRole("button", { name: "Next" })).toBeEnabled();
expect(screen.queryByText(message)).not.toBeInTheDocument();
});

it("suspends setup for pairing and returns to the same step", async () => {
Expand Down Expand Up @@ -319,9 +345,12 @@ describe("Switchify PC shell", () => {
await waitFor(() => expect(guide).toHaveFocus());
expect(screen.getByLabelText("Step 1 of 5")).toBeInTheDocument();
await waitFor(() => expect(markShown).toHaveBeenCalledOnce());
fireEvent.click(screen.getByRole("button", { name: "Skip for now" }));
fireEvent.click(screen.getByRole("button", { name: "Skip setup for now" }));
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Input access" })).not.toBeInTheDocument());
expect(markShown).toHaveBeenCalledTimes(2);
fireEvent.click(screen.getByRole("button", { name: "Support" }));
fireEvent.click(screen.getByRole("button", { name: "Open setup guide" }));
expect(await screen.findByRole("dialog", { name: "Input access" })).toBeInTheDocument();
});

it("does not force setup on an existing paired user", async () => {
Expand Down Expand Up @@ -355,13 +384,16 @@ describe("Switchify PC shell", () => {
fireEvent.click(await screen.findByRole("button", { name: "Next" }));
expect(screen.getByRole("dialog", { name: "Add your switch" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Next" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Next" })).toHaveAccessibleDescription(/If you use only remote switches/);
fireEvent.click(screen.getByRole("button", { name: "Skip switch configuration" }));
expect(screen.getByRole("dialog", { name: "Scanning basics" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Next" }));
expect(screen.getByRole("button", { name: "Next" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Next" })).toHaveAccessibleDescription("Choose Start with system or Start manually to enable Next.");
fireEvent.click(screen.getByRole("button", { name: "Start manually" }));
fireEvent.click(screen.getByRole("button", { name: "Next" }));
expect(screen.getByRole("button", { name: "Finish" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Finish" })).toHaveAccessibleDescription("Choose whether to share diagnostics to enable Finish.");
fireEvent.click(screen.getByRole("button", { name: "Don’t share" }));
fireEvent.click(screen.getByRole("button", { name: "Finish" }));
await waitFor(() => expect(complete).toHaveBeenCalledWith(false, false));
Expand Down
23 changes: 21 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
import {
Accessibility, Bluetooth, ChevronRight, CircleHelp, Download,
Copy, Home, Keyboard, Plus, Power, RefreshCw, Save, Settings,
Expand Down Expand Up @@ -361,6 +361,24 @@ function SetupGuide({ state, switches, suspended, busy, error, skip, finish, acc
const switchBusy = capturing || !!switches.pending || switches.unsaved || switchDraft;
const titles = ["Input access", "Add your switch", "Scanning basics", "Start with system", "Anonymous diagnostics"];
const canContinue = step === 1 ? !switchBusy && !!switches.state?.settings.bindings.length : step === 3 ? startupChoice !== null : step === 4 ? diagnosticsChoice !== null : true;
const guidanceId = useId();
const navigationGuidance = capturing
? "Finish capturing a switch or cancel capture before continuing."
: switches.pending
? "Wait for your switch changes to finish saving before continuing."
: switches.unsaved
? "Your switch changes have not saved. Retry saving before continuing."
: switchDraft
? "Save or cancel the new switch before continuing."
: busy
? "Please wait while setup saves your changes."
: step === 1 && !switches.state?.settings.bindings.length
? "Add and save a local keyboard switch to enable Next. If you use only remote switches or want to configure switches later, choose Skip switch configuration to continue setup. Remote presets alone do not confirm a connected switch."
: step === 3 && startupChoice === null
? "Choose Start with system or Start manually to enable Next."
: step === 4 && diagnosticsChoice === null
? "Choose whether to share diagnostics to enable Finish."
: null;

useEffect(() => { if (!suspended && !capturing) dialogRef.current?.focus(); }, [step, suspended]);

Expand All @@ -387,7 +405,8 @@ function SetupGuide({ state, switches, suspended, busy, error, skip, finish, acc
{step === 3 && <div><h3>Choose startup behavior</h3><p>Switchify can start quietly when you sign in, ready for your switches.</p><div className="setup-choices" role="group" aria-label="Start with system choice"><button className="secondary" aria-pressed={startupChoice === true} onClick={() => setStartupChoice(true)}>Start with system</button><button className="secondary" aria-pressed={startupChoice === false} onClick={() => setStartupChoice(false)}>Start manually</button></div></div>}
{step === 4 && <div><h3>Choose whether to share diagnostics</h3><p>Optional anonymous app health and sanitized errors help improve Switchify. Typed text, commands, pairing secrets, device names, and full paths are never included.</p><div className="setup-choices" role="group" aria-label="Anonymous diagnostics choice"><button className="secondary" disabled={!state.telemetry.available} aria-pressed={diagnosticsChoice === true} onClick={() => setDiagnosticsChoice(true)}>Share diagnostics</button><button className="secondary" aria-pressed={diagnosticsChoice === false} onClick={() => setDiagnosticsChoice(false)}>Don’t share</button></div><a className="setup-privacy" href="https://switchifyapp.com/privacy" target="_blank" rel="noreferrer">Privacy policy</a></div>}
</div>
<footer>{step === 1 && <button className="secondary" disabled={busy || switchBusy} onClick={() => setStep(2)}>Skip switch configuration</button>}<button className="text-button" disabled={busy || switchBusy} onClick={() => void skip()}>Skip for now</button><span /><button className="secondary" disabled={busy || switchBusy || step === 0} onClick={() => setStep((current) => current - 1)}>Back</button><button className="primary" disabled={busy || switchBusy || !canContinue} onClick={() => step === 4 ? void finish(startupChoice!, diagnosticsChoice!) : setStep((current) => current + 1)}>{step === 4 ? "Finish" : "Next"}</button></footer>
<p id={guidanceId} className="setup-navigation-guidance" role="status">{navigationGuidance}</p>
<footer><button className="text-button" disabled={busy || switchBusy} onClick={() => void skip()}>Skip setup for now</button>{step === 1 && <button className="secondary" disabled={busy || switchBusy} onClick={() => setStep(2)}>Skip switch configuration</button>}<span /><button className="secondary" disabled={busy || switchBusy || step === 0} onClick={() => setStep((current) => current - 1)}>Back</button><button className="primary" aria-describedby={navigationGuidance ? guidanceId : undefined} disabled={busy || switchBusy || !canContinue} onClick={() => step === 4 ? void finish(startupChoice!, diagnosticsChoice!) : setStep((current) => current + 1)}>{step === 4 ? "Finish" : "Next"}</button></footer>
</section></div>;
}

Expand Down
3 changes: 3 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -441,3 +441,6 @@ button:disabled { cursor: default; opacity: 0.55; }
.scan-rate-stepper { display: flex; align-items: center; gap: 8px; }
.scan-rate-stepper button { min-width: 44px; min-height: 44px; font-size: 22px; }
.scan-rate-stepper output { min-width: 72px; text-align: center; font-variant-numeric: tabular-nums; }

.setup-navigation-guidance { flex: 0 0 auto; margin: 0; color: var(--muted); font-size: 13px; line-height: 1.5; }
.setup-navigation-guidance:not(:empty) { padding-bottom: 16px; }
Loading