diff --git a/src/elements/core/interfaces.ts b/src/elements/core/interfaces.ts index 4a7673d271..49cb62571d 100644 --- a/src/elements/core/interfaces.ts +++ b/src/elements/core/interfaces.ts @@ -1,3 +1,4 @@ export * from './interfaces/overlay-close-details.js'; +export * from './interfaces/paginator-page.js'; export * from './interfaces/types.js'; export * from './interfaces/validation-change.js'; diff --git a/src/elements/core/interfaces/paginator-page.ts b/src/elements/core/interfaces/paginator-page.ts new file mode 100644 index 0000000000..9d045e0d4a --- /dev/null +++ b/src/elements/core/interfaces/paginator-page.ts @@ -0,0 +1,6 @@ +export type SbbPaginatorPageEventDetails = { + length: number; + pageSize: number; + pageIndex: number; + previousPageIndex: number; +}; diff --git a/src/elements/paginator.ts b/src/elements/paginator.ts index 1373e2f42e..1bf8387e45 100644 --- a/src/elements/paginator.ts +++ b/src/elements/paginator.ts @@ -1 +1,3 @@ +export * from './paginator/common.js'; +export * from './paginator/compact-paginator.js'; export * from './paginator/paginator.js'; diff --git a/src/elements/paginator/common.ts b/src/elements/paginator/common.ts new file mode 100644 index 0000000000..5a4e64e169 --- /dev/null +++ b/src/elements/paginator/common.ts @@ -0,0 +1 @@ +export * from './common/paginator-common.js'; diff --git a/src/elements/paginator/common/paginator-common.ts b/src/elements/paginator/common/paginator-common.ts new file mode 100644 index 0000000000..ead400fb34 --- /dev/null +++ b/src/elements/paginator/common/paginator-common.ts @@ -0,0 +1,186 @@ +import { html, type LitElement, type PropertyValues, type TemplateResult } from 'lit'; +import { property } from 'lit/decorators.js'; + +import { SbbLanguageController } from '../../core/controllers.js'; +import { hostAttributes } from '../../core/decorators.js'; +import { EventEmitter } from '../../core/eventing.js'; +import { i18nNextPage, i18nPreviousPage, i18nSelectedPage } from '../../core/i18n.js'; +import type { SbbPaginatorPageEventDetails } from '../../core/interfaces.js'; +import { type AbstractConstructor, SbbDisabledMixin, SbbNegativeMixin } from '../../core/mixins.js'; + +import '../../button/mini-button.js'; +import '../../button/mini-button-group.js'; +import '../../divider.js'; + +export declare abstract class SbbPaginatorCommonElementMixinType { + public accessor negative: boolean; + public accessor disabled: boolean; + public accessor length: number; + public accessor pageSize: number; + public accessor pageIndex: number; + public accessor pagerPosition: 'start' | 'end'; + public accessor size: 'm' | 's'; + protected language: SbbLanguageController; + protected numberOfPages(): number; + protected pageIndexChanged(value: number): void; + protected emitPageEvent(previousPageIndex: number): void; + protected renderPrevNextButtons(): TemplateResult; + protected abstract renderPaginator(): TemplateResult; +} + +// eslint-disable-next-line @typescript-eslint/naming-convention +export const SbbPaginatorCommonElementMixin = >( + superClass: T, +): AbstractConstructor & T => { + @hostAttributes({ + role: 'group', + }) + abstract class SbbPaginatorCommonElement + extends SbbNegativeMixin(SbbDisabledMixin(superClass)) + implements Partial + { + public static readonly events: Record = { + page: 'page', + } as const; + + /** Total number of items. */ + @property({ type: Number }) + public set length(value: number) { + this._length = isNaN(value) || value < 0 ? 0 : value; + // Call setter of pageIndex to ensure bounds + // eslint-disable-next-line no-self-assign + this.pageIndex = this.pageIndex; + } + public get length(): number { + return this._length; + } + private _length: number = 0; + + /** Number of items per page. */ + @property({ attribute: 'page-size', type: Number }) + public set pageSize(value: number) { + // Current page needs to be updated to reflect the new page size. Navigate to the page + // containing the previous page's first item. + const previousPageSize = this.pageSize; + this._pageSize = Math.max(value, 0); + this.pageIndex = Math.floor((this.pageIndex * previousPageSize) / this.pageSize) || 0; + } + public get pageSize(): number { + return this._pageSize; + } + private _pageSize: number = 10; + + /** Current page index. */ + @property({ attribute: 'page-index', type: Number }) + public set pageIndex(value: number) { + this._pageIndex = this._coercePageIndexInRange(value); + } + public get pageIndex(): number { + return this._pageIndex; + } + private _pageIndex: number = 0; + + /** Position of the prev/next buttons. */ + @property({ attribute: 'pager-position', reflect: true }) public accessor pagerPosition: + | 'start' + | 'end' = 'start'; + + /** Size variant, either m or s. */ + @property({ reflect: true }) public accessor size: 'm' | 's' = 'm'; + + private _page: EventEmitter = new EventEmitter( + this, + SbbPaginatorCommonElement.events.page, + { composed: true, bubbles: true }, + ); + protected language = new SbbLanguageController(this); + protected abstract renderPaginator(): string; + + protected override updated(changedProperties: PropertyValues): void { + super.updated(changedProperties); + + // To reliably announce page change, we have to set the label in updated() (a tick later than the other changes). + this.shadowRoot!.querySelector('sbb-screen-reader-only')!.textContent = + this._currentPageLabel(); + } + + /** Evaluate `pageIndex` by excluding edge cases. */ + private _coercePageIndexInRange(pageIndex: number): number { + return Math.max( + Math.min(Math.max(isNaN(pageIndex) ? 0 : pageIndex, 0), this.numberOfPages() - 1), + 0, + ); + } + + private _currentPageLabel(): string { + return i18nSelectedPage(this.pageIndex + 1)[this.language.current]; + } + + /** + * Calculates the current number of pages based on the `length` and the `pageSize`; + * value must be rounded up (e.g. `length = 21` and `pageSize = 10` means 3 pages). + */ + protected numberOfPages(): number { + return this.pageSize ? Math.ceil(this.length / this.pageSize) : 0; + } + + /** + * If the `pageIndex` changes due to user interaction, + * emit the `page` event and then update the `pageIndex` value. + */ + protected pageIndexChanged(value: number): void { + const previousPageIndex = this.pageIndex; + this.pageIndex = value; + + if (previousPageIndex !== this.pageIndex) { + this.emitPageEvent(previousPageIndex); + } + } + + protected emitPageEvent(previousPageIndex: number): void { + this._page.emit({ + previousPageIndex, + pageIndex: this.pageIndex, + length: this.length, + pageSize: this.pageSize, + }); + } + + protected renderPrevNextButtons(): TemplateResult { + return html` + + this.pageIndexChanged(this._pageIndex - 1)} + > + + this.pageIndexChanged(this._pageIndex + 1)} + > + + `; + } + + protected override render(): TemplateResult { + return html` + ${this.renderPaginator()} + + `; + } + } + return SbbPaginatorCommonElement as unknown as AbstractConstructor & + T; +}; + +declare global { + interface HTMLElementEventMap { + page: CustomEvent; + } +} diff --git a/src/elements/paginator/compact-paginator.ts b/src/elements/paginator/compact-paginator.ts new file mode 100644 index 0000000000..b81712b867 --- /dev/null +++ b/src/elements/paginator/compact-paginator.ts @@ -0,0 +1 @@ +export * from './compact-paginator/compact-paginator.js'; diff --git a/src/elements/paginator/compact-paginator/__snapshots__/compact-paginator.snapshot.spec.snap.js b/src/elements/paginator/compact-paginator/__snapshots__/compact-paginator.snapshot.spec.snap.js new file mode 100644 index 0000000000..539ddf67c0 --- /dev/null +++ b/src/elements/paginator/compact-paginator/__snapshots__/compact-paginator.snapshot.spec.snap.js @@ -0,0 +1,134 @@ +/* @web/test-runner snapshot v1 */ +export const snapshots = {}; + +snapshots["sbb-compact-paginator renders DOM"] = +` + +`; +/* end snapshot sbb-compact-paginator renders DOM */ + +snapshots["sbb-compact-paginator renders Shadow DOM"] = +`
+ + + + + + + + + + 1 + + 10 + +
+ + Page 1 selected. + +`; +/* end snapshot sbb-compact-paginator renders Shadow DOM */ + +snapshots["sbb-compact-paginator renders A11y tree Firefox"] = +`

