Switch component with storybook and testing
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap");
|
||||
|
||||
:root {
|
||||
--font-inter: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI",
|
||||
Roboto, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji",
|
||||
"Segoe UI Emoji";
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import "../app/globals.css";
|
||||
import "./fonts.css";
|
||||
|
||||
/** @type { import('@storybook/react').Preview } */
|
||||
const preview = {
|
||||
@@ -12,7 +13,7 @@ const preview = {
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="font-sans">
|
||||
<div className="font-inter">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import React, { memo, useCallback, useId, forwardRef } from "react";
|
||||
|
||||
const Switch = memo(
|
||||
forwardRef((props, ref) => {
|
||||
const {
|
||||
checked = false,
|
||||
onChange,
|
||||
onFocus,
|
||||
onBlur,
|
||||
state = "default",
|
||||
label,
|
||||
className = "",
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const switchId = useId();
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e) => {
|
||||
if (onChange) {
|
||||
onChange(e);
|
||||
}
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
if (onChange) {
|
||||
onChange(e);
|
||||
}
|
||||
}
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(
|
||||
(e) => {
|
||||
if (onFocus) {
|
||||
onFocus(e);
|
||||
}
|
||||
},
|
||||
[onFocus]
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(
|
||||
(e) => {
|
||||
if (onBlur) {
|
||||
onBlur(e);
|
||||
}
|
||||
},
|
||||
[onBlur]
|
||||
);
|
||||
|
||||
// Switch track styles based on checked state
|
||||
const getTrackStyles = useCallback(() => {
|
||||
return checked
|
||||
? "bg-[var(--color-surface-inverse-tertiary)]"
|
||||
: "bg-[var(--color-surface-default-tertiary)]";
|
||||
}, [checked]);
|
||||
|
||||
// Switch thumb styles based on checked state
|
||||
const getThumbStyles = useCallback(() => {
|
||||
return "bg-[var(--color-gray-000)]";
|
||||
}, []);
|
||||
|
||||
// Focus styles
|
||||
const getFocusStyles = useCallback(() => {
|
||||
if (state === "focus") {
|
||||
return "shadow-[0_0_5px_3px_#3281F8] rounded-full";
|
||||
}
|
||||
return "";
|
||||
}, [state]);
|
||||
|
||||
const trackStyles = getTrackStyles();
|
||||
const thumbStyles = getThumbStyles();
|
||||
const focusStyles = getFocusStyles();
|
||||
|
||||
const switchClasses = `
|
||||
relative
|
||||
inline-flex
|
||||
items-center
|
||||
cursor-pointer
|
||||
transition-all
|
||||
duration-200
|
||||
focus:outline-none
|
||||
focus-visible:shadow-[0_0_5px_3px_#3281F8]
|
||||
focus-visible:rounded-full
|
||||
${focusStyles}
|
||||
${className}
|
||||
`
|
||||
.trim()
|
||||
.replace(/\s+/g, " ");
|
||||
|
||||
const trackClasses = `
|
||||
${trackStyles}
|
||||
w-[40px]
|
||||
h-[24px]
|
||||
rounded-full
|
||||
transition-all
|
||||
duration-200
|
||||
flex
|
||||
items-center
|
||||
${checked ? "justify-end" : "justify-start"}
|
||||
p-[2px]
|
||||
`
|
||||
.trim()
|
||||
.replace(/\s+/g, " ");
|
||||
|
||||
const thumbClasses = `
|
||||
${thumbStyles}
|
||||
w-[var(--measures-sizing-020)]
|
||||
h-[var(--measures-sizing-020)]
|
||||
rounded-[var(--measures-radius-xlarge)]
|
||||
transition-all
|
||||
duration-200
|
||||
shadow-sm
|
||||
`
|
||||
.trim()
|
||||
.replace(/\s+/g, " ");
|
||||
|
||||
const labelClasses = `
|
||||
ml-[var(--measures-spacing-008)]
|
||||
font-inter
|
||||
font-normal
|
||||
text-[14px]
|
||||
leading-[20px]
|
||||
text-[var(--color-content-default-primary)]
|
||||
`
|
||||
.trim()
|
||||
.replace(/\s+/g, " ");
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
ref={ref}
|
||||
id={switchId}
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label || "Toggle switch"}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
className={switchClasses}
|
||||
{...rest}
|
||||
>
|
||||
<div className={trackClasses}>
|
||||
<div className={thumbClasses} />
|
||||
</div>
|
||||
</button>
|
||||
{label && <span className={labelClasses}>{label}</span>}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
Switch.displayName = "Switch";
|
||||
|
||||
export default Switch;
|
||||
+61
-261
@@ -1,286 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import ToggleGroup from "../components/ToggleGroup";
|
||||
import Switch from "../components/Switch";
|
||||
|
||||
export default function FormsPlayground() {
|
||||
const [selectedToggle, setSelectedToggle] = useState("active");
|
||||
const [selectedFilter, setSelectedFilter] = useState("all");
|
||||
const [toggleStates, setToggleStates] = useState({
|
||||
default: false,
|
||||
hover: false,
|
||||
selected: true,
|
||||
focus: false,
|
||||
const [switchStates, setSwitchStates] = useState({
|
||||
switch1: false,
|
||||
switch2: true,
|
||||
switch3: false,
|
||||
switch4: true,
|
||||
});
|
||||
|
||||
const handleToggleChange = (key) => (e) => {
|
||||
setToggleStates((prev) => ({
|
||||
const handleSwitchChange = (switchName) => {
|
||||
setSwitchStates((prev) => ({
|
||||
...prev,
|
||||
[key]: !prev[key],
|
||||
[switchName]: !prev[switchName],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleToggleGroupChange = (position) => (e) => {
|
||||
setSelectedToggle(position);
|
||||
};
|
||||
|
||||
const handleFilterChange = (filter) => (e) => {
|
||||
setSelectedFilter(filter);
|
||||
};
|
||||
|
||||
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]">Toggle Group Examples</h2>
|
||||
<h2 className="font-space text-[18px]">Switch Examples</h2>
|
||||
<div
|
||||
className="max-w-[520px] space-y-[16px] bg-white p-6 rounded-lg border border-gray-200 shadow-lg"
|
||||
style={{ backgroundColor: "white" }}
|
||||
//style={{ backgroundColor: "white" }}
|
||||
>
|
||||
<div>
|
||||
<h3 className="font-space text-[14px] mb-[8px]">Switch States</h3>
|
||||
<div className="space-y-4">
|
||||
<Switch
|
||||
checked={switchStates.switch1}
|
||||
onChange={() => handleSwitchChange("switch1")}
|
||||
label="Switch label"
|
||||
/>
|
||||
<Switch
|
||||
checked={switchStates.switch2}
|
||||
onChange={() => handleSwitchChange("switch2")}
|
||||
label="Switch label"
|
||||
/>
|
||||
<Switch
|
||||
checked={switchStates.switch3}
|
||||
onChange={() => handleSwitchChange("switch3")}
|
||||
state="focus"
|
||||
label="Switch label"
|
||||
/>
|
||||
<Switch
|
||||
checked={switchStates.switch4}
|
||||
onChange={() => handleSwitchChange("switch4")}
|
||||
state="focus"
|
||||
label="Switch label"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-space text-[14px] mb-[8px]">
|
||||
Interactive Toggle Group
|
||||
Interactive Example
|
||||
</h3>
|
||||
<div className="flex">
|
||||
<ToggleGroup
|
||||
position="left"
|
||||
state={selectedToggle === "active" ? "selected" : "default"}
|
||||
showText={true}
|
||||
onChange={handleToggleGroupChange("active")}
|
||||
>
|
||||
Active Deals
|
||||
</ToggleGroup>
|
||||
<ToggleGroup
|
||||
position="middle"
|
||||
state={selectedToggle === "inactive" ? "selected" : "default"}
|
||||
showText={true}
|
||||
onChange={handleToggleGroupChange("inactive")}
|
||||
>
|
||||
Inactive Deals
|
||||
</ToggleGroup>
|
||||
<ToggleGroup
|
||||
position="right"
|
||||
state={selectedToggle === "pending" ? "selected" : "default"}
|
||||
showText={true}
|
||||
onChange={handleToggleGroupChange("pending")}
|
||||
>
|
||||
Pending Deals
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-space text-[14px] mb-[8px]">States</h3>
|
||||
<div className="flex space-x-2">
|
||||
<ToggleGroup position="left" state="default" showText={true}>
|
||||
Default
|
||||
</ToggleGroup>
|
||||
<ToggleGroup position="middle" state="hover" showText={true}>
|
||||
Hover
|
||||
</ToggleGroup>
|
||||
<ToggleGroup position="middle" state="focus" showText={true}>
|
||||
Focus
|
||||
</ToggleGroup>
|
||||
<ToggleGroup position="right" state="selected" showText={true}>
|
||||
Selected
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-space text-[14px] mb-[8px]">Positions</h3>
|
||||
<div className="flex">
|
||||
<ToggleGroup position="left" state="default" showText={true}>
|
||||
Left
|
||||
</ToggleGroup>
|
||||
<ToggleGroup position="middle" state="default" showText={true}>
|
||||
Middle
|
||||
</ToggleGroup>
|
||||
<ToggleGroup position="middle" state="default" showText={true}>
|
||||
Middle
|
||||
</ToggleGroup>
|
||||
<ToggleGroup position="right" state="default" showText={true}>
|
||||
Right
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-space text-[14px] mb-[8px]">Without Text</h3>
|
||||
<div className="flex">
|
||||
<ToggleGroup position="left" state="default" showText={false}>
|
||||
Icon
|
||||
</ToggleGroup>
|
||||
<ToggleGroup position="middle" state="selected" showText={false}>
|
||||
Icon
|
||||
</ToggleGroup>
|
||||
<ToggleGroup position="right" state="default" showText={false}>
|
||||
Icon
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Content Visibility Examples */}
|
||||
<section className="space-y-[12px]">
|
||||
<h2 className="font-space text-[18px]">Content Visibility Examples</h2>
|
||||
|
||||
{/* Deal Management Example */}
|
||||
<div className="max-w-[520px] space-y-[16px] bg-white p-6 rounded-lg border border-gray-200 shadow-lg">
|
||||
<h3 className="font-space text-[14px] mb-[8px]">Deal Management</h3>
|
||||
<div className="flex">
|
||||
<ToggleGroup
|
||||
position="left"
|
||||
state={selectedToggle === "active" ? "selected" : "default"}
|
||||
showText={true}
|
||||
onChange={handleToggleGroupChange("active")}
|
||||
>
|
||||
Active Deals
|
||||
</ToggleGroup>
|
||||
<ToggleGroup
|
||||
position="middle"
|
||||
state={selectedToggle === "inactive" ? "selected" : "default"}
|
||||
showText={true}
|
||||
onChange={handleToggleGroupChange("inactive")}
|
||||
>
|
||||
Inactive Deals
|
||||
</ToggleGroup>
|
||||
<ToggleGroup
|
||||
position="right"
|
||||
state={selectedToggle === "pending" ? "selected" : "default"}
|
||||
showText={true}
|
||||
onChange={handleToggleGroupChange("pending")}
|
||||
>
|
||||
Pending Deals
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
{/* Content that changes based on toggle selection */}
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
{selectedToggle === "active" && (
|
||||
<div>
|
||||
<h4 className="font-semibold text-green-700 mb-2">
|
||||
Active Deals
|
||||
</h4>
|
||||
<ul className="space-y-2">
|
||||
<li className="flex justify-between items-center p-2 bg-white rounded border">
|
||||
<span>Summer Sale - 50% Off</span>
|
||||
<span className="text-green-600 font-semibold">$299</span>
|
||||
</li>
|
||||
<li className="flex justify-between items-center p-2 bg-white rounded border">
|
||||
<span>Black Friday Special</span>
|
||||
<span className="text-green-600 font-semibold">$199</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedToggle === "inactive" && (
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-700 mb-2">
|
||||
Inactive Deals
|
||||
</h4>
|
||||
<ul className="space-y-2">
|
||||
<li className="flex justify-between items-center p-2 bg-white rounded border opacity-60">
|
||||
<span>Holiday Sale - Expired</span>
|
||||
<span className="text-gray-500 line-through">$399</span>
|
||||
</li>
|
||||
<li className="flex justify-between items-center p-2 bg-white rounded border opacity-60">
|
||||
<span>Spring Clearance - Ended</span>
|
||||
<span className="text-gray-500 line-through">$149</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedToggle === "pending" && (
|
||||
<div>
|
||||
<h4 className="font-semibold text-yellow-700 mb-2">
|
||||
Pending Deals
|
||||
</h4>
|
||||
<ul className="space-y-2">
|
||||
<li className="flex justify-between items-center p-2 bg-white rounded border">
|
||||
<span>Cyber Monday - Coming Soon</span>
|
||||
<span className="text-yellow-600 font-semibold">$99</span>
|
||||
</li>
|
||||
<li className="flex justify-between items-center p-2 bg-white rounded border">
|
||||
<span>New Year Sale - Pending</span>
|
||||
<span className="text-yellow-600 font-semibold">$79</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Example */}
|
||||
<div className="max-w-[520px] space-y-[16px] bg-white p-6 rounded-lg border border-gray-200 shadow-lg">
|
||||
<h3 className="font-space text-[14px] mb-[8px]">Content Filter</h3>
|
||||
<div className="flex">
|
||||
<ToggleGroup
|
||||
position="left"
|
||||
state={selectedFilter === "all" ? "selected" : "default"}
|
||||
showText={true}
|
||||
onChange={handleFilterChange("all")}
|
||||
>
|
||||
All
|
||||
</ToggleGroup>
|
||||
<ToggleGroup
|
||||
position="middle"
|
||||
state={selectedFilter === "featured" ? "selected" : "default"}
|
||||
showText={true}
|
||||
onChange={handleFilterChange("featured")}
|
||||
>
|
||||
Featured
|
||||
</ToggleGroup>
|
||||
<ToggleGroup
|
||||
position="right"
|
||||
state={selectedFilter === "recent" ? "selected" : "default"}
|
||||
showText={true}
|
||||
onChange={handleFilterChange("recent")}
|
||||
>
|
||||
Recent
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div
|
||||
className={`p-4 rounded-lg border ${
|
||||
selectedFilter === "all" || selectedFilter === "featured"
|
||||
? "block"
|
||||
: "hidden"
|
||||
}`}
|
||||
>
|
||||
<h4 className="font-semibold">Featured Article</h4>
|
||||
<p className="text-gray-600 text-sm">
|
||||
This is a featured article that shows when "All" or "Featured"
|
||||
is selected.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={`p-4 rounded-lg border ${
|
||||
selectedFilter === "all" || selectedFilter === "recent"
|
||||
? "block"
|
||||
: "hidden"
|
||||
}`}
|
||||
>
|
||||
<h4 className="font-semibold">Recent Post</h4>
|
||||
<p className="text-gray-600 text-sm">
|
||||
This is a recent post that shows when "All" or "Recent" is
|
||||
selected.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={`p-4 rounded-lg border ${
|
||||
selectedFilter === "all" ? "block" : "hidden"
|
||||
}`}
|
||||
>
|
||||
<h4 className="font-semibold">General Content</h4>
|
||||
<p className="text-gray-600 text-sm">
|
||||
This content only shows when "All" is selected.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<Switch
|
||||
checked={switchStates.switch1}
|
||||
onChange={() => handleSwitchChange("switch1")}
|
||||
label="Enable notifications"
|
||||
/>
|
||||
<Switch
|
||||
checked={switchStates.switch2}
|
||||
onChange={() => handleSwitchChange("switch2")}
|
||||
label="Auto-save documents"
|
||||
/>
|
||||
<Switch
|
||||
checked={switchStates.switch3}
|
||||
onChange={() => handleSwitchChange("switch3")}
|
||||
label="Dark mode"
|
||||
/>
|
||||
<Switch
|
||||
checked={switchStates.switch4}
|
||||
onChange={() => handleSwitchChange("switch4")}
|
||||
label="Email updates"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import React from "react";
|
||||
import Switch from "../app/components/Switch";
|
||||
|
||||
export default {
|
||||
title: "Forms/Switch",
|
||||
component: Switch,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
argTypes: {
|
||||
checked: {
|
||||
control: "boolean",
|
||||
description: "Whether the switch is checked (on) or not (off)",
|
||||
},
|
||||
state: {
|
||||
control: "select",
|
||||
options: ["default", "focus"],
|
||||
description: "Visual state of the switch",
|
||||
},
|
||||
label: {
|
||||
control: "text",
|
||||
description: "Label text displayed next to the switch",
|
||||
},
|
||||
onChange: {
|
||||
action: "changed",
|
||||
description: "Callback fired when the switch is toggled",
|
||||
},
|
||||
onFocus: {
|
||||
action: "focused",
|
||||
description: "Callback fired when the switch receives focus",
|
||||
},
|
||||
onBlur: {
|
||||
action: "blurred",
|
||||
description: "Callback fired when the switch loses focus",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const Template = (args) => <Switch {...args} />;
|
||||
|
||||
export const Default = Template.bind({});
|
||||
Default.args = {
|
||||
checked: false,
|
||||
label: "Switch label",
|
||||
};
|
||||
|
||||
export const Checked = Template.bind({});
|
||||
Checked.args = {
|
||||
checked: true,
|
||||
label: "Switch label",
|
||||
};
|
||||
|
||||
export const Focus = Template.bind({});
|
||||
Focus.args = {
|
||||
checked: false,
|
||||
state: "focus",
|
||||
label: "Switch label",
|
||||
};
|
||||
|
||||
export const FocusChecked = Template.bind({});
|
||||
FocusChecked.args = {
|
||||
checked: true,
|
||||
state: "focus",
|
||||
label: "Switch label",
|
||||
};
|
||||
|
||||
export const States = () => (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold">Switch States</h3>
|
||||
<div className="space-y-4">
|
||||
<Switch checked={false} label="Switch label" />
|
||||
<Switch checked={true} label="Switch label" />
|
||||
<Switch checked={false} state="focus" label="Switch label" />
|
||||
<Switch checked={true} state="focus" label="Switch label" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Interactive = () => {
|
||||
const [checked, setChecked] = React.useState(false);
|
||||
const [state, setState] = React.useState("default");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Interactive Switch</h3>
|
||||
<Switch
|
||||
checked={checked}
|
||||
state={state}
|
||||
onChange={() => setChecked(!checked)}
|
||||
label="Enable notifications"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-md font-semibold">Controls</h4>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">State:</label>
|
||||
<select
|
||||
value={state}
|
||||
onChange={(e) => setState(e.target.value)}
|
||||
className="px-3 py-1 border border-gray-300 rounded"
|
||||
>
|
||||
<option value="default">Default</option>
|
||||
<option value="focus">Focus</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const WithText = () => (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold">Switch with Different Labels</h3>
|
||||
<div className="space-y-4">
|
||||
<Switch checked={false} label="Enable notifications" />
|
||||
<Switch checked={true} label="Auto-save documents" />
|
||||
<Switch checked={false} label="Dark mode" />
|
||||
<Switch checked={true} label="Email updates" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,98 @@
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { axe, toHaveNoViolations } from "jest-axe";
|
||||
import Switch from "../../app/components/Switch";
|
||||
|
||||
expect.extend(toHaveNoViolations);
|
||||
|
||||
describe("Switch Accessibility", () => {
|
||||
it("has proper ARIA attributes", () => {
|
||||
render(<Switch checked={false} label="Test Switch" />);
|
||||
const switchButton = screen.getByRole("switch");
|
||||
|
||||
expect(switchButton).toHaveAttribute("role", "switch");
|
||||
expect(switchButton).toHaveAttribute("aria-checked", "false");
|
||||
expect(switchButton).toHaveAttribute("aria-label", "Test Switch");
|
||||
});
|
||||
|
||||
it("has proper ARIA attributes when checked", () => {
|
||||
render(<Switch checked={true} label="Test Switch" />);
|
||||
const switchButton = screen.getByRole("switch");
|
||||
|
||||
expect(switchButton).toHaveAttribute("aria-checked", "true");
|
||||
});
|
||||
|
||||
it("has proper ARIA attributes when focused", () => {
|
||||
render(<Switch state="focus" label="Test Switch" />);
|
||||
const switchButton = screen.getByRole("switch");
|
||||
|
||||
expect(switchButton).toHaveAttribute("aria-checked", "false");
|
||||
expect(switchButton).toHaveClass("shadow-[0_0_5px_3px_#3281F8]");
|
||||
expect(switchButton).toHaveClass("rounded-full");
|
||||
expect(switchButton).toHaveAttribute("aria-label", "Test Switch");
|
||||
});
|
||||
|
||||
it("handles keyboard navigation", () => {
|
||||
const handleChange = vi.fn();
|
||||
render(<Switch onChange={handleChange} label="Test Switch" />);
|
||||
const switchButton = screen.getByRole("switch");
|
||||
|
||||
// Test Enter key
|
||||
fireEvent.keyDown(switchButton, { key: "Enter" });
|
||||
expect(handleChange).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Test Space key
|
||||
fireEvent.keyDown(switchButton, { key: " " });
|
||||
expect(handleChange).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("handles focus state accessibility", () => {
|
||||
const handleFocus = vi.fn();
|
||||
render(<Switch onFocus={handleFocus} label="Test Switch" />);
|
||||
const switchButton = screen.getByRole("switch");
|
||||
|
||||
fireEvent.focus(switchButton);
|
||||
expect(handleFocus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("handles checked state accessibility", () => {
|
||||
const { rerender } = render(<Switch checked={false} label="Test Switch" />);
|
||||
let switchButton = screen.getByRole("switch");
|
||||
expect(switchButton).toHaveAttribute("aria-checked", "false");
|
||||
|
||||
rerender(<Switch checked={true} label="Test Switch" />);
|
||||
switchButton = screen.getByRole("switch");
|
||||
expect(switchButton).toHaveAttribute("aria-checked", "true");
|
||||
});
|
||||
|
||||
it("has no accessibility violations", async () => {
|
||||
const { container } = render(<Switch label="Test Switch" />);
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
|
||||
it("has no accessibility violations when checked", async () => {
|
||||
const { container } = render(<Switch checked={true} label="Test Switch" />);
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
|
||||
it("has no accessibility violations when focused", async () => {
|
||||
const { container } = render(<Switch state="focus" label="Test Switch" />);
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
|
||||
it("has no accessibility violations with text", async () => {
|
||||
const { container } = render(<Switch label="Enable notifications" />);
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
|
||||
it("has no accessibility violations without text", async () => {
|
||||
const { container } = render(<Switch />);
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import Switch from "../../app/components/Switch";
|
||||
|
||||
// Test form component
|
||||
const TestForm = ({ onSubmit }) => {
|
||||
const [switch1, setSwitch1] = React.useState(false);
|
||||
const [switch2, setSwitch2] = React.useState(true);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
onSubmit({ switch1, switch2 });
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Switch
|
||||
checked={switch1}
|
||||
onChange={() => setSwitch1(!switch1)}
|
||||
label="First Switch"
|
||||
/>
|
||||
<Switch
|
||||
checked={switch2}
|
||||
onChange={() => setSwitch2(!switch2)}
|
||||
label="Second Switch"
|
||||
/>
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
// Dynamic switch component
|
||||
const DynamicSwitch = ({ initialState = false }) => {
|
||||
const [checked, setChecked] = React.useState(initialState);
|
||||
|
||||
// Update state when initialState prop changes
|
||||
React.useEffect(() => {
|
||||
setChecked(initialState);
|
||||
}, [initialState]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={() => setChecked(!checked)}
|
||||
label="Dynamic Switch"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
describe("Switch Integration", () => {
|
||||
it("handles form submission", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleSubmit = vi.fn();
|
||||
|
||||
render(<TestForm onSubmit={handleSubmit} />);
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: "Submit" });
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(handleSubmit).toHaveBeenCalledWith({
|
||||
switch1: false,
|
||||
switch2: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("handles keyboard navigation between switches", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<div>
|
||||
<Switch label="First Switch" />
|
||||
<Switch label="Second Switch" />
|
||||
<Switch label="Third Switch" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const switches = screen.getAllByRole("switch");
|
||||
expect(switches).toHaveLength(3);
|
||||
|
||||
// Focus first switch
|
||||
await user.tab();
|
||||
expect(switches[0]).toHaveFocus();
|
||||
|
||||
// Tab to second switch
|
||||
await user.tab();
|
||||
expect(switches[1]).toHaveFocus();
|
||||
|
||||
// Tab to third switch
|
||||
await user.tab();
|
||||
expect(switches[2]).toHaveFocus();
|
||||
});
|
||||
|
||||
it("handles dynamic prop changes", () => {
|
||||
const { rerender } = render(<DynamicSwitch initialState={false} />);
|
||||
|
||||
let switchButton = screen.getByRole("switch");
|
||||
expect(switchButton).toHaveAttribute("aria-checked", "false");
|
||||
|
||||
// Change initial state - the DynamicSwitch component should handle this internally
|
||||
rerender(<DynamicSwitch initialState={true} />);
|
||||
switchButton = screen.getByRole("switch");
|
||||
// The DynamicSwitch component manages its own state, so it should be checked
|
||||
expect(switchButton).toHaveAttribute("aria-checked", "true");
|
||||
});
|
||||
|
||||
it("handles multiple switches in form", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleSubmit = vi.fn();
|
||||
|
||||
const TestForm = () => {
|
||||
const [switch1, setSwitch1] = React.useState(false);
|
||||
const [switch2, setSwitch2] = React.useState(false);
|
||||
const [switch3, setSwitch3] = React.useState(false);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}}
|
||||
>
|
||||
<Switch
|
||||
label="Switch 1"
|
||||
checked={switch1}
|
||||
onChange={() => setSwitch1(!switch1)}
|
||||
/>
|
||||
<Switch
|
||||
label="Switch 2"
|
||||
checked={switch2}
|
||||
onChange={() => setSwitch2(!switch2)}
|
||||
/>
|
||||
<Switch
|
||||
label="Switch 3"
|
||||
checked={switch3}
|
||||
onChange={() => setSwitch3(!switch3)}
|
||||
/>
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
render(<TestForm />);
|
||||
|
||||
const switches = screen.getAllByRole("switch");
|
||||
expect(switches).toHaveLength(3);
|
||||
|
||||
// Toggle first switch
|
||||
await user.click(switches[0]);
|
||||
expect(switches[0]).toHaveAttribute("aria-checked", "true");
|
||||
|
||||
// Toggle second switch
|
||||
await user.click(switches[1]);
|
||||
expect(switches[1]).toHaveAttribute("aria-checked", "true");
|
||||
|
||||
// Submit form
|
||||
const submitButton = screen.getByRole("button", { name: "Submit" });
|
||||
await user.click(submitButton);
|
||||
expect(handleSubmit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles state changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const TestComponent = () => {
|
||||
const [checked, setChecked] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={() => setChecked(!checked)}
|
||||
label="Test Switch"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render(<TestComponent />);
|
||||
|
||||
const switchButton = screen.getByRole("switch");
|
||||
|
||||
// Initially unchecked
|
||||
expect(switchButton).toHaveAttribute("aria-checked", "false");
|
||||
|
||||
// Toggle checked state
|
||||
await user.click(switchButton);
|
||||
expect(switchButton).toHaveAttribute("aria-checked", "true");
|
||||
});
|
||||
|
||||
it("handles content changes", () => {
|
||||
const { rerender } = render(<Switch label="Original Label" />);
|
||||
expect(screen.getByText("Original Label")).toBeInTheDocument();
|
||||
|
||||
rerender(<Switch label="Updated Label" />);
|
||||
expect(screen.getByText("Updated Label")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Original Label")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles performance with many switches", () => {
|
||||
const switches = Array.from({ length: 100 }, (_, i) => (
|
||||
<Switch key={i} label={`Switch ${i + 1}`} />
|
||||
));
|
||||
|
||||
const startTime = performance.now();
|
||||
render(<div>{switches}</div>);
|
||||
const endTime = performance.now();
|
||||
|
||||
// Should render within reasonable time (less than 1 second)
|
||||
expect(endTime - startTime).toBeLessThan(1000);
|
||||
|
||||
const renderedSwitches = screen.getAllByRole("switch");
|
||||
expect(renderedSwitches).toHaveLength(100);
|
||||
});
|
||||
|
||||
it("handles rapid state changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const TestComponent = () => {
|
||||
const [checked, setChecked] = React.useState(false);
|
||||
|
||||
return (
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={() => setChecked(!checked)}
|
||||
label="Rapid Toggle Switch"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
render(<TestComponent />);
|
||||
|
||||
const switchButton = screen.getByRole("switch");
|
||||
|
||||
// Rapidly toggle the switch
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await user.click(switchButton);
|
||||
await waitFor(() => {
|
||||
expect(switchButton).toHaveAttribute(
|
||||
"aria-checked",
|
||||
i % 2 === 0 ? "true" : "false"
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("handles mixed content types", () => {
|
||||
render(
|
||||
<div>
|
||||
<Switch label="Text Switch" />
|
||||
<Switch label="Another Text Switch" />
|
||||
<Switch />
|
||||
<Switch label="Final Switch" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const switches = screen.getAllByRole("switch");
|
||||
expect(switches).toHaveLength(4);
|
||||
|
||||
// Check that labels are rendered correctly
|
||||
expect(screen.getByText("Text Switch")).toBeInTheDocument();
|
||||
expect(screen.getByText("Another Text Switch")).toBeInTheDocument();
|
||||
expect(screen.getByText("Final Switch")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import Switch from "../../app/components/Switch";
|
||||
|
||||
describe("Switch Component", () => {
|
||||
it("renders with default props", () => {
|
||||
render(<Switch />);
|
||||
const switchButton = screen.getByRole("switch");
|
||||
expect(switchButton).toBeInTheDocument();
|
||||
expect(switchButton).toHaveAttribute("aria-checked", "false");
|
||||
});
|
||||
|
||||
it("renders with custom props", () => {
|
||||
const handleChange = vi.fn();
|
||||
render(
|
||||
<Switch
|
||||
checked={true}
|
||||
onChange={handleChange}
|
||||
label="Test Switch"
|
||||
state="focus"
|
||||
/>
|
||||
);
|
||||
|
||||
const switchButton = screen.getByRole("switch");
|
||||
expect(switchButton).toHaveAttribute("aria-checked", "true");
|
||||
expect(screen.getByText("Test Switch")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles checked prop correctly", () => {
|
||||
const { rerender } = render(<Switch checked={false} />);
|
||||
let switchButton = screen.getByRole("switch");
|
||||
expect(switchButton).toHaveAttribute("aria-checked", "false");
|
||||
|
||||
rerender(<Switch checked={true} />);
|
||||
switchButton = screen.getByRole("switch");
|
||||
expect(switchButton).toHaveAttribute("aria-checked", "true");
|
||||
});
|
||||
|
||||
it("handles state prop correctly", () => {
|
||||
const { rerender } = render(<Switch state="default" />);
|
||||
let switchButton = screen.getByRole("switch");
|
||||
expect(switchButton).not.toHaveClass("shadow-[0_0_5px_1px_#3281F8]");
|
||||
|
||||
rerender(<Switch state="focus" />);
|
||||
switchButton = screen.getByRole("switch");
|
||||
expect(switchButton).toHaveClass("shadow-[0_0_5px_3px_#3281F8]");
|
||||
});
|
||||
|
||||
it("calls onChange when clicked", () => {
|
||||
const handleChange = vi.fn();
|
||||
render(<Switch onChange={handleChange} />);
|
||||
|
||||
const switchButton = screen.getByRole("switch");
|
||||
fireEvent.click(switchButton);
|
||||
expect(handleChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onFocus when focused", () => {
|
||||
const handleFocus = vi.fn();
|
||||
render(<Switch onFocus={handleFocus} />);
|
||||
|
||||
const switchButton = screen.getByRole("switch");
|
||||
fireEvent.focus(switchButton);
|
||||
expect(handleFocus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onBlur when blurred", () => {
|
||||
const handleBlur = vi.fn();
|
||||
render(<Switch onBlur={handleBlur} />);
|
||||
|
||||
const switchButton = screen.getByRole("switch");
|
||||
fireEvent.blur(switchButton);
|
||||
expect(handleBlur).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("handles keyboard events correctly", () => {
|
||||
const handleChange = vi.fn();
|
||||
render(<Switch onChange={handleChange} />);
|
||||
|
||||
const switchButton = screen.getByRole("switch");
|
||||
|
||||
// Test Enter key
|
||||
fireEvent.keyDown(switchButton, { key: "Enter" });
|
||||
expect(handleChange).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Test Space key
|
||||
fireEvent.keyDown(switchButton, { key: " " });
|
||||
expect(handleChange).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Test other key (should not trigger)
|
||||
fireEvent.keyDown(switchButton, { key: "Tab" });
|
||||
expect(handleChange).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("applies correct classes for different states", () => {
|
||||
const { rerender } = render(<Switch checked={false} />);
|
||||
let switchButton = screen.getByRole("switch");
|
||||
expect(switchButton).toHaveClass("cursor-pointer");
|
||||
|
||||
rerender(<Switch checked={true} />);
|
||||
switchButton = screen.getByRole("switch");
|
||||
expect(switchButton).toHaveClass("cursor-pointer");
|
||||
});
|
||||
|
||||
it("applies correct track styles based on checked state", () => {
|
||||
const { rerender } = render(<Switch checked={false} />);
|
||||
let switchButton = screen.getByRole("switch");
|
||||
let track = switchButton.querySelector("div");
|
||||
expect(track).toHaveClass("bg-[var(--color-surface-default-tertiary)]");
|
||||
|
||||
rerender(<Switch checked={true} />);
|
||||
switchButton = screen.getByRole("switch");
|
||||
track = switchButton.querySelector("div");
|
||||
expect(track).toHaveClass("bg-[var(--color-surface-inverse-tertiary)]");
|
||||
|
||||
switchButton = screen.getByRole("switch");
|
||||
track = switchButton.querySelector("div");
|
||||
expect(track).toHaveClass("bg-[var(--color-surface-inverse-tertiary)]");
|
||||
});
|
||||
|
||||
it("applies correct focus styles", () => {
|
||||
const { rerender } = render(<Switch state="default" />);
|
||||
let switchButton = screen.getByRole("switch");
|
||||
expect(switchButton).not.toHaveClass("shadow-[0_0_5px_1px_#3281F8]");
|
||||
|
||||
rerender(<Switch state="focus" />);
|
||||
switchButton = screen.getByRole("switch");
|
||||
expect(switchButton).toHaveClass("shadow-[0_0_5px_3px_#3281F8]");
|
||||
});
|
||||
|
||||
it("applies correct base classes", () => {
|
||||
render(<Switch />);
|
||||
const switchButton = screen.getByRole("switch");
|
||||
expect(switchButton).toHaveClass(
|
||||
"relative",
|
||||
"inline-flex",
|
||||
"items-center",
|
||||
"cursor-pointer",
|
||||
"transition-all",
|
||||
"duration-200",
|
||||
"focus:outline-none",
|
||||
"focus-visible:shadow-[0_0_5px_3px_#3281F8]"
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards ref correctly", () => {
|
||||
const ref = React.createRef();
|
||||
render(<Switch ref={ref} />);
|
||||
expect(ref.current).toBeInstanceOf(HTMLButtonElement);
|
||||
});
|
||||
|
||||
it("applies custom className", () => {
|
||||
render(<Switch className="custom-class" />);
|
||||
const switchButton = screen.getByRole("switch");
|
||||
expect(switchButton).toHaveClass("custom-class");
|
||||
});
|
||||
|
||||
it("renders label when provided", () => {
|
||||
render(<Switch label="Test Label" />);
|
||||
expect(screen.getByText("Test Label")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render label when not provided", () => {
|
||||
render(<Switch />);
|
||||
expect(screen.queryByText("Switch label")).not.toBeInTheDocument();
|
||||
// Should have aria-label for accessibility
|
||||
const switchButton = screen.getByRole("switch");
|
||||
expect(switchButton).toHaveAttribute("aria-label", "Toggle switch");
|
||||
});
|
||||
|
||||
it("applies correct label styles", () => {
|
||||
render(<Switch label="Test Label" />);
|
||||
const label = screen.getByText("Test Label");
|
||||
expect(label).toHaveClass(
|
||||
"ml-[var(--measures-spacing-008)]",
|
||||
"font-inter",
|
||||
"font-normal",
|
||||
"text-[14px]",
|
||||
"leading-[20px]",
|
||||
"text-[var(--color-content-default-primary)]"
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user