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 ( + + + Git + + + + Commit
⌘+K
+
+ + Push
⇧+⌘+K
+
+ + Update Project
⌘+T
+
+ + + GitHub +
+ +
+
+ + + Create Pull Request… + View Pull Requests + Sync Fork + + Open on GitHub + + +
+ + + + + + + + Show Git Log + + + + + + Show History + + + + + + + Branches + + + + + + + main + + + + + + develop + + + +
+
+
+ + + File + + + + New Tab
⌘+T
+
+ + New Window
⌘+N
+
+ + New Incognito Window + + + + + + + Share +
+ +
+
+ + + Email Link + Messages + Notes + + +
+ + + + + Print...
⌘+P
+
+
+
+
+ + + Edit + + + + Undo
⌘+Z
+
+ + Redo
⇧+⌘+Z
+
+ + + + + + Find +
+ +
+
+ + + Search The Web + + Find... + Find Next + Find Previous + + +
+ + + + Cut + Copy + Paste +
+
+
+
+ ); +} 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 ( + + + + Git + + + + + Commit + + + Push + + + Update Project + + + + GitHub + + + + + + Create Pull Request… + + + View Pull Requests + + Sync Fork + + + Open on GitHub + + + + + + + + + + + + Show Git Log + + + + + + Show History + + + + + + + Branches + + + + + + + main + + + + + + develop + + + + + + + + + + File + + + + + New Tab + + + New Window + + + New Incognito Window + + + + + + + Share + + + + + + Email Link + + + Messages + + + Notes + + + + + + + + + Print... + + + + + + + + Edit + + + + + Undo + + + Redo + + + + + + + Find + + + + + + Search The Web + + + + Find... + + + Find Next + + + Find Previous + + + + + + + + + Cut + + + Copy + + + Paste + + + + + + ); + } + ``` + + + + ```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>>( + new Map>() + ); + + const dataset: Accessor = createMemo(() => ({ + "data-expanded": value() !== undefined ? "" : undefined, + "data-closed": value() === undefined ? "" : undefined, + })); + + const [autoFocusMenu, setAutoFocusMenu] = createSignal(false); + + const context: MenubarContextValue = { + dataset, + value, + setValue, + lastValue, + setLastValue, + menus: () => new Set([...menuRefs().keys()]), + menuRefs: () => [...menuRefs().values()].flat(), + registerMenu: (value, refs) => { + setMenuRefs(prev => { + prev.set(value, refs); + return prev; + }); + }, + unregisterMenu: (value: string) => { + setMenuRefs(prev => { + prev.delete(value); + return prev; + }); + }, + nextMenu: () => { + const menusArray = [...menuRefs().keys()]; + + if (value() === undefined) { + setValue(menusArray[0]); + return; + } + + const currentIndex = menusArray.indexOf(value()!); + + if (currentIndex === menusArray.length - 1) { + if (local.loop) setValue(menusArray[0]); + return; + } + + setValue(menusArray[currentIndex + 1]); + }, + previousMenu: () => { + const menusArray = [...menuRefs().keys()]; + + if (value() === undefined) { + setValue(menusArray[0]); + return; + } + + const currentIndex = menusArray.indexOf(value()!); + + if (currentIndex === 0) { + if (local.loop) setValue(menusArray[menusArray.length - 1]); + return; + } + + setValue(menusArray[currentIndex - 1]); + }, + closeMenu: () => { + setAutoFocusMenu(false); + setValue(undefined); + }, + autoFocusMenu, + setAutoFocusMenu, + generateId: createGenerateId(() => others.id!), + }; + createInteractOutside( + { + onInteractOutside: () => { + context.closeMenu(); + }, + shouldExcludeElement: element => { + return [ref, ...menuRefs().values()].flat().some(ref => contains(ref, element)); + }, + }, + () => ref + ); + + const keydownHandler = (e: KeyboardEvent) => { + if (e.key === "Alt") { + e.preventDefault(); + e.stopPropagation(); + if (context.value() === undefined) context.nextMenu(); + else context.closeMenu(); + } + }; + + createEffect(() => { + if (isServer) return; + if (local.focusOnAlt) window.addEventListener("keydown", keydownHandler); + else window.removeEventListener("keydown", keydownHandler); + }); + + createEffect(() => { + if (value() !== undefined) setLastValue(value()); + }); + + onCleanup(() => { + if (isServer) return; + window.removeEventListener("keydown", keydownHandler); + }); + + return ( + + (ref = el), local.ref)} + {...others} + role="menubar" + data-orientation="horizontal" + /> + + ); +} diff --git a/packages/core/src/menubar/menubar.test.tsx b/packages/core/src/menubar/menubar.test.tsx new file mode 100644 index 00000000..a675bebc --- /dev/null +++ b/packages/core/src/menubar/menubar.test.tsx @@ -0,0 +1,241 @@ +import { fireEvent, render, screen } from "@solidjs/testing-library"; + +import * as Menubar from "."; + +const commonUI = () => ( + <> + + + Test 1 + + + + Item 1 + + Item 2 + + + {"Sub 3 >"} + + + Item 4 + + Item 5 + + + + + + + + + Test 2 + + + + Item A + + Item B + + + {"Sub C >"} + + + Item D + + Item E + + + + + + + + + Test 3 + + + + Item Z + + Item Y + + + {"Sub X >"} + + + Item W + + Item V + + + + + + + + + External + +); + +describe("Menubar", () => { + it("renders correctly", async () => { + // Can't be tested as jsdom doesn't support onPointer events. + // Test code should be valid for the future. + if (!!true) return; + + render(commonUI); + + expect(screen.getByText("Test 1")).toBeVisible(); + expect(screen.getByText("Test 2")).toBeVisible(); + expect(screen.getByText("Test 3")).toBeVisible(); + + screen.getByText("Test 1").click(); + + expect(screen.getByText("Test 1")).toHaveAttribute("data-highlighted", "true"); + + expect(screen.getByText("Item 1")).toBeVisible(); + expect(screen.getByText("Item 2")).toBeVisible(); + expect(screen.getByText("Sub 3")).toBeVisible(); + + screen.getByText("Test 2").click(); + + expect(screen.getByText("Test 1")).not.toHaveAttribute("data-highlighted", "true"); + expect(screen.getByText("Test 2")).toHaveAttribute("data-highlighted", "true"); + + expect(screen.queryByText("Item 1")).not.toBeInTheDocument(); + expect(screen.queryByText("Item 2")).not.toBeInTheDocument(); + expect(screen.queryByText("Sub 3")).not.toBeInTheDocument(); + + expect(screen.getByText("Item A")).toBeVisible(); + expect(screen.getByText("Item B")).toBeVisible(); + expect(screen.getByText("Sub C")).toBeVisible(); + + fireEvent.click(screen.getByText("Sub C")); + + expect(screen.getByText("Item D")).toBeVisible(); + expect(screen.getByText("Item E")).toBeVisible(); + + fireEvent.click(screen.getByText("External")); + + expect(screen.getByText("Test 2")).not.toHaveAttribute("data-highlighted", "true"); + + expect(screen.queryByText("Item A")).not.toBeInTheDocument(); + expect(screen.queryByText("Item B")).not.toBeInTheDocument(); + expect(screen.queryByText("Sub C")).not.toBeInTheDocument(); + }); + + it("handles keyboard navigation correctly", async () => { + // Can't be tested as jsdom doesn't support onPointer events. + // Test code should be valid for the future. + if (!!true) return; + + render(commonUI); + + expect(screen.getByText("Test 1")).toHaveAttribute("tabindex", "0"); + expect(screen.getByText("Test 2")).toHaveAttribute("tabindex", "-1"); + expect(screen.getByText("Test 3")).toHaveAttribute("tabindex", "-1"); + + expect(screen.getByText("Test 1")).not.toHaveAttribute("data-highlighted", "true"); + + fireEvent.focus(screen.getByText("Test 1")); + + expect(screen.queryByText("Item 1")).not.toBeInTheDocument(); + + expect(screen.getByText("Test 1")).toHaveAttribute("data-highlighted", "true"); + + fireEvent.keyPress(screen.getByText("Test 1"), { key: "ArrowRight", code: "ArrowRight" }); + + expect(screen.queryByText("Item A")).not.toBeInTheDocument(); + + expect(screen.getByText("Test 1")).not.toHaveAttribute("data-highlighted", "true"); + expect(screen.getByText("Test 2")).toHaveAttribute("data-highlighted", "true"); + + expect(screen.getByText("Test 1")).toHaveAttribute("tabindex", "-1"); + expect(screen.getByText("Test 2")).toHaveAttribute("tabindex", "0"); + + expect(screen.getByText("Test 2")).toHaveFocus(); + + fireEvent.keyPress(screen.getByText("Test 2"), { key: "ArrowRight", code: "ArrowRight" }); + + expect(screen.queryByText("Item Z")).not.toBeInTheDocument(); + + expect(screen.getByText("Test 2")).not.toHaveAttribute("data-highlighted", "true"); + expect(screen.getByText("Test 3")).toHaveAttribute("data-highlighted", "true"); + + expect(screen.getByText("Test 2")).toHaveAttribute("tabindex", "-1"); + expect(screen.getByText("Test 3")).toHaveAttribute("tabindex", "0"); + + expect(screen.getByText("Test 3")).toHaveFocus(); + + fireEvent.keyPress(screen.getByText("Test 3"), { key: "ArrowRight", code: "ArrowRight" }); + + expect(screen.getByText("Test 1")).toHaveFocus(); + + fireEvent.keyPress(screen.getByText("Test 1"), { key: "ArrowDown", code: "ArrowDown" }); + + expect(screen.getByText("Item 1")).toBeVisible(); + + fireEvent.keyPress(document.activeElement as Element, { + key: "ArrowRight", + code: "ArrowRight", + }); + + expect(screen.getByText("Item A")).toBeVisible(); + + fireEvent.keyPress(document.activeElement as Element, { key: "ArrowDown", code: "ArrowDown" }); + + expect(screen.getByText("Item A")).toHaveFocus(); + + fireEvent.keyPress(document.activeElement as Element, { key: "ArrowDown", code: "ArrowDown" }); + fireEvent.keyPress(document.activeElement as Element, { key: "ArrowDown", code: "ArrowDown" }); + + expect(screen.getByText("Sub C")).toHaveFocus(); + + fireEvent.keyPress(document.activeElement as Element, { + key: "ArrowRight", + code: "ArrowRight", + }); + + expect(screen.getByText("Item D")).toHaveFocus(); + + fireEvent.keyPress(document.activeElement as Element, { key: "ArrowLeft", code: "ArrowLeft" }); + + expect(screen.getByText("Sub C")).toHaveFocus(); + + fireEvent.keyPress(document.activeElement as Element, { + key: "ArrowRight", + code: "ArrowRight", + }); + fireEvent.keyPress(document.activeElement as Element, { + key: "ArrowRight", + code: "ArrowRight", + }); + + expect(screen.getByText("Item Z")).toBeVisible(); + }); + + it("handles hover correctly", async () => { + // Can't be tested as jsdom doesn't support onPointer events. + // Test code should be valid for the future. + if (!!true) return; + + render(commonUI); + + fireEvent.mouseEnter(screen.getByText("Test 2")); + + expect(screen.getByText("Test 1")).toHaveAttribute("tabindex", "0"); + + expect(screen.queryByText("Item A")).not.toBeInTheDocument(); + + screen.getByText("Test 1").click(); + + expect(screen.getByText("Item 1")).toBeVisible(); + + screen.getByText("Test 2").click(); + + expect(screen.queryByText("Item 1")).not.toBeInTheDocument(); + expect(screen.getByText("Item A")).toBeVisible(); + }); +});