+ { + "role": "document", + "name": "", + "children": [ + { + "role": "button", + "name": "Previous page", + "disabled": true + }, + { + "role": "button", + "name": "Next page" + }, + { + "role": "text leaf", + "name": "1" + }, + { + "role": "text leaf", + "name": "10" + }, + { + "role": "text leaf", + "name": "Page 1 selected." + } + ] +} +

+`; +/* end snapshot sbb-compact-paginator renders A11y tree Firefox */ + +snapshots["sbb-compact-paginator renders A11y tree Chrome"] = +`

+ { + "role": "WebArea", + "name": "", + "children": [ + { + "role": "button", + "name": "Previous page", + "disabled": true + }, + { + "role": "button", + "name": "Next page" + }, + { + "role": "text", + "name": "1" + }, + { + "role": "text", + "name": "10" + }, + { + "role": "text", + "name": "Page 1 selected." + } + ] +} +

+`; +/* end snapshot sbb-compact-paginator renders A11y tree Chrome */ + diff --git a/src/elements/paginator/compact-paginator/compact-paginator.scss b/src/elements/paginator/compact-paginator/compact-paginator.scss new file mode 100644 index 0000000000..d48576b411 --- /dev/null +++ b/src/elements/paginator/compact-paginator/compact-paginator.scss @@ -0,0 +1,45 @@ +@use '../../core/styles' as sbb; + +// Box-sizing rules contained in typography are not traversing Shadow DOM boundaries. We need to include box-sizing mixin in every component. +@include sbb.box-sizing; + +:host { + display: block; + + --sbb-compact-paginator-height: var(--sbb-size-element-m); + --sbb-compact-paginator-color: var(--sbb-color-metal); + --sbb-paginator-compact-justify-content: start; +} + +:host([size='s']) { + --sbb-compact-paginator-height: var(--sbb-size-element-xs); +} + +:host([negative]) { + --sbb-compact-paginator-color: var(--sbb-color-storm); +} + +:host([pager-position='end']) { + --sbb-paginator-compact-justify-content: end; +} + +.sbb-compact-paginator { + display: flex; + gap: var(--sbb-spacing-fixed-5x); + justify-content: var(--sbb-paginator-compact-justify-content); + min-height: var(--sbb-compact-paginator-height); +} + +.sbb-paginator__pages { + @include sbb.text-m--regular; + + display: flex; + align-items: center; + justify-content: center; + gap: var(--sbb-spacing-fixed-2x); + color: var(--sbb-compact-paginator-color); +} + +.sbb-compact-paginator__divider { + height: #{sbb.px-to-rem-build(16)}; +} diff --git a/src/elements/paginator/compact-paginator/compact-paginator.snapshot.spec.ts b/src/elements/paginator/compact-paginator/compact-paginator.snapshot.spec.ts new file mode 100644 index 0000000000..1530605f80 --- /dev/null +++ b/src/elements/paginator/compact-paginator/compact-paginator.snapshot.spec.ts @@ -0,0 +1,29 @@ +import { expect } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { fixture, testA11yTreeSnapshot } from '../../core/testing/private.js'; + +import type { SbbCompactPaginatorElement } from './compact-paginator.js'; +import './compact-paginator.js'; + +describe(`sbb-compact-paginator`, () => { + describe('renders', () => { + let element: SbbCompactPaginatorElement; + + beforeEach(async () => { + element = await fixture( + html``, + ); + }); + + it('DOM', async () => { + await expect(element).dom.to.be.equalSnapshot(); + }); + + it('Shadow DOM', async () => { + await expect(element).shadowDom.to.be.equalSnapshot(); + }); + + testA11yTreeSnapshot(); + }); +}); diff --git a/src/elements/paginator/compact-paginator/compact-paginator.spec.ts b/src/elements/paginator/compact-paginator/compact-paginator.spec.ts new file mode 100644 index 0000000000..6962818c64 --- /dev/null +++ b/src/elements/paginator/compact-paginator/compact-paginator.spec.ts @@ -0,0 +1,145 @@ +import { assert, expect } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; +import { spy } from 'sinon'; + +import type { SbbMiniButtonElement } from '../../button/mini-button.js'; +import { fixture } from '../../core/testing/private.js'; +import { EventSpy, waitForLitRender } from '../../core/testing.js'; + +import { SbbCompactPaginatorElement } from './compact-paginator.js'; + +describe('sbb-compact-paginator', () => { + let element: SbbCompactPaginatorElement; + + beforeEach(async () => { + element = await fixture( + html``, + ); + }); + + it('renders', async () => { + assert.instanceOf(element, SbbCompactPaginatorElement); + }); + + it('change pages via prev/next buttons and emits `page` event', async () => { + const pageEventSpy = spy(); + const goToPrev: SbbMiniButtonElement = element.shadowRoot!.querySelector( + '#sbb-paginator-prev-page', + )!; + const goToNext: SbbMiniButtonElement = element.shadowRoot!.querySelector( + '#sbb-paginator-next-page', + )!; + + element.addEventListener('page', (event) => { + expect(event.detail.pageIndex).to.be.equal(element.pageIndex); + pageEventSpy(); + }); + + expect(goToPrev).to.have.attribute('disabled'); + goToPrev.click(); + await waitForLitRender(element); + expect(pageEventSpy).not.to.have.been.called; + + expect(goToNext).not.to.have.attribute('disabled'); + goToNext.click(); + await waitForLitRender(element); + expect(pageEventSpy).to.have.been.calledOnce; + expect(element.pageIndex).to.be.equal(1); + expect(goToPrev).not.to.have.attribute('disabled'); + expect(goToNext).not.to.have.attribute('disabled'); + + goToPrev.click(); + await waitForLitRender(element); + expect(pageEventSpy).to.have.been.calledTwice; + expect(element.pageIndex).to.be.equal(0); + }); + + /* + * NOTE: when checking `selectedElement.textContent`, the sbb-divider is not considered, + * so the current page and the last page are joined in a single value. + */ + it('changes to the correct selected page when pageSize changes', async () => { + // go to page 5 / pageIndex=4, which includes items 21-25 + element.setAttribute('page-index', '4'); + await waitForLitRender(element); + let selectedElement = element.shadowRoot!.querySelector('.sbb-paginator__pages')!; + // lenght = 50 / pageSize = 5 / numberOfPages = 10 + expect(selectedElement.textContent).to.be.equal('510'); + + // switching to pageSize=10, item 21 should be on page 3 / pageIndex=2 + element.setAttribute('page-size', '10'); + await waitForLitRender(element); + selectedElement = element.shadowRoot!.querySelector('.sbb-paginator__pages')!; + // lenght = 50 / pageSize = 10 / numberOfPages = 5 + expect(selectedElement.textContent).to.be.equal('35'); + + // go to page 4 / pageIndex=3, which now includes items 31-40 + element.setAttribute('page-index', '3'); + await waitForLitRender(element); + selectedElement = element.shadowRoot!.querySelector('.sbb-paginator__pages')!; + // lenght = 50 / pageSize = 10 / numberOfPages = 5 + expect(selectedElement.textContent).to.be.equal('45'); + + // switching to pageSize=2, item 31 should be on page 16 / pageIndex=15 + element.setAttribute('page-size', '2'); + await waitForLitRender(element); + selectedElement = element.shadowRoot!.querySelector('.sbb-paginator__pages')!; + // lenght = 50 / pageSize = 2 / numberOfPages = 25 + expect(selectedElement.textContent).to.be.equal('1625'); + }); + + it('the `page` event is not emitted when pageSize and pageIndex change programmatically', async () => { + const pageEventSpy = new EventSpy(SbbCompactPaginatorElement.events.page); + element.setAttribute('page-index', '4'); + await waitForLitRender(element); + expect(element.pageIndex).to.be.equal(4); + expect(pageEventSpy.count).to.be.equal(0); + + element.setAttribute('page-size', '10'); + await waitForLitRender(element); + expect(element.pageSize).to.be.equal(10); + expect(pageEventSpy.count).to.be.equal(0); + }); + + it('handles length change', () => { + element.pageIndex = 9; + element.length = 100; + expect(element.pageIndex).to.be.equal(9); + + element.length = 10; + expect(element.pageIndex).to.be.equal(1); + + element.length = -1; + expect(element.length).to.be.equal(0); + expect(element.pageIndex).to.be.equal(0); + }); + + it('handles pageSize change', () => { + element.pageIndex = 9; + expect(element.pageIndex).to.be.equal(9); + + element.pageSize = 1; + expect(element.pageIndex).to.be.equal(45); + + element.pageSize = 10; + expect(element.pageIndex).to.be.equal(4); + + element.pageSize = -1; + expect(element.pageSize).to.be.equal(0); + expect(element.pageIndex).to.be.equal(0); + }); + + it('handles pageIndex change', () => { + element.pageIndex = 10; + expect(element.pageIndex).to.be.equal(9); + + element.pageIndex = -1; + expect(element.pageIndex).to.be.equal(0); + + element.pageIndex = 0; + expect(element.pageIndex).to.be.equal(0); + + element.pageIndex = 5; + expect(element.pageIndex).to.be.equal(5); + }); +}); diff --git a/src/elements/paginator/compact-paginator/compact-paginator.ssr.spec.ts b/src/elements/paginator/compact-paginator/compact-paginator.ssr.spec.ts new file mode 100644 index 0000000000..2fdfa81067 --- /dev/null +++ b/src/elements/paginator/compact-paginator/compact-paginator.ssr.spec.ts @@ -0,0 +1,23 @@ +import { assert } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { ssrHydratedFixture } from '../../core/testing/private.js'; + +import { SbbCompactPaginatorElement } from './compact-paginator.js'; + +describe(`sbb-compact-paginator ssr`, () => { + let root: SbbCompactPaginatorElement; + + beforeEach(async () => { + root = await ssrHydratedFixture( + html``, + { + modules: ['./compact-paginator.js'], + }, + ); + }); + + it('renders', () => { + assert.instanceOf(root, SbbCompactPaginatorElement); + }); +}); diff --git a/src/elements/paginator/compact-paginator/compact-paginator.stories.ts b/src/elements/paginator/compact-paginator/compact-paginator.stories.ts new file mode 100644 index 0000000000..626cec4922 --- /dev/null +++ b/src/elements/paginator/compact-paginator/compact-paginator.stories.ts @@ -0,0 +1,147 @@ +import { withActions } from '@storybook/addon-actions/decorator'; +import type { InputType } from '@storybook/types'; +import type { + Args, + ArgTypes, + Decorator, + Meta, + StoryContext, + StoryObj, +} from '@storybook/web-components'; +import type { TemplateResult } from 'lit'; +import { html } from 'lit'; + +import { sbbSpread } from '../../../storybook/helpers/spread.js'; + +import { SbbCompactPaginatorElement } from './compact-paginator.js'; +import readme from './readme.md?raw'; + +const length: InputType = { + control: { + type: 'number', + }, +}; + +const pageSize: InputType = { + control: { + type: 'number', + }, +}; + +const pageIndex: InputType = { + control: { + type: 'number', + }, +}; + +const pagerPosition: InputType = { + control: { + type: 'inline-radio', + }, + options: ['start', 'end'], +}; + +const size: InputType = { + control: { + type: 'inline-radio', + }, + options: ['m', 's'], +}; + +const negative: InputType = { + control: { + type: 'boolean', + }, +}; + +const disabled: InputType = { + control: { + type: 'boolean', + }, +}; + +const defaultArgTypes: ArgTypes = { + length, + 'page-size': pageSize, + 'page-index': pageIndex, + 'pager-position': pagerPosition, + size, + negative, + disabled, +}; + +const defaultArgs: Args = { + length: 100, + 'page-size': 10, + 'page-index': 0, + 'pager-position': pagerPosition.options![0], + size: size.options![0], + negative: false, + disabled: false, +}; + +const Template = ({ ...args }: Args): TemplateResult => { + return html` `; +}; + +export const Default: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs }, +}; + +export const Negative: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, negative: true }, +}; + +export const Disabled: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, disabled: true }, +}; + +export const DisabledNegative: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, negative: true, disabled: true }, +}; + +export const SizeS: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, size: size.options![1] }, +}; + +export const NegativeSizeS: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, negative: true, size: size.options![1] }, +}; + +export const PagerPositionEnd: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, 'pager-position': 'end' }, +}; + +const meta: Meta = { + decorators: [withActions as Decorator], + parameters: { + actions: { + handles: [SbbCompactPaginatorElement.events.page], + }, + backgroundColor: (context: StoryContext) => + context.args.negative ? 'var(--sbb-color-black)' : 'var(--sbb-color-white)', + docs: { + extractComponentDescription: () => readme, + }, + }, + title: 'elements/sbb-paginator/sbb-compact-paginator', +}; + +export default meta; diff --git a/src/elements/paginator/compact-paginator/compact-paginator.ts b/src/elements/paginator/compact-paginator/compact-paginator.ts new file mode 100644 index 0000000000..c159970ae1 --- /dev/null +++ b/src/elements/paginator/compact-paginator/compact-paginator.ts @@ -0,0 +1,53 @@ +import type { CSSResultGroup, TemplateResult } from 'lit'; +import { html, LitElement } from 'lit'; +import { customElement } from 'lit/decorators.js'; + +import { SbbPaginatorCommonElementMixin } from '../common.js'; +import '../../divider.js'; + +import style from './compact-paginator.scss?lit&inline'; + +/** + * It displays a paginator component in compact mode. + * + * @event {CustomEvent} page - Emits when the pageIndex changes. + */ +export +@customElement('sbb-compact-paginator') +class SbbCompactPaginatorElement extends SbbPaginatorCommonElementMixin(LitElement) { + public static override styles: CSSResultGroup = style; + public static readonly events: Record = { + page: 'page', + } as const; + + private _renderPageNumbers(): TemplateResult { + return html` + ${this.pageIndex + 1}${this.numberOfPages()} + `; + } + + protected override renderPaginator(): TemplateResult { + return html` +
+ ${this.pagerPosition === 'start' + ? html`${this.renderPrevNextButtons()} ${this._renderPageNumbers()}` + : html`${this._renderPageNumbers()} ${this.renderPrevNextButtons()}`} +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-compact-paginator': SbbCompactPaginatorElement; + } +} diff --git a/src/elements/paginator/compact-paginator/compact-paginator.visual.spec.ts b/src/elements/paginator/compact-paginator/compact-paginator.visual.spec.ts new file mode 100644 index 0000000000..3693d4b1c8 --- /dev/null +++ b/src/elements/paginator/compact-paginator/compact-paginator.visual.spec.ts @@ -0,0 +1,76 @@ +import { html } from 'lit'; + +import { describeViewports, visualDiffDefault } from '../../core/testing/private.js'; + +import './compact-paginator.js'; + +describe('sbb-compact-paginator', () => { + describeViewports({ viewports: ['zero', 'medium'] }, () => { + for (const negative of [false, true]) { + describe(`negative=${negative}`, () => { + const wrapperStyle = { + backgroundColor: negative ? 'var(--sbb-color-black)' : undefined, + }; + + for (const forcedColors of [false, true]) { + describe(`forcedColors=${forcedColors}`, () => { + for (const pagerPosition of ['start', 'end']) { + describe(`pagerPosition=${pagerPosition}`, () => { + it( + visualDiffDefault.name, + visualDiffDefault.with(async (setup) => { + await setup.withFixture( + html` `, + { ...wrapperStyle, forcedColors }, + ); + }), + ); + }); + } + + it( + 'disabled=true', + visualDiffDefault.with(async (setup) => { + await setup.withFixture( + html``, + { ...wrapperStyle, forcedColors }, + ); + }), + ); + }); + } + + for (const size of ['s', 'm']) { + describe(`size=${size}`, () => { + for (const pageIndex of [0, 5, 9]) { + it( + `pageIndex=${pageIndex}`, + visualDiffDefault.with(async (setup) => { + await setup.withFixture( + html` `, + wrapperStyle, + ); + }), + ); + } + }); + } + }); + } + }); +}); diff --git a/src/elements/paginator/compact-paginator/readme.md b/src/elements/paginator/compact-paginator/readme.md new file mode 100644 index 0000000000..585e2cb9de --- /dev/null +++ b/src/elements/paginator/compact-paginator/readme.md @@ -0,0 +1,74 @@ +The `sbb-compact-paginator` provides an alternative to the `sbb-paginator` +as a navigation for content split across multiple pages, e.g. a table with many rows. +Differently from the `sbb-paginator`, it displays only the current page and the total number of pages +together with the `sbb-mini-button-group` to move to previous or next page. + +It can be controlled via the following properties: + +- `length`: the total number of items being paged; +- `pageSize`: the number of items per page (default: `10`); +- `pageIndex`: the index of the current displayed page (default: `0`). + +```html + +``` + +By default, a [sbb-mini-button-group](/docs/elements-sbb-button-sbb-mini-button-group--docs) with two buttons is displayed, +which allows moving to the previous/next pages. +The positioning of this element relative to the page numbers is set using the `pagerPosition` property (default: `start`): + +```html + +``` + +## States + +The component can be disabled by using the `disabled` property. + +```html + +``` + +## Style + +The component has two `size`, named `s` and `m` (default). + +```html + +``` + +## Events + +Consumers can listen to the `page` event on the `sbb-compact-paginator` component to intercept the page change event. +The `event.detail` contains both the information about the `pageIndex` and the `previousPageIndex`, +as well as the `length` and the `pageSize`. + +## Accessibility + +The component has `role="group"` to semantically group its child controls; +consumers should add an appropriate `aria-label` attribute with a text +that describes the content controlled by the paginator. + +```html + +``` + + + +## Properties + +| Name | Attribute | Privacy | Type | Default | Description | +| --------------- | ---------------- | ------- | ------------------ | --------- | ---------------------------------- | +| `disabled` | `disabled` | public | `boolean` | `false` | Whether the component is disabled. | +| `length` | `length` | public | `number` | `0` | Total number of items. | +| `negative` | `negative` | public | `boolean` | `false` | Negative coloring variant flag. | +| `pageIndex` | `page-index` | public | `number` | `0` | Current page index. | +| `pagerPosition` | `pager-position` | public | `'start' \| 'end'` | `'start'` | Position of the prev/next buttons. | +| `pageSize` | `page-size` | public | `number` | `10` | Number of items per page. | +| `size` | `size` | public | `'m' \| 's'` | `'m'` | Size variant, either m or s. | + +## Events + +| Name | Type | Description | Inherited From | +| ------ | ------------------------------------------- | --------------------------------- | -------------- | +| `page` | `CustomEvent` | Emits when the pageIndex changes. | | diff --git a/src/elements/paginator/paginator.ts b/src/elements/paginator/paginator.ts index 1eaf41fac7..1373e2f42e 100644 --- a/src/elements/paginator/paginator.ts +++ b/src/elements/paginator/paginator.ts @@ -1,387 +1 @@ -import { - type CSSResultGroup, - html, - LitElement, - nothing, - type PropertyValues, - type TemplateResult, -} from 'lit'; -import { customElement, property } from 'lit/decorators.js'; -import { repeat } from 'lit/directives/repeat.js'; - -import { sbbInputModalityDetector } from '../core/a11y.js'; -import { SbbLanguageController } from '../core/controllers.js'; -import { hostAttributes } from '../core/decorators.js'; -import { EventEmitter } from '../core/eventing.js'; -import { - i18nItemsPerPage, - i18nNextPage, - i18nPage, - i18nPreviousPage, - i18nSelectedPage, -} from '../core/i18n.js'; -import { SbbDisabledMixin, SbbNegativeMixin } from '../core/mixins.js'; -import type { SbbSelectElement } from '../select.js'; - -import style from './paginator.scss?lit&inline'; - -import '../button/mini-button.js'; -import '../button/mini-button-group.js'; -import '../divider.js'; -import '../form-field.js'; -import '../select.js'; -import '../option.js'; -import '../screen-reader-only.js'; - -export type SbbPaginatorPageEventDetails = { - length: number; - pageSize: number; - pageIndex: number; - previousPageIndex: number; -}; - -const MAX_PAGE_NUMBERS_DISPLAYED = 3; - -let nextId = 0; - -/** - * It displays a paginator component. - * - * @event {CustomEvent} page - Emits when the pageIndex changes. - */ -export -@customElement('sbb-paginator') -@hostAttributes({ - role: 'group', -}) -class SbbPaginatorElement extends SbbNegativeMixin(SbbDisabledMixin(LitElement)) { - public static override styles: CSSResultGroup = style; - public static readonly events: Record = { - page: 'page', - } as const; - - /** Total number of items. */ - @property({ type: Number }) - public set length(value: number) { - this._length = isNaN(value) || value < 0 ? 0 : value; - // Call setter of pageIndex to ensure bounds - // eslint-disable-next-line no-self-assign - this.pageIndex = this.pageIndex; - } - public get length(): number { - return this._length; - } - private _length: number = 0; - - /** Number of items per page. */ - @property({ attribute: 'page-size', type: Number }) - public set pageSize(value: number) { - // Current page needs to be updated to reflect the new page size. Navigate to the page - // containing the previous page's first item. - const previousPageSize = this.pageSize; - this._pageSize = Math.max(value, 0); - this.pageIndex = Math.floor((this.pageIndex * previousPageSize) / this.pageSize) || 0; - } - public get pageSize(): number { - return this._pageSize; - } - private _pageSize: number = 10; - - /** Current page index. */ - @property({ attribute: 'page-index', type: Number }) - public set pageIndex(value: number) { - this._pageIndex = this._coercePageIndexInRange(value); - } - public get pageIndex(): number { - return this._pageIndex; - } - private _pageIndex: number = 0; - - /** The available `pageSize` choices. */ - @property({ attribute: 'page-size-options', type: Array }) - public set pageSizeOptions(value: number[]) { - this._pageSizeOptions = value; - this._updateSelectAriaLabelledBy = true; - } - public get pageSizeOptions(): number[] | undefined { - return this._pageSizeOptions; - } - private _pageSizeOptions?: number[]; - - /** - * Position of the prev/next buttons: if `pageSizeOptions` is set, the sbb-select for the pageSize change - * will be positioned oppositely with the page numbers always in the center. - */ - @property({ attribute: 'pager-position', reflect: true }) public accessor pagerPosition: - | 'start' - | 'end' = 'start'; - - /** Size variant, either m or s. */ - @property({ reflect: true }) public accessor size: 'm' | 's' = 'm'; - - private _page: EventEmitter = new EventEmitter( - this, - SbbPaginatorElement.events.page, - { composed: true, bubbles: true }, - ); - - private _paginatorOptionsLabel = `sbb-paginator-options-label-${++nextId}`; - private _language = new SbbLanguageController(this); - private _markForFocus: number | null = null; - private _updateSelectAriaLabelledBy: boolean = false; - - protected override updated(changedProperties: PropertyValues): void { - super.updated(changedProperties); - - /** Tab navigation can force a rerender when ellipsis elements need to be displayed; the focus must stay on the correct element. */ - if (this._markForFocus && sbbInputModalityDetector.mostRecentModality === 'keyboard') { - const focusElement = this._getVisiblePages().find( - (e) => this.pageIndex === +e.getAttribute('data-index')!, - ); - if (focusElement) { - (focusElement as HTMLElement).focus(); - } - // Reset mark for focus - this._markForFocus = null; - } - - /** - * TODO: Accessibility fix required to correctly read the label; - * can be possibly removed after the merge of https://github.com/sbb-design-systems/lyne-components/issues/3062 - */ - const select = this.shadowRoot!.querySelector('sbb-select'); - if (select && this._updateSelectAriaLabelledBy) { - select.setAttribute('aria-labelledby', this._paginatorOptionsLabel); - this._updateSelectAriaLabelledBy = false; - } - - // To reliably announce page change, we have to set the label in updated() (a tick later than the other changes). - this.shadowRoot!.querySelector('sbb-screen-reader-only')!.textContent = - this._currentPageLabel(); - } - - /** - * Calculates the current number of pages based on the `length` and the `pageSize`; - * value must be rounded up (e.g. `length = 21` and `pageSize = 10` means 3 pages). - */ - private _numberOfPages(): number { - return this.pageSize ? Math.ceil(this.length / this.pageSize) : 0; - } - - /** - * If the `pageSize` changes due to user interaction with the `pageSizeOptions` select, - * emit the `page` event and then update the `pageSize` value. - */ - private _pageSizeChanged(value: number): void { - const previousPageSize = this.pageSize; - const previousPageIndex = this.pageIndex; - this.pageSize = value; - - if (previousPageSize !== this.pageSize) { - this._emitPageEvent(previousPageIndex); - } - } - - /** - * If the `pageIndex` changes due to user interaction, - * emit the `page` event and then update the `pageIndex` value. - */ - private _pageIndexChanged(value: number): void { - const previousPageIndex = this.pageIndex; - this.pageIndex = value; - - if (previousPageIndex !== this.pageIndex) { - this._emitPageEvent(previousPageIndex); - } - } - - private _emitPageEvent(previousPageIndex: number): void { - this._page.emit({ - previousPageIndex, - pageIndex: this.pageIndex, - length: this.length, - pageSize: this.pageSize, - }); - } - - /** Returns the displayed page elements. */ - private _getVisiblePages(): Element[] { - return Array.from(this.shadowRoot!.querySelectorAll('.sbb-paginator__page--number-item')); - } - - /** Evaluate `pageIndex` by excluding edge cases. */ - private _coercePageIndexInRange(pageIndex: number): number { - return Math.max( - Math.min(Math.max(isNaN(pageIndex) ? 0 : pageIndex, 0), this._numberOfPages() - 1), - 0, - ); - } - - /** - * Calculate the pages set based on the following rules: - * - the first page must always be visible; - * - the last page must always be visible; - * - if there are more than `MAX_PAGE_NUMBERS_DISPLAYED` other pages, ellipsis button must be used. - */ - private _getVisiblePagesIndex(): (number | 'ellipsis')[] { - const totalPages: number = this._numberOfPages(); - const currentPageIndex: number = this.pageIndex; - - if (totalPages <= MAX_PAGE_NUMBERS_DISPLAYED + 2) { - return this._range(totalPages); - } else if (currentPageIndex < MAX_PAGE_NUMBERS_DISPLAYED) { - return [...this._range(MAX_PAGE_NUMBERS_DISPLAYED + 1), 'ellipsis', totalPages - 1]; - } else if (currentPageIndex >= totalPages - MAX_PAGE_NUMBERS_DISPLAYED) { - return [ - 0, - 'ellipsis', - ...this._range(MAX_PAGE_NUMBERS_DISPLAYED + 1, totalPages - 1 - MAX_PAGE_NUMBERS_DISPLAYED), - ]; - } else { - return [ - 0, - 'ellipsis', - currentPageIndex - 1, - currentPageIndex, - currentPageIndex + 1, - 'ellipsis', - totalPages - 1, - ]; - } - } - - /** Creates an array of consecutive numbers given the length and the starting value. */ - private _range(length: number, offset: number = 0): number[] { - return Array.from({ length }, (_, k) => k + offset); - } - - private _handleKeyUp(event: KeyboardEvent): void { - if (event.key !== ' ' && event.key !== 'Enter') { - return; - } - - const current = this._getVisiblePages().find((e: Element) => e === event.target); - if (current) { - this._markForFocus = this.pageIndex; - } - } - - private _currentPageLabel(): string { - return i18nSelectedPage(this.pageIndex + 1)[this._language.current]; - } - - private _renderPrevNextButtons(): TemplateResult { - return html` - - this._pageIndexChanged(this._pageIndex - 1)} - > - - this._pageIndexChanged(this._pageIndex + 1)} - > - - `; - } - - private _renderItemPerPageTemplate(): TemplateResult | typeof nothing { - return this.pageSizeOptions && this.pageSizeOptions.length > 0 - ? html` -
- - - e === this.pageSize) ?? - this.pageSizeOptions![0]} - @change=${(e: CustomEvent) => - this._pageSizeChanged(+((e.target as SbbSelectElement).value as string))} - ?disabled=${this.disabled} - > - ${repeat( - this.pageSizeOptions!, - (element) => html`${element}`, - )} - - -
- ` - : nothing; - } - - private _renderPageNumbers(): TemplateResult { - return html` -
    - ${repeat( - this._getVisiblePagesIndex(), - (item: number | 'ellipsis'): TemplateResult => - item === 'ellipsis' - ? html` -
  • - -
  • - ` - : html` -
  • - -
  • - `, - )} -
