@simai-studio/view 0.0.0-stage → 1.1.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/LICENSE.md +183 -0
- package/README.md +75 -2
- package/dist/package-receipt.json +972 -0
- package/dist/set.json +141 -0
- package/dist/smart/rule.js +278 -0
- package/dist/smart/rule.js.gz +0 -0
- package/dist/smart/view/aliases.json +129 -0
- package/dist/smart/view/aliases.json.gz +0 -0
- package/dist/smart/view/component.json +1628 -0
- package/dist/smart/view/component.json.gz +0 -0
- package/dist/smart/view/css/view.css +208 -0
- package/dist/smart/view/css/view.css.gz +0 -0
- package/dist/smart/view/css/view.min.css +2 -0
- package/dist/smart/view/css/view.min.css.gz +0 -0
- package/dist/smart/view/examples.json +668 -0
- package/dist/smart/view/examples.json.gz +0 -0
- package/dist/smart/view/js/view.js +3955 -0
- package/dist/smart/view/js/view.js.gz +0 -0
- package/dist/smart/view/js/view.min.js +1 -0
- package/dist/smart/view/js/view.min.js.gz +0 -0
- package/dist/smart/view/lang/en.json +61 -0
- package/dist/smart/view/lang/en.json.gz +0 -0
- package/dist/smart/view/lang/ru.json +61 -0
- package/dist/smart/view/lang/ru.json.gz +0 -0
- package/dist/smart/view/probe.json +165 -0
- package/dist/smart/view/probe.json.gz +0 -0
- package/dist/smart/view/smart.manifest.json +1577 -0
- package/dist/smart/view/smart.manifest.json.gz +0 -0
- package/dist/smart/view/static/default/embedded.html +96 -0
- package/dist/smart/view/static/default/embedded.html.gz +0 -0
- package/dist/smart/view/static/default/open-record.html +242 -0
- package/dist/smart/view/static/default/open-record.html.gz +0 -0
- package/dist/smart/view/static/default/reference.html +242 -0
- package/dist/smart/view/static/default/reference.html.gz +0 -0
- package/dist/smart/view/static/sheets.json +282 -0
- package/dist/smart/view/static/sheets.json.gz +0 -0
- package/dist/smart/view-properties/aliases.json +174 -0
- package/dist/smart/view-properties/aliases.json.gz +0 -0
- package/dist/smart/view-properties/component.json +1906 -0
- package/dist/smart/view-properties/component.json.gz +0 -0
- package/dist/smart/view-properties/css/view-properties.css +1158 -0
- package/dist/smart/view-properties/css/view-properties.css.gz +0 -0
- package/dist/smart/view-properties/css/view-properties.min.css +2 -0
- package/dist/smart/view-properties/css/view-properties.min.css.gz +0 -0
- package/dist/smart/view-properties/examples.json +841 -0
- package/dist/smart/view-properties/examples.json.gz +0 -0
- package/dist/smart/view-properties/js/view-properties.js +10551 -0
- package/dist/smart/view-properties/js/view-properties.js.gz +0 -0
- package/dist/smart/view-properties/js/view-properties.min.js +1392 -0
- package/dist/smart/view-properties/js/view-properties.min.js.gz +0 -0
- package/dist/smart/view-properties/lang/en.json +89 -0
- package/dist/smart/view-properties/lang/en.json.gz +0 -0
- package/dist/smart/view-properties/lang/ru.json +89 -0
- package/dist/smart/view-properties/lang/ru.json.gz +0 -0
- package/dist/smart/view-properties/probe.json +188 -0
- package/dist/smart/view-properties/probe.json.gz +0 -0
- package/dist/smart/view-properties/skeleton.html +13 -0
- package/dist/smart/view-properties/skeleton.html.gz +0 -0
- package/dist/smart/view-properties/smart.manifest.json +1593 -0
- package/dist/smart/view-properties/smart.manifest.json.gz +0 -0
- package/dist/smart/view-properties/static/default/full.html +2 -0
- package/dist/smart/view-properties/static/default/full.html.gz +0 -0
- package/dist/smart/view-properties/static/default/panel-closed.html +4 -0
- package/dist/smart/view-properties/static/default/panel-closed.html.gz +0 -0
- package/dist/smart/view-properties/static/default/panel.html +4 -0
- package/dist/smart/view-properties/static/default/panel.html.gz +0 -0
- package/dist/smart/view-properties/static/default/reference.html +2 -0
- package/dist/smart/view-properties/static/default/reference.html.gz +0 -0
- package/dist/smart/view-properties/static/sheets.json +426 -0
- package/dist/smart/view-properties/static/sheets.json.gz +0 -0
- package/dist/smart/view-properties/template/default.css +992 -0
- package/dist/smart/view-properties/template/default.css.gz +0 -0
- package/dist/smart/view-properties/template/default.js +1658 -0
- package/dist/smart/view-properties/template/default.js.gz +0 -0
- package/dist/smart/view-table/aliases.json +448 -0
- package/dist/smart/view-table/aliases.json.gz +0 -0
- package/dist/smart/view-table/component.json +2746 -0
- package/dist/smart/view-table/component.json.gz +0 -0
- package/dist/smart/view-table/css/view-table.css +3658 -0
- package/dist/smart/view-table/css/view-table.css.gz +0 -0
- package/dist/smart/view-table/css/view-table.min.css +2 -0
- package/dist/smart/view-table/css/view-table.min.css.gz +0 -0
- package/dist/smart/view-table/examples.json +499 -0
- package/dist/smart/view-table/examples.json.gz +0 -0
- package/dist/smart/view-table/js/view-table.js +14166 -0
- package/dist/smart/view-table/js/view-table.js.gz +0 -0
- package/dist/smart/view-table/js/view-table.min.js +1440 -0
- package/dist/smart/view-table/js/view-table.min.js.gz +0 -0
- package/dist/smart/view-table/lang/en.json +132 -0
- package/dist/smart/view-table/lang/en.json.gz +0 -0
- package/dist/smart/view-table/lang/ru.json +132 -0
- package/dist/smart/view-table/lang/ru.json.gz +0 -0
- package/dist/smart/view-table/probe.json +275 -0
- package/dist/smart/view-table/probe.json.gz +0 -0
- package/dist/smart/view-table/smart.manifest.json +2531 -0
- package/dist/smart/view-table/smart.manifest.json.gz +0 -0
- package/dist/smart/view-table/static/default/current.html +141 -0
- package/dist/smart/view-table/static/default/current.html.gz +0 -0
- package/dist/smart/view-table/static/default/current.skeleton.html +141 -0
- package/dist/smart/view-table/static/default/current.skeleton.html.gz +0 -0
- package/dist/smart/view-table/static/default/empty.html +149 -0
- package/dist/smart/view-table/static/default/empty.html.gz +0 -0
- package/dist/smart/view-table/static/default/loading.html +180 -0
- package/dist/smart/view-table/static/default/loading.html.gz +0 -0
- package/dist/smart/view-table/static/default/many.html +180 -0
- package/dist/smart/view-table/static/default/many.html.gz +0 -0
- package/dist/smart/view-table/static/default/reference.html +136 -0
- package/dist/smart/view-table/static/default/reference.html.gz +0 -0
- package/dist/smart/view-table/static/default/reference.skeleton.html +136 -0
- package/dist/smart/view-table/static/default/reference.skeleton.html.gz +0 -0
- package/dist/smart/view-table/static/default/typed-cells.html +229 -0
- package/dist/smart/view-table/static/default/typed-cells.html.gz +0 -0
- package/dist/smart/view-table/static/default/typed-cells.skeleton.html +229 -0
- package/dist/smart/view-table/static/default/typed-cells.skeleton.html.gz +0 -0
- package/dist/smart/view-table/static/sheets.json +480 -0
- package/dist/smart/view-table/static/sheets.json.gz +0 -0
- package/dist/smart/view-table/studio.fixture.json +17 -0
- package/dist/smart/view-table/studio.fixture.json.gz +0 -0
- package/dist/smart/view-table/studio.scenario.json +30 -0
- package/dist/smart/view-table/studio.scenario.json.gz +0 -0
- package/dist/smart/view-table/template/default.css +1301 -0
- package/dist/smart/view-table/template/default.css.gz +0 -0
- package/dist/smart/view-table/template/default.js +1215 -0
- package/dist/smart/view-table/template/default.js.gz +0 -0
- package/dist/smart/view-table/template/link-cell-contract.js +48 -0
- package/dist/smart/view-table/template/link-cell-contract.js.gz +0 -0
- package/package.json +29 -4
|
@@ -0,0 +1,1215 @@
|
|
|
1
|
+
import {html, nothing} from "lit";
|
|
2
|
+
import {keyed} from "lit/directives/keyed.js";
|
|
3
|
+
import {repeat} from "lit/directives/repeat.js";
|
|
4
|
+
import {ref} from "lit/directives/ref.js";
|
|
5
|
+
import {isTemplateResult} from "lit/directive-helpers.js";
|
|
6
|
+
import "./default.css";
|
|
7
|
+
import {normalizeTableLinkCell} from "./link-cell-contract.js";
|
|
8
|
+
|
|
9
|
+
function getEventInputValue(event, fallback = '') {
|
|
10
|
+
const pathTarget = event?.composedPath?.()?.[0];
|
|
11
|
+
const candidates = [pathTarget, event?.target, event?.currentTarget];
|
|
12
|
+
|
|
13
|
+
for (const candidate of candidates) {
|
|
14
|
+
if (!candidate) continue;
|
|
15
|
+
|
|
16
|
+
if (typeof candidate.value !== 'undefined') {
|
|
17
|
+
return candidate.value;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const input = candidate.querySelector?.('input, textarea');
|
|
21
|
+
if (input && typeof input.value !== 'undefined') {
|
|
22
|
+
return input.value;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return fallback;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The toolbar of the compact list: one line, the search taking the room and the
|
|
30
|
+
// filter and create as icons beside it -- the table's toolbar wrapped to three
|
|
31
|
+
// lines on a phone and took a quarter of the screen. Active filters follow as a
|
|
32
|
+
// row of chips that scrolls sideways, each removable, and the filter itself
|
|
33
|
+
// opens as a sheet over the whole screen.
|
|
34
|
+
function renderListToolbar(context) {
|
|
35
|
+
const {state} = context.component;
|
|
36
|
+
const {value} = state.search;
|
|
37
|
+
const filterTags = context.component.getCurrentFilterTags();
|
|
38
|
+
const filterValues = context.component.getCurrentFilterValues();
|
|
39
|
+
const active = Object.entries(filterTags || {}).filter(([, tag]) => tag?.active !== false);
|
|
40
|
+
return html`
|
|
41
|
+
<div class="sf-table-toolbar sf-table-toolbar--list flex flex-col gap-1/3 flex-none"
|
|
42
|
+
data-sf-filter-tags="${active.length ? '1' : '0'}">
|
|
43
|
+
<div class="flex items-cross-center gap-1/3 min-w-0">
|
|
44
|
+
<sf-input
|
|
45
|
+
size=${context.size}
|
|
46
|
+
root-class="sf-search-input flex-1 min-w-0"
|
|
47
|
+
appearance="bordered"
|
|
48
|
+
value="${value}"
|
|
49
|
+
placeholder=${context.searchPlaceholder}
|
|
50
|
+
name="sf_table_search"
|
|
51
|
+
aria-label=${context.searchPlaceholder}
|
|
52
|
+
@input=${(e) => context.component.searchChange({value: getEventInputValue(e, value)})}
|
|
53
|
+
@keydown=${(e) => context.component.handleSearchKeyDown(e)}
|
|
54
|
+
@keyup=${(e) => context.component.handleSearchKeyUp(e)}
|
|
55
|
+
hint-icon="search"
|
|
56
|
+
></sf-input>
|
|
57
|
+
${context.component.offersFilter(active.length) ? html`<span class="sf-table-list-filter">
|
|
58
|
+
<sf-button-icon
|
|
59
|
+
size=${context.size}
|
|
60
|
+
appearance=${active.length ? 'default' : 'outline'}
|
|
61
|
+
scheme="primary"
|
|
62
|
+
icon="filter_list"
|
|
63
|
+
aria-label=${active.length
|
|
64
|
+
? context.component.text('listFilterLabel').replace('{count}', String(active.length))
|
|
65
|
+
: context.filterText}
|
|
66
|
+
@click=${(e) => context.component.openContextMenu(e, {type: 'bottom', menu: 'filter-settings'})}
|
|
67
|
+
></sf-button-icon>
|
|
68
|
+
${active.length ? html`<span class="sf-table-list-filter-count" aria-hidden="true">${active.length}</span>` : nothing}
|
|
69
|
+
</span>` : nothing}
|
|
70
|
+
${context.component.getColumns().some((column) => column?.sortable && !column.system) ? html`
|
|
71
|
+
<sf-button-icon
|
|
72
|
+
size=${context.size}
|
|
73
|
+
appearance=${state.sort?.key ? 'default' : 'outline'}
|
|
74
|
+
scheme=${state.sort?.key ? 'primary' : 'on-surface'}
|
|
75
|
+
icon="swap_vert"
|
|
76
|
+
aria-label=${context.component.text('sortLabel')}
|
|
77
|
+
aria-haspopup="menu"
|
|
78
|
+
@click=${(e) => context.component.openContextMenu(e, {type: 'bottom', menu: 'list-sort'})}
|
|
79
|
+
></sf-button-icon>` : nothing}
|
|
80
|
+
${context.actions && context.creatable ? html`
|
|
81
|
+
<sf-button-icon
|
|
82
|
+
size=${context.size}
|
|
83
|
+
appearance="outline"
|
|
84
|
+
scheme="on-surface"
|
|
85
|
+
icon="add"
|
|
86
|
+
aria-label=${context.createText}
|
|
87
|
+
@click=${(e) => (state.createItems?.length
|
|
88
|
+
? context.component.openContextMenu(e, {type: 'bottom', menu: 'create'})
|
|
89
|
+
: context.component.requestCreateIntent(null))}
|
|
90
|
+
></sf-button-icon>` : nothing}
|
|
91
|
+
</div>
|
|
92
|
+
${active.length ? html`
|
|
93
|
+
<div class="sf-table-list-tags flex items-cross-center gap-1/3">
|
|
94
|
+
${active.map(([k, tag]) => {
|
|
95
|
+
const filterValue = filterValues?.[k] || {};
|
|
96
|
+
const count = Array.isArray(filterValue.values) ? filterValue.values.length : tag.count || '';
|
|
97
|
+
return html`
|
|
98
|
+
<sf-tag type="default" size=${context.size} text='${tag.label}' interactive closable :key=${k}
|
|
99
|
+
root-class="active" count='${count}'
|
|
100
|
+
@close="${() => context.component.removeFilterTag(k)}"
|
|
101
|
+
@click=${(e) => context.component.openContextMenu(e, {type: 'bottom', menu: 'tag-settings', fieldKey: k, anchorSelector: '.sf-tag'})}
|
|
102
|
+
></sf-tag>`;
|
|
103
|
+
})}
|
|
104
|
+
<sf-button size=${context.size} appearance="link" scheme="on-surface" text=${context.clearText}
|
|
105
|
+
@click="${() => context.component.removeFilterTag()}"></sf-button>
|
|
106
|
+
</div>` : nothing}
|
|
107
|
+
</div>
|
|
108
|
+
`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function renderToolbar(context) {
|
|
112
|
+
const {state} = context.component;
|
|
113
|
+
const {value} = state.search;
|
|
114
|
+
const filterTags = context.component.getCurrentFilterTags();
|
|
115
|
+
const filterValues = context.component.getCurrentFilterValues();
|
|
116
|
+
const hasUnsavedFilterDataChanges =
|
|
117
|
+
context.component.hasUnsavedFilterDataChanges();
|
|
118
|
+
const activeFilterTags = Object.entries(filterTags || {}).filter(
|
|
119
|
+
([, tag]) => tag?.active !== false,
|
|
120
|
+
);
|
|
121
|
+
return html`
|
|
122
|
+
<div
|
|
123
|
+
class="sf-table-toolbar flex gap-1/2 content-main-between flex-none"
|
|
124
|
+
data-sf-filter-tags="${activeFilterTags.length ? '1' : '0'}"
|
|
125
|
+
>
|
|
126
|
+
<div class="sf-table-toolbar-start flex items-cross-center gap-1/3 flex-wrap min-w-0">
|
|
127
|
+
${context.component.offersFilter(activeFilterTags.length) ? html`<div class="flex items-cross-center">
|
|
128
|
+
<sf-button
|
|
129
|
+
size=${context.size}
|
|
130
|
+
appearance="default"
|
|
131
|
+
scheme="primary"
|
|
132
|
+
text=${context.filterText}
|
|
133
|
+
segment=${context.filterTemplates ? 'start' : nothing}
|
|
134
|
+
icon-start="filter_list"
|
|
135
|
+
@click=${(e) => {
|
|
136
|
+
context.component.openContextMenu(e, {
|
|
137
|
+
type: 'bottom',
|
|
138
|
+
menu: 'filter-settings',
|
|
139
|
+
})
|
|
140
|
+
}}
|
|
141
|
+
></sf-button>
|
|
142
|
+
${context.filterTemplates ? html`
|
|
143
|
+
<sf-button-icon
|
|
144
|
+
size=${context.size}
|
|
145
|
+
appearance="default"
|
|
146
|
+
scheme="primary"
|
|
147
|
+
aria-label="Сохранить шаблон фильтра"
|
|
148
|
+
?disabled=${!activeFilterTags.length}
|
|
149
|
+
segment="end"
|
|
150
|
+
icon="bookmark_add"
|
|
151
|
+
@click=${(e) => {
|
|
152
|
+
context.component.openContextMenu(e, {
|
|
153
|
+
type: 'bottom',
|
|
154
|
+
menu: 'filter-favorites',
|
|
155
|
+
parent: true
|
|
156
|
+
})
|
|
157
|
+
}}
|
|
158
|
+
></sf-button-icon>` : nothing}
|
|
159
|
+
</div>` : nothing}
|
|
160
|
+
${activeFilterTags.map(([k, tag]) => {
|
|
161
|
+
const filterValue = filterValues?.[k] || {};
|
|
162
|
+
const hasFilteredValues = Object.prototype.hasOwnProperty.call(
|
|
163
|
+
filterValues || {},
|
|
164
|
+
k,
|
|
165
|
+
);
|
|
166
|
+
const count = Array.isArray(filterValue.values)
|
|
167
|
+
? filterValue.values.length
|
|
168
|
+
: tag.count || '';
|
|
169
|
+
|
|
170
|
+
return html`
|
|
171
|
+
<div class="sf-table-tags">
|
|
172
|
+
<sf-tag
|
|
173
|
+
type="default"
|
|
174
|
+
size=${context.size}
|
|
175
|
+
text='${tag.label}'
|
|
176
|
+
interactive
|
|
177
|
+
closable
|
|
178
|
+
:key=${k}
|
|
179
|
+
root-class="${hasFilteredValues ? 'active' : ''}"
|
|
180
|
+
@close="${() => {
|
|
181
|
+
context.component.removeFilterTag(k);
|
|
182
|
+
}}"
|
|
183
|
+
@click=${(e) => {
|
|
184
|
+
context.component.openContextMenu(e, {
|
|
185
|
+
type: 'bottom',
|
|
186
|
+
menu: 'tag-settings',
|
|
187
|
+
fieldKey: k,
|
|
188
|
+
anchorSelector: '.sf-tag',
|
|
189
|
+
})
|
|
190
|
+
}}
|
|
191
|
+
count='${count}'
|
|
192
|
+
></sf-tag>
|
|
193
|
+
</div>`
|
|
194
|
+
})}
|
|
195
|
+
${activeFilterTags.length || hasUnsavedFilterDataChanges ? html`
|
|
196
|
+
${hasUnsavedFilterDataChanges ? html`
|
|
197
|
+
<sf-button
|
|
198
|
+
size=${context.size}
|
|
199
|
+
appearance="default"
|
|
200
|
+
scheme="primary"
|
|
201
|
+
text="Сохранить"
|
|
202
|
+
@click="${() => context.component.saveCurrentFilterTemplateData()}"
|
|
203
|
+
></sf-button>
|
|
204
|
+
` : nothing}
|
|
205
|
+
${activeFilterTags.length ? html`
|
|
206
|
+
<sf-button
|
|
207
|
+
size=${context.size}
|
|
208
|
+
appearance="outline"
|
|
209
|
+
scheme="on-surface"
|
|
210
|
+
text=${context.clearText}
|
|
211
|
+
@click="${() => context.component.removeFilterTag()}"
|
|
212
|
+
></sf-button>
|
|
213
|
+
` : nothing}
|
|
214
|
+
` : nothing}
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
<div class="sf-table-toolbar-end flex items-cross-center gap-1/3 flex-wrap flex-auto min-w-0">
|
|
218
|
+
<sf-input
|
|
219
|
+
size=${context.size}
|
|
220
|
+
root-class="sf-search-input m-inline-start-auto"
|
|
221
|
+
appearance="bordered"
|
|
222
|
+
value="${value}"
|
|
223
|
+
placeholder=${context.searchPlaceholder}
|
|
224
|
+
name="sf_table_search"
|
|
225
|
+
@input=${(e) => {
|
|
226
|
+
context.component.searchChange({
|
|
227
|
+
value: getEventInputValue(e, value)
|
|
228
|
+
});
|
|
229
|
+
}}
|
|
230
|
+
@keydown=${(e) => {
|
|
231
|
+
context.component.handleSearchKeyDown(e);
|
|
232
|
+
}}
|
|
233
|
+
@keyup=${(e) => {
|
|
234
|
+
context.component.handleSearchKeyUp(e);
|
|
235
|
+
}}
|
|
236
|
+
hint-icon="search"
|
|
237
|
+
>
|
|
238
|
+
<span slot="right">
|
|
239
|
+
<sf-button-icon
|
|
240
|
+
size=${context.size}
|
|
241
|
+
appearance="link"
|
|
242
|
+
scheme="on-surface"
|
|
243
|
+
icon=${value.length ? 'close' : 'search'}
|
|
244
|
+
aria-label=${value.length ? 'Очистить поиск' : 'Поиск'}
|
|
245
|
+
@click=${(e) => {
|
|
246
|
+
e.preventDefault();
|
|
247
|
+
e.stopPropagation();
|
|
248
|
+
if (!value.length) return false;
|
|
249
|
+
context.component.updateSearchState({
|
|
250
|
+
value: '',
|
|
251
|
+
});
|
|
252
|
+
}}
|
|
253
|
+
></sf-button-icon>
|
|
254
|
+
|
|
255
|
+
</span>
|
|
256
|
+
</sf-input>
|
|
257
|
+
|
|
258
|
+
${context.actions && context.creatable
|
|
259
|
+
? html`
|
|
260
|
+
<div class="flex items-cross-center">
|
|
261
|
+
<sf-button
|
|
262
|
+
size=${context.size}
|
|
263
|
+
appearance="outline"
|
|
264
|
+
scheme="on-surface"
|
|
265
|
+
text=${context.createText}
|
|
266
|
+
segment=${state.createItems?.length ? 'start' : nothing}
|
|
267
|
+
@click=${() => context.component.requestCreateIntent(null)}
|
|
268
|
+
></sf-button>
|
|
269
|
+
${state.createItems?.length ? html`
|
|
270
|
+
<sf-button-icon
|
|
271
|
+
size=${context.size}
|
|
272
|
+
appearance="outline"
|
|
273
|
+
scheme="primary"
|
|
274
|
+
segment="end"
|
|
275
|
+
icon="keyboard_arrow_down"
|
|
276
|
+
aria-label="Дополнительные действия создания"
|
|
277
|
+
aria-haspopup="menu"
|
|
278
|
+
@click=${(e) => {
|
|
279
|
+
context.component.openContextMenu(e, {
|
|
280
|
+
type: 'bottom',
|
|
281
|
+
menu: 'create',
|
|
282
|
+
});
|
|
283
|
+
}}
|
|
284
|
+
></sf-button-icon>
|
|
285
|
+
` : nothing}
|
|
286
|
+
</div>
|
|
287
|
+
`
|
|
288
|
+
: nothing}
|
|
289
|
+
|
|
290
|
+
<!-- The extra actions of the view, from the host. Column
|
|
291
|
+
settings are not here: they are the gear in the table's own
|
|
292
|
+
head, where they have always been. Without actions there is
|
|
293
|
+
no control, because an empty menu is a dead end. -->
|
|
294
|
+
${context.settings && state.toolbarActions?.length
|
|
295
|
+
? html`
|
|
296
|
+
<sf-button-icon
|
|
297
|
+
size=${context.size}
|
|
298
|
+
appearance="outline"
|
|
299
|
+
scheme="primary"
|
|
300
|
+
icon="more_vert"
|
|
301
|
+
aria-label="Действия с представлением"
|
|
302
|
+
aria-haspopup="menu"
|
|
303
|
+
@click=${e => {
|
|
304
|
+
context.component.openContextMenu(e, { menu: 'toolbar-actions' });
|
|
305
|
+
}}
|
|
306
|
+
></sf-button-icon>
|
|
307
|
+
`
|
|
308
|
+
: nothing}
|
|
309
|
+
</div>
|
|
310
|
+
</div>
|
|
311
|
+
`;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
// The state the region is actually in: the author's data-state, or what the
|
|
316
|
+
// rows resolve to. It is declared on the region, not guessed from the markup.
|
|
317
|
+
// What the table says when it has no rows, one brick for every reason
|
|
318
|
+
// (owner, 2026-10-14): sf-empty-state at the table's size, the reason and its
|
|
319
|
+
// one action from what the table and its data view know -- no access, could
|
|
320
|
+
// not load (Повторить), nothing found (what the reset clears), no data
|
|
321
|
+
// (Создать when the table may create). A slot `empty` replaces it whole.
|
|
322
|
+
function renderEmptyState(context, dataState) {
|
|
323
|
+
const component = context.component;
|
|
324
|
+
if (component?.hasSlotContent?.('empty')) return component.getSlotContent('empty');
|
|
325
|
+
const brick = component.emptyBrick(dataState);
|
|
326
|
+
return html`<sf-empty-state kind=${brick.kind} size=${brick.size} heading-level=${brick.headingLevel}
|
|
327
|
+
heading=${brick.title || nothing} text=${brick.text || nothing} live=${brick.live || nothing}
|
|
328
|
+
.actions=${brick.actions}></sf-empty-state>`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function resolvedDataState(context) {
|
|
332
|
+
if (context.dataState !== 'auto') return context.dataState;
|
|
333
|
+
const data = context.component?.getTableData?.() ?? {};
|
|
334
|
+
if ((data.rows || []).length) return 'populated';
|
|
335
|
+
// Nothing given yet -- no columns, no rows -- is a table waiting for its
|
|
336
|
+
// first answer, drawn as its skeleton: «Пока ничего нет» stood there
|
|
337
|
+
// before any answer came (owner, 2026-10-15, frame by frame).
|
|
338
|
+
if (!(data.columns || []).length) return 'loading';
|
|
339
|
+
return 'empty';
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function renderHeadLabel(context, column) {
|
|
343
|
+
// The actions column is as wide as its icons, and its heading broke in two
|
|
344
|
+
// over them («Дей / ствия»): it is named for assistive technology only.
|
|
345
|
+
if (column.key === 'actions' && !column.component) {
|
|
346
|
+
return html`<span class="sf-table-visually-hidden">${column.label}</span>`;
|
|
347
|
+
}
|
|
348
|
+
// The gear opens the column settings; without them (settings-menu="false",
|
|
349
|
+
// a host that saves no personal settings) the column keeps its row menus and
|
|
350
|
+
// its heading is only a name.
|
|
351
|
+
if (column.key === 'settings' && context.settingsMenu === false) {
|
|
352
|
+
return html`<span class="sf-table-visually-hidden">${context.component.text('settingsColumnLabel')}</span>`;
|
|
353
|
+
}
|
|
354
|
+
const content = column.component
|
|
355
|
+
? context.component.renderSmartElement(column.component.type, column.component.props)
|
|
356
|
+
: column.label;
|
|
357
|
+
if (column.sortable !== true || column.system) {
|
|
358
|
+
return content;
|
|
359
|
+
}
|
|
360
|
+
const sort = context.component.state.sort || {};
|
|
361
|
+
const direction = sort.key === column.key ? sort.direction : null;
|
|
362
|
+
const icon = direction === 'asc' ? 'arrow_upward' : direction === 'desc' ? 'arrow_downward' : 'unfold_more';
|
|
363
|
+
|
|
364
|
+
return html`
|
|
365
|
+
<button type="button" class="sf-table-sort-button flex items-cross-center gap-1/4 min-w-0"
|
|
366
|
+
data-direction=${direction || nothing}>
|
|
367
|
+
<span class="truncate">${content}</span>
|
|
368
|
+
<i class="sf-icon sf-table-sort-icon" aria-hidden="true">${icon}</i>
|
|
369
|
+
</button>
|
|
370
|
+
`;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function ariaSort(context, column) {
|
|
374
|
+
if (column.sortable !== true || column.system) return nothing;
|
|
375
|
+
const sort = context.component.state.sort || {};
|
|
376
|
+
if (sort.key !== column.key) return 'none';
|
|
377
|
+
return sort.direction === 'asc' ? 'ascending' : 'descending';
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function renderHead(context) {
|
|
381
|
+
if (context.component?.hasSlotContent?.("head")) {
|
|
382
|
+
return context.component.getSlotContent("head");
|
|
383
|
+
}
|
|
384
|
+
const columns = context.component?.getColumns?.() || [];
|
|
385
|
+
|
|
386
|
+
return html`
|
|
387
|
+
<thead class="sticky top-0 z-5">
|
|
388
|
+
<tr>
|
|
389
|
+
${columns.map((column, k) => {
|
|
390
|
+
return keyed(`head:${column.key}`, html`
|
|
391
|
+
<th
|
|
392
|
+
class="relative"
|
|
393
|
+
data-key="${column.key}"
|
|
394
|
+
data-label="${column.label || column.key || ''}"
|
|
395
|
+
data-sf-pinned=${context.component.isPinnedColumn(column) ? '' : nothing}
|
|
396
|
+
style=${context.component.getStickyCellStyle(columns, k) || nothing}
|
|
397
|
+
aria-sort=${ariaSort(context, column)}
|
|
398
|
+
@click=${column.sortable === true
|
|
399
|
+
? (event) => context.component.handleHeaderSortClick(event, column)
|
|
400
|
+
: nothing}
|
|
401
|
+
>
|
|
402
|
+
${column.system || k === columns.length - 1 ?
|
|
403
|
+
renderHeadLabel(context, column) :
|
|
404
|
+
html`<div
|
|
405
|
+
class="sidebar-resizer flex items-cross-center h-full select-none"
|
|
406
|
+
>
|
|
407
|
+
${renderHeadLabel(context, column)}
|
|
408
|
+
<!-- The width is taken by the line between
|
|
409
|
+
two headers. There is no handle to find
|
|
410
|
+
first: this is the line's own hit strip,
|
|
411
|
+
invisible, astride the separator, and
|
|
412
|
+
still a button, so the keyboard keeps
|
|
413
|
+
the name and the arrows it had. -->
|
|
414
|
+
<button type="button"
|
|
415
|
+
role="separator"
|
|
416
|
+
aria-orientation="vertical"
|
|
417
|
+
class="sf-table-column-resizer"
|
|
418
|
+
id=${context.component.getColumnResizerId(column.key)}
|
|
419
|
+
data-sf-column-resizer=${column.key}
|
|
420
|
+
aria-label="Изменить ширину столбца: ${column.label || column.key}"
|
|
421
|
+
aria-keyshortcuts="ArrowLeft ArrowRight Home End Enter"
|
|
422
|
+
></button>
|
|
423
|
+
</div>`}
|
|
424
|
+
</th>`
|
|
425
|
+
)
|
|
426
|
+
}
|
|
427
|
+
)}
|
|
428
|
+
</tr>
|
|
429
|
+
</thead>
|
|
430
|
+
`
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function toColumnSize(value) {
|
|
434
|
+
if (value === null || typeof value === "undefined" || value === "") {
|
|
435
|
+
return "";
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return typeof value === "number" ? `${value}px` : String(value);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// How narrow a data column may become before it stops being readable.
|
|
442
|
+
//
|
|
443
|
+
// A fixed-layout table divides whatever room it has between its columns and
|
|
444
|
+
// will divide it to nothing: at 620px with five columns they came out 61px
|
|
445
|
+
// each, which shreds «Код решения» over three lines and cannot hold a date at
|
|
446
|
+
// all. The consumer asked for a floor and for the table to scroll instead
|
|
447
|
+
// (Larena, 2026-10-06; measured on the admin reference, 2026-10-09).
|
|
448
|
+
//
|
|
449
|
+
// It is the table's own min-width rather than the columns', because
|
|
450
|
+
// `table-layout: fixed` ignores a minimum on a cell -- the widths come from
|
|
451
|
+
// the first row. So the floor is summed here and the table carries the total;
|
|
452
|
+
// the scroll box around it already scrolls, and starts doing so the moment the
|
|
453
|
+
// sum does not fit.
|
|
454
|
+
//
|
|
455
|
+
// System columns are not in it: select, settings and actions are sized for what
|
|
456
|
+
// they hold and are not text anyone reads.
|
|
457
|
+
// Field types whose values are prose rather than a name or a code: drawn on one
|
|
458
|
+
// line in a row (see renderCellValue).
|
|
459
|
+
const LONG_TEXT_TYPES = new Set(['text', 'textarea', 'markdown', 'html']);
|
|
460
|
+
|
|
461
|
+
const SYSTEM_COLUMNS = new Set(['select', 'settings', 'actions']);
|
|
462
|
+
|
|
463
|
+
// The first load draws the rows it is waiting for, in the Framework's skeleton
|
|
464
|
+
// (owner, 2026-10-12): the shape of the list is on screen before its records,
|
|
465
|
+
// instead of one spinner in an empty table. Widths vary by row and column so
|
|
466
|
+
// the placeholder reads as text, not as a grid of bars. The words are for a
|
|
467
|
+
// reader and stay in a status; the bars are hidden from one.
|
|
468
|
+
const SKELETON_ROWS = 6;
|
|
469
|
+
const SKELETON_WIDTHS = ['w-3/4', 'w-1/2', 'w-2/3', 'w-1/3', 'w-2/5', 'w-3/5'];
|
|
470
|
+
// Where a loaded row has a box, a menu or a gear, a loading row keeps a block
|
|
471
|
+
// as tall as that control, so the row is as tall before the data as after it
|
|
472
|
+
// (owner, 2026-10-14: the rows jumped when the data came).
|
|
473
|
+
const skeletonControl = html`<div class="sf-table-skeleton-control" aria-hidden="true"></div>`;
|
|
474
|
+
const skeletonBar = (row, column) => html`<div class="sf-skeleton sf-skeleton--animation-pulse" aria-hidden="true">
|
|
475
|
+
<div class="sf-skeleton-text sf-skeleton-text--size-1/2 ${SKELETON_WIDTHS[(row + column * 2) % SKELETON_WIDTHS.length]}"></div>
|
|
476
|
+
</div>`;
|
|
477
|
+
|
|
478
|
+
// The floor is the component's measurement, not a declaration here: it is
|
|
479
|
+
// applied to a column only while there is not room for every column to be
|
|
480
|
+
// readable. Written always it would be the width rather than a minimum -- a
|
|
481
|
+
// cell that needs more would wrap and a typed row would grow, measured at 81px
|
|
482
|
+
// against 57 (owner, 2026-10-09).
|
|
483
|
+
|
|
484
|
+
// A column's width, floor and cap as its <col> style: the template draws it,
|
|
485
|
+
// and the table writes it straight onto the <col> after measuring its content,
|
|
486
|
+
// rather than rendering itself again for it (owner, 2026-10-15: a sort was five
|
|
487
|
+
// renders of four hundred elements).
|
|
488
|
+
export function columnStyle(component, column) {
|
|
489
|
+
const width = toColumnSize(column.colWidth ?? column.width ?? column.size)
|
|
490
|
+
|| (column.key === 'actions' ? (component?.actionsColumnWidth || 'var(--sf-table--actions-column-width, var(--sf-f0))') : '')
|
|
491
|
+
|| component?.contentWidth?.(column)
|
|
492
|
+
|| (component?.columnsAreCramped
|
|
493
|
+
&& component?.columnTakesFloor?.(column)
|
|
494
|
+
? 'var(--sf-table--column-min-inline-size)'
|
|
495
|
+
: '');
|
|
496
|
+
const minWidth = toColumnSize(column.min_width ?? column.minWidth);
|
|
497
|
+
const maxWidth = toColumnSize(column.maxWidth);
|
|
498
|
+
return [
|
|
499
|
+
width ? `width: ${width}` : "",
|
|
500
|
+
minWidth ? `min-width: ${minWidth}` : "",
|
|
501
|
+
maxWidth ? `max-width: ${maxWidth}` : "",
|
|
502
|
+
].filter(Boolean).join("; ");
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function renderColgroup(context) {
|
|
506
|
+
const columns = context.component?.getColumns?.() || [];
|
|
507
|
+
|
|
508
|
+
if (!columns.length) {
|
|
509
|
+
return nothing;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
return html`
|
|
513
|
+
<colgroup>
|
|
514
|
+
${columns.map((column) => {
|
|
515
|
+
// In a fixed layout the column widths come from here, and a
|
|
516
|
+
// column with none is given whatever is left -- divided to
|
|
517
|
+
// nothing when there is little. The floor is written as the
|
|
518
|
+
// width: fixed layout shares any surplus between the columns
|
|
519
|
+
// that have one, so they grow together above it and stop
|
|
520
|
+
// shrinking below it, and the scroll box takes over.
|
|
521
|
+
// `||` and not `??`: toColumnSize answers with an empty string
|
|
522
|
+
// for a column nobody sized, and an empty string is neither
|
|
523
|
+
// null nor undefined. The same shape cost sf-button its icon
|
|
524
|
+
// shortcut for as long as that component existed.
|
|
525
|
+
// The actions column is sized for its buttons. Without a width it
|
|
526
|
+
// took whatever room was left -- a thousand pixels on a wide
|
|
527
|
+
// page -- and the data columns were measured as cramped beside
|
|
528
|
+
// it and held at their floor.
|
|
529
|
+
const style = columnStyle(context.component, column);
|
|
530
|
+
|
|
531
|
+
return keyed(`col:${column.key}`, html`
|
|
532
|
+
<col
|
|
533
|
+
data-key=${column.key || nothing}
|
|
534
|
+
style=${style || nothing}
|
|
535
|
+
/>
|
|
536
|
+
`);
|
|
537
|
+
})}
|
|
538
|
+
</colgroup>
|
|
539
|
+
`;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Several values in a cell -- tags for each choice, badges for each language --
|
|
543
|
+
// stay on one line: what does not fit folds into «+N», whose title names the
|
|
544
|
+
// rest (owner, 2026-10-13). Wrapped, three tags made a row three lines tall in
|
|
545
|
+
// a list of one-line rows. The folded values stay in the text, visually hidden,
|
|
546
|
+
// so a screen reader still reads them all; the table's afterRender does the
|
|
547
|
+
// folding (fitCellValues), because only the laid-out row knows what fits.
|
|
548
|
+
function renderCellValues(items, owner) {
|
|
549
|
+
return html`
|
|
550
|
+
<div class="sf-table-cell-values flex items-cross-center gap-1/3">
|
|
551
|
+
${items.map((item) => renderCellItem(item, owner))}
|
|
552
|
+
<span class="sf-table-cell-more" aria-hidden="true" hidden></span>
|
|
553
|
+
</div>
|
|
554
|
+
`;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function renderComponent(component, owner) {
|
|
558
|
+
if (!component?.type) {
|
|
559
|
+
return nothing;
|
|
560
|
+
}
|
|
561
|
+
const props = {...(component.props || {})};
|
|
562
|
+
return owner.renderCellElement ? owner.renderCellElement(component.type, props) : owner.renderSmartElement(component.type, props);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function renderCellItem(item, owner) {
|
|
566
|
+
if (item === null || typeof item === "undefined") {
|
|
567
|
+
return nothing;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// What the shared renderer set answers with is markup, not a descriptor.
|
|
571
|
+
// Nine of its eleven cell renderers happen to answer with a descriptor --
|
|
572
|
+
// a badge, a tag, an avatar, a reference link -- and the two that answer
|
|
573
|
+
// with markup, the colour and the included block, fell through the object
|
|
574
|
+
// branch below and were dropped: every colour cell and every code cell in
|
|
575
|
+
// every table drew nothing at all. A unit calling the renderer saw correct
|
|
576
|
+
// markup, so only a table could show it (admin polygon, 2026-10-10).
|
|
577
|
+
if (isTemplateResult(item)) {
|
|
578
|
+
return item;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (typeof item === "object") {
|
|
582
|
+
const {
|
|
583
|
+
component,
|
|
584
|
+
props: itemProps,
|
|
585
|
+
...itemMeta
|
|
586
|
+
} = item;
|
|
587
|
+
|
|
588
|
+
if (!component?.type) {
|
|
589
|
+
return nothing;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
return renderComponent({
|
|
593
|
+
...component,
|
|
594
|
+
props: {
|
|
595
|
+
...itemMeta,
|
|
596
|
+
...(itemProps || {}),
|
|
597
|
+
...(component.props || {}),
|
|
598
|
+
},
|
|
599
|
+
}, owner);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
return item;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function renderLinkCell(value) {
|
|
606
|
+
const cell = normalizeTableLinkCell(value);
|
|
607
|
+
if (!cell) {
|
|
608
|
+
return nothing;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const content = html`
|
|
612
|
+
<span class="sf-table-link-cell-content flex flex-col min-w-0">
|
|
613
|
+
<span class="sf-table-link-cell-text">${cell.text}</span>
|
|
614
|
+
${cell.subtext === null ? nothing : html`
|
|
615
|
+
<span class="sf-table-link-cell-subtext sf-text-1/2">${cell.subtext}</span>
|
|
616
|
+
`}
|
|
617
|
+
</span>
|
|
618
|
+
`;
|
|
619
|
+
|
|
620
|
+
return cell.href
|
|
621
|
+
? html`<a class="sf-table-link-cell" href=${cell.href}>${content}</a>`
|
|
622
|
+
: html`<span class="sf-table-link-cell">${content}</span>`;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
// A row's markup, composed per arrangement of its cells (standard 1.3.8
|
|
627
|
+
// §18.4, simai/ui-pro#2). A row of plain cells is one Lit template with no
|
|
628
|
+
// part per cell: its strings are made once for each sequence of cell kinds
|
|
629
|
+
// (a box, the row menu, a text, an empty cell, a full cell) and kept, so Lit
|
|
630
|
+
// parses each arrangement once and a drawn row holds no marker per cell --
|
|
631
|
+
// about twenty-five nodes where a template per cell held forty-six. The
|
|
632
|
+
// fragments are fixed; a cell's values are bound, never written as HTML.
|
|
633
|
+
// This is the migration form (§16) until template.html is compiled.
|
|
634
|
+
const ROW_HOLE = Symbol("row-hole");
|
|
635
|
+
const ROW_STRINGS = new Map();
|
|
636
|
+
function rowStrings(kinds) {
|
|
637
|
+
const signature = kinds.join("|");
|
|
638
|
+
const known = ROW_STRINGS.get(signature);
|
|
639
|
+
if (known) return known;
|
|
640
|
+
const pieces = ['<tr data-selected=', ROW_HOLE, ' draggable=', ROW_HOLE, ' aria-level=', ROW_HOLE,
|
|
641
|
+
' aria-expanded=', ROW_HOLE, ' aria-setsize=', ROW_HOLE, ' aria-posinset=', ROW_HOLE, ' aria-busy=', ROW_HOLE,
|
|
642
|
+
' aria-rowindex=', ROW_HOLE, ' data-row-id=', ROW_HOLE, ' data-current=', ROW_HOLE, ' aria-current=', ROW_HOLE,
|
|
643
|
+
' tabindex="-1" @mousedown=', ROW_HOLE, ' @click=', ROW_HOLE, ' @dblclick=', ROW_HOLE, ' @keydown=', ROW_HOLE, '>'];
|
|
644
|
+
for (const kind of kinds) {
|
|
645
|
+
pieces.push('<td data-key=', ROW_HOLE, ' data-item=', ROW_HOLE, ' data-sf-pinned=', ROW_HOLE, ' style=', ROW_HOLE, '>');
|
|
646
|
+
if (kind === 'box') {
|
|
647
|
+
pieces.push('<label class=', ROW_HOLE, '><span class="sf-checkbox-box transition flex"><input type="checkbox" data-sf-row-box value=', ROW_HOLE,
|
|
648
|
+
' .checked=', ROW_HOLE, ' aria-label=', ROW_HOLE, ' @change=', ROW_HOLE, '></span></label>');
|
|
649
|
+
} else if (kind === 'menu') {
|
|
650
|
+
pieces.push('<button type="button" class=', ROW_HOLE, ' aria-label=', ROW_HOLE, ' value="menu" data-sf-row-menu @click=', ROW_HOLE,
|
|
651
|
+
'><i class="sf-icon sf-icon--weight-regular pointer-event-none" aria-hidden="true">menu</i></button>');
|
|
652
|
+
} else if (kind === 'text') {
|
|
653
|
+
pieces.push('<span class=', ROW_HOLE, ' title=', ROW_HOLE, ' .textContent=', ROW_HOLE, '></span>');
|
|
654
|
+
} else if (kind === 'node') {
|
|
655
|
+
pieces.push(ROW_HOLE);
|
|
656
|
+
}
|
|
657
|
+
pieces.push('</td>');
|
|
658
|
+
}
|
|
659
|
+
pieces.push('</tr>');
|
|
660
|
+
const strings = [''];
|
|
661
|
+
for (const piece of pieces) {
|
|
662
|
+
if (piece === ROW_HOLE) strings.push('');
|
|
663
|
+
else strings[strings.length - 1] += piece;
|
|
664
|
+
}
|
|
665
|
+
const made = Object.freeze(Object.assign(strings, {raw: Object.freeze(strings.slice())}));
|
|
666
|
+
ROW_STRINGS.set(signature, made);
|
|
667
|
+
return made;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
export function renderTableTemplate(context) {
|
|
671
|
+
const {state} = context.component;
|
|
672
|
+
|
|
673
|
+
function renderBody() {
|
|
674
|
+
if (context.component?.hasSlotContent?.("body")) {
|
|
675
|
+
return html`
|
|
676
|
+
<tbody>
|
|
677
|
+
${context.component.getSlotContent("body")}
|
|
678
|
+
</tbody>`;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const {state} = context.component;
|
|
682
|
+
const columns = context.component?.getColumns?.() || [];
|
|
683
|
+
const {rows = []} = state.data || {};
|
|
684
|
+
const dataState = resolvedDataState(context);
|
|
685
|
+
|
|
686
|
+
// A re-query over rows that are already on screen leaves them there,
|
|
687
|
+
// dimmed, with one small spinner in the middle of what the person can
|
|
688
|
+
// see. Replacing them with a loader — which is what happened before —
|
|
689
|
+
// threw away the place they were reading and moved the answer to the top
|
|
690
|
+
// left corner of an empty table.
|
|
691
|
+
if (dataState === 'loading' && rows.length) {
|
|
692
|
+
return renderRows(context, columns, rows, {busy: true});
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
if (dataState !== 'populated') {
|
|
696
|
+
const stateContent = {
|
|
697
|
+
// One indicator, the same one a re-query shows, centred in the
|
|
698
|
+
// body rather than lined up at its start. The spinner paints its
|
|
699
|
+
// own `label` as text and defaults it to "Loading...", so it is
|
|
700
|
+
// given an empty one: the words are for a reader, in the
|
|
701
|
+
// language the host set, and the circle is for everyone else.
|
|
702
|
+
// Before this there were two indicators here, one of them in
|
|
703
|
+
// English on a Russian page.
|
|
704
|
+
loading: null,
|
|
705
|
+
error: renderEmptyState(context, 'error'),
|
|
706
|
+
empty: renderEmptyState(context, 'empty'),
|
|
707
|
+
}[dataState] || renderEmptyState(context, 'empty');
|
|
708
|
+
|
|
709
|
+
if (dataState === 'loading') {
|
|
710
|
+
return html`
|
|
711
|
+
<tbody data-state="loading" class="sf-table-skeleton">
|
|
712
|
+
${Array.from({length: SKELETON_ROWS}, (_, row) => html`<tr aria-hidden="true">
|
|
713
|
+
${columns.some((column) => !SYSTEM_COLUMNS.has(column?.key))
|
|
714
|
+
? columns.map((column, at) => html`<td>${SYSTEM_COLUMNS.has(column?.key) ? skeletonControl : skeletonBar(row, at)}</td>`)
|
|
715
|
+
// Before the host's first answer the columns are not
|
|
716
|
+
// known yet: three bars across the whole row.
|
|
717
|
+
: html`<td colspan=${String(Math.max(columns.length, 1))}>
|
|
718
|
+
<div class="flex gap-2">${[0, 1, 2].map((at) => html`<div class="flex-1">${skeletonBar(row, at)}</div>`)}</div>
|
|
719
|
+
</td>`}
|
|
720
|
+
</tr>`)}
|
|
721
|
+
</tbody>
|
|
722
|
+
<tbody><tr><td colspan=${String(Math.max(columns.length, 1))} class="sf-table-visually-hidden">
|
|
723
|
+
<span role="status">${context.loadingText}</span>
|
|
724
|
+
</td></tr></tbody>
|
|
725
|
+
`;
|
|
726
|
+
}
|
|
727
|
+
return html`
|
|
728
|
+
<tbody data-state=${dataState}>
|
|
729
|
+
<tr>
|
|
730
|
+
<td colspan=${String(Math.max(columns.length, 1))}>
|
|
731
|
+
<div class="sf-table-state">${stateContent}</div>
|
|
732
|
+
</td>
|
|
733
|
+
</tr>
|
|
734
|
+
</tbody>
|
|
735
|
+
`;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
return renderRows(context, columns, rows);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// The compact list: each record as a block -- its box, its title (with the
|
|
742
|
+
// tree's indent and button when it is a tree), the few fields the columns'
|
|
743
|
+
// priority puts first, and its row menu. Opening and selecting are the
|
|
744
|
+
// row's, so a record does in the list what it does in the table. The rest
|
|
745
|
+
// of the record is the record panel's, opened the way a row opens it.
|
|
746
|
+
function renderList() {
|
|
747
|
+
const columns = context.component?.getColumns?.() || [];
|
|
748
|
+
const {rows = []} = state.data || {};
|
|
749
|
+
const dataState = resolvedDataState(context);
|
|
750
|
+
if (dataState !== 'populated' && !(dataState === 'loading' && rows.length)) {
|
|
751
|
+
if (dataState === 'loading') {
|
|
752
|
+
return html`<div class="sf-table-list sf-table-skeleton flex flex-col" data-state="loading">
|
|
753
|
+
${Array.from({length: SKELETON_ROWS}, (_, row) => html`<div class="sf-table-list-item flex flex-col gap-1/3 p-1" aria-hidden="true">
|
|
754
|
+
${skeletonBar(row, 0)}${skeletonBar(row, 1)}
|
|
755
|
+
</div>`)}
|
|
756
|
+
<span class="sf-table-visually-hidden" role="status">${context.loadingText}</span>
|
|
757
|
+
</div>`;
|
|
758
|
+
}
|
|
759
|
+
return html`<div class="sf-table-state" data-state=${dataState}>${renderEmptyState(context, dataState)}</div>`;
|
|
760
|
+
}
|
|
761
|
+
const select = columns.find((column) => column.key === 'select');
|
|
762
|
+
const menu = columns.find((column) => column.key === 'settings');
|
|
763
|
+
const data = columns.filter((column) => column?.key && !['select', 'settings', 'actions'].includes(column.key)
|
|
764
|
+
&& !column.system && column.visible !== false);
|
|
765
|
+
const title = data.find((column) => column.role === 'title') ?? data[0];
|
|
766
|
+
// The title the host marked opens its record with one click here as
|
|
767
|
+
// in the table's title column; a list on a laptop is the same admin.
|
|
768
|
+
const listTitle = (row) => (title.role === 'title'
|
|
769
|
+
? renderTitleLink(context, row, renderCellValue(row, title))
|
|
770
|
+
: renderCellValue(row, title));
|
|
771
|
+
const limit = Math.max(0, Number(context.component.getProp?.('listFields') ?? 3) || 0);
|
|
772
|
+
const fields = data.filter((column) => column !== title)
|
|
773
|
+
.map((column, order) => ({column, order}))
|
|
774
|
+
.sort((a, b) => ((a.column.priority ?? 1e6) - (b.column.priority ?? 1e6)) || (a.order - b.order))
|
|
775
|
+
.slice(0, limit)
|
|
776
|
+
.map(({column}) => column);
|
|
777
|
+
const filled = (value) => !(value === null || value === undefined || value === '' || (Array.isArray(value) && !value.length));
|
|
778
|
+
return html`
|
|
779
|
+
<ul class="sf-table-list" role="list" aria-label=${context.ariaLabel || nothing}
|
|
780
|
+
aria-busy=${dataState === 'loading' ? 'true' : nothing}>
|
|
781
|
+
${rows.map((row, rowIndex) => row.__tree?.more
|
|
782
|
+
? keyed(`item:${row.id}`, html`
|
|
783
|
+
<li class="sf-table-list-more" style=${`--sf-table-tree--level: ${row.__tree.level}`}>
|
|
784
|
+
<sf-button appearance="link" size="1/2" text=${context.component.text('treeMore')}
|
|
785
|
+
@click=${() => context.component.requestTreeMore(row)}></sf-button>
|
|
786
|
+
</li>`)
|
|
787
|
+
: keyed(`item:${context.component.rowsGeneration?.() ?? 0}:${row.id ?? rowIndex}`, html`
|
|
788
|
+
<li class="sf-table-list-item"
|
|
789
|
+
data-row-id=${context.component.getRowKey(row, rowIndex)}
|
|
790
|
+
data-selected=${row.select?.component?.props?.checked === true ? '' : nothing}
|
|
791
|
+
data-current=${context.component.isCurrentRow(row, rowIndex) ? '' : nothing}
|
|
792
|
+
aria-level=${row.__tree ? String(row.__tree.level + 1) : nothing}
|
|
793
|
+
aria-expanded=${row.__tree?.hasChildren ? String(row.__tree.expanded === true) : nothing}
|
|
794
|
+
tabindex="-1"
|
|
795
|
+
@mousedown=${(event) => context.component.handleRowPointerDown(event)}
|
|
796
|
+
@click=${(event) => context.component.handleRowClick(event, row, rowIndex)}
|
|
797
|
+
@dblclick=${(event) => context.component.handleRowOpen(event, row)}
|
|
798
|
+
@keydown=${(event) => context.component.handleRowKeydown(event, row)}>
|
|
799
|
+
${select ? html`<span class="sf-table-list-select">${renderCellValue(row, select)}</span>` : nothing}
|
|
800
|
+
<div class="sf-table-list-main">
|
|
801
|
+
<div class="sf-table-list-title">${title
|
|
802
|
+
? (row.__tree || row.path ? renderTreeCell(context, row, listTitle(row)) : listTitle(row))
|
|
803
|
+
: nothing}</div>
|
|
804
|
+
${fields.some((column) => filled(row[column.key])) ? html`
|
|
805
|
+
<div class="sf-table-list-fields">
|
|
806
|
+
${fields.filter((column) => filled(row[column.key])).map((column) => html`
|
|
807
|
+
<span class="sf-table-list-field">
|
|
808
|
+
<span class="sf-table-list-label">${column.label ?? column.key}</span>
|
|
809
|
+
<span class="sf-table-list-value">${renderCellValue(row, column)}</span>
|
|
810
|
+
</span>`)}
|
|
811
|
+
</div>` : nothing}
|
|
812
|
+
</div>
|
|
813
|
+
${menu ? html`<span class="sf-table-list-menu">${renderCellValue(row, menu)}</span>` : nothing}
|
|
814
|
+
</li>`))}
|
|
815
|
+
</ul>`;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// The title opens its record with one click (owner, 2026-10-14): the
|
|
819
|
+
// column the host marked as the title is a link to the record's view when
|
|
820
|
+
// the row offers it, so a record is a click away rather than a double
|
|
821
|
+
// click or a trip through the row's menu.
|
|
822
|
+
function renderTitleLink(context, row, value) {
|
|
823
|
+
if (!context.component.canOpenRow?.(row) || row?.id == null) return value;
|
|
824
|
+
return html`<a class="sf-table-title-link" href=${row.href ?? row.url ?? '#'}
|
|
825
|
+
@click=${(event) => {
|
|
826
|
+
if (event.button > 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
|
|
827
|
+
if (row.href ?? row.url) return;
|
|
828
|
+
}
|
|
829
|
+
event.preventDefault();
|
|
830
|
+
event.stopPropagation();
|
|
831
|
+
// The second click of a double click is the same wish:
|
|
832
|
+
// opened once, not twice.
|
|
833
|
+
if (event.detail > 1) return;
|
|
834
|
+
context.component.requestActionIntent('view', [row.id]);
|
|
835
|
+
}}>${value}</a>`;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// repeat, not keyed: a sort moves the rows it already drew. keyed at each
|
|
839
|
+
// position redrew every row and every value in it when the order changed --
|
|
840
|
+
// four hundred elements for one click (owner, 2026-10-15).
|
|
841
|
+
//
|
|
842
|
+
// Many entries (standard simai.smart-component 1.3.8 §18.4, gate 17;
|
|
843
|
+
// simai/ui-pro#2). From the quality profile's threshold on (300 rows) the
|
|
844
|
+
// table draws only the rows in view and a margin, with one spacer row
|
|
845
|
+
// above and one below holding the height of the rest; the component keeps
|
|
846
|
+
// the range (windowSlice) and moves it with the scroll. A cell that only
|
|
847
|
+
// shows a value is class markup: the row's box is the checkbox's class
|
|
848
|
+
// markup over a native input, the row's menu the icon button's class
|
|
849
|
+
// markup over a native button, and text a span -- no Smart element per
|
|
850
|
+
// cell, so a row is about twenty-five nodes instead of a hundred and eight.
|
|
851
|
+
function renderRows(context, columns, rows, {busy = false} = {}) {
|
|
852
|
+
const component = context.component;
|
|
853
|
+
const treeColumn = component.treeColumnKey?.(columns);
|
|
854
|
+
const titleColumn = columns.find((column) => column?.role === 'title' && !column.system)?.key ?? null;
|
|
855
|
+
const slice = component.windowSlice?.(rows) ?? null;
|
|
856
|
+
const shown = slice ? rows.slice(slice.first, slice.last + 1) : rows;
|
|
857
|
+
const offset = slice ? slice.first : 0;
|
|
858
|
+
const span = String(Math.max(columns.length, 1));
|
|
859
|
+
const draggable = (component.hasAttribute('drag-rows') || component.getAttribute('row-reorder') === 'true');
|
|
860
|
+
const sticky = columns.map((column, index) => component.getStickyCellStyle(columns, index) || nothing);
|
|
861
|
+
const pinned = columns.map((column) => (component.isPinnedColumn(column) ? '' : nothing));
|
|
862
|
+
// The stripes are nth-child: a spacer of no height keeps the parity of
|
|
863
|
+
// the drawn rows the parity they have in the whole list.
|
|
864
|
+
const spacer = (height, key) => html`<tr class="sf-table-window-spacer" aria-hidden="true" data-sf-window-spacer=${key}><td colspan=${span} style=${`block-size: ${Math.max(0, Math.round(height))}px`}></td></tr>`;
|
|
865
|
+
const fullCell = (row, column) => (column.key === treeColumn
|
|
866
|
+
? renderTreeCell(context, row, column.key === titleColumn ? renderTitleLink(context, row, renderCellValue(row, column)) : renderCellValue(row, column))
|
|
867
|
+
: column.key === titleColumn ? renderTitleLink(context, row, renderCellValue(row, column)) : renderCellValue(row, column));
|
|
868
|
+
const renderRow = (row, at) => {
|
|
869
|
+
const index = at + offset;
|
|
870
|
+
const current = component.isCurrentRow(row, index);
|
|
871
|
+
const kinds = [];
|
|
872
|
+
const values = [
|
|
873
|
+
row.select?.component?.props?.checked === true ? '' : nothing,
|
|
874
|
+
draggable && !row.__tree?.more ? 'true' : nothing,
|
|
875
|
+
row.__tree ? String(row.__tree.level + 1) : nothing,
|
|
876
|
+
row.__tree?.hasChildren ? String(row.__tree.expanded === true) : nothing,
|
|
877
|
+
row.__tree ? String(row.__tree.setsize) : nothing,
|
|
878
|
+
row.__tree ? String(row.__tree.posinset) : nothing,
|
|
879
|
+
row.__tree?.loading ? 'true' : nothing,
|
|
880
|
+
slice ? String(index + 2) : nothing,
|
|
881
|
+
component.getRowKey(row, index),
|
|
882
|
+
current ? '' : nothing,
|
|
883
|
+
current ? 'true' : nothing,
|
|
884
|
+
component.rowPointerDown,
|
|
885
|
+
(event) => component.handleRowClick(event, row, index),
|
|
886
|
+
(event) => component.handleRowOpen(event, row),
|
|
887
|
+
(event) => component.handleRowKeydown(event, row),
|
|
888
|
+
];
|
|
889
|
+
columns.forEach((column, cellIndex) => {
|
|
890
|
+
const mark = component.rowMarkupCell?.(row, column) ?? null;
|
|
891
|
+
values.push(column.key, `${row.id}_${column.key}`, pinned[cellIndex], sticky[cellIndex]);
|
|
892
|
+
if (mark?.kind === 'box') {
|
|
893
|
+
kinds.push('box');
|
|
894
|
+
values.push(`sf-checkbox sf-checkbox--size-${mark.size} flex content-main-between cursor-pointer`, mark.value, mark.checked, mark.label, mark.change);
|
|
895
|
+
} else if (mark?.kind === 'menu') {
|
|
896
|
+
kinds.push('menu');
|
|
897
|
+
values.push(`sf-icon-button sf-icon-button--size-${mark.size} sf-icon-button--icon sf-icon-button--link flex items-cross-center sf-icon-button--on-surface`, mark.label, mark.click);
|
|
898
|
+
} else if (mark?.kind === 'text') {
|
|
899
|
+
kinds.push('text');
|
|
900
|
+
values.push(mark.className, mark.title ?? nothing, mark.text);
|
|
901
|
+
} else if (mark?.kind === 'empty') {
|
|
902
|
+
kinds.push('empty');
|
|
903
|
+
} else {
|
|
904
|
+
kinds.push('node');
|
|
905
|
+
values.push(fullCell(row, column));
|
|
906
|
+
}
|
|
907
|
+
});
|
|
908
|
+
return html(rowStrings(kinds), ...values);
|
|
909
|
+
};
|
|
910
|
+
return html`<tbody data-busy=${busy ? '' : nothing} data-sf-window=${slice ? `${slice.first}-${slice.last}` : nothing}>${slice ? spacer(slice.before, 'before') : nothing}${slice && slice.first % 2 === 0 ? spacer(0, 'parity') : nothing}${repeat(shown, (row, at) => (row.__tree?.more ? `more:${row.id}` : `row:${component.rowsGeneration?.() ?? 0}:${row.id ?? row.value ?? (at + offset)}`), (row, at) => (row.__tree?.more ? renderMoreRow(context, columns, row) : renderRow(row, at)))}${slice ? spacer(slice.after, 'after') : nothing}</tbody>`;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// The first cell of a tree row: indented by its level, with the button that
|
|
914
|
+
// opens or closes it when the host said it has children, and -- in a search
|
|
915
|
+
// over a tree -- the path it lives under, so a found row says where it is.
|
|
916
|
+
function renderTreeCell(context, row, value) {
|
|
917
|
+
const tree = row.__tree;
|
|
918
|
+
const path = Array.isArray(row.path) && row.path.length
|
|
919
|
+
? row.path.map((item) => item?.label ?? item).join(' / ')
|
|
920
|
+
: '';
|
|
921
|
+
if (!tree && !path) return value;
|
|
922
|
+
const level = tree?.level ?? 0;
|
|
923
|
+
const toggle = tree?.hasChildren
|
|
924
|
+
? html`<button type="button" class="sf-table-tree-toggle"
|
|
925
|
+
data-expanded=${tree.expanded ? '' : nothing}
|
|
926
|
+
aria-label=${context.component.text(tree.expanded ? 'treeCollapse' : 'treeExpand')}
|
|
927
|
+
@click=${(event) => {
|
|
928
|
+
event.stopPropagation();
|
|
929
|
+
context.component.toggleTreeRow(row);
|
|
930
|
+
}}><i class="sf-icon" aria-hidden="true">chevron_right</i></button>`
|
|
931
|
+
: tree ? html`<span class="sf-table-tree-toggle" aria-hidden="true"></span>` : nothing;
|
|
932
|
+
// The node's icon, the host's to choose (port 1.9.0): a page with pages
|
|
933
|
+
// under it is still a page. Without one, a folder for a node with
|
|
934
|
+
// children -- open when it is open -- and a document for a leaf.
|
|
935
|
+
const icon = tree
|
|
936
|
+
? (tree.expanded ? (row.icon_expanded ?? row.icon) : row.icon)
|
|
937
|
+
?? (tree.hasChildren ? (tree.expanded ? 'folder_open' : 'folder') : 'description')
|
|
938
|
+
: row.icon;
|
|
939
|
+
return html`
|
|
940
|
+
<span class="sf-table-tree-cell" style=${`--sf-table-tree--level: ${level}`}>
|
|
941
|
+
${toggle}
|
|
942
|
+
${icon ? html`<i class="sf-icon sf-table-tree-icon" aria-hidden="true">${icon}</i>` : nothing}
|
|
943
|
+
<span class="sf-table-tree-value flex flex-col min-w-0">
|
|
944
|
+
<span class="min-w-0">${value}</span>
|
|
945
|
+
${path ? html`<span class="sf-table-tree-path">${path}</span>` : nothing}
|
|
946
|
+
</span>
|
|
947
|
+
</span>`;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// The next page of one level, as a row of its own at that level.
|
|
951
|
+
function renderMoreRow(context, columns, row) {
|
|
952
|
+
return html`
|
|
953
|
+
<tr class="sf-table-tree-more" aria-level=${String(row.__tree.level + 1)}>
|
|
954
|
+
<td colspan=${String(columns.length)}>
|
|
955
|
+
<span class="sf-table-tree-cell" style=${`--sf-table-tree--level: ${row.__tree.level}`}>
|
|
956
|
+
<span class="sf-table-tree-toggle" aria-hidden="true"></span>
|
|
957
|
+
<sf-button appearance="link" size="1/2" text=${context.component.text('treeMore')}
|
|
958
|
+
@click=${() => context.component.requestTreeMore(row)}></sf-button>
|
|
959
|
+
</span>
|
|
960
|
+
</td>
|
|
961
|
+
</tr>`;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function renderCellValue(row, column) {
|
|
965
|
+
if (!column || !row) {
|
|
966
|
+
return nothing;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
let value = row[column.key];
|
|
970
|
+
|
|
971
|
+
if (column.renderer === "link") {
|
|
972
|
+
return renderLinkCell(value);
|
|
973
|
+
}
|
|
974
|
+
// `row.actions` is read here in two vocabularies. From a host it is ids
|
|
975
|
+
// -- `["view"]`, the data view port's word for which of the list's
|
|
976
|
+
// actions apply to this record -- and from a consumer that builds its
|
|
977
|
+
// own controls it is the items themselves. Ids select from the table's
|
|
978
|
+
// list; items are the list.
|
|
979
|
+
//
|
|
980
|
+
// Both went through the second branch before, so an array of ids was
|
|
981
|
+
// handed to the filter AS the action list, where `action.id` is
|
|
982
|
+
// undefined for a string: everything was filtered out and the
|
|
983
|
+
// «Действия» column drew an empty container in every row of every list.
|
|
984
|
+
// A consumer reported it on an embedded one, which is only where they
|
|
985
|
+
// were looking (owner, 2026-10-09).
|
|
986
|
+
// The cell is built from whichever vocabulary the row holds, and built
|
|
987
|
+
// once. From a host `row.actions` is ids -- the data view port's word
|
|
988
|
+
// for which of the list's actions apply to this record -- and after
|
|
989
|
+
// normalizeRow it is the built items.
|
|
990
|
+
//
|
|
991
|
+
// Items are not rebuilt here. Passing them back through the builder
|
|
992
|
+
// made new objects on every render, and a table that hands lit new
|
|
993
|
+
// objects rebuilds the cells rather than keeping them, which costs
|
|
994
|
+
// whatever the cells hold. The round-3 probe measures exactly that.
|
|
995
|
+
// `row.actions` is the host's ids -- the data view port's word for which
|
|
996
|
+
// of the list's actions apply to this record -- or, from a consumer that
|
|
997
|
+
// builds its own controls, the items themselves. Ids select from the
|
|
998
|
+
// list; items are the list. Handing ids to the builder AS the list was
|
|
999
|
+
// the defect: `action.id` is undefined for a string, so everything was
|
|
1000
|
+
// filtered out and the «Действия» column drew an empty container in
|
|
1001
|
+
// every row of every list (owner, 2026-10-09).
|
|
1002
|
+
//
|
|
1003
|
+
// The built items are kept on the row, because a render that builds new
|
|
1004
|
+
// objects makes lit rebuild the cells rather than keep them, and the
|
|
1005
|
+
// cells hold things worth keeping. A re-query brings new row objects,
|
|
1006
|
+
// so the cache is fresh exactly when the data is.
|
|
1007
|
+
if (column.key === 'actions' && context.component?.getActionRowItems) {
|
|
1008
|
+
const built = Array.isArray(value) && value.length
|
|
1009
|
+
&& value.every((item) => item && typeof item === 'object');
|
|
1010
|
+
if (!built) value = context.component.rowActionItems(row);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
// A boolean the host lets this person write is a checkbox in the cell;
|
|
1015
|
+
// it is disabled while the host answers.
|
|
1016
|
+
if (context.component?.cellEditable?.(row, column)) {
|
|
1017
|
+
return html`<sf-checkbox size=${context.size === '1/2' ? '1/2' : '1'} data-cell-edit=${column.key}
|
|
1018
|
+
?checked=${value === true} ?disabled=${context.component.cellPending(row, column)}
|
|
1019
|
+
aria-label=${column.label ?? column.key}
|
|
1020
|
+
@change=${(event) => context.component.setCellValue(row, column, Boolean(event.target?.checked ?? event.detail?.checked))}></sf-checkbox>`;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
if (column.component) {
|
|
1024
|
+
if (
|
|
1025
|
+
value !== null &&
|
|
1026
|
+
typeof value !== "undefined" &&
|
|
1027
|
+
(!Array.isArray(value) || value.length)
|
|
1028
|
+
) {
|
|
1029
|
+
return renderCellItem(value, context.component);
|
|
1030
|
+
}
|
|
1031
|
+
if (
|
|
1032
|
+
column.key === "settings" &&
|
|
1033
|
+
(!value || (Array.isArray(value) && !value.length))
|
|
1034
|
+
) {
|
|
1035
|
+
return renderComponent({
|
|
1036
|
+
type: "button-icon",
|
|
1037
|
+
props: {
|
|
1038
|
+
size: "1",
|
|
1039
|
+
appearance: "link",
|
|
1040
|
+
scheme: "on-surface",
|
|
1041
|
+
icon: "menu",
|
|
1042
|
+
ariaLabel: "Меню",
|
|
1043
|
+
value: "menu",
|
|
1044
|
+
},
|
|
1045
|
+
}, context.component);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
return renderComponent(column.rowComponent || column.component, context.component);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
if (value === null || typeof value === "undefined") {
|
|
1052
|
+
return nothing;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// A long text -- a description, a note -- is one line in a row, cut
|
|
1056
|
+
// with an ellipsis; the whole of it is in the title for the pointer and
|
|
1057
|
+
// in the text for a screen reader. Wrapped, one description made its row
|
|
1058
|
+
// three lines tall in a list of one-line rows (owner, 2026-10-13).
|
|
1059
|
+
if (LONG_TEXT_TYPES.has(column.propertyType ?? column.property_type ?? column.type)
|
|
1060
|
+
&& (typeof value === "string" || typeof value === "number")) {
|
|
1061
|
+
const text = context.component?.cellText?.(column, value) ?? String(value);
|
|
1062
|
+
return html`<span class="sf-table-cell-clip" title=${text}>${text}</span>`;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// The renderer set is asked before the shapes below, because a typed
|
|
1066
|
+
// cell's value is often an object -- a user or a related record arrives
|
|
1067
|
+
// as {value, label, href} -- and the object branch would render nothing
|
|
1068
|
+
// for it: it looks for a component the host put there. A cell that does
|
|
1069
|
+
// carry the host's own component still wins, which is the check here.
|
|
1070
|
+
const carriesHostComponent = typeof value === "object" && !Array.isArray(value)
|
|
1071
|
+
&& Boolean(value.component?.type);
|
|
1072
|
+
if (!carriesHostComponent) {
|
|
1073
|
+
const typed = context.component?.getTypedCellValue?.(column, value);
|
|
1074
|
+
if (typed !== null && typeof typed !== "undefined") {
|
|
1075
|
+
// Badges and tags from the renderer set wrap onto the next line,
|
|
1076
|
+
// as the host's own values do: this branch drew them in one
|
|
1077
|
+
// line that ran out of the cell (owner, 2026-10-12).
|
|
1078
|
+
if (Array.isArray(typed)) {
|
|
1079
|
+
// A person -- the avatar, then the name -- stays on one
|
|
1080
|
+
// line: wrapping put the avatar above a two-line name. The
|
|
1081
|
+
// name wraps beside it instead.
|
|
1082
|
+
if (typed[0]?.component?.type === 'avatar') {
|
|
1083
|
+
return html`
|
|
1084
|
+
<div class="flex flex-nowrap items-cross-center gap-1/3">
|
|
1085
|
+
${renderCellItem(typed[0], context.component)}
|
|
1086
|
+
<span style="min-inline-size: 0">
|
|
1087
|
+
${typed.slice(1).map((item) => renderCellItem(item, context.component))}
|
|
1088
|
+
</span>
|
|
1089
|
+
</div>
|
|
1090
|
+
`;
|
|
1091
|
+
}
|
|
1092
|
+
return renderCellValues(typed, context.component);
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
return renderCellItem(typed, context.component);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
if (Array.isArray(value)) {
|
|
1100
|
+
if (column.key === 'actions') {
|
|
1101
|
+
value = value.filter(el => {
|
|
1102
|
+
const {actions} = state;
|
|
1103
|
+
const actionId = el.id || el.component?.props?.value;
|
|
1104
|
+
const action = actions.find(action => action.id === actionId);
|
|
1105
|
+
// The host's own actions (its row_actions) live in the row
|
|
1106
|
+
// menu: a page has a dozen of them, which drawn in a row of
|
|
1107
|
+
// buttons wrapped it to three lines.
|
|
1108
|
+
if (el.inline === false) return false;
|
|
1109
|
+
|
|
1110
|
+
return action?.visible ?? true
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
// Several values -- badges for each language, tags for each choice
|
|
1114
|
+
// -- wrap onto the next line when the column is narrower than
|
|
1115
|
+
// they are, instead of running out of the cell under the next one.
|
|
1116
|
+
// The row of actions stays on one line: it is sized for it.
|
|
1117
|
+
if (column.key === 'actions') {
|
|
1118
|
+
// A row's buttons stand in the row a person is on -- under the
|
|
1119
|
+
// pointer, with the focus, or current -- and the column keeps its
|
|
1120
|
+
// width in the others (owner, 2026-10-15): drawn in every row they
|
|
1121
|
+
// were most of a page's elements.
|
|
1122
|
+
if (!context.component.actionsShownFor?.(row)) {
|
|
1123
|
+
return html`<div class="sf-table-row-actions" data-row-actions="idle"></div>`;
|
|
1124
|
+
}
|
|
1125
|
+
return html`
|
|
1126
|
+
<div class="sf-table-row-actions flex items-cross-center gap-1/3">
|
|
1127
|
+
${value.map((item) => renderCellItem(item, context.component))}
|
|
1128
|
+
</div>
|
|
1129
|
+
`;
|
|
1130
|
+
}
|
|
1131
|
+
return renderCellValues(value, context.component);
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
if (typeof value === "object") {
|
|
1135
|
+
return renderCellItem(value, context.component);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
// Plain text stops at three lines with an ellipsis: a long description
|
|
1139
|
+
// in a list made one row as tall as the screen (owner, 2026-10-12). The
|
|
1140
|
+
// whole text is the record's, and a long one reads in full on hover.
|
|
1141
|
+
const text = String(value);
|
|
1142
|
+
return html`<span class="sf-table-cell-text" title=${text.length > 80 ? text : nothing}>${text}</span>`;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
// The name is the table's (or the list's) alone: a named section is a
|
|
1146
|
+
// region, and the data view around the table names its own region by the
|
|
1147
|
+
// list's heading, so a second one of the same name only repeats it to a
|
|
1148
|
+
// screen reader (Larena storage review, 2026-10-28).
|
|
1149
|
+
return html`
|
|
1150
|
+
<section
|
|
1151
|
+
class="sf-table ${context.list ? 'sf-table--list' : ''} min-w-0 flex flex-col gap-1/3 flex-1 min-h-0 ${context.rootClass ||
|
|
1152
|
+
""}"
|
|
1153
|
+
style=${context.rootStyle || nothing}
|
|
1154
|
+
data-sf-instance=${context.component.getInstanceId()}
|
|
1155
|
+
data-state=${resolvedDataState(context)}
|
|
1156
|
+
aria-busy=${resolvedDataState(context) === 'loading' ? 'true' : nothing}
|
|
1157
|
+
>
|
|
1158
|
+
${context.toolbar ? (context.list ? renderListToolbar(context) : renderToolbar(context)) : nothing}
|
|
1159
|
+
${context.component.renderBulkActions()}
|
|
1160
|
+
|
|
1161
|
+
<div class="table-wrap min-w-0 max-w-full flex flex-col flex-1 min-h-0">
|
|
1162
|
+
<div class="sf-table-scroll min-h-0 min-w-0 overflow-auto flex-1 relative"
|
|
1163
|
+
${ref(context.component.refs.scroll)}
|
|
1164
|
+
data-sf-vertical-scroll=${context.verticalScroll}>
|
|
1165
|
+
<div class="sf-table-resize-guide" aria-hidden="true" hidden
|
|
1166
|
+
${ref(context.component.refs.resizeGuide)}></div>
|
|
1167
|
+
${resolvedDataState(context) === 'loading' && (context.component.getTableData()?.rows?.length ?? 0) > 0
|
|
1168
|
+
? html`
|
|
1169
|
+
<div class="sf-table-busy" role="status">
|
|
1170
|
+
<sf-spinner size=${context.size} label="" aria-label=${context.loadingText}></sf-spinner>
|
|
1171
|
+
</div>
|
|
1172
|
+
`
|
|
1173
|
+
: nothing}
|
|
1174
|
+
${context.list ? renderList() : html`
|
|
1175
|
+
<table
|
|
1176
|
+
${ref(context.component.refs.table)}
|
|
1177
|
+
class="table border-separate border-spacing-0 min-w-full ${context.component?.hasContentWidths ? 'sf-table-content-sized' : 'w-max'} table-fixed transition"
|
|
1178
|
+
role=${context.component.isTree?.() ? 'treegrid' : nothing}
|
|
1179
|
+
aria-label=${context.ariaLabel || nothing}
|
|
1180
|
+
aria-rowcount=${context.component.windowRowCount?.() ?? nothing}
|
|
1181
|
+
>
|
|
1182
|
+
${renderColgroup(context)}
|
|
1183
|
+
${renderHead(context)}
|
|
1184
|
+
${renderBody(context)}
|
|
1185
|
+
</table>`}
|
|
1186
|
+
</div>
|
|
1187
|
+
${context.verticalScroll === 'page' && !context.list
|
|
1188
|
+
? html`<div class="sf-table-rail" aria-hidden="true" hidden ${ref(context.component.refs.rail)}>
|
|
1189
|
+
<div class="sf-table-rail-track"></div></div>`
|
|
1190
|
+
: nothing}
|
|
1191
|
+
|
|
1192
|
+
${context.pagin
|
|
1193
|
+
? html`
|
|
1194
|
+
<sf-pagination
|
|
1195
|
+
current="1"
|
|
1196
|
+
total=${String(context.paginationTotal)}
|
|
1197
|
+
bottom="true"
|
|
1198
|
+
show-action-for-all
|
|
1199
|
+
page-size=${String(context.paginationPageSize)}
|
|
1200
|
+
page-sizes=${context.pageSizes}
|
|
1201
|
+
show-page-size
|
|
1202
|
+
top-class="content-main-center"
|
|
1203
|
+
main-class="items-cross-center content-main-between"
|
|
1204
|
+
bottom-class="items-cross-center gap-2"
|
|
1205
|
+
></sf-pagination>`
|
|
1206
|
+
: nothing}
|
|
1207
|
+
</div>
|
|
1208
|
+
</section>
|
|
1209
|
+
${state.contextMenu?.open ? context.component.renderContextMenu(context.state.contextMenu) : nothing}
|
|
1210
|
+
<!-- A demo panel used to stand here, open to nothing, titled after a
|
|
1211
|
+
ticket. Closed, it still took a box of its own, and in a card that
|
|
1212
|
+
spaces its parts that box became the gap above Show more: three
|
|
1213
|
+
spacings instead of one. -->
|
|
1214
|
+
`;
|
|
1215
|
+
}
|