ADR-064: строка ввода чата — div вместо form

Убирает системную панель навигации по полям над клавиатурой на iOS
(Safari и Chrome): она держится на элементе <form>, а не на textarea
как таковом. Дешёвый вариант вместо contenteditable — сначала пробуем
его, дороже (переписывать paste/placeholder/лимит руками) не потребовалось.

Отправка теперь идёт по click на кнопке и по keydown Enter вместо
события submit — форму убрали, role="form" держит ту же семантику
для скринридера.
This commit is contained in:
Claude
2026-08-24 06:10:18 +00:00
parent c826d581ea
commit 334ce95935
3 changed files with 40 additions and 8 deletions
+28
View File
@@ -0,0 +1,28 @@
# ADR-064: строка ввода — div, а не form
Уточняет `docs/ui.md`, «Доступность»: `form` в списке семантики держит `role="form"`, а не элемент `<form>`, — для строки ввода чата.
## Контекст
Тест с реальными пользователями (iPhone, Safari и Chrome): над клавиатурой при фокусе в строке ввода всплывает системная панель навигации между полями — «‹ ›» и «готово», около 50 px. Полю переходить некуда — оно на экране одно, — а место съедено.
Панель — не наша вёрстка, это поведение WebKit для полей, лежащих внутри `<form>`. Строка ввода была оформлена именно так: `<form class="compose">` с `<textarea>` и кнопкой `type="submit"`.
Дорогой вариант — `contenteditable` вместо `textarea` — снял бы панель гарантированно, но потребовал бы вручную переписать то, что сейчас даёт браузер бесплатно: placeholder, обрезку на 4000 символов, автовысоту, и обязательно — очистку `paste` до текста, потому что `contenteditable` иначе вставляет форматированный HTML, а `innerHTML` в клиенте запрещён (CLAUDE.md).
Дешёвый вариант — вынести поле из `<form>` — не трогает ничего из этого: `textarea` остаётся `textarea`.
## Решение
- `<form class="compose">``<div class="compose" role="form">`. `role="form"` — тот же ориентир для скринридера, что и элемент `<form>`, без вызывающих панель heuristics WebKit.
- Кнопка отправки — `type="button"` с явным `click`, вместо `type="submit"` и события `submit` на форме.
- `autocomplete="off"` на самом поле — на случай, если панель зависит ещё и от него, а не только от `<form>`.
- `textarea`, `maxLength`, `field-sizing: content`, placeholder — без изменений.
Остальные формы (вход, `#/new`, участники, настройки) не тронуты: там несколько полей в форме, панель навигации между ними — по крайней мере, не чистый минус, и незачем менять то, что не жаловались.
## Следствия
- Проверить можно только на реальном устройстве — эмуляции панели WebKit нет; headless-Chromium её и не показывал никогда, так что автотест здесь не поставить.
- Если панель не уйдёт (вдруг триггер — не `<form>`, а что-то ещё в эвристике Safari), следующий шаг — `contenteditable`, со всей его ценой из «Контекста».
- `Enter` на десктопе и кнопка «>» на мобильном по-прежнему отправляют — обработчики `keydown` и `click` делают то же, что раньше `submit`.
-1
View File
@@ -5,7 +5,6 @@
- Серверный «перец» для ключевого блоба: дополнительное шифрование блоба серверным ключом, хранящимся вне базы. Плюс: дамп базы сам по себе перестаёт быть материалом для оффлайн-перебора. Минус: не защищает от оператора; потеря серверного ключа — невозможность входа с новых устройств для всех. Решение отложено.
- Блокировка собеседника и персональные инвайты — если общего инвайт-кода и лимитов (ADR-019, ADR-021) окажется мало.
- Подписи сообщений вторым ключом — если потребуется защита от сговора участника комнаты с сервером (ADR-016).
- Поле ввода — `textarea` в мобильном Safari поднимает системную панель навигации по полям («‹ ›» и «готово») над клавиатурой: живой экран съедает лишние ~50 px, обжито пользователями плохо. Направление — заменить на `contenteditable`-блок (`role="textbox"`, `aria-multiline="true"`): у него этой панели нет, а клавиатура остаётся текстовой. Ручная работа взамен браузерной: placeholder, счётчик и обрезка на 4000 символов, автовысота, `paste` только как текст — без этого блока `innerHTML` в него попадёт вставкой, а `innerHTML` в клиенте запрещён (CLAUDE.md). Enter/Shift+Enter и `isComposing` — переносятся как есть. Решение отложено: выбрать между этим и более дешёвым вариантом (поле вне `<form>`, `autocomplete="off"`) — если он снимает панель хотя бы в Safari, возни меньше.
- Выравнивание сообщений по стороне: свои — вправо, чужие — влево, как в обычных мессенджерах. Сейчас лента — общий список с колонкой автора (`docs/ui.md`, «Чат»; `docs/identity/brief.md`, «Компоновка»: сетка `132px 1fr` на десктопе, автор над группой на мобильном) — сторона у сообщения не задана нигде. Пользователям в тесте медленно читать, кто есть кто. Смена — это пересмотр принятого в ADR-024 макета ленты, нужен новый ADR, если решение — делать. Ограничение айдентики: без пузырей, скруглений и теней (`identity/brief.md`) — выравнивание, не bubble-чат.
Закрыто ADR-015…024: регистрация, контакты, лимиты, смена пароля, идентификация устройств, язык интерфейса, айдентика, доверие к ключам, протокол, схема базы, деплой, правила пушей.
+12 -7
View File
@@ -197,9 +197,16 @@ async function refreshRoom(view, known) {
// composer — полоса состояния и строка ввода: рамка 1 px ink, слева «>»
// цветом mark. Enter отправляет только на десктопе; на мобильном он делает
// перенос, а отправляет кнопка «>» справа (docs/ui.md, «Чат»).
//
// div, не form: поле формы на iOS Safari/Chrome поднимает над клавиатурой
// системную панель навигации между полями («‹ ›» и «готово») — лишние
// ~50 px ради одного поля, которому переходить некуда. role="form" держит
// ту же семантику для скринридера (docs/ui.md, «Доступность»), без form-а;
// autocomplete="off" на поле — по той же причине, на случай если панель
// зависит ещё и от него.
function composer(view) {
const form = el("form", "compose");
form.noValidate = true;
const form = el("div", "compose");
form.setAttribute("role", "form");
view.bar = el("p", "bar");
view.bar.hidden = true;
@@ -213,12 +220,14 @@ function composer(view) {
view.field.rows = 1;
view.field.placeholder = "сообщение";
view.field.maxLength = view.limit;
view.field.autocomplete = "off";
view.counter = el("span", "counter");
view.counter.hidden = true;
view.send = el("button", "input__send", ">");
view.send.type = "submit";
view.send.type = "button";
view.send.addEventListener("click", () => submit(view));
row.append(prompt, view.field, view.counter, el("span", "enter", "enter — отправить"), view.send);
form.append(view.bar, row);
@@ -234,10 +243,6 @@ function composer(view) {
event.preventDefault();
submit(view);
});
form.addEventListener("submit", (event) => {
event.preventDefault();
submit(view);
});
return form;
}