- `; - } - - protected override render(): TemplateResult { - return html` -
- ${ - this.pagerPosition === 'start' - ? html` - ${this._renderPrevNextButtons()} ${this._renderPageNumbers()} - - ${this._renderItemPerPageTemplate()}` - : html`${this._renderItemPerPageTemplate()} - - ${this._renderPageNumbers()} ${this._renderPrevNextButtons()} - ` - } -
- - `; - } -} - -declare global { - interface HTMLElementTagNameMap { - // eslint-disable-next-line @typescript-eslint/naming-convention - 'sbb-paginator': SbbPaginatorElement; - } - interface HTMLElementEventMap { - page: CustomEvent; - } -} +export * from './paginator/paginator.js'; diff --git a/src/elements/paginator/__snapshots__/paginator.snapshot.spec.snap.js b/src/elements/paginator/paginator/__snapshots__/paginator.snapshot.spec.snap.js similarity index 100% rename from src/elements/paginator/__snapshots__/paginator.snapshot.spec.snap.js rename to src/elements/paginator/paginator/__snapshots__/paginator.snapshot.spec.snap.js diff --git a/src/elements/paginator/paginator.scss b/src/elements/paginator/paginator/paginator.scss similarity index 99% rename from src/elements/paginator/paginator.scss rename to src/elements/paginator/paginator/paginator.scss index 0bd0b313d0..6c33b7aa84 100644 --- a/src/elements/paginator/paginator.scss +++ b/src/elements/paginator/paginator/paginator.scss @@ -1,4 +1,4 @@ -@use '../core/styles' as sbb; +@use '../../core/styles' as sbb; // Box-sizing rules contained in typography are not traversing Shadow DOM boundaries. We need to include box-sizing mixin in every component. @include sbb.box-sizing; diff --git a/src/elements/paginator/paginator.snapshot.spec.ts b/src/elements/paginator/paginator/paginator.snapshot.spec.ts similarity index 94% rename from src/elements/paginator/paginator.snapshot.spec.ts rename to src/elements/paginator/paginator/paginator.snapshot.spec.ts index 33e854273f..283d8ec36b 100644 --- a/src/elements/paginator/paginator.snapshot.spec.ts +++ b/src/elements/paginator/paginator/paginator.snapshot.spec.ts @@ -1,9 +1,9 @@ import { expect } from '@open-wc/testing'; import { html } from 'lit/static-html.js'; -import { isSafari } from '../core/dom.js'; -import { fixture, testA11yTreeSnapshot } from '../core/testing/private.js'; -import { describeIf } from '../core/testing.js'; +import { isSafari } from '../../core/dom.js'; +import { fixture, testA11yTreeSnapshot } from '../../core/testing/private.js'; +import { describeIf } from '../../core/testing.js'; import type { SbbPaginatorElement } from './paginator.js'; diff --git a/src/elements/paginator/paginator.spec.ts b/src/elements/paginator/paginator/paginator.spec.ts similarity index 96% rename from src/elements/paginator/paginator.spec.ts rename to src/elements/paginator/paginator/paginator.spec.ts index f0eb05f92f..013a9a2701 100644 --- a/src/elements/paginator/paginator.spec.ts +++ b/src/elements/paginator/paginator/paginator.spec.ts @@ -3,12 +3,12 @@ import { sendKeys } from '@web/test-runner-commands'; import { html } from 'lit/static-html.js'; import { spy } from 'sinon'; -import type { SbbMiniButtonElement } from '../button/mini-button.js'; -import { tabKey } from '../core/testing/private/keys.js'; -import { fixture } from '../core/testing/private.js'; -import { EventSpy, waitForCondition, waitForLitRender } from '../core/testing.js'; -import type { SbbOptionElement } from '../option.js'; -import { SbbSelectElement } from '../select.js'; +import type { SbbMiniButtonElement } from '../../button/mini-button.js'; +import { tabKey } from '../../core/testing/private/keys.js'; +import { fixture } from '../../core/testing/private.js'; +import { EventSpy, waitForCondition, waitForLitRender } from '../../core/testing.js'; +import type { SbbOptionElement } from '../../option.js'; +import { SbbSelectElement } from '../../select.js'; import { SbbPaginatorElement } from './paginator.js'; diff --git a/src/elements/paginator/paginator.ssr.spec.ts b/src/elements/paginator/paginator/paginator.ssr.spec.ts similarity index 89% rename from src/elements/paginator/paginator.ssr.spec.ts rename to src/elements/paginator/paginator/paginator.ssr.spec.ts index a98be8acf9..fbe2c7991c 100644 --- a/src/elements/paginator/paginator.ssr.spec.ts +++ b/src/elements/paginator/paginator/paginator.ssr.spec.ts @@ -1,7 +1,7 @@ import { assert } from '@open-wc/testing'; import { html } from 'lit/static-html.js'; -import { ssrHydratedFixture } from '../core/testing/private.js'; +import { ssrHydratedFixture } from '../../core/testing/private.js'; import { SbbPaginatorElement } from './paginator.js'; diff --git a/src/elements/paginator/paginator.stories.ts b/src/elements/paginator/paginator/paginator.stories.ts similarity index 97% rename from src/elements/paginator/paginator.stories.ts rename to src/elements/paginator/paginator/paginator.stories.ts index 73d0f6373f..9d5f3e45e0 100644 --- a/src/elements/paginator/paginator.stories.ts +++ b/src/elements/paginator/paginator/paginator.stories.ts @@ -11,7 +11,7 @@ import type { import type { TemplateResult } from 'lit'; import { html } from 'lit'; -import { sbbSpread } from '../../storybook/helpers/spread.js'; +import { sbbSpread } from '../../../storybook/helpers/spread.js'; import { SbbPaginatorElement } from './paginator.js'; import readme from './readme.md?raw'; @@ -203,7 +203,7 @@ const meta: Meta = { extractComponentDescription: () => readme, }, }, - title: 'elements/sbb-paginator', + title: 'elements/sbb-paginator/sbb-paginator', }; export default meta; diff --git a/src/elements/paginator/paginator/paginator.ts b/src/elements/paginator/paginator/paginator.ts new file mode 100644 index 0000000000..b92d6d920d --- /dev/null +++ b/src/elements/paginator/paginator/paginator.ts @@ -0,0 +1,246 @@ +import { + type CSSResultGroup, + html, + LitElement, + nothing, + type PropertyValues, + type TemplateResult, +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import { sbbInputModalityDetector } from '../../core/a11y.js'; +import { i18nItemsPerPage, i18nPage } from '../../core/i18n.js'; +import type { SbbSelectElement } from '../../select.js'; +import { SbbPaginatorCommonElementMixin } from '../common.js'; + +import style from './paginator.scss?lit&inline'; + +import '../../form-field.js'; +import '../../select.js'; +import '../../option.js'; +import '../../screen-reader-only.js'; + +const MAX_PAGE_NUMBERS_DISPLAYED = 3; + +let nextId = 0; + +/** + * It displays a paginator component. + * + * @event {CustomEvent} page - Emits when the pageIndex changes. + */ +export +@customElement('sbb-paginator') +class SbbPaginatorElement extends SbbPaginatorCommonElementMixin(LitElement) { + public static override styles: CSSResultGroup = style; + public static readonly events: Record = { + page: 'page', + } as const; + + /** The available `pageSize` choices. */ + @property({ attribute: 'page-size-options', type: Array }) + public set pageSizeOptions(value: number[]) { + this._pageSizeOptions = value; + this._updateSelectAriaLabelledBy = true; + } + public get pageSizeOptions(): number[] | undefined { + return this._pageSizeOptions; + } + private _pageSizeOptions?: number[]; + + /** + * Position of the prev/next buttons: if `pageSizeOptions` is set, + * the sbb-select for the pageSize change will be positioned oppositely, with the page numbers always in the center. + */ + @property({ attribute: 'pager-position', reflect: true }) public override accessor pagerPosition: + | 'start' + | 'end' = 'start'; + + private _paginatorOptionsLabel = `sbb-paginator-options-label-${++nextId}`; + private _markForFocus: number | null = null; + private _updateSelectAriaLabelledBy: boolean = false; + + protected override updated(changedProperties: PropertyValues): void { + super.updated(changedProperties); + + /** Tab navigation can force a rerender when ellipsis elements need to be displayed; the focus must stay on the correct element. */ + if (this._markForFocus && sbbInputModalityDetector.mostRecentModality === 'keyboard') { + const focusElement = this._getVisiblePages().find( + (e) => this.pageIndex === +e.getAttribute('data-index')!, + ); + if (focusElement) { + (focusElement as HTMLElement).focus(); + } + // Reset mark for focus + this._markForFocus = null; + } + + /** + * TODO: Accessibility fix required to correctly read the label; + * can be possibly removed after the merge of https://github.com/sbb-design-systems/lyne-components/issues/3062 + */ + const select = this.shadowRoot!.querySelector('sbb-select'); + if (select && this._updateSelectAriaLabelledBy) { + select.setAttribute('aria-labelledby', this._paginatorOptionsLabel); + this._updateSelectAriaLabelledBy = false; + } + } + + /** + * If the `pageSize` changes due to user interaction with the `pageSizeOptions` select, + * emit the `page` event and then update the `pageSize` value. + */ + private _pageSizeChanged(value: number): void { + const previousPageSize = this.pageSize; + const previousPageIndex = this.pageIndex; + this.pageSize = value; + + if (previousPageSize !== this.pageSize) { + this.emitPageEvent(previousPageIndex); + } + } + + /** Returns the displayed page elements. */ + private _getVisiblePages(): Element[] { + return Array.from(this.shadowRoot!.querySelectorAll('.sbb-paginator__page--number-item')); + } + + /** + * Calculate the pages set based on the following rules: + * - the first page must always be visible; + * - the last page must always be visible; + * - if there are more than `MAX_PAGE_NUMBERS_DISPLAYED` other pages, ellipsis button must be used. + */ + private _getVisiblePagesIndex(): (number | 'ellipsis')[] { + const totalPages: number = this.numberOfPages(); + const currentPageIndex: number = this.pageIndex; + + if (totalPages <= MAX_PAGE_NUMBERS_DISPLAYED + 2) { + return this._range(totalPages); + } else if (currentPageIndex < MAX_PAGE_NUMBERS_DISPLAYED) { + return [...this._range(MAX_PAGE_NUMBERS_DISPLAYED + 1), 'ellipsis', totalPages - 1]; + } else if (currentPageIndex >= totalPages - MAX_PAGE_NUMBERS_DISPLAYED) { + return [ + 0, + 'ellipsis', + ...this._range(MAX_PAGE_NUMBERS_DISPLAYED + 1, totalPages - 1 - MAX_PAGE_NUMBERS_DISPLAYED), + ]; + } else { + return [ + 0, + 'ellipsis', + currentPageIndex - 1, + currentPageIndex, + currentPageIndex + 1, + 'ellipsis', + totalPages - 1, + ]; + } + } + + /** Creates an array of consecutive numbers given the length and the starting value. */ + private _range(length: number, offset: number = 0): number[] { + return Array.from({ length }, (_, k) => k + offset); + } + + private _handleKeyUp(event: KeyboardEvent): void { + if (event.key !== ' ' && event.key !== 'Enter') { + return; + } + + const current = this._getVisiblePages().find((e: Element) => e === event.target); + if (current) { + this._markForFocus = this.pageIndex; + } + } + + private _renderItemPerPageTemplate(): TemplateResult | typeof nothing { + return this.pageSizeOptions && this.pageSizeOptions.length > 0 + ? html` +
+ + + e === this.pageSize) ?? + this.pageSizeOptions![0]} + @change=${(e: CustomEvent) => + this._pageSizeChanged(+((e.target as SbbSelectElement).value as string))} + > + ${repeat( + this.pageSizeOptions!, + (element) => html`${element}`, + )} + + +
+ ` + : nothing; + } + + private _renderPageNumbers(): TemplateResult { + return html` +
    + ${repeat( + this._getVisiblePagesIndex(), + (item: number | 'ellipsis'): TemplateResult => + item === 'ellipsis' + ? html` +
  • + +
  • + ` + : html` +
  • + +
  • + `, + )} +
