/
githubmirror
/
ionic-framework
Обзор
Документация
Войти
/
githubmirror
/
ionic-framework
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
core/src/components/item/item.tsx
513 строк
18 KB
Shane
fix(checkbox,radio,toggle): add missing keyboard focus indicators (#31295)
01 авг 2026, 00:57
Не верифицирован
01 авг 2026, 00:57
2f9c0b7
Код
Авторство
О чём код?
import type { ComponentInterface } from '@stencil/core'; import { Build, Component, Element, Host, Listen, Prop, State, Watch, forceUpdate, h } from '@stencil/core'; import type { AnchorInterface, ButtonInterface } from '@utils/element-interface'; import type { Attributes } from '@utils/helpers'; import { inheritAttributes, raf } from '@utils/helpers'; import { createColorClasses, hostContext, openURL } from '@utils/theme'; import { chevronForward } from 'ionicons/icons'; import { getIonMode } from '../../global/ionic-global'; import type { AnimationBuilder, Color, CssClassMap, StyleEventDetail } from '../../interface'; import type { RouterDirection } from '../router/utils/interface'; const INDICATOR_CONTROL_SELECTOR = 'ion-checkbox, ion-radio, ion-toggle'; /** * @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use. * * @slot - Content is placed between the named slots if provided without a slot. * @slot start - Content is placed to the left of the item text in LTR, and to the right in RTL. * @slot end - Content is placed to the right of the item text in LTR, and to the left in RTL. * * @part native - The native HTML button, anchor or div element that wraps all child elements. * @part inner - The inner wrapper element that arranges the item content. * @part container - The wrapper element that contains the default slot. * @part detail-icon - The chevron icon for the item. Only applies when `detail="true"`. */ @Component({ tag: 'ion-item', styleUrls: { ios: 'item.ios.scss', md: 'item.md.scss', }, shadow: true, }) export class Item implements ComponentInterface, AnchorInterface, ButtonInterface { private labelColorStyles = {}; private itemStyles = new Map<string, CssClassMap>(); private inheritedAriaAttributes: Attributes = {}; private indicatorControlObserver?: MutationObserver; private didLoad = false; @Element() el!: HTMLIonItemElement; @State() multipleInputs = false; @State() focusable = true; @State() isInteractive = false; @State() hasSlottedIndicatorControl = false; /** * The color to use from your application's color palette. * Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. * For more information on colors, see [theming](/docs/theming/basics). */ @Prop({ reflect: true }) color?: Color; /** * If `true`, a button tag will be rendered and the item will be tappable. */ @Prop() button = false; /** * If `true`, a detail arrow will appear on the item. Defaults to `false` unless the `mode` * is `ios` and an `href` or `button` property is present. */ @Prop() detail?: boolean; /** * The icon to use when `detail` is set to `true`. */ @Prop() detailIcon = chevronForward; /** * If `true`, the user cannot interact with the item. */ @Prop({ reflect: true }) disabled = false; /** * This attribute instructs browsers to download a URL instead of navigating to * it, so the user will be prompted to save it as a local file. If the attribute * has a value, it is used as the pre-filled file name in the Save prompt * (the user can still change the file name if they want). */ @Prop() download: string | undefined; /** * Contains a URL or a URL fragment that the hyperlink points to. * If this property is set, an anchor tag will be rendered. */ @Prop() href: string | undefined; /** * Specifies the relationship of the target object to the link object. * The value is a space-separated list of [link types](https://developer.mozilla.org/en-US/docs/Web/HTML/Link_types). */ @Prop() rel: string | undefined; /** * How the bottom border should be displayed on the item. */ @Prop() lines?: 'full' | 'inset' | 'none'; /** * When using a router, it specifies the transition animation when navigating to * another page using `href`. */ @Prop() routerAnimation: AnimationBuilder | undefined; /** * When using a router, it specifies the transition direction when navigating to * another page using `href`. */ @Prop() routerDirection: RouterDirection = 'forward'; /** * Specifies where to display the linked URL. * Only applies when an `href` is provided. * Special keywords: `"_blank"`, `"_self"`, `"_parent"`, `"_top"`. */ @Prop() target: string | undefined; /** * The type of the button. Only used when an `onclick` or `button` property is present. */ @Prop() type: 'submit' | 'reset' | 'button' = 'button'; @Watch('button') buttonChanged() { // Update the focusable option when the button option is changed this.focusable = this.isFocusable(); } @Listen('ionColor') labelColorChanged(ev: CustomEvent<string>) { const { color } = this; // There will be a conflict with item color if // we apply the label color to item, so we ignore // the label color if the user sets a color on item if (color === undefined) { this.labelColorStyles = ev.detail; } } @Listen('ionStyle') itemStyle(ev: CustomEvent<StyleEventDetail>) { ev.stopPropagation(); const tagName = (ev.target as HTMLElement).tagName; const updatedStyles = ev.detail; const newStyles = {} as CssClassMap; const childStyles = this.itemStyles.get(tagName) || {}; let hasStyleChange = false; Object.keys(updatedStyles).forEach((key) => { if (updatedStyles[key]) { const itemKey = `item-${key}`; if (!childStyles[itemKey]) { hasStyleChange = true; } newStyles[itemKey] = true; } }); if (!hasStyleChange && Object.keys(newStyles).length !== Object.keys(childStyles).length) { hasStyleChange = true; } if (hasStyleChange) { this.itemStyles.set(tagName, newStyles); forceUpdate(this); } } connectedCallback() { this.hasStartEl(); /** * `componentDidLoad` doesn't run again when the item is moved, so re-arm the * observer and re-read the light DOM, which may have changed while detached. */ if (this.didLoad) { this.watchForIndicatorControls(); this.updateInteractivityOnSlotChange(); } } componentWillLoad() { this.inheritedAriaAttributes = inheritAttributes(this.el, ['aria-label']); } componentDidLoad() { raf(() => { this.setMultipleInputs(); this.setIsInteractive(); this.setHasSlottedIndicatorControl(); this.focusable = this.isFocusable(); }); this.watchForIndicatorControls(); this.didLoad = true; } disconnectedCallback() { if (this.indicatorControlObserver) { this.indicatorControlObserver.disconnect(); this.indicatorControlObserver = undefined; } } private totalNestedInputs() { // The following elements have a clickable cover that is relative to the entire item const covers = this.el.querySelectorAll('ion-checkbox, ion-datetime, ion-select, ion-radio'); // The following elements can accept focus alongside the previous elements // therefore if these elements are also a child of item, we don't want the // input cover on top of those interfering with their clicks const inputs = this.el.querySelectorAll( 'ion-input, ion-range, ion-searchbar, ion-segment, ion-textarea, ion-toggle' ); // The following elements should also stay clickable when an input with cover is present const clickables = this.el.querySelectorAll('ion-router-link, ion-button, a, button'); return { covers, inputs, clickables, }; } // If the item contains multiple clickable elements and/or inputs, then the item // should not have a clickable input cover over the entire item to prevent // interfering with their individual click events private setMultipleInputs() { const { covers, inputs, clickables } = this.totalNestedInputs(); // Check for multiple inputs to change the position of the input cover to relative // for all of the covered inputs above this.multipleInputs = covers.length + inputs.length > 1 || covers.length + clickables.length > 1 || (covers.length > 0 && this.isClickable()); } private setIsInteractive() { // If item contains any interactive children, set isInteractive to `true` const { covers, inputs, clickables } = this.totalNestedInputs(); this.isInteractive = covers.length > 0 || inputs.length > 0 || clickables.length > 0; } /** * `slotchange` only fires for nodes assigned directly to a slot, so a control * inside a slotted wrapper (`<div><ion-toggle>`) never reaches * `updateInteractivityOnSlotChange`. The light DOM is observed instead. * * The callback runs the whole handler because a control below a wrapper can also * make the item multi-input, which is what decides whether the controls draw * their own indicator at all. * * `:host(:has())` would avoid the observer, but the `:has()` fallback in * `core.scss` is still open as FW-6106 and it's unreliable for slotted content * in Android WebView. Worth revisiting when FW-6106 closes. */ private watchForIndicatorControls() { if (!Build.isBrowser || typeof MutationObserver === 'undefined') { return; } // `Node.moveBefore` relocates the item without either callback firing, so // never leave a previous observer behind this.indicatorControlObserver?.disconnect(); this.indicatorControlObserver = new MutationObserver((records) => { // The subtree observer also fires for text and hidden input churn, so only // re-read the DOM when a control was added or removed if (records.some(touchesIndicatorControl)) { this.updateInteractivityOnSlotChange(); } }); this.indicatorControlObserver.observe(this.el, { childList: true, subtree: true }); } // These controls paint a focus indicator that overhangs their own bounds, and // only the default slot is clipped, so only a control there needs extra room. private setHasSlottedIndicatorControl() { const controls = this.el.querySelectorAll<HTMLElement>(INDICATOR_CONTROL_SELECTOR); this.hasSlottedIndicatorControl = Array.from(controls).some((control) => { // The control isn't always a direct child, so walk up to the element the item // slots, which is the one carrying the slot name. let slotted: HTMLElement | null = control; while (slotted !== null && slotted.parentElement !== this.el) { slotted = slotted.parentElement; } return slotted !== null && !slotted.getAttribute('slot'); }); } // slot change listener updates state to reflect how/if item should be interactive private updateInteractivityOnSlotChange = () => { this.setIsInteractive(); this.setMultipleInputs(); this.setHasSlottedIndicatorControl(); }; // If the item contains an input including a checkbox, datetime, select, or radio // then the item will have a clickable input cover that covers the item // that should get the hover, focused and activated states UNLESS it has multiple // inputs, then those need to individually get each click private hasCover(): boolean { const inputs = this.el.querySelectorAll('ion-checkbox, ion-datetime, ion-select, ion-radio'); return inputs.length === 1 && !this.multipleInputs; } // If the item has an href or button property it will render a native // anchor or button that is clickable private isClickable(): boolean { return this.href !== undefined || this.button; } private canActivate(): boolean { return this.isClickable() || this.hasCover(); } private isFocusable(): boolean { const focusableChild = this.el.querySelector('.ion-focusable'); return this.canActivate() || focusableChild !== null; } private hasStartEl() { const startEl = this.el.querySelector('[slot="start"]'); if (startEl !== null) { this.el.classList.add('item-has-start-slot'); } } private getFirstInteractive() { const controls = this.el.querySelectorAll<HTMLElement>( 'ion-toggle:not([disabled]), ion-checkbox:not([disabled]), ion-radio:not([disabled]), ion-select:not([disabled]), ion-input:not([disabled]), ion-textarea:not([disabled])' ); return controls[0]; } render() { const { detail, detailIcon, download, labelColorStyles, lines, disabled, href, rel, target, routerAnimation, routerDirection, inheritedAriaAttributes, multipleInputs, } = this; const childStyles = {} as StyleEventDetail; const mode = getIonMode(this); const clickable = this.isClickable(); const canActivate = this.canActivate(); const TagType = clickable ? (href === undefined ? 'button' : 'a') : ('div' as any); const attrs = TagType === 'button' ? { type: this.type } : { download, href, rel, target, }; let clickFn = {}; const firstInteractive = this.getFirstInteractive(); // Only set onClick if the item is clickable to prevent screen // readers from reading all items as clickable if (clickable || (firstInteractive !== undefined && !multipleInputs)) { clickFn = { onClick: (ev: MouseEvent) => { if (clickable) { openURL(href, ev, routerDirection, routerAnimation); } if (firstInteractive !== undefined && !multipleInputs) { const path = ev.composedPath(); const target = path[0] as HTMLElement; if (ev.isTrusted) { /** * Dispatches a click event to the first interactive element, * when it is the result of a user clicking on the item. * * We check if the click target is in the shadow root, * which means the user clicked on the .item-native or * .item-inner padding. */ const clickedWithinShadowRoot = this.el.shadowRoot!.contains(target); if (clickedWithinShadowRoot) { /** * For input/textarea clicking the padding should focus the * text field (thus making it editable). For everything else, * we want to click the control so it activates. */ if (firstInteractive.tagName === 'ION-INPUT' || firstInteractive.tagName === 'ION-TEXTAREA') { (firstInteractive as HTMLIonInputElement | HTMLIonTextareaElement).setFocus(); } firstInteractive.click(); /** * Stop the item event from being triggered * as the firstInteractive click event will also * trigger the item click event. */ ev.stopImmediatePropagation(); } } } }, }; } const showDetail = detail !== undefined ? detail : mode === 'ios' && clickable; this.itemStyles.forEach((value) => { Object.assign(childStyles, value); }); const ariaDisabled = disabled || childStyles['item-interactive-disabled'] ? 'true' : null; const inList = hostContext('ion-list', this.el) && !hostContext('ion-radio-group', this.el); /** * Inputs and textareas do not need to show a cursor pointer. * However, other form controls such as checkboxes and radios do. */ const firstInteractiveNeedsPointerCursor = firstInteractive !== undefined && !['ION-INPUT', 'ION-TEXTAREA'].includes(firstInteractive.tagName); /** * A control in a single-input item defers its indicator to the item, so there's * nothing to clip and nothing to make room for. It draws its own indicator in a * multi-input item, and in a clickable item, which is a second tab stop. */ const slottedIndicatorNeedsRoom = this.hasSlottedIndicatorControl && (multipleInputs || this.isClickable()); return ( <Host aria-disabled={ariaDisabled} class={{ ...childStyles, ...labelColorStyles, ...createColorClasses(this.color, { item: true, [mode]: true, 'item-lines-default': lines === undefined, [`item-lines-${lines}`]: lines !== undefined, 'item-control-needs-pointer-cursor': firstInteractiveNeedsPointerCursor, 'item-disabled': disabled, 'in-list': inList, 'item-multiple-inputs': this.multipleInputs, 'item-focus-indicator-room': slottedIndicatorNeedsRoom, 'ion-activatable': canActivate, 'ion-focusable': this.focusable, 'item-rtl': document.dir === 'rtl', }), }} role={inList ? 'listitem' : null} > <TagType {...attrs} {...inheritedAriaAttributes} class="item-native" part="native" disabled={disabled} {...clickFn} > <slot name="start" onSlotchange={this.updateInteractivityOnSlotChange}></slot> <div class="item-inner" part="inner"> <div class="input-wrapper" part="container"> <slot onSlotchange={this.updateInteractivityOnSlotChange}></slot> </div> <slot name="end" onSlotchange={this.updateInteractivityOnSlotChange}></slot> {showDetail && ( <ion-icon icon={detailIcon} lazy={false} class="item-detail-icon" part="detail-icon" aria-hidden="true" flip-rtl={detailIcon === chevronForward} ></ion-icon> )} </div> {canActivate && mode === 'md' && <ion-ripple-effect></ion-ripple-effect>} </TagType> </Host> ); } } const isIndicatorControl = (node: Node): boolean => { if (node.nodeType !== Node.ELEMENT_NODE) { return false; } const el = node as Element; return el.matches(INDICATOR_CONTROL_SELECTOR) || el.querySelector(INDICATOR_CONTROL_SELECTOR) !== null; }; const touchesIndicatorControl = (record: MutationRecord): boolean => Array.from(record.addedNodes).some(isIndicatorControl) || Array.from(record.removedNodes).some(isIndicatorControl);