mirror of
https://github.com/penpot/penpot.git
synced 2026-05-01 14:18:07 +00:00
✨ Add Switch component to @penpot/ui
This commit is contained in:
parent
e33b820f98
commit
2991da47c3
@ -47,3 +47,5 @@ export type {
|
||||
HintMessageProps,
|
||||
HintMessageType,
|
||||
} from "./lib/controls/utilities/HintMessage";
|
||||
export { Switch } from "./lib/controls/Switch";
|
||||
export type { SwitchProps } from "./lib/controls/Switch";
|
||||
|
||||
108
frontend/packages/ui/src/lib/controls/Switch.module.scss
Normal file
108
frontend/packages/ui/src/lib/controls/Switch.module.scss
Normal file
@ -0,0 +1,108 @@
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) KALEIDOS INC
|
||||
|
||||
@use "../_ds/_borders.scss" as *;
|
||||
@use "../_ds/_utils.scss" as *;
|
||||
@use "../_ds/typography.scss" as t;
|
||||
|
||||
.switch {
|
||||
--switch-label-foreground-color: var(--color-foreground-primary);
|
||||
--switch-track-outline-color: none;
|
||||
--switch-track-shadow: inset 0 1px 2px var(--color-shadow-light);
|
||||
--switch-thumb-shadow: 0 1px 2px var(--color-shadow-light);
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
gap: var(--sp-s);
|
||||
inline-size: fit-content;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.switch.off {
|
||||
--switch-track-justify-content: start;
|
||||
--switch-track-background-color: var(--color-foreground-secondary);
|
||||
--switch-thumb-width: #{px2rem(14)};
|
||||
--switch-thumb-height: #{px2rem(14)};
|
||||
--switch-thumb-background-color: var(--color-accent-off);
|
||||
--switch-thumb-border-radius: #{$br-circle};
|
||||
}
|
||||
|
||||
.switch.neutral {
|
||||
--switch-track-justify-content: center;
|
||||
--switch-track-background-color: var(--color-accent-tertiary);
|
||||
--switch-thumb-width: #{px2rem(14)};
|
||||
--switch-thumb-height: #{px2rem(4)};
|
||||
--switch-thumb-background-color: var(--color-accent-off);
|
||||
--switch-thumb-border-radius: #{$br-8};
|
||||
}
|
||||
|
||||
.switch.on {
|
||||
--switch-track-justify-content: end;
|
||||
--switch-track-background-color: var(--color-accent-tertiary);
|
||||
--switch-thumb-width: #{px2rem(14)};
|
||||
--switch-thumb-height: #{px2rem(14)};
|
||||
--switch-thumb-background-color: var(--color-accent-off);
|
||||
--switch-thumb-border-radius: #{$br-circle};
|
||||
}
|
||||
|
||||
.switch[data-disabled] {
|
||||
pointer-events: none;
|
||||
|
||||
--switch-label-foreground-color: var(--color-foreground-secondary);
|
||||
--switch-track-shadow: none;
|
||||
--switch-thumb-shadow: none;
|
||||
}
|
||||
|
||||
.switch.off[data-disabled] {
|
||||
--switch-track-background-color: var(--color-background-primary);
|
||||
--switch-track-border-color: var(--color-background-disabled);
|
||||
--switch-thumb-background-color: var(--color-background-disabled);
|
||||
}
|
||||
|
||||
.switch.on[data-disabled],
|
||||
.switch.neutral[data-disabled] {
|
||||
--switch-track-background-color: var(--color-background-disabled);
|
||||
--switch-thumb-background-color: var(--color-background-primary);
|
||||
}
|
||||
|
||||
.switch:focus-visible {
|
||||
--switch-track-outline-color: var(--color-accent-primary);
|
||||
}
|
||||
|
||||
.switch:hover {
|
||||
--switch-thumb-background-color: var(--color-static-white);
|
||||
}
|
||||
|
||||
.switch-label {
|
||||
@include t.use-typography("body-small");
|
||||
|
||||
color: var(--switch-label-foreground-color);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.switch-track {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: var(--switch-track-justify-content);
|
||||
inline-size: px2rem(30);
|
||||
block-size: px2rem(18);
|
||||
padding: var(--sp-xxs);
|
||||
border-radius: $br-12;
|
||||
border: $b-1 solid var(--switch-track-border-color);
|
||||
outline: $b-1 solid var(--switch-track-outline-color);
|
||||
outline-offset: $b-1;
|
||||
box-shadow: var(--switch-track-shadow);
|
||||
background-color: var(--switch-track-background-color);
|
||||
}
|
||||
|
||||
.switch-thumb {
|
||||
inline-size: var(--switch-thumb-width);
|
||||
block-size: var(--switch-thumb-height);
|
||||
border-radius: var(--switch-thumb-border-radius);
|
||||
box-shadow: var(--switch-thumb-shadow);
|
||||
background-color: var(--switch-thumb-background-color);
|
||||
}
|
||||
138
frontend/packages/ui/src/lib/controls/Switch.spec.tsx
Normal file
138
frontend/packages/ui/src/lib/controls/Switch.spec.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) KALEIDOS INC
|
||||
|
||||
import { fireEvent, render } from "@testing-library/react";
|
||||
import { Switch } from "./Switch";
|
||||
|
||||
describe("Switch", () => {
|
||||
it("should render successfully", () => {
|
||||
const { baseElement } = render(<Switch />);
|
||||
expect(baseElement).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should render with role=switch", () => {
|
||||
const { container } = render(<Switch />);
|
||||
const el = container.querySelector("[role='switch']");
|
||||
expect(el).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should reflect aria-checked=false when defaultChecked is false", () => {
|
||||
const { container } = render(<Switch defaultChecked={false} />);
|
||||
const el = container.querySelector("[role='switch']");
|
||||
expect(el?.getAttribute("aria-checked")).toBe("false");
|
||||
});
|
||||
|
||||
it("should reflect aria-checked=true when defaultChecked is true", () => {
|
||||
const { container } = render(<Switch defaultChecked={true} />);
|
||||
const el = container.querySelector("[role='switch']");
|
||||
expect(el?.getAttribute("aria-checked")).toBe("true");
|
||||
});
|
||||
|
||||
it("should toggle from false to true on click", () => {
|
||||
const onChange = vi.fn();
|
||||
const { container } = render(
|
||||
<Switch defaultChecked={false} onChange={onChange} />,
|
||||
);
|
||||
const el = container.querySelector("[role='switch']") as HTMLElement;
|
||||
fireEvent.click(el);
|
||||
expect(onChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("should toggle from true to false on click", () => {
|
||||
const onChange = vi.fn();
|
||||
const { container } = render(
|
||||
<Switch defaultChecked={true} onChange={onChange} />,
|
||||
);
|
||||
const el = container.querySelector("[role='switch']") as HTMLElement;
|
||||
fireEvent.click(el);
|
||||
expect(onChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("should toggle on Space key", () => {
|
||||
const onChange = vi.fn();
|
||||
const { container } = render(
|
||||
<Switch defaultChecked={false} onChange={onChange} />,
|
||||
);
|
||||
const el = container.querySelector("[role='switch']") as HTMLElement;
|
||||
fireEvent.keyDown(el, { key: " " });
|
||||
expect(onChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("should toggle on Enter key", () => {
|
||||
const onChange = vi.fn();
|
||||
const { container } = render(
|
||||
<Switch defaultChecked={false} onChange={onChange} />,
|
||||
);
|
||||
const el = container.querySelector("[role='switch']") as HTMLElement;
|
||||
fireEvent.keyDown(el, { key: "Enter" });
|
||||
expect(onChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("should not toggle when disabled", () => {
|
||||
const onChange = vi.fn();
|
||||
const { container } = render(
|
||||
<Switch defaultChecked={false} disabled onChange={onChange} />,
|
||||
);
|
||||
const el = container.querySelector("[role='switch']") as HTMLElement;
|
||||
fireEvent.click(el);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should set tabIndex=-1 when disabled", () => {
|
||||
const { container } = render(<Switch disabled />);
|
||||
const el = container.querySelector("[role='switch']");
|
||||
expect(el?.getAttribute("tabindex")).toBe("-1");
|
||||
});
|
||||
|
||||
it("should set aria-disabled when disabled", () => {
|
||||
const { container } = render(<Switch disabled />);
|
||||
const el = container.querySelector("[role='switch']");
|
||||
expect(el?.getAttribute("aria-disabled")).toBe("true");
|
||||
});
|
||||
|
||||
it("should set tabIndex=0 when not disabled", () => {
|
||||
const { container } = render(<Switch />);
|
||||
const el = container.querySelector("[role='switch']");
|
||||
expect(el?.getAttribute("tabindex")).toBe("0");
|
||||
});
|
||||
|
||||
it("should render label when provided", () => {
|
||||
const { container } = render(<Switch label="Toggle" />);
|
||||
const lbl = container.querySelector("label");
|
||||
expect(lbl?.textContent).toBe("Toggle");
|
||||
});
|
||||
|
||||
it("should not render label element when label is not provided", () => {
|
||||
const { container } = render(<Switch />);
|
||||
expect(container.querySelector("label")).toBeNull();
|
||||
});
|
||||
|
||||
it("should use aria-label when no visible label", () => {
|
||||
const { container } = render(<Switch aria-label="Toggle feature" />);
|
||||
const el = container.querySelector("[role='switch']");
|
||||
expect(el?.getAttribute("aria-label")).toBe("Toggle feature");
|
||||
});
|
||||
|
||||
it("should not set aria-label on root when label prop is present", () => {
|
||||
const { container } = render(
|
||||
<Switch label="Toggle" aria-label="Toggle feature" />,
|
||||
);
|
||||
const el = container.querySelector("[role='switch']");
|
||||
expect(el?.getAttribute("aria-label")).toBeNull();
|
||||
});
|
||||
|
||||
it("should merge className", () => {
|
||||
const { container } = render(<Switch className="extra" />);
|
||||
const el = container.querySelector("[role='switch']");
|
||||
expect(el?.getAttribute("class")).toContain("extra");
|
||||
});
|
||||
|
||||
it("should pass through HTML attributes", () => {
|
||||
const { container } = render(<Switch data-testid="sw" />);
|
||||
const el = container.querySelector("[role='switch']");
|
||||
expect(el?.getAttribute("data-testid")).toBe("sw");
|
||||
});
|
||||
});
|
||||
45
frontend/packages/ui/src/lib/controls/Switch.stories.tsx
Normal file
45
frontend/packages/ui/src/lib/controls/Switch.stories.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) KALEIDOS INC
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Switch } from "./Switch";
|
||||
|
||||
const meta = {
|
||||
title: "Controls/Switch",
|
||||
component: Switch,
|
||||
args: {
|
||||
label: "Enable feature",
|
||||
},
|
||||
} satisfies Meta<typeof Switch>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const On: Story = {
|
||||
args: { defaultChecked: true },
|
||||
};
|
||||
|
||||
export const Off: Story = {
|
||||
args: { defaultChecked: false },
|
||||
};
|
||||
|
||||
export const Neutral: Story = {
|
||||
args: { defaultChecked: null },
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: { disabled: true, defaultChecked: false },
|
||||
};
|
||||
|
||||
export const DisabledOn: Story = {
|
||||
args: { disabled: true, defaultChecked: true },
|
||||
};
|
||||
|
||||
export const NoLabel: Story = {
|
||||
args: { label: undefined, "aria-label": "Toggle feature" },
|
||||
};
|
||||
110
frontend/packages/ui/src/lib/controls/Switch.tsx
Normal file
110
frontend/packages/ui/src/lib/controls/Switch.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) KALEIDOS INC
|
||||
|
||||
import {
|
||||
type ComponentPropsWithRef,
|
||||
type KeyboardEvent,
|
||||
memo,
|
||||
useCallback,
|
||||
useId,
|
||||
useState,
|
||||
} from "react";
|
||||
import clsx from "clsx";
|
||||
import styles from "./Switch.module.scss";
|
||||
|
||||
export interface SwitchProps extends Omit<
|
||||
ComponentPropsWithRef<"div">,
|
||||
"onChange" | "defaultChecked"
|
||||
> {
|
||||
/** Text label rendered next to the track */
|
||||
label?: string;
|
||||
/** Accessible label used when there is no visible label */
|
||||
"aria-label"?: string;
|
||||
/** Initial checked state (uncontrolled). null = neutral/indeterminate. */
|
||||
defaultChecked?: boolean | null;
|
||||
/** Called with the new boolean state after each toggle */
|
||||
onChange?: (checked: boolean) => void;
|
||||
/** When true the switch cannot be interacted with */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function SwitchInner(
|
||||
{
|
||||
label,
|
||||
"aria-label": ariaLabel,
|
||||
defaultChecked = null,
|
||||
onChange,
|
||||
disabled = false,
|
||||
className,
|
||||
...rest
|
||||
}: SwitchProps,
|
||||
ref: React.Ref<HTMLDivElement>,
|
||||
) {
|
||||
const [checked, setChecked] = useState<boolean | null>(
|
||||
defaultChecked ?? null,
|
||||
);
|
||||
const trackId = useId();
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
if (disabled) return;
|
||||
const next = !checked;
|
||||
setChecked(next);
|
||||
onChange?.(next);
|
||||
}, [checked, disabled, onChange]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === " " || event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
handleToggle();
|
||||
}
|
||||
},
|
||||
[handleToggle],
|
||||
);
|
||||
|
||||
const hasLabel = label != null && label.trim().length > 0;
|
||||
|
||||
const rootClass = clsx(
|
||||
styles.switch,
|
||||
{
|
||||
[styles.off]: checked === false,
|
||||
[styles.neutral]: checked === null,
|
||||
[styles.on]: checked === true,
|
||||
},
|
||||
className,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="switch"
|
||||
aria-label={hasLabel ? undefined : ariaLabel}
|
||||
aria-checked={checked ?? false}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
className={rootClass}
|
||||
onClick={handleToggle}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-disabled={disabled || undefined}
|
||||
data-disabled={disabled || undefined}
|
||||
{...rest}
|
||||
>
|
||||
<div id={trackId} className={styles["switch-track"]}>
|
||||
<div className={styles["switch-thumb"]} />
|
||||
</div>
|
||||
{hasLabel && (
|
||||
<label htmlFor={trackId} className={styles["switch-label"]}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Switch = memo(
|
||||
SwitchInner as (
|
||||
props: SwitchProps & { ref?: React.Ref<HTMLDivElement> },
|
||||
) => React.ReactElement | null,
|
||||
);
|
||||
Loading…
x
Reference in New Issue
Block a user