@ionic/core
Version:
Base components for Ionic
665 lines (664 loc) • 29.9 kB
JavaScript
/*!
* (C) Ionic http://ionicframework.com - MIT License
*/
import { Build, Host, forceUpdate, h } from "@stencil/core";
import { createAttributeController } from "../../utils/attribute-controller";
import { raf } from "../../utils/helpers";
import { createColorClasses, hostContext, openURL } from "../../utils/theme";
import { chevronForward } from "ionicons/icons";
import { getIonMode } from "../../global/ionic-global";
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"`.
*/
export class Item {
constructor() {
this.labelColorStyles = {};
this.itemStyles = new Map();
this.didLoad = false;
this.multipleInputs = false;
this.focusable = true;
this.isInteractive = false;
this.hasSlottedIndicatorControl = false;
/**
* If `true`, a button tag will be rendered and the item will be tappable.
*/
this.button = false;
/**
* The icon to use when `detail` is set to `true`.
*/
this.detailIcon = chevronForward;
/**
* If `true`, the user cannot interact with the item.
*/
this.disabled = false;
/**
* When using a router, it specifies the transition direction when navigating to
* another page using `href`.
*/
this.routerDirection = 'forward';
/**
* The type of the button. Only used when an `onclick` or `button` property is present.
*/
this.type = 'button';
// slot change listener updates state to reflect how/if item should be interactive
this.updateInteractivityOnSlotChange = () => {
this.setIsInteractive();
this.setMultipleInputs();
this.setHasSlottedIndicatorControl();
};
}
buttonChanged() {
// Update the focusable option when the button option is changed
this.focusable = this.isFocusable();
}
labelColorChanged(ev) {
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;
}
}
itemStyle(ev) {
ev.stopPropagation();
const tagName = ev.target.tagName;
const updatedStyles = ev.detail;
const newStyles = {};
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();
}
this.ariaController?.init();
}
componentWillLoad() {
/**
* Only the initial copy takes the attribute off the host, so an `aria-label` written
* after load names both the native element and the Host, which is a `listitem` when
* the item is in an `ion-list`. The two names always agree, so a screen reader just
* reads it twice.
*/
this.ariaController = createAttributeController(this.el, ['aria-label'], () => forceUpdate(this));
}
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;
}
this.ariaController?.destroy();
}
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
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());
}
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.
*/
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.
setHasSlottedIndicatorControl() {
const controls = this.el.querySelectorAll(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 = control;
while (slotted !== null && slotted.parentElement !== this.el) {
slotted = slotted.parentElement;
}
return slotted !== null && !slotted.getAttribute('slot');
});
}
// 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
hasCover() {
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
isClickable() {
return this.href !== undefined || this.button;
}
canActivate() {
return this.isClickable() || this.hasCover();
}
isFocusable() {
const focusableChild = this.el.querySelector('.ion-focusable');
return this.canActivate() || focusableChild !== null;
}
hasStartEl() {
const startEl = this.el.querySelector('[slot="start"]');
if (startEl !== null) {
this.el.classList.add('item-has-start-slot');
}
}
getFirstInteractive() {
const controls = this.el.querySelectorAll('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, multipleInputs, } = this;
const inheritedAriaAttributes = this.ariaController?.attributes ?? {};
const childStyles = {};
const mode = getIonMode(this);
const clickable = this.isClickable();
const canActivate = this.canActivate();
const TagType = clickable ? (href === undefined ? 'button' : 'a') : 'div';
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) => {
if (clickable) {
openURL(href, ev, routerDirection, routerAnimation);
}
if (firstInteractive !== undefined && !multipleInputs) {
const path = ev.composedPath();
const target = path[0];
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.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 (h(Host, { key: '7fe924642310c19f057ae1bfa29c1acdf5cc7ed6', "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 }, h(TagType, { key: '8fddfb2a076b4b98e45ee8b23579d011dba12acb', ...attrs, ...inheritedAriaAttributes, class: "item-native", part: "native", disabled: disabled, ...clickFn }, h("slot", { key: '7f888b62b2023cf501fd308e32629e7a291c2d05', name: "start", onSlotchange: this.updateInteractivityOnSlotChange }), h("div", { key: '6d5cdde307caa65b131edb65cb0a5f8294cf151c', class: "item-inner", part: "inner" }, h("div", { key: '9fbfffdff4cf51adf1f02d573953cf8b145574b0', class: "input-wrapper", part: "container" }, h("slot", { key: '574c7e61cdaa3959a28bad7353105ef217a76ed9', onSlotchange: this.updateInteractivityOnSlotChange })), h("slot", { key: 'ed7e211960cd65ed17f865a6d50d5bdde069416b', name: "end", onSlotchange: this.updateInteractivityOnSlotChange }), showDetail && (h("ion-icon", { key: '2847bb21f42e9d5ab8ea86262b2bc1c3b3f7c902', icon: detailIcon, lazy: false, class: "item-detail-icon", part: "detail-icon", "aria-hidden": "true", "flip-rtl": detailIcon === chevronForward }))), canActivate && mode === 'md' && h("ion-ripple-effect", { key: '55502ef394db0953144387fa3baebf9fa6a976a9' }))));
}
static get is() { return "ion-item"; }
static get encapsulation() { return "shadow"; }
static get originalStyleUrls() {
return {
"ios": ["item.ios.scss"],
"md": ["item.md.scss"]
};
}
static get styleUrls() {
return {
"ios": ["item.ios.css"],
"md": ["item.md.css"]
};
}
static get properties() {
return {
"color": {
"type": "string",
"mutable": false,
"complexType": {
"original": "Color",
"resolved": "\"danger\" | \"dark\" | \"light\" | \"medium\" | \"primary\" | \"secondary\" | \"success\" | \"tertiary\" | \"warning\" | string & Record<never, never> | undefined",
"references": {
"Color": {
"location": "import",
"path": "../../interface",
"id": "src/interface.d.ts::Color",
"referenceLocation": "Color"
}
}
},
"required": false,
"optional": true,
"docs": {
"tags": [],
"text": "The color to use from your application's color palette.\nDefault options are: `\"primary\"`, `\"secondary\"`, `\"tertiary\"`, `\"success\"`, `\"warning\"`, `\"danger\"`, `\"light\"`, `\"medium\"`, and `\"dark\"`.\nFor more information on colors, see [theming](/docs/theming/basics)."
},
"getter": false,
"setter": false,
"reflect": true,
"attribute": "color"
},
"button": {
"type": "boolean",
"mutable": false,
"complexType": {
"original": "boolean",
"resolved": "boolean",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "If `true`, a button tag will be rendered and the item will be tappable."
},
"getter": false,
"setter": false,
"reflect": false,
"attribute": "button",
"defaultValue": "false"
},
"detail": {
"type": "boolean",
"mutable": false,
"complexType": {
"original": "boolean",
"resolved": "boolean | undefined",
"references": {}
},
"required": false,
"optional": true,
"docs": {
"tags": [],
"text": "If `true`, a detail arrow will appear on the item. Defaults to `false` unless the `mode`\nis `ios` and an `href` or `button` property is present."
},
"getter": false,
"setter": false,
"reflect": false,
"attribute": "detail"
},
"detailIcon": {
"type": "string",
"mutable": false,
"complexType": {
"original": "string",
"resolved": "string",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "The icon to use when `detail` is set to `true`."
},
"getter": false,
"setter": false,
"reflect": false,
"attribute": "detail-icon",
"defaultValue": "chevronForward"
},
"disabled": {
"type": "boolean",
"mutable": false,
"complexType": {
"original": "boolean",
"resolved": "boolean",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "If `true`, the user cannot interact with the item."
},
"getter": false,
"setter": false,
"reflect": true,
"attribute": "disabled",
"defaultValue": "false"
},
"download": {
"type": "string",
"mutable": false,
"complexType": {
"original": "string | undefined",
"resolved": "string | undefined",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "This attribute instructs browsers to download a URL instead of navigating to\nit, so the user will be prompted to save it as a local file. If the attribute\nhas a value, it is used as the pre-filled file name in the Save prompt\n(the user can still change the file name if they want)."
},
"getter": false,
"setter": false,
"reflect": false,
"attribute": "download"
},
"href": {
"type": "string",
"mutable": false,
"complexType": {
"original": "string | undefined",
"resolved": "string | undefined",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "Contains a URL or a URL fragment that the hyperlink points to.\nIf this property is set, an anchor tag will be rendered."
},
"getter": false,
"setter": false,
"reflect": false,
"attribute": "href"
},
"rel": {
"type": "string",
"mutable": false,
"complexType": {
"original": "string | undefined",
"resolved": "string | undefined",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "Specifies the relationship of the target object to the link object.\nThe value is a space-separated list of [link types](https://developer.mozilla.org/en-US/docs/Web/HTML/Link_types)."
},
"getter": false,
"setter": false,
"reflect": false,
"attribute": "rel"
},
"lines": {
"type": "string",
"mutable": false,
"complexType": {
"original": "'full' | 'inset' | 'none'",
"resolved": "\"full\" | \"inset\" | \"none\" | undefined",
"references": {}
},
"required": false,
"optional": true,
"docs": {
"tags": [],
"text": "How the bottom border should be displayed on the item."
},
"getter": false,
"setter": false,
"reflect": false,
"attribute": "lines"
},
"routerAnimation": {
"type": "unknown",
"mutable": false,
"complexType": {
"original": "AnimationBuilder | undefined",
"resolved": "((baseEl: any, opts?: any) => Animation) | undefined",
"references": {
"AnimationBuilder": {
"location": "import",
"path": "../../interface",
"id": "src/interface.d.ts::AnimationBuilder",
"referenceLocation": "AnimationBuilder"
}
}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "When using a router, it specifies the transition animation when navigating to\nanother page using `href`."
},
"getter": false,
"setter": false
},
"routerDirection": {
"type": "string",
"mutable": false,
"complexType": {
"original": "RouterDirection",
"resolved": "\"back\" | \"forward\" | \"root\"",
"references": {
"RouterDirection": {
"location": "import",
"path": "../router/utils/interface",
"id": "src/components/router/utils/interface.ts::RouterDirection",
"referenceLocation": "RouterDirection"
}
}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "When using a router, it specifies the transition direction when navigating to\nanother page using `href`."
},
"getter": false,
"setter": false,
"reflect": false,
"attribute": "router-direction",
"defaultValue": "'forward'"
},
"target": {
"type": "string",
"mutable": false,
"complexType": {
"original": "string | undefined",
"resolved": "string | undefined",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "Specifies where to display the linked URL.\nOnly applies when an `href` is provided.\nSpecial keywords: `\"_blank\"`, `\"_self\"`, `\"_parent\"`, `\"_top\"`."
},
"getter": false,
"setter": false,
"reflect": false,
"attribute": "target"
},
"type": {
"type": "string",
"mutable": false,
"complexType": {
"original": "'submit' | 'reset' | 'button'",
"resolved": "\"button\" | \"reset\" | \"submit\"",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "The type of the button. Only used when an `onclick` or `button` property is present."
},
"getter": false,
"setter": false,
"reflect": false,
"attribute": "type",
"defaultValue": "'button'"
}
};
}
static get states() {
return {
"multipleInputs": {},
"focusable": {},
"isInteractive": {},
"hasSlottedIndicatorControl": {}
};
}
static get elementRef() { return "el"; }
static get watchers() {
return [{
"propName": "button",
"methodName": "buttonChanged"
}];
}
static get listeners() {
return [{
"name": "ionColor",
"method": "labelColorChanged",
"target": undefined,
"capture": false,
"passive": false
}, {
"name": "ionStyle",
"method": "itemStyle",
"target": undefined,
"capture": false,
"passive": false
}];
}
}
const isIndicatorControl = (node) => {
if (node.nodeType !== Node.ELEMENT_NODE) {
return false;
}
const el = node;
return el.matches(INDICATOR_CONTROL_SELECTOR) || el.querySelector(INDICATOR_CONTROL_SELECTOR) !== null;
};
const touchesIndicatorControl = (record) => Array.from(record.addedNodes).some(isIndicatorControl) || Array.from(record.removedNodes).some(isIndicatorControl);