sparkle-design 1.0.2 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -39,8 +39,9 @@ This automatically:
39
39
  1. Detects your package manager (pnpm / npm / yarn / bun)
40
40
  2. Adds `sparkle-design` to dependencies and `tailwindcss` + `@tailwindcss/postcss` to devDependencies
41
41
  3. Generates `sparkle.config.json` / `postcss.config.mjs` / Tailwind entry CSS (Next.js: `globals.css`, Vite: `index.css`) if missing
42
- 4. Adds a Sparkle Design guard block and `lint:sparkle` script to `CLAUDE.md`
43
- 5. Generates `sparkle-design.css` and `SparkleHead.tsx`
42
+ 4. Writes the Sparkle Design guard block + `lint:sparkle` script to `CLAUDE.md` (and to `AGENTS.md` too when one already exists)
43
+ 5. Installs an assistant-specific Stop hook into `.claude/settings.json` / `.cursor/hooks.json` / `.codex/hooks.json` (`lint:sparkle --strict || exit 2`) so findings block the agent from finishing the turn
44
+ 6. Generates `sparkle-design.css` and `SparkleHead.tsx` (and for Vite projects, also injects a managed font `<link>` block into `index.html`'s `<head>`)
44
45
 
45
46
  `--assistant` accepts `claude` / `cursor` / `codex` / `generic`. Existing files are never overwritten.
46
47
 
@@ -73,21 +74,31 @@ Customize primary color, fonts, border radius, and more via `sparkle.config.json
73
74
  > @import "./sparkle-design.css";
74
75
  > ```
75
76
  >
76
- > From `sparkle-design-cli` v2.0.6 onward, `generate` / `setup` **auto-scans your `package.json`** (dependencies / devDependencies) and automatically inserts the matching `@source` directive when it detects `sparkle-design`. No manual config is required. If you want to include additional design system packages, add them to `extend.source-packages` in `sparkle.config.json`; the detected packages and your explicit list will be merged.
77
+ > `sparkle-design-cli` **auto-scans your `package.json`** (dependencies / devDependencies) on `generate` / `setup` and automatically inserts the matching `@source` directive when it detects `sparkle-design`. No manual config is required. If you want to include additional design system packages, add them to `extend.source-packages` in `sparkle.config.json`; the detected packages and your explicit list will be merged. When an existing entry CSS lacks `@import "tailwindcss";` (e.g. straight from `create-vite`), the CLI auto-prepends the canonical import before continuing the patch, and the order of `@import` / `@source` is normalized to comply with the CSS spec, so both Vite and Next.js layouts reach a fully working state in a single setup run.
77
78
 
78
79
  #### Installing as an AI Agent Skill (optional)
79
80
 
80
- If you use an AI agent such as Claude Code, Codex, or Cursor, you can also install the Sparkle Design skills with [Vercel's skills CLI](https://github.com/vercel-labs/skills) to have the agent walk you through setup.
81
+ If you use an AI agent such as Claude Code, GitHub Copilot, Cursor, Codex, Gemini CLI, or Antigravity, you can install the Sparkle Design skills through the [official `gh skill` command](https://github.blog/changelog/2026-04-16-manage-agent-skills-with-github-cli/) (requires gh CLI v2.90+), which lets the agent guide you through setup.
81
82
 
82
83
  ```bash
83
- # Install only the setup-sparkle-design skill
84
- npx skills add goodpatch/sparkle-design -s setup-sparkle-design
84
+ # Install only the setup-sparkle-design skill (recommended)
85
+ gh skill install goodpatch/sparkle-design setup-sparkle-design --agent claude-code
85
86
 
86
- # Install all skills (setup / add-component / accessibility-checker)
87
- npx skills add goodpatch/sparkle-design --all
87
+ # Interactively pick multiple skills (setup / add-component / accessibility-checker / change-sparkle-config)
88
+ gh skill install goodpatch/sparkle-design --agent claude-code
89
+
90
+ # Inspect a skill before installing
91
+ gh skill preview goodpatch/sparkle-design setup-sparkle-design
88
92
  ```
89
93
 
90
- After installation, asking the agent to "install Sparkle Design" triggers the `setup-sparkle-design` skill, which inspects the project and guides you through only the missing steps. Use `-a claude-code` / `-a codex` etc. to target a specific agent.
94
+ After installation, asking the agent to "install Sparkle Design" triggers the `setup-sparkle-design` skill, which inspects the project and guides you through only the missing steps. Use `--agent claude-code` / `--agent github-copilot` / `--agent cursor` / `--agent codex` / `--agent gemini` / `--agent antigravity` to target a specific agent (the default for non-interactive runs is `github-copilot`).
95
+
96
+ > **Fallback if gh CLI is not available**: The same skills are also published via [Vercel's skills CLI](https://github.com/vercel-labs/skills):
97
+ >
98
+ > ```bash
99
+ > npx skills add goodpatch/sparkle-design -s setup-sparkle-design
100
+ > npx skills add goodpatch/sparkle-design --all
101
+ > ```
91
102
 
92
103
  ### 2. Use components
93
104
 
@@ -134,7 +145,7 @@ npx sparkle-design-cli check src
134
145
 
135
146
  ### Manual installation (advanced)
136
147
 
137
- If you prefer a step-by-step installation without CLI setup, see the [CLI documentation](https://github.com/goodpatch/sparkle-design-cli#readme).
148
+ If you prefer a step-by-step installation without CLI setup, run `npx --yes sparkle-design-cli --help` / `npx --yes sparkle-design-cli generate --help` / `npx --yes sparkle-design-cli setup --help` to see the usage of each subcommand. See also the [sparkle-design-cli page on npm](https://www.npmjs.com/package/sparkle-design-cli).
138
149
 
139
150
  ## Install individual components
140
151
 
package/README.md CHANGED
@@ -39,8 +39,9 @@ npx --yes sparkle-design-cli setup --assistant claude
39
39
  1. パッケージマネージャー(pnpm / npm / yarn / bun)を自動検出
40
40
  2. `sparkle-design` を dependencies、`tailwindcss` + `@tailwindcss/postcss` を devDependencies に追加
41
41
  3. `sparkle.config.json` / `postcss.config.mjs` / Tailwind エントリ CSS(Next.js: `globals.css`、Vite: `index.css`)を必要に応じて生成
42
- 4. `CLAUDE.md` に Sparkle Design ガードブロックと `lint:sparkle` スクリプトを追加
43
- 5. `sparkle-design.css` と `SparkleHead.tsx` を生成
42
+ 4. `CLAUDE.md` / `AGENTS.md`(既存があれば併用書き込み)に Sparkle Design ガードブロックと `lint:sparkle` スクリプトを追加
43
+ 5. `--assistant` に応じて `.claude/settings.json` / `.cursor/hooks.json` / `.codex/hooks.json` の Stop hook を自動設定(`lint:sparkle --strict || exit 2` で findings があれば応答終了をブロック)
44
+ 6. `sparkle-design.css` と `SparkleHead.tsx` を生成(Vite プロジェクトでは `index.html` の `<head>` 内に font `<link>` の managed block も自動注入)
44
45
 
45
46
  `--assistant` は `claude` / `cursor` / `codex` / `generic` から選択できます。既存ファイルは上書きされません。
46
47
 
@@ -73,21 +74,31 @@ export default function RootLayout({ children }) {
73
74
  > @import "./sparkle-design.css";
74
75
  > ```
75
76
  >
76
- > `sparkle-design-cli` v2.0.6 以降は、`generate` / `setup` 実行時に **`package.json` の dependencies / devDependencies を自動スキャン** し、`sparkle-design` が含まれていれば該当する `@source` を自動挿入します。`sparkle.config.json` 側で何も設定しなくても動きます。独自のデザインシステムパッケージを併用したい場合のみ `extend.source-packages` に追記すると、自動検出分と合算されます。
77
+ > `sparkle-design-cli` は `generate` / `setup` 実行時に **`package.json` の dependencies / devDependencies を自動スキャン** し、`sparkle-design` が含まれていれば該当する `@source` を自動挿入します。`sparkle.config.json` 側で何も設定しなくても動きます。独自のデザインシステムパッケージを併用したい場合のみ `extend.source-packages` に追記すると、自動検出分と合算されます。`@import "tailwindcss";` が無い既存プロジェクト(`create-vite` 直後など)には canonical な `@import` を自動 prepend し、`@import` と `@source` の順序も CSS 仕様に従って整列するため、Vite / Next.js のどちらのレイアウトでもセットアップ 1 回で動く状態に到達できます。
77
78
 
78
79
  #### AI エージェントに Skill として導入する場合(任意)
79
80
 
80
- Claude Code / Codex / Cursor などの AI エージェントを使っている場合は、[Vercel の skills CLI](https://github.com/vercel-labs/skills) 経由で Sparkle Design のスキルセットを導入しておくと、会話から誘導してもらうこともできます。
81
+ Claude Code / GitHub Copilot / Cursor / Codex / Gemini CLI / Antigravity などの AI エージェントを使っている場合は、[GitHub 公式の `gh skill` コマンド](https://github.blog/changelog/2026-04-16-manage-agent-skills-with-github-cli/)(gh CLI v2.90 以上)経由で Sparkle Design のスキルセットを導入しておくと、会話から誘導してもらえます。
81
82
 
82
83
  ```bash
83
- # setup-sparkle-design スキルだけを導入
84
- npx skills add goodpatch/sparkle-design -s setup-sparkle-design
84
+ # setup-sparkle-design スキルだけを導入(推奨)
85
+ gh skill install goodpatch/sparkle-design setup-sparkle-design --agent claude-code
85
86
 
86
- # 全スキル(setup / add-component / accessibility-checker)を導入
87
- npx skills add goodpatch/sparkle-design --all
87
+ # 対話モードで複数スキルをまとめて選択(setup / add-component / accessibility-checker / change-sparkle-config)
88
+ gh skill install goodpatch/sparkle-design --agent claude-code
89
+
90
+ # インストール前に内容を検査したい場合
91
+ gh skill preview goodpatch/sparkle-design setup-sparkle-design
88
92
  ```
89
93
 
90
- 導入後に「Sparkle Design を導入して」と依頼すると `setup-sparkle-design` スキルが発動し、プロジェクト状態に合わせて不足ステップだけ案内してくれます。`-a claude-code` / `-a codex` などで対象エージェントを指定することもできます。
94
+ 導入後に「Sparkle Design を導入して」と依頼すると `setup-sparkle-design` スキルが発動し、プロジェクト状態に合わせて不足ステップだけ案内してくれます。`--agent` には `claude-code` / `github-copilot` / `cursor` / `codex` / `gemini` / `antigravity` などを指定できます(非対話実行のデフォルトは `github-copilot`)。
95
+
96
+ > **gh CLI が手元に無い場合の fallback**: [Vercel の skills CLI](https://github.com/vercel-labs/skills) でも同じスキルを配布しています:
97
+ >
98
+ > ```bash
99
+ > npx skills add goodpatch/sparkle-design -s setup-sparkle-design
100
+ > npx skills add goodpatch/sparkle-design --all
101
+ > ```
91
102
 
92
103
  ### 2. コンポーネントの使用
93
104
 
@@ -134,7 +145,7 @@ npx sparkle-design-cli check src
134
145
 
135
146
  ### 手動インストール(高度な利用)
136
147
 
137
- CLI setup を使わずに段階的に導入したい場合は、[CLI ドキュメント](https://github.com/goodpatch/sparkle-design-cli#readme) を参照してください。
148
+ CLI setup を使わずに段階的に導入したい場合は、`npx --yes sparkle-design-cli --help` / `npx --yes sparkle-design-cli generate --help` / `npx --yes sparkle-design-cli setup --help` で各サブコマンドの使い方を確認できます。npm ページは [sparkle-design-cli on npm](https://www.npmjs.com/package/sparkle-design-cli) を参照してください。
138
149
 
139
150
  ## 個別コンポーネントの導入
140
151
 
@@ -52,6 +52,27 @@ export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElem
52
52
  * en: Callback function for icon button click
53
53
  */
54
54
  onIconButtonClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
55
+ /**
56
+ * トリガーボタンへフォワードする HTML / ARIA 属性。`isTrigger` が true のときのみ有効。
57
+ * `aria-haspopup` / `aria-expanded` / `aria-controls` のように、Popover や Date Picker と
58
+ * 連携する際に必要な属性を宣言的に渡すために使用する。
59
+ *
60
+ * 以下の属性は Input が内部で制御するため、`triggerProps` で渡しても無視される:
61
+ * `ref` / `onFocus` / `onBlur` / `type` / `disabled` / `aria-label`(`triggerAriaLabel` が優先)
62
+ * / `onClick`(`onIconButtonClick` が指定された場合のみ優先。未指定なら `triggerProps.onClick`
63
+ * が使われる)。アイコンボタン自体の見た目(`icon` / `theme` / `variant` / `size`)も同様に
64
+ * Input 側で固定される。
65
+ *
66
+ * en: HTML / ARIA attributes forwarded to the trigger button. Only applied when
67
+ * `isTrigger` is true. Use for attributes like `aria-haspopup`,
68
+ * `aria-expanded`, `aria-controls` when integrating with Popover or Date Picker.
69
+ * The following are controlled by Input and dropped if passed via
70
+ * `triggerProps`: `ref`, `onFocus`, `onBlur`, `type`, `disabled`, `aria-label`
71
+ * (the dedicated `triggerAriaLabel` wins), and the visual props
72
+ * `icon` / `theme` / `variant` / `size`. `onClick` is overridden only when
73
+ * `onIconButtonClick` is provided; otherwise `triggerProps.onClick` is used.
74
+ */
75
+ triggerProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
55
76
  }
56
77
  /**
57
78
  * **概要 / Overview**
@@ -110,6 +131,18 @@ export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElem
110
131
  * isTrigger
111
132
  * triggerIcon="search"
112
133
  * />
134
+ *
135
+ * // Popover / Date Picker 連携時は triggerProps で ARIA を宣言的に渡す
136
+ * <Input
137
+ * isTrigger
138
+ * triggerIcon="calendar_today"
139
+ * triggerAriaLabel="日付を選択"
140
+ * triggerProps={{
141
+ * "aria-haspopup": "dialog",
142
+ * "aria-expanded": open,
143
+ * "aria-controls": "date-picker-popover",
144
+ * }}
145
+ * />
113
146
  * ```
114
147
  *
115
148
  * @param {InputProps} props
@@ -18,6 +18,8 @@ import * as React from "react";
18
18
  import { cva } from "class-variance-authority";
19
19
  import { cn } from "../../../lib/utils.js";
20
20
  import { IconButton } from "../../../components/ui/icon-button/index.js";
21
+ import { useMergeRefs } from "../../../hooks/useMergeRefs.js";
22
+ import { useInputContainerFocus } from "../../../hooks/useInputContainerFocus.js";
21
23
  // 入力フィールドのスタイル定義
22
24
  const inputVariants = cva("flex gap-0 items-center w-full rounded-action border bg-white transition-colors p-1", {
23
25
  variants: {
@@ -72,19 +74,6 @@ const inputVariants = cva("flex gap-0 items-center w-full rounded-action border
72
74
  isFocused: false,
73
75
  },
74
76
  });
75
- // 複数のrefをマージするユーティリティ関数
76
- function useMergeRefs(...refs) {
77
- return React.useCallback((value) => {
78
- refs.forEach(ref => {
79
- if (typeof ref === "function") {
80
- ref(value);
81
- }
82
- else if (ref != null) {
83
- ref.current = value;
84
- }
85
- });
86
- }, [refs]);
87
- }
88
77
  /**
89
78
  * **概要 / Overview**
90
79
  *
@@ -142,12 +131,24 @@ function useMergeRefs(...refs) {
142
131
  * isTrigger
143
132
  * triggerIcon="search"
144
133
  * />
134
+ *
135
+ * // Popover / Date Picker 連携時は triggerProps で ARIA を宣言的に渡す
136
+ * <Input
137
+ * isTrigger
138
+ * triggerIcon="calendar_today"
139
+ * triggerAriaLabel="日付を選択"
140
+ * triggerProps={{
141
+ * "aria-haspopup": "dialog",
142
+ * "aria-expanded": open,
143
+ * "aria-controls": "date-picker-popover",
144
+ * }}
145
+ * />
145
146
  * ```
146
147
  *
147
148
  * @param {InputProps} props
148
149
  */
149
150
  const Input = React.forwardRef((_a, ref) => {
150
- var { className, size, isInvalid = false, isDisabled = false, isTrigger = false, triggerIcon = "edit", triggerAriaLabel, onIconButtonClick, disabled, defaultValue, value, onChange, onBlur, onFocus } = _a, props = __rest(_a, ["className", "size", "isInvalid", "isDisabled", "isTrigger", "triggerIcon", "triggerAriaLabel", "onIconButtonClick", "disabled", "defaultValue", "value", "onChange", "onBlur", "onFocus"]);
151
+ var { className, size, isInvalid = false, isDisabled = false, isTrigger = false, triggerIcon = "edit", triggerAriaLabel, onIconButtonClick, triggerProps, disabled, defaultValue, value, onChange, onBlur, onFocus } = _a, props = __rest(_a, ["className", "size", "isInvalid", "isDisabled", "isTrigger", "triggerIcon", "triggerAriaLabel", "onIconButtonClick", "triggerProps", "disabled", "defaultValue", "value", "onChange", "onBlur", "onFocus"]);
151
152
  // Refs
152
153
  const containerRef = React.useRef(null);
153
154
  const buttonRef = React.useRef(null);
@@ -191,21 +192,13 @@ const Input = React.forwardRef((_a, ref) => {
191
192
  setIsIconButtonFocused(false);
192
193
  }
193
194
  }, []);
194
- // コンテナクリック時にインプットにフォーカスを当てる
195
- const handleContainerClick = React.useCallback((e) => {
196
- if (isInputDisabled)
197
- return;
198
- // アイコンボタン上でのクリックを除外
199
- if (buttonRef.current &&
200
- (buttonRef.current === e.target ||
201
- buttonRef.current.contains(e.target))) {
202
- return;
203
- }
204
- // 入力要素が無効でなければフォーカスを当てる
205
- if (inputRef.current) {
206
- inputRef.current.focus();
207
- }
208
- }, [isInputDisabled]);
195
+ // コンテナクリック時にインプットへフォーカスを移すハンドラ(ボタン上のクリックは除外)
196
+ const excludeRefs = React.useMemo(() => [buttonRef], []);
197
+ const handleContainerClick = useInputContainerFocus({
198
+ targetRef: inputRef,
199
+ isDisabled: isInputDisabled,
200
+ excludeRefs,
201
+ });
209
202
  // 外部クリックでフォーカスを解除するためのハンドラ
210
203
  React.useEffect(() => {
211
204
  // 無効状態の場合はイベントリスナーを追加しない
@@ -249,8 +242,13 @@ const Input = React.forwardRef((_a, ref) => {
249
242
  // aria-disabled={isInputDisabled}
250
243
  // aria-invalid={isInvalid === null ? undefined : isInvalid}
251
244
  onClick: handleContainerClick, role: "presentation", tabIndex: -1, children: [_jsx("input", Object.assign({ ref: mergedInputRef, disabled: isInputDisabled, "aria-invalid": isInvalid || undefined, className: cn("w-full h-full bg-transparent border-none outline-hidden focus:outline-hidden", "text-text-high placeholder:text-text-placeholder px-2", isInputDisabled &&
252
- "cursor-not-allowed text-neutral-400 placeholder:text-text-disabled"), onChange: handleChange, onFocus: handleInputFocus, onBlur: handleInputBlur, defaultValue: defaultValue, value: value, "aria-disabled": isInputDisabled }, props)), isTrigger && (_jsx(IconButton, { ref: buttonRef, icon: triggerIcon, theme: "neutral", variant: "ghost", size: iconButtonSize, onClick: onIconButtonClick, isDisabled: isInputDisabled, disabled: isInputDisabled, type: "button" // フォーム内でデフォルトのsubmit動作を防ぐ
253
- , "aria-label": triggerAriaLabel, onFocus: handleIconButtonFocus, onBlur: handleIconButtonBlur }))] }));
245
+ "cursor-not-allowed text-neutral-400 placeholder:text-text-disabled"), onChange: handleChange, onFocus: handleInputFocus, onBlur: handleInputBlur, defaultValue: defaultValue, value: value, "aria-disabled": isInputDisabled }, props)), isTrigger && (_jsx(IconButton
246
+ // triggerProps を先に展開し、専用 props (triggerAriaLabel / onIconButtonClick)
247
+ // と内部制御プロパティで上書きする
248
+ // en: Spread triggerProps first, then let dedicated props and internal
249
+ // control props override them
250
+ , Object.assign({}, triggerProps, { ref: buttonRef, icon: triggerIcon, theme: "neutral", variant: "ghost", size: iconButtonSize, onClick: onIconButtonClick !== null && onIconButtonClick !== void 0 ? onIconButtonClick : triggerProps === null || triggerProps === void 0 ? void 0 : triggerProps.onClick, isDisabled: isInputDisabled, disabled: isInputDisabled, type: "button" // フォーム内でデフォルトのsubmit動作を防ぐ
251
+ , "aria-label": triggerAriaLabel !== null && triggerAriaLabel !== void 0 ? triggerAriaLabel : triggerProps === null || triggerProps === void 0 ? void 0 : triggerProps["aria-label"], onFocus: handleIconButtonFocus, onBlur: handleIconButtonBlur })))] }));
254
252
  });
