diff --git a/app/components/ToggleGroup.js b/app/components/ToggleGroup.js new file mode 100644 index 0000000..08ba1e3 --- /dev/null +++ b/app/components/ToggleGroup.js @@ -0,0 +1,137 @@ +import React, { memo, useCallback, useId, forwardRef } from "react"; + +const ToggleGroup = memo( + forwardRef((props, ref) => { + const { + children, + className = "", + position = "left", + state = "default", + showText = true, + ariaLabel, + onChange, + onFocus, + onBlur, + ...rest + } = props; + + const groupId = useId(); + + // Position-based styling for border radius + const getPositionStyles = useCallback((pos) => { + switch (pos) { + case "left": + return "rounded-l-[var(--measures-radius-medium)] rounded-r-none"; + case "middle": + return "rounded-none"; + case "right": + return "rounded-r-[var(--measures-radius-medium)] rounded-l-none"; + default: + return "rounded-[var(--measures-radius-medium)]"; + } + }, []); + + // State-based styling + const getStateStyles = useCallback((state) => { + switch (state) { + case "hover": + return "bg-[var(--color-magenta-magenta100)] text-[var(--color-content-default-primary)]"; + case "focus": + return "bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] shadow-[0_0_5px_1px_#3281F8]"; + case "selected": + return "bg-[var(--color-magenta-magenta100)] text-[var(--color-content-default-primary)] shadow-[inset_0_0_0_1px_var(--color-border-default-secondary)]"; + case "default": + default: + return "bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)]"; + } + }, []); + + const positionStyles = getPositionStyles(position); + const stateStyles = getStateStyles(state); + + const handleClick = useCallback( + (e) => { + if (onChange) { + onChange(e); + } + }, + [onChange] + ); + + const handleFocus = useCallback( + (e) => { + if (onFocus) { + onFocus(e); + } + }, + [onFocus] + ); + + const handleBlur = useCallback( + (e) => { + if (onBlur) { + onBlur(e); + } + }, + [onBlur] + ); + + const handleKeyDown = useCallback( + (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + if (onChange) { + onChange(e); + } + } + }, + [onChange] + ); + + const toggleClasses = ` + ${positionStyles} + ${stateStyles} + py-[var(--measures-spacing-008)] + px-[var(--measures-spacing-008)] + gap-[var(--measures-spacing-008)] + font-inter + font-medium + text-[12px] + leading-[12px] + cursor-pointer + transition-all + duration-200 + focus:outline-none + focus-visible:shadow-[0_0_5px_1px_#3281F8] + hover:bg-[var(--color-magenta-magenta100)] + flex + items-center + justify-center + ${className} + ` + .trim() + .replace(/\s+/g, " "); + + return ( + + ); + }) +); + +ToggleGroup.displayName = "ToggleGroup"; + +export default ToggleGroup; diff --git a/app/forms/page.js b/app/forms/page.js index 3453d6c..7b154a5 100644 --- a/app/forms/page.js +++ b/app/forms/page.js @@ -1,18 +1,16 @@ "use client"; import React, { useState } from "react"; -import Toggle from "../components/Toggle"; +import ToggleGroup from "../components/ToggleGroup"; 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, - disabled: false, - icon: false, - text: false, - both: false, }); const handleToggleChange = (key) => (e) => { @@ -22,76 +20,267 @@ export default function FormsPlayground() { })); }; + const handleToggleGroupChange = (position) => (e) => { + setSelectedToggle(position); + }; + + const handleFilterChange = (filter) => (e) => { + setSelectedFilter(filter); + }; + return (

Forms Playground

-

Toggle Examples

+

Toggle Group Examples

-

States

-
- - - - - +

+ Interactive Toggle Group +

+
+ + Active Deals + + + Inactive Deals + + + Pending Deals +
-

Content Types

-
- - - +

States

+
+ + Default + + + Hover + + + Focus + + + Selected + +
+
+ +
+

Positions

+
+ + Left + + + Middle + + + Middle + + + Right + +
+
+ +
+

Without Text

+
+ + Icon + + + Icon + + + Icon + +
+
+
+
+ + {/* Content Visibility Examples */} +
+

Content Visibility Examples

+ + {/* Deal Management Example */} +
+

Deal Management

+
+ + Active Deals + + + Inactive Deals + + + Pending Deals + +
+ + {/* Content that changes based on toggle selection */} +
+ {selectedToggle === "active" && ( +
+

+ Active Deals +

+
    +
  • + Summer Sale - 50% Off + $299 +
  • +
  • + Black Friday Special + $199 +
  • +
+
+ )} + + {selectedToggle === "inactive" && ( +
+

+ Inactive Deals +

+
    +
  • + Holiday Sale - Expired + $399 +
  • +
  • + Spring Clearance - Ended + $149 +
  • +
+
+ )} + + {selectedToggle === "pending" && ( +
+

+ Pending Deals +

+
    +
  • + Cyber Monday - Coming Soon + $99 +
  • +
  • + New Year Sale - Pending + $79 +
  • +
+
+ )} +
+
+ + {/* Filter Example */} +
+

Content Filter

+
+ + All + + + Featured + + + Recent + +
+ +
+
+

Featured Article

+

+ This is a featured article that shows when "All" or "Featured" + is selected. +

+
+
+

Recent Post

+

+ This is a recent post that shows when "All" or "Recent" is + selected. +

+
+
+

General Content

+

+ This content only shows when "All" is selected. +

diff --git a/stories/ToggleGroup.stories.js b/stories/ToggleGroup.stories.js new file mode 100644 index 0000000..b22af10 --- /dev/null +++ b/stories/ToggleGroup.stories.js @@ -0,0 +1,210 @@ +import React from "react"; +import ToggleGroup from "../app/components/ToggleGroup"; + +export default { + title: "Forms/ToggleGroup", + component: ToggleGroup, + parameters: { + layout: "centered", + }, + argTypes: { + position: { + control: { type: "select" }, + options: ["left", "middle", "right"], + }, + state: { + control: { type: "select" }, + options: ["default", "hover", "focus", "selected"], + }, + showText: { + control: { type: "boolean" }, + }, + }, +}; + +const Template = (args) => Toggle Item; + +export const Default = Template.bind({}); +Default.args = { + position: "left", + state: "default", + showText: true, +}; + +export const Middle = Template.bind({}); +Middle.args = { + position: "middle", + state: "default", + showText: true, +}; + +export const Right = Template.bind({}); +Right.args = { + position: "right", + state: "default", + showText: true, +}; + +export const States = () => ( +
+
+

Toggle Group States

+
+ + Default + + + Hover + + + Focus + + + Selected + +
+
+
+); + +export const Positions = () => ( +
+
+

Toggle Group Positions

+
+ + Left + + + Middle + + + Middle + + + Right + +
+
+
+); + +export const WithText = Template.bind({}); +WithText.args = { + position: "left", + state: "default", + showText: true, + children: "Active Deals", +}; + +export const WithoutText = Template.bind({}); +WithoutText.args = { + position: "left", + state: "default", + showText: false, + children: "☰", +}; + +export const WithIcons = () => ( +
+
+

Toggle Group with Icons

+
+ + ☰ + + + ☰ + + + ☰ + +
+
+
+); + +export const Interactive = () => { + const [selectedPosition, setSelectedPosition] = React.useState("left"); + const [state, setState] = React.useState("default"); + const [showText, setShowText] = React.useState(true); + + return ( +
+
+

Interactive Toggle Group

+
+ setSelectedPosition("left")} + ariaLabel={!showText ? "Active Deals" : undefined} + > + {showText ? "Active Deals" : "☰"} + + setSelectedPosition("middle")} + ariaLabel={!showText ? "Inactive Deals" : undefined} + > + {showText ? "Inactive Deals" : "☰"} + + setSelectedPosition("right")} + ariaLabel={!showText ? "Pending Deals" : undefined} + > + {showText ? "Pending Deals" : "☰"} + +
+
+
+

Controls

+
+
+ + +
+
+ setShowText(e.target.checked)} + /> + +
+
+
+
+ ); +}; diff --git a/tests/accessibility/ToggleGroup.a11y.test.jsx b/tests/accessibility/ToggleGroup.a11y.test.jsx new file mode 100644 index 0000000..387b1e3 --- /dev/null +++ b/tests/accessibility/ToggleGroup.a11y.test.jsx @@ -0,0 +1,92 @@ +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 ToggleGroup from "../../app/components/ToggleGroup"; + +expect.extend(toHaveNoViolations); + +describe("ToggleGroup Accessibility", () => { + it("has proper ARIA attributes", () => { + render(Toggle Item); + const toggleGroup = screen.getByRole("button"); + expect(toggleGroup).toHaveAttribute("type", "button"); + expect(toggleGroup).toHaveAttribute("role", "button"); + }); + + it("has proper ARIA attributes when focused", () => { + render(Focused Toggle); + const toggleGroup = screen.getByRole("button"); + expect(toggleGroup).toHaveAttribute("type", "button"); + expect(toggleGroup).toHaveAttribute("role", "button"); + }); + + it("handles keyboard navigation", () => { + const handleChange = vi.fn(); + render(Keyboard Toggle); + const toggleGroup = screen.getByRole("button"); + + // Test Enter key + fireEvent.keyDown(toggleGroup, { key: "Enter" }); + expect(handleChange).toHaveBeenCalledTimes(1); + + // Test Space key + fireEvent.keyDown(toggleGroup, { key: " " }); + expect(handleChange).toHaveBeenCalledTimes(2); + }); + + it("handles focus state accessibility", () => { + render(Focus Toggle); + const toggleGroup = screen.getByRole("button"); + expect(toggleGroup).toHaveClass("shadow-[0_0_5px_1px_#3281F8]"); + }); + + it("handles selected state accessibility", () => { + render(Selected Toggle); + const toggleGroup = screen.getByRole("button"); + expect(toggleGroup).toHaveClass("bg-[var(--color-magenta-magenta100)]"); + expect(toggleGroup).toHaveClass( + "shadow-[inset_0_0_0_1px_var(--color-border-default-secondary)]" + ); + }); + + it("has no accessibility violations", async () => { + const { container } = render(Accessible Toggle); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it("has no accessibility violations when focused", async () => { + const { container } = render( + Focused Toggle + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it("has no accessibility violations when selected", async () => { + const { container } = render( + Selected Toggle + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it("has no accessibility violations with text", async () => { + const { container } = render( + Text Toggle + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it("has no accessibility violations without text", async () => { + const { container } = render( + + Icon Toggle + + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); +}); diff --git a/tests/integration/ToggleGroup.integration.test.jsx b/tests/integration/ToggleGroup.integration.test.jsx new file mode 100644 index 0000000..1802d3c --- /dev/null +++ b/tests/integration/ToggleGroup.integration.test.jsx @@ -0,0 +1,215 @@ +import React, { useState } from "react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import ToggleGroup from "../../app/components/ToggleGroup"; + +// Test component for form integration +const TestForm = () => { + const [selectedToggle, setSelectedToggle] = useState("left"); + + return ( +
+
+ setSelectedToggle("left")} + > + Left Option + + setSelectedToggle("middle")} + > + Middle Option + + setSelectedToggle("right")} + > + Right Option + +
+
+ ); +}; + +// Dynamic component for prop changes +const DynamicToggleGroup = ({ position, state, showText }) => { + return ( + + Dynamic Content + + ); +}; + +describe("ToggleGroup Integration", () => { + it("handles form submission", async () => { + const handleSubmit = vi.fn(); + render( +
+
+ {}}> + First Option + + {}}> + Second Option + + {}}> + Third Option + +
+ +
+ ); + + const submitButton = screen.getByRole("button", { name: "Submit" }); + fireEvent.click(submitButton); + expect(handleSubmit).toHaveBeenCalledTimes(1); + }); + + it("handles keyboard navigation between toggle groups", () => { + render(); + const toggleGroups = screen.getAllByRole("button"); + + // Focus first toggle group + toggleGroups[0].focus(); + expect(toggleGroups[0]).toHaveFocus(); + + // Test keyboard navigation + fireEvent.keyDown(toggleGroups[0], { key: "Tab" }); + // Note: Tab navigation behavior depends on browser implementation + }); + + it("handles dynamic prop changes", () => { + const { rerender } = render( + + ); + + let toggleGroup = screen.getByRole("button"); + expect(toggleGroup).toHaveClass( + "rounded-l-[var(--measures-radius-medium)]", + "rounded-r-none" + ); + expect(toggleGroup).toHaveTextContent("Dynamic Content"); + + rerender( + + ); + toggleGroup = screen.getByRole("button"); + expect(toggleGroup).toHaveClass("rounded-none"); + expect(toggleGroup).toHaveClass("bg-[var(--color-magenta-magenta100)]"); + expect(toggleGroup).toHaveTextContent("Dynamic Content"); + }); + + it("handles multiple toggle groups in form", () => { + render(); + const toggleGroups = screen.getAllByRole("button"); + expect(toggleGroups).toHaveLength(3); + + // Test clicking different toggle groups + fireEvent.click(toggleGroups[0]); + fireEvent.click(toggleGroups[1]); + fireEvent.click(toggleGroups[2]); + }); + + it("handles state changes", async () => { + const { rerender } = render(); + const toggleGroups = screen.getAllByRole("button"); + + // Initially, left should be selected + expect(toggleGroups[0]).toHaveClass("bg-[var(--color-magenta-magenta100)]"); + + // Click middle toggle + fireEvent.click(toggleGroups[1]); + await waitFor(() => { + expect(toggleGroups[1]).toHaveClass( + "bg-[var(--color-magenta-magenta100)]" + ); + }); + }); + + it("handles content changes", () => { + const { rerender } = render( + Initial Content + ); + + let toggleGroup = screen.getByRole("button"); + expect(toggleGroup).toHaveTextContent("Initial Content"); + + rerender(Updated Content); + toggleGroup = screen.getByRole("button"); + expect(toggleGroup).toHaveTextContent("Updated Content"); + + rerender(Hidden Content); + toggleGroup = screen.getByRole("button"); + expect(toggleGroup).toHaveTextContent("Hidden Content"); + }); + + it("handles performance with many toggle groups", () => { + const ManyToggleGroups = () => { + const [selected, setSelected] = useState(0); + + return ( +
+ {Array.from({ length: 10 }, (_, i) => ( + setSelected(i)} + > + Option {i + 1} + + ))} +
+ ); + }; + + render(); + const toggleGroups = screen.getAllByRole("button"); + expect(toggleGroups).toHaveLength(10); + + // Test clicking different toggle groups + fireEvent.click(toggleGroups[5]); + expect(toggleGroups[5]).toHaveClass("bg-[var(--color-magenta-magenta100)]"); + }); + + it("handles rapid state changes", async () => { + const { rerender } = render(); + const toggleGroups = screen.getAllByRole("button"); + + // Rapidly change states + for (let i = 0; i < 5; i++) { + fireEvent.click(toggleGroups[i % 3]); + await waitFor(() => { + expect(toggleGroups[i % 3]).toHaveClass( + "bg-[var(--color-magenta-magenta100)]" + ); + }); + } + }); + + it("handles mixed content types", () => { + render( +
+ + Text Only + + + Icon Only + + + Text Only + +
+ ); + + const toggleGroups = screen.getAllByRole("button"); + expect(toggleGroups[0]).toHaveTextContent("Text Only"); + expect(toggleGroups[1]).toHaveTextContent("Icon Only"); + expect(toggleGroups[2]).toHaveTextContent("Text Only"); + }); +}); diff --git a/tests/unit/ToggleGroup.test.jsx b/tests/unit/ToggleGroup.test.jsx new file mode 100644 index 0000000..a284398 --- /dev/null +++ b/tests/unit/ToggleGroup.test.jsx @@ -0,0 +1,213 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import ToggleGroup from "../../app/components/ToggleGroup"; + +describe("ToggleGroup Component", () => { + it("renders with default props", () => { + render(Test Content); + const toggleGroup = screen.getByRole("button"); + expect(toggleGroup).toBeInTheDocument(); + expect(toggleGroup).toHaveTextContent("Test Content"); + }); + + it("renders with custom props", () => { + render( + + Custom Content + + ); + const toggleGroup = screen.getByRole("button"); + expect(toggleGroup).toBeInTheDocument(); + expect(toggleGroup).toHaveTextContent("Custom Content"); + }); + + it("handles position prop correctly", () => { + const { rerender } = render( + Left + ); + let toggleGroup = screen.getByRole("button"); + expect(toggleGroup).toHaveClass( + "rounded-l-[var(--measures-radius-medium)]", + "rounded-r-none" + ); + + rerender(Middle); + toggleGroup = screen.getByRole("button"); + expect(toggleGroup).toHaveClass("rounded-none"); + + rerender(Right); + toggleGroup = screen.getByRole("button"); + expect(toggleGroup).toHaveClass( + "rounded-r-[var(--measures-radius-medium)]", + "rounded-l-none" + ); + }); + + it("handles state prop correctly", () => { + const { rerender } = render( + Default + ); + let toggleGroup = screen.getByRole("button"); + expect(toggleGroup).toHaveClass( + "bg-[var(--color-surface-default-primary)]" + ); + + rerender(Hover); + toggleGroup = screen.getByRole("button"); + expect(toggleGroup).toHaveClass("bg-[var(--color-magenta-magenta100)]"); + + rerender(Focus); + toggleGroup = screen.getByRole("button"); + expect(toggleGroup).toHaveClass( + "bg-[var(--color-surface-default-primary)]", + "shadow-[0_0_5px_1px_#3281F8]" + ); + + rerender(Selected); + toggleGroup = screen.getByRole("button"); + expect(toggleGroup).toHaveClass( + "bg-[var(--color-magenta-magenta100)]", + "shadow-[inset_0_0_0_1px_var(--color-border-default-secondary)]" + ); + }); + + it("handles showText prop correctly", () => { + const { rerender } = render( + Visible Text + ); + let toggleGroup = screen.getByRole("button"); + expect(toggleGroup).toHaveTextContent("Visible Text"); + + rerender(); + toggleGroup = screen.getByRole("button"); + expect(toggleGroup).toHaveTextContent("☰"); + }); + + it("calls onChange when clicked", () => { + const handleChange = vi.fn(); + render(Clickable); + const toggleGroup = screen.getByRole("button"); + + fireEvent.click(toggleGroup); + expect(handleChange).toHaveBeenCalledTimes(1); + }); + + it("calls onFocus when focused", () => { + const handleFocus = vi.fn(); + render(Focusable); + const toggleGroup = screen.getByRole("button"); + + fireEvent.focus(toggleGroup); + expect(handleFocus).toHaveBeenCalledTimes(1); + }); + + it("calls onBlur when blurred", () => { + const handleBlur = vi.fn(); + render(Blurable); + const toggleGroup = screen.getByRole("button"); + + fireEvent.blur(toggleGroup); + expect(handleBlur).toHaveBeenCalledTimes(1); + }); + + it("handles keyboard events correctly", () => { + const handleChange = vi.fn(); + render(Keyboard); + const toggleGroup = screen.getByRole("button"); + + // Test Enter key + fireEvent.keyDown(toggleGroup, { key: "Enter" }); + expect(handleChange).toHaveBeenCalledTimes(1); + + // Test Space key + fireEvent.keyDown(toggleGroup, { key: " " }); + expect(handleChange).toHaveBeenCalledTimes(2); + + // Test other key (should not trigger) + fireEvent.keyDown(toggleGroup, { key: "Escape" }); + expect(handleChange).toHaveBeenCalledTimes(2); + }); + + it("applies correct classes for different states", () => { + const { rerender } = render( + Default + ); + let toggleGroup = screen.getByRole("button"); + expect(toggleGroup).toHaveClass( + "bg-[var(--color-surface-default-primary)]" + ); + + rerender(Hover); + toggleGroup = screen.getByRole("button"); + expect(toggleGroup).toHaveClass("bg-[var(--color-magenta-magenta100)]"); + + rerender(Focus); + toggleGroup = screen.getByRole("button"); + expect(toggleGroup).toHaveClass("shadow-[0_0_5px_1px_#3281F8]"); + + rerender(Selected); + toggleGroup = screen.getByRole("button"); + expect(toggleGroup).toHaveClass( + "bg-[var(--color-magenta-magenta100)]", + "shadow-[inset_0_0_0_1px_var(--color-border-default-secondary)]" + ); + }); + + it("applies correct position classes", () => { + const { rerender } = render( + Left + ); + let toggleGroup = screen.getByRole("button"); + expect(toggleGroup).toHaveClass( + "rounded-l-[var(--measures-radius-medium)]", + "rounded-r-none" + ); + + rerender(Middle); + toggleGroup = screen.getByRole("button"); + expect(toggleGroup).toHaveClass("rounded-none"); + + rerender(Right); + toggleGroup = screen.getByRole("button"); + expect(toggleGroup).toHaveClass( + "rounded-r-[var(--measures-radius-medium)]", + "rounded-l-none" + ); + }); + + it("applies correct base classes", () => { + render(Base); + const toggleGroup = screen.getByRole("button"); + expect(toggleGroup).toHaveClass( + "py-[var(--measures-spacing-008)]", + "px-[var(--measures-spacing-008)]", + "gap-[var(--measures-spacing-008)]", + "font-inter", + "font-medium", + "text-[12px]", + "leading-[12px]", + "cursor-pointer", + "transition-all", + "duration-200", + "focus:outline-none", + "focus-visible:shadow-[0_0_5px_1px_#3281F8]", + "hover:bg-[var(--color-magenta-magenta100)]", + "flex", + "items-center", + "justify-center" + ); + }); + + it("forwards ref correctly", () => { + const ref = React.createRef(); + render(Ref Test); + expect(ref.current).toBeInstanceOf(HTMLButtonElement); + }); + + it("applies custom className", () => { + render(Custom); + const toggleGroup = screen.getByRole("button"); + expect(toggleGroup).toHaveClass("custom-class"); + }); +});