Files
community-rule/app/components/controls/Chip/Chip.view.tsx
T

331 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
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,
palette,
size,
className = "",
disabled = false,
onClick,
onRemove,
onCheck,
onClose,
inputValue,
onInputChange,
onInputKeyDown,
inputRef,
ariaLabel,
confirmAriaLabel,
typeToAddPlaceholder,
closeAriaLabel,
}: ChipViewProps) {
// 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 isSmall = size === "s";
const surfaceStyle = chipSurfaceStyle(state, palette, size);
// Size-based styles from Figma tokens
// Custom state has different padding
const sizeClasses = isCustom
? isSmall
? "px-[var(--measures-spacing-100,4px)] py-[3px] text-x-small-paragraph"
: "px-[var(--measures-spacing-150,6px)] py-[10px] text-medium-paragraph"
: isSmall
? "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";
const baseClasses = `
appearance-none
inline-flex
max-w-full
items-center
justify-center
rounded-[var(--measures-radius-full,9999px)]
overflow-clip
box-border
focus:outline-none
focus-visible:ring-2
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]
duration-200
ease-in-out
`
.trim()
.replace(/\s+/g, " ");
const stateClasses = isDisabled
? "cursor-not-allowed opacity-60"
: "cursor-pointer";
const combinedClasses = [baseClasses, sizeClasses, stateClasses, className]
.filter(Boolean)
.join(" ");
const handleClick = (
event: React.MouseEvent<HTMLButtonElement | HTMLDivElement>,
) => {
if (isDisabled) {
event.preventDefault();
return;
}
onClick?.(event as React.MouseEvent<HTMLButtonElement>);
};
const sharedA11y = {
"aria-label": ariaLabel,
...(isToggle ? { "aria-pressed": isSelected } : {}),
};
// Custom state rendering with check/close buttons
if (isCustom) {
return (
<div
className={combinedClasses}
style={surfaceStyle}
role="button"
tabIndex={0}
onClick={handleClick}
onKeyDown={(e) => {
if (e.target instanceof HTMLInputElement) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleClick(e as unknown as React.MouseEvent<HTMLButtonElement>);
}
}}
{...sharedA11y}
>
<div
className={`flex items-center ${isSmall ? "gap-[8px]" : "gap-[12px]"}`}
>
{/* Check button */}
{onCheck && (
<button
type="button"
className="flex items-center justify-center p-[var(--measures-spacing-150,6px)] rounded-full hover:bg-[var(--color-surface-default-semi-opaque,rgba(0,0,0,0.1))] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
aria-label={confirmAriaLabel}
disabled={!inputValue || !inputValue.trim()}
onClick={(event) => {
event.stopPropagation();
// The container's handleCheck will get the value from state
if (inputValue && inputValue.trim() && onCheck) {
onCheck(inputValue.trim(), event);
}
}}
>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="text-[var(--color-content-default-brand-primary,#fefcc9)]"
>
<path
d="M10 3L4.5 8.5L2 6"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
)}
{/* Input field */}
<div className="flex items-center flex-1 min-w-0">
<input
ref={inputRef}
type="text"
value={inputValue ?? ""}
onChange={(e) => onInputChange?.(e.target.value)}
onKeyDown={onInputKeyDown}
placeholder={typeToAddPlaceholder}
className="bg-transparent border-none outline-none flex-1 min-w-0 font-normal text-[color:var(--color-content-default-tertiary,#b4b4b4)] placeholder:text-[color:var(--color-content-default-tertiary,#b4b4b4)]"
style={{
fontSize: isSmall
? "var(--sizing-300,12px)"
: "var(--sizing-400,16px)",
lineHeight: isSmall ? "16px" : "24px",
}}
onClick={(e) => e.stopPropagation()}
onFocus={(e) => e.stopPropagation()}
/>
</div>
{/* Close button */}
{onClose && (
<button
type="button"
className="flex items-center justify-center p-[var(--measures-spacing-150,6px)] rounded-full hover:bg-[var(--color-surface-default-semi-opaque,rgba(0,0,0,0.1))] transition-colors"
aria-label={closeAriaLabel}
onClick={(event) => {
event.stopPropagation();
onClose(event);
}}
>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="text-[var(--color-content-default-brand-primary,#fefcc9)]"
>
<path
d="M9 3L3 9M3 3L9 9"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
)}
</div>
</div>
);
}
// Regular state rendering
return (
<button
type="button"
className={combinedClasses}
style={surfaceStyle}
disabled={isDisabled}
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"
style={{
color: surfaceStyle.color,
WebkitTextFillColor: surfaceStyle.WebkitTextFillColor,
}}
>
{label}
</span>
{onRemove && !isDisabled && (
<button
type="button"
className="ml-[var(--measures-spacing-050,2px)] p-[var(--measures-spacing-050,2px)] rounded-full hover:bg-[var(--color-surface-default-semi-opaque,rgba(0,0,0,0.05))]"
aria-label={`Remove ${label}`}
onClick={(event) => {
event.stopPropagation();
onRemove(event);
}}
>
<span className="block w-[12px] h-[12px] leading-none text-[10px]">
×
</span>
</button>
)}
</button>
);
}
ChipView.displayName = "ChipView";
export default memo(ChipView);