Перейти к содержимому
Skein
← Все проекты

frontend · intermediate · 5d

Палитра команд

Палитра команд ⌘K с нечётким ранжированием, асинхронными источниками действий и полным клавиатурным управлением (стрелки, Enter, Escape, области видимости) — интерактивный слой, который нужен каждому профессиональному инструменту.

Палитры команд обманчиво малы, но затрагивают удивительно широкий спектр сложных задач: нечёткое ранжирование, ощущающееся правильным (чистая функция, учёт Unicode, детерминированные ничьи), асинхронные источники без гонок (дебаунс + AbortController), вложенное навигационное состояние как чистый редьюсер и доступное клавиатурное управление с live region. Собери её один раз правильно — и больше никогда не потянешься за библиотекой, заодно поняв, почему каждая библиотека что-то из этого делает неправильно. Финальный этап превращает игрушку в продукт: правильные ARIA, виртуализованный список на 10k элементов с измеренным INP и обоснованный компромисс между пре-индексацией и brute-force ранжированием.

Результат

Палитра, открывающаяся по ⌘K, нечётко фильтрующая (чистая scoring-функция, подсветка совпадений) по синхронным и асинхронным источникам, поддерживающая вложенные области с хлебными крошками и полностью управляемая с клавиатуры/a11y с виртуализацией для 10k+ элементов.

Этапы

