Skip to content

feat(autocomplete, tags, tooltip, popover, dropdown, core): anchor pop-ups to the text caret (#DS-4857, #DS-4858) - #2065

Open
lskramarov wants to merge 20 commits into
mainfrom
feat/DS-4857
Open

lskramarov wants to merge 20 commits into
mainfrom
feat/DS-4857

Conversation

@lskramarov

@lskramarov lskramarov commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Opens the tooltip and the autocomplete panel at the text caret instead of the whole field (DS-4857, DS-4858), and adds a text mode to the autocomplete that completes the word or the trigger query at the caret — suggestions in a textarea, commands, mentions — with an inline hint. Everything is opt-in through new inputs; the default behavior of both components does not change.

List of notable changes:

  • added kbqRelativeToCaret and kbqRelativeToCaretVertical to the tooltip: it anchors to the caret or the selection of the field and follows it, with every placement and fallback available. By default it stays above a single-line input and at the caret line in a textarea.
  • added kbqAutocompleteRelativeToCaret: the panel opens right below the caret line and is only as wide as its options (panelMinWidth does not apply). Tag autocomplete in a wide field uses it.
  • added autocomplete text mode: kbqAutocompleteTextMode, kbqAutocompleteTriggers, kbqAutocompleteMinLength, kbqAutocompleteInlineHint and (kbqAutocompleteQueryChange). The query is the word before the caret or the text after a trigger such as / or @; choosing an option replaces only that query; Tab or accept the hint; the form value stays the whole text.
  • added WAI-ARIA 1.2 combobox semantics to the autocomplete trigger and an accessible name for its option list, taken from the field.
  • added kbqPopoverOrigin and kbqDropdownTriggerOrigin: KbqPopUpTrigger takes an origin of its own — an element or a rectangle in viewport coordinates — so the rectangle kbqCreateCaretOrigin keeps on the caret drives a popover or a dropdown too. kbqPopoverAutoFocus, kbqDropdownTriggerAutoFocus and activeDescendantNavigation keep the focus in the field while the panel is open. Two examples come with it: a formatting toolbar next to a text selection, and a menu opened by / at the caret of a contenteditable editor.
  • added caret helpers to @koobiq/components/core: kbqGetCaretRect, kbqGetSelectionRect, kbqCreateCaretOrigin, kbqGetTextQuery, kbqListenForCaretMoves and the KbqOverlayOrigin type with kbqResolveOverlayOrigin, plus the docs-private text mirror behind the hint. KbqPopUpTrigger gains a protected getAnchorSize(), so the arrow offset can be taken from the caret.
  • added examples, overview sections in both languages, dev-app and e2e scenarios.

What should reviewers focus on?

  • Caret measurement in core/form-field/caret-rect.ts: a text ruler for an input, a wrapping mirror for a textarea (it lays out the rest of the word, so a word the field wraps takes the caret along) and the selection range for contenteditable. password-like fields, fields without a text selection (email, number), right-to-left text and centred or right-aligned inputs fall back to the field box.
  • The overlay origin is measured again on every positioning pass (kbqCreateCaretOrigin), so scrolls and resizes keep the overlay on the caret. The tooltip lifts its position lock only while following the caret, so that it can still flip.
  • Text-mode state in autocomplete-trigger.directive.ts: the query and the hint are dropped when the form writes a value, when the text changes without an input event and when text mode, the hint or the autocomplete is switched off. Insertion goes through setRangeText and a dispatched input event, so native undo does not cover it.
  • The inline hint (core/form-field/text-mirror.ts) is drawn in a transparent mirror of the field and offered only where the field would lay it out: on the caret's row and inside the visible part of the field.
  • The baselines of the new screenshots — autocomplete 03, tags 06, tooltip 02 — were rendered in the e2e Docker image.
  • Origin plumbing in core/pop-up/pop-up-trigger.ts: updateOrigin() moves the position origin only — the host element keeps the event listeners, the focus target and the scroll containers the pop-up follows, which is what setExternalNativeElement moves — and null puts the position back on the host.

@github-actions github-actions Bot added the enhancement New feature or request label Sep 17, 2026
@github-actions

github-actions Bot commented Sep 17, 2026

Copy link
Copy Markdown

Visit the preview URL for this PR (updated for commit b2c7c2e):

https://koobiq-next--prs-2065-u5o91tsc.web.app

(expires Sat, 26 Sep 2026 16:32:30 GMT)

🔥 via Firebase Hosting GitHub Action 🌎

Sign: c9e37e518febda70d0317d07e8ceb35ac43c534c

@github-actions

Copy link
Copy Markdown

🚨 E2E tests failed

Review the report for details.


💡 Comment /approve-snapshots to approve snapshot changes.

@lskramarov lskramarov self-assigned this Sep 17, 2026
@lskramarov lskramarov changed the title feat(autocomplete, tags, tooltip, core): anchor tooltip and autocomplete to the text caret (#DS-4857, #DS-4858) feat(autocomplete, tags, tooltip, popover, dropdown, core): anchor pop-ups to the text caret (#DS-4857, #DS-4858) Sep 18, 2026
@lskramarov

Copy link
Copy Markdown
Contributor Author

/redeploy-preview

@github-actions

Copy link
Copy Markdown

🔄 Redeploying documentation preview.

@lskramarov
lskramarov marked this pull request as ready for review September 22, 2026 12:56
@lskramarov
lskramarov requested a review from rmnturov September 22, 2026 12:56
@artembelik
artembelik requested a balanced review from Copilot September 22, 2026 13:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

It adds a large, low-level caret-measurement and overlay-positioning surface across five components with new public API and visual baselines, which warrants final human review.

Review effort: Balanced
Findings: 1 Low severity

Open (1)
What changed in this PR

This PR adds opt-in caret-anchored positioning to Koobiq's pop-up components and a text-completion mode to the autocomplete. It introduces new caret-measurement helpers to @koobiq/components/core (kbqGetCaretRect, kbqGetSelectionRect, kbqCreateCaretOrigin, kbqGetTextQuery, kbqListenForCaretMoves, the KbqOverlayOrigin type, and a text-mirror), then wires them into the tooltip (kbqRelativeToCaret), autocomplete (kbqAutocompleteRelativeToCaret + text mode), and gives popover/dropdown their own origin and auto-focus inputs so an overlay can follow the caret while keeping focus in the field. Default behavior of every touched component is unchanged.

Changes:

  • New core caret/overlay-origin helpers and a docs-private text mirror, plus getAnchorSize()/origin plumbing on KbqPopUpTrigger so any pop-up can anchor to a rectangle in viewport coordinates.
  • Tooltip caret anchoring, autocomplete text mode with inline hint and WAI-ARIA 1.2 combobox semantics, and kbqPopoverOrigin/kbqDropdownTriggerOrigin + auto-focus / active-descendant navigation.
  • New examples, bilingual docs sections, dev-app and e2e scenarios, refreshed screenshot baselines, and updated public-API guard goldens.
File Description
tools/​public_api_guard/​components/​tooltip.api.md Records new KbqCaretVerticalAnchor, relativeToCaret(Vertical), applyRelativeToCaret, getAnchorSize.
tools/​public_api_guard/​components/​popover.api.md Records new autoFocus and popoverOrigin inputs.
tools/​public_api_guard/​components/​dropdown.api.md Records activeDescendantNavigation, autoFocus, origin, updatePosition.
tools/​public_api_guard/​components/​core.api.md Records new caret/overlay-origin public API surface.
packages/​components/​dropdown/​dropdown-trigger.directive.ts Adds origin/autoFocus inputs and updatePosition(); hasPointOrigin carries a copy-pasted JSDoc that misdescribes it.
packages/​components/​core/​overlay/​overlay-origin.ts New KbqOverlayOrigin type and resolve/size helpers (SSR-safe duck typing).
packages/​components/​core/​form-field/​text-query.ts New word/trigger query extraction with surrogate-aware walk-back.
packages/​components/​core/​form-field/​caret-rect.ts, text-mirror.ts, caret-origin.ts Caret measurement, transparent mirror hint, and per-microtask cached origin.
packages/​components/​core/​pop-up/​pop-up-trigger.ts Origin plumbing: updateOrigin, getResolvedOrigin, getAnchorSize.
packages/​components/​{tooltip,popover}/​*.ts Caret anchoring / auto-focus and origin inputs.
packages/​components/​{tags,autocomplete,dropdown,popover}/​e2e.ts + *.playwright-spec.ts + screenshots New caret scenarios and baselines.
packages/​docs-examples/​**, apps/​docs/​**, *.mdx New examples, generated module/loader, bilingual docs.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/components/dropdown/dropdown-trigger.directive.ts Outdated
Comment thread packages/components/autocomplete/autocomplete-trigger.directive.ts Outdated
Comment thread packages/components/autocomplete/autocomplete.ru.mdx
Comment thread packages/components/popover/popover.ru.mdx
Comment thread packages/components/tooltip/tooltip.component.ts
@rmnturov

rmnturov commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

Пример с поповером над выделенным текстом.
Можно доработать отдельной задачей

Заметил в примере странное поведение: когда выделяю текст мышкой слева направо, поповер появляется еще до того, как я закончил выделение. Из-за этого он в итоге оказывается не по центру выделенного текста, а ближе к левому краю.

Посоветовался с Codex и посмотрел похожие примеры.

Хорошо бы для мыши показывать поповер только после завершения выделения — на pointerup / mouseup, а не на первом selectionchange.

Для клавиатуры (Shift + Arrow) отдельного момента «закончил выделять» нет, поэтому там можно реагировать на изменение selection, но с небольшим debounce, например 100–250 мс.

Итого
— исправить момент показа тулбара
— обновлять позицию
— добавить навигацию с клавы внутрь поповера (Tab)

Смотреть со звуком. Комментарию хорошую реализацию из интернета
https://github.com/user-attachments/assets/c0042f06-703b-4cd7-b1e9-6fe38360ddee

@rmnturov

rmnturov commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

Пример «Проверка по мере ввода» в статье Validation.

Мне кажется, поведение примера не соответствует описанию выше по тексту в доках. Там написано, что сообщение пропадает при вводе корректного символа или потере фокуса. Но сейчас оно просто скрывается по таймеру и не закрывается в этих случаях.

Можно доработать отдельно

…(#DS-4857)

Adds `kbqGetCaretRect` and `kbqGetSelectionRect` to the core entry point and
`[kbqRelativeToCaret]` to the tooltip trigger, which anchors the overlay to the
caret of the field it is attached to and follows it while the user types, moves
the caret and scrolls the field.

The caret is handed to CDK as a virtual rectangle, so every placement stays
available and the usual fallback placements keep the tooltip on screen - unlike
`kbqRelativeToPointer`, which passes a bare point and is limited to top/bottom.

An `<input>` is measured with the existing text ruler, a `<textarea>` with a
wrapping mirror added next to it, and a `contenteditable` through the range the
browser already laid out. Fields that render something other than their value
and right-to-left text fall back to the field box.
…#DS-4858)

Adds `kbqAutocompleteRelativeToCaret`, which opens the autocomplete panel from the text caret,
right below the caret's line and only as wide as its options, so that tag autocomplete in a
wide field keeps the options next to the typed text.

Adds text mode to the autocomplete (`kbqAutocompleteTextMode`): the query is the word before
the caret or the text after one of `kbqAutocompleteTriggers`, choosing an option replaces only
that query, and an inline hint draws the rest of the active option after the caret, accepted
with Tab or the right arrow. The form value stays the whole text; the default mode is unchanged.

Adds `kbqRelativeToCaretVertical` to the tooltip: by default a single-line input keeps the
tooltip above the field, as in the DS-4857 mockup, and a textarea keeps it at the caret line.

The core gains the query parser, the caret listener shared by both, and the text mirror that
renders the hint in the field's own layout.
…on list (#DS-4858)

The trigger told assistive technology nothing about its panel. A text input now follows the
WAI-ARIA 1.2 combobox pattern: `role="combobox"`, `aria-autocomplete`, `aria-expanded` while the
panel is open, `aria-controls` on the option list and `aria-activedescendant` on the active option.
ARIA in HTML allows the role on a text input only, so a textarea and the search, email, tel and
url inputs keep their native role and get the rest, while other input types get none of it.
Text mode shares these bindings and still reports `aria-autocomplete="both"` for the inline hint.
A disabled autocomplete drops them all.

The option list had no accessible name, which axe reports as `aria-input-field-name`. It now
takes the name of the field that opens the panel: its `aria-labelledby`, its `aria-label`, its
label or its placeholder. `aria-controls` points at a new id on the list, so `KbqAutocomplete.id`
stays on the panel element.
…etion in step with the field (#DS-4858)

The caret is measured again whenever the overlay is positioned instead of once on opening, so a
caret-anchored panel or tooltip stays on the caret through page scrolls, window resizes and layout
shifts. A panel near the right edge of the viewport may end at the caret instead of running off
screen, a tooltip following the caret can still flip, and its arrow points at the caret rather
than at the middle of the field.

Text mode no longer acts on a stale query or hint: a value written by the form, text changed
without an input event and switching text mode, the hint or the autocomplete off all drop them,
so Tab and the right arrow cannot write an option into text it was not offered for. An option
chosen from a panel opened with `open()` goes in at the caret. Shift with an arrow selects text,
an option made active while the options arrive is no longer selected, and an OnPush view is
told about the active option.

The word before the caret is found without a pattern that backtracks over long tokens, and it
keeps combining marks. The caret measurement lays out the rest of the word, follows the field's
`white-space` and falls back to the field for centred and right-aligned inputs; the inline hint
is only offered where the field would draw it. Showing a tooltip measures nothing, so a server
render no longer throws.

The option list takes its name from a label without an id or from `title`, and the boolean
inputs of text mode accept `null` and `undefined` in strict templates. The docs describe where
the caret cannot be located and no longer contradict the panel width rules.
…ve examples (#DS-4858)

The documentation pages are compiled from MDX now, where an HTML comment is not valid syntax, so
the examples added for the caret are embedded with `<Example id="..." />` like the rest of each
page. The regenerated example module lists their ids in `LiveExampleId`.
…rary fields and the whole panel (#DS-4858)

The tooltip scenario used bare browser fields, so its screenshot showed native controls instead of
the library's; it now puts `kbqInput` and `kbqTextarea` into `kbq-form-field`. The tag scenario
filled the field with tags up to its right edge, and the panel opened from the caret there ran out
of the captured area; two tags keep the caret, and the panel, inside it.

Adds the baselines of the new screenshots, rendered in the Docker image CI uses.
…lection (#DS-4857)

Extends caret anchoring past the tooltip: `KbqPopUpTrigger` takes an origin of its
own, and the popover and the dropdown expose it as `kbqPopoverOrigin` and
`kbqDropdownTriggerOrigin`. An origin is an element or a rectangle in viewport
coordinates, so the rectangle `kbqCreateCaretOrigin` keeps on the caret is handed
straight to it.

Two documentation examples come with it: a formatting toolbar next to a text
selection, and a menu opened by `/` at the caret of a contenteditable editor. Both
need the panel to leave the caret alone, hence `kbqPopoverAutoFocus`,
`kbqDropdownTriggerAutoFocus` and `activeDescendantNavigation`, which moves the
dropdown's highlight without taking focus.
…selection toolbar work (#DS-4857)

The slash-menu editor was a hand-drawn box: no background, no typography, and two of its `var()`
references did not exist — `--kbq-states-line-theme`, so the focus border never changed, and the
`font: var(--kbq-typography-text-compact)` shorthand, which the design tokens do not publish. It
takes its background, border, radius and focus ring from the form-field tokens now, and the footer
keeps the styles the dropdown already gives it, spaces included.

The toolbar of the popover example did nothing: its button toggles carried no handler. It is a
button group now, and the buttons wrap the selection in `<strong>`, `<em>` or `<u>`, unwrap it on a
second click, and report through `aria-pressed` the formats the selection already carries. The
panel no longer scrolls: `.kbq-popover__content` grows a scrollbar from a single pixel of overflow.
…ge (#DS-4857)

The tables duplicated the generated API tab, and no other overview page carries an API section: of
144 pages the heading appeared in the two tooltip files alone. The page ends on Recommendations now,
as the rest of them do.
…4857)

The repo compiles against `lib: ["ES2022", "DOM"]`, so spreading a `NodeList` does not type-check;
`Array.from` takes the array-like instead. The dev app builds accept the spread, the docs-examples
library build and `ssr:build` do not.
…S-4858)

The keys were inline code, and the key column wrapped `Tab, →` onto two lines. They are
`docs-hot-key-button` spans now, in a min-width key column, as on the modal, sidepanel and
app-switcher pages, and the section is named after theirs: "Управление с клавиатуры" and
"Keyboard navigation".
…(#DS-4857)

The footer read "Press Esc to close" as one line of text. It holds "Close menu" and the key now,
pushed to the two ends of the row, with the key in the secondary text color.
…orners (#DS-4857)

A button group squares the corners where its buttons meet, so the active button lost its rounding
on the inner side, and the buttons touched the edge of the panel. The toolbar is a plain group of
transparent buttons now, each with its own corners, inside a 4px padding.
…r the blur tooltip to the caret (#DS-4857)

Both come from the review. `hasPointOrigin` carried the JSDoc of `shouldMatchTriggerWidth`, which
describes neither what it reports nor what it drives: pushing a point-anchored panel back into the
viewport and skipping the trigger width.

The tooltip of the validation-on-blur example still pointed at the field, although it is shown while
typing, exactly as the one in validation-on-type.
…e pop-up (#DS-4857)

A tooltip closes on scroll through `scrollStrategies.close()`, which detaches the overlay without
going through `hide()`. The pop-up is destroyed, its visibility stream completes instead of emitting
`false`, and the trigger is left `isOpen` for good: the validation examples, which show a tooltip
only while it reports closed, never show it again. `detach` resets the state and emits it now.

Keyed on `isOpen` rather than on `visible`, which mirrors the requested state and is already set by
the time `show()` detaches a previous overlay.
…n is done (#DS-4857)

Shown on the first `selectionchange`, the toolbar anchored to the first characters of a selection
the user was still dragging out, and stayed there. It opens on `pointerup` now, or after a pause
for a keyboard selection, and an open panel is moved to a selection that changed — `updatePosition`
re-reads the origin only when asked to re-apply the position.

`Tab` moves into the toolbar as well: the panel is in an overlay at the end of the document, so the
tab order walks past it.
… for an optional numeric input (#DS-4857)

Four components carried the same transform, and no two of them agreed: `null` and an empty string
became `undefined` in the autocomplete, `undefined` and `0` in the dl, and `0` in the other two,
because Angular's `numberAttribute` reads them as zero. The core helper keeps the strictest
reading, so an input bound to nothing falls back to its default instead of to `0`.
…857)

`kbqOptionalNumberAttribute` takes `unknown`, as Angular's own `numberAttribute` does: an input
transform is handed whatever the template binds. Narrowing it would narrow the write type of the
four inputs built on it, so the count is recorded rather than the parameter changed.
Two changes collided on main: #2074 added `pipe-state.spec.ts`, whose assertions live in
`expectStyle`, and #2082 replaced the `expect*` wildcard in the rule with exact names. Each was
green on its own base; together they leave `yarn run eslint` failing on main, and on every branch
rebased onto it.
… is typed (#DS-4857)

The hint now behaves the way the validation page already documents it: it goes away on the first
valid character and on focus loss, instead of only after a three-second timeout.
@lskramarov

Copy link
Copy Markdown
Contributor Author

Пример «Проверка по мере ввода» в статье Validation.

Мне кажется, поведение примера не соответствует описанию выше по тексту в доках. Там написано, что сообщение пропадает при вводе корректного символа или потере фокуса. Но сейчас оно просто скрывается по таймеру и не закрывается в этих случаях.

Можно доработать отдельно

поправил

@lskramarov
lskramarov requested a review from NikGurev September 24, 2026 06:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants