Skip to content

Commit

Permalink
feat: use custom states for button and anchor button variants (#31718)
Browse files Browse the repository at this point in the history
  • Loading branch information
chrisdholt authored Jun 17, 2024
1 parent 5b3fcbd commit 1fb910e
Show file tree
Hide file tree
Showing 15 changed files with 441 additions and 136 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"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',
transparent: 'transparent',
} as const;

Expand Down
Loading

0 comments on commit 1fb910e

Please sign in to comment.