mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-13 06:03:39 -06:00
fix(ui): unify channel pickers with icons
Preserve channel values and mutation ownership across Automations, pairing, setup, and Workboard while sharing accessible picker rendering and artwork.
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
import { html } from "lit";
|
||||
import {
|
||||
pluginArtPath,
|
||||
pluginFallbackGradient,
|
||||
pluginMonogram,
|
||||
} from "../pages/plugins/presentation.ts";
|
||||
import "../styles/channels.css";
|
||||
|
||||
/** Bundled channel art reuses the plugin art set because channel ids match plugin slugs. */
|
||||
export function renderChannelIcon(
|
||||
channelId: string,
|
||||
label: string,
|
||||
variant: "tile" | "cover" | "picker",
|
||||
) {
|
||||
const artVariant = variant === "picker" ? "tile" : variant;
|
||||
const art = pluginArtPath(channelId);
|
||||
const [from, to] = art ? ["", ""] : pluginFallbackGradient(channelId);
|
||||
const style = `${variant === "picker" ? "--channels-art-size:24px;" : ""}${
|
||||
art ? "" : `--channels-art-a:${from};--channels-art-b:${to}`
|
||||
}`;
|
||||
return html`<span
|
||||
class=${`channels-${artVariant}${art ? "" : ` channels-${artVariant}--fallback`}`}
|
||||
style=${style}
|
||||
aria-hidden="true"
|
||||
>
|
||||
${art
|
||||
? html`<img src=${art} alt="" loading="lazy" decoding="async" />`
|
||||
: html`<span>${pluginMonogram(label)}</span>`}
|
||||
</span>`;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/* @vitest-environment jsdom */
|
||||
import { render } from "lit";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderChannelPicker } from "./channel-picker.ts";
|
||||
|
||||
describe("renderChannelPicker", () => {
|
||||
it("renders neutral and channel artwork while preserving a missing current channel", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderChannelPicker({
|
||||
label: "Channel",
|
||||
value: "retired-channel",
|
||||
options: [
|
||||
{ value: "last", label: "last", kind: "neutral" },
|
||||
{ value: "telegram", label: "Telegram" },
|
||||
],
|
||||
onChange: vi.fn(),
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.querySelector('wa-option[value="last"] [slot="start"]')).toBeNull();
|
||||
expect(container.querySelector('wa-option[value="telegram"] img')).not.toBeNull();
|
||||
expect(container.querySelector('wa-option[value="retired-channel"]')?.textContent).toContain(
|
||||
"retired-channel",
|
||||
);
|
||||
expect(
|
||||
container.querySelector('wa-option[value="retired-channel"] .channels-tile--fallback'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("honors disabled choices and reports enabled changes", () => {
|
||||
const container = document.createElement("div");
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
renderChannelPicker({
|
||||
label: "Channel",
|
||||
value: "telegram",
|
||||
options: [
|
||||
{ value: "telegram", label: "Telegram" },
|
||||
{ value: "disabled", label: "Disabled", disabled: true },
|
||||
],
|
||||
onChange,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
const picker = container.querySelector<HTMLElement & { value: string }>("wa-select");
|
||||
expect(container.querySelector('wa-option[value="disabled"]')?.hasAttribute("disabled")).toBe(
|
||||
true,
|
||||
);
|
||||
if (!picker) {
|
||||
return;
|
||||
}
|
||||
Object.defineProperty(picker, "value", { configurable: true, value: "disabled" });
|
||||
picker.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
Reflect.deleteProperty(picker, "value");
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
Object.defineProperty(picker, "value", { configurable: true, value: "telegram" });
|
||||
picker.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
Reflect.deleteProperty(picker, "value");
|
||||
expect(onChange).toHaveBeenCalledWith("telegram");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { nothing } from "lit";
|
||||
import { renderChannelIcon } from "./channel-icon.ts";
|
||||
import { renderPicker, type PickerOption, type PickerParams } from "./select-picker.ts";
|
||||
|
||||
export type ChannelPickerOption = PickerOption & {
|
||||
/** Neutral choices such as "last" or "all" are routing policy, not transports. */
|
||||
kind?: "channel" | "neutral";
|
||||
};
|
||||
|
||||
export function renderChannelPicker(params: PickerParams<ChannelPickerOption>) {
|
||||
return renderPicker({
|
||||
...params,
|
||||
className: "channel-picker",
|
||||
renderLeading: (option) =>
|
||||
option.kind === "neutral" ? nothing : renderChannelIcon(option.value, option.label, "picker"),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { html, nothing } from "lit";
|
||||
import "./web-awesome-select.ts";
|
||||
|
||||
export type PickerOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export type PickerParams<Option extends PickerOption> = {
|
||||
id?: string;
|
||||
label: string;
|
||||
value: string | null;
|
||||
options: readonly Option[];
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
onChange: (value: string) => void;
|
||||
renderLeading?: (option: Option) => unknown;
|
||||
};
|
||||
|
||||
export function renderPicker<Option extends PickerOption>(params: PickerParams<Option>) {
|
||||
const options =
|
||||
params.value === null || params.options.some((option) => option.value === params.value)
|
||||
? params.options
|
||||
: [...params.options, { value: params.value, label: params.value } as Option];
|
||||
const leading = (option: Option | undefined) => {
|
||||
const content = option && params.renderLeading?.(option);
|
||||
return content === undefined || content === null || content === nothing
|
||||
? nothing
|
||||
: html`<span slot="start">${content}</span>`;
|
||||
};
|
||||
return html`
|
||||
<wa-select
|
||||
id=${params.id ?? nothing}
|
||||
class=${`settings-select picker-select ${params.className ?? ""}`}
|
||||
style="width:100%;min-width:0"
|
||||
.value=${params.value}
|
||||
?disabled=${params.disabled}
|
||||
@change=${(event: Event) => {
|
||||
const value = (event.currentTarget as HTMLElement & { value?: unknown }).value;
|
||||
const option = typeof value === "string" && options.find((entry) => entry.value === value);
|
||||
if (option && !option.disabled) {
|
||||
params.onChange(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span slot="label" class="settings-control__sr-label">${params.label}</span>
|
||||
${leading(options.find((option) => option.value === params.value))}
|
||||
${options.map(
|
||||
(option) => html`
|
||||
<wa-option
|
||||
class="picker-select__option"
|
||||
value=${option.value}
|
||||
.label=${option.label}
|
||||
?selected=${option.value === params.value}
|
||||
?disabled=${option.disabled}
|
||||
>
|
||||
${leading(option)}
|
||||
<span class="picker-select__copy">
|
||||
<span class="picker-select__label">${option.label}</span>
|
||||
${option.description
|
||||
? html`<span class="picker-select__description">${option.description}</span>`
|
||||
: nothing}
|
||||
</span>
|
||||
</wa-option>
|
||||
`,
|
||||
)}
|
||||
</wa-select>
|
||||
`;
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { html, nothing, type TemplateResult } from "lit";
|
||||
import type { WizardStep } from "../api/types.ts";
|
||||
import { t } from "../i18n/index.ts";
|
||||
import { renderChannelPicker } from "./channel-picker.ts";
|
||||
import { handleCopyButton } from "./copy-button.ts";
|
||||
import { renderPicker } from "./select-picker.ts";
|
||||
import { renderSensitiveInput } from "./sensitive-input.ts";
|
||||
import "../styles/wizard-step-controls.css";
|
||||
|
||||
@@ -19,6 +21,7 @@ type WizardStepControlsProps = {
|
||||
onValueChange: (value: unknown) => void;
|
||||
onAnswer: (value: unknown) => void;
|
||||
presentation?: "channels";
|
||||
channelSelect?: boolean;
|
||||
answerLabel?: string;
|
||||
confirmAffirmativeLabel?: string;
|
||||
leadingAction?: TemplateResult;
|
||||
@@ -112,29 +115,19 @@ function renderAnswerButton(
|
||||
function renderOption(
|
||||
props: WizardStepControlsProps,
|
||||
option: WizardStepOption,
|
||||
index: number,
|
||||
selected: unknown[],
|
||||
) {
|
||||
const checked = selected.some((value) => Object.is(value, option.value));
|
||||
if (props.presentation === "channels") {
|
||||
return props.step.type === "select"
|
||||
? html`<wa-radio
|
||||
class="channels-wizard__option"
|
||||
appearance="button"
|
||||
value=${String(index)}
|
||||
.checked=${checked}
|
||||
>
|
||||
${renderOptionBody(option, props.presentation)}
|
||||
</wa-radio>`
|
||||
: html`<button
|
||||
type="button"
|
||||
class="channels-wizard__option"
|
||||
aria-pressed=${checked ? "true" : "false"}
|
||||
?disabled=${props.busy}
|
||||
@click=${() => props.onValueChange(option.value)}
|
||||
>
|
||||
${renderOptionBody(option, props.presentation, checked)}
|
||||
</button>`;
|
||||
return html`<button
|
||||
type="button"
|
||||
class="channels-wizard__option"
|
||||
aria-pressed=${checked ? "true" : "false"}
|
||||
?disabled=${props.busy}
|
||||
@click=${() => props.onValueChange(option.value)}
|
||||
>
|
||||
${renderOptionBody(option, props.presentation, checked)}
|
||||
</button>`;
|
||||
}
|
||||
return html`<label class="wizard-step__option">
|
||||
<input
|
||||
@@ -242,24 +235,28 @@ function renderOptionsStep(props: WizardStepControlsProps) {
|
||||
const selected = multiple ? (Array.isArray(props.value) ? props.value : []) : [props.value];
|
||||
if (props.presentation === "channels" && !multiple) {
|
||||
const selectedIndex = options.findIndex((option) => Object.is(option.value, props.value));
|
||||
const channels =
|
||||
props.channelSelect && options.every((option) => typeof option.value === "string");
|
||||
const picker = channels ? renderChannelPicker : renderPicker;
|
||||
return html`
|
||||
<wa-radio-group
|
||||
class="channels-wizard__options"
|
||||
label=${props.step.message ?? ""}
|
||||
orientation="vertical"
|
||||
.value=${selectedIndex >= 0 ? String(selectedIndex) : null}
|
||||
?disabled=${props.busy}
|
||||
@change=${(event: Event) => {
|
||||
const index = (event.currentTarget as HTMLElement & { value?: string | number | null })
|
||||
.value;
|
||||
const option = options[Number(index)];
|
||||
if (option) {
|
||||
props.onAnswer(option.value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
${options.map((option, index) => renderOption(props, option, index, selected))}
|
||||
</wa-radio-group>
|
||||
${renderMessage(props)}
|
||||
${picker({
|
||||
label: props.step.message ?? "",
|
||||
value:
|
||||
selectedIndex < 0
|
||||
? null
|
||||
: channels
|
||||
? String(options[selectedIndex]?.value)
|
||||
: String(selectedIndex),
|
||||
options: options.map((option, index) => ({
|
||||
value: channels ? String(option.value) : String(index),
|
||||
label: option.label,
|
||||
description: option.hint,
|
||||
kind: channels ? "channel" : "neutral",
|
||||
})),
|
||||
disabled: props.busy,
|
||||
onChange: (value) => props.onAnswer(channels ? value : options[Number(value)]?.value),
|
||||
})}
|
||||
`;
|
||||
}
|
||||
const answer = multiple
|
||||
@@ -270,7 +267,7 @@ function renderOptionsStep(props: WizardStepControlsProps) {
|
||||
return html`
|
||||
${renderMessage(props)}
|
||||
<div class=${stepClass(props, "options")} role=${multiple ? nothing : "radiogroup"}>
|
||||
${options.map((option, index) => renderOption(props, option, index, selected))}
|
||||
${options.map((option) => renderOption(props, option, selected))}
|
||||
</div>
|
||||
${renderAnswerButton(
|
||||
props,
|
||||
|
||||
@@ -276,11 +276,15 @@ suite.define(() => {
|
||||
await page.locator(".channels-detail").getByRole("button", { name: "Run setup" }).click();
|
||||
const wizard = page.locator(".channels-wizard");
|
||||
await gateway.deferNext("wizard.next");
|
||||
await wizard.getByRole("radio", { name: "Work bot" }).click();
|
||||
const account = wizard.locator("wa-select");
|
||||
await account.evaluate(async (select) => {
|
||||
const picker = select as HTMLElement & { value: string; updateComplete: Promise<unknown> };
|
||||
picker.value = "1";
|
||||
await picker.updateComplete;
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
await expect.poll(async () => gateway.getRequests("wizard.next")).toHaveLength(1);
|
||||
await expect
|
||||
.poll(() => wizard.locator("wa-radio-group").getAttribute("disabled"))
|
||||
.not.toBeNull();
|
||||
await expect.poll(() => account.getAttribute("disabled")).not.toBeNull();
|
||||
await gateway.resolveDeferred("wizard.next");
|
||||
|
||||
const token = wizard.getByLabel("Telegram bot token");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Control UI tests cover Automations form native-select display state.
|
||||
// Control UI tests cover Automations form select display state.
|
||||
import { expect, it } from "vitest";
|
||||
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
|
||||
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
|
||||
@@ -55,15 +55,27 @@ suite.define(() => {
|
||||
expect(await page.locator("select.cron-run-sort").inputValue()).toBe("asc");
|
||||
await page.locator('[data-test-id="cron-new-task"]').click();
|
||||
|
||||
const action = page.locator("select#cron-payload-kind");
|
||||
const pickerValue = (selector: string) =>
|
||||
page
|
||||
.locator(selector)
|
||||
.evaluate((element) => String((element as HTMLElement & { value?: string }).value));
|
||||
const action = page.locator("wa-select#cron-payload-kind");
|
||||
await action.waitFor({ state: "visible" });
|
||||
// Form defaults are agentTurn / isolated / minutes — none of which is
|
||||
// the first option of its select; the rendered selection must agree.
|
||||
expect(await action.inputValue()).toBe("agentTurn");
|
||||
expect(await page.locator("select#cron-session-target").inputValue()).toBe("isolated");
|
||||
expect(await page.locator('select[aria-label="Unit"]').inputValue()).toBe("minutes");
|
||||
expect(await pickerValue("wa-select#cron-payload-kind")).toBe("agentTurn");
|
||||
expect(await pickerValue("wa-select#cron-session-target")).toBe("isolated");
|
||||
const unit = page.locator("wa-select").filter({
|
||||
has: page.locator('[slot="label"]', { hasText: "Unit" }),
|
||||
});
|
||||
expect(
|
||||
await unit.evaluate((element) =>
|
||||
String((element as HTMLElement & { value?: string }).value),
|
||||
),
|
||||
).toBe("minutes");
|
||||
// Control: delivery mode's default is also its first option.
|
||||
expect(await page.locator("select#cron-delivery-mode").inputValue()).toBe("announce");
|
||||
expect(await pickerValue("wa-select#cron-delivery-mode")).toBe("announce");
|
||||
expect(await pickerValue("wa-select#cron-delivery-channel")).toBe("last");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
// Channel hub presentation: bundled art reuse plus typed per-channel setup
|
||||
// helper links surfaced in the setup wizard. Labels/order still come from the
|
||||
// gateway channels.status snapshot; this table only decorates known channels.
|
||||
import { html, type TemplateResult } from "lit";
|
||||
import { pluginArtPath, pluginFallbackGradient, pluginMonogram } from "../plugins/presentation.ts";
|
||||
|
||||
type ChannelSetupLink = {
|
||||
label: string;
|
||||
url: string;
|
||||
@@ -46,26 +43,3 @@ export function channelHubMeta(channelId: string): ChannelHubMeta {
|
||||
export function channelDocsUrl(channelId: string): string {
|
||||
return `https://docs.openclaw.ai/channels/${encodeURIComponent(channelId)}`;
|
||||
}
|
||||
|
||||
/** Bundled channel art reuses the plugin art set (channel ids match slugs). */
|
||||
export function renderChannelArt(
|
||||
channelId: string,
|
||||
label: string,
|
||||
variant: "tile" | "cover",
|
||||
): TemplateResult {
|
||||
const art = pluginArtPath(channelId);
|
||||
if (art) {
|
||||
return html`<span class="channels-${variant}">
|
||||
<img src=${art} alt="" loading="lazy" decoding="async" />
|
||||
</span>`;
|
||||
}
|
||||
const [from, to] = pluginFallbackGradient(channelId);
|
||||
const monogram = pluginMonogram(label);
|
||||
return html`<span
|
||||
class="channels-${variant} channels-${variant}--fallback"
|
||||
style=${`--channels-art-a:${from};--channels-art-b:${to}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span>${monogram}</span>
|
||||
</span>`;
|
||||
}
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
import { asNullableRecord, readStringField } from "@openclaw/normalization-core/record-coerce";
|
||||
import { html, nothing, type TemplateResult } from "lit";
|
||||
import type { NostrProfile } from "../../api/types.ts";
|
||||
import { renderChannelIcon } from "../../components/channel-icon.ts";
|
||||
import { renderSettingsSection } from "../../components/settings-ui.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import "../../components/modal-dialog.ts";
|
||||
import { resolveChannelAccounts } from "../../lib/channels/index.ts";
|
||||
import { formatRelativeTimestamp } from "../../lib/format.ts";
|
||||
import { channelDocsUrl, renderChannelArt } from "./hub-meta.ts";
|
||||
import { channelDocsUrl } from "./hub-meta.ts";
|
||||
import { renderChannelConfigSection } from "./view.config.ts";
|
||||
import { renderNostrCard } from "./view.nostr.ts";
|
||||
import { renderChannelPairingDetail } from "./view.pairing.ts";
|
||||
@@ -221,7 +222,7 @@ export function renderChannelDetail(params: {
|
||||
<openclaw-modal-dialog label=${params.label} @modal-cancel=${() => params.onClose()}>
|
||||
<div class="channels-detail">
|
||||
<div class="channels-detail__header">
|
||||
${renderChannelArt(params.channelId, params.label, "cover")}
|
||||
${renderChannelIcon(params.channelId, params.label, "cover")}
|
||||
<div class="channels-detail__header-actions">
|
||||
<a
|
||||
class="btn btn--sm"
|
||||
|
||||
@@ -166,10 +166,43 @@ describe("channel DM access request views", () => {
|
||||
expect(errorContainer.querySelector(".callout")).toBeNull();
|
||||
});
|
||||
|
||||
it("uses settings controls for both pairing filters", () => {
|
||||
const container = renderInto(renderChannelPairingQueue(createProps()));
|
||||
it("uses the channel picker and clears the account filter when the channel changes", () => {
|
||||
const onPairingFilterChange = vi.fn();
|
||||
const base = createProps();
|
||||
const container = renderInto(
|
||||
renderChannelPairingQueue(
|
||||
createProps({
|
||||
pairingChannelFilter: "whatsapp",
|
||||
pairingAccountFilter: "personal",
|
||||
pairingSnapshot: {
|
||||
...base.pairingSnapshot!,
|
||||
accounts: [
|
||||
...base.pairingSnapshot!.accounts,
|
||||
{
|
||||
channel: "telegram",
|
||||
channelLabel: "Telegram",
|
||||
accountId: "work",
|
||||
accountLabel: "Work",
|
||||
notifySupported: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
onPairingFilterChange,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(container.querySelectorAll("select.settings-select")).toHaveLength(2);
|
||||
const selects = container.querySelectorAll<HTMLElement & { value: string }>("wa-select");
|
||||
const channel = selects.item(0);
|
||||
const account = selects.item(1);
|
||||
expect(channel?.querySelector('wa-option[value="whatsapp"] img')).not.toBeNull();
|
||||
expect(selects).toHaveLength(2);
|
||||
expect(container.querySelectorAll("select.settings-select")).toHaveLength(0);
|
||||
expect(account.querySelector("wa-option[selected]")?.getAttribute("value")).toBe("personal");
|
||||
Object.defineProperty(channel, "value", { configurable: true, value: "telegram" });
|
||||
channel.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
Reflect.deleteProperty(channel, "value");
|
||||
expect(onPairingFilterChange).toHaveBeenCalledWith("telegram", null);
|
||||
});
|
||||
|
||||
it("disables every request action while one mutation is active", () => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// DM sender access request queue shared by the Channels hub and detail panels.
|
||||
import { html, nothing } from "lit";
|
||||
import type { ChannelsPairingAccount, ChannelsPairingRequest } from "../../api/types.ts";
|
||||
import { renderChannelPicker } from "../../components/channel-picker.ts";
|
||||
import "../../components/modal-dialog.ts";
|
||||
import { renderPicker } from "../../components/select-picker.ts";
|
||||
import {
|
||||
renderSettingsEmpty,
|
||||
renderSettingsSection,
|
||||
@@ -24,11 +26,6 @@ function formatRequestTime(value: string): string {
|
||||
return Number.isFinite(time) ? formatRelativeTimestamp(time) : value;
|
||||
}
|
||||
|
||||
function selectValue(event: Event): string | null {
|
||||
const value = event.currentTarget instanceof HTMLSelectElement ? event.currentTarget.value : "";
|
||||
return value || null;
|
||||
}
|
||||
|
||||
function filteredAccounts(props: ChannelsProps): ChannelsPairingAccount[] {
|
||||
const accounts = props.pairingSnapshot?.accounts ?? [];
|
||||
return props.pairingChannelFilter
|
||||
@@ -58,29 +55,32 @@ function renderFilters(props: ChannelsProps) {
|
||||
<div class="channels-pairing-filters">
|
||||
<label>
|
||||
<span>${t("channels.pairing.channelFilter")}</span>
|
||||
<select
|
||||
class="settings-select"
|
||||
.value=${props.pairingChannelFilter ?? ""}
|
||||
@change=${(event: Event) => props.onPairingFilterChange(selectValue(event), null)}
|
||||
>
|
||||
<option value="">${t("channels.pairing.allChannels")}</option>
|
||||
${channels.map(([channel, label]) => html`<option value=${channel}>${label}</option>`)}
|
||||
</select>
|
||||
${renderChannelPicker({
|
||||
label: t("channels.pairing.channelFilter"),
|
||||
value: props.pairingChannelFilter ?? "",
|
||||
options: [
|
||||
{ value: "", label: t("channels.pairing.allChannels"), kind: "neutral" },
|
||||
...channels.map(([value, label]) => ({ value, label })),
|
||||
],
|
||||
onChange: (value) => props.onPairingFilterChange(value || null, null),
|
||||
})}
|
||||
</label>
|
||||
<label>
|
||||
<span>${t("channels.pairing.accountFilter")}</span>
|
||||
<select
|
||||
class="settings-select"
|
||||
.value=${props.pairingAccountFilter ?? ""}
|
||||
?disabled=${!props.pairingChannelFilter}
|
||||
@change=${(event: Event) =>
|
||||
props.onPairingFilterChange(props.pairingChannelFilter, selectValue(event))}
|
||||
>
|
||||
<option value="">${t("channels.pairing.allAccounts")}</option>
|
||||
${accountsForChannel.map(
|
||||
(account) => html`<option value=${account.accountId}>${accountName(account)}</option>`,
|
||||
)}
|
||||
</select>
|
||||
${renderPicker({
|
||||
label: t("channels.pairing.accountFilter"),
|
||||
value: props.pairingAccountFilter ?? "",
|
||||
options: [
|
||||
{ value: "", label: t("channels.pairing.allAccounts") },
|
||||
...accountsForChannel.map((account) => ({
|
||||
value: account.accountId,
|
||||
label: accountName(account),
|
||||
})),
|
||||
],
|
||||
disabled: !props.pairingChannelFilter,
|
||||
onChange: (value) =>
|
||||
props.onPairingFilterChange(props.pairingChannelFilter, value || null),
|
||||
})}
|
||||
</label>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
TelegramStatus,
|
||||
WhatsAppStatus,
|
||||
} from "../../api/types.ts";
|
||||
import { renderChannelIcon } from "../../components/channel-icon.ts";
|
||||
import { icons } from "../../components/icons.ts";
|
||||
import "../../components/openclaw-mascot.ts";
|
||||
import {
|
||||
@@ -24,7 +25,6 @@ import {
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import { resolveChannelAccounts } from "../../lib/channels/index.ts";
|
||||
import { formatRelativeTimestamp } from "../../lib/format.ts";
|
||||
import { renderChannelArt } from "./hub-meta.ts";
|
||||
import { renderChannelDetail } from "./view.detail.ts";
|
||||
import { renderChannelPairingPrompt, renderChannelPairingQueue } from "./view.pairing.ts";
|
||||
import { channelEnabled, resolveChannelDisplayState } from "./view.shared.ts";
|
||||
@@ -232,7 +232,7 @@ function renderConnectedRow(key: ChannelKey, props: ChannelsProps) {
|
||||
class="settings-row settings-row--nav channels-item"
|
||||
@click=${() => props.onShowDetail(key)}
|
||||
>
|
||||
${renderChannelArt(key, label, "tile")}
|
||||
${renderChannelIcon(key, label, "tile")}
|
||||
<div class="settings-row__text">
|
||||
<span class="settings-row__title">${label}</span>
|
||||
<span class="settings-row__desc">${description}</span>
|
||||
@@ -257,7 +257,7 @@ function renderAvailableRow(key: ChannelKey, props: ChannelsProps) {
|
||||
title=${t("channels.hub.openDetails")}
|
||||
@click=${() => props.onShowDetail(key)}
|
||||
>
|
||||
${renderChannelArt(key, label, "tile")}
|
||||
${renderChannelIcon(key, label, "tile")}
|
||||
<span class="settings-row__text">
|
||||
<span class="settings-row__title">${label}</span>
|
||||
<span class="settings-row__desc">${description}</span>
|
||||
|
||||
@@ -118,11 +118,9 @@ describe("renderChannelWizard busy controls", () => {
|
||||
{ label: "Beta", value: "beta" },
|
||||
],
|
||||
});
|
||||
const group = select.container.querySelector<HTMLElement & { disabled: boolean }>(
|
||||
"wa-radio-group",
|
||||
);
|
||||
expect(group?.disabled).toBe(true);
|
||||
const group = select.container.querySelector("wa-select");
|
||||
expect(group?.hasAttribute("disabled")).toBe(true);
|
||||
expect(group?.querySelector('[slot="label"]')?.textContent).toBe("Pick one");
|
||||
});
|
||||
|
||||
it("disables multiselect choices and submission while a step is running", () => {
|
||||
@@ -182,9 +180,7 @@ describe("renderChannelWizard busy controls", () => {
|
||||
},
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
select.container.querySelector<HTMLElement & { disabled: boolean }>("wa-radio-group")
|
||||
?.disabled,
|
||||
).toBe(false);
|
||||
const picker = select.container.querySelector("wa-select");
|
||||
expect(picker?.hasAttribute("disabled")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
// Channel setup wizard modal: renders gateway wizard steps (note/select/text/
|
||||
// confirm/multiselect) plus the WhatsApp QR linking phase after config write.
|
||||
import "@awesome.me/webawesome/dist/components/radio/radio.js";
|
||||
import "@awesome.me/webawesome/dist/components/radio-group/radio-group.js";
|
||||
import { html, nothing, type TemplateResult } from "lit";
|
||||
import { renderChannelIcon } from "../../components/channel-icon.ts";
|
||||
import { handleCopyButton } from "../../components/copy-button.ts";
|
||||
import { renderWizardStepControls } from "../../components/wizard-step-controls.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import "../../components/modal-dialog.ts";
|
||||
import { channelDocsUrl, channelHubMeta, renderChannelArt } from "./hub-meta.ts";
|
||||
import { channelDocsUrl, channelHubMeta } from "./hub-meta.ts";
|
||||
import type { ChannelWizardState, ChannelWizardStep } from "./wizard-controller.ts";
|
||||
|
||||
type ChannelWizardViewProps = {
|
||||
@@ -99,6 +98,7 @@ function renderStepBody(step: ChannelWizardStep, props: ChannelWizardViewProps)
|
||||
busy: stepIsBusy(props),
|
||||
inputId: "channel-wizard-text-input",
|
||||
presentation: "channels",
|
||||
channelSelect: props.wizard.phase === "step" && props.wizard.channel === null,
|
||||
answerLabel: t("channels.setup.continue"),
|
||||
sensitiveRevealed: props.secretVisible,
|
||||
onValueChange:
|
||||
@@ -260,7 +260,7 @@ export function renderChannelWizard(
|
||||
>
|
||||
<div class="channels-wizard">
|
||||
<div class="channels-wizard__header">
|
||||
${channel ? renderChannelArt(channel, label, "tile") : nothing}
|
||||
${channel ? renderChannelIcon(channel, label, "tile") : nothing}
|
||||
<div class="channels-wizard__heading">
|
||||
<h2>${t("channels.setup.title", { channel: label })}</h2>
|
||||
<div class="muted">${t("channels.setup.subtitle")}</div>
|
||||
|
||||
@@ -519,7 +519,13 @@ describe("cron view editor", () => {
|
||||
channels: ["telegram"],
|
||||
channelMeta: [{ id: "telegram", label: "", detailLabel: "Telegram" }],
|
||||
channelLabels: { telegram: "Telegram fallback" },
|
||||
form: { ...DEFAULT_CRON_FORM, scheduleKind: "cron", failureAlertMode: "custom" },
|
||||
form: {
|
||||
...DEFAULT_CRON_FORM,
|
||||
scheduleKind: "cron",
|
||||
deliveryChannel: "telegram",
|
||||
failureAlertMode: "custom",
|
||||
failureAlertChannel: "retired-channel",
|
||||
},
|
||||
onFormChange,
|
||||
});
|
||||
|
||||
@@ -539,10 +545,25 @@ describe("cron view editor", () => {
|
||||
expect(onFormChange).toHaveBeenLastCalledWith({ [field]: field });
|
||||
}
|
||||
|
||||
const channel = getElement(container, "#cron-failure-alert-channel", HTMLSelectElement);
|
||||
channel.value = "telegram";
|
||||
expect(channel.selectedOptions[0]?.textContent).toBe("Telegram fallback");
|
||||
const channel = getElement(
|
||||
container,
|
||||
"#cron-failure-alert-channel",
|
||||
HTMLElement,
|
||||
) as HTMLElement & {
|
||||
value: string;
|
||||
};
|
||||
expect(
|
||||
Array.from(channel.querySelectorAll("wa-option"), (option) => option.getAttribute("value")),
|
||||
).toContain("retired-channel");
|
||||
expect(channel.localName).toBe("wa-select");
|
||||
expect(channel.querySelector('wa-option[value="telegram"] img')).not.toBeNull();
|
||||
expect(
|
||||
(channel.querySelector('wa-option[value="telegram"]') as HTMLElement & { label?: string })
|
||||
?.label,
|
||||
).toBe("Telegram fallback");
|
||||
Object.defineProperty(channel, "value", { configurable: true, value: "telegram" });
|
||||
channel.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
Reflect.deleteProperty(channel, "value");
|
||||
expect(onFormChange).toHaveBeenLastCalledWith({ failureAlertChannel: "telegram" });
|
||||
});
|
||||
|
||||
@@ -664,8 +685,16 @@ describe("cron view editor", () => {
|
||||
everyUnit: "seconds",
|
||||
},
|
||||
});
|
||||
const unitSelect = getElement(container, 'select[aria-label="Unit"]', HTMLSelectElement);
|
||||
const values = Array.from(unitSelect.querySelectorAll("option")).map((option) => option.value);
|
||||
const unitSelect = Array.from(container.querySelectorAll("wa-select")).find(
|
||||
(select) => select.querySelector('[slot="label"]')?.textContent === "Unit",
|
||||
);
|
||||
expect(unitSelect).toBeInstanceOf(HTMLElement);
|
||||
if (!unitSelect) {
|
||||
throw new Error("Expected the interval unit picker");
|
||||
}
|
||||
const values = Array.from(unitSelect.querySelectorAll("wa-option"), (option) =>
|
||||
option.getAttribute("value"),
|
||||
);
|
||||
expect(values).toEqual(["seconds", "minutes", "hours", "days"]);
|
||||
});
|
||||
|
||||
@@ -727,8 +756,10 @@ describe("cron view editor", () => {
|
||||
deliveryMode: "announce",
|
||||
},
|
||||
});
|
||||
const delivery = getElement(container, "#cron-delivery-mode", HTMLSelectElement);
|
||||
const values = Array.from(delivery.querySelectorAll("option")).map((option) => option.value);
|
||||
const delivery = getElement(container, "#cron-delivery-mode", HTMLElement);
|
||||
const values = Array.from(delivery.querySelectorAll("wa-option"), (option) =>
|
||||
option.getAttribute("value"),
|
||||
);
|
||||
expect(values).toEqual(["webhook", "none"]);
|
||||
expect(container.querySelector("#cron-delivery-channel")).toBeNull();
|
||||
});
|
||||
@@ -1041,21 +1072,35 @@ describe("cron view editor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("cron view native selects", () => {
|
||||
describe("cron view selects", () => {
|
||||
it("shows authoritative form values instead of first options in the create form", () => {
|
||||
const container = renderView({ createOpen: true });
|
||||
const action = getElement(container, "select#cron-payload-kind", HTMLSelectElement);
|
||||
expect(action.value).toBe("agentTurn");
|
||||
const runsIn = getElement(container, "select#cron-session-target", HTMLSelectElement);
|
||||
expect(runsIn.value).toBe("isolated");
|
||||
const unit = Array.from(container.querySelectorAll("select")).find(
|
||||
(entry) => entry.getAttribute("aria-label") === "Unit",
|
||||
);
|
||||
expect(unit?.value).toBe("minutes");
|
||||
const action = getElement(
|
||||
container,
|
||||
"wa-select#cron-payload-kind",
|
||||
HTMLElement,
|
||||
) as HTMLElement & {
|
||||
value: string;
|
||||
};
|
||||
expect(action.querySelector("wa-option[selected]")?.getAttribute("value")).toBe("agentTurn");
|
||||
const runsIn = getElement(
|
||||
container,
|
||||
"wa-select#cron-session-target",
|
||||
HTMLElement,
|
||||
) as HTMLElement & { value: string };
|
||||
expect(runsIn.querySelector("wa-option[selected]")?.getAttribute("value")).toBe("isolated");
|
||||
const unit = Array.from(
|
||||
container.querySelectorAll<HTMLElement & { value: string }>("wa-select"),
|
||||
).find((select) => select.querySelector('[slot="label"]')?.textContent === "Unit");
|
||||
expect(unit?.querySelector("wa-option[selected]")?.getAttribute("value")).toBe("minutes");
|
||||
// Negative control: the delivery-mode default is also the first option, so
|
||||
// this passes before and after the fix and proves the harness reads selects.
|
||||
const delivery = getElement(container, "select#cron-delivery-mode", HTMLSelectElement);
|
||||
expect(delivery.value).toBe("announce");
|
||||
const delivery = getElement(
|
||||
container,
|
||||
"wa-select#cron-delivery-mode",
|
||||
HTMLElement,
|
||||
) as HTMLElement & { value: string };
|
||||
expect(delivery.querySelector("wa-option[selected]")?.getAttribute("value")).toBe("announce");
|
||||
});
|
||||
|
||||
it("shows persisted non-first values in jobs filters and runs sort", () => {
|
||||
|
||||
+29
-38
@@ -18,9 +18,11 @@ import type {
|
||||
CronJobsSortBy,
|
||||
CronSortDir,
|
||||
} from "../../api/types.ts";
|
||||
import { renderChannelPicker, type ChannelPickerOption } from "../../components/channel-picker.ts";
|
||||
import { renderCronJobsPagination } from "../../components/cron-jobs-pagination.ts";
|
||||
import { icon, icons } from "../../components/icons.ts";
|
||||
import { highlightCodeHtml } from "../../components/markdown-code-blocks.ts";
|
||||
import { renderPicker, type PickerOption } from "../../components/select-picker.ts";
|
||||
import "../../components/tooltip.ts";
|
||||
import "../../components/web-awesome.ts";
|
||||
import "../../components/web-awesome-popover.ts";
|
||||
@@ -135,16 +137,17 @@ type CronProps = {
|
||||
|
||||
// ── Shared option helpers ──
|
||||
|
||||
function buildChannelOptions(props: CronProps): string[] {
|
||||
const current = props.form.deliveryChannel?.trim();
|
||||
return uniqueStrings(["last", ...props.channels.filter(Boolean), ...(current ? [current] : [])]);
|
||||
}
|
||||
|
||||
function resolveChannelLabel(props: CronProps, channel: string): string {
|
||||
return channel === "last"
|
||||
? channel
|
||||
: props.channelMeta?.find((entry) => entry.id === channel)?.label ||
|
||||
(props.channelLabels?.[channel] ?? channel);
|
||||
function buildChannelOptions(props: CronProps): ChannelPickerOption[] {
|
||||
return [
|
||||
{ value: "last", label: "last", kind: "neutral" },
|
||||
...uniqueStrings(props.channels.filter(Boolean)).map((value) => ({
|
||||
value,
|
||||
label:
|
||||
props.channelMeta?.find((entry) => entry.id === value)?.label ||
|
||||
props.channelLabels?.[value] ||
|
||||
value,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
function renderSuggestionList(id: string, options: string[]) {
|
||||
@@ -347,7 +350,7 @@ function renderCronInputField(
|
||||
});
|
||||
}
|
||||
|
||||
type CronSelectOption = { value: string; label: string };
|
||||
type CronSelectOption = PickerOption;
|
||||
|
||||
type CronSelectOptions = {
|
||||
label: string;
|
||||
@@ -356,6 +359,7 @@ type CronSelectOptions = {
|
||||
value?: string;
|
||||
disabled?: boolean;
|
||||
standalone?: boolean;
|
||||
channel?: boolean;
|
||||
};
|
||||
|
||||
function renderCronSelect(
|
||||
@@ -364,24 +368,15 @@ function renderCronSelect(
|
||||
options: CronSelectOptions,
|
||||
) {
|
||||
const selected = options.value ?? props.form[field];
|
||||
return html`
|
||||
<select
|
||||
id=${ifDefined(options.standalone ? undefined : inputIdForField(field))}
|
||||
class="settings-select"
|
||||
.value=${selected}
|
||||
aria-label=${ifDefined(options.standalone ? options.label : undefined)}
|
||||
?disabled=${options.disabled ?? false}
|
||||
@change=${(event: Event) =>
|
||||
props.onFormChange({ [field]: (event.currentTarget as HTMLSelectElement).value })}
|
||||
>
|
||||
${options.options.map(
|
||||
// The .value property commits before these mapped options exist, so the
|
||||
// browser falls back to the first option; ?selected marks the real one.
|
||||
({ value, label }) =>
|
||||
html`<option value=${value} ?selected=${value === selected}>${label}</option>`,
|
||||
)}
|
||||
</select>
|
||||
`;
|
||||
const picker = options.channel ? renderChannelPicker : renderPicker;
|
||||
return picker({
|
||||
id: options.standalone ? undefined : inputIdForField(field),
|
||||
label: options.label,
|
||||
value: options.channel ? selected || "last" : selected,
|
||||
options: options.options,
|
||||
disabled: options.disabled,
|
||||
onChange: (value) => props.onFormChange({ [field]: value }),
|
||||
});
|
||||
}
|
||||
|
||||
function renderCronSelectField(
|
||||
@@ -1475,10 +1470,8 @@ function renderDeliverySection(
|
||||
label: t("cron.form.channel"),
|
||||
help: t("cron.form.channelHelp"),
|
||||
value: props.form.deliveryChannel || "last",
|
||||
options: channelOptions.map((channel) => ({
|
||||
value: channel,
|
||||
label: resolveChannelLabel(props, channel),
|
||||
})),
|
||||
options: channelOptions,
|
||||
channel: true,
|
||||
})}
|
||||
${renderCronInputField(props, "deliveryTo", {
|
||||
label: t("cron.form.to"),
|
||||
@@ -1645,7 +1638,7 @@ function renderAdvanced(
|
||||
`;
|
||||
}
|
||||
|
||||
function renderFailureAlertRows(props: CronProps, channelOptions: string[]) {
|
||||
function renderFailureAlertRows(props: CronProps, channelOptions: readonly ChannelPickerOption[]) {
|
||||
return html`
|
||||
${renderCronSelectField(props, "failureAlertMode", {
|
||||
label: t("cron.form.failureAlerts"),
|
||||
@@ -1673,10 +1666,8 @@ function renderFailureAlertRows(props: CronProps, channelOptions: string[]) {
|
||||
${renderCronSelectField(props, "failureAlertChannel", {
|
||||
label: t("cron.form.failureAlertChannel"),
|
||||
value: props.form.failureAlertChannel || "last",
|
||||
options: channelOptions.map((channel) => ({
|
||||
value: channel,
|
||||
label: resolveChannelLabel(props, channel),
|
||||
})),
|
||||
options: channelOptions,
|
||||
channel: true,
|
||||
})}
|
||||
${renderCronInputField(props, "failureAlertTo", {
|
||||
label: t("cron.form.failureAlertTo"),
|
||||
|
||||
@@ -94,6 +94,7 @@ function changeWorkboardSelect(select: Element | null | undefined, value: string
|
||||
}
|
||||
Object.defineProperty(control, "value", { configurable: true, value, writable: true });
|
||||
control.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
Reflect.deleteProperty(control, "value");
|
||||
}
|
||||
|
||||
function selectWorkboardAgent(select: Element | null | undefined, value: string) {
|
||||
@@ -950,11 +951,13 @@ describe("renderWorkboard", () => {
|
||||
),
|
||||
];
|
||||
expect(selects).toHaveLength(2);
|
||||
expect(selects.map((select) => select.getAttribute("label"))).toEqual([
|
||||
expect(selects.map((select) => select.querySelector('[slot="label"]')?.textContent)).toEqual([
|
||||
"Workboard view",
|
||||
"All priorities",
|
||||
]);
|
||||
expect(selects.map((select) => select.getAttribute("value"))).toEqual(["all", "all"]);
|
||||
expect(
|
||||
selects.map((select) => select.querySelector("wa-option[selected]")?.getAttribute("value")),
|
||||
).toEqual(["all", "all"]);
|
||||
const agentSelect = container.querySelector<
|
||||
HTMLElement & { accessibleLabel: string; value: string }
|
||||
>(".workboard-agent-select--toolbar");
|
||||
@@ -2503,7 +2506,9 @@ describe("renderWorkboard", () => {
|
||||
expect(state.draftSessionKey).toBe(testCase.sessionKey);
|
||||
expect(
|
||||
[...container.querySelectorAll(".workboard-draft wa-select")].some(
|
||||
(select) => select.getAttribute("value") === testCase.sessionKey,
|
||||
(select) =>
|
||||
select.querySelector("wa-option[selected]")?.getAttribute("value") ===
|
||||
testCase.sessionKey,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
@@ -2646,6 +2651,7 @@ describe("renderWorkboard", () => {
|
||||
expect(
|
||||
[...(container.querySelector(".workboard-draft")?.querySelectorAll("wa-select") ?? [])]
|
||||
.at(1)
|
||||
?.querySelector("wa-option[selected]")
|
||||
?.getAttribute("value"),
|
||||
).toBe("high");
|
||||
});
|
||||
@@ -2796,7 +2802,9 @@ describe("renderWorkboard", () => {
|
||||
.querySelector(".workboard-draft")
|
||||
?.querySelectorAll<HTMLElement>(".workboard-select") ?? []),
|
||||
].at(2);
|
||||
expect(sessionSelect?.getAttribute("value")).toBe("agent:main:archived-session");
|
||||
expect(sessionSelect?.querySelector("wa-option[selected]")?.getAttribute("value")).toBe(
|
||||
"agent:main:archived-session",
|
||||
);
|
||||
expect(
|
||||
sessionSelect?.querySelector('wa-option[value="agent:main:archived-session"]'),
|
||||
).not.toBeNull();
|
||||
@@ -2834,8 +2842,8 @@ describe("renderWorkboard", () => {
|
||||
.querySelector(".workboard-draft")
|
||||
?.querySelectorAll<HTMLElement>(".workboard-select") ?? []),
|
||||
].at(2);
|
||||
const labels = [...(sessionOptions?.querySelectorAll(".workboard-select__option") ?? [])].map(
|
||||
(option) => option.textContent?.trim(),
|
||||
const labels = [...(sessionOptions?.querySelectorAll("wa-option") ?? [])].map((option) =>
|
||||
option.textContent?.trim(),
|
||||
);
|
||||
expect(labels).toContain("Dashboard session");
|
||||
expect(labels).not.toContain("heartbeat");
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { renderPicker, type PickerOption } from "../../components/select-picker.ts";
|
||||
import { renderWorkboardBoardGlyph } from "../../components/workboard-board-glyph.ts";
|
||||
import "../../components/web-awesome-select.ts";
|
||||
|
||||
export type WorkboardSelectOption<Value extends string = string> = {
|
||||
export type WorkboardSelectOption<Value extends string = string> = PickerOption & {
|
||||
value: Value;
|
||||
label: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
boardId?: string;
|
||||
@@ -22,66 +20,26 @@ export function renderWorkboardSelect<Value extends string>(params: {
|
||||
showLabel?: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const selectedOption = params.options.find((option) => option.value === params.value);
|
||||
const select = html`
|
||||
<wa-select
|
||||
class="workboard-select ${params.className ?? ""}"
|
||||
label=${params.label}
|
||||
value=${params.value}
|
||||
?disabled=${params.disabled}
|
||||
@change=${(event: Event) => {
|
||||
const value = (event.currentTarget as HTMLElement & { value?: string }).value as
|
||||
| Value
|
||||
| undefined;
|
||||
if (
|
||||
value !== undefined &&
|
||||
params.options.some((option) => option.value === value && !option.disabled)
|
||||
) {
|
||||
params.onChange(value);
|
||||
params.requestUpdate?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
${selectedOption?.boardId
|
||||
? html`<span slot="start"
|
||||
>${renderWorkboardBoardGlyph({
|
||||
id: selectedOption.boardId,
|
||||
name: selectedOption.label,
|
||||
icon: selectedOption.icon,
|
||||
color: selectedOption.color,
|
||||
})}</span
|
||||
>`
|
||||
: nothing}
|
||||
${params.options.map(
|
||||
(option) => html`
|
||||
<wa-option
|
||||
class="workboard-select__option"
|
||||
value=${option.value}
|
||||
.label=${option.label}
|
||||
?selected=${option.value === params.value}
|
||||
?disabled=${option.disabled}
|
||||
>
|
||||
${option.boardId
|
||||
? html`<span slot="start"
|
||||
>${renderWorkboardBoardGlyph({
|
||||
id: option.boardId,
|
||||
name: option.label,
|
||||
icon: option.icon,
|
||||
color: option.color,
|
||||
})}</span
|
||||
>`
|
||||
: nothing}
|
||||
<span class="workboard-select__copy">
|
||||
<span class="workboard-select__label">${option.label}</span>
|
||||
${option.description
|
||||
? html`<span class="workboard-select__description">${option.description}</span>`
|
||||
: nothing}
|
||||
</span>
|
||||
</wa-option>
|
||||
`,
|
||||
)}
|
||||
</wa-select>
|
||||
`;
|
||||
const select = renderPicker({
|
||||
value: params.value,
|
||||
options: params.options,
|
||||
label: params.label,
|
||||
className: `workboard-select ${params.className ?? ""}`,
|
||||
disabled: params.disabled,
|
||||
renderLeading: (option) =>
|
||||
option.boardId
|
||||
? renderWorkboardBoardGlyph({
|
||||
id: option.boardId,
|
||||
name: option.label,
|
||||
icon: option.icon,
|
||||
color: option.color,
|
||||
})
|
||||
: nothing,
|
||||
onChange: (value) => {
|
||||
params.onChange(value as Value);
|
||||
params.requestUpdate?.();
|
||||
},
|
||||
});
|
||||
if (params.showLabel === false) {
|
||||
return select;
|
||||
}
|
||||
|
||||
@@ -20,9 +20,9 @@
|
||||
|
||||
.channels-tile {
|
||||
display: grid;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex: 0 0 44px;
|
||||
width: var(--channels-art-size, 44px);
|
||||
height: var(--channels-art-size, 44px);
|
||||
flex: 0 0 var(--channels-art-size, 44px);
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
|
||||
@@ -797,6 +797,39 @@ wa-select.settings-select wa-option:state(current) {
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.picker-select__option {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.picker-select [slot="start"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.picker-select__label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.picker-select__copy {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.picker-select__description {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* Forced colors suppresses the box-shadow ring and accent fill that stand in
|
||||
for the outlines removed above, so restore system-color outlines there. */
|
||||
@media (forced-colors: active) {
|
||||
|
||||
@@ -428,39 +428,6 @@
|
||||
inset 0 1px 0 color-mix(in srgb, white 5%, transparent);
|
||||
}
|
||||
|
||||
.workboard-select__option {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.workboard-select [slot="start"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.workboard-select__label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.workboard-select__copy {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workboard-select__description {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.workboard-field--wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user