@ecatchup/basercms-ui 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Catchup Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,129 @@
1
+ # @ecatchup/basercms-ui
2
+
3
+ 検索付きの選択UIコンポーネント集。React 19 以降で動作し、react / react-dom 以外の依存を持ちません。
4
+
5
+ ## インストール
6
+
7
+ ```bash
8
+ npm install @ecatchup/basercms-ui
9
+ ```
10
+
11
+ ## 使い方
12
+
13
+ ```tsx
14
+ import { SearchSelect, MultiSelectField } from '@ecatchup/basercms-ui';
15
+ import '@ecatchup/basercms-ui/style.css';
16
+ import type { SelectOption } from '@ecatchup/basercms-ui';
17
+
18
+ const options: SelectOption[] = [
19
+ { id: '1', label: '管理者', sublabel: 'managers' },
20
+ { id: '2', label: '利用者', sublabel: 'employee' },
21
+ ];
22
+
23
+ // 単一選択
24
+ <SearchSelect options={options} value={value} onChange={setValue} emptyLabel="指定なし" />
25
+
26
+ // 複数選択(タグ形式)
27
+ <MultiSelectField options={options} value={selected} onChange={setSelected} />
28
+ ```
29
+
30
+ ## コンポーネント
31
+
32
+ | 名前 | 用途 |
33
+ | --- | --- |
34
+ | `SearchSelect` | 検索して1つ選ぶ |
35
+ | `MultiSelectField` | 追加ボタン+選択済みタグ+モーダルを一体にしたフォーム部品 |
36
+ | `MultiSelectDialog` | 複数選択のモーダル単体 |
37
+ | `MultiSelectPicker` | 検索+一覧+選択済みタグの中身のみ(ページ直置き用) |
38
+
39
+ `SearchSelect` は `searchPlaceholder`(検索欄の placeholder、既定「検索...」)を受け取れます。
40
+
41
+ ## 選択肢の型
42
+
43
+ ```ts
44
+ type SelectOption = {
45
+ id: string;
46
+ label: string; // 表示名
47
+ sublabel?: string; // 一覧行にのみ括弧付きで表示。検索対象に含まれる
48
+ disabled?: boolean;
49
+ };
50
+ ```
51
+
52
+ `disabled: true` の選択肢は、キーボード操作・クリックのどちらでも選択できません。該当行には `data-disabled` / `aria-disabled` が付与されます。
53
+
54
+ データ取得は行いません。`options` は呼び出し側が用意してください。
55
+
56
+ ## 複数選択の上限
57
+
58
+ `MultiSelectPicker` / `MultiSelectDialog` / `MultiSelectField` は `maxSelected` を受け取れます。`MultiSelectField` ではこれを**累計の選択件数の上限**として扱い、上限に達すると追加ボタンが無効になります。一方、`MultiSelectPicker` / `MultiSelectDialog` を単体で使う場合、`maxSelected` はその部品自身の選択状態(`value` / `initialValue`)に対する上限であり、それ以上の追加ができなくなります。
59
+
60
+ ## モーダルの挙動(MultiSelectDialog / MultiSelectField)
61
+
62
+ モーダルは Escape キー・閉じるボタン(×)・キャンセルボタンで閉じます。誤操作防止のため、オーバーレイ(背景)のクリックでは閉じません。開いている間はフォーカスがモーダル内に閉じ込められます(Tab / Shift+Tab で外へ出ません)。
63
+
64
+ ## ボタンのクラス・属性(既定で baserCMS のボタンスタイルが当たります)
65
+
66
+ `MultiSelectField` の `addButtonProps`、`MultiSelectDialog` の `submitButtonProps` /
67
+ `cancelButtonProps` は、既定で以下の値になっています(baserCMS の `.bca-btn` を
68
+ 前提にしています)。
69
+
70
+ | prop | 既定値 |
71
+ | --- | --- |
72
+ | `addButtonProps`(MultiSelectField) | `{ className: 'bca-btn', 'data-bca-btn-type': 'add' }` |
73
+ | `submitButtonProps`(MultiSelectDialog) | `{ className: 'bca-btn', 'data-bca-btn-type': 'save' }` |
74
+ | `cancelButtonProps`(MultiSelectDialog) | `{ className: 'bca-btn' }` |
75
+
76
+ **既定値は「置き換え」です。マージではありません。** 何か1つでも渡すと、既定値は使われず渡した内容がそのまま使われます。既定のクラス・属性が不要な場合は空オブジェクト(`{}`)を渡してください。ただしこれで置き換わるのは追加のクラス・属性だけで、部品自身の BEM クラス(`bca-multi-select-field__add` 等)は常に付くため、`{}` を渡しても部品同梱の CSS は効き続けます(baserCMS のボタンスタイルだけ外したい場合に使えます)。`MultiSelectField` に渡した `submitButtonProps` / `cancelButtonProps` はモーダルへそのまま転送されます。`type` / `onClick` / `disabled` は部品側が制御するため渡せません。
77
+
78
+ ```tsx
79
+ // baserCMS 以外の見た目にしたい場合
80
+ <MultiSelectField
81
+ addButtonProps={{ className: 'my-add-button' }}
82
+ submitButtonProps={{ className: 'my-submit-button' }}
83
+ cancelButtonProps={{ className: 'my-cancel-button' }}
84
+ ...
85
+ />
86
+
87
+ // 何も付けたくない場合
88
+ <MultiSelectField
89
+ addButtonProps={{}}
90
+ submitButtonProps={{}}
91
+ cancelButtonProps={{}}
92
+ ...
93
+ />
94
+ ```
95
+
96
+ ## フォーム連携
97
+
98
+ `name` を渡すと hidden input を描画します。
99
+
100
+ ```tsx
101
+ <SearchSelect name="data[User][approver_id]" ... /> // hidden 1つ
102
+ <MultiSelectField name="data[User][group_ids][]" ... /> // 選択件数分の hidden
103
+ ```
104
+
105
+ ## テーマ
106
+
107
+ CSS 変数を上書きしてください。
108
+
109
+ | 変数 | 既定値 |
110
+ | --- | --- |
111
+ | `--bca-accent` | `#D4EDC9` |
112
+ | `--bca-accent-hover` | `#E9F7E3` |
113
+ | `--bca-border` | `#ccc` |
114
+ | `--bca-radius` | `4px` |
115
+ | `--bca-font-size` | `14px` |
116
+ | `--bca-z-index` | `9999` |
117
+
118
+ ## 開発
119
+
120
+ ```bash
121
+ npm install
122
+ npm run dev # 動作確認用デモ
123
+ npm test # テスト
124
+ npm run build # dist を生成
125
+ ```
126
+
127
+ ## ライセンス
128
+
129
+ MIT
@@ -0,0 +1,106 @@
1
+ import { ButtonHTMLAttributes } from 'react';
2
+ import { JSX } from 'react';
3
+
4
+ /**
5
+ * 部品内のボタン(追加ボタン、モーダルの決定/キャンセルボタン)へ、利用側から
6
+ * 任意のクラス・data 属性等を渡すための型。
7
+ *
8
+ * `type` / `onClick` / `disabled` は部品側が制御する(type="button" の強制、開閉の
9
+ * ハンドラ、無効化判定)ため除外している。baserCMS 等、既存のデザインシステムの
10
+ * ボタンスタイル(クラス+ data 属性)を当てたい場合に使う。
11
+ */
12
+ export declare type ButtonPassthroughProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'type' | 'onClick' | 'disabled'> & {
13
+ [key: `data-${string}`]: string | number | boolean | undefined;
14
+ };
15
+
16
+ export declare const MultiSelectDialog: ({ open, title, options, initialValue, onSubmit, onCancel, submitLabel, cancelLabel, requireSelection, submitButtonProps, cancelButtonProps, className, ...pickerProps }: MultiSelectDialogProps) => JSX.Element | null;
17
+
18
+ export declare type MultiSelectDialogProps = Pick<MultiSelectPickerProps, 'options' | 'searchPlaceholder' | 'noResultsText' | 'listHeight' | 'maxSelected' | 'className'> & {
19
+ open: boolean;
20
+ title?: string;
21
+ initialValue?: SelectOption[];
22
+ onSubmit: (selected: SelectOption[]) => void;
23
+ onCancel: () => void;
24
+ submitLabel?: string;
25
+ cancelLabel?: string;
26
+ /** true のとき、未選択なら決定ボタンを無効にする */
27
+ requireSelection?: boolean;
28
+ /** 決定ボタンへ渡す任意のクラス・属性(baserCMS 等のボタンスタイル用) */
29
+ submitButtonProps?: ButtonPassthroughProps;
30
+ /** キャンセルボタンへ渡す任意のクラス・属性(baserCMS 等のボタンスタイル用) */
31
+ cancelButtonProps?: ButtonPassthroughProps;
32
+ };
33
+
34
+ /**
35
+ * 「追加ボタン → モーダルで選択 → 決定 → タグとして並ぶ → × で削除」
36
+ * をひとまとめにしたフォーム部品
37
+ */
38
+ export declare const MultiSelectField: ({ options, value, onChange, name, addButtonLabel, dialogTitle, emptyText, disabled, className, maxSelected, submitLabel, cancelLabel, requireSelection, addButtonProps, submitButtonProps, cancelButtonProps, ...pickerProps }: MultiSelectFieldProps) => JSX.Element;
39
+
40
+ export declare type MultiSelectFieldProps = Pick<MultiSelectPickerProps, 'options' | 'searchPlaceholder' | 'noResultsText' | 'listHeight' | 'maxSelected' | 'className'> & {
41
+ value: SelectOption[];
42
+ onChange: (selected: SelectOption[]) => void;
43
+ /** 指定時、選択件数分の hidden input を描画する */
44
+ name?: string;
45
+ addButtonLabel?: string;
46
+ dialogTitle?: string;
47
+ emptyText?: string;
48
+ disabled?: boolean;
49
+ submitLabel?: string;
50
+ cancelLabel?: string;
51
+ requireSelection?: boolean;
52
+ /** 追加ボタンへ渡す任意のクラス・属性(baserCMS 等のボタンスタイル用) */
53
+ addButtonProps?: ButtonPassthroughProps;
54
+ /** モーダルの決定ボタンへそのまま転送する(baserCMS 等のボタンスタイル用) */
55
+ submitButtonProps?: ButtonPassthroughProps;
56
+ /** モーダルのキャンセルボタンへそのまま転送する(baserCMS 等のボタンスタイル用) */
57
+ cancelButtonProps?: ButtonPassthroughProps;
58
+ };
59
+
60
+ export declare const MultiSelectPicker: ({ options, value, onChange, searchPlaceholder, noResultsText, listHeight, maxSelected, className, }: MultiSelectPickerProps) => JSX.Element;
61
+
62
+ export declare type MultiSelectPickerProps = {
63
+ options: SelectOption[];
64
+ value: SelectOption[];
65
+ onChange: (selected: SelectOption[]) => void;
66
+ searchPlaceholder?: string;
67
+ noResultsText?: string;
68
+ listHeight?: number | string;
69
+ maxSelected?: number;
70
+ className?: string;
71
+ };
72
+
73
+ export declare const SearchSelect: ({ options, value, onChange, name, placeholder, emptyLabel, clearable, disabled, noResultsText, searchPlaceholder, dropdownPlacement, className, }: SearchSelectProps) => JSX.Element;
74
+
75
+ export declare type SearchSelectProps = {
76
+ options: SelectOption[];
77
+ value: string | null;
78
+ onChange: (id: string | null) => void;
79
+ /** 指定時、hidden input を描画する */
80
+ name?: string;
81
+ placeholder?: string;
82
+ /** 指定時、「指定なし」相当の選択肢を先頭に出す */
83
+ emptyLabel?: string;
84
+ clearable?: boolean;
85
+ disabled?: boolean;
86
+ noResultsText?: string;
87
+ /** 検索欄の placeholder */
88
+ searchPlaceholder?: string;
89
+ dropdownPlacement?: 'auto' | 'top' | 'bottom';
90
+ className?: string;
91
+ };
92
+
93
+ /**
94
+ * 選択肢の共通型
95
+ *
96
+ * sublabel は一覧行にのみ括弧付きで表示し、検索対象に含める。
97
+ * 選択済みタグには表示しない。
98
+ */
99
+ export declare type SelectOption = {
100
+ id: string;
101
+ label: string;
102
+ sublabel?: string;
103
+ disabled?: boolean;
104
+ };
105
+
106
+ export { }
package/dist/index.js ADDED
@@ -0,0 +1,365 @@
1
+ import { jsxs as g, jsx as c } from "react/jsx-runtime";
2
+ import { useMemo as P, useEffect as C, useState as D, useLayoutEffect as U, useRef as $, useId as j } from "react";
3
+ const q = (t, e) => {
4
+ const s = e.trim().toLowerCase();
5
+ return s ? t.filter(
6
+ (i) => i.label.toLowerCase().includes(s) || (i.sublabel ?? "").toLowerCase().includes(s)
7
+ ) : t;
8
+ }, F = (t, e) => P(() => q(t, e), [t, e]), Q = (t, e, s) => {
9
+ C(() => {
10
+ if (!e) return;
11
+ const i = (n) => {
12
+ t.current && !t.current.contains(n.target) && s();
13
+ };
14
+ return document.addEventListener("mousedown", i), () => document.removeEventListener("mousedown", i);
15
+ }, [t, e, s]);
16
+ }, H = (t, e, s, i = 240) => {
17
+ const [n, r] = D(s === "top" ? "top" : "bottom");
18
+ return U(() => {
19
+ if (!e) return;
20
+ if (s !== "auto") {
21
+ r(s);
22
+ return;
23
+ }
24
+ const m = t.current;
25
+ if (!m) return;
26
+ const o = m.getBoundingClientRect(), f = window.innerHeight - o.bottom;
27
+ r(f < i && o.top > f ? "top" : "bottom");
28
+ }, [t, e, s, i]), n;
29
+ }, R = "", V = ({
30
+ options: t,
31
+ value: e,
32
+ onChange: s,
33
+ name: i,
34
+ placeholder: n = "選択してください",
35
+ emptyLabel: r,
36
+ clearable: m = !0,
37
+ disabled: o = !1,
38
+ noResultsText: f = "一致する項目がありません",
39
+ searchPlaceholder: p = "検索...",
40
+ dropdownPlacement: k = "auto",
41
+ className: w = ""
42
+ }) => {
43
+ const [d, b] = D(!1), [v, l] = D(""), [u, h] = D(0), y = $(null), x = $(null), S = $(null), M = j(), I = (a) => `${M}-option-${a || "__empty__"}`, L = F(t, v), E = r ? [{ id: R, label: r }, ...L] : L, _ = t.find((a) => a.id === e) ?? null;
44
+ Q(y, d, () => b(!1));
45
+ const O = H(y, d, k);
46
+ C(() => {
47
+ d || (l(""), h(0));
48
+ }, [d]), C(() => {
49
+ d && x.current?.focus();
50
+ }, [d]);
51
+ const A = (a) => {
52
+ s(a === R ? null : a), b(!1), S.current?.focus();
53
+ }, T = (a) => {
54
+ if (!o && !a.nativeEvent.isComposing && !(a.key !== "Escape" && a.target.closest(".bca-search-select__clear"))) {
55
+ if (a.key === "Escape") {
56
+ b(!1), S.current?.focus();
57
+ return;
58
+ }
59
+ if (!d) {
60
+ (a.key === "ArrowDown" || a.key === "Enter") && (a.preventDefault(), b(!0));
61
+ return;
62
+ }
63
+ if (a.key === "ArrowDown")
64
+ a.preventDefault(), h((N) => Math.min(N + 1, E.length - 1));
65
+ else if (a.key === "ArrowUp")
66
+ a.preventDefault(), h((N) => Math.max(N - 1, 0));
67
+ else if (a.key === "Enter") {
68
+ a.preventDefault();
69
+ const N = E[u];
70
+ N && !N.disabled && A(N.id);
71
+ }
72
+ }
73
+ };
74
+ return /* @__PURE__ */ g("div", { ref: y, className: `bca-search-select ${w}`.trim(), onKeyDown: T, children: [
75
+ i && /* @__PURE__ */ c("input", { type: "hidden", name: i, value: e ?? "" }),
76
+ /* @__PURE__ */ g(
77
+ "div",
78
+ {
79
+ ref: S,
80
+ role: "combobox",
81
+ "aria-expanded": d,
82
+ "aria-haspopup": "listbox",
83
+ "aria-disabled": o,
84
+ "aria-controls": d ? M : void 0,
85
+ "aria-activedescendant": d && E[u] ? I(E[u].id) : void 0,
86
+ tabIndex: o ? -1 : 0,
87
+ className: "bca-search-select__trigger",
88
+ "data-disabled": o || void 0,
89
+ onClick: () => !o && b(!d),
90
+ children: [
91
+ /* @__PURE__ */ c("span", { className: _ ? "bca-search-select__value" : "bca-search-select__placeholder", children: _ ? _.label : n }),
92
+ !o && m && e ? /* @__PURE__ */ c(
93
+ "button",
94
+ {
95
+ type: "button",
96
+ className: "bca-search-select__clear",
97
+ "aria-label": "選択を解除",
98
+ onClick: (a) => {
99
+ a.stopPropagation(), s(null);
100
+ },
101
+ children: "×"
102
+ }
103
+ ) : /* @__PURE__ */ c("span", { className: "bca-search-select__arrow", "aria-hidden": "true", children: "▼" })
104
+ ]
105
+ }
106
+ ),
107
+ d && /* @__PURE__ */ g("div", { className: "bca-search-select__dropdown", "data-placement": O, children: [
108
+ /* @__PURE__ */ c("div", { className: "bca-search-select__search", children: /* @__PURE__ */ c(
109
+ "input",
110
+ {
111
+ ref: x,
112
+ type: "text",
113
+ className: "bca-search-select__search-input",
114
+ placeholder: p,
115
+ value: v,
116
+ onChange: (a) => {
117
+ l(a.target.value), h(0);
118
+ },
119
+ onClick: (a) => a.stopPropagation()
120
+ }
121
+ ) }),
122
+ /* @__PURE__ */ c("ul", { role: "listbox", id: M, className: "bca-search-select__list", children: E.length > 0 ? E.map((a, N) => /* @__PURE__ */ g(
123
+ "li",
124
+ {
125
+ id: I(a.id),
126
+ role: "option",
127
+ "aria-selected": a.id === (e ?? R),
128
+ "aria-disabled": a.disabled || void 0,
129
+ className: "bca-search-select__option",
130
+ "data-active": N === u || void 0,
131
+ "data-selected": a.id === (e ?? R) || void 0,
132
+ "data-disabled": a.disabled || void 0,
133
+ onMouseEnter: () => !a.disabled && h(N),
134
+ onClick: () => !a.disabled && A(a.id),
135
+ children: [
136
+ /* @__PURE__ */ c("span", { className: "bca-search-select__label", children: a.label }),
137
+ a.sublabel && /* @__PURE__ */ c("span", { className: "bca-search-select__sublabel", children: `(${a.sublabel})` })
138
+ ]
139
+ },
140
+ a.id || "__empty__"
141
+ )) : /* @__PURE__ */ c("li", { className: "bca-search-select__no-results", children: f }) })
142
+ ] })
143
+ ] });
144
+ }, K = ({ items: t, onRemove: e, emptyText: s = "選択されていません", disabled: i = !1 }) => t.length === 0 ? /* @__PURE__ */ c("span", { className: "bca-selected-tags__empty", children: s }) : /* @__PURE__ */ c("ul", { className: "bca-selected-tags", children: t.map((n) => /* @__PURE__ */ g("li", { className: "bca-selected-tags__item", children: [
145
+ /* @__PURE__ */ c("span", { className: "bca-selected-tags__label", children: n.label }),
146
+ /* @__PURE__ */ c(
147
+ "button",
148
+ {
149
+ type: "button",
150
+ className: "bca-selected-tags__remove",
151
+ "aria-label": `${n.label} を削除`,
152
+ disabled: i,
153
+ onClick: () => e(n.id),
154
+ children: "×"
155
+ }
156
+ )
157
+ ] }, n.id)) }), Y = ({
158
+ options: t,
159
+ value: e,
160
+ onChange: s,
161
+ searchPlaceholder: i = "検索...",
162
+ noResultsText: n = "選択可能な項目はありません",
163
+ listHeight: r = 350,
164
+ maxSelected: m,
165
+ className: o = ""
166
+ }) => {
167
+ const [f, p] = D(""), k = new Set(e.map((l) => l.id)), w = F(t, f).filter((l) => !k.has(l.id)), d = m !== void 0 && e.length >= m, b = (l) => {
168
+ d || l.disabled || s([...e, l]);
169
+ }, v = (l) => {
170
+ s(e.filter((u) => u.id !== l));
171
+ };
172
+ return /* @__PURE__ */ g("div", { className: `bca-multi-select-picker ${o}`.trim(), children: [
173
+ /* @__PURE__ */ g("div", { className: "bca-multi-select-picker__search", children: [
174
+ /* @__PURE__ */ c(
175
+ "input",
176
+ {
177
+ type: "text",
178
+ className: "bca-multi-select-picker__search-input",
179
+ placeholder: i,
180
+ value: f,
181
+ onChange: (l) => p(l.target.value)
182
+ }
183
+ ),
184
+ f && /* @__PURE__ */ c(
185
+ "button",
186
+ {
187
+ type: "button",
188
+ className: "bca-multi-select-picker__search-clear",
189
+ "aria-label": "検索語をクリア",
190
+ onClick: () => p(""),
191
+ children: "×"
192
+ }
193
+ )
194
+ ] }),
195
+ /* @__PURE__ */ c("div", { className: "bca-multi-select-picker__list-wrapper", style: { maxHeight: r }, children: /* @__PURE__ */ c("ul", { className: "bca-multi-select-picker__list", role: "listbox", "aria-multiselectable": "true", "aria-label": "選択可能な項目", children: w.length > 0 ? w.map((l) => {
196
+ const u = l.disabled || d;
197
+ return /* @__PURE__ */ g(
198
+ "li",
199
+ {
200
+ className: "bca-multi-select-picker__option",
201
+ role: "option",
202
+ "aria-selected": !1,
203
+ "aria-disabled": u || void 0,
204
+ "data-disabled": u || void 0,
205
+ tabIndex: u ? -1 : 0,
206
+ onClick: () => b(l),
207
+ onKeyDown: (h) => {
208
+ h.nativeEvent.isComposing || (h.key === "Enter" || h.key === " ") && (h.preventDefault(), b(l));
209
+ },
210
+ children: [
211
+ /* @__PURE__ */ c("span", { className: "bca-multi-select-picker__label", children: l.label }),
212
+ l.sublabel && /* @__PURE__ */ c("span", { className: "bca-multi-select-picker__sublabel", children: `(${l.sublabel})` })
213
+ ]
214
+ },
215
+ l.id
216
+ );
217
+ }) : /* @__PURE__ */ c("li", { className: "bca-multi-select-picker__no-results", children: n }) }) }),
218
+ /* @__PURE__ */ c("div", { className: "bca-multi-select-picker__selected", children: /* @__PURE__ */ c(K, { items: e, onRemove: v }) })
219
+ ] });
220
+ }, z = 'a[href], button, input:not([type="hidden"]), select, textarea, [tabindex]:not([tabindex="-1"])', B = (t) => "disabled" in t && t.disabled, G = (t) => Array.from(t.querySelectorAll(z)).filter((e) => !B(e)), J = (t, e) => {
221
+ C(() => {
222
+ if (!e) return;
223
+ const s = t.current;
224
+ if (!s) return;
225
+ const i = (n) => {
226
+ if (n.key !== "Tab") return;
227
+ const r = G(s);
228
+ if (r.length === 0) return;
229
+ const m = r[0], o = r[r.length - 1];
230
+ n.shiftKey ? (document.activeElement === m || !s.contains(document.activeElement)) && (n.preventDefault(), o.focus()) : (document.activeElement === o || !s.contains(document.activeElement)) && (n.preventDefault(), m.focus());
231
+ };
232
+ return s.addEventListener("keydown", i), () => s.removeEventListener("keydown", i);
233
+ }, [t, e]);
234
+ }, W = ({
235
+ open: t,
236
+ title: e = "選択",
237
+ options: s,
238
+ initialValue: i,
239
+ onSubmit: n,
240
+ onCancel: r,
241
+ submitLabel: m = "決定",
242
+ cancelLabel: o = "キャンセル",
243
+ requireSelection: f = !0,
244
+ submitButtonProps: p = { className: "bca-btn", "data-bca-btn-type": "save" },
245
+ cancelButtonProps: k = { className: "bca-btn" },
246
+ className: w = "",
247
+ ...d
248
+ }) => {
249
+ const [b, v] = D(i ?? []), l = $(null), u = $(null);
250
+ if (C(() => {
251
+ t && v(i ?? []);
252
+ }, [t]), C(() => {
253
+ if (!t) return;
254
+ const y = (x) => {
255
+ x.isComposing || x.key === "Escape" && r();
256
+ };
257
+ return document.addEventListener("keydown", y), () => document.removeEventListener("keydown", y);
258
+ }, [t, r]), C(() => {
259
+ t ? (u.current = document.activeElement, l.current?.focus()) : (u.current?.focus(), u.current = null);
260
+ }, [t]), J(l, t), !t) return null;
261
+ const h = !f || b.length > 0;
262
+ return /* @__PURE__ */ c("div", { className: "bca-multi-select-dialog__overlay", children: /* @__PURE__ */ g(
263
+ "div",
264
+ {
265
+ ref: l,
266
+ role: "dialog",
267
+ "aria-modal": "true",
268
+ "aria-label": e,
269
+ tabIndex: -1,
270
+ className: `bca-multi-select-dialog ${w}`.trim(),
271
+ children: [
272
+ /* @__PURE__ */ g("div", { className: "bca-multi-select-dialog__header", children: [
273
+ /* @__PURE__ */ c("span", { className: "bca-multi-select-dialog__title", children: e }),
274
+ /* @__PURE__ */ c("button", { type: "button", className: "bca-multi-select-dialog__close", "aria-label": "閉じる", onClick: r, children: "×" })
275
+ ] }),
276
+ /* @__PURE__ */ c("div", { className: "bca-multi-select-dialog__body", children: /* @__PURE__ */ c(Y, { options: s, value: b, onChange: v, ...d }) }),
277
+ /* @__PURE__ */ g("div", { className: "bca-multi-select-dialog__footer", children: [
278
+ /* @__PURE__ */ c(
279
+ "button",
280
+ {
281
+ ...k,
282
+ type: "button",
283
+ className: `bca-multi-select-dialog__button ${k?.className ?? ""}`.trim(),
284
+ onClick: r,
285
+ children: o
286
+ }
287
+ ),
288
+ /* @__PURE__ */ c(
289
+ "button",
290
+ {
291
+ ...p,
292
+ type: "button",
293
+ className: `bca-multi-select-dialog__button bca-multi-select-dialog__button--primary ${p?.className ?? ""}`.trim(),
294
+ disabled: !h,
295
+ onClick: () => n(b),
296
+ children: m
297
+ }
298
+ )
299
+ ] })
300
+ ]
301
+ }
302
+ ) });
303
+ }, ee = ({
304
+ options: t,
305
+ value: e,
306
+ onChange: s,
307
+ name: i,
308
+ addButtonLabel: n = "追加",
309
+ dialogTitle: r = "選択",
310
+ emptyText: m = "選択されていません",
311
+ disabled: o = !1,
312
+ className: f = "",
313
+ maxSelected: p,
314
+ submitLabel: k,
315
+ cancelLabel: w,
316
+ requireSelection: d,
317
+ addButtonProps: b = { className: "bca-btn", "data-bca-btn-type": "add" },
318
+ submitButtonProps: v,
319
+ cancelButtonProps: l,
320
+ ...u
321
+ }) => {
322
+ const [h, y] = D(!1), x = new Set(e.map((_) => _.id)), S = t.filter((_) => !x.has(_.id)), M = p !== void 0 ? Math.max(0, p - e.length) : void 0, I = p !== void 0 && e.length >= p, L = (_) => {
323
+ s(e.filter((O) => O.id !== _));
324
+ }, E = (_) => {
325
+ s([...e, ..._]), y(!1);
326
+ };
327
+ return /* @__PURE__ */ g("div", { className: `bca-multi-select-field ${f}`.trim(), children: [
328
+ i && e.map((_) => /* @__PURE__ */ c("input", { type: "hidden", name: i, value: _.id }, _.id)),
329
+ /* @__PURE__ */ c("div", { className: "bca-multi-select-field__tags", children: /* @__PURE__ */ c(K, { items: e, onRemove: L, emptyText: m, disabled: o }) }),
330
+ /* @__PURE__ */ c("div", { className: "bca-multi-select-field__actions", children: /* @__PURE__ */ c(
331
+ "button",
332
+ {
333
+ ...b,
334
+ type: "button",
335
+ className: `bca-multi-select-field__add ${b?.className ?? ""}`.trim(),
336
+ disabled: o || I,
337
+ onClick: () => y(!0),
338
+ children: n
339
+ }
340
+ ) }),
341
+ /* @__PURE__ */ c(
342
+ W,
343
+ {
344
+ open: h,
345
+ title: r,
346
+ options: S,
347
+ onSubmit: E,
348
+ onCancel: () => y(!1),
349
+ maxSelected: M,
350
+ submitLabel: k,
351
+ cancelLabel: w,
352
+ requireSelection: d,
353
+ submitButtonProps: v,
354
+ cancelButtonProps: l,
355
+ ...u
356
+ }
357
+ )
358
+ ] });
359
+ };
360
+ export {
361
+ W as MultiSelectDialog,
362
+ ee as MultiSelectField,
363
+ Y as MultiSelectPicker,
364
+ V as SearchSelect
365
+ };
package/dist/style.css ADDED
@@ -0,0 +1 @@
1
+ :root{--bca-accent: #D4EDC9;--bca-accent-hover: #E9F7E3;--bca-border: #ccc;--bca-radius: 4px;--bca-font-size: 14px;--bca-z-index: 9999}.bca-selected-tags{display:flex;flex-wrap:wrap;gap:5px;list-style:none;margin:0;padding:0}.bca-selected-tags__empty{color:#999;font-size:var(--bca-font-size)}.bca-selected-tags__item{display:flex;align-items:center;padding:5px 10px;background-color:var(--bca-accent);border-radius:15px;font-size:var(--bca-font-size)}.bca-selected-tags__remove{margin-left:5px}:where(.bca-selected-tags__remove){font:inherit;border:none;background:transparent;font-weight:700;cursor:pointer}.bca-selected-tags__remove:disabled{cursor:not-allowed;opacity:.5}.bca-search-select{position:relative;width:100%;font-size:var(--bca-font-size)}.bca-search-select__trigger{position:relative;display:flex;align-items:center;box-sizing:border-box;width:100%;padding:4px 32px 4px 8px;border:1px solid var(--bca-border);border-radius:var(--bca-radius);background-color:#fff;cursor:pointer}.bca-search-select__trigger[data-disabled]{background-color:#f4f4f4;color:#999;cursor:not-allowed}.bca-search-select__value,.bca-search-select__placeholder{flex:1 1 auto;min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.bca-search-select__placeholder{color:#999}.bca-search-select__clear,.bca-search-select__arrow{position:absolute;right:8px;top:50%;transform:translateY(-50%);color:#999}.bca-search-select__clear{border:none;background:transparent;font-weight:700;cursor:pointer;padding:0 4px}.bca-search-select__arrow{font-size:12px;pointer-events:none}.bca-search-select__dropdown{position:absolute;left:0;width:100%;box-sizing:border-box;z-index:var(--bca-z-index);background-color:#fff;border:1px solid var(--bca-border);border-radius:var(--bca-radius);box-shadow:0 2px 6px #00000026;max-height:240px;display:flex;flex-direction:column}.bca-search-select__dropdown[data-placement=bottom]{top:100%}.bca-search-select__dropdown[data-placement=top]{bottom:100%}.bca-search-select__search{padding:8px;border-bottom:1px solid #eee}.bca-search-select__search-input{font:inherit;box-sizing:border-box;width:100%;padding:4px 8px;border:1px solid var(--bca-border);border-radius:var(--bca-radius)}.bca-search-select__list{list-style:none;margin:0;padding:0;overflow-y:auto;flex:1}.bca-search-select__option{padding:6px 12px;cursor:pointer;border-bottom:1px solid #f9f9f9}.bca-search-select__option[data-active]{background-color:var(--bca-accent-hover)}.bca-search-select__option[data-selected]{background-color:var(--bca-accent);font-weight:700}.bca-search-select__option[data-disabled]{color:#999;cursor:not-allowed;background-color:transparent}.bca-search-select__sublabel{margin-left:4px;font-size:.8em;color:#666}.bca-search-select__no-results{padding:8px 12px;color:#999;text-align:center}.bca-multi-select-picker{font-size:var(--bca-font-size)}.bca-multi-select-picker__search{position:relative;margin-bottom:10px}.bca-multi-select-picker__search-input{font:inherit;box-sizing:border-box;width:100%;padding:4px 30px 4px 8px;border:1px solid var(--bca-border);border-radius:var(--bca-radius)}.bca-multi-select-picker__search-clear{position:absolute;right:8px;top:50%;transform:translateY(-50%);border:none;background:transparent;font-size:16px;color:#999;cursor:pointer}.bca-multi-select-picker__list-wrapper{overflow-y:auto;border:1px solid var(--bca-border);border-radius:var(--bca-radius)}.bca-multi-select-picker__list{list-style:none;margin:0;padding:0}.bca-multi-select-picker__option{padding:8px 10px;border-bottom:1px solid #eee;cursor:pointer}.bca-multi-select-picker__option:hover{background-color:var(--bca-accent-hover)}.bca-multi-select-picker__option:focus-visible{outline:2px solid var(--bca-accent-hover);outline-offset:-2px;background-color:var(--bca-accent-hover)}.bca-multi-select-picker__option[data-disabled]{color:#999;cursor:not-allowed}.bca-multi-select-picker__option[data-disabled]:hover{background-color:transparent}.bca-multi-select-picker__sublabel{margin-left:4px;font-size:.8em;color:#666}.bca-multi-select-picker__no-results{padding:10px;color:#999;text-align:center}.bca-multi-select-picker__selected{margin-top:10px;padding:10px;border:1px solid var(--bca-border);border-radius:var(--bca-radius);min-height:40px;max-height:120px;overflow-y:auto}.bca-multi-select-dialog__overlay{position:fixed;inset:0;display:flex;justify-content:center;align-items:center;background-color:#0000004d;z-index:var(--bca-z-index)}.bca-multi-select-dialog{width:500px;max-width:calc(100vw - 32px);max-height:calc(100vh - 32px);display:flex;flex-direction:column;background-color:#fff;border:1px solid var(--bca-border);border-radius:var(--bca-radius);box-shadow:0 4px 16px #0003;font-size:var(--bca-font-size)}.bca-multi-select-dialog:focus{outline:none}.bca-multi-select-dialog__header{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;border-bottom:1px solid var(--bca-border);background-color:#f5f5f5}.bca-multi-select-dialog__title{font-weight:700}:where(.bca-multi-select-dialog__close){font:inherit;border:none;background:transparent;font-size:18px;cursor:pointer;color:#666}.bca-multi-select-dialog__body{flex:1 1 auto;min-height:0;overflow-y:auto;padding:1em}.bca-multi-select-dialog__footer{display:flex;justify-content:flex-end;gap:8px;padding:8px 12px;border-top:1px solid var(--bca-border)}:where(.bca-multi-select-dialog__button){font:inherit;padding:4px 16px;border:1px solid var(--bca-border);border-radius:var(--bca-radius);background-color:#fff;cursor:pointer}:where(.bca-multi-select-dialog__button--primary){background-color:var(--bca-accent)}.bca-multi-select-dialog__button:disabled{opacity:.5;cursor:not-allowed}.bca-multi-select-field{font-size:var(--bca-font-size)}.bca-multi-select-field__tags{padding:10px;border:1px solid var(--bca-border);border-radius:var(--bca-radius);min-height:40px;box-sizing:border-box}.bca-multi-select-field__actions{margin-top:8px}:where(.bca-multi-select-field__add){font:inherit;padding:4px 16px;border:1px solid var(--bca-border);border-radius:var(--bca-radius);background-color:#fff;cursor:pointer}.bca-multi-select-field__add:disabled{opacity:.5;cursor:not-allowed}
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@ecatchup/basercms-ui",
3
+ "version": "0.1.0",
4
+ "description": "検索付きの選択UIコンポーネント集",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/ecatchup/basercms-ui.git"
10
+ },
11
+ "main": "./dist/index.js",
12
+ "module": "./dist/index.js",
13
+ "types": "./dist/index.d.ts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "import": "./dist/index.js"
18
+ },
19
+ "./style.css": "./dist/style.css"
20
+ },
21
+ "files": ["dist"],
22
+ "sideEffects": ["**/*.css"],
23
+ "scripts": {
24
+ "dev": "vite",
25
+ "build": "tsc --noEmit && vite build",
26
+ "test": "vitest run",
27
+ "test:watch": "vitest",
28
+ "prepublishOnly": "npm run build"
29
+ },
30
+ "peerDependencies": {
31
+ "react": "^19",
32
+ "react-dom": "^19"
33
+ },
34
+ "devDependencies": {
35
+ "@testing-library/jest-dom": "^6.6.3",
36
+ "@types/node": "^24.13.3",
37
+ "@testing-library/react": "^16.1.0",
38
+ "@testing-library/user-event": "^14.5.2",
39
+ "@types/react": "^19.2.9",
40
+ "@types/react-dom": "^19.2.3",
41
+ "@vitejs/plugin-react": "^5.1.2",
42
+ "jsdom": "^25.0.1",
43
+ "react": "^19.2.3",
44
+ "react-dom": "^19.2.3",
45
+ "typescript": "^5.9.3",
46
+ "vite": "^7.3.1",
47
+ "vite-plugin-dts": "^4.5.0",
48
+ "vitest": "^3.2.4"
49
+ }
50
+ }