Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Next: Add Switch Component #2698

Merged
merged 21 commits into from
Jun 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/chilled-comics-hear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@skeletonlabs/skeleton-svelte": minor
"@skeletonlabs/skeleton-react": minor
---

Feature: Added the Switch component.
1 change: 1 addition & 0 deletions packages/skeleton-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"jsdom": "^24.0.0",
"lucide-react": "^0.341.0",
"postcss": "^8.4.38",
"react-router-dom": "^6.22.3",
"tailwindcss": "^3.4.3",
Expand Down
122 changes: 72 additions & 50 deletions packages/skeleton-react/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,57 +1,79 @@
import { Suspense } from "react";
import { Suspense, useState } from "react";
import { RouterProvider } from "react-router-dom";
import { router } from "./router.js";
import { Switch } from "./lib/index.js";
import { Moon as IconMoon, Sun as IconSun } from "lucide-react";

function App() {
return (
<div
className="h-screen grid grid-cols-[320px_minmax(0,_1fr)]"
data-testid="app"
>
{/* Nav */}
<div className="bg-surface-100-900 p-8 overflow-y-auto space-y-8">
<a
className="bg-blue-500 text-white p-2 type-scale-3 font-bold font-mono"
href="/"
>
skeleton-react
</a>
<hr className="hr" />
{/* Components */}
<div className="space-y-8">
<span className="font-bold">Components</span>
<nav className="flex flex-col gap-2 type-scale-2">
{/* <a className="anchor" href="/components/test">
Test
</a> */}
<a className="anchor" href="/components/accordions">
Accordions
</a>
<a className="anchor" href="/components/avatars">
Avatars
</a>
<a className="anchor" href="/components/app-bars">
App Bars
</a>
<a className="anchor" href="/components/progress">
Progress
</a>
<a className="anchor" href="/components/tabs">
Tabs
</a>
</nav>
</div>
</div>
{/* Page */}
<main className="p-8 overflow-y-auto">
{/* --- Route Slot --- */}
<Suspense fallback={<div>Loading...</div>}>
<RouterProvider router={router} />
</Suspense>
{/* --- / --- */}
</main>
</div>
);
const [lightswitch, setLightswitch] = useState(false);

function onModeChange(newValue: boolean) {
setLightswitch(newValue);
document.documentElement.classList.toggle("dark");
}

return (
<div
className="h-screen grid grid-cols-[320px_minmax(0,_1fr)]"
data-testid="app"
>
{/* Nav */}
<div className="bg-surface-100-900 p-8 overflow-y-auto space-y-8">
<a
className="bg-blue-500 text-white p-2 type-scale-3 font-bold font-mono"
href="/"
>
skeleton-react
</a>
<hr className="hr" />
<label className="label flex justify-between items-center gap-4">
<p>Set Mode</p>
<Switch
id="mode"
name="mode"
stateActive="bg-surface-200"
checked={lightswitch}
onCheckedChange={onModeChange}
inactiveChild={<IconMoon size="14" />}
activeChild={<IconSun size="14" />}
/>
</label>
<hr className="hr" />
{/* Components */}
<div className="space-y-8">
<span className="font-bold">Components</span>
<nav className="flex flex-col gap-2 type-scale-2">
<a className="anchor" href="/components/accordions">
Accordions
</a>
<a className="anchor" href="/components/avatars">
Avatars
</a>
<a className="anchor" href="/components/app-bars">
App Bars
</a>
<a className="anchor" href="/components/progress">
Progress
</a>
<a className="anchor" href="/components/switch">
Switch
</a>
<a className="anchor" href="/components/tabs">
Tabs
</a>
</nav>
</div>
</div>
{/* Page */}
<main className="p-8 overflow-y-auto">
{/* --- Route Slot --- */}
<Suspense fallback={<div>Loading...</div>}>
<RouterProvider router={router} />
</Suspense>
{/* --- / --- */}
</main>
</div>
);
}

export default App;
63 changes: 63 additions & 0 deletions packages/skeleton-react/src/lib/components/Switch/Switch.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { render } from "@testing-library/react";

import { Switch } from "./Switch.js";

