Expose builder chip selection beyond color, raise unselected contrast, and make the five-value limit visible #76

Merged
an.di merged 2 commits from adilallo/fix/CR-186-builder-chips into main 2026-09-10 17:15:36 +00:00
2 changed files with 99 additions and 67 deletions
Showing only changes of commit 6c1d83e89d - Show all commits
+89 -67
View File
@@ -1,8 +1,81 @@
"use client"; "use client";
import { memo } from "react"; import { memo, type CSSProperties } from "react";
import type { ChipViewProps } from "./Chip.types"; import type { ChipViewProps } from "./Chip.types";
function chipSurfaceStyle(
state: ChipViewProps["state"],
palette: ChipViewProps["palette"],
size: ChipViewProps["size"],
): CSSProperties {
const borderWidth = size === "s" ? "1.25px" : "2px";
const reset: CSSProperties = {
appearance: "none",
WebkitAppearance: "none",
backgroundImage: "none",
};
const paint = (
backgroundColor: string,
color: string,
borderColor?: string,
): CSSProperties => ({
...reset,
backgroundColor,
color,
WebkitTextFillColor: color,
...(borderColor
? { borderWidth, borderStyle: "solid", borderColor }
: { borderWidth: 0, borderStyle: "none", borderColor: "transparent" }),
});
if (palette === "inverse") {
if (state === "disabled") {
return paint(
"var(--color-surface-inverse-tertiary)",
"var(--color-content-inverse-primary)",
);
}
if (state === "selected") {
return paint(
"var(--color-surface-default-semi-opaque)",
"var(--color-content-inverse-primary)",
"var(--color-border-default-primary)",
);
}
return paint(
"transparent",
"var(--color-content-inverse-primary)",
"var(--color-border-default-primary)",
);
}
if (state === "custom") {
return paint(
"var(--color-surface-default-secondary)",
"var(--color-content-default-tertiary)",
);
}
if (state === "disabled") {
return paint(
"var(--color-surface-default-secondary)",
"var(--color-content-invert-tertiary)",
);
}
if (state === "selected") {
return paint(
"var(--color-surface-invert-brand-primary, #fefcc9)",
"var(--color-content-invert-primary, #000)",
"var(--color-border-default-brand-primary, #fdfaa8)",
);
}
return paint(
"transparent",
"var(--color-content-default-brand-primary, #fefcc9)",
"var(--color-border-default-tertiary, #464646)",
);
}
function ChipView({ function ChipView({
label, label,
state, state,
@@ -30,10 +103,8 @@ function ChipView({
const isCustom = state === "custom"; const isCustom = state === "custom";
const isToggle = !isCustom; const isToggle = !isCustom;
const isInverse = palette === "inverse";
const isDefault = palette === "default";
const isSmall = size === "s"; const isSmall = size === "s";
const surfaceStyle = chipSurfaceStyle(state, palette, size);
// Size-based styles from Figma tokens // Size-based styles from Figma tokens
// Custom state has different padding // Custom state has different padding
@@ -45,59 +116,8 @@ function ChipView({
? "h-[30px] px-[var(--measures-spacing-200,8px)] gap-[var(--measures-spacing-050,2px)] text-x-small-label" ? "h-[30px] px-[var(--measures-spacing-200,8px)] gap-[var(--measures-spacing-050,2px)] text-x-small-label"
: "px-[var(--measures-spacing-300,12px)] py-[var(--measures-spacing-300,12px)] gap-[var(--measures-spacing-150,6px)] text-medium-label"; : "px-[var(--measures-spacing-300,12px)] py-[var(--measures-spacing-300,12px)] gap-[var(--measures-spacing-150,6px)] text-medium-label";
// Palette + state styling based on Figma examples
// Use consistent border width to prevent layout shift
const borderWidth = isSmall ? "border-[1.25px]" : "border-2";
let background =
"bg-[var(--color-surface-default-transparent,rgba(0,0,0,0))]";
let border = `${borderWidth} border-[var(--color-border-default-tertiary,#464646)]`;
let textColor =
"text-[color:var(--color-content-default-brand-primary,#fefcc9)]";
if (isDefault) {
if (state === "custom") {
background = "bg-[var(--color-surface-default-secondary,#141414)]"; // dark background for custom
border = "border-none";
textColor = "text-[color:var(--color-content-default-tertiary,#b4b4b4)]";
} else if (state === "disabled") {
background = "bg-[var(--color-surface-default-secondary,#141414)]"; // dark background
border = "border-none";
// Per Figma (node 19839:13842) disabled uses invert-tertiary for the
// strongly dimmed look, not default-tertiary.
textColor = "text-[color:var(--color-content-invert-tertiary,#2d2d2d)]";
} else if (isSelected) {
background = "bg-[var(--color-surface-invert-brand-primary,#fefcc9)]"; // yellow selected
border = `${borderWidth} border-[var(--color-border-default-brand-primary,#fdfaa8)]`;
textColor = "text-[color:var(--color-content-invert-primary,black)]";
} else {
// Unselected default
background =
"bg-[var(--color-surface-default-transparent,rgba(0,0,0,0))]";
border = `${borderWidth} border-[var(--color-border-default-tertiary,#464646)]`;
textColor =
"text-[color:var(--color-content-default-brand-primary,#fefcc9)]";
}
} else if (isInverse) {
if (state === "disabled") {
background = "bg-[var(--color-surface-inverse-tertiary,#d2d2d2)]";
border = "border-none";
textColor = "text-[color:var(--color-content-inverse-primary,black)]";
} else if (isSelected) {
background =
"bg-[var(--color-surface-default-semi-opaque,rgba(0,0,0,0.05))]";
border = `${borderWidth} border-[var(--color-border-default-primary,#141414)]`;
textColor = "text-[color:var(--color-content-inverse-primary,black)]";
} else {
// Unselected / custom inverse
background =
"bg-[var(--color-surface-default-transparent,rgba(0,0,0,0))]";
border = `${borderWidth} border-[var(--color-border-default-primary,#141414)]`;
textColor = "text-[color:var(--color-content-inverse-primary,black)]";
}
}
const baseClasses = ` const baseClasses = `
appearance-none
inline-flex inline-flex
max-w-full max-w-full
items-center items-center
@@ -107,7 +127,7 @@ function ChipView({
box-border box-border
focus:outline-none focus:outline-none
focus-visible:ring-2 focus-visible:ring-2
focus-visible:ring-[var(--color-border-default-primary,#141414)] focus-visible:ring-[var(--color-border-default-primary)]
focus-visible:ring-offset-2 focus-visible:ring-offset-2
focus-visible:ring-offset-transparent focus-visible:ring-offset-transparent
transition-[background,border-color,color,box-shadow] transition-[background,border-color,color,box-shadow]
@@ -121,15 +141,7 @@ function ChipView({
? "cursor-not-allowed opacity-60" ? "cursor-not-allowed opacity-60"
: "cursor-pointer"; : "cursor-pointer";
const combinedClasses = [ const combinedClasses = [baseClasses, sizeClasses, stateClasses, className]
baseClasses,
sizeClasses,
background,
border,
textColor,
stateClasses,
className,
]
.filter(Boolean) .filter(Boolean)
.join(" "); .join(" ");
@@ -153,6 +165,7 @@ function ChipView({
return ( return (
<div <div
className={combinedClasses} className={combinedClasses}
style={surfaceStyle}
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={handleClick} onClick={handleClick}
@@ -262,6 +275,7 @@ function ChipView({
<button <button
type="button" type="button"
className={combinedClasses} className={combinedClasses}
style={surfaceStyle}
disabled={isDisabled} disabled={isDisabled}
onClick={handleClick} onClick={handleClick}
{...sharedA11y} {...sharedA11y}
@@ -283,7 +297,15 @@ function ChipView({
/> />
</svg> </svg>
) : null} ) : null}
<span className="min-w-0 truncate">{label}</span> <span
className="min-w-0 truncate"
style={{
color: surfaceStyle.color,
WebkitTextFillColor: surfaceStyle.WebkitTextFillColor,
}}
>
{label}
</span>
{onRemove && !isDisabled && ( {onRemove && !isDisabled && (
<button <button
type="button" type="button"
+10
View File
@@ -41,6 +41,16 @@ describe("Chip", () => {
expect(chip.querySelector("svg")).toBeNull(); expect(chip.querySelector("svg")).toBeNull();
}); });
it("paints unselected chips on the element so native button styles cannot win", () => {
render(<Chip label="Worker cooperative" state="unselected" />);
const chip = screen.getByRole("button", { name: "Worker cooperative" });
expect(chip.style.color).toContain("--color-content-default-brand-primary");
expect(chip.style.backgroundColor).toBe("transparent");
expect(chip.style.borderColor).toContain(
"--color-border-default-tertiary",
);
});
it("exposes aria-pressed and a check mark when selected", () => { it("exposes aria-pressed and a check mark when selected", () => {
render(<Chip label="Worker cooperative" state="selected" />); render(<Chip label="Worker cooperative" state="selected" />);
const chip = screen.getByRole("button", { name: "Worker cooperative" }); const chip = screen.getByRole("button", { name: "Worker cooperative" });