Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
942045c
chore(button): created pf-v6-button component
ArathyKumar Aug 4, 2026
f21ae79
chore(button): added react variants
ArathyKumar Aug 5, 2026
fc7267d
chore(button): replaced pf-v5-button with pf-v6-button
ArathyKumar Aug 6, 2026
cecde5f
fix(button): stabilize unit tests and add changeset for pf-v6-button
ArathyKumar Aug 7, 2026
0b7becf
fix: style changes
ArathyKumar Aug 20, 2026
e905c19
fix: style
ArathyKumar Aug 20, 2026
38b2134
chore: updated variant types
ArathyKumar Aug 21, 2026
e3c5971
chore: added pf-v6-button variant demo fixes
ArathyKumar Aug 21, 2026
a7159c4
Merge remote-tracking branch 'origin/staging/pfv6' into tmp-conflict-…
ArathyKumar Aug 21, 2026
808bef6
chore: updated demo styles
ArathyKumar Aug 22, 2026
2fb7df3
fix: accessibility issues
ArathyKumar Aug 23, 2026
acfbbc3
chore: added test cases
ArathyKumar Aug 23, 2026
72754ed
chore: updated screenshot
ArathyKumar Aug 24, 2026
5217c2b
fix(button): submit only the activated submitter's name and value
bennypowers Nov 9, 2026
cce305a
fix: style embedded v6 buttons with v6 tokens
bennypowers Nov 9, 2026
509e93b
fix(core): wait for focus to settle before hiding combobox listbox
bennypowers Nov 9, 2026
fa58bfe
test(core): cover internals and roving tabindex controller updates
bennypowers Nov 9, 2026
47ec5b4
test: drop global focus sentinel and restore upstream assertions
bennypowers Nov 9, 2026
5f9f26a
fix(button): restore focus ring and harden form and badge behavior
bennypowers Nov 9, 2026
69bc5aa
fix(core): harden combobox focusout, tabindex focus API, and submit f…
bennypowers Nov 9, 2026
6493db4
fix: correct migrated v6 button demos and alert close label
bennypowers Nov 9, 2026
45fa2a2
test(button): add e2e snapshot and SSR coverage
bennypowers Nov 9, 2026
3fc2f6d
fix(button): preserve authored and controller-assigned tabindex
bennypowers Nov 9, 2026
ecb3d1e
feat(button): support implicit form submission
bennypowers Nov 9, 2026
0f88854
docs(button): correct href and implicit-submission contract
bennypowers Nov 9, 2026
8e4d483
fix(button): complete implicit-submit helper semantics
bennypowers Nov 9, 2026
49236ed
docs: note v5 button token overrides on embedding components
bennypowers Nov 9, 2026
9c162f0
test(core): annotate types required by isolatedDeclarations
bennypowers Nov 9, 2026
f8807d8
fix(button): avoid static private member in decorated class
bennypowers Nov 9, 2026
42cf25f
ci: pin unit test job to ubuntu-24.04
bennypowers Nov 9, 2026
addceed
fix(button): skip light-DOM text detection during SSR
bennypowers Nov 9, 2026
f3a83f0
docs(button): correct implicit-submission value contract
bennypowers Nov 9, 2026
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
35 changes: 35 additions & 0 deletions .changeset/bright-buttons-port.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
"@patternfly/elements": major
"@patternfly/pfe-core": patch
---

`<pf-v6-button>`: replaces `<pf-v5-button>` with PatternFly v6 design specs and React Button parity.

```html
<pf-v6-button variant="secondary">Secondary</pf-v6-button>
<pf-v6-button type="submit">Save</pf-v6-button>
```

**Breaking Changes from v5**

- Renamed tag from `<pf-v5-button>` to `<pf-v6-button>`
- CSS custom properties renamed from `--pf-v5-c-button--*` to `--pf-v6-c-button--*`
- Boolean flag names reflect React Button parity (`disabled`, `loading`, `block`, `danger`, …); the v5 flags were already unprefixed
- `label` renamed to `accessible-label`
- `plain` boolean replaced by `variant="plain"`
- `warning` boolean replaced by `variant="warning"`
- `size="small"|"large"` replaced by `sm`/`lg`
- Default `type` no longer submits forms (matches React); set `type="submit"` explicitly
- Components that previously embedded `<pf-v5-button>` now render `<pf-v6-button>`; page-level `--pf-v5-c-button--*` overrides targeting them no longer apply. Override `--pf-v6-c-button--*` instead.