0/5 · 0%
  1. 01Реестр + управление с клавиатуры

    Собери реестр команд и машину состояний клавиатуры, управляющую палитрой. Реестр — плоская карта `{id, label, action, keywords?, scope?}` с register/unregister в рантайме, чтобы любая фича могла добавлять команды. Палитра открывается/закрывается по ⌘K (и Ctrl+K не на Mac), ловит стрелки для перемещения активного элемента, Enter для запуска, Escape для закрытия — всё без мыши. Фокус заперт внутри открытой палитры (Tab циклится внутри), и возвращается на элемент-триггер при закрытии, чтобы клавиатурные пользователи не теряли место. Клавиатурная логика — чистый редьюсер `(state, key) → state` без доступа к DOM: выделение правильно оборачивается в обоих направлениях, работает со списком из одного элемента или пустым, обрабатывает быстрые нажатия без потери событий. Докажи: открой по ⌘K, походи стрелками (включая оборачивание на границах), запусти Enter, закрой Escape и покажи возврат фокуса на триггер — всё в headless-тесте без DOM.

    Критерии готовности
    • ⌘K/Ctrl+K открывает/закрывает палитру; стрелки двигают активный элемент с оборачиванием на обоих концах (включая списки из 1 и 0 элементов), Enter запускает активную команду, Escape закрывает — всё без мыши, редьюсер — чистая функция.
    • Фокус заперт внутри открытой палитры (Tab циклится внутри) и возвращается на триггер при закрытии — проверено тестом focus-trap и headless-тестом редьюсера без DOM.
    Самопроверка

    Покажи чистый тест редьюсера (оборачивание, пустой, один элемент, быстрые клавиши) и тест ловушки/восстановления фокуса. Senior-ревьюер проверяет отсутствие доступа к DOM в редьюсере и вынесенность управления фокусом наружу.

  2. 02Нечёткое ранжирование (чистая scoring-функция)

    Добавь нечёткую фильтрацию, где ранжирование — чистая функция `(items, query) → sorted items` без побочных эффектов: без мутаций состояния, без DOM, без асинхронности. Сопоставление по подпоследовательности (каждый символ запроса встречается по порядку, не обязательно подряд) дешевле и интуитивнее Левенштейна: 'fc' совпадает с 'fuzzyCheck', потому что 'f' стоит перед 'c', а оценка вознаграждает префиксные совпадения и смежные прогоны, штрафуя рассеянные совпадения и пропуски (например, бонус за совпадение на границах слов/инициалах camelCase). Функция подсвечивает совпавшие символы, обрабатывает пустой запрос (показать всё) и отсутствие совпадений (явное пустое состояние), детерминированно разрешает ничьи и корректно обрабатывает границы Unicode. Сложность O(n·m) на элемент (n=длина запроса, m=длина элемента) и O(N·m log N) итого — укажи и обоснуй. Держи её отдельно тестируемой в Node: те же входы → тот же выход, так что мемоизация тривиальна, а слой отрисовки остаётся тонким представлением.

    Критерии готовности
    • Оценка — чистая функция (items, query) → отсортированный массив с индексами подсветки; префиксные/смежные совпадения выше рассеянных, пустой запрос показывает всё, нет совпадений — пустое состояние, ничьи детерминированны, границы Unicode обработаны.
    • Функция покрыта юнит-тестами в Node без DOM (включая Unicode и ничьи), сложность O(N·m log N) указана с обоснованием, почему подпоследовательность здесь лучше Левенштейна.
    Самопроверка

    Покажи тесты чистой функции (префикс > рассеянное, Unicode, ничьи, пустой/нет совпадений) и объясни, почему подпоследовательность O(n) на кандидата лучше Левенштейна для поиска команд. Senior-ревьюер проверяет отсутствие побочных эффектов и указание сложности.

  3. 03Асинхронные источники с дебаунсом и отменой

    Поддержи асинхронные источники действий (например, search API) рядом с синхронными командами без гонок. Важны два механизма: (1) дебаунс — откладывай вызов асинхронного источника до паузы пользователя в наборе (150–250 мс), чтобы не стрелять сетевым round trip на каждое нажатие; (2) отмена — используй AbortController для отмены in-flight fetch, когда следующее нажатие происходит до получения предыдущего результата, чтобы медленный ответ на ранний запрос никогда не перезаписывал быстрый ответ на поздний (классическая гонка out-of-order). Палитра показывает индикатор загрузки, пока асинхронный источник в полёте, прогоняет синхронные и асинхронные результаты через ту же чистую функцию ранжирования и никогда не показывает устаревшие результаты. Измерь: с дебаунсом 200 мс и симулированными ответами 300 мс против 50 мс докажи, что результат 300 мс от запроса 'a' никогда не перезаписывает результат 50 мс от запроса 'ab'.

    Критерии готовности
    • Асинхронный источник дебаунсится (150–250 мс), показывает загрузку в полёте и отменяется через AbortController при смене ввода — устаревший медленный ответ никогда не перезаписывает более новый быстрый, доказано тестом out-of-order.
    • Синхронные и асинхронные результаты сливаются через ту же чистую функцию ранжирования; пустые асинхронные результаты не скрывают синхронные команды, состояния загрузки/ошибки явные.
    Самопроверка

    Покажи out-of-order тест: запрос 'a' (медленный) → 'ab' (быстрый), докажи отбрасывание медленного результата. Senior-ревьюер проверяет обоснованность задержки дебаунса, использование AbortController и слияние синхронных+асинхронных через чистый scorer.

  4. 04Вложенные области с хлебными крошками

    Добавь области видимости, чтобы команда могла открывать новый контекст со своим отфильтрованным списком — например, вход в 'Switch project >' показывает только имена проектов, 'Theme >' — только темы. Каждая область — кадр стека `{query, activeIndex, results, breadcrumb}`; Enter на команде-области пушит, Backspace на пустом запросе (или отдельный Back) попает, хлебные крошки показывают путь (например, 'All > Switch project'). Клавиатурный редьюсер расширяется для push/pop области как чистых переходов состояния, функция ранжирования перезапускается на наборе элементов области. Докажи: зарегистрируй команду-области с 20 элементами, войди, отфильтруй внутри, выйди Backspace и покажи обновление крошек и удержание фокуса. Задокументируй, почему области — модель навигации, а не просто фильтр: они меняют набор элементов, а не только запрос.

    Критерии готовности
    • Команда-области открывает новый контекст со своим отфильтрованным списком и крошкой; Backspace на пустом запросе (или Back) выходит из области и восстанавливает предыдущий query/activeIndex — проверено тестом вложенной навигации.
    • Области — чистый переход редьюсера; цепочка крошек видима, фокус остаётся запертым при push/pop.
    Самопроверка

    Покажи вход в область, фильтрацию внутри и выход Backspace с крошками и ловушкой фокуса. Senior-ревьюер проверяет чистоту редьюсера для областей и что на push меняется набор элементов, а не только запрос.

  5. 05Доступность и виртуализованная производительность

    Сделай палитру доступной и быстрой в масштабе. Доступность: правильные ARIA — инпут `role=combobox` с `aria-expanded`, `aria-controls` на `role=listbox`, каждый элемент `role=option` с `aria-selected`, live region (`aria-live=polite`) объявляет изменения числа результатов ('3 результата' / 'Нет результатов'), чтобы скринридеры не пропускали обновления. Ловушка фокуса из этапа 1 сохраняется. Производительность: список из 10 000+ команд должен рендериться без лагов — виртуализируй так, чтобы в DOM были только 8–12 видимых строк плюс небольшой overscan-буфер, сохраняя DOM постоянного размера вне зависимости от длины списка. Компромисс — сложность отслеживания позиции прокрутки; для списков меньше ~200 элементов накладные расходы виртуализации превышают выгоду, измерь перед добавлением. Бенчмарк: отрендери 10k команд и зафиксируй INP/латентность нажатия с виртуализацией и без, а также память для пре-индексированного (trie/инвертированный индекс, строящийся один раз при регистрации, сублинейно на нажатие) против brute-force ранжирования. Обоснуй выбор числами.

    Критерии готовности
    • ARIA корректна (combobox + listbox + option + aria-selected, live region объявляет число) — проверено axe или ручным скринридером; ловушка фокуса держится.
    • 10k команд без лагов: виртуализированы (постоянный DOM) или пре-индексированы (сублинейно на нажатие) с зафиксированными бенчмарками INP/латентности и памяти и обоснованным компромиссом виртуализация-против-brute-force.
    Самопроверка

    Покажи проверку a11y (роли + live region) и бенчмарк INP на 10k элементах виртуализировано vs brute-force. Senior-ревьюер проверяет корректность ролей, объявления live region и числовое обоснование выбора производительности, а не 'всегда виртуализируй'.

