@simai-studio/view 0.0.0-stage → 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.
Files changed (127) hide show
  1. package/LICENSE.md +183 -0
  2. package/README.md +75 -2
  3. package/dist/package-receipt.json +971 -0
  4. package/dist/set.json +141 -0
  5. package/dist/smart/rule.js +278 -0
  6. package/dist/smart/rule.js.gz +0 -0
  7. package/dist/smart/view/aliases.json +129 -0
  8. package/dist/smart/view/aliases.json.gz +0 -0
  9. package/dist/smart/view/component.json +1620 -0
  10. package/dist/smart/view/component.json.gz +0 -0
  11. package/dist/smart/view/css/view.css +208 -0
  12. package/dist/smart/view/css/view.css.gz +0 -0
  13. package/dist/smart/view/css/view.min.css +2 -0
  14. package/dist/smart/view/css/view.min.css.gz +0 -0
  15. package/dist/smart/view/examples.json +668 -0
  16. package/dist/smart/view/examples.json.gz +0 -0
  17. package/dist/smart/view/js/view.js +3938 -0
  18. package/dist/smart/view/js/view.js.gz +0 -0
  19. package/dist/smart/view/js/view.min.js +1 -0
  20. package/dist/smart/view/js/view.min.js.gz +0 -0
  21. package/dist/smart/view/lang/en.json +61 -0
  22. package/dist/smart/view/lang/en.json.gz +0 -0
  23. package/dist/smart/view/lang/ru.json +61 -0
  24. package/dist/smart/view/lang/ru.json.gz +0 -0
  25. package/dist/smart/view/probe.json +165 -0
  26. package/dist/smart/view/probe.json.gz +0 -0
  27. package/dist/smart/view/smart.manifest.json +1570 -0
  28. package/dist/smart/view/smart.manifest.json.gz +0 -0
  29. package/dist/smart/view/static/default/embedded.html +96 -0
  30. package/dist/smart/view/static/default/embedded.html.gz +0 -0
  31. package/dist/smart/view/static/default/open-record.html +247 -0
  32. package/dist/smart/view/static/default/open-record.html.gz +0 -0
  33. package/dist/smart/view/static/default/reference.html +247 -0
  34. package/dist/smart/view/static/default/reference.html.gz +0 -0
  35. package/dist/smart/view/static/sheets.json +282 -0
  36. package/dist/smart/view/static/sheets.json.gz +0 -0
  37. package/dist/smart/view-properties/aliases.json +174 -0
  38. package/dist/smart/view-properties/aliases.json.gz +0 -0
  39. package/dist/smart/view-properties/component.json +1898 -0
  40. package/dist/smart/view-properties/component.json.gz +0 -0
  41. package/dist/smart/view-properties/css/view-properties.css +1153 -0
  42. package/dist/smart/view-properties/css/view-properties.css.gz +0 -0
  43. package/dist/smart/view-properties/css/view-properties.min.css +2 -0
  44. package/dist/smart/view-properties/css/view-properties.min.css.gz +0 -0
  45. package/dist/smart/view-properties/examples.json +841 -0
  46. package/dist/smart/view-properties/examples.json.gz +0 -0
  47. package/dist/smart/view-properties/js/view-properties.js +10416 -0
  48. package/dist/smart/view-properties/js/view-properties.js.gz +0 -0
  49. package/dist/smart/view-properties/js/view-properties.min.js +1421 -0
  50. package/dist/smart/view-properties/js/view-properties.min.js.gz +0 -0
  51. package/dist/smart/view-properties/lang/en.json +96 -0
  52. package/dist/smart/view-properties/lang/en.json.gz +0 -0
  53. package/dist/smart/view-properties/lang/ru.json +96 -0
  54. package/dist/smart/view-properties/lang/ru.json.gz +0 -0
  55. package/dist/smart/view-properties/probe.json +188 -0
  56. package/dist/smart/view-properties/probe.json.gz +0 -0
  57. package/dist/smart/view-properties/skeleton.html +13 -0
  58. package/dist/smart/view-properties/skeleton.html.gz +0 -0
  59. package/dist/smart/view-properties/smart.manifest.json +1590 -0
  60. package/dist/smart/view-properties/smart.manifest.json.gz +0 -0
  61. package/dist/smart/view-properties/static/default/full.html +2 -0
  62. package/dist/smart/view-properties/static/default/full.html.gz +0 -0
  63. package/dist/smart/view-properties/static/default/panel-closed.html +4 -0
  64. package/dist/smart/view-properties/static/default/panel-closed.html.gz +0 -0
  65. package/dist/smart/view-properties/static/default/panel.html +4 -0
  66. package/dist/smart/view-properties/static/default/panel.html.gz +0 -0
  67. package/dist/smart/view-properties/static/default/reference.html +2 -0
  68. package/dist/smart/view-properties/static/default/reference.html.gz +0 -0
  69. package/dist/smart/view-properties/static/sheets.json +426 -0
  70. package/dist/smart/view-properties/static/sheets.json.gz +0 -0
  71. package/dist/smart/view-properties/template/default.css +989 -0
  72. package/dist/smart/view-properties/template/default.css.gz +0 -0
  73. package/dist/smart/view-properties/template/default.js +1660 -0
  74. package/dist/smart/view-properties/template/default.js.gz +0 -0
  75. package/dist/smart/view-table/aliases.json +448 -0
  76. package/dist/smart/view-table/aliases.json.gz +0 -0
  77. package/dist/smart/view-table/component.json +2738 -0
  78. package/dist/smart/view-table/component.json.gz +0 -0
  79. package/dist/smart/view-table/css/view-table.css +3658 -0
  80. package/dist/smart/view-table/css/view-table.css.gz +0 -0
  81. package/dist/smart/view-table/css/view-table.min.css +2 -0
  82. package/dist/smart/view-table/css/view-table.min.css.gz +0 -0
  83. package/dist/smart/view-table/examples.json +499 -0
  84. package/dist/smart/view-table/examples.json.gz +0 -0
  85. package/dist/smart/view-table/js/view-table.js +13879 -0
  86. package/dist/smart/view-table/js/view-table.js.gz +0 -0
  87. package/dist/smart/view-table/js/view-table.min.js +1440 -0
  88. package/dist/smart/view-table/js/view-table.min.js.gz +0 -0
  89. package/dist/smart/view-table/lang/en.json +132 -0
  90. package/dist/smart/view-table/lang/en.json.gz +0 -0
  91. package/dist/smart/view-table/lang/ru.json +132 -0
  92. package/dist/smart/view-table/lang/ru.json.gz +0 -0
  93. package/dist/smart/view-table/probe.json +275 -0
  94. package/dist/smart/view-table/probe.json.gz +0 -0
  95. package/dist/smart/view-table/smart.manifest.json +2524 -0
  96. package/dist/smart/view-table/smart.manifest.json.gz +0 -0
  97. package/dist/smart/view-table/static/default/current.html +141 -0
  98. package/dist/smart/view-table/static/default/current.html.gz +0 -0
  99. package/dist/smart/view-table/static/default/current.skeleton.html +141 -0
  100. package/dist/smart/view-table/static/default/current.skeleton.html.gz +0 -0
  101. package/dist/smart/view-table/static/default/empty.html +149 -0
  102. package/dist/smart/view-table/static/default/empty.html.gz +0 -0
  103. package/dist/smart/view-table/static/default/loading.html +180 -0
  104. package/dist/smart/view-table/static/default/loading.html.gz +0 -0
  105. package/dist/smart/view-table/static/default/many.html +180 -0
  106. package/dist/smart/view-table/static/default/many.html.gz +0 -0
  107. package/dist/smart/view-table/static/default/reference.html +136 -0
  108. package/dist/smart/view-table/static/default/reference.html.gz +0 -0
  109. package/dist/smart/view-table/static/default/reference.skeleton.html +136 -0
  110. package/dist/smart/view-table/static/default/reference.skeleton.html.gz +0 -0
  111. package/dist/smart/view-table/static/default/typed-cells.html +229 -0
  112. package/dist/smart/view-table/static/default/typed-cells.html.gz +0 -0
  113. package/dist/smart/view-table/static/default/typed-cells.skeleton.html +229 -0
  114. package/dist/smart/view-table/static/default/typed-cells.skeleton.html.gz +0 -0
  115. package/dist/smart/view-table/static/sheets.json +480 -0
  116. package/dist/smart/view-table/static/sheets.json.gz +0 -0
  117. package/dist/smart/view-table/studio.fixture.json +17 -0
  118. package/dist/smart/view-table/studio.fixture.json.gz +0 -0
  119. package/dist/smart/view-table/studio.scenario.json +30 -0
  120. package/dist/smart/view-table/studio.scenario.json.gz +0 -0
  121. package/dist/smart/view-table/template/default.css +1301 -0
  122. package/dist/smart/view-table/template/default.css.gz +0 -0
  123. package/dist/smart/view-table/template/default.js +1215 -0
  124. package/dist/smart/view-table/template/default.js.gz +0 -0
  125. package/dist/smart/view-table/template/link-cell-contract.js +48 -0
  126. package/dist/smart/view-table/template/link-cell-contract.js.gz +0 -0
  127. 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 = 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
+ }