feat(sw): viewer header keeps every menu down to 520px
Deploy Storybook / deploy (push) Successful in 1m16s

- File name: clamp(80px, min(45cqw - 256px, 100cqw - 780px), 360px),
  56px below 560px; 1280px and wider unchanged
- Model / Layout tabs narrow (name, then count ellipsized) down to the new
  --hm-sw-viewer-tabs-min-width (48px) before anything moves into More;
  the measurement counts them at that minimum once every icon step applies
- Switch items get an optional shortLabel shown below 560px (어두운 배경 ->
  배경); counts hidden there
- More appears only below about 520px; viewer test and AI guides updated
This commit is contained in:
shkim4 committed 2026-10-26 20:00:50 +09:00
1 parent 7976801034
commit cabd2f67de
8 files changed
+83 -34

No files matched your search

@@ -121,9 +121,11 @@ function responsiveSteps(groups: SWViewerHeaderGroup[], extraIcons: string[] = [
* width up to its usual width (so a short name leaves room to the menus), the object surface without stretching and
* the trailing area without its auto margin (`data-measuring`, sw-viewer.css). Read and reset in one layout effect,
* never painted; positioned layers (menus, logo menu) never count. Same method as SWHeader's measured More.
* `tabsAtMin` (2026-10-26): once every icon step is applied, the Model / Layout tabs count at their minimum width
* (`data-measuring="min"`), so they narrow with an ellipsis before anything moves into More.
*/
function measureRoom(root: HTMLElement) {
root.setAttribute("data-measuring", "")
function measureRoom(root: HTMLElement, tabsAtMin = false) {
root.setAttribute("data-measuring", tabsAtMin ? "min" : "")
const style = getComputedStyle(root), box = root.getBoundingClientRect()
const end = box.right - parseFloat(style.borderRightWidth) - parseFloat(style.paddingRight)
let used = box.left + parseFloat(style.borderLeftWidth) + parseFloat(style.paddingLeft)
@@ -226,7 +228,11 @@ function SwitchField({ item, onCommand }: { item: Extract<SWViewerHeaderItem, {
const id = useId()
return (
<div className="hm-sw-viewer-header__switch-field" title={item.title}>
<label htmlFor={id} className="hm-sw-viewer-header__switch-label">{item.label}</label>
{/* shortLabel (2026-10-26): the narrow-header caption (sw-viewer.css); the full label stays the accessible name. */}
<label htmlFor={id} className="hm-sw-viewer-header__switch-label" data-short={item.shortLabel ? "" : undefined}>
<span className="hm-sw-viewer-header__switch-label-full">{item.label}</span>
{item.shortLabel && <span className="hm-sw-viewer-header__switch-label-short" aria-hidden="true">{item.shortLabel}</span>}
</label>
<SWViewerSwitch id={id} checked={item.checked} disabled={item.disabled}
onCheckedChange={next => { item.onCheckedChange?.(next); onCommand({ id: item.id, itemId: item.id, value: next }) }} />
</div>
@@ -342,7 +348,7 @@ export const SWViewerHeader = forwardRef<HTMLElement, SWViewerHeaderProps>(funct
if (level !== 0) return setLevel(0)
}
if (level > steps.length) return setLevel(steps.length)
const room = measureRoom(root)
const room = measureRoom(root, level >= steps.filter(step => step.kind === "icons").length)
const freed = freedRef.current
const last = level > 0 ? freed[level - 1] : undefined
if (last && last.room === undefined) last.room = room - last.before
@@ -108,6 +108,11 @@ export type SWViewerHeaderItem =
| (SWViewerHeaderItemBase & {
/** Caption above a switch, e.g. 2.5D. */
type: "switch"
/**
* Shorter caption shown instead of `label` in a narrow header (below 560px header width), e.g. 배경 for 어두운 배경.
* `label` stays the accessible name. Default: `label`.
*/
shortLabel?: string
checked: boolean
onCheckedChange?: (checked: boolean) => void
})
+33 -13
View File
@@ -28,6 +28,9 @@
--hm-sw-viewer-button-height: 34px;
/* Model / Layout field face: widest size (2026-10-23); narrower when the header has less room. */
--hm-sw-viewer-tabs-max-width: 378px;
/* Narrowest Model / Layout field (2026-10-26): below its natural width the tabs narrow down to this (names and counts
ellipsized) before the measured steps move anything into More. */
--hm-sw-viewer-tabs-min-width: 48px;
/* Model / Layout colors and shape (Figma 7652:40344, 2026-10-23). Used only by .hm-sw-viewer-header__tabs / __tab:
set any of them on the SW scope or the header to recolor the tabs alone. Container: */
/* The outer height of the adjacent field group (40px control row + its 1px top and bottom border = 42px). */
@@ -165,10 +168,11 @@ button.hm-sw-viewer-header__brand { text-align: left; }
align-items: center;
/* Very large shrink weight (2026-10-23): the file name gives way first, as before; the object (shrink 1) only once
the file name is at its minimum (see .hm-sw-viewer-header__object).
2026-10-26 (user request): narrower below 1280px (171px at 950px, was 25cqw = 237px) so a 950px header has no More;
from 1280px the same as before (320px, 360px from 1370px). The measurement (data-measuring) uses the same value. */
flex: 0 1000000 clamp(140px, calc(45cqw - 256px), 360px);
width: clamp(220px, calc(45cqw - 256px), 360px);
2026-10-26 (user request): narrower below 1280px so 950px and 860px headers have no More (about 170px at 950px and
80px at 860px, was 25cqw = 237px / 215px; minimum 80px, was 140px); from 1280px the same as before (320px, 360px
from 1370px). The measurement (data-measuring) uses the same value. */
flex: 0 1000000 clamp(80px, min(45cqw - 256px, 100cqw - 780px), 360px);
width: clamp(220px, min(45cqw - 256px, 100cqw - 780px), 360px);
min-width: 56px;
height: calc(var(--hm-sw-viewer-height) - 2px);
padding: 0 16px 0 24px;
@@ -314,7 +318,7 @@ button.hm-sw-viewer-header__brand { text-align: left; }
Every part at its natural width: the file name at its usual flex basis (2026-10-21 user request: the header width
decides it, never the length of the name, so a short and a long name give the same steps, Open label and widths),
the object surface neither stretched nor shrunk, the trailing area without its auto margin. */
.hm-sw-viewer-header[data-measuring] .hm-sw-viewer-header__document { flex: 0 0 clamp(140px, calc(45cqw - 256px), 360px); }
.hm-sw-viewer-header[data-measuring] .hm-sw-viewer-header__document { flex: 0 0 clamp(80px, min(45cqw - 256px, 100cqw - 780px), 360px); }
.hm-sw-viewer-header[data-measuring] .hm-sw-viewer-header__object { flex: 0 0 auto; }
.hm-sw-viewer-header[data-measuring] .hm-sw-viewer-header__actions { margin-left: 0; }
/* While the logo menu or the document list is open, the header takes the viewer dropdown layer (SWViewerMenu, 60) so
@@ -403,8 +407,11 @@ button.hm-sw-viewer-header__brand { text-align: left; }
not-selected one has a green top gloss. Every color is a --hm-sw-viewer-tab(s)-* token (see the token block).
Width (2026-10-23, kept): equal columns (1fr: each at least the widest name + count, so they never overlap). A group
holding only the tabs starts at that natural width and takes the object's free room up to
--hm-sw-viewer-tabs-max-width (the container is the group face then); with less room it stays narrower, and below
the natural width the measured steps move it into More as before (`data-measuring` uses the natural width). */
--hm-sw-viewer-tabs-max-width (the container is the group face then); with less room it stays narrower.
2026-10-26 (user request): below the natural width the tabs narrow (names, then counts ellipsized) down to
--hm-sw-viewer-tabs-min-width instead of moving into More. The group's own width is that minimum and it grows into
the free room, so the file name keeps its width; the measurement uses the natural width until every icon step is
applied, then the minimum (`data-measuring="min"`, SWViewerHeader). */
.hm-sw-viewer-header__tabs {
position: relative;
display: grid;
@@ -431,7 +438,9 @@ button.hm-sw-viewer-header__brand { text-align: left; }
.hm-sw-viewer-header__tabs::after { content: ""; position: absolute; z-index: 1; inset: 0; border-radius: var(--hm-sw-viewer-tabs-radius); box-shadow: var(--hm-sw-viewer-tabs-shadow); pointer-events: none; }
/* A group holding only the tabs: the container is its face (no field face around it, as in Figma: the tabs sit on
the header object surface). */
.hm-sw-viewer-header__group:has(> .hm-sw-viewer-header__tabs:only-child) { flex: 1 0 auto; box-sizing: border-box; max-width: var(--hm-sw-viewer-tabs-max-width); min-height: 0; padding: 0; border: 0; background: none; box-shadow: none; }
.hm-sw-viewer-header__group:has(> .hm-sw-viewer-header__tabs:only-child) { flex: 1 0 auto; box-sizing: border-box; width: var(--hm-sw-viewer-tabs-min-width); max-width: var(--hm-sw-viewer-tabs-max-width); min-height: 0; padding: 0; border: 0; background: none; box-shadow: none; }
.hm-sw-viewer-header[data-measuring] .hm-sw-viewer-header__group:has(> .hm-sw-viewer-header__tabs:only-child) { flex: 0 0 auto; width: max-content; }
.hm-sw-viewer-header[data-measuring="min"] .hm-sw-viewer-header__group:has(> .hm-sw-viewer-header__tabs:only-child) { width: var(--hm-sw-viewer-tabs-min-width); }
.hm-sw-viewer-header__group > .hm-sw-viewer-header__tabs:only-child { flex: 1 1 auto; }
/* 2026-10-23 user value: the tabs-only group that starts the object surface gets 1px more on its right (the buttons
give it back in their side padding). Other groups keep the object gap. */
@@ -475,18 +484,20 @@ button.hm-sw-viewer-header__brand { text-align: left; }
.hm-sw-viewer-header .hm-sw-viewer-header__tab[data-state="active"] { --hm-sw-viewer-tab-border-face: var(--hm-sw-viewer-tab-active-border); color: var(--hm-sw-viewer-tab-active-text); }
.hm-sw-viewer-header__count { font-size: var(--hm-sw-viewer-label-size); font-weight: 400; font-variant-numeric: tabular-nums; }
/* In a tab the count has the name's size (Figma: 14px Regular) and color. */
.hm-sw-viewer-header__tab .hm-sw-viewer-header__count { flex: 0 0 auto; font-size: inherit; }
.hm-sw-viewer-header__tab .hm-sw-viewer-header__count { flex: 0 1 auto; min-width: 0; overflow: clip; overflow-clip-margin: var(--hm-sw-header-text-outline-room); text-overflow: ellipsis; white-space: nowrap; font-size: inherit; }
/* Long names (2026-10-23 user decision): the name gives way first with an ellipsis, the count keeps its width; the
outline may paint 2px outside the clipped name (as the file name). The name counts at most
--hm-sw-viewer-tab-name-basis in the tabs' natural width (width + max-width: max-content: shorter names keep their own
width), so a long name changes neither the tabs' width nor the measured steps of the other items; it grows (flex-grow,
up to its text) only into room the box has anyway. */
.hm-sw-viewer-header .hm-sw-viewer-header__tab { min-width: 0; }
up to its text) only into room the box has anyway.
2026-10-26 (narrow tabs): the name gives way first (large shrink weight) down to its first letter + ellipsis, then the
count ellipsizes too; whatever is still too wide is clipped at the button edge. */
.hm-sw-viewer-header .hm-sw-viewer-header__tab { min-width: 0; overflow: clip; }
.hm-sw-viewer-header__tab-name {
flex: 1 1 auto;
flex: 1 1000 auto;
width: var(--hm-sw-viewer-tab-name-basis);
max-width: max-content;
min-width: 0;
min-width: 1.6em;
overflow: clip;
overflow-clip-margin: var(--hm-sw-header-text-outline-room);
text-overflow: ellipsis;
@@ -503,6 +514,7 @@ button.hm-sw-viewer-header__brand { text-align: left; }
min-height: var(--hm-sw-viewer-control-height);
padding-inline: 8px;
}
.hm-sw-viewer-header__switch-label-short { display: none; }
.hm-sw-viewer-header__switch-label { color: var(--hm-sw-viewer-muted); font-size: var(--hm-sw-viewer-label-size); font-weight: 400; line-height: 1; white-space: nowrap; }
.hm-sw-viewer-header :is(.hm-sw-viewer-header__button, .hm-sw-viewer-header__brand, .hm-sw-viewer-switch):focus-visible {
@@ -668,6 +680,14 @@ button.hm-sw-viewer-header__brand { text-align: left; }
@container hm-sw-viewer (max-width: 559.98px) {
.hm-sw-viewer-header__document { padding-right: 8px; }
.hm-sw-viewer-status__label { display: none; }
/* 2026-10-26 (user request: every menu stays in the header): the file name goes down to 56px and a switch with a
`shortLabel` shows it as its caption (어두운 배경 → 배경); the full label stays its accessible name. */
.hm-sw-viewer-header__document, .hm-sw-viewer-header[data-measuring] .hm-sw-viewer-header__document { flex-basis: 56px; }
.hm-sw-viewer-header__switch-label[data-short] > .hm-sw-viewer-header__switch-label-full { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.hm-sw-viewer-header__switch-label[data-short] > .hm-sw-viewer-header__switch-label-short { display: inline; }
.hm-sw-viewer-header__switch-field { padding-inline: 1px; }
/* The Model / Layout counts are hidden (2026-10-26 user request); the names stay, ellipsized. */
.hm-sw-viewer-header__tab .hm-sw-viewer-header__count { display: none; }
}
/* ---------- Switch (header / panel faces) ---------- */