mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-08-18 19:21:34 +08:00
refactor(select): remove legacy property and support for legacy syntax (#29024)
Issue number: internal --------- ## What is the current behavior? In Ionic Framework v7, we [simplified the select syntax](https://ionic.io/blog/ionic-7-is-here#simplified-form-control-syntax) so that it was no longer required to be placed inside of an `ion-item`. We maintained backwards compatibility by adding a `legacy` property which allowed it to continue to be styled properly when written in the following way: ```html <ion-item> <ion-label>Label</ion-label> <ion-select></ion-select> </ion-item> ``` While this was supported in v7, console warnings were logged to notify developers that they needed to update this syntax for the best accessibility experience. ## What is the new behavior? - Removes the `legacy` property and support for the legacy syntax. Developers should follow the [migration guide](https://ionicframework.com/docs/api/select#migrating-from-legacy-select-syntax) in the select documentation to update their apps. The new syntax requires a `label` or `aria-label` on `ion-select`: ```html <ion-item> <ion-select label="Label"></ion-select> </ion-item> ``` - Removes the legacy tests under under `select/test/legacy/` and all related screenshots - Removes the select usage from `item/test/disabled`, `item/test/legacy/alignment`, and `item/test/legacy/disabled` and all related screenshots if the test was removed ## Does this introduce a breaking change? - [x] Yes - [ ] No 1. Developers have had console warnings when using the legacy syntax since the v7 release. The migration guide for the new select syntax is outlined in the [Select documentation](https://ionicframework.com/docs/api/select#migrating-from-legacy-select-syntax). 2. This change has been documented in the Breaking Changes document with a link to the migration guide. BREAKING CHANGE: 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 from select. 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). --------- Co-authored-by: ionitron <hi@ionicframework.com>
This commit is contained in:
@ -1,10 +1,9 @@
|
||||
import type { ComponentInterface, EventEmitter } from '@stencil/core';
|
||||
import { Component, Element, Event, Host, Method, Prop, State, Watch, h, forceUpdate } from '@stencil/core';
|
||||
import type { LegacyFormController, NotchController } from '@utils/forms';
|
||||
import { compareOptions, createLegacyFormController, createNotchController, isOptionSelected } from '@utils/forms';
|
||||
import { findItemLabel, focusElement, getAriaLabel, renderHiddenInput, inheritAttributes } from '@utils/helpers';
|
||||
import type { NotchController } from '@utils/forms';
|
||||
import { compareOptions, createNotchController, isOptionSelected } from '@utils/forms';
|
||||
import { focusElement, getAriaLabel, renderHiddenInput, inheritAttributes } from '@utils/helpers';
|
||||
import type { Attributes } from '@utils/helpers';
|
||||
import { printIonWarning } from '@utils/logging';
|
||||
import { actionSheetController, alertController, popoverController } from '@utils/overlays';
|
||||
import type { OverlaySelect } from '@utils/overlays-interface';
|
||||
import { isRTL } from '@utils/rtl';
|
||||
@ -55,16 +54,12 @@ export class Select implements ComponentInterface {
|
||||
private overlay?: OverlaySelect;
|
||||
private focusEl?: HTMLButtonElement;
|
||||
private mutationO?: MutationObserver;
|
||||
private legacyFormController!: LegacyFormController;
|
||||
private inheritedAttributes: Attributes = {};
|
||||
private nativeWrapperEl: HTMLElement | undefined;
|
||||
private notchSpacerEl: HTMLElement | undefined;
|
||||
|
||||
private notchController?: NotchController;
|
||||
|
||||
// This flag ensures we log the deprecation warning at most once.
|
||||
private hasLoggedDeprecationWarning = false;
|
||||
|
||||
@Element() el!: HTMLIonSelectElement;
|
||||
|
||||
@State() isExpanded = false;
|
||||
@ -152,17 +147,6 @@ export class Select implements ComponentInterface {
|
||||
*/
|
||||
@Prop() labelPlacement?: 'start' | 'end' | 'floating' | 'stacked' | 'fixed' = 'start';
|
||||
|
||||
/**
|
||||
* Set the `legacy` property to `true` to forcibly use the legacy form control markup.
|
||||
* Ionic will only opt components in to the modern form markup when they are
|
||||
* using either the `aria-label` attribute or the `label` property. As a result,
|
||||
* the `legacy` property should only be used as an escape hatch when you want to
|
||||
* avoid this automatic opt-in behavior.
|
||||
* Note that this property will be removed in an upcoming major release
|
||||
* of Ionic, and all form components will be opted-in to using the modern form markup.
|
||||
*/
|
||||
@Prop() legacy?: boolean;
|
||||
|
||||
/**
|
||||
* If `true`, the select can accept multiple values.
|
||||
*/
|
||||
@ -262,7 +246,6 @@ export class Select implements ComponentInterface {
|
||||
async connectedCallback() {
|
||||
const { el } = this;
|
||||
|
||||
this.legacyFormController = createLegacyFormController(el);
|
||||
this.notchController = createNotchController(
|
||||
el,
|
||||
() => this.notchSpacerEl,
|
||||
@ -515,44 +498,27 @@ export class Select implements ComponentInterface {
|
||||
let event: Event | CustomEvent = ev;
|
||||
let size = 'auto';
|
||||
|
||||
if (this.legacyFormController.hasLegacyControl()) {
|
||||
const item = this.el.closest('ion-item');
|
||||
const hasFloatingOrStackedLabel = labelPlacement === 'floating' || labelPlacement === 'stacked';
|
||||
/**
|
||||
* The popover should take up the full width
|
||||
* when using a fill in MD mode or if the
|
||||
* label is floating/stacked.
|
||||
*/
|
||||
if (hasFloatingOrStackedLabel || (mode === 'md' && fill !== undefined)) {
|
||||
size = 'cover';
|
||||
|
||||
// If the select is inside of an item containing a floating
|
||||
// or stacked label then the popover should take up the
|
||||
// full width of the item when it presents
|
||||
if (item && (item.classList.contains('item-label-floating') || item.classList.contains('item-label-stacked'))) {
|
||||
event = {
|
||||
...ev,
|
||||
detail: {
|
||||
ionShadowTarget: item,
|
||||
},
|
||||
};
|
||||
size = 'cover';
|
||||
}
|
||||
} else {
|
||||
const hasFloatingOrStackedLabel = labelPlacement === 'floating' || labelPlacement === 'stacked';
|
||||
/**
|
||||
* The popover should take up the full width
|
||||
* when using a fill in MD mode or if the
|
||||
* label is floating/stacked.
|
||||
* Otherwise the popover
|
||||
* should be positioned relative
|
||||
* to the native element.
|
||||
*/
|
||||
if (hasFloatingOrStackedLabel || (mode === 'md' && fill !== undefined)) {
|
||||
size = 'cover';
|
||||
|
||||
/**
|
||||
* Otherwise the popover
|
||||
* should be positioned relative
|
||||
* to the native element.
|
||||
*/
|
||||
} else {
|
||||
event = {
|
||||
...ev,
|
||||
detail: {
|
||||
ionShadowTarget: this.nativeWrapperEl,
|
||||
},
|
||||
};
|
||||
}
|
||||
} else {
|
||||
event = {
|
||||
...ev,
|
||||
detail: {
|
||||
ionShadowTarget: this.nativeWrapperEl,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const popoverOpts: PopoverOptions = {
|
||||
@ -618,23 +584,6 @@ export class Select implements ComponentInterface {
|
||||
}
|
||||
|
||||
private async openAlert() {
|
||||
/**
|
||||
* TODO FW-3194
|
||||
* Remove legacyFormController logic.
|
||||
* Remove label and labelText vars
|
||||
* Pass `this.labelText` instead of `labelText`
|
||||
* when setting the header.
|
||||
*/
|
||||
let label: HTMLElement | null;
|
||||
let labelText: string | null | undefined;
|
||||
|
||||
if (this.legacyFormController.hasLegacyControl()) {
|
||||
label = this.getLabel();
|
||||
labelText = label ? label.textContent : null;
|
||||
} else {
|
||||
labelText = this.labelText;
|
||||
}
|
||||
|
||||
const interfaceOptions = this.interfaceOptions;
|
||||
const inputType = this.multiple ? 'checkbox' : 'radio';
|
||||
const mode = getIonMode(this);
|
||||
@ -643,7 +592,7 @@ export class Select implements ComponentInterface {
|
||||
mode,
|
||||
...interfaceOptions,
|
||||
|
||||
header: interfaceOptions.header ? interfaceOptions.header : labelText,
|
||||
header: interfaceOptions.header ? interfaceOptions.header : this.labelText,
|
||||
inputs: this.createAlertInputs(this.childOpts, inputType, this.value),
|
||||
buttons: [
|
||||
{
|
||||
@ -692,11 +641,6 @@ export class Select implements ComponentInterface {
|
||||
return this.overlay.dismiss();
|
||||
}
|
||||
|
||||
// TODO FW-3194 Remove this
|
||||
private getLabel() {
|
||||
return findItemLabel(this.el);
|
||||
}
|
||||
|
||||
private hasValue(): boolean {
|
||||
return this.getText() !== '';
|
||||
}
|
||||
@ -745,21 +689,11 @@ export class Select implements ComponentInterface {
|
||||
|
||||
private emitStyle() {
|
||||
const { disabled } = this;
|
||||
|
||||
const style: StyleEventDetail = {
|
||||
'interactive-disabled': disabled,
|
||||
};
|
||||
|
||||
if (this.legacyFormController.hasLegacyControl()) {
|
||||
style['interactive'] = true;
|
||||
style['select'] = true;
|
||||
style['select-disabled'] = disabled;
|
||||
style['has-placeholder'] = this.placeholder !== undefined;
|
||||
style['has-value'] = this.hasValue();
|
||||
style['has-focus'] = this.isExpanded;
|
||||
// TODO(FW-3194): remove this
|
||||
style['legacy'] = !!this.legacy;
|
||||
}
|
||||
|
||||
this.ionStyle.emit(style);
|
||||
}
|
||||
|
||||
@ -889,157 +823,6 @@ export class Select implements ComponentInterface {
|
||||
return this.renderLabel();
|
||||
}
|
||||
|
||||
private renderSelect() {
|
||||
const { disabled, el, isExpanded, expandedIcon, labelPlacement, justify, placeholder, fill, shape, name, value } =
|
||||
this;
|
||||
const mode = getIonMode(this);
|
||||
const hasFloatingOrStackedLabel = labelPlacement === 'floating' || labelPlacement === 'stacked';
|
||||
const justifyEnabled = !hasFloatingOrStackedLabel;
|
||||
const rtl = isRTL(el) ? 'rtl' : 'ltr';
|
||||
const inItem = hostContext('ion-item', this.el);
|
||||
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);
|
||||
|
||||
/**
|
||||
* If the label is stacked, it should always sit above the select.
|
||||
* 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 || hasStartEndSlots));
|
||||
|
||||
return (
|
||||
<Host
|
||||
onClick={this.onClick}
|
||||
class={createColorClasses(this.color, {
|
||||
[mode]: true,
|
||||
'in-item': inItem,
|
||||
'in-item-color': hostContext('ion-item.ion-color', el),
|
||||
'select-disabled': disabled,
|
||||
'select-expanded': isExpanded,
|
||||
'has-expanded-icon': expandedIcon !== undefined,
|
||||
'has-value': hasValue,
|
||||
'label-floating': labelShouldFloat,
|
||||
'has-placeholder': placeholder !== undefined,
|
||||
'ion-focusable': true,
|
||||
[`select-${rtl}`]: true,
|
||||
[`select-fill-${fill}`]: fill !== undefined,
|
||||
[`select-justify-${justify}`]: justifyEnabled,
|
||||
[`select-shape-${shape}`]: shape !== undefined,
|
||||
[`select-label-placement-${labelPlacement}`]: true,
|
||||
})}
|
||||
>
|
||||
<label class="select-wrapper" id="select-label">
|
||||
{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()}
|
||||
</div>
|
||||
<slot name="end"></slot>
|
||||
{!hasFloatingOrStackedLabel && this.renderSelectIcon()}
|
||||
</div>
|
||||
{/**
|
||||
* The icon in a floating/stacked select
|
||||
* must be centered with the entire select,
|
||||
* while the start/end slots and native control
|
||||
* are vertically offset in the default or
|
||||
* solid fills. As a result, we render the
|
||||
* icon outside the inner wrapper, which holds
|
||||
* those components.
|
||||
*/}
|
||||
{hasFloatingOrStackedLabel && this.renderSelectIcon()}
|
||||
{shouldRenderHighlight && <div class="select-highlight"></div>}
|
||||
</label>
|
||||
</Host>
|
||||
);
|
||||
}
|
||||
|
||||
// TODO FW-3194 - Remove this
|
||||
private renderLegacySelect() {
|
||||
if (!this.hasLoggedDeprecationWarning) {
|
||||
printIonWarning(
|
||||
`ion-select now requires providing a label with either the "label" property or the "aria-label" attribute. To migrate, remove any usage of "ion-label" and pass the label text to either the "label" property or the "aria-label" attribute.
|
||||
|
||||
Example: <ion-select label="Favorite Color">...</ion-select>
|
||||
Example with aria-label: <ion-select aria-label="Favorite Color">...</ion-select>
|
||||
|
||||
Developers can use the "legacy" property to continue using the legacy form markup. This property will be removed in an upcoming major release of Ionic where this form control will use the modern form markup.`,
|
||||
this.el
|
||||
);
|
||||
|
||||
if (this.legacy) {
|
||||
printIonWarning(
|
||||
`ion-select is being used with the "legacy" property enabled which will forcibly enable the legacy form markup. This property will be removed in an upcoming major release of Ionic where this form control will use the modern form markup.
|
||||
Developers can dismiss this warning by removing their usage of the "legacy" property and using the new select syntax.`,
|
||||
this.el
|
||||
);
|
||||
}
|
||||
this.hasLoggedDeprecationWarning = true;
|
||||
}
|
||||
|
||||
const { disabled, el, inputId, isExpanded, expandedIcon, name, placeholder, value } = this;
|
||||
const mode = getIonMode(this);
|
||||
const { labelText, labelId } = getAriaLabel(el, inputId);
|
||||
|
||||
renderHiddenInput(true, el, name, parseValue(value), disabled);
|
||||
|
||||
const displayValue = this.getText();
|
||||
|
||||
let selectText = displayValue;
|
||||
if (selectText === '' && placeholder !== undefined) {
|
||||
selectText = placeholder;
|
||||
}
|
||||
|
||||
// If there is a label then we need to concatenate it with the
|
||||
// current value (or placeholder) and a comma so it separates
|
||||
// nicely when the screen reader announces it, otherwise just
|
||||
// announce the value / placeholder
|
||||
const displayLabel =
|
||||
labelText !== undefined ? (selectText !== '' ? `${selectText}, ${labelText}` : labelText) : selectText;
|
||||
|
||||
return (
|
||||
<Host
|
||||
onClick={this.onClick}
|
||||
role="button"
|
||||
aria-haspopup="listbox"
|
||||
aria-disabled={disabled ? 'true' : null}
|
||||
aria-label={displayLabel}
|
||||
class={{
|
||||
[mode]: true,
|
||||
'in-item': hostContext('ion-item', el),
|
||||
'in-item-color': hostContext('ion-item.ion-color', el),
|
||||
'select-disabled': disabled,
|
||||
'select-expanded': isExpanded,
|
||||
'has-expanded-icon': expandedIcon !== undefined,
|
||||
'legacy-select': true,
|
||||
}}
|
||||
>
|
||||
{this.renderSelectText()}
|
||||
{this.renderSelectIcon()}
|
||||
<label id={labelId}>{displayLabel}</label>
|
||||
{this.renderListbox()}
|
||||
</Host>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders either the placeholder
|
||||
* or the selected values based on
|
||||
@ -1138,9 +921,86 @@ Developers can use the "legacy" property to continue using the legacy form marku
|
||||
}
|
||||
|
||||
render() {
|
||||
const { legacyFormController } = this;
|
||||
const { disabled, el, isExpanded, expandedIcon, labelPlacement, justify, placeholder, fill, shape, name, value } =
|
||||
this;
|
||||
const mode = getIonMode(this);
|
||||
const hasFloatingOrStackedLabel = labelPlacement === 'floating' || labelPlacement === 'stacked';
|
||||
const justifyEnabled = !hasFloatingOrStackedLabel;
|
||||
const rtl = isRTL(el) ? 'rtl' : 'ltr';
|
||||
const inItem = hostContext('ion-item', this.el);
|
||||
const shouldRenderHighlight = mode === 'md' && fill !== 'outline' && !inItem;
|
||||
|
||||
return legacyFormController.hasLegacyControl() ? this.renderLegacySelect() : this.renderSelect();
|
||||
const hasValue = this.hasValue();
|
||||
const hasStartEndSlots = el.querySelector('[slot="start"], [slot="end"]') !== null;
|
||||
|
||||
renderHiddenInput(true, el, name, parseValue(value), disabled);
|
||||
|
||||
/**
|
||||
* If the label is stacked, it should always sit above the select.
|
||||
* 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 || hasStartEndSlots));
|
||||
|
||||
return (
|
||||
<Host
|
||||
onClick={this.onClick}
|
||||
class={createColorClasses(this.color, {
|
||||
[mode]: true,
|
||||
'in-item': inItem,
|
||||
'in-item-color': hostContext('ion-item.ion-color', el),
|
||||
'select-disabled': disabled,
|
||||
'select-expanded': isExpanded,
|
||||
'has-expanded-icon': expandedIcon !== undefined,
|
||||
'has-value': hasValue,
|
||||
'label-floating': labelShouldFloat,
|
||||
'has-placeholder': placeholder !== undefined,
|
||||
'ion-focusable': true,
|
||||
[`select-${rtl}`]: true,
|
||||
[`select-fill-${fill}`]: fill !== undefined,
|
||||
[`select-justify-${justify}`]: justifyEnabled,
|
||||
[`select-shape-${shape}`]: shape !== undefined,
|
||||
[`select-label-placement-${labelPlacement}`]: true,
|
||||
})}
|
||||
>
|
||||
<label class="select-wrapper" id="select-label">
|
||||
{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()}
|
||||
</div>
|
||||
<slot name="end"></slot>
|
||||
{!hasFloatingOrStackedLabel && this.renderSelectIcon()}
|
||||
</div>
|
||||
{/**
|
||||
* The icon in a floating/stacked select
|
||||
* must be centered with the entire select,
|
||||
* while the start/end slots and native control
|
||||
* are vertically offset in the default or
|
||||
* solid fills. As a result, we render the
|
||||
* icon outside the inner wrapper, which holds
|
||||
* those components.
|
||||
*/}
|
||||
{hasFloatingOrStackedLabel && this.renderSelectIcon()}
|
||||
{shouldRenderHighlight && <div class="select-highlight"></div>}
|
||||
</label>
|
||||
</Host>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user