255
253
  Input.displayName = "Input";
256
254
  export { Input, inputVariants };
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Copyright 2026 Goodpatch Inc.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ export { useMergeRefs } from "./useMergeRefs";
6
+ export { useInputContainerFocus, type UseInputContainerFocusOptions, } from "./useInputContainerFocus";
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Copyright 2026 Goodpatch Inc.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ export { useMergeRefs } from "./useMergeRefs.js";
6
+ export { useInputContainerFocus, } from "./useInputContainerFocus.js";
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Copyright 2026 Goodpatch Inc.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ import * as React from "react";
6
+ /**
7
+ * `useInputContainerFocus` のオプション
8
+ * en: Options for `useInputContainerFocus`
9
+ */
10
+ export interface UseInputContainerFocusOptions<TTarget extends HTMLElement> {
11
+ /**
12
+ * クリック時にフォーカスさせるターゲット要素の ref
13
+ * en: Ref to the target element that should receive focus on container click
14
+ */
15
+ targetRef: React.RefObject<TTarget | null>;
16
+ /**
17
+ * 無効状態(true のときは何もしない)
18
+ * en: When true, the handler does nothing
19
+ */
20
+ isDisabled?: boolean;
21
+ /**
22
+ * クリックがこの ref の要素内で発生したときはフォーカスを移さない
23
+ * en: When the click originates inside any of these refs, focus is not moved
24
+ */
25
+ excludeRefs?: ReadonlyArray<React.RefObject<HTMLElement | null>>;
26
+ }
27
+ /**
28
+ * コンテナ要素クリック時に、内側のターゲット要素(典型的には `<input>`)へ
29
+ * フォーカスを移すクリックハンドラを返す hook。
30
+ *
31
+ * en: Returns a click handler that forwards focus to a target element (typically
32
+ * the inner `<input>`) when the surrounding container element is clicked.
33
+ * Clicks that originate within `excludeRefs` are ignored so embedded controls
34
+ * (icon buttons, clear buttons, etc.) keep their own click semantics.
35
+ *
36
+ * @example
37
+ * ```tsx
38
+ * const containerRef = React.useRef<HTMLDivElement>(null);
39
+ * const inputRef = React.useRef<HTMLInputElement>(null);
40
+ * const buttonRef = React.useRef<HTMLButtonElement>(null);
41
+ *
42
+ * const handleContainerClick = useInputContainerFocus({
43
+ * targetRef: inputRef,
44
+ * isDisabled,
45
+ * excludeRefs: [buttonRef],
46
+ * });
47
+ *
48
+ * return (
49
+ * <div ref={containerRef} onClick={handleContainerClick}>
50
+ * <input ref={inputRef} />
51
+ * <button ref={buttonRef}>...</button>
52
+ * </div>
53
+ * );
54
+ * ```
55
+ */
56
+ export declare function useInputContainerFocus<TTarget extends HTMLElement>(options: UseInputContainerFocusOptions<TTarget>): (e: React.MouseEvent) => void;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Copyright 2026 Goodpatch Inc.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ import * as React from "react";
6
+ /**
7
+ * コンテナ要素クリック時に、内側のターゲット要素(典型的には `<input>`)へ
8
+ * フォーカスを移すクリックハンドラを返す hook。
9
+ *
10
+ * en: Returns a click handler that forwards focus to a target element (typically
11
+ * the inner `<input>`) when the surrounding container element is clicked.
12
+ * Clicks that originate within `excludeRefs` are ignored so embedded controls
13
+ * (icon buttons, clear buttons, etc.) keep their own click semantics.
14
+ *
15
+ * @example
16
+ * ```tsx
17
+ * const containerRef = React.useRef<HTMLDivElement>(null);
18
+ * const inputRef = React.useRef<HTMLInputElement>(null);
19
+ * const buttonRef = React.useRef<HTMLButtonElement>(null);
20
+ *
21
+ * const handleContainerClick = useInputContainerFocus({
22
+ * targetRef: inputRef,
23
+ * isDisabled,
24
+ * excludeRefs: [buttonRef],
25
+ * });
26
+ *
27
+ * return (
28
+ * <div ref={containerRef} onClick={handleContainerClick}>
29
+ * <input ref={inputRef} />
30
+ * <button ref={buttonRef}>...</button>
31
+ * </div>
32
+ * );
33
+ * ```
34
+ */
35
+ export function useInputContainerFocus(options) {
36
+ const { targetRef, isDisabled = false, excludeRefs } = options;
37
+ return React.useCallback((e) => {
38
+ var _a;
39
+ if (isDisabled)
40
+ return;
41
+ if (excludeRefs) {
42
+ for (const ref of excludeRefs) {
43
+ const node = ref.current;
44
+ if (node && (node === e.target || node.contains(e.target))) {
45
+ return;
46
+ }
47
+ }
48
+ }
49
+ (_a = targetRef.current) === null || _a === void 0 ? void 0 : _a.focus();
50
+ }, [isDisabled, targetRef, excludeRefs]);
51
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Copyright 2026 Goodpatch Inc.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ import * as React from "react";
6
+ /**
7
+ * 複数の ref を 1 つにまとめる ref コールバックを返す hook
8
+ * en: Returns a single ref callback that forwards to multiple refs
9
+ *
10
+ * @example
11
+ * ```tsx
12
+ * const Foo = React.forwardRef<HTMLInputElement, Props>((props, ref) => {
13
+ * const innerRef = React.useRef<HTMLInputElement>(null);
14
+ * const mergedRef = useMergeRefs(innerRef, ref);
15
+ * return <input ref={mergedRef} {...props} />;
16
+ * });
17
+ * ```
18
+ *
19
+ * @param refs マージ対象の ref。`React.Ref<T>` 互換のもの(callback ref / ref object)。
20
+ */
21
+ export declare function useMergeRefs<T>(...refs: Array<React.Ref<T> | undefined>): React.RefCallback<T>;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Copyright 2026 Goodpatch Inc.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ import * as React from "react";
6
+ /**
7
+ * 複数の ref を 1 つにまとめる ref コールバックを返す hook
8
+ * en: Returns a single ref callback that forwards to multiple refs
9
+ *
10
+ * @example
11
+ * ```tsx
12
+ * const Foo = React.forwardRef<HTMLInputElement, Props>((props, ref) => {
13
+ * const innerRef = React.useRef<HTMLInputElement>(null);
14
+ * const mergedRef = useMergeRefs(innerRef, ref);
15
+ * return <input ref={mergedRef} {...props} />;
16
+ * });
17
+ * ```
18
+ *
19
+ * @param refs マージ対象の ref。`React.Ref<T>` 互換のもの(callback ref / ref object)。
20
+ */
21
+ export function useMergeRefs(...refs) {
22
+ return React.useCallback((value) => {
23
+ refs.forEach(ref => {
24
+ if (typeof ref === "function") {
25
+ ref(value);
26
+ }
27
+ else if (ref != null) {
28
+ ref.current = value;
29
+ }
30
+ });
31
+ },
32
+ // eslint-disable-next-line react-hooks/exhaustive-deps
33
+ refs);
34
+ }
package/dist/index.d.ts CHANGED
@@ -25,3 +25,4 @@ export * from "./components/ui/tag";
25
25
  export * from "./components/ui/textarea";
26
26
  export * from "./components/ui/toast";
27
27
  export * from "./components/ui/tooltip";
28
+ export * from "./hooks";
package/dist/index.js CHANGED
@@ -25,3 +25,4 @@ export * from "./components/ui/tag/index.js";
25
25
  export * from "./components/ui/textarea/index.js";
26
26
  export * from "./components/ui/toast/index.js";
27
27
  export * from "./components/ui/tooltip/index.js";
28
+ export * from "./hooks/index.js";
@@ -15,9 +15,10 @@ export class TestContainer {
15
15
  this.root = ReactDOM.createRoot(this.container);
16
16
  }
17
17
  cleanup() {
18
- if (this.root) {
18
+ const root = this.root;
19
+ if (root) {
19
20
  act(() => {
20
- this.root.unmount();
21
+ root.unmount();
21
22
  });
22
23
  this.root = null;
23
24
  }
@@ -27,11 +28,12 @@ export class TestContainer {
27
28
  }
28
29
  }
29
30
  render(element) {
30
- if (!this.root) {
31
+ const root = this.root;
32
+ if (!root) {
31
33
  throw new Error("Container not set up. Call setup() first.");
32
34
  }
33
35
  act(() => {
34
- this.root.render(element);
36
+ root.render(element);
35
37
  });
36
38
  }
37
39
  getContainer() {
@@ -156,6 +158,9 @@ export const AsyncHelpers = {
156
158
  waitFor(callback, timeout = 1000, interval = 50) {
157
159
  return new Promise((resolve, reject) => {
158
160
  const startTime = Date.now();
161
+ // タイムアウト時に最後に起きたエラーを含めて reject するため保持する
162
+ // en: Keep the last thrown error so we can surface it on timeout.
163
+ let lastError;
159
164
  const check = () => {
160
165
  try {
161
166
  const result = callback();
@@ -165,10 +170,13 @@ export const AsyncHelpers = {
165
170
  }
166
171
  }
167
172
  catch (error) {
168
- // Continue checking
173
+ lastError = error;
169
174
  }
170
175
  if (Date.now() - startTime >= timeout) {
171
- reject(new Error(`Timeout after ${timeout}ms`));
176
+ const message = lastError
177
+ ? `Timeout after ${timeout}ms (last error: ${lastError instanceof Error ? lastError.message : String(lastError)})`
178
+ : `Timeout after ${timeout}ms`;
179
+ reject(new Error(message));
172
180
  return;
173
181
  }
174
182
  setTimeout(check, interval);
@@ -33,6 +33,6 @@ Object.defineProperty(window, "matchMedia", {
33
33
  removeListener: () => { },
34
34
  addEventListener: () => { },
35
35
  removeEventListener: () => { },
36
- dispatchEvent: () => { },
36
+ dispatchEvent: () => true,
37
37
  }),
38
38
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sparkle-design",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org",
6
6
  "access": "public"
@@ -91,7 +91,7 @@
91
91
  "eslint-plugin-import": "^2.32.0",
92
92
  "eslint-plugin-jsx-a11y": "^6.10.2",
93
93
  "eslint-plugin-storybook": "^10.3.5",
94
- "next": "15.5.15",
94
+ "next": "15.5.18",
95
95
  "playwright": "^1.51.1",
96
96
  "prettier": "^3.6.2",
97
97
  "storybook": "^10.3.5",
@@ -125,7 +125,7 @@
125
125
  "js-yaml@>=4.0.0 <4.1.1": "^4.1.1",
126
126
  "playwright": "^1.59.1",
127
127
  "form-data": "^4.0.4",
128
- "hono": "^4.12.14",
128
+ "hono": "^4.12.18",
129
129
  "yaml@>=2.0.0 <2.8.3": "^2.8.3",
130
130
  "@eslint/plugin-kit": "^0.3.4",
131
131
  "brace-expansion@<1.1.13": "^1.1.13"
@@ -273,6 +273,11 @@
273
273
  "types": "./dist/components/ui/tooltip/index.d.ts",
274
274
  "import": "./dist/components/ui/tooltip/index.js",
275
275
  "require": "./dist/components/ui/tooltip/index.js"
276
+ },
277
+ "./hooks": {
278
+ "types": "./dist/hooks/index.d.ts",
279
+ "import": "./dist/hooks/index.js",
280
+ "require": "./dist/hooks/index.js"
276
281
  }
277
282
  },
278
283
  "files": [