@ecatchup/basercms-ui
v0.6.4
Published
検索付きの選択UIコンポーネント集
Readme
@ecatchup/basercms-ui
検索付きの選択UIコンポーネント集。React 19 以降で動作し、react / react-dom 以外の依存を持ちません。
インストール
npm install @ecatchup/basercms-ui使い方
import { SearchSelect, MultiSelectField } from '@ecatchup/basercms-ui';
import '@ecatchup/basercms-ui/style.css';
import type { SelectOption } from '@ecatchup/basercms-ui';
const options: SelectOption[] = [
{ id: '1', label: '管理者', sublabel: 'managers' },
{ id: '2', label: '利用者', sublabel: 'employee' },
];
// 単一選択
<SearchSelect options={options} value={value} onChange={setValue} emptyLabel="指定なし" />
// 複数選択(タグ形式)
<MultiSelectField options={options} value={selected} onChange={setSelected} />コンポーネント
| 名前 | 用途 |
| --- | --- |
| SearchSelect | 検索して1つ選ぶ |
| MultiSelectField | 追加ボタン+選択済みタグ+モーダルを一体にしたフォーム部品 |
| MultiSelectDialog | 複数選択のモーダル単体 |
| MultiSelectPicker | 検索+一覧+選択済みタグの中身のみ(ページ直置き用) |
SearchSelect は searchPlaceholder(検索欄の placeholder、既定「検索...」)を受け取れます。
選択肢の型
type SelectOption = {
id: string;
label: string; // 表示名
sublabel?: string; // 一覧行にのみ括弧付きで表示。検索対象に含まれる
disabled?: boolean;
};disabled: true の選択肢は、キーボード操作・クリックのどちらでも選択できません。該当行には data-disabled / aria-disabled が付与されます。
データ取得は行いません。options は呼び出し側が用意してください。
複数選択の上限
MultiSelectPicker / MultiSelectDialog / MultiSelectField は maxSelected を受け取れます。MultiSelectField ではこれを累計の選択件数の上限として扱い、上限に達すると追加ボタンが無効になります。一方、MultiSelectPicker / MultiSelectDialog を単体で使う場合、maxSelected はその部品自身の選択状態(value / initialValue)に対する上限であり、それ以上の追加ができなくなります。
全て選択
モーダルのヘッダーには、既定で「全て選択」ボタンが表示されます。選択可能な候補をまとめて追加できます。
<MultiSelectField
options={options}
value={selected}
onChange={setSelected}
selectAllLabel="全て選択"
/>全て選択ボタンを表示したくない場合は allowSelectAll={false} を指定してください。
maxSelected を併用した場合は、残り枠を超えない範囲でだけ一括追加されます。
モーダルの挙動(MultiSelectDialog / MultiSelectField)
モーダルは Escape キー・閉じるボタン(×)・キャンセルボタンで閉じます。誤操作防止のため、オーバーレイ(背景)のクリックでは閉じません。開いている間はフォーカスがモーダル内に閉じ込められます(Tab / Shift+Tab で外へ出ません)。
幅
SearchSelect の幅の調整点は、ルート要素ひとつに集約されています。
ルート要素(.bca-search-select)の既定は display: inline-block で、幅は最も長い選択肢に合わせた内容幅になります(ネイティブの <select> と同じ挙動)。部品が親要素いっぱいに広がることはありません。トリガーとドロップダウンはどちらもルート要素の幅に追従する為、利用側が指定するのはルート要素の幅だけで済みます。
選択を変えても幅は変わらず、ドロップダウンの項目も折り返しません。これは JS で幅を計算しているのではなく、高さ 0 の実測用要素(.bca-search-select__sizer)をトリガー内に描画してブラウザに測らせている為で、フォントや文字サイズが変わっても追従します。実測用要素の文字列はテキストノードではなく data-text 属性で持ち、CSS の content: attr(data-text) で描画される為、getByText 等の DOM 検索に二重にヒットすることはありません。
この挙動が不要な場合は sizeToLongestOption={false} を渡してください(幅は選択中のラベルの長さに応じて変わります)。
幅いっぱいにしたい場合は、className を渡して利用側の CSS で width: 100% を指定してください。トリガーもドロップダウンも一緒に追従します。
<SearchSelect className="my-search-select" options={options} value={value} onChange={setValue} />.my-search-select {
width: 100%;
}トリガーの display は部品側では宣言していません。baserCMS 環境では既定の triggerProps(className: 'bca-textbox__input')の inline-block がそのまま効き、それ以外の環境では部品同梱の :where() の既定(inline-block)が効きます。triggerProps にインラインスタイルを渡せば上書きできます。
<SearchSelect triggerProps={{ className: 'bca-textbox__input', style: { display: 'flex' } }} ... />なお、ルート要素の幅を指定しないと overflow / text-overflow: ellipsis によるラベルの省略表示は発動しません(省略は要素の幅が中身より狭いときに効くため、幅が中身に合わせて伸びる状態では発動しようがありません)。長いラベルを省略したい場合は、上記のように className で幅を制限してください。
一方、MultiSelectPicker の検索欄など、モーダル内で使われる要素は従来どおり幅いっぱい(width: 100%)のままです。これらはモーダルという明確な幅を持つコンテナの中に置かれるため、幅いっぱいが自然な既定だからです。
MultiSelectField の選択済みタグ欄(.bca-multi-select-field__tags)も、0.6.0 から枠線と余白を指定していません(min-height: 28px のみ)。囲みが欲しい場合は className を渡して利用側の CSS で指定してください。
.my-tags-field .bca-multi-select-field__tags {
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
}ボタンのクラス・属性(既定で baserCMS のボタンスタイルが当たります)
MultiSelectField の addButtonProps、MultiSelectDialog の submitButtonProps /
cancelButtonProps は、既定で以下の値になっています(baserCMS の .bca-btn を
前提にしています)。
| prop | 既定値 |
| --- | --- |
| addButtonProps(MultiSelectField) | { className: 'bca-btn', 'data-bca-btn-type': 'add' } |
| submitButtonProps(MultiSelectDialog) | { className: 'bca-btn', 'data-bca-btn-type': 'save' } |
| cancelButtonProps(MultiSelectDialog) | { className: 'bca-btn' } |
既定値は「置き換え」です。マージではありません。 何か1つでも渡すと、既定値は使われず渡した内容がそのまま使われます。既定のクラス・属性が不要な場合は空オブジェクト({})を渡してください。ただしこれで置き換わるのは追加のクラス・属性だけで、部品自身の BEM クラス(bca-multi-select-field__add 等)は常に付くため、{} を渡しても部品同梱の CSS は効き続けます(baserCMS のボタンスタイルだけ外したい場合に使えます)。MultiSelectField に渡した submitButtonProps / cancelButtonProps はモーダルへそのまま転送されます。type / onClick / disabled は部品側が制御するため渡せません。
// baserCMS 以外の見た目にしたい場合
<MultiSelectField
addButtonProps={{ className: 'my-add-button' }}
submitButtonProps={{ className: 'my-submit-button' }}
cancelButtonProps={{ className: 'my-cancel-button' }}
...
/>
// 何も付けたくない場合
<MultiSelectField
addButtonProps={{}}
submitButtonProps={{}}
cancelButtonProps={{}}
...
/>入力欄のクラス・属性(既定で baserCMS の input スタイルが当たります)
SearchSelect の triggerProps / searchInputProps、MultiSelectPicker の
searchInputProps は、既定で以下の値になっています(baserCMS の
.bca-textbox__input を前提にしています)。
| prop | 既定値 |
| --- | --- |
| triggerProps(SearchSelect) | { className: 'bca-textbox__input' } |
| searchInputProps(SearchSelect) | { className: 'bca-textbox__input' } |
| searchInputProps(MultiSelectPicker) | { className: 'bca-textbox__input' } |
既定値は「置き換え」です。マージではありません。 何か1つでも渡すと、既定値は使われず渡した内容がそのまま使われます。既定のクラス・属性が不要な場合は空オブジェクト({})を渡してください。ただしこれで置き換わるのは追加のクラス・属性だけで、部品自身の BEM クラス(bca-search-select__trigger 等)は常に付くため、{} を渡しても部品同梱の CSS は効き続けます(baserCMS の input スタイルだけ外したい場合に使えます)。MultiSelectDialog / MultiSelectField に渡した searchInputProps は、内部の MultiSelectPicker の検索欄へそのまま転送されます。type / value / onChange / placeholder / disabled(triggerProps では加えて role / tabIndex / onClick / onKeyDown / ARIA 属性)は部品側が制御するため渡せません。
// baserCMS 以外の見た目にしたい場合
<SearchSelect
triggerProps={{ className: 'my-trigger' }}
searchInputProps={{ className: 'my-search-input' }}
...
/>
// 何も付けたくない場合
<SearchSelect
triggerProps={{}}
searchInputProps={{}}
...
/>フォーム連携
name を渡すと hidden input を描画します。
<SearchSelect name="data[User][approver_id]" ... /> // hidden 1つ
<MultiSelectField name="data[User][group_ids][]" ... /> // 選択件数分の hidden落とし穴: 複数選択で「全部外す」と何も送信されない問題
HTML フォームには、選択が 0 件だとそのキー自体が送信されないという性質があります。
2件選択 → data[User][group_ids][] = 5
data[User][group_ids][] = 8
全部外す → (何も送信されない)サーバー側はこれを「全部外した」のか「そもそもこのフォームにこの項目が無かった」のか区別できません。結果として、全解除の保存ができないという不具合になります(SearchSelect の単一選択は未選択でも value="" の hidden が常に1つ出るため、この問題は起きません)。
MultiSelectField は name を渡すと、既定でこの問題を解決する「センチネル」の hidden を追加で出力します。
<input type="hidden" name="data[User][group_ids]" value=""> ← センチネル([] なし・常に出力)
<input type="hidden" name="data[User][group_ids][]" value="5"> ← 選択分([] あり)
<input type="hidden" name="data[User][group_ids][]" value="8">なぜセンチネルの name から [] を外すのか。 name をそのまま([] 付きの配列記法)にして空の hidden を送ると、サーバー側では [''](要素が1つだけの配列)として届きます。PHP の empty($value) はこれに対して false(=値がある)を返してしまうため、必須チェックが素通りしてしまいます。[] を外すと、全解除時はセンチネルの ''(空文字列)だけが届き、empty() は正しく true を返します。これは CakePHP の FormHelper::select()(multiple 指定時)が採用しているのと同じ方式です。
このパッケージは、name の末尾に [] があればそれを取り除いた name をセンチネルに自動採用します。挙動を変えたい場合は emptyName で上書き・無効化できます。なお、空文字("")を渡した場合も false と同じくセンチネルは出力されません(動的に組み立てた name が意図せず空文字になる場合はご注意ください)。
<MultiSelectField name="data[User][group_ids][]" ... />
// → センチネルは自動で "data[User][group_ids]"([] なし)
<MultiSelectField name="data[User][group_ids][]" emptyName="custom_name" ... />
// → センチネルの name を "custom_name" にする
<MultiSelectField name="data[User][group_ids][]" emptyName={false} ... />
// → センチネルを出力しない(自前でハンドリングしたい場合)センチネルは選択件数に関わらず常に出力され、選択分の hidden より DOM 上で前に出ます。これも CakePHP と同じ順序で、PHP のフォーム解析では同名キーが複数あると「後に来た方が勝つ」ため、センチネルを先に出すことで、選択がある場合はその値で正しく上書きされます。
テーマ
CSS 変数を上書きしてください。
| 変数 | 既定値 |
| --- | --- |
| --bca-accent | #D4EDC9 |
| --bca-accent-hover | #E9F7E3 |
| --bca-border | #ccc |
| --bca-radius | 4px |
| --bca-font-size | 14px |
| --bca-z-index | 9999 |
開発
npm install
npm run dev # 動作確認用デモ
npm test # テスト
npm run build # dist を生成ライセンス
MIT
