Skip to content

Commit 56014cf

Browse files
authored
fix(range, select): prefer labels passed by developer (#29145)
1 parent b148b32 commit 56014cf

File tree

8 files changed

+122
-281
lines changed

8 files changed

+122
-281
lines changed

.github/COMPONENT-GUIDE.md

Lines changed: 46 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -439,53 +439,38 @@ render() {
439439
440440
#### Labels
441441
442-
A helper function has been created to get the proper `aria-label` for the checkbox. This can be imported as `getAriaLabel` like the following:
442+
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.
443443
444-
```tsx
445-
const { label, labelId, labelText } = getAriaLabel(el, inputId);
446-
```
444+
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.
447445
448-
where `el` and `inputId` are the following:
446+
> [!NOTE]
447+
> Use `inheritAttributes` to specify which attributes should be inherited or `inheritAriaAttributes` to inherit all of the possible `aria` attributes.
449448
450449
```tsx
451-
export class Checkbox implements ComponentInterface {
452-
private inputId = `ion-cb-${checkboxIds++}`;
450+
import { Prop } from '@stencil/core';
451+
import { inheritAttributes } from '@utils/helpers';
452+
import type { Attributes } from '@utils/helpers';
453453

454-
@Element() el!: HTMLElement;
455-
456-
...
457-
}
458-
```
454+
...
459455

460-
This can then be added to the `Host` like the following:
456+
private inheritedAttributes: Attributes = {};
461457

462-
```tsx
463-
<Host
464-
aria-labelledby={label ? labelId : null}
465-
aria-checked={`${checked}`}
466-
aria-hidden={disabled ? 'true' : null}
467-
role="checkbox"
468-
>
469-
```
458+
@Prop() labelText?: string;
470459

471-
In addition to that, the checkbox input should have a label added:
460+
componentWillLoad() {
461+
this.inheritedAttributes = inheritAttributes(this.el, ['aria-label']);
462+
}
472463

473-
```tsx
474-
<Host
475-
aria-labelledby={label ? labelId : null}
476-
aria-checked={`${checked}`}
477-
aria-hidden={disabled ? 'true' : null}
478-
role="checkbox"
479-
>
480-
<label htmlFor={inputId}>
481-
{labelText}
482-
</label>
483-
<input
484-
type="checkbox"
485-
aria-checked={`${checked}`}
486-
disabled={disabled}
487-
id={inputId}
488-
/>
464+
render() {
465+
return (
466+
<Host>
467+
<label>
468+
{this.labelText}
469+
<input type="checkbox" {...this.inheritedAttributes} />
470+
</label>
471+
</Host>
472+
)
473+
}
489474
```
490475
491476
#### Hidden Input
@@ -567,57 +552,40 @@ render() {
567552
568553
#### Labels
569554
570-
A helper function has been created to get the proper `aria-label` for the switch. This can be imported as `getAriaLabel` like the following:
555+
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.
571556
572-
```tsx
573-
const { label, labelId, labelText } = getAriaLabel(el, inputId);
574-
```
557+
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.
575558
576-
where `el` and `inputId` are the following:
559+
> [!NOTE]
560+
> Use `inheritAttributes` to specify which attributes should be inherited or `inheritAriaAttributes` to inherit all of the possible `aria` attributes.
577561
578562
```tsx
579-
export class Toggle implements ComponentInterface {
580-
private inputId = `ion-tg-${toggleIds++}`;
581-
582-
@Element() el!: HTMLElement;
563+
import { Prop } from '@stencil/core';
564+
import { inheritAttributes } from '@utils/helpers';
565+
import type { Attributes } from '@utils/helpers';
583566

584-
...
585-
}
586-
```
567+
...
587568

588-
This can then be added to the `Host` like the following:
569+
private inheritedAttributes: Attributes = {};
589570

590-
```tsx
591-
<Host
592-
aria-labelledby={label ? labelId : null}
593-
aria-checked={`${checked}`}
594-
aria-hidden={disabled ? 'true' : null}
595-
role="switch"
596-
>
597-
```
571+
@Prop() labelText?: string;
598572

599-
In addition to that, the checkbox input should have a label added:
573+
componentWillLoad() {
574+
this.inheritedAttributes = inheritAttributes(this.el, ['aria-label']);
575+
}
600576

601-
```tsx
602-
<Host
603-
aria-labelledby={label ? labelId : null}
604-
aria-checked={`${checked}`}
605-
aria-hidden={disabled ? 'true' : null}
606-
role="switch"
607-
>
608-
<label htmlFor={inputId}>
609-
{labelText}
610-
</label>
611-
<input
612-
type="checkbox"
613-
role="switch"
614-
aria-checked={`${checked}`}
615-
disabled={disabled}
616-
id={inputId}
617-
/>
577+
render() {
578+
return (
579+
<Host>
580+
<label>
581+
{this.labelText}
582+
<input type="checkbox" role="switch" {...this.inheritedAttributes} />
583+
</label>
584+
</Host>
585+
)
586+
}
618587
```
619588
620-
621589
#### Hidden Input
622590
623591
A helper function to render a hidden input has been added, it can be added in the `render`:

BREAKING.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -254,11 +254,11 @@ For more information on styling toast buttons, refer to the [Toast Theming docum
254254

255255
<h4 id="version-8x-range">Range</h4>
256256

257-
- 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).
257+
- 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).
258258

259259
<h4 id="version-8x-select">Select</h4>
260260

261-
- 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).
261+
- 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).
262262

