Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: use custom states for button and anchor button variants #31718

Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
chrisdholt marked this conversation as resolved.
Show resolved Hide resolved
"type": "prerelease",
"comment": "feat: update button and anchor button to leverage custom states for variants",
"packageName": "@fluentui/web-components",
"email": "[email protected]",
"dependentChangeType": "patch"
}
65 changes: 65 additions & 0 deletions packages/web-components/src/anchor-button/anchor-button.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */ `
<fluent-anchor-button href="${expectedUrl}"></fluent-anchor-button>
`);

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 */ `
<fluent-anchor-button href="${expectedUrl}"></fluent-anchor-button>
`);

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 */ `
<fluent-anchor-button href="${expectedUrl}"></fluent-anchor-button>
`);

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 */ `
<fluent-anchor-button href="${expectedUrl}"></fluent-anchor-button>
`);

await element.focus();

const [newPage] = await Promise.all([context.waitForEvent('page'), element.press('ControlOrMeta+Enter')]);

expect(newPage.url()).toContain(expectedUrl);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ export function anchorTemplate<T extends AnchorButton>(options: AnchorOptions =
return html<T>`
<template
tabindex="0"
@click="${x => 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)}
<span class="content" part="content">
Expand Down
90 changes: 82 additions & 8 deletions packages/web-components/src/anchor-button/anchor-button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -30,6 +31,12 @@ export type AnchorOptions = StartEndOptions<AnchorButton>;
* @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.
*
Expand Down Expand Up @@ -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
Expand All @@ -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;
}
}
Expand All @@ -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.
*
Expand All @@ -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.
*
Expand All @@ -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
*
Expand All @@ -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);
}
}

/**
Expand Down
1 change: 0 additions & 1 deletion packages/web-components/src/button/button.options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ export const ButtonAppearance = {
primary: 'primary',
outline: 'outline',
subtle: 'subtle',
secondary: 'secondary',
chrisdholt marked this conversation as resolved.
Show resolved Hide resolved
chrisdholt marked this conversation as resolved.
Show resolved Hide resolved
transparent: 'transparent',
} as const;

Expand Down
Loading
Loading