@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.
- package/LICENSE.md +183 -0
- package/README.md +75 -2
- package/dist/package-receipt.json +971 -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 +1620 -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 +3938 -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 +1570 -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 +247 -0
- package/dist/smart/view/static/default/open-record.html.gz +0 -0
- package/dist/smart/view/static/default/reference.html +247 -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 +1898 -0
- package/dist/smart/view-properties/component.json.gz +0 -0
- package/dist/smart/view-properties/css/view-properties.css +1153 -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 +10416 -0
- package/dist/smart/view-properties/js/view-properties.js.gz +0 -0
- package/dist/smart/view-properties/js/view-properties.min.js +1421 -0
- package/dist/smart/view-properties/js/view-properties.min.js.gz +0 -0
- package/dist/smart/view-properties/lang/en.json +96 -0
- package/dist/smart/view-properties/lang/en.json.gz +0 -0
- package/dist/smart/view-properties/lang/ru.json +96 -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 +1590 -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 +989 -0
- package/dist/smart/view-properties/template/default.css.gz +0 -0
- package/dist/smart/view-properties/template/default.js +1660 -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 +2738 -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 +13879 -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 +2524 -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,1660 @@
|
|
|
1
|
+
import {html, nothing} from 'lit';
|
|
2
|
+
import "./default.css";
|
|
3
|
+
import {renderViewField, renderEditField} from '../../../field-renderers/index.js';
|
|
4
|
+
import {renderDateTimeEdit} from '../datetime-edit.js';
|
|
5
|
+
|
|
6
|
+
// The arrangement, drawn in the component's own light DOM. Not in a shadow root:
|
|
7
|
+
// the field controls are Smart elements whose stylesheets are document-level, and
|
|
8
|
+
// a document stylesheet does not cross a shadow boundary. sf-view slots its
|
|
9
|
+
// children for exactly this reason and says so beside the slot; the first version
|
|
10
|
+
// of this composite built them inside a shadow root and every control came out
|
|
11
|
+
// with its markup and none of its CSS.
|
|
12
|
+
|
|
13
|
+
function renderFieldValue(context, field, editing, {disabled = false} = {}) {
|
|
14
|
+
const key = String(field.key ?? '');
|
|
15
|
+
const editHost = {
|
|
16
|
+
setFieldValue: (target, value) => context.component.changeField(target, value),
|
|
17
|
+
// A control that knows its entry is not a value yet says so here (port
|
|
18
|
+
// 1.16.0: a masked string that does not fill its mask); the view holds
|
|
19
|
+
// both saves until it is completed or cleared.
|
|
20
|
+
setFieldError: (target, message) => context.component.reportFieldError(target, message),
|
|
21
|
+
...(context.canUploadFiles
|
|
22
|
+
? {
|
|
23
|
+
getFieldUploads: (target) => context.component.getFieldUploads(target),
|
|
24
|
+
startFieldUpload: (target, items) => context.component.startFieldUpload(target, items),
|
|
25
|
+
retryFieldUpload: (target, index) => context.component.retryFieldUpload(target, index),
|
|
26
|
+
}
|
|
27
|
+
: {}),
|
|
28
|
+
...(context.canPickRecords
|
|
29
|
+
? {
|
|
30
|
+
getFieldOptionsPage: (target) => context.component.getFieldOptionsPage(target),
|
|
31
|
+
getFieldOptionSearch: (target) => context.component.getFieldOptionSearch(target),
|
|
32
|
+
setFieldOptionSearch: (target, text) => context.component.setFieldOptionSearch(target, text),
|
|
33
|
+
loadFieldOptions: (target, request) => context.component.loadFieldOptions(target, request),
|
|
34
|
+
}
|
|
35
|
+
: {}),
|
|
36
|
+
};
|
|
37
|
+
// A datetime is edited as a day and a time (../datetime-edit.js): the
|
|
38
|
+
// shared set's control edits the day alone. A readonly one is shown, as the
|
|
39
|
+
// set shows every readonly field.
|
|
40
|
+
const type = field.propertyType || field.type || field.dataType;
|
|
41
|
+
const content = editing && type === 'datetime' && field.readonly !== true
|
|
42
|
+
? renderDateTimeEdit(context, field, context.changed.has(key) ? context.changed.get(key) : field.value)
|
|
43
|
+
: editing
|
|
44
|
+
? renderEditField(
|
|
45
|
+
field,
|
|
46
|
+
context.changed.has(key) ? context.changed.get(key) : field.value,
|
|
47
|
+
editHost,
|
|
48
|
+
// A field its condition turns off is drawn disabled where the set
|
|
49
|
+
// draws disabled apart from read-only (a choice as buttons).
|
|
50
|
+
{size: context.size, ...(disabled ? {disabled: true} : {})},
|
|
51
|
+
)
|
|
52
|
+
: renderViewField(field, field.value, context.hostPort, {size: context.size});
|
|
53
|
+
|
|
54
|
+
const reference = ['record', 'relation', 'user'].includes(field.propertyType || field.type)
|
|
55
|
+
&& field.value && typeof field.value === 'object';
|
|
56
|
+
if (!editing && reference && content) {
|
|
57
|
+
return html`<span
|
|
58
|
+
class="sf-property-view-reference"
|
|
59
|
+
@click=${(event) => {
|
|
60
|
+
event.preventDefault();
|
|
61
|
+
// And no further: the field in view is a button that opens
|
|
62
|
+
// the group for editing, so a press that reached it put the
|
|
63
|
+
// whole group into edit behind the record that had just
|
|
64
|
+
// opened (owner, 2026-10-08). Following a reference is one
|
|
65
|
+
// intent, not two.
|
|
66
|
+
event.stopPropagation();
|
|
67
|
+
context.component.openReference(field, field.value);
|
|
68
|
+
}}
|
|
69
|
+
>${content}</span>`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// An edit control the set cannot build is not an empty value. record and
|
|
73
|
+
// relation have no editor until the picker's host methods exist, and the
|
|
74
|
+
// first version showed «не заполнено» over a field that had a value -- a
|
|
75
|
+
// consumer read it as the value having been lost. Fall back to the view, so
|
|
76
|
+
// the value is shown and plainly not editable yet.
|
|
77
|
+
if (editing && (content === null || typeof content === 'undefined' || content === nothing)) {
|
|
78
|
+
const shown = renderViewField(field, field.value, context.hostPort, {size: context.size});
|
|
79
|
+
if (shown !== null && typeof shown !== 'undefined' && shown !== nothing) {
|
|
80
|
+
return html`<span class="sf-property-view-readonly color-on-surface-variant" data-readonly="true">${shown}</span>`;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (content === null || typeof content === 'undefined' || content === nothing) {
|
|
85
|
+
return html`<span class="sf-property-view-empty color-on-surface-variant">${context.emptyText}</span>`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return content;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// While the view is being configured every entry is listed, hidden and rare ones
|
|
92
|
+
// included: a hidden field a person cannot see is a field they cannot show again,
|
|
93
|
+
// and «показать редкие поля» is a reading choice rather than part of the
|
|
94
|
+
// arrangement being edited.
|
|
95
|
+
function listed(context, entries) {
|
|
96
|
+
return context.configuring ? (entries ?? []) : context.visible(entries);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The chooser names the layers this person may write and nothing else. A layer
|
|
100
|
+
// they may not write is drawn disabled with the host's own reason on it rather
|
|
101
|
+
// than left out, because a «Для всех» that is simply absent reads as a feature
|
|
102
|
+
// the product does not have.
|
|
103
|
+
// The controls of the arranging mode live in the bar at the foot (owner,
|
|
104
|
+
// 2026-10-08), where saving, cancelling and deleting live when a group is being
|
|
105
|
+
// edited: the foot is where a mode's own answers belong, and a band of them
|
|
106
|
+
// under the head pushed the record down by a row it did not need.
|
|
107
|
+
function renderConfigureBar(context) {
|
|
108
|
+
if (!context.canConfigureView) return nothing;
|
|
109
|
+
// Not configuring: nothing is drawn. Entering is the «⋯» menu's, as the mockup
|
|
110
|
+
// has it -- a standalone «Настроить вид» link stood as its own band under the
|
|
111
|
+
// head and was one of the two empty-looking rows the owner saw.
|
|
112
|
+
if (!context.configuring) return nothing;
|
|
113
|
+
|
|
114
|
+
// One row of controls and nothing else: the view being arranged and the
|
|
115
|
+
// way out. Whose copy is written follows from the view itself -- a view
|
|
116
|
+
// for everyone is written for everyone, a personal one for its person --
|
|
117
|
+
// so the «Персональный / Для всех» toggle that stood beside the chooser
|
|
118
|
+
// said the same thing twice and is gone (owner, 2026-10-14).
|
|
119
|
+
|
|
120
|
+
// Which named view is being arranged, chosen, made, renamed and removed in
|
|
121
|
+
// the Framework's profile picker (owner, 2026-10-14) -- the one the admin
|
|
122
|
+
// menu's profiles use.
|
|
123
|
+
const writable = context.editableLayers ?? [];
|
|
124
|
+
const chooser = html`
|
|
125
|
+
<sf-profile-picker
|
|
126
|
+
size=${context.size}
|
|
127
|
+
data-action="view-profile"
|
|
128
|
+
label=${context.viewProfileLabel}
|
|
129
|
+
current=${context.viewProfile ?? ''}
|
|
130
|
+
.profiles=${(context.viewProfiles ?? []).map((profile) => ({
|
|
131
|
+
id: profile.id, label: profile.label, shared: Boolean(profile.shared),
|
|
132
|
+
editable: writable.includes(profile.shared ? 'shared' : 'personal')
|
|
133
|
+
&& (!profile.shared || context.canDeleteViewProfile(profile.id)),
|
|
134
|
+
}))}
|
|
135
|
+
.rights=${{create: {personal: writable.includes('personal'), shared: writable.includes('shared')}}}
|
|
136
|
+
@sf-profile-picker-select=${(event) => context.component.selectViewProfile(event.detail.id)}
|
|
137
|
+
@sf-profile-picker-create=${(event) => context.component.createViewProfile(event.detail.label, {shared: event.detail.shared})}
|
|
138
|
+
@sf-profile-picker-rename=${(event) => context.component.renameViewProfile(event.detail.id, event.detail.label)}
|
|
139
|
+
@sf-profile-picker-delete=${(event) => context.component.deleteViewProfile(event.detail.id)}
|
|
140
|
+
></sf-profile-picker>`;
|
|
141
|
+
|
|
142
|
+
return html`
|
|
143
|
+
<div class="sf-property-view-actions sf-property-view-configure-bar flex items-cross-center gap-2 flex-wrap"
|
|
144
|
+
data-configuring="true" role="group"
|
|
145
|
+
aria-label=${context.configureText}>
|
|
146
|
+
${chooser}
|
|
147
|
+
<span class="grow"></span>
|
|
148
|
+
${context.resetAsked
|
|
149
|
+
? html`
|
|
150
|
+
<span class="sf-property-view-reset-ask color-error">${context.resetViewAskText}</span>
|
|
151
|
+
<sf-button
|
|
152
|
+
appearance="link"
|
|
153
|
+
size=${context.size}
|
|
154
|
+
status="error"
|
|
155
|
+
text=${context.resetViewText}
|
|
156
|
+
data-action="reset-view-confirm"
|
|
157
|
+
@click=${() => context.component.resetView()}
|
|
158
|
+
></sf-button>
|
|
159
|
+
<sf-button
|
|
160
|
+
appearance="outline"
|
|
161
|
+
size=${context.size}
|
|
162
|
+
text=${context.cancelConfiguringText}
|
|
163
|
+
data-action="reset-view-cancel"
|
|
164
|
+
@click=${() => context.component.cancelReset()}
|
|
165
|
+
></sf-button>`
|
|
166
|
+
: html`
|
|
167
|
+
<!-- Reset, cancel, done: the further a control reaches, the
|
|
168
|
+
quieter it is drawn. Reset takes the whole layer away
|
|
169
|
+
and asks first; cancel puts back the arrangement this
|
|
170
|
+
mode was entered with; done keeps what is there. -->
|
|
171
|
+
<sf-button
|
|
172
|
+
appearance="link"
|
|
173
|
+
size=${context.size}
|
|
174
|
+
text=${context.resetViewText}
|
|
175
|
+
data-action="reset-view"
|
|
176
|
+
@click=${() => context.component.askReset()}
|
|
177
|
+
></sf-button>
|
|
178
|
+
<sf-button
|
|
179
|
+
appearance="outline"
|
|
180
|
+
size=${context.size}
|
|
181
|
+
text=${context.cancelConfiguringText}
|
|
182
|
+
data-action="cancel-configuring"
|
|
183
|
+
@click=${() => context.component.cancelConfiguringView()}
|
|
184
|
+
></sf-button>
|
|
185
|
+
<sf-button
|
|
186
|
+
appearance="default"
|
|
187
|
+
size=${context.size}
|
|
188
|
+
text=${context.doneConfiguringText}
|
|
189
|
+
data-action="done-configuring"
|
|
190
|
+
@click=${() => context.component.stopConfiguringView()}
|
|
191
|
+
></sf-button>`}
|
|
192
|
+
</div>
|
|
193
|
+
`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Width, template variant and hint sit behind the field's own settings rather
|
|
197
|
+
// than under every field at once. Drawn under each field they were four controls
|
|
198
|
+
// per row, so a group of eight fields was thirty-two controls and the
|
|
199
|
+
// arrangement -- the thing being edited -- could no longer be read.
|
|
200
|
+
function renderFieldSettings(context, field, key) {
|
|
201
|
+
const variants = Array.isArray(field.variantOptions) ? field.variantOptions : [];
|
|
202
|
+
|
|
203
|
+
return html`
|
|
204
|
+
<sf-popover placement="bottom-end" width="16rem" label=${context.fieldSettingsText}
|
|
205
|
+
data-settings-for=${key}>
|
|
206
|
+
<sf-button-icon
|
|
207
|
+
slot="trigger"
|
|
208
|
+
size="1/2"
|
|
209
|
+
appearance="link"
|
|
210
|
+
scheme="secondary"
|
|
211
|
+
icon="settings"
|
|
212
|
+
data-action="field-settings"
|
|
213
|
+
title=${context.fieldSettingsText}
|
|
214
|
+
aria-label=${context.fieldSettingsText}
|
|
215
|
+
></sf-button-icon>
|
|
216
|
+
<div slot="content" class="flex flex-col gap-1 p-1">
|
|
217
|
+
<label class="sf-property-view-settings-row sf-property-view-settings-row--stacked flex flex-col gap-1/4">
|
|
218
|
+
<span class="sf-property-view-settings-label color-on-surface-variant">${context.fieldLabelText}</span>
|
|
219
|
+
<sf-input
|
|
220
|
+
size="1/2"
|
|
221
|
+
appearance="bordered"
|
|
222
|
+
data-action="field-label"
|
|
223
|
+
aria-label=${context.fieldLabelText}
|
|
224
|
+
placeholder=${String(field.label ?? key)}
|
|
225
|
+
value=${String(field.label ?? '')}
|
|
226
|
+
@change=${(event) => context.component.setFieldView(key, {
|
|
227
|
+
label: String(event.target?.value ?? '').trim() || null,
|
|
228
|
+
})}
|
|
229
|
+
></sf-input>
|
|
230
|
+
</label>
|
|
231
|
+
<label class="sf-property-view-settings-row sf-property-view-settings-row--stacked flex flex-col gap-1/4">
|
|
232
|
+
<span class="sf-property-view-settings-label color-on-surface-variant">${context.hintText}</span>
|
|
233
|
+
<sf-input
|
|
234
|
+
size="1/2"
|
|
235
|
+
appearance="bordered"
|
|
236
|
+
data-action="hint"
|
|
237
|
+
aria-label=${context.hintText}
|
|
238
|
+
placeholder=${context.hintText}
|
|
239
|
+
value=${String(field.hint ?? '')}
|
|
240
|
+
@change=${(event) => context.component.setFieldView(key, {hint: event.target?.value ?? ''})}
|
|
241
|
+
></sf-input>
|
|
242
|
+
</label>
|
|
243
|
+
${variants.length
|
|
244
|
+
? html`
|
|
245
|
+
<label class="sf-property-view-settings-row flex items-cross-center justify-between gap-1">
|
|
246
|
+
<span class="sf-property-view-settings-label color-on-surface-variant">${context.variantText}</span>
|
|
247
|
+
<sf-dropdown
|
|
248
|
+
size="1/2"
|
|
249
|
+
appearance="outlined"
|
|
250
|
+
mode="select"
|
|
251
|
+
search="false"
|
|
252
|
+
data-action="variant"
|
|
253
|
+
aria-label=${context.variantText}
|
|
254
|
+
value=${String(field.variant ?? '')}
|
|
255
|
+
.options=${variants.map((variant) => ({
|
|
256
|
+
type: 'text',
|
|
257
|
+
text: variant.label ?? variant.text ?? String(variant.value ?? variant),
|
|
258
|
+
value: String(variant.value ?? variant),
|
|
259
|
+
selected: String(field.variant ?? '') === String(variant.value ?? variant),
|
|
260
|
+
}))}
|
|
261
|
+
@change=${(event) => context.component.setFieldView(key, {variant: event.detail?.value ?? null})}
|
|
262
|
+
></sf-dropdown>
|
|
263
|
+
</label>`
|
|
264
|
+
: nothing}
|
|
265
|
+
<label class="sf-property-view-settings-row flex items-cross-center justify-between gap-1">
|
|
266
|
+
<span class="sf-property-view-settings-label color-on-surface-variant">${context.widthText}</span>
|
|
267
|
+
<sf-switch
|
|
268
|
+
size="1/2"
|
|
269
|
+
data-action="width"
|
|
270
|
+
?checked=${field.width === 'full'}
|
|
271
|
+
@change=${(event) => context.component.setFieldView(key, {
|
|
272
|
+
width: event.target?.checked ? 'full' : 'half',
|
|
273
|
+
})}
|
|
274
|
+
></sf-switch>
|
|
275
|
+
</label>
|
|
276
|
+
</div>
|
|
277
|
+
</sf-popover>
|
|
278
|
+
`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// The two controls a field carries while the view is arranged: taking it out of
|
|
282
|
+
// this area and where everything else about it lives. They sit at the end of the
|
|
283
|
+
// field, on the ladder the rest of the panel uses.
|
|
284
|
+
//
|
|
285
|
+
// None of them is red (owner, 2026-10-08). Red is the record's own delete, in
|
|
286
|
+
// the bar at the foot; a mode whose rows each carry a red mark reads as a list
|
|
287
|
+
// of warnings, and taking a field out of an area is an ordinary act of
|
|
288
|
+
// arranging that the «+» beside it undoes.
|
|
289
|
+
//
|
|
290
|
+
// Taking out rather than hiding (owner, 2026-10-07): a field removed here joins
|
|
291
|
+
// the unplaced, where the list behind the «+» offers it back, so removing is
|
|
292
|
+
// visible and undoable in the same place. Hiding is still what the port carries
|
|
293
|
+
// and what a shared layer does to a field for everyone -- a field hidden above
|
|
294
|
+
// this layer is drawn with its reason and cannot be taken out from here.
|
|
295
|
+
function renderFieldActions(context, field, key) {
|
|
296
|
+
const locked = !context.canChangeVisibility(field);
|
|
297
|
+
|
|
298
|
+
return html`
|
|
299
|
+
<div class="sf-property-view-field-actions flex items-cross-center gap-1/4">
|
|
300
|
+
<sf-button-icon
|
|
301
|
+
size="1/2"
|
|
302
|
+
appearance="link"
|
|
303
|
+
scheme="secondary"
|
|
304
|
+
icon=${field.hidden ? 'visibility_off' : 'close'}
|
|
305
|
+
data-action="remove-field"
|
|
306
|
+
?disabled=${locked}
|
|
307
|
+
title=${locked ? (context.layerDenied('shared') || context.hiddenText) : context.removeFieldText}
|
|
308
|
+
aria-label=${context.removeFieldText}
|
|
309
|
+
@click=${() => context.component.moveFieldToUnplaced(key)}
|
|
310
|
+
></sf-button-icon>
|
|
311
|
+
${renderFieldSettings(context, field, key)}
|
|
312
|
+
</div>
|
|
313
|
+
`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// A name that can be changed, where it stands. In the arranging mode the title
|
|
317
|
+
// is the control: pressing it, or the pencil beside it, turns it into a field
|
|
318
|
+
// with a tick and a cross -- the two answers a person has to a name they are
|
|
319
|
+
// typing. Nothing is written until the tick, so an area keeps its name while
|
|
320
|
+
// somebody is still deciding on another.
|
|
321
|
+
function renderEntryName(context, kind, key, label, titleClass) {
|
|
322
|
+
const renaming = context.renaming?.kind === kind && context.renaming?.key === String(key);
|
|
323
|
+
|
|
324
|
+
if (!context.configuring) {
|
|
325
|
+
return html`<h3 class=${titleClass}>${label}</h3>`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (!renaming) {
|
|
329
|
+
// The pencil stands right after the name rather than at the end of the
|
|
330
|
+
// row: at the end it read as another of the area's controls, and what
|
|
331
|
+
// it edits is the name beside it (owner, 2026-10-08). The name takes
|
|
332
|
+
// only the room it needs, and the space that was taken from it goes to
|
|
333
|
+
// the spacer, which keeps the controls at the end of the row.
|
|
334
|
+
return html`
|
|
335
|
+
<h3 class=${titleClass}>
|
|
336
|
+
<button type="button" class="sf-property-view-rename-trigger text-start cursor-pointer"
|
|
337
|
+
data-action="rename"
|
|
338
|
+
title=${context.renameText}
|
|
339
|
+
@click=${() => context.component.startRenaming(kind, key)}
|
|
340
|
+
>${label}</button>
|
|
341
|
+
</h3>
|
|
342
|
+
<sf-button-icon
|
|
343
|
+
size="1/2"
|
|
344
|
+
appearance="link"
|
|
345
|
+
scheme="secondary"
|
|
346
|
+
icon="edit"
|
|
347
|
+
data-action="rename"
|
|
348
|
+
title=${context.renameText}
|
|
349
|
+
aria-label=${context.renameText}
|
|
350
|
+
@click=${() => context.component.startRenaming(kind, key)}
|
|
351
|
+
></sf-button-icon>
|
|
352
|
+
<span class="grow"></span>
|
|
353
|
+
`;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const commit = (event) => {
|
|
357
|
+
const input = event.target?.closest?.('.sf-property-view-rename')?.querySelector('sf-input');
|
|
358
|
+
context.component.renameEntry(kind, key, input?.value ?? label);
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
return html`
|
|
362
|
+
<span class="sf-property-view-rename flex items-cross-center gap-1/4 grow min-w-0">
|
|
363
|
+
<sf-input
|
|
364
|
+
size="1/2"
|
|
365
|
+
appearance="bordered"
|
|
366
|
+
autofocus
|
|
367
|
+
root-class="flex-auto min-w-0"
|
|
368
|
+
data-action="rename-input"
|
|
369
|
+
aria-label=${context.renameText}
|
|
370
|
+
value=${label}
|
|
371
|
+
@keydown=${(event) => {
|
|
372
|
+
if (event.key === 'Enter') commit(event);
|
|
373
|
+
if (event.key === 'Escape') context.component.cancelRenaming();
|
|
374
|
+
}}
|
|
375
|
+
></sf-input>
|
|
376
|
+
<sf-button-icon
|
|
377
|
+
size="1/2"
|
|
378
|
+
appearance="link"
|
|
379
|
+
scheme="on-surface"
|
|
380
|
+
icon="check"
|
|
381
|
+
data-action="rename-save"
|
|
382
|
+
title=${context.saveNameText}
|
|
383
|
+
aria-label=${context.saveNameText}
|
|
384
|
+
@click=${commit}
|
|
385
|
+
></sf-button-icon>
|
|
386
|
+
<sf-button-icon
|
|
387
|
+
size="1/2"
|
|
388
|
+
appearance="link"
|
|
389
|
+
scheme="on-surface"
|
|
390
|
+
icon="close"
|
|
391
|
+
data-action="rename-cancel"
|
|
392
|
+
title=${context.cancelNameText}
|
|
393
|
+
aria-label=${context.cancelNameText}
|
|
394
|
+
@click=${() => context.component.cancelRenaming()}
|
|
395
|
+
></sf-button-icon>
|
|
396
|
+
</span>
|
|
397
|
+
`;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// A value is a way in. Reading a record and changing it are the same screen in the
|
|
401
|
+
// mockup: a person presses the value and its group opens for editing, with a pen
|
|
402
|
+
// showing that it can be. Only a readonly field, a field already being edited and
|
|
403
|
+
// a field being arranged are not buttons -- each already means something else.
|
|
404
|
+
// What a press inside a field's value may land on that acts on its own.
|
|
405
|
+
const INNER_CONTROL = 'a[href], button, input, select, textarea, [role="button"], [role="link"]';
|
|
406
|
+
|
|
407
|
+
function renderField(context, field, editing, sectionKey = '', groupKey = '') {
|
|
408
|
+
// A field as its conditions make it now (port 1.14.0): required or not,
|
|
409
|
+
// enabled or drawn disabled as a readonly field is. Not while the view is
|
|
410
|
+
// arranged: arranging places fields, it does not fill them in.
|
|
411
|
+
const disabled = !context.configuring && Boolean(field.when) && context.fieldDisabled(field);
|
|
412
|
+
if (!context.configuring && field.when) field = context.conditionedField(field);
|
|
413
|
+
const key = String(field.key ?? '');
|
|
414
|
+
// A message the field's own control draws under itself (port 1.16.0: an
|
|
415
|
+
// incomplete masked string) is not drawn a second time here; it still
|
|
416
|
+
// counts among the errors beside Save, and still holds the save.
|
|
417
|
+
const error = context.controlErrors?.has(key) ? null : context.errors.get(key);
|
|
418
|
+
|
|
419
|
+
const arrangementError = context.configuring ? context.entryError('field', key) : null;
|
|
420
|
+
|
|
421
|
+
return html`
|
|
422
|
+
<div class="sf-property-view-field flex flex-col gap-1 min-w-0 ${field.width === 'full' ? 'sf-property-view-field--full' : ''}"
|
|
423
|
+
data-field=${key}
|
|
424
|
+
data-sf-sortable-item=${context.configuring ? key : nothing}
|
|
425
|
+
data-arranging=${context.configuring ? 'true' : nothing}
|
|
426
|
+
data-hidden=${context.configuring && field.hidden ? 'true' : nothing}>
|
|
427
|
+
${context.configuring
|
|
428
|
+
? html`<i class="sf-icon sf-property-view-grip items-cross-center" data-sf-sortable-handle
|
|
429
|
+
aria-hidden="true">drag_indicator</i>`
|
|
430
|
+
: nothing}
|
|
431
|
+
<div class="sf-property-view-field-label flex items-cross-center gap-1/3 color-on-surface-variant">
|
|
432
|
+
${String(field.label ?? key)}
|
|
433
|
+
${field.required && editing
|
|
434
|
+
? html`<span class="sf-property-view-required color-error" aria-hidden="true">*</span>`
|
|
435
|
+
: nothing}
|
|
436
|
+
${field.hint
|
|
437
|
+
// The hint is the Framework's own affordance for a hint,
|
|
438
|
+
// sf-help-mark: a button that opens a short explanation on
|
|
439
|
+
// a press, by touch, by pointer and by keyboard alike, and
|
|
440
|
+
// announces it politely. It was a glyph carrying the words
|
|
441
|
+
// in a native `title`, which needs a hover -- so on a touch
|
|
442
|
+
// device the hint could not be read at all (Larena storage
|
|
443
|
+
// QA, 2026-10-22). The accessible name stays the hint
|
|
444
|
+
// itself, as it was on the glyph's aria-label: `label` names
|
|
445
|
+
// the button, `text` is the explanation it opens.
|
|
446
|
+
? html`<sf-help-mark class="sf-property-view-hint"
|
|
447
|
+
label=${String(field.hint)}
|
|
448
|
+
text=${String(field.hint)}></sf-help-mark>`
|
|
449
|
+
: nothing}
|
|
450
|
+
${field.rare ? html`<span class="sf-property-view-rare color-on-surface-variant">${context.rareText}</span>` : nothing}
|
|
451
|
+
${field.inherited && !editing
|
|
452
|
+
? html`<span class="sf-property-view-inherited color-on-surface-variant">${context.inheritedText}</span>`
|
|
453
|
+
: nothing}
|
|
454
|
+
</div>
|
|
455
|
+
${editing || context.configuring || field.readonly === true || !context.mayEdit || context.openAll
|
|
456
|
+
? html`<div class="sf-property-view-field-value min-w-0 flex items-cross-center gap-2 flex-wrap">${renderFieldValue(context, field, editing, {disabled})}</div>`
|
|
457
|
+
: html`
|
|
458
|
+
<button
|
|
459
|
+
type="button"
|
|
460
|
+
class="sf-property-view-field-value sf-property-view-field-open min-w-0 flex items-cross-center gap-1 text-start cursor-pointer"
|
|
461
|
+
data-open-group=${groupKey}
|
|
462
|
+
title=${context.editText}
|
|
463
|
+
@click=${(event) => {
|
|
464
|
+
// A control inside the value -- a file's download
|
|
465
|
+
// button, a URL, an address -- does its own thing
|
|
466
|
+
// and nothing more: downloading a stored file also
|
|
467
|
+
// opened the group for editing (simai.main,
|
|
468
|
+
// 2026-10-28). sf-file-preview's download is a
|
|
469
|
+
// button, not a link, so every control between the
|
|
470
|
+
// press and this button counts.
|
|
471
|
+
const path = event.composedPath();
|
|
472
|
+
const inner = path.slice(0, Math.max(0, path.indexOf(event.currentTarget)));
|
|
473
|
+
if (inner.some((node) => node.matches?.(INNER_CONTROL))) return;
|
|
474
|
+
context.component.editGroup(sectionKey, groupKey);
|
|
475
|
+
}}
|
|
476
|
+
>
|
|
477
|
+
${renderFieldValue(context, field, editing)}
|
|
478
|
+
<i class="sf-icon sf-icon--size-1/2 sf-property-view-pen" aria-hidden="true">edit</i>
|
|
479
|
+
</button>`}
|
|
480
|
+
${context.configuring ? renderFieldActions(context, field, key) : nothing}
|
|
481
|
+
${error || arrangementError
|
|
482
|
+
? html`<div class="sf-property-view-field-error color-error" role="alert">${error || arrangementError}</div>`
|
|
483
|
+
: nothing}
|
|
484
|
+
</div>
|
|
485
|
+
`;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// A group of related records: the component composes a data view in it and the
|
|
489
|
+
// view asks the host for its own port. The records never come through the
|
|
490
|
+
// property-view seam -- rows, columns, sorting and paging are the data-view
|
|
491
|
+
// port's vocabulary, and relaying them through this one would duplicate all of it.
|
|
492
|
+
//
|
|
493
|
+
// `chrome=""` leaves out the search box and the page row: the list stands inside a
|
|
494
|
+
// group that already has a heading, and a page row under four rows of a capped
|
|
495
|
+
// list would offer paging through records the cap is not showing anyway.
|
|
496
|
+
//
|
|
497
|
+
// The table is slotted in, which is how a data view is composed -- its own page
|
|
498
|
+
// markup slots one too. An earlier version left it out and relied on the fallback
|
|
499
|
+
// inside the data view's content slot instead, to satisfy a fence that said a
|
|
500
|
+
// component rendering another one has to fetch it. That traded one defect for a
|
|
501
|
+
// worse one: fallback content lives in the shadow tree, where the Framework's
|
|
502
|
+
// page-level stylesheets do not reach, so a consumer got a list whose sort buttons
|
|
503
|
+
// read «unfold_more» as text and whose reference cells read «arrow_forward». The
|
|
504
|
+
// fence was right and the clever fix was wrong: this component does render a table,
|
|
505
|
+
// so it fetches cl-view-table.
|
|
506
|
+
function renderRelatedList(context, group, groupKey) {
|
|
507
|
+
return html`
|
|
508
|
+
<sf-view
|
|
509
|
+
class="sf-property-view-related"
|
|
510
|
+
chrome=""
|
|
511
|
+
row-cap=${String(group.rowCap ?? 5)}
|
|
512
|
+
port-owner="property-view"
|
|
513
|
+
port-record=${context.recordId || ''}
|
|
514
|
+
port-group=${groupKey}
|
|
515
|
+
aria-label=${String(group.label ?? groupKey)}
|
|
516
|
+
>
|
|
517
|
+
<sf-view-table
|
|
518
|
+
slot="content"
|
|
519
|
+
toolbar="false"
|
|
520
|
+
aria-label=${String(group.label ?? groupKey)}
|
|
521
|
+
></sf-view-table>
|
|
522
|
+
</sf-view>
|
|
523
|
+
`;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Placing a field in this group without dragging it. Dragging is the gesture the
|
|
527
|
+
// mockup asks for and it stays, but it is a poor way to reach a field that is
|
|
528
|
+
// three screens away, and no way at all without a pointer -- and a collection
|
|
529
|
+
// can have hundreds of fields, which no list anybody scrolls will answer.
|
|
530
|
+
//
|
|
531
|
+
// So: a search and a set of ticks. What is ticked is what this area shows, which
|
|
532
|
+
// makes unticking the way a field leaves it, and each row says where the field
|
|
533
|
+
// is now, because a field lives in one place and taking it here takes it from
|
|
534
|
+
// there. The surface is anchored by hand because sf-dropdown brings its own
|
|
535
|
+
// trigger and the trigger here is the «+».
|
|
536
|
+
function renderAddField(context, groupKey) {
|
|
537
|
+
const options = context.fieldChoices(groupKey).map((option) => ({
|
|
538
|
+
type: 'checkbox',
|
|
539
|
+
size: '1/2',
|
|
540
|
+
text: option.label,
|
|
541
|
+
value: option.key,
|
|
542
|
+
group: option.group,
|
|
543
|
+
selected: option.selected,
|
|
544
|
+
checked: option.selected,
|
|
545
|
+
ariaLabel: option.label,
|
|
546
|
+
}));
|
|
547
|
+
const placed = options.filter((option) => option.selected).map((option) => option.value);
|
|
548
|
+
|
|
549
|
+
// The list is sf-dropdown's: it carries the search, the groups, the
|
|
550
|
+
// multiple selection and the surface, and takes the «+» as its trigger.
|
|
551
|
+
// What is acted on is the option the change names, not the whole value the
|
|
552
|
+
// dropdown now holds: the component moves a field rather than setting a
|
|
553
|
+
// list, and reading the whole list as the new truth once took five fields
|
|
554
|
+
// out of an area because one was ticked.
|
|
555
|
+
return html`
|
|
556
|
+
<sf-dropdown
|
|
557
|
+
class="sf-property-view-add"
|
|
558
|
+
size="1/2"
|
|
559
|
+
multiple
|
|
560
|
+
search="true"
|
|
561
|
+
search-placeholder=${context.addFieldText}
|
|
562
|
+
aria-label=${context.addFieldText}
|
|
563
|
+
data-add-for=${groupKey}
|
|
564
|
+
value=${placed.join(',')}
|
|
565
|
+
.options=${options}
|
|
566
|
+
@change=${(event) => {
|
|
567
|
+
const key = String(event.detail?.value ?? '');
|
|
568
|
+
if (!key) return;
|
|
569
|
+
const values = Array.isArray(event.detail?.values)
|
|
570
|
+
? event.detail.values.map(String)
|
|
571
|
+
: [];
|
|
572
|
+
context.component.toggleFieldInGroup(key, groupKey, values.includes(key));
|
|
573
|
+
}}
|
|
574
|
+
>
|
|
575
|
+
<sf-button-icon
|
|
576
|
+
slot="trigger"
|
|
577
|
+
size="1/2"
|
|
578
|
+
appearance="link"
|
|
579
|
+
scheme="secondary"
|
|
580
|
+
icon="add"
|
|
581
|
+
data-action="add-field"
|
|
582
|
+
title=${context.addFieldText}
|
|
583
|
+
aria-label=${context.addFieldText}
|
|
584
|
+
></sf-button-icon>
|
|
585
|
+
</sf-dropdown>
|
|
586
|
+
`;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function renderGroup(context, section, group) {
|
|
590
|
+
const groupKey = String(group.key ?? '');
|
|
591
|
+
// On a surface open all at once (edit, create) every group is open; a
|
|
592
|
+
// group the host keeps closed stays shown.
|
|
593
|
+
const editing = (context.openAll && group.kind !== 'related' && group.editable !== false)
|
|
594
|
+
|| (context.editing?.group === groupKey
|
|
595
|
+
&& context.editing?.section === String(section.key ?? ''));
|
|
596
|
+
const fields = listed(context, group.fields);
|
|
597
|
+
const configuring = context.configuring;
|
|
598
|
+
const groupError = configuring ? context.entryError('group', groupKey) : null;
|
|
599
|
+
const groupLocked = configuring && !context.canChangeVisibility(group);
|
|
600
|
+
// The field list is a sortable list and nothing more: the drop is reported by
|
|
601
|
+
// sf-sortable and applied or refused by the component, which is the seam that
|
|
602
|
+
// mechanism already publishes. Its id carries the kind and the container, so
|
|
603
|
+
// one listener can tell a field dropped into a group from a group dropped into
|
|
604
|
+
// a section.
|
|
605
|
+
// A group read by itself (port 1.15.0, group-load) and not read yet holds
|
|
606
|
+
// no fields but says how many: it is drawn as that many skeleton lines, and
|
|
607
|
+
// busy, until its read answers -- never as an empty group.
|
|
608
|
+
const waiting = !Array.isArray(group.fields) || group.fields.length === 0
|
|
609
|
+
? Number.isInteger(group.field_count) && group.field_count > 0 ? group.field_count : 0
|
|
610
|
+
: 0;
|
|
611
|
+
const reading = waiting > 0 || Boolean(context.component.isGroupReading?.(groupKey));
|
|
612
|
+
const rows = waiting > 0
|
|
613
|
+
? html`${Array.from({length: waiting}, (_, at) => html`<div class="sf-skeleton sf-skeleton--animation-pulse" aria-hidden="true">
|
|
614
|
+
<div class="sf-skeleton-text sf-skeleton-text--size-1 ${SKELETON_WIDTHS[at % SKELETON_WIDTHS.length]}"></div></div>`)}`
|
|
615
|
+
: html`${fields.map((field) => renderField(context, field, editing, String(section.key ?? ''), groupKey))}`;
|
|
616
|
+
|
|
617
|
+
return html`
|
|
618
|
+
<section class="sf-property-view-group bg-surface-0 flex flex-col gap-2 ${editing ? 'sf-property-view-group--editing' : ''}"
|
|
619
|
+
data-group=${groupKey}
|
|
620
|
+
aria-busy=${reading ? 'true' : nothing}
|
|
621
|
+
data-sf-sortable-item=${configuring ? groupKey : nothing}
|
|
622
|
+
data-hidden=${configuring && group.hidden ? 'true' : nothing}
|
|
623
|
+
data-columns=${group.columns === 2 ? '2' : '1'}>
|
|
624
|
+
<header class="sf-property-view-group-header flex items-cross-center justify-between gap-2">
|
|
625
|
+
${configuring
|
|
626
|
+
? html`<i class="sf-icon sf-property-view-grip items-cross-center" data-sf-sortable-handle
|
|
627
|
+
aria-hidden="true">drag_indicator</i>`
|
|
628
|
+
: nothing}
|
|
629
|
+
${renderEntryName(context, 'group', groupKey, String(group.label ?? groupKey),
|
|
630
|
+
'sf-property-view-group-title')}
|
|
631
|
+
${group.rare ? html`<span class="sf-property-view-rare color-on-surface-variant">${context.rareText}</span>` : nothing}
|
|
632
|
+
${configuring
|
|
633
|
+
? html`
|
|
634
|
+
<span class="flex items-cross-center gap-1/4">
|
|
635
|
+
${renderAddField(context, groupKey)}
|
|
636
|
+
<sf-button-icon
|
|
637
|
+
size="1/2"
|
|
638
|
+
appearance="link"
|
|
639
|
+
scheme="secondary"
|
|
640
|
+
icon="keyboard_arrow_up"
|
|
641
|
+
data-action="group-up"
|
|
642
|
+
?disabled=${context.groupAt(groupKey) === 0}
|
|
643
|
+
title=${context.moveUpText}
|
|
644
|
+
aria-label=${context.moveUpText}
|
|
645
|
+
@click=${() => context.component.moveGroupBy(groupKey, -1)}
|
|
646
|
+
></sf-button-icon>
|
|
647
|
+
<sf-button-icon
|
|
648
|
+
size="1/2"
|
|
649
|
+
appearance="link"
|
|
650
|
+
scheme="secondary"
|
|
651
|
+
icon="keyboard_arrow_down"
|
|
652
|
+
data-action="group-down"
|
|
653
|
+
?disabled=${context.groupAt(groupKey) === context.groupCount - 1}
|
|
654
|
+
title=${context.moveDownText}
|
|
655
|
+
aria-label=${context.moveDownText}
|
|
656
|
+
@click=${() => context.component.moveGroupBy(groupKey, 1)}
|
|
657
|
+
></sf-button-icon>
|
|
658
|
+
<sf-button-icon
|
|
659
|
+
size="1/2"
|
|
660
|
+
appearance="link"
|
|
661
|
+
scheme="secondary"
|
|
662
|
+
icon=${group.hidden ? 'visibility_off' : 'delete'}
|
|
663
|
+
data-action="remove-group"
|
|
664
|
+
?disabled=${groupLocked}
|
|
665
|
+
title=${groupLocked ? (context.layerDenied('shared') || context.hiddenText) : context.removeGroupText}
|
|
666
|
+
aria-label=${context.removeGroupText}
|
|
667
|
+
@click=${() => context.component.removeGroup(groupKey)}
|
|
668
|
+
></sf-button-icon>
|
|
669
|
+
</span>`
|
|
670
|
+
: nothing}
|
|
671
|
+
${!configuring && !editing && context.mayEdit && !context.openAll && group.kind !== 'related' && group.editable !== false
|
|
672
|
+
? html`
|
|
673
|
+
<sf-button
|
|
674
|
+
appearance="link"
|
|
675
|
+
size=${context.size}
|
|
676
|
+
text=${context.editText}
|
|
677
|
+
data-action="edit"
|
|
678
|
+
@click=${() => context.component.editGroup(String(section.key ?? ''), groupKey)}
|
|
679
|
+
></sf-button>`
|
|
680
|
+
: nothing}
|
|
681
|
+
</header>
|
|
682
|
+
${groupError
|
|
683
|
+
? html`<div class="sf-property-view-group-error" role="alert">${groupError}</div>`
|
|
684
|
+
: nothing}
|
|
685
|
+
${group.kind === 'related'
|
|
686
|
+
? html`<div class="sf-property-view-group-related">${renderRelatedList(context, group, groupKey)}</div>`
|
|
687
|
+
: configuring
|
|
688
|
+
? html`
|
|
689
|
+
<sf-sortable
|
|
690
|
+
class="sf-property-view-group-fields min-w-0"
|
|
691
|
+
group="field"
|
|
692
|
+
accepts="field"
|
|
693
|
+
id=${`field:${groupKey}`}
|
|
694
|
+
label=${String(group.label ?? groupKey)}
|
|
695
|
+
>${rows}</sf-sortable>`
|
|
696
|
+
: html`<div class="sf-property-view-group-fields min-w-0">${rows}</div>`}
|
|
697
|
+
</section>
|
|
698
|
+
`;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// The phone's first screen: the sections as a list, each one leading to its own
|
|
702
|
+
// screen. Drawn only when there is more than one, for the same reason the desktop
|
|
703
|
+
// list is: a list of one is a control nobody can use, and it would put a screen
|
|
704
|
+
// between a person and the only groups there are.
|
|
705
|
+
function renderPhoneSectionList(context, sections) {
|
|
706
|
+
return html`
|
|
707
|
+
<nav class="sf-property-view-phone-sections flex flex-col" aria-label=${context.sectionsLabel}>
|
|
708
|
+
${sections.map((section) => html`
|
|
709
|
+
<button
|
|
710
|
+
type="button"
|
|
711
|
+
class="sf-property-view-phone-section flex items-cross-center justify-between gap-1"
|
|
712
|
+
data-phone-section=${String(section.key)}
|
|
713
|
+
@click=${() => context.component.openPhoneSection(String(section.key))}
|
|
714
|
+
>
|
|
715
|
+
<span class="sf-property-view-phone-section-label">${String(section.label ?? section.key)}</span>
|
|
716
|
+
<i class="sf-icon" aria-hidden="true">chevron_right</i>
|
|
717
|
+
</button>
|
|
718
|
+
`)}
|
|
719
|
+
</nav>
|
|
720
|
+
`;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// The second screen's head: back to the list, and the name of where you are.
|
|
724
|
+
function renderPhoneSectionHead(context) {
|
|
725
|
+
return html`
|
|
726
|
+
<div class="sf-property-view-phone-head flex items-cross-center gap-2">
|
|
727
|
+
<sf-button-icon
|
|
728
|
+
size=${context.size}
|
|
729
|
+
appearance="link"
|
|
730
|
+
icon="arrow_back"
|
|
731
|
+
data-action="phone-back"
|
|
732
|
+
aria-label=${context.backLabel}
|
|
733
|
+
@click=${() => context.component.closePhoneSection()}
|
|
734
|
+
></sf-button-icon>
|
|
735
|
+
<h2 class="sf-property-view-phone-title">${context.phoneSectionLabel}</h2>
|
|
736
|
+
</div>
|
|
737
|
+
`;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function renderSectionList(context, sections) {
|
|
741
|
+
// One section is drawn as its groups alone: a list of one is a control nobody
|
|
742
|
+
// can use, and the consumer's request asks for the groups to stand alone.
|
|
743
|
+
if (sections.length < 2 && !context.configuring) return nothing;
|
|
744
|
+
|
|
745
|
+
return html`
|
|
746
|
+
<nav class="sf-property-view-sections self-cross-stretch flex flex-col min-w-0" aria-label=${context.sectionsLabel}>
|
|
747
|
+
${context.configuring
|
|
748
|
+
? html`
|
|
749
|
+
${renderSectionTabsArranged(context, sections)}
|
|
750
|
+
<div class="sf-property-view-additions flex">${renderAddSection(context)}</div>`
|
|
751
|
+
: renderSectionTree(context, sections)}
|
|
752
|
+
</nav>
|
|
753
|
+
`;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Reading: the sections are a tree, which carries the keyboard walk and the
|
|
757
|
+
// selection a list of destinations needs.
|
|
758
|
+
//
|
|
759
|
+
// The items are given as data rather than as children: sf-tree reads its child
|
|
760
|
+
// items once and removes them, so a section renamed while the view is being
|
|
761
|
+
// arranged kept its old name in this list.
|
|
762
|
+
function renderSectionTree(context, sections) {
|
|
763
|
+
const items = sections.map((section) => ({
|
|
764
|
+
label: sectionName(context, section),
|
|
765
|
+
value: String(section.key),
|
|
766
|
+
selected: String(section.key) === context.selectedSection,
|
|
767
|
+
openIcon: section.icon ? String(section.icon) : '',
|
|
768
|
+
closedIcon: section.icon ? String(section.icon) : '',
|
|
769
|
+
}));
|
|
770
|
+
|
|
771
|
+
// selection "one": sf-tree 2.0's name for a single choice; its manifest
|
|
772
|
+
// declares it since sf-tree 2.0.1 ("single" is a former value until
|
|
773
|
+
// 2026-12-01).
|
|
774
|
+
return html`
|
|
775
|
+
<sf-tree
|
|
776
|
+
selection="one"
|
|
777
|
+
.items=${items}
|
|
778
|
+
@sf-tree-select=${(event) => {
|
|
779
|
+
// sf-tree 2.0 (standard 1.3.8 §8): select carries the entry's
|
|
780
|
+
// id, which is the section key given as its value.
|
|
781
|
+
const id = event.detail?.id;
|
|
782
|
+
if (id) context.component.selectSection(String(id));
|
|
783
|
+
}}
|
|
784
|
+
></sf-tree>
|
|
785
|
+
`;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Arranging: the same tabs, in an order a drag can change, and each one still a
|
|
789
|
+
// place to drop a field or an area. A tree cannot be reordered by a pointer --
|
|
790
|
+
// it takes its items once and owns their rows -- so while the view is arranged
|
|
791
|
+
// the tabs are rows of this component's own, which is also what lets a tab carry
|
|
792
|
+
// a handle.
|
|
793
|
+
function renderSectionTabsArranged(context, sections) {
|
|
794
|
+
return html`
|
|
795
|
+
<sf-sortable
|
|
796
|
+
class="sf-property-view-section-tabs flex flex-col"
|
|
797
|
+
group="section"
|
|
798
|
+
accepts="section"
|
|
799
|
+
id="section:all"
|
|
800
|
+
label=${context.sectionsLabel}
|
|
801
|
+
>
|
|
802
|
+
${sections.map((section) => html`
|
|
803
|
+
<button
|
|
804
|
+
type="button"
|
|
805
|
+
class="sf-property-view-section-tab flex items-cross-center gap-1/3 min-w-0 text-start cursor-pointer"
|
|
806
|
+
data-section-tab=${String(section.key)}
|
|
807
|
+
data-sf-sortable-item=${String(section.key)}
|
|
808
|
+
aria-current=${String(section.key) === context.selectedSection ? 'true' : nothing}
|
|
809
|
+
@click=${() => context.component.selectSection(String(section.key))}
|
|
810
|
+
>
|
|
811
|
+
<i class="sf-icon sf-property-view-section-grip color-on-surface-variant" data-sf-sortable-handle
|
|
812
|
+
aria-hidden="true">drag_indicator</i>
|
|
813
|
+
<i class="sf-icon" aria-hidden="true">${section.icon ? String(section.icon) : 'folder'}</i>
|
|
814
|
+
<span class="sf-property-view-section-tab-label flex-auto min-w-0">${sectionName(context, section)}</span>
|
|
815
|
+
</button>
|
|
816
|
+
`)}
|
|
817
|
+
</sf-sortable>
|
|
818
|
+
`;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// A rare section says so in its own name. A tree row takes a label string and
|
|
822
|
+
// draws it as text -- markup put into sf-tree-item once printed lit's own
|
|
823
|
+
// markers into the list -- so a trailing mark of its own is a gap in that
|
|
824
|
+
// component rather than something to work around here.
|
|
825
|
+
function sectionName(context, section) {
|
|
826
|
+
const label = String(section.label ?? section.key);
|
|
827
|
+
return section.rare ? `${label} · ${context.rareText}` : label;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// The bar at the foot of the panel: what is being done to the record right now,
|
|
831
|
+
// and what can be done to the record itself. Arranging the view is neither, so
|
|
832
|
+
// while the view is being arranged there is no bar -- its own band carries that
|
|
833
|
+
// mode's actions, and two bars at once was the state the owner saw.
|
|
834
|
+
//
|
|
835
|
+
// The count of changed fields is gone. It named a number nobody acts on, while
|
|
836
|
+
// the two controls that matter stood after it; what is still said is what stops
|
|
837
|
+
// a save -- errors and a conflict.
|
|
838
|
+
// Sections as a row of tabs rather than a list beside the fields. The same
|
|
839
|
+
// sections either way -- what changes is where they are read, not what they are
|
|
840
|
+
// -- so a section is still selected the same way and a group still moves to one
|
|
841
|
+
// by being dropped on it.
|
|
842
|
+
// sf-tabs reads its set from an attribute, through a parser that splits on `|`
|
|
843
|
+
// and reads `::` as flags; it has no declarative way to take richer items, so a
|
|
844
|
+
// section's icon cannot travel with it here. A label carrying either sequence
|
|
845
|
+
// would be read as structure, so it is spelled out of the way.
|
|
846
|
+
function tabLabel(context, section) {
|
|
847
|
+
return sectionName(context, section).replace(/\|/gu, '\u2502').replace(/::/gu, ':\u2236');
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// The tab row only. The groups are drawn under it by the layout, as they are
|
|
851
|
+
// beside the list, so a section's content is in one place whichever way the
|
|
852
|
+
// sections are read. Slotting empty panels in here drew empty tabs: a slot for
|
|
853
|
+
// a tab replaces its label.
|
|
854
|
+
function renderSectionTabs(context, sections) {
|
|
855
|
+
const at = Math.max(0, sections.findIndex((section) => String(section.key) === context.selectedSection));
|
|
856
|
+
return html`
|
|
857
|
+
<sf-tabs
|
|
858
|
+
class="sf-property-view-tabs"
|
|
859
|
+
size=${context.size}
|
|
860
|
+
active-index=${String(at)}
|
|
861
|
+
aria-label=${context.sectionsLabel}
|
|
862
|
+
items=${sections.map((section) => tabLabel(context, section)).join('|')}
|
|
863
|
+
@sf-tabs-change=${(event) => {
|
|
864
|
+
const section = sections[Number(event.detail?.current)];
|
|
865
|
+
if (section) context.component.selectSection(String(section.key));
|
|
866
|
+
}}
|
|
867
|
+
></sf-tabs>
|
|
868
|
+
`;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// The type of a view at 1/2: a step down from 1 in every role, so the record
|
|
872
|
+
// reads at the table's size. Variables rather than a class: the panel is drawn
|
|
873
|
+
// in a drawer moved to the page's end, outside the element.
|
|
874
|
+
function sizeStyle(context) {
|
|
875
|
+
if (context.size !== '1/2') return '';
|
|
876
|
+
return ' --sf-property-view--title-size: var(--sf-heading-5--size); --sf-property-view--title-height: var(--sf-heading-5--height);'
|
|
877
|
+
+ ' --sf-property-view--group-size: var(--sf-text-medium--size); --sf-property-view--group-height: var(--sf-text-medium--height);'
|
|
878
|
+
+ ' --sf-property-view--label-size: var(--sf-label-medium--size); --sf-property-view--label-height: var(--sf-label-medium--height);'
|
|
879
|
+
+ ' --sf-property-view--value-size: var(--sf-text-small--size); --sf-property-view--value-height: var(--sf-text-small--height);';
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// A save the host makes only once confirmed (port 1.14.0): its words, and the
|
|
883
|
+
// two answers, in the bar where save was pressed -- the same place and the same
|
|
884
|
+
// arrangement as the view's reset question. Going back sends nothing and keeps
|
|
885
|
+
// what was entered.
|
|
886
|
+
function renderConfirmation(context) {
|
|
887
|
+
return html`
|
|
888
|
+
<div class="sf-property-view-actions flex items-cross-center gap-2 flex-wrap" role="group"
|
|
889
|
+
data-confirming="true">
|
|
890
|
+
<span class="sf-property-view-confirm-text" role="alert">${context.confirmation.text}</span>
|
|
891
|
+
<span class="grow"></span>
|
|
892
|
+
<sf-button
|
|
893
|
+
appearance="default"
|
|
894
|
+
size=${context.size}
|
|
895
|
+
text=${context.confirmSaveText}
|
|
896
|
+
data-action="confirm-save"
|
|
897
|
+
@click=${() => context.component.confirmSave()}
|
|
898
|
+
></sf-button>
|
|
899
|
+
<sf-button
|
|
900
|
+
appearance="outline"
|
|
901
|
+
size=${context.size}
|
|
902
|
+
text=${context.keepEditingText}
|
|
903
|
+
data-action="keep-editing"
|
|
904
|
+
@click=${() => context.component.cancelConfirmation()}
|
|
905
|
+
></sf-button>
|
|
906
|
+
</div>
|
|
907
|
+
`;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// The foot of a surface open all at once: Create or Save, and Cancel, with
|
|
911
|
+
// what went wrong beside them.
|
|
912
|
+
function renderOpenAllBar(context) {
|
|
913
|
+
if (context.confirmation) return renderConfirmation(context);
|
|
914
|
+
const creating = context.mode === 'create';
|
|
915
|
+
return html`
|
|
916
|
+
<div class="sf-property-view-actions flex items-cross-center gap-2 flex-wrap" role="group">
|
|
917
|
+
<sf-button
|
|
918
|
+
appearance="default"
|
|
919
|
+
size=${context.size}
|
|
920
|
+
text=${creating ? context.createText : context.saveText}
|
|
921
|
+
data-action=${creating ? 'create' : 'save'}
|
|
922
|
+
?disabled=${(!creating && context.changed.size === 0) || context.inputsInvalid}
|
|
923
|
+
@click=${() => context.component.saveAll()}
|
|
924
|
+
></sf-button>
|
|
925
|
+
<sf-button
|
|
926
|
+
appearance="outline"
|
|
927
|
+
size=${context.size}
|
|
928
|
+
text=${context.cancelText}
|
|
929
|
+
data-action="cancel"
|
|
930
|
+
@click=${() => context.component.cancelEdit()}
|
|
931
|
+
></sf-button>
|
|
932
|
+
${context.errors.size
|
|
933
|
+
? html`<span class="sf-property-view-errors color-error">${context.errorsText}: ${context.errors.size}</span>`
|
|
934
|
+
: nothing}
|
|
935
|
+
${context.conflict
|
|
936
|
+
? html`<span class="sf-property-view-conflict color-error" role="alert">${context.conflict}</span>`
|
|
937
|
+
: nothing}
|
|
938
|
+
</div>
|
|
939
|
+
`;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function renderActions(context, {panel = false} = {}) {
|
|
943
|
+
// A surface placed to show the object has no footer of its own (owner,
|
|
944
|
+
// 2026-10-14): a reader sees values, not controls that would refuse.
|
|
945
|
+
if (context.mode === 'view') return nothing;
|
|
946
|
+
if (context.openAll) return renderOpenAllBar(context);
|
|
947
|
+
const removable = context.mode !== 'create' && context.recordActions.includes('record.delete');
|
|
948
|
+
// While the view is arranged the foot carries that mode's controls instead:
|
|
949
|
+
// saving a record and arranging the view it is read in are two modes, and a
|
|
950
|
+
// bar holding both would offer a save for something nobody was editing.
|
|
951
|
+
if (context.configuring) return renderConfigureBar(context);
|
|
952
|
+
if (context.editing && context.confirmation) return renderConfirmation(context);
|
|
953
|
+
// A panel's foot holds every action on the record -- the host's own, slotted
|
|
954
|
+
// as `footer`, and the delete -- in one bar under the fields (owner,
|
|
955
|
+
// 2026-10-14). They were drawn in the scrolling body, the delete as one more
|
|
956
|
+
// section of it.
|
|
957
|
+
const hosted = panel && context.hasHostSlot('footer');
|
|
958
|
+
if (!context.editing && !removable && !hosted) return nothing;
|
|
959
|
+
|
|
960
|
+
return html`
|
|
961
|
+
<div class="sf-property-view-actions flex items-cross-center gap-2 flex-wrap" role="group">
|
|
962
|
+
${context.editing
|
|
963
|
+
? html`
|
|
964
|
+
<sf-button
|
|
965
|
+
appearance="default"
|
|
966
|
+
size=${context.size}
|
|
967
|
+
text=${context.saveText}
|
|
968
|
+
data-action="save"
|
|
969
|
+
?disabled=${context.changed.size === 0 || context.inputsInvalid}
|
|
970
|
+
@click=${() => context.component.saveGroup()}
|
|
971
|
+
></sf-button>
|
|
972
|
+
<sf-button
|
|
973
|
+
appearance="outline"
|
|
974
|
+
size=${context.size}
|
|
975
|
+
text=${context.cancelText}
|
|
976
|
+
data-action="cancel"
|
|
977
|
+
@click=${() => context.component.cancelEdit()}
|
|
978
|
+
></sf-button>`
|
|
979
|
+
: nothing}
|
|
980
|
+
${context.errors.size
|
|
981
|
+
? html`<span class="sf-property-view-errors color-error">${context.errorsText}: ${context.errors.size}</span>`
|
|
982
|
+
: nothing}
|
|
983
|
+
${context.conflict
|
|
984
|
+
? html`<span class="sf-property-view-conflict color-error" role="alert">${context.conflict}</span>`
|
|
985
|
+
: nothing}
|
|
986
|
+
<span class="grow"></span>
|
|
987
|
+
${hosted ? html`<div class="sf-property-view-host-footer flex flex-wrap items-cross-center gap-2">${context.hostSlot('footer')}</div>` : nothing}
|
|
988
|
+
${removable
|
|
989
|
+
? html`
|
|
990
|
+
<sf-button-icon
|
|
991
|
+
appearance="link"
|
|
992
|
+
status="error"
|
|
993
|
+
size=${context.size}
|
|
994
|
+
icon="delete"
|
|
995
|
+
data-action="delete"
|
|
996
|
+
title=${context.deleteText}
|
|
997
|
+
aria-label=${context.deleteText}
|
|
998
|
+
@click=${() => context.component.deleteRecord()}
|
|
999
|
+
></sf-button-icon>`
|
|
1000
|
+
: nothing}
|
|
1001
|
+
</div>
|
|
1002
|
+
`;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// An unplaced field is being arranged, not read: it shows its name and a handle
|
|
1006
|
+
// and nothing else. Drawn like a placed one it carried its value, so the area
|
|
1007
|
+
// read as a group of fields that happen to be last rather than as the fields
|
|
1008
|
+
// this view does not show.
|
|
1009
|
+
function renderUnplacedField(context, field) {
|
|
1010
|
+
const key = String(field.key ?? '');
|
|
1011
|
+
return html`
|
|
1012
|
+
<div class="sf-property-view-field sf-property-view-field--unplaced flex flex-col gap-1 min-w-0"
|
|
1013
|
+
data-field=${key}
|
|
1014
|
+
data-sf-sortable-item=${key}
|
|
1015
|
+
data-arranging="true">
|
|
1016
|
+
<i class="sf-icon sf-property-view-grip items-cross-center" data-sf-sortable-handle aria-hidden="true">drag_indicator</i>
|
|
1017
|
+
<div class="sf-property-view-field-label flex items-cross-center gap-1/3 color-on-surface-variant">
|
|
1018
|
+
${String(field.label ?? key)}
|
|
1019
|
+
${field.rare ? html`<span class="sf-property-view-rare color-on-surface-variant">${context.rareText}</span>` : nothing}
|
|
1020
|
+
</div>
|
|
1021
|
+
</div>
|
|
1022
|
+
`;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// Fields the view places nowhere. Drawn only while the view is being arranged,
|
|
1026
|
+
// because they are not part of what a person reads -- and drawn even when there
|
|
1027
|
+
// are none, so that dragging a field out of every group has somewhere to land
|
|
1028
|
+
// and a person can see that taking a field out does not destroy it.
|
|
1029
|
+
function renderUnplaced(context) {
|
|
1030
|
+
if (!context.configuring) return nothing;
|
|
1031
|
+
|
|
1032
|
+
const fields = context.unplaced;
|
|
1033
|
+
// The Framework's own accordion rather than a fold built here: the owner
|
|
1034
|
+
// asked for it by name, and a second thing that opens and closes is a
|
|
1035
|
+
// second thing to keep looking right (owner, 2026-10-09).
|
|
1036
|
+
//
|
|
1037
|
+
// The accordion owns whether it is open -- its script claims any
|
|
1038
|
+
// .sf-accordion the document gains and remembers the state -- so the panel
|
|
1039
|
+
// does not bind it. What the panel does instead is press the trigger when a
|
|
1040
|
+
// drag starts, the way a person would, and press it back when the drag
|
|
1041
|
+
// ends. One controller, and a drop target that is visible exactly when
|
|
1042
|
+
// something is in the air.
|
|
1043
|
+
//
|
|
1044
|
+
// The hint paragraph is gone: it described in two lines what the «+» beside
|
|
1045
|
+
// every area does, under a heading that already says what the bin is.
|
|
1046
|
+
return html`
|
|
1047
|
+
<article
|
|
1048
|
+
class="sf-accordion sf-accordion--outline sf-property-view-group--unplaced"
|
|
1049
|
+
data-unplaced="true"
|
|
1050
|
+
>
|
|
1051
|
+
<h3 class="sf-accordion-heading">
|
|
1052
|
+
<button
|
|
1053
|
+
type="button"
|
|
1054
|
+
class="sf-accordion-trigger"
|
|
1055
|
+
data-action="toggle-unplaced"
|
|
1056
|
+
aria-expanded="false"
|
|
1057
|
+
>
|
|
1058
|
+
<span class="sf-accordion-title flex items-cross-center gap-1/3">
|
|
1059
|
+
${context.unplacedTitle}
|
|
1060
|
+
${fields.length
|
|
1061
|
+
? html`<sf-tag
|
|
1062
|
+
kind="default"
|
|
1063
|
+
size="1/3"
|
|
1064
|
+
appearance="surface"
|
|
1065
|
+
text=${String(fields.length)}
|
|
1066
|
+
></sf-tag>`
|
|
1067
|
+
: nothing}
|
|
1068
|
+
</span>
|
|
1069
|
+
<sf-icon icon="chevron_right" size="1/2"></sf-icon>
|
|
1070
|
+
</button>
|
|
1071
|
+
</h3>
|
|
1072
|
+
<div class="sf-accordion-content" role="region" hidden>
|
|
1073
|
+
<sf-sortable
|
|
1074
|
+
class="sf-property-view-group-fields sf-property-view-unplaced"
|
|
1075
|
+
group="field"
|
|
1076
|
+
accepts="field"
|
|
1077
|
+
id=${`unplaced:${context.recordId || 'record'}`}
|
|
1078
|
+
label=${context.unplacedTitle}
|
|
1079
|
+
>${fields.map((field) => renderUnplacedField(context, field))}</sf-sortable>
|
|
1080
|
+
</div>
|
|
1081
|
+
</article>
|
|
1082
|
+
`;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// The three controls a panel carries, and none of them invents anything. Close is
|
|
1086
|
+
// the component's own business; copying and opening need an address, and the
|
|
1087
|
+
// address is the host's -- a composite that constructed a URL would be guessing at
|
|
1088
|
+
// the consumer's routing and would be wrong in the first consumer whose routing
|
|
1089
|
+
// did not match the guess. No address, no two controls.
|
|
1090
|
+
// The panel's head, as the mockup draws it: the collection on its own line above,
|
|
1091
|
+
// then the record's name with its favourite star, the host's own actions, and a
|
|
1092
|
+
// «⋯» for what belongs to the view rather than to the record. One block, so there
|
|
1093
|
+
// are no empty bands between the title and the first card -- the view this replaces
|
|
1094
|
+
// had two of about sixty pixels, which is what the owner saw first.
|
|
1095
|
+
// What belongs to the view rather than to the record: arranging it, resetting it
|
|
1096
|
+
// and whether the rare entries are shown. It is a menu because there is more than
|
|
1097
|
+
// one of them; pressing «⋯» used to run the first item straight away, so the
|
|
1098
|
+
// second never had anywhere to be.
|
|
1099
|
+
function renderMenuControl(context) {
|
|
1100
|
+
const items = context.panelMenuItems;
|
|
1101
|
+
const host = context.hasHostSlot('menu') ? context.hostSlot('menu') : null;
|
|
1102
|
+
// The views are chosen from this menu too, so more than one of them is a
|
|
1103
|
+
// reason for it to stand even with nothing else in it.
|
|
1104
|
+
const views = !context.configuring && (context.viewProfiles?.length ?? 0) > 1;
|
|
1105
|
+
if (!items.length && !host && !views) return nothing;
|
|
1106
|
+
|
|
1107
|
+
// Always the menu, never the one item drawn in its place. The place is the
|
|
1108
|
+
// panel's own: what belongs in it depends on the task, and a consumer that
|
|
1109
|
+
// slots its items into `menu` finds the same «\u2026» wherever it looks --
|
|
1110
|
+
// which it could not when one item meant a different glyph in that corner
|
|
1111
|
+
// (owner, 2026-10-08).
|
|
1112
|
+
// The «⋯» opens the Framework's standard menu (owner, 2026-10-12), and so
|
|
1113
|
+
// do the items a host slots into `menu` (owner, 2026-10-14): they were a
|
|
1114
|
+
// popover of link buttons, the one menu of the panel drawn otherwise. The
|
|
1115
|
+
// slotted elements stay in the page, hidden, and a row of the menu stands
|
|
1116
|
+
// for each -- a link row for a link, a press on the element for the rest.
|
|
1117
|
+
return html`
|
|
1118
|
+
<sf-button-icon
|
|
1119
|
+
appearance="link"
|
|
1120
|
+
scheme="on-surface"
|
|
1121
|
+
size=${context.size}
|
|
1122
|
+
icon="more_horiz"
|
|
1123
|
+
data-action="menu"
|
|
1124
|
+
aria-haspopup="menu"
|
|
1125
|
+
aria-expanded="false"
|
|
1126
|
+
aria-label=${context.menuLabel}
|
|
1127
|
+
@click=${(event) => context.component.openPanelMenu(event.currentTarget.querySelector('button') ?? event.currentTarget)}
|
|
1128
|
+
></sf-button-icon>
|
|
1129
|
+
${host ? html`<div class="sf-property-view-host-menu" data-host-menu hidden>${host}</div>` : nothing}
|
|
1130
|
+
`;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function renderPanelHead(context) {
|
|
1134
|
+
return html`
|
|
1135
|
+
<header class="sf-property-view-head flex flex-col gap-1">
|
|
1136
|
+
<div class="sf-property-view-title-row flex items-cross-center gap-1 min-w-0">
|
|
1137
|
+
<h2 class="sf-property-view-title min-w-0 overflow-hidden">${context.panelLabel || context.recordLabel || context.recordId || ''}</h2>
|
|
1138
|
+
<!-- One glyph, filled when the record is a favourite and outlined
|
|
1139
|
+
when it is not: a star that changes shape as well as fill
|
|
1140
|
+
says two things where one will do (owner, 2026-10-08). The
|
|
1141
|
+
press raises the component's own event; whether the record
|
|
1142
|
+
is a favourite stays the host's to decide and to answer.
|
|
1143
|
+
A record being created is not one yet and cannot be a
|
|
1144
|
+
favourite: no star until it has an id (Larena storage
|
|
1145
|
+
review, 2026-10-28). -->
|
|
1146
|
+
${context.recordId && context.mode !== 'create' ? html`<sf-button-icon
|
|
1147
|
+
appearance="link"
|
|
1148
|
+
size=${context.size}
|
|
1149
|
+
icon="star"
|
|
1150
|
+
?filled=${context.favourite}
|
|
1151
|
+
data-action="favourite"
|
|
1152
|
+
aria-pressed=${context.favourite ? 'true' : 'false'}
|
|
1153
|
+
aria-label=${context.favouriteLabel}
|
|
1154
|
+
@click=${() => context.component.toggleFavourite()}
|
|
1155
|
+
></sf-button-icon>` : nothing}
|
|
1156
|
+
<span class="grow"></span>
|
|
1157
|
+
${context.hasHostSlot('actions') ? context.hostSlot('actions') : nothing}
|
|
1158
|
+
${renderMenuControl(context)}
|
|
1159
|
+
</div>
|
|
1160
|
+
</header>
|
|
1161
|
+
`;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// The edge controls stand on a raised surface, elevation 3 as a menu does: its
|
|
1165
|
+
// own fill and shadow, and the text role that goes with it. On --sf-surface-0
|
|
1166
|
+
// they were the page's darkest colour on a dimmed page in the dark theme and
|
|
1167
|
+
// could hardly be found (owner, 2026-10-13). Inline, because the button's own
|
|
1168
|
+
// appearance rule outranks any selector this sheet could write.
|
|
1169
|
+
const EDGE_SURFACE = '--sf-icon-button--background-color: var(--sf-elevation-3--surface); --sf-on-surface-variant: var(--sf-on-elevation-3--variant); box-shadow: var(--sf-elevation-3--shadow)';
|
|
1170
|
+
|
|
1171
|
+
// Round, and on the panel's outer edge rather than inside its head: in a stack
|
|
1172
|
+
// each panel keeps its own set visible past the one above it, which is what the
|
|
1173
|
+
// mockup shows and what a row inside the head cannot do.
|
|
1174
|
+
//
|
|
1175
|
+
// A panel filling the screen has no outside, and the drawer brings the strip in
|
|
1176
|
+
// along the top of it, at its inline start. There the controls stand in a row,
|
|
1177
|
+
// as the drawer lays its own edge out, and the head keeps their room
|
|
1178
|
+
// (panelFills): a column there stood over the record's name and the first rows
|
|
1179
|
+
// on a phone (docs review, round 4, 2026-10-20).
|
|
1180
|
+
function panelFills(context) {
|
|
1181
|
+
return !context.wide || context.panelSize === 'full';
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
function renderPanelControls(context) {
|
|
1185
|
+
return html`
|
|
1186
|
+
<div class="sf-property-view-edge flex ${panelFills(context) ? 'flex-row' : 'flex-col'} gap-1/2" slot="edge"
|
|
1187
|
+
aria-label=${context.closeLabel}>
|
|
1188
|
+
<sf-button-icon
|
|
1189
|
+
appearance="tonal"
|
|
1190
|
+
scheme="on-surface"
|
|
1191
|
+
root-style=${EDGE_SURFACE}
|
|
1192
|
+
size=${context.size}
|
|
1193
|
+
icon="close"
|
|
1194
|
+
data-action="close"
|
|
1195
|
+
aria-label=${context.closeLabel}
|
|
1196
|
+
title=${context.closeLabel}
|
|
1197
|
+
@click=${() => context.component.closePanel('close')}
|
|
1198
|
+
></sf-button-icon>
|
|
1199
|
+
${context.address
|
|
1200
|
+
? html`
|
|
1201
|
+
<sf-button-copy
|
|
1202
|
+
appearance="tonal"
|
|
1203
|
+
root-style=${EDGE_SURFACE}
|
|
1204
|
+
size=${context.size}
|
|
1205
|
+
icon="link"
|
|
1206
|
+
value=${context.addressUrl}
|
|
1207
|
+
label=${context.copyLinkLabel}
|
|
1208
|
+
success-text=${context.linkCopiedLabel}
|
|
1209
|
+
duration=${String(context.copyFeedbackMs)}
|
|
1210
|
+
data-action="copy-link"
|
|
1211
|
+
.propertyView=${context.component}
|
|
1212
|
+
@sf-copy-button-copied=${() => context.component.announceAddressCopy(true)}
|
|
1213
|
+
@sf-copy-button-copy-failed=${() => context.component.announceAddressCopy(false)}
|
|
1214
|
+
></sf-button-copy>`
|
|
1215
|
+
: nothing}
|
|
1216
|
+
${context.canOpenFullPage
|
|
1217
|
+
? html`
|
|
1218
|
+
<sf-button-icon
|
|
1219
|
+
appearance="tonal"
|
|
1220
|
+
scheme="on-surface"
|
|
1221
|
+
root-style=${EDGE_SURFACE}
|
|
1222
|
+
size=${context.size}
|
|
1223
|
+
icon="open_in_full"
|
|
1224
|
+
aria-label=${context.openFullLabel}
|
|
1225
|
+
title=${context.openFullLabel}
|
|
1226
|
+
data-action="open-full"
|
|
1227
|
+
@click=${() => context.component.openFullPage()}
|
|
1228
|
+
></sf-button-icon>`
|
|
1229
|
+
: nothing}
|
|
1230
|
+
</div>
|
|
1231
|
+
`;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
// The phone's list screen: below the breakpoint, with more than one section and
|
|
1235
|
+
// none of them opened. Named once rather than written out at each of the three
|
|
1236
|
+
// places that ask it -- the first version repeated the expression and the third
|
|
1237
|
+
// copy was the one that forgot to suppress the groups, so the list screen drew the
|
|
1238
|
+
// list with a section's groups underneath it.
|
|
1239
|
+
function onPhoneSectionList(context, sections) {
|
|
1240
|
+
return !context.wide && sections.length > 1 && context.phoneSection === null;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
// The section being arranged, named where its areas are. The section list is
|
|
1244
|
+
// drawn by sf-tree, whose rows take a label as a string and nothing else
|
|
1245
|
+
// (sf-tree 2.0 draws class markup, not sf-tree-item elements), so neither a pencil nor a field can stand in the list
|
|
1246
|
+
// itself -- and a section shown as a tab has no list at all. The name of the
|
|
1247
|
+
// place you are arranging belongs above the things you are arranging anyway.
|
|
1248
|
+
function renderSectionBar(context, section, count) {
|
|
1249
|
+
if (!section) return nothing;
|
|
1250
|
+
|
|
1251
|
+
return html`
|
|
1252
|
+
<div class="sf-property-view-section-bar flex items-cross-center gap-1/4"
|
|
1253
|
+
data-section=${String(section.key)}>
|
|
1254
|
+
${context.configuring
|
|
1255
|
+
? renderSectionIcon(context, section)
|
|
1256
|
+
: html`<i class="sf-icon" aria-hidden="true">${section.icon ? String(section.icon) : 'folder'}</i>`}
|
|
1257
|
+
${renderEntryName(context, 'section', String(section.key),
|
|
1258
|
+
String(section.label ?? section.key), 'sf-property-view-section-title min-w-0')}
|
|
1259
|
+
<sf-button-icon
|
|
1260
|
+
size="1/2"
|
|
1261
|
+
appearance="link"
|
|
1262
|
+
scheme="secondary"
|
|
1263
|
+
icon="delete"
|
|
1264
|
+
data-action="remove-section"
|
|
1265
|
+
?disabled=${count < 2}
|
|
1266
|
+
title=${context.removeSectionText}
|
|
1267
|
+
aria-label=${context.removeSectionText}
|
|
1268
|
+
@click=${() => context.component.removeSection(String(section.key))}
|
|
1269
|
+
></sf-button-icon>
|
|
1270
|
+
</div>
|
|
1271
|
+
`;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// The glyph a section is marked with, chosen from a grid of them. A column of a
|
|
1275
|
+
// hundred icons is a column nobody scrolls to the end of, so the dropdown lays
|
|
1276
|
+
// its options out several to a row and searches by the name of the glyph, which
|
|
1277
|
+
// is the only thing about an icon a person can type.
|
|
1278
|
+
function renderSectionIcon(context, section) {
|
|
1279
|
+
const current = section.icon ? String(section.icon) : '';
|
|
1280
|
+
const options = context.sectionIconChoices.map((icon) => ({
|
|
1281
|
+
kind: 'icon',
|
|
1282
|
+
icon,
|
|
1283
|
+
text: icon,
|
|
1284
|
+
value: icon,
|
|
1285
|
+
ariaLabel: icon,
|
|
1286
|
+
selected: icon === current,
|
|
1287
|
+
}));
|
|
1288
|
+
|
|
1289
|
+
// The chooser is the dropdown laid out as a grid: a hundred and twenty
|
|
1290
|
+
// glyphs in a column is a column nobody scrolls to the end of, and the
|
|
1291
|
+
// search is the only thing about a glyph a person can type. The trigger is
|
|
1292
|
+
// the glyph itself, because a field spelling out the name of what is drawn
|
|
1293
|
+
// beside it says the same thing twice.
|
|
1294
|
+
return html`
|
|
1295
|
+
<sf-dropdown
|
|
1296
|
+
class="sf-property-view-section-icon"
|
|
1297
|
+
size="1/2"
|
|
1298
|
+
layout="grid"
|
|
1299
|
+
columns="6"
|
|
1300
|
+
search="true"
|
|
1301
|
+
search-placeholder=${context.sectionIconText}
|
|
1302
|
+
aria-label=${context.sectionIconText}
|
|
1303
|
+
data-icon-for=${String(section.key)}
|
|
1304
|
+
value=${current}
|
|
1305
|
+
.options=${options}
|
|
1306
|
+
@change=${(event) => context.component.setSectionIcon(
|
|
1307
|
+
String(section.key), event.detail?.value ?? '')}
|
|
1308
|
+
>
|
|
1309
|
+
<sf-button-icon
|
|
1310
|
+
slot="trigger"
|
|
1311
|
+
size="1/2"
|
|
1312
|
+
appearance="link"
|
|
1313
|
+
scheme="on-surface"
|
|
1314
|
+
icon=${current || 'folder'}
|
|
1315
|
+
data-action="section-icon"
|
|
1316
|
+
title=${context.sectionIconText}
|
|
1317
|
+
aria-label=${context.sectionIconText}
|
|
1318
|
+
></sf-button-icon>
|
|
1319
|
+
</sf-dropdown>
|
|
1320
|
+
`;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// Making something new, at the end of the arrangement, where a person who has
|
|
1324
|
+
// run out of places to put a field looks for one (owner, 2026-10-07). An area
|
|
1325
|
+
// and a section are the two boxes this view has: one holds fields, the other
|
|
1326
|
+
// holds areas. Both are named the moment they are made -- the new name opens
|
|
1327
|
+
// for typing -- because an area called «Новая область» among four real ones is
|
|
1328
|
+
// a box nobody can tell from the others.
|
|
1329
|
+
//
|
|
1330
|
+
// Both stand the width of what they add to, in the neutral scheme and with no
|
|
1331
|
+
// glyph: making a box is not the primary act of this mode, and a full-width
|
|
1332
|
+
// outlined button with a plus in it read as the main thing to do on the screen.
|
|
1333
|
+
// «Add an area» takes the ladder's size 1, the size the panel's own controls
|
|
1334
|
+
// use; «add a section» stays a step below it, which is where it sits in the
|
|
1335
|
+
// rail (owner, 2026-10-09).
|
|
1336
|
+
// Keeping the list of named views, under the arrangement they belong to --
|
|
1337
|
+
// the same place a new area and a new section are made, because making a box is
|
|
1338
|
+
// one act whatever the box is (owner, 2026-10-09).
|
|
1339
|
+
//
|
|
1340
|
+
// A view is named the moment it is made, like an area and a section: a view
|
|
1341
|
+
// called «Новый вид» among four real ones is one nobody can tell from the
|
|
1342
|
+
// others. Removing is offered per view and not at all for the last shared one:
|
|
1343
|
+
// a scope with no shared view has nothing for a person who has made none of
|
|
1344
|
+
// their own, and the host refuses it anyway.
|
|
1345
|
+
function renderViewProfiles(context) {
|
|
1346
|
+
const profiles = context.viewProfiles ?? [];
|
|
1347
|
+
if (!profiles.length) return nothing;
|
|
1348
|
+
|
|
1349
|
+
return html`
|
|
1350
|
+
<section class="sf-property-view-profiles flex flex-col gap-1/3">
|
|
1351
|
+
<h3 class="sf-property-view-profiles-title">${context.viewProfileLabel}</h3>
|
|
1352
|
+
${profiles.map((profile) => html`
|
|
1353
|
+
<div class="sf-property-view-profile flex items-cross-center gap-1/3" data-profile=${profile.id}>
|
|
1354
|
+
<sf-input
|
|
1355
|
+
size="1/2"
|
|
1356
|
+
appearance="bordered"
|
|
1357
|
+
class="flex-auto min-w-0"
|
|
1358
|
+
value=${profile.label}
|
|
1359
|
+
aria-label=${profile.label}
|
|
1360
|
+
data-action="rename-profile"
|
|
1361
|
+
@change=${(event) => context.component.renameViewProfile(
|
|
1362
|
+
profile.id, event.detail?.value ?? event.target?.value ?? '')}
|
|
1363
|
+
></sf-input>
|
|
1364
|
+
<span class="sf-property-view-profile-scope color-on-surface-variant">
|
|
1365
|
+
${profile.shared ? context.sharedLayerText : context.personalLayerText}
|
|
1366
|
+
</span>
|
|
1367
|
+
<sf-button-icon
|
|
1368
|
+
size="1/2"
|
|
1369
|
+
appearance="link"
|
|
1370
|
+
scheme="secondary"
|
|
1371
|
+
icon="close"
|
|
1372
|
+
data-action="delete-profile"
|
|
1373
|
+
?disabled=${!context.canDeleteViewProfile(profile.id)}
|
|
1374
|
+
title=${context.canDeleteViewProfile(profile.id)
|
|
1375
|
+
? context.deleteViewText
|
|
1376
|
+
: context.lastSharedViewText}
|
|
1377
|
+
aria-label=${context.deleteViewText}
|
|
1378
|
+
@click=${() => context.component.deleteViewProfile(profile.id)}
|
|
1379
|
+
></sf-button-icon>
|
|
1380
|
+
</div>
|
|
1381
|
+
`)}
|
|
1382
|
+
${context.namingView ? renderNewViewName(context) : html`
|
|
1383
|
+
<sf-button
|
|
1384
|
+
appearance="outline"
|
|
1385
|
+
scheme="on-surface"
|
|
1386
|
+
size="1/2"
|
|
1387
|
+
text=${context.addViewText}
|
|
1388
|
+
data-action="add-profile"
|
|
1389
|
+
@click=${() => context.component.startNamingView()}
|
|
1390
|
+
></sf-button>`}
|
|
1391
|
+
</section>
|
|
1392
|
+
`;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
// The name of a view about to be made, in the row the rename of an area uses.
|
|
1396
|
+
// Nothing is written until it is confirmed with a name.
|
|
1397
|
+
function renderNewViewName(context) {
|
|
1398
|
+
const commit = (event) => {
|
|
1399
|
+
const input = event.target?.closest?.('.sf-property-view-rename')?.querySelector('sf-input');
|
|
1400
|
+
context.component.createViewProfile(input?.value ?? '');
|
|
1401
|
+
};
|
|
1402
|
+
|
|
1403
|
+
return html`
|
|
1404
|
+
<span class="sf-property-view-rename flex items-cross-center gap-1/4 min-w-0" data-action="new-profile">
|
|
1405
|
+
<sf-input
|
|
1406
|
+
size="1/2"
|
|
1407
|
+
appearance="bordered"
|
|
1408
|
+
autofocus
|
|
1409
|
+
root-class="flex-auto min-w-0"
|
|
1410
|
+
data-action="new-profile-input"
|
|
1411
|
+
aria-label=${context.addViewText}
|
|
1412
|
+
placeholder=${context.newViewText}
|
|
1413
|
+
@keydown=${(event) => {
|
|
1414
|
+
if (event.key === 'Enter') commit(event);
|
|
1415
|
+
if (event.key === 'Escape') context.component.cancelNamingView();
|
|
1416
|
+
}}
|
|
1417
|
+
></sf-input>
|
|
1418
|
+
<sf-button-icon
|
|
1419
|
+
size="1/2"
|
|
1420
|
+
appearance="link"
|
|
1421
|
+
scheme="on-surface"
|
|
1422
|
+
icon="check"
|
|
1423
|
+
data-action="new-profile-save"
|
|
1424
|
+
title=${context.saveNameText}
|
|
1425
|
+
aria-label=${context.saveNameText}
|
|
1426
|
+
@click=${commit}
|
|
1427
|
+
></sf-button-icon>
|
|
1428
|
+
<sf-button-icon
|
|
1429
|
+
size="1/2"
|
|
1430
|
+
appearance="link"
|
|
1431
|
+
scheme="on-surface"
|
|
1432
|
+
icon="close"
|
|
1433
|
+
data-action="new-profile-cancel"
|
|
1434
|
+
title=${context.cancelNameText}
|
|
1435
|
+
aria-label=${context.cancelNameText}
|
|
1436
|
+
@click=${() => context.component.cancelNamingView()}
|
|
1437
|
+
></sf-button-icon>
|
|
1438
|
+
</span>
|
|
1439
|
+
`;
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
function renderArrangementAdditions(context, section) {
|
|
1443
|
+
return html`
|
|
1444
|
+
<div class="sf-property-view-additions flex flex-col items-cross-stretch gap-1">
|
|
1445
|
+
<sf-button
|
|
1446
|
+
appearance="outline"
|
|
1447
|
+
scheme="on-surface"
|
|
1448
|
+
size=${context.size}
|
|
1449
|
+
text=${context.addGroupText}
|
|
1450
|
+
data-action="add-group"
|
|
1451
|
+
@click=${() => context.component.addGroup(section ? String(section.key) : '')}
|
|
1452
|
+
></sf-button>
|
|
1453
|
+
${sidebarDrawn(context) ? nothing : renderAddSection(context)}
|
|
1454
|
+
${renderViewProfiles(context)}
|
|
1455
|
+
</div>
|
|
1456
|
+
`;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
// A new section is made at the end of the sections (owner, 2026-10-08), which is
|
|
1460
|
+
// where a person looks for one: under the list of them. Without a rail there is
|
|
1461
|
+
// no list to put it under, so it stands beside «add an area» as before.
|
|
1462
|
+
function renderAddSection(context) {
|
|
1463
|
+
return html`
|
|
1464
|
+
<sf-button
|
|
1465
|
+
appearance="outline"
|
|
1466
|
+
scheme="on-surface"
|
|
1467
|
+
size="1/2"
|
|
1468
|
+
text=${context.addSectionText}
|
|
1469
|
+
data-action="add-section"
|
|
1470
|
+
@click=${() => context.component.addSection()}
|
|
1471
|
+
></sf-button>
|
|
1472
|
+
`;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
// Whether the column of tabs is drawn beside the fields. It holds the tabs and
|
|
1476
|
+
// nothing else (owner, 2026-10-07), so what used to stand above the whole panel
|
|
1477
|
+
// -- the band of the arranging mode -- stands above the fields instead, and the
|
|
1478
|
+
// column reaches the top of the panel the way a rail of tabs does.
|
|
1479
|
+
function sidebarDrawn(context) {
|
|
1480
|
+
const sections = listed(context, context.sections);
|
|
1481
|
+
return Boolean(context.wide && context.sectionsAs === 'list'
|
|
1482
|
+
&& (sections.length > 1 || context.configuring));
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
// The record on its way: the arrangement's shape in the Framework's skeleton
|
|
1486
|
+
// -- a sentence in its place said nothing about it (owner, 2026-10-14). Each
|
|
1487
|
+
// group is the card a group read by itself draws until its fields come
|
|
1488
|
+
// (port 1.15.0 group-load, renderGroup's `waiting`): its header with a bar for
|
|
1489
|
+
// the title and a line per field, so the record and its groups arrive in the
|
|
1490
|
+
// boxes this draws (standard simai.smart-component 1.3.8 §5.4, item 25). The
|
|
1491
|
+
// same markup is skeleton.html, which a server prints before any code, so the
|
|
1492
|
+
// element's first render after the printed skeleton changes nothing.
|
|
1493
|
+
const SKELETON_WIDTHS = ['w-1/3', 'w-2/3', 'w-1/2', 'w-3/4', 'w-2/5'];
|
|
1494
|
+
const SKELETON_GROUPS = 3;
|
|
1495
|
+
const SKELETON_FIELDS = 3;
|
|
1496
|
+
function renderLoading(context) {
|
|
1497
|
+
const bar = (at) => html`<div class="sf-skeleton sf-skeleton--animation-pulse" aria-hidden="true"><div class="sf-skeleton-text sf-skeleton-text--size-1 ${SKELETON_WIDTHS[at % SKELETON_WIDTHS.length]}"></div></div>`;
|
|
1498
|
+
// The section tabs are drawn with the skeleton, in the box the tabs take
|
|
1499
|
+
// (simai/ui-pro#3): they came only with the data before, and the record
|
|
1500
|
+
// moved down by their height when it arrived. The stylesheet draws the
|
|
1501
|
+
// strip only where the record has tabs -- sections-as tabs, from the wide
|
|
1502
|
+
// arrangement on -- since a printed skeleton knows no viewport.
|
|
1503
|
+
const tabs = html`<div class="sf-property-view-tabs sf-property-view-tabs--skeleton flex items-cross-center gap-2" aria-hidden="true" data-sf-skeleton="tabs" data-sections-as=${context.sectionsAs}>${[0, 1, 2].map((at) => html`<div class="flex-none" style="inline-size: 6rem">${bar(at + 3)}</div>`)}</div>`;
|
|
1504
|
+
return html`${tabs}<div class="sf-property-view-loading flex flex-col gap-4" data-state="loading">${Array.from({length: SKELETON_GROUPS}, (_, group) => html`<section class="sf-property-view-group bg-surface-0 flex flex-col gap-2" aria-busy="true"><header class="sf-property-view-group-header flex items-cross-center content-main-between gap-2"><div class="flex-1">${bar(group)}</div></header>${Array.from({length: SKELETON_FIELDS}, (_, field) => bar(group + field + 1))}</section>`)}<span class="sr-only" role="status">${context.message}</span></div>`;
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
function renderLoadFailure(context) {
|
|
1508
|
+
const failure = context.loadFailure;
|
|
1509
|
+
return html`<sf-empty-state kind=${failure.kind} size=${context.size === '1/2' ? '1/2' : '1'} heading-level="2"
|
|
1510
|
+
text=${failure.text || nothing}
|
|
1511
|
+
.actions=${failure.kind === 'error' ? null : []}
|
|
1512
|
+
@sf-empty-state-action=${(event) => { if (event.detail?.id === 'retry') context.component.load('retry'); }}></sf-empty-state>`;
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
function renderBody(context, railOutside = false, withActions = true) {
|
|
1516
|
+
if (context.loading) return renderLoading(context);
|
|
1517
|
+
if (context.loadFailure) return renderLoadFailure(context);
|
|
1518
|
+
// Sections are listed the same way fields and groups are while the view is
|
|
1519
|
+
// being configured: a hidden section filtered out here would be a section
|
|
1520
|
+
// nobody could ever show again.
|
|
1521
|
+
const sections = listed(context, context.sections);
|
|
1522
|
+
const current = sections.find((section) => String(section.key) === context.selectedSection)
|
|
1523
|
+
?? sections[0] ?? null;
|
|
1524
|
+
|
|
1525
|
+
return html`
|
|
1526
|
+
<div class="sf-property-view-status" role="status" aria-live="polite" ?hidden=${!context.message}>
|
|
1527
|
+
${context.message}
|
|
1528
|
+
</div>
|
|
1529
|
+
${!context.panel && (context.hasHostSlot('actions') || context.hasHostSlot('menu') || context.panelMenuItems.length)
|
|
1530
|
+
? html`
|
|
1531
|
+
<div class="sf-property-view-host-actions flex flex-wrap items-cross-center justify-end gap-1">
|
|
1532
|
+
${context.hasHostSlot('actions') ? context.hostSlot('actions') : nothing}
|
|
1533
|
+
${renderMenuControl(context)}
|
|
1534
|
+
</div>`
|
|
1535
|
+
: nothing}
|
|
1536
|
+
${onPhoneSectionList(context, sections) ? renderPhoneSectionList(context, sections) : nothing}
|
|
1537
|
+
${context.wide && context.sectionsAs === 'tabs' && sections.length > 1
|
|
1538
|
+
? renderSectionTabs(context, sections)
|
|
1539
|
+
: nothing}
|
|
1540
|
+
<div class="sf-property-view-layout"
|
|
1541
|
+
style=${`--sf-property-view--sections-width: ${context.sectionsWidth};${context.panel ? '' : sizeStyle(context)}`}
|
|
1542
|
+
data-sections=${String(sections.length)}
|
|
1543
|
+
data-screen=${onPhoneSectionList(context, sections) ? 'sections' : 'groups'}>
|
|
1544
|
+
${!context.wide && sections.length > 1 && context.phoneSection !== null
|
|
1545
|
+
? renderPhoneSectionHead(context)
|
|
1546
|
+
: nothing}
|
|
1547
|
+
${sidebarDrawn(context) && !railOutside ? renderSectionList(context, sections) : nothing}
|
|
1548
|
+
${context.isEmptyRecord && !context.configuring
|
|
1549
|
+
? html`<p class="sf-property-view-empty-record color-on-surface-variant"
|
|
1550
|
+
role="status">${context.emptyRecordText}</p>`
|
|
1551
|
+
: nothing}
|
|
1552
|
+
${onPhoneSectionList(context, sections) ? nothing : context.configuring
|
|
1553
|
+
? html`
|
|
1554
|
+
<div class="sf-property-view-body flex-auto min-w-0 flex flex-col gap-4">
|
|
1555
|
+
${renderSectionBar(context, current, sections.length)}
|
|
1556
|
+
<sf-sortable
|
|
1557
|
+
class="sf-property-view-groups flex flex-col gap-4"
|
|
1558
|
+
group="group"
|
|
1559
|
+
accepts="group"
|
|
1560
|
+
id=${`group:${current ? String(current.key) : ''}`}
|
|
1561
|
+
label=${String(current?.label ?? current?.key ?? '')}
|
|
1562
|
+
>${listed(context, current?.groups).map((group) => renderGroup(context, current, group))}</sf-sortable>
|
|
1563
|
+
${renderArrangementAdditions(context, current)}
|
|
1564
|
+
${renderUnplaced(context)}
|
|
1565
|
+
</div>`
|
|
1566
|
+
: html`
|
|
1567
|
+
<div class="sf-property-view-body flex-auto min-w-0 flex flex-col gap-4">
|
|
1568
|
+
${context.visible(current?.groups).map((group) => renderGroup(context, current, group))}
|
|
1569
|
+
</div>`}
|
|
1570
|
+
</div>
|
|
1571
|
+
${withActions && context.hasHostSlot('footer')
|
|
1572
|
+
? html`<div class="sf-property-view-host-footer flex flex-wrap items-cross-center gap-2">${context.hostSlot('footer')}</div>`
|
|
1573
|
+
: nothing}
|
|
1574
|
+
${withActions ? renderActions(context) : nothing}
|
|
1575
|
+
`;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
// A deeper window is narrower by a step, so the one below shows along the
|
|
1579
|
+
// inline-start edge. That is what makes a stack visible rather than merely true
|
|
1580
|
+
// in the DOM, and `width` is a declared input on sf-drawer, so it reaches into
|
|
1581
|
+
// none of that component's internals.
|
|
1582
|
+
//
|
|
1583
|
+
// A window above the stack takes no step in from the one below (owner,
|
|
1584
|
+
// 2026-10-07): the step read as the panel having slipped rather than as a stack,
|
|
1585
|
+
// and the page behind is already dimmed, which says there is something under
|
|
1586
|
+
// this. sf-drawer keeps `stack-offset` for a consumer that wants the step; this
|
|
1587
|
+
// one asks for none.
|
|
1588
|
+
//
|
|
1589
|
+
// The panel lies *over* the page: `overlay` is on, so the page behind is dimmed and
|
|
1590
|
+
// keeps its own layout. It was off before, which is how a consumer's page came to
|
|
1591
|
+
// be squeezed instead of covered -- the first thing the owner pointed at.
|
|
1592
|
+
//
|
|
1593
|
+
// `modal` is on because the drawer draws its overlay only when it is -- the
|
|
1594
|
+
// element is marked hidden unless modal and overlay are both set, which was
|
|
1595
|
+
// measured rather than assumed. Dimming the page is what the mockup asks for, and
|
|
1596
|
+
// a panel that dims the page is modal in the sense that matters. Esc is still
|
|
1597
|
+
// handled here rather than by the drawer, because in a stack it must close the top
|
|
1598
|
+
// window and not every one of them.
|
|
1599
|
+
//
|
|
1600
|
+
// The drawer is given no `title`: the panel draws its own head, with the collection
|
|
1601
|
+
// above the name and the star and the «⋯» beside it, and a drawer title as well
|
|
1602
|
+
// would be the same words twice.
|
|
1603
|
+
export function renderPropertyViewTemplate(context) {
|
|
1604
|
+
if (!context.panel) return renderBody(context);
|
|
1605
|
+
|
|
1606
|
+
// The panel is laid out the way a page is (owner, 2026-10-08): a head across
|
|
1607
|
+
// the top, a foot across the bottom, and what is between them scrolling
|
|
1608
|
+
// under both. The bands were sticky inside the panel's own scroller before,
|
|
1609
|
+
// which held them only while their containing block was on screen -- so a
|
|
1610
|
+
// record scrolled far enough took its own head and foot with it.
|
|
1611
|
+
//
|
|
1612
|
+
// With a rail of tabs what is between them is two columns, each with its own
|
|
1613
|
+
// scrollbar: a long list of sections scrolls without moving the record, and
|
|
1614
|
+
// a long record scrolls without moving the list.
|
|
1615
|
+
const rail = sidebarDrawn(context);
|
|
1616
|
+
|
|
1617
|
+
// `show-close` is off because the panel draws its own close, in the strip
|
|
1618
|
+
// beside copy-link and open-full. With both, a panel carried two close
|
|
1619
|
+
// buttons a step apart, and on a phone neither was reachable: the strip is
|
|
1620
|
+
// placed a gutter's width beyond the panel's inline start, which on a
|
|
1621
|
+
// full-screen drawer is off the screen.
|
|
1622
|
+
return html`
|
|
1623
|
+
<sf-drawer
|
|
1624
|
+
?open=${context.panelOpen}
|
|
1625
|
+
placement="inline-end"
|
|
1626
|
+
modal="true"
|
|
1627
|
+
overlay="true"
|
|
1628
|
+
size=${context.wide ? context.panelSize : 'full'}
|
|
1629
|
+
width=${context.wide ? (context.drawerWidth || nothing) : nothing}
|
|
1630
|
+
gutter="var(--sf-space-1)"
|
|
1631
|
+
panel-style=${`--sf-drawer-background: var(--sf-surface-1); --sf-property-view--sections-width: ${context.sectionsWidth}; scrollbar-gutter: auto;${sizeStyle(context)}`}
|
|
1632
|
+
show-close="false"
|
|
1633
|
+
close-label=${context.closeLabel}
|
|
1634
|
+
close-on-esc="false"
|
|
1635
|
+
label=${context.panelLabel || context.recordId || ''}
|
|
1636
|
+
>
|
|
1637
|
+
${renderPanelControls(context)}
|
|
1638
|
+
<div class="sf-property-view-panel relative flex flex-col gap-2${rail ? ' sf-property-view-panel--railed' : ''}${panelFills(context) ? ' sf-property-view-panel--fills' : ''}"
|
|
1639
|
+
style=${panelFills(context)
|
|
1640
|
+
? `--sf-property-view--edge-room: calc(${context.edgeControlCount} * ${context.edgeControlWidth} + ${context.edgeControlCount - 1} * var(--sf-space-1\\/2));`
|
|
1641
|
+
: nothing}
|
|
1642
|
+
data-record-id=${context.recordId || ''}
|
|
1643
|
+
data-stack-depth=${String(context.stackDepth)}
|
|
1644
|
+
data-stacked=${context.isTopPanel ? 'false' : 'true'}
|
|
1645
|
+
>
|
|
1646
|
+
${renderPanelHead(context)}
|
|
1647
|
+
<div class="sf-property-view-scroll flex-auto min-h-0 ${rail ? 'flex' : 'flex flex-col'}">
|
|
1648
|
+
${rail
|
|
1649
|
+
? html`
|
|
1650
|
+
${renderSectionList(context, listed(context, context.sections))}
|
|
1651
|
+
<div class="sf-property-view-frame flex-auto min-w-0 flex flex-col gap-2">
|
|
1652
|
+
${renderBody(context, true, false)}
|
|
1653
|
+
</div>`
|
|
1654
|
+
: renderBody(context, false, false)}
|
|
1655
|
+
</div>
|
|
1656
|
+
${renderActions(context, {panel: true})}
|
|
1657
|
+
</div>
|
|
1658
|
+
</sf-drawer>
|
|
1659
|
+
`;
|
|
1660
|
+
}
|