mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-11 02:46:29 +00:00
fix(plugin): support icon-only composer pills
Composer pills use an omitted label to fall back to the accessible title, but rejected every explicit no-label value. Preserve that omission default while representing null and empty labels as icon-only controls.
This commit is contained in:
@@ -71,6 +71,9 @@ export default function contribute(client) {
|
||||
const review = pill("review", { title: "Run review", icon: "Scan", label: "Review", behavior: { kind: "action", async onPress() { await new Promise((resolve) => { finishReview = resolve; }); review.update({ label: "Reviewed" }); } } });
|
||||
const pillMenu = pill("checks", { title: "Composer checks", icon: "ListChecks", label: "Checks", behavior: menu });
|
||||
pill("usage", { title: "Composer status", icon: StatusIcon, label: "Status", behavior: { kind: "popover", Content: Details } });
|
||||
pill("icon-empty", { title: "Empty label pill", icon: "Scan", label: "", behavior: { kind: "action", onPress() {} } });
|
||||
pill("icon-null", { title: "Null label pill", icon: "Scan", label: null, behavior: { kind: "action", onPress() {} } });
|
||||
pill("label-default", { title: "Default label pill", icon: "Scan", behavior: { kind: "action", onPress() {} } });
|
||||
const command = (id, title, onSelect) => client.addCommandCenterItem({ id, title, icon: "Settings", context: "workspace", onSelect });
|
||||
command("finish", "Finish deployment", () => finish());
|
||||
command("finish-review", "Finish review", () => finishReview());
|
||||
@@ -243,6 +246,20 @@ export async function withButtonShowcase(
|
||||
await run({
|
||||
openWideMenusAndPopovers: () =>
|
||||
test.step("wide buttons have placement-owned labels and chevrons", async () => {
|
||||
for (const title of ["Empty label pill", "Null label pill"]) {
|
||||
const iconOnlyPill = page.getByRole("button", { name: title, exact: true });
|
||||
await expect(iconOnlyPill).toBeVisible();
|
||||
await expect(iconOnlyPill).toHaveText("");
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await iconOnlyPill.boundingBox();
|
||||
return box ? { width: box.width, height: box.height } : null;
|
||||
})
|
||||
.toEqual({ width: 32, height: 32 });
|
||||
}
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Default label pill", exact: true }),
|
||||
).toHaveText("Default label pill");
|
||||
await expect(
|
||||
page
|
||||
.getByRole("button", { name: "Header checks", exact: true })
|
||||
|
||||
@@ -89,6 +89,27 @@ describe("plugin buttons", () => {
|
||||
expect(buttons.getSnapshot()[0].button.title).toBe("Review");
|
||||
});
|
||||
|
||||
it("keeps omitted labels distinct from explicit icon-only labels", () => {
|
||||
const buttons = store();
|
||||
const plugin = installation();
|
||||
const omitted = buttons.addComposerPill(plugin, {
|
||||
id: "omitted",
|
||||
workspaceId: "workspace",
|
||||
agentId: "agent",
|
||||
button: button(),
|
||||
});
|
||||
buttons.addComposerPill(plugin, {
|
||||
id: "empty",
|
||||
workspaceId: "workspace",
|
||||
agentId: "agent",
|
||||
button: { ...button(), label: " " },
|
||||
});
|
||||
|
||||
expect(buttons.getSnapshot().map((entry) => entry.button.label)).toEqual([undefined, null]);
|
||||
omitted.update({ label: null });
|
||||
expect(buttons.getSnapshot()[0].button.label).toBeNull();
|
||||
});
|
||||
|
||||
it("prevents repeated presses and cannot resurrect a removed pending button", async () => {
|
||||
const buttons = store();
|
||||
let finish = () => {};
|
||||
|
||||
@@ -76,9 +76,15 @@ function validateBehavior(
|
||||
export type ResolvedPluginButton = PluginButton & {
|
||||
visible: boolean;
|
||||
disabled: boolean;
|
||||
label: string | undefined;
|
||||
label: string | null | undefined;
|
||||
};
|
||||
|
||||
function optionalLabel(value: string | null | undefined): string | null | undefined {
|
||||
if (value === undefined || value === null) return value;
|
||||
if (typeof value !== "string") throw new Error("Plugin button needs label");
|
||||
return value.trim() || null;
|
||||
}
|
||||
|
||||
export function validateButton(
|
||||
button: PluginButton,
|
||||
validation: ButtonValidation,
|
||||
@@ -87,7 +93,7 @@ export function validateButton(
|
||||
return {
|
||||
...button,
|
||||
title: requireText(button.title, "title"),
|
||||
label: button.label === undefined ? undefined : requireText(button.label, "label"),
|
||||
label: optionalLabel(button.label),
|
||||
visible: optionalBoolean(button.visible, true),
|
||||
disabled: optionalBoolean(button.disabled, false),
|
||||
behavior: validateBehavior(button.behavior, validation),
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
iconButtonChromeStyle,
|
||||
type IconButtonChromeState,
|
||||
} from "@/components/ui/icon-button-chrome";
|
||||
import { composerPillStyles } from "@/composer/pill-styles";
|
||||
import { COMPOSER_PILL_MIN_HEIGHT, composerPillStyles } from "@/composer/pill-styles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { ToastApiProvider, useToast } from "@/contexts/toast-context";
|
||||
import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime";
|
||||
@@ -260,9 +260,10 @@ function ButtonControl({ view }: { view: ButtonView }) {
|
||||
const disabled = button.disabled || entry.pending;
|
||||
const expanded = button.behavior.kind !== "action";
|
||||
const chevron = !composer && !props.layout.compact && expanded;
|
||||
let label = button.label;
|
||||
if (composer) label = button.label ?? button.title;
|
||||
else if (props.layout.compact) label = undefined;
|
||||
let label = button.label ?? undefined;
|
||||
if (composer && button.label === undefined) label = button.title;
|
||||
else if (!composer && props.layout.compact) label = undefined;
|
||||
const composerIconOnly = composer && label === undefined;
|
||||
const press = useCallback(() => pressButton(view, []), [view]);
|
||||
const setOpen = useCallback(
|
||||
(open: boolean) => pluginButtonStore.setOpen(entry.key, open),
|
||||
@@ -274,11 +275,12 @@ function ButtonControl({ view }: { view: ButtonView }) {
|
||||
? [
|
||||
composerPillStyles.body,
|
||||
styles.button,
|
||||
composerIconOnly && styles.composerIconOnly,
|
||||
(hovered || pressed || entry.open) && styles.active,
|
||||
disabled && styles.disabled,
|
||||
]
|
||||
: headerButtonStyle(props.layout.compact, { hovered, pressed, open: entry.open }, disabled),
|
||||
[composer, disabled, entry.open, props.layout.compact],
|
||||
[composer, composerIconOnly, disabled, entry.open, props.layout.compact],
|
||||
);
|
||||
const contents = (
|
||||
<>
|
||||
@@ -654,6 +656,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
flexShrink: 1,
|
||||
},
|
||||
composerIconOnly: {
|
||||
width: COMPOSER_PILL_MIN_HEIGHT,
|
||||
minWidth: COMPOSER_PILL_MIN_HEIGHT,
|
||||
paddingHorizontal: 0,
|
||||
},
|
||||
label: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, flexShrink: 1 },
|
||||
icon: {
|
||||
width: 16,
|
||||
|
||||
@@ -32,8 +32,8 @@ export type PluginButtonMenuEntry =
|
||||
export interface PluginButton {
|
||||
title: string;
|
||||
icon: PluginButtonIcon;
|
||||
/** Omit for an icon-only header button. Composer pills use title when omitted. */
|
||||
label?: string;
|
||||
/** Omit to use the placement default. Pass null or an empty string for icon-only. */
|
||||
label?: string | null;
|
||||
visible?: boolean;
|
||||
disabled?: boolean;
|
||||
behavior: PluginButtonBehavior;
|
||||
|
||||
Reference in New Issue
Block a user