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) {