Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .changeset/brave-bikes-teach.md
Original file line number Diff line number Diff line change
@@ -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
<div class="spectrum-ActionMenu">
<button
class="spectrum-ActionMenu-trigger spectrum-ActionButton"
aria-haspopup="menu"
aria-expanded="false"
>
<!-- icon/label -->
</button>
<div class="spectrum-ActionMenu-popover spectrum-Popover">
<ul class="spectrum-ActionMenu-menu spectrum-Menu">
<!-- menu items -->
</ul>
</div>
<!-- popover positioning/visibility is owned by your implementation -->
<!-- use long-press behavior when appropriate to your UX -->
<!-- use Popover placement options: bottom-start, bottom-end, start-top, end-top -->
</div>
```

### 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.
18 changes: 7 additions & 11 deletions components/actionbutton/dist/metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is for S2 and we've agreed to remove modifiers, I removed the mods on any lines I needed to update for this change anyway.

"--mod-actionbutton-background-color-default",
"--mod-actionbutton-background-color-default-selected",
"--mod-actionbutton-background-color-default-selected-emphasized",
Expand Down Expand Up @@ -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",
Expand Down
64 changes: 29 additions & 35 deletions components/actionbutton/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this update component properties instead of mods, or is it a case where it hits an issue going into SWC environment?

Either way - a good case to flag for our upcoming discussions around this 👍

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That said - I also don't see this change in the Action Menu tokens, possibly because "selected" is a different state/intent than "expanded"? Should we double-check with design if this should take on the darker background?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, action button should be our first attempt to introduce layers I suspect because it is riddled with specificity battles and often hooking the modifiers was the only way to ensure properties updated in context.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

got it, ok!

--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));
Expand Down Expand Up @@ -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;
}
Expand All @@ -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 {
Expand All @@ -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)));
}
Expand Down Expand Up @@ -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));
Expand All @@ -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 {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just me tidying up a bit by combining these styles in with the initial definition for .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 {
/**
Expand Down Expand Up @@ -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;
}
}
Expand All @@ -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;
Expand Down
35 changes: 27 additions & 8 deletions components/actionbutton/stories/actionbutton.stories.js
Original file line number Diff line number Diff line change
@@ -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.
*
Expand Down Expand Up @@ -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" },
Expand All @@ -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 },
Expand All @@ -77,6 +95,7 @@ export default {
isQuiet: false,
isEmphasized: false,
hasPopup: "false",
hasLongPress: false,
isActive: false,
isFocused: false,
isHovered: false,
Expand All @@ -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",
Expand Down Expand Up @@ -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 herethis 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({});
Expand Down
2 changes: 2 additions & 0 deletions components/actionbutton/stories/actionbutton.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
</div>
`;
Expand Down
Loading
Loading