sparkle-design 1.0.1 → 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 +21 -10
- package/README.md +21 -10
- package/dist/components/ui/input/index.d.ts +33 -0
- package/dist/components/ui/input/index.js +29 -31
- package/dist/hooks/index.d.ts +6 -0
- package/dist/hooks/index.js +6 -0
- package/dist/hooks/useInputContainerFocus.d.ts +56 -0
- package/dist/hooks/useInputContainerFocus.js +51 -0
- package/dist/hooks/useMergeRefs.d.ts +21 -0
- package/dist/hooks/useMergeRefs.js +34 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/test/helpers.js +14 -6
- package/dist/test/setup.js +1 -1
- package/package.json +8 -3
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.
|
|
43
|
-
5.
|
|
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
|
-
>
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
87
|
-
|
|
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
|
|
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 [
|
|
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`
|
|
43
|
-
5. `
|
|
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`
|
|
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 /
|
|
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
|
-
|
|
84
|
+
# setup-sparkle-design スキルだけを導入(推奨)
|
|
85
|
+
gh skill install goodpatch/sparkle-design setup-sparkle-design --agent claude-code
|
|
85
86
|
|
|
86
|
-
#
|
|
87
|
-
|
|
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`
|
|
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
|
|
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
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
|
253
|
-
|
|
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,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
package/dist/index.js
CHANGED
package/dist/test/helpers.js
CHANGED
|
@@ -15,9 +15,10 @@ export class TestContainer {
|
|
|
15
15
|
this.root = ReactDOM.createRoot(this.container);
|
|
16
16
|
}
|
|
17
17
|
cleanup() {
|
|
18
|
-
|
|
18
|
+
const root = this.root;
|
|
19
|
+
if (root) {
|
|
19
20
|
act(() => {
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
173
|
+
lastError = error;
|
|
169
174
|
}
|
|
170
175
|
if (Date.now() - startTime >= timeout) {
|
|
171
|
-
|
|
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);
|
package/dist/test/setup.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sparkle-design",
|
|
3
|
-
"version": "1.0.
|
|
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.
|
|
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.
|
|
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": [
|