Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 `<base>` 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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion api-reference/igc-expansion-panel.md
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
3 changes: 2 additions & 1 deletion api-reference/igc-tab.md
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
2 changes: 2 additions & 0 deletions api-reference/igc-tree-item.md
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
57 changes: 57 additions & 0 deletions src/components/accordion/accordion.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<HTMLElement>(html`
<div>
<a href="#accordion-target">Go to the content</a>
<igc-accordion single-expand>
<igc-expansion-panel open>
<span slot="title">First</span>
<div style="height: 1000px">Tall content</div>
</igc-expansion-panel>
<igc-expansion-panel>
<span slot="title">Second</span>
<p>Content</p>
</igc-expansion-panel>
<igc-expansion-panel>
<span slot="title">Third</span>
<p id="accordion-target">Found content</p>
</igc-expansion-panel>
</igc-accordion>
<div style="height: 2000px"></div>
</div>
`);
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() {
Expand Down
10 changes: 10 additions & 0 deletions src/components/accordion/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
83 changes: 76 additions & 7 deletions src/components/chat/chat.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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/
);
}
Expand All @@ -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');
}
});
Expand All @@ -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()
);
}
Expand All @@ -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'
);
}
Expand Down Expand Up @@ -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'
);
}
Expand Down Expand Up @@ -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 });

Expand Down
Loading
Loading