Skip to content

Commit 5a2f94b

Browse files
authored
Next: Add Switch Component (#2698)
1 parent beede84 commit 5a2f94b

34 files changed

+7721
-8048
lines changed

.changeset/chilled-comics-hear.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@skeletonlabs/skeleton-svelte": minor
3+
"@skeletonlabs/skeleton-react": minor
4+
---
5+
6+
Feature: Added the Switch component.

packages/skeleton-react/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
"eslint-plugin-react-hooks": "^4.6.0",
6868
"eslint-plugin-react-refresh": "^0.4.6",
6969
"jsdom": "^24.0.0",
70+
"lucide-react": "^0.341.0",
7071
"postcss": "^8.4.38",
7172
"react-router-dom": "^6.22.3",
7273
"tailwindcss": "^3.4.3",

packages/skeleton-react/src/App.tsx

+72-50
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,79 @@
1-
import { Suspense } from "react";
1+
import { Suspense, useState } from "react";
22
import { RouterProvider } from "react-router-dom";
33
import { router } from "./router.js";
4+
import { Switch } from "./lib/index.js";
5+
import { Moon as IconMoon, Sun as IconSun } from "lucide-react";
46

57
function App() {
6-
return (
7-
<div
8-
className="h-screen grid grid-cols-[320px_minmax(0,_1fr)]"
9-
data-testid="app"
10-
>
11-
{/* Nav */}
12-
<div className="bg-surface-100-900 p-8 overflow-y-auto space-y-8">
13-
<a
14-
className="bg-blue-500 text-white p-2 type-scale-3 font-bold font-mono"
15-
href="/"
16-
>
17-
skeleton-react
18-
</a>
19-
<hr className="hr" />
20-
{/* Components */}
21-
<div className="space-y-8">
22-
<span className="font-bold">Components</span>
23-
<nav className="flex flex-col gap-2 type-scale-2">
24-
{/* <a className="anchor" href="/components/test">
25-
Test
26-
</a> */}
27-
<a className="anchor" href="/components/accordions">
28-
Accordions
29-
</a>
30-
<a className="anchor" href="/components/avatars">
31-
Avatars
32-
</a>
33-
<a className="anchor" href="/components/app-bars">
34-
App Bars
35-
</a>
36-
<a className="anchor" href="/components/progress">
37-
Progress
38-
</a>
39-
<a className="anchor" href="/components/tabs">
40-
Tabs
41-
</a>
42-
</nav>
43-
</div>
44-
</div>
45-
{/* Page */}
46-
<main className="p-8 overflow-y-auto">
47-
{/* --- Route Slot --- */}
48-
<Suspense fallback={<div>Loading...</div>}>
49-
<RouterProvider router={router} />
50-
</Suspense>
51-
{/* --- / --- */}
52-
</main>
53-
</div>
54-
);
8+
const [lightswitch, setLightswitch] = useState(false);
9+
10+
function onModeChange(newValue: boolean) {
11+
setLightswitch(newValue);
12+
document.documentElement.classList.toggle("dark");
13+
}
14+
15+
return (
16+
<div
17+
className="h-screen grid grid-cols-[320px_minmax(0,_1fr)]"
18+
data-testid="app"
19+
>
20+
{/* Nav */}
21+
<div className="bg-surface-100-900 p-8 overflow-y-auto space-y-8">
22+
<a
23+
className="bg-blue-500 text-white p-2 type-scale-3 font-bold font-mono"
24+
href="/"
25+
>
26+
skeleton-react
27+
</a>
28+
<hr className="hr" />
29+
<label className="label flex justify-between items-center gap-4">
30+
<p>Set Mode</p>
31+
<Switch
32+
id="mode"
33+
name="mode"
34+
stateActive="bg-surface-200"
35+
checked={lightswitch}
36+
onCheckedChange={onModeChange}
37+
inactiveChild={<IconMoon size="14" />}
38+
activeChild={<IconSun size="14" />}
39+
/>
40+
</label>
41+
<hr className="hr" />
42+
{/* Components */}
43+
<div className="space-y-8">
44+
<span className="font-bold">Components</span>
45+
<nav className="flex flex-col gap-2 type-scale-2">
46+
<a className="anchor" href="/components/accordions">
47+
Accordions
48+
</a>
49+
<a className="anchor" href="/components/avatars">
50+
Avatars
51+
</a>
52+
<a className="anchor" href="/components/app-bars">
53+
App Bars
54+
</a>
55+
<a className="anchor" href="/components/progress">
56+
Progress
57+
</a>
58+
<a className="anchor" href="/components/switch">
59+
Switch
60+
</a>
61+
<a className="anchor" href="/components/tabs">
62+
Tabs
63+
</a>
64+
</nav>
65+
</div>
66+
</div>
67+
{/* Page */}
68+
<main className="p-8 overflow-y-auto">
69+
{/* --- Route Slot --- */}
70+
<Suspense fallback={<div>Loading...</div>}>
71+
<RouterProvider router={router} />
72+
</Suspense>
73+
{/* --- / --- */}
74+
</main>
75+
</div>
76+
);
5577
}
5678

5779
export default App;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { describe, expect, it } from "vitest";
2+
import { render } from "@testing-library/react";
3+
4+
import { Switch } from "./Switch.js";
5+
6+
describe("<Switch>", () => {
7+
it("should render the component", () => {
8+
const { getByTestId } = render(<Switch id="test" name="test" />);
9+
const component = getByTestId("switch");
10+
expect(component).toBeInTheDocument();
11+
});
12+
13+
it("should render the component in the off state", () => {
14+
const { getByTestId } = render(
15+
<Switch id="test" name="test" checked={false} />
16+
);
17+
const component = getByTestId("switch");
18+
const ariaChecked = component.getAttribute("aria-checked");
19+
expect(ariaChecked).toBeFalsy;
20+
});
21+
22+
it("should render the component in the on state", () => {
23+
const { getByTestId } = render(
24+
<Switch id="test" name="test" checked={true} />
25+
);
26+
const component = getByTestId("switch");
27+
const ariaChecked = component.getAttribute("aria-checked");
28+
expect(ariaChecked).toBeTruthy;
29+
});
30+
31+
it("should render the component with an inactive icon", () => {
32+
const testIcon = "iconOff";
33+
const { getByTestId } = render(
34+
<Switch id="test" name="test" checked={false} inactiveChild={testIcon} />
35+
);
36+
const component = getByTestId("switch");
37+
const elemSpan = component.querySelector("div span");
38+
expect(elemSpan).toHaveTextContent(testIcon);
39+
});
40+
41+
it("should render the component with an active icon", () => {
42+
const testIcon = "iconActive";
43+
const { getByTestId } = render(
44+
<Switch id="test" name="test" checked={false} inactiveChild={testIcon} />
45+
);
46+
const component = getByTestId("switch");
47+
const elemSpan = component.querySelector("div span");
48+
expect(elemSpan).toHaveTextContent(testIcon);
49+
});
50+
51+
it("should render the component in the disabled state", () => {
52+
const { getByTestId } = render(<Switch id="test" name="test" disabled />);
53+
const component = getByTestId("switch");
54+
expect(component).toHaveClass("opacity-50");
55+
expect(component).toHaveClass("cursor-not-allowed");
56+
});
57+
58+
it("should render the component in the compact mode", () => {
59+
const { getByTestId } = render(<Switch id="test" name="test" compact />);
60+
const component = getByTestId("switch");
61+
expect(component).toHaveClass("aspect-square");
62+
});
63+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"use client";
2+
3+
import React from "react";
4+
import { SwitchProps } from "./types.js";
5+
6+
export const Switch: React.FC<SwitchProps> = ({
7+
id = "",
8+
name = "",
9+
checked = false,
10+
disabled = false,
11+
compact = false,
12+
// Aria
13+
labelledby = undefined,
14+
describedby = undefined,
15+
// Root (Track)
16+
base = "flex cursor-pointer transition duration-200",
17+
stateInactive = "preset-filled-surface-200-800",
18+
stateActive = "preset-filled-primary-500",
19+
stateDisabled = "opacity-50 cursor-not-allowed",
20+
width = "w-10",
21+
height = "h-6",
22+
padding = "p-0.5",
23+
rounded = "rounded-full",
24+
hover = "hover:brightness-90 dark:hover:brightness-110",
25+
classes = "",
26+
// Thumb
27+
thumbBase = "right-0 aspect-square h-full flex justify-center items-center text-right",
28+
thumbInactive = "preset-filled-surface-50-950",
29+
thumbActive = "bg-surface-50 text-surface-contrast-50",
30+
thumbRounded = "rounded-full",
31+
thumbTranslateX = "translate-x-4",
32+
thumbTransition = "transition",
33+
thumbEase = "ease-in-out",
34+
thumbDuration = "duration-200",
35+
thumbClasses = "",
36+
// Icons
37+
iconInactiveBase = "pointer-events-none",
38+
iconActiveBase = "pointer-events-none",
39+
// Events
40+
onCheckedChange = () => {},
41+
// Children
42+
inactiveChild,
43+
activeChild,
44+
}) => {
45+
// Set Compact Mode
46+
if (compact) {
47+
base = `${thumbBase} aspect-square`;
48+
// Removes the height class
49+
height = "";
50+
// Thumb inherits track styles
51+
thumbInactive = stateInactive;
52+
thumbActive = stateActive;
53+
// Remove X-axis translate
54+
thumbTranslateX = "";
55+
// Remove padding
56+
padding = "";
57+
}
58+
59+
function toggle() {
60+
if (disabled) return;
61+
checked = !checked;
62+
onCheckedChange(checked);
63+
}
64+
65+
const rxTrackState = checked ? stateActive : stateInactive;
66+
const rxThumbState = checked
67+
? `${thumbActive} ${thumbTranslateX}`
68+
: thumbInactive;
69+
const rxDisabled = disabled ? stateDisabled : "";
70+
71+
return (
72+
<button
73+
type="button"
74+
className={`${base} ${rxTrackState} ${width} ${height} ${padding} ${rounded} ${hover} ${rxDisabled} ${classes}`}
75+
role="switch"
76+
aria-checked={checked}
77+
aria-labelledby={labelledby}
78+
aria-describedby={describedby}
79+
onClick={toggle}
80+
data-testid="switch"
81+
>
82+
{/* Input (hidden) */}
83+
<input
84+
type="checkbox"
85+
id={id}
86+
name={name}
87+
checked={checked}
88+
onChange={() => {}}
89+
className="hidden"
90+
disabled={disabled}
91+
/>
92+
{/* Thumb */}
93+
<div
94+
className={`${thumbBase} ${rxThumbState} ${thumbRounded} ${thumbTransition} ${thumbEase} ${thumbDuration} ${thumbClasses}`}
95+
>
96+
{/* Icon Inactive */}
97+
{!checked && inactiveChild ? (
98+
<span className={iconInactiveBase}>{inactiveChild}</span>
99+
) : null}
100+
{/* Icon Active */}
101+
{checked && activeChild ? (
102+
<span className={iconActiveBase}>{activeChild}</span>
103+
) : null}
104+
</div>
105+
</button>
106+
);
107+
};

0 commit comments

Comments
 (0)