**New features**

- Form-associated custom element (`formAssociated`) with ElementInternals ARIA
- `loading` tri-state and `loading-label`
- Slots: `icon`, `count`; parts: `button`, `icon`, `text`, `count`, `progress`
- `icon-position` (`start`/`end`, with deprecated `left`/`right` aliases)
- `disabled-focusable`, `favorite`/`favorited`, `hamburger`/`expanded`, `settings`, `no-padding`
- Link rendering via `href`/`target` on any variant (`variant="link"` for link styling)
- Implicit form submission: a hidden native submit button is added to the owning form so Enter in a text field submits it. The button's `name`/`value` participates in both click-initiated and implicit submissions, matching a native default submit button.

Also fixes `RovingTabindexController` so index updates do not steal focus after Tab leaves a widget, and `ComboboxController` so an open listbox waits for the browser to finish moving focus before hiding, preventing Tab/Shift+Tab from being cancelled.
3 changes: 2 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ jobs:

test:
name: Unit Tests (Web Test Runner)
runs-on: ubuntu-latest
# Playwright 1.57 does not support ubuntu-26.04, which ubuntu-latest now resolves to.
runs-on: ubuntu-24.04
steps:
- name: Checkout repository
uses: actions/checkout@v4
Expand Down
26 changes: 24 additions & 2 deletions core/pfe-core/controllers/combobox-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ export class ComboboxController<
#listbox: HTMLElement | null = null;
#buttonInitialRole: string | null = null;
#buttonHasMouseDown = false;
#focusoutGeneration = 0;
#mo = new MutationObserver(() => this.#initItems());
#microcopy = new Map<string, Record<Lang, string>>(Object.entries({
dimmed: {
Expand Down Expand Up @@ -375,6 +376,7 @@ export class ComboboxController<
}

hostDisconnected(): void {
this.#focusoutGeneration++;
this.#fc?.hostDisconnected();
}

Expand Down Expand Up @@ -760,7 +762,7 @@ export class ComboboxController<
}
};

