@spectrum-css/menu
Version:
The Spectrum CSS menu component
778 lines (763 loc) • 15.6 kB
JavaScript
import { Template as ActionButton } from "@spectrum-css/actionbutton/stories/template.js";
import { Template as Checkbox } from "@spectrum-css/checkbox/stories/template.js";
import { Template as Divider } from "@spectrum-css/divider/stories/template.js";
import { Template as Icon } from "@spectrum-css/icon/stories/template.js";
import { Template as Popover } from "@spectrum-css/popover/stories/template.js";
import { Container, getRandomId } from "@spectrum-css/preview/decorators";
import { Template as Switch } from "@spectrum-css/switch/stories/template.js";
import { Template as Tray } from "@spectrum-css/tray/stories/template.js";
import { html } from "lit";
import { classMap } from "lit/directives/class-map.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { styleMap } from "lit/directives/style-map.js";
import { when } from "lit/directives/when.js";
import "../index.css";
import "../themes/spectrum.css";
/* Must be imported last */
import "../themes/express.css";
/**
* Get the tray submenu back arrow name with scale number (defined in design spec).
*/
const iconWithScale = (size = "m", iconName = "ArrowLeft") => {
switch (size) {
case "s":
return `${iconName}200`;
case "l":
return `${iconName}400`;
case "xl":
return `${iconName}500`;
default:
return `${iconName}300`;
}
};
export const MenuItem = (
{
rootClass = "spectrum-Menu-item",
label,
description,
iconName,
iconSet = "workflow",
hasActions = false,
id = getRandomId("menuitem"),
idx = 0,
isActive = false,
isCollapsible = false,
isDisabled = false,
isDrillIn = false,
isFocused = false,
isHighlighted = false,
isHovered = false,
isOpen = false,
isSelected = false,
items = [],
role = "menuitem",
shouldTruncate = false,
size = "m",
selectionMode = "none",
value,
customClasses = [],
customStyles = {},
} = {},
context = {},
) => html`
<li
class=${classMap({
[rootClass]: true,
"is-highlighted": isHighlighted,
"is-active": isActive,
"is-focus-visible": isFocused,
"is-selected": isSelected,
"is-disabled": isDisabled,
"is-hover": isHovered,
[`${rootClass}--drillIn`]: isDrillIn,
[`${rootClass}--collapsible`]: isCollapsible,
"is-open": isOpen,
...customClasses.reduce((a, c) => ({ ...a, [c]: true }), {}),
})}
style=${styleMap(customStyles)}
id=${ifDefined(id)}
role=${ifDefined(role)}
aria-selected=${isSelected ? "true" : "false"}
aria-disabled=${isDisabled ? "true" : "false"}
tabindex=${ifDefined(!isDisabled ? "0" : undefined)}
>
${when(isCollapsible || (selectionMode == "single" && isSelected), () =>
Icon(
{
iconName: iconWithScale(
size,
isCollapsible ? "ChevronRight" : "Checkmark",
),
setName: "ui",
useRef: false,
size,
customClasses: [
`${rootClass}Icon`,
isCollapsible ? "spectrum-Menu-chevron" : "spectrum-Menu-checkmark",
],
},
context,
),
)}
${when(selectionMode === "multiple" && !hasActions, () =>
Checkbox(
{
size,
isEmphasized: true,
isChecked: isSelected,
isDisabled,
id: `menu-checkbox-${idx}`,
customClasses: [`${rootClass}Checkbox`],
},
context,
),
)}
${when(iconName, () =>
Icon(
{
iconName,
setName: iconSet,
size,
customClasses: [`${rootClass}Icon`, `${rootClass}Icon--workflowIcon`],
},
context,
),
)}
${when(
isCollapsible,
() => html`
<span
class=${classMap({
["spectrum-Menu-sectionHeading"]: true,
[`${rootClass}Label--truncate`]: shouldTruncate,
})}
>
${label}
</span>
`,
() => html`
<span
class=${classMap({
[`${rootClass}Label`]: true,
["spectrum-Switch-label"]: hasActions,
[`${rootClass}Label--truncate`]: shouldTruncate,
})}
>
${label}
</span>
`,
)}
${when(
description,
() => html`
<span
class=${classMap({
[`${rootClass}Description`]: true,
})}
>
${description}
</span>
`,
)}
${when(
value,
() => html`
<span
class=${classMap({
[`${rootClass}Value`]: true,
})}
>
${value}
</span>
`,
)}
${when(
hasActions && selectionMode == "multiple",
() => html`
<div
class=${classMap({
[`${rootClass}Actions`]: true,
})}
>
${Switch(
{
size,
isChecked: isSelected,
isDisabled,
label: null,
id: `menu-switch-${idx}`,
customClasses: [`${rootClass}Switch`],
},
context,
)}
</div>
`,
)}
${when(isDrillIn, () =>
Icon(
{
iconName: iconWithScale(size, "ChevronRight"),
setName: "ui",
useRef: false,
size,
customClasses: [`${rootClass}Icon`, "spectrum-Menu-chevron"],
},
context,
),
)}
${when(isCollapsible && items.length > 0, () =>
Template(
{
items,
isOpen,
size,
shouldTruncate,
},
context,
),
)}
</li>
`;
export const MenuGroup = (
{
hasActions = false,
heading,
id = getRandomId("menugroup"),
idx = 0,
items = [],
isDisabled = false,
isSelectable = false,
isTraySubmenu = false,
shouldTruncate = false,
subrole = "menuitem",
size = "m",
selectionMode = "none",
customStyles = {},
} = {},
context = {},
) => html`
<li id=${ifDefined(id)} role="presentation">
${when(
!isTraySubmenu,
() => html`
<span
class=${classMap({
["spectrum-Menu-sectionHeading"]: true,
["spectrum-Menu-itemLabel--truncate"]: shouldTruncate,
})}
id=${ifDefined(id ?? `menu-heading-category-${idx}`)}
aria-hidden="true"
>
${heading}
</span>
`,
() => html`
<div
class=${classMap({
["spectrum-Menu-back"]: true,
})}
>
<button
aria-label="Back to previous menu"
class="spectrum-Menu-backButton"
type="button"
role="menuitem"
>
${Icon(
{
iconName: iconWithScale(size),
setName: "ui",
size,
customClasses: ["spectrum-Menu-backIcon"],
},
context,
)}
</button>
${when(
heading,
() => html`
<span
class=${classMap({
["spectrum-Menu-sectionHeading"]: true,
["spectrum-Menu-itemLabel--truncate"]: shouldTruncate,
})}
style=${styleMap(customStyles)}
id=${id}
aria-hidden="true"
>
${heading}
</span>
`,
)}
</div>
`,
)}
${Template(
{
role: "group",
subrole,
labelledby: id,
hasActions,
items,
isDisabled,
isSelectable,
selectionMode,
shouldTruncate: true,
size,
},
context,
)}
</li>
`;
export const Template = (
{
rootClass = "spectrum-Menu",
customClasses = [],
customStyles = {},
id = getRandomId("menu"),
hasActions = false,
hasDividers = false,
isDisabled = false,
isOpen = false,
isTraySubmenu = false,
items = [],
labelledby = getRandomId("menu-label"),
role = "menu",
selectionMode = "none",
singleItemValue,
shouldTruncate,
size = "m",
subrole = "menuitem",
} = {},
context = {},
) => {
const menuMarkup = html`
<ul
class=${classMap({
[rootClass]: true,
[`${rootClass}--size${size?.toUpperCase()}`]:
typeof size !== "undefined",
"is-selectable": selectionMode === "single",
"is-selectableMultiple": selectionMode === "multiple",
"is-open": isOpen,
...customClasses.reduce((a, c) => ({ ...a, [c]: true }), {}),
})}
id=${ifDefined(id)}
role=${ifDefined(role)}
aria-labelledby=${ifDefined(labelledby)}
aria-disabled=${isDisabled ? "true" : "false"}
style=${styleMap(customStyles)}
>
${items.map((i, idx) => {
if (i.type === "divider")
return html`${hasDividers
? Divider({
tag: "li",
size: "s",
customClasses: [`${rootClass}-divider`],
})
: ""}`;
else if (i.heading || i.isTraySubmenu)
return MenuGroup(
{
...i,
subrole,
size,
selectionMode,
isTraySubmenu,
shouldTruncate,
},
context,
);
else
return MenuItem(
{
...i,
hasActions,
idx,
isDisabled: isDisabled || i.isDisabled,
role: subrole,
rootClass: `${rootClass}-item`,
selectionMode,
shouldTruncate,
size,
value: singleItemValue || i.value,
},
context,
);
})}
</ul>
`;
if (isTraySubmenu) return Tray({
isOpen: true,
content: [
menuMarkup
],
}, context);
return menuMarkup;
};
export const DisabledItemGroup = (args, context) => {
const groupData = [
{
heading: "Menu with icons",
items: [
{
label: "Cut",
iconName: "Cut",
},
{
label: "Copy",
iconName: "Copy",
},
{
label: "Paste",
iconName: "Paste",
isDisabled: true,
}
],
},
{
heading: "Menu with descriptions",
items: [
{
label: "Quick export",
description: "Share a snapshot",
},
{
label: "Open a copy",
description: "Illustrator for iPad",
},
{
label: "Share link",
description: "Enable comments and download",
isDisabled: true,
}
]
},
{
heading: "Menu with icons & descriptions",
items: [
{
label: "Quick export",
description: "Share a snapshot",
iconName: "Export",
},
{
label: "Open a copy",
description: "Illustrator for iPad",
iconName: "FolderOpen",
},
{
label: "Share link",
description: "Enable comments and download",
iconName: "Share",
isDisabled: true,
}
]
}
];
return Container({
withBorder: false,
content: groupData.map((group) => html`
${Container({
heading: group.heading,
content: html`
${Template({
...args,
context,
shouldTruncate: group.shouldTruncate || false,
items: group.items,
})}
`
}, context)}
`)
}, context);
};
export const OverflowGroup = (args, context) => {
const groupData = [
{
heading: "Text overflow without descriptions",
items: [
{ label: "Small (works best for mobile phones)" },
{ label: "Medium (all purpose)" },
{ label: "Large (works best for printing)" }
],
},
{
heading: "Text overflow with descriptions",
items: [
{
label: "Small (works best for mobile phones)",
description: "A small description about small is here",
},
{
label: "Medium (all purpose)",
description: "A medium description about medium is here",
},
{
label: "Large (works best for printing)",
description: "A large description about large is here",
}
],
},
{
heading: "Text truncation with descriptions",
shouldTruncate: true,
items: [
{
label: "Small (works best for mobile phones)",
description: "A small description about small is here",
},
{
label: "Medium (all purpose)",
description: "A medium description about medium is here",
},
{
label: "Large (works best for printing)",
description: "A large description about large is here",
}
],
},
{
heading: "Text truncation for section headings",
shouldTruncate: true,
items: [
{
idx: 1,
heading: "Section heading with longer text that truncates",
id: "menu-heading",
items: [
{
label: "Small (works best for mobile phones)",
},
{
label: "Medium (all purpose)",
},
{
label: "Large (works best for printing)",
}
]
}
]
},
{
heading: "Text truncation with drill-ins and values",
shouldTruncate:true,
items: [
{
label: "Quick export truncated text",
iconName: "Export",
description: "Share a low-res snapshot",
},
{
label: "Open a copy truncated text",
iconName: "Copy",
description: "Illustrator for iPad or desktop",
isDrillIn: true,
},
{
label: "Preview timelapse truncated text",
iconName: "Pending",
value: "Value",
}
]
}
];
return Container({
withBorder: false,
content: groupData.map((group) => html`
${Container({
heading: group.heading,
content: html`
${Template({
...args,
context,
shouldTruncate: group.shouldTruncate || false,
items: group.items,
})}
`
})}
`)
});
};
export const SelectionGroup = (args, context) => {
const groupData = [
{
heading: "No selection",
items: [
{ label: "Cut" },
{ label: "Copy" },
{ label: "Paste" },
],
},
{
heading: "Single selection",
selectionMode: "single",
items: [
{
label: "Marquee",
isSelected: true,
},
{
label: "Add",
},
{
label: "Subtract",
}
],
},
{
heading: "Multiple selection with checkboxes",
selectionMode: "multiple",
items: [
{
label: "Marquee",
isSelected: true,
},
{
label: "Add",
},
{
label: "Subtract",
}
],
},
{
heading: "Multiple selection with checkboxes and icons",
selectionMode: "multiple",
items: [
{
label: "Marquee",
iconName: "Selection",
isSelected: true,
},
{
label: "Add",
iconName: "SelectAdd",
},
{
label: "Subtract",
iconName: "SelectSubtract",
}
],
},
{
heading: "Multiple selection with switches",
selectionMode: "multiple",
hasActions: true,
items: [
{
label: "Guides",
isSelected: true,
},
{
label: "Grid",
},
{
label: "Rulers",
isSelected: true,
}
],
},
{
heading: "Multiple selection with switches and icons",
selectionMode: "multiple",
hasActions: true,
items: [
{
label: "Marquee",
iconName: "Selection",
isSelected: true,
},
{
label: "Add",
iconName: "SelectAdd",
},
{
label: "Subtract",
iconName: "SelectSubtract",
}
],
},
];
return Container({
withBorder: false,
content: groupData.map((group) => Container({
heading: group.heading,
content: Template({
...args,
context,
selectionMode: group.selectionMode || "none",
hasActions: group.hasActions || false,
items: group.items,
})
}, context))
});
};
export const SubmenuInPopover = (context) => Popover({
isOpen: true,
position: "end-top",
customStyles: {
"inline-size": "200px",
},
trigger: (args, context) => ActionButton({
label: "Settings",
iconName: "Settings",
...args,
}, context),
content: [
(args, context) => Template({
items: [
{
label: "Language",
value: "English (US)",
isDrillIn: true,
isHovered: true,
},
{
label: "Notifications",
},
{
label: "Show grid",
}
],
...args
}, context),
(args, context) => Popover({
isOpen: true,
position: "end-top",
customStyles: {
top: "-120px",
"inline-size": "120px",
},
content: [
(args, context) => Template({
selectionMode: "single",
items: [
{
label: "Deutsch",
},
{
label: "English (US)",
isSelected: true,
},
{
label: "Español",
},
{
label: "Français",
},
{
label: "Italiano",
},
{
label: "日本語",
}
],
...args,
}, context)
],
...args,
}, context)
],
}, context);