style(storybook): group form, selection and date picker story pages
- Button page shows Toggle; new Selection Controls page compares Checkbox, Radio Group and Switch states (specimens moved to *-examples.tsx) - Date Picker page includes Calendar; Combobox, Input and Slider layouts (Korean slider labels) tidied - Absorbed representative Stories keep their IDs as verification Stories - Component docs updated to the new representative pages
This commit is contained in:
1 parent
fe9c115cf9
commit
71d504407d
28 files changed
+1251
-621
No files matched your search
@@ -1,8 +1,9 @@
|
||||
import { useState, type ReactNode } from "react"
|
||||
import { createContext, useCallback, useContext, useId, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react"
|
||||
import { DownloadIcon } from "lucide-react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Button, type ButtonAppearance } from "@hm-design/ui"
|
||||
import { StoryPage, StoryPageHeader, StorySection, StorySectionGrid } from "@/components/story-page"
|
||||
import { ToggleSections } from "./toggle-examples"
|
||||
|
||||
// Story structure follows ui_viz (implementations/web/storybook/Button.stories.jsx):
|
||||
// one representative "Button" Story shows kinds, sizes and states on a single screen; the same sections also exist
|
||||
@@ -231,52 +232,96 @@ function DestructiveBoard() {
|
||||
// Group label: the StateBoard H3 style (14/20/500, secondary). `data-sb-board` gives the block the common one-step section indent.
|
||||
const groupLabel = "m-0 text-sm font-medium text-muted-foreground"
|
||||
|
||||
// Sizes / Variants (top, side by side): one row per group, label on a fixed 128px left column so every group title and first
|
||||
// button line up. At 640px and below the label moves above its buttons. Buttons keep the page-wide 8px gap, across and between
|
||||
// rows (same as the States matrices), and wrap only when too narrow.
|
||||
function GroupRows({ groups }: { groups: { id: string; label: string; children: ReactNode }[] }) {
|
||||
// 2026-11-13 (user request): one column per treatment, Sizes and States inside it. Each block is its H3 label (StateBoard H3
|
||||
// style), 8px, then the specimens; Sizes and States are 24px apart. The blocks stay on the section edge (no data-sb-board
|
||||
// indent) so the 420px Standard Solid matrix fits an equal third at the 1400px breakpoint without an inner scroll.
|
||||
function GroupBlock({ id, label, children }: { id: string; label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div data-sb-board="" className="grid gap-2 max-sm:gap-5">
|
||||
{groups.map(({ id, label, children }) => (
|
||||
<section key={id} data-overview-state={id} aria-label={label} className="flex min-w-0 items-center gap-4 max-sm:flex-col max-sm:items-start max-sm:gap-2">
|
||||
<h3 className={`${groupLabel} w-32 shrink-0 max-sm:w-auto`}>{label}</h3>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">{children}</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// States (bottom): Brand | Standard Solid | Standard Outline columns, title on top, all tops aligned. The columns use the
|
||||
// whole width, shared in proportion to their matrices (about 301 / 420 / 338px, never narrower than the matrix); each matrix
|
||||
// keeps its 8px button gap and sits at the start of its column. 3 columns from a 1250px preview (the three need about 1106px
|
||||
// of content plus the page margins), 2 columns (Brand + Solid, then Outline) from 890px, 1 column below; a 1-column matrix
|
||||
// wider than the screen scrolls in its own box only (the page never scrolls sideways).
|
||||
function GroupColumns({ groups }: { groups: { id: string; label: string; children: ReactNode }[] }) {
|
||||
return (
|
||||
<div
|
||||
data-sb-board=""
|
||||
className="grid grid-cols-[minmax(0,1fr)] items-start gap-6 min-[890px]:grid-cols-[minmax(max-content,301fr)_minmax(max-content,420fr)] min-[1250px]:grid-cols-[minmax(max-content,301fr)_minmax(max-content,420fr)_minmax(max-content,338fr)]"
|
||||
>
|
||||
{groups.map(({ id, label, children }) => (
|
||||
<section key={id} data-overview-state={id} aria-label={label} className="grid min-w-0 content-start gap-2">
|
||||
<h3 className={groupLabel}>{label}</h3>
|
||||
{children}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
<section data-overview-state={id} aria-label={label} className="grid min-w-0 content-start gap-2">
|
||||
<h3 className={groupLabel}>{label}</h3>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// States: one matrix per treatment, rows = Default / Hover / Pressed / Disabled (standardStates, the same 4 rows for Brand;
|
||||
// the live Interact button stays on the verification "Brand States"), columns = that treatment's colors in the Variants order.
|
||||
// Columns follow their buttons (auto table layout). 4px cell padding gives the same 8px gap as Sizes / Variants between
|
||||
// buttons, rows, the state label and the color header; the outer edge keeps none (pl-0 / last:pr-0) so the three matrices
|
||||
// fit one row at 1440px. Buttons sit centered under their color and are never resized.
|
||||
// the live Interact button stays on the verification "Brand States"), columns = that treatment's colors in the roles order.
|
||||
// Columns follow their buttons (auto table layout). 4px cell padding gives the same 8px gap as Sizes between buttons, rows,
|
||||
// the state label and the color header; the outer edge keeps none (pl-0 / last:pr-0). Buttons sit centered under their color
|
||||
// and are never resized. Matrix widths: Brand 301px, Standard Solid 420px, Standard Outline 338px.
|
||||
// 2026-11-13 (user request, no inner scroll on narrow screens): when a matrix is wider than its column (the 420px Standard Solid
|
||||
// matrix in a 358px column at 390px), every treatment switches together to the stacked layout: one row per state, its label
|
||||
// above the buttons, which wrap with the same 8px gap. Each matrix measures its own table (the auto-layout width it needs) and
|
||||
// reports whether it fits; MatrixFitProvider stacks all of them while any does not, so the three treatments keep one layout.
|
||||
// Storybook presentation only: the buttons, their props and states are unchanged.
|
||||
// Stacked layout state names (2026-11-13, user request): one step below the H3 "States" label (14/20/500) so the two levels
|
||||
// read apart — 12/16/400, same secondary color. Only these names change; the common H2 / H3 styles are untouched.
|
||||
const stackedStateLabel = "text-xs font-normal text-muted-foreground"
|
||||
|
||||
const MatrixFit = createContext<{ stacked: boolean; report: (id: string, overflow: boolean) => void }>({ stacked: false, report: () => {} })
|
||||
|
||||
function MatrixFitProvider({ children }: { children: ReactNode }) {
|
||||
const [overflowing, setOverflowing] = useState<ReadonlySet<string>>(() => new Set())
|
||||
const report = useCallback((id: string, overflow: boolean) => {
|
||||
setOverflowing((current) => {
|
||||
if (current.has(id) === overflow) return current
|
||||
const next = new Set(current)
|
||||
if (overflow) next.add(id)
|
||||
else next.delete(id)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
const value = useMemo(() => ({ stacked: overflowing.size > 0, report }), [overflowing, report])
|
||||
return <MatrixFit.Provider value={value}>{children}</MatrixFit.Provider>
|
||||
}
|
||||
|
||||
function StateMatrix({ appearance, states }: { appearance: ButtonAppearance; states: readonly (readonly [string, Record<string, unknown>])[] }) {
|
||||
const colors = rolesFor(appearance)
|
||||
const { stacked, report } = useContext(MatrixFit)
|
||||
const frame = useRef<HTMLDivElement>(null)
|
||||
// The table's own width, kept while the stacked layout is shown (no table to measure then).
|
||||
const tableWidth = useRef(0)
|
||||
const labelId = useId()
|
||||
useLayoutEffect(() => {
|
||||
const element = frame.current
|
||||
if (!element) return
|
||||
const check = () => {
|
||||
const table = element.querySelector("table")
|
||||
if (table) tableWidth.current = table.offsetWidth
|
||||
report(appearance, tableWidth.current > element.clientWidth)
|
||||
}
|
||||
const observer = new ResizeObserver(check)
|
||||
observer.observe(element)
|
||||
check()
|
||||
// The labels may get wider once the web font has loaded.
|
||||
void document.fonts?.ready.then(check)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
report(appearance, false)
|
||||
}
|
||||
}, [appearance, report])
|
||||
if (stacked) {
|
||||
return (
|
||||
<div ref={frame} data-state-matrix="stacked" className="grid min-w-0 max-w-full gap-3">
|
||||
{states.map(([state, props], index) => (
|
||||
<div key={state} role="group" aria-labelledby={`${labelId}-${index}`} className="grid gap-1">
|
||||
<span id={`${labelId}-${index}`} className={stackedStateLabel}>
|
||||
{state}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
{colors.map(([variant, color]) => (
|
||||
<Button key={variant} variant={variant} appearance={appearance} {...props}>
|
||||
{color}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="min-w-0 max-w-full overflow-x-auto">
|
||||
<div ref={frame} data-state-matrix="table" className="min-w-0 max-w-full overflow-x-auto">
|
||||
<table className="border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -311,8 +356,16 @@ function StateMatrix({ appearance, states }: { appearance: ButtonAppearance; sta
|
||||
)
|
||||
}
|
||||
|
||||
// Representative Story: the common Story page (Sizes → Variants → States).
|
||||
// The Toggle Button section of the representative page, in the same stacked / matrix form as the state matrices.
|
||||
function ToggleButtonSection() {
|
||||
const { stacked } = useContext(MatrixFit)
|
||||
return <ToggleSections titlePrefix="Toggle Button" idPrefix="toggle-" flat stacked={stacked} />
|
||||
}
|
||||
|
||||
// Representative Story: the common Story page, one section per treatment (Brand | Standard Solid | Standard Outline).
|
||||
// The former Overview button was the default Brand Medium button already shown under Sizes, so Overview is merged into Sizes.
|
||||
// 2026-11-13 (user request): the Variants section and the page-level Sizes / States titles are removed; each treatment section
|
||||
// (H2, common title → content 12px) holds its own Sizes and States (H3). The verification "Roles" Story keeps the role rows.
|
||||
// Named after the component, as in ui_viz. It is the only Button entry in the default sidebar, so Storybook shows Button as a single 1-depth item.
|
||||
export const All: Story = {
|
||||
name: "Button",
|
||||
@@ -320,45 +373,37 @@ export const All: Story = {
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Button" />
|
||||
{/* Sizes | Variants on one row from a 1080px preview (1280px screen with the 180px LNB), sharing the whole width in two
|
||||
equal halves (never narrower than their rows). Narrower previews stack the two sections. */}
|
||||
<StorySectionGrid className="grid-cols-1 items-start min-[1080px]:grid-cols-[minmax(max-content,1fr)_minmax(max-content,1fr)]">
|
||||
<StorySection title="Sizes">
|
||||
<GroupRows
|
||||
groups={appearances.slice(0, 2).map(([appearance, label]) => ({
|
||||
id: `sizes-${appearance}`,
|
||||
label,
|
||||
children: sizes.map(([size, sizeLabel]) => (
|
||||
<Button key={size} appearance={appearance} size={size}>
|
||||
{sizeLabel}
|
||||
</Button>
|
||||
)),
|
||||
}))}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Variants">
|
||||
<GroupRows
|
||||
groups={appearances.map(([appearance, label]) => ({
|
||||
id: `roles-${appearance}`,
|
||||
label,
|
||||
children: rolesFor(appearance).map(([variant, color]) => (
|
||||
<Button key={variant} variant={variant} appearance={appearance}>
|
||||
{color}
|
||||
</Button>
|
||||
)),
|
||||
}))}
|
||||
/>
|
||||
</StorySection>
|
||||
</StorySectionGrid>
|
||||
<StorySection title="States">
|
||||
<GroupColumns
|
||||
groups={appearances.map(([appearance, label]) => ({
|
||||
id: `${appearance}-states`,
|
||||
label,
|
||||
children: <StateMatrix appearance={appearance} states={standardStates} />,
|
||||
}))}
|
||||
/>
|
||||
</StorySection>
|
||||
{/* Equal-width columns from a 1400px preview (the 420px Standard Solid matrix fits a third: 3 × 420 + 2 × 24 plus the page
|
||||
margins and the scrollbar); below that Brand → Standard Solid → Standard Outline in one column, 28px apart (the
|
||||
common section gap of StorySectionGrid). */}
|
||||
<MatrixFitProvider>
|
||||
<StorySectionGrid className="grid-cols-1 items-start min-[1400px]:grid-cols-3">
|
||||
{appearances.map(([appearance, label]) => (
|
||||
<StorySection key={appearance} id={`${appearance}-group`} title={label}>
|
||||
<div className="grid min-w-0 gap-6">
|
||||
<GroupBlock id={`sizes-${appearance}`} label="Sizes">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
{sizes.map(([size, sizeLabel]) => (
|
||||
<Button key={size} appearance={appearance} size={size}>
|
||||
{sizeLabel}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</GroupBlock>
|
||||
<GroupBlock id={`${appearance}-states`} label="States">
|
||||
<StateMatrix appearance={appearance} states={standardStates} />
|
||||
</GroupBlock>
|
||||
</div>
|
||||
</StorySection>
|
||||
))}
|
||||
</StorySectionGrid>
|
||||
{/* 2026-11-13 (user request): Toggle Button joins the Button page ("버튼") after the treatment columns, from
|
||||
./toggle-examples unchanged; the Toggle Stories keep their IDs and direct URLs but are hidden from the sidebar.
|
||||
`flat`: one "Toggle Button" H2 section, 36px below the columns above, with 3 equal group columns (Variants / States,
|
||||
Groups, Sizes) from 1400px like the treatments, each row in the States matrix form (label left, 8px rhythm).
|
||||
Inside MatrixFitProvider so its rows switch to the stacked form together with the matrices. */}
|
||||
<ToggleButtonSection />
|
||||
</MatrixFitProvider>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user