Start container controller diff

创建于 差异过期
45 删除
163 行
102 添加
205 行
import { raf } from '@utils/helpers';
import { raf } from '@utils/helpers';
import { isRTL } from '@utils/rtl';
import { isRTL } from '@utils/rtl';


/**
/**
* Applied to the host while the label is being repositioned. The
* Applied to the host while the label is being repositioned. The
* component stylesheets style this class, so it must stay in sync with
* component stylesheets style this class, so it must stay in sync with
* the `skip-label-transition` selector in those files. Renaming it here
* the `skip-label-transition` selector in those files. Renaming it here
* requires renaming it there too.
* requires renaming it there too.
*/
*/
export const SKIP_LABEL_TRANSITION_CLASS = 'skip-label-transition';
export const SKIP_LABEL_TRANSITION_CLASS = 'skip-label-transition';


/**
/**
* Set on the host as an inline style. The component stylesheets declare
* 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
* this property and read it with `var()`, so it must stay in sync with
* the `--internal-start-container-adjustment` references in those files.
* the `--internal-start-container-adjustment` references in those files.
* Renaming it here requires renaming it there too.
* Renaming it here requires renaming it there too.
*/
*/
export const START_CONTAINER_ADJUSTMENT_VAR = '--internal-start-container-adjustment';
export const START_CONTAINER_ADJUSTMENT_VAR = '--internal-start-container-adjustment';


/**
/**
* A utility to measure and update the start container width adjustment
* Set on the host as an inline style. The component stylesheets declare
* for form components (input, textarea, select) with `fill="outline"`.
* this property and read it with `var()`, so it must stay in sync with
* This adjustment compensates for the start container width when
* the `--internal-start-end-container-width` references in those files.
* positioning floating/stacked labels within the outline border.
* Renaming it here requires renaming it there too.
*/
export 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
* Measurement is deferred with raf() to avoid forcing layout during
* component updates, and a ResizeObserver watches for dynamic width
* component updates, and a ResizeObserver watches for dynamic width
* changes (e.g., when web fonts load or async content changes).
* changes (e.g., when web fonts load or async content changes).
*
*
* @internal
* @internal
* @param el - The host element (ion-input, ion-textarea, or ion-select).
* @param el - The host element (ion-input, ion-textarea, or ion-select).
* @param getStartContainer - A callback that returns the start container element.
* @param getStartContainer - A callback that returns the start container element.
* @param shouldApplyAdjustment - A callback that determines whether the adjustment should be applied.
* @param getEndContainer - A callback that returns the end container element.
* @param shouldMeasure - A callback that determines whether the containers should be measured.
*/
*/
export const createStartContainerController = (
export const createStartEndContainerController = (
el: HTMLElement,
el: HTMLElement,
getStartContainer: () => HTMLElement | undefined,
getStartContainer: () => HTMLElement | undefined,
shouldApplyAdjustment: () => boolean
getEndContainer: () => HTMLElement | undefined,
): StartContainerController => {
shouldMeasure: () => boolean
): StartEndContainerController => {
let resizeObserver: ResizeObserver | undefined;
let resizeObserver: ResizeObserver | undefined;
let observedContainer: HTMLElement | undefined;
let observedStartContainer: HTMLElement | undefined;
let observedEndContainer: HTMLElement | undefined;
let measurementRaf: number | undefined;
let measurementRaf: number | undefined;
let transitionRaf: number | undefined;
let transitionRaf: number | undefined;
let appliedAdjustment: string | undefined;
let appliedAdjustment: string | undefined;

let appliedStartEndWidth: string | undefined;
const calculateAdjustment = (): string => {
const startSlot = getStartContainer();
if (!startSlot || !shouldApplyAdjustment()) {
return '';
}


/*
/*
* Round to one decimal place so fractional widths produce a stable
* Round to one decimal place so fractional widths produce a stable
* label offset when the start container is added, removed, or resized.
* label offset and width when a container is added, removed, or resized.
*/
*/
const startContainerWidth = startSlot.getBoundingClientRect().width;
const roundWidth = (width: number) => Math.round(width * 10) / 10;
const roundedWidth = Math.round(startContainerWidth * 10) / 10;

const measureContainers = (): { adjustment: string; startEndWidth: string } => {
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) ? '' : '-';
const sign = isRTL(el) ? '' : '-';
return roundedWidth ? `${sign}${roundedWidth}px` : '0px';

return {
adjustment: startContainerWidth ? `${sign}${startContainerWidth}px` : '0px',
startEndWidth: `${roundWidth(startContainerWidth + endContainerWidth)}px`,
};
};
};


const scheduleAdjustmentUpdate = () => {
const scheduleMeasurement = () => {
// Return if there is already a measurement queued to avoid a flicker
// Return if there is already a measurement queued to avoid a flicker
if (measurementRaf !== undefined) {
if (measurementRaf !== undefined) {
return;
return;
}
}


/*
/*
* Defer the measurement until the next animation frame so layout
* Defer the measurement until the next animation frame so layout
* is measured after the current update has been applied.
* is measured after the current update has been applied.
*/
*/
measurementRaf = raf(() => {
measurementRaf = raf(() => {
measurementRaf = undefined;
measurementRaf = undefined;


const adjustment = calculateAdjustment();
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
// Only suppress the label transition when the label has to move
if (adjustment !== appliedAdjustment) {
if (adjustment !== appliedAdjustment) {
// Discard a removal queued by an earlier change
// Discard a removal queued by an earlier change
if (transitionRaf !== undefined) {
if (transitionRaf !== undefined) {
cancelAnimationFrame(transitionRaf);
cancelAnimationFrame(transitionRaf);
}
}


el.classList.add(SKIP_LABEL_TRANSITION_CLASS);
el.classList.add(SKIP_LABEL_TRANSITION_CLASS);
el.style.setProperty(START_CONTAINER_ADJUSTMENT_VAR, adjustment);
el.style.setProperty(START_CONTAINER_ADJUSTMENT_VAR, adjustment);
appliedAdjustment = adjustment;
appliedAdjustment = adjustment;


// Re-enable the transition once the new offset has been painted
// Re-enable the transition once the new offset has been painted
transitionRaf = raf(() => {
transitionRaf = raf(() => {
transitionRaf = undefined;
transitionRaf = undefined;
el.classList.remove(SKIP_LABEL_TRANSITION_CLASS);
el.classList.remove(SKIP_LABEL_TRANSITION_CLASS);
});
});
}
}


setupResizeObserver();
setupResizeObserver();
});
});
};
};


const disconnectResizeObserver = () => {
const disconnectResizeObserver = () => {
if (resizeObserver) {
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver.disconnect();
resizeObserver = undefined;
resizeObserver = undefined;
observedContainer = undefined;
observedStartContainer = undefined;
observedEndContainer = undefined;
}
}
};
};