263263
<h4 id="version-8x-textarea">Textarea</h4>
264264

core/src/components/range/range.tsx

Lines changed: 11 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core';
22
import { Component, Element, Event, Host, Prop, State, Watch, h } from '@stencil/core';
33
import { findClosestIonContent, disableContentScrollY, resetContentScrollY } from '@utils/content';
44
import type { Attributes } from '@utils/helpers';
5-
import { inheritAriaAttributes, clamp, debounceEvent, getAriaLabel, renderHiddenInput } from '@utils/helpers';
5+
import { inheritAriaAttributes, clamp, debounceEvent, renderHiddenInput } from '@utils/helpers';
66
import { printIonWarning } from '@utils/logging';
77
import { isRTL } from '@utils/rtl';
88
import { createColorClasses, hostContext } from '@utils/theme';
@@ -624,28 +624,16 @@ export class Range implements ComponentInterface {
624624
min,
625625
max,
626626
step,
627-
el,
628627
handleKeyboard,
629628
pressedKnob,
630629
disabled,
631630
pin,
632631
ratioLower,
633632
ratioUpper,
634-
inheritedAttributes,
635-
rangeId,
636633
pinFormatter,
634+
inheritedAttributes,
637635
} = this;
638636

639-
/**
640-
* Look for external label, ion-label, or aria-labelledby.
641-
* If none, see if user placed an aria-label on the host
642-
* and use that instead.
643-
*/
644-
let { labelText } = getAriaLabel(el, rangeId!);
645-
if (labelText === undefined || labelText === null) {
646-
labelText = inheritedAttributes['aria-label'];
647-
}
648-
649637
let barStart = `${ratioLower * 100}%`;
650638
let barEnd = `${100 - ratioUpper * 100}%`;
651639

@@ -715,11 +703,6 @@ export class Range implements ComponentInterface {
715703
}
716704
}
717705

718-
let labelledBy: string | undefined;
719-
if (this.hasLabel) {
720-
labelledBy = 'range-label';
721-
}
722-
723706
return (
724707
<div
725708
class="range-slider"
@@ -791,8 +774,7 @@ export class Range implements ComponentInterface {
791774
handleKeyboard,
792775
min,
793776
max,
794-
labelText,
795-
labelledBy,
777+
inheritedAttributes,
796778
})}
797779

798780
{this.dualKnobs &&
@@ -807,8 +789,7 @@ export class Range implements ComponentInterface {
807789
handleKeyboard,
808790
min,
809791
max,
810-
labelText,
811-
labelledBy,
792+
inheritedAttributes,
812793
})}
813794
</div>
814795
);
@@ -887,27 +868,13 @@ interface RangeKnob {
887868
pressed: boolean;
888869
pin: boolean;
889870
pinFormatter: PinFormatter;
890-
labelText?: string | null;
891-
labelledBy?: string;
871+
inheritedAttributes: Attributes;
892872
handleKeyboard: (name: KnobName, isIncrease: boolean) => void;
893873
}
894874

