feat(sw): integrated improvement 0.2.0 (popup content, header open, tool buttons)
SW package 0.2.0 integrated improvement (Task A, steps 1-8). All new props are
optional; existing SW Stories are pixel-identical to the previous build (28/28).
- Build reproducibility: dist text normalized to LF, dist/build-info.json
(commit, dirty, tool versions, no timestamp), packages/sw/.gitattributes
(eol=lf). LF checkouts give identical dist and tarball.
- SWHeader: documentOpenPlacement ("menu" default | "header": Open right of
the logo in the existing document-button look, reusing onDocumentOpen and
documentOpenLabel, no duplicate Open in the dropdown), leading slot,
hiddenControls sub-items documentNew / documentOpen / layerFreeze /
layerLock. Open moves into More on measured overflow (with hysteresis).
- showNew / showOpen (SWHeaderDocumentTitle), showFreeze / showLock (layer
menu, field, property window list).
- SWToolButton (text / pill / document) and SWToolGroup (radio selection),
reusing the existing toggle, pill and file-name looks.
- SWPopup / SWPopupSection / SWPopupRow on the shared property window frame;
SWHeaderPropertyPanel gains children, title, autoFocus and optional
selection / properties / groups. Escape stack for several popups.
- Field dropdowns inside a popup use the shared fixed overlay
(useSWFixedDropdownPosition), so the scrolling body no longer clips them.
- AI guide, examples E/F, README, Storybook (SW/Header/Options, SW/Tool,
SW/Popup) and verification record docs/sw/sw-integrated-improvement-2026-10-19.md.
- Regression scripts rescoped for the intended changes (unused-removal 81/81,
overview 52/54 with the two pre-existing historical assertions).
Known limitation (unresolved): tools / leading content (incl. SWToolGroup)
does not move into More. With a wide tool bar the configured fields shrink
instead; in Example F the document name and layer field reach 0px at 390px.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5faa1f1ffe
commit
55179b4577
138 files changed
+17194
-162
No files matched your search
@@ -19,7 +19,8 @@ import printHover from "../../assets/print-hover.svg"
|
||||
import { SWHeaderPropertyPanel } from "./components/SWHeaderPropertyPanel"
|
||||
import { SWHeaderDimensionStyleMenu, SWHeaderTextStyleMenu } from "./components/SWHeaderStyleListMenus"
|
||||
import { SWHeaderWindowControls } from "./components/SWHeaderWindowControls"
|
||||
import type { SWHeaderControlId, SWHeaderControlConfig, SWHeaderFrameControlId, SWHeaderHideableControlId, SWHeaderMenuId, SWHeaderProps } from "./types"
|
||||
import { SWToolButton } from "../sw-tool/SWTool"
|
||||
import type { SWHeaderControlId, SWHeaderControlConfig, SWHeaderFrameControlId, SWHeaderHideableControlId, SWHeaderMenuId, SWHeaderProps, SWHeaderSubControlId } from "./types"
|
||||
|
||||
const defaults = {
|
||||
layer: { name: "0" },
|
||||
@@ -37,6 +38,7 @@ const menuSlot: Record<SWHeaderMenuId, string> = {
|
||||
const defaultMenuOrder = Object.keys(menuSlot) as SWHeaderMenuId[]
|
||||
const defaultControls: SWHeaderControlConfig[] = (["document", "drawingMode", ...defaultMenuOrder, "details", "propertyBar", "propertyPanel", "print", "popupGuide"] as const).map(id => ({ id }))
|
||||
const frameControls: readonly SWHeaderFrameControlId[] = ["home", "windowMove", "minimize", "close"]
|
||||
const subControls: readonly SWHeaderSubControlId[] = ["documentNew", "documentOpen", "layerFreeze", "layerLock"]
|
||||
const isMenu = (id: string): id is SWHeaderMenuId => id in menuSlot
|
||||
|
||||
/**
|
||||
@@ -73,6 +75,8 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
|
||||
documentIcon,
|
||||
documentNewLabel,
|
||||
documentOpenLabel,
|
||||
documentOpenPlacement = "menu",
|
||||
leading,
|
||||
drawingModeLabel = "그리기모드",
|
||||
layer = defaults.layer,
|
||||
layers,
|
||||
@@ -161,7 +165,9 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
|
||||
const enabled = (id: SWHeaderControlId) => configuration.some(item => item.id === id)
|
||||
// Default design with removed object parts: same structure and breakpoints, but the script decides what
|
||||
// moves into More (the CSS alone assumes every field is present). Untouched defaults stay CSS-only.
|
||||
const scripted = controls !== undefined || [...removed].some(id => !frameControls.includes(id as SWHeaderFrameControlId))
|
||||
// Sub-items live inside a part and never change the layout mode.
|
||||
const scripted = controls !== undefined || [...removed].some(id => !frameControls.includes(id as SWHeaderFrameControlId) && !subControls.includes(id as SWHeaderSubControlId))
|
||||
const layerStates = { showFreeze: shown("layerFreeze"), showLock: shown("layerLock") }
|
||||
const presentRef = useRef(new Set(configuration.map(item => item.id)))
|
||||
presentRef.current = new Set(configuration.map(item => item.id))
|
||||
const label = (id: SWHeaderControlId, fallback: string) => configuration.find(item => item.id === id)?.label ?? fallback
|
||||
@@ -204,6 +210,13 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
|
||||
|
||||
const [headerWidth, setHeaderWidth] = useState(1920)
|
||||
const [barOffset, setBarOffset] = useState(0)
|
||||
// Open next to the logo (documentOpenPlacement="header") moves into More when the measured header has no room.
|
||||
const openInHeader = documentOpenPlacement === "header" && shown("documentOpen")
|
||||
const leadingRef = useRef<HTMLDivElement>(null)
|
||||
const [openCollapsed, setOpenCollapsed] = useState(false)
|
||||
const collapseRef = useRef<{ at: number; width: number } | null>(null)
|
||||
const openShown = openInHeader && !openCollapsed
|
||||
const hasLeading = openShown || (leading !== undefined && leading !== null && leading !== false)
|
||||
useLayoutEffect(() => {
|
||||
const header = fieldsRef.current?.closest(".hm-sw-header")
|
||||
if (!header) return
|
||||
@@ -218,6 +231,35 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
|
||||
if (panelAnchorRef.current) observer.observe(panelAnchorRef.current)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
// Space for the parts right of the logo, measured: the file name gives way first (its CSS clamp subtracts
|
||||
// --hm-sw-header-leading-width). Open moves into More only when the bar or object area really overflows,
|
||||
// and returns once the header is wider than at that moment by the Open width (no flicker at the edge).
|
||||
useLayoutEffect(() => {
|
||||
const header = fieldsRef.current?.closest<HTMLElement>(".hm-sw-header")
|
||||
if (!header) return
|
||||
const leadingWidth = leadingRef.current ? leadingRef.current.getBoundingClientRect().width : 0
|
||||
const value = hasLeading ? `${leadingWidth + 2}px` : ""
|
||||
if (header.style.getPropertyValue("--hm-sw-header-leading-width") !== value) {
|
||||
if (value) header.style.setProperty("--hm-sw-header-leading-width", value)
|
||||
else header.style.removeProperty("--hm-sw-header-leading-width")
|
||||
}
|
||||
if (!openInHeader) {
|
||||
collapseRef.current = null
|
||||
if (openCollapsed) setOpenCollapsed(false)
|
||||
return
|
||||
}
|
||||
if (!openCollapsed) {
|
||||
const bar = header.querySelector<HTMLElement>(":scope > .hm-sw-header__bar")
|
||||
const overflow = [bar, panelAnchorRef.current].some(element => !!element && element.scrollWidth - element.clientWidth > 1)
|
||||
const open = leadingRef.current?.querySelector<HTMLElement>("[data-document-open]")
|
||||
if (overflow && open) {
|
||||
collapseRef.current = { at: header.getBoundingClientRect().width, width: open.getBoundingClientRect().width + 2 }
|
||||
setOpenCollapsed(true)
|
||||
}
|
||||
} else if (collapseRef.current && headerWidth >= collapseRef.current.at + collapseRef.current.width) {
|
||||
setOpenCollapsed(false)
|
||||
}
|
||||
})
|
||||
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
|
||||
@@ -302,7 +344,7 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
|
||||
if (!item) return []
|
||||
const hidden = config.id in menuSlot ? fieldHidden(config.id as SWHeaderMenuId) : isOverflow(config.id)
|
||||
return [{ ...item, label: config.label ?? item.label, visible: scripted ? hidden : undefined }]
|
||||
})
|
||||
}).concat(openInHeader ? [{ id: "documentOpen", label: documentOpenLabel ?? "Open", tier: "action" as const, disabled, visible: openCollapsed, onSelect: onDocumentOpen }] : [])
|
||||
const fieldNodes: Record<SWHeaderMenuId, ReactNode> = {
|
||||
layer: (layers ? (
|
||||
<SWHeaderLayerMenu label={label("layer", "레이어")}
|
||||
@@ -318,9 +360,10 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
|
||||
onFreezeChange={onLayerFreezeChange}
|
||||
onLockChange={onLayerLockChange}
|
||||
onDetail={onLayerDetail}
|
||||
{...layerStates}
|
||||
/>
|
||||
) : (
|
||||
<SWHeaderLayerControl label={label("layer", "레이어")} value={layer} selected={activeMenu === "layer"} disabled={disabled} onClick={onLayerOpen} />
|
||||
<SWHeaderLayerControl label={label("layer", "레이어")} value={layer} selected={activeMenu === "layer"} disabled={disabled} onClick={onLayerOpen} {...layerStates} />
|
||||
)),
|
||||
color: (colors ? (
|
||||
<SWHeaderColorMenu label={label("color", "색상")}
|
||||
@@ -404,6 +447,8 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
|
||||
onDocumentClose={onDocumentClose}
|
||||
onNew={onDocumentNew}
|
||||
onOpen={onDocumentOpen}
|
||||
showNew={shown("documentNew")}
|
||||
showOpen={shown("documentOpen") && !openInHeader}
|
||||
documentIcon={documentIcon}
|
||||
newLabel={documentNewLabel}
|
||||
openLabel={documentOpenLabel}
|
||||
@@ -424,16 +469,21 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
|
||||
const windowReduced = removed.size > 0 && (frameControls.some(id => removed.has(id)) || (controls === undefined && !(enabled("print") && enabled("popupGuide"))))
|
||||
// CSS-only default layout: a field holding the first slot instead of the removed layer is styled as promoted.
|
||||
const promotedSlot = validPromoted ?? (!scripted && baseMenu && baseMenu !== "layer" ? baseMenu : null)
|
||||
const barData = propertyBar ?? (propertyPanel && {
|
||||
// The bar shares the panel data only when the panel has a selection (a content-only panel has no bar rows).
|
||||
const barData = propertyBar ?? (propertyPanel?.selection && {
|
||||
selection: propertyPanel.selection,
|
||||
properties: propertyPanel.properties,
|
||||
properties: propertyPanel.properties ?? [],
|
||||
onPropertyChange: propertyPanel.onPropertyChange,
|
||||
})
|
||||
|
||||
return (
|
||||
<header ref={ref} className={`hm-sw-header${className ? ` ${className}` : ""}`} {...rest}>
|
||||
<header ref={ref} className={`hm-sw-header${className ? ` ${className}` : ""}`} data-leading={openShown ? "open" : hasLeading ? "" : undefined} {...rest}>
|
||||
<div className="hm-sw-header__bar">
|
||||
<SWHeaderHome onClick={shown("home") ? onHomeClick : undefined} interactive={shown("home")} logo={logo} logoSrc={logoSrc} logoAlt={logoAlt} />
|
||||
{hasLeading && <div ref={leadingRef} className="hm-sw-header__leading">
|
||||
{openShown && <SWToolButton appearance="document" data-document-open="" disabled={disabled} onClick={onDocumentOpen}>{documentOpenLabel ?? "Open"}</SWToolButton>}
|
||||
{leading}
|
||||
</div>}
|
||||
{controls === undefined && enabled("document") && documentNode}
|
||||
<div ref={panelAnchorRef} className="hm-sw-header__object-area" role="toolbar" aria-label="객체 수정" data-configured={controls !== undefined || undefined} data-promoted={promotedSlot ? menuSlot[promotedSlot] : undefined}>
|
||||
{controls === undefined && enabled("drawingMode") && <span className="hm-sw-header__mode">{drawingModeLabel}</span>}
|
||||
@@ -446,7 +496,7 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
|
||||
)) : menuIds.map(id => <Fragment key={id}>{fieldNodes[id]}</Fragment>)}
|
||||
{tools}
|
||||
</div>
|
||||
{moreItems.some(item => item.visible !== false) && <SWHeaderMore items={moreItems} />}
|
||||
{moreItems.some(item => item.visible !== false) && <SWHeaderMore items={moreItems} forceVisible={openCollapsed} />}
|
||||
{controls === undefined && <SWHeaderProperties barOpen={barOpen} panelOpen={panelOpen} panelId={propertyPanel ? panelId : undefined} disabled={disabled} onBarToggle={toggleBar} onPanelToggle={togglePanel} showCaption={enabled("details")} showBar={enabled("propertyBar")} showPanel={enabled("propertyPanel")} />}
|
||||
</div>
|
||||
{windowShown && <div className="hm-sw-header__window" data-configured={controls !== undefined || undefined} data-reduced={windowReduced || undefined}>
|
||||
@@ -455,7 +505,7 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
|
||||
</div>}
|
||||
</div>
|
||||
{barOpen && barData && <div className="hm-sw-header__bar-anchor" style={{ left: barOffset, maxWidth: `calc(100% - ${barOffset}px)` }}><SWHeaderPropertyBar {...barData} disabled={disabled || propertyBar?.disabled} id={barId} /></div>}
|
||||
{panelOpen && propertyPanel && <SWHeaderPropertyPanel {...propertyPanel} id={panelId} anchorRef={panelAnchorRef} onClose={closePanel} />}
|
||||
{panelOpen && propertyPanel && <SWHeaderPropertyPanel {...propertyPanel} menus={{ showLayerFreeze: layerStates.showFreeze, showLayerLock: layerStates.showLock, ...propertyPanel.menus }} id={panelId} anchorRef={panelAnchorRef} onClose={closePanel} />}
|
||||
</header>
|
||||
)
|
||||
})
|
||||
@@ -33,6 +33,12 @@ export interface SWHeaderDocumentTitleProps {
|
||||
documentIcon?: ReactNode
|
||||
newLabel?: string
|
||||
openLabel?: string
|
||||
/**
|
||||
* Footer actions for products without that function (default true). Hiding both removes the footer;
|
||||
* hiding Open avoids a duplicate when the Header shows its own Open button next to the logo.
|
||||
*/
|
||||
showNew?: boolean
|
||||
showOpen?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,8 +61,11 @@ export function SWHeaderDocumentTitle({
|
||||
documentIcon: iconOverride,
|
||||
newLabel = "+ New",
|
||||
openLabel = "Open",
|
||||
showNew = true,
|
||||
showOpen = true,
|
||||
}: SWHeaderDocumentTitleProps) {
|
||||
const hasMenu = documents !== undefined
|
||||
// Nothing to list and no footer action: the file name stays a plain button (no empty dropdown).
|
||||
const hasMenu = documents !== undefined && (documents.length > 0 || showNew || showOpen)
|
||||
const [internalOpen, setInternalOpen] = useState(false)
|
||||
const open = hasMenu && (menuOpen ?? internalOpen)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
@@ -173,10 +182,10 @@ export function SWHeaderDocumentTitle({
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="hm-sw-header__document-footer">
|
||||
<button type="button" className="hm-sw-header__document-action" onClick={() => { onNew?.(); close(true) }}>{newLabel}</button>
|
||||
<button type="button" className="hm-sw-header__document-action" onClick={() => { onOpen?.(); close(true) }}>{openLabel}</button>
|
||||
</div>
|
||||
{(showNew || showOpen) && <div className="hm-sw-header__document-footer">
|
||||
{showNew && <button type="button" className="hm-sw-header__document-action" data-action="new" onClick={() => { onNew?.(); close(true) }}>{newLabel}</button>}
|
||||
{showOpen && <button type="button" className="hm-sw-header__document-action" data-action="open" onClick={() => { onOpen?.(); close(true) }}>{openLabel}</button>}
|
||||
</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState, type FocusEvent, type KeyboardEvent } from "react"
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState, type FocusEvent, type KeyboardEvent, type RefObject } from "react"
|
||||
import detailIcon from "../../../assets/layer-detail.svg"
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react"
|
||||
|
||||
@@ -9,6 +9,45 @@ export interface SWHeaderDropdownOptions {
|
||||
disabled?: boolean
|
||||
/** `start`: left edge under the field (color, text, dimension). `center`: centered under it (line style). */
|
||||
align?: "start" | "center"
|
||||
/** Inside an SW popup (scrolling, clipping body) the list becomes a fixed overlay. Default true. */
|
||||
escapePopup?: boolean
|
||||
}
|
||||
|
||||
export interface SWFixedDropdownPosition { left: number; top: number; flipped: boolean; offset: number }
|
||||
|
||||
/**
|
||||
* Fixed overlay position of a field dropdown, kept on screen: below the field (or above it when only there is
|
||||
* room), aligned to the field start / end / center and shifted sideways only at the screen edge. Used by the
|
||||
* property window pickers and by every field dropdown placed inside an SW popup, whose body scrolls and clips.
|
||||
* `offset` is the panel left relative to the field.
|
||||
*/
|
||||
export function useSWFixedDropdownPosition(open: boolean, rootRef: RefObject<HTMLElement | null>, panelRef: RefObject<HTMLElement | null>, align: "start" | "end" | "center", enabled = true) {
|
||||
const [position, setPosition] = useState<SWFixedDropdownPosition | null>(null)
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !enabled) { setPosition(null); return }
|
||||
const update = () => {
|
||||
const field = rootRef.current?.getBoundingClientRect()
|
||||
const panel = panelRef.current
|
||||
if (!field || !panel) return
|
||||
const width = panel.offsetWidth, height = panel.offsetHeight
|
||||
const viewportWidth = document.documentElement.clientWidth, viewportHeight = window.innerHeight
|
||||
const start = align === "end" ? field.right - width : align === "center" ? field.left + Math.round((field.width - width) / 2) : field.left
|
||||
const left = Math.max(4, Math.min(start, viewportWidth - width - 4))
|
||||
const below = field.bottom
|
||||
const flipped = below + height > viewportHeight - 4 && field.top - height >= 4
|
||||
const top = flipped ? field.top - height : below
|
||||
const offset = Math.round(left - field.left)
|
||||
setPosition(current => (current && current.left === left && current.top === top && current.flipped === flipped ? current : { left, top, flipped, offset }))
|
||||
}
|
||||
update()
|
||||
window.addEventListener("resize", update)
|
||||
window.addEventListener("scroll", update, true)
|
||||
return () => {
|
||||
window.removeEventListener("resize", update)
|
||||
window.removeEventListener("scroll", update, true)
|
||||
}
|
||||
}, [open, enabled, align, rootRef, panelRef])
|
||||
return position
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -18,7 +57,7 @@ export interface SWHeaderDropdownOptions {
|
||||
* a click outside / Tab out close. The list overlays below the field and is shifted horizontally only
|
||||
* when it would leave the header (or the screen).
|
||||
*/
|
||||
export function useSWHeaderDropdown({ open: openProp, onOpenChange, disabled = false, align = "start" }: SWHeaderDropdownOptions) {
|
||||
export function useSWHeaderDropdown({ open: openProp, onOpenChange, disabled = false, align = "start", escapePopup = true }: SWHeaderDropdownOptions) {
|
||||
const [internalOpen, setInternalOpen] = useState(false)
|
||||
const open = !disabled && (openProp ?? internalOpen)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
@@ -70,6 +109,9 @@ export function useSWHeaderDropdown({ open: openProp, onOpenChange, disabled = f
|
||||
return () => window.removeEventListener("resize", update)
|
||||
}) // Reposition after a host-driven control reorder as well as open/resize.
|
||||
|
||||
const inPopup = open && escapePopup && !!rootRef.current?.closest(".hm-sw-property")
|
||||
const fixed = useSWFixedDropdownPosition(open, rootRef, panelRef, align, inPopup)
|
||||
|
||||
/** Focusable entries of the panel in DOM order (rows, inputs, footer). */
|
||||
const entries = () => Array.from(panelRef.current?.querySelectorAll<HTMLElement>("button:not(:disabled), input:not(:disabled)") ?? [])
|
||||
|
||||
@@ -120,9 +162,11 @@ export function useSWHeaderDropdown({ open: openProp, onOpenChange, disabled = f
|
||||
triggerRef,
|
||||
menuId,
|
||||
/** Panel `left` relative to the field (centered and / or shifted to stay on screen). */
|
||||
panelStyle: position.left !== 0 ? { left: position.left } : undefined,
|
||||
panelStyle: fixed ? { position: "fixed" as const, left: fixed.left, top: fixed.top } : inPopup ? { visibility: "hidden" as const } : position.left !== 0 ? { left: position.left } : undefined,
|
||||
/** Panel left relative to the field (also in the fixed popup overlay). */
|
||||
panelOffset: fixed ? fixed.offset : position.left,
|
||||
/** The panel no longer sits under the field (the outline then rings the panel only). */
|
||||
shifted: position.left !== position.base,
|
||||
shifted: fixed ? fixed.flipped || fixed.offset !== position.base : position.left !== position.base,
|
||||
rootProps: { ref: rootRef, onKeyDown, onBlur, "data-open": open ? "true" : undefined },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,15 +18,18 @@ export interface SWHeaderLayerControlProps extends FieldBase {
|
||||
value: SWHeaderLayerValue
|
||||
/** State of the current layer for the three field icons (gray when off). Defaults to all colored. */
|
||||
state?: Pick<SWHeaderLayer, "visible" | "frozen" | "locked">
|
||||
/** Products without freeze / lock: the icon is not drawn, its 20px place stays (default true). */
|
||||
showFreeze?: boolean
|
||||
showLock?: boolean
|
||||
}
|
||||
|
||||
export function SWHeaderLayerControl({ value, state, label = "레이어", ...rest }: SWHeaderLayerControlProps) {
|
||||
export function SWHeaderLayerControl({ value, state, label = "레이어", showFreeze = true, showLock = true, ...rest }: SWHeaderLayerControlProps) {
|
||||
return (
|
||||
<SWHeaderField label={label} variant="layer" {...rest}>
|
||||
<span className="hm-sw-header__layer-icons" aria-hidden="true">
|
||||
<SWHeaderLayerIconArt kind="visibility" on={layerIconOn("visibility", state)} />
|
||||
<SWHeaderLayerIconArt kind="freeze" on={layerIconOn("freeze", state)} />
|
||||
<SWHeaderLayerIconArt kind="lock" on={layerIconOn("lock", state)} />
|
||||
{showFreeze ? <SWHeaderLayerIconArt kind="freeze" on={layerIconOn("freeze", state)} /> : <span className="hm-sw-header__layer-icon" data-empty="" />}
|
||||
{showLock ? <SWHeaderLayerIconArt kind="lock" on={layerIconOn("lock", state)} /> : <span className="hm-sw-header__layer-icon" data-empty="" />}
|
||||
</span>
|
||||
<SWHeaderFieldText label={label} value={value.name} />
|
||||
</SWHeaderField>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState, type KeyboardEvent } from "react"
|
||||
import { SWHeaderMenuDetail } from "./SWHeaderDropdown"
|
||||
import { SWHeaderMenuDetail, useSWFixedDropdownPosition } from "./SWHeaderDropdown"
|
||||
import type { SWHeaderLayer, SWHeaderLayerValue } from "../types"
|
||||
import { SWHeaderLayerControl } from "./SWHeaderFields"
|
||||
import { SWHeaderLayerToggle } from "./SWHeaderLayerIcons"
|
||||
@@ -26,6 +26,12 @@ export interface SWHeaderLayerMenuProps {
|
||||
onLockChange?: (layer: SWHeaderLayer, nextLocked: boolean) => void
|
||||
/** "레이어 상세". Optional host action; omitting it does not disable hover or simulate CAD work. */
|
||||
onDetail?: () => void
|
||||
/**
|
||||
* Products without freeze / lock (default true): the snowflake / lock is not drawn in the field or in
|
||||
* any row and is no button; its 20px place stays so names and the other icons keep their positions.
|
||||
*/
|
||||
showFreeze?: boolean
|
||||
showLock?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,6 +55,8 @@ export function SWHeaderLayerMenu({
|
||||
onFreezeChange,
|
||||
onLockChange,
|
||||
onDetail,
|
||||
showFreeze = true,
|
||||
showLock = true,
|
||||
}: SWHeaderLayerMenuProps) {
|
||||
const [internalOpen, setInternalOpen] = useState(false)
|
||||
const open = !disabled && (openProp ?? internalOpen)
|
||||
@@ -150,6 +158,9 @@ export function SWHeaderLayerMenu({
|
||||
}
|
||||
|
||||
const current = layers.find(item => item.id === activeLayerId)
|
||||
// Inside an SW popup the scrolling body would clip the list: the shared fixed overlay takes over.
|
||||
const inPopup = open && !!rootRef.current?.closest(".hm-sw-property")
|
||||
const fixed = useSWFixedDropdownPosition(open, rootRef, menuRef, "start", inPopup)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -167,6 +178,8 @@ export function SWHeaderLayerMenu({
|
||||
label={label}
|
||||
value={current ? { name: current.name } : value}
|
||||
state={current}
|
||||
showFreeze={showFreeze}
|
||||
showLock={showLock}
|
||||
selected={selected || open}
|
||||
expanded={open}
|
||||
controls={open ? menuId : undefined}
|
||||
@@ -183,8 +196,8 @@ export function SWHeaderLayerMenu({
|
||||
className="hm-sw-header__layer-menu"
|
||||
role="group"
|
||||
aria-label="레이어 목록"
|
||||
data-shifted={offset !== 0 ? "true" : undefined}
|
||||
style={offset !== 0 ? { left: offset } : undefined}
|
||||
data-shifted={(fixed ? fixed.flipped || fixed.offset !== 0 : offset !== 0) ? "true" : undefined}
|
||||
style={fixed ? { position: "fixed", left: fixed.left, top: fixed.top } : inPopup ? { visibility: "hidden" } : offset !== 0 ? { left: offset } : undefined}
|
||||
>
|
||||
<div className="hm-sw-header__layer-panel">
|
||||
<SWHeaderLayerRows
|
||||
@@ -198,6 +211,8 @@ export function SWHeaderLayerMenu({
|
||||
onVisibilityChange={onVisibilityChange}
|
||||
onFreezeChange={onFreezeChange}
|
||||
onLockChange={onLockChange}
|
||||
showFreeze={showFreeze}
|
||||
showLock={showLock}
|
||||
onDetail={onDetail && (() => { onDetail(); close(true) })}
|
||||
/>
|
||||
</div>
|
||||
@@ -207,7 +222,7 @@ export function SWHeaderLayerMenu({
|
||||
)
|
||||
}
|
||||
|
||||
export interface SWHeaderLayerRowsProps extends Pick<SWHeaderLayerMenuProps, "onVisibilityChange" | "onFreezeChange" | "onLockChange"> {
|
||||
export interface SWHeaderLayerRowsProps extends Pick<SWHeaderLayerMenuProps, "onVisibilityChange" | "onFreezeChange" | "onLockChange" | "showFreeze" | "showLock"> {
|
||||
layers: SWHeaderLayer[]
|
||||
/** Row shown as current (#FFC600 name, hover emphasis). None when the selection mixes layers. */
|
||||
current?: SWHeaderLayer
|
||||
@@ -222,7 +237,7 @@ export interface SWHeaderLayerRowsProps extends Pick<SWHeaderLayerMenuProps, "on
|
||||
* Shared by the header layer field and the property window layer field: same rows, toggles and callbacks.
|
||||
* The bulb, snowflake and lock are siblings of the row selection, so they never select the row.
|
||||
*/
|
||||
export function SWHeaderLayerRows({ layers, current, onSelect, onVisibilityChange, onFreezeChange, onLockChange, onDetail, detailDisabled = false }: SWHeaderLayerRowsProps) {
|
||||
export function SWHeaderLayerRows({ layers, current, onSelect, onVisibilityChange, onFreezeChange, onLockChange, onDetail, detailDisabled = false, showFreeze = true, showLock = true }: SWHeaderLayerRowsProps) {
|
||||
return (
|
||||
<>
|
||||
{layers.length > 0 && (
|
||||
@@ -236,8 +251,8 @@ export function SWHeaderLayerRows({ layers, current, onSelect, onVisibilityChang
|
||||
>
|
||||
<span className="hm-sw-header__layer-toggles">
|
||||
<SWHeaderLayerToggle kind="visibility" layer={layer} onChange={onVisibilityChange} />
|
||||
<SWHeaderLayerToggle kind="freeze" layer={layer} onChange={onFreezeChange} />
|
||||
<SWHeaderLayerToggle kind="lock" layer={layer} onChange={onLockChange} />
|
||||
{showFreeze ? <SWHeaderLayerToggle kind="freeze" layer={layer} onChange={onFreezeChange} /> : <span className="hm-sw-header__layer-toggle" data-empty="" />}
|
||||
{showLock ? <SWHeaderLayerToggle kind="lock" layer={layer} onChange={onLockChange} /> : <span className="hm-sw-header__layer-toggle" data-empty="" />}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -60,7 +60,7 @@ export function SWHeaderLineStyleMenu({
|
||||
value, label, lineTypes = [], lineWeights = [], activeLineTypeId, activeLineWeightId, open: openProp, onOpenChange, onClick,
|
||||
selected = false, disabled = false, onLineTypeSelect, onLineWeightSelect, onLineWeightValueChange, onScaleChange, onDetail,
|
||||
}: SWHeaderLineStyleMenuProps) {
|
||||
const { open, setOpen, close, panelRef, triggerRef, menuId, panelStyle, rootProps } = useSWHeaderDropdown({ open: openProp, onOpenChange, disabled, align: "center" })
|
||||
const { open, setOpen, close, panelRef, triggerRef, menuId, panelStyle, panelOffset, rootProps } = useSWHeaderDropdown({ open: openProp, onOpenChange, disabled, align: "center" })
|
||||
const currentType = lineTypes.find(item => (activeLineTypeId === undefined ? item.name === value.name : item.id === activeLineTypeId))
|
||||
const currentWeight = lineWeights.find(item => (activeLineWeightId === undefined ? item.name === value.weight : item.id === activeLineWeightId))
|
||||
|
||||
@@ -94,7 +94,7 @@ export function SWHeaderLineStyleMenu({
|
||||
/>
|
||||
{open && (
|
||||
<div ref={panelRef} id={menuId} className="hm-sw-header__menu-panel hm-sw-header__line-menu" role="group" aria-label="선스타일"
|
||||
style={{ ...panelStyle, "--hm-sw-line-seam-left": `${-(panelStyle?.left ?? 0)}px` } as CSSProperties}>
|
||||
style={{ ...panelStyle, ...({ "--hm-sw-line-seam-left": `${-panelOffset}px` } as CSSProperties) }}>
|
||||
<SWHeaderLineStyleColumns
|
||||
lineTypes={lineTypes}
|
||||
lineWeights={lineWeights}
|
||||
|
||||
@@ -25,10 +25,12 @@ export interface SWHeaderMoreItem {
|
||||
|
||||
export interface SWHeaderMoreProps {
|
||||
items: SWHeaderMoreItem[]
|
||||
/** Shows the trigger at any width (an item moved in by measurement, not by a breakpoint). */
|
||||
forceVisible?: boolean
|
||||
}
|
||||
|
||||
/** Overflow menu that keeps every moved control reachable. */
|
||||
export function SWHeaderMore({ items }: SWHeaderMoreProps) {
|
||||
export function SWHeaderMore({ items, forceVisible }: SWHeaderMoreProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
@@ -64,7 +66,7 @@ export function SWHeaderMore({ items }: SWHeaderMoreProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="hm-sw-header__more" onKeyDown={onKeyDown} onPointerLeave={() => setGuideOpen(false)}>
|
||||
<div ref={rootRef} className="hm-sw-header__more" data-forced={forceVisible || undefined} onKeyDown={onKeyDown} onPointerLeave={() => setGuideOpen(false)}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useLayoutEffect, useState, type CSSProperties, type ReactNode, type Ref } from "react"
|
||||
import { type CSSProperties, type ReactNode, type Ref } from "react"
|
||||
import type { SWHeaderColorOption, SWHeaderLayer, SWHeaderLineType } from "../types"
|
||||
import { SWHeaderColorPanelBody, useSWHeaderColorMode } from "./SWHeaderColorMenu"
|
||||
import { useSWHeaderDropdown } from "./SWHeaderDropdown"
|
||||
import { useSWFixedDropdownPosition, useSWHeaderDropdown } from "./SWHeaderDropdown"
|
||||
import { SWHeaderInput, parseSWHeaderColor } from "./SWHeaderInput"
|
||||
import { SWHeaderLayerRows } from "./SWHeaderLayerMenu"
|
||||
import { SWHeaderLinePreview, SWHeaderLineStyleColumns } from "./SWHeaderLineStyleMenu"
|
||||
@@ -38,30 +38,9 @@ const byValue = <T extends { id: string; name: string }>(items: readonly T[], va
|
||||
* the list flips above the field and shifts sideways only when the screen has no room.
|
||||
*/
|
||||
function useFieldDropdown(align: "start" | "end", disabled: boolean) {
|
||||
const dropdown = useSWHeaderDropdown({ disabled })
|
||||
const { open, rootRef, panelRef } = dropdown
|
||||
const [position, setPosition] = useState<CSSProperties>({ visibility: "hidden" })
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return
|
||||
const update = () => {
|
||||
const field = rootRef.current?.getBoundingClientRect()
|
||||
const panel = panelRef.current
|
||||
if (!field || !panel) return
|
||||
const width = panel.offsetWidth, height = panel.offsetHeight
|
||||
const viewportWidth = document.documentElement.clientWidth, viewportHeight = window.innerHeight
|
||||
const left = Math.max(4, Math.min(align === "end" ? field.right - width : field.left, viewportWidth - width - 4))
|
||||
const below = field.bottom
|
||||
const top = below + height > viewportHeight - 4 && field.top - height >= 4 ? field.top - height : below
|
||||
setPosition(current => (current.left === left && current.top === top ? current : { left, top }))
|
||||
}
|
||||
update()
|
||||
window.addEventListener("resize", update)
|
||||
window.addEventListener("scroll", update, true)
|
||||
return () => {
|
||||
window.removeEventListener("resize", update)
|
||||
window.removeEventListener("scroll", update, true)
|
||||
}
|
||||
}, [open, align, rootRef, panelRef])
|
||||
const dropdown = useSWHeaderDropdown({ disabled, escapePopup: false })
|
||||
const fixed = useSWFixedDropdownPosition(dropdown.open, dropdown.rootRef, dropdown.panelRef, align)
|
||||
const position: CSSProperties = fixed ? { left: fixed.left, top: fixed.top } : { visibility: "hidden" }
|
||||
return { ...dropdown, position }
|
||||
}
|
||||
|
||||
@@ -125,6 +104,8 @@ export function SWHeaderPropertyPickerField({ picker, property, id, editable, me
|
||||
onVisibilityChange={menus?.onLayerVisibilityChange}
|
||||
onFreezeChange={menus?.onLayerFreezeChange}
|
||||
onLockChange={menus?.onLayerLockChange}
|
||||
showFreeze={menus?.showLayerFreeze}
|
||||
showLock={menus?.showLayerLock}
|
||||
onDetail={menus?.onLayerDetail && (() => { menus.onLayerDetail?.(); close(true) })}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from "react"
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type HTMLAttributes, type ReactNode, type RefObject } from "react"
|
||||
import circleGraphic from "../../../assets/property-circle.svg"
|
||||
import titleGraphic from "../../../assets/property-title.svg"
|
||||
import singleTitleGraphic from "../../../assets/property-title-single.svg"
|
||||
@@ -59,6 +59,9 @@ export interface SWHeaderPropertyMenus {
|
||||
onLayerFreezeChange?: (layer: SWHeaderLayer, nextFrozen: boolean) => void
|
||||
onLayerLockChange?: (layer: SWHeaderLayer, nextLocked: boolean) => void
|
||||
onLayerDetail?: () => void
|
||||
/** Products without freeze / lock: the state is not drawn in the layer list (its place stays). Default true. */
|
||||
showLayerFreeze?: boolean
|
||||
showLayerLock?: boolean
|
||||
lineTypes?: SWHeaderLineType[]
|
||||
/** Weight column of the line style dropdown; omitted => no column. */
|
||||
lineWeights?: SWHeaderLineWeight[]
|
||||
@@ -108,9 +111,20 @@ export interface SWHeaderPropertyShapeContext {
|
||||
*/
|
||||
export type SWHeaderPropertyShapeContent = ReactNode | ((context: SWHeaderPropertyShapeContext) => ReactNode)
|
||||
export interface SWHeaderPropertyPanelProps {
|
||||
selection: SWHeaderPropertySelection
|
||||
properties: readonly SWHeaderProperty[]
|
||||
groups: readonly SWHeaderPropertyGroup[]
|
||||
/** Optional (2026-10-19): without it the panel is a plain popup (no selection tool) titled by `title`. */
|
||||
selection?: SWHeaderPropertySelection
|
||||
/** Optional (default []): data rows; combine with or replace by `children`. */
|
||||
properties?: readonly SWHeaderProperty[]
|
||||
groups?: readonly SWHeaderPropertyGroup[]
|
||||
/** Title text; overrides `selection.title`. */
|
||||
title?: string
|
||||
/**
|
||||
* Free content of the scrolling body, after the property groups (before the selection counts). Any React
|
||||
* content: SW controls, @hm-design/ui components, the project's own fields. The frame never changes.
|
||||
*/
|
||||
children?: ReactNode
|
||||
/** Focus the window when it opens. Default: true with `anchorRef` (SWHeader), false standalone. */
|
||||
autoFocus?: boolean
|
||||
onPropertyChange?: (property: SWHeaderProperty, value: string) => void
|
||||
/** Lists and callbacks of the layer / line style / color field dropdowns. */
|
||||
menus?: SWHeaderPropertyMenus
|
||||
@@ -247,81 +261,159 @@ function PropertyEditor({ property, onChange, menus }: { property: SWHeaderPrope
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape among several open popups: every mounted popup is on a stack ordered by its last activation (open,
|
||||
* focus or pointer inside). Escape inside a popup closes that popup; Escape elsewhere closes only the top one.
|
||||
* An Escape already handled inside (preventDefault / stopPropagation, e.g. a dropdown or an edited input)
|
||||
* never closes the popup.
|
||||
*/
|
||||
const popupStack: { close: () => void }[] = []
|
||||
function onDocumentEscape(event: KeyboardEvent) {
|
||||
if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return
|
||||
popupStack.at(-1)?.close()
|
||||
}
|
||||
function useSWPopupStack(onClose?: () => void) {
|
||||
const entry = useRef({ close: () => {} })
|
||||
entry.current.close = () => onClose?.()
|
||||
useEffect(() => {
|
||||
const item = entry.current
|
||||
popupStack.push(item)
|
||||
if (popupStack.length === 1) document.addEventListener("keydown", onDocumentEscape)
|
||||
return () => {
|
||||
popupStack.splice(popupStack.indexOf(item), 1)
|
||||
if (popupStack.length === 0) document.removeEventListener("keydown", onDocumentEscape)
|
||||
}
|
||||
}, [])
|
||||
/** Moves this popup to the top of the stack. */
|
||||
return () => {
|
||||
const index = popupStack.indexOf(entry.current)
|
||||
if (index >= 0 && index !== popupStack.length - 1) popupStack.push(...popupStack.splice(index, 1))
|
||||
}
|
||||
}
|
||||
|
||||
export interface SWPopupFrameProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
|
||||
title: string
|
||||
/** "single": title art without the selection tool (default). "tool": art with the tool slot. */
|
||||
titleArt?: "single" | "tool"
|
||||
/** Buttons of the tool slot (property window selection tool). */
|
||||
titleActions?: ReactNode
|
||||
/** Accessible name of the close button. */
|
||||
closeLabel?: string
|
||||
onClose?: () => void
|
||||
anchorRef?: RefObject<HTMLElement | null>
|
||||
autoFocus?: boolean
|
||||
/** Remounts the body when it changes (property window selection). */
|
||||
bodyKey?: string
|
||||
/** Rendered after the scrolling body (property window footer). */
|
||||
after?: ReactNode
|
||||
/** Value of data-selection-kind (frame CSS variants). */
|
||||
kind?: SWHeaderPropertySelection["kind"]
|
||||
}
|
||||
|
||||
/**
|
||||
* The SW popup frame (Figma 26:11822): 314px, #E4DBC9 surface, #A67D03 border, 4px radius, 30px title bar with
|
||||
* the Figma title art and close button, scrolling body (viewport-bound). Non-modal: no dim, no portal, no focus
|
||||
* trap. Shared by SWPopup and SWHeaderPropertyPanel, so both render the same DOM and classes.
|
||||
*/
|
||||
export function SWPopupFrame({
|
||||
title, titleArt = "single", titleActions, closeLabel = "속성창 닫기", onClose, anchorRef, autoFocus, bodyKey, after, kind = "single",
|
||||
id, style: customStyle, className, children, onKeyDown, onFocusCapture, onPointerDownCapture, ...rest
|
||||
}: SWPopupFrameProps) {
|
||||
const titleId = useId()
|
||||
const { panelRef, style } = useSWHeaderOverlay(anchorRef, 314)
|
||||
const raise = useSWPopupStack(onClose)
|
||||
const focus = autoFocus ?? !!anchorRef
|
||||
useLayoutEffect(() => {
|
||||
if (!focus) return
|
||||
const panel = panelRef.current
|
||||
const previous = document.activeElement as HTMLElement | null
|
||||
panel?.focus()
|
||||
return () => {
|
||||
// Closing with focus still inside (e.g. Escape on a field) returns it to where it came from.
|
||||
const active = document.activeElement
|
||||
if (panel && (active === document.body || active === null || panel.contains(active)) && previous?.isConnected) previous.focus()
|
||||
}
|
||||
}, [focus, panelRef])
|
||||
return (
|
||||
<div ref={panelRef} id={id} tabIndex={-1} className={`hm-sw-property${className ? ` ${className}` : ""}`} data-selection-kind={kind} role="dialog" aria-modal="false" aria-labelledby={titleId}
|
||||
style={{ ...style, ...customStyle }} {...rest}
|
||||
onFocusCapture={event => { raise(); onFocusCapture?.(event) }}
|
||||
onPointerDownCapture={event => { raise(); onPointerDownCapture?.(event) }}
|
||||
onKeyDown={event => {
|
||||
onKeyDown?.(event)
|
||||
if (event.key === "Escape" && !event.defaultPrevented && !event.nativeEvent.isComposing) { event.stopPropagation(); onClose?.() }
|
||||
}}>
|
||||
<div className="hm-sw-property__titlebar">
|
||||
<img className="hm-sw-property__title-art" src={titleArt === "single" ? singleTitleGraphic : titleGraphic} alt="" />
|
||||
<span id={titleId} className="hm-sw-property__title" title={title}>{title}</span>
|
||||
{titleActions}
|
||||
<button type="button" className="hm-sw-property__close" aria-label={closeLabel} onClick={onClose}>
|
||||
<img className="hm-sw-property__close-default" src={closeDefault} alt="" />
|
||||
<img className="hm-sw-property__close-hover" src={closeHover} alt="" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="hm-sw-property__body" key={bodyKey}>{children}</div>
|
||||
{after}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A single data-driven, non-modal property tool window (Figma 26:11822). */
|
||||
export function SWHeaderPropertyPanel({
|
||||
selection, properties, groups, onPropertyChange, menus, expandedGroupIds, defaultExpandedGroupIds = [],
|
||||
selection: selectionProp, properties = [], groups = [], title, children, autoFocus, onPropertyChange, menus, expandedGroupIds, defaultExpandedGroupIds = [],
|
||||
onExpandedGroupsChange, toolActive: controlledTool, onToolActiveChange, toolLabel = "객체 선택 도구",
|
||||
onOverviewClick, shapeContent, onClose, id, anchorRef, style: customStyle,
|
||||
onOverviewClick, shapeContent, onClose, id, anchorRef, style,
|
||||
}: SWHeaderPropertyPanelProps) {
|
||||
const titleId = useId()
|
||||
const selection: SWHeaderPropertySelection = selectionProp ?? { id: "", title: title ?? "", kind: "single" }
|
||||
const [expanded, setExpanded] = useState<string[]>([...defaultExpandedGroupIds])
|
||||
const [internalTool, setInternalTool] = useState(false)
|
||||
const active = controlledTool ?? internalTool
|
||||
const expandedIds = expandedGroupIds ?? expanded
|
||||
const { panelRef, style } = useSWHeaderOverlay(anchorRef, 314)
|
||||
useLayoutEffect(() => { if (anchorRef) panelRef.current?.focus() }, [anchorRef, panelRef])
|
||||
useEffect(() => {
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && !event.defaultPrevented && !event.isComposing) onClose?.()
|
||||
}
|
||||
document.addEventListener("keydown", onKey)
|
||||
return () => document.removeEventListener("keydown", onKey)
|
||||
}, [onClose])
|
||||
const toggleGroup = (groupId: string) => {
|
||||
const next = expandedIds.includes(groupId) ? expandedIds.filter(id => id !== groupId) : [...expandedIds, groupId]
|
||||
if (expandedGroupIds === undefined) setExpanded(next)
|
||||
onExpandedGroupsChange?.(next)
|
||||
}
|
||||
return (
|
||||
<div ref={panelRef} id={id} tabIndex={-1} className="hm-sw-property" data-selection-kind={selection.kind} role="dialog" aria-modal="false" aria-labelledby={titleId} style={{ ...style, ...customStyle }} onKeyDown={event => {
|
||||
if (event.key === "Escape" && !event.nativeEvent.isComposing) { event.stopPropagation(); onClose?.() }
|
||||
}}>
|
||||
<div className="hm-sw-property__titlebar">
|
||||
<img className="hm-sw-property__title-art" src={selection.kind === "single" ? singleTitleGraphic : titleGraphic} alt="" />
|
||||
<span id={titleId} className="hm-sw-property__title" title={selection.title}>{selection.title}</span>
|
||||
{selection.kind !== "single" && <button type="button" className="hm-sw-property__tool" aria-label={toolLabel} title={toolLabel} aria-pressed={active} onClick={() => {
|
||||
if (controlledTool === undefined) setInternalTool(!active)
|
||||
onToolActiveChange?.(!active)
|
||||
}}>
|
||||
<img className={active ? "hm-sw-property__tool-active" : "hm-sw-property__tool-default"} src={active ? toolActiveGraphic : toolDefault} alt="" />
|
||||
{!active && <img className="hm-sw-property__tool-hover" src={toolHover} alt="" />}
|
||||
</button>}
|
||||
<button type="button" className="hm-sw-property__close" aria-label="속성창 닫기" onClick={onClose}>
|
||||
<img className="hm-sw-property__close-default" src={closeDefault} alt="" />
|
||||
<img className="hm-sw-property__close-hover" src={closeHover} alt="" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="hm-sw-property__body" key={selection.id}>
|
||||
{ordered(groups).map(group => {
|
||||
const items = ordered(properties.filter(property => property.groupId === group.id))
|
||||
if (!items.length || (selection.kind === "none" && group.layout && group.layout !== "rows")) return null
|
||||
const isExpanded = expandedIds.includes(group.id)
|
||||
const expandable = group.collapsedCount !== undefined && items.length > group.collapsedCount
|
||||
const visible = expandable && !isExpanded ? items.slice(0, group.collapsedCount) : items
|
||||
return (
|
||||
<section key={group.id} className={`hm-sw-property__group hm-sw-property__group--${group.layout ?? "rows"}`} aria-label={group.name ?? "공통 속성"}>
|
||||
{group.name && <h3>{group.name}</h3>}
|
||||
<div className="hm-sw-property__group-content">
|
||||
{group.layout === "circle" && (() => {
|
||||
const content = typeof shapeContent === "function" ? shapeContent({ selection, group, properties: items }) : shapeContent
|
||||
const fallback = shapeContent === undefined
|
||||
return <div className="hm-sw-property__shape" data-shape-slot={fallback ? "default" : content === null || content === false ? "empty" : "custom"}>
|
||||
{fallback ? <img className="hm-sw-property__circle" src={circleGraphic} alt="원형 도형의 지름과 반지름 안내" /> : content}
|
||||
</div>
|
||||
})()}
|
||||
{group.layout === "position" && <div className="hm-sw-property__position-head"><span>구분</span>{visible.map(item => <span key={item.id}>{item.name}</span>)}</div>}
|
||||
<div className="hm-sw-property__editors">
|
||||
{group.layout === "position" && <span className="hm-sw-property__position-label">{group.positionLabel}</span>}
|
||||
{visible.map(property => <div key={property.id} className="hm-sw-property__editor" data-slot={property.graphicSlot}><PropertyEditor property={property} onChange={onPropertyChange} menus={menus} /></div>)}
|
||||
<SWPopupFrame id={id} title={title ?? selection.title} kind={selection.kind} titleArt={selection.kind === "single" ? "single" : "tool"}
|
||||
anchorRef={anchorRef} autoFocus={autoFocus} style={style} onClose={onClose} bodyKey={selection.id}
|
||||
titleActions={selection.kind !== "single" && <button type="button" className="hm-sw-property__tool" aria-label={toolLabel} title={toolLabel} aria-pressed={active} onClick={() => {
|
||||
if (controlledTool === undefined) setInternalTool(!active)
|
||||
onToolActiveChange?.(!active)
|
||||
}}>
|
||||
<img className={active ? "hm-sw-property__tool-active" : "hm-sw-property__tool-default"} src={active ? toolActiveGraphic : toolDefault} alt="" />
|
||||
{!active && <img className="hm-sw-property__tool-hover" src={toolHover} alt="" />}
|
||||
</button>}
|
||||
after={selection.overviewTitle && <button type="button" className="hm-sw-property__overview" onClick={onOverviewClick} disabled={!onOverviewClick}><img src={footerGraphic} alt="" /><span>{selection.overviewTitle}</span></button>}>
|
||||
{ordered(groups).map(group => {
|
||||
const items = ordered(properties.filter(property => property.groupId === group.id))
|
||||
if (!items.length || (selection.kind === "none" && group.layout && group.layout !== "rows")) return null
|
||||
const isExpanded = expandedIds.includes(group.id)
|
||||
const expandable = group.collapsedCount !== undefined && items.length > group.collapsedCount
|
||||
const visible = expandable && !isExpanded ? items.slice(0, group.collapsedCount) : items
|
||||
return (
|
||||
<section key={group.id} className={`hm-sw-property__group hm-sw-property__group--${group.layout ?? "rows"}`} aria-label={group.name ?? "공통 속성"}>
|
||||
{group.name && <h3>{group.name}</h3>}
|
||||
<div className="hm-sw-property__group-content">
|
||||
{group.layout === "circle" && (() => {
|
||||
const content = typeof shapeContent === "function" ? shapeContent({ selection, group, properties: items }) : shapeContent
|
||||
const fallback = shapeContent === undefined
|
||||
return <div className="hm-sw-property__shape" data-shape-slot={fallback ? "default" : content === null || content === false ? "empty" : "custom"}>
|
||||
{fallback ? <img className="hm-sw-property__circle" src={circleGraphic} alt="원형 도형의 지름과 반지름 안내" /> : content}
|
||||
</div>
|
||||
{expandable && <button type="button" className="hm-sw-property__expand" aria-label={`${group.name ?? "공통 속성"} ${isExpanded ? "접기" : "펼치기"}`} aria-expanded={isExpanded} onClick={() => toggleGroup(group.id)}><img src={expandGraphic} alt="" /></button>}
|
||||
})()}
|
||||
{group.layout === "position" && <div className="hm-sw-property__position-head"><span>구분</span>{visible.map(item => <span key={item.id}>{item.name}</span>)}</div>}
|
||||
<div className="hm-sw-property__editors">
|
||||
{group.layout === "position" && <span className="hm-sw-property__position-label">{group.positionLabel}</span>}
|
||||
{visible.map(property => <div key={property.id} className="hm-sw-property__editor" data-slot={property.graphicSlot}><PropertyEditor property={property} onChange={onPropertyChange} menus={menus} /></div>)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
{selection.kind === "all" && !!selection.counts?.length && <div className="hm-sw-property__counts" aria-label="선택된 객체 종류와 개수">{selection.counts.map(item => <span key={item.id} style={{ gridColumn: item.column, gridRow: item.row }}>{item.name} {item.count}</span>)}</div>}
|
||||
</div>
|
||||
{selection.overviewTitle && <button type="button" className="hm-sw-property__overview" onClick={onOverviewClick} disabled={!onOverviewClick}><img src={footerGraphic} alt="" /><span>{selection.overviewTitle}</span></button>}
|
||||
</div>
|
||||
{expandable && <button type="button" className="hm-sw-property__expand" aria-label={`${group.name ?? "공통 속성"} ${isExpanded ? "접기" : "펼치기"}`} aria-expanded={isExpanded} onClick={() => toggleGroup(group.id)}><img src={expandGraphic} alt="" /></button>}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
{children}
|
||||
{selection.kind === "all" && !!selection.counts?.length && <div className="hm-sw-property__counts" aria-label="선택된 객체 종류와 개수">{selection.counts.map(item => <span key={item.id} style={{ gridColumn: item.column, gridRow: item.row }}>{item.name} {item.count}</span>)}</div>}
|
||||
</SWPopupFrame>
|
||||
)
|
||||
}
|
||||
@@ -30,4 +30,4 @@ export type {
|
||||
} from "./types"
|
||||
|
||||
export { SWHeaderPropertyBar, type SWHeaderPropertyBarProps } from "./components/SWHeaderPropertyBar"
|
||||
export type { SWHeaderControlId, SWHeaderControlConfig, SWHeaderFrameControlId, SWHeaderHideableControlId } from "./types"
|
||||
export type { SWHeaderControlId, SWHeaderControlConfig, SWHeaderFrameControlId, SWHeaderSubControlId, SWHeaderHideableControlId } from "./types"
|
||||
@@ -138,8 +138,14 @@ export interface SWHeaderDimensionStyleOption {
|
||||
export type SWHeaderControlId = "document" | SWHeaderMenuId | "drawingMode" | "details" | "propertyBar" | "propertyPanel" | "print" | "popupGuide"
|
||||
/** Common frame parts outside the object area. Their position is fixed; each can be removed. */
|
||||
export type SWHeaderFrameControlId = "home" | "windowMove" | "minimize" | "close"
|
||||
/**
|
||||
* Items inside a part, for products without that function: the document dropdown's `+ New` / `Open`
|
||||
* and the layer freeze / lock state. Removing one never changes the layout or the breakpoints; a layer
|
||||
* state keeps its 20px place (empty), so names and the other icons stay aligned.
|
||||
*/
|
||||
export type SWHeaderSubControlId = "documentNew" | "documentOpen" | "layerFreeze" | "layerLock"
|
||||
/** Every Header part except the logo, which is always shown. */
|
||||
export type SWHeaderHideableControlId = SWHeaderControlId | SWHeaderFrameControlId
|
||||
export type SWHeaderHideableControlId = SWHeaderControlId | SWHeaderFrameControlId | SWHeaderSubControlId
|
||||
export interface SWHeaderControlConfig {
|
||||
id: SWHeaderControlId
|
||||
visible?: boolean
|
||||
@@ -154,6 +160,9 @@ export interface SWHeaderProps extends Omit<HTMLAttributes<HTMLElement>, "title"
|
||||
* Parts to remove, in either layout (default design or `controls`). Works without passing a full
|
||||
* `controls` list, so the default design and order of the remaining parts are kept. Removing
|
||||
* "home" keeps the logo as a static image. Unknown ids are ignored. Default: none.
|
||||
* Sub-items (`SWHeaderSubControlId`): "documentNew" / "documentOpen" remove the document dropdown
|
||||
* actions (both: no footer), "layerFreeze" / "layerLock" remove that state from the layer field,
|
||||
* the layer dropdown and the property window layer list.
|
||||
*/
|
||||
hiddenControls?: readonly SWHeaderHideableControlId[]
|
||||
/**
|
||||
@@ -187,6 +196,19 @@ export interface SWHeaderProps extends Omit<HTMLAttributes<HTMLElement>, "title"
|
||||
onDocumentOpen?: () => void
|
||||
/** Default icon for documents without `icon`. Defaults to the Figma document icon. */
|
||||
documentIcon?: ReactNode
|
||||
/**
|
||||
* Where "Open" is offered. `menu` (default): the document dropdown footer (unchanged). `header`: an Open
|
||||
* button right of the logo in the file-name look (`SWToolButton appearance="document"`), and no Open in the
|
||||
* dropdown, so it never appears twice. Both call `onDocumentOpen` with the `documentOpenLabel` text.
|
||||
* When the header lacks room (measured), the button moves into More. `hiddenControls: ["documentOpen"]`
|
||||
* removes Open everywhere.
|
||||
*/
|
||||
documentOpenPlacement?: "menu" | "header"
|
||||
/**
|
||||
* Extra controls right of the logo (after the header Open button, before the file name), e.g.
|
||||
* `SWToolButton appearance="document"`. They keep their place at every width; the file name gives way.
|
||||
*/
|
||||
leading?: ReactNode
|
||||
/** Footer labels. Default `+ New` / `Open` (Figma). */
|
||||
documentNewLabel?: string
|
||||
documentOpenLabel?: string
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { cloneElement, isValidElement, type HTMLAttributes, type MouseEvent, type ReactNode, type RefObject } from "react"
|
||||
import { SWPopupFrame } from "../sw-header/components/SWHeaderPropertyPanel"
|
||||
|
||||
export interface SWPopupProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
|
||||
/** Title text (700, #FFC600, ellipsis). */
|
||||
title: string
|
||||
/** Close button and Escape (inside the popup, or anywhere while it is the top popup). */
|
||||
onClose?: () => void
|
||||
/** Accessible name of the close button. Default `닫기`. */
|
||||
closeLabel?: string
|
||||
/**
|
||||
* Free content of the scrolling body: SWPopupSection / SWPopupRow, SW controls, @hm-design/ui components or
|
||||
* the project's own elements. Nothing is restricted; the frame keeps the SW design whatever is inside.
|
||||
*/
|
||||
children?: ReactNode
|
||||
/** Focus the popup when it opens (and return focus when it closes from inside). Default false. */
|
||||
autoFocus?: boolean
|
||||
/** Anchor element: the popup is fixed below it and kept inside the viewport (as SWHeader does). */
|
||||
anchorRef?: RefObject<HTMLElement | null>
|
||||
}
|
||||
|
||||
/**
|
||||
* HMDesign SW popup (the property window frame, Figma 26:11822) with free content. 314px wide, height follows the
|
||||
* content up to the viewport (the body scrolls). Non-modal (no dim, no portal, no focus trap); the host decides
|
||||
* when it is shown and where (`style`, or `anchorRef`). Wrap it in <SWTheme> outside SWHeader.
|
||||
*/
|
||||
export function SWPopup({ closeLabel = "닫기", ...props }: SWPopupProps) {
|
||||
return <SWPopupFrame closeLabel={closeLabel} titleArt="single" kind="single" {...props} />
|
||||
}
|
||||
|
||||
export interface SWPopupSectionProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
|
||||
/** Group heading (700, 70% black). Also the section's accessible name. */
|
||||
title?: string
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
/** A content group of the popup, the property window group (`rows` layout). Holds SWPopupRow or any content. */
|
||||
export function SWPopupSection({ title, children, className, ...rest }: SWPopupSectionProps) {
|
||||
return (
|
||||
<section className={`hm-sw-property__group hm-sw-property__group--rows${className ? ` ${className}` : ""}`} aria-label={rest["aria-label"] ?? title} {...rest}>
|
||||
{title && <h3>{title}</h3>}
|
||||
<div className="hm-sw-property__group-content"><div className="hm-sw-property__editors">{children}</div></div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export interface SWPopupRowProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/** Row label (72px, 700, ellipsis). */
|
||||
label: string
|
||||
/** Id of the control in `children`, for the label. */
|
||||
htmlFor?: string
|
||||
/** Unit text after the value (12px). */
|
||||
unit?: string
|
||||
/**
|
||||
* Value well: `box` (default) the 200px property field (Default / Hover border), `compact` the 41px hug field
|
||||
* of a bare number (children must be one SWHeaderInput), `none` no well (switches, buttons, text).
|
||||
*/
|
||||
field?: "box" | "compact" | "none"
|
||||
/** Number editing look (Figma On #0F0D0A while focused); text values keep Default / Hover only. */
|
||||
numeric?: boolean
|
||||
/** Read-only value: no hover / editing look. */
|
||||
readOnly?: boolean
|
||||
/** 40% row. */
|
||||
disabled?: boolean
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* One label + value row of the property window. The value is `children` (SWHeaderInput for SW fields, or any
|
||||
* control). A click on the empty part of a `box` focuses its first input, like the property window.
|
||||
*/
|
||||
export function SWPopupRow({ label, htmlFor, unit, field = "box", numeric, readOnly, disabled, children, className, ...rest }: SWPopupRowProps) {
|
||||
const focusInput = (event: MouseEvent<HTMLDivElement>) => {
|
||||
if (field !== "box" || disabled || readOnly) return
|
||||
const target = event.target as HTMLElement
|
||||
if (target.closest("input, select, textarea, button, a, [tabindex]")) return
|
||||
const input = event.currentTarget.querySelector<HTMLElement>("input, select, textarea")
|
||||
if (!input) return
|
||||
event.preventDefault()
|
||||
input.focus()
|
||||
}
|
||||
return (
|
||||
<div className={`hm-sw-property__editor${className ? ` ${className}` : ""}`} {...rest}>
|
||||
<div className="hm-sw-property__row" data-disabled={disabled || undefined}>
|
||||
<label htmlFor={htmlFor} title={label}>{label}</label>
|
||||
<div className="hm-sw-property__value" data-field={field === "none" ? undefined : field === "box" ? "box" : undefined}
|
||||
data-on={field === "box" && numeric && !readOnly ? "edit" : undefined} data-readonly={readOnly ? "true" : undefined}
|
||||
onMouseDown={focusInput}>
|
||||
{field === "compact" && isValidElement<{ className?: string }>(children)
|
||||
? cloneElement(children, { className: `${children.props.className ? `${children.props.className} ` : ""}hm-sw-property__compact` })
|
||||
: children}
|
||||
{unit && <span className="hm-sw-property__unit">{unit}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { createContext, forwardRef, useContext, type ButtonHTMLAttributes, type HTMLAttributes, type KeyboardEvent, type ReactNode } from "react"
|
||||
|
||||
/**
|
||||
* Existing SW button looks, reused as one public button (no new design values):
|
||||
* - `text`: the 속성Bar / 속성창 text toggle (700, text-only hover #FFC600, selected #FFC600).
|
||||
* - `pill`: the 40 x 38 print / K button of the dark pill (gradient hover ring, optional hover icon).
|
||||
* - `document`: the file-name surface (`hm-sw-header__document-button`, text-only hover #FFC600). Placed right
|
||||
* after `SWHeaderHome` it shares the slanted logo junction exactly like the file name.
|
||||
*/
|
||||
export type SWToolButtonAppearance = "text" | "pill" | "document"
|
||||
|
||||
export interface SWToolButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
|
||||
/** Default `text`. */
|
||||
appearance?: SWToolButtonAppearance
|
||||
/** Toggle state (aria-pressed, #FFC600 text). Inside `SWToolGroup selection="single"` it is the checked radio. */
|
||||
pressed?: boolean
|
||||
/** Button text. For `pill` it is the accessible name only (visually hidden: the pill shows its icon). */
|
||||
children?: ReactNode
|
||||
/** `pill` only: icon centered in the 40 x 38 button. */
|
||||
icon?: ReactNode
|
||||
/** `pill` only: icon shown instead of `icon` on hover (like print-hover.svg). */
|
||||
hoverIcon?: ReactNode
|
||||
}
|
||||
|
||||
interface GroupContext { selection: "none" | "single" }
|
||||
const SWToolGroupContext = createContext<GroupContext | null>(null)
|
||||
|
||||
export const SWToolButton = forwardRef<HTMLButtonElement, SWToolButtonProps>(function SWToolButton(
|
||||
{ appearance = "text", pressed, children, icon, hoverIcon, className, type = "button", ...rest }, ref,
|
||||
) {
|
||||
const group = useContext(SWToolGroupContext)
|
||||
const radio = group?.selection === "single"
|
||||
const state = pressed === undefined ? {} : radio
|
||||
? { role: "radio", "aria-checked": pressed, tabIndex: rest.tabIndex ?? (pressed ? 0 : -1) }
|
||||
: { "aria-pressed": pressed }
|
||||
const classes = (base: string) => `${base}${className ? ` ${className}` : ""}`
|
||||
if (appearance === "document") {
|
||||
// The wrapper is the Header flex item; [data-tool] keeps it at its content width (not the file-name clamp).
|
||||
return (
|
||||
<div className="hm-sw-header__document" data-tool="">
|
||||
<button ref={ref} type={type} className={classes("hm-sw-header__document-button")} data-selected={pressed ? "true" : undefined} {...state} {...rest}>
|
||||
<span className="hm-sw-header__document-name">{children}</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (appearance === "pill") {
|
||||
return (
|
||||
<button ref={ref} type={type} className={classes("hm-sw-header__action hm-sw-header__action--print hm-sw-tool__pill-button")} data-selected={pressed ? "true" : undefined} {...state} {...rest}>
|
||||
{icon !== undefined && <span className="hm-sw-header__icon hm-sw-tool__icon" aria-hidden="true">{icon}</span>}
|
||||
{hoverIcon !== undefined && <span className="hm-sw-header__icon-hover hm-sw-tool__icon" aria-hidden="true">{hoverIcon}</span>}
|
||||
{children !== undefined && <span className="hm-sw-tool__label">{children}</span>}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<button ref={ref} type={type} className={classes("hm-sw-header__toggle hm-sw-tool__text-button")} data-selected={pressed ? "true" : undefined} {...state} {...rest}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Existing SW group surfaces:
|
||||
* - `text`: the 상세보기 group (optional 12px caption above a 19px row; 1px dividers between buttons).
|
||||
* - `pill`: the dark rounded print / K pill (shrinks to its buttons).
|
||||
* `selection="single"` makes it a radio group (e.g. Model / Layout): give each button `pressed`, arrows move.
|
||||
*/
|
||||
export interface SWToolGroupProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/** Default `text`. */
|
||||
appearance?: "text" | "pill"
|
||||
/** `text` only: the 12px caption above the row, e.g. `상세보기`. Also the group's accessible name. */
|
||||
label?: string
|
||||
/** `single`: one pressed button (radio group, arrow keys move and select by focus only). Default `none`. */
|
||||
selection?: "none" | "single"
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
function moveFocus(event: KeyboardEvent<HTMLDivElement>) {
|
||||
const keys = ["ArrowRight", "ArrowDown", "ArrowLeft", "ArrowUp", "Home", "End"]
|
||||
if (!keys.includes(event.key)) return
|
||||
const items = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('button[role="radio"]:not(:disabled)'))
|
||||
const index = items.indexOf(document.activeElement as HTMLButtonElement)
|
||||
if (index < 0 || !items.length) return
|
||||
event.preventDefault()
|
||||
const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1
|
||||
: (index + (event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length
|
||||
items[next].focus()
|
||||
items[next].click()
|
||||
}
|
||||
|
||||
export const SWToolGroup = forwardRef<HTMLDivElement, SWToolGroupProps>(function SWToolGroup(
|
||||
{ appearance = "text", label, selection = "none", children, className, onKeyDown, ...rest }, ref,
|
||||
) {
|
||||
const role = selection === "single" ? "radiogroup" : "group"
|
||||
const keys = (event: KeyboardEvent<HTMLDivElement>) => { onKeyDown?.(event); if (!event.defaultPrevented && selection === "single") moveFocus(event) }
|
||||
const items = Array.isArray(children) ? children.filter(child => child !== null && child !== undefined && child !== false) : [children]
|
||||
const content = (
|
||||
<SWToolGroupContext.Provider value={{ selection }}>
|
||||
{appearance === "text" ? items.map((child, index) => (
|
||||
<span key={index} className="hm-sw-tool__item">
|
||||
{index > 0 && <span className="hm-sw-header__properties-divider" aria-hidden="true" />}
|
||||
{child}
|
||||
</span>
|
||||
)) : children}
|
||||
</SWToolGroupContext.Provider>
|
||||
)
|
||||
if (appearance === "pill") {
|
||||
return <div ref={ref} role={role} aria-label={rest["aria-label"] ?? label} className={`hm-sw-header__actions hm-sw-tool__pill${className ? ` ${className}` : ""}`} data-partial="" onKeyDown={keys} {...rest}>{content}</div>
|
||||
}
|
||||
return (
|
||||
<div ref={ref} role={role} aria-label={rest["aria-label"] ?? label} className={`hm-sw-header__properties hm-sw-tool__group${className ? ` ${className}` : ""}`} data-partial="" data-caption={label ? "" : undefined} onKeyDown={keys} {...rest}>
|
||||
{label && <span className="hm-sw-header__properties-caption" aria-hidden="true">{label}</span>}
|
||||
<div className="hm-sw-header__properties-row">{content}</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
Reference in new issue
Block a user