diff --git a/.cursor/rules/create-flow.mdc b/.cursor/rules/create-flow.mdc index cf8d0a2..0d872bb 100644 --- a/.cursor/rules/create-flow.mdc +++ b/.cursor/rules/create-flow.mdc @@ -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 diff --git a/app/(app)/create/components/ApplicableScopeField.tsx b/app/(app)/create/components/ApplicableScopeField.tsx index bdb0635..61d9467 100644 --- a/app/(app)/create/components/ApplicableScopeField.tsx +++ b/app/(app)/create/components/ApplicableScopeField.tsx @@ -74,11 +74,16 @@ function ApplicableScopeFieldComponent({
{scopes.map((scope) => { const isSelected = selectedScopes.includes(scope); + const chipState = isSelected + ? "selected" + : readOnly + ? "disabled" + : "unselected"; return ( 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() { @@ -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 && ( diff --git a/app/components/controls/Chip/Chip.types.ts b/app/components/controls/Chip/Chip.types.ts index 2a082a6..f0d2974 100644 --- a/app/components/controls/Chip/Chip.types.ts +++ b/app/components/controls/Chip/Chip.types.ts @@ -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) => void; diff --git a/app/components/controls/Chip/Chip.view.tsx b/app/components/controls/Chip/Chip.view.tsx index 5658f59..7b5f7c8 100644 --- a/app/components/controls/Chip/Chip.view.tsx +++ b/app/components/controls/Chip/Chip.view.tsx @@ -1,8 +1,81 @@ "use client"; -import { memo } from "react"; +import { memo, type CSSProperties } from "react"; 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({ label, state, @@ -23,17 +96,15 @@ 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 isInverse = palette === "inverse"; - const isDefault = palette === "default"; + const isToggle = !isCustom; const isSmall = size === "s"; + const surfaceStyle = chipSurfaceStyle(state, palette, size); // Size-based styles from Figma tokens // 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" : "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 = ` + appearance-none inline-flex max-w-full items-center @@ -107,7 +127,7 @@ function ChipView({ box-border focus:outline-none 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-transparent transition-[background,border-color,color,box-shadow] @@ -121,15 +141,7 @@ function ChipView({ ? "cursor-not-allowed opacity-60" : "cursor-pointer"; - const combinedClasses = [ - baseClasses, - sizeClasses, - background, - border, - textColor, - stateClasses, - className, - ] + const combinedClasses = [baseClasses, sizeClasses, stateClasses, className] .filter(Boolean) .join(" "); @@ -145,6 +157,7 @@ function ChipView({ const sharedA11y = { "aria-label": ariaLabel, + ...(isToggle ? { "aria-pressed": isSelected } : {}), }; // Custom state rendering with check/close buttons @@ -152,6 +165,7 @@ function ChipView({ return (
- {label} + {isSelected ? ( + + + + ) : null} + + {label} + {onRemove && !isDisabled && (