#onFocusoutListbox = (event: FocusEvent) => {
#onFocusoutListbox = async (event: FocusEvent) => {
if (!this.#hasTextInput && this.options.isExpanded()) {
const root = this.#element?.getRootNode();
// Check if focus moved to the toggle button via mouse click
Expand All @@ -771,7 +773,27 @@ export class ComboboxController<
if ((root instanceof ShadowRoot || root instanceof Document)
&& !this.items.includes(event.relatedTarget as Item)
&& !isClickOnToggleButton) {
this.#hide();
const related = event.relatedTarget as Node | null;
const focusStayedInsideElement = !!related && !!this.#element?.contains(related);
if (!focusStayedInsideElement) {
// Wait for the browser to finish moving focus (e.g. Tab out of the
// listbox) before hiding it. Hiding while the focused option is
// removed can cancel the focus move and leave focus on the combobox.
const generation = ++this.#focusoutGeneration;
await new Promise(requestAnimationFrame);
if (generation !== this.#focusoutGeneration || !this.host.isConnected) {
return;
}
}
// Slotted options are not DOM descendants of the shadow listbox div,
// so check both the item list and `contains()`; focus on the toggle
// button is neither an item nor a listbox descendant, and should hide.
const { activeElement } = root;
if (this.options.isExpanded()
&& !this.items.includes(activeElement as Item)
&& !this.#listbox?.contains(activeElement)) {
this.#hide();
}
}
}
};
Expand Down
36 changes: 31 additions & 5 deletions core/pfe-core/controllers/internals-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,20 @@ type InternalsHost = ReactiveControllerHost & HTMLElement;
export class InternalsController implements ReactiveController, ARIAMixin {
private static instances = new WeakMap<HTMLElement, InternalsController>();

declare readonly form: ElementInternals['form'];
declare readonly shadowRoot: ElementInternals['shadowRoot'];

// https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals/states
declare readonly states: unknown;
declare readonly willValidate: ElementInternals['willValidate'];
declare readonly validationMessage: ElementInternals['validationMessage'];

/** Form associated with the host via ElementInternals (FACE). */
get form(): ElementInternals['form'] {
return this.attachOrRetrieveInternals().form;
}

get shadowRoot(): ElementInternals['shadowRoot'] {
return this.attachOrRetrieveInternals().shadowRoot;
}

public static getLabels(host: InternalsHost): Element[] {
return Array.from(this.instances.get(host)?.internals.labels ?? []) as Element[];
}
Expand Down Expand Up @@ -321,8 +327,28 @@ export class InternalsController implements ReactiveController, ARIAMixin {
return this.internals.reportValidity(...args);
}

submit(): void {
this.internals.form?.requestSubmit();
/**
* Submit the associated form.
* @param submitter - optional submitter; ignored when the UA rejects non-button submitters
* (common for form-associated custom elements today)
*/
submit(submitter?: HTMLElement): void {
const { form } = this.internals;
if (!form) {
return;
}
if (submitter) {
try {
form.requestSubmit(submitter);
return;
} catch (error) {
// FACE hosts are not yet accepted as submitters in all engines.
if (!(error instanceof TypeError)) {
throw error;
}
}
}
form.requestSubmit();
}

reset(): void {
Expand Down
23 changes: 22 additions & 1 deletion core/pfe-core/controllers/roving-tabindex-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,33 @@ export class RovingTabindexController<
for (const i of this.items) {
i.tabIndex = item === i ? 0 : -1;
}
if (this.#gainedInitialFocus) {
// Only move DOM focus when focus is still inside the container.
// Otherwise Tab/Shift+Tab out of the widget gets stolen back by
// subsequent index updates (e.g. during Lit updateComplete).
if (this.#gainedInitialFocus && this.#isFocusWithin) {
item?.focus();
}
this.host.requestUpdate();
}

get #isFocusWithin(): boolean {
const container = this.itemsContainerElement;
return !!container?.matches(':focus-within');
}

/**
* Sets assistive-technology focus to the item at `index` and moves DOM focus
* to it, even when focus is currently outside the widget. Use this for
* documented focus APIs; prefer the `atFocusedItemIndex` setter for keyboard
* navigation, which does not steal focus from outside the widget.
* @param index item index
*/
focusItem(index: number): void {
this.atFocusedItemIndex = index;
const item = this.items.at(this.atFocusedItemIndex);
item?.focus();
}

get items() {
return this._items;
}
Expand Down
64 changes: 64 additions & 0 deletions core/pfe-core/controllers/test/at-focus-controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { expect, fixture } from '@open-wc/testing';
import { sendKeys } from '@web/test-runner-commands';
import { customElement } from 'lit/decorators/custom-element.js';
import { LitElement, html } from 'lit';

Expand Down Expand Up @@ -240,4 +241,67 @@ describe('ATFocusController', function() {
});
});
});

describe('DOM focus', function() {
let wrapper: HTMLElement;
let element: TestElement;
let outside: HTMLButtonElement;
let items: HTMLElement[];

beforeEach(async function() {
wrapper = await fixture(html`
<div>
<x-at-focus-test>
<div class="item">0</div>
<div class="item">1</div>
<div class="item">2</div>
</x-at-focus-test>
<button id="outside">outside</button>
</div>
`);
element = wrapper.querySelector('x-at-focus-test')!;
outside = wrapper.querySelector('#outside')!;
items = [...element.querySelectorAll<HTMLElement>('.item')];
});

describe('while focus is inside the container', function() {
beforeEach(async function() {
await sendKeys({ press: 'Tab' });
await element.updateComplete;
});

it('moves focus when the index changes', function() {
expect(document.activeElement).to.equal(items[0]);
element.controller.atFocusedItemIndex = 1;
expect(document.activeElement).to.equal(items[1]);
});
});

describe('after focus leaves the container', function() {
beforeEach(async function() {
await sendKeys({ press: 'Tab' });
await sendKeys({ press: 'Tab' });
await element.updateComplete;
expect(document.activeElement).to.equal(outside);
});

it('does not steal focus back when the index changes', function() {
element.controller.atFocusedItemIndex = 1;
expect(document.activeElement).to.equal(outside);
});

it('still updates the roving tabindex', function() {
element.controller.atFocusedItemIndex = 1;
expect(items[0].tabIndex).to.equal(-1);
expect(items[1].tabIndex).to.equal(0);
});

it('moves DOM focus to the item via focusItem()', function() {
element.controller.focusItem(1);
expect(document.activeElement).to.equal(items[1]);
expect(items[1].tabIndex).to.equal(0);
expect(items[0].tabIndex).to.equal(-1);
});
});
});
});
88 changes: 87 additions & 1 deletion core/pfe-core/controllers/test/combobox-controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { a11ySnapshot } from '@patternfly/pfe-tools/test/a11y-snapshot.js';

