Compare commits

..

1 Commits

Author SHA1 Message Date
Liam DeBeasi
c2918e7324 feat(react): add stronger typing for useIonModal and useIonPopover
Co-authored-by: aeharding <aeharding@users.noreply.github.com>
2024-03-13 15:55:41 -04:00
45 changed files with 456 additions and 194 deletions

View File

@@ -439,38 +439,53 @@ render() {
#### Labels
Labels should be passed directly to the component in the form of either visible text or an `aria-label`. The visible text can be set inside of a `label` element, and the `aria-label` can be set directly on the interactive element.
In the following example the `aria-label` can be inherited from the Host using the `inheritAttributes` or `inheritAriaAttributes` utilities. This allows developers to set `aria-label` on the host element since they do not have access to inside the shadow root.
> [!NOTE]
> Use `inheritAttributes` to specify which attributes should be inherited or `inheritAriaAttributes` to inherit all of the possible `aria` attributes.
A helper function has been created to get the proper `aria-label` for the checkbox. This can be imported as `getAriaLabel` like the following:
```tsx
import { Prop } from '@stencil/core';
import { inheritAttributes } from '@utils/helpers';
import type { Attributes } from '@utils/helpers';
const { label, labelId, labelText } = getAriaLabel(el, inputId);
```
...
where `el` and `inputId` are the following:
private inheritedAttributes: Attributes = {};
```tsx
export class Checkbox implements ComponentInterface {
private inputId = `ion-cb-${checkboxIds++}`;
@Prop() labelText?: string;
@Element() el!: HTMLElement;
componentWillLoad() {
this.inheritedAttributes = inheritAttributes(this.el, ['aria-label']);
...
}
```
render() {
return (
<Host>
<label>
{this.labelText}
<input type="checkbox" {...this.inheritedAttributes} />
</label>
</Host>
)
}
This can then be added to the `Host` like the following:
```tsx
<Host
aria-labelledby={label ? labelId : null}
aria-checked={`${checked}`}
aria-hidden={disabled ? 'true' : null}
role="checkbox"
>
```
In addition to that, the checkbox input should have a label added:
```tsx
<Host
aria-labelledby={label ? labelId : null}
aria-checked={`${checked}`}
aria-hidden={disabled ? 'true' : null}
role="checkbox"
>
<label htmlFor={inputId}>
{labelText}
</label>
<input
type="checkbox"
aria-checked={`${checked}`}
disabled={disabled}
id={inputId}
/>
```
#### Hidden Input
@@ -552,40 +567,57 @@ render() {
#### Labels
Labels should be passed directly to the component in the form of either visible text or an `aria-label`. The visible text can be set inside of a `label` element, and the `aria-label` can be set directly on the interactive element.
In the following example the `aria-label` can be inherited from the Host using the `inheritAttributes` or `inheritAriaAttributes` utilities. This allows developers to set `aria-label` on the host element since they do not have access to inside the shadow root.
> [!NOTE]
> Use `inheritAttributes` to specify which attributes should be inherited or `inheritAriaAttributes` to inherit all of the possible `aria` attributes.
A helper function has been created to get the proper `aria-label` for the switch. This can be imported as `getAriaLabel` like the following:
```tsx
import { Prop } from '@stencil/core';
import { inheritAttributes } from '@utils/helpers';
import type { Attributes } from '@utils/helpers';
const { label, labelId, labelText } = getAriaLabel(el, inputId);
```
...
where `el` and `inputId` are the following:
private inheritedAttributes: Attributes = {};
```tsx
export class Toggle implements ComponentInterface {
private inputId = `ion-tg-${toggleIds++}`;
@Prop() labelText?: string;
@Element() el!: HTMLElement;
componentWillLoad() {
this.inheritedAttributes = inheritAttributes(this.el, ['aria-label']);
}
render() {
return (
<Host>
<label>
{this.labelText}
<input type="checkbox" role="switch" {...this.inheritedAttributes} />
</label>
</Host>
)
...
}
```
This can then be added to the `Host` like the following:
```tsx
<Host
aria-labelledby={label ? labelId : null}
aria-checked={`${checked}`}
aria-hidden={disabled ? 'true' : null}
role="switch"
>
```
In addition to that, the checkbox input should have a label added:
```tsx
<Host
aria-labelledby={label ? labelId : null}
aria-checked={`${checked}`}
aria-hidden={disabled ? 'true' : null}
role="switch"
>
<label htmlFor={inputId}>
{labelText}
</label>
<input
type="checkbox"
role="switch"
aria-checked={`${checked}`}
disabled={disabled}
id={inputId}
/>
```
#### Hidden Input
A helper function to render a hidden input has been added, it can be added in the `render`:

View File

@@ -254,11 +254,11 @@ For more information on styling toast buttons, refer to the [Toast Theming docum
<h4 id="version-8x-range">Range</h4>
- The `legacy` property and support for the legacy syntax, which involved placing an `ion-range` inside of an `ion-item` with an `ion-label`, have been removed. Ionic will also no longer attempt to automatically associate form controls with sibling `<label>` elements as these label elements are now used inside the form control. Developers should provide a label (either visible text or `aria-label`) directly to the form control. For more information on migrating from the legacy range syntax, refer to the [Range documentation](https://ionicframework.com/docs/api/range#migrating-from-legacy-range-syntax).
- The `legacy` property and support for the legacy syntax, which involved placing an `ion-range` inside of an `ion-item` with an `ion-label`, have been removed. For more information on migrating from the legacy range syntax, refer to the [Range documentation](https://ionicframework.com/docs/api/range#migrating-from-legacy-range-syntax).
<h4 id="version-8x-select">Select</h4>
- The `legacy` property and support for the legacy syntax, which involved placing an `ion-select` inside of an `ion-item` with an `ion-label`, have been removed. Ionic will also no longer attempt to automatically associate form controls with sibling `<label>` elements as these label elements are now used inside the form control. Developers should provide a label (either visible text or `aria-label`) directly to the form control. For more information on migrating from the legacy select syntax, refer to the [Select documentation](https://ionicframework.com/docs/api/select#migrating-from-legacy-select-syntax).
- The `legacy` property and support for the legacy syntax, which involved placing an `ion-select` inside of an `ion-item` with an `ion-label`, have been removed. For more information on migrating from the legacy select syntax, refer to the [Select documentation](https://ionicframework.com/docs/api/select#migrating-from-legacy-select-syntax).
<h4 id="version-8x-textarea">Textarea</h4>

View File

@@ -40,7 +40,7 @@ const testAriaButton = async (
await expect(actionSheetButton).toHaveAttribute('aria-label', expectedAriaLabel);
};
configs({ directions: ['ltr'], palettes: ['dark', 'light'] }).forEach(({ config, title }) => {
configs({ directions: ['ltr'], themes: ['dark', 'light'] }).forEach(({ config, title }) => {
test.describe(title('action-sheet: Axe testing'), () => {
test('should not have accessibility violations when header is defined', async ({ page }) => {
await page.setContent(

View File

@@ -28,7 +28,7 @@ const testAria = async (
expect(ariaDescribedBy).toBe(expectedAriaDescribedBy);
};
configs({ directions: ['ltr'], palettes: ['dark', 'light'] }).forEach(({ title, config }) => {
configs({ directions: ['ltr'], themes: ['dark', 'light'] }).forEach(({ title, config }) => {
test.describe(title('alert: Axe testing'), () => {
test('should not have accessibility violations when header and message are defined', async ({ page }) => {
await page.setContent(

View File

@@ -100,7 +100,7 @@ configs().forEach(({ config, screenshot, title }) => {
});
});
configs({ palettes: ['light', 'dark'] }).forEach(({ config, screenshot, title }) => {
configs({ themes: ['light', 'dark'] }).forEach(({ config, screenshot, title }) => {
test.describe(title('should not have visual regressions'), () => {
test('more than two buttons', async ({ page }) => {
await page.setContent(

View File

@@ -5,7 +5,7 @@ import { configs, test } from '@utils/test/playwright';
/**
* Only ios mode uses ion-color() for the back button
*/
configs({ directions: ['ltr'], modes: ['ios'], palettes: ['light', 'dark'] }).forEach(({ title, config }) => {
configs({ directions: ['ltr'], modes: ['ios'], themes: ['light', 'dark'] }).forEach(({ title, config }) => {
test.describe(title('back-button: a11y for ion-color()'), () => {
test('should not have accessibility violations', async ({ page }) => {
await page.setContent(

View File

@@ -25,7 +25,7 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
});
});
configs({ directions: ['ltr'], palettes: ['light', 'dark'] }).forEach(({ config, title }) => {
configs({ directions: ['ltr'], themes: ['light', 'dark'] }).forEach(({ config, title }) => {
test.describe(title('badge: a11y'), () => {
test('should not have accessibility violations', async ({ page }) => {
/**

View File

@@ -2,7 +2,7 @@ import AxeBuilder from '@axe-core/playwright';
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
configs({ directions: ['ltr'], palettes: ['light', 'dark'] }).forEach(({ title, config }) => {
configs({ directions: ['ltr'], themes: ['light', 'dark'] }).forEach(({ title, config }) => {
test.describe(title('button: a11y for ion-color()'), () => {
test('should not have accessibility violations', async ({ page }) => {
await page.setContent(
@@ -52,7 +52,7 @@ configs({ directions: ['ltr'], palettes: ['light', 'dark'] }).forEach(({ title,
/**
* Only ios mode uses ion-color() for the activated button state
*/
configs({ directions: ['ltr'], modes: ['ios'], palettes: ['light', 'dark'] }).forEach(({ title, config }) => {
configs({ directions: ['ltr'], modes: ['ios'], themes: ['light', 'dark'] }).forEach(({ title, config }) => {
test.describe(title('button: ios contrast'), () => {
test('activated state should not have accessibility violations', async ({ page }) => {
await page.setContent(

View File

@@ -2,7 +2,7 @@ import AxeBuilder from '@axe-core/playwright';
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
configs({ directions: ['ltr'], palettes: ['light', 'dark'] }).forEach(({ title, config }) => {
configs({ directions: ['ltr'], themes: ['light', 'dark'] }).forEach(({ title, config }) => {
test.describe(title('checkbox: a11y'), () => {
test('should not have accessibility violations', async ({ page }) => {
await page.setContent(

View File

@@ -2,7 +2,7 @@ import AxeBuilder from '@axe-core/playwright';
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
configs({ directions: ['ltr'], palettes: ['light', 'dark'] }).forEach(({ title, config }) => {
configs({ directions: ['ltr'], themes: ['light', 'dark'] }).forEach(({ title, config }) => {
test.describe(title('fab-button: a11y'), () => {
test('should not have accessibility violations', async ({ page }) => {
await page.setContent(

View File

@@ -2,7 +2,7 @@ import AxeBuilder from '@axe-core/playwright';
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
configs({ directions: ['ltr'], palettes: ['light', 'dark'] }).forEach(({ title, config }) => {
configs({ directions: ['ltr'], themes: ['light', 'dark'] }).forEach(({ title, config }) => {
test.describe(title('input: a11y'), () => {
test('default layout should not have accessibility violations', async ({ page }) => {
await page.setContent(

View File

@@ -4,7 +4,7 @@ import { configs, test } from '@utils/test/playwright';
import { testSlidingItem } from '../test.utils';
configs({ directions: ['ltr'], palettes: ['light', 'dark'] }).forEach(({ title, config }) => {
configs({ directions: ['ltr'], themes: ['light', 'dark'] }).forEach(({ title, config }) => {
test.describe(title('item-sliding: a11y for ion-color()'), () => {
test('should not have accessibility violations', async ({ page }) => {
await page.setContent(

View File

@@ -103,6 +103,15 @@
@include margin($item-ios-icon-slot-margin-top, $item-ios-icon-slot-margin-end, $item-ios-icon-slot-margin-bottom, $item-ios-icon-slot-margin-start);
}
// iOS Slotted Toggle
// --------------------------------------------------
::slotted(ion-toggle[slot="start"]),
::slotted(ion-toggle[slot="end"]) {
@include margin(0);
}
// iOS Stacked / Floating Labels
// --------------------------------------------------
@@ -148,6 +157,16 @@
@include margin(($item-ios-padding-end * 0.5));
}
// iOS Radio / Toggle Item Label
// -----------------------------------------
:host(.item-radio) ::slotted(ion-label),
:host(.item-toggle) ::slotted(ion-label) {
@include margin-horizontal(0px, null);
}
// iOS Slotted Label
// --------------------------------------------------

View File

@@ -122,6 +122,15 @@
@include margin-horizontal($item-md-icon-end-slot-margin-start, $item-md-icon-end-slot-margin-end);
}
// Material Design Slotted Toggle
// --------------------------------------------------
::slotted(ion-toggle[slot="start"]),
::slotted(ion-toggle[slot="end"]) {
@include margin(0);
}
// Material Design Slotted Note
// --------------------------------------------------
@@ -186,6 +195,14 @@
@include margin($item-md-label-slot-end-margin-top, $item-md-label-slot-end-margin-end, $item-md-label-slot-end-margin-bottom, $item-md-label-slot-end-margin-start);
}
// Material Design Toggle/Radio Item
// --------------------------------------------------
:host(.item-toggle) ::slotted(ion-label),
:host(.item-radio) ::slotted(ion-label) {
@include margin-horizontal(0, null);
}
// Material Design Item Button
// --------------------------------------------------
@@ -209,6 +226,8 @@
}
:host(.ion-focused:not(.ion-color)) ::slotted(.label-stacked),
:host(.ion-focused:not(.ion-color)) ::slotted(.label-floating) {
:host(.ion-focused:not(.ion-color)) ::slotted(.label-floating),
:host(.item-has-focus:not(.ion-color)) ::slotted(.label-stacked),
:host(.item-has-focus:not(.ion-color)) ::slotted(.label-floating) {
color: $label-md-text-color-focused;
}

View File

@@ -347,6 +347,13 @@ a {
max-width: 100%;
}
// Item Input
// --------------------------------------------------
:host(.item-input) {
align-items: center;
}
.input-wrapper {
display: flex;
@@ -388,6 +395,13 @@ a {
position: relative;
}
// Item Textarea
// --------------------------------------------------
:host(.item-textarea) {
align-items: stretch;
}
// Item Reorder
// --------------------------------------------------

View File

@@ -27,6 +27,21 @@
transition: transform 150ms ease-in-out;
}
:host-context(.item-textarea).label-floating {
@include transform(translate(0, 28px));
}
:host-context(.item-has-focus).label-stacked,
:host-context(.item-has-focus).label-floating {
color: $label-ios-text-color-focused;
}
:host-context(.item-has-focus).label-floating,
:host-context(.item-has-placeholder:not(.item-input)).label-floating,
:host-context(.item-has-value).label-floating {
@include transform(scale(0.82));
}
// iOS Typography
// --------------------------------------------------

View File

@@ -45,20 +45,32 @@
transform 150ms $label-md-transition-timing-function;
}
:host-context(.ion-focused).label-floating {
:host-context(.ion-focused).label-floating,
:host-context(.item-has-focus).label-floating,
:host-context(.item-has-placeholder:not(.item-input)).label-floating,
:host-context(.item-has-value).label-floating {
@include transform(translateY(50%), scale(.75));
}
:host-context(.ion-focused).label-stacked:not(.ion-color),
:host-context(.ion-focused).label-floating:not(.ion-color) {
:host-context(.ion-focused).label-floating:not(.ion-color),
:host-context(.item-has-focus).label-stacked:not(.ion-color),
:host-context(.item-has-focus).label-floating:not(.ion-color) {
color: $label-md-text-color-focused;
}
:host-context(.ion-focused.ion-color).label-stacked:not(.ion-color),
:host-context(.ion-focused.ion-color).label-floating:not(.ion-color) {
:host-context(.ion-focused.ion-color).label-floating:not(.ion-color),
:host-context(.item-has-focus.ion-color).label-stacked:not(.ion-color),
:host-context(.item-has-focus.ion-color).label-floating:not(.ion-color) {
color: #{current-color(contrast)};
}
:host-context(.ion-invalid.ion-touched).label-stacked:not(.ion-color),
:host-context(.ion-invalid.ion-touched).label-floating:not(.ion-color) {
color: var(--highlight-color-invalid);
}
// MD Typography
// --------------------------------------------------

View File

@@ -36,6 +36,17 @@
pointer-events: none;
}
:host-context(.item-input) {
flex: initial;
max-width: 200px;
pointer-events: none;
}
:host-context(.item-textarea) {
align-self: baseline;
}
// Overflow hidden is required for the proper
// margins between skeleton text elements
:host-context(.item-skeleton-text) {

View File

@@ -84,7 +84,7 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
});
});
configs({ directions: ['ltr'], modes: ['md'], palettes: ['light', 'dark'] }).forEach(({ title, config }) => {
configs({ directions: ['ltr'], modes: ['md'], themes: ['light', 'dark'] }).forEach(({ title, config }) => {
test.describe(title('label: a11y for ion-color()'), () => {
test('should not have accessibility violations when focused', async ({ page }) => {
/**

View File

@@ -2,7 +2,7 @@ import AxeBuilder from '@axe-core/playwright';
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
configs({ modes: ['ios'], directions: ['ltr'], palettes: ['light', 'dark'] }).forEach(({ title, config }) => {
configs({ modes: ['ios'], directions: ['ltr'], themes: ['light', 'dark'] }).forEach(({ title, config }) => {
test.describe(title('loading: a11y'), () => {
test('should set aria-labelledby with a message', async ({ page }) => {
await page.setContent(

View File

@@ -5,7 +5,7 @@ import { configs, test } from '@utils/test/playwright';
/**
* Only ios mode uses ion-color() for the menu button
*/
configs({ directions: ['ltr'], modes: ['ios'], palettes: ['light', 'dark'] }).forEach(({ title, config }) => {
configs({ directions: ['ltr'], modes: ['ios'], themes: ['light', 'dark'] }).forEach(({ title, config }) => {
test.describe(title('menu-button: a11y for ion-color()'), () => {
test('should not have accessibility violations', async ({ page }) => {
await page.setContent(

View File

@@ -2,7 +2,7 @@ import AxeBuilder from '@axe-core/playwright';
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
configs({ directions: ['ltr'], palettes: ['light', 'dark'] }).forEach(({ title, config }) => {
configs({ directions: ['ltr'], themes: ['light', 'dark'] }).forEach(({ title, config }) => {
test.describe(title('progress-bar: a11y'), () => {
test('should not have accessibility violations', async ({ page }) => {
/**

View File

@@ -2,7 +2,7 @@ import AxeBuilder from '@axe-core/playwright';
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
configs({ directions: ['ltr'], palettes: ['light', 'dark'] }).forEach(({ title, config }) => {
configs({ directions: ['ltr'], themes: ['light', 'dark'] }).forEach(({ title, config }) => {
test.describe(title('radio: a11y'), () => {
test('default layout should not have accessibility violations', async ({ page }) => {
await page.setContent(

View File

@@ -2,7 +2,7 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Component, Element, Event, Host, Prop, State, Watch, h } from '@stencil/core';
import { findClosestIonContent, disableContentScrollY, resetContentScrollY } from '@utils/content';
import type { Attributes } from '@utils/helpers';
import { inheritAriaAttributes, clamp, debounceEvent, renderHiddenInput } from '@utils/helpers';
import { inheritAriaAttributes, clamp, debounceEvent, getAriaLabel, renderHiddenInput } from '@utils/helpers';
import { printIonWarning } from '@utils/logging';
import { isRTL } from '@utils/rtl';
import { createColorClasses, hostContext } from '@utils/theme';
@@ -624,16 +624,28 @@ export class Range implements ComponentInterface {
min,
max,
step,
el,
handleKeyboard,
pressedKnob,
disabled,
pin,
ratioLower,
ratioUpper,
pinFormatter,
inheritedAttributes,
rangeId,
pinFormatter,
} = this;
/**
* Look for external label, ion-label, or aria-labelledby.
* If none, see if user placed an aria-label on the host
* and use that instead.
*/
let { labelText } = getAriaLabel(el, rangeId!);
if (labelText === undefined || labelText === null) {
labelText = inheritedAttributes['aria-label'];
}
let barStart = `${ratioLower * 100}%`;
let barEnd = `${100 - ratioUpper * 100}%`;
@@ -703,6 +715,11 @@ export class Range implements ComponentInterface {
}
}
let labelledBy: string | undefined;
if (this.hasLabel) {
labelledBy = 'range-label';
}
return (
<div
class="range-slider"
@@ -774,7 +791,8 @@ export class Range implements ComponentInterface {
handleKeyboard,
min,
max,
inheritedAttributes,
labelText,
labelledBy,
})}
{this.dualKnobs &&
@@ -789,7 +807,8 @@ export class Range implements ComponentInterface {
handleKeyboard,
min,
max,
inheritedAttributes,
labelText,
labelledBy,
})}
</div>
);
@@ -868,13 +887,27 @@ interface RangeKnob {
pressed: boolean;
pin: boolean;
pinFormatter: PinFormatter;
inheritedAttributes: Attributes;
labelText?: string | null;
labelledBy?: string;
handleKeyboard: (name: KnobName, isIncrease: boolean) => void;
}
const renderKnob = (
rtl: boolean,
{ knob, value, ratio, min, max, disabled, pressed, pin, handleKeyboard, pinFormatter, inheritedAttributes }: RangeKnob
{
knob,
value,
ratio,
min,
max,
disabled,
pressed,
pin,
handleKeyboard,
labelText,
labelledBy,
pinFormatter,
}: RangeKnob
) => {
const start = rtl ? 'right' : 'left';
@@ -886,9 +919,6 @@ const renderKnob = (
return style;
};
// The aria label should be preferred over visible text if both are specified
const ariaLabel = inheritedAttributes['aria-label'];
return (
<div
onKeyDown={(ev: KeyboardEvent) => {
@@ -916,8 +946,8 @@ const renderKnob = (
style={knobStyle()}
role="slider"
tabindex={disabled ? -1 : 0}
aria-label={ariaLabel !== undefined ? ariaLabel : null}
aria-labelledby={ariaLabel === undefined ? 'range-label' : null}
aria-label={labelledBy === undefined ? labelText : null}
aria-labelledby={labelledBy !== undefined ? labelledBy : null}
aria-valuemin={min}
aria-valuemax={max}
aria-disabled={disabled ? 'true' : null}

View File

@@ -2,7 +2,7 @@ import AxeBuilder from '@axe-core/playwright';
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
configs({ directions: ['ltr'], palettes: ['light', 'dark'] }).forEach(({ title, config }) => {
configs({ directions: ['ltr'], themes: ['light', 'dark'] }).forEach(({ title, config }) => {
test.describe(title('range: a11y'), () => {
test('should not have accessibility violations', async ({ page }) => {
await page.setContent(

View File

@@ -20,34 +20,4 @@ describe('range: label', () => {
expect(propEl).not.toBeNull();
expect(slotEl).toBeNull();
});
it('should prefer aria label if both attribute and visible text provided', async () => {
const page = await newSpecPage({
components: [Range],
html: `
<ion-range aria-label="Aria Label Text" label="Label Prop Text"></ion-range>
`,
});
const range = page.body.querySelector('ion-range')!;
const nativeSlider = range.shadowRoot!.querySelector('.range-knob-handle')!;
expect(nativeSlider.getAttribute('aria-label')).toBe('Aria Label Text');
expect(nativeSlider.getAttribute('aria-labelledby')).toBe(null);
});
it('should prefer visible label if only visible text provided', async () => {
const page = await newSpecPage({
components: [Range],
html: `
<ion-range label="Label Prop Text"></ion-range>
`,
});
const range = page.body.querySelector('ion-range')!;
const nativeSlider = range.shadowRoot!.querySelector('.range-knob-handle')!;
expect(nativeSlider.getAttribute('aria-label')).toBe(null);
expect(nativeSlider.getAttribute('aria-labelledby')).toBe('range-label');
});
});

View File

@@ -2,7 +2,7 @@ import AxeBuilder from '@axe-core/playwright';
import { expect } from '@playwright/test';
import { configs, test, dragElementBy } from '@utils/test/playwright';
configs({ directions: ['ltr'], modes: ['md'], palettes: ['light', 'dark'] }).forEach(({ config, title }) => {
configs({ directions: ['ltr'], modes: ['md'], themes: ['light', 'dark'] }).forEach(({ config, title }) => {
test.describe(title('refresher: a11y for ion-color()'), () => {
test('should not have accessibility violations', async ({ page }) => {
await page.setContent(

View File

@@ -2,7 +2,7 @@ import AxeBuilder from '@axe-core/playwright';
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
configs({ directions: ['ltr'], palettes: ['light', 'dark'] }).forEach(({ title, config }) => {
configs({ directions: ['ltr'], themes: ['light', 'dark'] }).forEach(({ title, config }) => {
test.describe(title('router-link: a11y'), () => {
test('should not have accessibility violations', async ({ page }) => {
/**

View File

@@ -5,7 +5,7 @@ import { configs, test } from '@utils/test/playwright';
/**
* Only md mode uses ion-color() for the segment button
*/
configs({ directions: ['ltr'], modes: ['md'], palettes: ['light', 'dark'] }).forEach(({ title, config }) => {
configs({ directions: ['ltr'], modes: ['md'], themes: ['light', 'dark'] }).forEach(({ title, config }) => {
test.describe(title('segment: a11y for ion-color()'), () => {
test('should not have accessibility violations', async ({ page }) => {
await page.setContent(

View File

@@ -2,7 +2,7 @@ import AxeBuilder from '@axe-core/playwright';
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
configs({ directions: ['ltr'], palettes: ['dark', 'light'] }).forEach(({ config, title }) => {
configs({ directions: ['ltr'], themes: ['dark', 'light'] }).forEach(({ config, title }) => {
test.describe(title('select-popover: a11y'), () => {
test('should not have accessibility violations when header is defined', async ({ page }) => {
await page.setContent(

View File

@@ -2,7 +2,7 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Component, Element, Event, Host, Method, Prop, State, Watch, h, forceUpdate } from '@stencil/core';
import type { NotchController } from '@utils/forms';
import { compareOptions, createNotchController, isOptionSelected } from '@utils/forms';
import { focusVisibleElement, renderHiddenInput, inheritAttributes } from '@utils/helpers';
import { focusVisibleElement, getAriaLabel, renderHiddenInput, inheritAttributes } from '@utils/helpers';
import type { Attributes } from '@utils/helpers';
import { actionSheetController, alertController, popoverController } from '@utils/overlays';
import type { OverlaySelect } from '@utils/overlays-interface';
@@ -874,11 +874,10 @@ export class Select implements ComponentInterface {
}
private get ariaLabel() {
const { placeholder, inheritedAttributes } = this;
const { placeholder, el, inputId, inheritedAttributes } = this;
const displayValue = this.getText();
// The aria label should be preferred over visible text if both are specified
const definedLabel = inheritedAttributes['aria-label'] ?? this.labelText;
const { labelText } = getAriaLabel(el, inputId);
const definedLabel = this.labelText ?? inheritedAttributes['aria-label'] ?? labelText;
/**
* If developer has specified a placeholder

View File

@@ -2,7 +2,7 @@ import AxeBuilder from '@axe-core/playwright';
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
configs({ directions: ['ltr'], palettes: ['light', 'dark'] }).forEach(({ title, config }) => {
configs({ directions: ['ltr'], themes: ['light', 'dark'] }).forEach(({ title, config }) => {
test.describe(title('textarea: a11y'), () => {
test('default layout should not have accessibility violations', async ({ page }) => {
await page.setContent(

View File

@@ -68,34 +68,6 @@ describe('ion-select', () => {
expect(propEl).not.toBe(null);
expect(slotEl).toBe(null);
});
it('should prefer aria label if both attribute and visible text provided', async () => {
const page = await newSpecPage({
components: [Select],
html: `
<ion-select aria-label="Aria Label Text" label="Label Prop Text"></ion-select>
`,
});
const select = page.body.querySelector('ion-select')!;
const nativeButton = select.shadowRoot!.querySelector('button')!;
expect(nativeButton.getAttribute('aria-label')).toBe('Aria Label Text');
});
it('should prefer visible label if only visible text provided', async () => {
const page = await newSpecPage({
components: [Select],
html: `
<ion-select label="Label Prop Text"></ion-select>
`,
});
const select = page.body.querySelector('ion-select')!;
const nativeButton = select.shadowRoot!.querySelector('button')!;
expect(nativeButton.getAttribute('aria-label')).toBe('Label Prop Text');
});
});
describe('select: slot interactivity', () => {

View File

@@ -2,7 +2,7 @@ import AxeBuilder from '@axe-core/playwright';
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
configs({ directions: ['ltr'], palettes: ['light', 'dark'] }).forEach(({ title, config }) => {
configs({ directions: ['ltr'], themes: ['light', 'dark'] }).forEach(({ title, config }) => {
test.describe(title('textarea: a11y'), () => {
test('default layout should not have accessibility violations', async ({ page }) => {
await page.setContent(

View File

@@ -2,7 +2,7 @@ import AxeBuilder from '@axe-core/playwright';
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
configs({ directions: ['ltr'], palettes: ['dark', 'light'] }).forEach(({ title, config }) => {
configs({ directions: ['ltr'], themes: ['dark', 'light'] }).forEach(({ title, config }) => {
test.describe(title('toast: Axe testing'), () => {
test('should not have any axe violations with inline toasts', async ({ page }) => {
await page.setContent(
@@ -234,7 +234,7 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
/**
* High contrast mode tests
*/
configs({ directions: ['ltr'], palettes: ['high-contrast-dark', 'high-contrast'] }).forEach(
configs({ directions: ['ltr'], themes: ['high-contrast-dark', 'high-contrast'] }).forEach(
({ title, config, screenshot }) => {
test.describe(title('toast: high contrast: buttons'), () => {
test.beforeEach(async ({ page }) => {

View File

@@ -2,7 +2,7 @@ import AxeBuilder from '@axe-core/playwright';
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
configs({ directions: ['ltr'], palettes: ['light', 'dark'] }).forEach(({ title, config }) => {
configs({ directions: ['ltr'], themes: ['light', 'dark'] }).forEach(({ title, config }) => {
test.describe(title('toggle: a11y'), () => {
test('should not have accessibility violations', async ({ page }) => {
await page.setContent(

View File

@@ -35,7 +35,7 @@ configs({ directions: ['ltr'], modes: ['ios'] }).forEach(({ title, screenshot, c
});
});
configs({ directions: ['ltr'], palettes: ['light', 'dark'] }).forEach(({ config, title }) => {
configs({ directions: ['ltr'], themes: ['light', 'dark'] }).forEach(({ config, title }) => {
test.describe(title('typography: a11y'), () => {
test('should not have accessibility violations for anchor tags', async ({ page }) => {
/**

View File

@@ -49,7 +49,7 @@ const styleTestHelpers = `
* 6) The base color as the text color against the base color at 0.12 opacity as the background color
* 7) The base color as the text color against the base color at 0.16 opacity as the background color
*/
configs({ modes: ['md'], directions: ['ltr'], palettes: ['light', 'dark'] }).forEach(({ config, title }) => {
configs({ modes: ['md'], directions: ['ltr'], themes: ['light', 'dark'] }).forEach(({ config, title }) => {
const colors = ['primary', 'secondary', 'tertiary', 'success', 'warning', 'danger', 'light', 'medium', 'dark'];
test.describe(title('theme'), () => {
@@ -153,7 +153,7 @@ configs({ modes: ['md'], directions: ['ltr'], palettes: ['light', 'dark'] }).for
});
});
configs({ modes: ['md'], directions: ['ltr'], palettes: ['high-contrast', 'high-contrast-dark'] }).forEach(
configs({ modes: ['md'], directions: ['ltr'], themes: ['high-contrast', 'high-contrast-dark'] }).forEach(
({ config, title }) => {
const colors = ['primary', 'secondary', 'tertiary', 'success', 'warning', 'danger', 'light', 'medium', 'dark'];

View File

@@ -273,6 +273,64 @@ export const focusVisibleElement = (el: HTMLElement) => {
}
};
/**
* This method is used for Ionic's input components that use Shadow DOM. In
* order to properly label the inputs to work with screen readers, we need
* to get the text content of the label outside of the shadow root and pass
* it to the input inside of the shadow root.
*
* Referencing label elements by id from outside of the component is
* impossible due to the shadow boundary, read more here:
* https://developer.salesforce.com/blogs/2020/01/accessibility-for-web-components.html
*
* @param componentEl The shadow element that needs the aria label
* @param inputId The unique identifier for the input
*/
export const getAriaLabel = (
componentEl: HTMLElement,
inputId: string
): { label: Element | null; labelId: string; labelText: string | null | undefined } => {
let labelText;
// If the user provides their own label via the aria-labelledby attr
// we should use that instead of looking for an ion-label
const labelledBy = componentEl.getAttribute('aria-labelledby');
// Grab the id off of the component in case they are using
// a custom label using the label element
const componentId = componentEl.id;
let labelId = labelledBy !== null && labelledBy.trim() !== '' ? labelledBy : inputId + '-lbl';
let label = labelledBy !== null && labelledBy.trim() !== '' ? document.getElementById(labelledBy) : null;
if (label) {
if (labelledBy === null) {
label.id = labelId;
}
labelText = label.textContent;
label.setAttribute('aria-hidden', 'true');
// if there is no label, check to see if the user has provided
// one by setting an id on the component and using the label element
} else if (componentId.trim() !== '') {
label = document.querySelector(`label[for="${componentId}"]`);
if (label) {
if (label.id !== '') {
labelId = label.id;
} else {
label.id = labelId = `${componentId}-lbl`;
}
labelText = label.textContent;
}
}
return { label, labelId, labelText };
};
/**
* This method is used to add a hidden input to a host element that contains
* a Shadow DOM. It does not add the input inside of the Shadow root which

View File

@@ -0,0 +1,98 @@
import { newSpecPage } from '@stencil/core/testing';
import { Item } from '../../components/item/item';
import { Label } from '../../components/label/label';
import { Toggle } from '../../components/toggle/toggle';
import { getAriaLabel } from '../helpers';
// TODO FW-5969
describe.skip('getAriaLabel()', () => {
it('should correctly link component to label', async () => {
const page = await newSpecPage({
components: [Item, Label, Toggle],
html: `
<ion-item>
<ion-label>My Label</ion-label>
<ion-toggle></ion-toggle>
</ion-item>
`,
});
const toggle = page.body.querySelector('ion-toggle')!;
const { label, labelId, labelText } = getAriaLabel(toggle, 'ion-tg-0');
expect(labelText).toEqual('My Label');
expect(labelId).toEqual('ion-tg-0-lbl');
expect(label).toEqual(page.body.querySelector('ion-label'));
});
it('should correctly link component when using custom label', async () => {
const page = await newSpecPage({
components: [Toggle],
html: `
<div id="my-label">Hello World</div>
<ion-toggle legacy="true" aria-labelledby="my-label"></ion-toggle>
`,
});
const toggle = page.body.querySelector('ion-toggle')!;
const { label, labelId, labelText } = getAriaLabel(toggle, 'ion-tg-0');
expect(labelText).toEqual('Hello World');
expect(labelId).toEqual('my-label');
expect(label).toEqual(page.body.querySelector('#my-label'));
});
it('should correctly link component when special characters are used', async () => {
const page = await newSpecPage({
components: [Toggle],
html: `
<div id="id.1">Hello World</div>
<ion-toggle legacy="true" aria-labelledby="id.1"></ion-toggle>
`,
});
const toggle = page.body.querySelector('ion-toggle')!;
const { labelId, labelText } = getAriaLabel(toggle, 'ion-tg-0');
expect(labelText).toEqual('Hello World');
expect(labelId).toEqual('id.1');
});
it('should only set the label id if one was not set already', async () => {
const page = await newSpecPage({
components: [Toggle],
html: `
<label id="my-id" for="id.1">Hello World</label>
<ion-toggle legacy="true" id="id.1"></ion-toggle>
`,
});
const toggle = page.body.querySelector('ion-toggle')!;
const { labelId, labelText } = getAriaLabel(toggle, 'ion-tg-0');
expect(labelText).toEqual('Hello World');
expect(labelId).toEqual('my-id');
});
it('should set label id', async () => {
const page = await newSpecPage({
components: [Toggle],
html: `
<label for="id.1">Hello World</label>
<ion-toggle legacy="true" id="id.1"></ion-toggle>
`,
});
const toggle = page.body.querySelector('ion-toggle')!;
const { labelId, labelText } = getAriaLabel(toggle, 'ion-tg-0');
expect(labelText).toEqual('Hello World');
expect(labelId).toEqual('id.1-lbl');
});
});

View File

@@ -8,7 +8,7 @@ export type Direction = 'ltr' | 'rtl';
* - `high-contrast`: The high contrast light theme values.
* - `high-contrast-dark`: The high contrast dark theme values.
*/
export type Palette = 'light' | 'dark' | 'high-contrast' | 'high-contrast-dark';
export type Theme = 'light' | 'dark' | 'high-contrast' | 'high-contrast-dark';
export type TitleFn = (title: string) => string;
export type ScreenshotFn = (fileName: string) => string;
@@ -16,7 +16,7 @@ export type ScreenshotFn = (fileName: string) => string;
export interface TestConfig {
mode: Mode;
direction: Direction;
palette: Palette;
theme: Theme;
}
interface TestUtilities {
@@ -28,7 +28,7 @@ interface TestUtilities {
interface TestConfigOption {
modes?: Mode[];
directions?: Direction[];
palettes?: Palette[];
themes?: Theme[];
}
/**
@@ -38,19 +38,19 @@ interface TestConfigOption {
* each test title is unique.
*/
const generateTitle = (title: string, config: TestConfig): string => {
const { mode, direction, palette } = config;
const { mode, direction, theme } = config;
if (palette === 'light') {
if (theme === 'light') {
/**
* Ionic has many existing tests that existed prior to
* the introduction of palette testing. To maintain backwards
* compatibility, we will not include the palette in the test
* title if the palette is set to light.
* the introduction of theme testing. To maintain backwards
* compatibility, we will not include the theme in the test
* title if the theme is set to light.
*/
return `${title} - ${mode}/${direction}`;
}
return `${title} - ${mode}/${direction}/${palette}`;
return `${title} - ${mode}/${direction}/${theme}`;
};
/**
@@ -58,19 +58,19 @@ const generateTitle = (title: string, config: TestConfig): string => {
* and a test config.
*/
const generateScreenshotName = (fileName: string, config: TestConfig): string => {
const { mode, direction, palette } = config;
const { mode, direction, theme } = config;
if (palette === 'light') {
if (theme === 'light') {
/**
* Ionic has many existing tests that existed prior to
* the introduction of palette testing. To maintain backwards
* compatibility, we will not include the palette in the screenshot
* name if the palette is set to light.
* the introduction of theme testing. To maintain backwards
* compatibility, we will not include the theme in the screenshot
* name if the theme is set to light.
*/
return `${fileName}-${mode}-${direction}.png`;
}
return `${fileName}-${mode}-${direction}-${palette}.png`;
return `${fileName}-${mode}-${direction}-${theme}.png`;
};
/**
@@ -87,12 +87,12 @@ export const configs = (testConfig: TestConfigOption = DEFAULT_TEST_CONFIG_OPTIO
*/
const processedMode = modes ?? DEFAULT_MODES;
const processedDirection = directions ?? DEFAULT_DIRECTIONS;
const processedPalette = testConfig.palettes ?? DEFAULT_PALETTES;
const processedTheme = testConfig.themes ?? DEFAULT_THEMES;
processedMode.forEach((mode) => {
processedDirection.forEach((direction) => {
processedPalette.forEach((palette) => {
configs.push({ mode, direction, palette });
processedTheme.forEach((theme) => {
configs.push({ mode, direction, theme });
});
});
});
@@ -108,10 +108,10 @@ export const configs = (testConfig: TestConfigOption = DEFAULT_TEST_CONFIG_OPTIO
const DEFAULT_MODES: Mode[] = ['ios', 'md'];
const DEFAULT_DIRECTIONS: Direction[] = ['ltr', 'rtl'];
const DEFAULT_PALETTES: Palette[] = ['light'];
const DEFAULT_THEMES: Theme[] = ['light'];
const DEFAULT_TEST_CONFIG_OPTION = {
modes: DEFAULT_MODES,
directions: DEFAULT_DIRECTIONS,
palettes: DEFAULT_PALETTES,
themes: DEFAULT_THEMES,
};

View File

@@ -1,5 +1,5 @@
import type { Page, TestInfo } from '@playwright/test';
import type { E2EPageOptions, Mode, Direction, Palette } from '@utils/test/playwright';
import type { E2EPageOptions, Mode, Direction, Theme } from '@utils/test/playwright';
/**
* Overwrites the default Playwright page.setContent method.
@@ -19,16 +19,16 @@ export const setContent = async (page: Page, html: string, testInfo: TestInfo, o
let mode: Mode;
let direction: Direction;
let palette: Palette;
let theme: Theme;
if (options == undefined) {
mode = testInfo.project.metadata.mode;
direction = testInfo.project.metadata.rtl ? 'rtl' : 'ltr';
palette = testInfo.project.metadata.palette;
theme = testInfo.project.metadata.theme;
} else {
mode = options.mode;
direction = options.direction;
palette = options.palette;
theme = options.theme;
}
const baseUrl = process.env.PLAYWRIGHT_TEST_BASE_URL;
@@ -42,7 +42,7 @@ export const setContent = async (page: Page, html: string, testInfo: TestInfo, o
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0" />
<link href="${baseUrl}/css/ionic.bundle.css" rel="stylesheet" />
<link href="${baseUrl}/scripts/testing/styles.css" rel="stylesheet" />
${palette !== 'light' ? `<link href="${baseUrl}/css/themes/${palette}.always.css" rel="stylesheet" />` : ''}
${theme !== 'light' ? `<link href="${baseUrl}/css/themes/${theme}.always.css" rel="stylesheet" />` : ''}
<script src="${baseUrl}/scripts/testing/scripts.js"></script>
<script type="module" src="${baseUrl}/dist/ionic/ionic.esm.js"></script>
<script>
@@ -60,8 +60,8 @@ export const setContent = async (page: Page, html: string, testInfo: TestInfo, o
`;
testInfo.annotations.push({
type: 'palette',
description: palette,
type: 'theme',
description: theme,
});
if (baseUrl) {

View File

@@ -8,14 +8,18 @@ import type { ReactComponentOrElement } from '../models/ReactComponentOrElement'
import type { HookOverlayOptions } from './HookOverlayOptions';
import { useOverlay } from './useOverlay';
// TODO(FW-2959): types
/**
* A hook for presenting/dismissing an IonModal component
* @param component The component that the modal will show. Can be a React Component, a functional component, or a JSX Element
* @param componentProps The props that will be passed to the component, if required
* @returns Returns the present and dismiss methods in an array
*/
export function useIonModal(component: JSX.Element, componentProps?: any): UseIonModalResult;
export function useIonModal<P extends undefined>(component: React.ComponentClass<P> | React.FC<P>): UseIonModalResult;
export function useIonModal<P extends Record<string, never>>(
component: React.ComponentClass<P> | React.FC<P>
): UseIonModalResult;
export function useIonModal<P>(component: React.ComponentClass<P> | React.FC<P>, componentProps: P): UseIonModalResult;
export function useIonModal(component: ReactComponentOrElement, componentProps?: any): UseIonModalResult {
const controller = useOverlay<ModalOptions, HTMLIonModalElement>(
'IonModal',

View File

@@ -8,14 +8,23 @@ import type { ReactComponentOrElement } from '../models/ReactComponentOrElement'
import type { HookOverlayOptions } from './HookOverlayOptions';
import { useOverlay } from './useOverlay';
// TODO(FW-2959): types
/**
* A hook for presenting/dismissing an IonPicker component
* @param component The component that the popover will show. Can be a React Component, a functional component, or a JSX Element
* @param componentProps The props that will be passed to the component, if required
* @returns Returns the present and dismiss methods in an array
*/
export function useIonPopover(component: JSX.Element, componentProps?: any): UseIonPopoverResult;
export function useIonPopover<P extends undefined>(
component: React.ComponentClass<P> | React.FC<P>
): UseIonPopoverResult;
export function useIonPopover<P extends Record<string, never>>(
component: React.ComponentClass<P> | React.FC<P>
): UseIonPopoverResult;
export function useIonPopover<P>(
component: React.ComponentClass<P> | React.FC<P>,
componentProps: P
): UseIonPopoverResult;
export function useIonPopover(component: ReactComponentOrElement, componentProps?: any): UseIonPopoverResult {
const controller = useOverlay<PopoverOptions, HTMLIonPopoverElement>(
'IonPopover',

View File

@@ -11,10 +11,10 @@ import {
import { useContext } from 'react';
const Body: React.FC<{
type: string;
count: number;
type?: string;
count?: number;
onDismiss: (data?: any, role?: string) => void;
onIncrement: () => void;
onIncrement?: () => void;
}> = ({ count, onDismiss, onIncrement, type }) => (
<IonPage>
<IonHeader>
@@ -24,7 +24,7 @@ const Body: React.FC<{
</IonHeader>
<IonContent>
Count in modal: {count}
<IonButton expand="block" onClick={() => onIncrement()}>
<IonButton expand="block" onClick={() => onIncrement?.()}>
Increment Count
</IonButton>
<IonButton expand="block" onClick={() => onDismiss({ test: true }, 'close')}>
@@ -51,7 +51,7 @@ const ModalHook: React.FC = () => {
setCount(count + 1);
}, [count, setCount]);
const handleDismissWithComponent = useCallback((data: any, role: string) => {
const handleDismissWithComponent = useCallback((data?: any, role?: string) => {
dismissWithComponent(data, role);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);