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} />
+5
View File
@@ -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