@ionic/core
Version:
Base components for Ionic
494 lines (488 loc) • 20.4 kB
JavaScript
/*!
* (C) Ionic http://ionicframework.com - MIT License
*/
import { r as raf, w as win } from './helpers-BJqKF1pr.js';
import { i as isRTL } from './dir-Dojwmvde.js';
/**
* A utility to calculate the size of an outline notch
* width relative to the content passed. This is used in
* components such as `ion-select` with `fill="outline"`
* where we need to pass slotted HTML content. This is not
* needed when rendering plaintext content because we can
* render the plaintext again hidden with `opacity: 0` inside
* of the notch. As a result we can rely on the intrinsic size
* of the element to correctly compute the notch width. We
* cannot do this with slotted content because we cannot project
* it into 2 places at once.
*
* @internal
* @param el: The host element
* @param getNotchSpacerEl: A function that returns a reference to the notch spacer element inside of the component template.
* @param getLabelSlot: A function that returns a reference to the slotted content.
*/
const createNotchController = (el, getNotchSpacerEl, getLabelSlot) => {
let notchVisibilityIO;
const needsExplicitNotchWidth = () => {
const notchSpacerEl = getNotchSpacerEl();
if (
/**
* If the notch is not being used
* then we do not need to set the notch width.
*/
notchSpacerEl === undefined ||
/**
* If either the label property is being
* used or the label slot is not defined,
* then we do not need to estimate the notch width.
*/
el.label !== undefined ||
getLabelSlot() === null) {
return false;
}
return true;
};
const calculateNotchWidth = () => {
if (needsExplicitNotchWidth()) {
/**
* Run this the frame after
* the browser has re-painted the host element.
* Otherwise, the label element may have a width
* of 0 and the IntersectionObserver will be used.
*/
raf(() => {
setNotchWidth();
});
}
};
/**
* When using a label prop we can render
* the label value inside of the notch and
* let the browser calculate the size of the notch.
* However, we cannot render the label slot in multiple
* places so we need to manually calculate the notch dimension
* based on the size of the slotted content.
*
* This function should only be used to set the notch width
* on slotted label content. The notch width for label prop
* content is automatically calculated based on the
* intrinsic size of the label text.
*/
const setNotchWidth = () => {
const notchSpacerEl = getNotchSpacerEl();
if (notchSpacerEl === undefined) {
return;
}
if (!needsExplicitNotchWidth()) {
notchSpacerEl.style.removeProperty('width');
return;
}
const width = getLabelSlot().scrollWidth;
if (
/**
* If the computed width of the label is 0
* and notchSpacerEl's offsetParent is null
* then that means the element is hidden.
* As a result, we need to wait for the element
* to become visible before setting the notch width.
*
* We do not check el.offsetParent because
* that can be null if the host element has
* position: fixed applied to it.
* notchSpacerEl does not have position: fixed.
*/
width === 0 &&
notchSpacerEl.offsetParent === null &&
win !== undefined &&
'IntersectionObserver' in win) {
/**
* If there is an IO already attached
* then that will update the notch
* once the element becomes visible.
* As a result, there is no need to create
* another one.
*/
if (notchVisibilityIO !== undefined) {
return;
}
const io = (notchVisibilityIO = new IntersectionObserver((ev) => {
/**
* If the element is visible then we
* can try setting the notch width again.
*/
if (ev[0].intersectionRatio === 1) {
setNotchWidth();
io.disconnect();
notchVisibilityIO = undefined;
}
},
/**
* Set the root to be the host element
* This causes the IO callback
* to be fired in WebKit as soon as the element
* is visible. If we used the default root value
* then WebKit would only fire the IO callback
* after any animations (such as a modal transition)
* finished, and there would potentially be a flicker.
*/
{ threshold: 0.01, root: el }));
io.observe(notchSpacerEl);
return;
}
/**
* If the element is visible then we can set the notch width.
* The notch is only visible when the label is scaled,
* which is why we multiply the width by 0.75 as this is
* the same amount the label element is scaled by in the host CSS.
* (See $form-control-label-stacked-scale in ionic.globals.scss).
*/
notchSpacerEl.style.setProperty('width', `${width * 0.75}px`);
};
const destroy = () => {
if (notchVisibilityIO) {
notchVisibilityIO.disconnect();
notchVisibilityIO = undefined;
}
};
return {
calculateNotchWidth,
destroy,
};
};
/**
* Applied to the host while the label is being repositioned. The
* component stylesheets style this class, so it must stay in sync with
* the `skip-label-transition` selector in those files. Renaming it here
* requires renaming it there too.
*/
const SKIP_LABEL_TRANSITION_CLASS = 'skip-label-transition';
/**
* Set on the host as an inline style. The component stylesheets declare
* this property and read it with `var()`, so it must stay in sync with
* the `--internal-start-container-adjustment` references in those files.
* Renaming it here requires renaming it there too.
*/
const START_CONTAINER_ADJUSTMENT_VAR = '--internal-start-container-adjustment';
/**
* Set on the host as an inline style. The component stylesheets declare
* this property and read it with `var()`, so it must stay in sync with
* the `--internal-start-end-container-width` references in those files.
* Renaming it here requires renaming it there too.
*/
const START_END_CONTAINER_WIDTH_VAR = '--internal-start-end-container-width';
/**
* A utility to measure the start and end containers of form components
* (input, textarea, select) with `fill="outline"` and write the results
* to the host. The start container width offsets floating/stacked labels
* so they line up with the outline border. The combined start and end
* container width lets the floating label size itself to the full width
* of the outline while still being positioned relative to the control.
*
* Measurement is deferred with raf() to avoid forcing layout during
* component updates, and a ResizeObserver watches for dynamic width
* changes (e.g., when web fonts load or async content changes).
*
* @internal
* @param el - The host element (ion-input, ion-textarea, or ion-select).
* @param getStartContainer - A callback that returns the start container element.
* @param getEndContainer - A callback that returns the end container element.
* @param shouldMeasure - A callback that determines whether the containers should be measured.
*/
const createStartEndContainerController = (el, getStartContainer, getEndContainer, shouldMeasure) => {
let resizeObserver;
let observedStartContainer;
let observedEndContainer;
let measurementRaf;
let transitionRaf;
let appliedAdjustment;
let appliedStartEndWidth;
/*
* Round to one decimal place so fractional widths produce a stable
* label offset and width when a container is added, removed, or resized.
*/
const roundWidth = (width) => Math.round(width * 10) / 10;
const measureContainers = () => {
const startContainer = getStartContainer();
if (!startContainer || !shouldMeasure()) {
return { adjustment: '', startEndWidth: '' };
}
const startContainerWidth = roundWidth(startContainer.getBoundingClientRect().width);
const endContainerWidth = roundWidth(getEndContainer()?.getBoundingClientRect().width ?? 0);
const sign = isRTL(el) ? '' : '-';
return {
adjustment: startContainerWidth ? `${sign}${startContainerWidth}px` : '0px',
startEndWidth: `${roundWidth(startContainerWidth + endContainerWidth)}px`,
};
};
const scheduleMeasurement = () => {
// Return if there is already a measurement queued to avoid a flicker
if (measurementRaf !== undefined) {
return;
}
/*
* Defer the measurement until the next animation frame so layout
* is measured after the current update has been applied.
*/
measurementRaf = raf(() => {
measurementRaf = undefined;
const { adjustment, startEndWidth } = measureContainers();
/*
* The width only changes how much of the label fits, so it can
* animate. The transition is not suppressed here because the end
* container can change size while the label is floating, such as
* when async content loads, and that should not cut the float short.
*/
if (startEndWidth !== appliedStartEndWidth) {
el.style.setProperty(START_END_CONTAINER_WIDTH_VAR, startEndWidth);
appliedStartEndWidth = startEndWidth;
}
// Only suppress the label transition when the label has to move
if (adjustment !== appliedAdjustment) {
// Discard a removal queued by an earlier change
if (transitionRaf !== undefined) {
cancelAnimationFrame(transitionRaf);
}
el.classList.add(SKIP_LABEL_TRANSITION_CLASS);
el.style.setProperty(START_CONTAINER_ADJUSTMENT_VAR, adjustment);
appliedAdjustment = adjustment;
// Re-enable the transition once the new offset has been painted
transitionRaf = raf(() => {
transitionRaf = undefined;
el.classList.remove(SKIP_LABEL_TRANSITION_CLASS);
});
}
setupResizeObserver();
});
};
const disconnectResizeObserver = () => {
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = undefined;
observedStartContainer = undefined;
observedEndContainer = undefined;
}
};
const setupResizeObserver = () => {
const startContainer = getStartContainer();
const endContainer = getEndContainer();
// Stop watching once the measurements no longer apply
if (!startContainer || !shouldMeasure()) {
disconnectResizeObserver();
return;
}
// Return when prerendering or already watching these containers
if (typeof ResizeObserver !== 'function' ||
(resizeObserver && observedStartContainer === startContainer && observedEndContainer === endContainer)) {
return;
}
disconnectResizeObserver();
resizeObserver = new ResizeObserver(() => {
scheduleMeasurement();
});
observedStartContainer = startContainer;
resizeObserver.observe(startContainer);
if (endContainer) {
observedEndContainer = endContainer;
resizeObserver.observe(endContainer);
}
};
const calculateContainerWidths = () => {
scheduleMeasurement();
};
const destroy = () => {
disconnectResizeObserver();
if (measurementRaf !== undefined) {
cancelAnimationFrame(measurementRaf);
measurementRaf = undefined;
}
// Tearing down mid-toggle would leave the transition disabled
if (transitionRaf !== undefined) {
cancelAnimationFrame(transitionRaf);
transitionRaf = undefined;
el.classList.remove(SKIP_LABEL_TRANSITION_CLASS);
}
// Re-apply from scratch if the element is connected again
appliedAdjustment = undefined;
appliedStartEndWidth = undefined;
};
return {
calculateContainerWidths,
destroy,
};
};
/**
* The content slotted into a form control's start or end slot that a click
* started on, or `null` when the click did not start on slotted content.
*
* The slotted element is compared against the host in case the form control
* itself is slotted into, for example, an item. Without that check a control
* carrying slot="start"/"end" would treat every click on itself as a slotted
* click.
*/
const getSlottedClickContent = (ev, el) => {
const slotted = ev.target.closest('[slot="start"], [slot="end"]');
return slotted !== null && slotted !== el && el.contains(slotted) ? slotted : null;
};
/**
* Whether a click started on content slotted into a form control's start or
* end slot.
*/
const isSlottedClick = (ev, el) => getSlottedClickContent(ev, el) !== null;
/**
* A utility for form components that wrap their content in a <label>, such as
* ion-input, ion-textarea and ion-select.
*
* Clicking slotted content also clicks that label, and the browser follows it
* with a click on the label's control. That second click would be emitted from
* the host as a duplicate, so the slotted click is remembered and the click
* that follows it is suppressed.
*
* Browsers skip the forwarding when the click lands on interactive content,
* such as a slotted button, so the slotted click is remembered for a frame
* rather than until a forwarded click that may never arrive.
*
* @internal
* @param el - The host element.
* @param getNativeInput - A callback returning the native form control the
* label points at, for components that have one. Those components emit the
* click from the host rather than the control, so the control's click is
* always stopped and re-dispatched from the host. Omit it for components whose
* label has no `for` attribute, such as ion-select, where the browser forwards
* to an internal control that already re-bubbles targeting the host.
*/
const createClickController = (el, getNativeInput) => {
let hasSlottedClick = false;
const handleClickCapture = (ev) => {
if (isSlottedClick(ev, el)) {
hasSlottedClick = true;
raf(() => (hasSlottedClick = false));
return;
}
if (getNativeInput === undefined) {
if (hasSlottedClick) {
ev.stopPropagation();
hasSlottedClick = false;
}
return;
}
const nativeInput = getNativeInput();
if (nativeInput !== undefined && ev.target === nativeInput) {
ev.stopPropagation();
if (!hasSlottedClick) {
el.click();
}
hasSlottedClick = false;
}
};
return { handleClickCapture };
};
/**
* Used to update a scoped component that uses emulated slots. This fires when
* content is passed into the slot or when the content inside of a slot changes.
* This is not needed for components using native slots in the Shadow DOM.
* @internal
* @param el The host element to observe
* @param slotName mutationCallback will fire when nodes on these slot(s) change
* @param mutationCallback The callback to fire whenever the slotted content changes
*/
const createSlotMutationController = (el, slotName, mutationCallback) => {
let hostMutationObserver;
let slottedContentMutationObserver;
if (win !== undefined && 'MutationObserver' in win) {
const slots = Array.isArray(slotName) ? slotName : [slotName];
hostMutationObserver = new MutationObserver((entries) => {
for (const entry of entries) {
for (const node of entry.addedNodes) {
/**
* Check to see if the added node
* is our slotted content.
*/
if (node.nodeType === Node.ELEMENT_NODE && slots.includes(node.slot)) {
/**
* If so, we want to watch the slotted
* content itself for changes. This lets us
* detect when content inside of the slot changes.
*/
mutationCallback();
/**
* Adding the listener in an raf
* waits until Stencil moves the slotted element
* into the correct place in the event that
* slotted content is being added.
*/
raf(() => watchForSlotChange(node));
return;
}
}
}
});
hostMutationObserver.observe(el, {
childList: true,
/**
* This fixes an issue with the `ion-input` and
* `ion-textarea` not re-rendering in some cases
* when using the label slot functionality.
*
* HTML element patches in Stencil that are enabled
* by the `experimentalSlotFixes` flag in Stencil v4
* result in DOM manipulations that won't trigger
* the current mutation observer configuration and
* callback.
*/
subtree: true,
});
}
/**
* Listen for changes inside of the slotted content.
* We can listen for subtree changes here to be
* informed of text within the slotted content
* changing. Doing this on the host is possible
* but it is much more expensive to do because
* it also listens for changes to the internals
* of the component.
*/
const watchForSlotChange = (slottedEl) => {
if (slottedContentMutationObserver) {
slottedContentMutationObserver.disconnect();
slottedContentMutationObserver = undefined;
}
slottedContentMutationObserver = new MutationObserver((entries) => {
mutationCallback();
for (const entry of entries) {
for (const node of entry.removedNodes) {
/**
* If the element was removed then we
* need to destroy the MutationObserver
* so the element can be garbage collected.
*/
if (node.nodeType === Node.ELEMENT_NODE && node.slot === slotName) {
destroySlottedContentObserver();
}
}
}
});
/**
* Listen for changes inside of the element
* as well as anything deep in the tree.
* We listen on the parentElement so that we can
* detect when slotted element itself is removed.
*/
slottedContentMutationObserver.observe(slottedEl.parentElement ?? slottedEl, { subtree: true, childList: true });
};
const destroy = () => {
if (hostMutationObserver) {
hostMutationObserver.disconnect();
hostMutationObserver = undefined;
}
destroySlottedContentObserver();
};
const destroySlottedContentObserver = () => {
if (slottedContentMutationObserver) {
slottedContentMutationObserver.disconnect();
slottedContentMutationObserver = undefined;
}
};
return {
destroy,
};
};
export { createNotchController as a, createStartEndContainerController as b, createSlotMutationController as c, createClickController as d, getSlottedClickContent as g };