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` +
+ 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/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', () => {
-
+ `; diff --git a/src/components/tree/tree.spec.ts b/src/components/tree/tree.spec.ts index 2141e057a..5a95b7719 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'; @@ -13,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 { @@ -177,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 () => { @@ -528,6 +536,274 @@ 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('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'); + // 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); + + 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; + }); + + it('keeps the expanded items in the revealed item with `singleBranchExpand`', async () => { + await setup(true); + // Only the outer item hides the match; the inner item is expanded. + items.inner.expanded = true; + await elementUpdated(items.inner); + + await clickWithMouse(link); + await aTimeout(0); + await elementUpdated(tree); + + 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(tree.items.flatMap((item) => item.shadowRoot!.getAnimations())) + .to.be.empty; + + // The scroll to the match ends the smooth scroll of the collapse. + // WebKit does not scroll to this match, so only the stability counts. + 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..0e01ab6a4 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) : []); + // During a reveal, the items below `item` keep their state: the browser + // does not tell where the match is, and an expanded one can hold it. + const revealing = isRevealing(); for (const other of this.items) { - if (!keepExpanded.has(other)) { + if (!(keepExpanded.has(other) || (revealing && item?.contains(other)))) { other.collapseWithEvent(); } } 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..3dc27ad9b --- /dev/null +++ b/src/internals/controllers/hidden-until-found.spec.ts @@ -0,0 +1,239 @@ +import { + aTimeout, + defineCE, + elementUpdated, + expect, + fixture, + html, + unsafeStatic, +} from '@open-wc/testing'; +import { LitElement, type TemplateResult } from 'lit'; +import { property } from 'lit/decorators.js'; +import { styles as componentBase } from '../../styles/common/component.css.js'; +import { clickWithMouse, resetLocation } from '../testing/helpers.spec.js'; +import { + addHiddenUntilFoundController, + isRevealing, +} 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(resetLocation); + + /** 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')!, + }; + } + + /** 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 clickWithMouse(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(isRevealing()).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 clickWithMouse(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 clickWithMouse(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 clickWithMouse(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 clickWithMouse(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; + + 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} +
+

Billing help

+ +

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

${answer}

+
+ ` + )} +
+
+ `; + }, +}; 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" }