diff --git a/packages/sw/src/components/sw-header/SWHeader.tsx b/packages/sw/src/components/sw-header/SWHeader.tsx index 49284eb..e6578fd 100644 --- a/packages/sw/src/components/sw-header/SWHeader.tsx +++ b/packages/sw/src/components/sw-header/SWHeader.tsx @@ -318,7 +318,8 @@ export const SWHeader = forwardRef(function SWHeader const menuIds = configuration.map(item => item.id).filter((id): id is SWHeaderMenuId => id in menuSlot) const baseMenu = menuIds.includes("layer") ? "layer" : menuIds[0] const validPromoted = promoted && enabled(promoted) ? promoted : null - const breakpointOverflow = (id: SWHeaderControlId) => id === "color" ? headerWidth < 900 : ["lineStyle", "textStyle", "dimensionStyle"].includes(id) ? headerWidth < 1800 : ["propertyBar", "propertyPanel", "details"].includes(id) ? headerWidth < 1080 : ["print", "popupGuide"].includes(id) ? headerWidth < 640 : id === "drawingMode" ? headerWidth < 1180 : false + const breakpointOverflowAt = (id: SWHeaderControlId, width: number) => id === "color" ? width < 900 : ["lineStyle", "textStyle", "dimensionStyle"].includes(id) ? width < 1800 : ["propertyBar", "propertyPanel", "details"].includes(id) ? width < 1080 : ["print", "popupGuide"].includes(id) ? width < 640 : id === "drawingMode" ? width < 1180 : false + const breakpointOverflow = (id: SWHeaderControlId) => breakpointOverflowAt(id, headerWidth) // Measured More: the parts that can move (the default layout moves the property block as one part; print / K sit in // the window area there and keep the breakpoint). Keys of the moved parts are kept in move order. const squeezeGroups = (controls === undefined @@ -327,7 +328,12 @@ export const SWHeader = forwardRef(function SWHeader : configuration.map(item => ({ key: item.id as string, ids: [item.id] }))) .map((group, index) => ({ ...group, ids: group.ids.filter(id => enabled(id) && squeezeRank[id] !== undefined), index })) .filter(group => group.ids.length > 0) - const squeezedIds = new Set(squeezeGroups.filter(group => squeezed.includes(group.key)).flatMap(group => group.ids)) + // Parts a breakpoint brings back while the header grows can be held in More until there is room (2026-10-20). In + // the default layout print / K sit in the window area: they are held as one part ("actions", the narrow window + // state of sw.css), never squeezed earlier than their breakpoint. + const actionIds = (["print", "popupGuide"] as SWHeaderControlId[]).filter(id => enabled(id)) + const holdGroups = controls === undefined && actionIds.length > 0 ? [...squeezeGroups, { key: "actions", ids: actionIds, index: squeezeGroups.length }] : squeezeGroups + const squeezedIds = new Set(holdGroups.filter(group => squeezed.includes(group.key)).flatMap(group => group.ids)) const isOverflow = (id: SWHeaderControlId) => breakpointOverflow(id) || squeezedIds.has(id) const representative = validPromoted && isOverflow(validPromoted) ? validPromoted : baseMenu const fieldHidden = (id: SWHeaderMenuId) => id !== representative && (isOverflow(id) || (id === baseMenu && representative !== baseMenu)) @@ -346,10 +352,18 @@ export const SWHeader = forwardRef(function SWHeader // Measured More (2026-10-19): on top of the breakpoints, parts move into More while the object area has no room for // them (leading content, tools, labels, a side panel) and return once the room they freed is back, so the header - // never relies on fixed widths alone. Open next to the logo moves first and returns last. One part per pass; the - // passes are synchronous layout effects, so only the final layout is painted. Opening a menu never moves a part: - // dropdowns and the logo menu are positioned layers, which the measurement ignores. + // never relies on fixed widths alone. Open next to the logo moves first when the header shrinks. Growing (2026-10-20, + // hysteresis fix A): a part its breakpoint brings back (print / K, color, the property block, …) returns only when + // the measured room is there, otherwise it waits in More; Open, once back, is not pushed out again while the header + // keeps growing. One part per pass; the passes are synchronous layout effects, so only the final layout is painted. + // Opening a menu never moves a part: dropdowns and the logo menu are positioned layers, which the measurement ignores. const squeezeRef = useRef(new Map()) + // Header width of this pass and of the previous one (growing or shrinking), and the width at which Open came back. + const widthRef = useRef({ current: 0, previous: 0 }) + const openReturnRef = useRef(null) + // Parts that left More at the current width: they are not held again (nor given the More relief) at this width, so + // the passes always settle (one hold / return decision per part and width). + const releasedRef = useRef<{ width: number; keys: Set }>({ width: 0, keys: new Set() }) useLayoutEffect(() => { const header = fieldsRef.current?.closest(".hm-sw-header") const area = panelAnchorRef.current @@ -359,16 +373,39 @@ export const SWHeader = forwardRef(function SWHeader collapseRef.current = null if (openCollapsed) return setOpenCollapsed(false) } - // Parts removed from the configuration leave the list silently. - const valid = squeezed.filter(key => squeezeGroups.some(group => group.key === key)) - if (valid.length !== squeezed.length) return setSqueezed(valid) + if (widthRef.current.current !== headerWidth) widthRef.current = { previous: widthRef.current.current, current: headerWidth } + const { previous } = widthRef.current + if (releasedRef.current.width !== headerWidth) releasedRef.current = { width: headerWidth, keys: new Set() } + const released = releasedRef.current.keys + const growing = previous > 0 && headerWidth > previous + 0.5 + // Shrinking (or below the width where it came back), Open is again the first part to move. + const shrinking = previous > 0 && headerWidth < previous - 0.5 + if (openReturnRef.current !== null && (openCollapsed || shrinking || headerWidth < openReturnRef.current - 0.5)) openReturnRef.current = null + // While Open is in More the header may shrink further: it returns relative to the narrowest width since then. + if (openCollapsed && collapseRef.current && headerWidth < collapseRef.current.at) collapseRef.current = { ...collapseRef.current, at: headerWidth } + // Parts removed from the configuration, and parts whose breakpoint now keeps them in More anyway, leave the list silently. + const valid = squeezed.filter(key => holdGroups.some(group => group.key === key && !group.ids.every(breakpointOverflow))) + if (valid.length !== squeezed.length) { + for (const key of squeezed) if (!valid.includes(key)) squeezeRef.current.delete(key) + return setSqueezed(valid) + } const room = measureRoom(header, area) const last = squeezed[squeezed.length - 1] const metrics = last === undefined ? undefined : squeezeRef.current.get(last) // The room a part frees is measured once, right after it moved (More appearing and padding changes included). if (metrics && metrics.room === undefined) metrics.room = room - metrics.before if (room < -0.5) { - const open = openShown ? leadingRef.current?.querySelector("[data-document-open]") : null + // Growing: a part its breakpoint just brought back stays in More until the room it needs is there (it returns + // through the measured return below), so it never pushes Open or another part out. + const held = growing ? holdGroups.find(group => !squeezed.includes(group.key) && !released.has(group.key) && group.ids.some(id => breakpointOverflowAt(id, previous)) + && !group.ids.some(breakpointOverflow) && !group.ids.some(id => id === baseMenu || id === validPromoted)) : undefined + if (held) { + squeezeRef.current.set(held.key, { before: room }) + return setSqueezed([...squeezed, held.key]) + } + // Open that came back while growing keeps its place: another part moves first; Open only when none can. + const keepOpen = openReturnRef.current !== null && squeezeCandidates.length > 0 + const open = openShown && !keepOpen ? leadingRef.current?.querySelector("[data-document-open]") : null if (open) { collapseRef.current = { at: header.getBoundingClientRect().width, width: open.getBoundingClientRect().width + 2 } return setOpenCollapsed(true) @@ -378,14 +415,29 @@ export const SWHeader = forwardRef(function SWHeader squeezeRef.current.set(next.key, { before: room }) setSqueezed([...squeezed, next.key]) } + } else if (openCollapsed && collapseRef.current && headerWidth >= collapseRef.current.at + collapseRef.current.width) { + // Open returns once the header is wider than when it moved by the Open width (no flicker at the edge). It does not + // wait for the parts still in More (2026-10-20): it takes its width from the file name, and while the header + // keeps growing it keeps its place (a part that needs room waits in More instead). + openReturnRef.current = headerWidth + setOpenCollapsed(false) } else if (last !== undefined) { - if (room >= (metrics?.room ?? Infinity) - 0.5) { + // The last part leaving More also removes the More button when nothing else is in it at this width (the room it + // freed was measured with More already there, e.g. a part held at a narrower tier). + const more = squeezed.length === 1 && !openCollapsed && !released.has(last) && !configuration.some(item => breakpointOverflow(item.id)) + ? area.querySelector(":scope > .hm-sw-header__more") : null + let relief = 0 + if (more) { + // Measured, like the room itself: the same synchronous read with More taken out (never painted). + more.style.display = "none" + relief = measureRoom(header, area) - room + more.style.display = "" + } + if (room + relief >= (metrics?.room ?? Infinity) - 0.5) { + released.add(last) squeezeRef.current.delete(last) setSqueezed(squeezed.slice(0, -1)) } - } else if (openCollapsed && collapseRef.current && headerWidth >= collapseRef.current.at + collapseRef.current.width) { - // Open returns once the header is wider than when it moved by the Open width (no flicker at the edge). - setOpenCollapsed(false) } }) @@ -592,7 +644,7 @@ export const SWHeader = forwardRef(function SWHeader }) return ( -
+
setHomeOpen(!homeOpen, "home") : onHomeClick} interactive={shown("home")} logo={logo} logoSrc={logoSrc} logoAlt={logoAlt} aria-expanded={homeMenu && shown("home") ? homeOpen : undefined} aria-controls={homeOpen ? homeMenu?.id ?? homeMenuId : undefined} /> diff --git a/packages/sw/src/theme/sw.css b/packages/sw/src/theme/sw.css index 94c6424..ac3a67c 100644 --- a/packages/sw/src/theme/sw.css +++ b/packages/sw/src/theme/sw.css @@ -1910,6 +1910,11 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l without the property block More sits at the end of the object area (as < 1080). */ .hm-sw-header__object-area[data-squeezed~="drawingMode"]:not([data-configured]) > .hm-sw-header__fields { padding-left: 4px; } .hm-sw-header__object-area[data-squeezed~="properties"] > .hm-sw-header__more { margin-left: auto; margin-right: 4px; } +/* Print / K held in More while the header grows (SWHeader data-actions-held, 2026-10-20): the window area keeps the + narrow state of the 640px tier (print / K and the move area hidden, 64px) until the object area has the room they + need. The file name keeps the clamp of the current width, so the measured room is the room they would take back. */ +.hm-sw-header[data-actions-held] :is(.hm-sw-header__actions, .hm-sw-header__motion) { display: none; } +.hm-sw-header[data-actions-held] .hm-sw-header__window { flex-basis: 64px; width: 64px; } /* Logo (home) menu layer (SWHeader homeMenu, 2026-10-19). Top of the header, left / max-width measured from the object area (−2px) to the window area. Above the property bar anchor (2), under the dropdowns (30) and the