Skip to content

Commit e16231f

Browse files
ieedanAidan BleserAidan Bleserhuntabyte
authored
fix(Menubar): Add onOpenChange to <Menubar.Menu/> and fix docs (#1324)
Co-authored-by: Aidan Bleser <[email protected]> Co-authored-by: Aidan Bleser <[email protected]> Co-authored-by: Hunter Johnston <[email protected]>
1 parent 55a4faa commit e16231f

File tree

7 files changed

+96
-28
lines changed

7 files changed

+96
-28
lines changed

.changeset/unlucky-trainers-worry.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"bits-ui": patch
3+
---
4+
5+
fix(Menubar): Add `onOpenChange` prop to `<Menubar.Menu/>`

docs/src/lib/content/api-reference/menubar.api.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type {
1919
MenubarTriggerPropsWithoutHTML,
2020
} from "bits-ui";
2121
import {
22+
childrenSnippet,
2223
createApiSchema,
2324
createBooleanProp,
2425
createCSSVarSchema,
@@ -28,7 +29,7 @@ import {
2829
withChildProps,
2930
} from "./helpers.js";
3031
import { menu as m } from "./menu.api.js";
31-
import { OnStringValueChangeProp } from "./extended-types/shared/index.js";
32+
import { OnOpenChangeProp, OnStringValueChangeProp } from "./extended-types/shared/index.js";
3233
import * as C from "$lib/content/constants.js";
3334

3435
export const root = createApiSchema<MenubarRootPropsWithoutHTML>({
@@ -63,7 +64,12 @@ export const menu = createApiSchema<MenubarMenuPropsWithoutHTML>({
6364
description:
6465
"The value of this menu within the menubar, used to identify it when determining which menu is active.",
6566
}),
66-
...m.root.props,
67+
onOpenChange: createFunctionProp({
68+
definition: OnOpenChangeProp,
69+
description: "A callback that is fired when the submenu's open state changes.",
70+
stringDefinition: "(open: boolean) => void",
71+
}),
72+
children: childrenSnippet(),
6773
},
6874
});
6975

packages/bits-ui/src/lib/bits/menubar/components/menubar-menu.svelte

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44
import { useMenubarMenu } from "../menubar.svelte.js";
55
import Menu from "$lib/bits/menu/components/menu.svelte";
66
import { useId } from "$lib/internal/use-id.js";
7+
import { noop } from "$lib/internal/noop.js";
78
8-
let { value = useId(), ...restProps }: MenubarMenuProps = $props();
9+
let { value = useId(), onOpenChange = noop, ...restProps }: MenubarMenuProps = $props();
910
1011
const menuState = useMenubarMenu({
1112
value: box.with(() => value),
13+
onOpenChange: box.with(() => onOpenChange),
1214
});
1315
</script>
1416

packages/bits-ui/src/lib/bits/menubar/menubar.svelte.ts

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
BitsFocusEvent,
1515
BitsKeyboardEvent,
1616
BitsPointerEvent,
17+
OnChangeFn,
1718
WithRefProps,
1819
} from "$lib/internal/types.js";
1920
import {
@@ -39,7 +40,7 @@ class MenubarRootState {
3940
rovingFocusGroup: UseRovingFocusReturn;
4041
wasOpenedByKeyboard = $state(false);
4142
triggerIds = $state<string[]>([]);
42-
valueToContentId = new Map<string, ReadableBox<string>>();
43+
valueToChangeHandler = new Map<string, ReadableBox<OnChangeFn<boolean>>>();
4344

4445
constructor(readonly opts: MenubarRootStateProps) {
4546
useRefById(opts);
@@ -74,31 +75,44 @@ class MenubarRootState {
7475
* @param contentId - the content id to associate with the value
7576
* @returns - a function to de-register the menu
7677
*/
77-
registerMenu(value: string, contentId: ReadableBox<string>) {
78-
this.valueToContentId.set(value, contentId);
78+
registerMenu(value: string, onOpenChange: ReadableBox<OnChangeFn<boolean>>) {
79+
this.valueToChangeHandler.set(value, onOpenChange);
7980

8081
return () => {
81-
this.valueToContentId.delete(value);
82+
this.valueToChangeHandler.delete(value);
8283
};
8384
}
8485

86+
updateValue(value: string) {
87+
const currValue = this.opts.value.current;
88+
const currHandler = this.valueToChangeHandler.get(currValue)?.current;
89+
const nextHandler = this.valueToChangeHandler.get(value)?.current;
90+
this.opts.value.current = value;
91+
if (currHandler && currValue !== value) {
92+
currHandler(false);
93+
}
94+
if (nextHandler) {
95+
nextHandler(true);
96+
}
97+
}
98+
8599
getTriggers() {
86100
const node = this.opts.ref.current;
87101
if (!node) return [];
88102
return Array.from(node.querySelectorAll<HTMLButtonElement>(`[${MENUBAR_TRIGGER_ATTR}]`));
89103
}
90104

91105
onMenuOpen(id: string, triggerId: string) {
92-
this.opts.value.current = id;
106+
this.updateValue(id);
93107
this.rovingFocusGroup.setCurrentTabStopId(triggerId);
94108
}
95109

96110
onMenuClose() {
97-
this.opts.value.current = "";
111+
this.updateValue("");
98112
}
99113

100114
onMenuToggle(id: string) {
101-
this.opts.value.current = this.opts.value.current ? "" : id;
115+
this.updateValue(this.opts.value.current ? "" : id);
102116
}
103117

104118
props = $derived.by(
@@ -113,6 +127,7 @@ class MenubarRootState {
113127

114128
type MenubarMenuStateProps = ReadableBoxedValues<{
115129
value: string;
130+
onOpenChange: OnChangeFn<boolean>;
116131
}>;
117132

118133
class MenubarMenuState {
@@ -135,10 +150,7 @@ class MenubarMenuState {
135150
);
136151

137152
onMount(() => {
138-
return this.root.registerMenu(
139-
this.opts.value.current,
140-
box.with(() => this.contentNode?.id ?? "")
141-
);
153+
return this.root.registerMenu(this.opts.value.current, opts.onOpenChange);
142154
});
143155
}
144156

packages/bits-ui/src/lib/bits/menubar/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ export type MenubarMenuPropsWithoutHTML = WithChildren<{
3939
* within the menubar.
4040
*/
4141
value?: string;
42+
43+
/**
44+
* A callback that is called when the menu is opened or closed.
45+
*/
46+
onOpenChange?: OnChangeFn<boolean>;
4247
}>;
4348

4449
export type MenubarMenuProps = MenubarMenuPropsWithoutHTML;
Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,26 @@
1+
<script lang="ts" module>
2+
export type MenubarTestProps = Menubar.RootProps & {
3+
one?: Partial<MenubarMenuProps>;
4+
two?: Partial<MenubarMenuProps>;
5+
three?: Partial<MenubarMenuProps>;
6+
four?: Partial<MenubarMenuProps>;
7+
};
8+
</script>
9+
110
<script lang="ts">
211
import { Menubar } from "bits-ui";
3-
import MenubarMenu from "./menubar-menu-test.svelte";
12+
import MenubarMenu, { type MenubarMenuProps } from "./menubar-menu-test.svelte";
413
5-
let restProps: Menubar.RootProps = $props();
14+
let { one, two, three, four, ...restProps }: MenubarTestProps = $props();
615
</script>
716

817
<main>
918
<button data-testid="previous-button">previous button</button>
1019
<Menubar.Root {...restProps} data-testid="root">
11-
<MenubarMenu id="1" />
12-
<MenubarMenu id="2" />
13-
<MenubarMenu id="3" />
14-
<MenubarMenu id="4" />
20+
<MenubarMenu id="1" {...one} />
21+
<MenubarMenu id="2" {...two} />
22+
<MenubarMenu id="3" {...three} />
23+
<MenubarMenu id="4" {...four} />
1524
</Menubar.Root>
1625
<button data-testid="next-button">next button</button>
1726
</main>

tests/src/tests/menubar/menubar.test.ts

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,24 @@
11
import { render, waitFor } from "@testing-library/svelte/svelte5";
22
import { userEvent } from "@testing-library/user-event";
33
import { axe } from "jest-axe";
4-
import { it } from "vitest";
5-
import type { Menubar } from "bits-ui";
4+
import { it, vi } from "vitest";
65
import { getTestKbd } from "../utils.js";
7-
import MenubarTest from "./menubar-test.svelte";
6+
import MenubarTest, { type MenubarTestProps } from "./menubar-test.svelte";
87

98
const kbd = getTestKbd();
109

1110
/**
1211
* Helper function to reduce boilerplate in tests
1312
*/
14-
function setup(props: Menubar.RootProps = {}) {
13+
function setup(props: MenubarTestProps = {}) {
1514
const user = userEvent.setup();
1615
const returned = render(MenubarTest, { ...props });
17-
const { getByTestId } = returned;
16+
const { getByTestId, queryByTestId } = returned;
1817

1918
const getTrigger = (id: string) => getByTestId(`${id}-trigger`);
20-
const getContent = (id: string) => getByTestId(`${id}-content`);
21-
const getSubTrigger = (id: string) => getByTestId(`${id}-sub-trigger`);
22-
const getSubContent = (id: string) => getByTestId(`${id}-sub-content`);
19+
const getContent = (id: string) => queryByTestId(`${id}-content`);
20+
const getSubTrigger = (id: string) => queryByTestId(`${id}-sub-trigger`);
21+
const getSubContent = (id: string) => queryByTestId(`${id}-sub-content`);
2322

2423
return { user, ...returned, getTrigger, getContent, getSubTrigger, getSubContent };
2524
}
@@ -108,3 +107,33 @@ it("should close the menu and focus the previous tabbable element when `SHIFT+TA
108107
await user.keyboard(kbd.SHIFT_TAB);
109108
await waitFor(() => expect(previousButton).toHaveFocus());
110109
});
110+
111+
it("should call the menus `onOpenChange` callback when the menu is opened or closed", async () => {
112+
const callbacks = {
113+
one: vi.fn(),
114+
two: vi.fn(),
115+
three: vi.fn(),
116+
four: vi.fn(),
117+
};
118+
const { user, getTrigger, getContent } = setup({
119+
one: { onOpenChange: callbacks.one },
120+
two: { onOpenChange: callbacks.two },
121+
three: { onOpenChange: callbacks.three },
122+
four: { onOpenChange: callbacks.four },
123+
});
124+
125+
for (const id of ["1", "2", "3", "4"]) {
126+
// @ts-expect-error - sh
127+
const callback = callbacks[Object.keys(callbacks)[Number(id) - 1]];
128+
129+
await user.click(getTrigger(id));
130+
await waitFor(() => expect(getContent(id)).toBeVisible());
131+
expect(callback).toHaveBeenCalledWith(true);
132+
expect(callback).toHaveBeenCalledTimes(1);
133+
134+
await user.keyboard(kbd.ESCAPE);
135+
await waitFor(() => expect(getContent(id)).not.toBeInTheDocument());
136+
expect(callback).toHaveBeenCalledWith(false);
137+
expect(callback).toHaveBeenCalledTimes(2);
138+
}
139+
});

0 commit comments

Comments
 (0)