const setupResizeObserver = () => {
const setupResizeObserver = () => {
const startSlot = getStartContainer();
const startContainer = getStartContainer();
const endContainer = getEndContainer();


// Stop watching once the adjustment no longer applies
// Stop watching once the measurements no longer apply
if (!startSlot || !shouldApplyAdjustment()) {
if (!startContainer || !shouldMeasure()) {
disconnectResizeObserver();
disconnectResizeObserver();
return;
return;
}
}


// Return when prerendering or already watching this container
// Return when prerendering or already watching these containers
if (typeof ResizeObserver !== 'function' || (resizeObserver && observedContainer === startSlot)) {
if (
typeof ResizeObserver !== 'function' ||
(resizeObserver && observedStartContainer === startContainer && observedEndContainer === endContainer)
) {
return;
return;
}
}


disconnectResizeObserver();
disconnectResizeObserver();


resizeObserver = new ResizeObserver(() => {
resizeObserver = new ResizeObserver(() => {
scheduleAdjustmentUpdate();
scheduleMeasurement();
});
});


observedContainer = startSlot;
observedStartContainer = startContainer;
resizeObserver.observe(startSlot);
resizeObserver.observe(startContainer);

if (endContainer) {
observedEndContainer = endContainer;
resizeObserver.observe(endContainer);
}
};
};


const calculateStartContainerWidth = () => {
const calculateContainerWidths = () => {
scheduleAdjustmentUpdate();
scheduleMeasurement();
};
};


const destroy = () => {
const destroy = () => {
disconnectResizeObserver();
disconnectResizeObserver();


if (measurementRaf !== undefined) {
if (measurementRaf !== undefined) {
cancelAnimationFrame(measurementRaf);
cancelAnimationFrame(measurementRaf);
measurementRaf = undefined;
measurementRaf = undefined;
}
}


// Tearing down mid-toggle would leave the transition disabled
// Tearing down mid-toggle would leave the transition disabled
if (transitionRaf !== undefined) {
if (transitionRaf !== undefined) {
cancelAnimationFrame(transitionRaf);
cancelAnimationFrame(transitionRaf);
transitionRaf = undefined;
transitionRaf = undefined;
el.classList.remove(SKIP_LABEL_TRANSITION_CLASS);
el.classList.remove(SKIP_LABEL_TRANSITION_CLASS);
}
}


// Re-apply from scratch if the element is connected again
// Re-apply from scratch if the element is connected again
appliedAdjustment = undefined;
appliedAdjustment = undefined;
appliedStartEndWidth = undefined;
};
};


return {
return {
calculateStartContainerWidth,
calculateContainerWidths,
destroy,
destroy,
};
};
};
};


export type StartContainerController = {
export type StartEndContainerController = {
calculateStartContainerWidth: () => void;
calculateContainerWidths: () => void;
destroy: () => void;
destroy: () => void;
};
};