Compare commits

...
3 Commits
16 changed files with 365 additions and 118 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ Reach for these before writing new markup:
| `[ value +]` numeric stepper (± label) | `app/components/controls/Incrementer` / `IncrementerBlock` | | `[ value +]` numeric stepper (± label) | `app/components/controls/Incrementer` / `IncrementerBlock` |
| Mid-paragraph "expand / see all" link button | `app/components/buttons/InlineTextButton` | | Mid-paragraph "expand / see all" link button | `app/components/buttons/InlineTextButton` |
| Help-icon + label above a control | `app/components/type/InputLabel` (`helpIcon` prop) | | 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"` | | Card-click → structured creation modal | `Create` with `backdropVariant="blurredYellow"` |
If a screen grows a 2nd inline copy of any pattern above, **extract a shared 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"> <div className="flex flex-wrap items-center gap-2">
{scopes.map((scope) => { {scopes.map((scope) => {
const isSelected = selectedScopes.includes(scope); const isSelected = selectedScopes.includes(scope);
const chipState = isSelected
? "selected"
: readOnly
? "disabled"
: "unselected";
return ( return (
<Chip <Chip
key={scope} key={scope}
label={scope} label={scope}
state={isSelected ? "selected" : "disabled"} state={chipState}
palette="default" palette="default"
size="s" size="s"
disabled={readOnly} disabled={readOnly}
@@ -303,7 +303,6 @@ export function CommunityStructureSelectScreen() {
<> <>
<MultiSelect <MultiSelect
label={cs.organizationMultiSelect.label} label={cs.organizationMultiSelect.label}
showHelpIcon
size="s" size="s"
options={organizationTypeOptions} options={organizationTypeOptions}
onChipClick={handleOrganizationTypeClick} onChipClick={handleOrganizationTypeClick}
@@ -313,7 +312,6 @@ export function CommunityStructureSelectScreen() {
/> />
<MultiSelect <MultiSelect
label={cs.scaleMultiSelect.label} label={cs.scaleMultiSelect.label}
showHelpIcon
size="s" size="s"
options={scaleOptions} options={scaleOptions}
onChipClick={handleScaleClick} onChipClick={handleScaleClick}
@@ -323,7 +321,6 @@ export function CommunityStructureSelectScreen() {
/> />
<MultiSelect <MultiSelect
label={cs.maturityMultiSelect.label} label={cs.maturityMultiSelect.label}
showHelpIcon
size="s" size="s"
options={maturityOptions} options={maturityOptions}
onChipClick={handleMaturityClick} onChipClick={handleMaturityClick}
@@ -587,17 +587,23 @@ export function CoreValuesSelectScreen() {
[draft, markCreateFlowInteraction, replaceState, wizardCustomizeChipId], [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(() => { const kebabMenuItems = useMemo(() => {
if (!modalSession || !activeModalChipId) return []; if (!modalSession || !activeModalChipId) return [];
const selectedCount = coreValueOptions.filter(
(o) => o.state === "selected",
).length;
return buildCustomRuleModalKebabMenu(modalKebabMenu, { return buildCustomRuleModalKebabMenu(modalKebabMenu, {
showCustomize: true, showCustomize: true,
onCustomize: handleCustomize, onCustomize: handleCustomize,
onDuplicate: onDuplicate:
(state.editingPublishedRuleId?.trim() ?? "") !== "" || (state.editingPublishedRuleId?.trim() ?? "") !== "" ||
selectedCount >= MAX_CORE_VALUES atSelectionLimit
? undefined ? undefined
: handleDuplicateCoreChip, : handleDuplicateCoreChip,
showRemove: modalSession === "editing", showRemove: modalSession === "editing",
@@ -605,7 +611,7 @@ export function CoreValuesSelectScreen() {
}); });
}, [ }, [
activeModalChipId, activeModalChipId,
coreValueOptions, atSelectionLimit,
handleCustomize, handleCustomize,
handleDuplicateCoreChip, handleDuplicateCoreChip,
handleRemoveFromKebab, handleRemoveFromKebab,
@@ -695,7 +701,8 @@ export function CoreValuesSelectScreen() {
<button <button
type="button" type="button"
onClick={addHandlers.onAddClick} 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} {cv.header.addLink}
</button> </button>
@@ -730,6 +737,9 @@ export function CoreValuesSelectScreen() {
onCustomChipClose={addHandlers.onCustomChipClose} onCustomChipClose={addHandlers.onCustomChipClose}
addButton addButton
addButtonText={cv.multiSelect.addButtonText} addButtonText={cv.multiSelect.addButtonText}
maxSelections={MAX_CORE_VALUES}
selectionCountText={selectionCountText}
limitReachedAnnouncement={cv.multiSelect.limitReached}
/> />
{detailModal && ( {detailModal && (
+3 -4
View File
@@ -29,10 +29,9 @@ export interface ChipProps {
className?: string; className?: string;
/** /**
* Whether the chip should be non-interactive. Defaults to `true` when * Whether the chip should be non-interactive. Defaults to `true` when
* `state === "disabled"` to preserve historical behavior. Pass * `state === "disabled"`. Toggle groups use `selected` / `unselected`
* `disabled={false}` alongside `state="disabled"` to render the dimmed * (Chip sets `aria-pressed`); pass `disabled` only when the chip cannot
* "disabled" visual while keeping the chip clickable — useful for toggle * be activated.
* groups where the unselected state is the disabled visual.
*/ */
disabled?: boolean; disabled?: boolean;
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void; onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
+110 -70
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,
@@ -23,17 +96,15 @@ function ChipView({
typeToAddPlaceholder, typeToAddPlaceholder,
closeAriaLabel, closeAriaLabel,
}: ChipViewProps) { }: ChipViewProps) {
// The container is the source of truth for `disabled`. This allows // The container is the source of truth for `disabled`. `state="disabled"`
// `state="disabled"` to be used purely as a visual (for toggle-group chips // is the non-interactive visual only — toggle groups use selected/unselected.
// that look dimmed while remaining clickable) by passing `disabled={false}`.
const isDisabled = disabled ?? false; const isDisabled = disabled ?? false;
const isSelected = state === "selected"; const isSelected = state === "selected";
const isCustom = state === "custom"; const isCustom = state === "custom";
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(" ");
@@ -145,6 +157,7 @@ function ChipView({
const sharedA11y = { const sharedA11y = {
"aria-label": ariaLabel, "aria-label": ariaLabel,
...(isToggle ? { "aria-pressed": isSelected } : {}),
}; };
// Custom state rendering with check/close buttons // Custom state rendering with check/close buttons
@@ -152,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}
@@ -261,11 +275,37 @@ function ChipView({
<button <button
type="button" type="button"
className={combinedClasses} className={combinedClasses}
style={surfaceStyle}
disabled={isDisabled} disabled={isDisabled}
onClick={handleClick} onClick={handleClick}
{...sharedA11y} {...sharedA11y}
> >
<span className="min-w-0 truncate">{label}</span> {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"
style={{
color: surfaceStyle.color,
WebkitTextFillColor: surfaceStyle.WebkitTextFillColor,
}}
>
{label}
</span>
{onRemove && !isDisabled && ( {onRemove && !isDisabled && (
<button <button
type="button" type="button"
@@ -12,7 +12,7 @@ import type { MultiSelectProps } from "./MultiSelect.types";
const MultiSelectContainer = memo<MultiSelectProps>( const MultiSelectContainer = memo<MultiSelectProps>(
({ ({
label, label,
showHelpIcon = true, showHelpIcon = false,
size: sizeProp = "m", size: sizeProp = "m",
palette: paletteProp = "default", palette: paletteProp = "default",
options, options,
@@ -23,6 +23,9 @@ const MultiSelectContainer = memo<MultiSelectProps>(
formHeader = true, formHeader = true,
onCustomChipConfirm, onCustomChipConfirm,
onCustomChipClose, onCustomChipClose,
maxSelections,
selectionCountText,
limitReachedAnnouncement,
className = "", className = "",
}) => { }) => {
const t = useTranslation("controlsChrome"); const t = useTranslation("controlsChrome");
@@ -46,6 +49,9 @@ const MultiSelectContainer = memo<MultiSelectProps>(
formHeader={formHeader} formHeader={formHeader}
onCustomChipConfirm={onCustomChipConfirm} onCustomChipConfirm={onCustomChipConfirm}
onCustomChipClose={onCustomChipClose} onCustomChipClose={onCustomChipClose}
maxSelections={maxSelections}
selectionCountText={selectionCountText}
limitReachedAnnouncement={limitReachedAnnouncement}
className={className} className={className}
/> />
); );
@@ -62,6 +62,15 @@ export interface MultiSelectProps {
* Callback when a custom chip is closed/removed * Callback when a custom chip is closed/removed
*/ */
onCustomChipClose?: (chipId: string) => void; 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; className?: string;
} }
@@ -79,5 +88,8 @@ export interface MultiSelectViewProps {
formHeader: boolean; formHeader: boolean;
onCustomChipConfirm?: (chipId: string, value: string) => void; onCustomChipConfirm?: (chipId: string, value: string) => void;
onCustomChipClose?: (chipId: string) => void; onCustomChipClose?: (chipId: string) => void;
maxSelections?: number;
selectionCountText?: string;
limitReachedAnnouncement?: string;
className: string; className: string;
} }
@@ -19,6 +19,9 @@ function MultiSelectView({
formHeader = true, formHeader = true,
onCustomChipConfirm, onCustomChipConfirm,
onCustomChipClose, onCustomChipClose,
maxSelections,
selectionCountText,
limitReachedAnnouncement,
className, className,
}: MultiSelectViewProps) { }: MultiSelectViewProps) {
const isSmall = size === "s"; const isSmall = size === "s";
@@ -30,6 +33,11 @@ function MultiSelectView({
: "gap-[var(--measures-spacing-300,12px)]"; : "gap-[var(--measures-spacing-300,12px)]";
const chipSize = size; 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 ( return (
<div <div
@@ -41,58 +49,83 @@ function MultiSelectView({
label={label} label={label}
helpIcon={showHelpIcon} helpIcon={showHelpIcon}
asterisk={false} asterisk={false}
helperText={false} helperText={helperText}
size={size} size={size}
palette={palette} 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 */} {/* Chips container */}
<div <div
className={`flex flex-wrap ${gapClass} items-center relative shrink-0 w-full`} className={`flex flex-wrap ${gapClass} items-center relative shrink-0 w-full`}
> >
{options.map((option) => ( {options.map((option) => {
<Chip const isCustom = option.state === "custom";
key={option.id} const isSelected = option.state === "selected";
label={option.state === "custom" ? "" : option.label} const chipDisabled = !isCustom && !isSelected && atLimit;
state={option.state || "unselected"} const chipState = chipDisabled
palette={palette} ? "disabled"
size={chipSize} : option.state || "unselected";
onClick={() => { return (
if (option.state !== "custom" && onChipClick) { <Chip
onChipClick(option.id); key={option.id}
} label={isCustom ? "" : option.label}
}} state={chipState}
onCheck={(value, e) => { palette={palette}
e.stopPropagation(); size={chipSize}
if (onCustomChipConfirm) { disabled={chipDisabled}
onCustomChipConfirm(option.id, value); onClick={() => {
} if (!isCustom && !chipDisabled && onChipClick) {
}} onChipClick(option.id);
onClose={(e) => { }
e.stopPropagation(); }}
if (onCustomChipClose) { onCheck={(value, e) => {
onCustomChipClose(option.id); e.stopPropagation();
} if (onCustomChipConfirm) {
}} onCustomChipConfirm(option.id, value);
/> }
))} }}
onClose={(e) => {
e.stopPropagation();
if (onCustomChipClose) {
onCustomChipClose(option.id);
}
}}
/>
);
})}
{/* Add button — icon-only: bordered circle + brand icon (chips stay yellow). With label: Figma 19688:38288 — brand + icon, primary label text, no fill/border. */} {/* 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 && ( {addButton && (
<button <button
type="button" type="button"
aria-label={addButtonAriaLabel} aria-label={addButtonAriaLabel}
disabled={atLimit}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (atLimit) return;
onAddClick?.(); onAddClick?.();
}} }}
className={ className={
!addButtonText !addButtonText
? // Circular button with border (Rule style) ? // 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) : // 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 isSmall
? "gap-[var(--measures-spacing-100,4px)] px-[var(--measures-spacing-300,12px)] py-[var(--measures-spacing-200,8px)]" ? "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)]" : "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." "descriptionTrail": "new values to the list."
}, },
"multiSelect": { "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": { "detailModal": {
"subtitle": "Edit or add to this description to describe what this value means to your community.", "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 = { export const Default = {
args: { args: {
label: "Organization type", label: "Organization type",
showHelpIcon: true, showHelpIcon: false,
size: "m", size: "m",
palette: "default", palette: "default",
options: defaultOptions, options: defaultOptions,
@@ -125,4 +125,32 @@ describe("ApplicableScopeField behavior", () => {
expect(screen.queryByAltText("Help")).not.toBeInTheDocument(); 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();
});
}); });
+28 -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 { import {
componentTestSuite, componentTestSuite,
type ComponentTestSuiteConfig, type ComponentTestSuiteConfig,
} from "../utils/componentTestSuite"; } from "../utils/componentTestSuite";
import { renderWithProviders as render } from "../utils/test-utils";
import Chip from "../../app/components/controls/Chip"; import Chip from "../../app/components/controls/Chip";
type Props = React.ComponentProps<typeof Chip>; type Props = React.ComponentProps<typeof Chip>;
@@ -30,4 +33,28 @@ const config: ComponentTestSuiteConfig<Props> = {
describe("Chip", () => { describe("Chip", () => {
componentTestSuite<Props>(config); 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("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", () => {
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); 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(); 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", () => { it("renders add button text when provided", () => {
render( render(
<MultiSelect <MultiSelect