From 1776974ba8e9654d570695b9be4c1504ffc9708a Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Thu, 12 Nov 2026 13:45:42 +0200 Subject: [PATCH 1/9] perf(tree, list, chat): skip the rendering of items far from the view Tree items, list items and chat messages far from the view skip style, layout and paint (content-visibility: auto with contain-intrinsic-size). Style and layout take 70-90% less time, and the first render of a tree with 2,000 items takes 41% less. - Tree: only collapsed items skip, with the row height as their size. The active and the focused item always render, because Firefox and WebKit do not scroll to an element in skipped content. - List: an estimated size per --ig-size until the first render. - Chat: a resize observer on the message list keeps the list at the bottom while messages render late. Each scroll of the chat records its position, so a scroll event that merges it with a scroll of the user still stops the follow. - Specs: 8 new. 5 chat template specs read textContent, because innerText of skipped content is empty. --- CHANGELOG.md | 4 ++ src/components/chat/chat.spec.ts | 83 +++++++++++++++++++++-- src/components/chat/chat.ts | 53 +++++++++++++-- src/components/chat/spec.md | 27 +++++--- src/components/chat/themes/chat.base.scss | 7 ++ src/components/list/list.spec.ts | 45 +++++++++++- src/components/list/spec.md | 13 +++- src/components/list/themes/item.base.scss | 5 ++ src/components/tree/spec.md | 16 ++++- src/components/tree/themes/item.base.scss | 10 +++ src/components/tree/tree.spec.ts | 61 +++++++++++++++++ 11 files changed, 299 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d60fceeb7..580f038bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - #### Highlight - Setting `searchText` or `caseSensitive` to its current value no longer runs the search again, so the active match stays. Before, a framework that sets each property on each render, such as React, moved the active match back to the first match on each render. [#2516](https://github.com/IgniteUI/igniteui-webcomponents/issues/2516) - The highlight now searches again when its content changes, so you no longer call `search()` after a change. The active match stays while it starts at the same place in the same text node. Otherwise the first match becomes active. Each change searches all the content again. `search()` still searches at once and makes the first match active. +- #### Chat, List, Tree + - Tree items, list items and chat messages that are far from the view now skip their rendering until they come near it (`content-visibility: auto`). A long tree, list or chat renders faster, and a resize or a change of `--ig-size` costs less. In our measurements, style and layout took 70-90% less time, and the first render of a tree with 2,000 items took 41% less. A skipped collapsed tree item keeps the space of one row, so scroll positions do not change, and the active item and the item with the focus always render. A list item and a chat message keep an estimated space until their first render, then their real size. + - In a container that takes its width from its content, a long list or tree now takes the width of the items near the view, and the width can change while the user scrolls. `innerText` of an item that skips its rendering returns an empty string; `textContent` works. To render every item, set `content-visibility: visible` on `igc-tree-item` or `igc-list-item`, or on `igc-chat::part(message-item)`. + - While the chat follows the newest message, it now also stays at the bottom when the message list grows for another reason, such as a message that renders late, a resize of the chat, or content that loads in a message. - #### Library - Dependencies: - New runtime dependency `tslib`. The components import the TypeScript helpers from it, so a bundle has one copy of each helper. [#2461](https://github.com/IgniteUI/igniteui-webcomponents/pull/2461) diff --git a/src/components/chat/chat.spec.ts b/src/components/chat/chat.spec.ts index b3b5ab9c1..b4d3d9389 100644 --- a/src/components/chat/chat.spec.ts +++ b/src/components/chat/chat.spec.ts @@ -1,4 +1,10 @@ -import { elementUpdated, expect, fixture, nextFrame } from '@open-wc/testing'; +import { + elementUpdated, + expect, + fixture, + nextFrame, + waitUntil, +} from '@open-wc/testing'; import { sendKeys } from '@web/test-runner-commands'; import { html, nothing } from 'lit'; import { restore, spy, stub, useFakeTimers } from 'sinon'; @@ -581,8 +587,8 @@ describe('Chat', () => { for (const attachment of attachments) { const { header, content } = getChatAttachmentDOM(attachment); - expect(header.querySelector('h5')?.innerText).matches(/^Custom/); - expect(content.querySelector('p')?.innerText).matches( + expect(header.querySelector('h5')?.textContent).matches(/^Custom/); + expect(content.querySelector('p')?.textContent).matches( /^This is a template/ ); } @@ -603,7 +609,7 @@ describe('Chat', () => { for (const message of getChatDOM(chat).messages) { expect( - getChatMessageDOM(message).container.querySelector('h5')?.innerText + getChatMessageDOM(message).container.querySelector('h5')?.textContent ).to.equal(message.message.sender === 'user' ? 'You' : 'Bot'); } }); @@ -617,7 +623,7 @@ describe('Chat', () => { await elementUpdated(chat); for (const [index, message] of getChatDOM(chat).messages.entries()) { - expect(getChatMessageDOM(message).content.innerText).to.equal( + expect(getChatMessageDOM(message).content.textContent?.trim()).to.equal( chat.messages[index].text.toUpperCase() ); } @@ -635,7 +641,7 @@ describe('Chat', () => { await elementUpdated(chat); for (const message of getChatDOM(chat).messages) { - expect(getChatMessageDOM(message).actions.innerText).to.equal( + expect(getChatMessageDOM(message).actions.textContent?.trim()).to.equal( message.message.sender === 'user' ? '' : 'Custom action' ); } @@ -697,7 +703,7 @@ describe('Chat', () => { await elementUpdated(chat); for (const message of getChatDOM(chat).messages) { - expect(getChatMessageDOM(message).header.innerText).to.equal( + expect(getChatMessageDOM(message).header.textContent?.trim()).to.equal( message.message.sender === 'user' ? '' : 'AI Assistant' ); } @@ -2427,6 +2433,69 @@ describe('Chat', () => { expect(messageArea.scrollTop).to.equal(0); }); + describe('Messages far from the view', () => { + const text = (i: number) => `${i} `.repeat(10 + (i % 7) * 40); + + // Appended to the current messages: WebKit moves the list to the top when + // all the messages are replaced (a known issue, not of this feature). + const longHistory = (): IgcChatMessage[] => [ + ...chat.messages, + ...Array.from({ length: 300 }, (_, i) => ({ + id: `long-${i}`, + text: text(i), + sender: i % 2 ? 'user' : 'bot', + })), + ]; + + // The message box always renders; its content is what skips. + const skipped = (message: Element) => + !message + .shadowRoot!.querySelector('[part~="message-container"]')! + .checkVisibility({ contentVisibilityAuto: true }); + + it('skip their rendering', async () => { + await setMessages(longHistory()); + const { messages } = getChatDOM(chat); + const [first, last] = [messages[0], messages.at(-1)!]; + + // Some browsers update the skipped messages a few frames after a scroll. + await waitUntil( + () => skipped(first) && !skipped(last), + 'The far message renders, or the last one does not' + ); + + expect(skipped(first)).to.be.true; + expect(skipped(last)).to.be.false; + }); + + it('do not keep a long history from the bottom', async () => { + await setMessages(longHistory()); + expectAtBottom(); + + await setMessages([ + ...chat.messages, + { id: 'long', text: text(6), sender: 'bot' }, + ]); + expectAtBottom(); + }); + + it('do not bring back the list after the user scrolls up a little between the scrolls', async () => { + const { messageArea } = getChatDOM(chat); + + chat.messages = longHistory(); + await elementUpdated(chat); + // The first scroll to the bottom runs in this frame. Its scroll event + // comes with the next frame, together with the scroll of the user. + await nextFrame(); + messageArea.scrollTop -= 300; + await nextFrame(); + await nextFrame(); + + const { scrollHeight, scrollTop, clientHeight } = messageArea; + expect(scrollHeight - scrollTop - clientHeight).to.be.greaterThan(100); + }); + }); + it('scrolls to the bottom when the messages are replaced', async () => { await simulateScroll(getChatDOM(chat).messageArea, { top: 0 }); diff --git a/src/components/chat/chat.ts b/src/components/chat/chat.ts index 40995060e..0d5275ee1 100644 --- a/src/components/chat/chat.ts +++ b/src/components/chat/chat.ts @@ -6,9 +6,11 @@ import { html, LitElement, nothing, type PropertyValues } from 'lit'; import { property, query } from 'lit/decorators.js'; import { cache } from 'lit/directives/cache.js'; import { guard } from 'lit/directives/guard.js'; +import { ref } from 'lit/directives/ref.js'; import { repeat } from 'lit/directives/repeat.js'; import { chatContext, chatUserInputContext } from '#internals/context.js'; import { addContextProvider } from '#internals/controllers/context-provider.js'; +import { addResizeObserverController } from '#internals/controllers/resize-observer.js'; import { addSlotController, type InferSlotNames, @@ -284,6 +286,14 @@ export default class IgcChatComponent extends I18nMixin( @query('[part="message-area-container"]', true) private readonly _scrollContainer!: HTMLElement; + // Messages far from the view skip their rendering, so the list can grow after + // a scroll to the bottom, in a later frame in some browsers. + private readonly _listResize = addResizeObserverController(this, { + target: null, + requestUpdate: false, + callback: this._handleListResize, + }); + /** Whether the list follows the newest message. */ private _followNewest = true; /** Set when the suggestion slots change, so that `updated()` scrolls. */ @@ -473,15 +483,46 @@ export default class IgcChatComponent extends I18nMixin( // Read in the frame, after the layout that the update caused. requestAnimationFrame(() => { // The user can scroll up before the frame. - if (!this._followNewest) { - return; + if (this._followNewest) { + this._scrollListToBottom(); } - - const { scrollTop, scrollHeight } = this._scrollContainer; - this._scrollContainer.scrollBy({ top: scrollHeight - scrollTop }); }); } + /** Follows the bottom when the list grows, for example as messages render. */ + private _handleListResize(): void { + const { scrollTop, scrollHeight, clientHeight } = this._scrollContainer; + + if ( + this._followNewest && + !this._state.options?.disableAutoScroll && + // A move up that has no scroll event yet is the user's; the event decides. + scrollTop >= this._lastScrollTop && + scrollHeight - scrollTop - clientHeight > BOTTOM_TOLERANCE + ) { + this._scrollListToBottom(); + } + } + + /** Observes the message list, which renders only with messages. */ + private _observeList(list?: Element): void { + if (list) { + this._listResize.observe(list); + } else { + for (const target of this._listResize.targets) { + this._listResize.unobserve(target); + } + } + } + + private _scrollListToBottom(): void { + const container = this._scrollContainer; + container.scrollBy({ top: container.scrollHeight - container.scrollTop }); + // The next scroll event can merge this scroll with one of the user, so it + // compares against this position. + this._lastScrollTop = container.scrollTop; + } + private _renderHeader() { const hasPrefix = this._hasSlotted('prefix'); const hasContent = @@ -500,7 +541,7 @@ export default class IgcChatComponent extends I18nMixin( private _renderMessages() { return html` -
+
${repeat( this._state.messages, (message) => message.id, diff --git a/src/components/chat/spec.md b/src/components/chat/spec.md index 5d67b06df..3778e913e 100644 --- a/src/components/chat/spec.md +++ b/src/components/chat/spec.md @@ -63,6 +63,7 @@ | 9 | 2026-11-02 | The events of the inner text area and of the attachment chips stay inside the chat | | 10 | 2026-11-04 | The code color variables of the Markdown renderer have GitHub fallbacks and apply in light and dark mode; `igcNavigate` for links in messages | | 11 | 2026-11-06 | The Enter that commits an IME composition does not send the message | +| 12 | 2026-11-12 | A message far from the view skips its rendering; the list also follows the bottom when it grows | ## Overview @@ -192,8 +193,9 @@ to the text area of the composer. While the list is at the bottom, it follows the newest message: each change of `messages`, the typing indicator and new suggestions scroll it to the bottom. When the user scrolls up, the list keeps its position, until the user scrolls back to the bottom. A new message of the current user, and a `messages` array without the previous last -message, such as another conversation, scroll to the bottom again. `disableAutoScroll` turns the automatic scrolling -off. +message, such as another conversation, scroll to the bottom again. While the list follows, it also stays at the +bottom when it grows for another reason: a message that renders late, a resize of the chat, or content that loads in +a message. `disableAutoScroll` turns the automatic scrolling off. #### Options @@ -543,20 +545,26 @@ The component exposes a part for every region it renders. The main groups are: without the previous last message, scroll the list to the bottom. So do slotted suggestions that appear while the list is at the bottom. +#### Messages far from the view + +36. A message far from the view skips its rendering, and a message at the bottom renders. +37. With a long history of messages of different heights, the list ends at the bottom, also after a new long message. +38. A scroll of the user between two scrolls to the bottom stops the follow; the list does not go back down. + ### Names -36. The message actions are named by the resource strings, like and dislike expose `aria-pressed`, and a change of +39. The message actions are named by the resource strings, like and dislike expose `aria-pressed`, and a change of `resourceStrings` renames them. -37. The text area, the attach button, the file input, the send button, the attachments list and the remove button of +40. The text area, the attach button, the file input, the send button, the attachments list and the remove button of an attachment chip are named by the resource strings, and a change of `resourceStrings` renames them. ### Links -38. A plain click or Enter on a link in a message, also an SVG link with `xlink:href`, emits +41. A plain click or Enter on a link in a message, also an SVG link with `xlink:href`, emits `igcNavigate` on the chat, with the message and the absolute URL. The event does not bubble. When the application cancels it, the chat cancels the click. -39. A click with a modifier key, a link with another target, `download` or a URL that does not parse, a click that +42. A click with a modifier key, a link with another target, `download` or a URL that does not parse, a click that cannot be canceled and a click that the content already canceled emit no event. -40. A link outside the messages, a link around the chat and a link in a chat in a message, also in its header, emit +43. A link outside the messages, a link around the chat and a link in a chat in a message, also in its header, emit no event on the chat. The chat in the message emits the event of its own message link. ### Not covered by the suite @@ -572,7 +580,10 @@ The component exposes a part for every region it renders. The main groups are: - The component is stateless with respect to the transport. It does not send, receive, retry or persist anything; the application owns `messages` and reacts to the events. -- The message list is not virtualized, so a very long history is rendered in full. +- The message list is not virtualized. A message that is far from the view skips its rendering + (`content-visibility: auto`). Until its first render, it keeps a space of about 100px; after it, its real size. + To render every message, use `igc-chat::part(message-item) { content-visibility: visible; }`. +- `innerText` of a message that skips its rendering is an empty string. `textContent` works. - There is no message status, no avatar handling, no emoji picker, no URL preview, no search and no contextual menu. These are planned and not part of this version. - The message list is a single tab stop and has no arrow key navigation between the messages. diff --git a/src/components/chat/themes/chat.base.scss b/src/components/chat/themes/chat.base.scss index 2fb0e0ca6..6b8544e1f 100644 --- a/src/components/chat/themes/chat.base.scss +++ b/src/components/chat/themes/chat.base.scss @@ -57,6 +57,13 @@ $inline-inset: rem(24px); gap: rem(24px); } +// A message far from the view skips its rendering. A rendered message keeps its +// real size; the chat follows the list as messages render late. +[part~='message-item'] { + content-visibility: auto; + contain-intrinsic-size: auto none auto rem(100px); +} + [part='empty-state'] { height: 100%; } diff --git a/src/components/list/list.spec.ts b/src/components/list/list.spec.ts index 36a5878cc..f34acc9af 100644 --- a/src/components/list/list.spec.ts +++ b/src/components/list/list.spec.ts @@ -1,4 +1,4 @@ -import { expect, fixture, html } from '@open-wc/testing'; +import { expect, fixture, html, nextFrame, waitUntil } from '@open-wc/testing'; import { internalsOf } from '#internals/controllers/internals.js'; import { defineComponents } from '#internals/definitions/defineComponents.js'; @@ -83,4 +83,47 @@ describe('List', () => { expect(container.localName).to.equal('div'); }); }); + + describe('Items far from the view', () => { + // The item box always renders; its content is what skips. + const skipped = (item: IgcListItemComponent) => + !item + .querySelector('span')! + .checkVisibility({ contentVisibilityAuto: true }); + + let items: IgcListItemComponent[]; + + beforeEach(async () => { + const box = await fixture(html` +
+ + ${Array.from( + { length: 100 }, + (_, i) => + html` + Item ${i} + ` + )} + +
+ `); + items = Array.from(box.querySelectorAll('igc-list-item')); + await nextFrame(); + await nextFrame(); + }); + + it('skip their rendering', () => { + expect(getComputedStyle(items[0]).contentVisibility).to.equal('auto'); + expect(skipped(items[0])).to.be.false; + expect(skipped(items[99])).to.be.true; + }); + + it('render when they scroll into view', async () => { + // `scrollIntoView()` renders its target at once; the others follow in a frame. + items[99].scrollIntoView(); + await waitUntil(() => skipped(items[0]), 'The first item still renders'); + + expect(skipped(items[99])).to.be.false; + }); + }); }); diff --git a/src/components/list/spec.md b/src/components/list/spec.md index 6c61e8360..acd846835 100644 --- a/src/components/list/spec.md +++ b/src/components/list/spec.md @@ -44,6 +44,7 @@ This directory hosts three public components: [`igc-list`](#igc-list), [`igc-lis | 2 | 2026-10-05 | A header has the `listitem` role | | 3 | 2026-10-05 | The title and subtitle container adds no role | | 4 | 2026-10-21 | API descriptions aligned with the JSDoc. | +| 5 | 2026-11-12 | An item far from the view skips its rendering | ## Overview @@ -222,6 +223,11 @@ The suite lives in [`list.spec.ts`](./list.spec.ts) and runs in a real browser t 5. The title and subtitle container is a `div`, so it adds no role around the title and the subtitle. +### Items far from the view + +6. An item far from the view skips its rendering; an item in view renders. +7. An item renders when it scrolls into view, and the item that left the view skips its rendering again. + ### Not covered by the suite - The fixture of the third scenario nests `igc-list` elements between the headers instead of `igc-list-item` @@ -234,7 +240,12 @@ The suite lives in [`list.spec.ts`](./list.spec.ts) and runs in a real browser t ## Assumptions and limitations - The list does not own a data source and does not virtualize. For large collections use - [`igc-virtual-scroll`](../virtualization/spec.md). + [`igc-virtual-scroll`](../virtualization/spec.md). An item that is far from the view skips its rendering + (`content-visibility: auto`). Until its first render, it keeps about the space of an item with a title and a + subtitle; after it, its real size. To render every item, set `content-visibility: visible` on `igc-list-item`. +- In a container that takes its width from its content, a long list takes the width of the items near the view, and + the width can change while the user scrolls. +- `innerText` of an item that skips its rendering is an empty string. `textContent` works. - Selection state lives on the individual items; the list neither enforces nor tracks it. - The list does not manage focus or keyboard navigation between the items. diff --git a/src/components/list/themes/item.base.scss b/src/components/list/themes/item.base.scss index 85360c3de..d1186e650 100644 --- a/src/components/list/themes/item.base.scss +++ b/src/components/list/themes/item.base.scss @@ -9,6 +9,11 @@ padding-inline: pad-inline(rem(4px), rem(8px), rem(16px)); padding-block: pad-block(rem(2px), rem(4px), rem(8px)); + // An item far from the view skips its rendering. The placeholder is about + // an item with a title and a subtitle; a rendered item keeps its real size. + content-visibility: auto; + contain-intrinsic-size: auto none auto sizable(rem(46px), rem(50px), rem(56px)); + ::slotted(igc-avatar) { --ig-size: 1; } diff --git a/src/components/tree/spec.md b/src/components/tree/spec.md index d4910e19d..3b612364a 100644 --- a/src/components/tree/spec.md +++ b/src/components/tree/spec.md @@ -51,6 +51,7 @@ | 3 | 2026-10-12 | `items` is empty during server-side rendering | | 4 | 2026-10-21 | API descriptions aligned with the JSDoc. | | 5 | 2026-11-02 | A real click on the checkbox of an item sends no `igcChange` out of the item | +| 6 | 2026-11-12 | A collapsed item far from the view skips its rendering, except the active and the focused item | ## Overview @@ -454,6 +455,12 @@ module with the fixtures, the selectors and the assertion helpers the other thre 59. A delegating item copies the host label and `aria-labelledby`, keeps an own label of its delegate, removes the copy, and moves it to a new delegate. An item that does not delegate keeps its label. +### Items far from the view + +60. A collapsed item far from the view skips its rendering; an expanded item and an item in view render. +61. A skipped item keeps the height of one row. +62. An item renders when it scrolls into view, and the item that left the view skips its rendering again. + ### Not covered by the suite - The `loading` state and the `loading` slot are exercised only through the slot rendering test; there is no @@ -469,8 +476,13 @@ module with the fixtures, the selectors and the assertion helpers the other thre available after the tree connects in the browser. - End-user selection is only possible through the checkbox of an item. A click on the label never changes the selection. -- The tree is not virtualized. Every item is rendered, so very large hierarchies should be built with load on - demand. +- The tree is not virtualized. Every item is in the DOM, so very large hierarchies should be built with load on + demand. A collapsed item that is far from the view skips its rendering (`content-visibility: auto`) and keeps the + space of one row (`--ig-tree-size`). The active item and the item with the focus always render, because the tree + scrolls to their row. To render every item, set `content-visibility: visible` on `igc-tree-item`. +- In a container that takes its width from its content, a long tree takes the width of the items near the view, and + the width can change while the user scrolls. +- `innerText` of an item that skips its rendering is an empty string. `textContent` works. - Cascade selection derives the state of a parent from its children, so setting `selected` on a parent and on a child inconsistently resolves in favor of the cascade. - The tree does not restrict what an item renders, so the developer is responsible for the accessibility of any diff --git a/src/components/tree/themes/item.base.scss b/src/components/tree/themes/item.base.scss index 1cd03ae9f..0b64ad08c 100644 --- a/src/components/tree/themes/item.base.scss +++ b/src/components/tree/themes/item.base.scss @@ -13,6 +13,16 @@ } } +// A collapsed item far from the view skips its rendering. It is one row of the +// size token, so the placeholder size is exact. An expanded item always renders +// its row, and each child decides for itself. The active and the focused item +// render too: the tree scrolls to their row, and Firefox and WebKit do not +// scroll to an element in skipped content. +:host(:not([expanded], [active], :focus-within)) { + content-visibility: auto; + contain-intrinsic-size: auto none auto var(--size); +} + :host(:focus-within) { outline: none; } diff --git a/src/components/tree/tree.spec.ts b/src/components/tree/tree.spec.ts index 2141e057a..043e2c103 100644 --- a/src/components/tree/tree.spec.ts +++ b/src/components/tree/tree.spec.ts @@ -4,6 +4,7 @@ import { expect, fixture, html, + nextFrame, waitUntil, } from '@open-wc/testing'; import { registerI18n, setCurrentI18n } from 'igniteui-i18n-core'; @@ -528,6 +529,66 @@ describe('Tree', () => { }); }); + describe('Items far from the view', () => { + // The item box always renders; its content is what skips. + const skipped = (item: IgcTreeItemComponent) => + !item.renderRoot + .querySelector('[part~="wrapper"]')! + .checkVisibility({ contentVisibilityAuto: true }); + + beforeEach(async () => { + const box = await fixture(html` +
+ + + + + ${Array.from( + { length: 100 }, + (_, i) => html`` + )} + +
+ `); + tree = box.querySelector(IgcTreeComponent.tagName)!; + await elementUpdated(tree); + await nextFrame(); + await nextFrame(); + }); + + it('skip their rendering when they are collapsed', () => { + const [parent, child, first] = tree.items; + const last = tree.items.at(-1)!; + + expect(getComputedStyle(parent).contentVisibility).to.equal('visible'); + expect(getComputedStyle(child).contentVisibility).to.equal('auto'); + expect(skipped(first)).to.be.false; + expect(skipped(last)).to.be.true; + }); + + it('keep the row height of the item as their size', () => { + const [, , first] = tree.items; + const last = tree.items.at(-1)!; + const row = first.getBoundingClientRect().height; + + expect(getComputedStyle(last).containIntrinsicHeight).to.equal( + `auto ${row}px` + ); + expect(last.getBoundingClientRect().height).to.equal(row); + }); + + it('render when they scroll into view', async () => { + const [, , first] = tree.items; + const last = tree.items.at(-1)!; + + // `scrollIntoView()` renders its target at once; the others follow in a frame. + last.scrollIntoView(); + await waitUntil(() => skipped(first), 'The first item still renders'); + + expect(skipped(last)).to.be.false; + }); + }); + describe('Outside of a tree', () => { it('Should render a standalone item at the root level without a tree', async () => { const item = await fixture(html` From 229f9da5cd9e2677dac2ae4a519cd6ff15c7ce81 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Thu, 12 Nov 2026 13:46:00 +0200 Subject: [PATCH 2/9] feat(internals): add a hidden-until-found controller Groundwork for find-in-page in collapsed content. The controller binds hidden="until-found" on the content of a collapsible host and opens the host on beforematch, through its user path with events. The attribute stays the browser's until the next task, because a removal in the microtasks after the event stops the reveal of the outer ancestors in Chromium and WebKit. A refused open sets plain hidden, and live() restores until-found after the reveal. - The component base sheet no longer forces display: none on hidden="until-found". - lit-analyzer treats hidden as a global attribute, because its data still calls hidden boolean. --- .../controllers/hidden-until-found.spec.ts | 244 ++++++++++++++++++ .../controllers/hidden-until-found.ts | 112 ++++++++ src/styles/common/component.scss | 4 +- tsconfig.json | 2 +- 4 files changed, 360 insertions(+), 2 deletions(-) create mode 100644 src/internals/controllers/hidden-until-found.spec.ts create mode 100644 src/internals/controllers/hidden-until-found.ts diff --git a/src/internals/controllers/hidden-until-found.spec.ts b/src/internals/controllers/hidden-until-found.spec.ts new file mode 100644 index 000000000..2f6e37e69 --- /dev/null +++ b/src/internals/controllers/hidden-until-found.spec.ts @@ -0,0 +1,244 @@ +import { + aTimeout, + defineCE, + elementUpdated, + expect, + fixture, + html, + unsafeStatic, +} from '@open-wc/testing'; +import { resetMouse, sendMouse } from '@web/test-runner-commands'; +import { LitElement, type TemplateResult } from 'lit'; +import { property } from 'lit/decorators.js'; +import { styles as componentBase } from '../../styles/common/component.css.js'; +import { addHiddenUntilFoundController } from './hidden-until-found.js'; + +describe('Hidden until found controller', () => { + /** The order of the reveals of all hosts in a test. */ + let order: string[]; + + class TestHost extends LitElement { + public static override styles = [componentBase]; + + @property({ type: Boolean, reflect: true }) + public open = false; + + @property({ type: Boolean }) + public findable = true; + + /** Refuses the open, as a handler that cancels the opening event. */ + public refuse = false; + + /** `revealing` at each call of `reveal`. */ + public readonly reveals: boolean[] = []; + + public readonly untilFound = addHiddenUntilFoundController(this, { + reveal: () => { + this.reveals.push(this.untilFound.revealing); + order.push(this.id); + + if (!this.refuse) { + this.open = true; + } + + return this.open; + }, + }); + + public get content(): HTMLElement { + return this.renderRoot.querySelector('[part="content"]')!; + } + + protected override render() { + return html` + + `; + } + } + + let tag: ReturnType; + let targetCount = 0; + + before(() => { + tag = unsafeStatic(defineCE(TestHost)); + }); + + beforeEach(() => { + order = []; + }); + + afterEach(async () => { + history.replaceState(null, '', location.pathname + location.search); + await resetMouse(); + }); + + /** A fixture with a link to a new target id, and the content around it. */ + async function setup( + content: (id: string) => TemplateResult + ): Promise<{ root: HTMLElement; target: HTMLElement; link: HTMLElement }> { + const id = `until-found-target-${++targetCount}`; + const root = await fixture(html` +
+ Go + ${content(id)} +
+ `); + + return { + root, + target: root.querySelector(`#${id}`)!, + link: root.querySelector('a')!, + }; + } + + /** A trusted click, which reveals with the timing of find-in-page. */ + async function follow(link: HTMLElement): Promise { + const { left, top, width, height } = link.getBoundingClientRect(); + await sendMouse({ + type: 'click', + position: [Math.round(left + width / 2), Math.round(top + height / 2)], + }); + } + + /** Waits for the end of the reveal, in the next task, and the render after it. */ + async function settle(...hosts: TestHost[]): Promise { + await aTimeout(0); + await Promise.all(hosts.map((host) => elementUpdated(host))); + } + + const hostOf = (root: HTMLElement, id = 'host') => + root.querySelector(`#${id}`)!; + + it('hides the content until found while it is closed', async () => { + const { root } = await setup( + (id) => html`<${tag} id="host">

Match

` + ); + const host = hostOf(root); + + expect(host.content.getAttribute('hidden')).to.equal('until-found'); + // The base sheet keeps the box of `until-found`, so find-in-page can reach it. + expect(getComputedStyle(host.content).display).to.not.equal('none'); + + host.findable = false; + await elementUpdated(host); + + expect(host.content.getAttribute('hidden')).to.equal(''); + expect(getComputedStyle(host.content).display).to.equal('none'); + + host.open = true; + await elementUpdated(host); + + expect(host.content.hasAttribute('hidden')).to.be.false; + }); + + it('opens the host when a link targets its content', async () => { + const { root, target, link } = await setup( + (id) => html`<${tag} id="host">

Match

` + ); + const host = hostOf(root); + + await follow(link); + + expect(host.open).to.be.true; + expect(host.reveals).to.eql([true]); + expect(host.content.hasAttribute('hidden')).to.be.false; + + await settle(host); + + expect(host.untilFound.revealing).to.be.false; + expect(host.content.hasAttribute('hidden')).to.be.false; + expect(target.checkVisibility()).to.be.true; + }); + + it('opens nested hosts from the inside out', async () => { + const { root, target, link } = await setup( + (id) => html` + <${tag} id="outer"> + <${tag} id="inner">

Match

+ + ` + ); + const outer = hostOf(root, 'outer'); + const inner = hostOf(root, 'inner'); + + await follow(link); + await settle(outer, inner); + + expect(order).to.eql(['inner', 'outer']); + expect(outer.open).to.be.true; + expect(inner.open).to.be.true; + expect(outer.content.hasAttribute('hidden')).to.be.false; + expect(inner.content.hasAttribute('hidden')).to.be.false; + expect(target.checkVisibility()).to.be.true; + }); + + it('keeps the content hidden when the host refuses to open', async () => { + const { root, target, link } = await setup( + (id) => html`<${tag} id="host">

Match

` + ); + const host = hostOf(root); + host.refuse = true; + + await follow(link); + await settle(host); + + expect(host.reveals).to.eql([true]); + expect(host.open).to.be.false; + expect(host.content.getAttribute('hidden')).to.equal('until-found'); + expect(target.checkVisibility()).to.be.false; + }); + + it('does not reveal content that is not findable', async () => { + const { root, target, link } = await setup( + (id) => + html`<${tag} id="host" .findable=${false} + >

Match

` + ); + const host = hostOf(root); + + await follow(link); + await settle(host); + + expect(host.reveals).to.be.empty; + expect(host.open).to.be.false; + expect(host.content.getAttribute('hidden')).to.equal(''); + expect(target.checkVisibility()).to.be.false; + }); + + it('ignores the reveal of an element in the slotted content', async () => { + const { root, target, link } = await setup( + (id) => html` + <${tag} id="host" open> + + + ` + ); + const host = hostOf(root); + + await follow(link); + await settle(host); + + expect(host.reveals).to.be.empty; + expect(target.checkVisibility()).to.be.true; + }); + + it('ends the reveal when the host disconnects', async () => { + const { root } = await setup( + (id) => html`<${tag} id="host">

Match

` + ); + const host = hostOf(root); + + host.content.dispatchEvent(new Event('beforematch')); + expect(host.untilFound.revealing).to.be.true; + + host.remove(); + expect(host.untilFound.revealing).to.be.false; + }); +}); diff --git a/src/internals/controllers/hidden-until-found.ts b/src/internals/controllers/hidden-until-found.ts new file mode 100644 index 000000000..d513a8ae6 --- /dev/null +++ b/src/internals/controllers/hidden-until-found.ts @@ -0,0 +1,112 @@ +import { + noChange, + nothing, + type ReactiveController, + type ReactiveControllerHost, +} from 'lit'; +import type { DirectiveResult } from 'lit/directive.js'; +import { type LiveDirective, live } from 'lit/directives/live.js'; + +type HiddenUntilFoundOptions = { + /** + * Opens the host the way a user does, with its events. Returns whether the + * host is open, or opening, when the call returns: `false` when a handler + * of the cancelable event stopped the open. + */ + reveal: () => boolean; +}; + +/** + * Hides the content of a collapsible host with `hidden="until-found"`, so that + * find-in-page, a text fragment or a link to an element in the content opens + * the host. + * + * @remarks + * The host binds {@link HiddenUntilFoundController.value} to the `hidden` + * attribute of the content element and + * {@link HiddenUntilFoundController.handleBeforeMatch} as its `beforematch` + * listener. The element that carries `hidden` must have no + * author `display` rule: where the browser does not know `until-found`, its + * `[hidden]` rule hides the element. + * + * The browser removes the attribute of each closed ancestor, innermost first, + * and stops when an attribute changed in a `beforematch` handler or in the + * microtasks after it. So the host state changes at once, and the attribute + * stays the browser's until the next task. + */ +class HiddenUntilFoundController implements ReactiveController { + private readonly _host: ReactiveControllerHost; + private readonly _options: HiddenUntilFoundOptions; + private _timer?: ReturnType; + + constructor(host: ReactiveControllerHost, options: HiddenUntilFoundOptions) { + this._host = host; + this._options = options; + host.addController(this); + } + + /** + * Whether a reveal of the browser runs. The host opens without its + * animation then: the browser scrolls to the match right after the reveal. + */ + public get revealing(): boolean { + return this._timer !== undefined; + } + + /** + * The value of the `hidden` attribute of the content: `until-found` while it + * is hidden and `findable`, an empty string (plain `hidden`) while it is + * hidden and not findable, and no attribute while it shows. + */ + public value( + hidden: boolean, + findable = true + ): DirectiveResult | typeof noChange { + if (this.revealing) { + return noChange; + } + + // `live()` writes the value back after the browser removed it. + return live(hidden ? (findable ? 'until-found' : '') : nothing); + } + + /** The `beforematch` listener of the content element. */ + public readonly handleBeforeMatch = (event: Event): void => { + // An element of the slotted content that the browser reveals. + if (event.target !== event.currentTarget) { + return; + } + + clearTimeout(this._timer); + this._timer = setTimeout(() => this._end()); + + if (!this._options.reveal()) { + // Stops the reveal where the browser follows the standard. Elsewhere, + // the next render hides the content again. + (event.currentTarget as Element).setAttribute('hidden', ''); + } + }; + + private _end(): void { + this._timer = undefined; + this._host.requestUpdate(); + } + + /** @internal */ + public hostDisconnected(): void { + if (this.revealing) { + clearTimeout(this._timer); + this._end(); + } + } +} + +/** Creates a {@link HiddenUntilFoundController} for the given host. */ +export function addHiddenUntilFoundController( + host: ReactiveControllerHost, + options: HiddenUntilFoundOptions +): HiddenUntilFoundController { + return new HiddenUntilFoundController(host, options); +} + +export type { HiddenUntilFoundController }; diff --git a/src/styles/common/component.scss b/src/styles/common/component.scss index 4e606445e..ccb7e75f9 100644 --- a/src/styles/common/component.scss +++ b/src/styles/common/component.scss @@ -22,8 +22,10 @@ } } +// `hidden="until-found"` keeps its box, so that find-in-page can reveal it. +// Where the browser does not know the value, its own `[hidden]` rule applies. :host([hidden]), -[hidden] { +[hidden]:not([hidden='until-found' i]) { display: none !important; } diff --git a/tsconfig.json b/tsconfig.json index 3f9112ac2..02289082d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,7 +27,7 @@ { "name": "ts-lit-plugin", "strict": true, - "globalAttributes": ["command", "commandfor", "popover", "inert"], + "globalAttributes": ["command", "commandfor", "popover", "inert", "hidden"], "rules": { "no-incompatible-type-binding": "warning" } From bfb9e7272ccd41f8844f5dd371377b5840a9230e Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Thu, 12 Nov 2026 13:57:48 +0200 Subject: [PATCH 3/9] feat(expansion-panel, accordion): open a collapsed panel for find-in-page Find-in-page, a text fragment or a link to an element in the content now opens a collapsed expansion panel. The content uses hidden="until-found" through the hidden-until-found controller, and the panel opens through the user path: igcOpening (cancelable) and igcOpened. A disabled panel stays closed, with plain hidden. - The content shows, and is inert, only during the close animation. The open animation removes hidden first, so it measures the content. - While a reveal runs in any host (isRevealing()), panels open and close without their animation. Before, a single-expand accordion that animated a panel closed above the match left the match out of view. - Specs reveal with a real click on a link (clickWithMouse). The 6 lines that asserted inert now assert hidden. --- CHANGELOG.md | 2 + api-reference/igc-expansion-panel.md | 3 +- src/components/accordion/accordion.spec.ts | 57 ++++++++ src/components/accordion/spec.md | 10 ++ .../expansion-panel/expansion-panel.spec.ts | 123 +++++++++++++++++- .../expansion-panel/expansion-panel.ts | 50 ++++++- src/components/expansion-panel/spec.md | 32 +++++ .../controllers/hidden-until-found.spec.ts | 41 +++--- .../controllers/hidden-until-found.ts | 24 +++- src/internals/testing/helpers.spec.ts | 25 +++- 10 files changed, 327 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 580f038bd..ea1957f81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] ### Added +- #### Accordion, Expansion panel + - Find-in-page now searches the content of a collapsed expansion panel. A match, a text fragment or a link to an element in the content opens the panel at once and the browser scrolls to the match. The panel sends `igcOpening`, which can be canceled, and `igcOpened`, as for a user toggle. A disabled panel stays closed and its content is not searchable. In an accordion with `single-expand`, the other panels close at once, without the animation, so the match stays in view. The content uses `hidden="until-found"` (Chrome 102, Firefox 139, Safari 26.2); other browsers hide it as before. - #### API documentation - The manifest and the API documentation now list the custom states: `ig-invalid` of the form controls, `disabled` and `label-before` of the radio group, and `start-collapsed` and `end-collapsed` of the splitter. Style them with `:state()`, for example `igc-input:state(ig-invalid)`. - They also list the parts that were not documented: `prefixed`, `suffixed`, `filled`, `labelled`, `start`, `notch`, `filler` and `end` of the input, mask input, file input, date time input and textarea, `placeholder` of the textarea, `file-parts` of the file input, `dialog-base`, `title` and `footer` of the date pickers in dialog mode, `tabs`, `inner` and `scrollable` of the tabs, `indicator` and `inactive` of the carousel, and `counter` and `fraction` of the progress indicators. diff --git a/api-reference/igc-expansion-panel.md b/api-reference/igc-expansion-panel.md index 37fc74700..9f5c4d42f 100644 --- a/api-reference/igc-expansion-panel.md +++ b/api-reference/igc-expansion-panel.md @@ -1,7 +1,8 @@ # igc-expansion-panel Shows a header with a title and a subtitle, and content that the user can -expand and collapse. +expand and collapse. Find-in-page, a text fragment or a link to an element in +the content opens a collapsed panel that is not disabled. ```ts import { defineComponents, IgcExpansionPanelComponent } from 'igniteui-webcomponents'; diff --git a/src/components/accordion/accordion.spec.ts b/src/components/accordion/accordion.spec.ts index 2e0ec80be..b12752f23 100644 --- a/src/components/accordion/accordion.spec.ts +++ b/src/components/accordion/accordion.spec.ts @@ -9,6 +9,11 @@ import { shiftKey, } from '#internals/controllers/key-bindings.js'; import { defineComponents } from '#internals/definitions/defineComponents.js'; +import { + allowMotion, + clickWithMouse, + resetLocation, +} from '#internals/testing/helpers.spec.js'; import { simulateClick, simulateKeyboard, @@ -325,6 +330,58 @@ describe('Accordion', () => { expect(panels[2].matches(':focus')).to.be.false; }); }); + + describe('Content found by the browser', () => { + allowMotion(); + + let link: HTMLAnchorElement; + let target: HTMLElement; + + beforeEach(async () => { + const root = await fixture(html` +
+ Go to the content + + + First +
Tall content
+
+ + Second +

Content

+
+ + Third +

Found content

+
+
+
+
+ `); + accordion = root.querySelector(IgcAccordionComponent.tagName)!; + panels = accordion.panels; + link = root.querySelector('a')!; + target = root.querySelector('#accordion-target')!; + }); + + afterEach(resetLocation); + + it('closes the other panels, and the match stays in view', async () => { + const closed = new Promise((resolve) => + panels[0].addEventListener('igcClosed', resolve, { once: true }) + ); + + await clickWithMouse(link); + verifyPanelsOpenState([false, false, true]); + + // The first panel closes with its animation, above the match. + await closed; + const { top, bottom } = target.getBoundingClientRect(); + + expect(top).to.be.at.least(0); + expect(bottom).to.be.at.most(innerHeight); + }); + }); }); function createAccordion() { diff --git a/src/components/accordion/spec.md b/src/components/accordion/spec.md index 3fe3a6bcb..42a1f9927 100644 --- a/src/components/accordion/spec.md +++ b/src/components/accordion/spec.md @@ -41,6 +41,7 @@ | 1 | 2026-09-21 | Initial specification | | 2 | 2026-10-01 | The navigation skips panels that do not render | | 3 | 2026-10-21 | API descriptions aligned with the JSDoc. | +| 4 | 2026-11-12 | A panel that the browser opens closes the others at once | ## Overview @@ -127,6 +128,10 @@ previously expanded panel collapses at the same time. Expanding a collapsed panel collapses the currently expanded one. Without the property, any number of panels can be expanded together, and only explicit user interaction or the API changes their state. +Find-in-page, a text fragment or a link to an element in a collapsed panel opens it (see the +[expansion panel](../expansion-panel/spec.md#content-found-by-the-browser)). With single expansion, the other panels +then close at once, without the animation, so the match stays where the browser scrolled to. + #### Programmatic control ```typescript @@ -241,6 +246,11 @@ with `@open-wc/testing` fixtures and assertions. The groups below mirror the `de 11. A nested accordion keeps its own navigation and single expansion behavior, and does not affect the outer one. +### Content found by the browser + +12. With single expansion and motion on, a link to an element in a collapsed panel opens it and closes the expanded + panel above it, and the target stays in view. + ## Assumptions and limitations - Only direct `igc-expansion-panel` children are members of the accordion. diff --git a/src/components/expansion-panel/expansion-panel.spec.ts b/src/components/expansion-panel/expansion-panel.spec.ts index a1d67935c..9035f5736 100644 --- a/src/components/expansion-panel/expansion-panel.spec.ts +++ b/src/components/expansion-panel/expansion-panel.spec.ts @@ -1,4 +1,5 @@ import { + aTimeout, elementUpdated, expect, fixture, @@ -18,7 +19,9 @@ import { import { defineComponents } from '#internals/definitions/defineComponents.js'; import { allowMotion, + clickWithMouse, getAnimationsFor, + resetLocation, } from '#internals/testing/helpers.spec.js'; import { runHostAriaTests } from '#internals/testing/host-aria.spec.js'; import { @@ -124,7 +127,7 @@ describe('Expansion Panel', () => {
-
+ `; diff --git a/src/components/tree/tree.spec.ts b/src/components/tree/tree.spec.ts index 043e2c103..f9508b3ee 100644 --- a/src/components/tree/tree.spec.ts +++ b/src/components/tree/tree.spec.ts @@ -14,7 +14,14 @@ import { } from 'igniteui-i18n-resources'; import { spy } from 'sinon'; import { defineComponents } from '#internals/definitions/defineComponents.js'; -import { scrolledIntoView } from '#internals/testing/helpers.spec.js'; +import { + allowMotion, + clickWithMouse, + finishAnimationsFor, + getAnimationsFor, + resetLocation, + scrolledIntoView, +} from '#internals/testing/helpers.spec.js'; import type IgcCheckboxComponent from '../checkbox/checkbox.js'; import type IgcTreeItemComponent from './tree-item.js'; import { @@ -178,12 +185,12 @@ describe('Tree', () => { const content1 = topLevelItems[0].renderRoot.querySelector( 'div[role="group"]' ) as HTMLElement; - expect(content1.inert).to.be.true; + expect(content1).to.have.attribute('hidden', 'until-found'); const content2 = topLevelItems[1].renderRoot.querySelector( 'div[role="group"]' ) as HTMLElement; - expect(content2.inert).to.be.false; + expect(content2).to.not.have.attribute('hidden'); }); it('Should not render expand indicator if an item has no children', async () => { @@ -589,6 +596,200 @@ describe('Tree', () => { }); }); + describe('Content found by the browser', () => { + let box: HTMLElement; + let link: HTMLAnchorElement; + let target: HTMLElement; + let items: Record; + let eventSpy: ReturnType; + + const groupOf = (item: IgcTreeItemComponent) => + item.renderRoot.querySelector('[role="group"]')!; + + async function setup(singleBranchExpand = false): Promise { + const root = await fixture(html` +
+ Go to the item +
+ + + + + + ${Array.from( + { length: 30 }, + (_, i) => + html`` + )} + + + + Found + + + + +
+
+ `); + tree = root.querySelector(IgcTreeComponent.tagName)!; + box = tree.parentElement!; + link = root.querySelector('a')!; + target = root.querySelector('#tree-target')!; + items = Object.fromEntries( + tree.items.filter((item) => item.id).map((item) => [item.id, item]) + ); + await elementUpdated(tree); + + if (singleBranchExpand) { + // The tree collapses the other branches when it starts; the branch + // opens again here, so that the reveal must collapse it. + finishAnimationsFor(tree, { subtree: true }); + items.branch.expanded = true; + await elementUpdated(items.branch); + } + + eventSpy = spy(tree, 'emitEvent'); + } + + const expansionEvents = () => + (eventSpy.args as [string, CustomEventInit][]) + .filter(([name]) => name.startsWith('igcItemExpand')) + .map(([name, init]) => `${name} ${init.detail!.label}`); + + afterEach(resetLocation); + + it('hides the children of a collapsed item until found, and of a disabled one from the browser', async () => { + await setup(); + + expect(groupOf(items.outer)).to.have.attribute('hidden', 'until-found'); + expect(groupOf(items.branch)).to.not.have.attribute('hidden'); + + items.outer.disabled = true; + await elementUpdated(items.outer); + + expect(groupOf(items.outer)).to.have.attribute('hidden', ''); + }); + + it('expands the path to a linked item from the inside out, with the user events', async () => { + await setup(); + + await clickWithMouse(link); + await waitUntil(() => expansionEvents().length === 4); + + expect(items.inner.expanded).to.be.true; + expect(items.outer.expanded).to.be.true; + // Each item ends its expansion before the browser reveals the next one. + expect(expansionEvents()).to.eql([ + 'igcItemExpanding Inner', + 'igcItemExpanded Inner', + 'igcItemExpanding Outer', + 'igcItemExpanded Outer', + ]); + expect(target.checkVisibility()).to.be.true; + }); + + it('stays collapsed when `igcItemExpanding` is canceled', async () => { + await setup(); + tree.addEventListener('igcItemExpanding', (e) => e.preventDefault()); + + await clickWithMouse(link); + await aTimeout(0); + await elementUpdated(items.outer); + + // The group attributes, not `checkVisibility()`: WebKit reports the + // target visible in a nested `until-found` group. + expect(items.inner.expanded).to.be.false; + expect(items.outer.expanded).to.be.false; + expect(groupOf(items.inner)).to.have.attribute('hidden', 'until-found'); + expect(groupOf(items.outer)).to.have.attribute('hidden', 'until-found'); + }); + + it('does not expand a disabled item', async () => { + await setup(); + items.inner.disabled = true; + await elementUpdated(items.inner); + + await clickWithMouse(link); + await aTimeout(0); + + // A link still reveals the enabled ancestors; find-in-page finds nothing. + expect(items.inner.expanded).to.be.false; + expect(target.checkVisibility()).to.be.false; + }); + + it('keeps the path to the match expanded with `singleBranchExpand`', async () => { + await setup(true); + + await clickWithMouse(link); + await aTimeout(0); + await elementUpdated(tree); + + expect(items.branch.expanded).to.be.false; + expect(items.outer.expanded).to.be.true; + expect(items.inner.expanded).to.be.true; + expect(target.checkVisibility()).to.be.true; + }); + + describe('with motion', () => { + allowMotion(); + + it('expands at once, and the match stays in view when another branch collapses', async () => { + await setup(true); + items.first.focus(); + + await clickWithMouse(link); + + expect(getAnimationsFor(tree, { subtree: true })).to.be.empty; + expect( + items.outer.shadowRoot!.getAnimations().length + + items.branch.shadowRoot!.getAnimations().length + ).to.equal(0); + + // A collapse smooth-scrolls to the focused item; the scroll of the + // browser to the match ends it. WebKit does not scroll to this match + // (a target in a collapsed item that skips its rendering), so the spec + // checks that the scroll does not change. + const scrolled = box.scrollTop; + await aTimeout(500); + expect(box.scrollTop).to.equal(scrolled); + }); + + it('animates the expand from the height of the children', async () => { + await setup(); + + const expanding = items.outer.expandWithEvent(); + const [animation] = items.outer.shadowRoot!.getAnimations(); + const keyframes = (animation.effect as KeyframeEffect).getKeyframes(); + const group = groupOf(items.outer); + + expect(group.scrollHeight).to.be.greaterThan(0); + expect(Number.parseFloat(String(keyframes.at(-1)!.height))).to.equal( + group.scrollHeight + ); + await expanding; + }); + + it('shows the children during the collapse animation, inert only then', async () => { + await setup(); + const group = groupOf(items.branch); + + const collapsing = items.branch.collapseWithEvent(); + await elementUpdated(items.branch); + + expect(items.branch.shadowRoot!.getAnimations()).not.to.be.empty; + expect(group).to.not.have.attribute('hidden'); + expect(group.inert).to.be.true; + + await collapsing; + await elementUpdated(items.branch); + + expect(group).to.have.attribute('hidden', 'until-found'); + expect(group.inert).to.be.false; + }); + }); + }); + describe('Outside of a tree', () => { it('Should render a standalone item at the root level without a tree', async () => { const item = await fixture(html` diff --git a/src/components/tree/tree.ts b/src/components/tree/tree.ts index e0426e068..726d726f7 100644 --- a/src/components/tree/tree.ts +++ b/src/components/tree/tree.ts @@ -1,6 +1,7 @@ import { TreeResourceStringsEN } from 'igniteui-i18n-core'; import { html, isServer, LitElement, type PropertyValues } from 'lit'; import { property } from 'lit/decorators.js'; +import { isRevealing } from '#internals/controllers/hidden-until-found.js'; import { blazorAdditionalDependencies } from '#internals/decorators/blazorAdditionalDependencies.js'; import { registerComponent } from '#internals/definitions/register.js'; import { EventEmitterMixin } from '#internals/mixins/event-emitter.js'; @@ -167,9 +168,12 @@ export default class IgcTreeComponent extends I18nMixin( /** @hidden @internal Collapses every item except the ancestors of `item`. */ public _collapseOtherBranches(item: IgcTreeItemComponent | null): void { const keepExpanded = new Set(item ? item.path.slice(0, -1) : []); + // A reveal of the browser expands the path to its match from the inside + // out, so the items below `item` are on that path. + const revealing = isRevealing(); for (const other of this.items) { - if (!keepExpanded.has(other)) { + if (!(keepExpanded.has(other) || (revealing && item?.contains(other)))) { other.collapseWithEvent(); } } From b8d3422abbf02a6ba58a460b8b1115fdd9cf52fb Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Thu, 12 Nov 2026 14:28:33 +0200 Subject: [PATCH 5/9] feat(tabs): select the tab of a panel for find-in-page Find-in-page, a text fragment or a link to an element in a panel that is not selected now selects its tab. The panels use hidden="until-found" through the hidden-until-found controller, and the tab is selected as a click does, with igcChange, but without moving the focus or following the link of a link tab. The panel of a disabled tab has plain hidden. - A hidden panel takes no height and does not scroll, so it does not cover the selected panel in their shared area. - A hidden tab panel and the children container of a collapsed tree item get aria-hidden. The browser keeps their box for find-in-page, and Chromium exposed them as empty containers. - Specs reveal with a real click on a link. The checks for inert and for display: none on unselected panels now check hidden. --- CHANGELOG.md | 1 + api-reference/igc-tab.md | 3 +- src/components/tabs/spec.md | 24 +++++ src/components/tabs/tab.spec.ts | 13 ++- src/components/tabs/tab.ts | 17 ++- src/components/tabs/tabs.spec.ts | 131 ++++++++++++++++++++++- src/components/tabs/tabs.ts | 11 ++ src/components/tabs/themes/tab.base.scss | 11 +- src/components/tree/spec.md | 7 +- src/components/tree/tree-item.ts | 2 + src/components/tree/tree.spec.ts | 3 + 11 files changed, 206 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29e7ca531..2cbac65ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - `header-background` theme token. It paints the full header strip behind the tab items. Until you set it, it follows `item-background`, so a translucent `item-background` does not stack on a copy of itself. You can also make only the header transparent. The `tabs-theme` function of `igniteui-theming` 29.1.0 has the matching `$header-background` parameter. [#2431](https://github.com/IgniteUI/igniteui-webcomponents/pull/2431) - Link tabs: with `href`, a tab renders its header as a link with the tab role. The user can open the link in a new browser tab with a middle click, Ctrl/Cmd+click or Ctrl/Cmd+Enter, and copy its address. These do not change the selection. A plain click, Enter or Space selects the tab, emits `igcChange`, emits `igcNavigate` on the tab and follows the link. Cancel `igcNavigate` to route on the client; a click in the panel does not emit it. The event does not bubble and is not composed. `target` and `rel` go to the link. A click on a tab whose `target`, or the target of the page's `` element, is not `_self` does not change the selection. In a page that is not in a frame, `_parent` and `_top` count as `_self`. A disabled tab has no URL. - `headerPosition` (`header-position`) puts the headers in a column on the `start` or `end` side of the panels. The default, `top`, keeps the row above the panels. The sides follow the direction, so a `start` column is on the right in a right-to-left layout. In a column, Up and Down move between the tabs, the tab list has `aria-orientation="vertical"`, `alignment` applies along the column, and the indicator is on the side of the panels in every theme. The column is as wide as the widest header, up to the width limit of a header. In a narrow column, a long label wraps and the panel keeps part of the width. Each header is at least as tall as in a row. Without a set height, the tabs grow with the selected panel. Give the tabs a `height` or a `max-height`, or put them in a parent that sets a height, to make a taller panel scroll its content and to show scroll buttons above and below the headers when they do not fit. A parent that sizes the tabs from their content, such as a grid row with an auto height, lets them grow with the panel. The new `TabsHeaderPosition` type describes the values. [#2553](https://github.com/IgniteUI/igniteui-webcomponents/issues/2553) + - Find-in-page now searches the panels that are not selected. A match, a text fragment or a link to an element in a panel selects its tab and sends `igcChange`, as a click does. The reveal does not move the focus, and a link tab does not follow its link. The panel of a disabled tab is not searchable. The panels use `hidden="until-found"` (Chrome 102, Firefox 139, Safari 26.2); other browsers hide them as before. - #### Text selection - The date picker, date time input, input, mask input and textarea have read-only `selectionStart` and `selectionEnd`. Before, you could set the selection with `setSelectionRange()`, but not read it. In the input, both are `null` for the `email` and `number` types, as on the native input. In the mask input and the date time input, they give the selection from `setRangeText()` when `updateComplete` resolves. [#2451](https://github.com/IgniteUI/igniteui-webcomponents/pull/2451) [#2452](https://github.com/IgniteUI/igniteui-webcomponents/pull/2452) - #### Theming diff --git a/api-reference/igc-tab.md b/api-reference/igc-tab.md index 4dbf72912..07b31cb56 100644 --- a/api-reference/igc-tab.md +++ b/api-reference/igc-tab.md @@ -1,6 +1,7 @@ # igc-tab -A tab nested in a tabs component. +A tab nested in a tabs component. Find-in-page, a text fragment or a link to +an element in the panel of a tab that is not disabled selects it. ```ts import { defineComponents, IgcTabComponent } from 'igniteui-webcomponents'; diff --git a/src/components/tabs/spec.md b/src/components/tabs/spec.md index ead10efc9..cd3406ad0 100644 --- a/src/components/tabs/spec.md +++ b/src/components/tabs/spec.md @@ -69,6 +69,7 @@ This directory hosts two public components: [`igc-tabs`](#igc-tabs) and [`igc-ta | 11 | 2026-10-29 | In a column, each header has the minimum height of a header row, the indicator follows the headers that `alignment` moves, and a `max-height` on the tabs, without their padding and border, limits the column as a definite height does. A selection made in the same task as a change of position stays. A tab that leaves its tabs drops the position. With `justify`, a hidden tab leaves no gap, and tabs in a panel keep the width limit of a header. The scroll buttons and the selection of a hidden tab do not scroll the strip | | 12 | 2026-11-04 | `igcNavigate` is not composed, so it does not reach a shadow host around the tabs; `_parent` and `_top` count as `_self` in a page that is not in a frame | | 13 | 2026-11-05 | With `prefers-reduced-motion: reduce`, the scroll buttons and the selection scroll the header strip at once; the scroll buttons follow the `scroll-behavior` of the `tabs` part | +| 14 | 2026-11-12 | Find-in-page and links to an element in a panel select its tab; a panel that is not selected has `hidden="until-found"` | ## Overview @@ -183,6 +184,13 @@ The `selected` attribute of the tabs also sets the selection, after each change selected at once, the last one in document order wins, as in the initial markup. A selected tab that moves keeps the selection. +#### Content found by the browser + +A panel that is not selected has `hidden="until-found"`, or plain `hidden` when its tab is disabled. Find-in-page +searches it, and a match, a text fragment or a link to an element in it selects the tab with `igcChange`, as a click +does. The reveal does not move the focus, and a link tab does not follow its link. A hidden panel takes no height, so +it does not cover the selected panel in the shared area. + #### Alignment ```html @@ -595,6 +603,15 @@ The groups below mirror the `describe` blocks. 58. Each tab gets the position, also a tab added later. 59. A column hides at the same time as a hidden ancestor (the shared `runHiddenAncestorTests` suite). +### Content found by the browser + +60. A link to an element in a panel selects its tab with `igcChange`, hides the other panels until found, and does + not move the focus. +61. A disabled tab hides its panel with plain `hidden` and is not selected. +62. A click on the selected panel reaches its content: the hidden panels in the shared area take no height. +63. A link tab is selected without following its link. +64. A collapsed expansion panel in a panel opens, and the tab is selected. + ## Assumptions and limitations - A tab owns both its header and its body; the two cannot be declared separately. @@ -612,6 +629,11 @@ The groups below mirror the `describe` blocks. [Prefix and suffix content](#prefix-and-suffix-content). - With `activation="auto"`, the arrow keys select a link tab but never follow its link. - Do not put the Angular `routerLink` directive on a tab. See [Link tabs](#link-tabs). +- Find-in-page reaches a panel that is not selected where the browser supports `hidden="until-found"`: Chrome 102, + Firefox 139 and Safari 26.2. Firefox before 148 and Safari 26.2 select the tab but do not scroll to the match. Other + browsers treat the panel as plain `hidden`. A `display` value on `::part(tab-body)` shows the hidden panels there. +- A tab finds its tabs with `closest()`. A tab projected through a slot of another component is not revealed: its + panel stays hidden. ## Accessibility @@ -619,6 +641,8 @@ The groups below mirror the `describe` blocks. - The header strip is a tab list, each header is a tab, and each body is a tab panel labelled by its header, following the WAI-ARIA tabs pattern. +- A panel that is not selected keeps a box for find-in-page, so it also has `aria-hidden="true"`: assistive + technology skips it, as before. - The header strip is a single tab stop: the selected header carries the tab index and the arrow keys move within the strip. - The tab list has `aria-orientation="horizontal"` in a row and `aria-orientation="vertical"` in a column, so screen diff --git a/src/components/tabs/tab.spec.ts b/src/components/tabs/tab.spec.ts index a0dec30bf..a7a4e9558 100644 --- a/src/components/tabs/tab.spec.ts +++ b/src/components/tabs/tab.spec.ts @@ -144,20 +144,23 @@ describe('Tab component', () => { expect(getTabDOM(element.tabs[0]).header.tabIndex).to.equal(0); }); - it('makes only the selected panel focusable and inerts the rest', async () => { + it('makes only the selected panel focusable and hides the rest until found', async () => { const [first, second] = element.tabs; expect(getTabDOM(first).body.tabIndex).to.equal(0); - expect(getTabDOM(first).body.inert).to.be.false; + expect(getTabDOM(first).body).to.not.have.attribute('hidden'); expect(getTabDOM(second).body.tabIndex).to.equal(-1); - expect(getTabDOM(second).body.inert).to.be.true; + expect(getTabDOM(second).body).to.have.attribute('hidden', 'until-found'); + // The browser keeps the box of the hidden panel; assistive technology skips it. + expect(getTabDOM(first).body).to.not.have.attribute('aria-hidden'); + expect(getTabDOM(second).body).to.have.attribute('aria-hidden', 'true'); element.select(second); await elementUpdated(element); - expect(getTabDOM(first).body.inert).to.be.true; + expect(getTabDOM(first).body).to.have.attribute('hidden', 'until-found'); expect(getTabDOM(second).body.tabIndex).to.equal(0); - expect(getTabDOM(second).body.inert).to.be.false; + expect(getTabDOM(second).body).to.not.have.attribute('hidden'); }); it('reflects the selected and disabled state to the header', async () => { diff --git a/src/components/tabs/tab.ts b/src/components/tabs/tab.ts index 853312b97..f5cf52011 100644 --- a/src/components/tabs/tab.ts +++ b/src/components/tabs/tab.ts @@ -13,6 +13,7 @@ import { hostAria, } from '#internals/controllers/aria-projection.js'; import { addAsyncContextConsumer } from '#internals/controllers/async-consumer.js'; +import { addHiddenUntilFoundController } from '#internals/controllers/hidden-until-found.js'; import { addInternalsController } from '#internals/controllers/internals.js'; import { registerComponent } from '#internals/definitions/register.js'; import { EventEmitterMixin } from '#internals/mixins/event-emitter.js'; @@ -34,7 +35,8 @@ export interface IgcTabComponentEventMap { } /** - * A tab nested in a tabs component. + * A tab nested in a tabs component. Find-in-page, a text fragment or a link to + * an element in the panel of a tab that is not disabled selects it. * * @element igc-tab * @@ -81,6 +83,15 @@ export default class IgcTabComponent extends EventEmitterMixin { + this.closest('igc-tabs')?._revealTab(this); + return this.selected; + }, + }); + //#endregion //#region Public properties @@ -264,7 +275,9 @@ export default class IgcTabComponent extends EventEmitterMixin
diff --git a/src/components/tabs/tabs.spec.ts b/src/components/tabs/tabs.spec.ts index 5fc359e4a..bf78d319c 100644 --- a/src/components/tabs/tabs.spec.ts +++ b/src/components/tabs/tabs.spec.ts @@ -22,8 +22,10 @@ import { import { defineComponents } from '#internals/definitions/defineComponents.js'; import { allowMotion, + clickWithMouse, finishAnimationsFor, isUnderPageLayer, + resetLocation, withDocumentSheets, } from '#internals/testing/helpers.spec.js'; import { runHiddenAncestorTests } from '#internals/testing/hidden-ancestor.spec.js'; @@ -41,6 +43,7 @@ import { styles as fluent } from '../../styles/themes/light/fluent.css.js'; import { styles as indigo } from '../../styles/themes/light/indigo.css.js'; import { styles as material } from '../../styles/themes/light/material.css.js'; import type IgcIconButtonComponent from '../button/icon-button.js'; +import IgcExpansionPanelComponent from '../expansion-panel/expansion-panel.js'; import IgcTabComponent from './tab.js'; import IgcTabsComponent from './tabs.js'; @@ -122,8 +125,8 @@ describe('Tabs component', () => { verifySelection(tabsComponent, tabsComponent.tabs[1]); for (const tab of tabsComponent.tabs) { - expect(getComputedStyle(getTabDOM(tab).body).display).to.equal( - tab.selected ? 'block' : 'none' + expect(getTabDOM(tab).body.getAttribute('hidden')).to.equal( + tab.selected ? null : 'until-found' ); } }); @@ -3726,6 +3729,130 @@ describe('Tabs component', () => { `); }); + describe('Content found by the browser', () => { + let tabs: IgcTabsComponent; + let link: HTMLAnchorElement; + let changeSpy: ReturnType; + + async function setup(content: ReturnType): Promise { + const root = await fixture(html` +
+ Go to the content + ${content} +
+ `); + tabs = root.querySelector(IgcTabsComponent.tagName)!; + link = root.querySelector('a')!; + changeSpy = spy(); + tabs.addEventListener('igcChange', changeSpy); + } + + afterEach(resetLocation); + + it('selects the tab with `igcChange` when a link targets its panel', async () => { + await setup(html` + + One + Two +

Three

+
+ `); + const [first, , third] = tabs.tabs; + + await clickWithMouse(link); + await elementUpdated(tabs); + + expect(tabs.selectedTab).to.equal(third); + expect(changeSpy.calledOnce).to.be.true; + expect(changeSpy.firstCall.args[0].detail).to.equal(third); + expect(getTabDOM(third).body).to.not.have.attribute('hidden'); + expect(getTabDOM(first).body).to.have.attribute('hidden', 'until-found'); + // A reveal does not move the focus, as a click on the header does. + expect(document.activeElement).to.not.equal(third); + }); + + it('does not select a disabled tab', async () => { + await setup(html` + + One +

Two

+
+ `); + const [first, second] = tabs.tabs; + + expect(getTabDOM(second).body).to.have.attribute('hidden', ''); + + await clickWithMouse(link); + await elementUpdated(tabs); + + expect(tabs.selectedTab).to.equal(first); + expect(changeSpy.called).to.be.false; + }); + + it('lets a click on the selected panel reach its content', async () => { + await setup(html` + + +

Two

+ Three +
+ `); + const button = tabs.querySelector('button')!; + const clicked = spy(); + button.addEventListener('click', clicked); + + // The hidden panels share the area of the selected one. + await clickWithMouse(button); + + expect(clicked.calledOnce).to.be.true; + }); + + it('selects a link tab without following its link', async () => { + await setup(html` + + One + +

Two

+
+
+ `); + const [, second] = tabs.tabs; + const navigateSpy = spy(); + second.addEventListener('igcNavigate', navigateSpy); + + await clickWithMouse(link); + await elementUpdated(tabs); + + expect(tabs.selectedTab).to.equal(second); + expect(navigateSpy.called).to.be.false; + expect(location.hash).to.equal('#tabs-target'); + }); + + it('opens a collapsed expansion panel in a panel, and selects the tab', async () => { + defineComponents(IgcExpansionPanelComponent); + await setup(html` + + One + + + Details +

Found

+
+
+
+ `); + const [, second] = tabs.tabs; + const panel = second.querySelector(IgcExpansionPanelComponent.tagName)!; + + await clickWithMouse(link); + await elementUpdated(tabs); + + expect(panel.open).to.be.true; + expect(tabs.selectedTab).to.equal(second); + expect(tabs.querySelector('#tabs-target')!.checkVisibility()).to.be.true; + }); + }); + runHiddenAncestorTests(html` One diff --git a/src/components/tabs/tabs.ts b/src/components/tabs/tabs.ts index 71426a805..be6b2a4b5 100644 --- a/src/components/tabs/tabs.ts +++ b/src/components/tabs/tabs.ts @@ -508,6 +508,17 @@ export default class IgcTabsComponent extends EventEmitterMixin diff --git a/src/components/tree/tree.spec.ts b/src/components/tree/tree.spec.ts index f9508b3ee..c9c8570eb 100644 --- a/src/components/tree/tree.spec.ts +++ b/src/components/tree/tree.spec.ts @@ -664,6 +664,9 @@ describe('Tree', () => { expect(groupOf(items.outer)).to.have.attribute('hidden', 'until-found'); expect(groupOf(items.branch)).to.not.have.attribute('hidden'); + // The browser keeps the box of the hidden group; assistive technology skips it. + expect(groupOf(items.outer)).to.have.attribute('aria-hidden', 'true'); + expect(groupOf(items.branch)).to.not.have.attribute('aria-hidden'); items.outer.disabled = true; await elementUpdated(items.outer); From 826cb7b7b2663fa956ff15675e624fd3895ad202 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Thu, 12 Nov 2026 14:32:32 +0200 Subject: [PATCH 6/9] docs(tree, list, chat): note the Safari find limit of skipped items In Safari before 26, which includes iOS 18, find-in-page does not find the text of an item that skips its rendering (content-visibility: auto). The spec files of the tree, the list and the chat, and the CHANGELOG entry of the change, now say so. --- CHANGELOG.md | 2 +- src/components/chat/spec.md | 2 ++ src/components/list/spec.md | 2 ++ src/components/tree/spec.md | 2 ++ 4 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cbac65ad..1e64a6981 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,7 +89,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - The highlight now searches again when its content changes, so you no longer call `search()` after a change. The active match stays while it starts at the same place in the same text node. Otherwise the first match becomes active. Each change searches all the content again. `search()` still searches at once and makes the first match active. - #### Chat, List, Tree - Tree items, list items and chat messages that are far from the view now skip their rendering until they come near it (`content-visibility: auto`). A long tree, list or chat renders faster, and a resize or a change of `--ig-size` costs less. In our measurements, style and layout took 70-90% less time, and the first render of a tree with 2,000 items took 41% less. A skipped collapsed tree item keeps the space of one row, so scroll positions do not change, and the active item and the item with the focus always render. A list item and a chat message keep an estimated space until their first render, then their real size. - - In a container that takes its width from its content, a long list or tree now takes the width of the items near the view, and the width can change while the user scrolls. `innerText` of an item that skips its rendering returns an empty string; `textContent` works. To render every item, set `content-visibility: visible` on `igc-tree-item` or `igc-list-item`, or on `igc-chat::part(message-item)`. + - In a container that takes its width from its content, a long list or tree now takes the width of the items near the view, and the width can change while the user scrolls. `innerText` of an item that skips its rendering returns an empty string; `textContent` works. In Safari before 26, which includes iOS 18, find-in-page does not find the text of an item that skips its rendering. To render every item, set `content-visibility: visible` on `igc-tree-item` or `igc-list-item`, or on `igc-chat::part(message-item)`. - While the chat follows the newest message, it now also stays at the bottom when the message list grows for another reason, such as a message that renders late, a resize of the chat, or content that loads in a message. - #### Library - Dependencies: diff --git a/src/components/chat/spec.md b/src/components/chat/spec.md index 3778e913e..16d3653f1 100644 --- a/src/components/chat/spec.md +++ b/src/components/chat/spec.md @@ -584,6 +584,8 @@ The component exposes a part for every region it renders. The main groups are: (`content-visibility: auto`). Until its first render, it keeps a space of about 100px; after it, its real size. To render every message, use `igc-chat::part(message-item) { content-visibility: visible; }`. - `innerText` of a message that skips its rendering is an empty string. `textContent` works. +- In Safari before 26, which includes iOS 18, find-in-page does not find the text of a message that skips its + rendering. Safari 26 and the other browsers find it. - There is no message status, no avatar handling, no emoji picker, no URL preview, no search and no contextual menu. These are planned and not part of this version. - The message list is a single tab stop and has no arrow key navigation between the messages. diff --git a/src/components/list/spec.md b/src/components/list/spec.md index acd846835..be73f70fb 100644 --- a/src/components/list/spec.md +++ b/src/components/list/spec.md @@ -246,6 +246,8 @@ The suite lives in [`list.spec.ts`](./list.spec.ts) and runs in a real browser t - In a container that takes its width from its content, a long list takes the width of the items near the view, and the width can change while the user scrolls. - `innerText` of an item that skips its rendering is an empty string. `textContent` works. +- In Safari before 26, which includes iOS 18, find-in-page does not find the text of an item that skips its + rendering. Safari 26 and the other browsers find it. - Selection state lives on the individual items; the list neither enforces nor tracks it. - The list does not manage focus or keyboard navigation between the items. diff --git a/src/components/tree/spec.md b/src/components/tree/spec.md index fcfe0eaff..5cc5b9bcf 100644 --- a/src/components/tree/spec.md +++ b/src/components/tree/spec.md @@ -505,6 +505,8 @@ module with the fixtures, the selectors and the assertion helpers the other thre - In a container that takes its width from its content, a long tree takes the width of the items near the view, and the width can change while the user scrolls. - `innerText` of an item that skips its rendering is an empty string. `textContent` works. +- In Safari before 26, which includes iOS 18, find-in-page does not find the text of an item that skips its + rendering. Safari 26 and the other browsers find it. - Cascade selection derives the state of a parent from its children, so setting `selected` on a parent and on a child inconsistently resolves in favor of the cascade. - The tree does not restrict what an item renders, so the developer is responsible for the accessibility of any From a56ce43aa195c1329c87029c004eceafb3bb1f1b Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Thu, 12 Nov 2026 14:38:46 +0200 Subject: [PATCH 7/9] docs(stories): add a help center story for find-in-page The HelpCenter story of the expansion panel keeps the answers of a help page in closed panels. Find-in-page, the links under "Popular answers" and a text fragment open the panel with the match, and a status line shows the igcOpened event. The AdvancedSettings description now says that the content of a closed panel is hidden, not inert. --- stories/expansion-panel.stories.ts | 119 ++++++++++++++++++++++++++++- 1 file changed, 117 insertions(+), 2 deletions(-) diff --git a/stories/expansion-panel.stories.ts b/stories/expansion-panel.stories.ts index f83ae0530..562fd6f89 100644 --- a/stories/expansion-panel.stories.ts +++ b/stories/expansion-panel.stories.ts @@ -40,7 +40,7 @@ const metadata: Meta = { docs: { description: { component: - 'Shows a header with a title and a subtitle, and content that the user can\nexpand and collapse.', + 'Shows a header with a title and a subtitle, and content that the user can\nexpand and collapse. Find-in-page, a text fragment or a link to an element in\nthe content opens a collapsed panel that is not disabled.', }, }, actions: { @@ -194,6 +194,33 @@ const styles = html` align-content: start; padding: 1rem 1.5rem; } + + .ep-help { + display: grid; + gap: 1rem; + max-width: 40rem; + } + + .ep-help :is(h3, p, ul) { + margin: 0; + } + + .ep-help ul { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 1.5rem; + padding: 0; + list-style: none; + } + + .ep-answers { + display: grid; + border-block-start: 1px solid var(--ig-gray-300); + } + + .ep-answers igc-expansion-panel { + border-block-end: 1px solid var(--ig-gray-300); + } `; @@ -272,7 +299,7 @@ export const AdvancedSettings: Story = { docs: { description: { story: - 'A form that shows only the required fields first, and keeps the advanced settings in a closed panel. The `subtitle` slot sums up the advanced settings, so the user sees the values without opening the panel, and the summary follows each change. `indicator-position="end"` puts the indicator after the text. After a failed submit, the form controls move the focus to the first invalid field. But the content of a closed panel is inert, so a field in it cannot take the focus, and the submit fails without a visible reason. So the form has `novalidate`, and the submit handler calls `checkValidity()`. When the first invalid field is in a closed panel, the handler opens the panel with `show()`. Then it moves the focus to the field. Clear "Default branch", close the panel, and then submit.', + 'A form that shows only the required fields first, and keeps the advanced settings in a closed panel. The `subtitle` slot sums up the advanced settings, so the user sees the values without opening the panel, and the summary follows each change. `indicator-position="end"` puts the indicator after the text. After a failed submit, the form controls move the focus to the first invalid field. But the content of a closed panel is hidden, so a field in it cannot take the focus, and the submit fails without a visible reason. So the form has `novalidate`, and the submit handler calls `checkValidity()`. When the first invalid field is in a closed panel, the handler opens the panel with `show()`. Then it moves the focus to the field. Clear "Default branch", close the panel, and then submit.', }, }, }, @@ -471,3 +498,91 @@ export const Sidebar: Story = {
`; }, }; + +// region help center + +const answers = [ + { + id: 'refunds', + question: 'Can I get a refund?', + answer: + 'Yes. Ask for a refund within 30 days of the purchase, in Billing > Orders. The money goes back to the card that you paid with, in 5 to 10 business days.', + }, + { + id: 'plan', + question: 'How do I change my plan?', + answer: + 'Open Billing > Plan and select the new plan. An upgrade starts at once, and you pay the difference for the rest of the month. A downgrade starts at the next renewal.', + }, + { + id: 'invoices', + question: 'Where do I find my invoices?', + answer: + 'Open Billing > Invoices. Each invoice has a PDF download. Add a company name and a tax number before you download it.', + }, + { + id: 'payment', + question: 'Which payment methods can I use?', + answer: + 'Visa, Mastercard and American Express cards. Yearly plans can also use a bank transfer.', + }, + { + id: 'cancel', + question: 'How do I cancel my subscription?', + answer: + 'Open Billing > Plan and select Cancel subscription. The workspace stays active until the end of the paid period.', + }, +]; + +const popular = ['refunds', 'invoices', 'cancel']; + +export const HelpCenter: Story = { + argTypes: disableStoryControls(metadata), + parameters: { + docs: { + description: { + story: + 'A help page that keeps its answers in closed panels. The browser searches the content of a closed panel: press Ctrl+F (Command+F on macOS), search for "invoice", and the panel with the match opens. The links under "Popular answers" point to an element in an answer, so they open its panel too. A link to the page with such a fragment, or with a text fragment, opens the panel when the page loads. The panel opens as it does for the user, with `igcOpening` and `igcOpened`, and the status line shows the last answer that opened. A closed panel hides its content with `hidden="until-found"`. A browser that does not support it hides the content as before and does not search it.', + }, + }, + }, + render: () => { + const opened = (event: Event) => { + const panel = event.currentTarget as IgcExpansionPanelComponent; + const question = panel.querySelector('[slot="title"]')!.textContent; + + panel + .closest('.ep-help')! + .querySelector('[role="status"]')!.textContent = `Opened: ${question}`; + }; + + return html` + ${styles} +
+

Billing help

+ +

+
+ ${answers.map( + ({ id, question, answer }) => html` + + ${question} +

${answer}

+
+ ` + )} +
+
+ `; + }, +}; From 4a3e906c37c1ddeb35ec3351c4026e1f35a0c972 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Thu, 12 Nov 2026 15:39:58 +0200 Subject: [PATCH 8/9] refactor(internals): move the until-found toggle steps into the controller The hidden-until-found controller now plays the open and close animation of its host. It skips the animation during a reveal, shows the content before an open measures it, and keeps the content visible and inert while a close runs. The expansion panel and the tree item drop their own copies of these steps. The controller uses createTimer and a set of the revealing controllers, the spec helpers reuse getCenterPoint, and long comments are shorter. --- src/components/chat/chat.spec.ts | 2 +- src/components/chat/chat.ts | 6 +- .../expansion-panel/expansion-panel.ts | 41 ++------- src/components/tabs/tab.ts | 2 - src/components/tabs/tabs.ts | 3 +- src/components/tree/themes/item.base.scss | 8 +- src/components/tree/tree-item.ts | 44 ++-------- .../controllers/hidden-until-found.spec.ts | 6 -- .../controllers/hidden-until-found.ts | 85 ++++++++++++------- src/internals/testing/helpers.spec.ts | 16 ++-- stories/expansion-panel.stories.ts | 24 +++--- 11 files changed, 100 insertions(+), 137 deletions(-) diff --git a/src/components/chat/chat.spec.ts b/src/components/chat/chat.spec.ts index b4d3d9389..8840c5dfd 100644 --- a/src/components/chat/chat.spec.ts +++ b/src/components/chat/chat.spec.ts @@ -2437,7 +2437,7 @@ describe('Chat', () => { const text = (i: number) => `${i} `.repeat(10 + (i % 7) * 40); // Appended to the current messages: WebKit moves the list to the top when - // all the messages are replaced (a known issue, not of this feature). + // all the messages are replaced. const longHistory = (): IgcChatMessage[] => [ ...chat.messages, ...Array.from({ length: 300 }, (_, i) => ({ diff --git a/src/components/chat/chat.ts b/src/components/chat/chat.ts index 0d5275ee1..9cd3fcfc6 100644 --- a/src/components/chat/chat.ts +++ b/src/components/chat/chat.ts @@ -491,11 +491,13 @@ export default class IgcChatComponent extends I18nMixin( /** Follows the bottom when the list grows, for example as messages render. */ private _handleListResize(): void { + if (!this._followNewest || this._state.options?.disableAutoScroll) { + return; + } + const { scrollTop, scrollHeight, clientHeight } = this._scrollContainer; if ( - this._followNewest && - !this._state.options?.disableAutoScroll && // A move up that has no scroll event yet is the user's; the event decides. scrollTop >= this._lastScrollTop && scrollHeight - scrollTop - clientHeight > BOTTOM_TOLERANCE diff --git a/src/components/expansion-panel/expansion-panel.ts b/src/components/expansion-panel/expansion-panel.ts index cf2d0496a..af9c51b36 100644 --- a/src/components/expansion-panel/expansion-panel.ts +++ b/src/components/expansion-panel/expansion-panel.ts @@ -1,5 +1,5 @@ import { html, LitElement, nothing } from 'lit'; -import { property, state } from 'lit/decorators.js'; +import { property } from 'lit/decorators.js'; import { createRef, ref } from 'lit/directives/ref.js'; import { addAnimationController } from '#animations/player.js'; import { growVerIn, growVerOut } from '#animations/presets/grow/index.js'; @@ -7,10 +7,7 @@ import { ariaBindings, hostAria, } from '#internals/controllers/aria-projection.js'; -import { - addHiddenUntilFoundController, - isRevealing, -} from '#internals/controllers/hidden-until-found.js'; +import { addHiddenUntilFoundController } from '#internals/controllers/hidden-until-found.js'; import { addKeybindings, altKey, @@ -90,39 +87,20 @@ export default class IgcExpansionPanelComponent extends EventEmitterMixin this, transition: (open, signal) => { this.open = open; - - // The browser scrolls to the match right after a reveal, also of another - // panel, such as one that closes this panel in an accordion. - if (isRevealing()) { - return true; - } - - if (open) { - // The animation measures the content, so it shows before the render. - this._panelRef.value?.removeAttribute('hidden'); - return this._player.playExclusive(growVerIn(), signal); - } - - this._closing = true; - return this._player.playExclusive(growVerOut(), signal).finally(() => { - this._closing = false; - }); + return this._untilFound.animate(open, () => + this._player.playExclusive(open ? growVerIn() : growVerOut(), signal) + ); }, }); - // Find-in-page, a text fragment or a link to an element in the content opens - // a closed panel. private readonly _untilFound = addHiddenUntilFoundController(this, { + content: this._panelRef, reveal: () => { this._show(); return this.open; }, }); - /** Whether the close animation runs. The content shows until it ends. */ - @state() - private _closing = false; - /** * Whether the panel is expanded and its content is visible. * @attr @@ -270,12 +248,9 @@ export default class IgcExpansionPanelComponent extends EventEmitterMixin