diff --git a/.changeset/brave-bikes-teach.md b/.changeset/brave-bikes-teach.md
new file mode 100644
index 00000000000..6f804bc4493
--- /dev/null
+++ b/.changeset/brave-bikes-teach.md
@@ -0,0 +1,61 @@
+---
+"@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.
+
+- Added this not to prevent clash with the `.is-selectable` placement.
+- 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..8a5fd1c4424 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:is(.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(.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--staticBlack",
".spectrum-ActionButton.spectrum-ActionButton--staticBlack.spectrum-ActionButton--quiet",
+ ".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:is(.is-selected, [aria-pressed=\"true\"], [aria-expanded=\"true\"])",
".spectrum-ActionButton::-moz-focus-inner",
".spectrum-ActionButton:active",
".spectrum-ActionButton:after",
@@ -35,11 +34,12 @@
".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:is(:disabled, .is-disabled, [aria-disabled=\"true\"])",
".spectrum-ActionButton:not(:has(.spectrum-ActionButton-label))",
"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 +71,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..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;
- &.is-selected {
+ &:is(.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 */
+ &: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));
@@ -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 */
+ &: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)));
}
@@ -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..ccfc5c96513 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,
@@ -88,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",
@@ -179,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/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..2530960daa7 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,
@@ -67,45 +69,65 @@ 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`