+ `; + } + + protected override renderPaginator(): TemplateResult { + return html` +
+ ${ + this.pagerPosition === 'start' + ? html` + ${this.renderPrevNextButtons()} ${this._renderPageNumbers()} + + ${this._renderItemPerPageTemplate()}` + : html`${this._renderItemPerPageTemplate()} + + ${this._renderPageNumbers()} ${this.renderPrevNextButtons()} + ` + } +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-paginator': SbbPaginatorElement; + } +} diff --git a/src/elements/paginator/paginator.visual.spec.ts b/src/elements/paginator/paginator/paginator.visual.spec.ts similarity index 98% rename from src/elements/paginator/paginator.visual.spec.ts rename to src/elements/paginator/paginator/paginator.visual.spec.ts index 9a540004e0..69aa8d6bdc 100644 --- a/src/elements/paginator/paginator.visual.spec.ts +++ b/src/elements/paginator/paginator/paginator.visual.spec.ts @@ -6,7 +6,7 @@ import { visualDiffDefault, visualDiffFocus, visualDiffHover, -} from '../core/testing/private.js'; +} from '../../core/testing/private.js'; import './paginator.js'; diff --git a/src/elements/paginator/readme.md b/src/elements/paginator/paginator/readme.md similarity index 95% rename from src/elements/paginator/readme.md rename to src/elements/paginator/paginator/readme.md index 155b37aded..7e9d77d7b6 100644 --- a/src/elements/paginator/readme.md +++ b/src/elements/paginator/paginator/readme.md @@ -65,16 +65,16 @@ that describes the content controlled by the paginator. ## Properties -| Name | Attribute | Privacy | Type | Default | Description | -| ----------------- | ------------------- | ------- | ----------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `disabled` | `disabled` | public | `boolean` | `false` | Whether the component is disabled. | -| `length` | `length` | public | `number` | `0` | Total number of items. | -| `negative` | `negative` | public | `boolean` | `false` | Negative coloring variant flag. | -| `pageIndex` | `page-index` | public | `number` | `0` | Current page index. | -| `pagerPosition` | `pager-position` | public | `'start' \| 'end'` | `'start'` | Position of the prev/next buttons: if `pageSizeOptions` is set, the sbb-select for the pageSize change will be positioned oppositely with the page numbers always in the center. | -| `pageSize` | `page-size` | public | `number` | `10` | Number of items per page. | -| `pageSizeOptions` | `page-size-options` | public | `number[] \| undefined` | | The available `pageSize` choices. | -| `size` | `size` | public | `'m' \| 's'` | `'m'` | Size variant, either m or s. | +| Name | Attribute | Privacy | Type | Default | Description | +| ----------------- | ------------------- | ------- | ----------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `disabled` | `disabled` | public | `boolean` | `false` | Whether the component is disabled. | +| `length` | `length` | public | `number` | `0` | Total number of items. | +| `negative` | `negative` | public | `boolean` | `false` | Negative coloring variant flag. | +| `pageIndex` | `page-index` | public | `number` | `0` | Current page index. | +| `pagerPosition` | `pager-position` | public | `'start' \| 'end'` | `'start'` | Position of the prev/next buttons: if `pageSizeOptions` is set, the sbb-select for the pageSize change will be positioned oppositely, with the page numbers always in the center. | +| `pageSize` | `page-size` | public | `number` | `10` | Number of items per page. | +| `pageSizeOptions` | `page-size-options` | public | `number[] \| undefined` | | The available `pageSize` choices. | +| `size` | `size` | public | `'m' \| 's'` | `'m'` | Size variant, either m or s. | ## Events diff --git a/tools/vite/generate-react-wrappers.ts b/tools/vite/generate-react-wrappers.ts index 690a3e727a..8367fa047d 100644 --- a/tools/vite/generate-react-wrappers.ts +++ b/tools/vite/generate-react-wrappers.ts @@ -153,11 +153,11 @@ function renderTemplate( .map((e) => e.type.text.substring(12).slice(0, -1)) .sort() .filter((v, i, a) => a.indexOf(v) === i && v.length > 1) ?? []; - // If a type or interface needs to be imported, the custom elements analyzer will not - // detect/extract these and therefore we need to have a manual list of required - // types/interfaces. + // If a type or interface needs to be imported, the custom elements analyzer will not detect/extract these, + // and therefore we need to have a manual list of required types/interfaces. const interfaces = new Map() .set('SbbOverlayCloseEventDetails', 'core/interfaces.js') + .set('SbbPaginatorPageEventDetails', 'core/interfaces.js') .set('SbbValidationChangeEvent', 'core/interfaces.js'); for (const customEventType of customEventTypes) { const exportModule = exports.find((e) => e.name === customEventType);