mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-11-07 15:07:13 +08:00
Issue number: resolves # --------- This PR introduces improvements to the visual focus styling of Ionic items when inside a select modal. ## What is the current behavior? - The CSS variables `--background-focused` and `--background-focused-opacity` were missing from `item.ionic.scss`, which resulted in the native default outline focus style being applied to focused items. - When no item is selected, the focus style is currently applied to the first list item by default, which we intend to change. ## What is the new behavior? - Added missing focus css variables - Hide the default focus style when there is no item selected ## NOTE - This change will require an additional interaction to observe the focus behavior when navigating through keyboard, since tap-based navigation does not rely on focus styling. ## Does this introduce a breaking change? - [ ] Yes - [X] No <!-- If this introduces a breaking change: 1. Describe the impact and migration path for existing applications below. 2. Update the BREAKING.md file with the breaking change. 3. Add "BREAKING CHANGE: [...]" to the commit description when merging. See https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer for more information. --> ## Other information <!-- Any other information that is important to this PR such as screenshots of how the component looks before and after the change. --> --------- Co-authored-by: ionitron <hi@ionicframework.com> Co-authored-by: Maria Hutt <thetaPC@users.noreply.github.com>
450 lines
15 KiB
TypeScript
450 lines
15 KiB
TypeScript
import caretRightRegular from '@phosphor-icons/core/assets/regular/caret-right.svg';
|
|
import type { ComponentInterface } from '@stencil/core';
|
|
import { 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 { config } from '../../global/config';
|
|
import { getIonTheme } from '../../global/ionic-global';
|
|
import type { AnimationBuilder, Color, CssClassMap, StyleEventDetail } from '../../interface';
|
|
import type { RouterDirection } from '../router/utils/interface';
|
|
|
|
/**
|
|
* @virtualProp {"ios" | "md"} mode - The mode determines the platform behaviors of the component.
|
|
* @virtualProp {"ios" | "md" | "ionic"} theme - The theme determines the visual appearance of the component.
|
|
*
|
|
* @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 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',
|
|
ionic: 'item.ionic.scss',
|
|
},
|
|
shadow: true,
|
|
})
|
|
export class Item implements ComponentInterface, AnchorInterface, ButtonInterface {
|
|
private labelColorStyles = {};
|
|
private itemStyles = new Map<string, CssClassMap>();
|
|
private inheritedAriaAttributes: Attributes = {};
|
|
|
|
@Element() el!: HTMLIonItemElement;
|
|
|
|
@State() multipleInputs = false;
|
|
@State() focusable = true;
|
|
@State() isInteractive = 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 `theme`
|
|
* 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?: string;
|
|
|
|
/**
|
|
* 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();
|
|
}
|
|
|
|
componentWillLoad() {
|
|
this.inheritedAriaAttributes = inheritAttributes(this.el, ['aria-label']);
|
|
}
|
|
|
|
componentDidLoad() {
|
|
raf(() => {
|
|
this.setMultipleInputs();
|
|
this.setIsInteractive();
|
|
this.focusable = this.isFocusable();
|
|
});
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// slot change listener updates state to reflect how/if item should be interactive
|
|
private updateInteractivityOnSlotChange = () => {
|
|
this.setIsInteractive();
|
|
this.setMultipleInputs();
|
|
};
|
|
|
|
// 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];
|
|
}
|
|
|
|
get itemDetailIcon() {
|
|
// Return the icon if it is explicitly set
|
|
if (this.detailIcon != null) {
|
|
return this.detailIcon;
|
|
}
|
|
|
|
// Determine the theme and map to default icons
|
|
const theme = getIonTheme(this);
|
|
const defaultIcons = {
|
|
ios: chevronForward,
|
|
ionic: caretRightRegular,
|
|
md: chevronForward,
|
|
};
|
|
|
|
// Get the default icon based on the theme, falling back to 'md' icon if necessary
|
|
const defaultIcon = defaultIcons[theme] || defaultIcons.md;
|
|
|
|
// Return the configured item detail icon or the default icon
|
|
return config.get('itemDetailIcon', defaultIcon);
|
|
}
|
|
|
|
/**
|
|
* The icon should be flipped when the app is RTL and
|
|
* the icon is a variation of chevron.
|
|
*/
|
|
get shouldFlipIcon() {
|
|
return this.itemDetailIcon === chevronForward || this.itemDetailIcon === caretRightRegular;
|
|
}
|
|
|
|
render() {
|
|
const {
|
|
detail,
|
|
download,
|
|
labelColorStyles,
|
|
lines,
|
|
disabled,
|
|
href,
|
|
itemDetailIcon,
|
|
shouldFlipIcon,
|
|
rel,
|
|
target,
|
|
routerAnimation,
|
|
routerDirection,
|
|
inheritedAriaAttributes,
|
|
multipleInputs,
|
|
} = this;
|
|
const childStyles = {} as StyleEventDetail;
|
|
const theme = getIonTheme(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 : theme === '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);
|
|
|
|
return (
|
|
<Host
|
|
aria-disabled={ariaDisabled}
|
|
class={{
|
|
...childStyles,
|
|
...labelColorStyles,
|
|
...createColorClasses(this.color, {
|
|
item: true,
|
|
[theme]: true,
|
|
'item-lines-default': lines === undefined,
|
|
[`item-lines-${lines}`]: lines !== undefined,
|
|
'item-control-needs-pointer-cursor': firstInteractiveNeedsPointerCursor,
|
|
'item-disabled': disabled,
|
|
'in-list': inList,
|
|
'in-select-modal': hostContext('ion-select-modal', this.el),
|
|
'item-multiple-inputs': this.multipleInputs,
|
|
'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">
|
|
<div class="input-wrapper">
|
|
<slot onSlotchange={this.updateInteractivityOnSlotChange}></slot>
|
|
</div>
|
|
<slot name="end" onSlotchange={this.updateInteractivityOnSlotChange}></slot>
|
|
{showDetail && (
|
|
<ion-icon
|
|
icon={itemDetailIcon}
|
|
lazy={false}
|
|
class="item-detail-icon"
|
|
part="detail-icon"
|
|
aria-hidden="true"
|
|
flip-rtl={shouldFlipIcon}
|
|
></ion-icon>
|
|
)}
|
|
</div>
|
|
{canActivate && theme === 'md' && <ion-ripple-effect></ion-ripple-effect>}
|
|
</TagType>
|
|
</Host>
|
|
);
|
|
}
|
|
}
|