fuzzy-select-menu@0.1.0
仕様
入力欄の上でスクロールすると項目を変えられる
検索キーワードは別に保持して、上書きしないようにする
何かを入力したら書き換える
項目は検索して絞り込んだものをそのまま使う
上下キーでも候補から選択できる
候補の確定
検索候補中の任意の項目を選んだ
クリックやエンターキーは必要ない
入力文字が既に存在する候補と一致した
確定すると入力欄が書き換わる
検索実行
入力欄の文字をuserが書き換えた
選択項目から選んだ結果書き換わった場合は何もしない
表示非表示の切り替え
入力欄[をクリックするか、focusを当てると切り替える
非表示
userが候補と入力欄以外を押した
エンターキーやクリックで候補を選んだ
表示
userが入力欄の文字を書き換える
候補がない時
表示状態であれば not found
というメッセージを出す
非表示状態なら何もしない
UI
まあスタイルとかは変えやすくはある
hookにするとしたら、どんな機能を提供するのか
出力
確定した候補
入力候補のリスト
onClick()
つき
選択用函数
onInput()
入力
リスト
キー付きで渡す
検索用文字列
リストから検索対象の文字列を生成するための函数
キーバインドは……別にしてもいいか?
これだけだとhookにする意味がない
hookに入れないもの
開閉判定
<DropdownMenu>
の座標計算
既知の問題
初期値の設定ができない
本当に初回だけを設定したい
list
の形式を固定したくない
hookにすればまだやりようはありそうなのだが……
mobileでproject候補のクリックがうまくいかない
backgroundを押していると判定される?
<span>
ではなく <button>
にして、click eventを発火させられるようにしよう
2021-06-27 03:26:52 応急処置として、どこの要素にもfocusが当たっていないときのみ、blurで補完windowを消すようにする
実装したいこと
スワイプで候補を切り替えたい
2021-06-27
<a>
タグに戻した
click
eventを使うようにした
2021-06-24
09:03:17 mobileだと click
eventを <span>
に対して発生させられないみたい
07:43:05 補完windowが閉じている場合は、 <tab>
で別のcomponentにfocusを移せるようにする
07:38:58 入力欄からfocusが外れたら補完windowを閉じる
07:24:12 入力補完候補の表示位置を直した
06:30:01 だいたい実装終了
入力候補上でもスクロールすると候補選択できるようにしたかったが、断念した
他の操作は大体チェックした
2021-06-22
2021-06-19
2021-06-13
2021-06-01
同じコードを何度も書きたくない
02:47:06 大体できた
2021-05-31
いや、Hookに切り出したほうがいいかも
Componentを自由に作れる
script.jsimport {html} from '../htm@3.0.4%2Fpreact/script.js';
import {DropdownMenu} from '../dropdown-container@0.1.1/script.js';
import {useState, useCallback, useEffect} from '../preact@10.5.13/hooks.js';
import {useSelect} from '../use-select@0.1.0/script.js';
import {useSearch} from '../use-search@0.1.0/script.js';
export const FuzzySelect = ({list: _initialList, onSelect, convert}) => {
// DropDownの座標計算
const [position, setPosition] = useState({});
const adjust = useCallback(ref => {
const {left= 0, bottom = 0} = ref?.current?.getBoudingClientRect?.() ?? {};
setPosition({top: bottom, left});
}, []);
const [query, setQuery] = useState('');
const [display, setDisplay] = useState(''); // <input>に表示する文字列
const list = useSearch({query, list: _initialList, convert});
const {
item,
selectPrev,
selectNext,
select,
blur,
} = useSelect({list});
useEffect(() => {
if (!item) return;
onSelect?.(item);
setDisplay(item.text);
}, [item]);
// 開閉判定・表示する文字列の更新・検索文字列の更新
const [open, setOpen] = useState(false);
const onInput = useCallback(({target: {value}}) => {
setDisplay(value);
setOpen(true);
if (item?.text === value) return;
blur();
setQuery(value);
}, [item]);
const onClick = () => setOpen(old => !old);
const onBlur = () => !document.activeElement && setOpen(false);
const onKeyDown = useCallback(e => {
const {key, shiftKey} = e;
if (key === 'Escape') {
e.preventDefault();
e.stopPropagation();
setOpen(false);
return;
}
if (key === 'Enter') {
e.preventDefault();
e.stopPropagation();
if (!item) {
select(0);
}
setOpen(false);
return;
}
// windowが開いていなければ、focusを別のcomponentに移す
if (key === 'Tab' && !open) return;
if (key === 'ArrowUp' || (key === 'Tab' && shiftKey)) {
e.preventDefault();
e.stopPropagation();
selectPrev();
return;
}
if (key === 'ArrowDown' || (key === 'Tab' && !shiftKey)) {
e.preventDefault();
e.stopPropagation();
selectNext();
return;
}
}, [selectPrev, selectNext, select, open]);
const onWheel = useCallback(e => {
e.preventDefault();
e.stopPropagation();
const {deltaY} = e;
if (deltaY > 0) {
selectNext();
return;
}
if (deltaY < 0) {
selectPrev();
return;
}
}, [selectNext, selectPrev]);
const onClickItem = useCallback((index) => {
select(index);
setOpen(false);
}, []);
return html`<span style="position: relative;">
<input
ref="${adjust}"
type="text"
value="${display}"
onInput="${onInput}"
onBlur="${onBlur}"
onClick="${onClick}"
onKeyDown="${onKeyDown}"
onWheel="${onWheel}"
/>
${open && html`<${DropdownMenu} position="${({})}"
messages="${list.length === 0 && html`<span class="message">Not found</span>`}"
selected="${item?.key}">
${list.map(
({key, text}, index) => html`
<a key="${key}" onClick="${() => onClickItem(index)}">${text}</a>
`
)}
<//>`}
</span>`;
};
custom hook
script.jsexport function useFuzzySelect(props) {
const {query, list: initialList = [], convert} = props ?? {};
const list = useSearch({query, list: initialList, convert});
const {item, selectPrev, selectNext}
= useSelect({
list,
defaultSelected: 0,
});
}
test code
jsimport('/api/code/programming-notes/fuzzy-select-menu@0.1.0/test.js');
test.jsimport {FuzzySelect} from './script.js';
import {useState} from '../preact@10.5.13/hooks.js';
import {html, render} from '../htm@3.0.4%2Fpreact/script.js';
import {getProjectInfo} from '../scrapboxのproject情報を一括して取得するUserScript/script.js';
const App = ({list}) => {
const [result, setResult] = useState('');
return html`
<span>Selected item is ${result}</span>
<br />
<${FuzzySelect}
list="${list}"
convert="${({text, key}) => `${text} ${key}`}"
onSelect="${({text, key}) => setResult(`/${key} ${text}`)}"
/>
`;
}
(async () => {
const watchListIds = Object.keys(JSON.parse(localStorage.getItem('projectsLastAccessed')));
const watchList = [...await getProjectInfo(watchListIds)]
.map(({name, displayName}) => ({text: displayName, key: name}))
.sort((a, b) => a.text.localeCompare(b.text));
const app = document.createElement('div');
app.dataset.userscriptName= 'fuzzy-select-test';
document.getElementById('editor').append(app);
app.attachShadow({mode: 'open'});
render(html`<${App} list="${watchList}"/>`, app.shadowRoot);
})();