895875
const renderKnob = (
896876
rtl: boolean,
897-
{
898-
knob,
899-
value,
900-
ratio,
901-
min,
902-
max,
903-
disabled,
904-
pressed,
905-
pin,
906-
handleKeyboard,
907-
labelText,
908-
labelledBy,
909-
pinFormatter,
910-
}: RangeKnob
877+
{ knob, value, ratio, min, max, disabled, pressed, pin, handleKeyboard, pinFormatter, inheritedAttributes }: RangeKnob
911878
) => {
912879
const start = rtl ? 'right' : 'left';
913880

@@ -919,6 +886,9 @@ const renderKnob = (
919886
return style;
920887
};
921888

889+
// The aria label should be preferred over visible text if both are specified
890+
const ariaLabel = inheritedAttributes['aria-label'];
891+
922892
return (
923893
<div
924894
onKeyDown={(ev: KeyboardEvent) => {
@@ -946,8 +916,8 @@ const renderKnob = (
946916
style={knobStyle()}
947917
role="slider"
948918
tabindex={disabled ? -1 : 0}
949-
aria-label={labelledBy === undefined ? labelText : null}
950-
aria-labelledby={labelledBy !== undefined ? labelledBy : null}
919+
aria-label={ariaLabel !== undefined ? ariaLabel : null}
920+
aria-labelledby={ariaLabel === undefined ? 'range-label' : null}
951921
aria-valuemin={min}
952922
aria-valuemax={max}
953923
aria-disabled={disabled ? 'true' : null}

core/src/components/range/test/label/range.spec.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,34 @@ describe('range: label', () => {
2020
expect(propEl).not.toBeNull();
2121
expect(slotEl).toBeNull();
2222
});
23+
it('should prefer aria label if both attribute and visible text provided', async () => {
24+
const page = await newSpecPage({
25+
components: [Range],
26+
html: `
27+
<ion-range aria-label="Aria Label Text" label="Label Prop Text"></ion-range>
28+
`,
29+
});
30+
31+
const range = page.body.querySelector('ion-range')!;
32+
33+
const nativeSlider = range.shadowRoot!.querySelector('.range-knob-handle')!;
34+
35+
expect(nativeSlider.getAttribute('aria-label')).toBe('Aria Label Text');
36+
expect(nativeSlider.getAttribute('aria-labelledby')).toBe(null);
37+
});
38+
it('should prefer visible label if only visible text provided', async () => {
39+
const page = await newSpecPage({
40+
components: [Range],
41+
html: `
42+
<ion-range label="Label Prop Text"></ion-range>
43+
`,
44+
});
45+
46+
const range = page.body.querySelector('ion-range')!;
47+
48+
const nativeSlider = range.shadowRoot!.querySelector('.range-knob-handle')!;
49+
50+
expect(nativeSlider.getAttribute('aria-label')).toBe(null);
51+
expect(nativeSlider.getAttribute('aria-labelledby')).toBe('range-label');
52+
});
2353
});

core/src/components/select/select.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core';
22
import { Component, Element, Event, Host, Method, Prop, State, Watch, h, forceUpdate } from '@stencil/core';
33
import type { NotchController } from '@utils/forms';
44
import { compareOptions, createNotchController, isOptionSelected } from '@utils/forms';
5-
import { focusVisibleElement, getAriaLabel, renderHiddenInput, inheritAttributes } from '@utils/helpers';
5+
import { focusVisibleElement, renderHiddenInput, inheritAttributes } from '@utils/helpers';
66
import type { Attributes } from '@utils/helpers';
77
import { actionSheetController, alertController, popoverController } from '@utils/overlays';
88
import type { OverlaySelect } from '@utils/overlays-interface';
@@ -874,10 +874,11 @@ export class Select implements ComponentInterface {
874874
}
875875

876876
private get ariaLabel() {
877-
const { placeholder, el, inputId, inheritedAttributes } = this;
877+
const { placeholder, inheritedAttributes } = this;
878878
const displayValue = this.getText();
879-
const { labelText } = getAriaLabel(el, inputId);
880-
const definedLabel = this.labelText ?? inheritedAttributes['aria-label'] ?? labelText;
879+
880+
// The aria label should be preferred over visible text if both are specified
881+
const definedLabel = inheritedAttributes['aria-label'] ?? this.labelText;
881882

882883
/**
883884
* If developer has specified a placeholder

core/src/components/select/test/select.spec.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,34 @@ describe('ion-select', () => {
6868
expect(propEl).not.toBe(null);
6969
expect(slotEl).toBe(null);
7070
});
71+
it('should prefer aria label if both attribute and visible text provided', async () => {
72+
const page = await newSpecPage({
73+
components: [Select],
74+
html: `
75+
<ion-select aria-label="Aria Label Text" label="Label Prop Text"></ion-select>
76+
`,
77+
});
78+
79+
const select = page.body.querySelector('ion-select')!;
80+
81+
const nativeButton = select.shadowRoot!.querySelector('button')!;
82+
83+
expect(nativeButton.getAttribute('aria-label')).toBe('Aria Label Text');
84+
});
85+
it('should prefer visible label if only visible text provided', async () => {
86+
const page = await newSpecPage({
87+
components: [Select],
88+
html: `
89+
<ion-select label="Label Prop Text"></ion-select>
90+
`,
91+
});
92+
93+
const select = page.body.querySelector('ion-select')!;
94+
95+
const nativeButton = select.shadowRoot!.querySelector('button')!;
96+
97+
expect(nativeButton.getAttribute('aria-label')).toBe('Label Prop Text');
98+
});
7199
});
72100

73101
describe('select: slot interactivity', () => {

0 commit comments

Comments
 (0)