Skip to content
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,8 @@ If you have any ideas or suggestions, please let me know in [Github Issues](http
- [ ] Table
- [x] Tabs
- [x] Toast
- [ ] Toggle
- [ ] Toggle Group
- [x] Toggle
- [x] Toggle Group
- [x] Tooltip
- CLI
- [x] Add
Expand Down
190 changes: 190 additions & 0 deletions packages/react/components/ToggleGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { type VariantProps, vcn } from "@pswui-lib";
import React from "react";

import { Toggle, type ToggleProps } from "./Toggle";

const [toggleGroupVariants, resolveToggleGroupVariantProps] = vcn({
base: "flex w-fit gap-2",
variants: {
orientation: {
horizontal: "flex-row flex-wrap items-center",
vertical: "flex-col items-start",
},
},
defaults: {
orientation: "horizontal",
},
});

interface ToggleGroupContextValue {
disabled?: boolean;
isItemPressed: (value: string) => boolean;
onItemPressedChange: (value: string, pressed: boolean) => void;
size?: ToggleProps["size"];
}

const ToggleGroupContext = React.createContext<ToggleGroupContextValue | null>(
null,
);

const useToggleGroupContext = () => {
const context = React.useContext(ToggleGroupContext);

if (!context) {
throw new Error("ToggleGroupItem must be used within ToggleGroup.");
}

return context;
};

interface ToggleGroupSharedProps
extends VariantProps<typeof toggleGroupVariants>,
Omit<
React.ComponentPropsWithoutRef<"div">,
"children" | "className" | "defaultValue" | "onChange" | "value"
> {
children?: React.ReactNode;
disabled?: boolean;
size?: ToggleProps["size"];
}

interface ToggleGroupSingleProps extends ToggleGroupSharedProps {
defaultValue?: string;
onValueChange?: (value: string | undefined) => void;
type?: "single";
value?: string;
}

interface ToggleGroupMultipleProps extends ToggleGroupSharedProps {
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
type: "multiple";
value?: string[];
}

type ToggleGroupProps = ToggleGroupSingleProps | ToggleGroupMultipleProps;

const ToggleGroup = React.forwardRef<HTMLDivElement, ToggleGroupProps>(
(props, ref) => {
const [variantProps, otherPropsCompressed] =
resolveToggleGroupVariantProps(props);
const {
children,
defaultValue: _defaultValue,
disabled,
onValueChange: _onValueChange,
role,
size,
type = "single",
value: _value,
...otherPropsExtracted
} = otherPropsCompressed;

const isMultiple = type === "multiple";
const singleProps = props as ToggleGroupSingleProps;
const multipleProps = props as ToggleGroupMultipleProps;

const defaultValue = isMultiple
? multipleProps.defaultValue ?? []
: singleProps.defaultValue;
const propValue = isMultiple ? multipleProps.value : singleProps.value;
const [uncontrolledValue, setUncontrolledValue] = React.useState<
string | string[] | undefined
>(defaultValue);
const isControlled = Object.prototype.hasOwnProperty.call(props, "value");
const value = isControlled ? propValue : uncontrolledValue;

const setValue = (nextValue: string | string[] | undefined) => {
if (!isControlled) {
setUncontrolledValue(nextValue);
}

if (isMultiple) {
multipleProps.onValueChange?.(
Array.isArray(nextValue) ? nextValue : [],
);
return;
}

singleProps.onValueChange?.(
typeof nextValue === "string" ? nextValue : undefined,
);
};

const contextValue = {
disabled,
isItemPressed: (itemValue: string) => {
if (isMultiple) {
return Array.isArray(value) ? value.includes(itemValue) : false;
}

return value === itemValue;
},
onItemPressedChange: (itemValue: string, pressed: boolean) => {
if (isMultiple) {
const currentValue = Array.isArray(value) ? value : [];
const nextValue = pressed
? [...currentValue, itemValue]
: currentValue.filter(
(currentItemValue) => currentItemValue !== itemValue,
);

setValue(nextValue);
return;
}

setValue(pressed ? itemValue : undefined);
},
size,
} satisfies ToggleGroupContextValue;

const orientation = variantProps.orientation ?? "horizontal";

return (
<ToggleGroupContext.Provider value={contextValue}>
<div
{...otherPropsExtracted}
ref={ref}
role={role ?? "toolbar"}
aria-disabled={disabled ? true : undefined}
aria-orientation={orientation}
className={toggleGroupVariants(variantProps)}
>
{children}
</div>
</ToggleGroupContext.Provider>
);
},
);
ToggleGroup.displayName = "ToggleGroup";

interface ToggleGroupItemProps
extends Omit<ToggleProps, "defaultPressed" | "pressed"> {
value: string;
}

const ToggleGroupItem = React.forwardRef<
HTMLButtonElement,
ToggleGroupItemProps
>((props, ref) => {
const { disabled, onPressedChange, size, value, ...otherPropsExtracted } =
props;
const group = useToggleGroupContext();

return (
<Toggle
{...otherPropsExtracted}
ref={ref}
disabled={group.disabled || disabled}
pressed={group.isItemPressed(value)}
size={size ?? group.size}
onPressedChange={(pressed) => {
group.onItemPressedChange(value, pressed);
onPressedChange?.(pressed);
}}
/>
);
});
ToggleGroupItem.displayName = "ToggleGroupItem";

export { ToggleGroup, ToggleGroupItem };
55 changes: 55 additions & 0 deletions packages/react/tests/harness/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ import {
import { Textarea, TextareaFrame } from "../../components/Textarea";
import { Toaster, useToast } from "../../components/Toast";
import { Toggle } from "../../components/Toggle";
import { ToggleGroup, ToggleGroupItem } from "../../components/ToggleGroup";
import { Tooltip, TooltipContent } from "../../components/Tooltip";

const avatarImageSrc = "/avatar-demo.svg";
Expand Down Expand Up @@ -795,6 +796,59 @@ const ToggleShowcase = () => {
);
};

const ToggleGroupShowcase = () => {
const [alignment, setAlignment] = React.useState<string | undefined>(
"center",
);
const [formats, setFormats] = React.useState<string[]>(["bold"]);
const alignmentState = alignment ?? "none";
const formatState = formats.join(",") || "none";

return (
<Section
testId="toggle-group"
title="Toggle Group"
description="Single and multiple selection with disabled and orientation semantics."
>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3">
<ToggleGroup
aria-label="Text alignment"
value={alignment}
onValueChange={setAlignment}
>
<ToggleGroupItem value="left">Left</ToggleGroupItem>
<ToggleGroupItem value="center">Center</ToggleGroupItem>
<ToggleGroupItem value="right">Right</ToggleGroupItem>
<ToggleGroupItem
value="justify"
disabled
>
Justify
</ToggleGroupItem>
</ToggleGroup>
<span data-testid="toggle-group-single-state">{alignmentState}</span>
</div>

<div className="flex flex-col gap-3">
<ToggleGroup
type="multiple"
aria-label="Text format"
orientation="vertical"
defaultValue={["bold"]}
onValueChange={setFormats}
>
<ToggleGroupItem value="bold">Bold</ToggleGroupItem>
<ToggleGroupItem value="italic">Italic</ToggleGroupItem>
<ToggleGroupItem value="underline">Underline</ToggleGroupItem>
</ToggleGroup>
<span data-testid="toggle-group-multiple-state">{formatState}</span>
</div>
</div>
</Section>
);
};

const TabsShowcase = () => {
const [submitCount, setSubmitCount] = React.useState(0);

Expand Down Expand Up @@ -921,6 +975,7 @@ const showcases = [
SwitchShowcase,
TableShowcase,
ToggleShowcase,
ToggleGroupShowcase,
TabsShowcase,
ToastShowcase,
TooltipShowcase,
Expand Down
64 changes: 64 additions & 0 deletions packages/react/tests/toggle-group.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { expect, test } from "@playwright/test";

import { gotoHarness } from "./helpers";

test("toggle group updates controlled single selection", async ({ page }) => {
await gotoHarness(page);

const section = page.getByTestId("toggle-group-section");
const group = section.getByRole("toolbar", { name: "Text alignment" });
const center = group.getByRole("button", { name: "Center" });
const right = group.getByRole("button", { name: "Right" });

await expect(center).toHaveAttribute("aria-pressed", "true");
await expect(right).toHaveAttribute("aria-pressed", "false");

await right.click();

await expect(right).toHaveAttribute("aria-pressed", "true");
await expect(center).toHaveAttribute("aria-pressed", "false");
await expect(section.getByTestId("toggle-group-single-state")).toHaveText(
"right",
);
});

test("toggle group supports uncontrolled multiple selection", async ({
page,
}) => {
await gotoHarness(page);

const section = page.getByTestId("toggle-group-section");
const group = section.getByRole("toolbar", { name: "Text format" });
const bold = group.getByRole("button", { name: "Bold" });
const italic = group.getByRole("button", { name: "Italic" });

await expect(bold).toHaveAttribute("aria-pressed", "true");
await expect(group).toHaveAttribute("aria-orientation", "vertical");

await italic.click();
await expect(section.getByTestId("toggle-group-multiple-state")).toHaveText(
"bold,italic",
);

await bold.click();
await expect(bold).toHaveAttribute("aria-pressed", "false");
await expect(section.getByTestId("toggle-group-multiple-state")).toHaveText(
"italic",
);
});

test("toggle group keeps disabled items non-interactive", async ({ page }) => {
await gotoHarness(page);

const section = page.getByTestId("toggle-group-section");
const group = section.getByRole("toolbar", { name: "Text alignment" });
const disabled = group.getByRole("button", { name: "Justify" });

await expect(disabled).toBeDisabled();
await disabled.click({ force: true });

await expect(disabled).toHaveAttribute("aria-pressed", "false");
await expect(section.getByTestId("toggle-group-single-state")).toHaveText(
"center",
);
});
Loading