@xmesh/system-design 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +472 -0
- package/assets/brand-lockup-dark.svg +9 -0
- package/assets/brand-lockup-light.svg +9 -0
- package/assets/brand-mark.svg +9 -0
- package/colors_and_type.css +11 -0
- package/dist/lit/components/alert/index.css +201 -0
- package/dist/lit/components/alert/index.d.ts +25 -0
- package/dist/lit/components/alert/index.js +191 -0
- package/dist/lit/components/app-bar/index.css +80 -0
- package/dist/lit/components/app-bar/index.d.ts +19 -0
- package/dist/lit/components/app-bar/index.js +120 -0
- package/dist/lit/components/artifact/index.css +166 -0
- package/dist/lit/components/artifact/index.d.ts +37 -0
- package/dist/lit/components/artifact/index.js +294 -0
- package/dist/lit/components/autocomplete/index.css +171 -0
- package/dist/lit/components/autocomplete/index.d.ts +47 -0
- package/dist/lit/components/autocomplete/index.js +404 -0
- package/dist/lit/components/avatar/index.css +62 -0
- package/dist/lit/components/avatar/index.d.ts +19 -0
- package/dist/lit/components/avatar/index.js +112 -0
- package/dist/lit/components/avatar-group/index.css +60 -0
- package/dist/lit/components/avatar-group/index.d.ts +19 -0
- package/dist/lit/components/avatar-group/index.js +97 -0
- package/dist/lit/components/badge/index.css +72 -0
- package/dist/lit/components/badge/index.d.ts +18 -0
- package/dist/lit/components/badge/index.js +115 -0
- package/dist/lit/components/brand-mark/index.css +109 -0
- package/dist/lit/components/brand-mark/index.d.ts +24 -0
- package/dist/lit/components/brand-mark/index.js +116 -0
- package/dist/lit/components/breadcrumbs/index.css +91 -0
- package/dist/lit/components/breadcrumbs/index.d.ts +19 -0
- package/dist/lit/components/breadcrumbs/index.js +104 -0
- package/dist/lit/components/bubble/index.css +182 -0
- package/dist/lit/components/bubble/index.d.ts +72 -0
- package/dist/lit/components/bubble/index.js +617 -0
- package/dist/lit/components/button/index.css +342 -0
- package/dist/lit/components/button/index.d.ts +32 -0
- package/dist/lit/components/button/index.js +202 -0
- package/dist/lit/components/card/index.css +99 -0
- package/dist/lit/components/card/index.d.ts +20 -0
- package/dist/lit/components/card/index.js +133 -0
- package/dist/lit/components/chat/index.css +292 -0
- package/dist/lit/components/chat/index.d.ts +74 -0
- package/dist/lit/components/chat/index.js +589 -0
- package/dist/lit/components/checkbox/index.css +126 -0
- package/dist/lit/components/checkbox/index.d.ts +21 -0
- package/dist/lit/components/checkbox/index.js +138 -0
- package/dist/lit/components/chip/index.css +145 -0
- package/dist/lit/components/chip/index.d.ts +30 -0
- package/dist/lit/components/chip/index.js +230 -0
- package/dist/lit/components/chip-group/index.css +19 -0
- package/dist/lit/components/chip-group/index.d.ts +24 -0
- package/dist/lit/components/chip-group/index.js +171 -0
- package/dist/lit/components/code/index.css +42 -0
- package/dist/lit/components/code/index.d.ts +12 -0
- package/dist/lit/components/code/index.js +68 -0
- package/dist/lit/components/composer/index.css +548 -0
- package/dist/lit/components/composer/index.d.ts +67 -0
- package/dist/lit/components/composer/index.js +713 -0
- package/dist/lit/components/data-table/index.css +166 -0
- package/dist/lit/components/data-table/index.d.ts +55 -0
- package/dist/lit/components/data-table/index.js +390 -0
- package/dist/lit/components/dialog/index.css +124 -0
- package/dist/lit/components/dialog/index.d.ts +24 -0
- package/dist/lit/components/dialog/index.js +199 -0
- package/dist/lit/components/divider/index.css +27 -0
- package/dist/lit/components/divider/index.d.ts +13 -0
- package/dist/lit/components/divider/index.js +67 -0
- package/dist/lit/components/empty-state/index.css +69 -0
- package/dist/lit/components/empty-state/index.d.ts +21 -0
- package/dist/lit/components/empty-state/index.js +123 -0
- package/dist/lit/components/expansion-panel/index.css +120 -0
- package/dist/lit/components/expansion-panel/index.d.ts +22 -0
- package/dist/lit/components/expansion-panel/index.js +174 -0
- package/dist/lit/components/field/index.css +223 -0
- package/dist/lit/components/field/index.d.ts +106 -0
- package/dist/lit/components/field/index.js +388 -0
- package/dist/lit/components/file-input/index.css +257 -0
- package/dist/lit/components/file-input/index.d.ts +30 -0
- package/dist/lit/components/file-input/index.js +298 -0
- package/dist/lit/components/form/index.css +29 -0
- package/dist/lit/components/form/index.d.ts +38 -0
- package/dist/lit/components/form/index.js +192 -0
- package/dist/lit/components/grid/index.css +53 -0
- package/dist/lit/components/grid/index.d.ts +14 -0
- package/dist/lit/components/grid/index.js +82 -0
- package/dist/lit/components/kbd/index.css +35 -0
- package/dist/lit/components/kbd/index.d.ts +11 -0
- package/dist/lit/components/kbd/index.js +43 -0
- package/dist/lit/components/list/index.css +15 -0
- package/dist/lit/components/list/index.d.ts +28 -0
- package/dist/lit/components/list/index.js +188 -0
- package/dist/lit/components/list-item/index.css +119 -0
- package/dist/lit/components/list-item/index.d.ts +20 -0
- package/dist/lit/components/list-item/index.js +127 -0
- package/dist/lit/components/menu/index.css +94 -0
- package/dist/lit/components/menu/index.d.ts +47 -0
- package/dist/lit/components/menu/index.js +386 -0
- package/dist/lit/components/navigation-drawer/index.css +114 -0
- package/dist/lit/components/navigation-drawer/index.d.ts +29 -0
- package/dist/lit/components/navigation-drawer/index.js +218 -0
- package/dist/lit/components/overlay/index.css +171 -0
- package/dist/lit/components/overlay/index.d.ts +65 -0
- package/dist/lit/components/overlay/index.js +566 -0
- package/dist/lit/components/pagination/index.css +102 -0
- package/dist/lit/components/pagination/index.d.ts +22 -0
- package/dist/lit/components/pagination/index.js +184 -0
- package/dist/lit/components/primitives/index.css +504 -0
- package/dist/lit/components/primitives/index.d.ts +25 -0
- package/dist/lit/components/primitives/index.js +283 -0
- package/dist/lit/components/progress/index.css +143 -0
- package/dist/lit/components/progress/index.d.ts +23 -0
- package/dist/lit/components/progress/index.js +180 -0
- package/dist/lit/components/radio-group/index.css +178 -0
- package/dist/lit/components/radio-group/index.d.ts +35 -0
- package/dist/lit/components/radio-group/index.js +292 -0
- package/dist/lit/components/select/index.css +151 -0
- package/dist/lit/components/select/index.d.ts +50 -0
- package/dist/lit/components/select/index.js +390 -0
- package/dist/lit/components/sidebar-item/index.css +133 -0
- package/dist/lit/components/sidebar-item/index.d.ts +20 -0
- package/dist/lit/components/sidebar-item/index.js +105 -0
- package/dist/lit/components/skeleton/index.css +81 -0
- package/dist/lit/components/skeleton/index.d.ts +19 -0
- package/dist/lit/components/skeleton/index.js +119 -0
- package/dist/lit/components/slider/index.css +171 -0
- package/dist/lit/components/slider/index.d.ts +36 -0
- package/dist/lit/components/slider/index.js +302 -0
- package/dist/lit/components/snackbar/index.css +279 -0
- package/dist/lit/components/snackbar/index.d.ts +33 -0
- package/dist/lit/components/snackbar/index.js +195 -0
- package/dist/lit/components/stack/index.css +41 -0
- package/dist/lit/components/stack/index.d.ts +20 -0
- package/dist/lit/components/stack/index.js +103 -0
- package/dist/lit/components/switch/index.css +126 -0
- package/dist/lit/components/switch/index.d.ts +17 -0
- package/dist/lit/components/switch/index.js +116 -0
- package/dist/lit/components/table/index.css +85 -0
- package/dist/lit/components/table/index.d.ts +25 -0
- package/dist/lit/components/table/index.js +139 -0
- package/dist/lit/components/tabs/index.css +116 -0
- package/dist/lit/components/tabs/index.d.ts +49 -0
- package/dist/lit/components/tabs/index.js +320 -0
- package/dist/lit/components/text-field/index.css +90 -0
- package/dist/lit/components/text-field/index.d.ts +17 -0
- package/dist/lit/components/text-field/index.js +101 -0
- package/dist/lit/components/textarea/index.css +55 -0
- package/dist/lit/components/textarea/index.d.ts +26 -0
- package/dist/lit/components/textarea/index.js +124 -0
- package/dist/lit/components/tooltip/index.css +37 -0
- package/dist/lit/components/tooltip/index.d.ts +31 -0
- package/dist/lit/components/tooltip/index.js +196 -0
- package/dist/lit/components/validation/index.css +386 -0
- package/dist/lit/components/validation/index.d.ts +45 -0
- package/dist/lit/components/validation/index.js +318 -0
- package/dist/lit/index.d.ts +50 -0
- package/dist/lit/index.js +59 -0
- package/package.json +81 -0
- package/styles/README.md +346 -0
- package/styles/_elevation.css +24 -0
- package/styles/_fonts.css +6 -0
- package/styles/_layout.css +37 -0
- package/styles/_primitives.css +154 -0
- package/styles/_scroll.css +75 -0
- package/styles/_semantic.css +146 -0
- package/styles/_space.css +61 -0
- package/styles/_type.css +139 -0
- package/styles/_xmesh-extensions.css +232 -0
- package/styles/index.css +44 -0
- package/styles/md3/_color.css +102 -0
- package/styles/md3/_elevation.css +26 -0
- package/styles/md3/_motion.css +35 -0
- package/styles/md3/_shape.css +22 -0
- package/styles/md3/_state.css +22 -0
- package/styles/md3/_type.css +111 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/*
|
|
2
|
+
file-input/index.ts — <xm-file-input>, a generic file picker / dropzone (Story 2.9).
|
|
3
|
+
|
|
4
|
+
Extends XmField for the chrome (label / helper / error / disabled / form
|
|
5
|
+
association / focus) and renders a dropzone: clicking (or Enter/Space) opens the
|
|
6
|
+
native file picker; dragging files over it shows the drag-active state; dropping
|
|
7
|
+
accepts them. After selection it lists each file's name + formatted size
|
|
8
|
+
(`412 KB` — number and unit separated by a space, UX-DR4).
|
|
9
|
+
|
|
10
|
+
This is the GENERIC picker — explicitly distinct from the shipped, chat-specific
|
|
11
|
+
xm-file-validation-block (FR-130). Both coexist.
|
|
12
|
+
|
|
13
|
+
Value contract (AD-6 / AD-8a): uncontrolled-first. On selection it emits
|
|
14
|
+
`change` with the bespoke typed payload detail.files: File[] (not the generic
|
|
15
|
+
value/checked shapes). The selected files are exposed via a public `files`
|
|
16
|
+
getter; the form value is set from the selection via ElementInternals.
|
|
17
|
+
|
|
18
|
+
Borders: the drag-active state is the ONE place a 2px border is allowed
|
|
19
|
+
(2px solid --md-sys-color-primary) — every other border is hairline 1px
|
|
20
|
+
(DESIGN.md). Errors render as icon + copy in the message row, never a hue (AD-11).
|
|
21
|
+
|
|
22
|
+
Shadow DOM. Lit is a bare `import` (peer dep).
|
|
23
|
+
*/
|
|
24
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
25
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
26
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
27
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
28
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
29
|
+
};
|
|
30
|
+
import { html, nothing } from "lit";
|
|
31
|
+
import { customElement, property, state, query } from "lit/decorators.js";
|
|
32
|
+
import { XmField } from "../field/index.js";
|
|
33
|
+
const FILE_INPUT_CSS = new URL("../file-input/index.css", import.meta.url).href;
|
|
34
|
+
const PRIMITIVES_CSS = new URL("../primitives/index.css", import.meta.url).href;
|
|
35
|
+
/** Format bytes as `B` / `KB` / `MB` with one space before the unit (UX-DR4).
|
|
36
|
+
Decimal units (1 KB = 1000 B) — matches the file-size convention users see in
|
|
37
|
+
macOS / browsers, so a 412000-byte file reads "412 KB". */
|
|
38
|
+
function formatBytes(bytes) {
|
|
39
|
+
if (bytes < 1000)
|
|
40
|
+
return `${bytes} B`;
|
|
41
|
+
const kb = bytes / 1000;
|
|
42
|
+
if (kb < 1000)
|
|
43
|
+
return `${Math.round(kb)} KB`;
|
|
44
|
+
const mb = kb / 1000;
|
|
45
|
+
return `${mb < 10 ? mb.toFixed(1) : Math.round(mb)} MB`;
|
|
46
|
+
}
|
|
47
|
+
let XmFileInput = class XmFileInput extends XmField {
|
|
48
|
+
constructor() {
|
|
49
|
+
super(...arguments);
|
|
50
|
+
/** Native accept filter (e.g. ".yml,.json,image/*"). */
|
|
51
|
+
this.accept = "";
|
|
52
|
+
/** Allow selecting more than one file. */
|
|
53
|
+
this.multiple = false;
|
|
54
|
+
this._files = [];
|
|
55
|
+
this._dragActive = false;
|
|
56
|
+
this._onNativeChange = (e) => {
|
|
57
|
+
const list = e.target.files;
|
|
58
|
+
this._setFiles(list ? Array.from(list) : []);
|
|
59
|
+
};
|
|
60
|
+
this._onKeydown = (e) => {
|
|
61
|
+
if (e.key === "Enter" || e.key === " " || e.key === "Spacebar") {
|
|
62
|
+
e.preventDefault();
|
|
63
|
+
this._openPicker();
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
this._onDragOver = (e) => {
|
|
67
|
+
if (this.nonInteractive)
|
|
68
|
+
return;
|
|
69
|
+
e.preventDefault();
|
|
70
|
+
this._dragActive = true;
|
|
71
|
+
};
|
|
72
|
+
this._onDragLeave = (e) => {
|
|
73
|
+
e.preventDefault();
|
|
74
|
+
// Only clear drag-active when the pointer actually leaves the dropzone —
|
|
75
|
+
// crossing between the zone's child elements fires dragleave/dragenter pairs
|
|
76
|
+
// and would otherwise flicker the 2px border.
|
|
77
|
+
const related = e.relatedTarget;
|
|
78
|
+
if (related && e.currentTarget instanceof Node && e.currentTarget.contains(related)) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
this._dragActive = false;
|
|
82
|
+
};
|
|
83
|
+
this._onDrop = (e) => {
|
|
84
|
+
if (this.nonInteractive)
|
|
85
|
+
return;
|
|
86
|
+
e.preventDefault();
|
|
87
|
+
this._dragActive = false;
|
|
88
|
+
const list = e.dataTransfer?.files;
|
|
89
|
+
if (!list || list.length === 0)
|
|
90
|
+
return;
|
|
91
|
+
// Apply the same `accept` filter the native picker enforces (the picker
|
|
92
|
+
// filters; a drop must not bypass it).
|
|
93
|
+
const picked = Array.from(list).filter((f) => this._accepts(f));
|
|
94
|
+
if (picked.length === 0)
|
|
95
|
+
return;
|
|
96
|
+
this._setFiles(this.multiple ? picked : picked.slice(0, 1));
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/** Public read contract (AD-6a) — the selected files. */
|
|
100
|
+
get files() {
|
|
101
|
+
return this._files;
|
|
102
|
+
}
|
|
103
|
+
_setFiles(files) {
|
|
104
|
+
this._files = files;
|
|
105
|
+
this._value = files.map((f) => f.name).join(", ");
|
|
106
|
+
if (files.length === 0) {
|
|
107
|
+
this.internals.setFormValue(null);
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
const data = new FormData();
|
|
111
|
+
const key = this.name || "files";
|
|
112
|
+
for (const f of files)
|
|
113
|
+
data.append(key, f, f.name);
|
|
114
|
+
this.internals.setFormValue(data);
|
|
115
|
+
}
|
|
116
|
+
this.dispatchEvent(new CustomEvent("change", {
|
|
117
|
+
bubbles: true,
|
|
118
|
+
composed: true,
|
|
119
|
+
detail: { files },
|
|
120
|
+
}));
|
|
121
|
+
}
|
|
122
|
+
_openPicker() {
|
|
123
|
+
if (this.nonInteractive)
|
|
124
|
+
return;
|
|
125
|
+
this._native?.click();
|
|
126
|
+
}
|
|
127
|
+
/** Does a dropped file satisfy the `accept` list? Mirrors the native picker so
|
|
128
|
+
drag-drop and click-to-pick enforce the same filter. */
|
|
129
|
+
_accepts(file) {
|
|
130
|
+
const accept = this.accept.trim();
|
|
131
|
+
if (accept === "")
|
|
132
|
+
return true;
|
|
133
|
+
const name = file.name.toLowerCase();
|
|
134
|
+
const type = file.type.toLowerCase();
|
|
135
|
+
return accept.split(",").some((raw) => {
|
|
136
|
+
const token = raw.trim().toLowerCase();
|
|
137
|
+
if (token === "")
|
|
138
|
+
return false;
|
|
139
|
+
if (token.startsWith("."))
|
|
140
|
+
return name.endsWith(token);
|
|
141
|
+
if (token.endsWith("/*"))
|
|
142
|
+
return type.startsWith(token.slice(0, -1));
|
|
143
|
+
return type === token;
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
_removeFile(index) {
|
|
147
|
+
if (this.nonInteractive)
|
|
148
|
+
return;
|
|
149
|
+
const next = this._files.filter((_, i) => i !== index);
|
|
150
|
+
this._setFiles(next);
|
|
151
|
+
// Clear the native input so re-picking the same file fires change again.
|
|
152
|
+
if (this._native)
|
|
153
|
+
this._native.value = "";
|
|
154
|
+
}
|
|
155
|
+
;
|
|
156
|
+
render() {
|
|
157
|
+
const cls = [
|
|
158
|
+
"file-input",
|
|
159
|
+
this._dragActive ? "file-input--drag-active" : "",
|
|
160
|
+
this.effectiveDisabled ? "file-input--disabled" : "",
|
|
161
|
+
this.isError ? "file-input--error" : "",
|
|
162
|
+
]
|
|
163
|
+
.filter(Boolean)
|
|
164
|
+
.join(" ");
|
|
165
|
+
const helperText = this.isError ? this.error : this.helper;
|
|
166
|
+
const ctrl = this.controlAria;
|
|
167
|
+
return html `
|
|
168
|
+
<link rel="stylesheet" href="${PRIMITIVES_CSS}" />
|
|
169
|
+
<link rel="stylesheet" href="${FILE_INPUT_CSS}" />
|
|
170
|
+
<style>
|
|
171
|
+
:host {
|
|
172
|
+
display: block;
|
|
173
|
+
}
|
|
174
|
+
:host([hidden]) {
|
|
175
|
+
display: none;
|
|
176
|
+
}
|
|
177
|
+
</style>
|
|
178
|
+
<div class="${cls}">
|
|
179
|
+
${this.label
|
|
180
|
+
? html `<label class="file-input__label" id="${ctrl.id}-label">
|
|
181
|
+
<span class="file-input__label-text">${this.label}</span>
|
|
182
|
+
${this.required
|
|
183
|
+
? html `<span class="file-input__required" aria-hidden="true"
|
|
184
|
+
>*</span
|
|
185
|
+
>`
|
|
186
|
+
: nothing}
|
|
187
|
+
</label>`
|
|
188
|
+
: nothing}
|
|
189
|
+
|
|
190
|
+
<input
|
|
191
|
+
class="file-input__native"
|
|
192
|
+
type="file"
|
|
193
|
+
accept=${this.accept || nothing}
|
|
194
|
+
?multiple=${this.multiple}
|
|
195
|
+
?disabled=${this.effectiveDisabled}
|
|
196
|
+
@change=${this._onNativeChange}
|
|
197
|
+
tabindex="-1"
|
|
198
|
+
aria-hidden="true"
|
|
199
|
+
/>
|
|
200
|
+
|
|
201
|
+
<div
|
|
202
|
+
class="file-input__dropzone"
|
|
203
|
+
id="${ctrl.id}"
|
|
204
|
+
role="button"
|
|
205
|
+
tabindex="${this.effectiveDisabled ? -1 : 0}"
|
|
206
|
+
aria-labelledby="${this.label ? `${ctrl.id}-label` : nothing}"
|
|
207
|
+
aria-label="${this.label ? nothing : "Choose file or drop here"}"
|
|
208
|
+
aria-describedby="${helperText ? ctrl.describedBy : nothing}"
|
|
209
|
+
aria-invalid="${ctrl.invalid ?? nothing}"
|
|
210
|
+
aria-disabled="${this.effectiveDisabled ? "true" : nothing}"
|
|
211
|
+
@click=${this._openPicker}
|
|
212
|
+
@keydown=${this._onKeydown}
|
|
213
|
+
@dragover=${this._onDragOver}
|
|
214
|
+
@dragleave=${this._onDragLeave}
|
|
215
|
+
@drop=${this._onDrop}
|
|
216
|
+
>
|
|
217
|
+
<span class="file-input__icon" aria-hidden="true">
|
|
218
|
+
<xm-paperclip-icon size="18"></xm-paperclip-icon>
|
|
219
|
+
</span>
|
|
220
|
+
<span class="file-input__copy">
|
|
221
|
+
<span class="file-input__primary">
|
|
222
|
+
${this._dragActive ? "Drop to upload" : "Choose file or drop here"}
|
|
223
|
+
</span>
|
|
224
|
+
<span class="file-input__secondary">
|
|
225
|
+
${this.accept
|
|
226
|
+
? this.accept
|
|
227
|
+
: this.multiple
|
|
228
|
+
? "One or more files"
|
|
229
|
+
: "A single file"}
|
|
230
|
+
</span>
|
|
231
|
+
</span>
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
${this._files.length > 0
|
|
235
|
+
? html `<ul class="file-input__files" aria-live="polite">
|
|
236
|
+
${this._files.map((f, i) => html `<li class="file-input__file">
|
|
237
|
+
<span class="file-input__file-icon" aria-hidden="true">
|
|
238
|
+
<xm-file-glyph-icon size="16"></xm-file-glyph-icon>
|
|
239
|
+
</span>
|
|
240
|
+
<span class="file-input__file-name">${f.name}</span>
|
|
241
|
+
<span class="file-input__file-size"
|
|
242
|
+
>${formatBytes(f.size)}</span
|
|
243
|
+
>
|
|
244
|
+
<button
|
|
245
|
+
type="button"
|
|
246
|
+
class="file-input__remove"
|
|
247
|
+
aria-label="Remove ${f.name}"
|
|
248
|
+
?disabled=${this.effectiveDisabled}
|
|
249
|
+
@click=${(e) => {
|
|
250
|
+
e.stopPropagation();
|
|
251
|
+
this._removeFile(i);
|
|
252
|
+
}}
|
|
253
|
+
>
|
|
254
|
+
<xm-x-icon size="10"></xm-x-icon>
|
|
255
|
+
</button>
|
|
256
|
+
</li>`)}
|
|
257
|
+
</ul>`
|
|
258
|
+
: nothing}
|
|
259
|
+
|
|
260
|
+
${helperText
|
|
261
|
+
? html `<div
|
|
262
|
+
class="file-input__message ${this.isError
|
|
263
|
+
? "file-input__message--error"
|
|
264
|
+
: "file-input__message--helper"}"
|
|
265
|
+
id="${ctrl.describedBy}"
|
|
266
|
+
role=${this.isError ? "alert" : nothing}
|
|
267
|
+
>
|
|
268
|
+
${this.isError
|
|
269
|
+
? html `<span class="file-input__error-icon" aria-hidden="true">
|
|
270
|
+
<xm-warn-icon size="14"></xm-warn-icon>
|
|
271
|
+
</span>`
|
|
272
|
+
: nothing}
|
|
273
|
+
<span class="file-input__message-text">${helperText}</span>
|
|
274
|
+
</div>`
|
|
275
|
+
: nothing}
|
|
276
|
+
</div>
|
|
277
|
+
`;
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
__decorate([
|
|
281
|
+
property({ type: String })
|
|
282
|
+
], XmFileInput.prototype, "accept", void 0);
|
|
283
|
+
__decorate([
|
|
284
|
+
property({ type: Boolean })
|
|
285
|
+
], XmFileInput.prototype, "multiple", void 0);
|
|
286
|
+
__decorate([
|
|
287
|
+
state()
|
|
288
|
+
], XmFileInput.prototype, "_files", void 0);
|
|
289
|
+
__decorate([
|
|
290
|
+
state()
|
|
291
|
+
], XmFileInput.prototype, "_dragActive", void 0);
|
|
292
|
+
__decorate([
|
|
293
|
+
query(".file-input__native")
|
|
294
|
+
], XmFileInput.prototype, "_native", void 0);
|
|
295
|
+
XmFileInput = __decorate([
|
|
296
|
+
customElement("xm-file-input")
|
|
297
|
+
], XmFileInput);
|
|
298
|
+
export { XmFileInput };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/* ============================================
|
|
2
|
+
xm-form — field aggregator / orchestrator (Story 2.10).
|
|
3
|
+
|
|
4
|
+
Layout-only chrome: a vertical stack of slotted fields with consistent --s-N
|
|
5
|
+
gaps so fields of the same `size` align (NFR-22). xm-form has NO surface of
|
|
6
|
+
its own and no bespoke color tokens — each child renders its own XmField
|
|
7
|
+
chrome, which the form must never restyle (AD-7 / AD-12).
|
|
8
|
+
|
|
9
|
+
BEM block: `form`. Registered in scripts/check-bem.sh STRICT_BLOCKS.
|
|
10
|
+
============================================ */
|
|
11
|
+
|
|
12
|
+
.form {
|
|
13
|
+
display: flex;
|
|
14
|
+
flex-direction: column;
|
|
15
|
+
gap: var(--s-5);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/* The slotted action row (a button or button group) sits at the foot of the
|
|
19
|
+
stack; authors mark it with slot="actions" if they want it grouped + set off
|
|
20
|
+
from the fields by a single hairline (the system's 1px rule — no surface, just
|
|
21
|
+
a divider). Default flow already stacks it under the last field. */
|
|
22
|
+
.form ::slotted([slot="actions"]) {
|
|
23
|
+
display: flex;
|
|
24
|
+
gap: var(--s-3);
|
|
25
|
+
align-items: center;
|
|
26
|
+
margin-top: var(--s-2);
|
|
27
|
+
padding-top: var(--s-4);
|
|
28
|
+
border-top: 1px solid var(--md-sys-color-outline-variant);
|
|
29
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { LitElement } from "lit";
|
|
2
|
+
import type { PropertyValues, TemplateResult } from "lit";
|
|
3
|
+
export interface FormResult {
|
|
4
|
+
valid: boolean;
|
|
5
|
+
values: Record<string, unknown>;
|
|
6
|
+
}
|
|
7
|
+
export declare class XmForm extends LitElement {
|
|
8
|
+
disabled: boolean;
|
|
9
|
+
readonly: boolean;
|
|
10
|
+
/** Each touched child's OWN readonly, snapshotted before the form ever sets it,
|
|
11
|
+
so OR-propagation never clears a child's intrinsic readonly. */
|
|
12
|
+
private readonly _ownReadonly;
|
|
13
|
+
connectedCallback(): void;
|
|
14
|
+
disconnectedCallback(): void;
|
|
15
|
+
protected updated(changed: PropertyValues<this>): void;
|
|
16
|
+
/** The slotted, form-associated children — discovered via the light DOM, never
|
|
17
|
+
by querying any child's shadow root (AD-12). */
|
|
18
|
+
private get _fields();
|
|
19
|
+
/** Read one child's current value by its kind (toggle / file / value field). */
|
|
20
|
+
private _readValue;
|
|
21
|
+
private _isToggle;
|
|
22
|
+
/** A child is invalid if it carries error copy OR is required-but-empty. */
|
|
23
|
+
private _isInvalid;
|
|
24
|
+
/** Public read contract: pull each child's live value at read time (children
|
|
25
|
+
are uncontrolled — the form does not drive their renders, AD-6). */
|
|
26
|
+
validate(): FormResult;
|
|
27
|
+
/** Imperative submit — same path the submit button / Enter take. */
|
|
28
|
+
submit(): FormResult;
|
|
29
|
+
private _onChildSubmit;
|
|
30
|
+
private _onKeydown;
|
|
31
|
+
private _propagate;
|
|
32
|
+
render(): TemplateResult;
|
|
33
|
+
}
|
|
34
|
+
declare global {
|
|
35
|
+
interface HTMLElementTagNameMap {
|
|
36
|
+
"xm-form": XmForm;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/*
|
|
2
|
+
form/index.ts — <xm-form>, the field aggregator / orchestrator (Story 2.10).
|
|
3
|
+
|
|
4
|
+
The CULMINATION of Epic 2: it composes every form-associated field via the
|
|
5
|
+
AD-6a public contract and reads each child ONLY through its public getters /
|
|
6
|
+
native form participation — NEVER by reaching into a child's shadow root
|
|
7
|
+
(AD-12). It is layout-only chrome; each child renders its own XmField chrome,
|
|
8
|
+
which the form must never re-render or restyle.
|
|
9
|
+
|
|
10
|
+
What it owns:
|
|
11
|
+
• validate(): { valid, values } — iterates the slotted fields, reads each
|
|
12
|
+
`value` / `checked` / `files` getter keyed by the child's `name`, and
|
|
13
|
+
aggregates `valid` from each child's validity (required-empty / error copy).
|
|
14
|
+
• submit path — on a submit button, Enter in a field, or submit() call, it
|
|
15
|
+
runs validate(), emits a composed `submit` with the same { values, valid }
|
|
16
|
+
detail (AD-8), and blocks when invalid so the host controls the action and
|
|
17
|
+
the offending fields keep showing their own icon+copy errors.
|
|
18
|
+
• disabled / readonly down-propagation with OR semantics (AD-6a): a child is
|
|
19
|
+
disabled/readonly if EITHER the form or the child sets it; neither source
|
|
20
|
+
re-enables the other. Disabled rides the base's setFormDisabled() hook;
|
|
21
|
+
readonly is OR'd against each child's OWN readonly (snapshotted once).
|
|
22
|
+
|
|
23
|
+
xm-form is NOT a field — it has no value of its own.
|
|
24
|
+
|
|
25
|
+
Shadow DOM (slot projection). Lit is a bare `import` (peer dep).
|
|
26
|
+
*/
|
|
27
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
28
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
29
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
30
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
31
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
32
|
+
};
|
|
33
|
+
import { LitElement, html } from "lit";
|
|
34
|
+
import { customElement, property } from "lit/decorators.js";
|
|
35
|
+
const FORM_CSS = new URL("../form/index.css", import.meta.url).href;
|
|
36
|
+
let XmForm = class XmForm extends LitElement {
|
|
37
|
+
constructor() {
|
|
38
|
+
super(...arguments);
|
|
39
|
+
this.disabled = false;
|
|
40
|
+
this.readonly = false;
|
|
41
|
+
/** Each touched child's OWN readonly, snapshotted before the form ever sets it,
|
|
42
|
+
so OR-propagation never clears a child's intrinsic readonly. */
|
|
43
|
+
this._ownReadonly = new WeakMap();
|
|
44
|
+
// ── Submit triggers ─────────────────────────────────────────────────
|
|
45
|
+
// A native <form> (or an xm-button[type=submit] dispatching submit) bubbles a
|
|
46
|
+
// `submit` event; intercept it, run our path, and stop the native navigation.
|
|
47
|
+
this._onChildSubmit = (e) => {
|
|
48
|
+
// Don't re-handle our OWN composed submit event.
|
|
49
|
+
if (e.detail && "valid" in (e.detail ?? {})) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
e.preventDefault();
|
|
53
|
+
e.stopPropagation();
|
|
54
|
+
this.submit();
|
|
55
|
+
};
|
|
56
|
+
this._onKeydown = (e) => {
|
|
57
|
+
// Enter inside a single-line field submits; leave textarea Enter to insert a
|
|
58
|
+
// newline (its own behavior), and don't hijack Enter on buttons.
|
|
59
|
+
if (e.key !== "Enter")
|
|
60
|
+
return;
|
|
61
|
+
const target = e.target;
|
|
62
|
+
const tag = target.tagName.toLowerCase();
|
|
63
|
+
if (tag === "xm-textarea" || tag === "xm-button" || tag === "button")
|
|
64
|
+
return;
|
|
65
|
+
if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey)
|
|
66
|
+
return;
|
|
67
|
+
e.preventDefault();
|
|
68
|
+
this.submit();
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
connectedCallback() {
|
|
72
|
+
super.connectedCallback();
|
|
73
|
+
this.addEventListener("submit", this._onChildSubmit);
|
|
74
|
+
this.addEventListener("keydown", this._onKeydown);
|
|
75
|
+
}
|
|
76
|
+
disconnectedCallback() {
|
|
77
|
+
super.disconnectedCallback();
|
|
78
|
+
this.removeEventListener("submit", this._onChildSubmit);
|
|
79
|
+
this.removeEventListener("keydown", this._onKeydown);
|
|
80
|
+
}
|
|
81
|
+
updated(changed) {
|
|
82
|
+
if (changed.has("disabled") || changed.has("readonly")) {
|
|
83
|
+
this._propagate();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** The slotted, form-associated children — discovered via the light DOM, never
|
|
87
|
+
by querying any child's shadow root (AD-12). */
|
|
88
|
+
get _fields() {
|
|
89
|
+
return Array.from(this.children).filter((el) => {
|
|
90
|
+
return (el instanceof HTMLElement &&
|
|
91
|
+
// A field carries a name and participates in the value contract.
|
|
92
|
+
"value" in el &&
|
|
93
|
+
typeof el.name === "string");
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/** Read one child's current value by its kind (toggle / file / value field). */
|
|
97
|
+
_readValue(field) {
|
|
98
|
+
if (typeof field.files !== "undefined" && Array.isArray(field.files)) {
|
|
99
|
+
return field.files;
|
|
100
|
+
}
|
|
101
|
+
// A toggle field reports `checked`; reading checked is only meaningful when
|
|
102
|
+
// the element actually exposes it as a boolean (checkbox / switch).
|
|
103
|
+
if (this._isToggle(field))
|
|
104
|
+
return field.checked === true;
|
|
105
|
+
return field.value;
|
|
106
|
+
}
|
|
107
|
+
_isToggle(field) {
|
|
108
|
+
const tag = field.tagName.toLowerCase();
|
|
109
|
+
return tag === "xm-checkbox" || tag === "xm-switch";
|
|
110
|
+
}
|
|
111
|
+
/** A child is invalid if it carries error copy OR is required-but-empty. */
|
|
112
|
+
_isInvalid(field) {
|
|
113
|
+
if (typeof field.error === "string" && field.error.trim() !== "") {
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
if (field.required) {
|
|
117
|
+
const v = this._readValue(field);
|
|
118
|
+
if (Array.isArray(v))
|
|
119
|
+
return v.length === 0;
|
|
120
|
+
if (typeof v === "boolean")
|
|
121
|
+
return v === false;
|
|
122
|
+
return v === undefined || v === null || v === "";
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
/** Public read contract: pull each child's live value at read time (children
|
|
127
|
+
are uncontrolled — the form does not drive their renders, AD-6). */
|
|
128
|
+
validate() {
|
|
129
|
+
const values = {};
|
|
130
|
+
let valid = true;
|
|
131
|
+
for (const field of this._fields) {
|
|
132
|
+
const name = field.name ?? "";
|
|
133
|
+
if (name === "")
|
|
134
|
+
continue;
|
|
135
|
+
values[name] = this._readValue(field);
|
|
136
|
+
if (this._isInvalid(field))
|
|
137
|
+
valid = false;
|
|
138
|
+
}
|
|
139
|
+
return { valid, values };
|
|
140
|
+
}
|
|
141
|
+
/** Imperative submit — same path the submit button / Enter take. */
|
|
142
|
+
submit() {
|
|
143
|
+
const result = this.validate();
|
|
144
|
+
this.dispatchEvent(new CustomEvent("submit", {
|
|
145
|
+
bubbles: true,
|
|
146
|
+
composed: true,
|
|
147
|
+
detail: result,
|
|
148
|
+
}));
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
// ── Disabled / readonly OR-propagation ──────────────────────────────
|
|
152
|
+
_propagate() {
|
|
153
|
+
for (const field of this._fields) {
|
|
154
|
+
// Disabled rides the base's OR hook (never re-enables a self-disabled field).
|
|
155
|
+
field.setFormDisabled?.(this.disabled);
|
|
156
|
+
// Readonly: OR against the child's OWN readonly, snapshotted once so the
|
|
157
|
+
// form never clears a child's intrinsic readonly.
|
|
158
|
+
if (!this._ownReadonly.has(field)) {
|
|
159
|
+
this._ownReadonly.set(field, field.readonly === true);
|
|
160
|
+
}
|
|
161
|
+
const own = this._ownReadonly.get(field) ?? false;
|
|
162
|
+
field.readonly = own || this.readonly;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
render() {
|
|
166
|
+
return html `
|
|
167
|
+
<link rel="stylesheet" href="${FORM_CSS}" />
|
|
168
|
+
<style>
|
|
169
|
+
:host {
|
|
170
|
+
display: block;
|
|
171
|
+
}
|
|
172
|
+
:host([hidden]) {
|
|
173
|
+
display: none;
|
|
174
|
+
}
|
|
175
|
+
</style>
|
|
176
|
+
<div class="form">
|
|
177
|
+
<slot @slotchange=${() => this._propagate()}></slot>
|
|
178
|
+
<slot name="actions"></slot>
|
|
179
|
+
</div>
|
|
180
|
+
`;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
__decorate([
|
|
184
|
+
property({ type: Boolean, reflect: true })
|
|
185
|
+
], XmForm.prototype, "disabled", void 0);
|
|
186
|
+
__decorate([
|
|
187
|
+
property({ type: Boolean, reflect: true })
|
|
188
|
+
], XmForm.prototype, "readonly", void 0);
|
|
189
|
+
XmForm = __decorate([
|
|
190
|
+
customElement("xm-form")
|
|
191
|
+
], XmForm);
|
|
192
|
+
export { XmForm };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/* ============================================
|
|
2
|
+
<xm-grid> — N-column (default 12) layout grid.
|
|
3
|
+
|
|
4
|
+
Slotted children are grid items (the <slot> is layout-transparent,
|
|
5
|
+
like xm-chip-group). A child spans N columns by setting the inline
|
|
6
|
+
custom property --xm-col-span on itself; default span is 1.
|
|
7
|
+
Optional --xm-col-start for explicit placement. Gutter via
|
|
8
|
+
gap="…" → --xm-gutter-* (the --s-N-aliased layout tier).
|
|
9
|
+
|
|
10
|
+
Below --xm-breakpoint-sm (520px) the grid collapses to a single
|
|
11
|
+
column. NOTE: the 520px literal is repeated in the @media below
|
|
12
|
+
because @media cannot read var(--xm-breakpoint-sm); the token in
|
|
13
|
+
styles/_layout.css is the source of truth (docs/adr/0001).
|
|
14
|
+
|
|
15
|
+
BEM block `grid`; modifiers --gap-*. Registered in
|
|
16
|
+
scripts/check-bem.sh STRICT_BLOCKS.
|
|
17
|
+
============================================ */
|
|
18
|
+
|
|
19
|
+
.grid {
|
|
20
|
+
display: grid;
|
|
21
|
+
grid-template-columns: repeat(var(--xm-grid-columns, 12), minmax(0, 1fr));
|
|
22
|
+
gap: var(--xm-gutter-md);
|
|
23
|
+
/* Inherited ink so currentColor in slotted content reads on the desk
|
|
24
|
+
surface (AD-13) — also satisfies the --md-sys-* token gate. */
|
|
25
|
+
color: var(--md-sys-color-on-surface);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.grid--gap-none { gap: var(--xm-gutter-none); }
|
|
29
|
+
.grid--gap-xs { gap: var(--xm-gutter-xs); }
|
|
30
|
+
.grid--gap-sm { gap: var(--xm-gutter-sm); }
|
|
31
|
+
.grid--gap-md { gap: var(--xm-gutter-md); }
|
|
32
|
+
.grid--gap-lg { gap: var(--xm-gutter-lg); }
|
|
33
|
+
.grid--gap-xl { gap: var(--xm-gutter-xl); }
|
|
34
|
+
|
|
35
|
+
/* Per-child placement, authored as inline custom properties on the child.
|
|
36
|
+
minmax(0, 1fr) + min-width: 0 keeps a wide / unbreakable child from
|
|
37
|
+
blowing its track out instead of honouring the span. */
|
|
38
|
+
.grid ::slotted(*) {
|
|
39
|
+
grid-column: var(--xm-col-start, auto) / span var(--xm-col-span, 1);
|
|
40
|
+
min-width: 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/* Compact: collapse to one column. The 520px literal mirrors
|
|
44
|
+
--xm-breakpoint-sm (styles/_layout.css); @media can't read the token
|
|
45
|
+
(docs/adr/0001). */
|
|
46
|
+
@media (max-width: 520px) {
|
|
47
|
+
.grid {
|
|
48
|
+
grid-template-columns: 1fr;
|
|
49
|
+
}
|
|
50
|
+
.grid ::slotted(*) {
|
|
51
|
+
grid-column: auto;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { LitElement } from "lit";
|
|
2
|
+
import type { TemplateResult } from "lit";
|
|
3
|
+
type GridGap = "none" | "xs" | "sm" | "md" | "lg" | "xl";
|
|
4
|
+
declare class XmGrid extends LitElement {
|
|
5
|
+
columns: number;
|
|
6
|
+
gap: GridGap;
|
|
7
|
+
render(): TemplateResult;
|
|
8
|
+
}
|
|
9
|
+
declare global {
|
|
10
|
+
interface HTMLElementTagNameMap {
|
|
11
|
+
"xm-grid": XmGrid;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export {};
|