diff --git a/selfdrive/carrot/web/css/pages/settings.css b/selfdrive/carrot/web/css/pages/settings.css index d76f565798..ec475dc6d2 100644 --- a/selfdrive/carrot/web/css/pages/settings.css +++ b/selfdrive/carrot/web/css/pages/settings.css @@ -536,10 +536,7 @@ overflow: hidden; opacity: 1; transform: translateY(0); - transition: - grid-template-rows 0.24s cubic-bezier(.2, 0, 0, 1), - opacity 0.16s ease, - transform 0.2s cubic-bezier(.2, 0, 0, 1); + will-change: grid-template-rows, opacity, transform; } .setting-profile-section.is-collapsed .setting-profile-section__body { @@ -554,6 +551,15 @@ overflow: hidden; } +.setting-profile-section.is-collapsing .setting-profile-section__body { + pointer-events: none; + animation: setting-profile-section-fold 0.24s cubic-bezier(.2, 0, 0, 1) both; +} + +.setting-profile-section.is-expanding .setting-profile-section__body { + animation: setting-profile-section-fold 0.24s cubic-bezier(.2, 0, 0, 1) reverse both; +} + .setting-subnav__tab--profile { border-color: color-mix(in srgb, var(--md-primary) 34%, var(--md-outline-var)); } @@ -760,16 +766,30 @@ } } +@keyframes setting-profile-section-fold { + from { + grid-template-rows: 1fr; + opacity: 1; + transform: translateY(0); + } + to { + grid-template-rows: 0fr; + opacity: 0; + transform: translateY(-4px); + } +} + @media (prefers-reduced-motion: reduce) { .setting-profile-panel, .page--setting #items > .setting.ui-stagger-item, .page--setting #items > .setting-profile-section.ui-stagger-item, - .page--setting #deviceItems > .setting.ui-stagger-item { + .page--setting #deviceItems > .setting.ui-stagger-item, + .setting-profile-section.is-collapsing .setting-profile-section__body, + .setting-profile-section.is-expanding .setting-profile-section__body { animation: none; } .setting-profile-section__header, - .setting-profile-section__body, .setting-profile-section__chevron, .setting-toolbar-action { transition: none; @@ -1222,7 +1242,8 @@ display: none; } - .page--setting.setting-layout-split #groupList .groupBtn .setting-group-label { + .page--setting.setting-layout-split #groupList .groupBtn .setting-group-label, + .page--setting.setting-layout-split #deviceGroupList .groupBtn .setting-group-label { display: inline-block; min-width: max-content; transform: translateX(0); @@ -1231,7 +1252,10 @@ .page--setting.setting-layout-split #groupList .groupBtn.is-overflowing:hover .setting-group-label, .page--setting.setting-layout-split #groupList .groupBtn.is-overflowing:focus .setting-group-label, - .page--setting.setting-layout-split #groupList .groupBtn.is-overflowing:active .setting-group-label { + .page--setting.setting-layout-split #groupList .groupBtn.is-overflowing:active .setting-group-label, + .page--setting.setting-layout-split #deviceGroupList .groupBtn.is-overflowing:hover .setting-group-label, + .page--setting.setting-layout-split #deviceGroupList .groupBtn.is-overflowing:focus .setting-group-label, + .page--setting.setting-layout-split #deviceGroupList .groupBtn.is-overflowing:active .setting-group-label { animation: setting-left-text-pan 4.2s ease-in-out 1; } diff --git a/selfdrive/carrot/web/index.html b/selfdrive/carrot/web/index.html index ea72565d79..844e9c20cf 100644 --- a/selfdrive/carrot/web/index.html +++ b/selfdrive/carrot/web/index.html @@ -77,7 +77,7 @@ - + @@ -624,12 +624,12 @@

Home

- + - + diff --git a/selfdrive/carrot/web/js/pages/setting.js b/selfdrive/carrot/web/js/pages/setting.js index cbde3d3611..d4e994fb88 100644 --- a/selfdrive/carrot/web/js/pages/setting.js +++ b/selfdrive/carrot/web/js/pages/setting.js @@ -550,6 +550,78 @@ async function loadSettings(options = {}) { return settingsLoadPromise; } +let settingOverflowSyncRaf = 0; +let settingOverflowSyncTimer = 0; +let settingOverflowResizeObserver = null; + +function measureSettingGroupButtonOverflow(button) { + if (!button) return; + const labelEl = button.querySelector(".setting-group-label"); + if (!labelEl) return; + const buttonWidth = button.clientWidth || 0; + if (buttonWidth <= 0) return; + const shift = Math.min(0, buttonWidth - labelEl.scrollWidth - 8); + button.style.setProperty("--setting-label-shift", `${shift}px`); + button.classList.toggle("is-overflowing", shift < 0); +} + +function syncSettingGroupLabelOverflow(root = document) { + const scope = root && typeof root.querySelectorAll === "function" ? root : document; + if (scope.matches?.("#groupList .groupBtn, #deviceGroupList .groupBtn")) { + measureSettingGroupButtonOverflow(scope); + } + const selector = (scope.id === "groupList" || scope.id === "deviceGroupList") + ? ".groupBtn" + : "#groupList .groupBtn, #deviceGroupList .groupBtn"; + scope.querySelectorAll(selector).forEach(measureSettingGroupButtonOverflow); +} + +function syncSettingOverflow(root = document) { + syncSettingMarqueeOverflow(root); + syncSettingGroupLabelOverflow(root); +} + +function scheduleSettingOverflowSync(root = document, delayMs = 0) { + if (settingOverflowSyncRaf) cancelAnimationFrame(settingOverflowSyncRaf); + if (settingOverflowSyncTimer) { + window.clearTimeout(settingOverflowSyncTimer); + settingOverflowSyncTimer = 0; + } + + const run = () => { + settingOverflowSyncRaf = requestAnimationFrame(() => { + settingOverflowSyncRaf = 0; + syncSettingOverflow(root); + }); + }; + + if (delayMs > 0) { + settingOverflowSyncTimer = window.setTimeout(() => { + settingOverflowSyncTimer = 0; + run(); + }, delayMs); + } else { + run(); + } +} + +function initSettingOverflowObservers() { + if (settingOverflowResizeObserver || typeof ResizeObserver !== "function") return; + settingOverflowResizeObserver = new ResizeObserver(() => scheduleSettingOverflowSync(document)); + [ + "settingScreenHost", + "settingScreenGroups", + "settingScreenItems", + "groupList", + "deviceGroupList", + "items", + "deviceItems", + ].forEach((id) => { + const el = document.getElementById(id); + if (el) settingOverflowResizeObserver.observe(el); + }); +} + function renderGroups(options = {}) { const box = document.getElementById("groupList"); const animateGroups = options.animateGroups !== false; @@ -560,13 +632,7 @@ function renderGroups(options = {}) { const text = Number.isFinite(Number(count)) ? `${label} (${count})` : label; button.title = text; button.innerHTML = `${escapeHtml(text)}`; - requestAnimationFrame(() => { - const labelEl = button.querySelector(".setting-group-label"); - if (!labelEl) return; - const shift = Math.min(0, button.clientWidth - labelEl.scrollWidth - 8); - button.style.setProperty("--setting-label-shift", `${shift}px`); - button.classList.toggle("is-overflowing", shift < 0); - }); + requestAnimationFrame(() => measureSettingGroupButtonOverflow(button)); } if (!animateGroups && box.dataset.groupsSignature === signature && box.children.length === groups.length) { @@ -588,6 +654,7 @@ function renderGroups(options = {}) { setGroupButtonLabel(button, label, g.count); button.onclick = () => selectGroup(g.group); }); + scheduleSettingOverflowSync(box); return; } @@ -617,6 +684,7 @@ function renderGroups(options = {}) { b.onclick = () => selectGroup(g.group); box.appendChild(b); }); + scheduleSettingOverflowSync(box); } function getSettingGroupMeta(group) { @@ -1300,10 +1368,28 @@ function syncSettingMarqueeOverflow(root = document) { root.querySelectorAll(".setting-marquee").forEach((el) => { const content = el.querySelector(".setting-marquee__content"); if (!content) return; + const elWidth = el.clientWidth || 0; + if (elWidth <= 0) return; const overflow = content.scrollWidth > el.clientWidth + 2; const distance = Math.max(0, content.scrollWidth - el.clientWidth + 18); + const nextDistance = `${distance}px`; + const prevDistance = el.style.getPropertyValue("--setting-marquee-distance"); + const wasOverflowing = el.classList.contains("is-overflowing"); + el.style.setProperty("--setting-marquee-distance", nextDistance); + el.scrollLeft = 0; + if (!overflow) { + el.classList.remove("is-overflowing"); + content.style.animation = ""; + return; + } + + if (!wasOverflowing || prevDistance !== nextDistance) { + el.classList.remove("is-overflowing"); + content.style.animation = "none"; + void content.offsetWidth; + content.style.animation = ""; + } el.classList.toggle("is-overflowing", overflow); - el.style.setProperty("--setting-marquee-distance", `${distance}px`); }); } @@ -2153,9 +2239,21 @@ async function renderItems(group, options = {}) { const bodyInner = document.createElement("div"); bodyInner.className = "setting-profile-section__bodyInner"; header.onclick = () => { - const nextExpanded = section.classList.toggle("is-collapsed") ? false : true; + const wasCollapsed = section.classList.contains("is-collapsed"); + const nextExpanded = wasCollapsed; + section.classList.remove("is-expanding", "is-collapsing"); + if (section.__settingProfileMotionTimer) { + window.clearTimeout(section.__settingProfileMotionTimer); + } + void section.offsetWidth; + section.classList.toggle("is-collapsed", !nextExpanded); + section.classList.add(nextExpanded ? "is-expanding" : "is-collapsing"); settingProfileSectionExpandedState.set(stateKey, nextExpanded); header.setAttribute("aria-expanded", nextExpanded ? "true" : "false"); + section.__settingProfileMotionTimer = window.setTimeout(() => { + section.classList.remove("is-expanding", "is-collapsing"); + section.__settingProfileMotionTimer = null; + }, 280); }; body.appendChild(bodyInner); section.appendChild(header); @@ -2347,7 +2445,7 @@ async function renderItems(group, options = {}) { }); itemsBox.dataset.renderedGroup = group; - requestAnimationFrame(() => syncSettingMarqueeOverflow(itemsBox)); + scheduleSettingOverflowSync(itemsBox); if (pendingSettingFocus?.group === group) { requestAnimationFrame(() => focusSettingItem(pendingSettingFocus.name)); @@ -2528,11 +2626,19 @@ window.addEventListener("carrot:paramsrestored", (event) => { window.addEventListener("resize", () => { scheduleSettingViewportLayoutSync(false); - requestAnimationFrame(() => syncSettingMarqueeOverflow(document.getElementById("items") || document)); + scheduleSettingOverflowSync(document, 80); }, { passive: true }); window.addEventListener("orientationchange", () => { scheduleSettingViewportLayoutSync(true); - window.setTimeout(() => syncSettingMarqueeOverflow(document.getElementById("items") || document), 160); + scheduleSettingOverflowSync(document, 180); }, { passive: true }); +if (window.visualViewport) { + window.visualViewport.addEventListener("resize", () => { + scheduleSettingOverflowSync(document, 80); + }, { passive: true }); +} + +initSettingOverflowObservers(); + diff --git a/selfdrive/carrot/web/js/pages/setting_device.js b/selfdrive/carrot/web/js/pages/setting_device.js index f7a7a42dfb..4085a68317 100644 --- a/selfdrive/carrot/web/js/pages/setting_device.js +++ b/selfdrive/carrot/web/js/pages/setting_device.js @@ -141,16 +141,55 @@ function renderDeviceGroups(options = {}) { if (!groupContainer) return; const animateGroups = options.animateGroups !== false; - groupContainer.innerHTML = ""; - if (subnavContainer) subnavContainer.innerHTML = ""; - const visibleGroups = getVisibleDeviceGroups(); if (!visibleGroups.some((group) => group.id === CURRENT_DEVICE_GROUP)) { CURRENT_DEVICE_GROUP = visibleGroups[0]?.id || "Device"; } + const groupEntries = visibleGroups.map((group) => ({ + group, + label: getDeviceGroupLabel(group.id), + })); + const signature = groupEntries.map((entry) => `${entry.group.id}:${entry.label}`).join("|"); + + if ( + !animateGroups && + groupContainer.dataset.deviceGroupsSignature === signature && + groupContainer.children.length === groupEntries.length && + (!subnavContainer || subnavContainer.children.length === groupEntries.length) + ) { + Array.from(groupContainer.children).forEach((button, index) => { + const entry = groupEntries[index]; + button.className = "btn groupBtn"; + if (entry.group.id === CURRENT_DEVICE_GROUP) button.classList.add("active"); + button.dataset.deviceGroup = entry.group.id; + button.innerHTML = `${escapeHtml(entry.label)}`; + button.onclick = () => selectDeviceGroup(entry.group.id); + }); + + if (subnavContainer && subnavContainer.children.length === groupEntries.length) { + Array.from(subnavContainer.children).forEach((tab, index) => { + const entry = groupEntries[index]; + tab.className = "setting-subnav__tab"; + if (entry.group.id === CURRENT_DEVICE_GROUP) tab.classList.add("is-active"); + tab.dataset.deviceGroup = entry.group.id; + tab.textContent = entry.label; + tab.onclick = () => selectDeviceGroup(entry.group.id); + }); + } + if (typeof scheduleSettingOverflowSync === "function") scheduleSettingOverflowSync(groupContainer); + return; + } + + groupContainer.innerHTML = ""; + groupContainer.dataset.deviceGroupsSignature = signature; + if (subnavContainer) { + subnavContainer.innerHTML = ""; + subnavContainer.dataset.deviceGroupsSignature = signature; + } - visibleGroups.forEach((group, index) => { - const label = getDeviceGroupLabel(group.id); + groupEntries.forEach((entry, index) => { + const group = entry.group; + const label = entry.label; const button = document.createElement("button"); button.type = "button"; button.className = animateGroups ? "btn groupBtn ui-stagger-item" : "btn groupBtn"; @@ -173,6 +212,7 @@ function renderDeviceGroups(options = {}) { subnavContainer.appendChild(tab); } }); + if (typeof scheduleSettingOverflowSync === "function") scheduleSettingOverflowSync(groupContainer); } function applyDeviceItemsStagger(container) {