diff --git a/.changeset/accordion-a11y-region-inert.md b/.changeset/accordion-a11y-region-inert.md new file mode 100644 index 00000000..e1a1b58a --- /dev/null +++ b/.changeset/accordion-a11y-region-inert.md @@ -0,0 +1,5 @@ +--- +"@sipe-team/accordion": patch +--- + +Fix Accordion accessibility: link Trigger and Content via generated `id`/`aria-controls`/`aria-labelledby` (now sourced from `@sipe-team/hooks`'s `useId`) so assistive tech can associate each panel with its trigger, expose each panel as an `aria-labelledby`-named `
` (implicit `role="region"`), and mark closed panels `inert` so their focusable descendants are removed from the tab order and accessibility tree instead of merely being visually clipped. The generated `id`/`aria-controls` on Trigger now always take precedence over any `id`/`aria-controls` passed via props, since they are managed internally to keep Trigger and Content in sync. diff --git a/.changeset/accordion-grid-compile-styles.md b/.changeset/accordion-grid-compile-styles.md new file mode 100644 index 00000000..a513acc7 --- /dev/null +++ b/.changeset/accordion-grid-compile-styles.md @@ -0,0 +1,6 @@ +--- +"@sipe-team/accordion": patch +"@sipe-team/grid": patch +--- + +Compile vanilla-extract styles so `./styles.css` resolves. Both packages declared a local `tsup` config without the vanilla-extract plugin, so their `.css.ts` styles were never compiled and the `dist/index.css` referenced by `publishConfig.exports["./styles.css"]` was absent from the published tarball. They now reuse the shared root config like every other component package, and shipped without styles until now. diff --git a/.changeset/accordion-single-mode.md b/.changeset/accordion-single-mode.md new file mode 100644 index 00000000..731582af --- /dev/null +++ b/.changeset/accordion-single-mode.md @@ -0,0 +1,5 @@ +--- +"@sipe-team/accordion": minor +--- + +Add single/multiple open mode support to Accordion component. When `type="single"` is set on `Accordion.Root`, only one item can be open at a time. Also supports controlled mode via `value` and `onValueChange` props. diff --git a/.changeset/add-card-ghost-variant.md b/.changeset/add-card-ghost-variant.md new file mode 100644 index 00000000..2bf367b6 --- /dev/null +++ b/.changeset/add-card-ghost-variant.md @@ -0,0 +1,5 @@ +--- +"@sipe-team/card": minor +--- + +Add ghost variant to Card component with transparent background, no border, and no padding diff --git a/.changeset/add-hooks-package.md b/.changeset/add-hooks-package.md new file mode 100644 index 00000000..d9cb7ba4 --- /dev/null +++ b/.changeset/add-hooks-package.md @@ -0,0 +1,5 @@ +--- +"@sipe-team/hooks": minor +--- + +Add `@sipe-team/hooks`, a shared package for cross-component React hooks. Starts with `useId()`, a thin wrapper around React's `useId()` that prefixes generated ids with `side-` for consistent, brand-namespaced DOM ids across Sipe Design System components. Accepts an optional `deterministicId` argument that passes through as-is, matching the `useId(props.id)` pattern already used ad hoc in components like Checkbox. diff --git a/.changeset/add-member-card.md b/.changeset/add-member-card.md new file mode 100644 index 00000000..9329c1c8 --- /dev/null +++ b/.changeset/add-member-card.md @@ -0,0 +1,12 @@ +--- +"@sipe-team/member-card": minor +--- + +Add MemberCard component + +- Ported from sipe.team's `UserCard` into the design system +- Uses semantic design tokens for colors, spacing, radius, and typography +- Composes `@sipe-team/avatar` for the profile image and `@sipe-team/typography` for text +- Supports 7 social icon types: GitHub, LinkedIn, Instagram, YouTube, Kakao, Email, Link (with automatic URL normalization) +- Includes `isOrganizer` badge, `introduce`, and `review` sections +- Provides `filled` (default) and `outline` variants diff --git a/.changeset/beige-cows-know.md b/.changeset/beige-cows-know.md new file mode 100644 index 00000000..f606943d --- /dev/null +++ b/.changeset/beige-cows-know.md @@ -0,0 +1,5 @@ +--- +"@sipe-team/tooltip": major +--- + +Refactor tooltip API: replace trigger prop with disableHoverListener, add controlled mode (open/onOpen/onClose), improve WCAG compliance, and fix animation on mount diff --git a/.changeset/bright-flex-responsive.md b/.changeset/bright-flex-responsive.md new file mode 100644 index 00000000..c356441b --- /dev/null +++ b/.changeset/bright-flex-responsive.md @@ -0,0 +1,6 @@ +--- +"@sipe-team/flex": minor +"@sipe-team/side": patch +--- + +Extend Flex props to accept responsive breakpoint values for direction, align, justify, wrap, and gap. diff --git a/.changeset/button-semantic-tokens.md b/.changeset/button-semantic-tokens.md new file mode 100644 index 00000000..c4c64181 --- /dev/null +++ b/.changeset/button-semantic-tokens.md @@ -0,0 +1,12 @@ +--- +"@sipe-team/button": patch +--- + +Apply new semantic design tokens to Button + +- Replace hardcoded brand colors (`#FF7C27`, gradient, `#FE4E07`, `#000`) with `vars.color.accent.{default,hover,subtle}` and `vars.color.foreground.onAccent` +- Replace primitive `color.gray500/600` used for disabled state with `vars.color.background.muted` and `vars.color.foreground.subtle` (`foreground.muted` is reserved for non-readable text per token spec) +- Use `vars.color.border.focus` for the focus-visible outline color +- Unify size padding/radius via `vars.spacing.component.*` and `vars.radius.component.md|lg` +- Add `opacity: 0.9` to fill `:active` for hover/press visual hierarchy +- Split the `border` shorthand into `borderWidth/Style/Color` to work around a vanilla-extract parsing issue with CSS custom properties in the `border` shorthand diff --git a/.changeset/button-v2-redesign.md b/.changeset/button-v2-redesign.md new file mode 100644 index 00000000..38ddb035 --- /dev/null +++ b/.changeset/button-v2-redesign.md @@ -0,0 +1,14 @@ +--- +"@sipe-team/button": major +"@sipe-team/tokens": minor +--- + +Redesign Button component based on 5th generation design system + +- **BREAKING**: Rename `ButtonVariant.filled` to `ButtonVariant.fill` +- **BREAKING**: Expand `ButtonSize` from `sm | lg` to `sm | md | lg | xl` +- Add `leftIcon` and `rightIcon` props for icon support +- Apply 5th design colors via `createVar()` (button-scoped CSS variables) +- Add interaction states: hover (gradient), pressed (`#FE4E07`), disabled (`gray500/600`) +- Fix disabled CSS specificity bug by moving styles into recipe base selectors +- Add `theme5th` color token to `@sipe-team/tokens` diff --git a/.changeset/card-semantic-tokens.md b/.changeset/card-semantic-tokens.md new file mode 100644 index 00000000..886c8dcf --- /dev/null +++ b/.changeset/card-semantic-tokens.md @@ -0,0 +1,10 @@ +--- +"@sipe-team/card": patch +--- + +Apply new semantic design tokens to Card + +- Replace primitive `color.gray50/100/200` and `color.cyan300` with `vars.color.background.{base,subtle}` and `vars.color.border.{default,strong}` +- outline variant border uses `border.strong` instead of `accent.default` to separate divider semantics from the interactive `accent` scale +- Standardize hardcoded `borderRadius: 12px` and `padding: 20px` to `vars.radius.component.lg` and `vars.spacing.component.lg` per token spec ("카드 모서리" / "카드 내부 패딩") +- Split the `border` shorthand into `borderWidth/Style/Color` to work around a vanilla-extract parsing issue with CSS custom properties in the `border` shorthand diff --git a/.changeset/checkbox-shared-use-id.md b/.changeset/checkbox-shared-use-id.md new file mode 100644 index 00000000..ad046653 --- /dev/null +++ b/.changeset/checkbox-shared-use-id.md @@ -0,0 +1,5 @@ +--- +"@sipe-team/checkbox": patch +--- + +Source Checkbox's internal `id` from `@sipe-team/hooks`'s `useId` instead of calling React's `useId` directly. The `props.id ?? internalId` fallback pattern is now handled by `useId`'s `deterministicId` argument. Behavior is unchanged aside from the generated id now carrying a `side-` prefix when no `id` prop is passed. diff --git a/.changeset/chip-normalize-package-json.md b/.changeset/chip-normalize-package-json.md new file mode 100644 index 00000000..9b2d3fa4 --- /dev/null +++ b/.changeset/chip-normalize-package-json.md @@ -0,0 +1,5 @@ +--- +"@sipe-team/chip": patch +--- + +Normalize `@sipe-team/chip` package.json to match the canonical structure used across other `@sipe-team/*` packages. Adds `publishConfig` with `access`/`registry`/`exports`, moves source `exports` to `./src/index.ts`, adds `type: "module"`, `repository`, `sideEffects: ["**/*.css"]`, and aligns scripts/dependencies. Consumer module resolution now uses the `import`/`require` branches declared under `publishConfig.exports`. diff --git a/.changeset/fix-avatar-error-loop-and-aschild.md b/.changeset/fix-avatar-error-loop-and-aschild.md new file mode 100644 index 00000000..05bc4582 --- /dev/null +++ b/.changeset/fix-avatar-error-loop-and-aschild.md @@ -0,0 +1,12 @@ +--- +'@sipe-team/avatar': patch +--- + +Fix two rendering bugs. + +- **`onError` loop**: when a broken `src` fell back to `fallback` and that URL also failed, the + handler reassigned the same URL endlessly. It now clears itself (`onerror = null`) before + swapping, so a failing fallback stops cleanly. +- **`asChild` sizing**: the `image` style (`width/height: 100%`) was declared after `size`, so + when `asChild` merged both onto the same `` the `100%` won and the avatar filled its parent + instead of keeping its size. `image` is now declared first, so `size` wins the tie. diff --git a/.changeset/fix-checkbox-assets-and-controlled.md b/.changeset/fix-checkbox-assets-and-controlled.md new file mode 100644 index 00000000..321716d9 --- /dev/null +++ b/.changeset/fix-checkbox-assets-and-controlled.md @@ -0,0 +1,8 @@ +--- +"@sipe-team/checkbox": patch +--- + +Fix two issues. + +- **SVG assets**: `Checkbox.css.ts` referenced `url("public/check.svg")` / `url("public/indeterminate.svg")`, which esbuild (tsup) resolved but source-compiling consumers (webpack + `@vanilla-extract/webpack-plugin`, e.g. the Docusaurus docs site) could not, breaking their production build. The icons are now inlined as `data:` URIs so both bundlers render identical marks with no external asset resolution. +- **`checked` + `defaultChecked` warning**: `Root` did not consume `defaultChecked`, so it leaked through context onto the `` alongside the always-set `checked`, triggering React's "input with both checked and defaultChecked" warning. `Root` now destructures `defaultChecked` (it only seeds the uncontrolled initial state), so the input receives just `checked`. diff --git a/.changeset/fix-radio-legend-style.md b/.changeset/fix-radio-legend-style.md new file mode 100644 index 00000000..d1e81958 --- /dev/null +++ b/.changeset/fix-radio-legend-style.md @@ -0,0 +1,8 @@ +--- +"@sipe-team/radio": patch +--- + +Apply the `radioGroupLegend` style to the `RadioGroup` legend. The style was +defined but never wired to the `` element, so the group label rendered +with browser-default styling instead of the intended weight, size, spacing, and +token color. diff --git a/.changeset/fix-token-css-chain.md b/.changeset/fix-token-css-chain.md new file mode 100644 index 00000000..b82d8292 --- /dev/null +++ b/.changeset/fix-token-css-chain.md @@ -0,0 +1,19 @@ +--- +'@sipe-team/tokens': minor +--- + +토큰 CSS 체인을 복구하고 `./styles.css` 진입점을 추가합니다. + +`packages/tokens/tsup.config.ts`가 21개 패키지 중 유일하게 루트 tsup 설정을 상속하지 않아 vanilla-extract esbuild 플러그인이 누락돼 있었습니다. 그 결과: + +- `contract.css.ts`가 일반 TS로 번들되어 **`dist`를 import하면 런타임 예외**가 발생했습니다 (`Styles were unable to be assigned to a file`). `@sipe-team/tokens`를 re-export하는 `@sipe-team/side`도 함께 영향받았습니다. +- `createGlobalTheme` 호출이 `sideEffects: false`로 트리셰이킹되어 `--side-*` 브릿지 CSS가 방출되지 않았습니다. +- 소비자가 토큰 CSS를 가져갈 진입점이 없어, `vars.*`를 쓰는 컴포넌트(Button)의 padding·radius·font-size·font-family·gap이 전부 무효화됐습니다. + +**변경 사항** + +- `tsup.config.ts`가 루트 설정을 상속합니다 (다른 20개 패키지와 동일한 패턴). +- `sideEffects`를 `["**/*.css"]`로 변경해 소비자의 CSS import가 번들러에 제거되지 않게 합니다. +- `./styles.css` 진입점을 추가합니다. Style Dictionary 값층(`--color-*`, `--spacing-*`)과 vanilla-extract 브릿지(`--side-*`)를 **한 파일로** 제공합니다 — 둘 중 하나만 로드하면 체인이 조용히 끊기기 때문입니다. + +**소비자 액션 필요:** 개별 패키지를 사용한다면 `import '@sipe-team/tokens/styles.css'`를 추가해야 컴포넌트 스타일이 적용됩니다. diff --git a/.changeset/fix-tooltip-ssr-portal-and-ref.md b/.changeset/fix-tooltip-ssr-portal-and-ref.md new file mode 100644 index 00000000..86256949 --- /dev/null +++ b/.changeset/fix-tooltip-ssr-portal-and-ref.md @@ -0,0 +1,13 @@ +--- +"@sipe-team/tooltip": patch +--- + +Fix two bugs. + +- **SSR crash**: `createPortal` ran into `document.body` whenever `isVisible` was true, even on + the server, where `document` doesn't exist. The portal is now deferred behind a `mounted` flag, + so it only renders after the client mounts. +- **Broken `ref` when `tooltipContent` is falsy**: the early `return <>{children}` skipped the + `wrapperRef` wiring entirely, leaving the forwarded `ref` permanently `null`. The trigger now + always renders through the normal `ref` path; only the tooltip markup is skipped when there's no + content. diff --git a/.changeset/input-semantic-tokens.md b/.changeset/input-semantic-tokens.md new file mode 100644 index 00000000..4e4196fa --- /dev/null +++ b/.changeset/input-semantic-tokens.md @@ -0,0 +1,11 @@ +--- +"@sipe-team/input": minor +--- + +Apply semantic design tokens and field API updates to Input + +- Replace local color/spacing/typography with semantic `vars` +- Fix `fontWeight` to regular (prop removed pending design review) +- Move field chrome (border/focus) onto the `input` element; use `span` wrapper +- Add `validation` prop for border-only `error` / `success` states (FormField-ready) +- Style native `readOnly` distinctly from `disabled` (muted background, default cursor) diff --git a/.changeset/package-json-divergence-cleanup.md b/.changeset/package-json-divergence-cleanup.md new file mode 100644 index 00000000..cc090b97 --- /dev/null +++ b/.changeset/package-json-divergence-cleanup.md @@ -0,0 +1,14 @@ +--- +"@sipe-team/accordion": patch +"@sipe-team/avatar": patch +"@sipe-team/checkbox": patch +"@sipe-team/divider": patch +"@sipe-team/plugin-figma-codegen": patch +"@sipe-team/radio": patch +"@sipe-team/side": patch +"@sipe-team/switch": patch +"@sipe-team/theme": patch +"@sipe-team/typography": patch +--- + +Align existing `@sipe-team/*` `package.json` metadata with the canonical shape. Adds `publishConfig.registry` to divider/radio/side/switch, unifies `lint` scripts on `pnpm exec biome lint`, normalizes `workspace:^` → `workspace:*` in avatar/switch/typography, and moves accordion/theme/checkbox direct `react`, `@types/react`, `react-dom`, and `@vanilla-extract/css` specifiers to the pnpm catalog. diff --git a/.changeset/provider-wonju.md b/.changeset/provider-wonju.md new file mode 100644 index 00000000..db4bcd72 --- /dev/null +++ b/.changeset/provider-wonju.md @@ -0,0 +1,13 @@ +--- +"@sipe-team/theme": major +"@sipe-team/tokens": minor +--- + +Replace brand-color ThemeProvider with light/dark mode toggle and align design tokens with SSOT. + +- `ThemeProvider` now applies themes via `data-theme` attribute instead of `assignInlineVars` +- `theme` prop changed from brand-color objects to `'light' | 'dark'` string union +- `ThemeMode` type is now exported from `@sipe-team/tokens` +- VE contract structure for `color`, `spacing`, and `radius` reorganized into semantic token hierarchy +- Token values in `themes.css.ts` now reference Style Dictionary CSS variables instead of hardcoded JS constants +- Removed `@vanilla-extract/dynamic` dependency from `@sipe-team/theme` diff --git a/.changeset/radio-shared-use-id.md b/.changeset/radio-shared-use-id.md new file mode 100644 index 00000000..7b0a7007 --- /dev/null +++ b/.changeset/radio-shared-use-id.md @@ -0,0 +1,5 @@ +--- +"@sipe-team/radio": patch +--- + +Source Radio's and RadioGroup's internal `id`/`name` fallback from `@sipe-team/hooks`'s `useId` instead of calling React's `useId` directly. Behavior is unchanged aside from the generated id now carrying a `side-` prefix. diff --git a/.changeset/reset-css-export-path.md b/.changeset/reset-css-export-path.md new file mode 100644 index 00000000..8e755361 --- /dev/null +++ b/.changeset/reset-css-export-path.md @@ -0,0 +1,5 @@ +--- +"@sipe-team/reset": patch +--- + +Point the `./reset.css` export at the emitted stylesheet. It referenced `./dist/reset.css`, but the build emits `./dist/index.css`, so importing `@sipe-team/reset/reset.css` failed to resolve. diff --git a/.changeset/semantic-tokens-foundation.md b/.changeset/semantic-tokens-foundation.md new file mode 100644 index 00000000..34e45734 --- /dev/null +++ b/.changeset/semantic-tokens-foundation.md @@ -0,0 +1,30 @@ +--- +"@sipe-team/tokens": major +"@sipe-team/button": patch +--- + +redesign token contract structure with semantic color, spacing, and radius hierarchy + +## Breaking Changes + +### `vars` path changes + +| Token | Before | After | +|-------|--------|-------| +| color | `vars.color.primary` `vars.color.background` `vars.color.text` | `vars.color.accent.{default\|hover\|subtle}` `vars.color.background.{base\|subtle\|muted}` `vars.color.foreground.{default\|subtle\|muted\|onAccent}` `vars.color.border.{default\|strong\|focus}` `vars.color.status.{success\|warning\|danger\|info}.{foreground\|background\|border}` | +| spacing | `vars.spacing.{xs\|sm\|md\|lg\|xl}` | `vars.spacing.component.{xs\|sm\|md\|lg\|xl}` `vars.spacing.layout.{sm\|md\|lg\|xl}` | +| radius | `vars.radius.{none\|sm\|md\|lg\|xl\|full}` | `vars.radius.component.{sm\|md\|lg\|xl\|full}` `vars.radius.layout.{sm\|md\|lg}` | + +`vars.color.gradient` and `vars.color.secondary` have been removed. + +### `defaultTheme` export removed + +`defaultTheme` is no longer exported from `@sipe-team/tokens`. Theme variables are now applied automatically via `createGlobalTheme` on `:root`. Remove any explicit `defaultTheme` import or usage. + +### Default color mode changed to dark + +The `:root` theme now defaults to `mode: 'dark'` (previously `mode: 'light'`). If your app relied on the light-mode defaults from `:root`, you will need to apply a `[data-theme]` attribute or supply your own light-mode overrides. + +### Deprecated named exports + +`opacity`, `zIndex`, `borderWidth`, `borderStyle`, `shadows`, `breakpoints`, `breakpointQuery`, `responsiveStyle`, `grid` and their associated types are deprecated and will be removed in a future release. diff --git a/.changeset/spicy-wasps-clap.md b/.changeset/spicy-wasps-clap.md new file mode 100644 index 00000000..45be6409 --- /dev/null +++ b/.changeset/spicy-wasps-clap.md @@ -0,0 +1,20 @@ +--- +"@sipe-team/accordion": patch +"@sipe-team/checkbox": patch +"@sipe-team/skeleton": patch +"@sipe-team/divider": patch +"@sipe-team/tooltip": patch +"@sipe-team/button": patch +"@sipe-team/switch": patch +"@sipe-team/badge": patch +"@sipe-team/input": patch +"@sipe-team/radio": patch +"@sipe-team/reset": patch +"@sipe-team/card": patch +"@sipe-team/flex": patch +"@sipe-team/grid": patch +"@sipe-team/side": patch +"@sipe-team/typography": patch +--- + +Preserve CSS imports in `sideEffects` so consumer bundlers don't tree-shake `./styles.css`. diff --git a/.changeset/tidy-planets-divide.md b/.changeset/tidy-planets-divide.md new file mode 100644 index 00000000..e6cc7a2f --- /dev/null +++ b/.changeset/tidy-planets-divide.md @@ -0,0 +1,6 @@ +--- +"@sipe-team/image": minor +"@sipe-team/side": minor +--- + +Add @sipe-team/image and refine after PR review. Forward onLoad callbacks and add radius token prop. diff --git a/.changeset/tokenize-tooltip-styles.md b/.changeset/tokenize-tooltip-styles.md new file mode 100644 index 00000000..e1fb2554 --- /dev/null +++ b/.changeset/tokenize-tooltip-styles.md @@ -0,0 +1,5 @@ +--- +"@sipe-team/tooltip": patch +--- + +Replace tooltip styles with design token references while preserving the rendered values. diff --git a/.changeset/tokens-token-names-resolution.md b/.changeset/tokens-token-names-resolution.md new file mode 100644 index 00000000..8f3574e9 --- /dev/null +++ b/.changeset/tokens-token-names-resolution.md @@ -0,0 +1,5 @@ +--- +"@sipe-team/tokens": patch +--- + +Fix `@sipe-team/tokens/token-names` failing to resolve under node16 ESM. The generated barrel declaration re-exported `./primitive` and `./semantic` without runtime extensions, and the types-only semantic layer had no runtime module behind it, so TypeScript could not resolve the sibling declarations. The barrel now emits extension-qualified specifiers and a runtime stub for the semantic layer. diff --git a/.changeset/tooltip-shared-use-id.md b/.changeset/tooltip-shared-use-id.md new file mode 100644 index 00000000..6e956c4b --- /dev/null +++ b/.changeset/tooltip-shared-use-id.md @@ -0,0 +1,5 @@ +--- +"@sipe-team/tooltip": patch +--- + +Source Tooltip's internal `id` from `@sipe-team/hooks`'s `useId` instead of calling React's `useId` directly. Behavior is unchanged aside from the generated id now carrying a `side-` prefix. diff --git a/.changeset/wet-pants-train.md b/.changeset/wet-pants-train.md new file mode 100644 index 00000000..76211d3c --- /dev/null +++ b/.changeset/wet-pants-train.md @@ -0,0 +1,5 @@ +--- +"@sipe-team/tooltip": patch +--- + +improve accessibility and refactor styles for Tooltip diff --git a/.mise.toml b/.mise.toml index 625d7472..0a4db0b3 100644 --- a/.mise.toml +++ b/.mise.toml @@ -1,3 +1,3 @@ [tools] -node = "22.22.2" +node = "24.19.0" pnpm = "10.33.0" diff --git a/AGENTS.md b/AGENTS.md index f20d2498..23b2d4a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,8 +11,8 @@ ## 빌드, 테스트, 개발 명령어 ```bash -mise install # Node v22.22.2 + pnpm 10.33.0 설치 (.mise.toml 기반) -pnpm install # 의존성 설치 (pnpm 10.33.0, Node v22.22.2) +mise install # Node v24.19.0 + pnpm 10.33.0 설치 (.mise.toml 기반) +pnpm install # 의존성 설치 (pnpm 10.33.0, Node v24.19.0) pnpm dev:storybook # Storybook 개발 서버 실행 (:6006) pnpm build:storybook # Storybook 빌드 pnpm lint # Biome 린트 + 수정 (변경된 패키지) diff --git a/package.json b/package.json index 4dc41c09..ec0c8dbd 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "@storybook/react": "catalog:", "@storybook/react-vite": "catalog:", "@tsconfig/strictest": "^2.0.5", - "@types/node": "^22.8.1", + "@types/node": "^24", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vanilla-extract/esbuild-plugin": "catalog:", @@ -61,7 +61,7 @@ }, "packageManager": "pnpm@10.33.0", "engines": { - "node": ">=22.22.2 <23", + "node": "24.x", "pnpm": "10.33.0" }, "lint-staged": { diff --git a/packages/accordion/package.json b/packages/accordion/package.json index 0a322e4c..ac2f0a8f 100644 --- a/packages/accordion/package.json +++ b/packages/accordion/package.json @@ -22,6 +22,7 @@ }, "dependencies": { "@radix-ui/react-slot": "catalog:", + "@sipe-team/hooks": "workspace:*", "@sipe-team/tokens": "workspace:*", "@vanilla-extract/css": "catalog:", "@vanilla-extract/recipes": "catalog:", diff --git a/packages/accordion/src/Accordion.test.tsx b/packages/accordion/src/Accordion.test.tsx index a3ad3c2b..7da6bde5 100644 --- a/packages/accordion/src/Accordion.test.tsx +++ b/packages/accordion/src/Accordion.test.tsx @@ -224,6 +224,85 @@ describe('Accordion single mode', () => { }); }); +describe('Accordion accessibility wiring', () => { + test('Trigger and Content are linked via aria-controls/id and aria-labelledby/id', () => { + render( + + + Test Trigger + Test Content + + , + ); + + const trigger = screen.getByRole('button'); + const content = screen.getByText('Test Content').closest('[class*="accordionContentWrapper"]'); + + expect(trigger).toHaveAttribute('id'); + expect(content).toHaveAttribute('id'); + expect(trigger.getAttribute('aria-controls')).toBe(content?.getAttribute('id')); + expect(content?.getAttribute('aria-labelledby')).toBe(trigger.getAttribute('id')); + }); + + test('Content panel exposes an accessible region name via the Trigger label', () => { + render( + + + Section Title + Section Body + + , + ); + + expect(screen.getByRole('region', { name: 'Section Title' })).toBeInTheDocument(); + }); + + test('closed panel is inert so its focusable descendants leave the tab order', () => { + render( + + + Test Trigger + + Hidden link + + + , + ); + + const trigger = screen.getByRole('button'); + const wrapper = screen.getByText('Hidden link').closest('[class*="accordionContentWrapper"]') as HTMLElement; + + expect(wrapper.inert).toBe(true); + + fireEvent.click(trigger); + expect(wrapper.inert).toBe(false); + + fireEvent.click(trigger); + expect(wrapper.inert).toBe(true); + }); + + test('user-supplied id/aria-controls on Trigger do not desync from Content', () => { + render( + + + + Test Trigger + + Test Content + + , + ); + + const trigger = screen.getByRole('button'); + const content = screen.getByText('Test Content').closest('[class*="accordionContentWrapper"]'); + + expect(trigger.getAttribute('id')).not.toBe('custom-id'); + expect(trigger.getAttribute('aria-controls')).not.toBe('custom-aria-controls'); + expect(trigger.getAttribute('aria-controls')).toBe(content?.getAttribute('id')); + expect(content?.getAttribute('aria-labelledby')).toBe(trigger.getAttribute('id')); + }); +}); + describe('Accordion structure', () => { test('renders children passed to Accordion correctly', () => { render( diff --git a/packages/accordion/src/Accordion.tsx b/packages/accordion/src/Accordion.tsx index e63b4d31..ccce1c5a 100644 --- a/packages/accordion/src/Accordion.tsx +++ b/packages/accordion/src/Accordion.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react'; import { type ComponentProps, type ForwardedRef, forwardRef, useState } from 'react'; +import { useId } from '@sipe-team/hooks'; import { AccordionArrowIcon } from '@sipe-team/icon'; import { Slot } from '@radix-ui/react-slot'; @@ -92,7 +93,11 @@ export const AccordionItem = forwardRef(function AccordionItem( } }; - const contextValue = { isOpen, toggleAccordion }; + const uid = useId(); + const triggerId = `${uid}-trigger`; + const contentId = `${uid}-content`; + + const contextValue = { isOpen, toggleAccordion, triggerId, contentId }; return ( @@ -107,7 +112,7 @@ export const AccordionTrigger = forwardRef(function AccordionTrigger( { children, className, asChild, ...props }: AccordionTriggerProps, ref: ForwardedRef, ) { - const { isOpen, toggleAccordion } = useAccordionItemContext(); + const { isOpen, toggleAccordion, triggerId, contentId } = useAccordionItemContext(); const Component = asChild ? Slot : 'button'; const buttonProps = asChild ? {} : { type: 'button' as const }; @@ -119,6 +124,8 @@ export const AccordionTrigger = forwardRef(function AccordionTrigger( onClick={toggleAccordion} aria-expanded={isOpen} {...props} + id={triggerId} + aria-controls={contentId} > {children} @@ -131,14 +138,16 @@ export const AccordionIndicator = () => { }; export const AccordionContent = ({ children, asChild, className, ...props }: AccordionContentProps) => { - const { isOpen } = useAccordionItemContext(); + const { isOpen, triggerId, contentId } = useAccordionItemContext(); const { ref, height, shouldTransition } = useAccordionAnimation(isOpen); const Component = asChild ? Slot : 'div'; return ( -
{children} -
+
); }; diff --git a/packages/accordion/src/context/AccordionItemContext.tsx b/packages/accordion/src/context/AccordionItemContext.tsx index 129fc93f..5a54c5ae 100644 --- a/packages/accordion/src/context/AccordionItemContext.tsx +++ b/packages/accordion/src/context/AccordionItemContext.tsx @@ -3,6 +3,8 @@ import { createContext, useContext } from 'react'; interface AccordionItemContextValue { isOpen: boolean; toggleAccordion: () => void; + triggerId: string; + contentId: string; } export const AccordionItemContext = createContext(null); diff --git a/packages/accordion/src/hooks/useAccordionAnimation.ts b/packages/accordion/src/hooks/useAccordionAnimation.ts index 6abf2159..e0140460 100644 --- a/packages/accordion/src/hooks/useAccordionAnimation.ts +++ b/packages/accordion/src/hooks/useAccordionAnimation.ts @@ -1,26 +1,31 @@ import { useLayoutEffect, useRef, useState } from 'react'; export const useAccordionAnimation = (isOpen: boolean) => { - const ref = useRef(null); + const ref = useRef(null); const [height, setHeight] = useState('0px'); const [shouldTransition, setShouldTransition] = useState(false); useLayoutEffect(() => { const el = ref.current; + if (!el) return; + + // A JSX `inert={false}` prop serializes to the string "false", which browsers + // treat as present (still inert). Setting the DOM property directly avoids that. + el.inert = !isOpen; if (isOpen) { - setHeight(`${el?.scrollHeight}px`); + setHeight(`${el.scrollHeight}px`); setShouldTransition(true); const handleTransitionEnd = () => { if (isOpen) setHeight('auto'); }; - el?.addEventListener('transitionend', handleTransitionEnd, { once: true }); - return () => el?.removeEventListener('transitionend', handleTransitionEnd); + el.addEventListener('transitionend', handleTransitionEnd, { once: true }); + return () => el.removeEventListener('transitionend', handleTransitionEnd); } - if (el?.style.height === 'auto') { - setHeight(`${el?.scrollHeight}px`); + if (el.style.height === 'auto') { + setHeight(`${el.scrollHeight}px`); requestAnimationFrame(() => { setShouldTransition(true); setHeight('0px'); diff --git a/packages/button/src/Button.css.ts b/packages/button/src/Button.css.ts index dbe0b4df..2ea5200c 100644 --- a/packages/button/src/Button.css.ts +++ b/packages/button/src/Button.css.ts @@ -1,14 +1,10 @@ -import { color, vars } from '@sipe-team/tokens'; +import { vars } from '@sipe-team/tokens'; -import { createVar, style } from '@vanilla-extract/css'; +import { style } from '@vanilla-extract/css'; import { recipe } from '@vanilla-extract/recipes'; import { ButtonSize, ButtonVariant } from './Button'; -export const buttonOrange = createVar(); -export const buttonGradient = createVar(); -export const buttonRed = createVar(); - export const iconWrapper = style({ display: 'flex', alignItems: 'center', @@ -16,92 +12,95 @@ export const iconWrapper = style({ }); export const iconLayout = style({ - gap: vars.spacing.component.md, + gap: vars.spacing.component.xs, }); export const button = recipe({ base: { - vars: { - [buttonOrange]: '#FF7C27', - [buttonGradient]: 'linear-gradient(225deg, #FF4500 0%, #FFB24D 100%)', - [buttonRed]: '#FE4E07', - }, display: 'flex', alignItems: 'center', justifyContent: 'center', fontStyle: 'normal', fontWeight: vars.typography.fontWeight.semiBold, - lineHeight: '150%', + lineHeight: vars.typography.lineHeight.regular, cursor: 'pointer', transition: 'all 0.2s ease-in-out', border: 'none', fontFamily: vars.typography.fontFamily, ':focus-visible': { - outline: `2px solid ${buttonOrange}`, + outline: `2px solid ${vars.color.border.focus}`, outlineOffset: '2px', }, selectors: { '&:disabled, &[aria-disabled="true"]': { - backgroundColor: color.gray500, - color: color.gray600, + backgroundColor: vars.color.background.muted, + color: vars.color.foreground.subtle, cursor: 'not-allowed', pointerEvents: 'none', border: 'none', - background: color.gray500, }, }, }, variants: { variant: { [ButtonVariant.fill]: { - backgroundColor: buttonOrange, - color: '#000', + backgroundColor: vars.color.accent.default, + color: vars.color.foreground.onAccent, ':hover': { - background: buttonGradient, + backgroundColor: vars.color.accent.hover, }, ':active': { - background: buttonRed, + backgroundColor: vars.color.accent.pressed, }, }, [ButtonVariant.outline]: { backgroundColor: 'transparent', - border: `1px solid ${buttonOrange}`, - color: buttonOrange, + borderWidth: '1px', + borderStyle: 'solid', + borderColor: vars.color.accent.default, + color: vars.color.accent.default, + ':hover': { + backgroundColor: vars.color.accent.subtle, + }, + ':active': { + borderColor: vars.color.accent.hover, + color: vars.color.accent.hover, + }, }, [ButtonVariant.ghost]: { backgroundColor: 'transparent', border: 'none', - color: buttonOrange, + color: vars.color.accent.default, ':hover': { - opacity: 0.8, + backgroundColor: vars.color.accent.subtle, }, ':active': { - color: buttonRed, + color: vars.color.accent.hover, }, }, }, size: { [ButtonSize.sm]: { height: '32px', - padding: `0 ${vars.spacing.component.xs}`, + padding: `0 ${vars.spacing.component.sm}`, borderRadius: vars.radius.component.md, fontSize: vars.typography.fontSize['050'], }, [ButtonSize.md]: { height: '40px', - padding: '0 12px', - borderRadius: '6px', + padding: `0 ${vars.spacing.component.md}`, + borderRadius: vars.radius.component.md, fontSize: vars.typography.fontSize['100'], }, [ButtonSize.lg]: { height: '48px', - padding: `0 ${vars.spacing.component.sm}`, - borderRadius: '6px', + padding: `0 ${vars.spacing.component.md}`, + borderRadius: vars.radius.component.md, fontSize: vars.typography.fontSize['200'], }, [ButtonSize.xl]: { height: '64px', - padding: `0 ${vars.spacing.component.md}`, + padding: `0 ${vars.spacing.component.lg}`, borderRadius: vars.radius.component.lg, fontSize: vars.typography.fontSize['500'], }, diff --git a/packages/card/src/Card.css.ts b/packages/card/src/Card.css.ts index cacce8d5..880a1fa8 100644 --- a/packages/card/src/Card.css.ts +++ b/packages/card/src/Card.css.ts @@ -1,4 +1,4 @@ -import { color } from '@sipe-team/tokens'; +import { vars } from '@sipe-team/tokens'; import { recipe } from '@vanilla-extract/recipes'; @@ -24,22 +24,26 @@ export const card = recipe({ display: 'flex', justifyContent: 'center', alignItems: 'center', - borderRadius: '12px', - padding: '20px', + borderRadius: vars.radius.component.lg, + padding: vars.spacing.component.lg, }, variants: { variant: { [CardVariant.filled]: { - backgroundColor: color.gray100, - border: `1px solid ${color.gray200}`, + backgroundColor: vars.color.background.subtle, + borderWidth: '1px', + borderStyle: 'solid', + borderColor: vars.color.border.default, }, [CardVariant.outline]: { - backgroundColor: color.gray50, - border: `1px solid ${color.cyan300}`, + backgroundColor: vars.color.background.default, + borderWidth: '1px', + borderStyle: 'solid', + borderColor: vars.color.border.strong, }, [CardVariant.ghost]: { backgroundColor: 'transparent', - border: 'none', + borderStyle: 'none', padding: 0, }, }, diff --git a/packages/card/src/Card.test.tsx b/packages/card/src/Card.test.tsx index c5ae109f..869d2b8b 100644 --- a/packages/card/src/Card.test.tsx +++ b/packages/card/src/Card.test.tsx @@ -1,11 +1,36 @@ -import { color } from '@sipe-team/tokens'; - import { render, screen } from '@testing-library/react'; import { expect, test } from 'vitest'; import { Card } from './Card'; -test('children으로 넘어간 요소를 반환한다. ', () => { +function ruleForClass(className: string): string { + const target = `.${className}`; + for (const sheet of Array.from(document.styleSheets)) { + let rules: CSSRuleList; + try { + rules = sheet.cssRules; + } catch { + continue; + } + for (const rule of Array.from(rules)) { + if (!('selectorText' in rule)) continue; + const selectorText = (rule as CSSStyleRule).selectorText ?? ''; + const matches = selectorText.split(',').some((s) => s.trim() === target); + if (matches) return rule.cssText; + } + } + return ''; +} + +// happy-dom does not resolve CSS custom properties to computed values, so +// tests using this helper assert on the token reference (e.g. `var(--side-...)`) +// rather than the resolved value. Verifying the token-to-value mapping is the +// responsibility of @sipe-team/tokens. +function rulesForElement(el: HTMLElement): string { + return el.className.split(/\s+/).filter(Boolean).map(ruleForClass).join('\n'); +} + +test('renders children passed to Card', () => { render( Card @@ -15,12 +40,7 @@ test('children으로 넘어간 요소를 반환한다. ', () => { expect(screen.getByText('Card')).toBeInTheDocument(); }); -test('Card는 default로 padding(20px)을 갖는다.', () => { - render(Card); - expect(screen.getByText('Card')).toHaveStyle({ padding: '20px' }); -}); - -test('Card는 default로 요소를 중앙정렬을 한다.', () => { +test('centers content by default', () => { render(Card); expect(screen.getByText('Card')).toHaveStyle({ display: 'flex', @@ -29,57 +49,53 @@ test('Card는 default로 요소를 중앙정렬을 한다.', () => { }); }); -test('ratio가 default로 aspect-ratio는 16/9로 반환한다.', () => { +test('applies default aspect-ratio 16/9 when ratio is not provided', () => { render(Card); expect(screen.getByText('Card')).toHaveStyle('aspect-ratio: 16 / 9'); }); -test('ratio에 wide로 넣으면 aspect-ratio는 21/9로 반환한다.', () => { +test('applies aspect-ratio 21/9 when ratio is "wide"', () => { render(Card); expect(screen.getByText('Card')).toHaveStyle('aspect-ratio: 21 / 9'); }); -test('ratio에 square로 넣으면 aspect-ratio는 1/1로 반환한다.', () => { +test('applies aspect-ratio 1/1 when ratio is "square"', () => { render(Card); expect(screen.getByText('Card')).toHaveStyle('aspect-ratio: 1 / 1'); }); -test('ratio에 portrait로 넣으면 aspect-ratio는 3/4로 반환한다.', () => { +test('applies aspect-ratio 3/4 when ratio is "portrait"', () => { render(Card); expect(screen.getByText('Card')).toHaveStyle('aspect-ratio: 3 / 4'); }); -test('ratio에 auto로 넣으면 aspect-ratio는 auto로 반환한다.', () => { +test('applies aspect-ratio auto when ratio is "auto"', () => { render(Card); expect(screen.getByText('Card')).toHaveStyle('aspect-ratio: auto'); }); -test('variant는 default로 filled를 적용한다.', () => { +test('defaults to filled variant referencing subtle background and default border tokens', () => { render(Card); - expect(screen.getByText('Card')).toHaveStyle({ - border: `1px solid ${color.gray200}`, - }); + const applied = rulesForElement(screen.getByText('Card')); + expect(applied).toContain('var(--side-color-background-subtle)'); + expect(applied).toContain('var(--side-color-border-default)'); }); -test(`variant가 outline으로 넣으면 border(${color.cyan300}) 색상을 적용한다.`, () => { +test('outline variant references default background and border.strong tokens', () => { render(Card); - expect(screen.getByText('Card')).toHaveStyle({ - border: `1px solid ${color.cyan300}`, - }); + const applied = rulesForElement(screen.getByText('Card')); + expect(applied).toContain('var(--side-color-background-default)'); + expect(applied).toContain('var(--side-color-border-strong)'); }); -test(`variant가 filled일 때 배경색이 ${color.gray100}이다.`, () => { +test('filled variant uses subtle background token', () => { render(Card); - expect(screen.getByText('Card')).toHaveStyle({ - backgroundColor: color.gray100, - }); + expect(rulesForElement(screen.getByText('Card'))).toContain('var(--side-color-background-subtle)'); }); -test(`variant가 outline일 때 배경색이 ${color.gray50}이다.`, () => { +test('outline variant uses default background token', () => { render(Card); - expect(screen.getByText('Card')).toHaveStyle({ - backgroundColor: color.gray50, - }); + expect(rulesForElement(screen.getByText('Card'))).toContain('var(--side-color-background-default)'); }); test('ghost variant has transparent background', () => { @@ -91,9 +107,8 @@ test('ghost variant has transparent background', () => { test('ghost variant has no border', () => { render(Card); - expect(screen.getByText('Card')).toHaveStyle({ - border: 'none', - }); + const applied = rulesForElement(screen.getByText('Card')); + expect(applied).toMatch(/border-style:\s*none/); }); test('ghost variant has no padding', () => { @@ -102,3 +117,13 @@ test('ghost variant has no padding', () => { padding: '0', }); }); + +test('applies semantic component spacing token as padding', () => { + render(Card); + expect(rulesForElement(screen.getByText('Card'))).toContain('var(--side-spacing-component-lg)'); +}); + +test('applies semantic component radius token as border-radius', () => { + render(Card); + expect(rulesForElement(screen.getByText('Card'))).toContain('var(--side-radius-component-lg)'); +}); diff --git a/packages/checkbox/package.json b/packages/checkbox/package.json index c80e6a9c..8ab6ad5d 100644 --- a/packages/checkbox/package.json +++ b/packages/checkbox/package.json @@ -21,6 +21,7 @@ "clean": "rm -rf node_modules dist" }, "dependencies": { + "@sipe-team/hooks": "workspace:*", "@sipe-team/tokens": "workspace:*", "clsx": "catalog:", "@vanilla-extract/recipes": "catalog:" diff --git a/packages/checkbox/src/Checkbox.tsx b/packages/checkbox/src/Checkbox.tsx index db74884b..20335244 100644 --- a/packages/checkbox/src/Checkbox.tsx +++ b/packages/checkbox/src/Checkbox.tsx @@ -1,12 +1,6 @@ -import { - type ChangeEventHandler, - type ComponentProps, - createContext, - forwardRef, - type Ref, - useContext, - useId, -} from 'react'; +import { type ChangeEventHandler, type ComponentProps, createContext, forwardRef, type Ref, useContext } from 'react'; + +import { useId } from '@sipe-team/hooks'; import clsx from 'clsx'; @@ -54,8 +48,7 @@ const Root = forwardRef( }, ref, ) => { - const internalId = useId(); - const id = props.id ?? internalId; + const id = useId(props.id); const [checkedState, setCheckedState] = useControllableState({ prop: checked, diff --git a/packages/flex/src/Flex.test.tsx b/packages/flex/src/Flex.test.tsx index 64db846a..7ad3c7ee 100644 --- a/packages/flex/src/Flex.test.tsx +++ b/packages/flex/src/Flex.test.tsx @@ -293,6 +293,20 @@ describe('Flex', () => { expect(flexContainer.style.getPropertyValue('--side-flex-gap-lg')).toBe('24px'); }); + it('adds pixel units to non-zero numeric responsive gap values', () => { + render( + +
item 1
+
item 2
+
, + ); + + const flexContainer = screen.getByTestId('flex-container'); + expect(flexContainer.style.getPropertyValue('--side-flex-gap-sm')).toBe('8px'); + expect(flexContainer.style.getPropertyValue('--side-flex-gap-md')).toBe('16px'); + expect(flexContainer.style.getPropertyValue('--side-flex-gap-lg')).toBe('0'); + }); + it('keeps nested responsive gap values independent from the parent Flex', () => { render( diff --git a/packages/flex/src/Flex.tsx b/packages/flex/src/Flex.tsx index 4a7409ff..016d7ebe 100644 --- a/packages/flex/src/Flex.tsx +++ b/packages/flex/src/Flex.tsx @@ -51,6 +51,10 @@ function getResponsiveClassNames( }); } +function normalizeResponsiveGapValue(value: CSSProperties['gap']) { + return typeof value === 'number' && value !== 0 ? `${value}px` : value; +} + function getResponsiveGapStyle(gap: FlexProps['gap']) { if (!isResponsiveValue(gap)) { return { @@ -59,9 +63,9 @@ function getResponsiveGapStyle(gap: FlexProps['gap']) { }; } - const sm = gap.sm ?? 'normal'; - const md = gap.md ?? sm; - const lg = gap.lg ?? md; + const sm = normalizeResponsiveGapValue(gap.sm ?? 'normal'); + const md = normalizeResponsiveGapValue(gap.md ?? sm); + const lg = normalizeResponsiveGapValue(gap.lg ?? md); return { className: styles.responsiveGap, diff --git a/packages/hooks/package.json b/packages/hooks/package.json new file mode 100644 index 00000000..46f9f568 --- /dev/null +++ b/packages/hooks/package.json @@ -0,0 +1,53 @@ +{ + "name": "@sipe-team/hooks", + "description": "Shared React hooks for Sipe Design System", + "version": "0.1.0", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/sipe-team/side" + }, + "type": "module", + "exports": "./src/index.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "lint": "pnpm exec biome lint", + "test": "vitest", + "typecheck": "tsc", + "prepack": "pnpm run build", + "clean": "rm -rf node_modules dist" + }, + "devDependencies": { + "@testing-library/jest-dom": "catalog:", + "@testing-library/react": "catalog:", + "@types/react": "catalog:react", + "happy-dom": "catalog:", + "react": "catalog:react", + "react-dom": "catalog:react", + "tsup": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "peerDependencies": { + "react": ">= 18" + }, + "publishConfig": { + "access": "public", + "registry": "https://npm.pkg.github.com", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + } + } +} diff --git a/packages/hooks/src/index.ts b/packages/hooks/src/index.ts new file mode 100644 index 00000000..e14d5881 --- /dev/null +++ b/packages/hooks/src/index.ts @@ -0,0 +1 @@ +export * from './useId'; diff --git a/packages/hooks/src/useId.test.tsx b/packages/hooks/src/useId.test.tsx new file mode 100644 index 00000000..e0f4eb2c --- /dev/null +++ b/packages/hooks/src/useId.test.tsx @@ -0,0 +1,52 @@ +import { act } from 'react'; +import { hydrateRoot } from 'react-dom/client'; +import { renderToString } from 'react-dom/server'; + +import { renderHook } from '@testing-library/react'; +import { describe, expect, test } from 'vitest'; + +import { useId } from './useId'; + +describe('useId', () => { + test('returns an id prefixed with "side-"', () => { + const { result } = renderHook(() => useId()); + expect(result.current).toMatch(/^side-/); + }); + + test('returns a different id, both prefixed, for each call site', () => { + const { result: a } = renderHook(() => useId()); + const { result: b } = renderHook(() => useId()); + expect(a.current).toMatch(/^side-/); + expect(b.current).toMatch(/^side-/); + expect(a.current).not.toBe(b.current); + }); + + test('passes a deterministicId through as-is, bypassing the prefix', () => { + const { result } = renderHook(() => useId('custom-id')); + expect(result.current).toBe('custom-id'); + }); + + test('hydrates server-rendered markup without an id mismatch', () => { + function Consumer() { + return {useId()}; + } + + const html = renderToString(); + const serverId = html.match(/data-testid="id">([^<]*) { + hydrateRoot(container, ); + }); + + const clientId = container.querySelector('[data-testid="id"]')?.textContent; + + expect(serverId).toBeTruthy(); + expect(clientId).toBe(serverId); + + document.body.removeChild(container); + }); +}); diff --git a/packages/hooks/src/useId.ts b/packages/hooks/src/useId.ts new file mode 100644 index 00000000..7f8ea841 --- /dev/null +++ b/packages/hooks/src/useId.ts @@ -0,0 +1,6 @@ +import { useId as useReactId } from 'react'; + +export function useId(deterministicId?: string): string { + const reactId = useReactId(); + return deterministicId ?? `side-${reactId}`; +} diff --git a/packages/hooks/tsconfig.json b/packages/hooks/tsconfig.json new file mode 100644 index 00000000..4082f16a --- /dev/null +++ b/packages/hooks/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} diff --git a/packages/hooks/tsup.config.ts b/packages/hooks/tsup.config.ts new file mode 100644 index 00000000..ee4b1170 --- /dev/null +++ b/packages/hooks/tsup.config.ts @@ -0,0 +1,3 @@ +import defaultConfig from '../../tsup.config'; + +export default defaultConfig; diff --git a/packages/hooks/vitest.config.ts b/packages/hooks/vitest.config.ts new file mode 100644 index 00000000..e663baf0 --- /dev/null +++ b/packages/hooks/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineProject, mergeConfig } from 'vitest/config'; + +import defaultConfig from '../../vitest.config'; + +export default mergeConfig( + defaultConfig, + defineProject({ + test: { + setupFiles: './vitest.setup.ts', + }, + }), +); diff --git a/packages/hooks/vitest.setup.ts b/packages/hooks/vitest.setup.ts new file mode 100644 index 00000000..7b0828bf --- /dev/null +++ b/packages/hooks/vitest.setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/packages/image/package.json b/packages/image/package.json new file mode 100644 index 00000000..77349062 --- /dev/null +++ b/packages/image/package.json @@ -0,0 +1,63 @@ +{ + "name": "@sipe-team/image", + "description": "Image for Sipe Design System", + "version": "0.0.0", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/sipe-team/side" + }, + "type": "module", + "exports": "./src/index.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "lint": "pnpm exec biome lint", + "test": "vitest", + "typecheck": "tsc", + "prepack": "pnpm run build" + }, + "dependencies": { + "@sipe-team/tokens": "workspace:*", + "clsx": "catalog:" + }, + "devDependencies": { + "@testing-library/jest-dom": "catalog:", + "@testing-library/react": "catalog:", + "@types/react": "catalog:react", + "@vanilla-extract/css": "catalog:", + "@vanilla-extract/dynamic": "catalog:", + "happy-dom": "catalog:", + "react": "catalog:react", + "react-dom": "catalog:react", + "tsup": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "peerDependencies": { + "react": ">= 18", + "react-dom": ">= 18" + }, + "publishConfig": { + "access": "public", + "registry": "https://npm.pkg.github.com", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./styles.css": "./dist/index.css" + } + }, + "sideEffects": [ + "**/*.css" + ] +} diff --git a/packages/image/src/Image.css.ts b/packages/image/src/Image.css.ts new file mode 100644 index 00000000..a12dd347 --- /dev/null +++ b/packages/image/src/Image.css.ts @@ -0,0 +1,37 @@ +import { vars } from '@sipe-team/tokens'; + +import { createVar, fallbackVar, style, styleVariants } from '@vanilla-extract/css'; + +export const widthVar = createVar(); +export const heightVar = createVar(); + +export const sized = style({ + width: fallbackVar(widthVar, 'auto'), + height: fallbackVar(heightVar, 'auto'), +}); + +export const fit = styleVariants({ + contain: { objectFit: 'contain' }, + cover: { objectFit: 'cover' }, + fill: { objectFit: 'fill' }, +}); + +export const radius = styleVariants({ + none: { borderRadius: 0 }, + sm: { borderRadius: vars.radius.component.sm }, + md: { borderRadius: vars.radius.component.md }, + lg: { borderRadius: vars.radius.component.lg }, + xl: { borderRadius: vars.radius.component.xl }, + full: { borderRadius: vars.radius.component.full }, +}); + +export const fill = style({ + position: 'absolute', + inset: 0, + width: '100%', + height: '100%', +}); + +export const hidden = style({ + visibility: 'hidden', +}); diff --git a/packages/image/src/Image.stories.tsx b/packages/image/src/Image.stories.tsx new file mode 100644 index 00000000..09e5daf5 --- /dev/null +++ b/packages/image/src/Image.stories.tsx @@ -0,0 +1,196 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { Image, ImageRadius } from './Image'; + +const meta: Meta = { + title: 'Components/Image', + component: Image, + parameters: { + backgrounds: { + default: 'light', + }, + }, + args: { + src: 'https://picsum.photos/400/300', + alt: '예시 이미지', + width: 400, + height: 300, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Basic: Story = { + parameters: { + docs: { + description: { + story: '기본 이미지 렌더링입니다. 기본 크기와 기본 fit 동작을 확인할 수 있습니다.', + }, + }, + }, +}; + +export const Fallbacks: Story = { + parameters: { + docs: { + description: { + story: 'fallbackSrc가 있을 때와 없을 때 동작을 한 번에 비교합니다.', + }, + }, + }, + render: (args) => ( +
+
+ fallback with src + with fallbackSrc +
+
+
+ error without fallback +
+ without fallbackSrc +
+
+ ), + args: { + width: 400, + height: 300, + }, +}; + +export const WithPlaceholder: Story = { + parameters: { + docs: { + description: { + story: '로딩 중 placeholder 확인은 DevTools에서 네트워크를 Slow 3G로 설정 후 새로고침하세요.', + }, + }, + }, + args: { + src: 'https://picsum.photos/400/300', + alt: 'placeholder 예시', + placeholder: ( +
+ 로딩 중... +
+ ), + }, +}; + +export const WithFill: Story = { + parameters: { + docs: { + description: { + story: 'fill 사용 예시입니다. 부모 컨테이너에 `position: relative`, `width: 400`, `height: 300`이 필요합니다.', + }, + }, + }, + render: (args) => ( +
+ +
+ ), + args: { + src: 'https://picsum.photos/400/300', + }, +}; + +export const Fits: Story = { + parameters: { + docs: { + description: { + story: 'fit 옵션(`contain`, `cover`)을 같은 크기 박스에서 비교하는 예시입니다.', + }, + }, + }, + render: (args) => ( +
+
+ fit contain + fit: contain +
+
+ fit cover + fit: cover +
+
+ ), + args: { + src: 'https://picsum.photos/600/200', + width: 300, + height: 300, + }, +}; + +export const Radiuses: Story = { + parameters: { + docs: { + description: { + story: 'radius 토큰(`none` ~ `full`)을 적용한 예시입니다.', + }, + }, + }, + render: (args) => ( +
+ {Object.values(ImageRadius).map((radius) => ( +
+ {`radius + radius: {radius} +
+ ))} +
+ ), +}; + +export const ResponsiveWidth: Story = { + parameters: { + docs: { + description: { + story: 'width를 string(100%)으로 전달했을 때 부모 너비를 따라 반응형으로 동작하는 예시입니다.', + }, + }, + }, + render: (args) => ( +
+ +
+ ), + args: { + src: 'https://picsum.photos/400/300', + width: '100%', + height: 300, + }, +}; diff --git a/packages/image/src/Image.test.tsx b/packages/image/src/Image.test.tsx new file mode 100644 index 00000000..1321c2e5 --- /dev/null +++ b/packages/image/src/Image.test.tsx @@ -0,0 +1,237 @@ +import { createRef } from 'react'; + +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { Image } from './Image'; + +describe('Image', () => { + it('renders with required src and alt props', () => { + render(sample image); + + const img = screen.getByRole('img', { name: 'sample image' }); + expect(img).toBeInTheDocument(); + expect(img).toHaveAttribute('src', 'https://picsum.photos/400/300'); + }); + + it('updates img src when the Image src prop changes after mount', () => { + const firstSrc = 'https://picsum.photos/id/10/200/300'; + const secondSrc = 'https://picsum.photos/id/20/200/300'; + + const { rerender } = render(gallery); + const img = screen.getByRole('img', { name: 'gallery' }); + expect(img).toHaveAttribute('src', firstSrc); + + rerender(gallery); + expect(img).toHaveAttribute('src', secondSrc); + }); + + it('forwards ref to the img element', () => { + const ref = createRef(); + render(ref test); + + expect(ref.current).toBeInstanceOf(HTMLImageElement); + expect(ref.current?.tagName).toBe('IMG'); + expect(ref.current).toHaveAttribute('alt', 'ref test'); + }); + + it('applies width and height styles for number and string values', () => { + render(size test); + + const img = screen.getByRole('img', { name: 'size test' }); + expect(img).toHaveStyle({ + width: '320px', + height: '50%', + }); + }); + + it('applies width only; height falls back to auto (CSS variables + sized)', () => { + render(width only); + + const img = screen.getByRole('img', { name: 'width only' }); + expect(img).toHaveStyle({ width: '200px', height: 'auto' }); + }); + + it('applies height only; width falls back to auto (CSS variables + sized)', () => { + render(height only); + + const img = screen.getByRole('img', { name: 'height only' }); + expect(img).toHaveStyle({ width: 'auto', height: '120px' }); + }); + + it('switches to fallbackSrc once when image load fails', () => { + render( + fallback test, + ); + + const img = screen.getByRole('img', { name: 'fallback test' }); + fireEvent.error(img); + + expect(img).toHaveAttribute('src', 'https://dummyimage.com/400x300/e5e7eb/111827&text=FALLBACK'); + }); + + it('moves to error state and hides image when both src and fallbackSrc fail', () => { + render(무한실패); + + const img = screen.getByRole('img', { name: '무한실패' }); + fireEvent.error(img); + + expect(img).toHaveAttribute('src', '이것도없음'); + + fireEvent.error(img); + + expect(img).toHaveAttribute('src', '이것도없음'); + expect(img).toHaveStyle({ visibility: 'hidden' }); + }); + + it('does not retry fallback after fallbackSrc fails once', () => { + const onError = vi.fn(); + render( + no infinite fallback, + ); + + const img = screen.getByRole('img', { name: 'no infinite fallback' }); + fireEvent.error(img); + + expect(img).toHaveAttribute('src', 'https://invalid-url.com/broken-fallback.jpg'); + expect(onError).toHaveBeenCalledTimes(1); + + fireEvent.error(img); + + expect(img).toHaveAttribute('src', 'https://invalid-url.com/broken-fallback.jpg'); + expect(onError).toHaveBeenCalledTimes(2); + expect(img).toHaveStyle({ visibility: 'hidden' }); + + fireEvent.error(img); + + expect(img).toHaveAttribute('src', 'https://invalid-url.com/broken-fallback.jpg'); + expect(onError).toHaveBeenCalledTimes(3); + }); + + it('moves to error state and hides image when fallback is unavailable', () => { + render(error test); + + const img = screen.getByRole('img', { name: 'error test' }); + fireEvent.error(img); + + expect(img).toHaveStyle({ visibility: 'hidden' }); + }); + + it('shows placeholder while loading and hides it after load', () => { + render( + placeholder test + loading... + + } + />, + ); + + const img = screen.getByAltText('placeholder test'); + expect(screen.getByTestId('placeholder')).toBeInTheDocument(); + expect(img).toHaveStyle({ visibility: 'hidden' }); + + fireEvent.load(img); + + expect(screen.queryByTestId('placeholder')).not.toBeInTheDocument(); + expect(img.style.visibility).toBe(''); + }); + + it('applies fill styles when fill is true', () => { + render(fill test); + + const img = screen.getByRole('img', { name: 'fill test' }); + expect(img).toHaveStyle({ + position: 'absolute', + width: '100%', + height: '100%', + }); + }); + + it('prefers fill layout over width prop (no sized / no pixel width from props)', () => { + render( +
+ fill and width +
, + ); + + const img = screen.getByRole('img', { name: 'fill and width' }); + expect(img).toHaveStyle({ + position: 'absolute', + width: '100%', + height: '100%', + }); + expect(img).not.toHaveStyle({ width: '200px' }); + }); + + it('applies object-fit from fit prop', () => { + render( + object-fit contain, + ); + + expect(screen.getByRole('img', { name: 'object-fit contain' })).toHaveStyle({ objectFit: 'contain' }); + }); + + it('applies object-fit fill variant from fit prop', () => { + render(object-fit fill); + + expect(screen.getByRole('img', { name: 'object-fit fill' })).toHaveStyle({ objectFit: 'fill' }); + }); + + it('uses lazy loading by default', () => { + render(loading attr test); + + const img = screen.getByRole('img', { name: 'loading attr test' }); + expect(img).toHaveAttribute('loading', 'lazy'); + }); + + it('always calls user onLoad callback', () => { + const onLoad = vi.fn(); + render(onLoad test); + + const img = screen.getByRole('img', { name: 'onLoad test' }); + fireEvent.load(img); + + expect(onLoad).toHaveBeenCalledTimes(1); + }); + + it('calls user onLoad callback when fallback image loads', () => { + const onLoad = vi.fn(); + render( + fallback onLoad test, + ); + + const img = screen.getByRole('img', { name: 'fallback onLoad test' }); + fireEvent.error(img); + fireEvent.load(img); + + expect(onLoad).toHaveBeenCalledTimes(1); + }); + + it('always calls user onError callback', () => { + const onError = vi.fn(); + render(onError test); + + const img = screen.getByRole('img', { name: 'onError test' }); + fireEvent.error(img); + + expect(onError).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/image/src/Image.tsx b/packages/image/src/Image.tsx new file mode 100644 index 00000000..97ce6a7c --- /dev/null +++ b/packages/image/src/Image.tsx @@ -0,0 +1,103 @@ +import { type ComponentPropsWithoutRef, type ForwardedRef, forwardRef, type ReactNode } from 'react'; + +import { assignInlineVars } from '@vanilla-extract/dynamic'; + +import { clsx as cx } from 'clsx'; + +import { useImageStatus } from './hooks/useImageStatus'; +import * as styles from './Image.css'; + +type ImageSize = number | string; +type ImageFit = 'contain' | 'cover' | 'fill'; + +export const ImageRadius = { + none: 'none', + sm: 'sm', + md: 'md', + lg: 'lg', + xl: 'xl', + full: 'full', +} as const; +export type ImageRadius = (typeof ImageRadius)[keyof typeof ImageRadius]; + +export interface ImageProps + extends Omit, 'src' | 'alt' | 'width' | 'height' | 'onLoad' | 'onError'> { + src: string; + alt: string; + width?: ImageSize; + height?: ImageSize; + fit?: ImageFit; + fill?: boolean; + radius?: ImageRadius; + fallbackSrc?: string; + placeholder?: ReactNode; + onLoad?: ComponentPropsWithoutRef<'img'>['onLoad']; + onError?: ComponentPropsWithoutRef<'img'>['onError']; +} + +export const Image = forwardRef(function Image( + { + src, + alt, + width, + height, + fit = 'cover', + fill = false, + radius = ImageRadius.none, + fallbackSrc, + placeholder, + loading = 'lazy', + onLoad, + onError, + className, + style, + ...props + }: ImageProps, + ref: ForwardedRef, +) { + const { status, imgSrc, handleLoad, handleError } = useImageStatus({ + src, + ...(fallbackSrc ? { fallbackSrc } : {}), + ...(onLoad ? { onLoad } : {}), + ...(onError ? { onError } : {}), + }); + + const showPlaceholder = (status === 'loading' || status === 'fallback') && placeholder !== undefined; + const isHidden = showPlaceholder || status === 'error'; + + const useSized = !fill && (width !== undefined || height !== undefined); + const dimensionStyle = + useSized && + assignInlineVars({ + ...(width !== undefined && { + [styles.widthVar]: typeof width === 'number' ? `${width}px` : width, + }), + ...(height !== undefined && { + [styles.heightVar]: typeof height === 'number' ? `${height}px` : height, + }), + }); + + return ( + <> + {showPlaceholder ? placeholder : null} + {alt} + + ); +}); diff --git a/packages/image/src/hooks/useImageStatus.ts b/packages/image/src/hooks/useImageStatus.ts new file mode 100644 index 00000000..f7583a53 --- /dev/null +++ b/packages/image/src/hooks/useImageStatus.ts @@ -0,0 +1,62 @@ +import { type SyntheticEvent, useCallback, useEffect, useState } from 'react'; + +export type ImageStatus = 'loading' | 'normal' | 'fallback' | 'error'; + +interface UseImageStatusParams { + src: string; + fallbackSrc?: string; + onLoad?: (event: SyntheticEvent) => void; + onError?: (event: SyntheticEvent) => void; +} + +interface UseImageStatusResult { + status: ImageStatus; + imgSrc: string; + handleLoad: (event: SyntheticEvent) => void; + handleError: (event: SyntheticEvent) => void; +} + +export function useImageStatus({ + src, + fallbackSrc, + onLoad: onLoadFromProps, + onError: onErrorFromProps, +}: UseImageStatusParams): UseImageStatusResult { + const [status, setStatus] = useState('loading'); + const [imgSrc, setImgSrc] = useState(src); + + useEffect(() => { + setImgSrc(src); + setStatus('loading'); + }, [src]); + + const handleLoad = useCallback( + (event: SyntheticEvent) => { + onLoadFromProps?.(event); + setStatus('normal'); + }, + [onLoadFromProps], + ); + + const handleError = useCallback( + (event: SyntheticEvent) => { + onErrorFromProps?.(event); + + if (status !== 'fallback' && fallbackSrc) { + setImgSrc(fallbackSrc); + setStatus('fallback'); + return; + } + + setStatus('error'); + }, + [fallbackSrc, onErrorFromProps, status], + ); + + return { + status, + imgSrc, + handleLoad, + handleError, + }; +} diff --git a/packages/image/src/index.ts b/packages/image/src/index.ts new file mode 100644 index 00000000..4bbac901 --- /dev/null +++ b/packages/image/src/index.ts @@ -0,0 +1 @@ +export * from './Image'; diff --git a/packages/image/tsconfig.json b/packages/image/tsconfig.json new file mode 100644 index 00000000..4082f16a --- /dev/null +++ b/packages/image/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} diff --git a/packages/image/tsup.config.ts b/packages/image/tsup.config.ts new file mode 100644 index 00000000..ee4b1170 --- /dev/null +++ b/packages/image/tsup.config.ts @@ -0,0 +1,3 @@ +import defaultConfig from '../../tsup.config'; + +export default defaultConfig; diff --git a/packages/image/vitest.config.ts b/packages/image/vitest.config.ts new file mode 100644 index 00000000..e663baf0 --- /dev/null +++ b/packages/image/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineProject, mergeConfig } from 'vitest/config'; + +import defaultConfig from '../../vitest.config'; + +export default mergeConfig( + defaultConfig, + defineProject({ + test: { + setupFiles: './vitest.setup.ts', + }, + }), +); diff --git a/packages/image/vitest.setup.ts b/packages/image/vitest.setup.ts new file mode 100644 index 00000000..7b0828bf --- /dev/null +++ b/packages/image/vitest.setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/packages/input/src/Input.css.ts b/packages/input/src/Input.css.ts index 9ab4300a..40ee17ff 100644 --- a/packages/input/src/Input.css.ts +++ b/packages/input/src/Input.css.ts @@ -1,140 +1,161 @@ -import { color } from '@sipe-team/tokens'; +import { vars } from '@sipe-team/tokens'; import { style } from '@vanilla-extract/css'; import { recipe } from '@vanilla-extract/recipes'; -import type { InputFontSize, InputFontWeight } from './Input'; - -// TODO ThemeProvider 적용 -export const colors = { - inputRing: color.gray800, - defaultInputOutline: color.gray400, - disabledBackground: color.gray300, -} as const; - -export const spacing = { - defaultInputPadding: '8px', - defaultBorderRadius: '8px', - defaultActionSize: '24px', -} as const; - -export const weight = { - regular: 400, - medium: 500, - semiBold: 600, - bold: 700, -} as const; +import type { InputFontSize, InputValidation } from './Input'; export const defaultFontSize: InputFontSize = 16; -export const defaultFontWeight: InputFontWeight = 'regular'; +export const defaultValidation: InputValidation = 'default'; -export const inputWrapper = recipe({ +export const inputWrapper = style({ + position: 'relative', + display: 'block', + flex: 1, + minWidth: 0, + cursor: 'default', + userSelect: 'none', +}); + +export const inputField = recipe({ base: { - display: 'flex', - flex: 1, - alignItems: 'center', + boxSizing: 'border-box', + display: 'block', + width: '100%', + margin: 0, fontStyle: 'normal', textAlign: 'start', - padding: spacing.defaultInputPadding, - borderRadius: spacing.defaultBorderRadius, - outline: `1px solid ${colors.defaultInputOutline}`, + color: vars.color.foreground.default, + fontFamily: vars.typography.fontFamily, + fontWeight: vars.typography.fontWeight.regular, + backgroundColor: 'transparent', + borderRadius: vars.radius.component.md, + borderWidth: '1px', + borderStyle: 'solid', + borderColor: vars.color.border.default, + paddingBlock: vars.spacing.component.sm, + paddingInline: vars.spacing.component.md, + outline: 'none', + cursor: 'text', - '@supports': { - 'selector(:has(*))': { + selectors: { + '&::-webkit-search-cancel-button': { + appearance: 'none', + }, + '&::placeholder': { + color: vars.color.foreground.muted, + }, + '&:hover:not(:disabled):not(:focus):not(:read-only)': { + borderColor: vars.color.border.strong, + }, + '&:read-only:not(:disabled)': { + backgroundColor: vars.color.background.muted, + borderColor: vars.color.border.default, + color: vars.color.foreground.default, + cursor: 'default', + }, + '&:disabled': { + backgroundColor: vars.color.background.muted, + borderColor: vars.color.border.default, + color: vars.color.foreground.subtle, + cursor: 'not-allowed', + }, + '&:where(:autofill, [data-com-onepassword-filled])': { + backgroundClip: 'text', + WebkitTextFillColor: vars.color.foreground.default, + }, + }, + }, + variants: { + fontSize: { + 12: { fontSize: vars.typography.fontSize['050'] }, + 14: { fontSize: vars.typography.fontSize['100'] }, + 16: { fontSize: vars.typography.fontSize['200'] }, + 18: { fontSize: vars.typography.fontSize['300'] }, + 20: { fontSize: vars.typography.fontSize['400'] }, + 24: { fontSize: vars.typography.fontSize['500'] }, + 28: { fontSize: vars.typography.fontSize['600'] }, + 32: { fontSize: vars.typography.fontSize['700'] }, + 36: { fontSize: vars.typography.fontSize['800'] }, + 48: { fontSize: vars.typography.fontSize['900'] }, + }, + validation: { + default: { selectors: { - '&:where(:has(input:focus))': { - outline: `2px solid ${colors.defaultInputOutline}`, + '&:focus': { + borderColor: vars.color.border.focus, + outlineWidth: '2px', + outlineStyle: 'solid', + outlineColor: vars.color.border.focus, outlineOffset: '-1px', }, - '&:where(:has(input:disabled))': { - backgroundColor: colors.disabledBackground, + }, + }, + error: { + borderColor: vars.color.status.danger.border, + selectors: { + '&:hover:not(:disabled):not(:focus):not(:read-only)': { + borderColor: vars.color.status.danger.border, + }, + '&:disabled': { + borderColor: vars.color.border.default, }, }, }, - 'not selector(:has(*))': { + success: { + borderColor: vars.color.status.success.border, selectors: { - '&:where(:focus-within)': { - outline: `2px solid ${colors.defaultInputOutline}`, - outlineOffset: '-1px', + '&:hover:not(:disabled):not(:focus):not(:read-only)': { + borderColor: vars.color.status.success.border, + }, + '&:disabled': { + borderColor: vars.color.border.default, }, }, }, }, }, - variants: { - fontSize: { - 12: { fontSize: '12px' }, - 14: { fontSize: '14px' }, - 16: { fontSize: '16px' }, - 18: { fontSize: '18px' }, - 20: { fontSize: '20px' }, - 24: { fontSize: '24px' }, - 28: { fontSize: '28px' }, - 32: { fontSize: '32px' }, - 36: { fontSize: '36px' }, - 48: { fontSize: '48px' }, - }, - fontWeight: { - regular: { fontWeight: weight.regular }, - medium: { fontWeight: weight.medium }, - semiBold: { fontWeight: weight.semiBold }, - bold: { fontWeight: weight.bold }, - }, - }, defaultVariants: { fontSize: defaultFontSize, - fontWeight: defaultFontWeight, + validation: defaultValidation, }, }); -export const inputElement = style({ - width: '100%', - display: 'flex', - alignItems: 'center', - textAlign: 'inherit', - outline: '1px solid transparent', - border: 'none', - fontSize: 'inherit', - fontWeight: 'inherit', - - selectors: { - '&::-webkit-search-cancel-button': { - appearance: 'none', - }, - }, - - '@supports': { - 'selector(:has(*))': { - selectors: { - '&:where(:autofill, [data-com-onepassword-filled])': { - backgroundClip: 'text', - WebkitTextFillColor: color.gray900, - }, - '&:where(:disabled)': { - backgroundColor: 'transparent', - }, - }, - }, - }, +/** Reserve trailing space so text/caret never sit under the absolute action. */ +export const inputFieldWithAction = style({ + paddingInlineEnd: `calc(${vars.spacing.component.xl} + ${vars.spacing.component.md} + ${vars.spacing.component.xs})`, }); export const inputAction = style({ all: 'unset', - width: spacing.defaultActionSize, - height: spacing.defaultActionSize, + boxSizing: 'border-box', + position: 'absolute', + top: '50%', + right: vars.spacing.component.md, + transform: 'translateY(-50%)', + zIndex: 1, + width: vars.spacing.component.xl, + height: vars.spacing.component.xl, display: 'flex', justifyContent: 'center', alignItems: 'center', - borderRadius: spacing.defaultBorderRadius, + color: vars.color.foreground.subtle, + borderRadius: vars.radius.component.md, + cursor: 'pointer', + transition: 'color 0.15s ease, background-color 0.15s ease', - '@supports': { - 'selector(:has(*))': { - selectors: { - '&:focus': { - outline: `2px solid ${colors.defaultInputOutline}`, - outlineOffset: '3px', - }, - }, + selectors: { + '&:hover': { + color: vars.color.foreground.default, + backgroundColor: vars.color.background.muted, + }, + '&:active': { + color: vars.color.foreground.default, + backgroundColor: vars.color.background.subtle, + }, + '&:focus-visible': { + outline: `2px solid ${vars.color.border.focus}`, + outlineOffset: '3px', }, }, }); diff --git a/packages/input/src/Input.stories.tsx b/packages/input/src/Input.stories.tsx index 86a9c3b1..88802479 100644 --- a/packages/input/src/Input.stories.tsx +++ b/packages/input/src/Input.stories.tsx @@ -1,19 +1,40 @@ -import { useRef, useState } from 'react'; +import { type ReactNode, useRef, useState } from 'react'; -import type { StoryObj } from '@storybook/react'; +import { vars } from '@sipe-team/tokens'; + +import type { Meta, StoryObj } from '@storybook/react'; import { Action, Input } from './Input'; +const DarkSurface = ({ children }: { children: ReactNode }) => ( +
+ {children} +
+); + const meta = { title: 'Components/Input', component: Input, parameters: { layout: 'centered', }, + decorators: [ + (Story) => ( + + + + ), + ], args: { disabled: false, fontSize: 16, - fontWeight: 'regular', placeholder: 'placeholder', type: 'text', }, @@ -22,8 +43,12 @@ const meta = { options: ['email', 'password', 'search', 'tel', 'text', 'url'], control: { type: 'radio' }, }, + validation: { + options: ['default', 'error', 'success'], + control: { type: 'radio' }, + }, }, -}; +} satisfies Meta; export default meta; type Story = StoryObj; @@ -31,7 +56,6 @@ type Story = StoryObj; export const Default: Story = { args: { type: 'password', - fontWeight: 'regular', fontSize: 20, }, @@ -52,6 +76,27 @@ export const disabled: Story = { }, }; +export const ReadOnly: Story = { + args: { + readOnly: true, + defaultValue: 'read only value', + }, +}; + +export const ValidationError: Story = { + args: { + validation: 'error', + defaultValue: 'invalid value', + }, +}; + +export const ValidationSuccess: Story = { + args: { + validation: 'success', + defaultValue: 'valid value', + }, +}; + export const WithActionButton: Story = { render: (args) => { const ref = useRef(null); diff --git a/packages/input/src/Input.test.tsx b/packages/input/src/Input.test.tsx index 3ed17606..bdb22c2a 100644 --- a/packages/input/src/Input.test.tsx +++ b/packages/input/src/Input.test.tsx @@ -7,7 +7,34 @@ import userEvent from '@testing-library/user-event'; import { describe, expect, test } from 'vitest'; import { Action, Input } from './Input'; -import { defaultFontSize, defaultFontWeight, weight } from './Input.css'; +import { defaultFontSize, inputFieldWithAction } from './Input.css'; + +function ruleForClass(className: string): string { + const target = `.${className}`; + const matched: string[] = []; + for (const sheet of Array.from(document.styleSheets)) { + let rules: CSSRuleList; + try { + rules = sheet.cssRules; + } catch { + continue; + } + for (const rule of Array.from(rules)) { + if (!('selectorText' in rule)) continue; + const selectorText = (rule as CSSStyleRule).selectorText ?? ''; + const matches = selectorText.split(',').some((s) => { + const part = s.trim(); + return part === target || part.startsWith(`${target}:`) || part.startsWith(`${target}.`); + }); + if (matches) matched.push(rule.cssText); + } + } + return matched.join('\n'); +} + +function rulesForElement(el: HTMLElement): string { + return el.className.split(/\s+/).filter(Boolean).map(ruleForClass).join('\n'); +} describe('Input 컴포넌트', () => { describe('렌더링', () => { @@ -33,6 +60,16 @@ describe('Input 컴포넌트', () => { expect(input).toBeDisabled(); }); + test('readOnly 상태가 올바르게 설정된다', () => { + render(); + expect(screen.getByRole('textbox')).toHaveAttribute('readonly'); + }); + + test('readOnly일 때 muted 배경 semantic 토큰을 참조한다', () => { + render(); + expect(rulesForElement(screen.getByRole('textbox'))).toContain('var(--side-color-background-muted)'); + }); + test('classNames가 올바르게 적용된다', () => { const { container } = render(); expect(container.firstChild).toHaveClass('custom-class'); @@ -43,27 +80,37 @@ describe('Input 컴포넌트', () => { expect(screen.getByRole('textbox')).toHaveAttribute('spellCheck', 'false'); }); - test(`fontWeight가 미지정시 ${defaultFontWeight}, fontSize가 미지정시 ${defaultFontSize}px이 기본값으로 적용된다`, () => { - const { container } = render(); - const element = container.firstChild as HTMLElement; - const computedStyle = getComputedStyle(element); + test(`fontSize 미지정시 ${defaultFontSize}, fontWeight는 regular semantic 토큰을 참조한다`, () => { + render(); + const applied = rulesForElement(screen.getByRole('textbox')); - expect(computedStyle.fontSize).toBe(`${defaultFontSize}px`); - expect(computedStyle.fontWeight).toBe(`${weight[defaultFontWeight]}`); + expect(applied).toContain('var(--side-font-size-200)'); + expect(applied).toContain('var(--side-font-weight-regular)'); }); - test('변경 폰트 사이즈, 폰트 웨이트 적용된다.', () => { - const fontSize = 24; - const fontWeight = 'semiBold'; - const { container } = render(); - const element = container.firstChild as HTMLElement; - const computedStyle = getComputedStyle(element); + test('변경 폰트 사이즈 semantic 토큰을 참조한다', () => { + render(); + const applied = rulesForElement(screen.getByRole('textbox')); - expect(computedStyle.fontSize).toBe(`${fontSize}px`); - expect(computedStyle.fontWeight).toBe(`${weight[fontWeight]}`); + expect(applied).toContain('var(--side-font-size-500)'); + expect(applied).toContain('var(--side-font-weight-regular)'); }); - }); + test('인풋 기본 테두리·패딩·반경 semantic 토큰을 참조한다', () => { + render(); + const fieldRules = rulesForElement(screen.getByRole('textbox')); + + expect(fieldRules).toContain('var(--side-color-border-default)'); + expect(fieldRules).toContain('var(--side-radius-component-md)'); + expect(fieldRules).toContain('var(--side-spacing-component-sm)'); + expect(fieldRules).toContain('var(--side-spacing-component-md)'); + }); + + test('래퍼는 span이다', () => { + const { container } = render(); + expect((container.firstChild as HTMLElement).tagName).toBe('SPAN'); + }); + }); describe('상호작용', () => { test('사용자 입력', async () => { render(); @@ -83,6 +130,20 @@ describe('Input 컴포넌트', () => { expect(screen.getByRole('button')).toBeInTheDocument(); }); + test('Action이 있으면 trailing padding 클래스가 적용된다', () => { + render( + + 버튼 + , + ); + expect(screen.getByRole('textbox')).toHaveClass(inputFieldWithAction); + }); + + test('falsy children이면 trailing padding 클래스가 적용되지 않는다', () => { + render({false}); + expect(screen.getByRole('textbox')).not.toHaveClass(inputFieldWithAction); + }); + test('Action 버튼에 커스텀 클래스가 적용된다', () => { render( @@ -109,5 +170,22 @@ describe('Input 컴포넌트', () => { render(); expect(screen.getByRole('textbox')).toBeInTheDocument(); }); + + test('validation이 error이면 aria-invalid가 설정된다', () => { + render(); + expect(screen.getByRole('textbox')).toHaveAttribute('aria-invalid', 'true'); + }); + }); + + describe('validation', () => { + test('error일 때 danger border semantic 토큰을 참조한다', () => { + render(); + expect(rulesForElement(screen.getByRole('textbox'))).toContain('var(--side-color-status-danger-border)'); + }); + + test('success일 때 success border semantic 토큰을 참조한다', () => { + render(); + expect(rulesForElement(screen.getByRole('textbox'))).toContain('var(--side-color-status-success-border)'); + }); }); }); diff --git a/packages/input/src/Input.tsx b/packages/input/src/Input.tsx index 06425228..eae1b432 100644 --- a/packages/input/src/Input.tsx +++ b/packages/input/src/Input.tsx @@ -1,46 +1,61 @@ -import { type ComponentPropsWithoutRef, type ElementRef, forwardRef } from 'react'; +import { Children, type ComponentPropsWithoutRef, type ElementRef, forwardRef } from 'react'; import { Slot } from '@radix-ui/react-slot'; import cx from 'clsx'; -import { defaultFontSize, defaultFontWeight, inputAction, inputElement, inputWrapper, type weight } from './Input.css'; +import { defaultFontSize, inputAction, inputField, inputFieldWithAction, inputWrapper } from './Input.css'; +// TODO(design): fontSize API naming (px vs sm/md/lg) — pending designer sync export type InputFontSize = 12 | 14 | 16 | 18 | 20 | 24 | 28 | 32 | 36 | 48; -export type InputFontWeight = keyof typeof weight; + +export const InputValidation = { + default: 'default', + error: 'error', + success: 'success', +} as const; +export type InputValidation = (typeof InputValidation)[keyof typeof InputValidation]; + +// TODO(design): confirm with design whether to expose fontWeight as a prop; currently fixed to regular type AllowedInputTypes = 'email' | 'password' | 'search' | 'tel' | 'text' | 'url'; type InputFieldElement = ElementRef<'input'>; interface InputProps extends Omit, 'type'> { type?: AllowedInputTypes; fontSize?: InputFontSize; - fontWeight?: InputFontWeight; + /** Border-only validation state for FormField composition. Messages stay outside Input. */ + validation?: InputValidation; } const Input = forwardRef((props, forwardedRef) => { const { children, type = 'text', - fontWeight = defaultFontWeight, fontSize = defaultFontSize, + validation = InputValidation.default, className, name, ...inputProps } = props; + const hasAction = Children.toArray(children).length > 0; return ( -
- + + {children} -
+ ); }); Input.displayName = 'Input'; -// * **************************************** -// * Input Action -// * **************************************** - type InputFieldActionElement = ElementRef<'button'>; type InputFieldActionType = 'button' | 'reset'; diff --git a/packages/member-card/package.json b/packages/member-card/package.json new file mode 100644 index 00000000..27931355 --- /dev/null +++ b/packages/member-card/package.json @@ -0,0 +1,67 @@ +{ + "name": "@sipe-team/member-card", + "description": "Member card component for Sipe Design System", + "version": "0.0.0", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/sipe-team/side" + }, + "type": "module", + "exports": "./src/index.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "lint": "pnpm exec biome lint", + "test": "vitest", + "typecheck": "tsc", + "prepack": "pnpm run build", + "clean": "rm -rf node_modules dist" + }, + "dependencies": { + "@radix-ui/react-slot": "catalog:", + "@sipe-team/avatar": "workspace:*", + "@sipe-team/tokens": "workspace:*", + "@sipe-team/typography": "workspace:*", + "@vanilla-extract/recipes": "catalog:", + "clsx": "catalog:" + }, + "devDependencies": { + "@testing-library/jest-dom": "catalog:", + "@testing-library/react": "catalog:", + "@types/react": "catalog:react", + "@vanilla-extract/css": "catalog:", + "happy-dom": "catalog:", + "react": "catalog:react", + "react-dom": "catalog:react", + "tsup": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "peerDependencies": { + "react": ">= 18", + "react-dom": ">= 18" + }, + "publishConfig": { + "access": "public", + "registry": "https://npm.pkg.github.com", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./styles.css": "./dist/index.css" + } + }, + "sideEffects": [ + "**/*.css" + ] +} diff --git a/packages/member-card/src/MemberCard.css.ts b/packages/member-card/src/MemberCard.css.ts new file mode 100644 index 00000000..f5dc5287 --- /dev/null +++ b/packages/member-card/src/MemberCard.css.ts @@ -0,0 +1,102 @@ +import { vars } from '@sipe-team/tokens'; + +import { style } from '@vanilla-extract/css'; +import { recipe } from '@vanilla-extract/recipes'; + +export const root = recipe({ + base: { + display: 'flex', + flexDirection: 'column', + gap: vars.spacing.component.md, + borderRadius: vars.radius.component.lg, + padding: vars.spacing.component.lg, + minHeight: 270, + boxSizing: 'border-box', + }, + variants: { + variant: { + filled: { + backgroundColor: vars.color.background.subtle, + borderWidth: '1px', + borderStyle: 'solid', + borderColor: vars.color.border.default, + }, + outline: { + backgroundColor: vars.color.background.default, + borderWidth: '1px', + borderStyle: 'solid', + borderColor: vars.color.border.strong, + }, + }, + }, + defaultVariants: { + variant: 'filled', + }, +}); + +export const header = style({ + display: 'flex', + alignItems: 'stretch', + gap: vars.spacing.component.md, +}); + +export const avatar = style({ + flexShrink: 0, +}); + +export const info = style({ + display: 'flex', + flexDirection: 'column', + justifyContent: 'space-between', + flex: 1, + minWidth: 0, + paddingBlock: vars.spacing.component.xs, +}); + +export const mainRow = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: vars.spacing.component.sm, +}); + +export const name = style({ + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +}); + +export const links = style({ + display: 'flex', + gap: vars.spacing.component.sm, + listStyle: 'none', + padding: 0, + margin: 0, +}); + +export const linkItem = style({ + display: 'inline-flex', +}); + +export const subRow = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: vars.spacing.component.sm, +}); + +export const organizer = style({ + display: 'inline-flex', + alignItems: 'center', + gap: vars.spacing.component.xs, +}); + +export const organizerMark = style({ + color: 'currentColor', +}); + +export const reviewSection = style({ + display: 'flex', + flexDirection: 'column', + gap: vars.spacing.component.xs, +}); diff --git a/packages/member-card/src/MemberCard.stories.tsx b/packages/member-card/src/MemberCard.stories.tsx new file mode 100644 index 00000000..07a802bf --- /dev/null +++ b/packages/member-card/src/MemberCard.stories.tsx @@ -0,0 +1,184 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { MemberCard } from './MemberCard'; + +const meta = { + title: 'Components/MemberCard', + component: MemberCard, + parameters: { + layout: 'centered', + }, + argTypes: { + variant: { + control: 'select', + options: ['filled', 'outline'], + }, + avatarShape: { + control: 'select', + options: ['circle', 'rounded', 'square'], + }, + socialSize: { + control: 'select', + options: ['sm', 'md'], + }, + isOrganizer: { control: 'boolean' }, + }, + args: { + name: '홍길동', + part: 'Frontend', + imgSrc: 'https://i.pravatar.cc/140?img=13', + variant: 'filled', + avatarShape: 'rounded', + socialSize: 'sm', + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ( +
+ +
+ ), +}; + +export const WithLinks: Story = { + render: (args) => ( +
+ +
+ ), +}; + +export const Organizer: Story = { + render: (args) => ( +
+ +
+ ), +}; + +export const WithIntroduce: Story = { + render: (args) => ( +
+ +
+ ), +}; + +export const WithReview: Story = { + render: (args) => ( +
+ +
+ ), +}; + +export const AllSocials: Story = { + render: (args) => ( +
+ +
+ ), +}; + +export const OutlineVariant: Story = { + render: (args) => ( +
+ +
+ ), +}; + +export const Gallery: Story = { + render: () => ( +
+ + + +
+ ), +}; diff --git a/packages/member-card/src/MemberCard.test.tsx b/packages/member-card/src/MemberCard.test.tsx new file mode 100644 index 00000000..ce223ea4 --- /dev/null +++ b/packages/member-card/src/MemberCard.test.tsx @@ -0,0 +1,104 @@ +import { render, screen } from '@testing-library/react'; +import { expect, test } from 'vitest'; + +import { MemberCard } from './MemberCard'; + +test('renders name and part', () => { + render(); + expect(screen.getByRole('heading', { name: '홍길동' })).toBeInTheDocument(); + expect(screen.getByText('Frontend')).toBeInTheDocument(); +}); + +test('renders introduce text when provided', () => { + render(); + expect(screen.getByText('반갑습니다.')).toBeInTheDocument(); +}); + +test('does not render introduce when omitted', () => { + render(); + expect(screen.queryByText(/반갑습니다/)).not.toBeInTheDocument(); +}); + +test('renders review section with heading when review provided', () => { + render(); + expect(screen.getByRole('heading', { name: '활동후기' })).toBeInTheDocument(); + expect(screen.getByText('정말 좋은 경험이었어요.')).toBeInTheDocument(); +}); + +test('does not render review section when omitted', () => { + render(); + expect(screen.queryByRole('heading', { name: '활동후기' })).not.toBeInTheDocument(); +}); + +test('renders Organizer badge when isOrganizer is true', () => { + render(); + expect(screen.getByText('Organizer')).toBeInTheDocument(); +}); + +test('does not render Organizer badge by default', () => { + render(); + expect(screen.queryByText('Organizer')).not.toBeInTheDocument(); +}); + +test('renders social links with proper hrefs and target', () => { + render( + , + ); + const github = screen.getByRole('link', { name: 'GitHub' }); + const linkedin = screen.getByRole('link', { name: 'LinkedIn' }); + expect(github).toHaveAttribute('href', 'https://github.com/gildong'); + expect(github).toHaveAttribute('target', '_blank'); + expect(github).toHaveAttribute('rel', 'noopener noreferrer'); + expect(linkedin).toHaveAttribute('href', 'https://linkedin.com/in/gildong'); +}); + +test('email links use mailto: scheme', () => { + render(); + expect(screen.getByRole('link', { name: 'Email' })).toHaveAttribute('href', 'mailto:gildong@example.com'); +}); + +test('does not render links list when links is empty', () => { + render(); + expect(screen.queryByRole('list')).not.toBeInTheDocument(); +}); + +test('renders avatar image when imgSrc provided', () => { + render(); + const img = screen.getByRole('img', { name: '홍길동 아바타' }); + expect(img).toHaveAttribute('src', '/avatar.png'); +}); + +test('renders as an article element', () => { + const { container } = render(); + expect(container.firstChild?.nodeName).toBe('ARTICLE'); +}); + +test('outline variant references border.strong token', () => { + const { container } = render(); + const root = container.firstChild as HTMLElement; + const rules = Array.from(document.styleSheets) + .flatMap((sheet) => { + try { + return Array.from(sheet.cssRules); + } catch { + return []; + } + }) + .filter((rule): rule is CSSStyleRule => 'selectorText' in rule) + .filter((rule) => + root.className + .split(/\s+/) + .filter(Boolean) + .some((cls) => rule.selectorText.split(',').some((s) => s.trim() === `.${cls}`)), + ) + .map((rule) => rule.cssText) + .join('\n'); + expect(rules).toContain('var(--side-color-border-strong)'); +}); diff --git a/packages/member-card/src/MemberCard.tsx b/packages/member-card/src/MemberCard.tsx new file mode 100644 index 00000000..1ad41f59 --- /dev/null +++ b/packages/member-card/src/MemberCard.tsx @@ -0,0 +1,133 @@ +import { type ComponentProps, type ForwardedRef, forwardRef } from 'react'; + +import { Avatar, type AvatarProps, type AvatarShape } from '@sipe-team/avatar'; +import { vars } from '@sipe-team/tokens'; +import { Typography } from '@sipe-team/typography'; + +import { clsx as cx } from 'clsx'; + +import * as styles from './MemberCard.css'; +import { SocialIconLink, type SocialIconLinkProps, type SocialType } from './SocialIconLink'; + +export const MemberCardVariant = { + filled: 'filled', + outline: 'outline', +} as const; +export type MemberCardVariant = (typeof MemberCardVariant)[keyof typeof MemberCardVariant]; + +export interface MemberCardLink { + type: SocialType; + url: string; +} + +export interface MemberCardProps extends Omit, 'children'> { + name: string; + part: string; + imgSrc?: string; + imgAlt?: string; + avatarShape?: AvatarShape; + isOrganizer?: boolean; + introduce?: string; + review?: string; + links?: MemberCardLink[]; + variant?: MemberCardVariant; + socialSize?: SocialIconLinkProps['size']; +} + +function OrganizerBadge() { + return ( + + + Organizer + + + + ); +} + +export const MemberCard = forwardRef(function MemberCard( + { + className, + name, + part, + imgSrc, + imgAlt, + avatarShape = 'rounded', + isOrganizer = false, + introduce, + review, + links, + variant = 'filled', + socialSize = 'sm', + ...props + }: MemberCardProps, + ref: ForwardedRef, +) { + const avatarProps: AvatarProps = { + className: styles.avatar, + alt: imgAlt ?? `${name} profile`, + size: 'lg', + shape: avatarShape, + fallback: name.charAt(0), + ...(imgSrc ? { src: imgSrc } : {}), + }; + + return ( +
+
+ +
+
+ +

{name}

+
+ {links && links.length > 0 && ( +
    + {links.map(({ type, url }) => ( +
  • + +
  • + ))} +
+ )} +
+
+ +

{part}

+
+ {isOrganizer && } +
+
+
+ {introduce && ( + +

{introduce}

+
+ )} + {review && ( +
+ +

활동후기

+
+ +

{review}

+
+
+ )} +
+ ); +}); diff --git a/packages/member-card/src/SocialIconLink.css.ts b/packages/member-card/src/SocialIconLink.css.ts new file mode 100644 index 00000000..1901543e --- /dev/null +++ b/packages/member-card/src/SocialIconLink.css.ts @@ -0,0 +1,36 @@ +import { vars } from '@sipe-team/tokens'; + +import { style, styleVariants } from '@vanilla-extract/css'; + +export const link = style({ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + color: vars.color.foreground.default, + textDecoration: 'none', + transition: 'color 150ms ease-in-out', + ':hover': { + color: vars.color.accent.default, + }, + ':focus-visible': { + outline: `2px solid ${vars.color.border.focus}`, + outlineOffset: 2, + borderRadius: vars.radius.component.sm, + }, +}); + +export const icon = style({ + color: 'inherit', + display: 'block', +}); + +export const size = styleVariants({ + sm: { + width: 18, + height: 18, + }, + md: { + width: 24, + height: 24, + }, +}); diff --git a/packages/member-card/src/SocialIconLink.test.tsx b/packages/member-card/src/SocialIconLink.test.tsx new file mode 100644 index 00000000..2a4e07fc --- /dev/null +++ b/packages/member-card/src/SocialIconLink.test.tsx @@ -0,0 +1,37 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, test } from 'vitest'; + +import { SocialIconLink, SocialType } from './SocialIconLink'; + +const ALL_TYPES = Object.values(SocialType); +const EXPECTED_LABELS: Record = { + github: 'GitHub', + linkedin: 'LinkedIn', + instagram: 'Instagram', + youtube: 'YouTube', + kakao: 'KakaoTalk', + email: 'Email', + link: 'Link', +}; + +describe('SocialIconLink', () => { + test.each(ALL_TYPES)('renders the icon with correct aria-label for %s type', (type) => { + render(); + expect(screen.getByRole('img', { name: EXPECTED_LABELS[type] })).toBeInTheDocument(); + }); + + test('email URLs already prefixed with mailto: are not double-prefixed', () => { + render(); + expect(screen.getByRole('link', { name: 'Email' })).toHaveAttribute('href', 'mailto:foo@example.com'); + }); + + test('http URLs are preserved without adding https:// prefix', () => { + render(); + expect(screen.getByRole('link', { name: 'GitHub' })).toHaveAttribute('href', 'http://example.com/user'); + }); + + test('bare URLs get an https:// prefix', () => { + render(); + expect(screen.getByRole('link', { name: 'Link' })).toHaveAttribute('href', 'https://example.com'); + }); +}); diff --git a/packages/member-card/src/SocialIconLink.tsx b/packages/member-card/src/SocialIconLink.tsx new file mode 100644 index 00000000..d6953236 --- /dev/null +++ b/packages/member-card/src/SocialIconLink.tsx @@ -0,0 +1,146 @@ +import type { ComponentProps, FunctionComponent, SVGProps } from 'react'; + +import { clsx as cx } from 'clsx'; + +import * as styles from './SocialIconLink.css'; + +export const SocialType = { + github: 'github', + linkedin: 'linkedin', + instagram: 'instagram', + youtube: 'youtube', + kakao: 'kakao', + email: 'email', + link: 'link', +} as const; +export type SocialType = (typeof SocialType)[keyof typeof SocialType]; + +export const SocialIconSize = { + sm: 'sm', + md: 'md', +} as const; +export type SocialIconSize = (typeof SocialIconSize)[keyof typeof SocialIconSize]; + +const GithubIcon: FunctionComponent> = (props) => ( + + + +); + +const LinkedInIcon: FunctionComponent> = (props) => ( + + + +); + +const InstagramIcon: FunctionComponent> = (props) => ( + + + +); + +const YoutubeIcon: FunctionComponent> = (props) => ( + + + +); + +const EmailIcon: FunctionComponent> = (props) => ( + + + +); + +const LinkIcon: FunctionComponent> = (props) => ( + + + +); + +const KakaoIcon: FunctionComponent> = (props) => ( + + + + +); + +const iconMap: Record>> = { + github: GithubIcon, + linkedin: LinkedInIcon, + instagram: InstagramIcon, + youtube: YoutubeIcon, + kakao: KakaoIcon, + email: EmailIcon, + link: LinkIcon, +}; + +const socialLabels: Record = { + github: 'GitHub', + linkedin: 'LinkedIn', + instagram: 'Instagram', + youtube: 'YouTube', + kakao: 'KakaoTalk', + email: 'Email', + link: 'Link', +}; + +function normalizeUrl(url: string, type: SocialType): string { + if (type === 'email') { + return url.startsWith('mailto:') ? url : `mailto:${url}`; + } + if (/^https?:\/\//i.test(url)) return url; + return `https://${url}`; +} + +export interface SocialIconLinkProps extends Omit, 'href' | 'type'> { + type: SocialType; + url?: string; + size?: SocialIconSize; +} + +export function SocialIconLink({ type, url, size = 'sm', className, ...props }: SocialIconLinkProps) { + const Icon = iconMap[type]; + const label = socialLabels[type]; + const iconClassName = cx(styles.icon, styles.size[size]); + + if (!url) { + return ; + } + + return ( + + + ); +} diff --git a/packages/member-card/src/index.ts b/packages/member-card/src/index.ts new file mode 100644 index 00000000..cc304bb7 --- /dev/null +++ b/packages/member-card/src/index.ts @@ -0,0 +1,3 @@ +export * from './MemberCard'; +export * from './MemberCard.css'; +export * from './SocialIconLink'; diff --git a/packages/member-card/tsconfig.json b/packages/member-card/tsconfig.json new file mode 100644 index 00000000..4082f16a --- /dev/null +++ b/packages/member-card/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} diff --git a/packages/member-card/tsup.config.ts b/packages/member-card/tsup.config.ts new file mode 100644 index 00000000..ee4b1170 --- /dev/null +++ b/packages/member-card/tsup.config.ts @@ -0,0 +1,3 @@ +import defaultConfig from '../../tsup.config'; + +export default defaultConfig; diff --git a/packages/member-card/vitest.config.ts b/packages/member-card/vitest.config.ts new file mode 100644 index 00000000..e663baf0 --- /dev/null +++ b/packages/member-card/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineProject, mergeConfig } from 'vitest/config'; + +import defaultConfig from '../../vitest.config'; + +export default mergeConfig( + defaultConfig, + defineProject({ + test: { + setupFiles: './vitest.setup.ts', + }, + }), +); diff --git a/packages/member-card/vitest.setup.ts b/packages/member-card/vitest.setup.ts new file mode 100644 index 00000000..7b0828bf --- /dev/null +++ b/packages/member-card/vitest.setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/packages/radio/package.json b/packages/radio/package.json index 271818dc..d88de2b2 100644 --- a/packages/radio/package.json +++ b/packages/radio/package.json @@ -21,6 +21,7 @@ "clean": "rm -rf node_modules dist" }, "dependencies": { + "@sipe-team/hooks": "workspace:*", "@sipe-team/tokens": "workspace:*", "@sipe-team/typography": "workspace:*", "@vanilla-extract/recipes": "catalog:", diff --git a/packages/radio/src/Radio.tsx b/packages/radio/src/Radio.tsx index eb4ae2dc..826b8a73 100644 --- a/packages/radio/src/Radio.tsx +++ b/packages/radio/src/Radio.tsx @@ -1,4 +1,6 @@ -import { type ComponentProps, type PropsWithChildren, useContext, useId } from 'react'; +import { type ComponentProps, type PropsWithChildren, useContext } from 'react'; + +import { useId } from '@sipe-team/hooks'; import clsx from 'clsx'; diff --git a/packages/radio/src/RadioGroup.tsx b/packages/radio/src/RadioGroup.tsx index 05468b18..5eec8477 100644 --- a/packages/radio/src/RadioGroup.tsx +++ b/packages/radio/src/RadioGroup.tsx @@ -1,9 +1,11 @@ -import { createContext, type PropsWithChildren, useId } from 'react'; +import { createContext, type PropsWithChildren } from 'react'; + +import { useId } from '@sipe-team/hooks'; import clsx from 'clsx'; import type { RadioSize } from './constants/sizes'; -import { radioGroup } from './Radio.css'; +import { radioGroup, radioGroupLegend } from './Radio.css'; type RadioGroupContext = Omit; @@ -34,7 +36,7 @@ export function RadioGroup({ return (
- {labelText && {labelText}} + {labelText && {labelText}} {children}
); diff --git a/packages/side/package.json b/packages/side/package.json index ebd6644d..29bcb337 100644 --- a/packages/side/package.json +++ b/packages/side/package.json @@ -25,6 +25,7 @@ "@sipe-team/button": "workspace:*", "@sipe-team/card": "workspace:*", "@sipe-team/divider": "workspace:*", + "@sipe-team/image": "workspace:*", "@sipe-team/input": "workspace:*", "@sipe-team/radio": "workspace:*", "@sipe-team/skeleton": "workspace:*", diff --git a/packages/side/src/index.ts b/packages/side/src/index.ts index 0fea7569..c02857d9 100644 --- a/packages/side/src/index.ts +++ b/packages/side/src/index.ts @@ -3,6 +3,7 @@ export * from '@sipe-team/button'; export * from '@sipe-team/card'; export * from '@sipe-team/divider'; export * from '@sipe-team/flex'; +export * from '@sipe-team/image'; export * from '@sipe-team/input'; export * from '@sipe-team/radio'; export * from '@sipe-team/skeleton'; diff --git a/packages/side/styles.css b/packages/side/styles.css index b1b1b2ae..8446dd9f 100644 --- a/packages/side/styles.css +++ b/packages/side/styles.css @@ -3,6 +3,7 @@ @import "~@sipe-team/card/styles.css"; @import "~@sipe-team/divider/styles.css"; @import "~@sipe-team/flex/styles.css"; +@import "~@sipe-team/image/styles.css"; @import "~@sipe-team/input/styles.css"; @import "~@sipe-team/radio/styles.css"; @import "~@sipe-team/skeleton/styles.css"; diff --git a/packages/tokens/package.json b/packages/tokens/package.json index 2effdfdc..8dfafdfc 100644 --- a/packages/tokens/package.json +++ b/packages/tokens/package.json @@ -28,7 +28,7 @@ "@vanilla-extract/css": "catalog:" }, "devDependencies": { - "@types/node": "^22", + "@types/node": "^24", "@types/react": "catalog:react", "react": "catalog:react", "react-dom": "catalog:react", diff --git a/packages/tooltip/package.json b/packages/tooltip/package.json index 63dfe002..9086f207 100644 --- a/packages/tooltip/package.json +++ b/packages/tooltip/package.json @@ -22,6 +22,8 @@ }, "dependencies": { "@radix-ui/react-slot": "catalog:", + "@sipe-team/hooks": "workspace:*", + "@sipe-team/tokens": "workspace:*", "@vanilla-extract/recipes": "catalog:", "clsx": "catalog:" }, diff --git a/packages/tooltip/src/Tooltip.css.ts b/packages/tooltip/src/Tooltip.css.ts index cdfc6230..ad9e6b10 100644 --- a/packages/tooltip/src/Tooltip.css.ts +++ b/packages/tooltip/src/Tooltip.css.ts @@ -1,6 +1,13 @@ +import { color, opacity, themeColor, vars, zIndex } from '@sipe-team/tokens'; + import { keyframes, style } from '@vanilla-extract/css'; import { recipe } from '@vanilla-extract/recipes'; +const tooltipBackgroundColor = `var(--tooltip-bg-color, ${themeColor['1st'].background})`; +const tooltipArrowSize = `calc(${vars.spacing.component.md} / 2)`; +const tooltipArrowOffset = `calc(${vars.spacing.component.md} / -2)`; +const tooltipShadow = `0 ${vars.spacing.component.xs} ${vars.spacing.component.sm} rgba(0, 0, 0, ${opacity[20]})`; + const fadeIn = keyframes({ from: { opacity: 0, transform: 'scale(0.95)' }, to: { opacity: 1, transform: 'scale(1)' }, @@ -9,17 +16,17 @@ const fadeIn = keyframes({ export const tooltip = recipe({ base: { position: 'fixed', - backgroundColor: 'var(--tooltip-bg-color, #000000)', - color: '#ffffff', - padding: '8px 12px', - borderRadius: '8px', - fontSize: '12px', - lineHeight: 1.5, + backgroundColor: tooltipBackgroundColor, + color: color.white, + padding: `${vars.spacing.component.sm} ${vars.spacing.component.md}`, + borderRadius: vars.radius.component.lg, + fontSize: vars.typography.fontSize['050'], + lineHeight: vars.typography.lineHeight.regular, whiteSpace: 'normal', wordWrap: 'break-word', maxWidth: '250px', - boxShadow: '0 4px 8px rgba(0, 0, 0, 0.2)', - zIndex: 1000, + boxShadow: tooltipShadow, + zIndex: zIndex.dropdown, animation: `${fadeIn} 0.15s ease`, '@media': { '(prefers-reduced-motion: reduce)': { @@ -41,84 +48,84 @@ export const tooltip = recipe({ 'top-left': { selectors: { '&::after': { - bottom: '-6px', - left: '8px', - borderWidth: '6px 6px 0 6px', - borderColor: 'var(--tooltip-bg-color, #000000) transparent transparent transparent', + bottom: tooltipArrowOffset, + left: vars.spacing.component.sm, + borderWidth: `${tooltipArrowSize} ${tooltipArrowSize} 0 ${tooltipArrowSize}`, + borderColor: `${tooltipBackgroundColor} transparent transparent transparent`, }, }, }, 'top-right': { selectors: { '&::after': { - bottom: '-6px', - right: '8px', - borderWidth: '6px 6px 0 6px', - borderColor: 'var(--tooltip-bg-color, #000000) transparent transparent transparent', + bottom: tooltipArrowOffset, + right: vars.spacing.component.sm, + borderWidth: `${tooltipArrowSize} ${tooltipArrowSize} 0 ${tooltipArrowSize}`, + borderColor: `${tooltipBackgroundColor} transparent transparent transparent`, }, }, }, 'bottom-left': { selectors: { '&::after': { - top: '-6px', - left: '8px', - borderWidth: '0 6px 6px 6px', - borderColor: 'transparent transparent var(--tooltip-bg-color, #000000) transparent', + top: tooltipArrowOffset, + left: vars.spacing.component.sm, + borderWidth: `0 ${tooltipArrowSize} ${tooltipArrowSize} ${tooltipArrowSize}`, + borderColor: `transparent transparent ${tooltipBackgroundColor} transparent`, }, }, }, 'bottom-right': { selectors: { '&::after': { - top: '-6px', - right: '8px', - borderWidth: '0 6px 6px 6px', - borderColor: 'transparent transparent var(--tooltip-bg-color, #000000) transparent', + top: tooltipArrowOffset, + right: vars.spacing.component.sm, + borderWidth: `0 ${tooltipArrowSize} ${tooltipArrowSize} ${tooltipArrowSize}`, + borderColor: `transparent transparent ${tooltipBackgroundColor} transparent`, }, }, }, top: { selectors: { '&::after': { - bottom: '-6px', + bottom: tooltipArrowOffset, left: '50%', transform: 'translateX(-50%)', - borderWidth: '6px 6px 0 6px', - borderColor: 'var(--tooltip-bg-color, #000000) transparent transparent transparent', + borderWidth: `${tooltipArrowSize} ${tooltipArrowSize} 0 ${tooltipArrowSize}`, + borderColor: `${tooltipBackgroundColor} transparent transparent transparent`, }, }, }, bottom: { selectors: { '&::after': { - top: '-6px', + top: tooltipArrowOffset, left: '50%', transform: 'translateX(-50%)', - borderWidth: '0 6px 6px 6px', - borderColor: 'transparent transparent var(--tooltip-bg-color, #000000) transparent', + borderWidth: `0 ${tooltipArrowSize} ${tooltipArrowSize} ${tooltipArrowSize}`, + borderColor: `transparent transparent ${tooltipBackgroundColor} transparent`, }, }, }, left: { selectors: { '&::after': { - right: '-6px', + right: tooltipArrowOffset, top: '50%', transform: 'translateY(-50%)', - borderWidth: '6px 0 6px 6px', - borderColor: 'transparent transparent transparent var(--tooltip-bg-color, #000000)', + borderWidth: `${tooltipArrowSize} 0 ${tooltipArrowSize} ${tooltipArrowSize}`, + borderColor: `transparent transparent transparent ${tooltipBackgroundColor}`, }, }, }, right: { selectors: { '&::after': { - left: '-6px', + left: tooltipArrowOffset, top: '50%', transform: 'translateY(-50%)', - borderWidth: '6px 6px 6px 0', - borderColor: 'transparent var(--tooltip-bg-color, #000000) transparent transparent', + borderWidth: `${tooltipArrowSize} ${tooltipArrowSize} ${tooltipArrowSize} 0`, + borderColor: `transparent ${tooltipBackgroundColor} transparent transparent`, }, }, }, @@ -127,10 +134,10 @@ export const tooltip = recipe({ }); export const button = style({ - padding: '8px 12px', - fontSize: '14px', + padding: `${vars.spacing.component.sm} ${vars.spacing.component.md}`, + fontSize: vars.typography.fontSize['100'], border: '1px solid #ccc', - borderRadius: '4px', + borderRadius: vars.radius.component.md, backgroundColor: '#f9f9f9', cursor: 'pointer', ':hover': { diff --git a/packages/tooltip/src/Tooltip.test.tsx b/packages/tooltip/src/Tooltip.test.tsx index ddbc618e..105e61b3 100644 --- a/packages/tooltip/src/Tooltip.test.tsx +++ b/packages/tooltip/src/Tooltip.test.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -29,6 +29,24 @@ describe('Tooltip basic behavior', () => { expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); }); + test('ref still forwards to the trigger element when tooltipContent is falsy.', () => { + const capturedRef: { current: HTMLElement | null } = { current: null }; + const TooltipWithRef = () => { + const ref = useRef(null); + useEffect(() => { + capturedRef.current = ref.current; + }); + return ( + + + + ); + }; + + render(); + expect(capturedRef.current).toBe(screen.getByText('Hover me')); + }); + test('Tooltip appears on mouse enter and disappears on mouse leave.', async () => { render( diff --git a/packages/tooltip/src/Tooltip.tsx b/packages/tooltip/src/Tooltip.tsx index 2af6aea6..7fec179a 100644 --- a/packages/tooltip/src/Tooltip.tsx +++ b/packages/tooltip/src/Tooltip.tsx @@ -5,11 +5,14 @@ import { type ForwardedRef, forwardRef, type ReactNode, - useId, + useEffect, useImperativeHandle, + useState, } from 'react'; import { createPortal } from 'react-dom'; +import { useId } from '@sipe-team/hooks'; + import { Slot } from '@radix-ui/react-slot'; import clsx from 'clsx'; @@ -93,10 +96,13 @@ export const Tooltip = forwardRef(function Tooltip( useImperativeHandle(ref, () => wrapperRef.current as HTMLElement); - if (!tooltipContent) { - return <>{children}; - } + // document.body doesn't exist during SSR, so defer the portal until after mount. + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); + const hasContent = Boolean(tooltipContent); const Component = asChild ? Slot : 'div'; return ( @@ -104,7 +110,7 @@ export const Tooltip = forwardRef(function Tooltip( {children} - {isVisible && + {hasContent && + mounted && + isVisible && createPortal(
=0.8'} - consola@3.2.3: - resolution: {integrity: sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==} - engines: {node: ^14.18.0 || >=16.10.0} - consola@3.4.2: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} @@ -8135,6 +8310,9 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + pretendard@1.3.9: + resolution: {integrity: sha512-PaQAADyLY5v4kYFwkpSJHbSSYIkiriY/1xXw75TKoZ9UQQqeU+tvP05yTdZAWibiIYoo8ZKtRv8PM7w0IaywSw==} + prettier@2.8.8: resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} engines: {node: '>=10.13.0'} @@ -9179,8 +9357,8 @@ packages: ufo@1.6.3: resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} - undici-types@6.20.0: - resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} unicode-canonical-property-names-ecmascript@2.0.1: resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} @@ -9968,8 +10146,8 @@ snapshots: dependencies: '@babel/parser': 7.26.5 '@babel/types': 7.26.5 - '@jridgewell/gen-mapping': 0.3.5 - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.0.2 '@babel/generator@7.29.1': @@ -10980,11 +11158,11 @@ snapshots: '@colors/colors@1.5.0': optional: true - '@commitlint/cli@20.5.0(@types/node@22.10.1)(conventional-commits-parser@6.4.0)(typescript@5.6.3)': + '@commitlint/cli@20.5.0(@types/node@24.13.3)(conventional-commits-parser@6.4.0)(typescript@5.6.3)': dependencies: '@commitlint/format': 20.5.0 '@commitlint/lint': 20.5.0 - '@commitlint/load': 20.5.0(@types/node@22.10.1)(typescript@5.6.3) + '@commitlint/load': 20.5.0(@types/node@24.13.3)(typescript@5.6.3) '@commitlint/read': 20.5.0(conventional-commits-parser@6.4.0) '@commitlint/types': 20.5.0 tinyexec: 1.1.1 @@ -11011,13 +11189,13 @@ snapshots: '@commitlint/types': 20.5.0 ajv: 8.17.1 - '@commitlint/cz-commitlint@20.5.1(@types/node@22.10.1)(commitizen@4.3.1(@types/node@22.10.1)(typescript@5.6.3))(inquirer@9.3.8(@types/node@22.10.1))(typescript@5.6.3)': + '@commitlint/cz-commitlint@20.5.1(@types/node@24.13.3)(commitizen@4.3.1(@types/node@24.13.3)(typescript@5.6.3))(inquirer@9.3.8(@types/node@24.13.3))(typescript@5.6.3)': dependencies: '@commitlint/ensure': 20.5.0 - '@commitlint/load': 20.5.0(@types/node@22.10.1)(typescript@5.6.3) + '@commitlint/load': 20.5.0(@types/node@24.13.3)(typescript@5.6.3) '@commitlint/types': 20.5.0 - commitizen: 4.3.1(@types/node@22.10.1)(typescript@5.6.3) - inquirer: 9.3.8(@types/node@22.10.1) + commitizen: 4.3.1(@types/node@24.13.3)(typescript@5.6.3) + inquirer: 9.3.8(@types/node@24.13.3) is-plain-obj: 4.1.0 picocolors: 1.1.1 word-wrap: 1.2.5 @@ -11056,7 +11234,7 @@ snapshots: '@commitlint/rules': 20.5.0 '@commitlint/types': 20.5.0 - '@commitlint/load@19.6.1(@types/node@22.10.1)(typescript@5.6.3)': + '@commitlint/load@19.6.1(@types/node@24.13.3)(typescript@5.6.3)': dependencies: '@commitlint/config-validator': 19.5.0 '@commitlint/execute-rule': 19.5.0 @@ -11064,7 +11242,7 @@ snapshots: '@commitlint/types': 19.5.0 chalk: 5.4.1 cosmiconfig: 9.0.0(typescript@5.6.3) - cosmiconfig-typescript-loader: 6.1.0(@types/node@22.10.1)(cosmiconfig@9.0.0(typescript@5.6.3))(typescript@5.6.3) + cosmiconfig-typescript-loader: 6.1.0(@types/node@24.13.3)(cosmiconfig@9.0.0(typescript@5.6.3))(typescript@5.6.3) lodash.isplainobject: 4.0.6 lodash.merge: 4.6.2 lodash.uniq: 4.5.0 @@ -11073,14 +11251,14 @@ snapshots: - typescript optional: true - '@commitlint/load@20.5.0(@types/node@22.10.1)(typescript@5.6.3)': + '@commitlint/load@20.5.0(@types/node@24.13.3)(typescript@5.6.3)': dependencies: '@commitlint/config-validator': 20.5.0 '@commitlint/execute-rule': 20.0.0 '@commitlint/resolve-extends': 20.5.0 '@commitlint/types': 20.5.0 cosmiconfig: 9.0.1(typescript@5.6.3) - cosmiconfig-typescript-loader: 6.1.0(@types/node@22.10.1)(cosmiconfig@9.0.1(typescript@5.6.3))(typescript@5.6.3) + cosmiconfig-typescript-loader: 6.1.0(@types/node@24.13.3)(cosmiconfig@9.0.1(typescript@5.6.3))(typescript@5.6.3) is-plain-obj: 4.1.0 lodash.mergewith: 4.6.2 picocolors: 1.1.1 @@ -12508,12 +12686,12 @@ snapshots: '@humanwhocodes/retry@0.4.1': optional: true - '@inquirer/external-editor@1.0.3(@types/node@22.10.1)': + '@inquirer/external-editor@1.0.3(@types/node@24.13.3)': dependencies: chardet: 2.1.1 iconv-lite: 0.7.2 optionalDependencies: - '@types/node': 22.10.1 + '@types/node': 24.13.3 '@inquirer/figures@1.0.15': {} @@ -12537,23 +12715,23 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 22.10.1 + '@types/node': 24.13.3 '@types/yargs': 17.0.33 chalk: 4.1.2 - '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@5.6.3)(vite@5.4.11(@types/node@22.10.1)(lightningcss@1.32.0)(terser@5.37.0))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@5.6.3)(vite@5.4.11(@types/node@24.13.3)(lightningcss@1.32.0)(terser@5.37.0))': dependencies: glob: 13.0.6 react-docgen-typescript: 2.2.2(typescript@5.6.3) - vite: 5.4.11(@types/node@22.10.1)(lightningcss@1.32.0)(terser@5.37.0) + vite: 5.4.11(@types/node@24.13.3)(lightningcss@1.32.0)(terser@5.37.0) optionalDependencies: typescript: 5.6.3 - '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@5.6.3)(vite@8.0.8(@types/node@22.10.1)(esbuild@0.27.7)(jiti@2.4.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@5.6.3)(vite@8.0.8(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.4.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1))': dependencies: glob: 13.0.6 react-docgen-typescript: 2.2.2(typescript@5.6.3) - vite: 8.0.8(@types/node@22.10.1)(esbuild@0.27.7)(jiti@2.4.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1) + vite: 8.0.8(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.4.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1) optionalDependencies: typescript: 5.6.3 @@ -12579,8 +12757,8 @@ snapshots: '@jridgewell/source-map@0.3.6': dependencies: - '@jridgewell/gen-mapping': 0.3.5 - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/sourcemap-codec@1.5.0': {} @@ -13092,10 +13270,10 @@ snapshots: ignore: 5.3.2 p-map: 4.0.0 - '@storybook/addon-docs@10.3.5(@types/react@18.3.13)(esbuild@0.27.7)(rollup@4.60.1)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@5.4.11(@types/node@22.10.1)(lightningcss@1.32.0)(terser@5.37.0))(webpack@5.97.1(esbuild@0.27.7))': + '@storybook/addon-docs@10.3.5(@types/react@18.3.13)(esbuild@0.27.7)(rollup@4.60.1)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@5.4.11(@types/node@24.13.3)(lightningcss@1.32.0)(terser@5.37.0))(webpack@5.97.1(esbuild@0.27.7))': dependencies: '@mdx-js/react': 3.1.0(@types/react@18.3.13)(react@18.3.1) - '@storybook/csf-plugin': 10.3.5(esbuild@0.27.7)(rollup@4.60.1)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@5.4.11(@types/node@22.10.1)(lightningcss@1.32.0)(terser@5.37.0))(webpack@5.97.1(esbuild@0.27.7)) + '@storybook/csf-plugin': 10.3.5(esbuild@0.27.7)(rollup@4.60.1)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@5.4.11(@types/node@24.13.3)(lightningcss@1.32.0)(terser@5.37.0))(webpack@5.97.1(esbuild@0.27.7)) '@storybook/icons': 2.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@storybook/react-dom-shim': 10.3.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) react: 18.3.1 @@ -13109,46 +13287,46 @@ snapshots: - vite - webpack - '@storybook/builder-vite@10.3.5(esbuild@0.27.7)(rollup@4.60.1)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@5.4.11(@types/node@22.10.1)(lightningcss@1.32.0)(terser@5.37.0))(webpack@5.97.1(esbuild@0.27.7))': + '@storybook/builder-vite@10.3.5(esbuild@0.27.7)(rollup@4.60.1)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@5.4.11(@types/node@24.13.3)(lightningcss@1.32.0)(terser@5.37.0))(webpack@5.97.1(esbuild@0.27.7))': dependencies: - '@storybook/csf-plugin': 10.3.5(esbuild@0.27.7)(rollup@4.60.1)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@5.4.11(@types/node@22.10.1)(lightningcss@1.32.0)(terser@5.37.0))(webpack@5.97.1(esbuild@0.27.7)) + '@storybook/csf-plugin': 10.3.5(esbuild@0.27.7)(rollup@4.60.1)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@5.4.11(@types/node@24.13.3)(lightningcss@1.32.0)(terser@5.37.0))(webpack@5.97.1(esbuild@0.27.7)) storybook: 10.3.5(@testing-library/dom@10.4.0)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) ts-dedent: 2.2.0 - vite: 5.4.11(@types/node@22.10.1)(lightningcss@1.32.0)(terser@5.37.0) + vite: 5.4.11(@types/node@24.13.3)(lightningcss@1.32.0)(terser@5.37.0) transitivePeerDependencies: - esbuild - rollup - webpack - '@storybook/builder-vite@10.3.5(esbuild@0.27.7)(rollup@4.60.1)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@8.0.8(@types/node@22.10.1)(esbuild@0.27.7)(jiti@2.4.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1))(webpack@5.97.1(esbuild@0.27.7))': + '@storybook/builder-vite@10.3.5(esbuild@0.27.7)(rollup@4.60.1)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@8.0.8(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.4.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1))(webpack@5.97.1(esbuild@0.27.7))': dependencies: - '@storybook/csf-plugin': 10.3.5(esbuild@0.27.7)(rollup@4.60.1)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@8.0.8(@types/node@22.10.1)(esbuild@0.27.7)(jiti@2.4.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1))(webpack@5.97.1(esbuild@0.27.7)) + '@storybook/csf-plugin': 10.3.5(esbuild@0.27.7)(rollup@4.60.1)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@8.0.8(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.4.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1))(webpack@5.97.1(esbuild@0.27.7)) storybook: 10.3.5(@testing-library/dom@10.4.0)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) ts-dedent: 2.2.0 - vite: 8.0.8(@types/node@22.10.1)(esbuild@0.27.7)(jiti@2.4.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1) + vite: 8.0.8(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.4.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1) transitivePeerDependencies: - esbuild - rollup - webpack - '@storybook/csf-plugin@10.3.5(esbuild@0.27.7)(rollup@4.60.1)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@5.4.11(@types/node@22.10.1)(lightningcss@1.32.0)(terser@5.37.0))(webpack@5.97.1(esbuild@0.27.7))': + '@storybook/csf-plugin@10.3.5(esbuild@0.27.7)(rollup@4.60.1)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@5.4.11(@types/node@24.13.3)(lightningcss@1.32.0)(terser@5.37.0))(webpack@5.97.1(esbuild@0.27.7))': dependencies: storybook: 10.3.5(@testing-library/dom@10.4.0)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) unplugin: 2.3.11 optionalDependencies: esbuild: 0.27.7 rollup: 4.60.1 - vite: 5.4.11(@types/node@22.10.1)(lightningcss@1.32.0)(terser@5.37.0) + vite: 5.4.11(@types/node@24.13.3)(lightningcss@1.32.0)(terser@5.37.0) webpack: 5.97.1(esbuild@0.27.7) - '@storybook/csf-plugin@10.3.5(esbuild@0.27.7)(rollup@4.60.1)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@8.0.8(@types/node@22.10.1)(esbuild@0.27.7)(jiti@2.4.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1))(webpack@5.97.1(esbuild@0.27.7))': + '@storybook/csf-plugin@10.3.5(esbuild@0.27.7)(rollup@4.60.1)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@8.0.8(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.4.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1))(webpack@5.97.1(esbuild@0.27.7))': dependencies: storybook: 10.3.5(@testing-library/dom@10.4.0)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) unplugin: 2.3.11 optionalDependencies: esbuild: 0.27.7 rollup: 4.60.1 - vite: 8.0.8(@types/node@22.10.1)(esbuild@0.27.7)(jiti@2.4.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1) + vite: 8.0.8(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.4.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1) webpack: 5.97.1(esbuild@0.27.7) '@storybook/global@5.0.0': {} @@ -13164,11 +13342,11 @@ snapshots: react-dom: 18.3.1(react@18.3.1) storybook: 10.3.5(@testing-library/dom@10.4.0)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@storybook/react-vite@10.3.5(esbuild@0.27.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.60.1)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.6.3)(vite@5.4.11(@types/node@22.10.1)(lightningcss@1.32.0)(terser@5.37.0))(webpack@5.97.1(esbuild@0.27.7))': + '@storybook/react-vite@10.3.5(esbuild@0.27.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.60.1)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.6.3)(vite@5.4.11(@types/node@24.13.3)(lightningcss@1.32.0)(terser@5.37.0))(webpack@5.97.1(esbuild@0.27.7))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@5.6.3)(vite@5.4.11(@types/node@22.10.1)(lightningcss@1.32.0)(terser@5.37.0)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@5.6.3)(vite@5.4.11(@types/node@24.13.3)(lightningcss@1.32.0)(terser@5.37.0)) '@rollup/pluginutils': 5.1.3(rollup@4.60.1) - '@storybook/builder-vite': 10.3.5(esbuild@0.27.7)(rollup@4.60.1)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@5.4.11(@types/node@22.10.1)(lightningcss@1.32.0)(terser@5.37.0))(webpack@5.97.1(esbuild@0.27.7)) + '@storybook/builder-vite': 10.3.5(esbuild@0.27.7)(rollup@4.60.1)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@5.4.11(@types/node@24.13.3)(lightningcss@1.32.0)(terser@5.37.0))(webpack@5.97.1(esbuild@0.27.7)) '@storybook/react': 10.3.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.6.3) empathic: 2.0.0 magic-string: 0.30.21 @@ -13178,7 +13356,7 @@ snapshots: resolve: 1.22.8 storybook: 10.3.5(@testing-library/dom@10.4.0)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) tsconfig-paths: 4.2.0 - vite: 5.4.11(@types/node@22.10.1)(lightningcss@1.32.0)(terser@5.37.0) + vite: 5.4.11(@types/node@24.13.3)(lightningcss@1.32.0)(terser@5.37.0) transitivePeerDependencies: - esbuild - rollup @@ -13186,11 +13364,11 @@ snapshots: - typescript - webpack - '@storybook/react-vite@10.3.5(esbuild@0.27.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.60.1)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.6.3)(vite@8.0.8(@types/node@22.10.1)(esbuild@0.27.7)(jiti@2.4.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1))(webpack@5.97.1(esbuild@0.27.7))': + '@storybook/react-vite@10.3.5(esbuild@0.27.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.60.1)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.6.3)(vite@8.0.8(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.4.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1))(webpack@5.97.1(esbuild@0.27.7))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@5.6.3)(vite@8.0.8(@types/node@22.10.1)(esbuild@0.27.7)(jiti@2.4.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@5.6.3)(vite@8.0.8(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.4.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1)) '@rollup/pluginutils': 5.1.3(rollup@4.60.1) - '@storybook/builder-vite': 10.3.5(esbuild@0.27.7)(rollup@4.60.1)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@8.0.8(@types/node@22.10.1)(esbuild@0.27.7)(jiti@2.4.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1))(webpack@5.97.1(esbuild@0.27.7)) + '@storybook/builder-vite': 10.3.5(esbuild@0.27.7)(rollup@4.60.1)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@8.0.8(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.4.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1))(webpack@5.97.1(esbuild@0.27.7)) '@storybook/react': 10.3.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.6.3) empathic: 2.0.0 magic-string: 0.30.21 @@ -13200,7 +13378,7 @@ snapshots: resolve: 1.22.8 storybook: 10.3.5(@testing-library/dom@10.4.0)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) tsconfig-paths: 4.2.0 - vite: 8.0.8(@types/node@22.10.1)(esbuild@0.27.7)(jiti@2.4.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1) + vite: 8.0.8(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.4.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1) transitivePeerDependencies: - esbuild - rollup @@ -13414,11 +13592,11 @@ snapshots: '@types/body-parser@1.19.5': dependencies: '@types/connect': 3.4.38 - '@types/node': 22.10.1 + '@types/node': 24.13.3 '@types/bonjour@3.5.13': dependencies: - '@types/node': 22.10.1 + '@types/node': 24.13.3 '@types/chai@5.2.3': dependencies: @@ -13428,15 +13606,15 @@ snapshots: '@types/connect-history-api-fallback@1.5.4': dependencies: '@types/express-serve-static-core': 5.0.6 - '@types/node': 22.10.1 + '@types/node': 24.13.3 '@types/connect@3.4.38': dependencies: - '@types/node': 22.10.1 + '@types/node': 24.13.3 '@types/conventional-commits-parser@5.0.1': dependencies: - '@types/node': 22.10.1 + '@types/node': 24.13.3 optional: true '@types/debug@4.1.12': @@ -13454,7 +13632,7 @@ snapshots: '@types/eslint@9.6.1': dependencies: - '@types/estree': 1.0.6 + '@types/estree': 1.0.8 '@types/json-schema': 7.0.15 '@types/estree-jsx@1.0.5': @@ -13467,14 +13645,14 @@ snapshots: '@types/express-serve-static-core@4.19.6': dependencies: - '@types/node': 22.10.1 + '@types/node': 24.13.3 '@types/qs': 6.9.18 '@types/range-parser': 1.2.7 '@types/send': 0.17.4 '@types/express-serve-static-core@5.0.6': dependencies: - '@types/node': 22.10.1 + '@types/node': 24.13.3 '@types/qs': 6.9.18 '@types/range-parser': 1.2.7 '@types/send': 0.17.4 @@ -13502,7 +13680,7 @@ snapshots: '@types/http-proxy@1.17.15': dependencies: - '@types/node': 22.10.1 + '@types/node': 24.13.3 '@types/istanbul-lib-coverage@2.0.6': {} @@ -13528,15 +13706,15 @@ snapshots: '@types/node-forge@1.3.11': dependencies: - '@types/node': 22.10.1 + '@types/node': 24.13.3 '@types/node@12.20.55': {} '@types/node@17.0.45': {} - '@types/node@22.10.1': + '@types/node@24.13.3': dependencies: - undici-types: 6.20.0 + undici-types: 7.18.2 '@types/parse-json@4.0.2': {} @@ -13580,12 +13758,12 @@ snapshots: '@types/sax@1.2.7': dependencies: - '@types/node': 22.10.1 + '@types/node': 24.13.3 '@types/send@0.17.4': dependencies: '@types/mime': 1.3.5 - '@types/node': 22.10.1 + '@types/node': 24.13.3 '@types/serve-index@1.9.4': dependencies: @@ -13594,12 +13772,12 @@ snapshots: '@types/serve-static@1.15.7': dependencies: '@types/http-errors': 2.0.4 - '@types/node': 22.10.1 + '@types/node': 24.13.3 '@types/send': 0.17.4 '@types/sockjs@0.3.36': dependencies: - '@types/node': 22.10.1 + '@types/node': 24.13.3 '@types/unist@2.0.11': {} @@ -13607,7 +13785,7 @@ snapshots: '@types/ws@8.5.14': dependencies: - '@types/node': 22.10.1 + '@types/node': 24.13.3 '@types/yargs-parser@21.0.3': {} @@ -13623,12 +13801,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@vanilla-extract/compiler@0.7.0(@types/node@22.10.1)(esbuild@0.27.7)(jiti@2.4.1)(lightningcss@1.32.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1)': + '@vanilla-extract/compiler@0.7.0(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.4.1)(lightningcss@1.32.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1)': dependencies: '@vanilla-extract/css': 1.20.1 '@vanilla-extract/integration': 8.0.10 - vite: 5.4.11(@types/node@22.10.1)(lightningcss@1.32.0)(terser@5.37.0) - vite-node: 6.0.0(@types/node@22.10.1)(esbuild@0.27.7)(jiti@2.4.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1) + vite: 5.4.11(@types/node@24.13.3)(lightningcss@1.32.0)(terser@5.37.0) + vite-node: 6.0.0(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.4.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1) transitivePeerDependencies: - '@types/node' - '@vitejs/devtools' @@ -13697,11 +13875,11 @@ snapshots: dependencies: '@vanilla-extract/css': 1.20.1 - '@vanilla-extract/vite-plugin@5.2.2(@types/node@22.10.1)(esbuild@0.27.7)(jiti@2.4.1)(lightningcss@1.32.0)(terser@5.37.0)(tsx@4.19.2)(vite@5.4.11(@types/node@22.10.1)(lightningcss@1.32.0)(terser@5.37.0))(yaml@2.6.1)': + '@vanilla-extract/vite-plugin@5.2.2(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.4.1)(lightningcss@1.32.0)(terser@5.37.0)(tsx@4.19.2)(vite@5.4.11(@types/node@24.13.3)(lightningcss@1.32.0)(terser@5.37.0))(yaml@2.6.1)': dependencies: - '@vanilla-extract/compiler': 0.7.0(@types/node@22.10.1)(esbuild@0.27.7)(jiti@2.4.1)(lightningcss@1.32.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1) + '@vanilla-extract/compiler': 0.7.0(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.4.1)(lightningcss@1.32.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1) '@vanilla-extract/integration': 8.0.10 - vite: 5.4.11(@types/node@22.10.1)(lightningcss@1.32.0)(terser@5.37.0) + vite: 5.4.11(@types/node@24.13.3)(lightningcss@1.32.0)(terser@5.37.0) transitivePeerDependencies: - '@types/node' - '@vitejs/devtools' @@ -13719,11 +13897,11 @@ snapshots: - tsx - yaml - '@vanilla-extract/vite-plugin@5.2.2(@types/node@22.10.1)(esbuild@0.27.7)(jiti@2.4.1)(lightningcss@1.32.0)(terser@5.37.0)(tsx@4.19.2)(vite@8.0.8(@types/node@22.10.1)(esbuild@0.27.7)(jiti@2.4.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1))(yaml@2.6.1)': + '@vanilla-extract/vite-plugin@5.2.2(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.4.1)(lightningcss@1.32.0)(terser@5.37.0)(tsx@4.19.2)(vite@8.0.8(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.4.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1))(yaml@2.6.1)': dependencies: - '@vanilla-extract/compiler': 0.7.0(@types/node@22.10.1)(esbuild@0.27.7)(jiti@2.4.1)(lightningcss@1.32.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1) + '@vanilla-extract/compiler': 0.7.0(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.4.1)(lightningcss@1.32.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1) '@vanilla-extract/integration': 8.0.10 - vite: 8.0.8(@types/node@22.10.1)(esbuild@0.27.7)(jiti@2.4.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1) + vite: 8.0.8(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.4.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1) transitivePeerDependencies: - '@types/node' - '@vitejs/devtools' @@ -13752,7 +13930,7 @@ snapshots: - babel-plugin-macros - supports-color - '@vitest/coverage-v8@2.1.8(vitest@2.1.8(@types/node@22.10.1)(happy-dom@15.11.7)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.37.0))': + '@vitest/coverage-v8@2.1.8(vitest@2.1.8(@types/node@24.13.3)(happy-dom@15.11.7)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.37.0))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 0.2.3 @@ -13766,7 +13944,7 @@ snapshots: std-env: 3.8.0 test-exclude: 7.0.1 tinyrainbow: 1.2.0 - vitest: 2.1.8(@types/node@22.10.1)(happy-dom@15.11.7)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.37.0) + vitest: 2.1.8(@types/node@24.13.3)(happy-dom@15.11.7)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.37.0) transitivePeerDependencies: - supports-color @@ -13785,13 +13963,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@2.1.8(vite@5.4.11(@types/node@22.10.1)(lightningcss@1.32.0)(terser@5.37.0))': + '@vitest/mocker@2.1.8(vite@5.4.11(@types/node@24.13.3)(lightningcss@1.32.0)(terser@5.37.0))': dependencies: '@vitest/spy': 2.1.8 estree-walker: 3.0.3 magic-string: 0.30.14 optionalDependencies: - vite: 5.4.11(@types/node@22.10.1)(lightningcss@1.32.0)(terser@5.37.0) + vite: 5.4.11(@types/node@24.13.3)(lightningcss@1.32.0)(terser@5.37.0) '@vitest/pretty-format@2.1.8': dependencies: @@ -14516,10 +14694,10 @@ snapshots: commander@8.3.0: {} - commitizen@4.3.1(@types/node@22.10.1)(typescript@5.6.3): + commitizen@4.3.1(@types/node@24.13.3)(typescript@5.6.3): dependencies: cachedir: 2.3.0 - cz-conventional-changelog: 3.3.0(@types/node@22.10.1)(typescript@5.6.3) + cz-conventional-changelog: 3.3.0(@types/node@24.13.3)(typescript@5.6.3) dedent: 0.7.0 detect-indent: 6.1.0 find-node-modules: 2.1.3 @@ -14580,8 +14758,6 @@ snapshots: connect-history-api-fallback@2.0.0: {} - consola@3.2.3: {} - consola@3.4.2: {} content-disposition@0.5.2: {} @@ -14635,17 +14811,17 @@ snapshots: core-util-is@1.0.3: {} - cosmiconfig-typescript-loader@6.1.0(@types/node@22.10.1)(cosmiconfig@9.0.0(typescript@5.6.3))(typescript@5.6.3): + cosmiconfig-typescript-loader@6.1.0(@types/node@24.13.3)(cosmiconfig@9.0.0(typescript@5.6.3))(typescript@5.6.3): dependencies: - '@types/node': 22.10.1 + '@types/node': 24.13.3 cosmiconfig: 9.0.0(typescript@5.6.3) jiti: 2.4.1 typescript: 5.6.3 optional: true - cosmiconfig-typescript-loader@6.1.0(@types/node@22.10.1)(cosmiconfig@9.0.1(typescript@5.6.3))(typescript@5.6.3): + cosmiconfig-typescript-loader@6.1.0(@types/node@24.13.3)(cosmiconfig@9.0.1(typescript@5.6.3))(typescript@5.6.3): dependencies: - '@types/node': 22.10.1 + '@types/node': 24.13.3 cosmiconfig: 9.0.1(typescript@5.6.3) jiti: 2.4.1 typescript: 5.6.3 @@ -14727,7 +14903,7 @@ snapshots: css-minimizer-webpack-plugin@5.0.1(clean-css@5.3.3)(esbuild@0.27.7)(lightningcss@1.32.0)(webpack@5.97.1(esbuild@0.27.7)): dependencies: - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/trace-mapping': 0.3.31 cssnano: 6.1.2(postcss@8.4.49) jest-worker: 29.7.0 postcss: 8.4.49 @@ -14846,16 +15022,16 @@ snapshots: csstype@3.2.3: {} - cz-conventional-changelog@3.3.0(@types/node@22.10.1)(typescript@5.6.3): + cz-conventional-changelog@3.3.0(@types/node@24.13.3)(typescript@5.6.3): dependencies: chalk: 2.4.2 - commitizen: 4.3.1(@types/node@22.10.1)(typescript@5.6.3) + commitizen: 4.3.1(@types/node@24.13.3)(typescript@5.6.3) conventional-commit-types: 3.0.0 lodash.map: 4.6.0 longest: 2.0.1 word-wrap: 1.2.5 optionalDependencies: - '@commitlint/load': 19.6.1(@types/node@22.10.1)(typescript@5.6.3) + '@commitlint/load': 19.6.1(@types/node@24.13.3)(typescript@5.6.3) transitivePeerDependencies: - '@types/node' - typescript @@ -15330,7 +15506,7 @@ snapshots: dependencies: '@types/estree-jsx': 1.0.5 astring: 1.9.0 - source-map: 0.7.4 + source-map: 0.7.6 estree-util-value-to-estree@3.2.1: dependencies: @@ -15355,7 +15531,7 @@ snapshots: eval@0.1.8: dependencies: - '@types/node': 22.10.1 + '@types/node': 24.13.3 require-like: 0.1.2 eventemitter3@4.0.7: {} @@ -15878,7 +16054,7 @@ snapshots: hast-util-to-estree@3.1.1: dependencies: - '@types/estree': 1.0.6 + '@types/estree': 1.0.8 '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 comma-separated-tokens: 2.0.3 @@ -16173,9 +16349,9 @@ snapshots: through: 2.3.8 wrap-ansi: 7.0.0 - inquirer@9.3.8(@types/node@22.10.1): + inquirer@9.3.8(@types/node@24.13.3): dependencies: - '@inquirer/external-editor': 1.0.3(@types/node@22.10.1) + '@inquirer/external-editor': 1.0.3(@types/node@24.13.3) '@inquirer/figures': 1.0.15 ansi-escapes: 4.3.2 cli-width: 4.1.0 @@ -16371,7 +16547,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 22.10.1 + '@types/node': 24.13.3 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -16379,13 +16555,13 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 22.10.1 + '@types/node': 24.13.3 merge-stream: 2.0.0 supports-color: 8.1.1 jest-worker@29.7.0: dependencies: - '@types/node': 22.10.1 + '@types/node': 24.13.3 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -16478,11 +16654,11 @@ snapshots: kleur@3.0.3: {} - knip@5.39.2(@types/node@22.10.1)(typescript@5.6.3): + knip@5.39.2(@types/node@24.13.3)(typescript@5.6.3): dependencies: '@nodelib/fs.walk': 1.2.8 '@snyk/github-codeowners': 1.1.0 - '@types/node': 22.10.1 + '@types/node': 24.13.3 easy-table: 1.2.0 enhanced-resolve: 5.17.1 fast-glob: 3.3.2 @@ -18150,6 +18326,8 @@ snapshots: prelude-ls@1.2.1: optional: true + pretendard@1.3.9: {} + prettier@2.8.8: {} prettier@3.8.3: {} @@ -18441,7 +18619,7 @@ snapshots: recma-parse@1.0.0: dependencies: - '@types/estree': 1.0.6 + '@types/estree': 1.0.8 esast-util-from-js: 2.0.1 unified: 11.0.5 vfile: 6.0.3 @@ -19161,7 +19339,7 @@ snapshots: sucrase@3.35.0: dependencies: - '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/gen-mapping': 0.3.13 commander: 4.1.1 glob: 10.4.5 lines-and-columns: 1.2.4 @@ -19399,7 +19577,7 @@ snapshots: ufo@1.6.3: {} - undici-types@6.20.0: {} + undici-types@7.18.2: {} unicode-canonical-property-names-ecmascript@2.0.1: {} @@ -19556,13 +19734,13 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.2 - vite-node@2.1.8(@types/node@22.10.1)(lightningcss@1.32.0)(terser@5.37.0): + vite-node@2.1.8(@types/node@24.13.3)(lightningcss@1.32.0)(terser@5.37.0): dependencies: cac: 6.7.14 debug: 4.4.0 es-module-lexer: 1.5.4 pathe: 1.1.2 - vite: 5.4.11(@types/node@22.10.1)(lightningcss@1.32.0)(terser@5.37.0) + vite: 5.4.11(@types/node@24.13.3)(lightningcss@1.32.0)(terser@5.37.0) transitivePeerDependencies: - '@types/node' - less @@ -19574,13 +19752,13 @@ snapshots: - supports-color - terser - vite-node@6.0.0(@types/node@22.10.1)(esbuild@0.27.7)(jiti@2.4.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1): + vite-node@6.0.0(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.4.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1): dependencies: cac: 7.0.0 es-module-lexer: 2.0.0 obug: 2.1.1 pathe: 2.0.3 - vite: 8.0.8(@types/node@22.10.1)(esbuild@0.27.7)(jiti@2.4.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1) + vite: 8.0.8(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.4.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1) transitivePeerDependencies: - '@types/node' - '@vitejs/devtools' @@ -19595,18 +19773,18 @@ snapshots: - tsx - yaml - vite@5.4.11(@types/node@22.10.1)(lightningcss@1.32.0)(terser@5.37.0): + vite@5.4.11(@types/node@24.13.3)(lightningcss@1.32.0)(terser@5.37.0): dependencies: esbuild: 0.21.5 postcss: 8.4.49 rollup: 4.28.0 optionalDependencies: - '@types/node': 22.10.1 + '@types/node': 24.13.3 fsevents: 2.3.3 lightningcss: 1.32.0 terser: 5.37.0 - vite@8.0.8(@types/node@22.10.1)(esbuild@0.27.7)(jiti@2.4.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1): + vite@8.0.8(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.4.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.6.1): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -19614,7 +19792,7 @@ snapshots: rolldown: 1.0.0-rc.15 tinyglobby: 0.2.16 optionalDependencies: - '@types/node': 22.10.1 + '@types/node': 24.13.3 esbuild: 0.27.7 fsevents: 2.3.3 jiti: 2.4.1 @@ -19622,10 +19800,10 @@ snapshots: tsx: 4.19.2 yaml: 2.6.1 - vitest@2.1.8(@types/node@22.10.1)(happy-dom@15.11.7)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.37.0): + vitest@2.1.8(@types/node@24.13.3)(happy-dom@15.11.7)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.37.0): dependencies: '@vitest/expect': 2.1.8 - '@vitest/mocker': 2.1.8(vite@5.4.11(@types/node@22.10.1)(lightningcss@1.32.0)(terser@5.37.0)) + '@vitest/mocker': 2.1.8(vite@5.4.11(@types/node@24.13.3)(lightningcss@1.32.0)(terser@5.37.0)) '@vitest/pretty-format': 2.1.8 '@vitest/runner': 2.1.8 '@vitest/snapshot': 2.1.8 @@ -19641,11 +19819,11 @@ snapshots: tinyexec: 0.3.1 tinypool: 1.0.2 tinyrainbow: 1.2.0 - vite: 5.4.11(@types/node@22.10.1)(lightningcss@1.32.0)(terser@5.37.0) - vite-node: 2.1.8(@types/node@22.10.1)(lightningcss@1.32.0)(terser@5.37.0) + vite: 5.4.11(@types/node@24.13.3)(lightningcss@1.32.0)(terser@5.37.0) + vite-node: 2.1.8(@types/node@24.13.3)(lightningcss@1.32.0)(terser@5.37.0) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 22.10.1 + '@types/node': 24.13.3 happy-dom: 15.11.7 jsdom: 26.1.0 transitivePeerDependencies: @@ -19798,7 +19976,7 @@ snapshots: dependencies: ansi-escapes: 4.3.2 chalk: 4.1.2 - consola: 3.2.3 + consola: 3.4.2 figures: 3.2.0 markdown-table: 2.0.0 pretty-time: 1.1.0 diff --git a/www/docs/components/_TEMPLATE.md b/www/docs/components/_TEMPLATE.md index 9bf354b6..b4881e30 100644 --- a/www/docs/components/_TEMPLATE.md +++ b/www/docs/components/_TEMPLATE.md @@ -38,8 +38,7 @@ sidebar_label: ComponentName you are demonstrating does. `children` is rendered live (and server-side, so it lands in the static HTML); `code` is the - source shown behind the "Show code" toggle. Keep the two in sync by hand. Pass `theme="dark"` - for dark-only components so the stage renders as an intentional dark canvas. + source shown behind the "Show code" toggle. Keep the two in sync by hand. import { Thing } from '@sipe-team/thing'; diff --git a/www/docs/components/accordion.mdx b/www/docs/components/accordion.mdx index 47ce8fe8..c12530b5 100644 --- a/www/docs/components/accordion.mdx +++ b/www/docs/components/accordion.mdx @@ -21,7 +21,6 @@ import '@sipe-team/accordion/styles.css'; ## Usage @@ -52,7 +51,6 @@ import '@sipe-team/accordion/styles.css'; Independent items — any number open at once, no `value` needed. @@ -93,7 +91,6 @@ Independent items — any number open at once, no `value` needed. Mutually exclusive — each item needs a `value`. @@ -134,7 +131,6 @@ Mutually exclusive — each item needs a `value`. `initialValue` opens an item in single mode; `defaultOpen` does the same per-item in multiple mode. @@ -175,7 +171,6 @@ Mutually exclusive — each item needs a `value`. `Root`, `Trigger`, and `Content` merge their props onto the child instead of rendering a `div` / `button` / `div`. With `asChild` on `Trigger`, pass a genuinely focusable element — the default `type="button"` is not applied.
diff --git a/www/docs/components/badge.mdx b/www/docs/components/badge.mdx new file mode 100644 index 00000000..385e4ab5 --- /dev/null +++ b/www/docs/components/badge.mdx @@ -0,0 +1,89 @@ +--- +sidebar_label: Badge +--- + +import { Badge } from '@sipe-team/badge'; + +# Badge + +A small inline label for statuses, counts, or short pieces of metadata. + +## Installation + +```sh +npm install @sipe-team/badge +``` + +```ts +import '@sipe-team/badge/styles.css'; +``` + +## Usage + +Wrap the text you want to label. The badge sizes itself to its content. + +Sipe`} +> + Sipe + + +## Examples + +### Variant + +`filled` (default) is the solid emphasis look; `outline` keeps only a border for a lighter touch; +`weak` uses a muted neutral background. + +filled +outline +weak`} +> +
+ filled + outline + weak +
+
+ +### Size + +`medium` (default) suits most inline uses; step up to `large` for emphasis, down to `small` for +dense layouts. The text size scales with the badge (12 / 14 / 18 px). + +small +medium +large`} +> +
+ small + medium + large +
+
+ +## Anatomy + +```tsx +import { Badge } from '@sipe-team/badge'; + +export default () => New; +``` + +Renders a `
` wrapping the `children` in a `Typography` ``. + +## API Reference + +Renders a `
` and forwards its `ref`. + +| Prop | Type | Default | Description | +| ---------- | ------------------------------------- | ---------- | ------------------------------------ | +| `children` | `ReactNode` | — | The badge's content, rendered as text. | +| `size` | `'small' \| 'medium' \| 'large'` | `'medium'` | Padding and text size. | +| `variant` | `'filled' \| 'outline' \| 'weak'` | `'filled'` | Visual emphasis. | + +Also accepts every `ComponentProps<'div'>` (`className`, `onClick`, …), forwarded to the rendered +`
`. The `
` carries `role="status"`, so screen readers announce its content as a live +status message. diff --git a/www/docs/components/card.mdx b/www/docs/components/card.mdx new file mode 100644 index 00000000..b17745b5 --- /dev/null +++ b/www/docs/components/card.mdx @@ -0,0 +1,111 @@ +--- +sidebar_label: Card +--- + +import { Card } from '@sipe-team/card'; + +# Card + +A container that groups related content into a surface, with a fixed aspect ratio. + +## Installation + +```sh +npm install @sipe-team/card +``` + +```ts +import '@sipe-team/card/styles.css'; +``` + +## Usage + +Give the card a width; its height follows from the `ratio`. Content is centered inside it. + +Card`} +> + Card + + +## Examples + +### Variant + +`filled` (default) sits on a subtle background with a soft border; `outline` uses the default +background with a stronger border; `ghost` is transparent with no border or padding. + +filled +outline +ghost`} +> +
+ filled + outline + ghost +
+
+ +### Ratio + +The card's height is derived from its width via `aspect-ratio`. `rectangle` (default) is 16:9; +`square` is 1:1; `wide` is 21:9; `portrait` is 3:4; `auto` lets the content size the height. + +16:9 +1:1 +21:9 +3:4 +auto`} +> +
+ 16:9 + 1:1 + 21:9 + 3:4 + auto +
+
+ +### `asChild` + +Drops the wrapping `div` and merges the card's styling onto the element it renders, so the card +becomes a single element — here, an ``. + + + Link card +`} +> + + Link card + + + +## Anatomy + +```tsx +import { Card } from '@sipe-team/card'; + +export default () => ( + + Content + +); +``` + +Renders a single `
` that centers its `children`. + +## API Reference + +Renders a `
` (or the child element when `asChild` is set) and forwards its `ref`. + +| Prop | Type | Default | Description | +| ---------- | ------------------------------------------------------------- | ------------- | -------------------------------------------------------- | +| `variant` | `'filled' \| 'outline' \| 'ghost'` | `'filled'` | Surface style — background, border, and padding. | +| `ratio` | `'rectangle' \| 'square' \| 'wide' \| 'portrait' \| 'auto'` | `'rectangle'` | `aspect-ratio` used to derive height from width. | +| `asChild` | `boolean` | `false` | Merge props onto the child instead of rendering a `div`. | + +Also accepts every `ComponentProps<'div'>` (`className`, `style`, `onClick`, …), forwarded to the +rendered element. The element carries `role="presentation"`. diff --git a/www/docs/components/chip.mdx b/www/docs/components/chip.mdx new file mode 100644 index 00000000..9f7cb0c2 --- /dev/null +++ b/www/docs/components/chip.mdx @@ -0,0 +1,212 @@ +--- +sidebar_label: Chip +--- + +import { useState } from 'react'; +import { Chip } from '@sipe-team/chip'; + +{/* Chip hardcodes light-surface colors (e.g. outline text is `gray700`), so it needs a light backdrop + to be legible on the dark-only docs stage. This wrapper is presentational only and is intentionally + left out of the `code` strings below. */} +export const LightStage = ({ children }) => ( +
+ {children} +
+); + +export const SelectableChips = () => { + const [selected, setSelected] = useState('react'); + const options = ['react', 'vue', 'svelte']; + return ( +
+ {options.map((option) => ( + setSelected(option)}> + {option} + + ))} +
+ ); +}; + +# Chip + +A compact, pill-shaped `
`} +> + + + + + +### Disabled + +The native `disabled` attribute is forwarded to the `
`} +> +
+ Left + + Right +
+ + +### Color + +`default` is a subtle gray; `primary` uses the brand cyan; `dark` is a strong near-black. + +Because `dark` is near-black, it is intended for light surfaces — on a dark background it blends +in. The panel below is kept light so all three colors stay visible regardless of the page theme. + + + +`} +> +
+ + + +
+
+ +## Anatomy + +```tsx +import { Divider } from '@sipe-team/divider'; + +export default () => ; +``` + +Renders a single `
` with `aria-orientation` set to match `orientation`. + +## API Reference + +Renders an `
` and forwards its `ref`. + +| Prop | Type | Default | Description | +| ------------- | ----------------------------- | -------------- | ---------------------------------------------------------------- | +| `orientation` | `'horizontal' \| 'vertical'` | `'horizontal'` | Line direction. `horizontal` is full-width; `vertical` is full-height. | +| `color` | `'default' \| 'primary' \| 'dark'` | `'default'` | Line color. | + +Also accepts every `ComponentProps<'hr'>` (`className`, `style`, …), forwarded to the `
`. +Override `style` for custom thickness, dashes, or color. diff --git a/www/docs/components/flex.mdx b/www/docs/components/flex.mdx new file mode 100644 index 00000000..d6e27b07 --- /dev/null +++ b/www/docs/components/flex.mdx @@ -0,0 +1,273 @@ +--- +sidebar_label: Flex +--- + +import { Flex } from '@sipe-team/flex'; + +{/* Flex's layout demos need visible blocks to show alignment/spacing; this placeholder box is + presentational only and is intentionally left out of the `code` strings below. */} +export const Box = ({ children, style }) => ( +
+ {children} +
+); + +# Flex + +A `
` (or polymorphic element) that lays out its children with CSS flexbox, with per-breakpoint +responsive values for `direction`, `align`, `justify`, `wrap`, and `gap`. + +## Installation + +```sh +npm install @sipe-team/flex +``` + +```ts +import '@sipe-team/flex/styles.css'; +``` + +## Usage + +Flex defaults to `direction="row"` with `nowrap`. Give it a `gap` to space children out. + + + 1 + 2 + 3 +`} +> + + 1 + 2 + 3 + + + +## Examples + +### Direction + +`row` (default), `column`, `row-reverse`, `column-reverse` — maps to `flex-direction`. + +123 +123 +123`} +> +
+ 123 + 123 + 123 +
+
+ +### Align + +`align` maps to `align-items`. Items below have different heights to make the effect visible. + + + A + B + C +`} +> + + A + B + C + + + +### Justify + +`justify` maps to `justify-content`. + + + 1 + 2 + 3 +`} +> + + 1 + 2 + 3 + + + +### Wrap + +`wrap` maps to `flex-wrap`; constrain the container's width to see items wrap onto new lines. + + + 1 + 2 + 3 +`} +> + + 1 + 2 + 3 + + + +### Item sizing — `basis`, `grow`, `shrink` + +`basis`, `grow`, and `shrink` apply to the `Flex` element itself (not to its children) — use them +when a `Flex` is nested as an item inside a parent flex container. + + + fixed 80px + grow 1 + grow 2 +`} +> + + fixed 80px + grow 1 + grow 2 + + + +### Inline + +`inline` switches `display` from `flex` to `inline-flex`, so the container sits inline with +surrounding text instead of taking a full line. + + + Text before + + 1 + 2 + + text after +

`} +> +

+ Text before + + 1 + 2 + + text after +

+
+ +### Responsive values + +`direction`, `align`, `justify`, `wrap`, and `gap` each accept either a single value or an object +keyed by breakpoint — `sm` (the default, no media query), `md` (`min-width: 780px`), `lg` +(`min-width: 1060px`). Each breakpoint falls back to the previous one when omitted, so only the +values that change need to be set. Resize the browser past 780px/1060px to see this example switch +from a stacked column to a row. + + + 1 + 2 + 3 +`} +> + + 1 + 2 + 3 + + + +### `asChild` + +Drops the wrapping `
` and merges the flex layout onto the element you provide. + + + +`} +> + + + + + +## Anatomy + +```tsx +import { Flex } from '@sipe-team/flex'; + +export default () => ( + +
Item
+
Item
+
+); +``` + +Renders a single `
` (or the child element when `asChild` is set) and forwards its `ref`. + +## API Reference + +Renders a `
` (or the child element when `asChild` is set) and forwards its `ref` to that +underlying element. + +| Prop | Type | Default | Description | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------ | ---------- | -------------------------------------------------------------------------- | +| `direction` | `ResponsiveValue<'row' \| 'column' \| 'row-reverse' \| 'column-reverse'>` | `'row'` | `flex-direction`. | +| `align` | `ResponsiveValue<'flex-start' \| 'flex-end' \| 'center' \| 'stretch' \| 'baseline' \| 'normal'>` | `'normal'` | `align-items`. | +| `justify` | `ResponsiveValue<'flex-start' \| 'flex-end' \| 'center' \| 'space-between' \| 'space-around' \| 'space-evenly' \| 'normal'>` | `'normal'` | `justify-content`. | +| `wrap` | `ResponsiveValue<'nowrap' \| 'wrap' \| 'wrap-reverse'>` | `'nowrap'` | `flex-wrap`. | +| `gap` | `ResponsiveValue` | — | Gap between items. Accepts any CSS `gap` value or a number (px). | +| `basis` | `CSSProperties['flexBasis']` | — | `flex-basis` of the `Flex` element itself, as an item in its own parent. | +| `grow` | `CSSProperties['flexGrow']` | — | `flex-grow` of the `Flex` element itself. | +| `shrink` | `CSSProperties['flexShrink']` | — | `flex-shrink` of the `Flex` element itself. | +| `inline` | `boolean` | `false` | Uses `inline-flex` instead of `flex`. | +| `asChild` | `boolean` | `false` | Merge props onto the child instead of rendering a `div`. | + +`ResponsiveValue` is `T | Partial>`, exported from +`@sipe-team/flex` along with `FlexBreakpoint` (`'sm' | 'md' | 'lg'`). + +Also accepts every `ComponentProps<'div'>` (`className`, `style`, `onClick`, …), forwarded to the +rendered element. `style` is merged after the computed layout styles, so it can override them. + +## Known limitations + +- The `md`/`lg` breakpoints (`780px`/`1060px`) are hardcoded locally rather than sourced from + `@sipe-team/tokens` — the token package's layout breakpoints are deprecated, and `Flex` keeps + these values in place until replacement tokens ship. They may change in a future version. +- Only `direction`, `align`, `justify`, `wrap`, and `gap` are responsive. `basis`, `grow`, `shrink`, + and `inline` apply the same value at every breakpoint. diff --git a/www/docs/components/grid.mdx b/www/docs/components/grid.mdx new file mode 100644 index 00000000..df9eede5 --- /dev/null +++ b/www/docs/components/grid.mdx @@ -0,0 +1,258 @@ +--- +sidebar_label: Grid +--- + +import { Grid, GridItem } from '@sipe-team/grid'; + +{/* Grid's layout demos need visible blocks to show placement; this placeholder box is presentational + only and is intentionally left out of the `code` strings below. */} +export const Box = ({ children, style }) => ( +
+ {children} +
+); + +# Grid + +A `
` that lays out its children with CSS grid, plus a `GridItem` for per-item placement +(column/row span or start/end, named areas, and self-alignment). + +## Installation + +```sh +npm install @sipe-team/grid +``` + +```ts +import '@sipe-team/grid/styles.css'; +``` + +## Usage + +Give `Grid` a `templateColumns` and `gap`; children fill the implicit grid in source order. + + + 1 + 2 + 3 +`} +> + + 1 + 2 + 3 + + + +## Examples + +### Named areas + +`templateAreas` names regions of the grid; each `GridItem` claims one with a matching `area`. + + + Header + Sidebar + Main + Footer +`} +> + + Header + Sidebar + Main + Footer + + + +### Spanning columns and rows + +`colSpan`/`rowSpan` are shorthand for `grid-column`/`grid-row: span N`. + + + rowSpan 2 + colSpan 2 + colSpan 1 + colSpan 3 +`} +> + + rowSpan 2 + colSpan 2 + colSpan 1 + colSpan 3 + + + +### Explicit start/end + +`colStart`/`colEnd` and `rowStart`/`rowEnd` build a `start / end` line range (each defaults to +`auto`). They only take effect when `column`/`row` and `colSpan`/`rowSpan` are unset — see +[GridItem](#griditem) for the full priority order. + + + col 2 → 4 +`} +> + + col 2 → 4 + + + +### Item self-alignment + +`justifySelf`/`alignSelf` on a `GridItem` position it within its own cell. + + + start + center + end +`} +> + + start + center + end + + + +### Nested grids + +`Grid` nests freely — an inner `Grid` lays out independently of its parent. + + + Outer + + Inner 1 + Inner 2 + +`} +> + + Outer + + Inner 1 + Inner 2 + + + + +### `asChild` + +Both `Grid` and `GridItem` support `asChild` to drop their wrapping `
` and merge onto the +element you provide. + + +
+ 1 + 2 +
+`} +> + +
+ 1 + 2 +
+
+
+ +## Anatomy + +```tsx +import { Grid, GridItem } from '@sipe-team/grid'; + +export default () => ( + + Item + Item + +); +``` + +`Grid` renders a single `
` (or the child element when `asChild` is set). `GridItem` is not +required — any element works as a grid child — it only adds per-item placement props. +`@sipe-team/grid` also exports `Root` and `Item` as aliases for `Grid` and `GridItem`, for a +``/`` compound-component import style (`import * as Grid from '@sipe-team/grid'`). + +## API Reference + +### `Grid` + +Renders a `
` (or the child element when `asChild` is set) and forwards its `ref`. + +| Prop | Type | Default | Description | +| ----------------- | ---------------------------------------- | ------- | ------------------------------------------------------------ | +| `templateColumns` | `CSSProperties['gridTemplateColumns']` | — | `grid-template-columns`. | +| `templateRows` | `CSSProperties['gridTemplateRows']` | — | `grid-template-rows`. | +| `templateAreas` | `CSSProperties['gridTemplateAreas']` | — | `grid-template-areas`. | +| `gap` | `CSSProperties['gap']` | `'0'` | `gap`. | +| `autoFlow` | `CSSProperties['gridAutoFlow']` | `'row'` | `grid-auto-flow`. | +| `inline` | `boolean` | `false` | Uses `inline-grid` instead of `grid`. | +| `asChild` | `boolean` | `false` | Merge props onto the child instead of rendering a `div`. | + +Also accepts every `ComponentProps<'div'>` (`className`, `style`, `onClick`, …), forwarded to the +rendered element. `Grid` also declares `autoRows`/`autoColumns` props +(`CSSProperties['gridAutoRows']`/`CSSProperties['gridAutoColumns']`), but see +[Known limitations](#known-limitations) — they currently have no visual effect. + +### `GridItem` + +Renders a `
` (or the child element when `asChild` is set) and forwards its `ref`. + +| Prop | Type | Default | Description | +| ------------- | --------------------------------- | ------- | ----------------------------------------------------------------------------------- | +| `column` | `CSSProperties['gridColumn']` | — | `grid-column`. Takes priority over `colSpan` and `colStart`/`colEnd`. | +| `row` | `CSSProperties['gridRow']` | — | `grid-row`. Takes priority over `rowSpan` and `rowStart`/`rowEnd`. | +| `area` | `CSSProperties['gridArea']` | — | `grid-area`, for placing into a named `templateAreas` region. | +| `colSpan` | `number` | — | Shorthand for `grid-column: span N`. Used only when `column` is unset. | +| `rowSpan` | `number` | — | Shorthand for `grid-row: span N`. Used only when `row` is unset. | +| `colStart` | `number \| 'auto'` | — | Start line of `grid-column`. Used only when `column` and `colSpan` are unset. | +| `colEnd` | `number \| 'auto'` | — | End line of `grid-column`. Used only when `column` and `colSpan` are unset. | +| `rowStart` | `number \| 'auto'` | — | Start line of `grid-row`. Used only when `row` and `rowSpan` are unset. | +| `rowEnd` | `number \| 'auto'` | — | End line of `grid-row`. Used only when `row` and `rowSpan` are unset. | +| `justifySelf` | `CSSProperties['justifySelf']` | — | `justify-self`. | +| `alignSelf` | `CSSProperties['alignSelf']` | — | `align-self`. | +| `asChild` | `boolean` | `false` | Merge props onto the child instead of rendering a `div`. | + +Also accepts every `ComponentProps<'div'>` (`className`, `style`, `onClick`, …), forwarded to the +rendered element. + +## Known limitations + +- `Grid`'s `autoRows` and `autoColumns` props set internal CSS custom properties, but the compiled + stylesheet never reads them back into `grid-auto-rows`/`grid-auto-columns` — passing either prop + currently has no visual effect. Use `style={{ gridAutoRows: ... }}` / `style={{ gridAutoColumns: ... }}` + as a workaround. +- `Grid` has no `alignItems`/`justifyItems`/`alignContent`/`justifyContent` props for container-level + alignment, even though the stylesheet has unwired hooks for them internally. Use + `style={{ alignItems: ... }}` etc. on `Grid`, or align individual children with `GridItem`'s + `alignSelf`/`justifySelf`. diff --git a/www/docs/components/icon.mdx b/www/docs/components/icon.mdx new file mode 100644 index 00000000..e1267c03 --- /dev/null +++ b/www/docs/components/icon.mdx @@ -0,0 +1,237 @@ +--- +sidebar_label: Icon +--- + +import { useState } from 'react'; +import { + AccordionArrowIcon, + CheckCircleIcon, + EmailIcon, + GithubIcon, + InstagramIcon, + KakaoIcon, + LinkedinIcon, + LinkIcon, + OrganizerIcon, + UserIcon, + YoutubeIcon, +} from '@sipe-team/icon'; + +export const ALL_ICONS = [ + ['AccordionArrowIcon', AccordionArrowIcon], + ['CheckCircleIcon', CheckCircleIcon], + ['EmailIcon', EmailIcon], + ['GithubIcon', GithubIcon], + ['InstagramIcon', InstagramIcon], + ['KakaoIcon', KakaoIcon], + ['LinkedinIcon', LinkedinIcon], + ['LinkIcon', LinkIcon], + ['OrganizerIcon', OrganizerIcon], + ['UserIcon', UserIcon], + ['YoutubeIcon', YoutubeIcon], +]; + +{/* Demonstrates the `onClick` pass-through without a native `alert()`; presentational only and left + out of the `code` string below. */} +export const ClickableIcon = () => { + const [clicked, setClicked] = useState(false); + return ( +
+ setClicked((value) => !value)} + style={{ cursor: 'pointer' }} + /> + {clicked ? 'Clicked!' : 'Click the icon'} +
+ ); +}; + +# Icon + +A set of pre-built SVG icon components, each a `forwardRef` wrapper around fixed artwork with +configurable `size` and `color`. + +## Installation + +```sh +npm install @sipe-team/icon +``` + +Icon components render inline SVG and ship no separate stylesheet — there is no `styles.css` to +import. + +## Usage + +Every icon is a named export. Render one directly; it defaults to 24×24. + +`} +> + + + +## Examples + +### All icons + + + {Object.entries(icons).map(([name, Icon]) => ( +
+ + {name} +
+ ))} +
`} +> +
+ {ALL_ICONS.map(([name, Icon]) => ( +
+ + {name} +
+ ))} +
+ + +### Size + +`size` sets both `width` and `height` in pixels. Default is `24`. To override `width`/`height` +independently of `size`, pass them directly — they are spread after `size`'s and take priority. + + + + +`} +> +
+ + + + +
+
+ +### Color + +`color` sets the fill of the icon's paths. When omitted, each icon falls back to its own hardcoded +default — see [Known limitations](#known-limitations). + + + +`} +> +
+ + + +
+
+ +### Two-tone icons + +`KakaoIcon` draws its badge background and face glyph as two separate paths with different default +fill colors. Passing `color` overrides every path to the same color, which loses the two-tone look — +compare the default (left) with a custom color (right). + + +`} +> +
+ + +
+
+ +### SVG props + +Every unlisted prop is spread onto the `` element — pass `className`, `onClick`, `aria-label`, +or any other SVG attribute. + + setClicked((value) => !value)} + style={{ cursor: 'pointer' }} +/>`} +> + + + +## Anatomy + +```tsx +import { GithubIcon } from '@sipe-team/icon'; + +export default () => ; +``` + +Each icon renders a single `` with fixed artwork (a set `viewBox` and one or more `` +elements), sized by `size` (`width`/`height`) and colored by `color` (`fill`). Every icon component +shares this same shape and forwards its `ref` to the ``. + +## API Reference + +### `IconProps` + +Shared prop type for every icon component, extending `SVGProps`. + +| Prop | Type | Default | Description | +| ------- | -------- | ------- | ---------------------------------------------------------------------------------- | +| `size` | `number` | `24` | Sets both `width` and `height`, in pixels. | +| `color` | `string` | — | Fill color for the icon's paths. No shared default — see [Known limitations](#known-limitations). | + +Also accepts every `SVGProps` (`className`, `style`, `onClick`, `aria-label`, …), +spread onto the ``. + +### Available icons + +| Component | Source SVG | +| ---------------------- | ------------------------- | +| `AccordionArrowIcon` | `accordion-arrow.svg` | +| `CheckCircleIcon` | `check-circle.svg` | +| `EmailIcon` | `email.svg` | +| `GithubIcon` | `github.svg` | +| `InstagramIcon` | `instagram.svg` | +| `KakaoIcon` | `kakao.svg` | +| `LinkedinIcon` | `linkedin.svg` | +| `LinkIcon` | `link.svg` | +| `OrganizerIcon` | `organizer.svg` | +| `UserIcon` | `user.svg` | +| `YoutubeIcon` | `youtube.svg` | + +New icons are generated from SVG files in the package's `icons/` directory — see the package's +`README.md` (`packages/icon/README.md`) for the naming convention and the generation script. + +## Known limitations + +- `IconProps.color`'s type doc says it "Defaults to currentColor", but no icon actually does this — + each hardcodes its own literal fallback fill when `color` is omitted. Most icons default to + `white`; `CheckCircleIcon` and `OrganizerIcon` default to cyan (`#00ffff`); `KakaoIcon` defaults to + `white` (badge) and `#1A202C` (face); `LinkIcon` defaults to `#CBD5E0`. Icons do not automatically + inherit surrounding text color — pass `color` explicitly if a specific color is needed. +- `KakaoIcon` is the only icon with more than one visible fill color by default. Passing `color` + recolors every path to that single color, so its two-tone look cannot be kept while overriding the + color. diff --git a/www/docs/components/input.mdx b/www/docs/components/input.mdx new file mode 100644 index 00000000..086d2be6 --- /dev/null +++ b/www/docs/components/input.mdx @@ -0,0 +1,241 @@ +--- +sidebar_label: Input +--- + +import { useRef } from 'react'; +import { Input, Action } from '@sipe-team/input'; + +export const ClearIcon = () => ( + +); + +export const ResettableInput = () => { + const ref = useRef(null); + return ( + + ); +}; + +# Input + +A text field styled with the design system's tokens, with an optional trailing action slot. + +## Installation + +```sh +npm install @sipe-team/input +``` + +```ts +import '@sipe-team/input/styles.css'; +``` + +## Usage + +An uncontrolled field with a placeholder. `Input` renders a native `
+ +
+ + +## Examples + +### Font size + +`fontSize` sets the field's text size in px, in fixed steps from `12` to `48`. Default is `16`. + + + + +`} +> +
+ + + + +
+
+ +### Font weight + +`fontWeight` accepts `regular` (default), `medium`, `semiBold`, and `bold`. + + + + +`} +> +
+ + + + +
+
+ +### Type + +`type` is restricted to the text-like input types: `email`, `password`, `search`, `tel`, `text` +(default), and `url`. It is forwarded to the native ``. + + + + +`} +> +
+ + + + +
+
+ +### Disabled + +The native `disabled` attribute is forwarded to the inner ``, and the wrapper takes a muted +background. + +`} +> +
+ +
+
+ +### Action button + +Render an `Action` as a child to add a trailing button inside the field — for example a control that +clears the value. `Action` renders a ` + +
+ Loaded content +
+
+
+ ); +}; + +# Skeleton + +A loading placeholder that swaps between a pulsing/shimmering shape and its `children`, based on a +`loading` flag. + +## Installation + +```sh +npm install @sipe-team/skeleton +``` + +```ts +import '@sipe-team/skeleton/styles.css'; +``` + +## Usage + +`Skeleton` needs an explicit `loading` value plus a `width`/`height` — without them, the placeholder +(`rectangular` by default) has no intrinsic size and renders as an empty box. + +`} +> + + + +## Examples + +### Loading vs. loaded + +While `loading` is `true`, `children` are not rendered — only the placeholder shape is shown. Once +`loading` is `false`, `Skeleton` renders `children` in its place. + + + + +
+ Loaded content +
+
+
`} +> + + + +### Variants + +`rectangular` (default), `circle`, `text`, and `rounded` control the placeholder's border radius — +`circle` also forces a 1:1 aspect ratio, and `text` defaults its height to `1em` when no `height` is +given. + + + + +`} +> +
+ + + + +
+
+ +### Multiple text lines + +`lines` (only meaningful with `variant="text"`) renders that many stacked line placeholders instead +of one; the last line is narrower (75% width) to read like a paragraph's ragged end. + +`} +> + + + +### Pulse and shimmer + +`pulse` (default `true`) fades the placeholder in and out. `shimmer` (default `false`) adds a +lighter sweep across it; the two can be combined. + + + +`} +> +
+ + + +
+
+ +### `asChild` + +Merges the placeholder styling onto the element you provide instead of wrapping it in a `div`, and — +unlike the default mode — renders `children` while `loading` is `true`. Use it to keep a real +element's exact box shaped correctly under the shimmer. + + +
+`} +> + +
+ + + +## Anatomy + +```tsx +import { Skeleton } from '@sipe-team/skeleton'; + +export default () => ( + + + +); +``` + +When `loading` is `true` (and it is not the multi-line `text` case below), renders a single `
` +(or the child element when `asChild` is set). When `loading` is `false`, renders `children` directly +with no wrapper at all. When `variant="text"` and `lines > 1`, always renders a wrapping `
` of +`lines` individual line placeholders, ignoring `asChild`. + +## API Reference + +Forwards its `ref` to the rendered placeholder element — except when `loading` is `false`, where +`children` is returned as-is and there is no ref target. + +| Prop | Type | Default | Description | +| ---------- | ----------------------------------------------------- | --------------- | -------------------------------------------------------------------------------------------------- | +| `loading` | `boolean` | — (required) | Shows the placeholder when `true`; renders `children` as-is when `false`. | +| `children` | `ReactNode` | — | Content to render once `loading` is `false` (and, with `asChild`, while loading too). | +| `variant` | `'rectangular' \| 'circle' \| 'text' \| 'rounded'` | `'rectangular'` | Placeholder shape. | +| `width` | `number \| string` | — | Placeholder width. Numbers are treated as pixels. | +| `height` | `number \| string` | — | Placeholder height. Numbers are treated as pixels. Defaults to `1em` when `variant="text"`. | +| `lines` | `number` | `1` | Number of stacked line placeholders, used when `variant="text"` and `lines > 1`. | +| `pulse` | `boolean` | `true` | Fade in/out animation. | +| `shimmer` | `boolean` | `false` | Adds a lighter sweep animation, layered with `pulse`. | +| `asChild` | `boolean` | `false` | Merge placeholder styling onto the child instead of rendering a `div`. Ignored when `variant="text"` and `lines > 1`. | + +Also accepts every `ComponentProps<'div'>` (`className`, `style`, `onClick`, …), forwarded to the +rendered element. + +## Accessibility + +- The single-placeholder render path (everything except multi-line `text`) sets `aria-busy="true"` + and, unless a custom `aria-label` is passed, a default `aria-label="Loading content"` while + `loading` is `true`. +- The multi-line `text` case (`variant="text"` with `lines > 1`) does **not** set `aria-busy` or a + default `aria-label` on its wrapper — pass an explicit `aria-label` if it needs to be announced. + +## Known limitations + +- Without an explicit `width`/`height` (or, for `text`, at least a `width`), the `rectangular`, + `circle`, and `rounded` variants have no intrinsic size and render as an empty box unless their + parent constrains it. +- When `loading` is `false`, `Skeleton` returns `children` directly — `className`, `style`, + `asChild`, and `ref` are all dropped in that render. `Skeleton` cannot be used as a stable wrapper + element across the loading/loaded transition. +- `asChild` is ignored when `variant="text"` and `lines > 1`; that case always renders a plain + `
` of line placeholders regardless of `asChild`. diff --git a/www/docs/components/switch.mdx b/www/docs/components/switch.mdx new file mode 100644 index 00000000..8afecd67 --- /dev/null +++ b/www/docs/components/switch.mdx @@ -0,0 +1,159 @@ +--- +sidebar_label: Switch +--- + +import { useState } from 'react'; +import { Switch } from '@sipe-team/switch'; + +export const ControlledSwitch = () => { + const [checked, setChecked] = useState(false); + return setChecked(e.target.checked)} label={`Notifications ${checked ? 'on' : 'off'}`} />; +}; + +{/* Switch's `label` text uses a hardcoded near-black color (`color.gray900`), so it needs a light + backdrop to be legible on the dark-only docs stage. This wrapper is presentational only and is + intentionally left out of the `code` strings below. */} +export const LightStage = ({ children }) => ( +
+ {children} +
+); + +# Switch + +A single on/off toggle — a `