describe("<Switch>", () => {
it("should render the component", () => {
const { getByTestId } = render(<Switch id="test" name="test" />);
const component = getByTestId("switch");
expect(component).toBeInTheDocument();
});

it("should render the component in the off state", () => {
const { getByTestId } = render(
<Switch id="test" name="test" checked={false} />
);
const component = getByTestId("switch");
const ariaChecked = component.getAttribute("aria-checked");
expect(ariaChecked).toBeFalsy;
});

it("should render the component in the on state", () => {
const { getByTestId } = render(
<Switch id="test" name="test" checked={true} />
);
const component = getByTestId("switch");
const ariaChecked = component.getAttribute("aria-checked");
expect(ariaChecked).toBeTruthy;
});

it("should render the component with an inactive icon", () => {
const testIcon = "iconOff";
const { getByTestId } = render(
<Switch id="test" name="test" checked={false} inactiveChild={testIcon} />
);
const component = getByTestId("switch");
const elemSpan = component.querySelector("div span");
expect(elemSpan).toHaveTextContent(testIcon);
});

it("should render the component with an active icon", () => {
const testIcon = "iconActive";
const { getByTestId } = render(
<Switch id="test" name="test" checked={false} inactiveChild={testIcon} />
);
const component = getByTestId("switch");
const elemSpan = component.querySelector("div span");
expect(elemSpan).toHaveTextContent(testIcon);
});

it("should render the component in the disabled state", () => {
const { getByTestId } = render(<Switch id="test" name="test" disabled />);
const component = getByTestId("switch");
expect(component).toHaveClass("opacity-50");
expect(component).toHaveClass("cursor-not-allowed");
});

it("should render the component in the compact mode", () => {
const { getByTestId } = render(<Switch id="test" name="test" compact />);
const component = getByTestId("switch");
expect(component).toHaveClass("aspect-square");
});
});
107 changes: 107 additions & 0 deletions packages/skeleton-react/src/lib/components/Switch/Switch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"use client";

import React from "react";
import { SwitchProps } from "./types.js";

export const Switch: React.FC<SwitchProps> = ({
id = "",
name = "",
checked = false,
disabled = false,
compact = false,
// Aria
labelledby = undefined,
describedby = undefined,
// Root (Track)
base = "flex cursor-pointer transition duration-200",
stateInactive = "preset-filled-surface-200-800",
stateActive = "preset-filled-primary-500",
stateDisabled = "opacity-50 cursor-not-allowed",
width = "w-10",
height = "h-6",
padding = "p-0.5",
rounded = "rounded-full",
hover = "hover:brightness-90 dark:hover:brightness-110",
classes = "",
// Thumb
thumbBase = "right-0 aspect-square h-full flex justify-center items-center text-right",
thumbInactive = "preset-filled-surface-50-950",
thumbActive = "bg-surface-50 text-surface-contrast-50",
thumbRounded = "rounded-full",
thumbTranslateX = "translate-x-4",
thumbTransition = "transition",
thumbEase = "ease-in-out",
thumbDuration = "duration-200",
thumbClasses = "",
// Icons
iconInactiveBase = "pointer-events-none",
iconActiveBase = "pointer-events-none",
// Events
onCheckedChange = () => {},
// Children
inactiveChild,
activeChild,
}) => {
// Set Compact Mode
if (compact) {
base = `${thumbBase} aspect-square`;
// Removes the height class
height = "";
// Thumb inherits track styles
thumbInactive = stateInactive;
thumbActive = stateActive;
// Remove X-axis translate
thumbTranslateX = "";
// Remove padding
padding = "";
}

function toggle() {
if (disabled) return;
checked = !checked;
onCheckedChange(checked);
}

const rxTrackState = checked ? stateActive : stateInactive;
const rxThumbState = checked
? `${thumbActive} ${thumbTranslateX}`
: thumbInactive;
const rxDisabled = disabled ? stateDisabled : "";

return (
<button
type="button"
className={`${base} ${rxTrackState} ${width} ${height} ${padding} ${rounded} ${hover} ${rxDisabled} ${classes}`}
role="switch"
aria-checked={checked}
aria-labelledby={labelledby}
aria-describedby={describedby}
onClick={toggle}
data-testid="switch"
>
{/* Input (hidden) */}
<input
type="checkbox"
id={id}
name={name}
checked={checked}
onChange={() => {}}
className="hidden"
disabled={disabled}
/>
{/* Thumb */}
<div
className={`${thumbBase} ${rxThumbState} ${thumbRounded} ${thumbTransition} ${thumbEase} ${thumbDuration} ${thumbClasses}`}
>
{/* Icon Inactive */}
{!checked && inactiveChild ? (
<span className={iconInactiveBase}>{inactiveChild}</span>
) : null}
{/* Icon Active */}
{checked && activeChild ? (
<span className={iconActiveBase}>{activeChild}</span>
) : null}
</div>
</button>
);
};
Loading