mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 08:03:13 +00:00
feat(plugin): add settings rows, selects and menu rows to ctx.ui (#61527)
This commit is contained in:
parent
922994ac1e
commit
3fcdcb35e8
11 changed files with 723 additions and 170 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import type { PluginInfo, SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { DialogProvider } from "@opencode/ui/context/dialog"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { Toast } from "@opencode/ui/toast"
|
||||
import {
|
||||
Panel,
|
||||
|
|
@ -545,8 +546,9 @@ export function mountPanelWidths(input: { definitions: readonly Entry[]; active:
|
|||
|
||||
/**
|
||||
* Mounts the real host and HostApis, and the parts of the session screen that render plugin content for the routed
|
||||
* session: the side region (`createRegion`, `RegionContent`) with every panel's tabs stored, and the
|
||||
* `session.panel.end` slot, whose input the screen builds this way. `route(id)` routes a session the way the screen
|
||||
* session: the side region (`createRegion`, `RegionContent`) with every panel's tabs stored, the
|
||||
* `session.panel.end` slot, whose input the screen builds this way, and the `session.location.menu` slot inside a
|
||||
* "Project menu" the way the session header's project menu holds it. `route(id)` routes a session the way the screen
|
||||
* does: a new session object each time, delivered through the same reactive inputs, while the one screen object
|
||||
* `ctx.screen` returns follows the route. Its composer records which session each attachment reached.
|
||||
*/
|
||||
|
|
@ -627,6 +629,24 @@ export function mountSessionRegion(input: {
|
|||
screen,
|
||||
}}
|
||||
/>
|
||||
<Menu modal={false}>
|
||||
{/* Without text, so the cases that read the region's text see only the plugins'. */}
|
||||
<Menu.Trigger aria-label="Project menu" style={{ width: "16px", height: "16px" }} />
|
||||
<Menu.Portal>
|
||||
<Menu.Content>
|
||||
<PluginSlot
|
||||
at="session.location.menu"
|
||||
input={{
|
||||
get session() {
|
||||
return view()
|
||||
},
|
||||
screen,
|
||||
shown: true,
|
||||
}}
|
||||
/>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
<RegionContent
|
||||
pane={region.pane("side")}
|
||||
view={view()}
|
||||
|
|
|
|||
|
|
@ -303,7 +303,7 @@ story("a plugin's toasts close when it goes away, and a handle closes only its o
|
|||
expect(result).toEqual({ kept: true, setups: 2 })
|
||||
})
|
||||
|
||||
story("ctx.ui renders the app's own components in a plugin's contribution and dialog", async ({ page }) => {
|
||||
story("ctx.ui renders the app's components in a contribution, a host menu and a dialog", async ({ page }) => {
|
||||
await page.evaluate(async (fixture) => {
|
||||
const { mountSessionRegion, until, createComponent, createSignal, Slot } = await import(fixture)
|
||||
|
||||
|
|
@ -322,6 +322,7 @@ story("ctx.ui renders the app's own components in a plugin's contribution and di
|
|||
const ui = ctx.ui
|
||||
const [on, setOn] = createSignal(false)
|
||||
const [name, setName] = createSignal("")
|
||||
const [period, setPeriod] = createSignal("day")
|
||||
|
||||
const dialog = () =>
|
||||
ctx.dialogs.open((handle) =>
|
||||
|
|
@ -374,6 +375,64 @@ story("ctx.ui renders the app's own components in a plugin's contribution and di
|
|||
createComponent(ui.Keybind, { keys: ["Ctrl", "K"] }),
|
||||
createComponent(ui.Markdown, { text: "# Fixture heading\n\nSome *text*." }),
|
||||
createComponent(ui.Code, { content: "const fence = `\n```\n`", language: "ts" }),
|
||||
createComponent(ui.SettingsLayout, {
|
||||
title: "Fixture settings",
|
||||
description: "Fixture page description",
|
||||
get children() {
|
||||
return createComponent(ui.SettingsSection, {
|
||||
title: "Fixture section",
|
||||
footer: "Fixture footer",
|
||||
get children() {
|
||||
return createComponent(ui.SettingsRow, {
|
||||
id: "fixture-period",
|
||||
title: "Period",
|
||||
description: "How far back to count",
|
||||
get children() {
|
||||
return createComponent(ui.Select<"day" | "month">, {
|
||||
label: "Period",
|
||||
options: [
|
||||
{ value: "day", label: "Day" },
|
||||
{ value: "month", label: "Month" },
|
||||
],
|
||||
get value() {
|
||||
return period()
|
||||
},
|
||||
onChange: (value: "day" | "month") => {
|
||||
setPeriod(value)
|
||||
record(`select ${value}`)
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
ctx.add(Slot, {
|
||||
at: "session.location.menu",
|
||||
render: () => [
|
||||
createComponent(ui.MenuSeparator, {}),
|
||||
createComponent(ui.MenuItem, {
|
||||
label: "Usage",
|
||||
icon: "status",
|
||||
keys: ["U"],
|
||||
onSelect: () => record("usage"),
|
||||
}),
|
||||
createComponent(ui.MenuGroup, {
|
||||
label: "Fixture menu group",
|
||||
get children() {
|
||||
return createComponent(ui.MenuSub, {
|
||||
label: "Open in",
|
||||
onOpenChange: (open: boolean) => record(`sub ${open}`),
|
||||
get children() {
|
||||
return createComponent(ui.MenuItem, { label: "Editor", onSelect: () => record("editor") })
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
},
|
||||
|
|
@ -427,6 +486,43 @@ story("ctx.ui renders the app's own components in a plugin's contribution and di
|
|||
await expect(dialog.locator('[data-slot="dialog-close-button"]')).toHaveCount(1)
|
||||
await dialog.getByRole("button", { name: "Close fixture" }).click()
|
||||
await expect(dialog).toHaveCount(0)
|
||||
|
||||
// Settings components render the host's settings markup, and search finds the row by its id.
|
||||
await expect(root.locator(".settings-tab-header h2.settings-tab-title")).toHaveText("Fixture settings")
|
||||
const section = root.getByRole("region", { name: "Fixture section" })
|
||||
await expect(section.locator("h3.settings-section-title")).toHaveText("Fixture section")
|
||||
await expect(section).toContainText("Fixture footer")
|
||||
const row = section.locator('[data-component="settings-list"] > [data-component="settings-row"]')
|
||||
await expect(row).toHaveAttribute("data-action", "fixture-period")
|
||||
await expect(row.locator('[data-slot="settings-row-description"]')).toHaveText("How far back to count")
|
||||
|
||||
const select = row.locator('[data-slot="settings-row-control"] [data-component="select-v2"]')
|
||||
// The trigger is named by the label and the selected choice.
|
||||
await expect(select).toHaveAccessibleName("Period Day")
|
||||
await select.click()
|
||||
await page.getByRole("option", { name: "Month" }).click()
|
||||
await expect(select).toHaveAccessibleName("Period Month")
|
||||
|
||||
// Menu rows render inside the host's menu as its own rows: keyboard items, a group, and a submenu.
|
||||
await root.getByRole("button", { name: "Project menu" }).click()
|
||||
const project = page.getByRole("menu")
|
||||
await expect(project.getByRole("separator")).toHaveCount(1)
|
||||
const usage = project.getByRole("menuitem", { name: "Usage" })
|
||||
await expect(usage).toHaveAttribute("data-component", "menu-v2-item")
|
||||
await expect(usage.locator('use[href="#opencode-v2-icon-status"]')).toHaveCount(1)
|
||||
await expect(usage.locator('[data-component="keybind-v2"]')).toHaveText("U")
|
||||
await usage.click()
|
||||
await expect(project).toHaveCount(0)
|
||||
|
||||
await root.getByRole("button", { name: "Project menu" }).click()
|
||||
await expect(page.getByRole("menu").getByText("Fixture menu group")).toBeVisible()
|
||||
await page.getByRole("menuitem", { name: "Open in" }).hover()
|
||||
await page.getByRole("menu", { name: "Open in" }).getByRole("menuitem", { name: "Editor" }).click()
|
||||
await expect(page.getByRole("menu")).toHaveCount(0)
|
||||
await expect(page.locator("body")).toHaveAttribute(
|
||||
"data-ui",
|
||||
/^button;menu;submit acme;select month;usage;sub true;editor;/,
|
||||
)
|
||||
})
|
||||
|
||||
story(
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { IconButton } from "@opencode/ui/icon-button"
|
|||
import { Keybind } from "@opencode/ui/keybind"
|
||||
import { Loader } from "@opencode/ui/loader"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { Select } from "@opencode/ui/select"
|
||||
import { Switch } from "@opencode/ui/switch"
|
||||
import { TextInput } from "@opencode/ui/text-input"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
|
|
@ -100,17 +101,38 @@ export const ui: Ui = {
|
|||
return (
|
||||
<Menu.Group>
|
||||
<Menu.GroupLabel>{entry.label}</Menu.GroupLabel>
|
||||
<For each={entry.items}>{(item) => <MenuRow item={item} />}</For>
|
||||
<For each={entry.items}>{(item) => <MenuItem {...item} />}</For>
|
||||
</Menu.Group>
|
||||
)
|
||||
|
||||
return <MenuRow item={entry} />
|
||||
return <MenuItem {...entry} />
|
||||
}}
|
||||
</For>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
),
|
||||
MenuItem,
|
||||
MenuSeparator: () => <Menu.Separator />,
|
||||
MenuGroup: (props) => (
|
||||
<Menu.Group>
|
||||
<Menu.GroupLabel>{props.label}</Menu.GroupLabel>
|
||||
{props.children}
|
||||
</Menu.Group>
|
||||
),
|
||||
MenuSub: (props) => (
|
||||
<Menu.Sub gutter={4} overflowPadding={16} overlap onOpenChange={(open) => props.onOpenChange?.(open)}>
|
||||
<Menu.SubTrigger disabled={props.disabled} class="[&_[data-slot=menu-v2-item-chevron]]:text-v2-icon-icon-muted">
|
||||
<Show when={props.icon}>{(icon) => <Icon name={icon()} class="text-v2-icon-icon-muted" />}</Show>
|
||||
<span class="min-w-0 truncate">{props.label}</span>
|
||||
</Menu.SubTrigger>
|
||||
<Menu.Portal>
|
||||
<Menu.SubContent class="w-max min-w-[172px] max-w-[calc(100vw-32px)]" aria-label={props.label}>
|
||||
{props.children}
|
||||
</Menu.SubContent>
|
||||
</Menu.Portal>
|
||||
</Menu.Sub>
|
||||
),
|
||||
Loader: (props) => (
|
||||
<Loader
|
||||
width={loaderSize[props.size ?? "normal"]}
|
||||
|
|
@ -171,6 +193,22 @@ export const ui: Ui = {
|
|||
}}
|
||||
/>
|
||||
),
|
||||
Select: (props) => (
|
||||
<Select
|
||||
options={[...props.options]}
|
||||
current={props.options.find((option) => option.value === props.value)}
|
||||
value={(option) => option.value}
|
||||
label={(option) => option.label}
|
||||
placeholder={props.placeholder}
|
||||
aria-label={props.label}
|
||||
disabled={props.disabled}
|
||||
invalid={props.invalid}
|
||||
placement={props.placement ?? "bottom-end"}
|
||||
gutter={6}
|
||||
class={props.class}
|
||||
onSelect={(option) => option && props.onChange(option.value)}
|
||||
/>
|
||||
),
|
||||
Keybind: (props) => (
|
||||
<Show when={props.keys.length > 0}>
|
||||
<Keybind keys={[...props.keys]} variant="neutral" class={props.class} />
|
||||
|
|
@ -179,17 +217,60 @@ export const ui: Ui = {
|
|||
Markdown: (props) => <Markdown text={props.text} class={props.class} />,
|
||||
// The timeline's code block: a fenced block in Markdown, highlighted by the same worker.
|
||||
Code: (props) => <Markdown text={fence(props.content, props.language)} class={props.class} />,
|
||||
// The settings screen's markup. Its stylesheet loads with that screen, where every `SettingsPage` renders, so the
|
||||
// startup chunk does not carry it.
|
||||
SettingsLayout: (props) => (
|
||||
<>
|
||||
<div class="settings-tab-header">
|
||||
<div class="settings-tab-header-row">
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<h2 class="settings-tab-title">{props.title}</h2>
|
||||
<Show when={props.description}>
|
||||
<span class="text-11-regular text-v2-text-text-muted">{props.description}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="settings-tab-header-actions">{props.actions}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-tab-body settings-tab-body--sectioned">{props.children}</div>
|
||||
</>
|
||||
),
|
||||
SettingsSection: (props) => (
|
||||
<section class="settings-section" aria-label={props.title}>
|
||||
<Show when={props.title}>
|
||||
<h3 class="settings-section-title">{props.title}</h3>
|
||||
</Show>
|
||||
<div data-component="settings-list">{props.children}</div>
|
||||
{props.footer}
|
||||
</section>
|
||||
),
|
||||
SettingsRow: (props) => {
|
||||
const description = children(() => props.description)
|
||||
|
||||
// Settings search reveals the row whose `data-action` is the entry it picked.
|
||||
return (
|
||||
<div data-component="settings-row" data-action={props.id}>
|
||||
<div data-slot="settings-row-copy">
|
||||
<div data-slot="settings-row-title">{props.title}</div>
|
||||
<Show when={description()}>
|
||||
<div data-slot="settings-row-description">{description()}</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div data-slot="settings-row-control">{props.children}</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
function MenuRow(props: { item: UiMenuItem }) {
|
||||
function MenuItem(props: UiMenuItem) {
|
||||
return (
|
||||
<Menu.Item
|
||||
disabled={props.item.disabled}
|
||||
onSelect={() => props.item.onSelect()}
|
||||
shortcut={props.item.keys?.length ? <Keybind keys={[...props.item.keys]} variant="neutral" /> : undefined}
|
||||
disabled={props.disabled}
|
||||
onSelect={() => props.onSelect()}
|
||||
shortcut={props.keys?.length ? <Keybind keys={[...props.keys]} variant="neutral" /> : undefined}
|
||||
>
|
||||
<Show when={props.item.icon}>{(icon) => <Icon name={icon()} class="text-v2-icon-icon-muted" />}</Show>
|
||||
<span class="min-w-0 truncate">{props.item.label}</span>
|
||||
<Show when={props.icon}>{(icon) => <Icon name={icon()} class="text-v2-icon-icon-muted" />}</Show>
|
||||
<span class="min-w-0 truncate">{props.label}</span>
|
||||
</Menu.Item>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -236,7 +236,7 @@ const screenActive = useQuery(() => ({
|
|||
|
||||
Each line links to the file whose TSDoc covers every field.
|
||||
|
||||
SDK icon fields take an `IconName` from `@opencode/plugin/gui`: the names of the app's icon catalog (`@opencode/ui/icons/catalog`), which `bun run generate` in `packages/plugin` copies into `packages/plugin/src/gui/icon-name.ts`. `src/icon-name.typecheck.ts` fails the typecheck when the two drift. A plugin bundle cannot require `@opencode/ui`, so it renders an icon with `ctx.ui.Icon`, and the app's buttons, menus, dialog frame, fields, Markdown and code with the other `ctx.ui` components.
|
||||
SDK icon fields take an `IconName` from `@opencode/plugin/gui`: the names of the app's icon catalog (`@opencode/ui/icons/catalog`), which `bun run generate` in `packages/plugin` copies into `packages/plugin/src/gui/icon-name.ts`. `src/icon-name.typecheck.ts` fails the typecheck when the two drift. A plugin bundle cannot require `@opencode/ui`, so it renders an icon with `ctx.ui.Icon`, and the app's buttons, menus, menu rows, dialog frame, fields, selects, settings pages and rows, Markdown and code with the other `ctx.ui` components. Built-ins use them too where they need nothing more, such as the settings rows of pairing, updates and review.
|
||||
|
||||
### Registries
|
||||
|
||||
|
|
@ -257,7 +257,8 @@ SDK icon fields take an `IconName` from `@opencode/plugin/gui`: the names of the
|
|||
|
||||
`session.location.menu` contributes items after the project menu's location details. It receives the owning `screen`,
|
||||
the live `session`, and `shown`; keep data subscriptions active only while `shown` is true. Loading headers without an
|
||||
owning screen show the host's location details without this slot.
|
||||
owning screen show the host's location details without this slot. Render its rows with `ctx.ui.MenuItem`,
|
||||
`ctx.ui.MenuSub`, `ctx.ui.MenuGroup` and `ctx.ui.MenuSeparator`, so they navigate and close like the host's rows.
|
||||
|
||||
`session.location.branch.menu` (built-ins only, through `BuiltinSlot`) contributes items after Copy branch name in
|
||||
the branch submenu, with the same `screen`, live `session`, and project-menu `shown` context. It renders only when a
|
||||
|
|
@ -291,7 +292,7 @@ to the displayed repository or worktree instead of adding a second menu item.
|
|||
| `ctx.commands` | `Commands` | Runs a published or app command, as the palette does |
|
||||
| `ctx.links` | `Links` | Opens a local link, or checks it exists, with the best handler |
|
||||
| `ctx.embeds` | `Embeds` | Shows a web page the main entry created |
|
||||
| `ctx.ui` | `Ui` | The app's icons, buttons, menus, dialog frame, Markdown and code |
|
||||
| `ctx.ui` | `Ui` | Icons, buttons, menu rows, dialog frame, fields, settings rows |
|
||||
| `ctx.app` | `App` | Version, channel, platform, packaged |
|
||||
| `ctx.locale` | `Locale` | Locale and writing direction |
|
||||
| `ctx.appearance` | `Appearance` | The user's mono font, and the light or dark scheme |
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { Button } from "@opencode/ui/button"
|
||||
import { Dialog, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog"
|
||||
import { Loader } from "@opencode/ui/loader"
|
||||
import { Switch } from "@opencode/ui/switch"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/solid-query"
|
||||
import { createMemo, createSignal, onCleanup, Show, type Accessor, type JSX } from "solid-js"
|
||||
import { createMemo, createSignal, onCleanup, Show, type Accessor } from "solid-js"
|
||||
import { renderSVG } from "@opencode/util/qr"
|
||||
import { usePlugin, type Live, type IpcClient } from "@opencode/plugin/gui"
|
||||
import type { Pairing } from "./contract"
|
||||
|
|
@ -33,6 +32,7 @@ export default function PairingPage(props: {
|
|||
pairing: Accessor<Live<Client>>
|
||||
}) {
|
||||
const ctx = usePlugin<typeof definition>()
|
||||
const ui = ctx.ui
|
||||
// The plugin's dialogs close with it, so disabling pairing also stops the dialog's code polling.
|
||||
const dialogs = ctx.dialogs
|
||||
const queryClient = useQueryClient()
|
||||
|
|
@ -140,74 +140,67 @@ export default function PairingPage(props: {
|
|||
}))
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="settings-tab-header">
|
||||
<div class="settings-tab-header-row">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="settings-tab-title">{ctx.t("title")}</h2>
|
||||
<span class="text-11-regular text-v2-text-text-muted">{ctx.t("description")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-tab-body settings-tab-body--sectioned">
|
||||
<section class="settings-section" aria-label={ctx.t("title")}>
|
||||
<div data-component="settings-list">
|
||||
<Row title={ctx.t("pair.title")} description={ctx.t("pair.description")}>
|
||||
<Button
|
||||
variant="neutral"
|
||||
disabled={!main() || !props.server() || (change.isPending && change.variables?.enabled === false)}
|
||||
onClick={pair}
|
||||
>
|
||||
{ctx.t("pair.open")}
|
||||
</Button>
|
||||
</Row>
|
||||
<Show when={main()}>
|
||||
<div data-action="settings-remote-access" class="border-b-[0.5px] border-v2-border-border-base">
|
||||
<Row title={ctx.t("remote.title")} description={ctx.t("remote.description")}>
|
||||
<Switch
|
||||
hideLabel
|
||||
checked={change.isPending ? change.variables?.enabled === true : remote.data === true}
|
||||
disabled={!remote.isSuccess || change.isPending}
|
||||
onChange={(enabled) => change.mutate({ enabled })}
|
||||
>
|
||||
{ctx.t("remote.title")}
|
||||
</Switch>
|
||||
</Row>
|
||||
</div>
|
||||
<div data-action="settings-keep-screen-active">
|
||||
<Row title={ctx.t("screenActive.title")} description={ctx.t("screenActive.description")}>
|
||||
<Switch
|
||||
hideLabel
|
||||
checked={screenActive.isSuccess && screenActive.data}
|
||||
disabled={screenActive.isPending || !!screenActive.error || screenActivity.isPending}
|
||||
onChange={(enabled) => screenActivity.mutate(enabled)}
|
||||
>
|
||||
{ctx.t("screenActive.title")}
|
||||
</Switch>
|
||||
</Row>
|
||||
</div>
|
||||
<ui.SettingsLayout title={ctx.t("title")} description={ctx.t("description")}>
|
||||
<ui.SettingsSection
|
||||
footer={
|
||||
<>
|
||||
<Show when={main() && (remote.error || change.error)}>
|
||||
<p class="text-text-danger-base" role="alert">
|
||||
{ctx.t("remote.error")}
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={main() && (remote.error || change.error)}>
|
||||
<p class="text-text-danger-base" role="alert">
|
||||
{ctx.t("remote.error")}
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={main() && (screenActive.error || screenActivity.error)}>
|
||||
<p class="text-text-danger-base" role="alert">
|
||||
{ctx.t("screenActive.error")}
|
||||
</p>
|
||||
</Show>
|
||||
{/* Pending main is still starting; anything else that leaves it inactive keeps pairing from turning on. */}
|
||||
<Show when={props.pairing().status === "inactive" || !props.server()}>
|
||||
<p class="text-text-danger-base" role="alert">
|
||||
{ctx.t("pair.unavailable")}
|
||||
</p>
|
||||
</Show>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
<Show when={main() && (screenActive.error || screenActivity.error)}>
|
||||
<p class="text-text-danger-base" role="alert">
|
||||
{ctx.t("screenActive.error")}
|
||||
</p>
|
||||
</Show>
|
||||
{/* Pending main is still starting; anything else that leaves it inactive keeps pairing from turning on. */}
|
||||
<Show when={props.pairing().status === "inactive" || !props.server()}>
|
||||
<p class="text-text-danger-base" role="alert">
|
||||
{ctx.t("pair.unavailable")}
|
||||
</p>
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ui.SettingsRow title={ctx.t("pair.title")} description={ctx.t("pair.description")}>
|
||||
<ui.Button
|
||||
disabled={!main() || !props.server() || (change.isPending && change.variables?.enabled === false)}
|
||||
onClick={pair}
|
||||
>
|
||||
{ctx.t("pair.open")}
|
||||
</ui.Button>
|
||||
</ui.SettingsRow>
|
||||
<Show when={main()}>
|
||||
<ui.SettingsRow
|
||||
id="settings-remote-access"
|
||||
title={ctx.t("remote.title")}
|
||||
description={ctx.t("remote.description")}
|
||||
>
|
||||
<ui.Switch
|
||||
label={ctx.t("remote.title")}
|
||||
hideLabel
|
||||
checked={change.isPending ? change.variables?.enabled === true : remote.data === true}
|
||||
disabled={!remote.isSuccess || change.isPending}
|
||||
onChange={(enabled) => change.mutate({ enabled })}
|
||||
/>
|
||||
</ui.SettingsRow>
|
||||
<ui.SettingsRow
|
||||
id="settings-keep-screen-active"
|
||||
title={ctx.t("screenActive.title")}
|
||||
description={ctx.t("screenActive.description")}
|
||||
>
|
||||
<ui.Switch
|
||||
label={ctx.t("screenActive.title")}
|
||||
hideLabel
|
||||
checked={screenActive.isSuccess && screenActive.data}
|
||||
disabled={screenActive.isPending || !!screenActive.error || screenActivity.isPending}
|
||||
onChange={(enabled) => screenActivity.mutate(enabled)}
|
||||
/>
|
||||
</ui.SettingsRow>
|
||||
</Show>
|
||||
</ui.SettingsSection>
|
||||
</ui.SettingsLayout>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -401,16 +394,3 @@ function DialogPairing(props: {
|
|||
function countdown(seconds: number) {
|
||||
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
// The host's settings row markup; its stylesheet is loaded with the settings screen.
|
||||
function Row(props: { title: string; description: string; children: JSX.Element }) {
|
||||
return (
|
||||
<div data-component="settings-row">
|
||||
<div data-slot="settings-row-copy">
|
||||
<div data-slot="settings-row-title">{props.title}</div>
|
||||
<div data-slot="settings-row-description">{props.description}</div>
|
||||
</div>
|
||||
<div data-slot="settings-row-control">{props.children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,34 +1,28 @@
|
|||
import { Switch } from "@opencode/ui/switch"
|
||||
import { usePlugin } from "@opencode/plugin/gui"
|
||||
import type Review from "./index"
|
||||
|
||||
/** The narrow-screen diff wrap row of the host's General settings section, in the host's settings row markup. */
|
||||
/** The narrow-screen diff wrap row of the host's General settings section. */
|
||||
export default function WrapLinesRow() {
|
||||
const ctx = usePlugin<typeof Review>()
|
||||
const ui = ctx.ui
|
||||
const mobileDiff = ctx.stores.mobileDiff
|
||||
|
||||
return (
|
||||
<div data-component="settings-row">
|
||||
<div data-slot="settings-row-copy">
|
||||
<div data-slot="settings-row-title">{ctx.t("settings.wrapLines.title")}</div>
|
||||
<div data-slot="settings-row-description">{ctx.t("settings.wrapLines.description")}</div>
|
||||
</div>
|
||||
<div data-slot="settings-row-control">
|
||||
<div data-action="settings-mobile-diff-wrap">
|
||||
<Switch
|
||||
aria-label={ctx.t("settings.wrapLines.title")}
|
||||
checked={mobileDiff.value.wrap}
|
||||
onChange={(checked) =>
|
||||
mobileDiff.update((draft) => {
|
||||
draft.wrap = checked
|
||||
})
|
||||
}
|
||||
hideLabel
|
||||
>
|
||||
{ctx.t("settings.wrapLines.title")}
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ui.SettingsRow
|
||||
id="settings-mobile-diff-wrap"
|
||||
title={ctx.t("settings.wrapLines.title")}
|
||||
description={ctx.t("settings.wrapLines.description")}
|
||||
>
|
||||
<ui.Switch
|
||||
label={ctx.t("settings.wrapLines.title")}
|
||||
hideLabel
|
||||
checked={mobileDiff.value.wrap}
|
||||
onChange={(checked) =>
|
||||
mobileDiff.update((draft) => {
|
||||
draft.wrap = checked
|
||||
})
|
||||
}
|
||||
/>
|
||||
</ui.SettingsRow>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import { createMemo, type JSX } from "solid-js"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { Switch } from "@opencode/ui/switch"
|
||||
import { createMemo } from "solid-js"
|
||||
import { usePlugin } from "@opencode/plugin/gui"
|
||||
import { updaterAction } from "./action"
|
||||
import type { UpdaterState } from "@opencode/plugin/gui/updater"
|
||||
|
|
@ -8,52 +6,34 @@ import type definition from "./index"
|
|||
|
||||
export default function UpdatesSection(props: { state: () => UpdaterState | undefined; run: () => void }) {
|
||||
const ctx = usePlugin<typeof definition>()
|
||||
const ui = ctx.ui
|
||||
const releaseNotes = ctx.stores.releaseNotes
|
||||
const action = createMemo(() => updaterAction(props.state()))
|
||||
|
||||
return (
|
||||
<div class="settings-section">
|
||||
<h3 class="settings-section-title">{ctx.t("section.title")}</h3>
|
||||
<ui.SettingsSection title={ctx.t("section.title")}>
|
||||
<ui.SettingsRow
|
||||
id="settings-release-notes"
|
||||
title={ctx.t("releaseNotes.title")}
|
||||
description={ctx.t("releaseNotes.description")}
|
||||
>
|
||||
<ui.Switch
|
||||
label={ctx.t("releaseNotes.title")}
|
||||
hideLabel
|
||||
checked={releaseNotes.value.enabled}
|
||||
onChange={(checked) =>
|
||||
releaseNotes.update((draft) => {
|
||||
draft.enabled = checked
|
||||
})
|
||||
}
|
||||
/>
|
||||
</ui.SettingsRow>
|
||||
|
||||
<div data-component="settings-list">
|
||||
<Row title={ctx.t("releaseNotes.title")} description={ctx.t("releaseNotes.description")}>
|
||||
<div data-action="settings-release-notes">
|
||||
<Switch
|
||||
checked={releaseNotes.value.enabled}
|
||||
onChange={(checked) =>
|
||||
releaseNotes.update((draft) => {
|
||||
draft.enabled = checked
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
<Row title={ctx.t("check.title")} description={ctx.t("check.description")}>
|
||||
<Button
|
||||
data-action="settings-check-updates"
|
||||
size="normal"
|
||||
variant="neutral"
|
||||
disabled={!action().run}
|
||||
onClick={() => props.run()}
|
||||
>
|
||||
{ctx.t(action().label)}
|
||||
</Button>
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// The host's settings row markup; its stylesheet is loaded with the settings screen.
|
||||
function Row(props: { title: string; description: string; children: JSX.Element }) {
|
||||
return (
|
||||
<div data-component="settings-row">
|
||||
<div data-slot="settings-row-copy">
|
||||
<div data-slot="settings-row-title">{props.title}</div>
|
||||
<div data-slot="settings-row-description">{props.description}</div>
|
||||
</div>
|
||||
<div data-slot="settings-row-control">{props.children}</div>
|
||||
</div>
|
||||
<ui.SettingsRow id="settings-check-updates" title={ctx.t("check.title")} description={ctx.t("check.description")}>
|
||||
<ui.Button disabled={!action().run} onClick={() => props.run()}>
|
||||
{ctx.t(action().label)}
|
||||
</ui.Button>
|
||||
</ui.SettingsRow>
|
||||
</ui.SettingsSection>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -214,8 +214,8 @@ export interface Context extends BaseContext {
|
|||
*/
|
||||
readonly embeds: Embeds
|
||||
/**
|
||||
* The app's own components: icons, buttons, tooltips, menus, the loading indicator, the dialog frame, switches,
|
||||
* text fields, keybind chips, Markdown and highlighted code.
|
||||
* The app's own components: icons, buttons, tooltips, menus and menu rows, the loading indicator, the dialog frame,
|
||||
* switches, text fields, selects, keybind chips, Markdown, highlighted code, and settings pages, sections and rows.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
|
|
|
|||
|
|
@ -1780,7 +1780,7 @@ export interface UiTooltipProps {
|
|||
readonly children: JSX.Element
|
||||
}
|
||||
|
||||
/** An action in `UiMenuProps.items`. */
|
||||
/** An action in `UiMenuProps.items`, and the props of `Ui.MenuItem`. */
|
||||
export interface UiMenuItem {
|
||||
/** Marks the entry as an action. Optional: an entry without `type` is an action. */
|
||||
readonly type?: "item"
|
||||
|
|
@ -1835,6 +1835,28 @@ export interface UiMenuProps {
|
|||
readonly disabled?: boolean
|
||||
}
|
||||
|
||||
/** Props of `Ui.MenuGroup`. */
|
||||
export interface UiMenuGroupProps {
|
||||
/** The group's heading. Pass translated copy. */
|
||||
readonly label: string
|
||||
/** The group's rows, such as `Ui.MenuItem` and `Ui.MenuSub`. */
|
||||
readonly children: JSX.Element
|
||||
}
|
||||
|
||||
/** Props of `Ui.MenuSub`. */
|
||||
export interface UiMenuSubProps {
|
||||
/** The text of the row that opens the submenu, which also names the submenu for screen readers. */
|
||||
readonly label: string
|
||||
/** An icon before the text. Omit it for none. */
|
||||
readonly icon?: IconName
|
||||
/** The row cannot open the submenu. Defaults to false. */
|
||||
readonly disabled?: boolean
|
||||
/** Runs with true when the submenu opens and false when it closes, for example to load its rows only while open. */
|
||||
readonly onOpenChange?: (open: boolean) => void
|
||||
/** The submenu's rows, such as `Ui.MenuItem`, `Ui.MenuSeparator` and `Ui.MenuGroup`. */
|
||||
readonly children: JSX.Element
|
||||
}
|
||||
|
||||
/** Props of `Ui.Loader`. */
|
||||
export interface UiLoaderProps {
|
||||
/**
|
||||
|
|
@ -1955,6 +1977,79 @@ export interface UiCodeProps {
|
|||
readonly class?: string
|
||||
}
|
||||
|
||||
/** One choice in `UiSelectProps.options`. */
|
||||
export interface UiSelectOption<T extends string = string> {
|
||||
/** The value `onChange` receives. Unique among the options. */
|
||||
readonly value: T
|
||||
/** The choice's text, in the list and on the closed select. Pass translated copy. */
|
||||
readonly label: string
|
||||
}
|
||||
|
||||
/** Props of `Ui.Select`. */
|
||||
export interface UiSelectProps<T extends string = string> {
|
||||
/** The choices, in order. */
|
||||
readonly options: readonly UiSelectOption<T>[]
|
||||
/** The selected choice's value. Undefined, or a value no option has, shows `placeholder`. */
|
||||
readonly value: T | undefined
|
||||
/**
|
||||
* Runs with the value the user picks. Pass it on to `value` to keep the select in step. Picking the selected choice
|
||||
* again does not call it.
|
||||
*/
|
||||
readonly onChange: (value: T) => void
|
||||
/** The select's accessible name, which screen readers announce. Pass translated copy; it is not shown. */
|
||||
readonly label: string
|
||||
/** Text the select shows while no choice is selected. Omit it for none. */
|
||||
readonly placeholder?: string
|
||||
/** The select cannot be opened. Defaults to false. */
|
||||
readonly disabled?: boolean
|
||||
/** Shows the select as invalid. Defaults to false. */
|
||||
readonly invalid?: boolean
|
||||
/**
|
||||
* Where the list opens from the select. Defaults to `bottom-end`, as the app's settings rows open theirs; it flips
|
||||
* when there is no room.
|
||||
*/
|
||||
readonly placement?: "bottom-start" | "bottom-end" | "top-start" | "top-end"
|
||||
/** Classes of the select, for layout. The app's styles give it its look. */
|
||||
readonly class?: string
|
||||
}
|
||||
|
||||
/** Props of `Ui.SettingsLayout`. */
|
||||
export interface UiSettingsLayoutProps {
|
||||
/** The page's title, at the top of the page. Pass translated copy, usually the `SettingsPage.title`. */
|
||||
readonly title: string
|
||||
/** Text under the title. Omit it for none. */
|
||||
readonly description?: string
|
||||
/** Controls at the end of the title row, such as a `Ui.Button` that opens a website. Omit it for none. */
|
||||
readonly actions?: JSX.Element
|
||||
/** The page's sections, usually `Ui.SettingsSection`s. */
|
||||
readonly children: JSX.Element
|
||||
}
|
||||
|
||||
/** Props of `Ui.SettingsSection`. */
|
||||
export interface UiSettingsSectionProps {
|
||||
/** The section's heading, which also names it for screen readers. Omit it for a section without a heading. */
|
||||
readonly title?: string
|
||||
/** The section's rows, usually `Ui.SettingsRow`s, in the app's bordered list. */
|
||||
readonly children: JSX.Element
|
||||
/** Content under the list, outside its border, such as an error that concerns the rows. Omit it for none. */
|
||||
readonly footer?: JSX.Element
|
||||
}
|
||||
|
||||
/** Props of `Ui.SettingsRow`. */
|
||||
export interface UiSettingsRowProps {
|
||||
/** The setting's name. Pass translated copy. */
|
||||
readonly title: JSX.Element
|
||||
/** Text under the title that says what the setting does. Omit it for none. */
|
||||
readonly description?: JSX.Element
|
||||
/**
|
||||
* The `SettingEntry.id` that reveals this row: when the user picks the entry in settings search, the host scrolls
|
||||
* to the row and highlights it. Omit it for a row that search does not list.
|
||||
*/
|
||||
readonly id?: string
|
||||
/** The row's control at its end, such as a `Ui.Switch`, `Ui.Select` or `Ui.Button`. Omit it for none. */
|
||||
readonly children?: JSX.Element
|
||||
}
|
||||
|
||||
/**
|
||||
* The app's own components, so a plugin's UI looks and behaves like the app's: the same buttons, menus, dialogs and
|
||||
* fields, in the user's theme, fonts and language. Each takes a small set of props the SDK keeps stable. A component
|
||||
|
|
@ -2018,6 +2113,64 @@ export interface Ui {
|
|||
* ```
|
||||
*/
|
||||
Menu(props: UiMenuProps): JSX.Element
|
||||
/**
|
||||
* One action row of a menu the host renders, such as the `session.location.menu` slot, so your rows look and
|
||||
* behave like the host's: keyboard navigation, the muted icon, shortcut chips, and the menu closing on a pick. It
|
||||
* also renders inside `Ui.MenuGroup` and `Ui.MenuSub`. It takes the fields of an entry of `UiMenuProps.items`.
|
||||
* Outside a menu it throws, so the host records the error and the contribution renders nothing.
|
||||
*
|
||||
* @param props - The row's text, icon, keys and action.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* ctx.add(Slot, {
|
||||
* at: "session.location.menu",
|
||||
* render: (input) => <ctx.ui.MenuItem label={ctx.t("usage")} icon="status" onSelect={() => open(input.session)} />,
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
MenuItem(props: UiMenuItem): JSX.Element
|
||||
/**
|
||||
* A line between rows of a menu the host renders, or of a `Ui.MenuSub`, with the menu's spacing and color.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <>
|
||||
* <ctx.ui.MenuSeparator />
|
||||
* <ctx.ui.MenuItem label={ctx.t("usage")} onSelect={open} />
|
||||
* </>
|
||||
* ```
|
||||
*/
|
||||
MenuSeparator(): JSX.Element
|
||||
/**
|
||||
* Rows of a menu the host renders under a heading, as `UiMenuGroup` groups the entries of `Ui.Menu`. Outside a menu
|
||||
* it throws, as `Ui.MenuItem` does.
|
||||
*
|
||||
* @param props - The heading and its rows.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ctx.ui.MenuGroup label={ctx.t("usage")}>
|
||||
* <ctx.ui.MenuItem label={ctx.t("today")} onSelect={() => open("today")} />
|
||||
* <ctx.ui.MenuItem label={ctx.t("month")} onSelect={() => open("month")} />
|
||||
* </ctx.ui.MenuGroup>
|
||||
* ```
|
||||
*/
|
||||
MenuGroup(props: UiMenuGroupProps): JSX.Element
|
||||
/**
|
||||
* A row of a menu the host renders that opens a submenu beside it, with the host's chevron, as the location menu's
|
||||
* branch and folder rows do. Outside a menu it throws, as `Ui.MenuItem` does.
|
||||
*
|
||||
* @param props - The row's text and icon, and the submenu's rows.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ctx.ui.MenuSub label={ctx.t("openIn")} icon="outline-share">
|
||||
* <For each={apps()}>{(app) => <ctx.ui.MenuItem label={app.name} onSelect={() => launch(app)} />}</For>
|
||||
* </ctx.ui.MenuSub>
|
||||
* ```
|
||||
*/
|
||||
MenuSub(props: UiMenuSubProps): JSX.Element
|
||||
/**
|
||||
* The app's loading indicator, for anything that loads, connects or runs that is not the AI working.
|
||||
*
|
||||
|
|
@ -2067,6 +2220,26 @@ export interface Ui {
|
|||
* ```
|
||||
*/
|
||||
TextInput(props: UiTextInputProps): JSX.Element
|
||||
/**
|
||||
* A select that opens a list of choices, as the app's settings rows pick a language or a width. The value is a
|
||||
* string; `T` narrows it to your choices' values.
|
||||
*
|
||||
* @param props - The choices, the selected value and its handler.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ctx.ui.Select
|
||||
* label={ctx.t("period")}
|
||||
* options={[
|
||||
* { value: "day", label: ctx.t("period.day") },
|
||||
* { value: "month", label: ctx.t("period.month") },
|
||||
* ]}
|
||||
* value={period()}
|
||||
* onChange={setPeriod}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
Select<T extends string>(props: UiSelectProps<T>): JSX.Element
|
||||
/**
|
||||
* Keys shown as chips, as menus and tooltips show shortcuts.
|
||||
*
|
||||
|
|
@ -2103,4 +2276,58 @@ export interface Ui {
|
|||
* ```
|
||||
*/
|
||||
Code(props: UiCodeProps): JSX.Element
|
||||
/**
|
||||
* The frame of a settings page of your own (a `SettingsPage` without `page`): the app's page header with the title,
|
||||
* then your sections, spaced as the app's pages are. Search that reveals the page itself highlights its header. The
|
||||
* settings components take their look from the Settings screen's styles, so render them from a `SettingsPage`.
|
||||
*
|
||||
* @param props - The page's title, header actions and sections.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* ctx.add(SettingsPage, {
|
||||
* id: "acme",
|
||||
* get title() {
|
||||
* return ctx.t("title")
|
||||
* },
|
||||
* render: () => (
|
||||
* <ctx.ui.SettingsLayout title={ctx.t("title")} description={ctx.t("description")}>
|
||||
* <ctx.ui.SettingsSection title={ctx.t("sync")}>{rows()}</ctx.ui.SettingsSection>
|
||||
* </ctx.ui.SettingsLayout>
|
||||
* ),
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
SettingsLayout(props: UiSettingsLayoutProps): JSX.Element
|
||||
/**
|
||||
* A section of settings rows: an optional heading over the app's bordered list. Render it in `SettingsLayout`, or on
|
||||
* its own as a section on a host page (`SettingsPage.page`).
|
||||
*
|
||||
* @param props - The heading and rows.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ctx.ui.SettingsSection title={ctx.t("sync")}>
|
||||
* <ctx.ui.SettingsRow id="acme-sync" title={ctx.t("sync")} description={ctx.t("sync.description")}>
|
||||
* <ctx.ui.Switch label={ctx.t("sync")} hideLabel checked={sync()} onChange={setSync} />
|
||||
* </ctx.ui.SettingsRow>
|
||||
* </ctx.ui.SettingsSection>
|
||||
* ```
|
||||
*/
|
||||
SettingsSection(props: UiSettingsSectionProps): JSX.Element
|
||||
/**
|
||||
* One setting, as the app's own settings rows show it: its title and description, and its control at the end. Render
|
||||
* it in a `SettingsSection`, or on its own as rows in a host section (`SettingsPage.section`), where it lines up
|
||||
* with the host's rows.
|
||||
*
|
||||
* @param props - The setting's text, search id and control.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ctx.ui.SettingsRow id="acme-sync" title={ctx.t("sync")} description={ctx.t("sync.description")}>
|
||||
* <ctx.ui.Switch label={ctx.t("sync")} hideLabel checked={sync()} onChange={setSync} />
|
||||
* </ctx.ui.SettingsRow>
|
||||
* ```
|
||||
*/
|
||||
SettingsRow(props: UiSettingsRowProps): JSX.Element
|
||||
}
|
||||
|
|
|
|||
|
|
@ -413,7 +413,8 @@ export interface SettingsPage {
|
|||
/** Search metadata, indexed without mounting the page. */
|
||||
readonly entries?: readonly SettingEntry[]
|
||||
/**
|
||||
* Renders the page, section or rows.
|
||||
* Renders the page, section or rows: `Ui.SettingsLayout` frames a page, `Ui.SettingsSection` a section, and
|
||||
* `Ui.SettingsRow` is one row, each as the app's own settings render them.
|
||||
*
|
||||
* @param input - `target` is the entry search is revealing.
|
||||
*/
|
||||
|
|
@ -799,7 +800,10 @@ export interface SlotMap {
|
|||
*/
|
||||
readonly compact: boolean
|
||||
}
|
||||
/** Items in the project/location menu, after its location details. Omitted on loading headers without a screen. */
|
||||
/**
|
||||
* Items in the project/location menu, after its location details. Omitted on loading headers without a screen.
|
||||
* Render rows with `Ui.MenuItem`, `Ui.MenuSub`, `Ui.MenuGroup` and `Ui.MenuSeparator`, so they match the host's.
|
||||
*/
|
||||
readonly "session.location.menu": {
|
||||
/** The menu's owning session screen; always present. */
|
||||
readonly screen: SessionScreen
|
||||
|
|
|
|||
|
|
@ -951,6 +951,28 @@ ctx.add(SettingsPage, {
|
|||
id as `target`.
|
||||
- A settings page has a `render`, so the host keeps it stable. Read its labels and entries from getters.
|
||||
|
||||
Render the page with the app's settings components, so it looks like the app's own pages and search can reveal its
|
||||
rows:
|
||||
|
||||
```tsx
|
||||
const ui = ctx.ui
|
||||
|
||||
const AcmeSettings = () => (
|
||||
<ui.SettingsLayout title={ctx.t("title")} description={ctx.t("description")}>
|
||||
<ui.SettingsSection title={ctx.t("settings.section")}>
|
||||
<ui.SettingsRow id="sync" title={ctx.t("settings.sync")} description={ctx.t("settings.sync.description")}>
|
||||
<ui.Switch label={ctx.t("settings.sync")} hideLabel checked={sync()} onChange={setSync} />
|
||||
</ui.SettingsRow>
|
||||
</ui.SettingsSection>
|
||||
</ui.SettingsLayout>
|
||||
)
|
||||
```
|
||||
|
||||
- A page of your own renders `SettingsLayout`, a section on a host page (`page`) renders `SettingsSection`, and rows
|
||||
in a host section (`section`) render `SettingsRow`s.
|
||||
- A row's `id` is the `SettingEntry.id` that reveals it: search scrolls to the row and highlights it.
|
||||
- See [Settings components](#settings-components) for their props.
|
||||
|
||||
#### Reference
|
||||
|
||||
```ts
|
||||
|
|
@ -1003,6 +1025,16 @@ ctx.add(Slot, {
|
|||
})
|
||||
```
|
||||
|
||||
The project menu is a host menu: render its rows with `ctx.ui.MenuItem`, `MenuSub`, `MenuGroup`, and `MenuSeparator`,
|
||||
so they navigate with the keyboard and close the menu like the host's rows. See [Menu rows](#menu-rows).
|
||||
|
||||
```tsx
|
||||
ctx.add(Slot, {
|
||||
at: "session.location.menu",
|
||||
render: (input) => <ctx.ui.MenuItem label={ctx.t("usage")} icon="status" onSelect={() => openUsage(input.session)} />,
|
||||
})
|
||||
```
|
||||
|
||||
#### Reference
|
||||
|
||||
```ts
|
||||
|
|
@ -2049,6 +2081,86 @@ ctx.dialogs.open((dialog) => (
|
|||
sizes.
|
||||
- Escape and a click outside close the dialog. `closable: false` hides the header's close button.
|
||||
|
||||
#### Menu rows
|
||||
|
||||
`MenuItem`, `MenuSub`, `MenuGroup`, and `MenuSeparator` are rows for a menu the host renders, such as the
|
||||
`session.location.menu` slot. They navigate with the keyboard, show the host's muted icons and shortcut chips, and close
|
||||
the menu on a pick.
|
||||
|
||||
```tsx
|
||||
import { For } from "solid-js"
|
||||
|
||||
const ui = ctx.ui
|
||||
|
||||
ctx.add(Slot, {
|
||||
at: "session.location.menu",
|
||||
render: (input) => (
|
||||
<>
|
||||
<ui.MenuItem
|
||||
label={ctx.t("usage")}
|
||||
icon="status"
|
||||
keys={["Ctrl", "U"]}
|
||||
onSelect={() => openUsage(input.session)}
|
||||
/>
|
||||
<ui.MenuSub label={ctx.t("openIn")} icon="outline-share">
|
||||
<For each={apps()}>{(app) => <ui.MenuItem label={app.name} onSelect={() => launch(app, input.session)} />}</For>
|
||||
</ui.MenuSub>
|
||||
</>
|
||||
),
|
||||
})
|
||||
```
|
||||
|
||||
- `MenuItem` takes the same fields as an entry of `Menu`'s `items`.
|
||||
- `MenuSub` opens its children in a submenu beside the row. `onOpenChange` tells you when, so you can load its rows
|
||||
only while it is open.
|
||||
- `MenuGroup` puts rows under a heading, and `MenuSeparator` draws a line between rows.
|
||||
- `MenuItem`, `MenuSub`, and `MenuGroup` throw outside a menu: the host records the error and renders nothing of that
|
||||
contribution.
|
||||
|
||||
#### Selects
|
||||
|
||||
`Select` picks one of a list of choices, as the app's settings rows pick a language. Its value is a string; the type of
|
||||
`options` narrows it.
|
||||
|
||||
```tsx
|
||||
const [period, setPeriod] = createSignal<"day" | "month">("day")
|
||||
|
||||
<ui.Select
|
||||
label={ctx.t("period")}
|
||||
options={[
|
||||
{ value: "day", label: ctx.t("period.day") },
|
||||
{ value: "month", label: ctx.t("period.month") },
|
||||
]}
|
||||
value={period()}
|
||||
onChange={setPeriod}
|
||||
/>
|
||||
```
|
||||
|
||||
- `label` is the select's accessible name; the selected option's `label` is what it shows.
|
||||
- A `value` that no option has shows `placeholder`. Picking the selected choice again does not call `onChange`.
|
||||
|
||||
#### Settings components
|
||||
|
||||
`SettingsLayout`, `SettingsSection`, and `SettingsRow` render a [settings page](#settings-pages) the way the app renders
|
||||
its own: the page header, sections with headings over bordered lists, and rows with a title, a description, and a
|
||||
control at the end.
|
||||
|
||||
```tsx
|
||||
<ui.SettingsLayout title={ctx.t("title")} description={ctx.t("description")} actions={<HelpButton />}>
|
||||
<ui.SettingsSection title={ctx.t("section.display")} footer={<Show when={error()}>{ctx.t("error")}</Show>}>
|
||||
<ui.SettingsRow id="acme-period" title={ctx.t("period")} description={ctx.t("period.description")}>
|
||||
<ui.Select label={ctx.t("period")} options={periods()} value={period()} onChange={setPeriod} />
|
||||
</ui.SettingsRow>
|
||||
</ui.SettingsSection>
|
||||
</ui.SettingsLayout>
|
||||
```
|
||||
|
||||
- Use `SettingsLayout` for a page of your own, `SettingsSection` for a section on a host page, and `SettingsRow` alone
|
||||
for rows in a host section. Rows line up with the host's rows in every case.
|
||||
- `id` on a row is the `SettingEntry.id` that reveals it. Search that reveals the page's own id highlights the header.
|
||||
- `actions` sits at the end of the title row. A section's `footer` sits under its list, outside the border.
|
||||
- They take their look from the Settings screen's styles, so render them from a `SettingsPage`.
|
||||
|
||||
#### Reference
|
||||
|
||||
```ts
|
||||
|
|
@ -2058,13 +2170,21 @@ interface Ui {
|
|||
IconButton(props: UiIconButtonProps): JSX.Element
|
||||
Tooltip(props: UiTooltipProps): JSX.Element
|
||||
Menu(props: UiMenuProps): JSX.Element
|
||||
MenuItem(props: UiMenuItem): JSX.Element
|
||||
MenuSeparator(): JSX.Element
|
||||
MenuGroup(props: UiMenuGroupProps): JSX.Element
|
||||
MenuSub(props: UiMenuSubProps): JSX.Element
|
||||
Loader(props: UiLoaderProps): JSX.Element
|
||||
Dialog(props: UiDialogProps): JSX.Element
|
||||
Switch(props: UiSwitchProps): JSX.Element
|
||||
TextInput(props: UiTextInputProps): JSX.Element
|
||||
Select<T extends string>(props: UiSelectProps<T>): JSX.Element
|
||||
Keybind(props: UiKeybindProps): JSX.Element
|
||||
Markdown(props: UiMarkdownProps): JSX.Element
|
||||
Code(props: UiCodeProps): JSX.Element
|
||||
SettingsLayout(props: UiSettingsLayoutProps): JSX.Element
|
||||
SettingsSection(props: UiSettingsSectionProps): JSX.Element
|
||||
SettingsRow(props: UiSettingsRowProps): JSX.Element
|
||||
}
|
||||
|
||||
interface UiIconProps {
|
||||
|
|
@ -2135,6 +2255,19 @@ interface UiMenuGroup {
|
|||
items: UiMenuItem[]
|
||||
}
|
||||
|
||||
interface UiMenuGroupProps {
|
||||
label: string
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
interface UiMenuSubProps {
|
||||
label: string
|
||||
icon?: IconName
|
||||
disabled?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
interface UiLoaderProps {
|
||||
size?: "small" | "normal" | "large"
|
||||
class?: string
|
||||
|
|
@ -2187,6 +2320,43 @@ interface UiCodeProps {
|
|||
language?: string
|
||||
class?: string
|
||||
}
|
||||
|
||||
interface UiSelectProps<T extends string> {
|
||||
options: UiSelectOption<T>[]
|
||||
value: T | undefined
|
||||
onChange: (value: T) => void
|
||||
label: string
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
invalid?: boolean
|
||||
placement?: "bottom-start" | "bottom-end" | "top-start" | "top-end"
|
||||
class?: string
|
||||
}
|
||||
|
||||
interface UiSelectOption<T extends string> {
|
||||
value: T
|
||||
label: string
|
||||
}
|
||||
|
||||
interface UiSettingsLayoutProps {
|
||||
title: string
|
||||
description?: string
|
||||
actions?: JSX.Element
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
interface UiSettingsSectionProps {
|
||||
title?: string
|
||||
children: JSX.Element
|
||||
footer?: JSX.Element
|
||||
}
|
||||
|
||||
interface UiSettingsRowProps {
|
||||
title: JSX.Element
|
||||
description?: JSX.Element
|
||||
id?: string
|
||||
children?: JSX.Element
|
||||
}
|
||||
```
|
||||
|
||||
### Toasts
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue