feat(rpc): add validated desktop Pi RPC command palette
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { RpcCommand, RpcField } from "../commands/rpc";
|
||||
|
||||
const controls = (container: HTMLElement | null) =>
|
||||
Array.from(
|
||||
container?.querySelectorAll<HTMLElement>(
|
||||
"button, input, select, textarea",
|
||||
) ?? [],
|
||||
).filter((node) => !node.hasAttribute("disabled"));
|
||||
|
||||
function valueFor(field: RpcField, value: string | boolean): unknown {
|
||||
if (field.kind === "boolean") return value === true;
|
||||
if (field.kind === "json")
|
||||
return value ? JSON.parse(value as string) : undefined;
|
||||
return value || undefined;
|
||||
}
|
||||
|
||||
export function CommandFormDialog({
|
||||
command,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
command?: RpcCommand;
|
||||
onSubmit: (input: Record<string, unknown>) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const opener = useRef<HTMLElement | null>(null);
|
||||
const [values, setValues] = useState<Record<string, string | boolean>>({});
|
||||
const [error, setError] = useState<string>();
|
||||
useEffect(() => {
|
||||
if (!command) return;
|
||||
opener.current = document.activeElement as HTMLElement | null;
|
||||
setValues({});
|
||||
setError(undefined);
|
||||
const frame = requestAnimationFrame(() =>
|
||||
controls(ref.current)[0]?.focus(),
|
||||
);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
opener.current?.focus();
|
||||
};
|
||||
}, [command]);
|
||||
if (!command) return null;
|
||||
const fields = command.input ?? [];
|
||||
const submit = () => {
|
||||
try {
|
||||
const input: Record<string, unknown> = {};
|
||||
for (const field of fields) {
|
||||
const value = valueFor(
|
||||
field,
|
||||
values[field.name] ?? (field.kind === "boolean" ? false : ""),
|
||||
);
|
||||
if (field.required && (value === undefined || value === ""))
|
||||
throw new Error(`${field.label} is required`);
|
||||
if (value !== undefined) input[field.name] = value;
|
||||
}
|
||||
onSubmit(input);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Invalid input");
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="extension-backdrop">
|
||||
<section
|
||||
ref={ref}
|
||||
className="extension"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="command-form-title"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
if (event.key === "Tab") {
|
||||
const items = controls(ref.current),
|
||||
first = items[0],
|
||||
last = items[items.length - 1];
|
||||
if (!first || !last) return;
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<p className="extension-eyebrow">Pi RPC command</p>
|
||||
<h2 id="command-form-title">/{command.command}</h2>
|
||||
<p>{command.description}</p>
|
||||
{fields.map((field) => (
|
||||
<label key={field.name}>
|
||||
{field.label}
|
||||
{field.kind === "boolean" ? (
|
||||
<input
|
||||
aria-label={field.label}
|
||||
type="checkbox"
|
||||
checked={values[field.name] === true}
|
||||
onChange={(event) =>
|
||||
setValues({
|
||||
...values,
|
||||
[field.name]: event.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : field.kind === "enum" ? (
|
||||
<select
|
||||
aria-label={field.label}
|
||||
value={(values[field.name] as string) ?? ""}
|
||||
onChange={(event) =>
|
||||
setValues({
|
||||
...values,
|
||||
[field.name]: event.currentTarget.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">Select…</option>
|
||||
{field.options?.map((option) => (
|
||||
<option key={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
) : field.kind === "json" ? (
|
||||
<textarea
|
||||
aria-label={field.label}
|
||||
value={(values[field.name] as string) ?? ""}
|
||||
onChange={(event) =>
|
||||
setValues({
|
||||
...values,
|
||||
[field.name]: event.currentTarget.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
aria-label={field.label}
|
||||
value={(values[field.name] as string) ?? ""}
|
||||
onChange={(event) =>
|
||||
setValues({
|
||||
...values,
|
||||
[field.name]: event.currentTarget.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
{error && <p role="alert">{error}</p>}
|
||||
<div className="extension-options">
|
||||
<button onClick={submit}>Run command</button>
|
||||
<button className="quiet" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user