Skip to content

Commit

Permalink
feat: add radio menu item
Browse files Browse the repository at this point in the history
  • Loading branch information
florianduros committed Feb 21, 2025
1 parent 98dccbf commit cc1ee91
Show file tree
Hide file tree
Showing 5 changed files with 194 additions and 0 deletions.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
61 changes: 61 additions & 0 deletions src/components/Menu/RadioMenuItem.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
* Copyright 2025 New Vector Ltd
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/

import React, { useState } from "react";
import { RadioMenuItem as RadioMenuItemComponent } from "./RadioMenuItem.tsx";
import { Meta, StoryObj } from "@storybook/react";

type Props = Omit<
React.ComponentProps<typeof RadioMenuItemComponent>,
"Icon" | "label" | "checked" | "onSelect" | "name"
>;

const Template: React.FC<Props> = (props: Props) => {
const [selected, setSelected] = useState<"first" | "second">("first");
return (
<div style={{ width: 300 }}>
<RadioMenuItemComponent
{...props}
label="First item"
checked={selected === "first"}
onSelect={(e) => {
e.preventDefault();
setSelected("first");
}}
/>
<RadioMenuItemComponent
{...props}
label="Second item"
checked={selected === "second"}
onSelect={(e) => {
e.preventDefault();
setSelected("second");
}}
/>
<RadioMenuItemComponent
{...props}
label="Third item with a name that's quite long"
checked
disabled
onSelect={() => {}}
/>
</div>
);
};

const meta = {
title: "Menu/RadioMenuItem",
component: Template,
tags: ["autodocs"],
argTypes: {},
args: {},
} satisfies Meta<typeof Template>;
export default meta;

type Story = StoryObj<typeof meta>;

export const Primary: Story = { args: {} };
40 changes: 40 additions & 0 deletions src/components/Menu/RadioMenuItem.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* Copyright 2025 New Vector Ltd
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/

import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { RadioMenuItem } from "./RadioMenuItem.tsx";
import userEvent from "@testing-library/user-event";
import React from "react";

describe("RadioMenuItem", () => {
it("renders", () => {
const { asFragment } = render(
<RadioMenuItem label="Always show" checked={false} onSelect={() => {}} />,
);
expect(asFragment()).toMatchSnapshot();
});

it("toggles", async () => {
const user = userEvent.setup();
const toggle = vi.fn();
render(
<RadioMenuItem label="Always show" checked={false} onSelect={toggle} />,
);

// Try toggling using keyboard controls
await user.tab();
await user.keyboard("[Space]");
expect(toggle).toBeCalledTimes(1);
toggle.mockClear();

// Try toggling by clicking
await user.click(screen.getByRole("menuitemradio"));
expect(toggle).toBeCalledTimes(1);
toggle.mockClear();
});
});
62 changes: 62 additions & 0 deletions src/components/Menu/RadioMenuItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* Copyright 2025 New Vector Ltd
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/

import React, { ComponentProps, forwardRef, useCallback, useId } from "react";
import { MenuItem } from "./MenuItem";
import { RadioInput } from "../Form";

type Props = Pick<
ComponentProps<typeof MenuItem>,
"className" | "label" | "onSelect" | "disabled"
> & {
/**
* Whether the radio is checked.
*/
checked: boolean;
};

/**
* A menu item with a radio control.
* Must be used within a compound Menu or other `menu` or `menubar` aria role subtree.
*/
export const RadioMenuItem = forwardRef<HTMLInputElement, Props>(
function RadioMenuItem(
{ className, label, onSelect, checked, disabled },
ref,
) {
const toggleId = useId();
// The radio is controlled and we intend to ignore its events. We do need
// to at least set onChange though to make React happy.
const onChange = useCallback(() => {}, []);

// <label> elements are not allowed to have a role like menuitemradio, so
// we must instead use a plain <div> for the menu item and use aria-checked
// etc. to communicate its state.
return (
<MenuItem
as="div"
role="menuitemradio"
aria-checked={checked}
className={className}
label={label}
onSelect={onSelect}
disabled={disabled}
Icon={
<RadioInput
id={toggleId}
ref={ref}
// This is purely cosmetic; really the whole MenuItem is the toggle.
aria-hidden
checked={checked}
disabled={disabled}
onChange={onChange}
/>
}
></MenuItem>
);
},
);
31 changes: 31 additions & 0 deletions src/components/Menu/__snapshots__/RadioMenuItem.test.tsx.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`RadioMenuItem > renders 1`] = `
<DocumentFragment>
<div
aria-checked="false"
class="_item_831119 _interactive_831119"
data-kind="primary"
role="menuitemradio"
>
<div
class="_container_5881bc _icon_831119"
>
<input
aria-hidden="true"
class="_input_5881bc"
id=":r0:"
type="radio"
/>
<div
class="_ui_5881bc"
/>
</div>
<span
class="_typography_489030 _font-body-md-medium_489030 _label_831119"
>
Always show
</span>
</div>
</DocumentFragment>
`;

0 comments on commit cc1ee91

Please sign in to comment.