@ornery/ui-grid-vanilla 0.1.10 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +196 -0
- package/demo/main.ts +0 -1
- package/dist/index.d.mts +575 -55
- package/dist/index.d.ts +575 -55
- package/dist/index.js +4894 -1503
- package/dist/index.mjs +4994 -1556
- package/package.json +11 -8
- package/scss-plugin.ts +65 -0
- package/src/adopt-styles.ts +39 -0
- package/src/attribute-bridge.ts +291 -0
- package/src/auto-height.test.ts +142 -0
- package/src/auto-resize.test.ts +206 -0
- package/src/cell-nav.test.ts +262 -0
- package/src/components/grid-body-cell/grid-body-cell.ts +23 -0
- package/src/components/grid-body-cell/index.ts +1 -0
- package/src/components/grid-cell-editor/grid-cell-editor.ts +104 -0
- package/src/components/grid-cell-editor/index.ts +1 -0
- package/src/components/grid-filter-cell/grid-filter-cell.scss +14 -0
- package/src/components/grid-filter-cell/grid-filter-cell.template.ts +20 -0
- package/src/components/grid-filter-cell/grid-filter-cell.ts +121 -0
- package/src/components/grid-filter-cell/index.ts +1 -0
- package/src/components/grid-group-row/grid-group-row.scss +24 -0
- package/src/components/grid-group-row/grid-group-row.template.ts +23 -0
- package/src/components/grid-group-row/grid-group-row.ts +94 -0
- package/src/components/grid-group-row/index.ts +1 -0
- package/src/components/grid-header-cell/grid-header-cell.ts +17 -0
- package/src/components/grid-header-cell/index.ts +1 -0
- package/src/components/grid-pagination/grid-pagination.scss +39 -0
- package/src/components/grid-pagination/grid-pagination.template.ts +40 -0
- package/src/components/grid-pagination/grid-pagination.ts +204 -0
- package/src/components/grid-pagination/index.ts +1 -0
- package/src/components/grid-template/grid-template.ts +166 -0
- package/src/components/grid-template/index.ts +1 -0
- package/src/events.ts +815 -0
- package/src/exporter.test.ts +186 -0
- package/src/focus.ts +353 -0
- package/src/framework-slots.ts +440 -0
- package/src/grid-controller.ts +1402 -18
- package/src/icons.ts +88 -0
- package/src/importer.test.ts +147 -0
- package/src/index.integration.test.ts +2 -2
- package/src/index.ts +9 -0
- package/src/infinite-scroll.test.ts +220 -0
- package/src/keyboard-navigation.test.ts +579 -0
- package/src/patch.ts +722 -0
- package/src/render.ts +465 -0
- package/src/row-edit.test.ts +191 -0
- package/src/row-selection.test.ts +550 -0
- package/src/save-state.test.ts +235 -0
- package/src/scss.d.ts +4 -0
- package/src/templates/index.ts +62 -0
- package/src/test-setup.ts +23 -0
- package/src/ui-grid-empty.template.ts +23 -0
- package/src/ui-grid-shell.scss +25 -0
- package/src/ui-grid-shell.template.ts +45 -0
- package/src/ui-grid-standalone.element.ts +741 -1112
- package/src/utils/cell-class.ts +70 -0
- package/src/utils/display-items.ts +15 -0
- package/src/utils/dom.ts +64 -0
- package/src/validate.test.ts +170 -0
- package/tsup.config.ts +10 -0
- package/vite.config.ts +10 -1
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { renderFilterCell } from './grid-filter-cell.template';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `<ui-grid-filter-cell>` — Column filter input element.
|
|
5
|
+
*
|
|
6
|
+
* An autonomous custom element that renders a filter input for a single grid column.
|
|
7
|
+
* Uses shadow DOM with scoped styles via `adoptedStyleSheets`.
|
|
8
|
+
*
|
|
9
|
+
* **Rendering pattern:** the template is mounted once in `connectedCallback` via
|
|
10
|
+
* `renderFilterCell()`. Attribute updates patch the existing input in place
|
|
11
|
+
* (value, placeholder, disabled) so the filter input never loses focus on re-renders.
|
|
12
|
+
*
|
|
13
|
+
* **CSS architecture:** Styles are scoped to the shadow DOM via `grid-filter-cell.scss`.
|
|
14
|
+
* CSS custom properties (`--ui-grid-*`) inherit from the parent grid's shadow tree.
|
|
15
|
+
*
|
|
16
|
+
* **Pinning:** The host element receives pinning classes (`is-pinned`, `is-pinned-left-last`,
|
|
17
|
+
* `is-pinned-right-first`) which are styled by the parent grid's CSS since the host element
|
|
18
|
+
* itself lives in the grid's shadow tree.
|
|
19
|
+
*/
|
|
20
|
+
export class UIGridFilterCell extends HTMLElement {
|
|
21
|
+
static readonly TAG = 'ui-grid-filter-cell';
|
|
22
|
+
|
|
23
|
+
columnName = '';
|
|
24
|
+
value = '';
|
|
25
|
+
placeholder = '';
|
|
26
|
+
|
|
27
|
+
private mounted = false;
|
|
28
|
+
private inputEl: HTMLInputElement | null = null;
|
|
29
|
+
|
|
30
|
+
constructor() {
|
|
31
|
+
super();
|
|
32
|
+
this.attachShadow({ mode: 'open' });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
static get observedAttributes(): string[] {
|
|
36
|
+
return ['data-column', 'data-value', 'data-placeholder', 'data-disabled',
|
|
37
|
+
'data-pinned', 'data-pinned-left-last', 'data-pinned-right-first',
|
|
38
|
+
'data-sticky-style'];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
connectedCallback(): void {
|
|
42
|
+
if (!this.mounted) {
|
|
43
|
+
this.columnName = this.getAttribute('data-column') ?? '';
|
|
44
|
+
this.value = this.getAttribute('data-value') ?? '';
|
|
45
|
+
this.placeholder = this.getAttribute('data-placeholder') ?? '';
|
|
46
|
+
renderFilterCell(this, this.shadowRoot!);
|
|
47
|
+
this.inputEl = this.shadowRoot!.querySelector('input');
|
|
48
|
+
this.mounted = true;
|
|
49
|
+
}
|
|
50
|
+
this.sync();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
attributeChangedCallback(): void {
|
|
54
|
+
if (this.mounted) {
|
|
55
|
+
this.sync();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private sync(): void {
|
|
60
|
+
const columnName = this.getAttribute('data-column') ?? '';
|
|
61
|
+
const value = this.getAttribute('data-value') ?? '';
|
|
62
|
+
const placeholder = this.getAttribute('data-placeholder') ?? '';
|
|
63
|
+
const disabled = this.getAttribute('data-disabled') === 'true';
|
|
64
|
+
const isPinned = this.getAttribute('data-pinned') === 'true';
|
|
65
|
+
const isPinnedLeftLast = this.getAttribute('data-pinned-left-last') === 'true';
|
|
66
|
+
const isPinnedRightFirst = this.getAttribute('data-pinned-right-first') === 'true';
|
|
67
|
+
const stickyStyle = this.getAttribute('data-sticky-style') ?? '';
|
|
68
|
+
|
|
69
|
+
this.columnName = columnName;
|
|
70
|
+
this.value = value;
|
|
71
|
+
this.placeholder = placeholder;
|
|
72
|
+
|
|
73
|
+
const nextClass = [
|
|
74
|
+
'filter-cell',
|
|
75
|
+
isPinned ? 'is-pinned' : '',
|
|
76
|
+
isPinnedLeftLast ? 'is-pinned-left-last' : '',
|
|
77
|
+
isPinnedRightFirst ? 'is-pinned-right-first' : '',
|
|
78
|
+
].filter(Boolean).join(' ');
|
|
79
|
+
if (this.className !== nextClass) {
|
|
80
|
+
this.className = nextClass;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (stickyStyle) {
|
|
84
|
+
if (this.getAttribute('style') !== stickyStyle) {
|
|
85
|
+
this.setAttribute('style', stickyStyle);
|
|
86
|
+
}
|
|
87
|
+
} else if (this.hasAttribute('style')) {
|
|
88
|
+
this.removeAttribute('style');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const input = this.inputEl;
|
|
92
|
+
if (input) {
|
|
93
|
+
if (input.dataset['column'] !== columnName) {
|
|
94
|
+
input.dataset['column'] = columnName;
|
|
95
|
+
}
|
|
96
|
+
if (input.placeholder !== placeholder) {
|
|
97
|
+
input.placeholder = placeholder;
|
|
98
|
+
}
|
|
99
|
+
// Only overwrite value when the input is not focused and the parent carries
|
|
100
|
+
// a different value — skipping this during typing preserves caret position.
|
|
101
|
+
const inputFocused = this.shadowRoot?.activeElement === input;
|
|
102
|
+
if (!inputFocused && input.value !== value) {
|
|
103
|
+
input.value = value;
|
|
104
|
+
}
|
|
105
|
+
if (input.disabled !== disabled) {
|
|
106
|
+
input.disabled = disabled;
|
|
107
|
+
}
|
|
108
|
+
// Non-filterable columns hide the input entirely — matching the old
|
|
109
|
+
// grid's empty ui-grid-filter-container behaviour. `display: none` is
|
|
110
|
+
// CSS-only (no DOM detach) so focus / value preservation still work
|
|
111
|
+
// when a column flips filterable on.
|
|
112
|
+
input.style.display = disabled ? 'none' : '';
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
static define(tagName = UIGridFilterCell.TAG): void {
|
|
117
|
+
if (!customElements.get(tagName)) {
|
|
118
|
+
customElements.define(tagName, UIGridFilterCell);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { UIGridFilterCell } from './grid-filter-cell';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Group row shadow-scoped styles — composed entirely from core mixins.
|
|
2
|
+
@use "@ornery/ui-grid-core/src/lib/styles/group-row" as gr;
|
|
3
|
+
@use "@ornery/ui-grid-core/src/lib/styles/utilities" as util;
|
|
4
|
+
|
|
5
|
+
:host {
|
|
6
|
+
@include gr.host-shadow;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
span,
|
|
10
|
+
small {
|
|
11
|
+
@include gr.muted-text;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.toggle-icon {
|
|
15
|
+
@include gr.toggle-icon;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.group-disclosure-icon {
|
|
19
|
+
@include gr.disclosure-icon;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.sr-only {
|
|
23
|
+
@include util.sr-only;
|
|
24
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import css from './grid-group-row.scss';
|
|
2
|
+
import { adoptStyles } from '../../adopt-styles';
|
|
3
|
+
|
|
4
|
+
const STYLE_KEY = {};
|
|
5
|
+
|
|
6
|
+
interface GroupRowContext {
|
|
7
|
+
iconViewBox: string;
|
|
8
|
+
iconPath: string;
|
|
9
|
+
disclosureLabel: string;
|
|
10
|
+
field: string;
|
|
11
|
+
label: string;
|
|
12
|
+
count: string;
|
|
13
|
+
rowsSuffix: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function renderGroupRow(ctx: GroupRowContext, shadowRoot: ShadowRoot): void {
|
|
17
|
+
adoptStyles(shadowRoot, css, STYLE_KEY);
|
|
18
|
+
shadowRoot.innerHTML =
|
|
19
|
+
`<svg class="toggle-icon group-disclosure-icon" viewBox="${ctx.iconViewBox}" aria-hidden="true" focusable="false"><path d="${ctx.iconPath}"></path></svg>` +
|
|
20
|
+
`<span class="sr-only">${ctx.disclosureLabel}</span>` +
|
|
21
|
+
`<strong>${ctx.field}: ${ctx.label}</strong>` +
|
|
22
|
+
`<span>${ctx.count} ${ctx.rowsSuffix}</span>`;
|
|
23
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { renderGroupRow } from './grid-group-row.template';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `<ui-grid-group-row>` — Group disclosure row element.
|
|
5
|
+
*
|
|
6
|
+
* An autonomous custom element that renders a group header row with a disclosure
|
|
7
|
+
* toggle icon, group field/label, and row count. Uses shadow DOM with scoped styles
|
|
8
|
+
* via `adoptedStyleSheets`.
|
|
9
|
+
*
|
|
10
|
+
* **Rendering pattern:** instance properties are populated from data attributes,
|
|
11
|
+
* then `renderGroupRow(this, shadowRoot)` writes the markup into the shadow root.
|
|
12
|
+
*
|
|
13
|
+
* **CSS architecture:** Styles are scoped to the shadow DOM via `grid-group-row.scss`.
|
|
14
|
+
* CSS custom properties (`--ui-grid-*`) inherit from the parent grid's shadow tree.
|
|
15
|
+
*
|
|
16
|
+
* **Event delegation:** The host element carries `data-action="toggle-group"` and
|
|
17
|
+
* `data-group` attributes so the parent grid's click handler can identify it without
|
|
18
|
+
* piercing the shadow boundary.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```html
|
|
22
|
+
* <ui-grid-group-row
|
|
23
|
+
* data-action="toggle-group"
|
|
24
|
+
* data-group="category:Fruit"
|
|
25
|
+
* data-collapsed="false"
|
|
26
|
+
* data-field="category"
|
|
27
|
+
* data-label="Fruit"
|
|
28
|
+
* data-count="5"
|
|
29
|
+
* data-depth="0"
|
|
30
|
+
* data-icon-path="M7 10l5 5 5-5z">
|
|
31
|
+
* </ui-grid-group-row>
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export class UIGridGroupRow extends HTMLElement {
|
|
35
|
+
static readonly TAG = 'ui-grid-group-row';
|
|
36
|
+
|
|
37
|
+
/** The grouping field name (e.g. "category"). */
|
|
38
|
+
field = '';
|
|
39
|
+
/** The group value label (e.g. "Fruit"). */
|
|
40
|
+
label = '';
|
|
41
|
+
/** Number of rows in this group. */
|
|
42
|
+
count = '0';
|
|
43
|
+
/** Accessibility label for the disclosure toggle. */
|
|
44
|
+
disclosureLabel = '';
|
|
45
|
+
/** SVG path data for the disclosure icon. */
|
|
46
|
+
iconPath = '';
|
|
47
|
+
/** SVG viewBox for the disclosure icon. */
|
|
48
|
+
iconViewBox = '0 0 24 24';
|
|
49
|
+
/** Suffix text after the count (e.g. "items"). */
|
|
50
|
+
rowsSuffix = '';
|
|
51
|
+
|
|
52
|
+
constructor() {
|
|
53
|
+
super();
|
|
54
|
+
this.attachShadow({ mode: 'open' });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
static get observedAttributes(): string[] {
|
|
58
|
+
return ['data-group', 'data-collapsed', 'data-field', 'data-label',
|
|
59
|
+
'data-count', 'data-depth', 'data-disclosure-label',
|
|
60
|
+
'data-icon-path', 'data-icon-view-box', 'data-rows-suffix'];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
connectedCallback(): void {
|
|
64
|
+
this.render();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
attributeChangedCallback(): void {
|
|
68
|
+
if (this.isConnected) {
|
|
69
|
+
this.render();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private render(): void {
|
|
74
|
+
const depth = parseInt(this.getAttribute('data-depth') ?? '0', 10);
|
|
75
|
+
this.field = this.getAttribute('data-field') ?? '';
|
|
76
|
+
this.label = this.getAttribute('data-label') ?? '';
|
|
77
|
+
this.count = this.getAttribute('data-count') ?? '0';
|
|
78
|
+
this.disclosureLabel = this.getAttribute('data-disclosure-label') ?? '';
|
|
79
|
+
this.iconPath = this.getAttribute('data-icon-path') ?? '';
|
|
80
|
+
this.iconViewBox = this.getAttribute('data-icon-view-box') ?? '0 0 24 24';
|
|
81
|
+
this.rowsSuffix = this.getAttribute('data-rows-suffix') ?? '';
|
|
82
|
+
|
|
83
|
+
this.className = 'group-row ui-grid-row ui-grid-group-row';
|
|
84
|
+
this.setAttribute('style', `grid-column: 1 / -1; padding-inline-start:${depth * 20 + 10}px`);
|
|
85
|
+
|
|
86
|
+
renderGroupRow(this, this.shadowRoot!);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
static define(tagName = UIGridGroupRow.TAG): void {
|
|
90
|
+
if (!customElements.get(tagName)) {
|
|
91
|
+
customElements.define(tagName, UIGridGroupRow);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { UIGridGroupRow } from './grid-group-row';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<ui-grid-header-cell>` — Column header cell element.
|
|
3
|
+
*
|
|
4
|
+
* An empty custom element. Same performance rationale as `<ui-grid-body-cell>`:
|
|
5
|
+
* the grid shell pre-computes `class`, `style`, `draggable`, and all `data-*`
|
|
6
|
+
* attributes into the emitted HTML string. No per-element lifecycle callback
|
|
7
|
+
* runs during parse — the parent is the single writer.
|
|
8
|
+
*/
|
|
9
|
+
export class UIGridHeaderCell extends HTMLElement {
|
|
10
|
+
static readonly TAG = 'ui-grid-header-cell';
|
|
11
|
+
|
|
12
|
+
static define(tagName = UIGridHeaderCell.TAG): void {
|
|
13
|
+
if (!customElements.get(tagName)) {
|
|
14
|
+
customElements.define(tagName, UIGridHeaderCell);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { UIGridHeaderCell } from './grid-header-cell';
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Pagination shadow-scoped styles — composed entirely from core mixins.
|
|
2
|
+
@use "@ornery/ui-grid-core/src/lib/styles/pagination" as pg;
|
|
3
|
+
@use "@ornery/ui-grid-core/src/lib/styles/utilities" as util;
|
|
4
|
+
|
|
5
|
+
:host {
|
|
6
|
+
@include pg.host-shadow;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
p {
|
|
10
|
+
@include pg.range-text;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.pagination-controls {
|
|
14
|
+
@include pg.controls;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.pagination-button {
|
|
18
|
+
@include pg.button-shadow;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.pagination-button:disabled {
|
|
22
|
+
@include pg.button-disabled;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.pagination-icon {
|
|
26
|
+
@include pg.icon;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.pagination-size {
|
|
30
|
+
@include pg.size-label;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.pagination-size select {
|
|
34
|
+
@include pg.size-select-shadow;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.sr-only {
|
|
38
|
+
@include util.sr-only;
|
|
39
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import css from './grid-pagination.scss';
|
|
2
|
+
import { adoptStyles } from '../../adopt-styles';
|
|
3
|
+
|
|
4
|
+
const STYLE_KEY = {};
|
|
5
|
+
|
|
6
|
+
interface PaginationContext {
|
|
7
|
+
rangeLabel: string;
|
|
8
|
+
prevLabel: string;
|
|
9
|
+
prevIconViewBox: string;
|
|
10
|
+
prevIconPath: string;
|
|
11
|
+
pageLabel: string;
|
|
12
|
+
currentPage: string;
|
|
13
|
+
ofLabel: string;
|
|
14
|
+
totalPages: string;
|
|
15
|
+
nextLabel: string;
|
|
16
|
+
nextIconViewBox: string;
|
|
17
|
+
nextIconPath: string;
|
|
18
|
+
rowsLabel: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function renderPagination(ctx: PaginationContext, shadowRoot: ShadowRoot): void {
|
|
22
|
+
adoptStyles(shadowRoot, css, STYLE_KEY);
|
|
23
|
+
shadowRoot.innerHTML =
|
|
24
|
+
`<p>${ctx.rangeLabel}</p>` +
|
|
25
|
+
`<div class="pagination-controls">` +
|
|
26
|
+
`<button type="button" class="pagination-button" data-action="page-prev" aria-label="${ctx.prevLabel}">` +
|
|
27
|
+
`<svg class="pagination-icon" viewBox="${ctx.prevIconViewBox}" aria-hidden="true" focusable="false"><path d="${ctx.prevIconPath}"></path></svg>` +
|
|
28
|
+
`<span class="sr-only">${ctx.prevLabel}</span>` +
|
|
29
|
+
`</button>` +
|
|
30
|
+
`<span>${ctx.pageLabel} ${ctx.currentPage} ${ctx.ofLabel} ${ctx.totalPages}</span>` +
|
|
31
|
+
`<button type="button" class="pagination-button" data-action="page-next" aria-label="${ctx.nextLabel}">` +
|
|
32
|
+
`<svg class="pagination-icon" viewBox="${ctx.nextIconViewBox}" aria-hidden="true" focusable="false"><path d="${ctx.nextIconPath}"></path></svg>` +
|
|
33
|
+
`<span class="sr-only">${ctx.nextLabel}</span>` +
|
|
34
|
+
`</button>` +
|
|
35
|
+
`<label class="pagination-size">` +
|
|
36
|
+
`<span class="sr-only">${ctx.rowsLabel}</span>` +
|
|
37
|
+
`<select class="page-size" data-role="page-size"></select>` +
|
|
38
|
+
`</label>` +
|
|
39
|
+
`</div>`;
|
|
40
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { renderPagination } from './grid-pagination.template';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `<ui-grid-pagination>` — Pagination controls element.
|
|
5
|
+
*
|
|
6
|
+
* Mounts once via `template(this).connect()` in `connectedCallback`; subsequent
|
|
7
|
+
* attribute changes patch the existing DOM in place (text labels, button disabled
|
|
8
|
+
* flags, select options). The parent grid re-uses a single `<ui-grid-pagination>`
|
|
9
|
+
* node and just sets data-attributes on it, so nothing inside ever loses focus.
|
|
10
|
+
*/
|
|
11
|
+
export class UIGridPagination extends HTMLElement {
|
|
12
|
+
static readonly TAG = 'ui-grid-pagination';
|
|
13
|
+
|
|
14
|
+
rangeLabel = '';
|
|
15
|
+
currentPage = '1';
|
|
16
|
+
totalPages = '1';
|
|
17
|
+
pageLabel = '';
|
|
18
|
+
ofLabel = '';
|
|
19
|
+
prevLabel = '';
|
|
20
|
+
nextLabel = '';
|
|
21
|
+
rowsLabel = '';
|
|
22
|
+
prevIconPath = '';
|
|
23
|
+
prevIconViewBox = '0 0 24 24';
|
|
24
|
+
nextIconPath = '';
|
|
25
|
+
nextIconViewBox = '0 0 24 24';
|
|
26
|
+
|
|
27
|
+
private mounted = false;
|
|
28
|
+
private rangeEl: HTMLElement | null = null;
|
|
29
|
+
private pageCounterEl: HTMLElement | null = null;
|
|
30
|
+
private prevBtn: HTMLButtonElement | null = null;
|
|
31
|
+
private nextBtn: HTMLButtonElement | null = null;
|
|
32
|
+
private prevIconPathEl: SVGPathElement | null = null;
|
|
33
|
+
private nextIconPathEl: SVGPathElement | null = null;
|
|
34
|
+
private prevLabelSr: HTMLElement | null = null;
|
|
35
|
+
private nextLabelSr: HTMLElement | null = null;
|
|
36
|
+
private rowsLabelSr: HTMLElement | null = null;
|
|
37
|
+
private sizeSelect: HTMLSelectElement | null = null;
|
|
38
|
+
private lastPageSizesKey = '';
|
|
39
|
+
|
|
40
|
+
constructor() {
|
|
41
|
+
super();
|
|
42
|
+
this.attachShadow({ mode: 'open' });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
static get observedAttributes(): string[] {
|
|
46
|
+
return ['data-range-label', 'data-current-page', 'data-total-pages',
|
|
47
|
+
'data-page-label', 'data-of-label', 'data-prev-label', 'data-next-label',
|
|
48
|
+
'data-rows-label', 'data-prev-icon-path', 'data-prev-icon-view-box',
|
|
49
|
+
'data-next-icon-path', 'data-next-icon-view-box',
|
|
50
|
+
'data-page-sizes', 'data-page-size', 'data-prev-disabled', 'data-next-disabled'];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
connectedCallback(): void {
|
|
54
|
+
if (!this.mounted) {
|
|
55
|
+
this.captureTemplateBindings();
|
|
56
|
+
renderPagination(this, this.shadowRoot!);
|
|
57
|
+
this.cacheElements();
|
|
58
|
+
this.attachSelectListener();
|
|
59
|
+
this.mounted = true;
|
|
60
|
+
}
|
|
61
|
+
this.sync();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
attributeChangedCallback(): void {
|
|
65
|
+
if (this.mounted) {
|
|
66
|
+
this.sync();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private captureTemplateBindings(): void {
|
|
71
|
+
this.rangeLabel = this.getAttribute('data-range-label') ?? '';
|
|
72
|
+
this.currentPage = this.getAttribute('data-current-page') ?? '1';
|
|
73
|
+
this.totalPages = this.getAttribute('data-total-pages') ?? '1';
|
|
74
|
+
this.pageLabel = this.getAttribute('data-page-label') ?? '';
|
|
75
|
+
this.ofLabel = this.getAttribute('data-of-label') ?? '';
|
|
76
|
+
this.prevLabel = this.getAttribute('data-prev-label') ?? '';
|
|
77
|
+
this.nextLabel = this.getAttribute('data-next-label') ?? '';
|
|
78
|
+
this.rowsLabel = this.getAttribute('data-rows-label') ?? '';
|
|
79
|
+
this.prevIconPath = this.getAttribute('data-prev-icon-path') ?? '';
|
|
80
|
+
this.prevIconViewBox = this.getAttribute('data-prev-icon-view-box') ?? '0 0 24 24';
|
|
81
|
+
this.nextIconPath = this.getAttribute('data-next-icon-path') ?? '';
|
|
82
|
+
this.nextIconViewBox = this.getAttribute('data-next-icon-view-box') ?? '0 0 24 24';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private cacheElements(): void {
|
|
86
|
+
const shadow = this.shadowRoot!;
|
|
87
|
+
this.rangeEl = shadow.querySelector('p');
|
|
88
|
+
this.prevBtn = shadow.querySelector('[data-action="page-prev"]');
|
|
89
|
+
this.nextBtn = shadow.querySelector('[data-action="page-next"]');
|
|
90
|
+
this.pageCounterEl = shadow.querySelector('.pagination-controls > span');
|
|
91
|
+
this.prevIconPathEl = this.prevBtn?.querySelector('path') ?? null;
|
|
92
|
+
this.nextIconPathEl = this.nextBtn?.querySelector('path') ?? null;
|
|
93
|
+
this.prevLabelSr = this.prevBtn?.querySelector('.sr-only') ?? null;
|
|
94
|
+
this.nextLabelSr = this.nextBtn?.querySelector('.sr-only') ?? null;
|
|
95
|
+
this.rowsLabelSr = shadow.querySelector('.pagination-size .sr-only');
|
|
96
|
+
this.sizeSelect = shadow.querySelector('select[data-role="page-size"]');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private attachSelectListener(): void {
|
|
100
|
+
const select = this.sizeSelect;
|
|
101
|
+
if (!select) return;
|
|
102
|
+
select.addEventListener('change', () => {
|
|
103
|
+
this.dispatchEvent(new CustomEvent('grid-page-size', {
|
|
104
|
+
bubbles: true,
|
|
105
|
+
composed: true,
|
|
106
|
+
detail: { pageSize: Number.parseInt(select.value, 10) },
|
|
107
|
+
}));
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private sync(): void {
|
|
112
|
+
this.className = 'pagination-bar ui-grid-pagination';
|
|
113
|
+
|
|
114
|
+
const rangeLabel = this.getAttribute('data-range-label') ?? '';
|
|
115
|
+
const currentPage = this.getAttribute('data-current-page') ?? '1';
|
|
116
|
+
const totalPages = this.getAttribute('data-total-pages') ?? '1';
|
|
117
|
+
const pageLabel = this.getAttribute('data-page-label') ?? '';
|
|
118
|
+
const ofLabel = this.getAttribute('data-of-label') ?? '';
|
|
119
|
+
const prevLabel = this.getAttribute('data-prev-label') ?? '';
|
|
120
|
+
const nextLabel = this.getAttribute('data-next-label') ?? '';
|
|
121
|
+
const rowsLabel = this.getAttribute('data-rows-label') ?? '';
|
|
122
|
+
const prevIconPath = this.getAttribute('data-prev-icon-path') ?? '';
|
|
123
|
+
const prevIconViewBox = this.getAttribute('data-prev-icon-view-box') ?? '0 0 24 24';
|
|
124
|
+
const nextIconPath = this.getAttribute('data-next-icon-path') ?? '';
|
|
125
|
+
const nextIconViewBox = this.getAttribute('data-next-icon-view-box') ?? '0 0 24 24';
|
|
126
|
+
const pageSizesAttr = this.getAttribute('data-page-sizes') ?? '[10,25,50,100]';
|
|
127
|
+
const pageSize = parseInt(this.getAttribute('data-page-size') ?? '25', 10);
|
|
128
|
+
const prevDisabled = this.getAttribute('data-prev-disabled') === 'true';
|
|
129
|
+
const nextDisabled = this.getAttribute('data-next-disabled') === 'true';
|
|
130
|
+
|
|
131
|
+
if (this.rangeEl && this.rangeEl.textContent !== rangeLabel) {
|
|
132
|
+
this.rangeEl.textContent = rangeLabel;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (this.pageCounterEl) {
|
|
136
|
+
const pageText = `${pageLabel} ${currentPage} ${ofLabel} ${totalPages}`;
|
|
137
|
+
if (this.pageCounterEl.textContent !== pageText) {
|
|
138
|
+
this.pageCounterEl.textContent = pageText;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (this.prevBtn) {
|
|
143
|
+
if (this.prevBtn.getAttribute('aria-label') !== prevLabel) {
|
|
144
|
+
this.prevBtn.setAttribute('aria-label', prevLabel);
|
|
145
|
+
}
|
|
146
|
+
if (this.prevBtn.disabled !== prevDisabled) {
|
|
147
|
+
this.prevBtn.disabled = prevDisabled;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (this.prevIconPathEl && this.prevIconPathEl.getAttribute('d') !== prevIconPath) {
|
|
151
|
+
this.prevIconPathEl.setAttribute('d', prevIconPath);
|
|
152
|
+
}
|
|
153
|
+
const prevSvg = this.prevBtn?.querySelector('svg');
|
|
154
|
+
if (prevSvg && prevSvg.getAttribute('viewBox') !== prevIconViewBox) {
|
|
155
|
+
prevSvg.setAttribute('viewBox', prevIconViewBox);
|
|
156
|
+
}
|
|
157
|
+
if (this.prevLabelSr && this.prevLabelSr.textContent !== prevLabel) {
|
|
158
|
+
this.prevLabelSr.textContent = prevLabel;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (this.nextBtn) {
|
|
162
|
+
if (this.nextBtn.getAttribute('aria-label') !== nextLabel) {
|
|
163
|
+
this.nextBtn.setAttribute('aria-label', nextLabel);
|
|
164
|
+
}
|
|
165
|
+
if (this.nextBtn.disabled !== nextDisabled) {
|
|
166
|
+
this.nextBtn.disabled = nextDisabled;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (this.nextIconPathEl && this.nextIconPathEl.getAttribute('d') !== nextIconPath) {
|
|
170
|
+
this.nextIconPathEl.setAttribute('d', nextIconPath);
|
|
171
|
+
}
|
|
172
|
+
const nextSvg = this.nextBtn?.querySelector('svg');
|
|
173
|
+
if (nextSvg && nextSvg.getAttribute('viewBox') !== nextIconViewBox) {
|
|
174
|
+
nextSvg.setAttribute('viewBox', nextIconViewBox);
|
|
175
|
+
}
|
|
176
|
+
if (this.nextLabelSr && this.nextLabelSr.textContent !== nextLabel) {
|
|
177
|
+
this.nextLabelSr.textContent = nextLabel;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (this.rowsLabelSr && this.rowsLabelSr.textContent !== rowsLabel) {
|
|
181
|
+
this.rowsLabelSr.textContent = rowsLabel;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const select = this.sizeSelect;
|
|
185
|
+
if (select) {
|
|
186
|
+
const pageSizesKey = `${pageSizesAttr}|${pageSize}`;
|
|
187
|
+
if (pageSizesKey !== this.lastPageSizesKey) {
|
|
188
|
+
const pageSizes = JSON.parse(pageSizesAttr) as number[];
|
|
189
|
+
select.innerHTML = pageSizes
|
|
190
|
+
.map((size) => `<option value="${size}" ${size === pageSize ? 'selected' : ''}>${size}</option>`)
|
|
191
|
+
.join('');
|
|
192
|
+
this.lastPageSizesKey = pageSizesKey;
|
|
193
|
+
} else if (select.value !== String(pageSize)) {
|
|
194
|
+
select.value = String(pageSize);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
static define(tagName = UIGridPagination.TAG): void {
|
|
200
|
+
if (!customElements.get(tagName)) {
|
|
201
|
+
customElements.define(tagName, UIGridPagination);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { UIGridPagination } from './grid-pagination';
|