Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
234 changes: 234 additions & 0 deletions playwright/cps-ui-kit/components/cps-button.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
import { test, expect, type Page, type Locator } from '@playwright/test';

const LOADING_TOGGLE_TIMEOUT_MS = 4000;

function example(page: Page, testId: string): Locator {
return page.getByTestId(testId);
}

test.describe('cps-button', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/button');
});

test.describe('Disabled state', () => {
test('cannot be focused and blocks pointer events', async ({ page }) => {
const button = example(page, 'solid-disabled-button').getByTestId(
'cps-button'
);

await expect(button).toBeDisabled();
await expect(button).toHaveCSS('pointer-events', 'none');

await button.focus();
await expect(button).not.toBeFocused();
});
});

test.describe('Enter/Space keyboard activation', () => {
test('Enter shows the key-active class while held and fires a real click', async ({
page
}) => {
const button = example(page, 'native-types-plain-button').getByTestId(
'cps-button'
);
await button.focus();

await page.keyboard.down('Enter');
await expect(button).toHaveClass(/cps-button--key-active/);
await page.keyboard.up('Enter');
await expect(button).not.toHaveClass(/cps-button--key-active/);

await expect(page.getByTestId('native-types-message')).toHaveText(
'Plain button clicked (no form action).'
);
});

test('Space fires a real click via native button semantics without the key-active class', async ({
page
}) => {
const button = example(page, 'native-types-plain-button').getByTestId(
'cps-button'
);
await button.focus();

await page.keyboard.down(' ');
await expect(button).not.toHaveClass(/cps-button--key-active/);
await page.keyboard.up(' ');

await expect(page.getByTestId('native-types-message')).toHaveText(
'Plain button clicked (no form action).'
);
await expect(button).not.toHaveClass(/cps-button--key-active/);
});
});

test.describe('Loading state - real timing and interaction gating', () => {
test('a real click starts loading and hides content behind the spinner', async ({
page
}) => {
const wrapper = example(page, 'misc-loading-toggle-button');
const button = wrapper.getByTestId('cps-button');

await wrapper.click();

await expect(button).toHaveAttribute('aria-busy', 'true');
await expect(
wrapper
.getByTestId('cps-button-spinner')
.locator('cps-progress-circular')
).toBeVisible();
await expect(wrapper.getByTestId('cps-button-content')).toHaveAttribute(
'aria-hidden',
'true'
);
await expect(button).toHaveCSS('pointer-events', 'none');
});

test('Enter while loading does not throw and the button stays loading', async ({
page
}) => {
const wrapper = example(page, 'misc-loading-toggle-button');
const button = wrapper.getByTestId('cps-button');

await wrapper.click();
await expect(button).toHaveAttribute('aria-busy', 'true');

await button.focus();
await page.keyboard.press('Enter');
await expect(button).toHaveAttribute('aria-busy', 'true');
});

test('loading auto-clears and the button becomes interactive again', async ({
page
}) => {
const wrapper = example(page, 'misc-loading-toggle-button');
const button = wrapper.getByTestId('cps-button');

await wrapper.click();
await expect(button).toHaveAttribute('aria-busy', 'true');

await expect(button).not.toHaveAttribute('aria-busy', 'true', {
timeout: LOADING_TOGGLE_TIMEOUT_MS
});
await expect(button).toHaveCSS('pointer-events', /^(?!none$).*/);

await wrapper.click();
await expect(button).toHaveAttribute('aria-busy', 'true');
});
});

test.describe('Icon-only buttons', () => {
test('expose their aria-label as the real accessible name', async ({
page
}) => {
await expect(page.getByRole('button', { name: 'Like' })).toBeVisible();
await expect(page.getByRole('button', { name: 'View' })).toBeVisible();
});
});

test.describe('Custom sizing - real rendered layout', () => {
test('renders the configured width and height', async ({ page }) => {
const button = example(page, 'misc-custom-size-button').getByTestId(
'cps-button'
);
await button.scrollIntoViewIfNeeded();
const box = await button.boundingBox();
expect(box).not.toBeNull();

expect(box!.width).toBeGreaterThan(250);
expect(box!.width).toBeLessThan(350);
expect(box!.height).toBeGreaterThan(50);
expect(box!.height).toBeLessThan(70);
await expect(button).not.toHaveCSS('border-radius', '4px');
});

test('a full-width button tracks its container rather than staying intrinsic width', async ({
page
}) => {
const button = example(page, 'misc-block-button').getByTestId(
'cps-button'
);
await button.scrollIntoViewIfNeeded();
const box = await button.boundingBox();
expect(box).not.toBeNull();

expect(box!.width).toBeGreaterThan(600);
});
});

