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:
shkim4 committed 2026-10-20 15:18:49 +09:00
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} />