Стартер

fallowlone/skein-projects

projects/command-palette

Открыть на GitHub ↗
  • README.md
  • src/palette.ts
  • test/palette.test.ts
Забрать только этот проект npx degit fallowlone/skein-projects/projects/command-palette command-palette

Реализуй заглушки, затем гоняй тесты, пока не позеленеют: bun test

Форкни репозиторий и запушь свою работу — workflow grade прогонит тесты и статические проверки на твоих раннерах.

Рубрика

Джуниор Миддл Сеньор
Качество совпадения Фильтрует элементы по вхождению подстроки; все совпадающие элементы выводятся без учёта позиции. Реализует поиск по подпоследовательности (не подстроке) и ставит префиксные / смежные совпадения выше рассеянных, чтобы нужный элемент оказывался первым. Функция оценки чистая и тестируется отдельно; ничьи разрешаются детерминированно; функция корректно обрабатывает границы Unicode, а её временная сложность указана и обоснована (O(n·m) на элемент, итого O(N·m log N)).
Корректность клавиатурной модели Стрелки перемещают выделение, Enter запускает выделенный элемент; перенос отсутствует или ломается на границах. Выделение правильно оборачивается в обоих направлениях, включая список из одного элемента или пустой; reduce — чистая функция без побочных эффектов. Редьюсер — единственный источник истины для всего клавиатурного состояния; управление фокусом (ловушка при открытии, восстановление при закрытии) вынесено за пределы редьюсера, чтобы чистая логика тестировалась в Node без DOM; граничные случаи (пустой список, быстрые нажатия) покрыты юнит-тестами.
Производительность под нагрузкой Перестраивает весь список при каждом нажатии клавиши синхронно; незаметно при коротких списках (< 50 элементов). Асинхронные источники дебаунсятся; функция ранжирования мемоизируется или пропускается при неизменном запросе; устаревшие in-flight запросы отменяются. Список из 10 000+ команд отображается без лагов: либо виртуализирован (в DOM только видимые строки), либо проиндексирован заранее (trie / инвертированный индекс строится один раз при регистрации, чтобы работа на каждое нажатие была сублинейной). Выбор обоснован измеренным компромиссом между памятью и CPU.
Эталонный разбор (спойлер)

Подпоследовательность vs подстрока: нечёткое сопоставление проверяет, что каждый символ запроса встречается в элементе по порядку, но не обязательно подряд — 'fc' совпадает с 'fuzzyCheck', потому что 'f' стоит перед 'c'. Расстояние Левенштейна — другая мера (минимум вставок/удалений/замен), которая одинаково штрафует пропуски; поиск по подпоследовательности дешевле (O(n) на кандидата) и ощущается естественнее для поиска команд, поскольку вознаграждает совпадение инициалов слов в camelCase.

Ранжирование как чистая функция: логика оценки должна принимать (items, query) и возвращать отсортированный массив без побочных эффектов — без мутаций состояния, без обращений к DOM, без асинхронной работы. Эта граница делает её юнит-тестируемой в Node, упрощает мемоизацию (те же входы → тот же выход) и позволяет слою отрисовки оставаться тонким представлением, которое только отображает ранжированные результаты в узлы DOM.

Дебаунс и отмена для асинхронных источников: дебаунс откладывает вызов асинхронного источника до паузы пользователя в наборе текста (обычно 150–250 мс), избегая сетевого round trip на каждое нажатие. AbortController позволяет отменить in-flight fetch, когда следующее нажатие происходит до получения предыдущего результата, предотвращая перезапись быстрого ответа на поздний запрос медленным ответом на ранний.

Виртуализация большого списка команд: рендеринг тысяч узлов DOM сразу вызывает перерасчёты макета. Виртуализатор (например, отрисовка только 8–12 видимых строк плюс небольшой буфер overscan) сохраняет DOM постоянного размера вне зависимости от длины списка. Компромисс — сложность отслеживания позиции прокрутки; для списков менее ~200 элементов накладные расходы виртуализации превышают выгоду, поэтому измеряй перед добавлением.

Сделай по-сеньорски

  • Добавь ранжирование по recent/frequent: поднимай недавно запускавшиеся команды и измерь, как это меняет hit rate топ-3 на симулированном логе использования.
  • Добавь ленивую регистрацию асинхронных источников (только при входе в область) и докажи отсутствие сетевой работы до push области.
  • Прогони хаос-тест, быстро регистрирующий/дерегистрирующий команды во время набора, и докажи, что ранжирование никогда не показывает устаревшую или удалённую команду.

Навыки

fuzzy search / scoring (fzf-style)async data sources with debounce + AbortControllerfocus trap + ARIA combobox/listboxcommand registry patternnested scoping & breadcrumb navigation

Рекомендуемый стек

preacttypescript

Материалы