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
16 changed files with 266 additions and 51 deletions
Showing only changes of commit 64a2ef5a00 - Show all commits
+1 -1
View File
@@ -33,7 +33,7 @@ Reach for these before writing new markup:
| `[ value +]` numeric stepper (± label) | `app/components/controls/Incrementer` / `IncrementerBlock` |
| Mid-paragraph "expand / see all" link button | `app/components/buttons/InlineTextButton` |
| Help-icon + label above a control | `app/components/type/InputLabel` (`helpIcon` prop) |
| Toggle chip (dim-but-clickable) | `Chip` with `state="Disabled" disabled={false}` |
| Toggle chip | `Chip` with `state="selected"` / `"unselected"` (Chip sets `aria-pressed`; selected includes a check mark) |
| Card-click → structured creation modal | `Create` with `backdropVariant="blurredYellow"` |
If a screen grows a 2nd inline copy of any pattern above, **extract a shared
@@ -74,11 +74,16 @@ function ApplicableScopeFieldComponent({
<div className="flex flex-wrap items-center gap-2">
{scopes.map((scope) => {
const isSelected = selectedScopes.includes(scope);
const chipState = isSelected
? "selected"
: readOnly
? "disabled"
: "unselected";
return (
<Chip
key={scope}
label={scope}
state={isSelected ? "selected" : "disabled"}
state={chipState}
palette="default"
size="s"
disabled={readOnly}
@@ -303,7 +303,6 @@ export function CommunityStructureSelectScreen() {
<>
<MultiSelect
label={cs.organizationMultiSelect.label}
showHelpIcon
size="s"
options={organizationTypeOptions}
onChipClick={handleOrganizationTypeClick}
@@ -313,7 +312,6 @@ export function CommunityStructureSelectScreen() {
/>
<MultiSelect
label={cs.scaleMultiSelect.label}
showHelpIcon
size="s"
options={scaleOptions}
onChipClick={handleScaleClick}
@@ -323,7 +321,6 @@ export function CommunityStructureSelectScreen() {
/>
<MultiSelect
label={cs.maturityMultiSelect.label}
showHelpIcon
size="s"
options={maturityOptions}
onChipClick={handleMaturityClick}
@@ -587,17 +587,23 @@ export function CoreValuesSelectScreen() {
[draft, markCreateFlowInteraction, replaceState, wizardCustomizeChipId],
);
const selectedCount = useMemo(
() => coreValueOptions.filter((o) => o.state === "selected").length,
[coreValueOptions],
);
const atSelectionLimit = selectedCount >= MAX_CORE_VALUES;
const selectionCountText = cv.multiSelect.selectionCount
.replace("{count}", String(selectedCount))
.replace("{max}", String(MAX_CORE_VALUES));
const kebabMenuItems = useMemo(() => {
if (!modalSession || !activeModalChipId) return [];
const selectedCount = coreValueOptions.filter(
(o) => o.state === "selected",
).length;
return buildCustomRuleModalKebabMenu(modalKebabMenu, {
showCustomize: true,
onCustomize: handleCustomize,
onDuplicate:
(state.editingPublishedRuleId?.trim() ?? "") !== "" ||
selectedCount >= MAX_CORE_VALUES
atSelectionLimit
? undefined
: handleDuplicateCoreChip,
showRemove: modalSession === "editing",
@@ -605,7 +611,7 @@ export function CoreValuesSelectScreen() {
});
}, [
activeModalChipId,
coreValueOptions,
atSelectionLimit,
handleCustomize,
handleDuplicateCoreChip,
handleRemoveFromKebab,
@@ -695,7 +701,8 @@ export function CoreValuesSelectScreen() {
<button
type="button"
onClick={addHandlers.onAddClick}
className="cursor-pointer font-normal leading-[1.3] text-[color:var(--color-content-default-tertiary,#b4b4b4)] underline decoration-solid underline-offset-[3px] hover:opacity-90"
disabled={atSelectionLimit}
className="cursor-pointer font-normal leading-[1.3] text-[color:var(--color-content-default-tertiary,#b4b4b4)] underline decoration-solid underline-offset-[3px] hover:opacity-90 disabled:cursor-not-allowed disabled:no-underline disabled:opacity-60 disabled:hover:opacity-60"
>
{cv.header.addLink}
</button>
@@ -730,6 +737,9 @@ export function CoreValuesSelectScreen() {
onCustomChipClose={addHandlers.onCustomChipClose}
addButton
addButtonText={cv.multiSelect.addButtonText}
maxSelections={MAX_CORE_VALUES}
selectionCountText={selectionCountText}
limitReachedAnnouncement={cv.multiSelect.limitReached}
/>
{detailModal && (
+3 -4
View File
@@ -29,10 +29,9 @@ export interface ChipProps {
className?: string;
/**
* Whether the chip should be non-interactive. Defaults to `true` when
* `state === "disabled"` to preserve historical behavior. Pass
* `disabled={false}` alongside `state="disabled"` to render the dimmed
* "disabled" visual while keeping the chip clickable — useful for toggle
* groups where the unselected state is the disabled visual.
* `state === "disabled"`. Toggle groups use `selected` / `unselected`
* (Chip sets `aria-pressed`); pass `disabled` only when the chip cannot
* be activated.
*/
disabled?: boolean;
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
+21 -3
View File
@@ -23,12 +23,12 @@ function ChipView({
typeToAddPlaceholder,
closeAriaLabel,
}: ChipViewProps) {
// The container is the source of truth for `disabled`. This allows
// `state="disabled"` to be used purely as a visual (for toggle-group chips
// that look dimmed while remaining clickable) by passing `disabled={false}`.
// The container is the source of truth for `disabled`. `state="disabled"`
// is the non-interactive visual only — toggle groups use selected/unselected.
const isDisabled = disabled ?? false;
const isSelected = state === "selected";
const isCustom = state === "custom";
const isToggle = !isCustom;
const isInverse = palette === "inverse";
const isDefault = palette === "default";
@@ -145,6 +145,7 @@ function ChipView({
const sharedA11y = {
"aria-label": ariaLabel,
...(isToggle ? { "aria-pressed": isSelected } : {}),
};
// Custom state rendering with check/close buttons
@@ -265,6 +266,23 @@ function ChipView({
onClick={handleClick}
{...sharedA11y}
>
{isSelected ? (
<svg
aria-hidden
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={`shrink-0 ${isSmall ? "size-[12px]" : "size-[16px]"}`}
>
<path
d="M10 3L4.5 8.5L2 6"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
) : null}
<span className="min-w-0 truncate">{label}</span>
{onRemove && !isDisabled && (
<button
@@ -12,7 +12,7 @@ import type { MultiSelectProps } from "./MultiSelect.types";
const MultiSelectContainer = memo<MultiSelectProps>(
({
label,
showHelpIcon = true,
showHelpIcon = false,
size: sizeProp = "m",
palette: paletteProp = "default",
options,
@@ -23,6 +23,9 @@ const MultiSelectContainer = memo<MultiSelectProps>(
formHeader = true,
onCustomChipConfirm,
onCustomChipClose,
maxSelections,
selectionCountText,
limitReachedAnnouncement,
className = "",
}) => {
const t = useTranslation("controlsChrome");
@@ -46,6 +49,9 @@ const MultiSelectContainer = memo<MultiSelectProps>(
formHeader={formHeader}
onCustomChipConfirm={onCustomChipConfirm}
onCustomChipClose={onCustomChipClose}
maxSelections={maxSelections}
selectionCountText={selectionCountText}
limitReachedAnnouncement={limitReachedAnnouncement}
className={className}
/>
);
@@ -62,6 +62,15 @@ export interface MultiSelectProps {
* Callback when a custom chip is closed/removed
*/
onCustomChipClose?: (chipId: string) => void;
/**
* When set, unselected chips and the add control become non-interactive
* once this many options are `selected`.
*/
maxSelections?: number;
/** Visible selection counter, e.g. "3 of 5". */
selectionCountText?: string;
/** Announced when the selection limit is reached. */
limitReachedAnnouncement?: string;
className?: string;
}
@@ -79,5 +88,8 @@ export interface MultiSelectViewProps {
formHeader: boolean;
onCustomChipConfirm?: (chipId: string, value: string) => void;
onCustomChipClose?: (chipId: string) => void;
maxSelections?: number;
selectionCountText?: string;
limitReachedAnnouncement?: string;
className: string;
}
@@ -19,6 +19,9 @@ function MultiSelectView({
formHeader = true,
onCustomChipConfirm,
onCustomChipClose,
maxSelections,
selectionCountText,
limitReachedAnnouncement,
className,
}: MultiSelectViewProps) {
const isSmall = size === "s";
@@ -30,6 +33,11 @@ function MultiSelectView({
: "gap-[var(--measures-spacing-300,12px)]";
const chipSize = size;
const selectedCount = options.filter((o) => o.state === "selected").length;
const atLimit =
maxSelections != null && selectedCount >= maxSelections;
const countCaption = selectionCountText?.trim() ?? "";
const helperText = countCaption.length > 0 ? countCaption : false;
return (
<div
@@ -41,25 +49,47 @@ function MultiSelectView({
label={label}
helpIcon={showHelpIcon}
asterisk={false}
helperText={false}
helperText={helperText}
size={size}
palette={palette}
/>
)}
{!formHeader || !label ? (
countCaption.length > 0 ? (
<p className="w-full text-x-small-paragraph text-[color:var(--color-content-default-tertiary,#b4b4b4)]">
{countCaption}
</p>
) : null
) : null}
{limitReachedAnnouncement ? (
<p className="sr-only" aria-live="polite">
{atLimit ? limitReachedAnnouncement : ""}
</p>
) : null}
{/* Chips container */}
<div
className={`flex flex-wrap ${gapClass} items-center relative shrink-0 w-full`}
>
{options.map((option) => (
{options.map((option) => {
const isCustom = option.state === "custom";
const isSelected = option.state === "selected";
const chipDisabled = !isCustom && !isSelected && atLimit;
const chipState = chipDisabled
? "disabled"
: option.state || "unselected";
return (
<Chip
key={option.id}
label={option.state === "custom" ? "" : option.label}
state={option.state || "unselected"}
label={isCustom ? "" : option.label}
state={chipState}
palette={palette}
size={chipSize}
disabled={chipDisabled}
onClick={() => {
if (option.state !== "custom" && onChipClick) {
if (!isCustom && !chipDisabled && onChipClick) {
onChipClick(option.id);
}
}}
@@ -76,23 +106,26 @@ function MultiSelectView({
}
}}
/>
))}
);
})}
{/* Add button — icon-only: bordered circle + brand icon (chips stay yellow). With label: Figma 19688:38288 — brand + icon, primary label text, no fill/border. */}
{addButton && (
<button
type="button"
aria-label={addButtonAriaLabel}
disabled={atLimit}
onClick={(e) => {
e.stopPropagation();
if (atLimit) return;
onAddClick?.();
}}
className={
!addButtonText
? // Circular button with border (Rule style)
`cursor-pointer bg-[var(--color-surface-default-transparent,rgba(0,0,0,0))] border-[1.25px] ${isInverse ? "border-[var(--color-border-default-primary,#141414)]" : "border-[var(--color-border-default-tertiary,#464646)]"} border-solid flex items-center justify-center ${isSmall ? "size-[30px]" : "size-[40px]"} rounded-[var(--measures-radius-full,9999px)] shrink-0 hover:opacity-80 transition-opacity`
`${atLimit ? "cursor-not-allowed opacity-60" : "cursor-pointer hover:opacity-80"} bg-[var(--color-surface-default-transparent,rgba(0,0,0,0))] border-[1.25px] ${isInverse ? "border-[var(--color-border-default-primary,#141414)]" : "border-[var(--color-border-default-tertiary,#464646)]"} border-solid flex items-center justify-center ${isSmall ? "size-[30px]" : "size-[40px]"} rounded-[var(--measures-radius-full,9999px)] shrink-0 transition-opacity`
: // Text add control (default palette: white label + brand “+”; inverse: inverse primary for both)
`cursor-pointer flex items-center justify-center overflow-hidden rounded-[var(--measures-radius-full,9999px)] shrink-0 hover:opacity-80 transition-opacity ${
`${atLimit ? "cursor-not-allowed opacity-60" : "cursor-pointer hover:opacity-80"} flex items-center justify-center overflow-hidden rounded-[var(--measures-radius-full,9999px)] shrink-0 transition-opacity ${
isSmall
? "gap-[var(--measures-spacing-100,4px)] px-[var(--measures-spacing-300,12px)] py-[var(--measures-spacing-200,8px)]"
: "gap-[var(--measures-spacing-150,6px)] px-[var(--space-400,16px)] py-[var(--measures-spacing-300,12px)]"
@@ -6,7 +6,9 @@
"descriptionTrail": "new values to the list."
},
"multiSelect": {
"addButtonText": "Add value"
"addButtonText": "Add value",
"selectionCount": "{count} of {max}",
"limitReached": "Five of five values selected. Remaining values are unavailable until one is removed."
},
"detailModal": {
"subtitle": "Edit or add to this description to describe what this value means to your community.",
+1 -1
View File
@@ -53,7 +53,7 @@ const defaultOptions = [
export const Default = {
args: {
label: "Organization type",
showHelpIcon: true,
showHelpIcon: false,
size: "m",
palette: "default",
options: defaultOptions,
@@ -125,4 +125,32 @@ describe("ApplicableScopeField behavior", () => {
expect(screen.queryByAltText("Help")).not.toBeInTheDocument();
});
it("exposes selected state on chips and keeps unselected chips enabled", () => {
renderWithProviders(<ApplicableScopeField {...baseProps} />);
const selected = screen.getByRole("button", { name: /Deselect Finance/i });
const unselected = screen.getByRole("button", {
name: /Select Operations/i,
});
expect(selected).toHaveAttribute("aria-pressed", "true");
expect(selected).toBeEnabled();
expect(unselected).toHaveAttribute("aria-pressed", "false");
expect(unselected).toBeEnabled();
});
it("uses a real disabled state when readOnly", () => {
renderWithProviders(<ApplicableScopeField {...baseProps} readOnly />);
expect(
screen.getByRole("button", { name: /Deselect Finance/i }),
).toBeDisabled();
expect(
screen.getByRole("button", { name: /Select Operations/i }),
).toBeDisabled();
expect(
screen.queryByRole("button", { name: /Add Applicable Scope/i }),
).not.toBeInTheDocument();
});
});
+18 -1
View File
@@ -1,8 +1,11 @@
import { describe } from "vitest";
import { describe, it, expect } from "vitest";
import { screen } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import {
componentTestSuite,
type ComponentTestSuiteConfig,
} from "../utils/componentTestSuite";
import { renderWithProviders as render } from "../utils/test-utils";
import Chip from "../../app/components/controls/Chip";
type Props = React.ComponentProps<typeof Chip>;
@@ -30,4 +33,18 @@ const config: ComponentTestSuiteConfig<Props> = {
describe("Chip", () => {
componentTestSuite<Props>(config);
it("exposes aria-pressed false when unselected", () => {
render(<Chip label="Worker cooperative" state="unselected" />);
const chip = screen.getByRole("button", { name: "Worker cooperative" });
expect(chip).toHaveAttribute("aria-pressed", "false");
expect(chip.querySelector("svg")).toBeNull();
});
it("exposes aria-pressed and a check mark when selected", () => {
render(<Chip label="Worker cooperative" state="selected" />);
const chip = screen.getByRole("button", { name: "Worker cooperative" });
expect(chip).toHaveAttribute("aria-pressed", "true");
expect(chip.querySelector("svg[aria-hidden]")).not.toBeNull();
});
});
@@ -0,0 +1,16 @@
import { describe, it, expect } from "vitest";
import { screen } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { renderWithProviders } from "../utils/test-utils";
import { CommunityStructureSelectScreen } from "../../app/(app)/create/screens/select/CommunityStructureSelectScreen";
describe("CommunityStructureSelectScreen", () => {
it("does not render dummy help icons", () => {
renderWithProviders(<CommunityStructureSelectScreen />);
expect(screen.getByText("Organization Type")).toBeInTheDocument();
expect(screen.getByText("Scale")).toBeInTheDocument();
expect(screen.getByText("Maturity")).toBeInTheDocument();
expect(screen.queryByAltText("Help")).not.toBeInTheDocument();
});
});
@@ -389,4 +389,38 @@ describe("CoreValuesSelectScreen", () => {
expect(countCustomChips(CUSTOM_LABEL)).toBe(1);
});
});
describe("five-value limit", () => {
async function addPresetValue(label: string) {
fireEvent.click(screen.getByText(label));
const dialog = await screen.findByRole("dialog");
fireEvent.click(within(dialog).getByRole("button", { name: "Add Value" }));
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
}
it("shows a counter, disables remaining chips, and announces the limit", async () => {
renderWithProviders(<CoreValuesSelectScreen />);
expect(screen.getByText("0 of 5")).toBeInTheDocument();
await addPresetValue("Accessibility");
await addPresetValue("Accountability");
await addPresetValue("Adaptability");
await addPresetValue("Agency");
await addPresetValue("Altruism");
expect(screen.getByText("5 of 5")).toBeInTheDocument();
expect(
screen.getByText(
"Five of five values selected. Remaining values are unavailable until one is removed.",
),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Anti-oppression" }),
).toBeDisabled();
expect(screen.getByRole("button", { name: "Add value" })).toBeDisabled();
expect(screen.getByRole("button", { name: /^add$/i })).toBeDisabled();
});
});
});
+38
View File
@@ -123,6 +123,44 @@ describe("MultiSelect behaviour specifics", () => {
expect(helpIcon).toBeInTheDocument();
});
it("does not show a help icon by default", () => {
render(<MultiSelect options={defaultChipOptions} label="Test Label" />);
expect(screen.queryByAltText("Help")).not.toBeInTheDocument();
});
it("disables leftover chips and add when maxSelections is reached", async () => {
const handleChipClick = vi.fn();
const handleAddClick = vi.fn();
const options = [
{ id: "1", label: "One", state: "selected" as const },
{ id: "2", label: "Two", state: "selected" as const },
{ id: "3", label: "Three", state: "unselected" as const },
];
render(
<MultiSelect
options={options}
onChipClick={handleChipClick}
onAddClick={handleAddClick}
addButton
addButtonText="Add option"
maxSelections={2}
selectionCountText="2 of 2"
limitReachedAnnouncement="Limit reached"
/>,
);
expect(screen.getByText("2 of 2")).toBeInTheDocument();
expect(screen.getByText("Limit reached")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "One" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Three" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Add option" })).toBeDisabled();
await userEvent.click(screen.getByRole("button", { name: "Three" }));
await userEvent.click(screen.getByRole("button", { name: "Add option" }));
expect(handleChipClick).not.toHaveBeenCalled();
expect(handleAddClick).not.toHaveBeenCalled();
});
it("renders add button text when provided", () => {
render(
<MultiSelect