diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7e0895c31..ab1752cad 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.
@@ -55,6 +57,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
@@ -63,6 +66,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- `--grid-padding` sets the padding of the grid. The default is still `1.25rem`. For example, set it to `0` to align the tiles with the rest of the page. The resize handles then stay inside the tiles, so they do not make the grid scroll. With a smaller padding, they stand out of the tiles only by the padding. Set the padding with this variable, not on the `base` part. A padding of more than one value, such as `0 1rem`, also keeps the handles inside the tiles.
- #### Tooltip
- `showAt()` shows the tooltip at a point or against a rectangle, in viewport coordinates. Use it for the pointer over a canvas, a chart or an SVG drawing, for example `tooltip.showAt({ x: event.clientX, y: event.clientY })`. A `DOMRect` also fits the new `TooltipRect` type. A call while the tooltip shows at a point moves it, with no animation and no events. `placement`, `offset`, `withArrow` and the flip at the viewport edges apply. The point binds no triggers and no `aria-describedby`, and the tooltip does not hide when the pointer leaves it, so the application shows and hides it. `Escape` still closes it. After it closes, the `anchor` applies again, also an `anchor` that changed while the tooltip showed at the point. The triggers of the `anchor` wait until then. The position is also correct inside an ancestor with `transform` or `zoom`. [#2538](https://github.com/IgniteUI/igniteui-webcomponents/issues/2538)
+- #### Tree
+ - Find-in-page now searches the children of a collapsed tree item. A match, a text fragment or a link to an element in them expands the collapsed items above it, from the inside out, and each item sends `igcItemExpanding`, which can be canceled, and `igcItemExpanded`, as for a user toggle. The items expand at once, without the animation, and with `singleBranchExpand` the path to the match stays expanded. A disabled item stays collapsed and its children are not searchable. In WebKit, the tree expands the path but does not scroll to a match in a collapsed item that skips its rendering.
### Changed
- #### Avatar, Badge, Carousel
@@ -98,6 +103,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. 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:
- 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/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/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/api-reference/igc-tree-item.md b/api-reference/igc-tree-item.md
index 3c73c197b..dbfddfe67 100644
--- a/api-reference/igc-tree-item.md
+++ b/api-reference/igc-tree-item.md
@@ -1,6 +1,8 @@
# igc-tree-item
The tree-item component represents a child item of the tree component or another tree item.
+Find-in-page, a text fragment or a link to an element in the children of a
+collapsed item expands it.
```ts
import { defineComponents, IgcTreeItemComponent } 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`
+
+ `);
+ 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/chat/chat.spec.ts b/src/components/chat/chat.spec.ts
index b3b5ab9c1..8840c5dfd 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.
+ 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..9cd3fcfc6 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,48 @@ 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 {
+ if (!this._followNewest || this._state.options?.disableAutoScroll) {
+ return;
+ }
+
+ const { scrollTop, scrollHeight, clientHeight } = this._scrollContainer;
+
+ if (
+ // 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 +543,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..16d3653f1 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,12 @@ 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.
+- 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/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/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', () => {
{
`
@@ -274,24 +277,24 @@ describe('Expansion Panel', () => {
await panel.show();
expect(panel.open).to.be.true;
- expect(getDOMPart('content')).to.not.have.attribute('inert');
+ expect(getDOMPart('content')).to.not.have.attribute('hidden');
await panel.hide();
expect(panel.open).to.be.false;
- expect(getDOMPart('content')).to.have.attribute('inert');
+ expect(getDOMPart('content')).to.have.attribute('hidden', 'until-found');
});
it('should toggle open state on `toggle()`', async () => {
await panel.toggle();
expect(panel.open).to.be.true;
- expect(getDOMPart('content')).to.not.have.attribute('inert');
+ expect(getDOMPart('content')).to.not.have.attribute('hidden');
await panel.toggle();
expect(panel.open).to.be.false;
- expect(getDOMPart('content')).to.have.attribute('inert');
+ expect(getDOMPart('content')).to.have.attribute('hidden', 'until-found');
});
it('`show()/hide()` return false for the current state', async () => {
@@ -484,4 +487,112 @@ describe('Expansion Panel', () => {
});
});
});
+
+ describe('Content found by the browser', () => {
+ let link: HTMLAnchorElement;
+ let target: HTMLElement;
+
+ beforeEach(async () => {
+ const root = await fixture(html`
+
+ `);
+ panel = root.querySelector(IgcExpansionPanelComponent.tagName)!;
+ link = root.querySelector('a')!;
+ target = root.querySelector('#panel-target')!;
+ eventSpy = spy(panel, 'emitEvent');
+ });
+
+ afterEach(resetLocation);
+
+ it('hides a closed panel until found, and a disabled one from the browser', async () => {
+ expect(getDOMPart('content')).to.have.attribute('hidden', 'until-found');
+
+ panel.disabled = true;
+ await elementUpdated(panel);
+
+ expect(getDOMPart('content')).to.have.attribute('hidden', '');
+ });
+
+ it('opens with the user events when a link targets its content', async () => {
+ await clickWithMouse(link);
+ await waitUntil(() => eventSpy.calledWith('igcOpened'));
+
+ verifyStateAndEventSequence({ open: true });
+ expect(getDOMPart('content')).to.not.have.attribute('hidden');
+ expect(target.checkVisibility()).to.be.true;
+ });
+
+ it('stays closed when `igcOpening` is canceled', async () => {
+ panel.addEventListener('igcOpening', (e) => e.preventDefault(), {
+ once: true,
+ });
+
+ await clickWithMouse(link);
+ await aTimeout(0);
+ await elementUpdated(panel);
+
+ expect(panel.open).to.be.false;
+ expect(getDOMPart('content')).to.have.attribute('hidden', 'until-found');
+ expect(target.checkVisibility()).to.be.false;
+ });
+
+ it('does not open when disabled', async () => {
+ panel.disabled = true;
+ await elementUpdated(panel);
+
+ await clickWithMouse(link);
+ await aTimeout(0);
+
+ expect(panel.open).to.be.false;
+ expect(eventSpy.called).to.be.false;
+ expect(target.checkVisibility()).to.be.false;
+ });
+
+ describe('with motion', () => {
+ allowMotion();
+
+ it('opens at once, with no animation', async () => {
+ await clickWithMouse(link);
+
+ expect(panel.open).to.be.true;
+ expect(getAnimationsFor(panel.shadowRoot!)).to.be.empty;
+ });
+
+ it('animates the open from the height of the content', async () => {
+ const opening = panel.show();
+ const [animation] = getAnimationsFor(panel.shadowRoot!);
+ const keyframes = (animation.effect as KeyframeEffect).getKeyframes();
+
+ expect(Number.parseFloat(String(keyframes.at(-1)!.height))).to.equal(
+ getDOMPart('content').scrollHeight
+ );
+ expect(getDOMPart('content').scrollHeight).to.be.greaterThan(0);
+ await opening;
+ });
+
+ it('shows the content during the close animation, inert only then', async () => {
+ panel.open = true;
+ await elementUpdated(panel);
+
+ const closing = panel.hide();
+ await elementUpdated(panel);
+ const content = getDOMPart('content');
+
+ expect(getAnimationsFor(panel.shadowRoot!)).not.to.be.empty;
+ expect(content).to.not.have.attribute('hidden');
+ expect(content.inert).to.be.true;
+
+ await closing;
+
+ expect(content).to.have.attribute('hidden', 'until-found');
+ expect(content.inert).to.be.false;
+ });
+ });
+ });
});
diff --git a/src/components/expansion-panel/expansion-panel.ts b/src/components/expansion-panel/expansion-panel.ts
index fc1768152..af9c51b36 100644
--- a/src/components/expansion-panel/expansion-panel.ts
+++ b/src/components/expansion-panel/expansion-panel.ts
@@ -7,6 +7,7 @@ import {
ariaBindings,
hostAria,
} from '#internals/controllers/aria-projection.js';
+import { addHiddenUntilFoundController } from '#internals/controllers/hidden-until-found.js';
import {
addKeybindings,
altKey,
@@ -40,7 +41,8 @@ const nextId = createIdGenerator('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.
*
* @element igc-expansion-panel
*
@@ -85,13 +87,20 @@ export default class IgcExpansionPanelComponent extends EventEmitterMixin this,
transition: (open, signal) => {
this.open = open;
- return this._player.playExclusive(
- open ? growVerIn() : growVerOut(),
- signal
+ return this._untilFound.animate(open, () =>
+ this._player.playExclusive(open ? growVerIn() : growVerOut(), signal)
);
},
});
+ private readonly _untilFound = addHiddenUntilFoundController(this, {
+ content: this._panelRef,
+ reveal: () => {
+ this._show();
+ return this.open;
+ },
+ });
+
/**
* Whether the panel is expanded and its content is visible.
* @attr
@@ -239,7 +248,9 @@ export default class IgcExpansionPanelComponent extends EventEmitterMixin
diff --git a/src/components/expansion-panel/spec.md b/src/components/expansion-panel/spec.md
index e6850d0cc..7973eb8ec 100644
--- a/src/components/expansion-panel/spec.md
+++ b/src/components/expansion-panel/spec.md
@@ -46,6 +46,7 @@
| 2 | 2026-10-02 | Forward the host `aria-label`, `aria-labelledby` and `aria-describedby` |
| 3 | 2026-10-21 | API descriptions aligned with the JSDoc. |
| 4 | 2026-10-29 | A change of `open` against a running transition ends it |
+| 5 | 2026-11-12 | Find-in-page and links to an element open a collapsed panel |
## Overview
@@ -108,6 +109,10 @@ The panel renders its header with the title, the subtitle and the indicator. Act
panel, animating the content into view and rotating or replacing the indicator. Activating it again collapses the
panel.
+Find-in-page searches the content of a collapsed panel. A match, a text fragment or a link to an element in the
+content opens the panel at once, without the animation, and the browser scrolls to the match. The content of a
+disabled panel is not searchable, and the panel stays closed.
+
### Developer experience
#### Basic initialization
@@ -166,6 +171,14 @@ Setting the `open` property changes the state as well.
A disabled panel is ignored for user interaction, and is skipped by the keyboard navigation of an accordion.
+#### Content found by the browser
+
+While the panel is closed, its content part has `hidden="until-found"`, or plain `hidden` when the panel is
+disabled. When the browser reveals the content, the panel opens through the user path: it sends `igcOpening`, which
+can be canceled, and `igcOpened`. A canceled `igcOpening` keeps the panel closed and its content hidden. While a
+reveal runs in any component, the panel opens and closes without its animation, so that nothing moves the match away
+from the place the browser scrolls to.
+
### Localization
The component renders no strings of its own; the header and content come from the application.
@@ -270,12 +283,29 @@ The suite lives in [`expansion-panel.spec.ts`](./expansion-panel.spec.ts) and ru
15. The shared `runHostAriaTests` suite: the host `aria-label`, `aria-labelledby` and `aria-describedby` reach the
target and follow a change, and an axe audit passes with a host label.
+### Content found by the browser
+
+16. A closed panel hides its content with `hidden="until-found"`, and a disabled one with plain `hidden`.
+17. A link to an element in the content opens the panel with `igcOpening` and `igcOpened`.
+18. A canceled `igcOpening` keeps the panel closed and its content hidden.
+19. A disabled panel does not open.
+20. The panel opens at once, with no animation.
+21. The open animation of a user toggle starts from the height of the content.
+22. The content shows during the close animation, and it is inert only then.
+
## Assumptions and limitations
- The panel animates its content; the methods do not resolve on the animation, unlike the bulk methods of the
accordion.
- The summary view always renders; only the default slot content is toggled.
- Grouping several panels and navigating between them is the job of [`igc-accordion`](../accordion/spec.md).
+- Find-in-page reaches a collapsed panel where the browser supports `hidden="until-found"`: Chrome 102, Firefox 139
+ and Safari 26.2. Firefox before 148 and Safari 26.2 open the panel but do not scroll to the match. Other browsers
+ treat the content as plain `hidden`.
+- A `display` value on `::part(content)` shows the content of a closed panel in a browser that does not support
+ `hidden="until-found"`, because the panel relies on the `[hidden]` rule of the browser there.
+- wtr cannot drive the find bar or text fragments. The suite reveals the content with a link and a real click; the
+ find bar is checked by hand.
## Accessibility
@@ -283,6 +313,8 @@ The suite lives in [`expansion-panel.spec.ts`](./expansion-panel.spec.ts) and ru
- The header is a button that exposes the expanded state and controls the content region.
- The content region is labelled by the header, so assistive technology announces what has been expanded.
+- The content of a collapsed panel is hidden from assistive technology by the `hidden` attribute. It is inert only
+ while it animates closed.
- A disabled panel exposes its disabled state and is not focusable.
- The host `aria-label`, `aria-labelledby` and `aria-describedby` name and describe the header button.
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`
+
+ `);
+ 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..be73f70fb 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,14 @@ 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.
+- 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/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/tabs/spec.md b/src/components/tabs/spec.md
index ead10efc9..b09d1c3fa 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,14 @@ 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,
+also when `::part(tab-body)` gives the panels a height, padding or a border, so it does not cover the selected panel
+in the shared area.
+
#### Alignment
```html
@@ -595,6 +604,16 @@ 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 height, padding and a border on `::part(tab-body)` do not give the hidden panels a box.
+64. A link tab is selected without following its link.
+65. 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 +631,12 @@ 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`. There, a `display` value on `::part(tab-body)` keeps the hidden panels
+ in the layout with no height, and their content can take the focus.
+- 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 +644,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..bdfaf107b 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,13 @@ export default class IgcTabComponent extends EventEmitterMixin {
+ this.closest('igc-tabs')?._revealTab(this);
+ return this.selected;
+ },
+ });
+
//#endregion
//#region Public properties
@@ -264,7 +273,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..8ca6cc94e 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,165 @@ 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`
+
+ `);
+
+ return {
+ root,
+ target: root.querySelector(`#${id}`)!,
+ link: root.querySelector('a')!,
+ };
+ }
+
+ /** 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
${tag}>`
+ );
+ 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">
${tag}>`
+ );
+ const host = hostOf(root);
+
+ host.content.dispatchEvent(new Event('beforematch'));
+ expect(host.untilFound.revealing).to.be.true;
+
+ expect(isRevealing()).to.be.true;
+
+ host.remove();
+ expect(host.untilFound.revealing).to.be.false;
+ expect(isRevealing()).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..c8f98652d
--- /dev/null
+++ b/src/internals/controllers/hidden-until-found.ts
@@ -0,0 +1,151 @@
+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';
+import type { Ref } from 'lit/directives/ref.js';
+import { createTimer } from '../timing.js';
+
+type HiddenUntilFoundOptions = {
+ /** Opens the host as a user does; returns `false` when the open is canceled. */
+ reveal: () => boolean;
+ /** The content element, which shows before an open animation measures it. */
+ content?: Ref;
+};
+
+/** The controllers in which a reveal of the browser runs. */
+const revealing = new Set();
+
+/**
+ * Whether a reveal of the browser runs in any host. Hosts open and close
+ * without animation then: the browser scrolls to the match right after the
+ * reveal, and an animated height above the match would move it away.
+ */
+export function isRevealing(): boolean {
+ return revealing.size > 0;
+}
+
+/**
+ * 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 readonly _timer = createTimer(() => this._end());
+ private _closing = false;
+
+ constructor(host: ReactiveControllerHost, options: HiddenUntilFoundOptions) {
+ this._host = host;
+ this._options = options;
+ host.addController(this);
+ }
+
+ /** Whether a reveal of the browser runs in this host. */
+ public get revealing(): boolean {
+ return this._timer.active;
+ }
+
+ /** Whether the close animation runs. The content shows until it ends. */
+ public get closing(): boolean {
+ return this._closing;
+ }
+
+ /** The `hidden` binding of the content; `noChange` while a reveal runs. */
+ 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 && !this._closing ? (findable ? 'until-found' : '') : nothing
+ );
+ }
+
+ /**
+ * Plays the open or close animation of the content with `play`. While a
+ * reveal runs in any host, it skips the animation and returns `true`.
+ */
+ public animate(
+ open: boolean,
+ play: () => Promise
+ ): Promise | boolean {
+ if (isRevealing()) {
+ return true;
+ }
+
+ if (open) {
+ this._options.content?.value?.removeAttribute('hidden');
+ return play();
+ }
+
+ this._setClosing(true);
+ return play().finally(() => this._setClosing(false));
+ }
+
+ /** 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;
+ }
+
+ revealing.add(this);
+ this._timer.start();
+
+ 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 _setClosing(closing: boolean): void {
+ this._closing = closing;
+ this._host.requestUpdate();
+ }
+
+ private _end(): void {
+ revealing.delete(this);
+ this._host.requestUpdate();
+ }
+
+ /** @internal */
+ public hostDisconnected(): void {
+ if (this.revealing) {
+ this._timer.stop();
+ 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/internals/testing/helpers.spec.ts b/src/internals/testing/helpers.spec.ts
index c5785414f..914c17e89 100644
--- a/src/internals/testing/helpers.spec.ts
+++ b/src/internals/testing/helpers.spec.ts
@@ -1,7 +1,8 @@
import { expect, nextFrame } from '@open-wc/testing';
-import { emulateMedia } from '@web/test-runner-commands';
+import { emulateMedia, resetMouse, sendMouse } from '@web/test-runner-commands';
import { stub } from 'sinon';
import { type CalendarDay, toCalendarDay } from '../date/model.js';
+import { getCenterPoint } from '../utils/dom.js';
import { toKebabCase } from '../utils/strings.js';
/** Returns the animations of the element and, optionally, its descendants. */
@@ -57,6 +58,24 @@ export async function viewTransitionComplete(): Promise {
await nextFrame();
}
+/**
+ * Clicks the center of `element` with real mouse input. A link click reveals
+ * `hidden="until-found"` content with the timing of find-in-page, which a
+ * `location.hash` write does not give.
+ */
+export async function clickWithMouse(element: Element): Promise {
+ element.scrollIntoView({ block: 'nearest' });
+ const { x, y } = getCenterPoint(element);
+ await sendMouse({ type: 'click', position: [Math.round(x), Math.round(y)] });
+}
+
+/** Removes the fragment and the scroll that a link to an element left. */
+export async function resetLocation(): Promise {
+ history.replaceState(null, '', location.pathname + location.search);
+ scrollTo(0, 0);
+ await resetMouse();
+}
+
/** Returns whether `el` is in the visible area of `view`. */
export function scrolledIntoView(el: HTMLElement, view: HTMLElement): boolean {
const { top, bottom, height } = el.getBoundingClientRect();
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/stories/expansion-panel.stories.ts b/stories/expansion-panel.stories.ts
index f83ae0530..8ca703512 100644
--- a/stories/expansion-panel.stories.ts
+++ b/stories/expansion-panel.stories.ts
@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from '@storybook/web-components-vite';
import { html } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
+import { createRef, ref } from 'lit/directives/ref.js';
import {
IgcButtonComponent,
@@ -40,7 +41,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 +195,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 +300,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 +499,92 @@ export const Sidebar: Story = {
`;
},
};
+
+// region help center
+
+const answers = [
+ {
+ id: 'refunds',
+ popular: true,
+ 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',
+ popular: true,
+ 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',
+ popular: true,
+ 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.',
+ },
+];
+
+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 status = createRef();
+ const opened = (event: Event) => {
+ const panel = event.currentTarget as IgcExpansionPanelComponent;
+ const question = panel.querySelector('[slot="title"]')!.textContent;
+ status.value!.textContent = `Opened: ${question}`;
+ };
+
+ return html`
+ ${styles}
+