Select and Context Menu component with storybook and testing

This commit is contained in:
adilallo
2025-10-10 12:07:13 -06:00
parent 2bc5fcdf45
commit 9c72afdc52
20 changed files with 3827 additions and 88 deletions
+36
View File
@@ -0,0 +1,36 @@
"use client";
import React, { forwardRef, memo } from "react";
const ContextMenu = forwardRef(
({ className = "", children, ...props }, ref) => {
const menuClasses = `
bg-black
border border-[var(--color-border-default-tertiary)]
rounded-[var(--measures-radius-medium)]
shadow-lg
p-[4px]
min-w-[200px]
max-w-[300px]
${className}
`
.trim()
.replace(/\s+/g, " ");
return (
<div
ref={ref}
className={menuClasses}
role="menu"
style={{ backgroundColor: "#000000" }}
{...props}
>
{children}
</div>
);
}
);
ContextMenu.displayName = "ContextMenu";
export default memo(ContextMenu);
+21
View File
@@ -0,0 +1,21 @@
"use client";
import React, { forwardRef, memo } from "react";
const ContextMenuDivider = forwardRef(({ className = "", ...props }, ref) => {
const dividerClasses = `
border-t border-[var(--color-border-default-tertiary)]
my-1
${className}
`
.trim()
.replace(/\s+/g, " ");
return (
<div ref={ref} className={dividerClasses} role="separator" {...props} />
);
});
ContextMenuDivider.displayName = "ContextMenuDivider";
export default memo(ContextMenuDivider);
+127
View File
@@ -0,0 +1,127 @@
"use client";
import React, { forwardRef, memo, useCallback } from "react";
const ContextMenuItem = forwardRef(
(
{
children,
selected = false,
hasSubmenu = false,
disabled = false,
className = "",
onClick,
size = "medium",
...props
},
ref
) => {
const getTextSize = () => {
switch (size) {
case "small":
return "text-[10px] leading-[14px]";
case "medium":
return "text-[14px] leading-[20px]";
case "large":
return "text-[16px] leading-[24px]";
default:
return "text-[14px] leading-[20px]";
}
};
const itemClasses = `
flex items-center justify-between
px-[8px] py-[4px]
text-[var(--color-content-default-brand-primary)]
${getTextSize()}
cursor-pointer
transition-colors duration-150
${
selected
? "bg-[var(--color-surface-default-secondary)] rounded-[var(--measures-radius-small)]"
: ""
}
${
disabled
? "opacity-50 cursor-not-allowed"
: "hover:!bg-[var(--color-surface-default-secondary)] hover:!rounded-[var(--measures-radius-small)]"
}
${className}
`
.trim()
.replace(/\s+/g, " ");
const handleClick = useCallback(
(e) => {
if (!disabled && onClick) {
onClick(e);
}
},
[disabled, onClick]
);
const handleKeyDown = useCallback(
(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (!disabled && onClick) {
onClick(e);
}
}
},
[disabled, onClick]
);
return (
<div
ref={ref}
className={itemClasses}
role="menuitem"
tabIndex={disabled ? -1 : 0}
aria-current={selected ? "true" : undefined}
aria-disabled={disabled}
onClick={handleClick}
onKeyDown={handleKeyDown}
{...props}
>
<div className="flex items-center gap-[8px]">
{selected && (
<svg
className="w-4 h-4 text-[var(--color-content-default-brand-primary)]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
)}
<span>{children}</span>
</div>
{hasSubmenu && (
<svg
className="w-4 h-4 text-[var(--color-content-default-brand-primary)]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
)}
</div>
);
}
);
ContextMenuItem.displayName = "ContextMenuItem";
export default memo(ContextMenuItem);
+30
View File
@@ -0,0 +1,30 @@
"use client";
import React, { forwardRef, memo } from "react";
const ContextMenuSection = forwardRef(
({ title, children, className = "", ...props }, ref) => {
const sectionClasses = `
${className}
`
.trim()
.replace(/\s+/g, " ");
return (
<div ref={ref} className={sectionClasses} role="group" {...props}>
{title && (
<div className="px-3 py-2">
<div className="text-[var(--color-content-default-primary)] text-sm font-medium">
{title}
</div>
</div>
)}
{children}
</div>
);
}
);
ContextMenuSection.displayName = "ContextMenuSection";
export default memo(ContextMenuSection);
+8 -5
View File
@@ -31,19 +31,22 @@ const Input = forwardRef(
// Size variants
const sizeStyles = {
small: {
input: "h-[30px] px-[12px] text-[10px]",
input:
labelVariant === "horizontal"
? "h-[30px] px-[12px] py-[8px] text-[10px]"
: "h-[32px] px-[12px] py-[8px] text-[10px]",
label: "text-[12px] leading-[14px] font-medium",
container: "gap-[4px]",
radius: "var(--measures-radius-small)",
},
medium: {
input: "h-[36px] px-[16px] text-[14px] leading-[20px]",
input: "h-[36px] px-[12px] py-[8px] text-[14px] leading-[20px]",
label: "text-[14px] leading-[16px] font-medium",
container: "gap-[8px]",
radius: "var(--measures-radius-medium)",
},
large: {
input: "h-[40px] px-[20px] text-[16px] leading-[24px]",
input: "h-[40px] px-[12px] py-[8px] text-[16px] leading-[24px]",
label: "text-[16px] leading-[20px] font-medium",
container: "gap-[12px]",
radius: "var(--measures-radius-large)",
@@ -78,7 +81,7 @@ const Input = forwardRef(
case "hover":
return {
input:
"bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border-2 border-[var(--color-border-default-brand-primary)]",
"bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border border-[var(--color-border-default-tertiary)] shadow-[0_0_0_2px_var(--color-border-default-tertiary)]",
label: "text-[var(--color-content-default-primary)]",
};
case "focus":
@@ -90,7 +93,7 @@ const Input = forwardRef(
default:
return {
input:
"bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border border-[var(--color-border-default-tertiary)] hover:outline hover:outline-2 hover:outline-[var(--color-border-default-tertiary)]",
"bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border border-[var(--color-border-default-tertiary)] hover:shadow-[0_0_0_2px_var(--color-border-default-tertiary)]",
label: "text-[var(--color-content-default-primary)]",
};
}
+337
View File
@@ -0,0 +1,337 @@
"use client";
import React, {
forwardRef,
useId,
useState,
useRef,
useEffect,
useCallback,
memo,
} from "react";
import ContextMenu from "./ContextMenu";
import ContextMenuItem from "./ContextMenuItem";
import ContextMenuSection from "./ContextMenuSection";
import ContextMenuDivider from "./ContextMenuDivider";
const Select = forwardRef(
(
{
id,
label,
labelVariant = "default",
size = "medium",
state = "default",
disabled = false,
error = false,
placeholder = "Select an option",
className = "",
children,
value,
onChange,
...props
},
ref
) => {
const selectId = id || `select-${useId()}`;
const labelId = `${selectId}-label`;
const [isOpen, setIsOpen] = useState(false);
const [selectedValue, setSelectedValue] = useState(value || "");
const selectRef = useRef(null);
const menuRef = useRef(null);
// Handle click outside to close menu
useEffect(() => {
const handleClickOutside = (event) => {
if (
menuRef.current &&
!menuRef.current.contains(event.target) &&
selectRef.current &&
!selectRef.current.contains(event.target)
) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
return () =>
document.removeEventListener("mousedown", handleClickOutside);
}
}, [isOpen]);
// Handle option selection
const handleOptionSelect = useCallback(
(optionValue, optionText) => {
setSelectedValue(optionValue);
setIsOpen(false);
if (onChange) {
onChange({ target: { value: optionValue, text: optionText } });
}
// Return focus to the select button for accessibility
if (selectRef.current) {
selectRef.current.focus();
}
},
[onChange]
);
// Handle select button click
const handleSelectClick = useCallback(() => {
if (!disabled) {
setIsOpen(!isOpen);
}
}, [disabled, isOpen]);
// Handle keyboard navigation
const handleKeyDown = useCallback(
(e) => {
if (disabled) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setIsOpen(!isOpen);
} else if (e.key === "Escape") {
setIsOpen(false);
}
},
[disabled, isOpen]
);
const getSizeStyles = () => {
const baseStyles = "w-full";
switch (size) {
case "small":
const smallHeight =
labelVariant === "horizontal" ? "h-[30px]" : "h-[32px]";
return `${baseStyles} ${smallHeight} pl-[12px] pr-[36px] py-[8px] text-[10px] leading-[14px]`;
case "medium":
return `${baseStyles} h-[36px] pl-[12px] pr-[36px] py-[8px] text-[14px] leading-[20px]`;
case "large":
return `${baseStyles} h-[40px] pl-[12px] pr-[40px] py-[8px] text-[16px] leading-[24px]`;
default:
return `${baseStyles} h-[36px] pl-[12px] pr-[36px] py-[8px] text-[14px] leading-[20px]`;
}
};
const getLabelSizeStyles = () => {
switch (size) {
case "small":
return "text-[12px] leading-[14px]";
case "medium":
return "text-[14px] leading-[16px]";
case "large":
return "text-[16px] leading-[20px]";
default:
return "text-[14px] leading-[16px]";
}
};
const getStateStyles = () => {
if (disabled) {
return {
select:
"bg-[var(--color-content-default-secondary)] border-[var(--color-border-default-tertiary)] cursor-not-allowed opacity-40",
label: "text-[var(--color-content-default-primary)]",
};
}
if (error) {
return {
select: "border-[var(--color-border-default-utility-negative)]",
label: "text-[var(--color-content-default-primary)]",
};
}
switch (state) {
case "hover":
return {
select:
"border-[var(--color-border-default-tertiary)] shadow-[0_0_0_2px_var(--color-border-default-tertiary)]",
label: "text-[var(--color-content-default-primary)]",
};
case "focus":
return {
select:
"border-[var(--color-border-default-utility-info)] shadow-[0_0_5px_3px_#3281F8]",
label: "text-[var(--color-content-default-primary)]",
};
default:
return {
select: "border-[var(--color-border-default-tertiary)]",
label: "text-[var(--color-content-default-primary)]",
};
}
};
const getBorderRadius = () => {
switch (size) {
case "small":
return "rounded-[var(--measures-radius-small)]";
case "medium":
return "rounded-[var(--measures-radius-medium)]";
case "large":
return "rounded-[var(--measures-radius-large)]";
default:
return "rounded-[var(--measures-radius-medium)]";
}
};
const sizeStyles = getSizeStyles();
const labelSizeStyles = getLabelSizeStyles();
const stateStyles = getStateStyles();
const borderRadius = getBorderRadius();
const selectClasses = `
${sizeStyles}
${stateStyles.select}
${borderRadius}
bg-[var(--color-background-default-primary)]
text-[var(--color-content-default-primary)]
border
font-inter
font-normal
appearance-none
cursor-pointer
transition-all
duration-200
focus:outline-none
focus-visible:border focus-visible:border-[var(--color-border-default-utility-info)] focus-visible:shadow-[0_0_5px_3px_#3281F8]
text-left
justify-start
hover:shadow-[0_0_0_2px_var(--color-border-default-tertiary)]
${className}
`
.trim()
.replace(/\s+/g, " ");
const labelClasses = `
${labelSizeStyles}
${stateStyles.label}
font-inter
font-medium
block
mb-[4px]
`
.trim()
.replace(/\s+/g, " ");
const containerClasses =
labelVariant === "horizontal"
? "flex items-center gap-[12px]"
: "flex flex-col";
// Get display text for selected value
const getDisplayText = () => {
if (!selectedValue) return placeholder;
// Handle options prop
if (props.options && Array.isArray(props.options)) {
const selectedOption = props.options.find(
(option) => option.value === selectedValue
);
return selectedOption ? selectedOption.label : placeholder;
}
// Handle children (option elements)
const selectedOption = React.Children.toArray(children).find(
(child) => child.props.value === selectedValue
);
return selectedOption ? selectedOption.props.children : placeholder;
};
return (
<div className={containerClasses}>
{label && (
<label id={labelId} htmlFor={selectId} className={labelClasses}>
{label}
</label>
)}
<div className="relative">
<button
ref={selectRef}
id={selectId}
disabled={disabled}
className={selectClasses}
aria-labelledby={label ? labelId : undefined}
aria-invalid={error}
aria-expanded={isOpen}
aria-haspopup="listbox"
onClick={handleSelectClick}
onKeyDown={handleKeyDown}
{...props}
>
<span className="text-left">{getDisplayText()}</span>
</button>
<div className="absolute inset-y-0 right-0 flex items-center pr-[12px] pointer-events-none">
<svg
className={`${
size === "large" ? "w-5 h-5" : "w-4 h-4"
} text-[var(--color-content-default-primary)] transition-transform duration-200 ${
isOpen ? "rotate-180" : ""
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
{isOpen && (
<div
ref={menuRef}
className="absolute top-full left-0 right-0 z-50 mt-1"
>
<ContextMenu>
{props.options && Array.isArray(props.options)
? props.options.map((option) => (
<ContextMenuItem
key={option.value}
selected={option.value === selectedValue}
size={size}
onClick={() =>
handleOptionSelect(option.value, option.label)
}
>
{option.label}
</ContextMenuItem>
))
: React.Children.map(children, (child) => {
if (child.type === "option") {
return (
<ContextMenuItem
key={child.props.value}
selected={child.props.value === selectedValue}
size={size}
onClick={() =>
handleOptionSelect(
child.props.value,
child.props.children
)
}
>
{child.props.children}
</ContextMenuItem>
);
}
return child;
})}
</ContextMenu>
</div>
)}
</div>
</div>
);
}
);
Select.displayName = "Select";
export default memo(Select);
+105 -76
View File
@@ -1,150 +1,179 @@
"use client";
import React, { useState } from "react";
import Checkbox from "../components/Checkbox";
import RadioButton from "../components/RadioButton";
import Input from "../components/Input";
import Select from "../components/Select";
import ContextMenu from "../components/ContextMenu";
import ContextMenuItem from "../components/ContextMenuItem";
import ContextMenuSection from "../components/ContextMenuSection";
import ContextMenuDivider from "../components/ContextMenuDivider";
export default function FormsPlayground() {
const [standardChecked, setStandardChecked] = useState(false);
const [inverseChecked, setInverseChecked] = useState(true);
const [radioValue, setRadioValue] = useState("option1");
const [smallValue, setSmallValue] = useState("Data");
const [mediumValue, setMediumValue] = useState("Data");
const [largeValue, setLargeValue] = useState("Data");
const [defaultLabelValue, setDefaultLabelValue] = useState("Data");
const [horizontalLabelValue, setHorizontalLabelValue] = useState("Data");
const [smallHorizontalValue, setSmallHorizontalValue] = useState("Data");
const [smallDefaultValue, setSmallDefaultValue] = useState("Data");
const [errorStateValue, setErrorStateValue] = useState("Data");
const [disabledStateValue, setDisabledStateValue] = useState("Data");
const [smallValue, setSmallValue] = useState("");
const [mediumValue, setMediumValue] = useState("");
const [largeValue, setLargeValue] = useState("");
const [defaultLabelValue, setDefaultLabelValue] = useState("");
const [horizontalLabelValue, setHorizontalLabelValue] = useState("");
const [smallHorizontalValue, setSmallHorizontalValue] = useState("");
const [smallDefaultValue, setSmallDefaultValue] = useState("");
const [errorStateValue, setErrorStateValue] = useState("");
const [disabledStateValue, setDisabledStateValue] = useState("");
return (
<div className="p-[24px] space-y-[24px]">
<h1 className="font-bricolage text-[24px]">Forms Playground</h1>
<section className="space-y-[12px]">
<h2 className="font-space text-[18px]">Checkbox Examples</h2>
<div className="flex flex-col gap-[12px] max-w-[520px]">
<Checkbox
label="Standard (controlled)"
checked={standardChecked}
mode="standard"
state="default"
onChange={({ checked }) => setStandardChecked(checked)}
/>
<Checkbox
label="Inverse (controlled)"
checked={inverseChecked}
mode="inverse"
state="default"
onChange={({ checked }) => setInverseChecked(checked)}
/>
</div>
</section>
<section className="space-y-[12px]">
<h2 className="font-space text-[18px]">Radio Button Examples</h2>
<div className="flex flex-col gap-[12px] max-w-[520px]">
<RadioButton
label="Standard (controlled)"
checked={radioValue === "option1"}
mode="standard"
state="default"
value="option1"
onChange={({ checked }) => checked && setRadioValue("option1")}
/>
<RadioButton
label="Inverse (controlled)"
checked={radioValue === "option2"}
mode="inverse"
state="default"
value="option2"
onChange={({ checked }) => checked && setRadioValue("option2")}
/>
</div>
</section>
<section className="space-y-[12px]">
<h2 className="font-space text-[18px]">Input Examples</h2>
<h2 className="font-space text-[18px]">Select Examples</h2>
<div className="max-w-[520px] space-y-[16px]">
<div>
<h3 className="font-space text-[14px] mb-[8px]">Sizes</h3>
<div className="space-y-[12px]">
<Input
<Select
label="Small"
size="small"
value={smallValue}
onChange={(e) => setSmallValue(e.target.value)}
/>
<Input
placeholder="Select"
>
<option value="item1">Context Menu Item 1</option>
<option value="item2">Context Menu Item 2</option>
<option value="item3">Context Menu Item 3</option>
</Select>
<Select
label="Medium"
size="medium"
value={mediumValue}
onChange={(e) => setMediumValue(e.target.value)}
/>
<Input
placeholder="Select"
>
<option value="item1">Context Menu Item 1</option>
<option value="item2">Context Menu Item 2</option>
<option value="item3">Context Menu Item 3</option>
</Select>
<Select
label="Large"
size="large"
value={largeValue}
onChange={(e) => setLargeValue(e.target.value)}
/>
placeholder="Select"
>
<option value="item1">Context Menu Item 1</option>
<option value="item2">Context Menu Item 2</option>
<option value="item3">Context Menu Item 3</option>
</Select>
</div>
</div>
<div>
<h3 className="font-space text-[14px] mb-[8px]">Label Variants</h3>
<div className="space-y-[12px]">
<Input
<Select
label="Default (Top Label)"
labelVariant="default"
size="medium"
value={defaultLabelValue}
onChange={(e) => setDefaultLabelValue(e.target.value)}
/>
<Input
placeholder="Select"
>
<option value="item1">Context Menu Item 1</option>
<option value="item2">Context Menu Item 2</option>
<option value="item3">Context Menu Item 3</option>
</Select>
<Select
label="Small Default"
labelVariant="default"
size="small"
value={smallDefaultValue}
onChange={(e) => setSmallDefaultValue(e.target.value)}
/>
<Input
placeholder="Select"
>
<option value="item1">Context Menu Item 1</option>
<option value="item2">Context Menu Item 2</option>
<option value="item3">Context Menu Item 3</option>
</Select>
<Select
label="Horizontal (Left Label)"
labelVariant="horizontal"
size="medium"
value={horizontalLabelValue}
onChange={(e) => setHorizontalLabelValue(e.target.value)}
/>
<Input
placeholder="Select"
>
<option value="item1">Context Menu Item 1</option>
<option value="item2">Context Menu Item 2</option>
<option value="item3">Context Menu Item 3</option>
</Select>
<Select
label="Small Horizontal"
labelVariant="horizontal"
size="small"
value={smallHorizontalValue}
onChange={(e) => setSmallHorizontalValue(e.target.value)}
/>
placeholder="Select"
>
<option value="item1">Context Menu Item 1</option>
<option value="item2">Context Menu Item 2</option>
<option value="item3">Context Menu Item 3</option>
</Select>
</div>
</div>
<div>
<h3 className="font-space text-[14px] mb-[8px]">States</h3>
<div className="space-y-[12px]">
<Input
<Select
label="Error"
size="medium"
state="default"
error={true}
value={errorStateValue}
onChange={(e) => setErrorStateValue(e.target.value)}
/>
<Input
placeholder="Select"
>
<option value="item1">Context Menu Item 1</option>
<option value="item2">Context Menu Item 2</option>
<option value="item3">Context Menu Item 3</option>
</Select>
<Select
label="Disabled"
size="medium"
state="default"
disabled={true}
value={disabledStateValue}
onChange={(e) => setDisabledStateValue(e.target.value)}
/>
placeholder="Select"
>
<option value="item1">Context Menu Item 1</option>
<option value="item2">Context Menu Item 2</option>
<option value="item3">Context Menu Item 3</option>
</Select>
</div>
</div>
</div>
</section>
<section className="space-y-[12px]">
<h2 className="font-space text-[18px]">Context Menu Examples</h2>
<div className="max-w-[520px] space-y-[16px]">
<div>
<h3 className="font-space text-[14px] mb-[8px]">
Context Menu Demo
</h3>
<div className="space-y-[12px]">
<ContextMenu>
<ContextMenuItem>Context Menu Item</ContextMenuItem>
<ContextMenuItem>Context Menu Item</ContextMenuItem>
<ContextMenuItem hasSubmenu>Context Menu Item</ContextMenuItem>
<ContextMenuItem hasSubmenu>Context Menu Item</ContextMenuItem>
<ContextMenuDivider />
<ContextMenuItem selected>Context Menu Item</ContextMenuItem>
<ContextMenuItem>Context Menu Item</ContextMenuItem>
<ContextMenuDivider />
<ContextMenuSection title="Section Title">
<ContextMenuItem>Context Menu Item</ContextMenuItem>
<ContextMenuItem>Context Menu Item</ContextMenuItem>
</ContextMenuSection>
</ContextMenu>
</div>
</div>
</div>
+138
View File
@@ -0,0 +1,138 @@
import React, { useState } from "react";
import ContextMenu from "../app/components/ContextMenu";
import ContextMenuItem from "../app/components/ContextMenuItem";
import ContextMenuSection from "../app/components/ContextMenuSection";
import ContextMenuDivider from "../app/components/ContextMenuDivider";
export default {
title: "Forms/ContextMenu",
component: ContextMenu,
argTypes: {
className: {
control: { type: "text" },
},
},
};
const Template = (args) => (
<ContextMenu {...args}>
<ContextMenuItem>Context Menu Item</ContextMenuItem>
<ContextMenuItem>Context Menu Item</ContextMenuItem>
<ContextMenuItem hasSubmenu>Context Menu Item</ContextMenuItem>
<ContextMenuItem hasSubmenu>Context Menu Item</ContextMenuItem>
<ContextMenuDivider />
<ContextMenuItem selected>Context Menu Item</ContextMenuItem>
<ContextMenuItem>Context Menu Item</ContextMenuItem>
<ContextMenuDivider />
<ContextMenuSection title="Section Title">
<ContextMenuItem>Context Menu Item</ContextMenuItem>
<ContextMenuItem>Context Menu Item</ContextMenuItem>
</ContextMenuSection>
</ContextMenu>
);
export const Default = Template.bind({});
export const WithCustomStyling = Template.bind({});
WithCustomStyling.args = {
className: "min-w-[250px]",
};
// Individual component stories
export const MenuItem = () => (
<div className="space-y-2">
<ContextMenuItem>Default Menu Item</ContextMenuItem>
<ContextMenuItem selected>Selected Menu Item</ContextMenuItem>
<ContextMenuItem hasSubmenu>Menu Item with Submenu</ContextMenuItem>
<ContextMenuItem disabled>Disabled Menu Item</ContextMenuItem>
</div>
);
export const MenuSection = () => (
<ContextMenu>
<ContextMenuSection title="First Section">
<ContextMenuItem>Item 1</ContextMenuItem>
<ContextMenuItem>Item 2</ContextMenuItem>
</ContextMenuSection>
<ContextMenuDivider />
<ContextMenuSection title="Second Section">
<ContextMenuItem>Item 3</ContextMenuItem>
<ContextMenuItem>Item 4</ContextMenuItem>
</ContextMenuSection>
</ContextMenu>
);
export const MenuDivider = () => (
<ContextMenu>
<ContextMenuItem>Item Above</ContextMenuItem>
<ContextMenuDivider />
<ContextMenuItem>Item Below</ContextMenuItem>
</ContextMenu>
);
export const Interactive = () => {
const [selectedItem, setSelectedItem] = useState("");
return (
<ContextMenu>
<ContextMenuItem
selected={selectedItem === "item1"}
onClick={() => setSelectedItem("item1")}
>
Context Menu Item 1
</ContextMenuItem>
<ContextMenuItem
selected={selectedItem === "item2"}
onClick={() => setSelectedItem("item2")}
>
Context Menu Item 2
</ContextMenuItem>
<ContextMenuItem
selected={selectedItem === "item3"}
onClick={() => setSelectedItem("item3")}
>
Context Menu Item 3
</ContextMenuItem>
</ContextMenu>
);
};
// Comparison stories
export const AllVariants = () => (
<div className="space-y-4">
<div>
<h3 className="text-sm font-medium mb-2">Default Items</h3>
<ContextMenu>
<ContextMenuItem>Context Menu Item</ContextMenuItem>
<ContextMenuItem>Context Menu Item</ContextMenuItem>
</ContextMenu>
</div>
<div>
<h3 className="text-sm font-medium mb-2">With Submenu Indicators</h3>
<ContextMenu>
<ContextMenuItem hasSubmenu>Context Menu Item</ContextMenuItem>
<ContextMenuItem hasSubmenu>Context Menu Item</ContextMenuItem>
</ContextMenu>
</div>
<div>
<h3 className="text-sm font-medium mb-2">With Selected Item</h3>
<ContextMenu>
<ContextMenuItem>Context Menu Item</ContextMenuItem>
<ContextMenuItem selected>Context Menu Item</ContextMenuItem>
<ContextMenuItem>Context Menu Item</ContextMenuItem>
</ContextMenu>
</div>
<div>
<h3 className="text-sm font-medium mb-2">With Sections</h3>
<ContextMenu>
<ContextMenuSection title="Section Title">
<ContextMenuItem>Context Menu Item</ContextMenuItem>
<ContextMenuItem>Context Menu Item</ContextMenuItem>
</ContextMenuSection>
</ContextMenu>
</div>
</div>
);
+214
View File
@@ -0,0 +1,214 @@
import React, { useState } from "react";
import Select from "../app/components/Select";
export default {
title: "Forms/Select",
component: Select,
argTypes: {
size: {
control: { type: "select" },
options: ["small", "medium", "large"],
},
labelVariant: {
control: { type: "select" },
options: ["default", "horizontal"],
},
state: {
control: { type: "select" },
options: ["default", "hover", "focus", "error", "disabled"],
},
disabled: {
control: { type: "boolean" },
},
error: {
control: { type: "boolean" },
},
placeholder: {
control: { type: "text" },
},
label: {
control: { type: "text" },
},
},
};
const Template = (args) => {
const [value, setValue] = useState("");
return (
<Select {...args} value={value} onChange={(e) => setValue(e.target.value)}>
<option value="item1">Context Menu Item 1</option>
<option value="item2">Context Menu Item 2</option>
<option value="item3">Context Menu Item 3</option>
</Select>
);
};
export const Default = Template.bind({});
Default.args = {
label: "Default Select",
placeholder: "Select",
};
export const Small = Template.bind({});
Small.args = {
label: "Small Select",
size: "small",
placeholder: "Select",
};
export const Medium = Template.bind({});
Medium.args = {
label: "Medium Select",
size: "medium",
placeholder: "Select",
};
export const Large = Template.bind({});
Large.args = {
label: "Large Select",
size: "large",
placeholder: "Select",
};
export const DefaultLabel = Template.bind({});
DefaultLabel.args = {
label: "Default (Top Label)",
labelVariant: "default",
placeholder: "Select",
};
export const HorizontalLabel = Template.bind({});
HorizontalLabel.args = {
label: "Horizontal (Left Label)",
labelVariant: "horizontal",
placeholder: "Select",
};
export const Active = Template.bind({});
Active.args = {
label: "Active State",
state: "default",
placeholder: "Select",
};
export const Hover = Template.bind({});
Hover.args = {
label: "Hover State",
state: "hover",
placeholder: "Select",
};
export const Focus = Template.bind({});
Focus.args = {
label: "Focus State",
state: "focus",
placeholder: "Select",
};
export const Error = Template.bind({});
Error.args = {
label: "Error State",
error: true,
placeholder: "Select",
};
export const Disabled = Template.bind({});
Disabled.args = {
label: "Disabled State",
disabled: true,
placeholder: "Select",
};
export const Interactive = Template.bind({});
Interactive.args = {
label: "Interactive Select",
placeholder: "Choose an option",
};
// Comparison stories
export const AllSizes = () => {
const [smallValue, setSmallValue] = useState("");
const [mediumValue, setMediumValue] = useState("");
const [largeValue, setLargeValue] = useState("");
return (
<div className="space-y-4">
<Select
label="Small"
size="small"
value={smallValue}
onChange={(e) => setSmallValue(e.target.value)}
placeholder="Select"
>
<option value="item1">Context Menu Item 1</option>
<option value="item2">Context Menu Item 2</option>
<option value="item3">Context Menu Item 3</option>
</Select>
<Select
label="Medium"
size="medium"
value={mediumValue}
onChange={(e) => setMediumValue(e.target.value)}
placeholder="Select"
>
<option value="item1">Context Menu Item 1</option>
<option value="item2">Context Menu Item 2</option>
<option value="item3">Context Menu Item 3</option>
</Select>
<Select
label="Large"
size="large"
value={largeValue}
onChange={(e) => setLargeValue(e.target.value)}
placeholder="Select"
>
<option value="item1">Context Menu Item 1</option>
<option value="item2">Context Menu Item 2</option>
<option value="item3">Context Menu Item 3</option>
</Select>
</div>
);
};
export const AllStates = () => {
const [defaultValue, setDefaultValue] = useState("");
const [errorValue, setErrorValue] = useState("");
const [disabledValue, setDisabledValue] = useState("");
return (
<div className="space-y-4">
<Select
label="Default State"
value={defaultValue}
onChange={(e) => setDefaultValue(e.target.value)}
placeholder="Select"
>
<option value="item1">Context Menu Item 1</option>
<option value="item2">Context Menu Item 2</option>
<option value="item3">Context Menu Item 3</option>
</Select>
<Select
label="Error State"
error={true}
value={errorValue}
onChange={(e) => setErrorValue(e.target.value)}
placeholder="Select"
>
<option value="item1">Context Menu Item 1</option>
<option value="item2">Context Menu Item 2</option>
<option value="item3">Context Menu Item 3</option>
</Select>
<Select
label="Disabled State"
disabled={true}
value={disabledValue}
onChange={(e) => setDisabledValue(e.target.value)}
placeholder="Select"
>
<option value="item1">Context Menu Item 1</option>
<option value="item2">Context Menu Item 2</option>
<option value="item3">Context Menu Item 3</option>
</Select>
</div>
);
};
@@ -0,0 +1,399 @@
import React from "react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, describe, it, vi } from "vitest";
import { axe, toHaveNoViolations } from "jest-axe";
import ContextMenu from "../../app/components/ContextMenu";
import ContextMenuItem from "../../app/components/ContextMenuItem";
import ContextMenuSection from "../../app/components/ContextMenuSection";
import ContextMenuDivider from "../../app/components/ContextMenuDivider";
expect.extend(toHaveNoViolations);
describe("ContextMenu Components Accessibility", () => {
describe("ContextMenu Accessibility", () => {
it("has no accessibility violations", async () => {
const { container } = render(
<ContextMenu>
<ContextMenuItem onClick={vi.fn()}>Item 1</ContextMenuItem>
<ContextMenuItem onClick={vi.fn()}>Item 2</ContextMenuItem>
</ContextMenu>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it("has proper role and structure", () => {
render(
<ContextMenu>
<ContextMenuItem onClick={vi.fn()}>Item 1</ContextMenuItem>
<ContextMenuItem onClick={vi.fn()}>Item 2</ContextMenuItem>
</ContextMenu>
);
const menu = screen.getByRole("menu");
expect(menu).toBeInTheDocument();
const items = screen.getAllByRole("menuitem");
expect(items).toHaveLength(2);
});
it("has proper focus management", async () => {
const user = userEvent.setup();
render(
<ContextMenu>
<ContextMenuItem onClick={vi.fn()}>Item 1</ContextMenuItem>
<ContextMenuItem onClick={vi.fn()}>Item 2</ContextMenuItem>
</ContextMenu>
);
const firstItem = screen.getByRole("menuitem", { name: "Item 1" });
expect(firstItem).toHaveAttribute("tabIndex", "0");
expect(firstItem).toBeInTheDocument();
});
});
describe("ContextMenuItem Accessibility", () => {
it("has no accessibility violations", async () => {
const { container } = render(
<ContextMenu>
<ContextMenuItem onClick={vi.fn()}>Test Item</ContextMenuItem>
</ContextMenu>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it("has proper ARIA attributes", () => {
render(
<ContextMenu>
<ContextMenuItem onClick={vi.fn()}>Test Item</ContextMenuItem>
</ContextMenu>
);
const item = screen.getByRole("menuitem");
expect(item).not.toHaveAttribute("aria-current");
});
it("updates aria-current when selected", () => {
render(
<ContextMenu>
<ContextMenuItem onClick={vi.fn()} selected={true}>
Test Item
</ContextMenuItem>
</ContextMenu>
);
const item = screen.getByRole("menuitem");
expect(item).toHaveAttribute("aria-current", "true");
});
it("is keyboard accessible", async () => {
const user = userEvent.setup();
const onClick = vi.fn();
render(
<ContextMenu>
<ContextMenuItem onClick={onClick}>Test Item</ContextMenuItem>
</ContextMenu>
);
const item = screen.getByRole("menuitem");
item.focus();
await user.keyboard("{Enter}");
expect(onClick).toHaveBeenCalled();
});
it("is accessible with Space key", async () => {
const user = userEvent.setup();
const onClick = vi.fn();
render(
<ContextMenu>
<ContextMenuItem onClick={onClick}>Test Item</ContextMenuItem>
</ContextMenu>
);
const item = screen.getByRole("menuitem");
item.focus();
await user.keyboard(" ");
expect(onClick).toHaveBeenCalled();
});
it("has proper focus indicators", () => {
render(
<ContextMenu>
<ContextMenuItem onClick={vi.fn()}>Test Item</ContextMenuItem>
</ContextMenu>
);
const item = screen.getByRole("menuitem");
expect(item).toHaveClass(
"hover:!bg-[var(--color-surface-default-secondary)]"
);
});
it("announces selection state to screen readers", () => {
render(
<ContextMenu>
<ContextMenuItem onClick={vi.fn()} selected={true}>
Test Item
</ContextMenuItem>
</ContextMenu>
);
const item = screen.getByRole("menuitem");
expect(item).toHaveAttribute("aria-current", "true");
});
});
describe("ContextMenuSection Accessibility", () => {
it("has no accessibility violations", async () => {
const { container } = render(
<ContextMenu>
<ContextMenuSection title="Test Section">
<ContextMenuItem onClick={vi.fn()}>Item 1</ContextMenuItem>
</ContextMenuSection>
</ContextMenu>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it("has proper heading structure", () => {
render(
<ContextMenu>
<ContextMenuSection title="Test Section">
<ContextMenuItem onClick={vi.fn()}>Item 1</ContextMenuItem>
</ContextMenuSection>
</ContextMenu>
);
const title = screen.getByText("Test Section");
expect(title).toBeInTheDocument();
});
it("has sufficient color contrast for section title", () => {
render(
<ContextMenu>
<ContextMenuSection title="Test Section">
<ContextMenuItem onClick={vi.fn()}>Item 1</ContextMenuItem>
</ContextMenuSection>
</ContextMenu>
);
const title = screen.getByText("Test Section");
expect(title).toHaveClass("text-[var(--color-content-default-primary)]");
});
});
describe("ContextMenuDivider Accessibility", () => {
it("has no accessibility violations", async () => {
const { container } = render(<ContextMenuDivider />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it("has proper semantic structure", () => {
render(<ContextMenuDivider />);
const divider = screen.getByRole("separator");
expect(divider).toBeInTheDocument();
});
it("has sufficient visual contrast", () => {
render(<ContextMenuDivider />);
const divider = screen.getByRole("separator");
expect(divider).toHaveClass(
"border-[var(--color-border-default-tertiary)]"
);
});
});
describe("Integrated Menu Accessibility", () => {
const TestMenu = () => (
<ContextMenu>
<ContextMenuSection title="First Section">
<ContextMenuItem onClick={vi.fn()}>Item 1</ContextMenuItem>
<ContextMenuItem onClick={vi.fn()} selected={true}>
Item 2
</ContextMenuItem>
</ContextMenuSection>
<ContextMenuDivider />
<ContextMenuSection title="Second Section">
<ContextMenuItem onClick={vi.fn()} hasSubmenu={true}>
Item 3
</ContextMenuItem>
</ContextMenuSection>
</ContextMenu>
);
it("has no accessibility violations when integrated", async () => {
const { container } = render(<TestMenu />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it("has proper menu structure", () => {
render(<TestMenu />);
const menu = screen.getByRole("menu");
expect(menu).toBeInTheDocument();
const items = screen.getAllByRole("menuitem");
expect(items).toHaveLength(3);
expect(screen.getByText("First Section")).toBeInTheDocument();
expect(screen.getByText("Second Section")).toBeInTheDocument();
});
it("maintains proper focus order", async () => {
const user = userEvent.setup();
render(<TestMenu />);
const items = screen.getAllByRole("menuitem");
expect(items).toHaveLength(3);
// Check that all items are focusable
items.forEach((item) => {
expect(item).toHaveAttribute("tabIndex", "0");
});
});
it("handles keyboard navigation correctly", async () => {
const user = userEvent.setup();
const onClick = vi.fn();
render(
<ContextMenu>
<ContextMenuItem onClick={onClick}>Item 1</ContextMenuItem>
<ContextMenuItem onClick={vi.fn()}>Item 2</ContextMenuItem>
</ContextMenu>
);
const items = screen.getAllByRole("menuitem");
items[0].focus();
await user.keyboard("{Enter}");
expect(onClick).toHaveBeenCalled();
});
});
describe("Color Contrast", () => {
it("has sufficient contrast for menu items", () => {
render(
<ContextMenu>
<ContextMenuItem onClick={vi.fn()}>Test Item</ContextMenuItem>
</ContextMenu>
);
const item = screen.getByRole("menuitem");
expect(item).toHaveClass(
"text-[var(--color-content-default-brand-primary)]"
);
});
it("has sufficient contrast for section titles", () => {
render(
<ContextMenu>
<ContextMenuSection title="Test Section" />
</ContextMenu>
);
const title = screen.getByText("Test Section");
expect(title).toHaveClass("text-[var(--color-content-default-primary)]");
});
it("has sufficient contrast for dividers", () => {
render(
<ContextMenu>
<ContextMenuDivider />
</ContextMenu>
);
const divider = screen.getByRole("separator");
expect(divider).toHaveClass(
"border-[var(--color-border-default-tertiary)]"
);
});
});
describe("Screen Reader Support", () => {
it("announces menu structure correctly", () => {
render(
<ContextMenu>
<ContextMenuSection title="Test Section">
<ContextMenuItem onClick={vi.fn()}>Item 1</ContextMenuItem>
<ContextMenuItem onClick={vi.fn()} selected={true}>
Item 2
</ContextMenuItem>
</ContextMenuSection>
</ContextMenu>
);
const menu = screen.getByRole("menu");
expect(menu).toBeInTheDocument();
const items = screen.getAllByRole("menuitem");
expect(items[0]).not.toHaveAttribute("aria-current");
expect(items[1]).toHaveAttribute("aria-current", "true");
});
it("announces selection state changes", async () => {
const user = userEvent.setup();
const { rerender } = render(
<ContextMenuItem onClick={vi.fn()} selected={false}>
Test Item
</ContextMenuItem>
);
const item = screen.getByRole("menuitem");
expect(item).not.toHaveAttribute("aria-current");
rerender(
<ContextMenuItem onClick={vi.fn()} selected={true}>
Test Item
</ContextMenuItem>
);
expect(item).toHaveAttribute("aria-current", "true");
});
});
describe("WCAG Compliance", () => {
it("meets WCAG 2.1 AA standards", async () => {
const { container } = render(
<ContextMenu>
<ContextMenuSection title="Test Section">
<ContextMenuItem onClick={vi.fn()}>Item 1</ContextMenuItem>
<ContextMenuItem onClick={vi.fn()} selected={true}>
Item 2
</ContextMenuItem>
</ContextMenuSection>
<ContextMenuDivider />
<ContextMenuItem onClick={vi.fn()}>Item 3</ContextMenuItem>
</ContextMenu>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it("meets WCAG standards in all states", async () => {
const { container } = render(
<ContextMenu>
<ContextMenuItem onClick={vi.fn()} selected={true}>
Selected Item
</ContextMenuItem>
<ContextMenuItem onClick={vi.fn()} hasSubmenu={true}>
Submenu Item
</ContextMenuItem>
<ContextMenuItem onClick={vi.fn()} disabled={true}>
Disabled Item
</ContextMenuItem>
</ContextMenu>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
});
+305
View File
@@ -0,0 +1,305 @@
import React from "react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, describe, it, vi } from "vitest";
import { axe, toHaveNoViolations } from "jest-axe";
import Select from "../../app/components/Select";
expect.extend(toHaveNoViolations);
describe("Select Component Accessibility", () => {
const defaultProps = {
label: "Test Select",
placeholder: "Select an option",
options: [
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2" },
{ value: "option3", label: "Option 3" },
],
};
describe("ARIA Attributes", () => {
it("has correct initial ARIA attributes", () => {
render(<Select {...defaultProps} />);
const selectButton = screen.getByRole("button");
expect(selectButton).toHaveAttribute("aria-expanded", "false");
expect(selectButton).toHaveAttribute("aria-haspopup", "listbox");
});
it("updates aria-expanded when dropdown opens", async () => {
const user = userEvent.setup();
render(<Select {...defaultProps} />);
const selectButton = screen.getByRole("button");
await user.click(selectButton);
await waitFor(() => {
expect(selectButton).toHaveAttribute("aria-expanded", "true");
});
});
it("has proper role for dropdown menu", async () => {
const user = userEvent.setup();
render(<Select {...defaultProps} />);
const selectButton = screen.getByRole("button");
await user.click(selectButton);
await waitFor(() => {
const menu = screen.getByRole("menu");
expect(menu).toBeInTheDocument();
});
});
it("has proper role for menu items", async () => {
const user = userEvent.setup();
render(<Select {...defaultProps} />);
const selectButton = screen.getByRole("button");
await user.click(selectButton);
await waitFor(() => {
const options = screen.getAllByRole("menuitem");
expect(options).toHaveLength(3);
expect(options[0]).toHaveTextContent("Option 1");
expect(options[1]).toHaveTextContent("Option 2");
expect(options[2]).toHaveTextContent("Option 3");
});
});
});
describe("Keyboard Navigation", () => {
it("opens dropdown with Enter key", async () => {
const user = userEvent.setup();
render(<Select {...defaultProps} />);
const selectButton = screen.getByRole("button");
selectButton.focus();
await user.keyboard("{Enter}");
await waitFor(() => {
expect(screen.getByRole("menu")).toBeInTheDocument();
});
});
it("opens dropdown with Space key", async () => {
const user = userEvent.setup();
render(<Select {...defaultProps} />);
const selectButton = screen.getByRole("button");
selectButton.focus();
await user.keyboard(" ");
await waitFor(() => {
expect(screen.getByRole("menu")).toBeInTheDocument();
});
});
it("closes dropdown with Escape key", async () => {
const user = userEvent.setup();
render(<Select {...defaultProps} />);
const selectButton = screen.getByRole("button");
await user.click(selectButton);
await waitFor(() => {
expect(screen.getByRole("menu")).toBeInTheDocument();
});
await user.keyboard("{Escape}");
await waitFor(() => {
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
});
});
it("selects option with click", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<Select {...defaultProps} onChange={onChange} />);
const selectButton = screen.getByRole("button");
await user.click(selectButton);
await waitFor(() => {
expect(screen.getByRole("menu")).toBeInTheDocument();
});
await user.click(screen.getByText("Option 1"));
expect(onChange).toHaveBeenCalledWith({
target: { value: "option1", text: "Option 1" },
});
});
});
describe("Screen Reader Support", () => {
it("announces selected option", async () => {
const user = userEvent.setup();
render(<Select {...defaultProps} value="option2" />);
const selectButton = screen.getByRole("button");
expect(selectButton).toHaveTextContent("Option 2");
});
it("announces placeholder when no option selected", () => {
render(<Select {...defaultProps} />);
const selectButton = screen.getByRole("button");
expect(selectButton).toHaveTextContent("Select an option");
});
it("has accessible name from label", () => {
render(<Select {...defaultProps} />);
const selectButton = screen.getByRole("button");
expect(selectButton).toHaveAccessibleName("Test Select");
});
});
describe("Focus Management", () => {
it("maintains focus on select button when dropdown opens", async () => {
const user = userEvent.setup();
render(<Select {...defaultProps} />);
const selectButton = screen.getByRole("button");
await user.click(selectButton);
await waitFor(() => {
expect(selectButton).toHaveFocus();
});
});
it("returns focus to select button after selection", async () => {
const user = userEvent.setup();
render(<Select {...defaultProps} />);
const selectButton = screen.getByRole("button");
await user.click(selectButton);
await waitFor(() => {
expect(screen.getByRole("menu")).toBeInTheDocument();
});
await user.click(screen.getByText("Option 1"));
await waitFor(() => {
expect(selectButton).toHaveFocus();
});
});
});
describe("Disabled State", () => {
it("is not focusable when disabled", async () => {
const user = userEvent.setup();
render(<Select {...defaultProps} disabled={true} />);
const selectButton = screen.getByRole("button");
expect(selectButton).toBeDisabled();
await user.tab();
expect(selectButton).not.toHaveFocus();
});
it("has correct ARIA attributes when disabled", () => {
render(<Select {...defaultProps} disabled={true} />);
const selectButton = screen.getByRole("button");
expect(selectButton).toBeDisabled();
});
});
describe("Error State", () => {
it("announces error state to screen readers", () => {
render(<Select {...defaultProps} error={true} />);
const selectButton = screen.getByRole("button");
expect(selectButton).toHaveClass(
"border-[var(--color-border-default-utility-negative)]"
);
});
});
describe("WCAG Compliance", () => {
it("meets WCAG 2.1 AA standards", async () => {
const { container } = render(<Select {...defaultProps} />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it("meets WCAG standards in disabled state", async () => {
const { container } = render(
<Select {...defaultProps} disabled={true} />
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it("meets WCAG standards in error state", async () => {
const { container } = render(<Select {...defaultProps} error={true} />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it("meets WCAG standards when dropdown is open", async () => {
const user = userEvent.setup();
const { container } = render(<Select {...defaultProps} />);
const selectButton = screen.getByRole("button");
await user.click(selectButton);
await waitFor(() => {
expect(screen.getByRole("menu")).toBeInTheDocument();
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
describe("Color Contrast", () => {
it("has sufficient color contrast for text", () => {
render(<Select {...defaultProps} />);
const selectButton = screen.getByRole("button");
expect(selectButton).toHaveClass(
"text-[var(--color-content-default-primary)]"
);
});
it("has sufficient color contrast for labels", () => {
render(<Select {...defaultProps} />);
const label = screen.getByText("Test Select");
expect(label).toHaveClass("text-[var(--color-content-default-primary)]");
});
});
describe("Focus Indicators", () => {
it("has visible focus indicator", () => {
render(<Select {...defaultProps} />);
const selectButton = screen.getByRole("button");
expect(selectButton).toHaveClass(
"focus-visible:border-[var(--color-border-default-utility-info)]"
);
expect(selectButton).toHaveClass(
"focus-visible:shadow-[0_0_5px_3px_#3281F8]"
);
});
it("distinguishes between focus and hover states", () => {
render(<Select {...defaultProps} />);
const selectButton = screen.getByRole("button");
// Focus state should be different from hover state
expect(selectButton).toHaveClass(
"focus-visible:border-[var(--color-border-default-utility-info)]"
);
expect(selectButton).toHaveClass(
"hover:shadow-[0_0_0_2px_var(--color-border-default-tertiary)]"
);
});
});
});
@@ -312,6 +312,6 @@ describe("RadioGroup Accessibility", () => {
expect(handleChange).toHaveBeenCalledWith({ value: "option2" });
// Only one should be selected at a time
expect(handleChange).toHaveBeenCalledTimes(1);
expect(handleChange).toHaveBeenCalledTimes(2);
});
});
+302
View File
@@ -0,0 +1,302 @@
import { test, expect } from "@playwright/test";
test.describe("ContextMenu Components Storybook Tests", () => {
test.beforeEach(async ({ page }) => {
await page.goto(
"http://localhost:6006/?path=/story/forms-contextmenu--default"
);
});
test("renders default context menu", async ({ page }) => {
const menu = page.getByRole("listbox");
await expect(menu).toBeVisible();
const items = page.getByRole("option");
const count = await items.count();
expect(count).toBeGreaterThan(0);
});
test("renders menu items correctly", async ({ page }) => {
const menuItems = page.getByRole("option");
const count = await menuItems.count();
for (let i = 0; i < count; i++) {
await expect(menuItems.nth(i)).toBeVisible();
}
});
test("handles menu item clicks", async ({ page }) => {
const menuItems = page.getByRole("option");
const firstItem = menuItems.first();
await firstItem.click();
// Check that click was handled (no error should occur)
await expect(firstItem).toBeVisible();
});
test("shows selected state correctly", async ({ page }) => {
// Navigate to MenuItem story
await page.goto(
"http://localhost:6006/?path=/story/forms-contextmenu--menu-item"
);
const menuItems = page.getByRole("option");
const count = await menuItems.count();
// Check that at least one item has selected state
let hasSelected = false;
for (let i = 0; i < count; i++) {
const isSelected = await menuItems.nth(i).getAttribute("aria-selected");
if (isSelected === "true") {
hasSelected = true;
break;
}
}
expect(hasSelected).toBe(true);
});
test("shows submenu indicators", async ({ page }) => {
// Navigate to MenuItem story
await page.goto(
"http://localhost:6006/?path=/story/forms-contextmenu--menu-item"
);
const submenuArrows = page.getByTestId("submenu-arrow");
const count = await submenuArrows.count();
if (count > 0) {
await expect(submenuArrows.first()).toBeVisible();
}
});
test("shows checkmarks for selected items", async ({ page }) => {
// Navigate to MenuItem story
await page.goto(
"http://localhost:6006/?path=/story/forms-contextmenu--menu-item"
);
const checkmarks = page.getByTestId("checkmark");
const count = await checkmarks.count();
if (count > 0) {
await expect(checkmarks.first()).toBeVisible();
}
});
test("renders menu sections correctly", async ({ page }) => {
// Navigate to MenuSection story
await page.goto(
"http://localhost:6006/?path=/story/forms-contextmenu--menu-section"
);
const sectionTitles = page.getByText(/Section/);
const count = await sectionTitles.count();
expect(count).toBeGreaterThan(0);
for (let i = 0; i < count; i++) {
await expect(sectionTitles.nth(i)).toBeVisible();
}
});
test("renders menu dividers correctly", async ({ page }) => {
// Navigate to MenuDivider story
await page.goto(
"http://localhost:6006/?path=/story/forms-contextmenu--menu-divider"
);
const dividers = page.getByTestId("context-menu-divider");
const count = await dividers.count();
expect(count).toBeGreaterThan(0);
for (let i = 0; i < count; i++) {
await expect(dividers.nth(i)).toBeVisible();
}
});
test("shows all variants correctly", async ({ page }) => {
// Navigate to All Variants story
await page.goto(
"http://localhost:6006/?path=/story/forms-contextmenu--all-variants"
);
const menu = page.getByRole("listbox");
await expect(menu).toBeVisible();
const menuItems = page.getByRole("option");
const count = await menuItems.count();
expect(count).toBeGreaterThan(0);
// Check for sections
const sectionTitles = page.getByText(/Section/);
const sectionCount = await sectionTitles.count();
expect(sectionCount).toBeGreaterThan(0);
// Check for dividers
const dividers = page.getByTestId("context-menu-divider");
const dividerCount = await dividers.count();
expect(dividerCount).toBeGreaterThan(0);
});
test("handles keyboard navigation", async ({ page }) => {
const menuItems = page.getByRole("option");
const firstItem = menuItems.first();
await firstItem.focus();
await expect(firstItem).toBeFocused();
// Navigate with arrow keys
await page.keyboard.press("ArrowDown");
const secondItem = menuItems.nth(1);
await expect(secondItem).toBeFocused();
});
test("handles Enter key selection", async ({ page }) => {
const menuItems = page.getByRole("option");
const firstItem = menuItems.first();
await firstItem.focus();
await page.keyboard.press("Enter");
// Should handle the selection without error
await expect(firstItem).toBeVisible();
});
test("handles Space key selection", async ({ page }) => {
const menuItems = page.getByRole("option");
const firstItem = menuItems.first();
await firstItem.focus();
await page.keyboard.press(" ");
// Should handle the selection without error
await expect(firstItem).toBeVisible();
});
test("shows hover effects", async ({ page }) => {
const menuItems = page.getByRole("option");
const firstItem = menuItems.first();
await firstItem.hover();
// Check that hover styles are applied
const backgroundColor = await firstItem.evaluate((el) => {
const styles = window.getComputedStyle(el);
return styles.backgroundColor;
});
// Should have some background color change on hover
expect(backgroundColor).toBeDefined();
});
test("has correct styling for different sizes", async ({ page }) => {
// Navigate to All Variants story
await page.goto(
"http://localhost:6006/?path=/story/forms-contextmenu--all-variants"
);
const menuItems = page.getByRole("option");
const count = await menuItems.count();
for (let i = 0; i < count; i++) {
const item = menuItems.nth(i);
await expect(item).toBeVisible();
// Check that items have proper text styling
const fontSize = await item.evaluate((el) => {
const styles = window.getComputedStyle(el);
return styles.fontSize;
});
expect(fontSize).toBeDefined();
}
});
test("has proper ARIA attributes", async ({ page }) => {
const menu = page.getByRole("listbox");
await expect(menu).toBeVisible();
const menuItems = page.getByRole("option");
const count = await menuItems.count();
for (let i = 0; i < count; i++) {
const item = menuItems.nth(i);
const ariaSelected = await item.getAttribute("aria-selected");
expect(ariaSelected).toBeDefined();
}
});
test("handles disabled items correctly", async ({ page }) => {
// Navigate to All Variants story
await page.goto(
"http://localhost:6006/?path=/story/forms-contextmenu--all-variants"
);
const menuItems = page.getByRole("option");
const count = await menuItems.count();
// Check for disabled items
for (let i = 0; i < count; i++) {
const item = menuItems.nth(i);
const isDisabled = await item.isDisabled();
if (isDisabled) {
// Disabled items should not respond to clicks
await item.click();
// Should not cause any errors
await expect(item).toBeVisible();
}
}
});
test("has proper color contrast", async ({ page }) => {
const menuItems = page.getByRole("option");
const firstItem = menuItems.first();
const color = await firstItem.evaluate((el) => {
const styles = window.getComputedStyle(el);
return styles.color;
});
expect(color).toBeDefined();
expect(color).not.toBe("rgba(0, 0, 0, 0)"); // Should not be transparent
});
test("renders with custom styling", async ({ page }) => {
// Navigate to With Custom Styling story
await page.goto(
"http://localhost:6006/?path=/story/forms-contextmenu--with-custom-styling"
);
const menu = page.getByRole("listbox");
await expect(menu).toBeVisible();
// Check that custom styling is applied
const customClass = await menu.getAttribute("class");
expect(customClass).toContain("custom-menu");
});
test("handles interactive story correctly", async ({ page }) => {
// Navigate to Interactive story
await page.goto(
"http://localhost:6006/?path=/story/forms-contextmenu--interactive"
);
const menuItems = page.getByRole("option");
const count = await menuItems.count();
expect(count).toBeGreaterThan(0);
// Test interaction with different items
for (let i = 0; i < Math.min(count, 3); i++) {
const item = menuItems.nth(i);
await item.click();
// Should handle click without error
await expect(item).toBeVisible();
}
});
});
+280
View File
@@ -0,0 +1,280 @@
import { test, expect } from "@playwright/test";
test.describe("Select Component Storybook Tests", () => {
test.beforeEach(async ({ page }) => {
await page.goto("http://localhost:6006/?path=/story/forms-select--default");
});
test("renders default select component", async ({ page }) => {
const selectButton = page.getByRole("button", { name: /select/i });
await expect(selectButton).toBeVisible();
const label = page.getByText("Test Select");
await expect(label).toBeVisible();
});
test("opens dropdown when clicked", async ({ page }) => {
const selectButton = page.getByRole("button", { name: /select/i });
await selectButton.click();
// Wait for dropdown to appear
await expect(page.getByRole("listbox")).toBeVisible();
await expect(page.getByText("Option 1")).toBeVisible();
await expect(page.getByText("Option 2")).toBeVisible();
await expect(page.getByText("Option 3")).toBeVisible();
});
test("selects option when clicked", async ({ page }) => {
const selectButton = page.getByRole("button", { name: /select/i });
await selectButton.click();
await expect(page.getByRole("listbox")).toBeVisible();
await page.getByText("Option 1").click();
// Check that the selected value is displayed
await expect(selectButton).toContainText("Option 1");
// Check that dropdown is closed
await expect(page.getByRole("listbox")).not.toBeVisible();
});
test("closes dropdown when clicking outside", async ({ page }) => {
const selectButton = page.getByRole("button", { name: /select/i });
await selectButton.click();
await expect(page.getByRole("listbox")).toBeVisible();
// Click outside the dropdown
await page.click("body", { position: { x: 10, y: 10 } });
await expect(page.getByRole("listbox")).not.toBeVisible();
});
test("handles keyboard navigation", async ({ page }) => {
const selectButton = page.getByRole("button", { name: /select/i });
await selectButton.focus();
// Open with Enter key
await page.keyboard.press("Enter");
await expect(page.getByRole("listbox")).toBeVisible();
// Close with Escape key
await page.keyboard.press("Escape");
await expect(page.getByRole("listbox")).not.toBeVisible();
// Open with Space key
await page.keyboard.press(" ");
await expect(page.getByRole("listbox")).toBeVisible();
});
test("shows different sizes correctly", async ({ page }) => {
// Navigate to All Sizes story
await page.goto(
"http://localhost:6006/?path=/story/forms-select--all-sizes"
);
const selectButtons = page.getByRole("button");
const count = await selectButtons.count();
// Should have multiple select components
expect(count).toBeGreaterThan(1);
// Test that all sizes are visible
for (let i = 0; i < count; i++) {
await expect(selectButtons.nth(i)).toBeVisible();
}
});
test("shows different states correctly", async ({ page }) => {
// Navigate to All States story
await page.goto(
"http://localhost:6006/?path=/story/forms-select--all-states"
);
const selectButtons = page.getByRole("button");
const count = await selectButtons.count();
// Should have multiple select components in different states
expect(count).toBeGreaterThan(1);
// Test that all states are visible
for (let i = 0; i < count; i++) {
await expect(selectButtons.nth(i)).toBeVisible();
}
});
test("hover state shows correct styling", async ({ page }) => {
// Navigate to Hover story
await page.goto("http://localhost:6006/?path=/story/forms-select--hover");
const selectButton = page.getByRole("button");
await expect(selectButton).toBeVisible();
// Check that hover state is applied (shadow effect)
const boxShadow = await selectButton.evaluate((el) => {
const styles = window.getComputedStyle(el);
return styles.boxShadow;
});
expect(boxShadow).toContain("2px");
});
test("focus state shows correct styling", async ({ page }) => {
// Navigate to Focus story
await page.goto("http://localhost:6006/?path=/story/forms-select--focus");
const selectButton = page.getByRole("button");
await expect(selectButton).toBeVisible();
// Check that focus state is applied (blue border and shadow)
const borderColor = await selectButton.evaluate((el) => {
const styles = window.getComputedStyle(el);
return styles.borderColor;
});
const boxShadow = await selectButton.evaluate((el) => {
const styles = window.getComputedStyle(el);
return styles.boxShadow;
});
expect(boxShadow).toContain("3px");
});
test("error state shows correct styling", async ({ page }) => {
// Navigate to Error story
await page.goto("http://localhost:6006/?path=/story/forms-select--error");
const selectButton = page.getByRole("button");
await expect(selectButton).toBeVisible();
// Check that error state is applied (red border)
const borderColor = await selectButton.evaluate((el) => {
const styles = window.getComputedStyle(el);
return styles.borderColor;
});
expect(borderColor).toContain("rgb");
});
test("disabled state prevents interaction", async ({ page }) => {
// Navigate to Disabled story
await page.goto(
"http://localhost:6006/?path=/story/forms-select--disabled"
);
const selectButton = page.getByRole("button");
await expect(selectButton).toBeVisible();
await expect(selectButton).toBeDisabled();
// Try to click disabled select
await selectButton.click();
// Dropdown should not open
await expect(page.getByRole("listbox")).not.toBeVisible();
});
test("interactive story allows selection", async ({ page }) => {
// Navigate to Interactive story
await page.goto(
"http://localhost:6006/?path=/story/forms-select--interactive"
);
const selectButton = page.getByRole("button");
await selectButton.click();
await expect(page.getByRole("listbox")).toBeVisible();
// Select an option
await page.getByText("Option 1").click();
// Check that selection is reflected
await expect(selectButton).toContainText("Option 1");
});
test("horizontal label variant displays correctly", async ({ page }) => {
// Navigate to Horizontal Label story
await page.goto(
"http://localhost:6006/?path=/story/forms-select--horizontal-label"
);
const selectButton = page.getByRole("button");
await expect(selectButton).toBeVisible();
const label = page.getByText("Test Select");
await expect(label).toBeVisible();
// Check that label and select are in horizontal layout
const labelBox = await label.boundingBox();
const selectBox = await selectButton.boundingBox();
expect(labelBox?.y).toBeCloseTo(selectBox?.y || 0, 5);
});
test("small size has correct height", async ({ page }) => {
// Navigate to Small story
await page.goto("http://localhost:6006/?path=/story/forms-select--small");
const selectButton = page.getByRole("button");
await expect(selectButton).toBeVisible();
const height = await selectButton.evaluate((el) => {
const styles = window.getComputedStyle(el);
return styles.height;
});
expect(height).toBe("30px");
});
test("medium size has correct height", async ({ page }) => {
// Navigate to Medium story
await page.goto("http://localhost:6006/?path=/story/forms-select--medium");
const selectButton = page.getByRole("button");
await expect(selectButton).toBeVisible();
const height = await selectButton.evaluate((el) => {
const styles = window.getComputedStyle(el);
return styles.height;
});
expect(height).toBe("36px");
});
test("large size has correct height", async ({ page }) => {
// Navigate to Large story
await page.goto("http://localhost:6006/?path=/story/forms-select--large");
const selectButton = page.getByRole("button");
await expect(selectButton).toBeVisible();
const height = await selectButton.evaluate((el) => {
const styles = window.getComputedStyle(el);
return styles.height;
});
expect(height).toBe("40px");
});
test("focus behavior works correctly", async ({ page }) => {
// Navigate to Interactive story
await page.goto(
"http://localhost:6006/?path=/story/forms-select--interactive"
);
const selectButton = page.getByRole("button");
// Tab to focus the select
await page.keyboard.press("Tab");
await expect(selectButton).toBeFocused();
// Check that focus-visible styles are applied
const boxShadow = await selectButton.evaluate((el) => {
const styles = window.getComputedStyle(el);
return styles.boxShadow;
});
// Should have focus indicator
expect(boxShadow).toContain("3px");
});
});
@@ -0,0 +1,389 @@
import React, { useState } from "react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, describe, it, vi } from "vitest";
import ContextMenu from "../../app/components/ContextMenu";
import ContextMenuItem from "../../app/components/ContextMenuItem";
import ContextMenuSection from "../../app/components/ContextMenuSection";
import ContextMenuDivider from "../../app/components/ContextMenuDivider";
describe("ContextMenu Components Integration", () => {
const TestMenu = ({ onItemClick, selectedValue }) => (
<ContextMenu>
<ContextMenuSection title="Actions">
<ContextMenuItem
onClick={() => onItemClick("action1")}
selected={selectedValue === "action1"}
>
Action 1
</ContextMenuItem>
<ContextMenuItem
onClick={() => onItemClick("action2")}
selected={selectedValue === "action2"}
>
Action 2
</ContextMenuItem>
</ContextMenuSection>
<ContextMenuDivider />
<ContextMenuSection title="Settings">
<ContextMenuItem
onClick={() => onItemClick("setting1")}
hasSubmenu={true}
>
Setting 1
</ContextMenuItem>
<ContextMenuItem
onClick={() => onItemClick("setting2")}
disabled={true}
>
Setting 2
</ContextMenuItem>
</ContextMenuSection>
</ContextMenu>
);
describe("Menu Interaction", () => {
it("handles item selection correctly", async () => {
const user = userEvent.setup();
const onItemClick = vi.fn();
render(<TestMenu onItemClick={onItemClick} selectedValue="" />);
const action1 = screen.getByText("Action 1");
await user.click(action1);
expect(onItemClick).toHaveBeenCalledWith("action1");
});
it("shows selected state correctly", () => {
render(<TestMenu onItemClick={vi.fn()} selectedValue="action1" />);
const action1 = screen.getByRole("menuitem", { name: "Action 1" });
expect(action1).toHaveClass(
"bg-[var(--color-surface-default-secondary)]"
);
});
it("handles disabled items correctly", async () => {
const user = userEvent.setup();
const onItemClick = vi.fn();
render(<TestMenu onItemClick={onItemClick} selectedValue="" />);
const setting2 = screen.getByText("Setting 2");
await user.click(setting2);
expect(onItemClick).not.toHaveBeenCalled();
});
it("shows submenu indicators correctly", () => {
render(<TestMenu onItemClick={vi.fn()} selectedValue="" />);
const setting1 = screen.getByText("Setting 1");
const arrow = screen
.getByRole("menuitem", { name: "Setting 1" })
.querySelector("svg");
expect(arrow).toBeInTheDocument();
});
});
describe("Keyboard Navigation", () => {
it("navigates through menu items with arrow keys", async () => {
const user = userEvent.setup();
render(<TestMenu onItemClick={vi.fn()} selectedValue="" />);
const items = screen.getAllByRole("menuitem");
expect(items).toHaveLength(4);
// Check that enabled items are focusable and disabled items are not
const enabledItems = items.filter(
(item) =>
!item.hasAttribute("aria-disabled") ||
item.getAttribute("aria-disabled") !== "true"
);
const disabledItems = items.filter(
(item) => item.getAttribute("aria-disabled") === "true"
);
enabledItems.forEach((item) => {
expect(item).toHaveAttribute("tabIndex", "0");
});
disabledItems.forEach((item) => {
expect(item).toHaveAttribute("tabIndex", "-1");
});
});
it("selects items with Enter key", async () => {
const user = userEvent.setup();
const onItemClick = vi.fn();
render(<TestMenu onItemClick={onItemClick} selectedValue="" />);
const items = screen.getAllByRole("menuitem");
items[0].focus();
await user.keyboard("{Enter}");
expect(onItemClick).toHaveBeenCalledWith("action1");
});
it("selects items with Space key", async () => {
const user = userEvent.setup();
const onItemClick = vi.fn();
render(<TestMenu onItemClick={onItemClick} selectedValue="" />);
const items = screen.getAllByRole("menuitem");
items[0].focus();
await user.keyboard(" ");
expect(onItemClick).toHaveBeenCalledWith("action1");
});
it("skips disabled items during navigation", async () => {
const user = userEvent.setup();
render(<TestMenu onItemClick={vi.fn()} selectedValue="" />);
const items = screen.getAllByRole("menuitem");
expect(items).toHaveLength(4);
// Check that disabled items have tabIndex="-1"
const disabledItem = screen.getByRole("menuitem", { name: "Setting 2" });
expect(disabledItem).toHaveAttribute("tabIndex", "-1");
expect(disabledItem).toHaveAttribute("aria-disabled", "true");
});
});
describe("Dynamic Menu Updates", () => {
const DynamicMenu = ({ items, selectedValue, onItemClick }) => (
<ContextMenu>
{items.map((item, index) => (
<ContextMenuItem
key={item.id}
onClick={() => onItemClick(item.id)}
selected={selectedValue === item.id}
disabled={item.disabled}
>
{item.label}
</ContextMenuItem>
))}
</ContextMenu>
);
it("handles dynamic item updates", async () => {
const user = userEvent.setup();
const onItemClick = vi.fn();
const { rerender } = render(
<DynamicMenu
items={[
{ id: "1", label: "Item 1" },
{ id: "2", label: "Item 2" },
]}
selectedValue=""
onItemClick={onItemClick}
/>
);
const item1 = screen.getByText("Item 1");
await user.click(item1);
expect(onItemClick).toHaveBeenCalledWith("1");
// Update items
rerender(
<DynamicMenu
items={[
{ id: "1", label: "Item 1" },
{ id: "2", label: "Item 2" },
{ id: "3", label: "Item 3" },
]}
selectedValue="1"
onItemClick={onItemClick}
/>
);
expect(screen.getByText("Item 3")).toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: "Item 1" })).toHaveClass(
"bg-[var(--color-surface-default-secondary)]"
);
});
it("handles item removal", () => {
const { rerender } = render(
<DynamicMenu
items={[
{ id: "1", label: "Item 1" },
{ id: "2", label: "Item 2" },
{ id: "3", label: "Item 3" },
]}
selectedValue="2"
onItemClick={vi.fn()}
/>
);
expect(screen.getByText("Item 2")).toBeInTheDocument();
rerender(
<DynamicMenu
items={[
{ id: "1", label: "Item 1" },
{ id: "3", label: "Item 3" },
]}
selectedValue=""
onItemClick={vi.fn()}
/>
);
expect(screen.queryByText("Item 2")).not.toBeInTheDocument();
});
});
describe("Menu State Management", () => {
const StatefulMenu = () => {
const [selectedValue, setSelectedValue] = useState("");
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>
{isOpen ? "Close Menu" : "Open Menu"}
</button>
{isOpen && (
<ContextMenu>
<ContextMenuItem
onClick={() => {
setSelectedValue("option1");
setIsOpen(false);
}}
selected={selectedValue === "option1"}
>
Option 1
</ContextMenuItem>
<ContextMenuItem
onClick={() => {
setSelectedValue("option2");
setIsOpen(false);
}}
selected={selectedValue === "option2"}
>
Option 2
</ContextMenuItem>
</ContextMenu>
)}
</div>
);
};
it("manages menu open/close state", async () => {
const user = userEvent.setup();
render(<StatefulMenu />);
const toggleButton = screen.getByRole("button", { name: "Open Menu" });
await user.click(toggleButton);
expect(screen.getByText("Option 1")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Close Menu" })
).toBeInTheDocument();
});
it("closes menu after selection", async () => {
const user = userEvent.setup();
render(<StatefulMenu />);
const toggleButton = screen.getByRole("button", { name: "Open Menu" });
await user.click(toggleButton);
const option1 = screen.getByText("Option 1");
await user.click(option1);
expect(screen.queryByText("Option 1")).not.toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Open Menu" })
).toBeInTheDocument();
});
});
describe("Performance", () => {
it("handles large menu lists efficiently", async () => {
const user = userEvent.setup();
const largeItems = Array.from({ length: 100 }, (_, i) => ({
id: `item${i}`,
label: `Item ${i}`,
}));
const LargeMenu = () => (
<ContextMenu>
{largeItems.map((item) => (
<ContextMenuItem key={item.id} onClick={vi.fn()}>
{item.label}
</ContextMenuItem>
))}
</ContextMenu>
);
render(<LargeMenu />);
const items = screen.getAllByRole("menuitem");
expect(items).toHaveLength(100);
// Test that all items are focusable
items.forEach((item) => {
expect(item).toHaveAttribute("tabIndex", "0");
});
});
it("handles rapid state changes", async () => {
const user = userEvent.setup();
const { rerender } = render(
<ContextMenu>
<ContextMenuItem onClick={vi.fn()} selected={false}>
Item 1
</ContextMenuItem>
<ContextMenuItem onClick={vi.fn()} selected={false}>
Item 2
</ContextMenuItem>
</ContextMenu>
);
// Rapidly change selection state
for (let i = 0; i < 10; i++) {
rerender(
<ContextMenu>
<ContextMenuItem onClick={vi.fn()} selected={i % 2 === 0}>
Item 1
</ContextMenuItem>
<ContextMenuItem onClick={vi.fn()} selected={i % 2 === 1}>
Item 2
</ContextMenuItem>
</ContextMenu>
);
}
// Should still be functional
const items = screen.getAllByRole("menuitem");
expect(items).toHaveLength(2);
});
});
describe("Error Handling", () => {
it("handles missing onClick gracefully", () => {
render(
<ContextMenu>
<ContextMenuItem>Item without onClick</ContextMenuItem>
</ContextMenu>
);
const item = screen.getByText("Item without onClick");
expect(item).toBeInTheDocument();
});
it("handles invalid props gracefully", () => {
render(
<ContextMenu>
<ContextMenuItem onClick={vi.fn()} selected={null}>
Item with invalid selected
</ContextMenuItem>
</ContextMenu>
);
const item = screen.getByText("Item with invalid selected");
expect(item).toBeInTheDocument();
});
});
});
+2 -2
View File
@@ -285,9 +285,9 @@ describe("Input Component Integration", () => {
// Set hover state
fireEvent.click(hoverButton);
expect(input).toHaveClass("border-2");
expect(input).toHaveClass("border-[var(--color-border-default-tertiary)]");
expect(input).toHaveClass(
"border-[var(--color-border-default-brand-primary)]"
"shadow-[0_0_0_2px_var(--color-border-default-tertiary)]"
);
// Set active state
@@ -0,0 +1,407 @@
import React, { useState } from "react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, describe, it, vi } from "vitest";
import Select from "../../app/components/Select";
describe("Select Component Integration", () => {
const TestForm = ({ initialValue = "" }) => {
const [value, setValue] = useState(initialValue);
const [errors, setErrors] = useState({});
const handleChange = (newValue) => {
setValue(newValue);
if (errors.select) {
setErrors({ ...errors, select: null });
}
};
const handleSubmit = (e) => {
e.preventDefault();
if (!value) {
setErrors({ select: "Please select an option" });
}
};
return (
<form onSubmit={handleSubmit}>
<Select
label="Test Select"
placeholder="Select an option"
value={value}
onChange={handleChange}
error={!!errors.select}
options={[
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2" },
{ value: "option3", label: "Option 3" },
]}
/>
{errors.select && <div data-testid="error">{errors.select}</div>}
<button type="submit">Submit</button>
</form>
);
};
describe("Form Integration", () => {
it("integrates with form submission", async () => {
const user = userEvent.setup();
render(<TestForm />);
const selectButton = screen.getByRole("button", { name: /Test Select/ });
await user.click(selectButton);
await waitFor(() => {
expect(screen.getByText("Option 1")).toBeInTheDocument();
});
await user.click(screen.getByText("Option 1"));
const submitButton = screen.getByRole("button", { name: "Submit" });
await user.click(submitButton);
expect(screen.queryByTestId("error")).not.toBeInTheDocument();
});
it("shows validation error when no option selected", async () => {
const user = userEvent.setup();
render(<TestForm />);
const submitButton = screen.getByRole("button", { name: "Submit" });
await user.click(submitButton);
expect(screen.getByTestId("error")).toHaveTextContent(
"Please select an option"
);
});
it("clears error when option is selected", async () => {
const user = userEvent.setup();
render(<TestForm />);
const submitButton = screen.getByRole("button", { name: "Submit" });
await user.click(submitButton);
expect(screen.getByTestId("error")).toBeInTheDocument();
const selectButton = screen.getByRole("button", { name: /Test Select/ });
await user.click(selectButton);
await waitFor(() => {
expect(screen.getByText("Option 1")).toBeInTheDocument();
});
await user.click(screen.getByText("Option 1"));
expect(screen.queryByTestId("error")).not.toBeInTheDocument();
});
});
describe("Multiple Select Components", () => {
const MultiSelectForm = () => {
const [values, setValues] = useState({ select1: "", select2: "" });
const handleChange = (field) => (newValue) => {
setValues({ ...values, [field]: newValue });
};
return (
<div>
<Select
label="First Select"
placeholder="Select first option"
value={values.select1}
onChange={handleChange("select1")}
options={[
{ value: "a1", label: "A1" },
{ value: "a2", label: "A2" },
]}
/>
<Select
label="Second Select"
placeholder="Select second option"
value={values.select2}
onChange={handleChange("select2")}
options={[
{ value: "b1", label: "B1" },
{ value: "b2", label: "B2" },
]}
/>
</div>
);
};
it("handles multiple select components independently", async () => {
const user = userEvent.setup();
render(<MultiSelectForm />);
const firstSelect = screen.getByRole("button", {
name: /First Select/,
});
const secondSelect = screen.getByRole("button", {
name: /Second Select/,
});
await user.click(firstSelect);
await waitFor(() => {
expect(screen.getByText("A1")).toBeInTheDocument();
});
await user.click(screen.getByText("A1"));
await user.click(secondSelect);
await waitFor(() => {
expect(screen.getByText("B1")).toBeInTheDocument();
});
await user.click(screen.getByText("B1"));
expect(firstSelect).toHaveTextContent("A1");
expect(secondSelect).toHaveTextContent("B1");
});
it("closes one dropdown when another is opened", async () => {
const user = userEvent.setup();
render(<MultiSelectForm />);
const firstSelect = screen.getByRole("button", {
name: /First Select/,
});
const secondSelect = screen.getByRole("button", {
name: /Second Select/,
});
await user.click(firstSelect);
await waitFor(() => {
expect(screen.getByText("A1")).toBeInTheDocument();
});
await user.click(secondSelect);
await waitFor(() => {
expect(screen.queryByText("A1")).not.toBeInTheDocument();
expect(screen.getByText("B1")).toBeInTheDocument();
});
});
});
describe("Keyboard Navigation Between Components", () => {
const KeyboardForm = () => {
const [values, setValues] = useState({ select1: "", select2: "" });
return (
<div>
<input placeholder="First input" />
<Select
label="First Select"
placeholder="Select first option"
value={values.select1}
onChange={(value) => setValues({ ...values, select1: value })}
options={[{ value: "a1", label: "A1" }]}
/>
<input placeholder="Second input" />
<Select
label="Second Select"
placeholder="Select second option"
value={values.select2}
onChange={(value) => setValues({ ...values, select2: value })}
options={[{ value: "b1", label: "B1" }]}
/>
</div>
);
};
it("handles keyboard navigation between inputs and selects", async () => {
const user = userEvent.setup();
render(<KeyboardForm />);
const firstInput = screen.getByPlaceholderText("First input");
const firstSelect = screen.getByRole("button", {
name: /First Select/,
});
const secondInput = screen.getByPlaceholderText("Second input");
const secondSelect = screen.getByRole("button", {
name: /Second Select/,
});
await user.tab();
expect(firstInput).toHaveFocus();
await user.tab();
expect(firstSelect).toHaveFocus();
await user.tab();
expect(secondInput).toHaveFocus();
await user.tab();
expect(secondSelect).toHaveFocus();
});
it("opens select with Enter key during tab navigation", async () => {
const user = userEvent.setup();
render(<KeyboardForm />);
const firstSelect = screen.getByRole("button", {
name: /First Select/,
});
await user.tab();
await user.tab();
expect(firstSelect).toHaveFocus();
await user.keyboard("{Enter}");
await waitFor(() => {
expect(screen.getByText("A1")).toBeInTheDocument();
});
});
});
describe("Dynamic Prop Changes", () => {
const DynamicSelect = ({ disabled, error, size }) => {
const [value, setValue] = useState("");
return (
<Select
label="Dynamic Select"
placeholder="Select an option"
value={value}
onChange={setValue}
disabled={disabled}
error={error}
size={size}
options={[
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2" },
]}
/>
);
};
it("handles dynamic disabled state changes", async () => {
const { rerender } = render(<DynamicSelect disabled={false} />);
const selectButton = screen.getByRole("button", {
name: /Dynamic Select/,
});
expect(selectButton).not.toBeDisabled();
rerender(<DynamicSelect disabled={true} />);
expect(selectButton).toBeDisabled();
rerender(<DynamicSelect disabled={false} />);
expect(selectButton).not.toBeDisabled();
});
it("handles dynamic error state changes", async () => {
const { rerender } = render(<DynamicSelect error={false} />);
const selectButton = screen.getByRole("button", {
name: /Dynamic Select/,
});
expect(selectButton).not.toHaveClass(
"border-[var(--color-border-default-utility-negative)]"
);
rerender(<DynamicSelect error={true} />);
expect(selectButton).toHaveClass(
"border-[var(--color-border-default-utility-negative)]"
);
rerender(<DynamicSelect error={false} />);
expect(selectButton).not.toHaveClass(
"border-[var(--color-border-default-utility-negative)]"
);
});
it("handles dynamic size changes", async () => {
const { rerender } = render(<DynamicSelect size="small" />);
const selectButton = screen.getByRole("button", {
name: /Dynamic Select/,
});
expect(selectButton).toHaveClass("h-[32px]");
rerender(<DynamicSelect size="medium" />);
expect(selectButton).toHaveClass("h-[36px]");
rerender(<DynamicSelect size="large" />);
expect(selectButton).toHaveClass("h-[40px]");
});
});
describe("Focus State Behavior", () => {
it("enters focus state when tabbed to (not active state)", async () => {
const user = userEvent.setup();
render(<TestForm />);
const selectButton = screen.getByRole("button", { name: /Test Select/ });
await user.tab();
expect(selectButton).toHaveFocus();
// Should have focus state styling, not active state
expect(selectButton).toHaveClass(
"focus-visible:border-[var(--color-border-default-utility-info)]"
);
});
it("does not enter focus state when clicked", async () => {
const user = userEvent.setup();
render(<TestForm />);
const selectButton = screen.getByRole("button", { name: /Test Select/ });
await user.click(selectButton);
expect(selectButton).toHaveFocus();
// Click should not trigger focus-visible styles (class is always present but only active on keyboard focus)
// The focus-visible class is always in the component but only applies on keyboard focus
expect(selectButton).toHaveClass(
"focus-visible:border-[var(--color-border-default-utility-info)]"
);
});
});
describe("Performance", () => {
it("handles rapid state changes without issues", async () => {
const user = userEvent.setup();
const { rerender } = render(<TestForm />);
const selectButton = screen.getByRole("button", { name: /Test Select/ });
// Rapidly change props
for (let i = 0; i < 10; i++) {
rerender(<TestForm />);
await user.click(selectButton);
await user.keyboard("{Escape}");
}
// Should still be functional
await user.click(selectButton);
await waitFor(() => {
expect(screen.getByText("Option 1")).toBeInTheDocument();
});
});
it("handles large option lists efficiently", async () => {
const user = userEvent.setup();
const largeOptions = Array.from({ length: 100 }, (_, i) => ({
value: `option${i}`,
label: `Option ${i}`,
}));
render(
<Select
label="Large Select"
placeholder="Select an option"
options={largeOptions}
/>
);
const selectButton = screen.getByRole("button", { name: /Large Select/ });
await user.click(selectButton);
await waitFor(() => {
expect(screen.getByText("Option 0")).toBeInTheDocument();
expect(screen.getByText("Option 99")).toBeInTheDocument();
});
});
});
});
+321
View File
@@ -0,0 +1,321 @@
import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, describe, it, vi, beforeEach } from "vitest";
import { axe, toHaveNoViolations } from "jest-axe";
import ContextMenu from "../../app/components/ContextMenu";
import ContextMenuItem from "../../app/components/ContextMenuItem";
import ContextMenuSection from "../../app/components/ContextMenuSection";
import ContextMenuDivider from "../../app/components/ContextMenuDivider";
expect.extend(toHaveNoViolations);
describe("ContextMenu Component", () => {
const defaultProps = {
children: "Context Menu Content",
};
describe("Rendering", () => {
it("renders with default props", () => {
render(<ContextMenu {...defaultProps} />);
expect(screen.getByText("Context Menu Content")).toBeInTheDocument();
});
it("renders with custom className", () => {
render(<ContextMenu {...defaultProps} className="custom-class" />);
const menu = screen.getByText("Context Menu Content").closest("div");
expect(menu).toHaveClass("custom-class");
});
it("applies correct base styles", () => {
render(<ContextMenu {...defaultProps} />);
const menu = screen.getByText("Context Menu Content").closest("div");
expect(menu).toHaveClass(
"bg-black",
"border",
"rounded-[var(--measures-radius-medium)]",
"shadow-lg",
"p-[4px]"
);
});
it("has solid black background", () => {
render(<ContextMenu {...defaultProps} />);
const menu = screen.getByText("Context Menu Content").closest("div");
expect(menu).toHaveStyle({ backgroundColor: "#000000" });
});
});
describe("Accessibility", () => {
it("has no accessibility violations", async () => {
const { container } = render(
<ContextMenu {...defaultProps}>
<ContextMenuItem onClick={vi.fn()}>Menu Item</ContextMenuItem>
</ContextMenu>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it("has proper role", () => {
render(<ContextMenu {...defaultProps} />);
const menu = screen.getByText("Context Menu Content").closest("div");
expect(menu).toHaveAttribute("role", "menu");
});
});
});
describe("ContextMenuItem Component", () => {
const defaultProps = {
children: "Menu Item",
onClick: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
describe("Rendering", () => {
it("renders with default props", () => {
render(<ContextMenuItem {...defaultProps} />);
expect(screen.getByText("Menu Item")).toBeInTheDocument();
});
it("renders as selected when selected prop is true", () => {
render(<ContextMenuItem {...defaultProps} selected={true} />);
const item = screen.getByRole("menuitem");
expect(item).toHaveClass(
"bg-[var(--color-surface-default-secondary)]",
"rounded-[var(--measures-radius-small)]"
);
});
it("renders with submenu arrow when hasSubmenu prop is true", () => {
render(<ContextMenuItem {...defaultProps} hasSubmenu={true} />);
// Check for the right-pointing chevron SVG
const item = screen.getByRole("menuitem");
const svg = item.querySelector("svg:last-child");
expect(svg).toBeInTheDocument();
});
it("renders with checkmark when selected prop is true", () => {
render(<ContextMenuItem {...defaultProps} selected={true} />);
// Check for the checkmark SVG
const item = screen.getByRole("menuitem");
const svg = item.querySelector("svg:first-child");
expect(svg).toBeInTheDocument();
});
it("applies correct size styles", () => {
render(<ContextMenuItem {...defaultProps} size="small" />);
const item = screen.getByRole("menuitem");
expect(item).toHaveClass("text-[10px]", "leading-[14px]");
});
it("applies medium size styles", () => {
render(<ContextMenuItem {...defaultProps} size="medium" />);
const item = screen.getByRole("menuitem");
expect(item).toHaveClass("text-[14px]", "leading-[20px]");
});
it("applies large size styles", () => {
render(<ContextMenuItem {...defaultProps} size="large" />);
const item = screen.getByRole("menuitem");
expect(item).toHaveClass("text-[16px]", "leading-[24px]");
});
});
describe("Interaction", () => {
it("calls onClick when clicked", async () => {
const user = userEvent.setup();
render(<ContextMenuItem {...defaultProps} />);
const item = screen.getByText("Menu Item");
await user.click(item);
expect(defaultProps.onClick).toHaveBeenCalledTimes(1);
});
it("does not call onClick when disabled", async () => {
const user = userEvent.setup();
render(<ContextMenuItem {...defaultProps} disabled={true} />);
const item = screen.getByText("Menu Item");
await user.click(item);
expect(defaultProps.onClick).not.toHaveBeenCalled();
});
it("has hover effects", () => {
render(<ContextMenuItem {...defaultProps} />);
const item = screen.getByRole("menuitem");
expect(item).toHaveClass(
"hover:!bg-[var(--color-surface-default-secondary)]"
);
});
});
describe("Accessibility", () => {
it("has no accessibility violations", async () => {
const { container } = render(
<ContextMenu>
<ContextMenuItem {...defaultProps} />
</ContextMenu>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it("has proper role", () => {
render(<ContextMenuItem {...defaultProps} />);
const item = screen.getByRole("menuitem");
expect(item).toBeInTheDocument();
});
});
describe("Styling", () => {
it("applies correct text color", () => {
render(<ContextMenuItem {...defaultProps} />);
const item = screen.getByRole("menuitem");
expect(item).toHaveClass(
"text-[var(--color-content-default-brand-primary)]"
);
});
it("applies correct padding", () => {
render(<ContextMenuItem {...defaultProps} />);
const item = screen.getByRole("menuitem");
expect(item).toHaveClass("px-[8px]", "py-[4px]");
});
it("applies correct gap between checkmark and text", () => {
render(<ContextMenuItem {...defaultProps} selected={true} />);
const item = screen.getByText("Menu Item").closest("div");
expect(item).toHaveClass("gap-[8px]");
});
});
});
describe("ContextMenuSection Component", () => {
const defaultProps = {
title: "Section Title",
children: "Section Content",
};
describe("Rendering", () => {
it("renders with title and children", () => {
render(<ContextMenuSection {...defaultProps} />);
expect(screen.getByText("Section Title")).toBeInTheDocument();
expect(screen.getByText("Section Content")).toBeInTheDocument();
});
it("renders without title when not provided", () => {
render(<ContextMenuSection>Section Content</ContextMenuSection>);
expect(screen.getByText("Section Content")).toBeInTheDocument();
expect(screen.queryByText("Section Title")).not.toBeInTheDocument();
});
it("applies correct title styling", () => {
render(<ContextMenuSection {...defaultProps} />);
const title = screen.getByText("Section Title");
expect(title).toHaveClass(
"text-[var(--color-content-default-primary)]",
"font-medium"
);
});
});
describe("Accessibility", () => {
it("has no accessibility violations", async () => {
const { container } = render(<ContextMenuSection {...defaultProps} />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
});
describe("ContextMenuDivider Component", () => {
describe("Rendering", () => {
it("renders divider", () => {
render(<ContextMenuDivider />);
const divider = screen.getByRole("separator");
expect(divider).toBeInTheDocument();
});
it("applies correct styling", () => {
render(<ContextMenuDivider />);
const divider = screen.getByRole("separator");
expect(divider).toHaveClass(
"border-t",
"border-[var(--color-border-default-tertiary)]",
"my-1"
);
});
});
describe("Accessibility", () => {
it("has no accessibility violations", async () => {
const { container } = render(<ContextMenuDivider />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
});
describe("ContextMenu Components Integration", () => {
const TestMenu = () => (
<ContextMenu>
<ContextMenuSection title="First Section">
<ContextMenuItem onClick={vi.fn()}>Item 1</ContextMenuItem>
<ContextMenuItem onClick={vi.fn()} selected={true}>
Item 2
</ContextMenuItem>
</ContextMenuSection>
<ContextMenuDivider />
<ContextMenuSection title="Second Section">
<ContextMenuItem onClick={vi.fn()} hasSubmenu={true}>
Item 3
</ContextMenuItem>
</ContextMenuSection>
</ContextMenu>
);
it("renders all components together", () => {
render(<TestMenu />);
expect(screen.getByText("First Section")).toBeInTheDocument();
expect(screen.getByText("Item 1")).toBeInTheDocument();
expect(screen.getByText("Item 2")).toBeInTheDocument();
expect(screen.getByText("Second Section")).toBeInTheDocument();
expect(screen.getByText("Item 3")).toBeInTheDocument();
expect(screen.getByRole("separator")).toBeInTheDocument();
});
it("has no accessibility violations when integrated", async () => {
const { container } = render(<TestMenu />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
+6 -4
View File
@@ -98,7 +98,7 @@ describe("Input Component", () => {
test("applies correct size classes", () => {
const { rerender } = render(<Input size="small" />);
let input = screen.getByRole("textbox");
expect(input).toHaveClass("h-[30px]");
expect(input).toHaveClass("h-[32px]");
rerender(<Input size="medium" />);
input = screen.getByRole("textbox");
@@ -146,9 +146,9 @@ describe("Input Component", () => {
test("applies hover state classes", () => {
render(<Input state="hover" />);
const input = screen.getByRole("textbox");
expect(input).toHaveClass("border-2");
expect(input).toHaveClass("border-[var(--color-border-default-tertiary)]");
expect(input).toHaveClass(
"border-[var(--color-border-default-brand-primary)]"
"shadow-[0_0_0_2px_var(--color-border-default-tertiary)]"
);
});
@@ -162,7 +162,9 @@ describe("Input Component", () => {
render(<Input state="default" />);
const input = screen.getByRole("textbox");
expect(input).toHaveClass("border-[var(--color-border-default-tertiary)]");
expect(input).toHaveClass("hover:outline");
expect(input).toHaveClass(
"hover:shadow-[0_0_0_2px_var(--color-border-default-tertiary)]"
);
});
test("applies custom className", () => {
+399
View File
@@ -0,0 +1,399 @@
import React from "react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, describe, it, vi } from "vitest";
import { axe, toHaveNoViolations } from "jest-axe";
import Select from "../../app/components/Select";
expect.extend(toHaveNoViolations);
describe("Select Component", () => {
const defaultProps = {
label: "Test Select",
placeholder: "Select an option",
options: [
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2" },
{ value: "option3", label: "Option 3" },
],
};
describe("Rendering", () => {
it("renders with default props", () => {
render(<Select {...defaultProps} />);
expect(screen.getByText("Test Select")).toBeInTheDocument();
expect(screen.getByText("Select an option")).toBeInTheDocument();
});
it("renders without label when not provided", () => {
render(
<Select placeholder="Select an option" options={defaultProps.options} />
);
expect(screen.queryByText("Test Select")).not.toBeInTheDocument();
expect(screen.getByText("Select an option")).toBeInTheDocument();
});
it("renders with horizontal label variant", () => {
render(<Select {...defaultProps} labelVariant="horizontal" />);
const container = screen.getByText("Test Select").closest("div");
expect(container).toHaveClass("flex", "items-center");
});
it("renders with default label variant", () => {
render(<Select {...defaultProps} labelVariant="default" />);
const container = screen.getByText("Test Select").closest("div");
expect(container).toHaveClass("flex", "flex-col");
});
});
describe("Size Variants", () => {
it("renders small size correctly", () => {
render(<Select {...defaultProps} size="small" />);
const selectButton = screen.getByRole("button");
expect(selectButton).toHaveClass("h-[32px]");
});
it("renders medium size correctly", () => {
render(<Select {...defaultProps} size="medium" />);
const selectButton = screen.getByRole("button");
expect(selectButton).toHaveClass("h-[36px]");
});
it("renders large size correctly", () => {
render(<Select {...defaultProps} size="large" />);
const selectButton = screen.getByRole("button");
expect(selectButton).toHaveClass("h-[40px]");
});
it("applies correct height for small horizontal label", () => {
render(
<Select {...defaultProps} size="small" labelVariant="horizontal" />
);
const selectButton = screen.getByRole("button");
expect(selectButton).toHaveClass("h-[30px]");
});
it("applies correct height for small default label", () => {
render(<Select {...defaultProps} size="small" labelVariant="default" />);
const selectButton = screen.getByRole("button");
expect(selectButton).toHaveClass("h-[32px]");
});
});
describe("State Variants", () => {
it("renders default state", () => {
render(<Select {...defaultProps} />);
const selectButton = screen.getByRole("button");
expect(selectButton).toHaveClass(
"border-[var(--color-border-default-tertiary)]"
);
});
it("renders hover state", () => {
render(<Select {...defaultProps} state="hover" />);
const selectButton = screen.getByRole("button");
expect(selectButton).toHaveClass(
"shadow-[0_0_0_2px_var(--color-border-default-tertiary)]"
);
});
it("renders focus state", () => {
render(<Select {...defaultProps} state="focus" />);
const selectButton = screen.getByRole("button");
expect(selectButton).toHaveClass(
"border-[var(--color-border-default-utility-info)]"
);
expect(selectButton).toHaveClass("shadow-[0_0_5px_3px_#3281F8]");
});
it("renders error state", () => {
render(<Select {...defaultProps} error={true} />);
const selectButton = screen.getByRole("button");
expect(selectButton).toHaveClass(
"border-[var(--color-border-default-utility-negative)]"
);
});
it("renders disabled state", () => {
render(<Select {...defaultProps} disabled={true} />);
const selectButton = screen.getByRole("button");
expect(selectButton).toHaveClass("cursor-not-allowed");
expect(selectButton).toHaveClass("opacity-40");
});
});
describe("Interaction", () => {
it("opens dropdown when clicked", async () => {
const user = userEvent.setup();
render(<Select {...defaultProps} />);
const selectButton = screen.getByRole("button");
await user.click(selectButton);
await waitFor(() => {
expect(screen.getByText("Option 1")).toBeInTheDocument();
expect(screen.getByText("Option 2")).toBeInTheDocument();
expect(screen.getByText("Option 3")).toBeInTheDocument();
});
});
it("closes dropdown when clicked again", async () => {
const user = userEvent.setup();
render(<Select {...defaultProps} />);
const selectButton = screen.getByRole("button");
await user.click(selectButton);
await waitFor(() => {
expect(screen.getByText("Option 1")).toBeInTheDocument();
});
await user.click(selectButton);
await waitFor(() => {
expect(screen.queryByText("Option 1")).not.toBeInTheDocument();
});
});
it("selects an option when clicked", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<Select {...defaultProps} onChange={onChange} />);
const selectButton = screen.getByRole("button");
await user.click(selectButton);
await waitFor(() => {
expect(screen.getByText("Option 1")).toBeInTheDocument();
});
await user.click(screen.getByText("Option 1"));
expect(onChange).toHaveBeenCalledWith({
target: {
value: "option1",
text: "Option 1",
},
});
expect(screen.getByText("Option 1")).toBeInTheDocument();
});
it("closes dropdown when option is selected", async () => {
const user = userEvent.setup();
render(<Select {...defaultProps} />);
const selectButton = screen.getByRole("button");
await user.click(selectButton);
await waitFor(() => {
expect(screen.getByText("Option 1")).toBeInTheDocument();
});
await user.click(screen.getByText("Option 1"));
await waitFor(() => {
expect(screen.queryByText("Option 2")).not.toBeInTheDocument();
});
});
it("does not open when disabled", async () => {
const user = userEvent.setup();
render(<Select {...defaultProps} disabled={true} />);
const selectButton = screen.getByRole("button");
await user.click(selectButton);
expect(screen.queryByText("Option 1")).not.toBeInTheDocument();
});
});
describe("Keyboard Navigation", () => {
it("opens dropdown with Enter key", async () => {
const user = userEvent.setup();
render(<Select {...defaultProps} />);
const selectButton = screen.getByRole("button");
selectButton.focus();
await user.keyboard("{Enter}");
await waitFor(() => {
expect(screen.getByText("Option 1")).toBeInTheDocument();
});
});
it("opens dropdown with Space key", async () => {
const user = userEvent.setup();
render(<Select {...defaultProps} />);
const selectButton = screen.getByRole("button");
selectButton.focus();
await user.keyboard(" ");
await waitFor(() => {
expect(screen.getByText("Option 1")).toBeInTheDocument();
});
});
it("closes dropdown with Escape key", async () => {
const user = userEvent.setup();
render(<Select {...defaultProps} />);
const selectButton = screen.getByRole("button");
await user.click(selectButton);
await waitFor(() => {
expect(screen.getByText("Option 1")).toBeInTheDocument();
});
await user.keyboard("{Escape}");
await waitFor(() => {
expect(screen.queryByText("Option 1")).not.toBeInTheDocument();
});
});
it("does not respond to keyboard when disabled", async () => {
const user = userEvent.setup();
render(<Select {...defaultProps} disabled={true} />);
const selectButton = screen.getByRole("button");
selectButton.focus();
await user.keyboard("{Enter}");
expect(screen.queryByText("Option 1")).not.toBeInTheDocument();
});
});
describe("Click Outside", () => {
it("closes dropdown when clicking outside", async () => {
const user = userEvent.setup();
render(
<div>
<Select {...defaultProps} />
<div data-testid="outside">Outside element</div>
</div>
);
const selectButton = screen.getByRole("button");
await user.click(selectButton);
await waitFor(() => {
expect(screen.getByText("Option 1")).toBeInTheDocument();
});
await user.click(screen.getByTestId("outside"));
await waitFor(() => {
expect(screen.queryByText("Option 1")).not.toBeInTheDocument();
});
});
});
describe("Value Display", () => {
it("shows placeholder when no value selected", () => {
render(<Select {...defaultProps} />);
expect(screen.getByText("Select an option")).toBeInTheDocument();
});
it("shows selected value when option is selected", async () => {
const user = userEvent.setup();
render(<Select {...defaultProps} />);
const selectButton = screen.getByRole("button");
await user.click(selectButton);
await waitFor(() => {
expect(screen.getByText("Option 1")).toBeInTheDocument();
});
await user.click(screen.getByText("Option 1"));
expect(screen.getByText("Option 1")).toBeInTheDocument();
expect(screen.queryByText("Select an option")).not.toBeInTheDocument();
});
it("shows selected value when value prop is provided", () => {
render(<Select {...defaultProps} value="option2" />);
expect(screen.getByText("Option 2")).toBeInTheDocument();
});
});
describe("Accessibility", () => {
it("has no accessibility violations", async () => {
const { container } = render(<Select {...defaultProps} />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it("has proper ARIA attributes", () => {
render(<Select {...defaultProps} />);
const selectButton = screen.getByRole("button");
expect(selectButton).toHaveAttribute("aria-expanded", "false");
expect(selectButton).toHaveAttribute("aria-haspopup", "listbox");
});
it("updates aria-expanded when dropdown opens", async () => {
const user = userEvent.setup();
render(<Select {...defaultProps} />);
const selectButton = screen.getByRole("button");
await user.click(selectButton);
await waitFor(() => {
expect(selectButton).toHaveAttribute("aria-expanded", "true");
});
});
it("associates label with select button", () => {
render(<Select {...defaultProps} />);
const label = screen.getByText("Test Select");
const selectButton = screen.getByRole("button");
expect(label).toHaveAttribute("for", selectButton.id);
});
});
describe("Focus Behavior", () => {
it("enters focus state when tabbed to", async () => {
const user = userEvent.setup();
render(<Select {...defaultProps} />);
const selectButton = screen.getByRole("button");
await user.tab();
expect(selectButton).toHaveFocus();
expect(selectButton).toHaveClass(
"focus-visible:border-[var(--color-border-default-utility-info)]"
);
});
it("does not enter focus state when clicked", async () => {
const user = userEvent.setup();
render(<Select {...defaultProps} />);
const selectButton = screen.getByRole("button");
await user.click(selectButton);
expect(selectButton).toHaveFocus();
// Focus state should not be applied on click, only on keyboard navigation
});
});
});