From 75c284c34dd2774c5d2fd2d74e1be468a08e549d Mon Sep 17 00:00:00 2001 From: castastrophe Date: Fri, 19 Sep 2025 12:40:47 -0400 Subject: [PATCH 1/2] feat(actionmenu): migrate to s2 tokens and styling --- .changeset/brave-bikes-teach.md | 64 ++ components/actionbutton/dist/metadata.json | 19 +- components/actionbutton/index.css | 64 +- .../stories/actionbutton.stories.js | 29 +- .../actionbutton/stories/actionbutton.test.js | 2 + components/actionbutton/stories/template.js | 17 +- components/actiongroup/dist/metadata.json | 2 +- components/actiongroup/index.css | 2 +- components/actionmenu/dist/metadata.json | 9 + components/actionmenu/index.css | 32 + components/actionmenu/project.json | 17 + .../actionmenu/stories/actionmenu.stories.js | 291 +++++- .../actionmenu/stories/actionmenu.test.js | 35 +- components/actionmenu/stories/template.js | 118 ++- components/coachmark/stories/template.js | 33 +- .../combobox/stories/combobox.stories.js | 76 +- components/icon/stories/template.js | 19 +- components/menu/dist/metadata.json | 40 +- components/menu/index.css | 19 +- components/menu/stories/menu.stories.js | 27 +- components/menu/stories/menu.test.js | 64 +- components/menu/stories/template.js | 842 +++++++++++------- components/switch/stories/template.js | 2 + tasks/component-compare.js | 3 - 24 files changed, 1178 insertions(+), 648 deletions(-) create mode 100644 .changeset/brave-bikes-teach.md create mode 100644 components/actionmenu/dist/metadata.json create mode 100644 components/actionmenu/index.css create mode 100644 components/actionmenu/project.json diff --git a/.changeset/brave-bikes-teach.md b/.changeset/brave-bikes-teach.md new file mode 100644 index 00000000000..f8b46b4c2b2 --- /dev/null +++ b/.changeset/brave-bikes-teach.md @@ -0,0 +1,64 @@ +--- +"@spectrum-css/actionmenu": major +"@spectrum-css/actionbutton": minor +"@spectrum-css/menu": patch +"@spectrum-css/actiongroup": patch +--- + +### Action menu component (now with custom styles!) + +Introduces `@spectrum-css/actionmenu`, a composition of `ActionButton`, `Popover`, and `Menu` to present action lists from a trigger. Now with custom styles! + +- Adds wrapper classes: `spectrum-ActionMenu`, `spectrum-ActionMenu-trigger`, `spectrum-ActionMenu-popover`, and `spectrum-ActionMenu-menu`. +- Supports long press triggers and four placements (start/end, top/bottom) via the underlying popover API. +- Design reference: [Figma S2 token specs](https://www.figma.com/design/eoZHKJH9a3LJkHYCGt60Vb/S2-Token-specs?node-id=19758-3424). + +#### Migration notes + +- If you previously composed an action menu manually (action button + popover + menu), you can adopt the new wrapper classes without changing the underlying markup semantics. Ensure the trigger has `aria-haspopup="menu"` and manages `aria-expanded` according to your application logic. +- For spacing customizations previously done with ad‑hoc margins, switch to the new `--spectrum-actionmenu-button-to-menu-gap` custom property. + +Example markup: + +```html +
+ +
+
    + +
