fix(sw-header): stabilize measured More while the header grows
Hysteresis fix A for the Open placement regression introduced by 170a5c2.
- Growing: a part its breakpoint brings back (print / K, color, the property
block, line / text / dimension style, drawing mode) returns only when the
measured room is there; otherwise it stays in More. Default-layout print / K
are held through data-actions-held (window area keeps its 640px-tier state).
- Open that came back while growing is not pushed into More again by another
part; shrinking still moves Open first, and its return width follows the
narrowest width while it is collapsed.
- Open no longer waits for the parts still in More; the last part leaving More
takes the More button relief into account (measured, never painted).
- A part released at a width is not held again at that width, so the layout
passes always settle (fixes the maximum update depth error found on
sw-header--responsive-more during verification).
Trace 520 -> 1080 (data-width 520 frame): before
[[520,0],[560,0],[600,1],[640,0],[680,0],[720,1],[760,1],[820,1],[900,0],[1080,0]],
after [[520,0],[560,0],[600,1],[640,1],[680,1],[720,1],[760,1],[820,1],[900,1],[1080,1]].
This commit is contained in:
1 parent
2b0e1a2262
commit
bbb0e71c91
2 files changed
+71
-14
No files matched your search
@@ -318,7 +318,8 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(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<HTMLElement, SWHeaderProps>(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<HTMLElement, SWHeaderProps>(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<string, { before: number; room?: number }>())
|
||||
// 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<number | null>(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<string> }>({ width: 0, keys: new Set() })
|
||||
useLayoutEffect(() => {
|
||||
const header = fieldsRef.current?.closest<HTMLElement>(".hm-sw-header")
|
||||
const area = panelAnchorRef.current
|
||||
@@ -359,16 +373,39 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(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<HTMLElement>("[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<HTMLElement>("[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<HTMLElement, SWHeaderProps>(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<HTMLElement>(":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<HTMLElement, SWHeaderProps>(function SWHeader
|
||||
})
|
||||
|
||||
return (
|
||||
<header ref={ref} className={`hm-sw-header${className ? ` ${className}` : ""}`} data-leading={openShown ? "open" : hasLeading ? "" : undefined} data-home-menu-open={homeOpen || undefined} {...rest}>
|
||||
<header ref={ref} className={`hm-sw-header${className ? ` ${className}` : ""}`} data-leading={openShown ? "open" : hasLeading ? "" : undefined} data-home-menu-open={homeOpen || undefined} data-actions-held={squeezed.includes("actions") || undefined} {...rest}>
|
||||
<div className="hm-sw-header__bar">
|
||||
<SWHeaderHome onClick={!shown("home") ? undefined : homeMenu ? () => 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} />
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user