@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.
Files changed (175) hide show
  1. package/README.md +472 -0
  2. package/assets/brand-lockup-dark.svg +9 -0
  3. package/assets/brand-lockup-light.svg +9 -0
  4. package/assets/brand-mark.svg +9 -0
  5. package/colors_and_type.css +11 -0
  6. package/dist/lit/components/alert/index.css +201 -0
  7. package/dist/lit/components/alert/index.d.ts +25 -0
  8. package/dist/lit/components/alert/index.js +191 -0
  9. package/dist/lit/components/app-bar/index.css +80 -0
  10. package/dist/lit/components/app-bar/index.d.ts +19 -0
  11. package/dist/lit/components/app-bar/index.js +120 -0
  12. package/dist/lit/components/artifact/index.css +166 -0
  13. package/dist/lit/components/artifact/index.d.ts +37 -0
  14. package/dist/lit/components/artifact/index.js +294 -0
  15. package/dist/lit/components/autocomplete/index.css +171 -0
  16. package/dist/lit/components/autocomplete/index.d.ts +47 -0
  17. package/dist/lit/components/autocomplete/index.js +404 -0
  18. package/dist/lit/components/avatar/index.css +62 -0
  19. package/dist/lit/components/avatar/index.d.ts +19 -0
  20. package/dist/lit/components/avatar/index.js +112 -0
  21. package/dist/lit/components/avatar-group/index.css +60 -0
  22. package/dist/lit/components/avatar-group/index.d.ts +19 -0
  23. package/dist/lit/components/avatar-group/index.js +97 -0
  24. package/dist/lit/components/badge/index.css +72 -0
  25. package/dist/lit/components/badge/index.d.ts +18 -0
  26. package/dist/lit/components/badge/index.js +115 -0
  27. package/dist/lit/components/brand-mark/index.css +109 -0
  28. package/dist/lit/components/brand-mark/index.d.ts +24 -0
  29. package/dist/lit/components/brand-mark/index.js +116 -0
  30. package/dist/lit/components/breadcrumbs/index.css +91 -0
  31. package/dist/lit/components/breadcrumbs/index.d.ts +19 -0
  32. package/dist/lit/components/breadcrumbs/index.js +104 -0
  33. package/dist/lit/components/bubble/index.css +182 -0
  34. package/dist/lit/components/bubble/index.d.ts +72 -0
  35. package/dist/lit/components/bubble/index.js +617 -0
  36. package/dist/lit/components/button/index.css +342 -0
  37. package/dist/lit/components/button/index.d.ts +32 -0
  38. package/dist/lit/components/button/index.js +202 -0
  39. package/dist/lit/components/card/index.css +99 -0
  40. package/dist/lit/components/card/index.d.ts +20 -0
  41. package/dist/lit/components/card/index.js +133 -0
  42. package/dist/lit/components/chat/index.css +292 -0
  43. package/dist/lit/components/chat/index.d.ts +74 -0
  44. package/dist/lit/components/chat/index.js +589 -0
  45. package/dist/lit/components/checkbox/index.css +126 -0
  46. package/dist/lit/components/checkbox/index.d.ts +21 -0
  47. package/dist/lit/components/checkbox/index.js +138 -0
  48. package/dist/lit/components/chip/index.css +145 -0
  49. package/dist/lit/components/chip/index.d.ts +30 -0
  50. package/dist/lit/components/chip/index.js +230 -0
  51. package/dist/lit/components/chip-group/index.css +19 -0
  52. package/dist/lit/components/chip-group/index.d.ts +24 -0
  53. package/dist/lit/components/chip-group/index.js +171 -0
  54. package/dist/lit/components/code/index.css +42 -0
  55. package/dist/lit/components/code/index.d.ts +12 -0
  56. package/dist/lit/components/code/index.js +68 -0
  57. package/dist/lit/components/composer/index.css +548 -0
  58. package/dist/lit/components/composer/index.d.ts +67 -0
  59. package/dist/lit/components/composer/index.js +713 -0
  60. package/dist/lit/components/data-table/index.css +166 -0
  61. package/dist/lit/components/data-table/index.d.ts +55 -0
  62. package/dist/lit/components/data-table/index.js +390 -0
  63. package/dist/lit/components/dialog/index.css +124 -0
  64. package/dist/lit/components/dialog/index.d.ts +24 -0
  65. package/dist/lit/components/dialog/index.js +199 -0
  66. package/dist/lit/components/divider/index.css +27 -0
  67. package/dist/lit/components/divider/index.d.ts +13 -0
  68. package/dist/lit/components/divider/index.js +67 -0
  69. package/dist/lit/components/empty-state/index.css +69 -0
  70. package/dist/lit/components/empty-state/index.d.ts +21 -0
  71. package/dist/lit/components/empty-state/index.js +123 -0
  72. package/dist/lit/components/expansion-panel/index.css +120 -0
  73. package/dist/lit/components/expansion-panel/index.d.ts +22 -0
  74. package/dist/lit/components/expansion-panel/index.js +174 -0
  75. package/dist/lit/components/field/index.css +223 -0
  76. package/dist/lit/components/field/index.d.ts +106 -0
  77. package/dist/lit/components/field/index.js +388 -0
  78. package/dist/lit/components/file-input/index.css +257 -0
  79. package/dist/lit/components/file-input/index.d.ts +30 -0
  80. package/dist/lit/components/file-input/index.js +298 -0
  81. package/dist/lit/components/form/index.css +29 -0
  82. package/dist/lit/components/form/index.d.ts +38 -0
  83. package/dist/lit/components/form/index.js +192 -0
  84. package/dist/lit/components/grid/index.css +53 -0
  85. package/dist/lit/components/grid/index.d.ts +14 -0
  86. package/dist/lit/components/grid/index.js +82 -0
  87. package/dist/lit/components/kbd/index.css +35 -0
  88. package/dist/lit/components/kbd/index.d.ts +11 -0
  89. package/dist/lit/components/kbd/index.js +43 -0
  90. package/dist/lit/components/list/index.css +15 -0
  91. package/dist/lit/components/list/index.d.ts +28 -0
  92. package/dist/lit/components/list/index.js +188 -0
  93. package/dist/lit/components/list-item/index.css +119 -0
  94. package/dist/lit/components/list-item/index.d.ts +20 -0
  95. package/dist/lit/components/list-item/index.js +127 -0
  96. package/dist/lit/components/menu/index.css +94 -0
  97. package/dist/lit/components/menu/index.d.ts +47 -0
  98. package/dist/lit/components/menu/index.js +386 -0
  99. package/dist/lit/components/navigation-drawer/index.css +114 -0
  100. package/dist/lit/components/navigation-drawer/index.d.ts +29 -0
  101. package/dist/lit/components/navigation-drawer/index.js +218 -0
  102. package/dist/lit/components/overlay/index.css +171 -0
  103. package/dist/lit/components/overlay/index.d.ts +65 -0
  104. package/dist/lit/components/overlay/index.js +566 -0
  105. package/dist/lit/components/pagination/index.css +102 -0
  106. package/dist/lit/components/pagination/index.d.ts +22 -0
  107. package/dist/lit/components/pagination/index.js +184 -0
  108. package/dist/lit/components/primitives/index.css +504 -0
  109. package/dist/lit/components/primitives/index.d.ts +25 -0
  110. package/dist/lit/components/primitives/index.js +283 -0
  111. package/dist/lit/components/progress/index.css +143 -0
  112. package/dist/lit/components/progress/index.d.ts +23 -0
  113. package/dist/lit/components/progress/index.js +180 -0
  114. package/dist/lit/components/radio-group/index.css +178 -0
  115. package/dist/lit/components/radio-group/index.d.ts +35 -0
  116. package/dist/lit/components/radio-group/index.js +292 -0
  117. package/dist/lit/components/select/index.css +151 -0
  118. package/dist/lit/components/select/index.d.ts +50 -0
  119. package/dist/lit/components/select/index.js +390 -0
  120. package/dist/lit/components/sidebar-item/index.css +133 -0
  121. package/dist/lit/components/sidebar-item/index.d.ts +20 -0
  122. package/dist/lit/components/sidebar-item/index.js +105 -0
  123. package/dist/lit/components/skeleton/index.css +81 -0
  124. package/dist/lit/components/skeleton/index.d.ts +19 -0
  125. package/dist/lit/components/skeleton/index.js +119 -0
  126. package/dist/lit/components/slider/index.css +171 -0
  127. package/dist/lit/components/slider/index.d.ts +36 -0
  128. package/dist/lit/components/slider/index.js +302 -0
  129. package/dist/lit/components/snackbar/index.css +279 -0
  130. package/dist/lit/components/snackbar/index.d.ts +33 -0
  131. package/dist/lit/components/snackbar/index.js +195 -0
  132. package/dist/lit/components/stack/index.css +41 -0
  133. package/dist/lit/components/stack/index.d.ts +20 -0
  134. package/dist/lit/components/stack/index.js +103 -0
  135. package/dist/lit/components/switch/index.css +126 -0
  136. package/dist/lit/components/switch/index.d.ts +17 -0
  137. package/dist/lit/components/switch/index.js +116 -0
  138. package/dist/lit/components/table/index.css +85 -0
  139. package/dist/lit/components/table/index.d.ts +25 -0
  140. package/dist/lit/components/table/index.js +139 -0
  141. package/dist/lit/components/tabs/index.css +116 -0
  142. package/dist/lit/components/tabs/index.d.ts +49 -0
  143. package/dist/lit/components/tabs/index.js +320 -0
  144. package/dist/lit/components/text-field/index.css +90 -0
  145. package/dist/lit/components/text-field/index.d.ts +17 -0
  146. package/dist/lit/components/text-field/index.js +101 -0
  147. package/dist/lit/components/textarea/index.css +55 -0
  148. package/dist/lit/components/textarea/index.d.ts +26 -0
  149. package/dist/lit/components/textarea/index.js +124 -0
  150. package/dist/lit/components/tooltip/index.css +37 -0
  151. package/dist/lit/components/tooltip/index.d.ts +31 -0
  152. package/dist/lit/components/tooltip/index.js +196 -0
  153. package/dist/lit/components/validation/index.css +386 -0
  154. package/dist/lit/components/validation/index.d.ts +45 -0
  155. package/dist/lit/components/validation/index.js +318 -0
  156. package/dist/lit/index.d.ts +50 -0
  157. package/dist/lit/index.js +59 -0
  158. package/package.json +81 -0
  159. package/styles/README.md +346 -0
  160. package/styles/_elevation.css +24 -0
  161. package/styles/_fonts.css +6 -0
  162. package/styles/_layout.css +37 -0
  163. package/styles/_primitives.css +154 -0
  164. package/styles/_scroll.css +75 -0
  165. package/styles/_semantic.css +146 -0
  166. package/styles/_space.css +61 -0
  167. package/styles/_type.css +139 -0
  168. package/styles/_xmesh-extensions.css +232 -0
  169. package/styles/index.css +44 -0
  170. package/styles/md3/_color.css +102 -0
  171. package/styles/md3/_elevation.css +26 -0
  172. package/styles/md3/_motion.css +35 -0
  173. package/styles/md3/_shape.css +22 -0
  174. package/styles/md3/_state.css +22 -0
  175. 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 {};