+
+ + + +
+``` + +### Menu refinements + +Updates `@spectrum-css/menu` styles to align with latest Spectrum 2 design specifications and improve accessibility. + +- Focus indicator tokens wired through: width, color, gap/offset, and outline style. +- CJK line-height tokens applied for labels, descriptions, and section headers. +- External link and drill‑in icon sizing variables exposed; thumbnail sizing and alignment refined. +- Forced-colors improvements and readability adjustments. +- Non-breaking; no class or DOM changes required. + +### Action button refinements + +- Selection styling now applies when components use ARIA pressed/expanded semantics, not just `.is-selected`. +- Implemented with `:where()` to keep selector specificity low and prevent downstream specificity battles. +- Non-breaking; no class changes required. + +### Action group refinements + +Aligns selection behavior of grouped items with action button updates. + +- Adds `:where([aria-pressed="true"], [aria-expanded="true"])` alongside `.is-selected` on items to cover more accessibility use-cases while keeping specificity low. +- Non-breaking; no class changes required. diff --git a/components/actionbutton/dist/metadata.json b/components/actionbutton/dist/metadata.json index 7a3a4bb0a35..90580bee21e 100644 --- a/components/actionbutton/dist/metadata.json +++ b/components/actionbutton/dist/metadata.json @@ -14,17 +14,16 @@ ".spectrum-ActionButton-label", ".spectrum-ActionButton-label:empty", ".spectrum-ActionButton.is-disabled", - ".spectrum-ActionButton.is-selected", - ".spectrum-ActionButton.is-selected.spectrum-ActionButton--emphasized", - ".spectrum-ActionButton.is-selected.spectrum-ActionButton--staticBlack", - ".spectrum-ActionButton.is-selected.spectrum-ActionButton--staticWhite", + ".spectrum-ActionButton.spectrum-ActionButton--emphasized:where(.is-selected, [aria-pressed=\"true\"], [aria-expanded=\"true\"])", ".spectrum-ActionButton.spectrum-ActionButton--quiet", - ".spectrum-ActionButton.spectrum-ActionButton--quiet.is-selected", - ".spectrum-ActionButton.spectrum-ActionButton--quiet:disabled:not(.is-selected)", + ".spectrum-ActionButton.spectrum-ActionButton--quiet:is(:disabled, .is-disabled, [aria-disabled=\"true\"]):not(:where(.is-selected, [aria-pressed=\"true\"], [aria-expanded=\"true\"]))", + ".spectrum-ActionButton.spectrum-ActionButton--quiet:where(.is-selected, [aria-pressed=\"true\"], [aria-expanded=\"true\"])", ".spectrum-ActionButton.spectrum-ActionButton--staticBlack", ".spectrum-ActionButton.spectrum-ActionButton--staticBlack.spectrum-ActionButton--quiet", + ".spectrum-ActionButton.spectrum-ActionButton--staticBlack:where(.is-selected, [aria-pressed=\"true\"], [aria-expanded=\"true\"])", ".spectrum-ActionButton.spectrum-ActionButton--staticWhite", ".spectrum-ActionButton.spectrum-ActionButton--staticWhite.spectrum-ActionButton--quiet", + ".spectrum-ActionButton.spectrum-ActionButton--staticWhite:where(.is-selected, [aria-pressed=\"true\"], [aria-expanded=\"true\"])", ".spectrum-ActionButton::-moz-focus-inner", ".spectrum-ActionButton:active", ".spectrum-ActionButton:after", @@ -35,11 +34,13 @@ ".spectrum-ActionButton:focus-visible:after", ".spectrum-ActionButton:has(.spectrum-ActionButton-icon)", ".spectrum-ActionButton:hover", + ".spectrum-ActionButton:is(.is-selected, [aria-pressed=\"true\"], [aria-expanded=\"true\"])", ".spectrum-ActionButton:not(:has(.spectrum-ActionButton-label))", + ".spectrum-ActionButton:where(.is-selected, [aria-pressed=\"true\"], [aria-expanded=\"true\"])", + ".spectrum-ActionButton:where(:disabled, .is-disabled, [aria-disabled=\"true\"])", "a.spectrum-ActionButton" ], "modifiers": [ - "--mod-actionbutton-animation-duration", "--mod-actionbutton-background-color-default", "--mod-actionbutton-background-color-default-selected", "--mod-actionbutton-background-color-default-selected-emphasized", @@ -71,10 +72,6 @@ "--mod-actionbutton-edge-to-text", "--mod-actionbutton-edge-to-visual", "--mod-actionbutton-edge-to-visual-only", - "--mod-actionbutton-focus-indicator-border-radius", - "--mod-actionbutton-focus-indicator-color", - "--mod-actionbutton-focus-indicator-gap", - "--mod-actionbutton-focus-indicator-thickness", "--mod-actionbutton-font-size", "--mod-actionbutton-font-style", "--mod-actionbutton-font-weight", diff --git a/components/actionbutton/index.css b/components/actionbutton/index.css index 8205eb370cb..132762e7d04 100644 --- a/components/actionbutton/index.css +++ b/components/actionbutton/index.css @@ -63,7 +63,7 @@ governing permissions and limitations under the License. --spectrum-actionbutton-background-color-focus: var(--spectrum-gray-200); --spectrum-actionbutton-background-color-disabled: transparent; - &.is-selected { + &:where(.is-selected, [aria-pressed="true"], [aria-expanded="true"]) { --spectrum-actionbutton-background-color-disabled: var(--spectrum-disabled-background-color); } } @@ -116,7 +116,8 @@ governing permissions and limitations under the License. } } - &.is-selected { + /* expanded is specific to action menu when the menu is open */ + &:where(.is-selected, [aria-pressed="true"], [aria-expanded="true"]) { --mod-actionbutton-background-color-default: var(--mod-actionbutton-background-color-default-selected, var(--spectrum-neutral-background-color-selected-default)); --mod-actionbutton-background-color-hover: var(--mod-actionbutton-background-color-hover-selected, var(--spectrum-neutral-background-color-selected-hover)); --mod-actionbutton-background-color-down: var(--mod-actionbutton-background-color-down-selected, var(--spectrum-neutral-background-color-selected-down)); @@ -298,6 +299,16 @@ governing permissions and limitations under the License. border-style: none; } + &::after { + position: absolute; + inset: 0; + margin: calc((var(--spectrum-actionbutton-focus-indicator-gap) + var(--spectrum-actionbutton-border-width)) * -1); + border-radius: var(--spectrum-actionbutton-focus-indicator-border-radius); + transition: box-shadow var(--highcontrast-actionbutton-animation-duration, var(--spectrum-actionbutton-animation-duration)) ease-in-out; + pointer-events: none; + content: ""; + } + &:focus { outline: none; } @@ -315,6 +326,13 @@ governing permissions and limitations under the License. &:focus-visible { background-color: var(--highcontrast-actionbutton-background-color-default, var(--mod-actionbutton-background-color-focus, var(--spectrum-actionbutton-background-color-focus))); color: var(--highcontrast-actionbutton-content-color-default, var(--mod-actionbutton-content-color-focus, var(--spectrum-actionbutton-content-color-focus))); + + box-shadow: none; + outline: none; + + &::after { + box-shadow: 0 0 0 var(--spectrum-actionbutton-focus-indicator-thickness) var(--highcontrast-actionbutton-focus-indicator-color, var(--spectrum-actionbutton-focus-indicator-color)); + } } &:active { @@ -323,8 +341,8 @@ governing permissions and limitations under the License. transform: perspective(var(--spectrum-actionbutton-downstate-perspective)) translateZ(var(--spectrum-component-size-difference-down)); } - &:disabled, - &.is-disabled { + /* ideal when we want to disable the button but still allow it's content to be focused */ + &:where(:disabled, .is-disabled, [aria-disabled="true"]) { background-color: var(--highcontrast-actionbutton-background-color-disabled, var(--mod-actionbutton-background-color-disabled, var(--spectrum-actionbutton-background-color-disabled))); color: var(--highcontrast-actionbutton-content-color-disabled, var(--mod-actionbutton-content-color-disabled, var(--spectrum-actionbutton-content-color-disabled))); } @@ -364,10 +382,6 @@ a.spectrum-ActionButton { /* Fixes horizontal alignment of text in anchor buttons */ text-align: center; - &:empty { - display: none; - } - pointer-events: none; font-size: var(--mod-actionbutton-font-size, var(--spectrum-actionbutton-font-size)); @@ -378,40 +392,21 @@ a.spectrum-ActionButton { text-overflow: ellipsis; overflow: hidden; + + &:empty { + display: none; + } } .spectrum-ActionButton-hold { position: absolute; inset-inline-end: calc(var(--mod-actionbutton-edge-to-hold-icon, var(--spectrum-actionbutton-edge-to-hold-icon)) - var(--spectrum-actionbutton-border-width)); inset-block-end: calc(var(--mod-actionbutton-edge-to-hold-icon, var(--spectrum-actionbutton-edge-to-hold-icon)) - var(--spectrum-actionbutton-border-width)); + display: block; color: inherit; transform: var(--spectrum-logical-rotation); } -/* Focus indicator */ -.spectrum-ActionButton { - transition: border-color var(--highcontrast-actionbutton-animation-duration, var(--mod-actionbutton-animation-duration, var(--spectrum-actionbutton-animation-duration))) ease-in-out; - - &::after { - position: absolute; - inset: 0; - margin: calc((var(--mod-actionbutton-focus-indicator-gap, var(--spectrum-actionbutton-focus-indicator-gap)) + var(--spectrum-actionbutton-border-width)) * -1); - border-radius: var(--mod-actionbutton-focus-indicator-border-radius, var(--spectrum-actionbutton-focus-indicator-border-radius)); - transition: box-shadow var(--highcontrast-actionbutton-animation-duration, var(--mod-actionbutton-animation-duration, var(--spectrum-actionbutton-animation-duration))) ease-in-out; - pointer-events: none; - content: ""; - } - - &:focus-visible { - box-shadow: none; - outline: none; - - &::after { - box-shadow: 0 0 0 var(--mod-actionbutton-focus-indicator-thickness, var(--spectrum-actionbutton-focus-indicator-thickness)) var(--highcontrast-actionbutton-focus-indicator-color, var(--mod-actionbutton-focus-indicator-color, var(--spectrum-actionbutton-focus-indicator-color))); - } - } -} - @media (forced-colors: active) { .spectrum-ActionButton { /** @@ -457,7 +452,7 @@ a.spectrum-ActionButton { --highcontrast-actionbutton-background-color-disabled: Canvas; --highcontrast-actionbutton-content-color-default: CanvasText; - &:disabled:not(.is-selected) { + &:is(:disabled, .is-disabled, [aria-disabled="true"]):not(:where(.is-selected, [aria-pressed="true"], [aria-expanded="true"])) { --highcontrast-actionbutton-border-color: Canvas; } } @@ -469,8 +464,7 @@ a.spectrum-ActionButton { --highcontrast-actionbutton-border-color: Highlight; } - /* Selected always shows as a solid highlighted color. */ - &.is-selected { + &:is(.is-selected, [aria-pressed="true"], [aria-expanded="true"]) { --highcontrast-actionbutton-border-color: Highlight; --highcontrast-actionbutton-background-color-default: Highlight; --highcontrast-actionbutton-content-color-default: HighlightText; diff --git a/components/actionbutton/stories/actionbutton.stories.js b/components/actionbutton/stories/actionbutton.stories.js index d1599920baa..62fc72d59f9 100644 --- a/components/actionbutton/stories/actionbutton.stories.js +++ b/components/actionbutton/stories/actionbutton.stories.js @@ -1,12 +1,14 @@ import { default as IconStories } from "@spectrum-css/icon/stories/icon.stories.js"; import { Sizes, withDownStateDimensionCapture } from "@spectrum-css/preview/decorators"; import { disableDefaultModes } from "@spectrum-css/preview/modes"; -import { isActive, isDisabled, isEmphasized, isFocused, isHovered, isQuiet, isSelected, size, staticColor } from "@spectrum-css/preview/types"; -import metadata from "../dist/metadata.json"; -import packageJson from "../package.json"; +import { isActive, isDisabled, isEmphasized, isFocused, isHovered, isOpen, isQuiet, isSelected, size, staticColor } from "@spectrum-css/preview/types"; import { ActionButtonGroup } from "./actionbutton.test.js"; import { ActionButtonsWithIconOptions, IconOnlyOption, Template, TreatmentTemplate } from "./template.js"; +// Local assets to render the component styles and structure +import metadata from "../dist/metadata.json"; +import packageJson from "../package.json"; + /** * The action button component represents an action a user can take. * @@ -56,8 +58,8 @@ export default { control: "boolean", }, hasPopup: { - name: "Has popup", - description: "If the button triggers a popup action, this should be set to reflect the type of element that pops-up.", + name: "Has pop-up", + description: "If the button triggers a pop-up action, this should be set to reflect the type of element that pops-up.", type: { name: "string" }, table: { type: { summary: "string" }, @@ -66,6 +68,22 @@ export default { control: "select", options: ["true", "menu", "listbox", "tree", "grid", "dialog", "false"], }, + hasLongPress: { + name: "Long press", + description: "If the trigger supports a long-press action which triggers the menu, this should be set to true.", + type: { name: "boolean" }, + table: { + type: { summary: "boolean" }, + category: "Accessibility", + }, + control: "boolean", + }, + isOpen: { + ...isOpen, + name: "Pop-up is open", + description: "When the button triggers a pop-up, this should be true when the pop-up is open.", + if: { arg: "hasPopup", truthy: true }, + }, staticColor: { ...staticColor, if: { arg: "isEmphasized", truthy: false }, @@ -77,6 +95,7 @@ export default { isQuiet: false, isEmphasized: false, hasPopup: "false", + hasLongPress: false, isActive: false, isFocused: false, isHovered: false, diff --git a/components/actionbutton/stories/actionbutton.test.js b/components/actionbutton/stories/actionbutton.test.js index 4a26724f80b..7e65e449655 100644 --- a/components/actionbutton/stories/actionbutton.test.js +++ b/components/actionbutton/stories/actionbutton.test.js @@ -17,12 +17,14 @@ export const ActionButtons = (args, context) => { ${Template({ ...args, hasPopup: "true", + hasLongPress: true, hideLabel: true, }, context)} ${Template({ ...args, iconName: undefined, hasPopup: "true", + hasLongPress: true, }, context)} `; diff --git a/components/actionbutton/stories/template.js b/components/actionbutton/stories/template.js index 942c3bccefa..28e81354eb4 100644 --- a/components/actionbutton/stories/template.js +++ b/components/actionbutton/stories/template.js @@ -54,7 +54,9 @@ export const Template = ({ isFocused = false, isActive = false, isDisabled = false, + isOpen = false, hasPopup = "false", + hasLongPress = false, popupId, hideLabel = false, staticColor, @@ -72,7 +74,8 @@ export const Template = ({ aria-label=${ifDefined(hideLabel ? label : undefined)} aria-haspopup=${ifDefined(hasPopup && hasPopup !== "false" ? hasPopup : undefined)} aria-controls=${hasPopup && hasPopup !== "false" ? popupId : undefined} - aria-pressed=${isSelected ? "true" : "false"} + aria-pressed=${ifDefined(isSelected ? "true" : undefined)} + aria-expanded=${ifDefined(hasPopup && hasPopup !== "false" ? isOpen ? "true" : "false" : undefined)} class=${classMap({ [rootClass]: true, [`${rootClass}--size${size?.toUpperCase()}`]: @@ -82,7 +85,6 @@ export const Template = ({ [`${rootClass}--static${capitalize(staticColor)}`]: typeof staticColor !== "undefined", ["is-disabled"]: isDisabled, - ["is-selected"]: isSelected, ["is-hover"]: isHovered, ["is-focus-visible"]: isFocused, ["is-active"]: isActive, @@ -90,6 +92,7 @@ export const Template = ({ })} id=${id} data-testid=${testId ?? id} + popovertarget=${ifDefined(hasPopup && hasPopup !== "false" ? popupId : undefined)} role=${ifDefined(role)} style=${styleMap(customStyles)} ?disabled=${isDisabled} @@ -105,7 +108,7 @@ export const Template = ({ updateArgs({ isFocused: false }); }} > - ${when(hasPopup && hasPopup !== "false", () => + ${when(hasLongPress && hasPopup && hasPopup !== "false", () => Icon({ size, iconName: "CornerTriangle" + ({ @@ -138,7 +141,7 @@ export const Template = ({ /** * Displays multiple action buttons in a row, with different combinations of - * label, icon, and hold button (has popup). + * label, icon, and hold button (has pop-up). */ export const ActionButtonsWithIconOptions = (args, context) => Container({ withBorder: false, @@ -162,11 +165,13 @@ export const ActionButtonsWithIconOptions = (args, context) => Container({ ...args, hideLabel: true, hasPopup: "true", + hasLongPress: true, }, context), Template({ ...args, iconName: undefined, hasPopup: "true", + hasLongPress: true, }, context) ], }, context); @@ -174,7 +179,7 @@ export const ActionButtonsWithIconOptions = (args, context) => Container({ /** * Displays two action buttons in a row: * - icon only action button - * - icon only action button with hold button (has popup) + * - icon only action button with hold button (has pop-up) */ export const IconOnlyOption = (args, context) => Container({ withBorder: false, @@ -187,12 +192,14 @@ export const IconOnlyOption = (args, context) => Container({ ...args, hideLabel: true, hasPopup: "true", + hasLongPress: true, }, context), Template({ ...args, hideLabel: true, isQuiet: true, hasPopup: "true", + hasLongPress: true, }, context), ], }, context); diff --git a/components/actiongroup/dist/metadata.json b/components/actiongroup/dist/metadata.json index 8d4d2ba86b7..9caa96f153d 100644 --- a/components/actiongroup/dist/metadata.json +++ b/components/actiongroup/dist/metadata.json @@ -14,11 +14,11 @@ ".spectrum-ActionGroup--compact:not(.spectrum-ActionGroup--quiet) .spectrum-ActionGroup-item", ".spectrum-ActionGroup--compact:not(.spectrum-ActionGroup--quiet) .spectrum-ActionGroup-item + .spectrum-ActionGroup-item", ".spectrum-ActionGroup--compact:not(.spectrum-ActionGroup--quiet) .spectrum-ActionGroup-item .spectrum-ActionButton-label", - ".spectrum-ActionGroup--compact:not(.spectrum-ActionGroup--quiet) .spectrum-ActionGroup-item.is-selected", ".spectrum-ActionGroup--compact:not(.spectrum-ActionGroup--quiet) .spectrum-ActionGroup-item:first-child", ".spectrum-ActionGroup--compact:not(.spectrum-ActionGroup--quiet) .spectrum-ActionGroup-item:focus-visible", ".spectrum-ActionGroup--compact:not(.spectrum-ActionGroup--quiet) .spectrum-ActionGroup-item:hover", ".spectrum-ActionGroup--compact:not(.spectrum-ActionGroup--quiet) .spectrum-ActionGroup-item:last-child", + ".spectrum-ActionGroup--compact:not(.spectrum-ActionGroup--quiet) .spectrum-ActionGroup-item:where(.is-selected, [aria-pressed=\"true\"], [aria-expanded=\"true\"])", ".spectrum-ActionGroup--justified .spectrum-ActionGroup-item", ".spectrum-ActionGroup--sizeL", ".spectrum-ActionGroup--sizeM", diff --git a/components/actiongroup/index.css b/components/actiongroup/index.css index ac427bc8636..f1bab0f61e0 100644 --- a/components/actiongroup/index.css +++ b/components/actiongroup/index.css @@ -102,7 +102,7 @@ margin-inline-end: var(--mod-actiongroup-border-radius-reset, var(--spectrum-actiongroup-border-radius-reset)); } - &.is-selected { + &:where(.is-selected, [aria-pressed="true"], [aria-expanded="true"]) { z-index: 1; } diff --git a/components/actionmenu/dist/metadata.json b/components/actionmenu/dist/metadata.json new file mode 100644 index 00000000000..530a08aae34 --- /dev/null +++ b/components/actionmenu/dist/metadata.json @@ -0,0 +1,9 @@ +{ + "sourceFile": "index.css", + "selectors": [".spectrum-ActionMenu", ".spectrum-ActionMenu-popover"], + "modifiers": [], + "component": ["--spectrum-actionmenu-button-to-menu-gap"], + "global": ["--spectrum-popover-animation-distance", "--spectrum-spacing-100"], + "passthroughs": [], + "high-contrast": [] +} diff --git a/components/actionmenu/index.css b/components/actionmenu/index.css new file mode 100644 index 00000000000..faaf082024e --- /dev/null +++ b/components/actionmenu/index.css @@ -0,0 +1,32 @@ +/*! + * Copyright 2024 Adobe. All rights reserved. + * + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* + * @spectrum-css/actionmenu + * This component is a combination of a menu, popover, and action button. + * It is used to display a list of actions in a popover menu when the user clicks on an action button. + * The markup has the following structure: + *
+ * + *
+ *
    + */ + +.spectrum-ActionMenu { + --spectrum-actionmenu-button-to-menu-gap: var(--spectrum-spacing-100); +} + +.spectrum-ActionMenu-popover { + /* @passthrough */ + --spectrum-popover-animation-distance: var(--spectrum-actionmenu-button-to-menu-gap); +} diff --git a/components/actionmenu/project.json b/components/actionmenu/project.json new file mode 100644 index 00000000000..5b51f022edd --- /dev/null +++ b/components/actionmenu/project.json @@ -0,0 +1,17 @@ +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "actionmenu", + "tags": ["component"], + "targets": { + "build": {}, + "clean": {}, + "compare": {}, + "format": {}, + "lint": {}, + "report": {}, + "test": { + "defaultConfiguration": "scope" + }, + "validate": {} + } +} diff --git a/components/actionmenu/stories/actionmenu.stories.js b/components/actionmenu/stories/actionmenu.stories.js index 4978bef9de9..490b8289051 100644 --- a/components/actionmenu/stories/actionmenu.stories.js +++ b/components/actionmenu/stories/actionmenu.stories.js @@ -1,41 +1,58 @@ +import { ArgGrid, Container } from "@spectrum-css/preview/decorators/utilities.js"; +import { disableDefaultModes } from "@spectrum-css/preview/modes"; +import { isOpen } from "@spectrum-css/preview/types"; + import { default as ActionButton } from "@spectrum-css/actionbutton/stories/actionbutton.stories.js"; -import { default as IconStories } from "@spectrum-css/icon/stories/icon.stories.js"; import { default as Menu } from "@spectrum-css/menu/stories/menu.stories.js"; import { default as Popover } from "@spectrum-css/popover/stories/popover.stories.js"; -import { disableDefaultModes } from "@spectrum-css/preview/modes"; -import { isOpen } from "@spectrum-css/preview/types"; -import packageJson from "../package.json"; + +import { Template as IconTemplate } from "@spectrum-css/icon/stories/template.js"; import { ActionMenuGroup } from "./actionmenu.test.js"; +import { Template } from "./template.js"; + +import metadata from "../dist/metadata.json"; +import packageJson from "../package.json"; /** - * The action menu component is an action button with a popover. The `is-selected` class should be applied to the button when the menu is open. Note that the accessibility roles are different for an action menu compared to a normal menu. + * Action menu allows users to access and execute various commands or tasks related to their current workflow. It's typically triggered from an action button by user interaction. + * + * Note that the accessibility roles are different for an action menu compared to a normal menu. The action menu is a combination of a menu, popover, and action button. */ export default { title: "Action menu", component: "ActionMenu", argTypes: { - withTip: Popover.argTypes.withTip, - position: Popover.argTypes.position, - isOpen, - iconName: { - ...(IconStories?.argTypes?.iconName ?? {}), - if: false, + position: { + ...Popover.argTypes.position, + options: [ + "bottom-start", + "bottom-end", + "start-top", + "end-top", + ] }, - label: { - name: "Label", - type: { name: "string" }, + isOpen, + hasLongPress: { + name: "Long press", + description: "If the trigger supports a long-press action which triggers the menu, this should be set to true.", + type: { name: "boolean" }, table: { - type: { summary: "string" }, - category: "Content", + type: { summary: "boolean" }, + category: "Accessibility", }, - control: { type: "text" }, + control: "boolean", }, - items: { table: { disable: true } }, + menuArgs: { table: { disable: true } }, + triggerArgs: { table: { disable: true } }, }, args: { - isOpen: false, - withTip: Popover.args.withTip, - position: Popover.args.position, + isOpen: true, + position: "bottom-start", + hasLongPress: false, + triggerArgs: { + iconName: "More", + iconSet: "workflow", + }, }, parameters: { actions: { @@ -50,39 +67,229 @@ export default { url: "https://www.figma.com/design/eoZHKJH9a3LJkHYCGt60Vb/S2-token-specs?node-id=20959-21513&node-type=frame&t=jbePQKK1yLdrHG2M-11", }, packageJson, - docs: { - story: { - height: "200px", - } - }, + metadata, status: { - type: "unmigrated", + type: "migrated", }, }, - tags: ["unmigrated"], + tags: ["migrated"], }; +/** + * Action menu allows users to access and execute various commands or tasks related to their current workflow. It's typically triggered from an action button by user interaction. + * + * Note that the accessibility roles are different for an action menu compared to a normal menu. The action menu is a combination of a menu, popover, and action button. + */ export const Default = ActionMenuGroup.bind({}); Default.args = { + triggerArgs: { + iconName: "AddCircle", + label: "Add", + }, + menuArgs: { + hasActions: true, + selectionMode: "multiple", + items: [{ + heading: "Menu section header", + description: "Menu section description", + items: [{ + label: "Menu item", + iconName: "Circle", + }, + { + label: "Menu item", + iconName: "Circle", + }, + { + label: "Menu item", + iconName: "Circle", + }], + }, { + heading: "Menu section header", + description: "Menu section description", + selectionMode: "none", + hasActions: false, + items: [{ + label: "Menu item", + iconName: "Circle", + }, + { + label: "Menu item", + iconName: "Circle", + }, + { + label: "Menu item", + iconName: "Circle", + },], + }], + }, +}; + +// ********* DOCS ONLY ********* // +/** + * By default, the menu is opened by pressing the trigger element or activating it via the Space or Enter keys. However, there may be cases where the trigger should perform a separate action on press such as selection, and should only display the menu when long pressed. For this use-case, the menu will only be opened when pressing and holding the trigger or by using the Option (Alt on Windows) + Down arrow/Up arrow keys while focusing the trigger. + * + * This example illustrates the expected visuals and states of the action menu for a trigger with both long press and short press behaviors. + */ +export const LongPress = Template.bind({}); +LongPress.args = { + position: "end-top", + hasLongPress: true, isOpen: true, - position: "bottom", - label: "More actions", - iconName: "More", - items: [ - { - label: "Action 1", - }, - { - label: "Action 2", - }, - { - label: "Action 3", + triggerArgs: { + iconName: "CropRotate", + }, + menuArgs: { + customStyles: { + "--mod-menu-inline-size": "max-content", }, - { - label: "Action 4", + selectionMode: "single", + items: [{ + label: "Crop rotate", + iconName: "CropRotate", + isSelected: true, + }, { + label: "Slice", + iconName: "VectorDraw", + }, { + label: "Clone stamp", + iconName: "StampClone", + }], + }, +}; +LongPress.tags = ["!dev"]; +LongPress.parameters = { + chromatic: { + disableSnapshot: true, + }, +}; + +/** + * Action menus can be positioned in four locals relative to the trigger but only one menu can be triggered at a single time. + */ +export const PlacementOptions = (args, context) => ArgGrid({ + Template, + argKey: "position", + withBorder: false, + ...args, +}, context); +PlacementOptions.args = { + triggerArgs: { + iconName: "More", + }, + menuArgs: { + customStyles: { + "--mod-menu-inline-size": "max-content", }, + items: [{ + label: "Details", + iconName: "FileText" + }, { + label: "Share", + iconName: "Share" + }, { + label: "Remove", + iconName: "Delete" + }], + }, +}; +PlacementOptions.tags = ["!dev"]; +PlacementOptions.parameters = { + chromatic: { + disableSnapshot: true, + }, +}; + +/** + * Icon used is a placeholder and can be swapped with any other from icon set along with corresponding label. + */ +export const PlaceholderIcon = (args, context) => Container({ + withBorder: false, + content: [ + Template(args, context), + IconTemplate({ + iconName: "ArrowRight400", + setName: "ui", + fill: "var(--spectrum-gray-400)", + customStyles: { + "margin-block-start": "var(--spectrum-spacing-200)", + }, + }, context), + Template({ + ...args, + isOpen: true, + triggerArgs: { + iconName: "AddCircle", + label: "Add", + }, + menuArgs: { + items: [{ + heading: "Add new page", + items: [ + { + label: "Same size", + iconName: "Copy" + }, + { + label: "Custom size", + iconName: "Properties" + }, + { + label: "Duplicate", + iconName: "Duplicate" + } + ] + }, { + heading: "Edit page", + items: [{ + label: "Edit timeline", + iconName: "Clock", + description: "Add time to this page" + }], + }] + }, + }, context), ], +}); +PlaceholderIcon.args = { + triggerArgs: { + iconName: "More", + label: "", + }, + isOpen: true, + menuArgs: { + customStyles: { + "--mod-menu-inline-size": "max-content", + }, + items: [{ + heading: "Menu section header", + customStyles: { + "--mod-menu-inline-size": "100%", + }, + items: [ + { + label: "Menu item", + iconName: "Circle" + }, + { + label: "Menu item", + iconName: "Circle" + }, + { + label: "Menu item", + iconName: "Circle" + } + ] + }], + }, }; +PlaceholderIcon.tags = ["!dev"]; +PlaceholderIcon.parameters = { + chromatic: { + disableSnapshot: true, + }, +}; + // ********* VRT ONLY ********* // export const WithForcedColors = ActionMenuGroup.bind({}); diff --git a/components/actionmenu/stories/actionmenu.test.js b/components/actionmenu/stories/actionmenu.test.js index 159a70c756b..e91cf61b34a 100644 --- a/components/actionmenu/stories/actionmenu.test.js +++ b/components/actionmenu/stories/actionmenu.test.js @@ -1,26 +1,25 @@ -import { Variants } from "@spectrum-css/preview/decorators"; +import { ArgGrid, Variants } from "@spectrum-css/preview/decorators"; import { Template } from "./template.js"; export const ActionMenuGroup = Variants({ Template, - testData: [{ - wrapperStyles: { - "min-block-size": "200px", - "align-items": "flex-start", - }, - }, { - testHeading: "Closed menu", + withSizes: false, + testData: [{}, { + testHeading: "Positioning", + withStates: false, + Template: (args, context) => ArgGrid({ + Template, + argKey: "position", + withBorder: false, + ...args, + }, context), + }], + stateData: [{ + testHeading: "Closed", isOpen: false, - wrapperStyles: { - "min-block-size": "50px", - }, }, { - testHeading: "Custom icon", + testHeading: "Has long press", + hasLongPress: true, isOpen: false, - iconName: "Add", - iconSet: "workflow", - wrapperStyles: { - "min-block-size": "50px", - }, - }], + }] }); diff --git a/components/actionmenu/stories/template.js b/components/actionmenu/stories/template.js index bbdba0578cb..d594230aa2c 100644 --- a/components/actionmenu/stories/template.js +++ b/components/actionmenu/stories/template.js @@ -3,48 +3,78 @@ import { Template as Menu } from "@spectrum-css/menu/stories/template.js"; import { Template as Popover } from "@spectrum-css/popover/stories/template.js"; import { getRandomId } from "@spectrum-css/preview/decorators"; -export const Template = ({ - id = getRandomId("actionmenu"), - testId, - triggerId = getRandomId("actionmenu-trigger"), - customClasses = [], - customStyles = {}, - items = [], - isOpen = false, - label, - iconName = "More", - iconSet = "workflow", - size = "m", - ...popoverArgs -} = {}, context = {}) => { - return Popover({ - size, - isOpen, - withTip: false, - id, - testId: testId ?? id, - triggerId, - trigger: (passthroughs) => - ActionButton({ - ...passthroughs, - size, - label, - hasPopup: "menu", - iconName, - iconSet, - id: triggerId, - }, context), - position: "bottom-start", - customWrapperClasses: customClasses, - customWrapperStyles: customStyles, - content: [ - (passthroughs) => Menu({ - ...passthroughs, - items, - isOpen, - size - }, context) - ], - ...popoverArgs, - }, context); +export const Template = ( + { + rootClass = "spectrum-ActionMenu", + id = getRandomId("actionmenu"), + testId, + triggerId = getRandomId("actionmenu-trigger"), + customClasses = [], + customStyles = {}, + isOpen = false, + hasLongPress = false, + position = "bottom-start", + // Object should match the schema of the Menu component + menuArgs = {}, + // Object should match the schema of the ActionButton component (or whatever component is used for the trigger) + triggerArgs = {}, + ...popoverArgs + } = {}, + context = {}, +) => { + const { updateArgs } = context; + + return Popover( + { + ...popoverArgs, + isOpen, + withTip: false, + id, + testId: testId ?? id, + triggerId, + trigger: (passthroughs) => + ActionButton( + { + ...passthroughs, + ...triggerArgs, + hasPopup: "menu", + hasLongPress, + id: triggerId, + customClasses: [`${rootClass}-trigger`], + onclick: hasLongPress + ? undefined + : () => { + updateArgs({ isOpen: !isOpen }); + }, + }, + context, + ), + position, + customClasses: [`${rootClass}-popover`], + customWrapperClasses: [rootClass, ...customClasses], + customWrapperStyles: customStyles, + content: [ + (passthroughs) => + Menu( + { + ...passthroughs, + ...menuArgs, + customClasses: [ + `${rootClass}-menu`, + ...(passthroughs?.customClasses ?? []), + ...(menuArgs?.customClasses ?? []), + ], + customStyles: { + "--mod-menu-inline-size": "100%", + ...(passthroughs?.customStyles ?? {}), + ...(menuArgs?.customStyles ?? {}), + }, + isOpen, + }, + context, + ), + ], + }, + context, + ); }; diff --git a/components/coachmark/stories/template.js b/components/coachmark/stories/template.js index e07eb7326fb..4397889a589 100644 --- a/components/coachmark/stories/template.js +++ b/components/coachmark/stories/template.js @@ -23,6 +23,7 @@ export const CoachContainer = ( currentStep = 2, totalStepCount = 8, isOpen = false, + alt = "", } = {}, context = {}, ) => { @@ -43,6 +44,7 @@ export const CoachContainer = ( ${alt}
`, @@ -59,19 +61,24 @@ export const CoachContainer = ( { isOpen, position: "bottom-start", - iconName: "More", - size: scale === "large" ? "s" : "m", - customClasses: [ - `${rootClass}-action-menu` - ], - items: [ - { - label: "Skip tour", - }, - { - label: "Reset tour", - }, - ], + triggerArgs: { + iconName: "More", + size: scale === "large" ? "s" : "m", + label: "More actions", + hideLabel: true, + }, + customClasses: [`${rootClass}-action-menu`], + menuArgs: { + size: scale === "large" ? "s" : "m", + items: [ + { + label: "Skip tour", + }, + { + label: "Reset tour", + }, + ], + }, }, context, ), diff --git a/components/combobox/stories/combobox.stories.js b/components/combobox/stories/combobox.stories.js index 2d1e240bd52..524f54eca85 100644 --- a/components/combobox/stories/combobox.stories.js +++ b/components/combobox/stories/combobox.stories.js @@ -141,33 +141,36 @@ export default { helpText: "", value: "Ballard", content: [ - (passthroughs, context) => Menu({ - role: "listbox", - subrole: "option", - selectionMode: "single", - hasDividers: true, - items: [ + (passthroughs, context) => + Menu( { - label: "Ballard", - isSelected: true, - isChecked: true, + role: "listbox", + selectionMode: "single", + hasDividers: true, + items: [ + { + label: "Ballard", + isSelected: true, + isChecked: true, + }, + { + label: "Fremont", + }, + { + label: "Greenwood", + }, + { + type: "divider", + }, + { + label: "United States of America", + isDisabled: true, + }, + ], + ...passthroughs, }, - { - label: "Fremont", - }, - { - label: "Greenwood", - }, - { - type: "divider", - }, - { - label: "United States of America", - isDisabled: true, - }, - ], - ...passthroughs, - }, context), + context, + ), ], }, parameters: { @@ -227,28 +230,31 @@ HelpText.args = { helpText: "This is a help text. Select an option", }; - /** * Comboboxes have a read-only option for when content in the disabled state still needs to be shown. This allows for content to be copied, but not interacted with or changed. A combobox does not have a read-only option if no selection has been made. To enable this feature, add the `.is-readOnly` class to the combobox. To enable this feature, add the `.is-readOnly` class to the combobox. Then within the nested textfield component, add the `.is-readOnly class and readonly attribute to the `input` element. -*/ + */ export const ReadOnly = Template.bind({}); ReadOnly.tags = ["!dev"]; ReadOnly.args = { isReadOnly: true, - value: "Ballard" + value: "Ballard", }; ReadOnly.parameters = { - chromatic: { disableSnapshot: true } + chromatic: { disableSnapshot: true }, }; ReadOnly.storyName = "Read-only"; -export const Sizing = (args, context) => Sizes({ - Template, - withBorder: false, - withHeading: false, - ...args, -}, context); +export const Sizing = (args, context) => + Sizes( + { + Template, + withBorder: false, + withHeading: false, + ...args, + }, + context, + ); Sizing.tags = ["!dev"]; Sizing.parameters = { chromatic: { disableSnapshot: true }, diff --git a/components/icon/stories/template.js b/components/icon/stories/template.js index ae7e10a192f..fc0ef8d893e 100644 --- a/components/icon/stories/template.js +++ b/components/icon/stories/template.js @@ -39,6 +39,7 @@ export const Template = ({ fill, id = getRandomId("icon"), customClasses = [], + customStyles = {}, useRef = true, } = {}, context = {}) => { // All icons SVG markup from the global IconLoader are in loaded.icons @@ -113,6 +114,9 @@ export const Template = ({ ...customClasses.reduce((a, c) => ({ ...a, [c]: true }), {}), }; + const fillStyles = fill ? { + "--mod-icon-color": fill, + } : {}; /** * Display full SVG file markup from global IconLoader data, when not using a reference to the sprite sheet. */ @@ -129,8 +133,16 @@ export const Template = ({ return acc; }, ""); + const stylesAsString = Object.entries({ + ...fillStyles, + ...customStyles, + }).reduce((acc, [key, value]) => { + acc += `${key}: ${value};`; + return acc; + }, ""); + return html`${unsafeSVG( - svgString.replace(/
    - ${MenuItem( - { - ...args, - rootClass: "spectrum-Menu-item", - thumbnailUrl: (args.hasThumbnail && "thumbnail.png") || args.thumbnailUrl, - }, - context, - )} -
- `, + Template: ({ + customStyles, + hasActions, + hasThumbnail, + selectionMode, + shouldTruncate, + ...args + }, context) => Template({ + customStyles, + hasActions, + selectionMode, + shouldTruncate, + items: [{ + ...args, + thumbnailUrl: hasThumbnail && !args.thumbnailUrl ? "thumbnail.png" : args.thumbnailUrl, + }], + }, context), wrapperStyles: { "min-block-size": "auto", }, @@ -133,14 +127,14 @@ export const MenuItemGroup = Variants({ { testHeading: "No selection, with thumbnails", description: undefined, - thumbnailUrl: "thumbnail.png" + hasThumbnail: true, }, { testHeading: "No selection, with description", }, { testHeading: "No selection, with thumbnails, description", - thumbnailUrl: "thumbnail.png" + hasThumbnail: true, }, { testHeading: "Single selection: selected", @@ -155,7 +149,7 @@ export const MenuItemGroup = Variants({ value: undefined, selectionMode: "single", isSelected: true, - thumbnailUrl: "thumbnail.png" + hasThumbnail: true, }, { testHeading: "Single selection: unselected", @@ -174,7 +168,7 @@ export const MenuItemGroup = Variants({ label: "Share", iconName: "Share", iconSet: "workflow", - thumbnailUrl: "thumbnail.png" + hasThumbnail: true, }, { testHeading: "Multi-selection: selected", @@ -189,7 +183,7 @@ export const MenuItemGroup = Variants({ value: undefined, selectionMode: "multiple", isSelected: true, - thumbnailUrl: "thumbnail.png" + hasThumbnail: true, }, { testHeading: "Multi-selection: unselected", @@ -206,7 +200,7 @@ export const MenuItemGroup = Variants({ label: "Share", iconName: "Share", iconSet: "workflow", - thumbnailUrl: "thumbnail.png" + hasThumbnail: true, }, { testHeading: "Multi-selection: unselected switches", @@ -221,7 +215,7 @@ export const MenuItemGroup = Variants({ hasActions: true, value: undefined, description: undefined, - thumbnailUrl: "thumbnail.png" + hasThumbnail: true, }, { testHeading: "Multi-selection: selected switches", @@ -238,7 +232,7 @@ export const MenuItemGroup = Variants({ value: undefined, description: undefined, isSelected: true, - thumbnailUrl: "thumbnail.png" + hasThumbnail: true, }, { testHeading: "Multi-selection: switches + description", @@ -251,7 +245,7 @@ export const MenuItemGroup = Variants({ selectionMode: "multiple", hasActions: true, label: "Menu item", - thumbnailUrl: "thumbnail.png" + hasThumbnail: true, }, { testHeading: "Drill-in", @@ -276,7 +270,7 @@ export const MenuItemGroup = Variants({ customStyles: { "inline-size": "175px", }, - thumbnailUrl: "thumbnail.png" + hasThumbnail: true, }, { testHeading: "Text wrapping", @@ -295,7 +289,7 @@ export const MenuItemGroup = Variants({ customStyles: { "inline-size": "175px", }, - thumbnailUrl: "thumbnail.png" + hasThumbnail: true, } ], stateData: [ diff --git a/components/menu/stories/template.js b/components/menu/stories/template.js index 2415b816acf..b9787141520 100644 --- a/components/menu/stories/template.js +++ b/components/menu/stories/template.js @@ -7,8 +7,12 @@ import { Template as Switch } from "@spectrum-css/switch/stories/template.js"; import { Template as Thumbnail } from "@spectrum-css/thumbnail/stories/template.js"; import { Template as Tray } from "@spectrum-css/tray/stories/template.js"; -import { Container, getRandomId } from "@spectrum-css/preview/decorators"; -import { html } from "lit"; +import { + Container, + getRandomId, + renderContent, +} from "@spectrum-css/preview/decorators"; +import { html, nothing } 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"; @@ -37,27 +41,38 @@ const Label = ({ isCollapsible = false, label, rootClass, + role, shouldTruncate = false, + id, }) => { if (isCollapsible) { - return html` - ${label} - `; + return html` + + ${label} + + `; } else { - return html` + })} + > ${label} - `; + + `; } }; @@ -68,47 +83,46 @@ const Visual = ({ size, thumbnailUrl, hasExternalLink, - isDrillIn + isDrillIn, }) => { if (thumbnailUrl && !(hasExternalLink || isDrillIn)) { return html` - ${Thumbnail({ - imageURL: thumbnailUrl, - altText: "Thumbnail alt text", - size, - customClasses: [`${rootClass}Thumbnail`], - })} - `; + ${Thumbnail({ + imageURL: thumbnailUrl, + altText: "Thumbnail alt text", + size, + customClasses: [`${rootClass}Thumbnail`], + })} + `; } else if (iconName) { return html` - ${Icon({ - iconName, - setName: iconSet, - size, - customClasses: [ - `${rootClass}Icon`, - `${rootClass}Icon--workflowIcon` - ] - })} - `; + ${Icon({ + iconName, + setName: iconSet, + size, + customClasses: [`${rootClass}Icon`, `${rootClass}Icon--workflowIcon`], + })} + `; } return; }; -const StartAction = ({ - hasActions, - idx, - isCollapsible, - isDisabled, - isSelected, - isUnavailable, - rootClass, - selectionMode, - size, - context -}) => { - if (isUnavailable) return null; +const StartAction = ( + { + hasActions = false, + idx = 0, + isCollapsible = false, + isDisabled = false, + isSelected = false, + isUnavailable = false, + rootClass, + selectionMode = "none", + size = "m", + } = {}, + context = {}, +) => { + if (isUnavailable) return nothing; if (isCollapsible || (selectionMode == "single" && isSelected)) { return html` @@ -128,97 +142,107 @@ const StartAction = ({ }, context, )} - `; + `; } else if (selectionMode == "multiple" && !hasActions) { - return html` - ${Checkbox({ + return html` ${Checkbox( + { size, isChecked: isSelected, isDisabled, id: `menu-checkbox-${idx}`, customClasses: [`${rootClass}Checkbox`], }, - context)}`; + context, + )}`; } - return null; + return nothing; }; -const EndAction = ({ - hasExternalLink, - hasActions, - idx, - isUnavailable, - isDisabled, - isDrillIn, - isSelected, - rootClass, - selectionMode, - size, - value, - context -}) => html` - ${when(value, () => html` - - ${value} - - `)} - +const EndAction = ( + { + hasExternalLink = false, + hasActions = false, + idx, + isUnavailable, + isDisabled, + isDrillIn, + isSelected, + rootClass, + selectionMode, + labelId, + size, + value, + } = {}, + context = {}, +) => html` ${when( - hasActions && selectionMode == "multiple", + value, () => html` -
- ${Switch({ - size, - isChecked: isSelected, - isDisabled, - label: null, - id: `menu-switch-${idx}`, - customClasses: [`${rootClass}Switch`], - }, - context)} -
` + [`${rootClass}Value`]: true, + })} + > + ${value} + + `, )} - ${when( - hasExternalLink && !(isUnavailable || isDrillIn) && !(hasActions && selectionMode === "multiple"), - () => html`
- ${Icon({ - setName: "ui", - iconName: iconWithScale(size, "LinkOut"), - size, - customClasses: [ - `${rootClass}Icon`, - "spectrum-Menu-linkout", - ], - }, - context)} -
` + hasActions && selectionMode == "multiple", + () => + html`
+ ${Switch( + { + size, + isChecked: isSelected, + isDisabled, + label: "", + labelId, + id: `menu-switch-${idx}`, + customClasses: [`${rootClass}Switch`], + }, + context, + )} +
`, )} - - ${when( - isUnavailable && !hasExternalLink && !(hasActions && selectionMode === "multiple"), - () => html`
- ${Icon({ - iconName: "InfoCircle", - size, - customClasses: [ - `${rootClass}Icon`, - "spectrum-Menu-unavailable", - ], - }, - context)} -
` + hasExternalLink && + !(isUnavailable || isDrillIn) && + !(hasActions && selectionMode === "multiple"), + () => + html`
+ ${Icon( + { + setName: "ui", + iconName: iconWithScale(size, "LinkOut"), + size, + customClasses: [`${rootClass}Icon`, "spectrum-Menu-linkout"], + }, + context, + )} +
`, + )} + ${when( + isUnavailable && + !hasExternalLink && + !(hasActions && selectionMode === "multiple"), + () => + html`
+ ${Icon( + { + iconName: "InfoCircle", + size, + customClasses: [`${rootClass}Icon`, "spectrum-Menu-unavailable"], + }, + context, + )} +
`, )} - ${when(isDrillIn && !(isUnavailable || hasExternalLink), () => Icon( { @@ -233,18 +257,10 @@ const EndAction = ({ )} `; -const Description = ({ - description, - rootClass -}) => html` - - ${description} - -`; +const Description = ({ rootClass, content } = {}, context = {}) => + html`${renderContent(content, { context })}`; export const MenuItem = ( { @@ -258,6 +274,7 @@ export const MenuItem = ( hasExternalLink = false, hasActions = false, id = getRandomId("menuitem"), + labelId = getRandomId("menuitem-label"), idx = 0, isActive = false, isCollapsible = false, @@ -280,6 +297,7 @@ export const MenuItem = ( } = {}, context = {}, ) => { + const hasSubMenu = isCollapsible && items.length > 0; if (exclusiveFeatures !== "none") { switch (exclusiveFeatures) { @@ -297,6 +315,16 @@ export const MenuItem = ( } } + // Update menuitem role for single and multiple selection if no actions are present + if (!hasActions && role === "menuitem") { + // Force-update the role for multiple selection to menuitemcheckbox per WCAG guidelines + if (selectionMode == "multiple") role = "menuitemcheckbox"; + // Force-update the role for single selection to menuitemradio per WCAG guidelines + else role = "menuitemradio"; + } + + // hasCheckbox = selectionMode == "multiple" && !hasActions + return html`
  • - ${StartAction({ hasActions, idx, isCollapsible, isDisabled, isSelected, isUnavailable, rootClass, selectionMode, size, context })} - ${Visual({ iconName, iconSet, rootClass, size, thumbnailUrl, hasExternalLink, isDrillIn })} - ${Label({ hasActions, isCollapsible, label, rootClass, shouldTruncate })} - ${when(description, () => Description({ description, rootClass }))} - ${EndAction({ hasExternalLink, hasActions, idx, isUnavailable, isDisabled, isDrillIn, isSelected, rootClass, selectionMode, size, value, context })} - ${when(isCollapsible && items.length > 0, () => + ${StartAction( + { + hasActions, + idx, + isCollapsible, + isDisabled, + isSelected, + isUnavailable, + rootClass, + selectionMode, + size, + }, + context, + )} + ${Visual({ + iconName, + iconSet, + rootClass, + size, + thumbnailUrl, + hasExternalLink, + isDrillIn, + })} + ${Label({ + hasActions, + isCollapsible, + label, + rootClass, + id: labelId, + shouldTruncate, + role: ["menuitemcheckbox", "menuitemradio"].includes(role) + ? "presentation" + : undefined, + })} + ${when(description, () => + Description( + { content: description, rootClass: `${rootClass}Description` }, + context, + ), + )} + ${EndAction( + { + hasExternalLink, + hasActions, + idx, + isUnavailable, + isDisabled, + isDrillIn, + isSelected, + rootClass, + selectionMode, + size, + value, + labelId, + }, + context, + )} + ${when(hasSubMenu, () => Template( { items, @@ -351,7 +456,6 @@ export const MenuGroup = ( isTraySubmenu = false, shouldTruncate = false, sectionDescription, - subrole = "menuitem", size = "m", selectionMode = "none", customStyles = {}, @@ -373,11 +477,12 @@ export const MenuGroup = ( ${heading} `, @@ -395,12 +500,14 @@ export const MenuGroup = ( > ${Icon( { - iconName: "ArrowRight" + ({ - s: "100", - m: "100", - l: "400", - xl: "400", - }[size] || "100"), + iconName: + "ArrowRight" + + ({ + s: "100", + m: "100", + l: "400", + xl: "400", + }[size] || "100"), setName: "ui", size, customClasses: ["spectrum-Menu-backIcon"], @@ -416,18 +523,18 @@ export const MenuGroup = ( ["spectrum-Menu-sectionHeading"]: true, ["spectrum-Menu-itemLabel--truncate"]: shouldTruncate, })} - style=${styleMap(customStyles)} id=${id} aria-hidden="true" > ${heading} `, @@ -438,7 +545,6 @@ export const MenuGroup = ( ${Template( { role: "group", - subrole, labelledby: id, hasActions, items, @@ -447,6 +553,7 @@ export const MenuGroup = ( selectionMode, shouldTruncate, size, + customStyles, }, context, )} @@ -472,7 +579,6 @@ export const Template = ( singleItemValue, shouldTruncate = false, size = "m", - subrole = "menuitem", } = {}, context = {}, ) => { @@ -490,65 +596,79 @@ export const Template = ( id=${ifDefined(id)} role=${ifDefined(role)} aria-labelledby=${ifDefined(labelledby)} - aria-disabled=${isDisabled ? "true" : "false"} style=${styleMap({ ...customStyles, - ...(shouldTruncate ? { "max-inline-size": "100%" } : {}) + ...(shouldTruncate ? { "max-inline-size": "100%" } : {}), })} > ${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, + const content = []; + if ( + (hasDividers && i.type === "divider") || + (items[idx - 1]?.heading && i?.heading) + ) + content.push( + Divider({ + tag: "li", + size: "s", + customClasses: [`${rootClass}-divider`], + }), + ); + + if (i.type === "divider") return content; + + if (i.heading || i.isTraySubmenu) + content.push( + MenuGroup( + { + size, + selectionMode, + isTraySubmenu, + shouldTruncate, + hasActions, + ...i, + }, + context, + ), ); else - return MenuItem( - { - ...i, - hasActions, - idx, - isDisabled: isDisabled || i.isDisabled, - role: subrole, - rootClass: `${rootClass}-item`, - selectionMode, - shouldTruncate, - size, - thumbnailUrl: (hasThumbnail && "thumbnail.png") || i.thumbnailUrl, - value: singleItemValue || i.value, - }, - context, + content.push( + MenuItem( + { + ...i, + hasActions, + idx, + isDisabled: isDisabled || i.isDisabled, + rootClass: `${rootClass}-item`, + selectionMode, + shouldTruncate, + size, + thumbnailUrl: + (hasThumbnail && "thumbnail.png") || i.thumbnailUrl, + value: singleItemValue || i.value, + role: role === "listbox" ? "option" : undefined, + }, + context, + ), ); + return content; })} `; - if (isTraySubmenu) return Tray({ - isOpen: true, - content: [ - menuMarkup - ], - }, context); + if (isTraySubmenu) + return Tray( + { + isOpen: true, + content: [menuMarkup], + }, + context, + ); return menuMarkup; }; -export const DisabledItemGroup = (args, context) => { +export const DisabledItemGroup = (args = {}, context = {}) => { const groupData = [ { heading: "Menu with icons", @@ -565,7 +685,7 @@ export const DisabledItemGroup = (args, context) => { label: "Paste", iconName: "Paste", isDisabled: true, - } + }, ], }, { @@ -583,8 +703,8 @@ export const DisabledItemGroup = (args, context) => { label: "Share link", description: "Enable comments and download", isDisabled: true, - } - ] + }, + ], }, { heading: "Menu with icons & descriptions", @@ -604,37 +724,47 @@ export const DisabledItemGroup = (args, context) => { 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, + return Container( + { + withBorder: false, + content: groupData.map( + (group) => html` + ${Container( + { + heading: group.heading, + content: html` + ${Template( + { + ...args, + shouldTruncate: group.shouldTruncate || false, + items: group.items, + }, + context, + )} + `, + }, context, - shouldTruncate: group.shouldTruncate || false, - items: group.items, - }, context)} - ` - }, context)} - `) - }, context); + )} + `, + ), + }, + context, + ); }; -export const OverflowGroup = (args, 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)" } + { label: "Large (works best for printing)" }, ], }, { @@ -651,7 +781,7 @@ export const OverflowGroup = (args, context) => { { label: "Large (works best for printing)", description: "A large description about large is here", - } + }, ], }, { @@ -669,7 +799,7 @@ export const OverflowGroup = (args, context) => { { label: "Large (works best for printing)", description: "A large description about large is here", - } + }, ], }, { @@ -689,14 +819,14 @@ export const OverflowGroup = (args, context) => { }, { label: "Large (works best for printing)", - } - ] - } - ] + }, + ], + }, + ], }, { heading: "Text truncation with drill-ins and values", - shouldTruncate:true, + shouldTruncate: true, items: [ { label: "Quick export truncated text", @@ -713,41 +843,47 @@ export const OverflowGroup = (args, context) => { label: "Preview timelapse truncated text", iconName: "Preview", value: "Value", - } - ] - } + }, + ], + }, ]; - return Container({ - withBorder: false, - content: groupData.map((group) => html` - ${Container({ - heading: group.heading, - content: html` - ${Template({ - ...args, + return Container( + { + withBorder: false, + content: groupData.map( + (group) => html` + ${Container( + { + heading: group.heading, + content: html` + ${Template( + { + ...args, + shouldTruncate: group.shouldTruncate || false, + items: group.items, + }, + context, + )} + `, + wrapperStyles: { + "max-inline-size": "200px", + }, + }, context, - shouldTruncate: group.shouldTruncate || false, - items: group.items, - }, context)} + )} `, - wrapperStyles: { - "max-inline-size": "200px", - }, - }, context)} - `) - }, context); + ), + }, + context, + ); }; -export const SelectionGroup = (args, context) => { +export const SelectionGroup = (args = {}, context = {}) => { const groupData = [ { heading: "No selection", - items: [ - { label: "Cut" }, - { label: "Copy" }, - { label: "Paste" }, - ], + items: [{ label: "Cut" }, { label: "Copy" }, { label: "Paste" }], }, { heading: "Single selection", @@ -762,7 +898,7 @@ export const SelectionGroup = (args, context) => { }, { label: "Subtract", - } + }, ], }, { @@ -772,16 +908,16 @@ export const SelectionGroup = (args, context) => { { label: "Preview 1", isSelected: true, - thumbnailUrl: "thumbnail.png" + thumbnailUrl: "thumbnail.png", }, { label: "Preview 2", - thumbnailUrl: "flowers.png" + thumbnailUrl: "flowers.png", }, { label: "Preview 3", - thumbnailUrl: "example-ava.png" - } + thumbnailUrl: "example-ava.png", + }, ], }, { @@ -797,7 +933,7 @@ export const SelectionGroup = (args, context) => { }, { label: "Subtract", - } + }, ], }, { @@ -816,7 +952,7 @@ export const SelectionGroup = (args, context) => { { label: "Subtract", iconName: "SelectNone", - } + }, ], }, { @@ -826,16 +962,16 @@ export const SelectionGroup = (args, context) => { { label: "Preview 1", isSelected: true, - thumbnailUrl: "thumbnail.png" + thumbnailUrl: "thumbnail.png", }, { label: "Preview 2", - thumbnailUrl: "flowers.png" + thumbnailUrl: "flowers.png", }, { label: "Preview 3", - thumbnailUrl: "example-ava.png" - } + thumbnailUrl: "example-ava.png", + }, ], }, { @@ -853,7 +989,7 @@ export const SelectionGroup = (args, context) => { { label: "Rulers", isSelected: true, - } + }, ], }, { @@ -873,7 +1009,7 @@ export const SelectionGroup = (args, context) => { { label: "Subtract", iconName: "SelectNone", - } + }, ], }, { @@ -884,99 +1020,123 @@ export const SelectionGroup = (args, context) => { { label: "Preview 1", isSelected: true, - thumbnailUrl: "thumbnail.png" + thumbnailUrl: "thumbnail.png", }, { label: "Preview 2", - thumbnailUrl: "flowers.png" + thumbnailUrl: "flowers.png", }, { label: "Preview 3", - thumbnailUrl: "example-ava.png" - } + thumbnailUrl: "example-ava.png", + }, ], }, ]; return Container({ withBorder: false, - content: groupData.map((group) => Container({ - heading: group.heading, - content: Template({ - ...args, + content: groupData.map((group) => + Container( + { + heading: group.heading, + content: Template( + { + ...args, + selectionMode: group.selectionMode || "none", + hasActions: group.hasActions || false, + items: group.items, + }, + context, + ), + }, context, - selectionMode: group.selectionMode || "none", - hasActions: group.hasActions || false, - items: group.items, - }, context) - }, context)) + ), + ), }); }; -export const SubmenuInPopover = (args, context) => Popover({ - isOpen: true, - position: "end-top", - customStyles: { - "inline-size": "202px", - }, - trigger: (args, context) => ActionButton({ - label: "Settings", - iconName: "Settings", - ...args, - }, context), - content: [ - Template({ - ...args, - items: [ - { - label: "Language", - value: "English (US)", - isDrillIn: true, - isHovered: true, - }, - { - label: "Notifications", - }, - { - label: "Show grid", - } - ], - }, context), - Popover({ +export const SubmenuInPopover = (args = {}, context = {}) => + Popover( + { isOpen: true, position: "end-top", customStyles: { - top: "-110px", - "inline-size": "120px", - "inset-inline-start": "calc(100% - 10px)" + "inline-size": "202px", }, + trigger: (passthroughs) => + ActionButton( + { + label: "Settings", + iconName: "Settings", + ...passthroughs, + }, + context, + ), content: [ - Template({ - ...args, - selectionMode: "single", - items: [ - { - label: "Deutsch", - }, - { - label: "English (US)", - isSelected: true, - }, - { - label: "Español", - }, - { - label: "Français", - }, - { - label: "Italiano", + Template( + { + ...args, + items: [ + { + label: "Language", + value: "English (US)", + isDrillIn: true, + isHovered: true, + }, + { + label: "Notifications", + }, + { + label: "Show grid", + }, + ], + }, + context, + ), + Popover( + { + isOpen: true, + position: "end-top", + customStyles: { + top: "-110px", + "inline-size": "120px", + "inset-inline-start": "calc(100% - 10px)", }, - { - label: "日本語", - } - ], - }, context) + content: [ + Template( + { + ...args, + selectionMode: "single", + items: [ + { + label: "Deutsch", + }, + { + label: "English (US)", + isSelected: true, + }, + { + label: "Español", + }, + { + label: "Français", + }, + { + label: "Italiano", + }, + { + label: "日本語", + }, + ], + }, + context, + ), + ], + }, + context, + ), ], - }, context) - ], -}, context); + }, + context, + ); diff --git a/components/switch/stories/template.js b/components/switch/stories/template.js index 2ab50097eef..9897a14c864 100644 --- a/components/switch/stories/template.js +++ b/components/switch/stories/template.js @@ -18,6 +18,7 @@ export const Template = ({ customClasses = [], customStyles = {}, id = getRandomId("switch"), + labelId, } = {}) => { // ID attribute value for the input element. const inputId = getRandomId("switch-input"); @@ -42,6 +43,7 @@ export const Template = ({ id=${inputId} ?disabled=${isDisabled} ?checked=${isChecked} + aria-labelledby=${ifDefined(labelId)} /> ${when(label, () => html` diff --git a/tasks/component-compare.js b/tasks/component-compare.js index 5a00daa90fd..54d30a64e9b 100644 --- a/tasks/component-compare.js +++ b/tasks/component-compare.js @@ -361,9 +361,6 @@ async function main( components = getAllComponentNames(false); } - // Strip out utilities - components = components.filter(c => !["actionmenu"].includes(c)); - pathing.output = output; pathing.cache = join(output, "packages"); pathing.base = join(output, "base"); From 03a7ae44c89fd1df0bd52272a4537882091eded8 Mon Sep 17 00:00:00 2001 From: castastrophe Date: Mon, 29 Sep 2025 12:12:40 -0400 Subject: [PATCH 2/2] chore: apply code review feedback awesomeness --- .changeset/brave-bikes-teach.md | 5 +- components/actionbutton/dist/metadata.json | 11 ++-- components/actionbutton/index.css | 6 +- .../stories/actionbutton.stories.js | 6 +- components/actionbutton/stories/template.js | 55 +++++++++++++------ .../actionmenu/stories/actionmenu.stories.js | 9 +-- components/actionmenu/stories/template.js | 21 +++++-- components/coachmark/stories/template.js | 2 +- components/popover/stories/template.js | 17 +----- 9 files changed, 72 insertions(+), 60 deletions(-) diff --git a/.changeset/brave-bikes-teach.md b/.changeset/brave-bikes-teach.md index f8b46b4c2b2..6f804bc4493 100644 --- a/.changeset/brave-bikes-teach.md +++ b/.changeset/brave-bikes-teach.md @@ -44,10 +44,7 @@ Example markup: Updates `@spectrum-css/menu` styles to align with latest Spectrum 2 design specifications and improve accessibility. -- Focus indicator tokens wired through: width, color, gap/offset, and outline style. -- CJK line-height tokens applied for labels, descriptions, and section headers. -- External link and drill‑in icon sizing variables exposed; thumbnail sizing and alignment refined. -- Forced-colors improvements and readability adjustments. +- Added this not to prevent clash with the `.is-selectable` placement. - Non-breaking; no class or DOM changes required. ### Action button refinements diff --git a/components/actionbutton/dist/metadata.json b/components/actionbutton/dist/metadata.json index 90580bee21e..8a5fd1c4424 100644 --- a/components/actionbutton/dist/metadata.json +++ b/components/actionbutton/dist/metadata.json @@ -14,16 +14,16 @@ ".spectrum-ActionButton-label", ".spectrum-ActionButton-label:empty", ".spectrum-ActionButton.is-disabled", - ".spectrum-ActionButton.spectrum-ActionButton--emphasized:where(.is-selected, [aria-pressed=\"true\"], [aria-expanded=\"true\"])", + ".spectrum-ActionButton.spectrum-ActionButton--emphasized:is(.is-selected, [aria-pressed=\"true\"], [aria-expanded=\"true\"])", ".spectrum-ActionButton.spectrum-ActionButton--quiet", + ".spectrum-ActionButton.spectrum-ActionButton--quiet:is(.is-selected, [aria-pressed=\"true\"], [aria-expanded=\"true\"])", ".spectrum-ActionButton.spectrum-ActionButton--quiet:is(:disabled, .is-disabled, [aria-disabled=\"true\"]):not(:where(.is-selected, [aria-pressed=\"true\"], [aria-expanded=\"true\"]))", - ".spectrum-ActionButton.spectrum-ActionButton--quiet:where(.is-selected, [aria-pressed=\"true\"], [aria-expanded=\"true\"])", ".spectrum-ActionButton.spectrum-ActionButton--staticBlack", ".spectrum-ActionButton.spectrum-ActionButton--staticBlack.spectrum-ActionButton--quiet", - ".spectrum-ActionButton.spectrum-ActionButton--staticBlack:where(.is-selected, [aria-pressed=\"true\"], [aria-expanded=\"true\"])", + ".spectrum-ActionButton.spectrum-ActionButton--staticBlack:is(.is-selected, [aria-pressed=\"true\"], [aria-expanded=\"true\"])", ".spectrum-ActionButton.spectrum-ActionButton--staticWhite", ".spectrum-ActionButton.spectrum-ActionButton--staticWhite.spectrum-ActionButton--quiet", - ".spectrum-ActionButton.spectrum-ActionButton--staticWhite:where(.is-selected, [aria-pressed=\"true\"], [aria-expanded=\"true\"])", + ".spectrum-ActionButton.spectrum-ActionButton--staticWhite:is(.is-selected, [aria-pressed=\"true\"], [aria-expanded=\"true\"])", ".spectrum-ActionButton::-moz-focus-inner", ".spectrum-ActionButton:active", ".spectrum-ActionButton:after", @@ -35,9 +35,8 @@ ".spectrum-ActionButton:has(.spectrum-ActionButton-icon)", ".spectrum-ActionButton:hover", ".spectrum-ActionButton:is(.is-selected, [aria-pressed=\"true\"], [aria-expanded=\"true\"])", + ".spectrum-ActionButton:is(:disabled, .is-disabled, [aria-disabled=\"true\"])", ".spectrum-ActionButton:not(:has(.spectrum-ActionButton-label))", - ".spectrum-ActionButton:where(.is-selected, [aria-pressed=\"true\"], [aria-expanded=\"true\"])", - ".spectrum-ActionButton:where(:disabled, .is-disabled, [aria-disabled=\"true\"])", "a.spectrum-ActionButton" ], "modifiers": [ diff --git a/components/actionbutton/index.css b/components/actionbutton/index.css index 132762e7d04..79d350400a6 100644 --- a/components/actionbutton/index.css +++ b/components/actionbutton/index.css @@ -63,7 +63,7 @@ governing permissions and limitations under the License. --spectrum-actionbutton-background-color-focus: var(--spectrum-gray-200); --spectrum-actionbutton-background-color-disabled: transparent; - &:where(.is-selected, [aria-pressed="true"], [aria-expanded="true"]) { + &:is(.is-selected, [aria-pressed="true"], [aria-expanded="true"]) { --spectrum-actionbutton-background-color-disabled: var(--spectrum-disabled-background-color); } } @@ -117,7 +117,7 @@ governing permissions and limitations under the License. } /* expanded is specific to action menu when the menu is open */ - &:where(.is-selected, [aria-pressed="true"], [aria-expanded="true"]) { + &:is(.is-selected, [aria-pressed="true"], [aria-expanded="true"]) { --mod-actionbutton-background-color-default: var(--mod-actionbutton-background-color-default-selected, var(--spectrum-neutral-background-color-selected-default)); --mod-actionbutton-background-color-hover: var(--mod-actionbutton-background-color-hover-selected, var(--spectrum-neutral-background-color-selected-hover)); --mod-actionbutton-background-color-down: var(--mod-actionbutton-background-color-down-selected, var(--spectrum-neutral-background-color-selected-down)); @@ -342,7 +342,7 @@ governing permissions and limitations under the License. } /* ideal when we want to disable the button but still allow it's content to be focused */ - &:where(:disabled, .is-disabled, [aria-disabled="true"]) { + &:is(:disabled, .is-disabled, [aria-disabled="true"]) { background-color: var(--highcontrast-actionbutton-background-color-disabled, var(--mod-actionbutton-background-color-disabled, var(--spectrum-actionbutton-background-color-disabled))); color: var(--highcontrast-actionbutton-content-color-disabled, var(--mod-actionbutton-content-color-disabled, var(--spectrum-actionbutton-content-color-disabled))); } diff --git a/components/actionbutton/stories/actionbutton.stories.js b/components/actionbutton/stories/actionbutton.stories.js index 62fc72d59f9..ccfc5c96513 100644 --- a/components/actionbutton/stories/actionbutton.stories.js +++ b/components/actionbutton/stories/actionbutton.stories.js @@ -107,7 +107,7 @@ export default { }, parameters: { actions: { - handles: ["click .spectrum-ActionButton:not([disabled])"], + handles: ["click .spectrum-ActionButton:not([disabled])", "mousedown .spectrum-ActionButton:not([disabled])", "mouseup .spectrum-ActionButton:not([disabled])", "touchstart .spectrum-ActionButton:not([disabled])", "touchend .spectrum-ActionButton:not([disabled])"], }, design: { type: "figma", @@ -198,8 +198,8 @@ Quiet.parameters = { /** * An action button can have a hold icon (a small corner triangle). This icon indicates that holding down the action button for a - * short amount of time can reveal a [popover](/docs/components-popover--docs) menu, which can be used, for example, to switch - * between related actions. Note that this popover menu is not demonstrated here—this would be handled by the implementation. + * short amount of time (currently the standard is 300ms) can reveal a [popover](/docs/components-popover--docs) menu, which can be used, for example, to switch + * between related actions. Note that this popover menu is not demonstrated here; this would be handled by the implementation. * Because of the way padding is calculated, the hold icon must be placed before the workflow icon in the markup. */ export const HoldIcon = IconOnlyOption.bind({}); diff --git a/components/actionbutton/stories/template.js b/components/actionbutton/stories/template.js index 28e81354eb4..2530960daa7 100644 --- a/components/actionbutton/stories/template.js +++ b/components/actionbutton/stories/template.js @@ -69,6 +69,20 @@ export const Template = ({ role = "button", } = {}, context = {}) => { const { updateArgs } = context; + + // If a custom onclick handler isn't provided, close the popover when clicking outside of the button + if (typeof onclick !== "function") { + document.body.addEventListener("click", function (evt) { + if (evt.target.closest(`.${rootClass}`)) { + return; + } + updateArgs({ + isSelected: false, + isOpen: false, + }); + }); + } + return html`