Skip to content
Merged
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
112 changes: 76 additions & 36 deletions playwright/cps-accessibility.spec.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import { test, expect } from './fixtures/axe-test';
import type { Page } from '@playwright/test';
import type { Page, TestInfo } from '@playwright/test';
import type AxeBuilder from '@axe-core/playwright';

interface ComponentState {
label: string;
setup: (page: Page) => Promise<void>;
}

interface ComponentEntry {
route: string;
name: string;
selector: string | string[];
/** For overlay components: trigger them before scanning */
setup?: (page: Page) => Promise<void>;
/** For components that need multiple scans (e.g., different states/tabs) */
states?: ComponentState[];
}

const components: ComponentEntry[] = [
Expand Down Expand Up @@ -147,7 +154,26 @@ const components: ComponentEntry[] = [
// selector: 'cps-progress-linear'
// },
{ route: '/radio-group', name: 'Radio', selector: 'cps-radio-group' },
// { route: '/scheduler', name: 'Scheduler', selector: 'cps-scheduler' },
{
route: '/scheduler',
name: 'Scheduler',
selector: 'cps-scheduler',
states: [
'Minutes',
'Hourly',
'Daily',
'Weekly',
'Monthly',
'Yearly',
'Advanced'
].map((tab) => ({
label: tab,
setup: async (page: Page) => {
await page.getByTestId('schedule-type-toggle').getByText(tab).click();
await page.waitForSelector('cps-scheduler');
}
}))
},
{
route: '/select',
name: 'Select',
Expand Down Expand Up @@ -249,31 +275,53 @@ async function waitForSelectors(page: Page, selector: string | string[]) {
// axe-core WCAG AA — all component pages
// ============================================================================

async function runScan(
page: Page,
makeAxeBuilder: () => AxeBuilder,
selector: string | string[],
label: string,
testInfo: TestInfo
) {
await waitForSelectors(page, selector);
// Wait for all animations/transitions to complete before scanning.
// Without this, axe may capture intermediate states (e.g. mid-transition
// background color) and report false-positive color contrast violations.
await page.evaluate(() =>
Promise.all(
document
.getAnimations()
.filter((a) => a.effect?.getTiming().iterations !== Infinity)
.map((a) => a.finished.catch(() => {}))
)
);
const results = await buildAxeWithSelectors(
makeAxeBuilder,
selector
).analyze();
await testInfo.attach(`${label}-accessibility-scan`, {
body: JSON.stringify(results, null, 2),
contentType: 'application/json'
});
expectNoViolations(results.violations);
}

test.describe('Accessibility - axe scan', () => {
for (const { route, name, selector, setup } of components) {
for (const { route, name, selector, setup, states } of components) {
test(`${name} should have no violations`, async ({
page,
makeAxeBuilder
}, testInfo) => {
await page.goto(route);

if (setup) {
await setup(page);
if (states) {
for (const state of states) {
if (state.setup) await state.setup(page);
await runScan(page, makeAxeBuilder, selector, state.label, testInfo);
}
} else {
if (setup) await setup(page);
await runScan(page, makeAxeBuilder, selector, 'default', testInfo);
}

await waitForSelectors(page, selector);

const results = await buildAxeWithSelectors(
makeAxeBuilder,
selector
).analyze();

await testInfo.attach('accessibility-scan-results', {
body: JSON.stringify(results, null, 2),
contentType: 'application/json'
});

expectNoViolations(results.violations);
});
}
});
Expand All @@ -283,31 +331,23 @@ test.describe('Accessibility - axe scan', () => {
// ============================================================================

test.describe('Accessibility - responsive axe scan', () => {
for (const { route, name, selector, setup } of components) {
for (const { route, name, selector, setup, states } of components) {
test(`${name} should have no violations at mobile width`, async ({
page,
makeAxeBuilder
}, testInfo) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto(route);

if (setup) {
await setup(page);
if (states) {
for (const state of states) {
if (state.setup) await state.setup(page);
await runScan(page, makeAxeBuilder, selector, state.label, testInfo);
}
} else {
if (setup) await setup(page);
await runScan(page, makeAxeBuilder, selector, 'default', testInfo);
}

await waitForSelectors(page, selector);

const results = await buildAxeWithSelectors(
makeAxeBuilder,
selector
).analyze();

await testInfo.attach('accessibility-scan-results', {
body: JSON.stringify(results, null, 2),
contentType: 'application/json'
});

expectNoViolations(results.violations);
});
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@
"default": "true",
"description": "Determines whether at least one of the options is mandatory."
},
{
"name": "radioNavigation",
"optional": false,
"readonly": false,
"type": "boolean",
"default": "true",
"description": "When multiple is false, and mandatory is true, uses native radio group behavior:\narrow-key navigation between options. Has no effect when multiple is true or mandatory is false."
},
{
"name": "equalWidths",
"optional": false,
Expand Down
8 changes: 8 additions & 0 deletions projects/composition/src/app/api-data/cps-scheduler.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@
"default": "",
"description": "Label of the component."
},
{
"name": "ariaLabel",
"optional": false,
"readonly": false,
"type": "string",
"default": "",
"description": "Aria label for the component, used for accessibility, it takes precedence over label."
},
{
"name": "cron",
"optional": false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,18 @@
[componentData]="componentData"
[services]="serviceData">
<!-- Example of component's usage -->
<cps-scheduler
label="Frequency"
[disabled]="false"
[(cron)]="cronExpression"
(cronChange)="onCronExpressionChanged($event)"
[(timeZone)]="timeZone"
(timeZoneChange)="onTimezoneChanged($event)"
[showAdvanced]="true"
[use24HourTime]="true"
[showNotSet]="true"
[showTimeZone]="true"
infoTooltip="Provide frequency"></cps-scheduler>
<div class="scheduler-wrapper">
<cps-scheduler
label="Frequency"
[disabled]="false"
[(cron)]="cronExpression"
(cronChange)="onCronExpressionChanged($event)"
[(timeZone)]="timeZone"
(timeZoneChange)="onTimezoneChanged($event)"
[showAdvanced]="true"
[use24HourTime]="true"
[showNotSet]="true"
[showTimeZone]="true"
infoTooltip="Provide frequency"></cps-scheduler>
</div>
</app-component-docs-viewer>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.scheduler-wrapper {
margin-left: 0.5rem;
margin-right: 0.5rem;
margin-bottom: 0.5rem;
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ $error-background: #fef3f2;
$autocomplete-placeholder-color: var(--cps-input-placeholder);
$autocomplete-label-color: var(--cps-color-text-dark);
$autocomplete-label-disabled-color: var(--cps-color-text-mild);
$autocomplete-items-disabled-color: var(--cps-color-text-light);
$autocomplete-items-disabled-color: var(--cps-color-text-mild);
$autocomplete-hint-color: var(--cps-color-text-mild);
$autocomplete-background-disabled: var(--cps-color-bg-disabled);
$option-hover-background: var(--cps-color-highlight-hover);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@
}
<div
class="cps-btn-toggle-content"
role="group"
[attr.aria-label]="ariaLabel || label || null">
[attr.role]="
radioNavigation && !multiple && mandatory ? 'radiogroup' : 'group'
"
[attr.aria-label]="ariaLabel || label || null"
[attr.aria-disabled]="disabled || null">
@for (option of options; track option.value) {
@if (option.tooltip) {
<span
Expand All @@ -28,7 +31,9 @@
[tooltipPosition]="optionTooltipPosition">
<ng-container
*ngTemplateOutlet="
buttonTemplate;
radioNavigation && !multiple && mandatory
? radioTemplate
: buttonTemplate;
context: { option }
"></ng-container>
</span>
Expand All @@ -37,14 +42,48 @@
<span class="cps-btn-toggle-option-wrapper">
<ng-container
*ngTemplateOutlet="
buttonTemplate;
radioNavigation && !multiple && mandatory
? radioTemplate
: buttonTemplate;
context: { option }
"></ng-container>
</span>
}
}
</div>
</div>
<ng-template #radioTemplate let-option="option">
<label
class="cps-btn-toggle-content-option"
[class.is-selected]="
option.value | checkOptionSelected: value : multiple : true : ''
"
[class.is-disabled]="option.disabled || disabled"
[style.min-width.rem]="equalWidths ? largestButtonWidthRem : null">
<input
type="radio"
class="cps-btn-toggle-radio-input"
[name]="groupName"
[checked]="
option.value | checkOptionSelected: value : multiple : true : ''
"
[disabled]="option.disabled || disabled"
(change)="onRadioChange(option.value)"
[attr.aria-label]="option.ariaLabel || option.label || null" />
<span class="cps-btn-toggle-content-option-inner">
@if (option.icon) {
<cps-icon
[class.me-2]="!!option.label"
[icon]="option.icon"
aria-hidden="true">
</cps-icon>
}
@if (option.label) {
<span>{{ option.label }}</span>
}
</span>
</label>
</ng-template>
<ng-template #buttonTemplate let-option="option">
<button
type="button"
Expand All @@ -61,7 +100,10 @@
[style.min-width.rem]="equalWidths ? largestButtonWidthRem : null">
<span class="cps-btn-toggle-content-option-inner">
@if (option.icon) {
<cps-icon [class.me-2]="!!option.label" [icon]="option.icon">
<cps-icon
[class.me-2]="!!option.label"
[icon]="option.icon"
aria-hidden="true">
</cps-icon>
}
@if (option.label) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ $option-active-background: var(--cps-color-highlight-active);

&:not(:last-child) .cps-btn-toggle-content-option {
border-right: 0;

.cps-btn-toggle-radio-input {
inset-inline-end: 0;
}
}
}

Expand Down Expand Up @@ -79,7 +83,8 @@ $option-active-background: var(--cps-color-highlight-active);
transition: background-color 0.2s ease;
}

&:focus-visible {
&:focus-visible,
&:has(.cps-btn-toggle-radio-input:focus-visible) {
&:not(.is-selected):not(:active) {
background: $option-hover-background;
}
Expand All @@ -92,13 +97,15 @@ $option-active-background: var(--cps-color-highlight-active);
color: white;
}

&:disabled {
&:disabled,
&.is-disabled {
background: $disabled-background-color;
color: $disabled-option-label-color;
cursor: default;
}

&:disabled.is-selected {
&:disabled.is-selected,
&.is-disabled.is-selected {
background: $selected-disabled-background-color;
color: $selected-disabled-option-label-color;
opacity: 1;
Expand All @@ -123,5 +130,19 @@ $option-active-background: var(--cps-color-highlight-active);
overflow: hidden;
text-overflow: ellipsis;
}

.cps-btn-toggle-radio-input {
position: absolute;
inset: -0.0625rem;
margin: 0;
opacity: 0;
pointer-events: none;
cursor: inherit;

&:focus,
&:focus-visible {
outline: none;
}
}
}
}
Loading
Loading