Repository navigation
Conversation
Phase 2. Core holds the types and the base class; swc holds the concrete element, its registration, and a layout-only stylesheet. Row chrome is the next story. Paths follow the repo's own convention rather than the ticket's, which still describes 2nd-gen/ and a menu/menu-item/ nesting that predate the gen2 rename and the flat component layout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 3. The surface is the migration plan's Phase A table: `size` (s/m/l/xl, default m) and `disabled`, with a named `label` slot and an optional leading `visual` slot. JSDoc on everything public. Breaking changes from the plan, all implemented by omission: - no `href`/`target`/`download`/`rel`/`referrerpolicy`; link rows are swc-menu-link's job, so this element never renders an anchor - no `selected`/`value`; the menuitem role cannot carry aria-selected and selection belongs to the checkbox and radio rows via aria-checked - no `hasSubmenu`/`open`, no `setRole()`, no `dispatchUpdate()` - the default slot becomes a named `label` slot, and `icon` becomes `visual` - all 45 --mod-menu-item-* custom properties dropped Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 4. role="menuitem" is set in connectedCallback, not at render: the owning menu collects rows on slotchange, which can run before this element first renders. An author-supplied role wins, so menuitemcheckbox and menuitemradio rows can arrive later without a change here. `disabled` maps to aria-disabled and never the native attribute, so the row keeps its place in the menu's roving set and stays arrow-reachable (B7). Mapped by hand rather than through DisabledMixin, settling Q20: the mixin also writes tabindex, which FocusgroupNavigationController owns. Arrow-key movement, tab stops and focus restoration stay with swc-menu; the row only has to be discoverable and keep its role. The reachability test dispatches its key from the row rather than through userEvent.keyboard. That helper tracks its own notion of focus and stops reaching the menu once the row has a shadow root, which made an earlier version of this test fail for reasons that had nothing to do with the component. Verified the assertion still fails when skipDisabled is flipped to true. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
📚 Branch Preview Links🔍 Gen1 Visual Regression Test ResultsWhen a visual regression test fails (or has previously failed while working on this branch), its results can be found in the following URLs:
Deployed to Azure Blob Storage: If the changes are expected, update the |
|
[nit] The PR description suggests we should look at Components → Menu item → States but that exists in Menu 😁 |
Screen.Recording.2026-10-28.at.11.47.29.AM.movClicking the trigger then pressing the down arrow key moves to the second menu item not the first. But if you tab to the button trigger, press Edit: Screen.Recording.2026-10-28.at.12.41.05.PM.mov |
rubencarvalho
left a comment
There was a problem hiding this comment.
I am ok with skipping DisabledMixin here, and connectedCallback for the role makes sense to me. left two questions, nothing huge
Review feedback. The accessibility analysis requires Enter and Space to activate a row (WCAG 2.1.1), but only Enter was handled, and it merely closed the menu: no click was ever dispatched, so a consumer's handler on the row never ran from the keyboard at all. Enter on a disabled row closed the menu too. `MenuItemBase` now turns both keys into a click, which is the same path pointer activation already takes, and swallows them on a disabled row. `swc-menu` treats Space like Enter and skips aria-disabled rows for both keyboard and pointer activation. Also drops the redundant `defaultSize: 'm'` (SizedMixin's own default) and uses the `ariaDisabled` reflection property in place of the hand-written attribute calls. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review feedback. The label is the row's main content, so it belongs in the default slot; named slots are for what sits alongside it. This matches swc-badge and swc-status-light, 1st-gen sp-menu-item, and React Spectrum S2, whose MenuItem takes children for the label and names only description. Rows are now `<swc-menu-item>Cut</swc-menu-item>`, which also closes Q23: the named slot was the heavier form with nothing to show for it. Adds a test for the keyboard open path after a review recording suggested the first arrow press skips a row. Focus lands on the first row either way; what differs is whether the browser paints a ring for programmatic focus. Recorded in the plan so the row chrome story keys that treatment off `:host(:focus)` rather than `:focus-visible`, as 1st-gen does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review feedback. swc-menu-item was showing up as a top-level Storybook entry beside Menu, which is not how gen2 treats a sub-element that only exists inside its parent: swc-accordion-item and swc-tab live beside their parent and share one Storybook entry and one docs page. Components that get their own folder, close-button and infield-button among them, are the ones reusable across parents. A menu row is not. Moves MenuItem.base.ts and MenuItem.types.ts into core/components/menu, and MenuItem.ts, menu-item.css and swc-menu-item.ts into swc/components/menu, re-exported from the menu barrels. Drops the separate core subpath export. Stories, tests and docs fold into the menu's own, so rows are documented in menu.mdx under an Item states section. This also lets swc-menu drop its placeholder rows for the real element across stories, tests and VRT. Two menu tests had to stop driving keys through userEvent.keyboard, which tracks its own notion of focus and stops reaching the menu once a row has a shadow root. The Tab trap step now asserts defaultPrevented rather than only that focus did not move, which it would have reported either way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
aramos-adobe
left a comment
There was a problem hiding this comment.
Nice work on this 🚀
rubencarvalho
left a comment
There was a problem hiding this comment.
Requesting changes so it also dismisses my approval.
After the change of putting the menu item inside menu, we're not seeing the API of the menu item in the docs...
Review feedback, three threads. Folding rows into the menu folder dropped swc-menu-item from the docs page: DocsFooter renders the meta's own `component` unless the meta lists its sibling elements. Declares `additionalApiTables: ['swc-menu-item']`, the same way accordion and tabs surface theirs. Adds the `description` slot, pulled into this phase rather than deferred. It is how similarly-named options are told apart, which is an accessibility concern in its own right, and 1st-gen `sp-menu-item` and React Spectrum S2 both carry it. The label and its description stack in a wrapper; typography and colour belong to the row chrome story. Note that a `menuitem` names itself from its contents, so the description joins the row's accessible name rather than being announced separately, which is also how 1st-gen behaves. Logged as Q25 for the a11y reviewer. Also documents `visual` as taking an icon **or a thumbnail**, which was the intent behind renaming it from `icon` but was not written down anywhere a reader would find it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reverses the slot half of 1c6a714. I moved the label to the default slot on the basis that React Spectrum S2 names only `description`. That was wrong: RSP takes `Text slot="label"` alongside `Text slot="description"`, which this plan's own API summary already recorded and I misread. With `description` shipping beside it, an unnamed label is both ambiguous and harder to place, since each part needs a handle to assign to a grid area in the row layout. Rows are `<swc-menu-item><span slot="label">Cut </span></swc-menu-item>` again, and Q23 closes the other way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…cription A `menuitem` takes its accessible name from its contents, so the description was being folded into the row's name instead of announced as a description. Point `ariaLabelledByElements` at the `label` slot and `ariaDescribedByElements` at the `description` slot instead, matching React Spectrum. Element references rather than ID refs, so consumer markup needs no `id` attributes. Closes Q25 in the menu-item migration plan. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `if (!this.hasAttribute('role'))` guard let an author swap in
`menuitemcheckbox` or `menuitemradio`, promising visuals and behavior the row
does not have. Selection rows ship as their own components, so set the role
unconditionally, as B24 in the migration plan already specified.
Also records the dropped `sp-menu-item.setRole()` in the consumer migration
guide.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| <div class="swc-MenuItem-text"> | ||
| <slot name="label"></slot> | ||
| <slot name="description"></slot> | ||
| <slot name="label" @slotchange=${this.handleTextSlotChange}></slot> |
There was a problem hiding this comment.
Also trigger handleTextSlotChange on first render in case slots are empty at first.
There was a problem hiding this comment.
Done in 6b7e04dc5d, and it turned out to matter for more than safety: an entirely empty row fires no slotchange at all, so first render is the only thing that reaches the new missing-label warning. That is the case the test pins.
| ]; | ||
| this.ariaLabelledByElements = label.length > 0 ? label : null; | ||
| this.ariaDescribedByElements = description.length > 0 ? description : null; | ||
| } |
There was a problem hiding this comment.
Could we write these to internals instead of this? Setting this.ariaLabelledByElements writes to the host's aria-labelledby, so it'll overwrite (or with null, remove) anything a consumer sets there. With internals they're just defaults and consumer attributes still win, same as _syncLabelling in the labelling mixin. Also swapped to assignedElements({ flatten: true }) so this still works if the slots get forwarded through another component.
| } | |
| @query('slot[name="label"]') | |
| private labelSlot?: HTMLSlotElement; | |
| @query('slot[name="description"]') | |
| private descriptionSlot?: HTMLSlotElement; | |
| protected handleTextSlotChange(): void { | |
| // `menuitem` names from contents, so without explicit references the | |
| // description is folded into the name instead of announced as a description. | |
| const label = this.labelSlot?.assignedElements({ flatten: true }) ?? []; | |
| const description = | |
| this.descriptionSlot?.assignedElements({ flatten: true }) ?? []; | |
| this.internals.ariaLabelledByElements = label.length > 0 ? label : null; | |
| this.internals.ariaDescribedByElements = | |
| description.length > 0 ? description : null; | |
| } |
This means a switch for the host to have internals from attachInternals() (where role = 'menuitem' is set).
If you don't want to switch to internals, then this would be the alternative to not override consumer-set attributes, but @nikkimk should weigh-in that concern:
// Skip when a consumer set IDREFs on the host. Element reflection leaves
// the attribute as "", so a non-empty value means it isn't ours.
if (!this.getAttribute('aria-labelledby')) {
this.ariaLabelledByElements = label.length > 0 ? label : null;
}
if (!this.getAttribute('aria-describedby')) {
this.ariaDescribedByElements = description.length > 0 ? description : null;
}
One edge case: if there's a description but no label, the description still gets folded into the name. Might be worth a debug warning, or falling back to internals.ariaLabel.
There was a problem hiding this comment.
I agree with @5t3ph's code suggestion here. I think if there is a description but no label, that would be a dev warning as we should always have a label.
There was a problem hiding this comment.
All three taken in 6b7e04dc5d: references moved onto internals (the host keeps no aria-labelledby/aria-describedby, and a consumer value now survives, which a test pins), slots read via assignedElements({ flatten: true }), and a missing-label warning via the shared validateRequiredSlot rather than an ariaLabel fallback, per @nikkimk.
One deliberate omission: I kept role as setAttribute rather than moving it to internals.role, because on internals an author attribute would win and that reopens the role="menuitemcheckbox" override you asked me to remove in the comment above. Happy to move it if you would rather have the platform layering there too.
Writing `ariaLabelledByElements` to the host reflects onto `aria-labelledby`,
so the references the component supplies clobbered anything a consumer had set
there. Move them onto `ElementInternals`, where they act as defaults a
consumer's own attributes still override.
Also resolve the slots with `assignedElements({ flatten: true })` so a row
whose slots are forwarded through another component still names itself, run
the handler on first render since a slot that starts empty never fires
`slotchange`, and warn when a row has no `label`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1fe56ce to
84dc1c4
Compare
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks @5t3ph and @nikkimk for the review! It caught three issues I would otherwise have shipped, all fixed in
I went with @nikkimk's shared One open question: I kept |
There was a problem hiding this comment.
Left some notes inline. Couple of things: the @example in Menu.ts still uses default-slot rows, menu.mdx line 34 still says swc-menu-item doesn't exist yet (and line 75 doesn't mention Space).
Might also be worth a check in -Firefox- (actually on all browsers), I'm getting a weird behaviour for Space: focus is already back on the trigger before keyup, and then the browser has clicked the newly focused button on keyup before, which would reopen the menu.
| // Enter would otherwise re-fire on the trigger the menu restores focus | ||
| // to, and Space would scroll the page under the open surface. | ||
| event.preventDefault(); | ||
| if (!this.disabled) { |
There was a problem hiding this comment.
this covers Enter/Space, but a mouse click on a disabled row still goes through. on the preview storybook (Item states), clicking the disabled row still runs a click listener on it, the menu just stays open. 1st-gen blocks this with a capture click listener (handleClickCapture in 1st-gen MenuItem.ts), could we do the same here? and maybe a test that actually clicks the disabled row, since the current one only presses Enter
| } | ||
| if (event.key === 'Enter' && this.isMenuItemEventTarget(event)) { | ||
| // Else the deferred default-action re-activates the refocused trigger. | ||
| if (event.key === 'Enter' || event.key === ' ') { |
There was a problem hiding this comment.
this runs on document capture, so with a real Enter/Space the menu closes first (swc-close fires, focus goes back to the trigger) and only then does the row's keydown call click(). with the mouse it's row click first, then close. what if this branch only did preventDefault and the row's click closed the menu through handleItemActivate, so both paths match? (I think the tests can't see this, since events dispatched from script don't let the update run between listeners)
| // Enter and Space activate a row. Turning them into a click is what makes | ||
| // keyboard and pointer activation reach the same consumer handler. | ||
| private readonly handleKeyDown = (event: KeyboardEvent): void => { | ||
| if (event.key !== 'Enter' && event.key !== ' ') { |
There was a problem hiding this comment.
maybe ignore event.repeat here (after preventDefault)? if you hold Enter on a row it runs, focus goes back to the trigger, the next repeat reopens the menu, and the one after that runs row 1 again
| ].map( | ||
| (label) => html` | ||
| <swc-menu-item role="menuitem">${label}</swc-menu-item> | ||
| <swc-menu-item>${label}</swc-menu-item> |
There was a problem hiding this comment.
this one still puts the text in the default slot, so these 15 rows render empty now and the long list overflow snapshot won't really show a long list. <span slot="label">${label}</span>?
| | `ignore` property | No replacement. | | ||
| | `getNeighboringFocusableElement()` method | No public replacement; arrow-key navigation is handled internally. | | ||
| | `role="menu"` on the host element | Moved to the shadow-internal surface. Do not select `sp-menu[role="menu"]`-style host selectors. | | ||
| | `setRole()` on menu items | No replacement. A row is always `role="menuitem"`; checkbox and radio rows arrive as dedicated components. | |
There was a problem hiding this comment.
swc-menu-item ships now, so a few spots in this guide are out of date: line 7 says sp-menu-item hasn't migrated, line 26 only imports swc-menu.js (the accordion and tabs guides list the child import too), and the After examples on 79 and 98 use <swc-menu-item>Deselect</swc-menu-item>, which renders an empty row now. could we fix those in this PR and leave the full row section for the docs phase?
| * this class supplies the styles and the render template. | ||
| * | ||
| * @element swc-menu-item | ||
| * @since 2.0.0-beta.5 |
There was a problem hiding this comment.
swc is already on 2.0.0-beta.6, so this should probably be the next version
| - [x] A disabled row remains reachable by arrow keys inside `swc-menu` ([B20](#must-ship)); this is the test that settles [Q20](#blockers-and-open-questions) | ||
| - [x] `label`, `visual` and `description` slots render in the expected order | ||
| - [x] `swc-menu` discovers rows via `:scope > swc-menu-item` with real elements, not placeholders | ||
| - [x] <kbd>Enter</kbd> and <kbd>Space</kbd> reach a consumer `click` handler on an enabled row, and neither activates a disabled one |
There was a problem hiding this comment.
this is ticked, but there's no test for Space on a disabled row, or for Enter reaching an enabled row's click handler (TabTrapAndEnterClosesTest only checks that the menu closes)
| | **Q24** | Should a row be able to mark itself as the current one? `menuitem` cannot carry `aria-selected`, but `aria-current` is available and would cover "this is where you are" without implying selection. Needs a product answer on whether menus need it at all before any `current` property is designed. | No | Open | | ||
| | **Q23** | Authoring ergonomics of the named `label` slot: `<swc-menu-item><span slot="label">Cut</span></swc-menu-item>` is heavier than the 1st-gen default-slot form, and every existing `swc-menu` story and test uses the lighter one. Confirm the trade is still wanted now that the cost is visible. | No | Open, decided in principle under [B22](#must-ship) | | ||
| | **Q23** | Authoring ergonomics of the named `label` slot. | No | **Closed.** Named slot kept: with `description` alongside it, an unnamed label is both ambiguous and harder to place in a grid. See [B22](#must-ship) | | ||
| | **Q25** | A `menuitem` takes its accessible name from its contents, so a `description` is announced as part of the row's name rather than as a separate description. 1st-gen behaves the same way. Wiring it as a real description instead would mean naming the row from its label alone, which the default slot makes awkward since the label can be a bare text node. Worth doing, or is name-from-contents the right answer here? | No | **Closed.** Reviewer confirmed React Spectrum references the label as `aria-labelledby` and the description as `aria-describedby`, so the row is named from its label alone. `MenuItemBase.handleTextSlotChange()` sets `ariaLabelledByElements` and `ariaDescribedByElements` from the slotted elements; the named `label` slot ([Q23](#open-questions)) is what makes this possible, since element references need an element | |
There was a problem hiding this comment.
tiny one: #open-questions doesn't exist here, the heading anchor is #blockers-and-open-questions
| tags: ['states'], | ||
| }; | ||
|
|
||
| export const ItemContent: Story = { |
There was a problem hiding this comment.
nit: this is tagged options but sits under the STATES banner, could move it up next to Sizes
| * pattern: `role="menuitem"` on the host, with the owning `swc-menu` driving | ||
| * arrow-key navigation and roving tabindex across its rows. | ||
| * | ||
| * @slot label - Text of the row. Required; referenced as the row's accessible |
There was a problem hiding this comment.
nit: the jsdoc guide asks for each @slot on one line (07_jsdoc-standards.md). the CEM output looks fine today, same thing in MenuItem.ts


Phases 2–4 for
swc-menu-item, one commit each. API and a11y only; no row chrome (next story).What's here
core/components/menu/MenuItem.types.ts,swc/components/menu/{menu-item.css,swc-menu-item.ts}, barrel exportscore/components/menu/MenuItem.base.ts,swc/components/menu/MenuItem.ts, storiesaria-disabledwiring inMenuItem.base.ts, testsPublic surface:
size(s/m/l/xl, defaultm),disabled, and three named slots:label,visual,description. That's it.EnterandSpaceactivate a row by dispatching aclick, so keyboard and pointer activation reach the same consumer handler.What to review
1.
DisabledMixinis deliberately not used —MenuItem.base.tsIt produces the right ARIA, but it also writes
tabindex, whichFocusgroupNavigationControllerowns for these rows. I mappeddisabled→aria-disabledby hand instead (3 lines). This settles Q20 via the fallback agreed in the plan review. Review question: agree the mixin should stay unused here?2.
roleis set inconnectedCallback, not render —MenuItem.base.tsswc-menucollects rows onslotchange, which can run before the row first renders, so the role can't wait for one. An author-supplied role wins, somenuitemcheckbox/menuitemradiocan arrive later without touching this. Review question: isconnectedCallbackthe right hook?3.
Authoring ergonomics of the— settled in reviewlabelslotNamed
labelslot kept. I briefly moved it to the default slot on a misreading of React Spectrum S2, which in fact takesText slot="label"alongsideText slot="description"; reverted. Withdescriptionshipping beside it, an unnamed label is ambiguous and has no handle for grid placement in the row layout. Closes Q23.4. The activation fix reaches into
swc-menu—menu/Menu.base.tsRaised in review: only
Enterwas handled, and it merely closed the menu without dispatching aclick, so a consumer's@clickon a row never ran from the keyboard.MenuItemBasenow turnsEnterandSpaceinto athis.click();swc-menutreatsSpacelikeEnterand skipsaria-disabledrows for keyboard and pointer activation. That last part is a behaviour change toswc-menu: clicking a disabled row used to close the menu. Menu's own 29 tests still pass. Review question: is thatswc-menuchange in scope here, or should it be split out?5. Breaking changes, implemented by omission — nothing to read, but worth confirming nothing is missing
No
href/target/download/rel/referrerpolicy(link rows →swc-menu-link). Noselected/value(menuitemcan't carryaria-selected; selection → checkbox/radio rows viaaria-checked). NohasSubmenu/open,setRole(),dispatchUpdate().iconslot renamed tovisual; the label stays in the default slot. All 45--mod-menu-item-*dropped.6.
descriptionjoins the accessible name — open question for a11y reviewA
menuitemnames itself from its contents, so a row's description is announced as part of its name, not as a separate description. 1st-gen behaves identically, so this is parity. Logged as Q25 in the plan. Review question: is name-from-contents right here, or is it worth naming the row from its label alone?Verified
skipDisabledis set totrueinMenu.base.tsthe consumer click handler runs: expected false to be truewhen the row's keydown listener is dropped, and withthe menu stays open: expected false to be truewhen the disabled guard is removedNot verified — needs a human
The ticket's screen reader matrix (JAWS / NVDA / VoiceOver) and WCAG 2.2 AA sign-off. I can't run those, and the automated tests above don't substitute for them. This phase isn't complete until someone does that pass.
On the ticket's paths
The ticket says
2nd-gen/packages/swc/components/menu/menu-item/withmenu-item.ts/menu-item.base.ts. It predates thegen2rename, and no gen2 component nests a sub-element in its own subdirectory. It was right that rows belong undermenu/though, which the first three commits got wrong and the last one fixes: rows now sit besideMenu.ts, the wayAccordionItem.tssits besideAccordion.tsandTab.tsbesideTabs.ts. There is no separate Menu item Storybook entry any more; rows are documented inmenu.mdxand storied, tested and VRT'd in the menu's own files. The base classes stay incore, notswc. The a11y doc is at03_components/menu-item/, not undermenu/.Author's checklist
Accessibility testing checklist
open, so there is no trigger to click, and note thatMenu → Statesis a different story, about the menu's ownopenstate. Arrow through the rows: focus should start on the first row and reach the disabled row rather than skip it. Chrome paints no ring on the first row after a mouse open; that is:focus-visible, not misplaced focus, and the row chrome story fixes it.