Expose builder chip selection beyond color, raise unselected contrast, and make the five-value limit visible #76
@@ -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 && (
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -23,12 +23,12 @@ 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 isInverse = palette === "inverse";
|
||||||
const isDefault = palette === "default";
|
const isDefault = palette === "default";
|
||||||
@@ -145,6 +145,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
|
||||||
@@ -265,6 +266,23 @@ function ChipView({
|
|||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
{...sharedA11y}
|
{...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>
|
<span className="min-w-0 truncate">{label}</span>
|
||||||
{onRemove && !isDisabled && (
|
{onRemove && !isDisabled && (
|
||||||
<button
|
<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,25 +49,47 @@ 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) => {
|
||||||
|
const isCustom = option.state === "custom";
|
||||||
|
const isSelected = option.state === "selected";
|
||||||
|
const chipDisabled = !isCustom && !isSelected && atLimit;
|
||||||
|
const chipState = chipDisabled
|
||||||
|
? "disabled"
|
||||||
|
: option.state || "unselected";
|
||||||
|
return (
|
||||||
<Chip
|
<Chip
|
||||||
key={option.id}
|
key={option.id}
|
||||||
label={option.state === "custom" ? "" : option.label}
|
label={isCustom ? "" : option.label}
|
||||||
state={option.state || "unselected"}
|
state={chipState}
|
||||||
palette={palette}
|
palette={palette}
|
||||||
size={chipSize}
|
size={chipSize}
|
||||||
|
disabled={chipDisabled}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (option.state !== "custom" && onChipClick) {
|
if (!isCustom && !chipDisabled && onChipClick) {
|
||||||
onChipClick(option.id);
|
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. */}
|
{/* 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.",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,18 @@ 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("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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user