test.describe('Native form integration', () => {
test('a plain button never submits, regardless of field validity', async ({
page
}) => {
await expect(page).toHaveURL(/\/button\/examples$/);
const urlBefore = page.url();

await example(page, 'native-types-plain-button').click();

await expect(page.getByTestId('native-types-message')).toHaveText(
'Plain button clicked (no form action).'
);
expect(page.url()).toBe(urlBefore);
});

test('submitting with an empty required field shows an invalid message and does not navigate', async ({
page
}) => {
await expect(page).toHaveURL(/\/button\/examples$/);
const urlBefore = page.url();

await example(page, 'native-types-submit-button').click();

await expect(page.getByTestId('native-types-message')).toHaveText(
'Form is invalid.'
);
expect(page.url()).toBe(urlBefore);
});

test('submitting with the field filled shows the submitted value', async ({
page
}) => {
const form = page.getByTestId('native-types-form');
await form.getByRole('textbox', { name: 'Name' }).fill('Ada');

await example(page, 'native-types-submit-button').click();

await expect(page.getByTestId('native-types-message')).toHaveText(
'Form submitted with name: "Ada"'
);
});

test('reset clears both the field and the message via the native reset event', async ({
page
}) => {
const form = page.getByTestId('native-types-form');
const nameInput = form.getByRole('textbox', { name: 'Name' });

await nameInput.fill('Ada');
await example(page, 'native-types-submit-button').click();
await expect(page.getByTestId('native-types-message')).toHaveText(
'Form submitted with name: "Ada"'
);

await example(page, 'native-types-reset-button').click();

await expect(nameInput).toHaveValue('');
await expect(page.getByTestId('native-types-message')).toHaveText('');
});

test('pressing Enter in the field implicitly submits the form', async ({
page
}) => {
const form = page.getByTestId('native-types-form');
const nameInput = form.getByRole('textbox', { name: 'Name' });

await nameInput.fill('Grace');
await nameInput.press('Enter');

await expect(page.getByTestId('native-types-message')).toHaveText(
'Form submitted with name: "Grace"'
);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
color="surprise"
iconPosition="after"></cps-button>
<cps-button
data-testid="solid-disabled-button"
icon="add"
label="Normal button"
color="luxury"
Expand Down Expand Up @@ -327,27 +328,34 @@
[tsCode]="examples.nativeTypes.ts">
<form
class="native-types-form"
data-testid="native-types-form"
[formGroup]="nativeForm"
(ngSubmit)="onNativeSubmit($event)"
(reset)="onNativeReset()">
<cps-input label="Name" formControlName="name"></cps-input>
<div class="buttons-group-row">
<cps-button
data-testid="native-types-plain-button"
label="Plain button"
nativeType="button"
type="outlined"
(clicked)="onNativePlainClick()"></cps-button>
<cps-button
data-testid="native-types-submit-button"
label="Submit"
nativeType="submit"
color="luxury"></cps-button>
<cps-button
data-testid="native-types-reset-button"
label="Reset"
nativeType="reset"
type="borderless"
color="surprise"></cps-button>
</div>
<div class="native-types-form__message" aria-live="polite">
<div
class="native-types-form__message"
data-testid="native-types-message"
aria-live="polite">
{{ nativeSubmitMessage }}
</div>
</form>
Expand All @@ -359,6 +367,7 @@
[tsCode]="examples.misc.ts">
<div class="buttons-group-row dark-background">
<cps-button
data-testid="misc-loading-toggle-button"
[loading]="isLoading"
label="Click to load"
type="outlined"
Expand All @@ -377,15 +386,21 @@
size="large"
ariaLabel="View"></cps-button>
<cps-button
data-testid="misc-custom-size-button"
label="Custom size"
borderRadius="2rem"
width="300"
height="60"
color="white"
type="outlined"
icon="avatar-top-menu"></cps-button>
<cps-button
label="Custom content color"
color="depth-highlighten"
contentColor="depth"></cps-button>
</div>
<cps-button
data-testid="misc-block-button"
label="Block large button"
borderRadius="0"
width="100%"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,182 +1,185 @@
export const buttonExamples: Record<string, { html: string; ts?: string }> = {
solid: {
html: `
<!-- large -->
<cps-button label="Large button" size="large" color="luxury"></cps-button>
<cps-button icon="add" label="Large button" size="large"></cps-button>
<cps-button icon="add" label="Large button" color="surprise" iconPosition="after" size="large"></cps-button>
<cps-button icon="add" label="Large button" color="luxury" size="large" [disabled]="true"></cps-button>
<cps-button [loading]="true" size="large" ariaLabel="Loading button"></cps-button>

<!-- normal -->
<cps-button label="Normal button" color="luxury"></cps-button>
<cps-button icon="add" label="Normal button"></cps-button>
<cps-button icon="add" label="Normal button" color="surprise" iconPosition="after"></cps-button>
<cps-button icon="add" label="Normal button" color="luxury" [disabled]="true"></cps-button>
<cps-button [loading]="true" ariaLabel="Loading button"></cps-button>

<!-- small -->
<cps-button label="Small button" size="small" color="luxury"></cps-button>
<cps-button icon="add" label="Small button" size="small"></cps-button>
<cps-button icon="add" label="Small button" color="surprise" iconPosition="after" size="small"></cps-button>
<cps-button icon="add" label="Small button" color="luxury" size="small" [disabled]="true"></cps-button>
<cps-button [loading]="true" size="small" ariaLabel="Loading button"></cps-button>

<!-- xsmall -->
<cps-button label="XSmall button" size="xsmall" color="luxury"></cps-button>
<cps-button icon="add" label="XSmall button" size="xsmall"></cps-button>
<cps-button icon="add" label="XSmall button" color="surprise" iconPosition="after" size="xsmall"></cps-button>
<cps-button icon="add" label="XSmall button" color="luxury" size="xsmall" [disabled]="true"></cps-button>
<cps-button [loading]="true" size="xsmall" ariaLabel="Loading button"></cps-button>`
},

outlined: {
html: `
<!-- large -->
<cps-button label="Large button" size="large" type="outlined" color="luxury"></cps-button>
<cps-button icon="add" label="Large button" size="large" type="outlined"></cps-button>
<cps-button icon="add" label="Large button" color="surprise" iconPosition="after" size="large" type="outlined"></cps-button>
<cps-button icon="add" label="Large button" color="luxury" size="large" type="outlined" [disabled]="true"></cps-button>
<cps-button [loading]="true" size="large" type="outlined" ariaLabel="Loading button"></cps-button>

<!-- normal -->
<cps-button label="Normal button" type="outlined" color="luxury"></cps-button>
<cps-button icon="add" label="Normal button" type="outlined"></cps-button>
<cps-button icon="add" label="Normal button" color="surprise" iconPosition="after" type="outlined"></cps-button>
<cps-button icon="add" label="Normal button" color="luxury" type="outlined" [disabled]="true"></cps-button>
<cps-button [loading]="true" type="outlined" ariaLabel="Loading button"></cps-button>

<!-- small -->
<cps-button label="Small button" size="small" type="outlined" color="luxury"></cps-button>
<cps-button icon="add" label="Small button" size="small" type="outlined"></cps-button>
<cps-button icon="add" label="Small button" color="surprise" iconPosition="after" size="small" type="outlined"></cps-button>
<cps-button icon="add" label="Small button" color="luxury" size="small" type="outlined" [disabled]="true"></cps-button>
<cps-button [loading]="true" size="small" type="outlined" ariaLabel="Loading button"></cps-button>

<!-- xsmall -->
<cps-button label="XSmall button" size="xsmall" type="outlined" color="luxury"></cps-button>
<cps-button icon="add" label="XSmall button" size="xsmall" type="outlined"></cps-button>
<cps-button icon="add" label="XSmall button" color="surprise" iconPosition="after" size="xsmall" type="outlined"></cps-button>
<cps-button icon="add" label="XSmall button" color="luxury" size="xsmall" type="outlined" [disabled]="true"></cps-button>
<cps-button [loading]="true" size="xsmall" type="outlined" ariaLabel="Loading button"></cps-button>`
},

borderless: {
html: `
<!-- large -->
<cps-button label="Large button" size="large" type="borderless" color="luxury"></cps-button>
<cps-button icon="add" label="Large button" size="large" type="borderless"></cps-button>
<cps-button icon="add" label="Large button" color="surprise" iconPosition="after" size="large" type="borderless"></cps-button>
<cps-button icon="add" label="Large button" color="luxury" size="large" type="borderless" [disabled]="true"></cps-button>
<cps-button [loading]="true" size="large" type="borderless" ariaLabel="Loading button"></cps-button>

<!-- normal -->
<cps-button label="Normal button" type="borderless" color="luxury"></cps-button>
<cps-button icon="add" label="Normal button" type="borderless"></cps-button>
<cps-button icon="add" label="Normal button" color="surprise" iconPosition="after" type="borderless"></cps-button>
<cps-button icon="add" label="Normal button" color="luxury" type="borderless" [disabled]="true"></cps-button>
<cps-button [loading]="true" type="borderless" ariaLabel="Loading button"></cps-button>

<!-- small -->
<cps-button label="Small button" size="small" type="borderless" color="luxury"></cps-button>
<cps-button icon="add" label="Small button" size="small" type="borderless"></cps-button>
<cps-button icon="add" label="Small button" color="surprise" iconPosition="after" size="small" type="borderless"></cps-button>
<cps-button icon="add" label="Small button" color="luxury" size="small" type="borderless" [disabled]="true"></cps-button>
<cps-button [loading]="true" size="small" type="borderless" ariaLabel="Loading button"></cps-button>

<!-- xsmall -->
<cps-button label="XSmall button" size="xsmall" type="borderless" color="luxury"></cps-button>
<cps-button icon="add" label="XSmall button" size="xsmall" type="borderless"></cps-button>
<cps-button icon="add" label="XSmall button" color="surprise" iconPosition="after" size="xsmall" type="borderless"></cps-button>
<cps-button icon="add" label="XSmall button" color="luxury" size="xsmall" type="borderless" [disabled]="true"></cps-button>
<cps-button [loading]="true" size="xsmall" type="borderless" ariaLabel="Loading button"></cps-button>`
},

nativeTypes: {
html: `
<form
class="native-types-form"
[formGroup]="nativeForm"
(ngSubmit)="onNativeSubmit($event)"
(reset)="onNativeReset()">
<cps-input label="Name" formControlName="name"></cps-input>
<div class="buttons-group-row">
<cps-button
label="Plain button"
nativeType="button"
type="outlined"
(clicked)="onNativePlainClick()"></cps-button>
<cps-button
label="Submit"
nativeType="submit"
color="luxury"></cps-button>
<cps-button
label="Reset"
nativeType="reset"
type="borderless"
color="surprise"></cps-button>
</div>
@if (nativeSubmitMessage) {
<div class="native-types-form__message">{{ nativeSubmitMessage }}</div>
}
</form>`,
ts: `
private readonly fb = inject(FormBuilder);

componentData = ComponentData;
isLoading = false;

nativeForm = this.fb.nonNullable.group({
name: ['', Validators.required]
});

nativeSubmitMessage = '';

onNativePlainClick() {
this.nativeSubmitMessage = 'Plain button clicked (no form action).';
}

onNativeSubmit(event: Event) {
event.preventDefault();
if (this.nativeForm.invalid) {
this.nativeSubmitMessage = 'Form is invalid.';
return;
}
this.nativeSubmitMessage = "Form submitted with name: " + this.nativeForm.value.name;
}

onNativeReset() {
this.nativeForm.reset();
this.nativeSubmitMessage = '';
}`
},

misc: {
html: `
<!-- Interactive loading state -->
<cps-button
label="Click to load"
type="outlined"
color="white"
[loading]="isLoading"
(clicked)="onClickForLoading()">
</cps-button>

<!-- Icon-only -->
<cps-button color="white" type="outlined" icon="like" ariaLabel="Like" size="large"></cps-button>
<cps-button color="graphite" icon="eye" size="large" ariaLabel="View"></cps-button>

<!-- Custom size -->
<cps-button label="Custom size" borderRadius="2rem" width="300" height="60" color="white" type="outlined" icon="avatar-top-menu"></cps-button>

<!-- Custom content color -->
<cps-button label="Custom content color" color="depth-highlighten" contentColor="depth"></cps-button>

<!-- Block / full-width -->
<cps-button label="Block large button" borderRadius="0" width="100%" size="large" color="depth"></cps-button>`,
ts: `
isLoading = false;

onClickForLoading(): void {
this.isLoading = true;
setTimeout(() => (this.isLoading = false), 2000);
}`
}
};

Check warning on line 185 in projects/composition/src/app/pages/button-page/button-page.examples.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<div>
<button
data-testid="cps-button"
[attr.type]="nativeType"
[ngClass]="classesList"
[disabled]="disabled"
Expand All @@ -13,13 +14,14 @@
(keydown.enter)="onEnterKeydown()"
(keyup.enter)="onEnterKeyup()"
(blur)="onBlur()"
[ngStyle]="{
backgroundColor: type === 'solid' ? buttonColor : 'transparent',
color: textColor,
height: cvtHeight || 'none',
borderRadius: borderRadius
}">
<div class="cps-button__spinner" aria-hidden="true">
[style.backgroundColor]="type === 'solid' ? buttonColor : 'transparent'"
[style.color]="textColor"
[style.height]="cvtHeight || null"
[style.borderRadius]="borderRadius">
<div
class="cps-button__spinner"
data-testid="cps-button-spinner"
aria-hidden="true">
@if (loading) {
<cps-progress-circular
color="currentColor"
Expand All @@ -30,11 +32,13 @@
</div>
<div
class="cps-button__content"
data-testid="cps-button-content"
[style.visibility]="loading ? 'hidden' : null"
[attr.aria-hidden]="loading ? true : null">
@if (icon) {
<cps-icon
class="cps-button__icon"
data-testid="cps-button-icon"
[icon]="icon"
[color]="
disabled
Expand All @@ -49,7 +53,8 @@
@if (label) {
<span
class="cps-button__text"
[ngStyle]="{ 'font-size': cvtFontSize || null }">
data-testid="cps-button-label"
[style.fontSize]="cvtFontSize || null">
{{ label }}
</span>
}
Expand Down
Loading