diff --git a/change/@fluentui-web-components-8fa1a0aa-7dd4-4129-89c3-fe350ad0fa58.json b/change/@fluentui-web-components-8fa1a0aa-7dd4-4129-89c3-fe350ad0fa58.json
new file mode 100644
index 00000000000000..d9fd162a58c391
--- /dev/null
+++ b/change/@fluentui-web-components-8fa1a0aa-7dd4-4129-89c3-fe350ad0fa58.json
@@ -0,0 +1,7 @@
+{
+ "type": "prerelease",
+ "comment": "feat: update button and anchor button to leverage custom states for variants",
+ "packageName": "@fluentui/web-components",
+ "email": "13071055+chrisdholt@users.noreply.github.com",
+ "dependentChangeType": "patch"
+}
diff --git a/packages/web-components/src/anchor-button/anchor-button.spec.ts b/packages/web-components/src/anchor-button/anchor-button.spec.ts
index 4d2b2441c70bd6..079d82d42dd464 100644
--- a/packages/web-components/src/anchor-button/anchor-button.spec.ts
+++ b/packages/web-components/src/anchor-button/anchor-button.spec.ts
@@ -77,4 +77,69 @@ test.describe('Anchor Button', () => {
await expect(proxy).toHaveAttribute(`${attribute}`, `${value}`);
});
}
+
+ test('should navigate to the provided url when clicked', async ({ page }) => {
+ const element = page.locator('fluent-anchor-button');
+ const expectedUrl = '#foo';
+
+ await page.setContent(/* html */ `
+
+ `);
+
+ await element.click();
+
+ expect(page.url()).toContain(expectedUrl);
+ });
+
+ test('should navigate to the provided url when clicked while pressing the `Control` key on Windows or Meta on Mac', async ({
+ page,
+ context,
+ }) => {
+ const element = page.locator('fluent-anchor-button');
+ const expectedUrl = '#foo';
+
+ await page.setContent(/* html */ `
+
+ `);
+
+ const [newPage] = await Promise.all([
+ context.waitForEvent('page'),
+ element.click({ modifiers: ['ControlOrMeta'] }),
+ ]);
+
+ expect(newPage.url()).toContain(expectedUrl);
+ });
+
+ test('should navigate to the provided url when `Enter` is pressed via keyboard', async ({ page }) => {
+ const element = page.locator('fluent-anchor-button');
+ const expectedUrl = '#foo';
+
+ await page.setContent(/* html */ `
+
+ `);
+
+ await element.focus();
+
+ await element.press('Enter');
+
+ expect(page.url()).toContain(expectedUrl);
+ });
+
+ test('should navigate to the provided url when `ctrl` and `Enter` are pressed via keyboard', async ({
+ page,
+ context,
+ }) => {
+ const element = page.locator('fluent-anchor-button');
+ const expectedUrl = '#foo';
+
+ await page.setContent(/* html */ `
+
+ `);
+
+ await element.focus();
+
+ const [newPage] = await Promise.all([context.waitForEvent('page'), element.press('ControlOrMeta+Enter')]);
+
+ expect(newPage.url()).toContain(expectedUrl);
+ });
});
diff --git a/packages/web-components/src/anchor-button/anchor-button.styles.ts b/packages/web-components/src/anchor-button/anchor-button.styles.ts
index 490bc3774b1cbd..3a1aea693695b1 100644
--- a/packages/web-components/src/anchor-button/anchor-button.styles.ts
+++ b/packages/web-components/src/anchor-button/anchor-button.styles.ts
@@ -3,7 +3,14 @@ import { baseButtonStyles } from '../button/button.styles.js';
import { forcedColorsStylesheetBehavior } from '../utils/index.js';
// Need to support icon hover styles
-export const styles = baseButtonStyles.withBehaviors(
+export const styles = css`
+ ${baseButtonStyles}
+
+ ::slotted(a) {
+ position: absolute;
+ inset: 0;
+ }
+`.withBehaviors(
forcedColorsStylesheetBehavior(css`
:host {
border-color: LinkText;
diff --git a/packages/web-components/src/anchor-button/anchor-button.template.ts b/packages/web-components/src/anchor-button/anchor-button.template.ts
index 7445870c4c94b7..46770804823883 100644
--- a/packages/web-components/src/anchor-button/anchor-button.template.ts
+++ b/packages/web-components/src/anchor-button/anchor-button.template.ts
@@ -10,8 +10,8 @@ export function anchorTemplate(options: AnchorOptions =
return html`
x.clickHandler()}"
- @keypress="${(x, c) => x.keypressHandler(c.event as KeyboardEvent)}"
+ @click="${(x, c) => x.clickHandler(c.event as PointerEvent)}"
+ @keydown="${(x, c) => x.keydownHandler(c.event as KeyboardEvent)}"
>
${startSlotTemplate(options)}
diff --git a/packages/web-components/src/anchor-button/anchor-button.ts b/packages/web-components/src/anchor-button/anchor-button.ts
index 656ac788bf4269..11be9aa058a363 100644
--- a/packages/web-components/src/anchor-button/anchor-button.ts
+++ b/packages/web-components/src/anchor-button/anchor-button.ts
@@ -3,6 +3,7 @@ import { keyEnter } from '@microsoft/fast-web-utilities';
import { StartEnd } from '../patterns/index.js';
import type { StartEndOptions } from '../patterns/index.js';
import { applyMixins } from '../utils/apply-mixins.js';
+import { toggleState } from '../utils/element-internals.js';
import {
AnchorAttributes,
type AnchorButtonAppearance,
@@ -30,6 +31,12 @@ export type AnchorOptions = StartEndOptions;
* @public
*/
export class BaseAnchor extends FASTElement {
+ /**
+ * Holds a reference to the platform to manage ctrl+click on Windows and cmd+click on Mac
+ * @internal
+ */
+ private readonly isMac = navigator.userAgent.includes('Mac');
+
/**
* The internal {@link https://developer.mozilla.org/docs/Web/API/ElementInternals | `ElementInternals`} instance for the component.
*
@@ -176,28 +183,43 @@ export class BaseAnchor extends FASTElement {
* @param e - The event object
* @internal
*/
- public clickHandler(): boolean {
- this.internalProxyAnchor.click();
+ public clickHandler(e: PointerEvent): boolean {
+ if (this.href) {
+ const newTab = !this.isMac ? e.ctrlKey : e.metaKey;
+ this.handleNavigation(newTab);
+ }
return true;
}
/**
- * Handles keypress events for the anchor.
+ * Handles keydown events for the anchor.
*
* @param e - the keyboard event
* @returns - the return value of the click handler
* @public
*/
- public keypressHandler(e: KeyboardEvent): boolean | void {
- if (e.key === keyEnter) {
- this.internalProxyAnchor.click();
- return;
+ public keydownHandler(e: KeyboardEvent): boolean | void {
+ if (this.href) {
+ if (e.key === keyEnter) {
+ const newTab = !this.isMac ? e.ctrlKey : e.metaKey || e.ctrlKey;
+ this.handleNavigation(newTab);
+ return;
+ }
}
return true;
}
+ /**
+ * Handles navigation based on input
+ * If the metaKey is pressed, opens the href in a new window, if false, uses the click on the proxy
+ * @internal
+ */
+ private handleNavigation(newTab: boolean): void {
+ newTab ? window.open(this.href, '_blank') : this.internalProxyAnchor.click();
+ }
+
/**
* A method for updating proxy attributes when attributes have changed
* @internal
@@ -214,7 +236,8 @@ export class BaseAnchor extends FASTElement {
private createProxyElement(): HTMLAnchorElement {
const proxy = this.internalProxyAnchor ?? document.createElement('a');
- proxy.hidden = true;
+ proxy.ariaHidden = 'true';
+ proxy.tabIndex = -1;
return proxy;
}
}
@@ -230,6 +253,20 @@ export class AnchorButton extends BaseAnchor {
@attr
public appearance?: AnchorButtonAppearance | undefined;
+ /**
+ * Handles changes to appearance attribute custom states
+ * @param prev - the previous state
+ * @param next - the next state
+ */
+ public appearanceChanged(prev: AnchorButtonAppearance | undefined, next: AnchorButtonAppearance | undefined) {
+ if (prev) {
+ toggleState(this.elementInternals, `${prev}`, false);
+ }
+ if (next) {
+ toggleState(this.elementInternals, `${next}`, true);
+ }
+ }
+
/**
* The shape the anchor button should have.
*
@@ -240,6 +277,20 @@ export class AnchorButton extends BaseAnchor {
@attr
public shape?: AnchorButtonShape | undefined;
+ /**
+ * Handles changes to shape attribute custom states
+ * @param prev - the previous state
+ * @param next - the next state
+ */
+ public shapeChanged(prev: AnchorButtonShape | undefined, next: AnchorButtonShape | undefined) {
+ if (prev) {
+ toggleState(this.elementInternals, `${prev}`, false);
+ }
+ if (next) {
+ toggleState(this.elementInternals, `${next}`, true);
+ }
+ }
+
/**
* The size the anchor button should have.
*
@@ -250,6 +301,20 @@ export class AnchorButton extends BaseAnchor {
@attr
public size?: AnchorButtonSize;
+ /**
+ * Handles changes to size attribute custom states
+ * @param prev - the previous state
+ * @param next - the next state
+ */
+ public sizeChanged(prev: AnchorButtonSize | undefined, next: AnchorButtonSize | undefined) {
+ if (prev) {
+ toggleState(this.elementInternals, `${prev}`, false);
+ }
+ if (next) {
+ toggleState(this.elementInternals, `${next}`, true);
+ }
+ }
+
/**
* The anchor button has an icon only, no text content
*
@@ -259,6 +324,15 @@ export class AnchorButton extends BaseAnchor {
*/
@attr({ attribute: 'icon-only', mode: 'boolean' })
public iconOnly: boolean = false;
+
+ /**
+ * Handles changes to icon only custom states
+ * @param prev - the previous state
+ * @param next - the next state
+ */
+ public iconOnlyChanged(prev: boolean, next: boolean) {
+ toggleState(this.elementInternals, 'icon', !!next);
+ }
}
/**
diff --git a/packages/web-components/src/button/button.options.ts b/packages/web-components/src/button/button.options.ts
index 484b8e50cd5cb6..33e01de55d290a 100644
--- a/packages/web-components/src/button/button.options.ts
+++ b/packages/web-components/src/button/button.options.ts
@@ -10,7 +10,6 @@ export const ButtonAppearance = {
primary: 'primary',
outline: 'outline',
subtle: 'subtle',
- secondary: 'secondary',
transparent: 'transparent',
} as const;
diff --git a/packages/web-components/src/button/button.styles.ts b/packages/web-components/src/button/button.styles.ts
index 6f19bb839e7e94..d3190dbaa73026 100644
--- a/packages/web-components/src/button/button.styles.ts
+++ b/packages/web-components/src/button/button.styles.ts
@@ -56,6 +56,17 @@ import {
strokeWidthThick,
strokeWidthThin,
} from '../theme/design-tokens.js';
+import {
+ circularState,
+ iconOnlyState,
+ largeState,
+ outlineState,
+ primaryState,
+ smallState,
+ squareState,
+ subtleState,
+ transparentState,
+} from '../styles/states/index.js';
/**
* @internal
@@ -65,6 +76,7 @@ export const baseButtonStyles = css`
:host {
--icon-spacing: ${spacingHorizontalSNudge};
+ position: relative;
contain: layout style;
vertical-align: middle;
align-items: center;
@@ -126,22 +138,20 @@ export const baseButtonStyles = css`
fill: currentColor;
}
- [slot='start'],
- ::slotted([slot='start']) {
+ :is([slot='start'], ::slotted([slot='start'])) {
margin-inline-end: var(--icon-spacing);
}
- [slot='end'],
- ::slotted([slot='end']) {
+ :is([slot='end'], ::slotted([slot='end'])) {
margin-inline-start: var(--icon-spacing);
}
- :host([icon-only]) {
+ :host(${iconOnlyState}) {
min-width: 32px;
max-width: 32px;
}
- :host([size='small']) {
+ :host(${smallState}) {
--icon-spacing: ${spacingHorizontalXS};
min-height: 24px;
min-width: 64px;
@@ -152,12 +162,12 @@ export const baseButtonStyles = css`
font-weight: ${fontWeightRegular};
}
- :host([size='small'][icon-only]) {
+ :host(${smallState}${iconOnlyState}) {
min-width: 24px;
max-width: 24px;
}
- :host([size='large']) {
+ :host(${largeState}) {
min-height: 40px;
border-radius: ${borderRadiusLarge};
padding: 0 ${spacingHorizontalL};
@@ -165,108 +175,103 @@ export const baseButtonStyles = css`
line-height: ${lineHeightBase400};
}
- :host([size='large'][icon-only]) {
+ :host(${largeState}${iconOnlyState}) {
min-width: 40px;
max-width: 40px;
}
- :host([size='large']) ::slotted(svg) {
+ :host(${largeState}) ::slotted(svg) {
font-size: 24px;
height: 24px;
width: 24px;
}
- :host([shape='circular']),
- :host([shape='circular']:focus-visible) {
+ :host(:is(${circularState}, ${circularState}:focus-visible)) {
border-radius: ${borderRadiusCircular};
}
- :host([shape='square']),
- :host([shape='square']:focus-visible) {
+ :host(:is(${squareState}, ${squareState}:focus-visible)) {
border-radius: ${borderRadiusNone};
}
- :host([appearance='primary']) {
+ :host(${primaryState}) {
background-color: ${colorBrandBackground};
color: ${colorNeutralForegroundOnBrand};
border-color: transparent;
}
- :host([appearance='primary']:hover) {
+ :host(${primaryState}:hover) {
background-color: ${colorBrandBackgroundHover};
}
- :host([appearance='primary']:hover),
- :host([appearance='primary']:hover:active) {
+ :host(${primaryState}:is(:hover, :hover:active)) {
border-color: transparent;
color: ${colorNeutralForegroundOnBrand};
}
- :host([appearance='primary']:hover:active) {
+ :host(${primaryState}:hover:active) {
background-color: ${colorBrandBackgroundPressed};
}
- :host([appearance='primary']:focus-visible) {
+ :host(${primaryState}:focus-visible) {
border-color: ${colorNeutralForegroundOnBrand};
box-shadow: ${shadow2}, 0 0 0 2px ${colorStrokeFocus2};
}
- :host([appearance='outline']) {
+ :host(${outlineState}) {
background-color: ${colorTransparentBackground};
}
- :host([appearance='outline']:hover) {
+ :host(${outlineState}:hover) {
background-color: ${colorTransparentBackgroundHover};
}
- :host([appearance='outline']:hover:active) {
+ :host(${outlineState}:hover:active) {
background-color: ${colorTransparentBackgroundPressed};
}
- :host([appearance='subtle']) {
+ :host(${subtleState}) {
background-color: ${colorSubtleBackground};
color: ${colorNeutralForeground2};
border-color: transparent;
}
- :host([appearance='subtle']:hover) {
+ :host(${subtleState}:hover) {
background-color: ${colorSubtleBackgroundHover};
color: ${colorNeutralForeground2Hover};
border-color: transparent;
}
- :host([appearance='subtle']:hover:active) {
+ :host(${subtleState}:hover:active) {
background-color: ${colorSubtleBackgroundPressed};
color: ${colorNeutralForeground2Pressed};
border-color: transparent;
}
- :host([appearance='subtle']:hover) ::slotted(svg) {
+ :host(${subtleState}:hover) ::slotted(svg) {
fill: ${colorNeutralForeground2BrandHover};
}
- :host([appearance='subtle']:hover:active) ::slotted(svg) {
+ :host(${subtleState}:hover:active) ::slotted(svg) {
fill: ${colorNeutralForeground2BrandPressed};
}
- :host([appearance='transparent']) {
+ :host(${transparentState}) {
background-color: ${colorTransparentBackground};
color: ${colorNeutralForeground2};
}
- :host([appearance='transparent']:hover) {
+ :host(${transparentState}:hover) {
background-color: ${colorTransparentBackgroundHover};
color: ${colorNeutralForeground2BrandHover};
}
- :host([appearance='transparent']:hover:active) {
+ :host(${transparentState}:hover:active) {
background-color: ${colorTransparentBackgroundPressed};
color: ${colorNeutralForeground2BrandPressed};
}
- :host([appearance='transparent']),
- :host([appearance='transparent']:hover),
- :host([appearance='transparent']:hover:active) {
+ :host(:is(${transparentState}, ${transparentState}:is(:hover, :active))) {
border-color: transparent;
}
`;
@@ -279,37 +284,33 @@ export const baseButtonStyles = css`
export const styles = css`
${baseButtonStyles}
- :host(:is([disabled], [disabled-focusable], [appearance][disabled], [appearance][disabled-focusable])),
- :host(:is([disabled], [disabled-focusable], [appearance][disabled], [appearance][disabled-focusable]):hover),
- :host(:is([disabled], [disabled-focusable], [appearance][disabled], [appearance][disabled-focusable]):hover:active) {
+ :host(:is(:disabled, [disabled-focusable], [appearance]:disabled, [appearance][disabled-focusable])),
+ :host(:is(:disabled, [disabled-focusable], [appearance]:disabled, [appearance][disabled-focusable]):hover),
+ :host(:is(:disabled, [disabled-focusable], [appearance]:disabled, [appearance][disabled-focusable]):hover:active) {
background-color: ${colorNeutralBackgroundDisabled};
border-color: ${colorNeutralStrokeDisabled};
color: ${colorNeutralForegroundDisabled};
cursor: not-allowed;
}
- :host(:is([disabled][appearance='primary'], [disabled-focusable][appearance='primary'])),
- :host(:is([disabled][appearance='primary'], [disabled-focusable][appearance='primary']):hover),
- :host(:is([disabled][appearance='primary'], [disabled-focusable][appearance='primary']):hover:active) {
+ :host(${primaryState}:is(:disabled, [disabled-focusable])),
+ :host(${primaryState}:is(:disabled, [disabled-focusable]):is(:hover, :hover:active)) {
border-color: transparent;
}
- :host(:is([disabled][appearance='outline'], [disabled-focusable][appearance='outline'])),
- :host(:is([disabled][appearance='outline'], [disabled-focusable][appearance='outline']):hover),
- :host(:is([disabled][appearance='outline'], [disabled-focusable][appearance='outline']):hover:active) {
+ :host(${outlineState}:is(:disabled, [disabled-focusable])),
+ :host(${outlineState}:is(:disabled, [disabled-focusable]):is(:hover, :hover:active)) {
background-color: ${colorTransparentBackground};
}
- :host(:is([disabled][appearance='subtle'], [disabled-focusable][appearance='subtle'])),
- :host(:is([disabled][appearance='subtle'], [disabled-focusable][appearance='subtle']):hover),
- :host(:is([disabled][appearance='subtle'], [disabled-focusable][appearance='subtle']):hover:active) {
+ :host(${subtleState}:is(:disabled, [disabled-focusable])),
+ :host(${subtleState}:is(:disabled, [disabled-focusable]):is(:hover, :hover:active)) {
background-color: ${colorTransparentBackground};
border-color: transparent;
}
- :host(:is([disabled][appearance='transparent'], [disabled-focusable][appearance='transparent'])),
- :host(:is([disabled][appearance='transparent'], [disabled-focusable][appearance='transparent']):hover),
- :host(:is([disabled][appearance='transparent'], [disabled-focusable][appearance='transparent']):hover:active) {
+ :host(${transparentState}:is(:disabled, [disabled-focusable])),
+ :host(${transparentState}:is(:disabled, [disabled-focusable]):is(:hover, :hover:active)) {
border-color: transparent;
background-color: ${colorTransparentBackground};
}
diff --git a/packages/web-components/src/button/button.ts b/packages/web-components/src/button/button.ts
index 977af0e66632a5..9536715bfa1db5 100644
--- a/packages/web-components/src/button/button.ts
+++ b/packages/web-components/src/button/button.ts
@@ -2,6 +2,7 @@ import { attr, FASTElement, observable } from '@microsoft/fast-element';
import { keyEnter, keySpace } from '@microsoft/fast-web-utilities';
import { StartEnd } from '../patterns/index.js';
import { applyMixins } from '../utils/apply-mixins.js';
+import { toggleState } from '../utils/element-internals.js';
import type { ButtonAppearance, ButtonFormTarget, ButtonShape, ButtonSize } from './button.options.js';
import { ButtonType } from './button.options.js';
@@ -27,6 +28,20 @@ export class Button extends FASTElement {
@attr
public appearance?: ButtonAppearance;
+ /**
+ * Handles changes to appearance attribute custom states
+ * @param prev - the previous state
+ * @param next - the next state
+ */
+ public appearanceChanged(prev: ButtonAppearance | undefined, next: ButtonAppearance | undefined) {
+ if (prev) {
+ toggleState(this.elementInternals, `${prev}`, false);
+ }
+ if (next) {
+ toggleState(this.elementInternals, `${next}`, true);
+ }
+ }
+
/**
* Indicates the button should be focused when the page is loaded.
* @see The {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#autofocus | `autofocus`} attribute
@@ -85,7 +100,7 @@ export class Button extends FASTElement {
*
* @internal
*/
- protected elementInternals: ElementInternals = this.attachInternals();
+ public elementInternals: ElementInternals = this.attachInternals();
/**
* The associated form element.
@@ -194,6 +209,15 @@ export class Button extends FASTElement {
@attr({ attribute: 'icon-only', mode: 'boolean' })
public iconOnly: boolean = false;
+ /**
+ * Handles changes to icon only custom states
+ * @param prev - the previous state
+ * @param next - the next state
+ */
+ public iconOnlyChanged(prev: boolean, next: boolean) {
+ toggleState(this.elementInternals, 'icon', next);
+ }
+
/**
* A reference to all associated label elements.
*
@@ -224,6 +248,20 @@ export class Button extends FASTElement {
@attr
public shape?: ButtonShape;
+ /**
+ * Handles changes to shape attribute custom states
+ * @param prev - the previous state
+ * @param next - the next state
+ */
+ public shapeChanged(prev: ButtonShape | undefined, next: ButtonShape | undefined) {
+ if (prev) {
+ toggleState(this.elementInternals, `${prev}`, false);
+ }
+ if (next) {
+ toggleState(this.elementInternals, `${next}`, true);
+ }
+ }
+
/**
* The size of the button.
*
@@ -234,6 +272,20 @@ export class Button extends FASTElement {
@attr
public size?: ButtonSize;
+ /**
+ * Handles changes to size attribute custom states
+ * @param prev - the previous state
+ * @param next - the next state
+ */
+ public sizeChanged(prev: ButtonSize | undefined, next: ButtonSize | undefined) {
+ if (prev) {
+ toggleState(this.elementInternals, `${prev}`, false);
+ }
+ if (next) {
+ toggleState(this.elementInternals, `${next}`, true);
+ }
+ }
+
/**
* The button type.
* @see The {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#type | `type`} attribute
diff --git a/packages/web-components/src/compound-button/compound-button.styles.ts b/packages/web-components/src/compound-button/compound-button.styles.ts
index 0a79fb3299ec2c..2502cfb2008f0a 100644
--- a/packages/web-components/src/compound-button/compound-button.styles.ts
+++ b/packages/web-components/src/compound-button/compound-button.styles.ts
@@ -18,6 +18,14 @@ import {
spacingHorizontalSNudge,
spacingHorizontalXS,
} from '../theme/design-tokens.js';
+import {
+ iconOnlyState,
+ largeState,
+ primaryState,
+ smallState,
+ subtleState,
+ transparentState,
+} from '../styles/states/index.js';
// Need to support icon hover styles
export const styles = css`
@@ -48,7 +56,7 @@ export const styles = css`
}
::slotted(svg),
- :host([size='large']) ::slotted(svg) {
+ :host(${largeState}) ::slotted(svg) {
font-size: 40px;
height: 40px;
width: 40px;
@@ -62,61 +70,59 @@ export const styles = css`
color: ${colorNeutralForeground2Pressed};
}
- :host(:is([appearance='primary'], [appearance='primary']:hover, [appearance='primary']:active))
- ::slotted([slot='description']) {
+ :host(:is(${primaryState}, ${primaryState}:hover, ${primaryState}:active)) ::slotted([slot='description']) {
color: ${colorNeutralForegroundOnBrand};
}
- :host(:is([appearance='subtle'], [appearance='subtle']:hover, [appearance='subtle']:active))
- ::slotted([slot='description']),
- :host([appearance='transparent']) ::slotted([slot='description']) {
+ :host(:is(${subtleState}, ${subtleState}:hover, ${subtleState}:active)) ::slotted([slot='description']),
+ :host(${transparentState}) ::slotted([slot='description']) {
color: ${colorNeutralForeground2};
}
- :host([appearance='transparent']:hover) ::slotted([slot='description']) {
+ :host(${transparentState}:hover) ::slotted([slot='description']) {
color: ${colorNeutralForeground2BrandHover};
}
- :host([appearance='transparent']:active) ::slotted([slot='description']) {
+ :host(${transparentState}:active) ::slotted([slot='description']) {
color: ${colorNeutralForeground2BrandPressed};
}
- :host(:is([disabled], [disabled][appearance], [disabled-focusable], [disabled-focusable][appearance]))
+ :host(:is(:disabled, :disabled[appearance], [disabled-focusable], [disabled-focusable][appearance]))
::slotted([slot='description']) {
color: ${colorNeutralForegroundDisabled};
}
- :host([size='small']) {
+ :host(${smallState}) {
padding: 8px;
padding-bottom: 10px;
}
- :host([icon-only]) {
+ :host(${iconOnlyState}) {
min-width: 52px;
max-width: 52px;
padding: ${spacingHorizontalSNudge};
}
- :host([icon-only][size='small']) {
+ :host(${iconOnlyState}${smallState}) {
min-width: 48px;
max-width: 48px;
padding: ${spacingHorizontalXS};
}
- :host([icon-only][size='large']) {
+ :host(${iconOnlyState}${largeState}) {
min-width: 56px;
max-width: 56px;
padding: ${spacingHorizontalS};
}
- :host([size='large']) {
+ :host(${largeState}) {
padding-top: 18px;
padding-inline: 16px;
padding-bottom: 20px;
font-size: ${fontSizeBase400};
line-height: ${lineHeightBase400};
}
- :host([size='large']) ::slotted([slot='description']) {
+ :host(${largeState}) ::slotted([slot='description']) {
font-size: ${fontSizeBase300};
}
`;
diff --git a/packages/web-components/src/link/link.styles.ts b/packages/web-components/src/link/link.styles.ts
index 330bda66e6b7ca..10eb9e4487e7ff 100644
--- a/packages/web-components/src/link/link.styles.ts
+++ b/packages/web-components/src/link/link.styles.ts
@@ -17,6 +17,7 @@ export const styles = css`
${display('inline')}
:host {
+ position: relative;
box-sizing: border-box;
background-color: transparent;
color: ${colorBrandForegroundLink};
@@ -71,6 +72,11 @@ export const styles = css`
color: inherit;
text-decoration: none;
}
+
+ ::slotted(a) {
+ position: absolute;
+ inset: 0;
+ }
`.withBehaviors(
forcedColorsStylesheetBehavior(css`
:host {
diff --git a/packages/web-components/src/link/link.template.ts b/packages/web-components/src/link/link.template.ts
index abef8ca653811d..b657f0b6575206 100644
--- a/packages/web-components/src/link/link.template.ts
+++ b/packages/web-components/src/link/link.template.ts
@@ -9,8 +9,8 @@ export function anchorTemplate(): ViewTemplate {
return html`
x.clickHandler()}"
- @keypress="${(x, c) => x.keypressHandler(c.event as KeyboardEvent)}"
+ @click="${(x, c) => x.clickHandler(c.event as PointerEvent)}"
+ @keydown="${(x, c) => x.keydownHandler(c.event as KeyboardEvent)}"
>
diff --git a/packages/web-components/src/styles/states/index.ts b/packages/web-components/src/styles/states/index.ts
new file mode 100644
index 00000000000000..aea5159ae63375
--- /dev/null
+++ b/packages/web-components/src/styles/states/index.ts
@@ -0,0 +1,61 @@
+import { css } from '@microsoft/fast-element';
+
+/**
+ * Selector for the `primary` state.
+ * @public
+ */
+export const primaryState = css.partial`:is([state--primary], :state(primary))`;
+
+/**
+ * Selector for the `outline` state.
+ * @public
+ */
+export const outlineState = css.partial`:is([state--outline], :state(outline))`;
+
+/**
+ * Selector for the `subtle` state.
+ * @public
+ */
+export const subtleState = css.partial`:is([state--subtle], :state(subtle))`;
+
+/**
+ * Selector for the `transparent` state.
+ * @public
+ */
+export const transparentState = css.partial`:is([state--transparent], :state(transparent))`;
+
+/**
+ * Selector for the `circular` state.
+ * @public
+ */
+export const circularState = css.partial`:is([state--circular], :state(circular))`;
+
+/**
+ * Selector for the `square` state.
+ * @public
+ */
+export const squareState = css.partial`:is([state--square], :state(square))`;
+
+/**
+ * Selector for the `small` state.
+ * @public
+ */
+export const smallState = css.partial`:is([state--small], :state(small))`;
+
+/**
+ * Selector for the `large` state.
+ * @public
+ */
+export const largeState = css.partial`:is([state--large], :state(large))`;
+
+/**
+ * Selector for the `iconOnly` state.
+ * @public
+ */
+export const iconOnlyState = css.partial`:is([state--icon], :state(icon))`;
+
+/**
+ * Selector for the `pressed` state.
+ * @public
+ */
+export const pressedState = css.partial`:is([state--pressed], :state(pressed))`;
diff --git a/packages/web-components/src/toggle-button/toggle-button.spec.ts b/packages/web-components/src/toggle-button/toggle-button.spec.ts
index 9a8a40c49c3197..0b9b3769ab1b12 100644
--- a/packages/web-components/src/toggle-button/toggle-button.spec.ts
+++ b/packages/web-components/src/toggle-button/toggle-button.spec.ts
@@ -1,5 +1,6 @@
import { expect, test } from '@playwright/test';
import { fixtureURL } from '../helpers.tests.js';
+import { ToggleButton } from './toggle-button.js';
test.describe('Toggle Button', () => {
test.beforeEach(async ({ page }) => {
@@ -15,7 +16,7 @@ test.describe('Toggle Button', () => {
Toggle
`);
- await expect(element).toHaveAttribute('aria-pressed', 'false');
+ await expect(element).toHaveJSProperty('elementInternals.ariaPressed', 'false');
});
test('should set the `aria-pressed` attribute to `true` when the `pressed` attribute is present', async ({
@@ -27,38 +28,31 @@ test.describe('Toggle Button', () => {
Toggle
`);
- await expect(element).toHaveAttribute('aria-pressed', 'true');
+ await expect(element).toHaveJSProperty('elementInternals.ariaPressed', 'true');
});
test('should toggle the `pressed` attribute when clicked', async ({ page }) => {
const element = page.locator('fluent-toggle-button');
- const pressed = page.locator('fluent-toggle-button[pressed]');
-
await page.setContent(/* html */ `
Toggle
`);
- await expect(element).toHaveAttribute('aria-pressed', 'false');
-
- expect(await element.evaluate(node => node.getAttribute('pressed'))).toBe(null);
+ await expect(element).toHaveJSProperty('elementInternals.ariaPressed', 'false');
- // await expect(element).not.toHaveAttribute('pressed');
- await expect(pressed).toHaveCount(0);
+ expect(await element.evaluate((node: ToggleButton) => node.elementInternals.states.has('pressed'))).toBe(false);
await element.click();
- await expect(element).toHaveAttribute('aria-pressed', 'true');
+ await expect(element).toHaveJSProperty('elementInternals.ariaPressed', 'true');
- // await expect(element).toHaveAttribute('pressed');
- await expect(pressed).toHaveCount(1);
+ expect(await element.evaluate((node: ToggleButton) => node.elementInternals.states.has('pressed'))).toBe(true);
await element.click();
- await expect(element).toHaveAttribute('aria-pressed', 'false');
+ await expect(element).toHaveJSProperty('elementInternals.ariaPressed', 'false');
- // await expect(element).not.toHaveAttribute('pressed');
- await expect(pressed).toHaveCount(0);
+ expect(await element.evaluate((node: ToggleButton) => node.elementInternals.states.has('pressed'))).toBe(false);
});
test('should NOT toggle the `pressed` attribute when clicked when the `disabled` attribute is present', async ({
@@ -66,46 +60,51 @@ test.describe('Toggle Button', () => {
}) => {
const element = page.locator('fluent-toggle-button');
- const pressed = page.locator('fluent-toggle-button[pressed]');
-
await page.setContent(/* html */ `
Toggle
`);
- await expect(element).toHaveAttribute('aria-pressed', 'false');
+ await expect(element).toHaveJSProperty('elementInternals.ariaPressed', 'false');
+
+ expect(await element.evaluate((node: ToggleButton) => node.elementInternals.states.has('pressed'))).toBe(false);
- // await expect(element).not.toHaveAttribute('pressed');
- await expect(pressed).toHaveCount(0);
+ await element.click();
+
+ await expect(element).toHaveJSProperty('elementInternals.ariaPressed', 'false');
+
+ expect(await element.evaluate((node: ToggleButton) => node.elementInternals.states.has('pressed'))).toBe(false);
await element.click();
- await expect(element).toHaveAttribute('aria-pressed', 'false');
+ await expect(element).toHaveJSProperty('elementInternals.ariaPressed', 'false');
- // await expect(element).not.toHaveAttribute('pressed');
- await expect(pressed).toHaveCount(0);
+ expect(await element.evaluate((node: ToggleButton) => node.elementInternals.states.has('pressed'))).toBe(false);
});
test('should NOT toggle the `pressed` attribute when clicked when the `disabled-focusable` attribute is present', async ({
page,
}) => {
const element = page.locator('fluent-toggle-button');
- const pressed = page.locator('fluent-toggle-button[pressed]');
await page.setContent(/* html */ `
Toggle
`);
- await expect(element).toHaveAttribute('aria-pressed', 'false');
+ await expect(element).toHaveJSProperty('elementInternals.ariaPressed', 'false');
+
+ expect(await element.evaluate((node: ToggleButton) => node.elementInternals.states.has('pressed'))).toBe(false);
- // await expect(element).not.toHaveAttribute('pressed');
- await expect(pressed).toHaveCount(0);
+ await element.click();
+
+ await expect(element).toHaveJSProperty('elementInternals.ariaPressed', 'false');
+
+ expect(await element.evaluate((node: ToggleButton) => node.elementInternals.states.has('pressed'))).toBe(false);
await element.click();
- await expect(element).toHaveAttribute('aria-pressed', 'false');
+ await expect(element).toHaveJSProperty('elementInternals.ariaPressed', 'false');
- // await expect(element).not.toHaveAttribute('pressed');
- await expect(pressed).toHaveCount(0);
+ expect(await element.evaluate((node: ToggleButton) => node.elementInternals.states.has('pressed'))).toBe(false);
});
test('should set the `aria-pressed` attribute to `mixed` when the `mixed` attribute is present', async ({ page }) => {
@@ -115,7 +114,17 @@ test.describe('Toggle Button', () => {
Toggle
`);
- await expect(element).toHaveAttribute('aria-pressed', 'mixed');
+ await expect(element).toHaveJSProperty('elementInternals.ariaPressed', 'mixed');
+ });
+
+ test('should set the `pressed` state when the `mixed` attribute is present', async ({ page }) => {
+ const element = page.locator('fluent-toggle-button');
+
+ await page.setContent(/* html */ `
+ Toggle
+ `);
+
+ expect(await element.evaluate((node: ToggleButton) => node.elementInternals.states.has('pressed'))).toBe(true);
});
test('should set the `aria-pressed` attribute to match the `pressed` attribute when the `mixed` attribute is removed', async ({
@@ -127,12 +136,28 @@ test.describe('Toggle Button', () => {
Toggle
`);
- await expect(element).toHaveAttribute('aria-pressed', 'mixed');
+ await expect(element).toHaveJSProperty('elementInternals.ariaPressed', 'mixed');
+
+ await element.evaluate(node => {
+ node.removeAttribute('mixed');
+ });
+
+ await expect(element).toHaveJSProperty('elementInternals.ariaPressed', 'true');
+ });
+
+ test('should persist the `pressed` state when the `mixed` attribute is removed', async ({ page }) => {
+ const element = page.locator('fluent-toggle-button');
+
+ await page.setContent(/* html */ `
+ Toggle
+ `);
+
+ expect(await element.evaluate((node: ToggleButton) => node.elementInternals.states.has('pressed'))).toBe(true);
await element.evaluate(node => {
node.removeAttribute('mixed');
});
- await expect(element).toHaveAttribute('aria-pressed', 'true');
+ expect(await element.evaluate((node: ToggleButton) => node.elementInternals.states.has('pressed'))).toBe(true);
});
});
diff --git a/packages/web-components/src/toggle-button/toggle-button.styles.ts b/packages/web-components/src/toggle-button/toggle-button.styles.ts
index f24d70aa7ce09a..5f59aa77c89e05 100644
--- a/packages/web-components/src/toggle-button/toggle-button.styles.ts
+++ b/packages/web-components/src/toggle-button/toggle-button.styles.ts
@@ -27,6 +27,7 @@ import {
strokeWidthThin,
} from '../theme/design-tokens.js';
import { forcedColorsStylesheetBehavior } from '../utils/behaviors/match-media-stylesheet-behavior.js';
+import { outlineState, pressedState, primaryState, subtleState, transparentState } from '../styles/states/index.js';
/**
* The styles for the ToggleButton component.
@@ -38,87 +39,87 @@ import { forcedColorsStylesheetBehavior } from '../utils/behaviors/match-media-s
export const styles = css`
${ButtonStyles}
- :host([aria-pressed='true']) {
+ :host(${pressedState}) {
border-color: ${colorNeutralStroke1};
background-color: ${colorNeutralBackground1Selected};
color: ${colorNeutralForeground1};
border-width: ${strokeWidthThin};
}
- :host([aria-pressed='true']:hover) {
+ :host(${pressedState}:hover) {
border-color: ${colorNeutralStroke1Hover};
background-color: ${colorNeutralBackground1Hover};
}
- :host([aria-pressed='true']:active) {
+ :host(${pressedState}:active) {
border-color: ${colorNeutralStroke1Pressed};
background-color: ${colorNeutralBackground1Pressed};
}
- :host([aria-pressed='true'][appearance='primary']) {
+ :host(${pressedState}${primaryState}) {
border-color: transparent;
background-color: ${colorBrandBackgroundSelected};
color: ${colorNeutralForegroundOnBrand};
}
- :host([aria-pressed='true'][appearance='primary']:hover) {
+ :host(${pressedState}${primaryState}:hover) {
background-color: ${colorBrandBackgroundHover};
}
- :host([aria-pressed='true'][appearance='primary']:active) {
+ :host(${pressedState}${primaryState}:active) {
background-color: ${colorBrandBackgroundPressed};
}
- :host([aria-pressed='true'][appearance='subtle']) {
+ :host(${pressedState}${subtleState}) {
border-color: transparent;
background-color: ${colorSubtleBackgroundSelected};
color: ${colorNeutralForeground2Selected};
}
- :host([aria-pressed='true'][appearance='subtle']:hover) {
+ :host(${pressedState}${subtleState}:hover) {
background-color: ${colorSubtleBackgroundHover};
color: ${colorNeutralForeground2Hover};
}
- :host([aria-pressed='true'][appearance='subtle']:active) {
+ :host(${pressedState}${subtleState}:active) {
background-color: ${colorSubtleBackgroundPressed};
color: ${colorNeutralForeground2Pressed};
}
- :host([aria-pressed='true'][appearance='outline']),
- :host([aria-pressed='true'][appearance='transparent']) {
+ :host(${pressedState}${outlineState}),
+ :host(${pressedState}${transparentState}) {
background-color: ${colorTransparentBackgroundSelected};
}
- :host([aria-pressed='true'][appearance='outline']:hover),
- :host([aria-pressed='true'][appearance='transparent']:hover) {
+ :host(${pressedState}${outlineState}:hover),
+ :host(${pressedState}${transparentState}:hover) {
background-color: ${colorTransparentBackgroundHover};
}
- :host([aria-pressed='true'][appearance='outline']:active),
- :host([aria-pressed='true'][appearance='transparent']:active) {
+ :host(${pressedState}${outlineState}:active),
+ :host(${pressedState}${transparentState}:active) {
background-color: ${colorTransparentBackgroundPressed};
}
- :host([aria-pressed='true'][appearance='transparent']) {
+ :host(${pressedState}${transparentState}) {
border-color: transparent;
color: ${colorNeutralForeground2BrandSelected};
}
- :host([aria-pressed='true'][appearance='transparent']:hover) {
+ :host(${pressedState}${transparentState}:hover) {
color: ${colorNeutralForeground2BrandHover};
}
- :host([aria-pressed='true'][appearance='transparent']:active) {
+ :host(${pressedState}${transparentState}:active) {
color: ${colorNeutralForeground2BrandPressed};
}
`.withBehaviors(
forcedColorsStylesheetBehavior(css`
- :host([aria-pressed='true']),
- :host([aria-pressed='true'][appearance='primary']),
- :host([aria-pressed='true'][appearance='subtle']),
- :host([aria-pressed='true'][appearance='outline']),
- :host([aria-pressed='true'][appearance='transparent']) {
+ :host(${pressedState}),
+ :host(${pressedState}${primaryState}),
+ :host(${pressedState}${subtleState}),
+ :host(${pressedState}${outlineState}),
+ :host(${pressedState}${transparentState}) {
background: SelectedItem;
color: SelectedItemText;
}
diff --git a/packages/web-components/src/toggle-button/toggle-button.ts b/packages/web-components/src/toggle-button/toggle-button.ts
index b510f181d01d28..9253efd49b7ce6 100644
--- a/packages/web-components/src/toggle-button/toggle-button.ts
+++ b/packages/web-components/src/toggle-button/toggle-button.ts
@@ -1,5 +1,6 @@
import { attr } from '@microsoft/fast-element';
import { Button } from '../button/button.js';
+import { toggleState } from '../utils/element-internals.js';
/**
* The base class used for constructing a `` custom element.
@@ -70,7 +71,7 @@ export class ToggleButton extends Button {
if (this.$fastController.isConnected) {
const ariaPressed = `${this.mixed ? 'mixed' : !!this.pressed}`;
this.elementInternals.ariaPressed = ariaPressed;
- this.setAttribute('aria-pressed', ariaPressed);
+ toggleState(this.elementInternals, 'pressed', !!this.pressed || !!this.mixed);
}
}
}