Drop Cookies Settings from the footer, link the Home features that already have pages, and give leftover routes their own descriptions.
331 lines
9.4 KiB
TypeScript
331 lines
9.4 KiB
TypeScript
"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" : "1.5px";
|
||
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-invert-tertiary)",
|
||
"var(--color-content-invert-primary)",
|
||
);
|
||
}
|
||
if (state === "selected") {
|
||
return paint(
|
||
"var(--color-surface-default-semi-opaque)",
|
||
"var(--color-content-invert-primary)",
|
||
"var(--color-border-default-primary)",
|
||
);
|
||
}
|
||
return paint(
|
||
"transparent",
|
||
"var(--color-content-invert-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)",
|
||
"var(--color-content-invert-primary)",
|
||
"var(--color-border-default-brand-primary)",
|
||
);
|
||
}
|
||
return paint(
|
||
"transparent",
|
||
"var(--color-content-default-brand-primary)",
|
||
"var(--color-border-default-control)",
|
||
);
|
||
}
|
||
|
||
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(--space-100)] py-[3px] text-x-small-paragraph"
|
||
: "px-[var(--space-150)] py-[10px] text-medium-paragraph"
|
||
: isSmall
|
||
? "h-[30px] px-[var(--space-200)] gap-[var(--space-050)] text-x-small-label"
|
||
: "px-[var(--space-300)] py-[var(--space-300)] gap-[var(--space-150)] text-medium-label";
|
||
|
||
const baseClasses = `
|
||
appearance-none
|
||
inline-flex
|
||
max-w-full
|
||
items-center
|
||
justify-center
|
||
rounded-[var(--radius-full)]
|
||
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 = {
|
||
...(ariaLabel ? { "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(--space-150)] rounded-full hover:bg-[var(--color-surface-default-semi-opaque)] 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)]"
|
||
>
|
||
<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)] placeholder:text-[color:var(--color-content-default-tertiary)]"
|
||
style={{
|
||
fontSize: isSmall
|
||
? "var(--sizing-300)"
|
||
: "var(--sizing-400)",
|
||
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(--space-150)] rounded-full hover:bg-[var(--color-surface-default-semi-opaque)] 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)]"
|
||
>
|
||
<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(--space-050)] p-[var(--space-050)] rounded-full hover:bg-[var(--color-surface-default-semi-opaque)]"
|
||
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);
|