import { customElement } from 'lit/decorators/custom-element.js';
import { query } from 'lit/decorators/query.js';
import { ReactiveElement, html, render, type PropertyValues, type TemplateResult } from 'lit';
import { LitElement, ReactiveElement, html, render, type PropertyValues, type TemplateResult } from 'lit';

import { ComboboxController } from '../combobox-controller.js';

Expand Down Expand Up @@ -303,8 +303,94 @@ class XComboboxShadow extends TestCombobox {
}
}

/**
* Toggle-button-only combobox (no text input): listbox items receive DOM focus.
*/
@customElement('x-combobox-toggle')
class XComboboxToggle extends LitElement {
static template = html`
<x-combobox-toggle>
<div class="opt" tabindex="0">1</div>
<div class="opt" tabindex="0">2</div>
<div class="opt" tabindex="0">3</div>
</x-combobox-toggle>
`;

hideCount = 0;

controller = ComboboxController.of(this, {
getItems: () => [...this.querySelectorAll<HTMLElement>('.opt')],
isItem: (item): item is HTMLElement =>
item instanceof HTMLElement && item.classList.contains('opt'),
getFallbackLabel: () => 'options',
getListboxElement: () => this.listbox ?? null,
getToggleButton: () => this.button ?? null,
getComboboxInput: () => null,
isExpanded: () => !this.listbox.hidden,
requestShowListbox: () => void (this.listbox.hidden = false),
requestHideListbox: () => void (this.hideCount += 1, this.listbox.hidden = true),
});

@query('#listbox') listbox!: HTMLElement;
@query('#button') button!: HTMLButtonElement;

get options(): HTMLElement[] {
return [...this.querySelectorAll<HTMLElement>('.opt')];
}

override render(): TemplateResult {
return html`
<button id="button">Show Options</button>
<div id="listbox" hidden><slot></slot></div>`;
}
}

describe('ComboboxController', function() {
describe('Cross-root ARIA', XComboboxCrossRoot.test.bind(XComboboxCrossRoot));
describe('Light-DOM only', XComboboxLight.test.bind(XComboboxLight));
describe('Shadow-DOM only', XComboboxShadow.test.bind(XComboboxShadow));

describe('listbox focusout', function() {
let element: XComboboxToggle;

beforeEach(async function() {
element = await fixture<XComboboxToggle>(XComboboxToggle.template);
// open the listbox and focus the first option
element.button.click();
await element.updateComplete;
element.options[0]!.focus();
});

function nextFrame() {
return new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
}

it('hides the listbox one frame after focus leaves it', async function() {
expect(element.listbox.hidden, 'listbox starts expanded').to.be.false;
await sendKeys({ press: 'Tab' });
await nextFrame();
expect(element.listbox.hidden, 'listbox hides after the frame').to.be.true;
expect(element.hideCount).to.equal(1);
});

it('does not hide when focus returns to an item within the frame', async function() {
expect(element.listbox.hidden, 'listbox starts expanded').to.be.false;
// focusout with focus headed outside the element ...
element.options[0]!.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
// ... but focus lands on another item before the pending frame resolves
element.options[1]!.focus();
await nextFrame();
expect(element.listbox.hidden, 'listbox stays expanded').to.be.false;
expect(element.hideCount).to.equal(0);
});

it('does not throw or hide when the host disconnects during the pending frame', async function() {
expect(element.listbox.hidden, 'listbox starts expanded').to.be.false;
element.options[0]!.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
element.remove();
await nextFrame();
expect(element.hideCount, 'no hide after disconnect').to.equal(0);
expect(element.listbox.hidden, 'listbox stays expanded').to.be.false;
});
});
});
Loading
Loading