mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2026-03-13 10:22:08 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2918e7324 |
130
.github/COMPONENT-GUIDE.md
vendored
130
.github/COMPONENT-GUIDE.md
vendored
@@ -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`:
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }) => {
|
||||
/**
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -14,14 +14,6 @@
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
// Native Text Input
|
||||
// --------------------------------------------------
|
||||
|
||||
:host(.input-label-placement-floating) .native-input,
|
||||
:host(.input-label-placement-stacked) .native-input {
|
||||
padding-top: 1em;
|
||||
}
|
||||
|
||||
// Input - Disabled
|
||||
// ----------------------------------------------------------------
|
||||
// The input, label, helper text, char counter and placeholder
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
* This makes the label sit above the input.
|
||||
*/
|
||||
:host(.label-floating.input-fill-outline) .label-text-wrapper {
|
||||
@include transform(translate(var(--start-slot-adjustment), -32%), scale(#{$form-control-label-stacked-scale}));
|
||||
@include transform(translateY(-32%), scale(#{$form-control-label-stacked-scale}));
|
||||
@include margin(0);
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,14 +20,6 @@
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
// Native Text Input
|
||||
// --------------------------------------------------
|
||||
|
||||
:host(.input-label-placement-floating:not(.input-fill-outline)) .native-input,
|
||||
:host(.input-label-placement-stacked:not(.input-fill-outline)) .native-input {
|
||||
padding-top: 1em;
|
||||
}
|
||||
|
||||
// Input - Disabled
|
||||
// ----------------------------------------------------------------
|
||||
// The input, label, helper text, char counter and placeholder
|
||||
|
||||
@@ -238,7 +238,7 @@
|
||||
|
||||
flex-grow: 1;
|
||||
|
||||
align-items: center;
|
||||
align-items: stretch;
|
||||
|
||||
height: inherit;
|
||||
|
||||
@@ -261,6 +261,9 @@
|
||||
|
||||
flex-grow: 1;
|
||||
|
||||
// ensure start/end slot content is vertically centered
|
||||
align-items: center;
|
||||
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -523,8 +526,8 @@
|
||||
*/
|
||||
:host(.input-label-placement-stacked) .input-wrapper,
|
||||
:host(.input-label-placement-floating) .input-wrapper {
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -544,8 +547,6 @@
|
||||
* autofill background too.
|
||||
*/
|
||||
z-index: 2;
|
||||
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -595,12 +596,6 @@
|
||||
// Start/End Slots
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
::slotted([slot="start"]),
|
||||
::slotted([slot="end"]) {
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
::slotted([slot="start"]) {
|
||||
margin-inline-end: $form-control-label-margin;
|
||||
margin-inline-start: 0;
|
||||
|
||||
@@ -626,7 +626,7 @@ export class Input implements ComponentInterface {
|
||||
* Renders the border container
|
||||
* when fill="outline".
|
||||
*/
|
||||
private renderOutlineLabelContainer() {
|
||||
private renderLabelContainer() {
|
||||
const mode = getIonMode(this);
|
||||
const hasOutlineFill = mode === 'md' && this.fill === 'outline';
|
||||
|
||||
@@ -653,10 +653,15 @@ export class Input implements ComponentInterface {
|
||||
</div>
|
||||
<div class="input-outline-end"></div>
|
||||
</div>,
|
||||
this.renderLabel(),
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
/**
|
||||
* If not using the outline style,
|
||||
* we can render just the label.
|
||||
*/
|
||||
return this.renderLabel();
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -667,20 +672,27 @@ export class Input implements ComponentInterface {
|
||||
const shouldRenderHighlight = mode === 'md' && fill !== 'outline' && !inItem;
|
||||
|
||||
const hasValue = this.hasValue();
|
||||
const hasStartEndSlots = el.querySelector('[slot="start"], [slot="end"]') !== null;
|
||||
|
||||
/**
|
||||
* If the label is stacked, it should always sit above the input.
|
||||
* For floating labels, the label should move above the input if
|
||||
* the input has a value, is focused, or has anything in either
|
||||
* the start or end slot.
|
||||
*
|
||||
* If there is content in the start slot, the label would overlap
|
||||
* it if not forced to float. This is also applied to the end slot
|
||||
* because with the default or solid fills, the input is not
|
||||
* vertically centered in the container, but the label is. This
|
||||
* causes the slots and label to appear vertically offset from each
|
||||
* other when the label isn't floating above the input. This doesn't
|
||||
* apply to the outline fill, but this was not accounted for to keep
|
||||
* things consistent.
|
||||
*
|
||||
* TODO(FW-5592): Remove hasStartEndSlots condition
|
||||
*/
|
||||
const labelShouldFloat =
|
||||
labelPlacement === 'stacked' || (labelPlacement === 'floating' && (hasValue || hasFocus));
|
||||
|
||||
const startSlotEl = el.querySelector('[slot="start"]');
|
||||
|
||||
// 16px is the margin after the start slot content, which we also need to account for.
|
||||
const startSlotWidth = startSlotEl ? startSlotEl.clientWidth + 16 : 0;
|
||||
labelPlacement === 'stacked' || (labelPlacement === 'floating' && (hasValue || hasFocus || hasStartEndSlots));
|
||||
|
||||
return (
|
||||
<Host
|
||||
@@ -696,9 +708,6 @@ export class Input implements ComponentInterface {
|
||||
'in-item-color': hostContext('ion-item.ion-color', this.el),
|
||||
'input-disabled': disabled,
|
||||
})}
|
||||
style={{
|
||||
'--start-slot-adjustment': `-${startSlotWidth}px`
|
||||
}}
|
||||
>
|
||||
{/**
|
||||
* htmlFor is needed so that clicking the label always focuses
|
||||
@@ -707,10 +716,9 @@ export class Input implements ComponentInterface {
|
||||
* since it comes before the input in the DOM.
|
||||
*/}
|
||||
<label class="input-wrapper" htmlFor={inputId}>
|
||||
<slot name="start"></slot>
|
||||
{this.renderOutlineLabelContainer()}
|
||||
{this.renderLabelContainer()}
|
||||
<div class="native-wrapper">
|
||||
{this.renderLabel()}
|
||||
<slot name="start"></slot>
|
||||
<input
|
||||
class="native-input"
|
||||
ref={(input) => (this.nativeInput = input)}
|
||||
@@ -763,8 +771,8 @@ export class Input implements ComponentInterface {
|
||||
<ion-icon aria-hidden="true" icon={mode === 'ios' ? closeCircle : closeSharp}></ion-icon>
|
||||
</button>
|
||||
)}
|
||||
<slot name="end"></slot>
|
||||
</div>
|
||||
<slot name="end"></slot>
|
||||
{shouldRenderHighlight && <div class="input-highlight"></div>}
|
||||
</label>
|
||||
{this.renderBottomContent()}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }) => {
|
||||
/**
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }) => {
|
||||
/**
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }) => {
|
||||
/**
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -229,7 +229,7 @@ button {
|
||||
transition: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.select-wrapper-inner, .select-wrapper-inner-start {
|
||||
.select-wrapper-inner {
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
@@ -508,12 +508,26 @@ button {
|
||||
* The placeholder should be hidden when the label
|
||||
* is on top of the select. This prevents the label
|
||||
* from overlapping any placeholder value.
|
||||
*
|
||||
* TODO(FW-5592): Remove :not(.label-floating) piece
|
||||
*/
|
||||
:host(.select-label-placement-floating) .native-wrapper .select-placeholder {
|
||||
:host(.select-label-placement-floating:not(.label-floating)) .native-wrapper .select-placeholder {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
:host(.label-floating.select-label-placement-floating) .native-wrapper .select-placeholder {
|
||||
/**
|
||||
* We don't use .label-floating here because that would
|
||||
* also include the case where the label is floating due
|
||||
* to content in the start/end slot. We want the opacity
|
||||
* to remain at the default in this case, since the select
|
||||
* isn't being actively interacted with.
|
||||
*
|
||||
* TODO(FW-5592): Change entire selector to:
|
||||
* :host(.label-floating.select-label-placement-floating) .native-wrapper .select-placeholder
|
||||
*/
|
||||
:host(.select-expanded.select-label-placement-floating) .native-wrapper .select-placeholder,
|
||||
:host(.ion-focused.select-label-placement-floating) .native-wrapper .select-placeholder,
|
||||
:host(.has-value.select-label-placement-floating) .native-wrapper .select-placeholder {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -785,7 +785,7 @@ export class Select implements ComponentInterface {
|
||||
* Renders the border container
|
||||
* when fill="outline".
|
||||
*/
|
||||
private renderOutlineLabelContainer() {
|
||||
private renderLabelContainer() {
|
||||
const mode = getIonMode(this);
|
||||
const hasOutlineFill = mode === 'md' && this.fill === 'outline';
|
||||
|
||||
@@ -811,11 +811,16 @@ export class Select implements ComponentInterface {
|
||||
</div>
|
||||
</div>
|
||||
<div class="select-outline-end"></div>
|
||||
</div>
|
||||
</div>,
|
||||
this.renderLabel(),
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
/**
|
||||
* If not using the outline style,
|
||||
* we can render just the label.
|
||||
*/
|
||||
return this.renderLabel();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -869,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
|
||||
@@ -927,6 +931,7 @@ export class Select implements ComponentInterface {
|
||||
const shouldRenderHighlight = mode === 'md' && fill !== 'outline' && !inItem;
|
||||
|
||||
const hasValue = this.hasValue();
|
||||
const hasStartEndSlots = el.querySelector('[slot="start"], [slot="end"]') !== null;
|
||||
|
||||
renderHiddenInput(true, el, name, parseValue(value), disabled);
|
||||
|
||||
@@ -935,12 +940,20 @@ export class Select implements ComponentInterface {
|
||||
* For floating labels, the label should move above the select if
|
||||
* the select has a value, is open, or has anything in either
|
||||
* the start or end slot.
|
||||
*
|
||||
* If there is content in the start slot, the label would overlap
|
||||
* it if not forced to float. This is also applied to the end slot
|
||||
* because with the default or solid fills, the select is not
|
||||
* vertically centered in the container, but the label is. This
|
||||
* causes the slots and label to appear vertically offset from each
|
||||
* other when the label isn't floating above the input. This doesn't
|
||||
* apply to the outline fill, but this was not accounted for to keep
|
||||
* things consistent.
|
||||
*
|
||||
* TODO(FW-5592): Remove hasStartEndSlots condition
|
||||
*/
|
||||
const labelShouldFloat =
|
||||
labelPlacement === 'stacked' || (labelPlacement === 'floating' && (hasValue || isExpanded));
|
||||
|
||||
const startSlotEl = el.querySelector('[slot="start"]');
|
||||
const startSlotWidth = startSlotEl ? startSlotEl.clientWidth + 16 : 0;
|
||||
labelPlacement === 'stacked' || (labelPlacement === 'floating' && (hasValue || isExpanded || hasStartEndSlots));
|
||||
|
||||
return (
|
||||
<Host
|
||||
@@ -962,22 +975,11 @@ export class Select implements ComponentInterface {
|
||||
[`select-shape-${shape}`]: shape !== undefined,
|
||||
[`select-label-placement-${labelPlacement}`]: true,
|
||||
})}
|
||||
style={{
|
||||
'--start-slot-adjustment': `-${startSlotWidth}px`
|
||||
}}
|
||||
>
|
||||
<label class="select-wrapper" id="select-label">
|
||||
{/**
|
||||
* Wrapping the start slot and label together ensures
|
||||
* they sit on the same side of the container when using
|
||||
* justify="space-between".
|
||||
*/}
|
||||
<div class="select-wrapper-inner-start">
|
||||
<slot name="start"></slot>
|
||||
{this.renderOutlineLabelContainer()}
|
||||
{this.renderLabel()}
|
||||
</div>
|
||||
{this.renderLabelContainer()}
|
||||
<div class="select-wrapper-inner">
|
||||
<slot name="start"></slot>
|
||||
<div class="native-wrapper" ref={(el) => (this.nativeWrapperEl = el)} part="container">
|
||||
{this.renderSelectText()}
|
||||
{this.renderListbox()}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -17,14 +17,6 @@
|
||||
font-size: $textarea-ios-font-size;
|
||||
}
|
||||
|
||||
// Native Textarea
|
||||
// --------------------------------------------------
|
||||
|
||||
:host(.textarea-label-placement-floating) .native-textarea,
|
||||
:host(.textarea-label-placement-stacked) .native-textarea {
|
||||
padding-top: 1em;
|
||||
}
|
||||
|
||||
// Textarea - Disabled
|
||||
// ----------------------------------------------------------------
|
||||
// The textarea, label, helper text, char counter and placeholder
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
* This makes the label sit above the textarea.
|
||||
*/
|
||||
:host(.label-floating.textarea-fill-outline) .label-text-wrapper {
|
||||
@include transform(translate(var(--start-slot-adjustment), -32%), scale(#{$form-control-label-stacked-scale}));
|
||||
@include transform(translateY(-32%), scale(#{$form-control-label-stacked-scale}));
|
||||
@include margin(0);
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,14 +19,6 @@
|
||||
font-size: $textarea-md-font-size;
|
||||
}
|
||||
|
||||
// Native Textarea
|
||||
// --------------------------------------------------
|
||||
|
||||
:host(.textarea-label-placement-floating:not(.textarea-fill-outline)) .native-textarea,
|
||||
:host(.textarea-label-placement-stacked:not(.textarea-fill-outline)) .native-textarea {
|
||||
padding-top: 1em;
|
||||
}
|
||||
|
||||
// Textarea Max Length Counter
|
||||
// ----------------------------------------------------------------
|
||||
.textarea-bottom .counter {
|
||||
|
||||
@@ -589,7 +589,7 @@
|
||||
*/
|
||||
:host(.textarea-label-placement-stacked) .textarea-wrapper,
|
||||
:host(.textarea-label-placement-floating) .textarea-wrapper {
|
||||
flex-direction: row;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@@ -611,8 +611,6 @@
|
||||
* autofill background too.
|
||||
*/
|
||||
z-index: 2;
|
||||
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -630,12 +628,7 @@
|
||||
:host(.textarea-label-placement-stacked) ::slotted([slot="end"]),
|
||||
:host(.textarea-label-placement-floating) ::slotted([slot="start"]),
|
||||
:host(.textarea-label-placement-floating) ::slotted([slot="end"]) {
|
||||
/**
|
||||
* 1em accounts for the padding applied to the native textarea.
|
||||
* This value may need adjusting to get everything lined up
|
||||
* in all conditions.
|
||||
*/
|
||||
margin-top: calc(1em + 8px);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -539,7 +539,7 @@ export class Textarea implements ComponentInterface {
|
||||
/**
|
||||
* Renders the border container when fill="outline".
|
||||
*/
|
||||
private renderOutlineLabelContainer() {
|
||||
private renderLabelContainer() {
|
||||
const mode = getIonMode(this);
|
||||
const hasOutlineFill = mode === 'md' && this.fill === 'outline';
|
||||
|
||||
@@ -566,10 +566,14 @@ export class Textarea implements ComponentInterface {
|
||||
</div>
|
||||
<div class="textarea-outline-end"></div>
|
||||
</div>,
|
||||
this.renderLabel(),
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
/**
|
||||
* If not using the outline style,
|
||||
* we can render just the label.
|
||||
*/
|
||||
return this.renderLabel();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -624,18 +628,27 @@ export class Textarea implements ComponentInterface {
|
||||
const shouldRenderHighlight = mode === 'md' && fill !== 'outline' && !inItem;
|
||||
|
||||
const hasValue = this.hasValue();
|
||||
const hasStartEndSlots = el.querySelector('[slot="start"], [slot="end"]') !== null;
|
||||
|
||||
/**
|
||||
* If the label is stacked, it should always sit above the textarea.
|
||||
* For floating labels, the label should move above the textarea if
|
||||
* the textarea has a value, is focused, or has anything in either
|
||||
* the start or end slot.
|
||||
*
|
||||
* If there is content in the start slot, the label would overlap
|
||||
* it if not forced to float. This is also applied to the end slot
|
||||
* because with the default or solid fills, the textarea is not
|
||||
* vertically centered in the container, but the label is. This
|
||||
* causes the slots and label to appear vertically offset from each
|
||||
* other when the label isn't floating above the input. This doesn't
|
||||
* apply to the outline fill, but this was not accounted for to keep
|
||||
* things consistent.
|
||||
*
|
||||
* TODO(FW-5592): Remove hasStartEndSlots condition
|
||||
*/
|
||||
const labelShouldFloat =
|
||||
labelPlacement === 'stacked' || (labelPlacement === 'floating' && (hasValue || hasFocus));
|
||||
|
||||
const startSlotEl = el.querySelector('[slot="start"]');
|
||||
const startSlotWidth = startSlotEl ? startSlotEl.clientWidth + 16 : 0;
|
||||
labelPlacement === 'stacked' || (labelPlacement === 'floating' && (hasValue || hasFocus || hasStartEndSlots));
|
||||
|
||||
return (
|
||||
<Host
|
||||
@@ -649,9 +662,6 @@ export class Textarea implements ComponentInterface {
|
||||
[`textarea-label-placement-${labelPlacement}`]: true,
|
||||
'textarea-disabled': disabled,
|
||||
})}
|
||||
style={{
|
||||
'--start-slot-adjustment': `-${startSlotWidth}px`
|
||||
}}
|
||||
>
|
||||
{/**
|
||||
* htmlFor is needed so that clicking the label always focuses
|
||||
@@ -660,19 +670,18 @@ export class Textarea implements ComponentInterface {
|
||||
* since it comes before the textarea in the DOM.
|
||||
*/}
|
||||
<label class="textarea-wrapper" htmlFor={inputId}>
|
||||
{/**
|
||||
* Some elements have their own padding styles which may
|
||||
* interfere with slot content alignment (such as icon-
|
||||
* only buttons setting --padding-top=0). To avoid this,
|
||||
* we wrap both the start and end slots in separate
|
||||
* elements and apply our padding styles to that instead.
|
||||
*/}
|
||||
<div class="start-slot-wrapper">
|
||||
<slot name="start"></slot>
|
||||
</div>
|
||||
{this.renderOutlineLabelContainer()}
|
||||
{this.renderLabelContainer()}
|
||||
<div class="textarea-wrapper-inner">
|
||||
{this.renderLabel()}
|
||||
{/**
|
||||
* Some elements have their own padding styles which may
|
||||
* interfere with slot content alignment (such as icon-
|
||||
* only buttons setting --padding-top=0). To avoid this,
|
||||
* we wrap both the start and end slots in separate
|
||||
* elements and apply our padding styles to that instead.
|
||||
*/}
|
||||
<div class="start-slot-wrapper">
|
||||
<slot name="start"></slot>
|
||||
</div>
|
||||
<div class="native-wrapper" ref={(el) => (this.textareaWrapper = el)}>
|
||||
<textarea
|
||||
class="native-textarea"
|
||||
@@ -703,9 +712,9 @@ export class Textarea implements ComponentInterface {
|
||||
{value}
|
||||
</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="end-slot-wrapper">
|
||||
<slot name="end"></slot>
|
||||
<div class="end-slot-wrapper">
|
||||
<slot name="end"></slot>
|
||||
</div>
|
||||
</div>
|
||||
{shouldRenderHighlight && <div class="textarea-highlight"></div>}
|
||||
</label>
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }) => {
|
||||
/**
|
||||
|
||||
@@ -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'];
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
98
core/src/utils/test/aria.spec.ts
Normal file
98
core/src/utils/test/aria.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}, []);
|
||||
|
||||
Reference in New Issue
Block a user