File size: 7,666 Bytes
fa9c65f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 | import {
MAX_PANEL_COL_SPAN,
MAX_PANEL_ROW_SPAN,
getExplicitColSpanClass,
getMaxColSpan,
isPanelGridColumnCountReady,
setColSpanClass,
} from '@/utils/panel-grid';
export const INITIAL_PANEL_MOUNT_BUDGET_DESKTOP = 8;
// Mobile mounts fewer panels eagerly; the rest get IntersectionObserver shells (700px
// lookahead) and mount before they scroll into view. Lowered 4->3 to trim boot DOM /
// main-thread work on mobile (#4460 / #4443 U4); the typically 1-2 above-the-fold panels
// still mount eagerly, so no added skeleton flash.
export const INITIAL_PANEL_MOUNT_BUDGET_MOBILE = 3;
export interface PanelMountDeferralInput {
enabled: boolean;
mountedEnabledCount: number;
isMobile: boolean;
}
export type DeferredPanelFootprintSource = 'natural' | 'saved';
export interface DeferredPanelShellFootprint {
className?: string;
rowSpan?: number;
rowSpanSource?: DeferredPanelFootprintSource;
colSpan?: number;
colSpanSource?: DeferredPanelFootprintSource;
collapsed?: boolean;
}
export interface DeferredPanelShellFootprintInput {
panelId: string;
naturalFootprints?: Readonly<Record<string, DeferredPanelShellFootprint | undefined>>;
dynamicFootprints?: Readonly<Record<string, DeferredPanelShellFootprint | undefined>>;
savedRowSpans?: Readonly<Record<string, number | undefined>>;
savedColSpans?: Readonly<Record<string, number | undefined>>;
savedCollapsed?: Readonly<Record<string, boolean | undefined>>;
}
const CONTROL_SELECTOR = [
'button',
'input',
'select',
'textarea',
'a[href]',
'[role="button"]',
'[role="tab"]',
'[role="checkbox"]',
'[role="switch"]',
'[tabindex]:not([tabindex="-1"])',
].join(',');
function clampSpan(value: number | undefined, max: number): number | undefined {
if (typeof value !== 'number' || !Number.isInteger(value)) return undefined;
if (value < 1 || value > max) return undefined;
return value;
}
function addClassTokens(element: HTMLElement, className: string | undefined): void {
if (!className) return;
for (const token of className.split(/\s+/)) {
if (token) element.classList.add(token);
}
}
function hasClassToken(className: string | undefined, token: string): boolean {
return className?.split(/\s+/).includes(token) === true;
}
function getNaturalFootprint({
panelId,
naturalFootprints,
dynamicFootprints,
}: Pick<DeferredPanelShellFootprintInput, 'panelId' | 'naturalFootprints' | 'dynamicFootprints'>): DeferredPanelShellFootprint {
const exact = naturalFootprints?.[panelId];
if (exact) return exact;
if (!dynamicFootprints) return {};
for (const [prefix, footprint] of Object.entries(dynamicFootprints)) {
if (panelId.startsWith(prefix) && footprint) return footprint;
}
return {};
}
export function getInitialPanelMountBudget(isMobile: boolean): number {
return isMobile ? INITIAL_PANEL_MOUNT_BUDGET_MOBILE : INITIAL_PANEL_MOUNT_BUDGET_DESKTOP;
}
export function shouldDeferInitialPanelMount({
enabled,
mountedEnabledCount,
isMobile,
}: PanelMountDeferralInput): boolean {
return enabled && mountedEnabledCount >= getInitialPanelMountBudget(isMobile);
}
export function getDeferredPanelShellFootprint({
panelId,
naturalFootprints,
dynamicFootprints,
savedRowSpans,
savedColSpans,
savedCollapsed,
}: DeferredPanelShellFootprintInput): DeferredPanelShellFootprint {
const natural = getNaturalFootprint({ panelId, naturalFootprints, dynamicFootprints });
const naturalRowSpan = clampSpan(natural.rowSpan, MAX_PANEL_ROW_SPAN);
const savedRowSpan = clampSpan(savedRowSpans?.[panelId], MAX_PANEL_ROW_SPAN);
const naturalColSpan = clampSpan(natural.colSpan, MAX_PANEL_COL_SPAN);
const savedColSpan = clampSpan(savedColSpans?.[panelId], MAX_PANEL_COL_SPAN);
const defaultColSpan = hasClassToken(natural.className, 'panel-wide') ? 2 : 1;
const footprint: DeferredPanelShellFootprint = {
className: natural.className,
collapsed: savedCollapsed?.[panelId] === true,
};
if (savedRowSpan !== undefined) {
footprint.rowSpan = savedRowSpan;
footprint.rowSpanSource = 'saved';
} else if (naturalRowSpan !== undefined && naturalRowSpan > 1) {
footprint.rowSpan = naturalRowSpan;
footprint.rowSpanSource = 'natural';
}
if (savedColSpan !== undefined) {
if (savedColSpan !== defaultColSpan) {
footprint.colSpan = savedColSpan;
footprint.colSpanSource = 'saved';
}
} else if (naturalColSpan !== undefined && naturalColSpan !== defaultColSpan) {
footprint.colSpan = naturalColSpan;
footprint.colSpanSource = 'natural';
}
return footprint;
}
export function createDeferredPanelShell(
panelId: string,
title: string,
footprint: DeferredPanelShellFootprint = {},
): HTMLElement {
const shell = document.createElement('div');
shell.className = 'panel panel-deferred-shell';
shell.dataset.panel = panelId;
shell.dataset.deferredPanel = 'true';
shell.setAttribute('aria-hidden', 'true');
addClassTokens(shell, footprint.className);
const rowSpan = clampSpan(footprint.rowSpan, MAX_PANEL_ROW_SPAN);
if (rowSpan !== undefined) {
shell.classList.add(`span-${rowSpan}`);
if (footprint.rowSpanSource === 'saved') {
shell.classList.add('resized');
}
}
const colSpan = clampSpan(footprint.colSpan, MAX_PANEL_COL_SPAN);
if (colSpan !== undefined) {
shell.classList.add(`col-span-${colSpan}`);
}
if (footprint.collapsed) {
shell.classList.add('panel-collapsed');
}
const header = document.createElement('div');
header.className = 'panel-header panel-deferred-header';
const headerLeft = document.createElement('div');
headerLeft.className = 'panel-header-left';
const titleEl = document.createElement('span');
titleEl.className = 'panel-title';
titleEl.textContent = title;
headerLeft.appendChild(titleEl);
header.appendChild(headerLeft);
const content = document.createElement('div');
content.className = 'panel-content panel-deferred-content';
for (let index = 0; index < 3; index++) {
const line = document.createElement('span');
line.className = 'panel-deferred-skeleton';
line.setAttribute('aria-hidden', 'true');
content.appendChild(line);
}
shell.appendChild(header);
shell.appendChild(content);
return shell;
}
/**
* Clamp a deferred shell's reserved `col-span-N` down to what the rendered
* grid can actually fit. Mirrors {@link Panel}'s `reconcileColSpanAfterAttach`:
* the grid's column template/width is only readable once the shell is attached,
* so when it is not yet connected we retry across up to `attempts` animation
* frames instead of clamping against a 0-width grid (which would read a wrong
* column count and leave an over-wide shell -- a layout shift in the opposite
* direction until the real panel mounts).
*/
export function reconcileDeferredPanelShellColSpan(shell: HTMLElement, attempts = 3): void {
const tryReconcile = (remaining: number): void => {
const currentSpan = getExplicitColSpanClass(shell);
if (currentSpan === undefined) return;
if (!shell.isConnected || !shell.parentElement || !isPanelGridColumnCountReady(shell)) {
if (remaining <= 0 || typeof requestAnimationFrame !== 'function') return;
requestAnimationFrame(() => tryReconcile(remaining - 1));
return;
}
const maxSpan = getMaxColSpan(shell);
const clampedSpan = Math.max(1, Math.min(maxSpan, currentSpan));
if (clampedSpan !== currentSpan) {
setColSpanClass(shell, clampedSpan);
}
};
tryReconcile(attempts);
}
export function countInteractiveControls(root: ParentNode): number {
return root.querySelectorAll(CONTROL_SELECTOR).length;
}
|