diff --git a/apps/docs/src/examples/context-menu.module.css b/apps/docs/src/examples/context-menu.module.css
index 1c576121..e7e570d5 100644
--- a/apps/docs/src/examples/context-menu.module.css
+++ b/apps/docs/src/examples/context-menu.module.css
@@ -17,9 +17,7 @@
background-color: white;
border-radius: 6px;
border: 1px solid hsl(240 6% 90%);
- box-shadow:
- 0 4px 6px -1px rgb(0 0 0 / 0.1),
- 0 2px 4px -2px rgb(0 0 0 / 0.1);
+ box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
outline: none;
transform-origin: var(--kb-menu-content-transform-origin);
animation: contentHide 250ms ease-in forwards;
diff --git a/apps/docs/src/examples/dropdown-menu.module.css b/apps/docs/src/examples/dropdown-menu.module.css
index 3c75e704..104cf20e 100644
--- a/apps/docs/src/examples/dropdown-menu.module.css
+++ b/apps/docs/src/examples/dropdown-menu.module.css
@@ -47,9 +47,7 @@
background-color: white;
border-radius: 6px;
border: 1px solid hsl(240 6% 90%);
- box-shadow:
- 0 4px 6px -1px rgb(0 0 0 / 0.1),
- 0 2px 4px -2px rgb(0 0 0 / 0.1);
+ box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
outline: none;
transform-origin: var(--kb-menu-content-transform-origin);
animation: contentHide 250ms ease-in forwards;
diff --git a/apps/docs/src/examples/menubar.module.css b/apps/docs/src/examples/menubar.module.css
new file mode 100644
index 00000000..0b5785d4
--- /dev/null
+++ b/apps/docs/src/examples/menubar.module.css
@@ -0,0 +1,191 @@
+.menubar__root {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+.menubar__trigger {
+ appearance: none;
+ display: inline-flex;
+ justify-content: center;
+ align-items: center;
+ height: 40px;
+ width: auto;
+ outline: none;
+ padding: 0 16px;
+ background-color: #f6f6f7;
+ color: hsl(240 4% 16%);
+ font-size: 16px;
+ gap: 8px;
+ line-height: 0;
+ transition: 250ms background-color;
+}
+
+.menubar__trigger[data-highlighted="true"] {
+ background-color: hsl(200 98% 39%);
+ color: white;
+}
+
+.menubar__trigger:first-child {
+ border-radius: 4px 0 0 4px;
+}
+
+.menubar__trigger:last-child {
+ border-radius: 0 4px 4px 0;
+}
+
+.menubar__content,
+.menubar__sub-content {
+ min-width: 220px;
+ padding: 8px;
+ background-color: white;
+ border-radius: 6px;
+ border: 1px solid hsl(240 6% 90%);
+ box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
+ outline: none;
+ transform-origin: var(--kb-menu-content-transform-origin);
+ animation: contentHide 250ms ease-in forwards;
+}
+
+.menubar__content[data-expanded],
+.menubar__sub-content[data-expanded] {
+ animation: contentShow 250ms ease-out;
+}
+
+.menubar__item,
+.menubar__checkbox-item,
+.menubar__radio-item,
+.menubar__sub-trigger {
+ font-size: 16px;
+ line-height: 1;
+ color: hsl(240 4% 16%);
+ border-radius: 4px;
+ display: flex;
+ align-items: center;
+ height: 32px;
+ padding: 0 8px 0 24px;
+ position: relative;
+ user-select: none;
+ outline: none;
+}
+
+.menubar__sub-trigger[data-expanded] {
+ background-color: hsl(204 94% 94%);
+ color: hsl(201 96% 32%);
+}
+
+.menubar__item[data-disabled],
+.menubar__checkbox-item[data-disabled],
+.menubar__radio-item[data-disabled],
+.menubar__sub-trigger[data-disabled] {
+ color: hsl(240 5% 65%);
+ opacity: 0.5;
+ pointer-events: none;
+}
+
+.menubar__item[data-highlighted],
+.menubar__checkbox-item[data-highlighted],
+.menubar__radio-item[data-highlighted],
+.menubar__sub-trigger[data-highlighted] {
+ outline: none;
+ background-color: hsl(200 98% 39%);
+ color: white;
+}
+
+.menubar__group-label {
+ padding: 0 24px;
+ font-size: 14px;
+ line-height: 32px;
+ color: hsl(240 4% 46%);
+}
+
+.menubar__separator {
+ height: 1px;
+ border-top: 1px solid hsl(240 6% 90%);
+ margin: 6px;
+}
+
+.menubar__item-indicator {
+ position: absolute;
+ left: 0;
+ height: 20px;
+ width: 20px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.menubar__item-right-slot {
+ margin-left: auto;
+ padding-left: 20px;
+ font-size: 14px;
+ color: hsl(240 4% 46%);
+}
+
+[data-highlighted] > .menubar__item-right-slot {
+ color: white;
+}
+
+[data-disabled] .menubar__item-right-slot {
+ color: hsl(240 5% 65%);
+ opacity: 0.5;
+}
+
+@keyframes contentShow {
+ from {
+ opacity: 0;
+ transform: scale(0.96);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
+@keyframes contentHide {
+ from {
+ opacity: 1;
+ transform: scale(1);
+ }
+ to {
+ opacity: 0;
+ transform: scale(0.96);
+ }
+}
+
+[data-kb-theme="dark"] .menubar__trigger {
+ background-color: hsl(240 4% 16%);
+ color: hsl(0 100% 100% / 0.9);
+}
+
+[data-kb-theme="dark"] .menubar__content,
+[data-kb-theme="dark"] .menubar__sub-content {
+ border: 1px solid hsl(240 5% 26%);
+ background-color: hsl(240 4% 16%);
+ box-shadow: none;
+}
+
+[data-kb-theme="dark"] .menubar__group-label {
+ color: hsl(0 100% 100% / 0.7);
+}
+
+[data-kb-theme="dark"] .menubar__item,
+[data-kb-theme="dark"] .menubar__checkbox-item,
+[data-kb-theme="dark"] .menubar__radio-item,
+[data-kb-theme="dark"] .menubar__sub-trigger {
+ color: hsl(0 100% 100% / 0.9);
+}
+
+[data-kb-theme="dark"] .menubar__sub-trigger[data-expanded]:not([data-highlighted]) {
+ background-color: hsl(202 80% 24% / 0.7);
+ color: hsl(198 93% 60%);
+}
+
+[data-kb-theme="dark"] .menubar__separator {
+ border-color: hsl(240 5% 34%);
+}
+
+[data-kb-theme="dark"] .menubar__trigger[data-highlighted="true"] {
+ background-color: hsl(201 96% 32%);
+ color: hsla(0 100% 100% / 0.9);
+}
diff --git a/apps/docs/src/examples/menubar.tsx b/apps/docs/src/examples/menubar.tsx
new file mode 100644
index 00000000..d4bde706
--- /dev/null
+++ b/apps/docs/src/examples/menubar.tsx
@@ -0,0 +1,175 @@
+import { Menubar } from "@kobalte/core";
+import { createSignal } from "solid-js";
+
+import { CheckIcon, ChevronRightIcon, DotFilledIcon } from "../components";
+import style from "./menubar.module.css";
+
+export function BasicExample() {
+ const [showGitLog, setShowGitLog] = createSignal(true);
+ const [showHistory, setShowHistory] = createSignal(false);
+ const [branch, setBranch] = createSignal("main");
+
+ return (
+
+ );
+}
diff --git a/apps/docs/src/routes/docs/core.tsx b/apps/docs/src/routes/docs/core.tsx
index a52d6923..a305f404 100644
--- a/apps/docs/src/routes/docs/core.tsx
+++ b/apps/docs/src/routes/docs/core.tsx
@@ -93,6 +93,11 @@ const CORE_NAV_SECTIONS: NavSection[] = [
title: "Link",
href: "/docs/core/components/link",
},
+ {
+ title: "Menubar",
+ href: "/docs/core/components/menubar",
+ status: "new",
+ },
{
title: "Pagination",
href: "/docs/core/components/pagination",
diff --git a/apps/docs/src/routes/docs/core/components/context-menu.mdx b/apps/docs/src/routes/docs/core/components/context-menu.mdx
index c532c8de..f2d659ad 100644
--- a/apps/docs/src/routes/docs/core/components/context-menu.mdx
+++ b/apps/docs/src/routes/docs/core/components/context-menu.mdx
@@ -13,7 +13,7 @@ import { ContextMenu } from "@kobalte/core";
## Features
-- Follow the [WAI ARIA Menu](https://www.w3.org/WAI/ARIA/apg/patterns/menu/) design pattern.
+- Follows the [WAI ARIA Menu](https://www.w3.org/WAI/ARIA/apg/patterns/menu/) design pattern.
- Triggers with a long-press on touch devices.
- Supports modal and non-modal modes.
- Supports submenus.
diff --git a/apps/docs/src/routes/docs/core/components/menubar.mdx b/apps/docs/src/routes/docs/core/components/menubar.mdx
new file mode 100644
index 00000000..598b55ca
--- /dev/null
+++ b/apps/docs/src/routes/docs/core/components/menubar.mdx
@@ -0,0 +1,757 @@
+import { Preview, Kbd, TabsSnippets } from "../../../../components";
+import { BasicExample } from "../../../../examples/menubar";
+
+# Menubar
+
+A visually persistent menu common in desktop applications that provides quick access to a consistent set of commands.
+
+## Import
+
+```ts
+import { Menubar } from "@kobalte/core";
+```
+
+## Features
+
+- Follows the [WAI ARIA Menubar](https://www.w3.org/WAI/ARIA/apg/patterns/menubar/) design pattern.
+- Supports modal and non-modal modes.
+- Supports submenus.
+- Supports items, labels, groups of items.
+- Supports checkable items (single or multiple) with optional indeterminate state.
+- Support disabled items.
+- Complex item labeling support for accessibility.
+- Keyboard opening and navigation support.
+- Automatic scrolling support during keyboard navigation.
+- Typeahead to allow focusing items by typing text.
+- Optionally render a pointing arrow.
+- Focus is fully managed.
+
+## Anatomy
+
+The context menu consists of:
+
+- **Menubar.Root:** The root container for a menubar.
+- **Menubar.Menu:** The container of each menu.
+- **Menubar.Trigger:** The button that toggles the menu.
+- **Menubar.Icon:** A small icon that can be displayed inside the menu trigger as a visual affordance for the fact it can be open.
+- **Menubar.Portal:** Portals its children into the `body` when the menu is open.
+- **Menubar.Content:** Contains the content to be rendered when the menu is open.
+- **Menubar.Arrow:** An optional arrow element to render alongside the menu content.
+- **Menubar.Separator:** Used to visually separate items in the menu.
+- **Menubar.Group:** Used to group multiple items. Use in conjunction with `Menubar.GroupLabel` to ensure good accessibility via automatic labelling.
+- **Menubar.GroupLabel:** Used to render the label of a group. It won't be focusable using arrow keys.
+- **Menubar.Sub:** Contains all the parts of a submenu.
+- **Menubar.SubTrigger:** An item that opens a submenu. Must be rendered inside `Menubar.Sub`.
+- **Menubar.SubContent:** The component that pops out when a submenu is open. Must be rendered inside `Menubar.Sub`.
+
+The menu item consists of:
+
+- **Menubar.Item:** An item of the select.
+- **Menubar.ItemLabel:** An accessible label to be announced for the item.
+- **Menubar.ItemDescription:** An optional accessible description to be announced for the item.
+- **Menubar.ItemIndicator:** The visual indicator rendered when the item is checked.
+
+The checkable menu item consists of:
+
+- **Menubar.RadioGroup:** Used to group multiple `Menubar.RadioItem`s and manage the selection.
+- **Menubar.RadioItem:** An item that can be controlled and rendered like a radio.
+- **Menubar.CheckboxItem:** An item that can be controlled and rendered like a checkbox.
+
+```tsx
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+## Example
+
+
+
+
+
+
+
+ index.tsx
+ style.css
+
+ {/* */}
+
+ ```tsx
+ import { Menubar } from "@kobalte/core";
+ import { createSignal } from "solid-js";
+ import { CheckIcon, ChevronRightIcon, DotFilledIcon } from "some-icon-library";
+ import "./style.css";
+
+ function App() {
+ const [showGitLog, setShowGitLog] = createSignal(true);
+ const [showHistory, setShowHistory] = createSignal(false);
+ const [branch, setBranch] = createSignal("main");
+
+ return (
+
+ );
+ }
+ ```
+
+
+
+ ```css
+ .menubar__root {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ }
+
+ .menubar__trigger {
+ appearance: none;
+ display: inline-flex;
+ justify-content: center;
+ align-items: center;
+ height: 40px;
+ width: auto;
+ outline: none;
+ padding: 0 16px;
+ background-color: #f6f6f7;
+ color: hsl(240 4% 16%);
+ font-size: 16px;
+ gap: 8px;
+ line-height: 0;
+ transition: 250ms background-color;
+ }
+
+ .menubar__trigger[data-highlighted="true"] {
+ background-color: hsl(200 98% 39%);
+ color: white;
+ }
+
+ .menubar__trigger:first-child {
+ border-radius: 4px 0 0 4px;
+ }
+
+ .menubar__trigger:last-child {
+ border-left: 2px dashed hsl(240 4% 46%);
+ border-radius: 0 4px 4px 0;
+ }
+
+ .menubar__content,
+ .menubar__sub-content {
+ min-width: 220px;
+ padding: 8px;
+ background-color: white;
+ border-radius: 6px;
+ border: 1px solid hsl(240 6% 90%);
+ box-shadow:
+ 0 4px 6px -1px rgb(0 0 0 / 0.1),
+ 0 2px 4px -2px rgb(0 0 0 / 0.1);
+ outline: none;
+ transform-origin: var(--kb-menu-content-transform-origin);
+ animation: contentHide 250ms ease-in forwards;
+ }
+
+ .menubar__content[data-expanded],
+ .menubar__sub-content[data-expanded] {
+ animation: contentShow 250ms ease-out;
+ }
+
+ .menubar__item,
+ .menubar__checkbox-item,
+ .menubar__radio-item,
+ .menubar__sub-trigger {
+ font-size: 16px;
+ line-height: 1;
+ color: hsl(240 4% 16%);
+ border-radius: 4px;
+ display: flex;
+ align-items: center;
+ height: 32px;
+ padding: 0 8px 0 24px;
+ position: relative;
+ user-select: none;
+ outline: none;
+ }
+
+ .menubar__sub-trigger[data-expanded] {
+ background-color: hsl(204 94% 94%);
+ color: hsl(201 96% 32%);
+ }
+
+ .menubar__item[data-disabled],
+ .menubar__checkbox-item[data-disabled],
+ .menubar__radio-item[data-disabled],
+ .menubar__sub-trigger[data-disabled] {
+ color: hsl(240 5% 65%);
+ opacity: 0.5;
+ pointer-events: none;
+ }
+
+ .menubar__item[data-highlighted],
+ .menubar__checkbox-item[data-highlighted],
+ .menubar__radio-item[data-highlighted],
+ .menubar__sub-trigger[data-highlighted] {
+ outline: none;
+ background-color: hsl(200 98% 39%);
+ color: white;
+ }
+
+ .menubar__group-label {
+ padding: 0 24px;
+ font-size: 14px;
+ line-height: 32px;
+ color: hsl(240 4% 46%);
+ }
+
+ .menubar__separator {
+ height: 1px;
+ border-top: 1px solid hsl(240 6% 90%);
+ margin: 6px;
+ }
+
+ .menubar__item-indicator {
+ position: absolute;
+ left: 0;
+ height: 20px;
+ width: 20px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ .menubar__item-right-slot {
+ margin-left: auto;
+ padding-left: 20px;
+ font-size: 14px;
+ color: hsl(240 4% 46%);
+ }
+
+ [data-highlighted] > .menubar__item-right-slot {
+ color: white;
+ }
+
+ [data-disabled] .menubar__item-right-slot {
+ color: hsl(240 5% 65%);
+ opacity: 0.5;
+ }
+
+ @keyframes contentShow {
+ from {
+ opacity: 0;
+ transform: scale(0.96);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
+ }
+
+ @keyframes contentHide {
+ from {
+ opacity: 1;
+ transform: scale(1);
+ }
+ to {
+ opacity: 0;
+ transform: scale(0.96);
+ }
+ }
+ ```
+
+
+ {/* */}
+
+
+## Usage
+
+### Origin-aware animations
+
+We expose a CSS custom property `--kb-popper-content-transform-origin` which can be used to animate the content from its computed origin.
+
+```css {3}
+/* style.css */
+.context-menu__content,
+.context-menu__sub-content {
+ transform-origin: var(--kb-menu-content-transform-origin);
+ animation: contentHide 250ms ease-in forwards;
+}
+
+.context-menu__content[data-expanded],
+.context-menu__sub-content[data-expanded] {
+ animation: contentShow 250ms ease-out;
+}
+
+@keyframes contentShow {
+ from {
+ opacity: 0;
+ transform: scale(0.96);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
+@keyframes contentHide {
+ from {
+ opacity: 1;
+ transform: scale(1);
+ }
+ to {
+ opacity: 0;
+ transform: scale(0.96);
+ }
+}
+```
+
+## API Reference
+
+### Menubar.Root
+
+| Prop | Description |
+| :------------ | :------------------------------------------------------------------------------------------------------------------------------------- |
+| defaultValue | `string`
The value of the menu that should be open when initially rendered. Use when you do not need to control the value state. |
+| value | `string`
The controlled value of the menu to open. Should be used in conjunction with onValueChange. |
+| onValueChange | `(value: string \| undefined) => void`
Event handler called when the value changes. |
+| loop | `boolean`
When true, keyboard navigation will loop from last item to first, and vice versa. |
+| focusOnAlt | `boolean`
When true, click on alt by itsef will focus this Menubar (some browsers interfere) |
+
+### Menubar.Menu
+
+| Prop | Description |
+| :------------ | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| onOpenChange | `(open: boolean) => void`
Event handler called when the open state of the menu changes. |
+| id | `string`
A unique identifier for the component. The id is used to generate id attributes for nested components. If no id prop is provided, a generated id will be used. |
+| modal | `boolean`
Whether the menu should be the only visible content for screen readers, when set to `true`:
- interaction with outside elements will be disabled.
- scroll will be locked.
- focus will be locked inside the menu content.
- elements outside the menu content will not be visible for screen readers. |
+| preventScroll | `boolean`
Whether the scroll should be locked even if the menu is not modal. |
+| forceMount | `boolean`
Used to force mounting the menu (portal and content) when more control is needed. Useful when controlling animation with SolidJS animation libraries. |
+| value | `string`
A unique value that associates the item with an active value when the navigation menu is controlled. This prop is managed automatically when uncontrolled. |
+
+`Menubar.Menu` also accepts the following props to customize the placement of the `Menubar.Content`.
+
+| Prop | Description |
+| :--------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| placement | `Placement`
The placement of the menu content. |
+| gutter | `number`
The distance between the menu content and the trigger element. By default, it's 0 plus half of the arrow offset, if it exists. |
+| shift | `number`
The skidding of the menu content along the trigger element. |
+| flip | `boolean \| string`
Controls the behavior of the menu content when it overflows the viewport:
- If a `boolean`, specifies whether the menu content should flip to the opposite side when it overflows.
- If a `string`, indicates the preferred fallback placements when it overflows.
The placements must be spaced-delimited, e.g. "top left". |
+| slide | `boolean`
Whether the menu content should slide when it overflows. |
+| overlap | `boolean`
Whether the menu content can overlap the trigger element when it overflows. |
+| sameWidth | `boolean`
Whether the menu content should have the same width as the trigger element. This will be exposed to CSS as `--kb-popper-anchor-width`. |
+| fitViewport | `boolean`
Whether the menu content should fit the viewport. If this is set to true, the menu content will have `maxWidth` and `maxHeight` set to the viewport size. This will be exposed to CSS as `--kb-popper-available-width` and `--kb-popper-available-height`. |
+| hideWhenDetached | `boolean`
Whether to hide the menu content when the trigger element becomes occluded. |
+| detachedPadding | `number`
The minimum padding in order to consider the trigger element occluded. |
+| arrowPadding | `number`
The minimum padding between the arrow and the menu content corner. |
+| overflowPadding | `number`
The minimum padding between the menu content and the viewport edge. This will be exposed to CSS as `--kb-popper-overflow-padding`. |
+
+### Menubar.Trigger
+
+| Prop | Description |
+| :------- | :------------------------------------------------------------------- |
+| disabled | `boolean`
Whether the context menu trigger is disabled or not. |
+
+| Data attribute | Description |
+| :------------- | :------------------------------------ |
+| data-expanded | Present when the menu is open. |
+| data-closed | Present when the menu is close. |
+| data-disabled | Present when the trigger is disabled. |
+
+`Menubar.Icon`, `Menubar.Content`, `Menubar.SubTrigger` and `Menubar.SubContent` share the same `data-expanded` attribute.
+
+### Menubar.Content
+
+| Prop | Description |
+| :------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| onOpenAutoFocus | `(event: Event) => void`
Event handler called when focus moves into the component after opening. It can be prevented by calling `event.preventDefault`. |
+| onCloseAutoFocus | `(event: Event) => void`
Event handler called when focus moves to the trigger after closing. It can be prevented by calling `event.preventDefault`. |
+| onEscapeKeyDown | `(event: KeyboardEvent) => void`
Event handler called when the escape key is down. It can be prevented by calling `event.preventDefault`. |
+| onPointerDownOutside | `(event: PointerDownOutsideEvent) => void`
Event handler called when a pointer event occurs outside the bounds of the component. It can be prevented by calling `event.preventDefault`. |
+| onFocusOutside | `(event: FocusOutsideEvent) => void`
Event handler called when the focus moves outside the bounds of the component. It can be prevented by calling `event.preventDefault`. |
+| onInteractOutside | `(event: InteractOutsideEvent) => void`
Event handler called when an interaction (pointer or focus event) happens outside the bounds of the component. It can be prevented by calling `event.preventDefault`. |
+
+### Menubar.Arrow
+
+| Prop | Description |
+| :--- | :------------------------------------ |
+| size | `number`
The size of the arrow. |
+
+### Menubar.Item
+
+| Prop | Description |
+| :------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| textValue | `string`
Optional text used for typeahead purposes. By default, the typeahead behavior will use the .textContent of the `Menubar.ItemLabel` part if provided, or fallback to the .textContent of the `Menubar.Item`. Use this when the content is complex, or you have non-textual content inside. |
+| disabled | `boolean`
Whether the item is disabled or not. |
+| closeOnSelect | `boolean`
Whether the menu should close when the item is activated. |
+| onSelect | `() => void`
Event handler called when the user selects an item (via mouse or keyboard). |
+
+| Data attribute | Description |
+| :--------------- | :------------------------------------ |
+| data-disabled | Present when the item is disabled. |
+| data-highlighted | Present when the item is highlighted. |
+
+`Menubar.ItemLabel`, `Menubar.ItemDescription` and `Menubar.ItemIndicator` shares the same data-attributes.
+
+### Menubar.ItemIndicator
+
+| Prop | Description |
+| :--------- | :-------------------------------------------------------------------------------------------------------------------------------------- |
+| forceMount | `boolean`
Used to force mounting when more control is needed. Useful when controlling animation with SolidJS animation libraries. |
+
+### Menubar.RadioGroup
+
+| Prop | Description |
+| :----------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| value | `string`
The controlled value of the menu radio item to check. |
+| defaultValue | `string`
The value of the menu radio item that should be checked when initially rendered. Useful when you do not need to control the state of the radio group. |
+| onChange | `(value: string) => void`
Event handler called when the value changes. |
+| disabled | `boolean`
Whether the radio group is disabled or not. |
+
+### Menubar.RadioItem
+
+| Prop | Description |
+| :------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| value | `string`
The value of the menu item radio. |
+| textValue | `string`
Optional text used for typeahead purposes. By default, the typeahead behavior will use the .textContent of the `Menubar.ItemLabel` part if provided, or fallback to the .textContent of the `Menubar.Item`. Use this when the content is complex, or you have non-textual content inside. |
+| disabled | `boolean`
Whether the item is disabled or not. |
+| closeOnSelect | `boolean`
Whether the menu should close when the item is checked. |
+| onSelect | `() => void`
Event handler called when the user selects an item (via mouse or keyboard). |
+
+| Data attribute | Description |
+| :--------------- | :------------------------------------ |
+| data-disabled | Present when the item is disabled. |
+| data-checked | Present when the item is checked. |
+| data-highlighted | Present when the item is highlighted. |
+
+### Menubar.CheckboxItem
+
+| Prop | Description |
+| :------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| checked | `boolean`
The controlled checked state of the item. |
+| defaultChecked | `boolean`
The default checked state when initially rendered. Useful when you do not need to control the checked state. |
+| onChange | `(checked: boolean) => void`
Event handler called when the checked state of the item changes. |
+| textValue | `string`
Optional text used for typeahead purposes. By default, the typeahead behavior will use the .textContent of the `Menubar.ItemLabel` part if provided, or fallback to the .textContent of the `Menubar.Item`. Use this when the content is complex, or you have non-textual content inside. |
+| indeterminate | `boolean`
Whether the item is in an indeterminate state. |
+| disabled | `boolean`
Whether the item is disabled or not. |
+| closeOnSelect | `boolean`
Whether the menu should close when the item is checked/unchecked. |
+| onSelect | `() => void`
Event handler called when the user selects an item (via mouse or keyboard). |
+
+| Data attribute | Description |
+| :----------------- | :-------------------------------------------------- |
+| data-disabled | Present when the item is disabled. |
+| data-indeterminate | Present when the item is in an indeterminate state. |
+| data-checked | Present when the item is checked. |
+| data-highlighted | Present when the item is highlighted. |
+
+### Menubar.Sub
+
+| Prop | Description |
+| :----------- | :--------------------------------------------------------------------------------------------------------------------- |
+| open | `boolean`
The controlled open state of the sub menu. |
+| defaultOpen | `boolean`
The default open state when initially rendered. Useful when you do not need to control the open state. |
+| onOpenChange | `(open: boolean) => void`
Event handler called when the open state of the sub menu changes. |
+
+`Menubar.Sub` also accepts the following props to customize the placement of the `Menubar.SubContent`.
+
+| Prop | Description |
+| :--------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| getAnchorRect | `(anchor?: HTMLElement) => AnchorRect \| undefined`
Function that returns the trigger element's DOMRect. |
+| gutter | `number`
The distance between the sub menu content and the trigger element. By default, it's 0 plus half of the arrow offset, if it exists. |
+| shift | `number`
The skidding of the sub menu content along the trigger element. |
+| slide | `boolean`
Whether the sub menu content should slide when it overflows. |
+| overlap | `boolean`
Whether the sub menu content can overlap the trigger element when it overflows. |
+| fitViewport | `boolean`
Whether the sub menu content should fit the viewport. If this is set to true, the sub menu content will have `maxWidth` and `maxHeight` set to the viewport size. This will be exposed to CSS as `--kb-popper-available-width` and `--kb-popper-available-height`. |
+| hideWhenDetached | `boolean`
Whether to hide the sub menu content when the trigger element becomes occluded. |
+| detachedPadding | `number`
The minimum padding in order to consider the trigger element occluded. |
+| arrowPadding | `number`
The minimum padding between the arrow and the sub menu content corner. |
+| overflowPadding | `number`
The minimum padding between the sub menu content and the viewport edge. This will be exposed to CSS as `--kb-popper-overflow-padding`. |
+
+### Menubar.SubTrigger
+
+| Prop | Description |
+| :-------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| textValue | `string`
Optional text used for typeahead purposes. By default, the typeahead behavior will use the .textContent of the `Menubar.SubTrigger`. Use this when the content is complex, or you have non-textual content inside. |
+| disabled | `boolean`
Whether the sub menu trigger is disabled or not. |
+
+| Data attribute | Description |
+| :--------------- | :------------------------------------ |
+| data-disabled | Present when the item is disabled. |
+| data-highlighted | Present when the item is highlighted. |
+
+### Menubar.SubContent
+
+| Prop | Description |
+| :------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| onEscapeKeyDown | `(event: KeyboardEvent) => void`
Event handler called when the escape key is down. It can be prevented by calling `event.preventDefault`. |
+| onPointerDownOutside | `(event: PointerDownOutsideEvent) => void`
Event handler called when a pointer event occurs outside the bounds of the component. It can be prevented by calling `event.preventDefault`. |
+| onFocusOutside | `(event: FocusOutsideEvent) => void`
Event handler called when the focus moves outside the bounds of the component. It can be prevented by calling `event.preventDefault`. |
+| onInteractOutside | `(event: InteractOutsideEvent) => void`
Event handler called when an interaction (pointer or focus event) happens outside the bounds of the component. It can be prevented by calling `event.preventDefault`. |
+
+## Rendered elements
+
+| Component | Default rendered element |
+| :------------------------ | :----------------------- |
+| `Menubar.Root` | `div` |
+| `Menubar.Menu` | none |
+| `Menubar.Trigger` | `div` |
+| `Menubar.Icon` | `div` |
+| `Menubar.Portal` | `Portal` |
+| `Menubar.Content` | `div` |
+| `Menubar.Arrow` | `div` |
+| `Menubar.Separator` | `hr` |
+| `Menubar.Group` | `div` |
+| `Menubar.GroupLabel` | `span` |
+| `Menubar.Sub` | none |
+| `Menubar.SubTrigger` | `div` |
+| `Menubar.SubContent` | `div` |
+| `Menubar.Item` | `div` |
+| `Menubar.ItemLabel` | `div` |
+| `Menubar.ItemDescription` | `div` |
+| `Menubar.ItemIndicator` | `div` |
+| `Menubar.RadioGroup` | `div` |
+| `Menubar.RadioItem` | `div` |
+| `Menubar.CheckboxItem` | `div` |
+
+## Accessibility
+
+### Keyboard Interactions
+
+| Key | Description |
+| :------------------------------------------- | :----------------------------------------------------------------------------------------------- |
+| Space | When focus is on an item, activates the item. |
+| Enter | When focus is on an item, activates the item. |
+| ArrowDown | When focus is on an item, moves focus to the next item. |
+| ArrowUp | When focus is on an item, moves focus to the previous item. |
+| ArrowRight | When focus is on an item (not sub menu trigger), moves focus to the next menu. |
+| ArrowLeft | When focus is on an item (not sub menu item), moves focus to the previous menu. |
+| ArrowRight / ArrowLeft | When focus is on a sub menu trigger, opens or closes the submenu depending on reading direction. |
+| Home | When focus is on an item, moves focus to first item. |
+| End | When focus is on an item, moves focus to last item. |
+| Esc | Closes the context menu. |
diff --git a/packages/core/src/context-menu/context-menu-context.tsx b/packages/core/src/context-menu/context-menu-context.tsx
index 1a0fefd5..557a76ef 100644
--- a/packages/core/src/context-menu/context-menu-context.tsx
+++ b/packages/core/src/context-menu/context-menu-context.tsx
@@ -15,7 +15,7 @@ export function useContextMenuContext() {
if (context === undefined) {
throw new Error(
- "[kobalte]: `useContextMenuContext` must be used within a `ContextMenu` component",
+ "[kobalte]: `useContextMenuContext` must be used within a `ContextMenu` component"
);
}
diff --git a/packages/core/src/context-menu/context-menu-root.tsx b/packages/core/src/context-menu/context-menu-root.tsx
index 48754653..20967e69 100644
--- a/packages/core/src/context-menu/context-menu-root.tsx
+++ b/packages/core/src/context-menu/context-menu-root.tsx
@@ -34,7 +34,7 @@ export function ContextMenuRoot(props: ContextMenuRootProps) {
gutter: 2,
shift: 2,
},
- props,
+ props
);
const [local, others] = splitProps(props, ["onOpenChange"]);
diff --git a/packages/core/src/context-menu/context-menu-trigger.tsx b/packages/core/src/context-menu/context-menu-trigger.tsx
index 986e7201..21afd7fd 100644
--- a/packages/core/src/context-menu/context-menu-trigger.tsx
+++ b/packages/core/src/context-menu/context-menu-trigger.tsx
@@ -35,7 +35,7 @@ export function ContextMenuTrigger(props: ContextMenuTriggerProps) {
{
id: rootContext.generateId("trigger"),
},
- props,
+ props
);
const [local, others] = splitProps(props, [
diff --git a/packages/core/src/index.tsx b/packages/core/src/index.tsx
index 71a12c4a..4a10a281 100644
--- a/packages/core/src/index.tsx
+++ b/packages/core/src/index.tsx
@@ -27,6 +27,7 @@ export * as HoverCard from "./hover-card";
export * as Image from "./image";
export * as Link from "./link";
export * as Listbox from "./listbox";
+export * as Menubar from "./menubar";
export * as Pagination from "./pagination";
export * as Popover from "./popover";
export * as Progress from "./progress";
diff --git a/packages/core/src/menu/menu-checkbox-item.tsx b/packages/core/src/menu/menu-checkbox-item.tsx
index 590d2520..1eb11475 100644
--- a/packages/core/src/menu/menu-checkbox-item.tsx
+++ b/packages/core/src/menu/menu-checkbox-item.tsx
@@ -29,7 +29,7 @@ export function MenuCheckboxItem(props: MenuCheckboxItemProps) {
{
closeOnSelect: false,
},
- props,
+ props
);
const [local, others] = splitProps(props, ["checked", "defaultChecked", "onChange", "onSelect"]);
diff --git a/packages/core/src/menu/menu-content-base.tsx b/packages/core/src/menu/menu-content-base.tsx
index 897a532e..a1a1eacb 100644
--- a/packages/core/src/menu/menu-content-base.tsx
+++ b/packages/core/src/menu/menu-content-base.tsx
@@ -28,6 +28,7 @@ import {
} from "../primitives";
import { useMenuContext } from "./menu-context";
import { useMenuRootContext } from "./menu-root-context";
+import { useOptionalMenubarContext } from "../menubar/menubar-context";
export interface MenuContentBaseOptions extends AsChildProp {
/** The HTML styles attribute (object form only). */
@@ -78,12 +79,13 @@ export function MenuContentBase(props: MenuContentBaseProps) {
const rootContext = useMenuRootContext();
const context = useMenuContext();
+ const optionalMenubarContext = useOptionalMenubarContext();
props = mergeDefaultProps(
{
id: rootContext.generateId(`content-${createUniqueId()}`),
},
- props,
+ props
);
const [local, others] = splitProps(props, [
@@ -106,7 +108,11 @@ export function MenuContentBase(props: MenuContentBaseProps) {
// Only the root menu can apply "modal" behavior (block pointer-events and trap focus).
const isRootModalContent = () => {
- return context.parentMenuContext() == null && rootContext.isModal();
+ return (
+ context.parentMenuContext() == null &&
+ optionalMenubarContext === undefined &&
+ rootContext.isModal()
+ );
};
const selectableList = createSelectableList(
@@ -118,16 +124,18 @@ export function MenuContentBase(props: MenuContentBaseProps) {
shouldFocusWrap: true,
disallowTypeAhead: () => !context.listState().selectionManager().isFocused(),
},
- () => ref,
+ () => ref
);
createFocusScope(
{
trapFocus: () => isRootModalContent() && context.isOpen(),
- onMountAutoFocus: local.onOpenAutoFocus,
+ onMountAutoFocus: event => {
+ if (optionalMenubarContext === undefined) local.onOpenAutoFocus?.(event);
+ },
onUnmountAutoFocus: local.onCloseAutoFocus,
},
- () => ref,
+ () => ref
);
const onKeyDown: JSX.EventHandlerUnion = e => {
@@ -140,11 +148,36 @@ export function MenuContentBase(props: MenuContentBaseProps) {
if (e.key === "Tab" && context.isOpen()) {
e.preventDefault();
}
+
+ if (optionalMenubarContext !== undefined) {
+ if (e.currentTarget.getAttribute("aria-haspopup") !== "true")
+ switch (e.key) {
+ case "ArrowRight":
+ e.stopPropagation();
+ e.preventDefault();
+ context.close(true);
+ optionalMenubarContext.setAutoFocusMenu(true);
+ optionalMenubarContext.nextMenu();
+
+ break;
+ case "ArrowLeft":
+ if (e.currentTarget.hasAttribute("data-closed")) break;
+
+ e.stopPropagation();
+ e.preventDefault();
+ context.close(true);
+ optionalMenubarContext.setAutoFocusMenu(true);
+ optionalMenubarContext.previousMenu();
+ break;
+ }
+ }
};
const onEscapeKeyDown = (e: KeyboardEvent) => {
local.onEscapeKeyDown?.(e);
+ optionalMenubarContext?.setAutoFocusMenu(false);
+
// `createSelectableList` prevent escape key down,
// which prevent our `onDismiss` in `DismissableLayer` to run,
// so we force "close on escape" here.
diff --git a/packages/core/src/menu/menu-context.tsx b/packages/core/src/menu/menu-context.tsx
index 7d311657..931bb441 100644
--- a/packages/core/src/menu/menu-context.tsx
+++ b/packages/core/src/menu/menu-context.tsx
@@ -40,6 +40,7 @@ export interface MenuContextValue {
registerItemToParentDomCollection: ((item: CollectionItemWithRef) => () => void) | undefined;
registerTriggerId: (id: string) => () => void;
registerContentId: (id: string) => () => void;
+ nestedMenus: Accessor;
}
export const MenuContext = createContext();
diff --git a/packages/core/src/menu/menu-group-context.tsx b/packages/core/src/menu/menu-group-context.tsx
index 34b17564..17f71c7c 100644
--- a/packages/core/src/menu/menu-group-context.tsx
+++ b/packages/core/src/menu/menu-group-context.tsx
@@ -12,7 +12,7 @@ export function useMenuGroupContext() {
if (context === undefined) {
throw new Error(
- "[kobalte]: `useMenuGroupContext` must be used within a `Menu.Group` component",
+ "[kobalte]: `useMenuGroupContext` must be used within a `Menu.Group` component"
);
}
diff --git a/packages/core/src/menu/menu-group-label.tsx b/packages/core/src/menu/menu-group-label.tsx
index f39cd45d..ef97ad12 100644
--- a/packages/core/src/menu/menu-group-label.tsx
+++ b/packages/core/src/menu/menu-group-label.tsx
@@ -25,7 +25,7 @@ export function MenuGroupLabel(props: MenuGroupLabelProps) {
{
id: context.generateId("label"),
},
- props,
+ props
);
const [local, others] = splitProps(props, ["id"]);
diff --git a/packages/core/src/menu/menu-group.tsx b/packages/core/src/menu/menu-group.tsx
index dee9335d..7c576d18 100644
--- a/packages/core/src/menu/menu-group.tsx
+++ b/packages/core/src/menu/menu-group.tsx
@@ -26,7 +26,7 @@ export function MenuGroup(props: MenuGroupProps) {
{
id: rootContext.generateId(`group-${createUniqueId()}`),
},
- props,
+ props
);
const [labelId, setLabelId] = createSignal();
diff --git a/packages/core/src/menu/menu-item-base.tsx b/packages/core/src/menu/menu-item-base.tsx
index 1b05f410..04f7a46d 100644
--- a/packages/core/src/menu/menu-item-base.tsx
+++ b/packages/core/src/menu/menu-item-base.tsx
@@ -69,7 +69,7 @@ export function MenuItemBase(props: MenuItemBaseProps) {
{
id: rootContext.generateId(`item-${createUniqueId()}`),
},
- props,
+ props
);
const [local, others] = splitProps(props, [
@@ -126,7 +126,7 @@ export function MenuItemBase(props: MenuItemBaseProps) {
allowsDifferentPressOrigin: true,
disabled: () => local.disabled,
},
- () => ref,
+ () => ref
);
/**
diff --git a/packages/core/src/menu/menu-item-description.tsx b/packages/core/src/menu/menu-item-description.tsx
index b6856742..3173a0f1 100644
--- a/packages/core/src/menu/menu-item-description.tsx
+++ b/packages/core/src/menu/menu-item-description.tsx
@@ -25,7 +25,7 @@ export function MenuItemDescription(props: MenuItemDescriptionProps) {
{
id: context.generateId("description"),
},
- props,
+ props
);
const [local, others] = splitProps(props, ["id"]);
diff --git a/packages/core/src/menu/menu-item-indicator.tsx b/packages/core/src/menu/menu-item-indicator.tsx
index 3f8d6dac..3d7d9235 100644
--- a/packages/core/src/menu/menu-item-indicator.tsx
+++ b/packages/core/src/menu/menu-item-indicator.tsx
@@ -26,7 +26,7 @@ export function MenuItemIndicator(props: MenuItemIndicatorProps) {
{
id: context.generateId("indicator"),
},
- props,
+ props
);
const [local, others] = splitProps(props, ["forceMount"]);
diff --git a/packages/core/src/menu/menu-item-label.tsx b/packages/core/src/menu/menu-item-label.tsx
index 4526d366..35d472ae 100644
--- a/packages/core/src/menu/menu-item-label.tsx
+++ b/packages/core/src/menu/menu-item-label.tsx
@@ -25,7 +25,7 @@ export function MenuItemLabel(props: MenuItemLabelProps) {
{
id: context.generateId("label"),
},
- props,
+ props
);
const [local, others] = splitProps(props, ["ref", "id"]);
diff --git a/packages/core/src/menu/menu-radio-group-context.tsx b/packages/core/src/menu/menu-radio-group-context.tsx
index 84d75aaf..9b8fc4db 100644
--- a/packages/core/src/menu/menu-radio-group-context.tsx
+++ b/packages/core/src/menu/menu-radio-group-context.tsx
@@ -13,7 +13,7 @@ export function useMenuRadioGroupContext() {
if (context === undefined) {
throw new Error(
- "[kobalte]: `useMenuRadioGroupContext` must be used within a `Menu.RadioGroup` component",
+ "[kobalte]: `useMenuRadioGroupContext` must be used within a `Menu.RadioGroup` component"
);
}
diff --git a/packages/core/src/menu/menu-radio-group.tsx b/packages/core/src/menu/menu-radio-group.tsx
index bf0fb750..914fb504 100644
--- a/packages/core/src/menu/menu-radio-group.tsx
+++ b/packages/core/src/menu/menu-radio-group.tsx
@@ -47,7 +47,7 @@ export function MenuRadioGroup(props: MenuRadioGroupProps) {
{
id: defaultId,
},
- props,
+ props
);
const [local, others] = splitProps(props, ["value", "defaultValue", "onChange", "disabled"]);
diff --git a/packages/core/src/menu/menu-root-context.tsx b/packages/core/src/menu/menu-root-context.tsx
index b2dc23d6..a6f91899 100644
--- a/packages/core/src/menu/menu-root-context.tsx
+++ b/packages/core/src/menu/menu-root-context.tsx
@@ -5,6 +5,9 @@ export interface MenuRootContextValue {
preventScroll: Accessor;
forceMount: Accessor;
generateId: (part: string) => string;
+
+ /** Used for Menubar */
+ value: Accessor;
}
export const MenuRootContext = createContext();
diff --git a/packages/core/src/menu/menu-root.tsx b/packages/core/src/menu/menu-root.tsx
index d11a003d..dffcf767 100644
--- a/packages/core/src/menu/menu-root.tsx
+++ b/packages/core/src/menu/menu-root.tsx
@@ -31,6 +31,14 @@ export interface MenuRootOptions extends MenuOptions {
* Useful when controlling animation with SolidJS animation libraries.
*/
forceMount?: boolean;
+
+ /**
+ * A unique value that associates the item with an active value
+ * when the navigation menu is controlled.
+ * This prop is managed automatically when uncontrolled.
+ * Only used inside a Menubar.
+ */
+ value?: string;
}
export interface MenuRootProps extends ParentProps {}
@@ -48,7 +56,7 @@ export function MenuRoot(props: MenuRootProps) {
modal: true,
preventScroll: false,
},
- props,
+ props
);
const [local, others] = splitProps(props, [
@@ -59,6 +67,7 @@ export function MenuRoot(props: MenuRootProps) {
"open",
"defaultOpen",
"onOpenChange",
+ "value",
]);
const disclosureState = createDisclosureState({
@@ -72,6 +81,7 @@ export function MenuRoot(props: MenuRootProps) {
preventScroll: () => local.preventScroll ?? false,
forceMount: () => local.forceMount ?? false,
generateId: createGenerateId(() => local.id!),
+ value: () => local.value,
};
return (
diff --git a/packages/core/src/menu/menu-sub-trigger.tsx b/packages/core/src/menu/menu-sub-trigger.tsx
index 7ff0548d..6fc726ef 100644
--- a/packages/core/src/menu/menu-sub-trigger.tsx
+++ b/packages/core/src/menu/menu-sub-trigger.tsx
@@ -63,7 +63,7 @@ export function MenuSubTrigger(props: MenuSubTriggerProps) {
{
id: rootContext.generateId(`sub-trigger-${createUniqueId()}`),
},
- props,
+ props
);
const [local, others] = splitProps(props, [
@@ -121,7 +121,7 @@ export function MenuSubTrigger(props: MenuSubTriggerProps) {
allowsDifferentPressOrigin: true,
disabled: () => local.disabled,
},
- () => ref,
+ () => ref
);
const onClick: JSX.EventHandlerUnion = e => {
@@ -278,8 +278,8 @@ export function MenuSubTrigger(props: MenuSubTriggerProps) {
window.clearTimeout(pointerGraceTimer);
context.parentMenuContext()?.setPointerGraceIntent(null);
});
- },
- ),
+ }
+ )
);
createEffect(() => onCleanup(context.registerTriggerId(local.id!)));
diff --git a/packages/core/src/menu/menu-trigger.tsx b/packages/core/src/menu/menu-trigger.tsx
index 06fa045f..60c6517a 100644
--- a/packages/core/src/menu/menu-trigger.tsx
+++ b/packages/core/src/menu/menu-trigger.tsx
@@ -7,11 +7,12 @@
*/
import { callHandler, mergeDefaultProps, mergeRefs, OverrideComponentProps } from "@kobalte/utils";
-import { createEffect, JSX, onCleanup, splitProps } from "solid-js";
+import { createDeferred, createEffect, createSignal, JSX, onCleanup, splitProps } from "solid-js";
import * as Button from "../button";
import { useMenuContext } from "./menu-context";
import { useMenuRootContext } from "./menu-root-context";
+import { useOptionalMenubarContext } from "../menubar/menubar-context";
export interface MenuTriggerOptions extends Button.ButtonRootOptions {}
@@ -21,14 +22,17 @@ export interface MenuTriggerProps extends OverrideComponentProps<"button", MenuT
* The button that toggles the menu.
*/
export function MenuTrigger(props: MenuTriggerProps) {
+ let ref: HTMLButtonElement | undefined;
+
const rootContext = useMenuRootContext();
const context = useMenuContext();
+ const optionalMenubarContext = useOptionalMenubarContext();
props = mergeDefaultProps(
{
id: rootContext.generateId("trigger"),
},
- props,
+ props
);
const [local, others] = splitProps(props, [
@@ -38,8 +42,40 @@ export function MenuTrigger(props: MenuTriggerProps) {
"onPointerDown",
"onClick",
"onKeyDown",
+ "onMouseOver",
+ "onFocus",
]);
+ let key: string | undefined;
+
+ if (optionalMenubarContext !== undefined) {
+ key = rootContext.value() ?? local.id!;
+
+ createEffect(() => {
+ optionalMenubarContext.registerMenu(key!, [
+ context.contentRef() ?? ref!,
+ ...context.nestedMenus(),
+ ]);
+ });
+
+ createEffect(() => {
+ if (optionalMenubarContext.value() === key) {
+ ref?.focus();
+ if (optionalMenubarContext.autoFocusMenu()) context.open(true);
+ } else context.close(true);
+ });
+
+ createEffect(() => {
+ if (context.isOpen()) optionalMenubarContext.setValue(key);
+ });
+
+ onCleanup(() => {
+ optionalMenubarContext.unregisterMenu(key!);
+ });
+
+ if (optionalMenubarContext.lastValue() === undefined) optionalMenubarContext.setLastValue(key);
+ }
+
const onPointerDown: JSX.EventHandlerUnion = e => {
callHandler(e, local.onPointerDown);
@@ -47,15 +83,20 @@ export function MenuTrigger(props: MenuTriggerProps) {
// For consistency with native, open the select on mouse down (main button), but touch up.
if (!local.disabled && e.pointerType !== "touch" && e.button === 0) {
- context.toggle(true);
+ // When opened by click, automatically focus Menubar menus
+ optionalMenubarContext?.setAutoFocusMenu(true);
+
+ // Don't auto focus element for Menubar
+ if (optionalMenubarContext !== undefined) context.toggle(false);
+ else context.toggle(true);
}
};
const onClick: JSX.EventHandlerUnion = e => {
callHandler(e, local.onClick);
- if (!local.disabled && e.currentTarget.dataset.pointerType === "touch") {
- context.toggle(true);
+ if (!local.disabled) {
+ if (e.currentTarget.dataset.pointerType === "touch") context.toggle(true);
}
};
@@ -80,22 +121,68 @@ export function MenuTrigger(props: MenuTriggerProps) {
e.preventDefault();
context.toggle("last");
break;
+ case "ArrowRight":
+ if (optionalMenubarContext === undefined) break;
+ e.stopPropagation();
+ e.preventDefault();
+ optionalMenubarContext.nextMenu();
+ break;
+ case "ArrowLeft":
+ if (optionalMenubarContext === undefined) break;
+ e.stopPropagation();
+ e.preventDefault();
+ optionalMenubarContext.previousMenu();
+ break;
}
};
+ const onMouseOver: JSX.EventHandlerUnion = e => {
+ callHandler(e, local.onMouseOver);
+
+ // When one of the menubar menus is open, automatically open others on trigger hover
+ if (
+ !local.disabled &&
+ optionalMenubarContext !== undefined &&
+ optionalMenubarContext.value() !== undefined
+ ) {
+ optionalMenubarContext.setValue(key);
+ }
+ };
+
+ const onFocus: JSX.EventHandlerUnion = e => {
+ callHandler(e, local.onFocus);
+
+ if (optionalMenubarContext !== undefined) optionalMenubarContext.setValue(key);
+ };
+
createEffect(() => onCleanup(context.registerTriggerId(local.id!)));
+ const [tabIndex, setTabIndex] = createSignal(undefined);
+
return (
(ref = el), context.setTriggerRef, local.ref)}
id={local.id}
disabled={local.disabled}
aria-haspopup="true"
aria-expanded={context.isOpen()}
aria-controls={context.isOpen() ? context.contentId() : undefined}
+ data-highlighted={
+ key !== undefined && optionalMenubarContext?.value() === key ? true : undefined
+ }
+ tabIndex={
+ optionalMenubarContext !== undefined
+ ? optionalMenubarContext.value() === key || optionalMenubarContext.lastValue() === key
+ ? 0
+ : -1
+ : undefined
+ }
onPointerDown={onPointerDown}
+ onMouseOver={onMouseOver}
onClick={onClick}
onKeyDown={onKeyDown}
+ onFocus={onFocus}
+ role={optionalMenubarContext !== undefined ? "menuitem" : undefined}
{...context.dataset()}
{...others}
/>
diff --git a/packages/core/src/menu/menu.tsx b/packages/core/src/menu/menu.tsx
index 7229533f..1cbebb16 100644
--- a/packages/core/src/menu/menu.tsx
+++ b/packages/core/src/menu/menu.tsx
@@ -65,7 +65,7 @@ export function Menu(props: MenuProps) {
{
placement: "bottom-start",
},
- props,
+ props
);
const [local, others] = splitProps(props, ["open", "defaultOpen", "onOpenChange"]);
@@ -95,7 +95,7 @@ export function Menu(props: MenuProps) {
});
const contentPresence = createPresence(
- () => rootContext.forceMount() || disclosureState.isOpen(),
+ () => rootContext.forceMount() || disclosureState.isOpen()
);
const listState = createListState({
@@ -199,6 +199,7 @@ export function Menu(props: MenuProps) {
dataset,
isOpen: disclosureState.isOpen,
contentPresence,
+ nestedMenus,
currentPlacement,
pointerGraceTimeoutId: () => pointerGraceTimeoutId,
autoFocus: focusStrategy,
diff --git a/packages/core/src/menubar/index.tsx b/packages/core/src/menubar/index.tsx
new file mode 100644
index 00000000..07a63a02
--- /dev/null
+++ b/packages/core/src/menubar/index.tsx
@@ -0,0 +1,124 @@
+import {
+ MenuContent as Content,
+ type MenuContentOptions as MenubarContentOptions,
+ type MenuContentProps as MenubarContentProps,
+ MenuCheckboxItem as CheckboxItem,
+ type MenuCheckboxItemOptions as MenubarCheckboxItemOptions,
+ type MenuCheckboxItemProps as MenubarCheckboxItemProps,
+ MenuGroup as Group,
+ MenuGroupLabel as GroupLabel,
+ type MenuGroupLabelProps as MenubarGroupLabelProps,
+ type MenuGroupProps as MenubarGroupProps,
+ MenuIcon as Icon,
+ type MenuIconProps as MenubarIconProps,
+ MenuItem as Item,
+ MenuItemDescription as ItemDescription,
+ type MenuItemDescriptionProps as MenubarItemDescriptionProps,
+ MenuItemIndicator as ItemIndicator,
+ type MenuItemIndicatorOptions as MenubarItemIndicatorOptions,
+ type MenuItemIndicatorProps as MenubarItemIndicatorProps,
+ MenuItemLabel as ItemLabel,
+ type MenuItemLabelProps as MenubarItemLabelProps,
+ type MenuItemOptions as MenubarItemOptions,
+ type MenuItemProps as MenubarItemProps,
+ MenuPortal as Portal,
+ type MenuPortalProps as MenubarPortalProps,
+ MenuRadioGroup as RadioGroup,
+ type MenuRadioGroupOptions as MenubarRadioGroupOptions,
+ type MenuRadioGroupProps as MenubarRadioGroupProps,
+ MenuRadioItem as RadioItem,
+ type MenuRadioItemOptions as MenubarRadioItemOptions,
+ type MenuRadioItemProps as MenubarRadioItemProps,
+ MenuSub as Sub,
+ MenuSubContent as SubContent,
+ type MenuSubContentOptions as MenubarSubContentOptions,
+ type MenuSubContentProps as MenubarSubContentProps,
+ type MenuSubOptions as MenubarSubOptions,
+ type MenuSubProps as MenubarSubProps,
+ MenuSubTrigger as SubTrigger,
+ type MenuSubTriggerOptions as MenubarSubTriggerOptions,
+ type MenuSubTriggerProps as MenubarSubTriggerProps,
+ MenuTrigger as Trigger,
+ type MenuTriggerOptions as MenubarTriggerOptions,
+ type MenuTriggerProps as MenubarTriggerProps,
+} from "../menu";
+import {
+ PopperArrow as Arrow,
+ type PopperArrowOptions as MenubarArrowOptions,
+ type PopperArrowProps as MenubarArrowProps,
+} from "../popper";
+import {
+ Root as Separator,
+ type SeparatorRootOptions as MenubarSeparatorOptions,
+ type SeparatorRootProps as MenubarSeparatorProps,
+} from "../separator";
+import {
+ MenubarRoot as Root,
+ type MenubarRootOptions,
+ type MenubarRootProps,
+} from "./menubar-root";
+import {
+ MenubarMenu as Menu,
+ type MenubarMenuProps,
+ type MenubarMenuOptions,
+} from "./menubar-menu";
+
+export type {
+ MenubarRootOptions,
+ MenubarRootProps,
+ MenubarMenuOptions,
+ MenubarMenuProps,
+ MenubarArrowOptions,
+ MenubarArrowProps,
+ MenubarCheckboxItemOptions,
+ MenubarCheckboxItemProps,
+ MenubarContentOptions,
+ MenubarContentProps,
+ MenubarGroupLabelProps,
+ MenubarGroupProps,
+ MenubarIconProps,
+ MenubarItemDescriptionProps,
+ MenubarItemIndicatorOptions,
+ MenubarItemIndicatorProps,
+ MenubarItemLabelProps,
+ MenubarItemOptions,
+ MenubarItemProps,
+ MenubarPortalProps,
+ MenubarRadioGroupOptions,
+ MenubarRadioGroupProps,
+ MenubarRadioItemOptions,
+ MenubarRadioItemProps,
+ MenubarSeparatorOptions,
+ MenubarSeparatorProps,
+ MenubarSubContentOptions,
+ MenubarSubContentProps,
+ MenubarSubOptions,
+ MenubarSubProps,
+ MenubarSubTriggerOptions,
+ MenubarSubTriggerProps,
+ MenubarTriggerOptions,
+ MenubarTriggerProps,
+};
+
+export {
+ Arrow,
+ CheckboxItem,
+ Content,
+ Group,
+ GroupLabel,
+ Icon,
+ Item,
+ ItemDescription,
+ ItemIndicator,
+ ItemLabel,
+ Portal,
+ RadioGroup,
+ RadioItem,
+ Root,
+ Menu,
+ Separator,
+ Sub,
+ SubContent,
+ SubTrigger,
+ Trigger,
+};
diff --git a/packages/core/src/menubar/menubar-context.tsx b/packages/core/src/menubar/menubar-context.tsx
new file mode 100644
index 00000000..5cfb8fba
--- /dev/null
+++ b/packages/core/src/menubar/menubar-context.tsx
@@ -0,0 +1,42 @@
+import { Accessor, createContext, Setter, useContext } from "solid-js";
+
+export interface MenubarDataSet {
+ "data-expanded": string | undefined;
+ "data-closed": string | undefined;
+}
+
+export interface MenubarContextValue {
+ dataset: Accessor;
+ value: Accessor;
+ setValue: (next: string | ((prev: string | undefined) => string | undefined) | undefined) => void;
+ menus: Accessor>;
+ menuRefs: Accessor>;
+ lastValue: Accessor;
+ setLastValue: (
+ next: string | ((prev: string | undefined) => string | undefined) | undefined
+ ) => void;
+ registerMenu: (value: string, refs: Array) => void;
+ unregisterMenu: (value: string) => void;
+ nextMenu: () => void;
+ previousMenu: () => void;
+ closeMenu: () => void;
+ setAutoFocusMenu: Setter;
+ autoFocusMenu: Accessor;
+ generateId: (part: string) => string;
+}
+
+export const MenubarContext = createContext();
+
+export function useOptionalMenubarContext() {
+ return useContext(MenubarContext);
+}
+
+export function useMenubarContext() {
+ const context = useOptionalMenubarContext();
+
+ if (context === undefined) {
+ throw new Error("[kobalte]: `useMenubarContext` must be used within a `Menubar` component");
+ }
+
+ return context;
+}
diff --git a/packages/core/src/menubar/menubar-menu.tsx b/packages/core/src/menubar/menubar-menu.tsx
new file mode 100644
index 00000000..32f39b60
--- /dev/null
+++ b/packages/core/src/menubar/menubar-menu.tsx
@@ -0,0 +1,44 @@
+import { mergeDefaultProps } from "@kobalte/utils";
+import { createUniqueId, ParentProps, splitProps } from "solid-js";
+
+import { MenuRoot, MenuRootOptions } from "../menu";
+import { useMenubarContext } from "./menubar-context";
+
+export interface MenubarMenuOptions extends MenuRootOptions {
+ /**
+ * Whether the menu should be the only visible content for screen readers.
+ * When set to `true`:
+ * - interaction with outside elements will be disabled.
+ * - scroll will be locked.
+ * - focus will be locked inside the menu content.
+ * - elements outside the menu content will not be visible for screen readers.
+ * Default false
+ */
+ modal?: boolean;
+}
+
+export interface MenubarMenuProps extends ParentProps {}
+
+/**
+ * Displays a menu to the user —such as a set of actions or functions— triggered by a button.
+ */
+export function MenubarMenu(props: MenubarMenuProps) {
+ const menubarContext = useMenubarContext();
+
+ props = mergeDefaultProps(
+ {
+ modal: false,
+ },
+ props
+ );
+
+ const [local, others] = splitProps(props, ["value"]);
+
+ const uniqueid = createUniqueId();
+
+ const defaultId = menubarContext.generateId(`menubar-menu-${uniqueid}`);
+
+ props = mergeDefaultProps({ id: defaultId }, props);
+
+ return ;
+}
diff --git a/packages/core/src/menubar/menubar-root.tsx b/packages/core/src/menubar/menubar-root.tsx
new file mode 100644
index 00000000..ee3e7c0c
--- /dev/null
+++ b/packages/core/src/menubar/menubar-root.tsx
@@ -0,0 +1,196 @@
+/*!
+ * Portions of this file are based on code from radix-ui-primitives.
+ * MIT Licensed, Copyright (c) 2022 WorkOS.
+ *
+ * Credits to the Radix UI team:
+ * https://github.com/radix-ui/primitives/blob/ea6376900d54af536dbb7b71b4fefd6ec2ce9dc0/packages/react/menubar/src/Menubar.tsx
+ */
+
+import {
+ mergeDefaultProps,
+ OverrideComponentProps,
+ contains,
+ createGenerateId,
+ mergeRefs,
+} from "@kobalte/utils";
+import {
+ Accessor,
+ createEffect,
+ createMemo,
+ createSignal,
+ createUniqueId,
+ onCleanup,
+ splitProps,
+} from "solid-js";
+import { isServer } from "solid-js/web";
+
+import { AsChildProp, Polymorphic } from "../polymorphic";
+import { createControllableSignal, createInteractOutside } from "../primitives";
+import { MenubarContext, MenubarContextValue, MenubarDataSet } from "./menubar-context";
+
+export interface MenubarRootOptions extends AsChildProp {
+ /** The value of the menu that should be open when initially rendered. Use when you do not need to control the value state. */
+ defaultValue?: string;
+
+ /** The controlled value of the menu to open. Should be used in conjunction with onValueChange. */
+ value?: string;
+
+ /** Event handler called when the value changes. */
+ onValueChange?: (value: string | undefined) => void;
+
+ /** When true, keyboard navigation will loop from last item to first, and vice versa. (default: true) */
+ loop?: boolean;
+
+ /** When true, click on alt by itsef will focus this Menubar (some browsers interfere) */
+ focusOnAlt?: boolean;
+}
+
+export interface MenubarRootProps extends OverrideComponentProps<"div", MenubarRootOptions> {}
+
+/**
+ * A visually persistent menu common in desktop applications that provides quick access to a consistent set of commands.
+ */
+export function MenubarRoot(props: MenubarRootProps) {
+ let ref: HTMLDivElement | undefined;
+ const defaultId = `menubar-${createUniqueId()}`;
+
+ props = mergeDefaultProps({ id: defaultId, loop: true }, props);
+
+ const [local, others] = splitProps(props, [
+ "ref",
+ "value",
+ "defaultValue",
+ "onValueChange",
+ "loop",
+ "focusOnAlt",
+ ]);
+
+ const [value, setValue] = createControllableSignal({
+ value: () => local.value,
+ defaultValue: () => local.defaultValue,
+ onChange: value => local.onValueChange?.(value),
+ });
+
+ const [lastValue, setLastValue] = createSignal();
+
+ const [menuRefs, setMenuRefs] = createSignal