feat(sw): viewer header logo menu and file list; simplify SW Storybook
SW Viewer Header (approved optional props, public export names unchanged at 170): - SWViewerHeader: logoMenu, documents, activeDocumentId, onDocumentSelect, onDocumentOpen, documentOpenLabel; measured responsive toggles (full -> icons -> More) with the SWHeader More / file-list styling. - SW AI guides, ExampleH, viewer/text-weight/theme-inheritance tests and the logo-document-menu tests with their QA record. SW Storybook cleanup (Story IDs and test fixtures kept): - Sidebar: Overview -> "SW Header" (first), 전체 Footer -> "SW Footer", Footer/Viewer responsive pages -> "Responsive More", Viewer content-variants -> "Settings Popup" (merges the settings popup page). - Hidden by the verification tag (direct URL kept): Header Dropdown, Document Open, Interactive States, Document Dropdown, Hidden Sub Controls, Home Menu standalone; Footer tool groups / custom content / parts; SW/Theme; Viewer menu groups / item states / variable size / settings panel / Header + Dropdown. - Removed demo UI: Overview size and callback output (demo data and selection only with &verification), Home Menu Figma node table and width demo, Header Responsive More reduced to 1440 / 768 / 390, Popup reopen button, property-panel comparison captions, Viewer context-menu demo (&verification only). - Tests: storybook-inventory records the hidden and renamed Stories; overview / range-cross-browser / viewer open the demo controls with &verification; integrated-popup reloads the Story instead of the reopen button; dropdown-consolidation asserts Dropdown is hidden from the sidebar and still opens by its direct URL.
This commit is contained in:
1 parent
46119eacb5
commit
23d522c136
57 files changed
+2623
-333
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react"
|
||||
import { useEffect, useId, useRef, useState, type KeyboardEvent, type ReactNode, type RefObject } from "react"
|
||||
import { useOutsidePointerDown } from "../../shared/use-outside-pointer"
|
||||
import { SWHeaderKGuidePreview } from "./SWHeaderKGuide"
|
||||
|
||||
@@ -6,6 +6,8 @@ export interface SWHeaderMoreItem {
|
||||
visible?: boolean
|
||||
id: string
|
||||
label: string
|
||||
/** 16px icon left of the label (SWViewerHeader logo menu, 2026-10-21). SWHeader's own items have none. */
|
||||
icon?: ReactNode
|
||||
/** Current value shown at the right (e.g. the line style name). */
|
||||
value?: string
|
||||
/**
|
||||
@@ -24,29 +26,73 @@ export interface SWHeaderMoreItem {
|
||||
guide?: boolean
|
||||
}
|
||||
|
||||
/** Props of a replacement trigger (`renderTrigger`): spread them on one button. */
|
||||
export interface SWHeaderMoreTriggerProps {
|
||||
ref: RefObject<HTMLButtonElement | null>
|
||||
"aria-haspopup": "menu"
|
||||
"aria-expanded": boolean
|
||||
"aria-controls"?: string
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export interface SWHeaderMoreProps {
|
||||
items: SWHeaderMoreItem[]
|
||||
/** Shows the trigger at any width (an item moved in by measurement, not by a breakpoint). */
|
||||
forceVisible?: boolean
|
||||
/**
|
||||
* Internal reuse of the same menu with another trigger (SWViewerHeader logo menu, 2026-10-21). Default: the
|
||||
* `...` button. With it, ArrowDown on the closed trigger opens the menu on its first item and a selected item
|
||||
* returns focus to the trigger; the `...` button keeps its existing behavior.
|
||||
*/
|
||||
renderTrigger?: (props: SWHeaderMoreTriggerProps) => ReactNode
|
||||
/** Title above the items, shown only when at least one item follows. Title and items are one group: no divider. */
|
||||
title?: string
|
||||
/** Accessible name of the menu when there is no `title`. Default `추가 도구`. */
|
||||
label?: string
|
||||
/** Controlled open state (menus that must not be open together). Leave undefined to let the component manage it. */
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
/** Menu edge aligned to the trigger. Default `end` (the `...` button at the right). */
|
||||
align?: "start" | "end"
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Overflow menu that keeps every moved control reachable. */
|
||||
export function SWHeaderMore({ items, forceVisible }: SWHeaderMoreProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
export function SWHeaderMore({ items, forceVisible, renderTrigger, title, label = "추가 도구", open: openProp, onOpenChange, align = "end", className }: SWHeaderMoreProps) {
|
||||
const [internalOpen, setInternalOpen] = useState(false)
|
||||
const open = openProp ?? internalOpen
|
||||
const setOpen = (next: boolean) => {
|
||||
if (openProp === undefined) setInternalOpen(next)
|
||||
onOpenChange?.(next)
|
||||
}
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const menuId = useId()
|
||||
const guideId = useId()
|
||||
const guideRef = useRef<HTMLButtonElement>(null)
|
||||
const titleId = useId()
|
||||
const [guideOpen, setGuideOpen] = useState(false)
|
||||
// Set when the keyboard opens a replacement trigger, so the first item gets focus once the menu renders.
|
||||
const focusFirstRef = useRef(false)
|
||||
useEffect(() => { if (!open) setGuideOpen(false) }, [open])
|
||||
|
||||
useOutsidePointerDown(open, rootRef, () => setOpen(false))
|
||||
|
||||
useEffect(() => {
|
||||
if (open && focusFirstRef.current) {
|
||||
focusFirstRef.current = false
|
||||
visibleItems()[0]?.focus()
|
||||
}
|
||||
})
|
||||
|
||||
const visibleItems = () => Array.from(rootRef.current?.querySelectorAll<HTMLButtonElement>('[role^="menuitem"]:not(:disabled)') ?? []).filter(item => item.offsetParent !== null)
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && open) {
|
||||
if (renderTrigger && event.key === "ArrowDown" && !open && event.target === triggerRef.current) {
|
||||
event.preventDefault()
|
||||
focusFirstRef.current = true
|
||||
setOpen(true)
|
||||
} else if (event.key === "Escape" && open) {
|
||||
event.stopPropagation()
|
||||
setOpen(false)
|
||||
triggerRef.current?.focus()
|
||||
@@ -59,9 +105,18 @@ export function SWHeaderMore({ items, forceVisible }: SWHeaderMoreProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const triggerProps: SWHeaderMoreTriggerProps = {
|
||||
ref: triggerRef,
|
||||
"aria-haspopup": "menu",
|
||||
"aria-expanded": open,
|
||||
"aria-controls": open ? menuId : undefined,
|
||||
onClick: () => setOpen(!open),
|
||||
}
|
||||
const titled = !!title && items.some(item => item.visible !== false)
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="hm-sw-header__more" data-forced={forceVisible || undefined} onKeyDown={onKeyDown} onPointerLeave={() => setGuideOpen(false)}>
|
||||
<button
|
||||
<div ref={rootRef} className={`hm-sw-header__more${className ? ` ${className}` : ""}`} data-forced={forceVisible || undefined} onKeyDown={onKeyDown} onPointerLeave={() => setGuideOpen(false)}>
|
||||
{renderTrigger ? renderTrigger(triggerProps) : <button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className="hm-sw-header__more-trigger"
|
||||
@@ -69,16 +124,19 @@ export function SWHeaderMore({ items, forceVisible }: SWHeaderMoreProps) {
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? menuId : undefined}
|
||||
onClick={() => setOpen(value => !value)}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<svg width="16" height="4" viewBox="0 0 16 4" aria-hidden="true" focusable="false">
|
||||
<circle cx="2" cy="2" r="1.6" fill="currentColor" />
|
||||
<circle cx="8" cy="2" r="1.6" fill="currentColor" />
|
||||
<circle cx="14" cy="2" r="1.6" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
</button>}
|
||||
{open && (
|
||||
<div id={menuId} role="menu" aria-label="추가 도구" className="hm-sw-header__more-menu">
|
||||
<div id={menuId} role="menu" aria-label={titled ? undefined : label} aria-labelledby={titled ? titleId : undefined}
|
||||
className="hm-sw-header__more-menu" data-align={align === "start" ? "start" : undefined}>
|
||||
{/* Title and its items are one group: no divider between them (dividers only separate groups). */}
|
||||
{titled && <div id={titleId} className="hm-sw-header__more-title">{title}</div>}
|
||||
{items.map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
@@ -100,8 +158,10 @@ export function SWHeaderMore({ items, forceVisible }: SWHeaderMoreProps) {
|
||||
onClick={() => {
|
||||
item.onSelect?.()
|
||||
setOpen(false)
|
||||
if (renderTrigger) triggerRef.current?.focus()
|
||||
}}
|
||||
>
|
||||
{item.icon && <span className="hm-sw-header__more-icon" aria-hidden="true">{item.icon}</span>}
|
||||
<span>{item.label}</span>
|
||||
{item.value && <span className="hm-sw-header__more-value">{item.value}</span>}
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { forwardRef, useId, useLayoutEffect, useRef, useState, type ButtonHTMLAttributes, type HTMLAttributes, type KeyboardEvent, type ReactNode } from "react"
|
||||
import { forwardRef, useEffect, useId, useLayoutEffect, useRef, useState, type ButtonHTMLAttributes, type HTMLAttributes, type KeyboardEvent, type ReactNode } from "react"
|
||||
import { moveRovingFocus } from "../shared/roving-focus"
|
||||
import { SWHeaderDocumentTitle } from "../sw-header/components/SWHeaderDocumentTitle"
|
||||
import { SWHeaderMore } from "../sw-header/components/SWHeaderMore"
|
||||
import type { SWHeaderDocument } from "../sw-header/types"
|
||||
import { SWToolButton } from "../sw-tool/SWTool"
|
||||
import { SWViewerMenu } from "./SWViewerMenu"
|
||||
import { SWViewerSwitch } from "./SWViewerControls"
|
||||
import { SWViewerIcon } from "./SWViewerIcon"
|
||||
@@ -25,8 +29,42 @@ export interface SWViewerHeaderProps extends Omit<HTMLAttributes<HTMLElement>, "
|
||||
documentPlaceholder?: string
|
||||
/** Tooltip of the file name. Default: the name, or `파일 열기 또는 샘플을 선택하세요`. */
|
||||
documentTooltip?: string
|
||||
/** Replaces the file-name text (the dark document well stays). */
|
||||
/** Replaces the file-name text (the dark document well stays). Takes precedence over `documents`. */
|
||||
document?: ReactNode
|
||||
/**
|
||||
* Logo menu (2026-10-21): the logo cap opens a file menu flush under the header (SWHeaderMore behavior: Escape,
|
||||
* outside click, arrow keys; ArrowDown opens it on its first item and focus returns to the logo) with the face, colors,
|
||||
* size, spacing and type of the other viewer header dropdowns (the More face). `title` adds a
|
||||
* title row directly above the items (title + items are one group: no divider), shown only when an item follows;
|
||||
* without a title only the items are listed. `icon`: 16px icon left of the label (SWViewerMenu icon slot).
|
||||
* Each item runs its `onSelect`, then `onCommand` with `{ id, itemId: id }`. Takes precedence over `onHomeClick`;
|
||||
* with no visible item the cap keeps the plain logo / `onHomeClick`. Never open together with the document list.
|
||||
*/
|
||||
logoMenu?: {
|
||||
title?: string
|
||||
items: { id: string; label: string; icon?: ReactNode; value?: string; checked?: boolean; disabled?: boolean; hidden?: boolean; onSelect?: () => void }[]
|
||||
}
|
||||
/**
|
||||
* Document list below the file name (2026-10-21): the SWHeader file-name button and dropdown (SWHeaderDocumentTitle)
|
||||
* without + New and Open (file open is the header button, `onDocumentOpen`). The button shows `documentTitle` (or `documentPlaceholder`); the list
|
||||
* is at least as wide as the file-name button and the SWHeader list, wider for longer rows. Omit to keep the plain
|
||||
* file name.
|
||||
*/
|
||||
documents?: SWHeaderDocument[]
|
||||
/** Id of the current document in `documents` (its name in #FFC600; the list order never changes). */
|
||||
activeDocumentId?: string
|
||||
/** A document row was chosen. The host loads it and updates `documentTitle` / `activeDocumentId`. */
|
||||
onDocumentSelect?: (document: SWHeaderDocument) => void
|
||||
/**
|
||||
* File open button in the header (2026-10-21), like SWHeader `documentOpenPlacement="header"`: the SWHeader file-name
|
||||
* surface (SWToolButton `appearance="document"`) right after the logo, with a bordered folder icon and the label.
|
||||
* Shown only with this callback; the document list never shows Open or + New in the viewer. When the header has no
|
||||
* room it shows the icon only (label as tooltip and accessible name, measured like the other items), never in More.
|
||||
* Runs the callback, then `onCommand` with `{ id: "open", itemId: "document" }`.
|
||||
*/
|
||||
onDocumentOpen?: () => void
|
||||
/** Label of that button. Default `Open`. */
|
||||
documentOpenLabel?: string
|
||||
/** Menu data, in order. Groups with `placement="end"` form the trailing action area. */
|
||||
groups: SWViewerHeaderGroup[]
|
||||
/** Free content after the trailing groups (status, info button, ...). */
|
||||
@@ -42,20 +80,64 @@ export interface SWViewerHeaderProps extends Omit<HTMLAttributes<HTMLElement>, "
|
||||
const MoreIcon = () => (
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="currentColor"><circle cx="3" cy="8" r="1.4" /><circle cx="8" cy="8" r="1.4" /><circle cx="13" cy="8" r="1.4" /></svg>
|
||||
)
|
||||
const FolderIcon = () => (
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" /></svg>
|
||||
)
|
||||
const ChevronDown = () => (
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||||
)
|
||||
|
||||
const isShown = (item: { hidden?: boolean; collapseBelow?: number }, width: number) => !item.hidden && !(item.collapseBelow !== undefined && width < item.collapseBelow)
|
||||
/** Step id of the header Open button (icon only in stage 2). */
|
||||
const OPEN_ID = ":document-open"
|
||||
|
||||
const isShown = (item: { id: string; hidden?: boolean }, collapsed: ReadonlySet<string>) => !item.hidden && !collapsed.has(item.id)
|
||||
|
||||
/**
|
||||
* Measured responsive steps (2026-10-21), applied one at a time while the header has no room left and undone in reverse
|
||||
* order once the room a step freed is back: 1. every item with an `icon` and `iconOnlyBelow` shows its icon only (label
|
||||
* kept as tooltip and accessible name); 2. `collapseBelow` items move into More one by one, the higher value first and,
|
||||
* for the same value, the rightmost first. No fixed width decides a step: the values only give the order.
|
||||
*/
|
||||
type Step = { kind: "icons"; ids: string[] } | { kind: "collapse"; id: string }
|
||||
function responsiveSteps(groups: SWViewerHeaderGroup[], extraIcons: string[] = []): Step[] {
|
||||
const icons: string[] = [...extraIcons], collapse: { id: string; rank: number; order: number }[] = []
|
||||
groups.filter(group => !group.hidden).flatMap(group => group.items).forEach((item, order) => {
|
||||
if (item.hidden) return
|
||||
if (item.type !== "custom" && item.icon && !item.iconOnly && item.iconOnlyBelow !== undefined) icons.push(item.id)
|
||||
if (item.collapseBelow !== undefined) collapse.push({ id: item.id, rank: item.collapseBelow, order })
|
||||
})
|
||||
collapse.sort((a, b) => b.rank - a.rank || b.order - a.order)
|
||||
return [...(icons.length ? [{ kind: "icons" as const, ids: icons }] : []), ...collapse.map(item => ({ kind: "collapse" as const, id: item.id }))]
|
||||
}
|
||||
|
||||
/**
|
||||
* Room left in the header row (negative: missing) with every part at its natural width: the file name at its text
|
||||
* 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.
|
||||
*/
|
||||
function measureRoom(root: HTMLElement) {
|
||||
root.setAttribute("data-measuring", "")
|
||||
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)
|
||||
for (const child of Array.from(root.children)) {
|
||||
const childStyle = getComputedStyle(child)
|
||||
if (childStyle.display === "none" || childStyle.position === "absolute" || childStyle.position === "fixed") continue
|
||||
used = Math.max(used, child.getBoundingClientRect().right + parseFloat(childStyle.marginRight))
|
||||
}
|
||||
root.removeAttribute("data-measuring")
|
||||
return end - used
|
||||
}
|
||||
|
||||
/** More entries of the collapsed items, one section per group (menus and tabs get their own section). */
|
||||
function moreEntries(groups: SWViewerHeaderGroup[], width: number, command: (c: SWViewerCommand) => void, owners: Map<string, string>): SWViewerMenuEntry[] {
|
||||
function moreEntries(groups: SWViewerHeaderGroup[], collapsedIds: ReadonlySet<string>, command: (c: SWViewerCommand) => void, owners: Map<string, string>): SWViewerMenuEntry[] {
|
||||
const sections: SWViewerMenuEntry[][] = []
|
||||
// Dropdown entries keep their own callbacks; the More menu reports them to onCommand with their owner item.
|
||||
const own = (entries: SWViewerMenuEntry[], itemId: string) => entries.forEach(entry => { owners.set(entry.id, itemId); if (entry.type === "submenu") own(entry.entries, itemId) })
|
||||
for (const group of groups) {
|
||||
if (group.hidden) continue
|
||||
const collapsed = group.items.filter(item => !item.hidden && item.collapseBelow !== undefined && width < item.collapseBelow)
|
||||
const collapsed = group.items.filter(item => !item.hidden && collapsedIds.has(item.id))
|
||||
let plain: SWViewerMenuEntry[] = []
|
||||
const flush = () => { if (plain.length) sections.push([{ type: "label", id: `${group.id}:more`, label: group.moreLabel ?? group.label }, ...plain]); plain = [] }
|
||||
for (const item of collapsed) {
|
||||
@@ -103,7 +185,8 @@ function MenuButton({ item, iconOnly, onCommand }: { item: Extract<SWViewerHeade
|
||||
{!iconOnly && <span className="hm-sw-viewer-header__label">{item.label}</span>}
|
||||
<span className="hm-sw-viewer-header__chevron"><SWViewerIcon><ChevronDown /></SWViewerIcon></span>
|
||||
</button>
|
||||
<SWViewerMenu id={menuId} entries={item.entries} open={open} onOpenChange={setOpen} anchorRef={ref} align={item.menuAlign} width={item.menuWidth} label={item.label} initialFocus={focus}
|
||||
{/* 2026-10-21: header dropdowns use the More face (beige), like every menu of the viewer header. */}
|
||||
<SWViewerMenu id={menuId} entries={item.entries} open={open} onOpenChange={setOpen} anchorRef={ref} align={item.menuAlign} width={item.menuWidth} surface="beige" label={item.label} initialFocus={focus}
|
||||
onEntrySelect={entry => onCommand({ id: entry.id, itemId: item.id, value: entry.type === "checkbox" ? !entry.checked : undefined })} />
|
||||
</>
|
||||
)
|
||||
@@ -143,15 +226,15 @@ function SwitchField({ item, onCommand }: { item: Extract<SWViewerHeaderItem, {
|
||||
)
|
||||
}
|
||||
|
||||
function Item({ item, width, onCommand }: { item: SWViewerHeaderItem; width: number; onCommand: (c: SWViewerCommand) => void }) {
|
||||
function Item({ item, compact, onCommand }: { item: SWViewerHeaderItem; compact: boolean; onCommand: (c: SWViewerCommand) => void }) {
|
||||
if (item.type === "custom") return <>{item.render()}</>
|
||||
const iconOnly = !!item.icon && (item.iconOnly ? "always" : item.iconOnlyBelow !== undefined && width < item.iconOnlyBelow)
|
||||
const iconOnly = !!item.icon && (item.iconOnly ? "always" : compact)
|
||||
if (item.type === "menu") return <MenuButton item={item} iconOnly={iconOnly} onCommand={onCommand} />
|
||||
if (item.type === "tabs") return <Tabs item={item} onCommand={onCommand} />
|
||||
if (item.type === "switch") return <SwitchField item={item} onCommand={onCommand} />
|
||||
const toggle = item.type === "toggle"
|
||||
return (
|
||||
<SWViewerHeaderButton label={item.label} icon={item.icon} iconOnly={iconOnly === "always" ? true : iconOnly ? "compact" : false} pressed={toggle ? item.pressed : undefined} title={item.title} disabled={item.disabled}
|
||||
<SWViewerHeaderButton label={item.label} icon={item.icon} iconOnly={iconOnly === "always" ? true : iconOnly ? "compact" : false} pressed={toggle ? item.pressed : undefined} title={item.title ?? (iconOnly ? item.label : undefined)} disabled={item.disabled}
|
||||
onClick={() => {
|
||||
if (toggle) { item.onPressedChange?.(!item.pressed); onCommand({ id: item.id, itemId: item.id, value: !item.pressed }) }
|
||||
else { item.onSelect?.(); onCommand({ id: item.id, itemId: item.id }) }
|
||||
@@ -192,19 +275,23 @@ export const SWViewerHeaderButton = forwardRef<HTMLButtonElement, SWViewerHeader
|
||||
|
||||
/**
|
||||
* SW viewer-mode header (reference DWG/DXF viewer top menu): slanted brand cap → dark file-name well → one green
|
||||
* object surface holding data-driven menu groups, with a trailing action area. Items move into the More dropdown
|
||||
* by `collapseBelow` (measured header width, never horizontal scroll). Pure presentation: values are props, every
|
||||
* object surface holding data-driven menu groups, with a trailing action area. Responsive by the measured room of the
|
||||
* header row, not by fixed widths (2026-10-21): full items while they fit, then icons only (`iconOnlyBelow` items with
|
||||
* an icon), then `collapseBelow` items move into the More dropdown one by one; they come back in reverse order once
|
||||
* the room is there. The numbers only set the order. Never horizontal scroll. Pure presentation: values are props, every
|
||||
* action is a callback (`onCommand` plus each item's own). Use SWHeader for the normal SW mode.
|
||||
*/
|
||||
export const SWViewerHeader = forwardRef<HTMLElement, SWViewerHeaderProps>(function SWViewerHeader(
|
||||
{
|
||||
logo, logoSrc, logoAlt, onHomeClick, documentTitle, documentPlaceholder = "열린 도면 없음", documentTooltip, document: documentNode,
|
||||
logoMenu, documents, activeDocumentId, onDocumentSelect, onDocumentOpen, documentOpenLabel,
|
||||
groups, trailing, onCommand, moreLabel = "추가 도구", toolbarLabel = "도면 작업 도구", className, ...rest
|
||||
},
|
||||
forwardedRef,
|
||||
) {
|
||||
const rootRef = useRef<HTMLElement | null>(null)
|
||||
const [width, setWidth] = useState(1920)
|
||||
// Header width: a resize re-renders, and the layout pass below measures again.
|
||||
const [, setWidth] = useState(1920)
|
||||
const gradientId = `hm-sw-viewer-brand-${useId().replace(/:/g, "")}`
|
||||
useLayoutEffect(() => {
|
||||
const root = rootRef.current
|
||||
@@ -215,23 +302,73 @@ export const SWViewerHeader = forwardRef<HTMLElement, SWViewerHeaderProps>(funct
|
||||
observer.observe(root)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
const start = groups.filter(group => group.placement !== "end")
|
||||
const end = groups.filter(group => group.placement === "end")
|
||||
// Measured responsive steps (see responsiveSteps): `level` steps are applied. Each layout pass measures the room; a
|
||||
// missing room applies the next step, and the last step is undone once the room it freed (measured right after it was
|
||||
// applied) is there again. The passes are synchronous layout effects, so only the settled layout is painted.
|
||||
const steps = responsiveSteps([...start, ...end], onDocumentOpen ? [OPEN_ID] : [])
|
||||
const [level, setLevel] = useState(0)
|
||||
const applied = steps.slice(0, level)
|
||||
const collapsed = new Set(applied.flatMap(step => (step.kind === "collapse" ? [step.id] : [])))
|
||||
const compact = new Set(applied.flatMap(step => (step.kind === "icons" ? step.ids : [])))
|
||||
const freedRef = useRef<{ before: number; room?: number }[]>([])
|
||||
// Steps or texts that change widths start the measurement over (file name, labels, items shown).
|
||||
const layoutKey = JSON.stringify([documentTitle, documentPlaceholder, documentOpenLabel, !!onDocumentOpen, steps, groups.map(group => [group.hidden, group.items.map(item => [item.id, item.hidden, "label" in item ? item.label : ""])])])
|
||||
const keyRef = useRef(layoutKey)
|
||||
// Web fonts change text widths without a resize: measure again once they are loaded.
|
||||
const [, setFontsLoaded] = useState(0)
|
||||
useEffect(() => {
|
||||
const fonts = typeof document === "undefined" ? undefined : document.fonts
|
||||
if (!fonts) return
|
||||
let live = true
|
||||
const loaded = () => { if (live) setFontsLoaded(count => count + 1) }
|
||||
fonts.addEventListener?.("loadingdone", loaded)
|
||||
fonts.ready.then(loaded)
|
||||
return () => { live = false; fonts.removeEventListener?.("loadingdone", loaded) }
|
||||
}, [])
|
||||
useLayoutEffect(() => {
|
||||
const root = rootRef.current
|
||||
if (!root) return
|
||||
if (keyRef.current !== layoutKey) {
|
||||
keyRef.current = layoutKey
|
||||
freedRef.current = []
|
||||
if (level !== 0) return setLevel(0)
|
||||
}
|
||||
if (level > steps.length) return setLevel(steps.length)
|
||||
const room = measureRoom(root)
|
||||
const freed = freedRef.current
|
||||
const last = level > 0 ? freed[level - 1] : undefined
|
||||
if (last && last.room === undefined) last.room = room - last.before
|
||||
if (room < -0.5) {
|
||||
if (level < steps.length) {
|
||||
freed[level] = { before: room }
|
||||
setLevel(level + 1)
|
||||
}
|
||||
} else if (level > 0 && (last?.room === undefined || room >= last.room)) {
|
||||
freed.length = level - 1
|
||||
setLevel(level - 1)
|
||||
}
|
||||
})
|
||||
const command = (value: SWViewerCommand) => onCommand?.(value)
|
||||
const owners = new Map<string, string>()
|
||||
const more = moreEntries(groups, width, command, owners)
|
||||
const more = moreEntries(groups, collapsed, command, owners)
|
||||
const moreRef = useRef<HTMLButtonElement>(null)
|
||||
const [moreOpen, setMoreOpen] = useState(false)
|
||||
const [moreFocus, setMoreFocus] = useState<"first" | "menu">("menu")
|
||||
// The logo menu and the document list are never open together (opening one closes the other).
|
||||
const [openMenu, setOpenMenu] = useState<"logo" | "document" | null>(null)
|
||||
const menuOpenChange = (menu: "logo" | "document") => (next: boolean) => setOpenMenu(current => (next ? menu : current === menu ? null : current))
|
||||
const logoItems = logoMenu?.items.filter(item => !item.hidden) ?? []
|
||||
const renderGroup = (group: SWViewerHeaderGroup) => {
|
||||
const items = group.items.filter(item => isShown(item, width))
|
||||
const items = group.items.filter(item => isShown(item, collapsed))
|
||||
if (group.hidden || !items.length) return null
|
||||
return (
|
||||
<div key={group.id} role="group" aria-label={group.label} className="hm-sw-viewer-header__group" data-surface={group.surface ?? "plain"} data-group={group.id}>
|
||||
{items.map(item => <Item key={item.id} item={item} width={width} onCommand={command} />)}
|
||||
{items.map(item => <Item key={item.id} item={item} compact={compact.has(item.id)} onCommand={command} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const start = groups.filter(group => group.placement !== "end")
|
||||
const end = groups.filter(group => group.placement === "end")
|
||||
const name = documentTitle || documentPlaceholder
|
||||
const mark = logo ?? (logoSrc ? <img src={logoSrc} alt="" /> : null)
|
||||
const brandContent = (
|
||||
@@ -253,11 +390,36 @@ export const SWViewerHeader = forwardRef<HTMLElement, SWViewerHeaderProps>(funct
|
||||
return (
|
||||
<header ref={node => { rootRef.current = node; if (typeof forwardedRef === "function") forwardedRef(node); else if (forwardedRef) forwardedRef.current = node }}
|
||||
className={`hm-sw-theme hm-sw-viewer-header${className ? ` ${className}` : ""}`} {...rest}>
|
||||
{onHomeClick
|
||||
? <button type="button" className="hm-sw-viewer-header__brand" data-logo={mark ? "" : undefined} aria-label={logoAlt ? `${logoAlt} 홈` : "홈"} title={logoAlt} onClick={onHomeClick}>{brandContent}</button>
|
||||
: <div className="hm-sw-viewer-header__brand" data-logo={mark ? "" : undefined} role={logoAlt ? "img" : undefined} aria-label={logoAlt}>{brandContent}</div>}
|
||||
<div className="hm-sw-viewer-header__document" role="group" aria-label="현재 도면">
|
||||
{documentNode ?? <span className="hm-sw-viewer-header__document-name" data-empty={!documentTitle || undefined} title={documentTooltip ?? (documentTitle || "파일 열기 또는 샘플을 선택하세요")}>{name}</span>}
|
||||
{logoItems.length > 0
|
||||
? <SWHeaderMore className="hm-sw-viewer-header__logo-menu" align="start" title={logoMenu?.title} label={logoMenu?.title ?? (logoAlt ? `${logoAlt} 메뉴` : "메뉴")}
|
||||
open={openMenu === "logo"} onOpenChange={menuOpenChange("logo")}
|
||||
items={logoItems.map(item => ({
|
||||
id: item.id, label: item.label, icon: item.icon, value: item.value, checked: item.checked, disabled: item.disabled, tier: "action" as const, visible: true,
|
||||
onSelect: () => { item.onSelect?.(); command({ id: item.id, itemId: item.id }) },
|
||||
}))}
|
||||
renderTrigger={({ ref, ...trigger }) => (
|
||||
<button ref={ref} type="button" className="hm-sw-viewer-header__brand" data-logo={mark ? "" : undefined} data-state={trigger["aria-expanded"] ? "open" : undefined}
|
||||
aria-label={logoAlt ? `${logoAlt} 메뉴` : "메뉴"} title={logoAlt} {...trigger}>{brandContent}</button>
|
||||
)} />
|
||||
: onHomeClick
|
||||
? <button type="button" className="hm-sw-viewer-header__brand" data-logo={mark ? "" : undefined} aria-label={logoAlt ? `${logoAlt} 홈` : "홈"} title={logoAlt} onClick={onHomeClick}>{brandContent}</button>
|
||||
: <div className="hm-sw-viewer-header__brand" data-logo={mark ? "" : undefined} role={logoAlt ? "img" : undefined} aria-label={logoAlt}>{brandContent}</div>}
|
||||
{onDocumentOpen && (() => {
|
||||
const label = documentOpenLabel ?? "Open", iconOnly = compact.has(OPEN_ID)
|
||||
return (
|
||||
<SWToolButton appearance="document" className="hm-sw-viewer-header__open" data-document-open="" data-icon-only={iconOnly ? "" : undefined}
|
||||
aria-label={iconOnly ? label : undefined} title={iconOnly ? label : undefined}
|
||||
onClick={() => { onDocumentOpen(); command({ id: "open", itemId: "document" }) }}>
|
||||
<span className="hm-sw-viewer-header__open-icon" aria-hidden="true"><FolderIcon /></span>
|
||||
{!iconOnly && <span className="hm-sw-viewer-header__open-label">{label}</span>}
|
||||
</SWToolButton>
|
||||
)
|
||||
})()}
|
||||
<div className="hm-sw-viewer-header__document" role="group" aria-label="현재 도면" data-list={!documentNode && documents ? "" : undefined}>
|
||||
{documentNode ?? (documents
|
||||
? <SWHeaderDocumentTitle title={name} label="도면" documents={documents} activeDocumentId={activeDocumentId} onDocumentSelect={onDocumentSelect}
|
||||
showNew={false} showOpen={false} menuOpen={openMenu === "document"} onMenuOpenChange={menuOpenChange("document")} />
|
||||
: <span className="hm-sw-viewer-header__document-name" data-empty={!documentTitle || undefined} title={documentTooltip ?? (documentTitle || "파일 열기 또는 샘플을 선택하세요")}>{name}</span>)}
|
||||
</div>
|
||||
<div className="hm-sw-viewer-header__object" role="toolbar" aria-label={toolbarLabel}>
|
||||
{start.map(renderGroup)}
|
||||
|
||||
@@ -114,6 +114,131 @@ button.hm-sw-viewer-header__brand { text-align: left; }
|
||||
}
|
||||
.hm-sw-viewer-header__document-name { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
|
||||
/* Logo menu (2026-10-21): the brand cap is the trigger of the SWHeader `...` menu (SWHeaderMore, sw.css). The wrapper
|
||||
takes the cap's place in the row but is not positioned: the cap keeps its own layer (z-index 2 over the file name,
|
||||
like the SWHeader logo), and the menu is placed by the header container itself — flush under the header's bottom
|
||||
border (100% of its padding box + its 1px border) at the left edge of the cap, at any header height or width. */
|
||||
.hm-sw-viewer-header__logo-menu.hm-sw-header__more {
|
||||
position: static;
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
height: calc(var(--hm-sw-viewer-height) - 2px);
|
||||
margin-left: 0;
|
||||
}
|
||||
.hm-sw-viewer-header__logo-menu > .hm-sw-header__more-menu {
|
||||
top: calc(100% + 1px);
|
||||
left: 0;
|
||||
font-size: var(--hm-sw-header-font-size);
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
/* Logo menu (2026-10-21): the size, spacing and type of the viewer header dropdowns (SWViewerMenu); its colors are
|
||||
those of the SWHeader `...` menu it is (sw.css .hm-sw-header__more-menu: face, border, shadow, hover); its place
|
||||
stays flush under the header. Only this menu: the document list stays the SWHeader file-name list. */
|
||||
.hm-sw-viewer-header__logo-menu > .hm-sw-header__more-menu {
|
||||
min-width: 160px;
|
||||
padding: 4px;
|
||||
border-width: 1px;
|
||||
border-radius: var(--hm-sw-viewer-radius);
|
||||
color: var(--hm-sw-header-text-color);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
.hm-sw-viewer-header__logo-menu .hm-sw-header__more-item {
|
||||
gap: 8px;
|
||||
min-height: var(--hm-sw-viewer-menu-item-height);
|
||||
padding: 0 12px;
|
||||
color: inherit;
|
||||
}
|
||||
.hm-sw-viewer-header__logo-menu .hm-sw-header__more-item:is(:hover, :focus):not(:disabled) { background: var(--hm-sw-header-hover); outline: none; }
|
||||
.hm-sw-viewer-header__logo-menu .hm-sw-header__more-item:disabled { opacity: 0.5; }
|
||||
/* Title = SWViewerMenu label, directly above its items (one group, no divider); icons = SWViewerMenu icon slot. */
|
||||
.hm-sw-viewer-header__logo-menu .hm-sw-header__more-title { min-height: 0; padding: 6px 12px; font-size: 12px; font-weight: 500; line-height: 18px; }
|
||||
|
||||
/* Document list (2026-10-21): the SWHeader file-name button and dropdown (SWHeaderDocumentTitle) fill the well.
|
||||
The button draws the well (border, gradient), sized to the viewer row; the text keeps the viewer inset. */
|
||||
.hm-sw-viewer-header__document[data-list] {
|
||||
--hm-sw-header-bar-height: calc(var(--hm-sw-viewer-height) - 2px);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
font-size: var(--hm-sw-header-font-size);
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
/* Header Open (2026-10-21, `onDocumentOpen`): SWHeader's documentOpenPlacement="header" surface (SWToolButton
|
||||
appearance="document", the file-name surface at its content width) right after the logo, sized to the viewer row,
|
||||
with the label after a folder icon (no border, no face). Icon only when the header has no room (measured,
|
||||
SWViewerHeader); the file name after it has no left padding (2026-10-21 user request). */
|
||||
.hm-sw-viewer-header > .hm-sw-header__document[data-tool] {
|
||||
--hm-sw-header-bar-height: calc(var(--hm-sw-viewer-height) - 2px);
|
||||
font-size: var(--hm-sw-header-font-size);
|
||||
line-height: normal;
|
||||
}
|
||||
.hm-sw-viewer-header .hm-sw-viewer-header__open { padding: 0 12px 0 16px; }
|
||||
.hm-sw-viewer-header__open .hm-sw-header__document-name { display: inline-flex; align-items: center; gap: 8px; overflow: visible; }
|
||||
.hm-sw-viewer-header__open-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
width: var(--hm-sw-viewer-button-height);
|
||||
height: var(--hm-sw-viewer-button-height);
|
||||
/* 2026-10-21 user request: no border and no face (transparent); the icon keeps its color, size and place. */
|
||||
border: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
text-shadow: none;
|
||||
}
|
||||
.hm-sw-viewer-header__open-icon > svg { width: var(--hm-sw-viewer-icon-size); height: var(--hm-sw-viewer-icon-size); }
|
||||
.hm-sw-viewer-header > .hm-sw-header__document[data-tool] + .hm-sw-viewer-header__document { padding-left: 0; }
|
||||
.hm-sw-viewer-header__document[data-list] > .hm-sw-header__document { flex: 1 1 auto; min-width: 0; }
|
||||
/* No file open button before it: the file name starts 10px after the slanted logo edge (at mid height), as in
|
||||
SWHeader (text 10px after its home slant; measured 10.5px here with 18px). */
|
||||
.hm-sw-viewer-header__document[data-list] .hm-sw-header__document-button { padding: 0 16px 0 18px; }
|
||||
/* After the file open button (2026-10-21 user decision): 8px in. */
|
||||
.hm-sw-viewer-header > .hm-sw-header__document[data-tool] + .hm-sw-viewer-header__document[data-list] .hm-sw-header__document-button { padding: 0 16px 0 8px; }
|
||||
/* The list (SWHeader face, placement and rows unchanged) is never narrower than the file-name button it opens from:
|
||||
its outer width is at least the button's (100% of the wrapper, plus the two borders the list starts left of it) and
|
||||
at least the SWHeader list width; longer rows widen it. min-width wins over the SWHeader max-width. */
|
||||
.hm-sw-viewer-header__document[data-list] .hm-sw-header__document-menu {
|
||||
width: max-content;
|
||||
min-width: max(
|
||||
calc(var(--hm-sw-header-document-menu-width) + 2 * var(--hm-sw-header-document-menu-border-width)),
|
||||
calc(100% + 2 * var(--hm-sw-header-document-menu-border-width))
|
||||
);
|
||||
}
|
||||
/* Viewer header dropdown colors (2026-10-21): the menus of the header — menu buttons, More and their submenus — use the
|
||||
colors of the SWHeader `...` menu (sw.css .hm-sw-header__more-menu): its face and shadow (fixed values there), the
|
||||
object border token, the header text, secondary text at 80% (as its values) and the white 8% row hover with the text
|
||||
unchanged. Size, spacing and type stay SWViewerMenu. A standalone SWViewerMenu (context menu) and the document list
|
||||
keep their own colors. */
|
||||
.hm-sw-viewer-header .hm-sw-viewer-menu[data-surface] {
|
||||
border-color: var(--hm-sw-header-object-border-color);
|
||||
background: #0e3d2f;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
color: var(--hm-sw-header-text-color);
|
||||
}
|
||||
.hm-sw-viewer-header .hm-sw-viewer-menu .hm-sw-viewer-menu__item:is(:focus, :hover, [data-state="open"]):not([aria-disabled]) {
|
||||
background: var(--hm-sw-header-hover);
|
||||
color: inherit;
|
||||
}
|
||||
.hm-sw-viewer-header .hm-sw-viewer-menu :is(.hm-sw-viewer-menu__label, .hm-sw-viewer-menu__shortcut, .hm-sw-viewer-menu__chevron) { color: inherit; opacity: 0.8; }
|
||||
.hm-sw-viewer-header .hm-sw-viewer-menu .hm-sw-viewer-menu__separator { background: var(--hm-sw-header-document-menu-divider-color); }
|
||||
|
||||
/* Measured responsive steps (2026-10-21, SWViewerHeader measureRoom): set only inside one layout pass, never painted.
|
||||
Every part at its natural width: the file name at its text width up to its usual flex basis, 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 auto; width: max-content; max-width: clamp(140px, 25cqw, 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
|
||||
the parts hanging below it stay above SWViewerPanel popups (41+). Inside, the SWHeader order is kept: the logo cap
|
||||
(z-index 2) over the file name and its list (z-index 1), so an open list never covers the logo. Closed: no layer. */
|
||||
.hm-sw-viewer-header:has(> .hm-sw-viewer-header__logo-menu > .hm-sw-header__more-menu, > .hm-sw-viewer-header__document .hm-sw-header__document-menu) { z-index: 60; }
|
||||
|
||||
.hm-sw-viewer-header__object {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -48,6 +48,10 @@
|
||||
--hm-sw-header-document-menu-inset-shadow: inset 0 4px 8px 0 rgba(0, 0, 0, 0.4);
|
||||
--hm-sw-header-document-menu-divider-color: rgba(194, 217, 210, 0.2);
|
||||
--hm-sw-header-document-list-max-height: 320px;
|
||||
/* Document list scrollbar (2026-10-21): the Storybook sidebar scrollbar (.storybook/manager.css, #BDDFD4 thumb at
|
||||
50% / 80% on hover, 6px, round, transparent track). */
|
||||
--hm-sw-header-document-scrollbar-thumb: rgba(189, 223, 212, 0.5);
|
||||
--hm-sw-header-document-scrollbar-thumb-hover: rgba(189, 223, 212, 0.8);
|
||||
--hm-sw-header-document-action-background: rgba(182, 208, 201, 0.2);
|
||||
--hm-sw-header-document-action-color: rgba(0, 0, 0, 0.7);
|
||||
/* Figma hover variants: list 26:10680, + New 26:10762, Open 26:10805 */
|
||||
@@ -422,6 +426,18 @@
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--hm-sw-header-document-menu-inset-shadow);
|
||||
}
|
||||
/* Scrollbar (2026-10-21): like the Storybook sidebar — 6px, round thumb, transparent track 3px in from top and bottom,
|
||||
thumb 50% (80% on the thumb). Shown whenever the list scrolls: Chromium does not repaint a custom scrollbar on the
|
||||
list's own :hover, so a hover-only thumb could stay hidden. Chromium / Safari draw it with ::-webkit-scrollbar;
|
||||
Firefox, which has no such pseudo-elements, uses the SW standard thin scrollbar with the same colors. Only the
|
||||
scrollbar changes: list size, rows and scrolling stay as they are. */
|
||||
@supports not selector(::-webkit-scrollbar) {
|
||||
.hm-sw-header__document-list { scrollbar-width: thin; scrollbar-color: var(--hm-sw-header-document-scrollbar-thumb) transparent; }
|
||||
}
|
||||
.hm-sw-header__document-list::-webkit-scrollbar { width: 6px; background: transparent; }
|
||||
.hm-sw-header__document-list::-webkit-scrollbar-track { margin: 3px 0; background: transparent; }
|
||||
.hm-sw-header__document-list::-webkit-scrollbar-thumb { border-radius: 9px; background: var(--hm-sw-header-document-scrollbar-thumb); }
|
||||
.hm-sw-header__document-list::-webkit-scrollbar-thumb:hover { background: var(--hm-sw-header-document-scrollbar-thumb-hover); }
|
||||
/* Row = select button + optional Close Document button (siblings). Hover and the current
|
||||
document are styled on the row so the overlay stays while the pointer is on the X button. */
|
||||
.hm-sw-header__document-row {
|
||||
@@ -1310,6 +1326,22 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
|
||||
.hm-sw-header__more-item[role="menuitemcheckbox"]::after { content: ""; width: 14px; text-align: center; }
|
||||
.hm-sw-header__more-item[aria-checked="true"]::after { content: "✓"; }
|
||||
.hm-sw-header__more-value { font-size: var(--hm-sw-header-label-size); opacity: 0.8; }
|
||||
/* Optional title and item icons (SWViewerHeader logo menu, 2026-10-21). The title is rendered only with at least one
|
||||
item and has no divider (title + items are one group); the `...` menu of SWHeader passes neither and is unchanged. */
|
||||
.hm-sw-header__more-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
padding: 0 10px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.hm-sw-header__more-item:has(> .hm-sw-header__more-icon) { justify-content: flex-start; }
|
||||
.hm-sw-header__more-icon { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 16px; width: 16px; height: 16px; }
|
||||
.hm-sw-header__more-icon > svg { width: 16px; height: 16px; }
|
||||
.hm-sw-header__more-item:has(> .hm-sw-header__more-icon) > .hm-sw-header__more-value { margin-left: auto; }
|
||||
.hm-sw-header__more-menu[data-align="start"] { right: auto; left: 0; }
|
||||
|
||||
/* Window area: print / guide pill, window move, minimize, close. */
|
||||
.hm-sw-header__window {
|
||||
|
||||
Reference in new issue
Block a user