On this pageOverview
Menu
A dropdown menu for actions, like a macOS context menu. Menu is fire-and-forget: it doesn’t track a selected value (use Listbox for persistent selection). It supports typeahead search, drag-to-select, keyboard navigation, grouped items, and anchor positioning.
For programmatic control in update functions, use the factory’s open(model), close(model), and selectItem(model, item, index) methods. Each returns the same [Model, Commands, Option<OutMessage>] tuple as update.
See it in an app
Check out how Menu is wired up in a real Foldkit app.
Pair view and update behind Ui.Menu.create<Item>() at module scope. The factory threads your item union through both, so Selected({ value, index }) carries the picked value directly. Menu closes automatically after selection.
// Pseudocode walkthrough of the Foldkit integration points. Each labeled
// block below is an excerpt. Fit them into your own Model, init, Message,
// update, and view definitions.
import { Effect, Match as M, Option } from 'effect'
import { Command, Ui } from 'foldkit'
import { html } from 'foldkit/html'
import { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'
// Add a field to your Model for the Menu Submodel:
const Model = S.Struct({
menu: Ui.Menu.Model,
// ...your other fields
})
// In your init function, initialize the Menu Submodel with a unique id:
const init = () => [
{
menu: Ui.Menu.init({ id: 'actions' }),
// ...your other fields
},
[],
]
// Embed the Menu Message in your parent Message:
const GotMenuMessage = m('GotMenuMessage', {
message: Ui.Menu.Message,
})
type Action = 'Edit' | 'Duplicate' | 'Archive' | 'Delete'
const actions: ReadonlyArray<Action> = [
'Edit',
'Duplicate',
'Archive',
'Delete',
]
// Pair view and update behind a single Item-typed factory at module scope:
const ActionMenu = Ui.Menu.create<Action>()
// Inside your update function's M.tagsExhaustive({...}), delegate to
// ActionMenu.update. The OutMessage's `Selected` carries the picked item
// directly (typed as `Action`):
GotMenuMessage: ({ message }) => {
const [nextMenu, commands, maybeOutMessage] = ActionMenu.update(
model.menu,
message,
)
const mappedCommands = Command.mapMessages(commands, message =>
GotMenuMessage({ message }),
)
return Option.match(maybeOutMessage, {
onNone: () => [evo(model, { menu: () => nextMenu }), mappedCommands],
onSome: M.type<Ui.Menu.OutMessage<Action>>().pipe(
M.tagsExhaustive({
Selected: ({ value }) => {
// The child has emitted `Selected`. The body commits the
// child's next state as usual. In this arm the parent can
// also update its own state or dispatch its own Commands,
// for example transition a page, mutate domain state, or
// trigger a downstream Command.
return [evo(model, { menu: () => nextMenu }), mappedCommands]
},
}),
),
})
}
// Inside your view function, render the menu via the factory's view:
const view = () => {
const h = html<Message>()
return h.submodel({
slotId: 'menu',
model: model.menu,
view: ActionMenu.view,
viewInputs: {
items: actions,
buttonContent: h.span([], ['Options']),
buttonClassName: 'rounded-lg border px-3 py-2 cursor-pointer',
itemsClassName: 'rounded-lg border shadow-lg',
itemToConfig: (action, { isActive }) => ({
className: isActive ? 'bg-blue-100' : '',
content: h.div([h.Class('px-3 py-2')], [action]),
}),
isItemDisabled: action => action === 'Archive',
backdropClassName: 'fixed inset-0',
anchor: { placement: 'bottom-start', gap: 4, padding: 8 },
},
toParentMessage: message => GotMenuMessage({ message }),
})
}Pass isAnimated: true at init for animation coordination.
// Pseudocode walkthrough using the same Model, Messages, and update as
// the basic menu; only init and view change. Each labeled block below is
// an excerpt.
import { Ui } from 'foldkit'
import { html } from 'foldkit/html'
import { m } from 'foldkit/message'
// Only init and view differ from the basic menu: init adds isAnimated, the
// view uses data-[closed] selectors for enter/leave transitions.
// In your init function, set isAnimated: true to coordinate CSS transitions:
const init = () => [
{
menu: Ui.Menu.init({ id: 'actions', isAnimated: true }),
// ...your other fields
},
[],
]
// Embed the Menu Message in your parent Message:
const GotMenuMessage = m('GotMenuMessage', {
message: Ui.Menu.Message,
})
// Pair view and update behind a single Item-typed factory at module scope:
const ActionMenu = Ui.Menu.create<Action>()
// Inside your view function, use data-[closed] for enter/leave transitions:
const view = () => {
const h = html<Message>()
return h.submodel({
slotId: 'menu',
model: model.menu,
view: ActionMenu.view,
viewInputs: {
items: actions,
buttonContent: h.span([], ['Options']),
buttonClassName: 'rounded-lg border px-3 py-2 cursor-pointer',
itemsClassName:
'rounded-lg border shadow-lg transition duration-150 ease-out data-[closed]:opacity-0 data-[closed]:scale-95',
itemToConfig: (action, { isActive }) => ({
className: isActive ? 'bg-blue-100' : '',
content: h.div([h.Class('px-3 py-2')], [action]),
}),
backdropClassName: 'fixed inset-0',
anchor: { placement: 'bottom-start', gap: 4, padding: 8 },
},
toParentMessage: message => GotMenuMessage({ message }),
})
}Menu is headless. The itemToConfig callback controls all item markup. Group items with itemGroupKey and groupToHeading.
When isAnimated is true, enter/leave animations flow through the Animation module. Style with CSS transitions or CSS keyframe animations. Animation advances once every animation on the element has settled.
| Attribute | Condition |
|---|---|
data-open | Present on the button when the menu is open. |
data-active | Present on the highlighted menu item. |
data-disabled | Present on disabled menu items. |
data-closed | Present during close animation. |
Menu uses aria-activedescendant. Focus stays on the items container while arrow keys update the highlighted item. Typeahead search accumulates characters for 350ms.
| Key | Description |
|---|---|
| Enter / Space | Opens the menu (from button) or selects the active item. |
| Arrow Down | Opens with first item active (from button) or moves to next item. |
| Arrow Up | Opens with last item active (from button) or moves to previous item. |
| Home / End | Moves to the first / last item. |
| Escape | Closes the menu and returns focus to the button. |
| Type a character | Typeahead search: jumps to the matching item. |
The button receives aria-haspopup="menu" and aria-expanded. The items container receives role="menu" with aria-activedescendant. Each item receives role="menuitem".
Configuration object passed to Menu.init().
| Name | Type | Default | Description |
|---|---|---|---|
id | string | - | Unique ID for the menu instance. |
isAnimated | boolean | false | Enables animation coordination. |
isModal | boolean | false | Locks page scroll and marks other elements inert when open. |
Configuration object passed to Menu.view().
| Name | Type | Default | Description |
|---|---|---|---|
model | Menu.Model | - | The menu state from your parent Model. |
toParentMessage | (childMessage: Menu.Message) => ParentMessage | - | Wraps Menu Messages in your parent Message type for Submodel delegation. |
items | ReadonlyArray<Item> | - | The list of menu items. |
itemToConfig | (item, context) => ItemConfig | - | Maps each item to its className and content. The context provides isActive and isDisabled. |
buttonContent | Html | - | Content rendered inside the trigger button. |
isItemDisabled | ((item, index) => boolean) | undefined | - | Disables individual menu items. |
itemToSearchText | ((item, index) => string) | undefined | - | Optional override for the string typeahead matches against. Defaults to the item itself; override when items carry searchable text distinct from their display content. |
isButtonDisabled | boolean | undefined | - | Disables the trigger button entirely. The menu cannot be opened while true. |
itemGroupKey | ((item, index) => string) | undefined | - | Groups contiguous items by key. |
groupToHeading | ((groupKey) => GroupHeading | undefined) | undefined | - | Renders a heading for each group. |
anchor | AnchorConfig | undefined | - | Floating positioning config: placement, gap, and padding. |
buttonClassName | string | undefined | - | CSS class for the trigger button. |
buttonAttributes | ReadonlyArray<ChildAttribute> | undefined | - | Extra attributes spread onto the trigger button alongside its built-in click/keyboard handlers and aria-* attributes. |
itemsClassName | string | undefined | - | CSS class for the items container (the panel root). |
itemsAttributes | ReadonlyArray<ChildAttribute> | undefined | - | Extra attributes spread onto the items container. |
itemsScrollClassName | string | undefined | - | CSS class for the inner scrollable wrapper around the item list. Useful for setting max-height/overflow without restyling the panel root. |
itemsScrollAttributes | ReadonlyArray<ChildAttribute> | undefined | - | Extra attributes spread onto the inner scrollable wrapper. |
backdropClassName | string | undefined | - | CSS class for the backdrop. |
backdropAttributes | ReadonlyArray<ChildAttribute> | undefined | - | Extra attributes spread onto the backdrop element. |
groupClassName | string | undefined | - | CSS class applied to each group wrapper. |
groupAttributes | ReadonlyArray<ChildAttribute> | undefined | - | Extra attributes spread onto each group wrapper. |
separatorClassName | string | undefined | - | CSS class applied to the separator rendered between adjacent groups. |
separatorAttributes | ReadonlyArray<ChildAttribute> | undefined | - | Extra attributes spread onto each group separator. |
className | string | undefined | - | CSS class applied to the outer Menu root element. |
attributes | ReadonlyArray<ChildAttribute> | undefined | - | Extra attributes spread onto the outer Menu root element. |
Messages emitted to the parent through the third element of [Model, Commands, Option<OutMessage>]. Pattern-match on the OutMessage in your update handler.
| Name | Type | Default | Description |
|---|---|---|---|
Selected | { value: Item; index: number } | - | Emitted when a menu item is selected. Carries both the value (typed as your `Item` union via `Menu.create<Item>()`) and its index into the items array supplied at view time. Menu closes itself on selection; the parent does not need to dispatch Menu.close. Pattern-match the third tuple element of Menu.update in your GotMenuMessage handler to dispatch the corresponding domain action. |