@spfx-fast/sharepoint-ui 0.0.0-stage → 0.4.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 (156) hide show
  1. package/AGENTS.md +211 -0
  2. package/LICENSE +21 -0
  3. package/README.md +897 -2
  4. package/components.json +5811 -0
  5. package/dist/appearance.d.ts +6 -0
  6. package/dist/appearance.js +217 -0
  7. package/dist/elements/accordion.d.ts +9 -0
  8. package/dist/elements/accordion.js +338 -0
  9. package/dist/elements/alert.d.ts +2 -0
  10. package/dist/elements/alert.js +62 -0
  11. package/dist/elements/avatar-group-primitives.d.ts +3 -0
  12. package/dist/elements/avatar-group-primitives.js +189 -0
  13. package/dist/elements/avatar-group.d.ts +2 -0
  14. package/dist/elements/avatar-group.js +110 -0
  15. package/dist/elements/avatar.d.ts +4 -0
  16. package/dist/elements/avatar.js +251 -0
  17. package/dist/elements/badge.d.ts +8 -0
  18. package/dist/elements/badge.js +182 -0
  19. package/dist/elements/breadcrumb.d.ts +4 -0
  20. package/dist/elements/breadcrumb.js +233 -0
  21. package/dist/elements/button-group.d.ts +1 -0
  22. package/dist/elements/button-group.js +20 -0
  23. package/dist/elements/button-variants.d.ts +6 -0
  24. package/dist/elements/button-variants.js +339 -0
  25. package/dist/elements/button.d.ts +5 -0
  26. package/dist/elements/button.js +233 -0
  27. package/dist/elements/card-primitives.d.ts +3 -0
  28. package/dist/elements/card-primitives.js +83 -0
  29. package/dist/elements/card.d.ts +5 -0
  30. package/dist/elements/card.js +213 -0
  31. package/dist/elements/carousel.d.ts +92 -0
  32. package/dist/elements/carousel.js +1112 -0
  33. package/dist/elements/color-picker.d.ts +24 -0
  34. package/dist/elements/color-picker.js +1004 -0
  35. package/dist/elements/combobox.d.ts +1 -0
  36. package/dist/elements/combobox.js +1012 -0
  37. package/dist/elements/data-grid-model.d.ts +117 -0
  38. package/dist/elements/data-grid-model.js +294 -0
  39. package/dist/elements/data-grid.d.ts +29 -0
  40. package/dist/elements/data-grid.js +1567 -0
  41. package/dist/elements/date-picker.d.ts +1 -0
  42. package/dist/elements/date-picker.js +486 -0
  43. package/dist/elements/dialog.d.ts +23 -0
  44. package/dist/elements/dialog.js +409 -0
  45. package/dist/elements/divider.d.ts +2 -0
  46. package/dist/elements/divider.js +66 -0
  47. package/dist/elements/drawer.d.ts +3 -0
  48. package/dist/elements/drawer.js +308 -0
  49. package/dist/elements/field-composition.d.ts +26 -0
  50. package/dist/elements/field-composition.js +133 -0
  51. package/dist/elements/field.d.ts +1 -0
  52. package/dist/elements/field.js +67 -0
  53. package/dist/elements/file-upload.d.ts +6 -0
  54. package/dist/elements/file-upload.js +211 -0
  55. package/dist/elements/floating-position.d.ts +26 -0
  56. package/dist/elements/floating-position.js +39 -0
  57. package/dist/elements/image.d.ts +28 -0
  58. package/dist/elements/image.js +38 -0
  59. package/dist/elements/info.d.ts +9 -0
  60. package/dist/elements/info.js +102 -0
  61. package/dist/elements/input.d.ts +5 -0
  62. package/dist/elements/input.js +255 -0
  63. package/dist/elements/label.d.ts +15 -0
  64. package/dist/elements/label.js +56 -0
  65. package/dist/elements/link.d.ts +1 -0
  66. package/dist/elements/link.js +92 -0
  67. package/dist/elements/list.d.ts +19 -0
  68. package/dist/elements/list.js +297 -0
  69. package/dist/elements/menu.d.ts +33 -0
  70. package/dist/elements/menu.js +1103 -0
  71. package/dist/elements/message-bar.d.ts +10 -0
  72. package/dist/elements/message-bar.js +299 -0
  73. package/dist/elements/nav.d.ts +32 -0
  74. package/dist/elements/nav.js +791 -0
  75. package/dist/elements/overflow.d.ts +9 -0
  76. package/dist/elements/overflow.js +218 -0
  77. package/dist/elements/persona.d.ts +3 -0
  78. package/dist/elements/persona.js +125 -0
  79. package/dist/elements/popover.d.ts +77 -0
  80. package/dist/elements/popover.js +840 -0
  81. package/dist/elements/progress-bar.d.ts +4 -0
  82. package/dist/elements/progress-bar.js +82 -0
  83. package/dist/elements/radio-group.d.ts +1 -0
  84. package/dist/elements/radio-group.js +303 -0
  85. package/dist/elements/rating-display.d.ts +2 -0
  86. package/dist/elements/rating-display.js +125 -0
  87. package/dist/elements/rating.d.ts +2 -0
  88. package/dist/elements/rating.js +206 -0
  89. package/dist/elements/rich-text-editor.d.ts +18 -0
  90. package/dist/elements/rich-text-editor.js +508 -0
  91. package/dist/elements/search.d.ts +3 -0
  92. package/dist/elements/search.js +207 -0
  93. package/dist/elements/select.d.ts +6 -0
  94. package/dist/elements/select.js +291 -0
  95. package/dist/elements/shared.d.ts +19 -0
  96. package/dist/elements/shared.js +396 -0
  97. package/dist/elements/skeleton.d.ts +6 -0
  98. package/dist/elements/skeleton.js +91 -0
  99. package/dist/elements/slider.d.ts +1 -0
  100. package/dist/elements/slider.js +248 -0
  101. package/dist/elements/spin-button.d.ts +7 -0
  102. package/dist/elements/spin-button.js +499 -0
  103. package/dist/elements/spinner.d.ts +4 -0
  104. package/dist/elements/spinner.js +124 -0
  105. package/dist/elements/swatch-picker.d.ts +5 -0
  106. package/dist/elements/swatch-picker.js +363 -0
  107. package/dist/elements/table.d.ts +21 -0
  108. package/dist/elements/table.js +261 -0
  109. package/dist/elements/tabs.d.ts +1 -0
  110. package/dist/elements/tabs.js +276 -0
  111. package/dist/elements/tag-picker.d.ts +29 -0
  112. package/dist/elements/tag-picker.js +697 -0
  113. package/dist/elements/tags.d.ts +21 -0
  114. package/dist/elements/tags.js +421 -0
  115. package/dist/elements/teaching-popover.d.ts +32 -0
  116. package/dist/elements/teaching-popover.js +412 -0
  117. package/dist/elements/text.d.ts +9 -0
  118. package/dist/elements/text.js +75 -0
  119. package/dist/elements/textarea.d.ts +1 -0
  120. package/dist/elements/textarea.js +182 -0
  121. package/dist/elements/toast.d.ts +25 -0
  122. package/dist/elements/toast.js +337 -0
  123. package/dist/elements/toaster.d.ts +6 -0
  124. package/dist/elements/toaster.js +356 -0
  125. package/dist/elements/toggle.d.ts +2 -0
  126. package/dist/elements/toggle.js +246 -0
  127. package/dist/elements/toolbar.d.ts +12 -0
  128. package/dist/elements/toolbar.js +381 -0
  129. package/dist/elements/tooltip.d.ts +76 -0
  130. package/dist/elements/tooltip.js +510 -0
  131. package/dist/elements/tree-model.d.ts +19 -0
  132. package/dist/elements/tree-model.js +34 -0
  133. package/dist/elements/tree.d.ts +5 -0
  134. package/dist/elements/tree.js +273 -0
  135. package/dist/elements/view-card.d.ts +2 -0
  136. package/dist/elements/view-card.js +78 -0
  137. package/dist/elements/virtual-list.d.ts +41 -0
  138. package/dist/elements/virtual-list.js +369 -0
  139. package/dist/elements.d.ts +72 -0
  140. package/dist/elements.js +207 -0
  141. package/dist/index.d.ts +76 -0
  142. package/dist/index.js +66 -0
  143. package/dist/react.d.ts +4546 -0
  144. package/dist/react.js +6837 -0
  145. package/dist/theme.d.ts +169 -0
  146. package/dist/theme.js +167 -0
  147. package/dist/virtualizer-hooks.d.ts +80 -0
  148. package/dist/virtualizer-hooks.js +253 -0
  149. package/package.json +87 -4
  150. package/scripts/generate-token-index.mjs +43 -0
  151. package/scripts/sync-component-catalog-signatures.mjs +55 -0
  152. package/scripts/validate-component-catalog.mjs +44 -0
  153. package/skills/sharepoint-ui/SKILL.md +12 -0
  154. package/skills/sharepoint-ui/references/choosing-components.md +20 -0
  155. package/skills/sharepoint-ui/references/sharepoint-patterns.md +17 -0
  156. package/tokens.json +3777 -0
@@ -0,0 +1,211 @@
1
+ import { base, syncHostAccessibleName } from "./shared.js";
2
+ /** File selection surface. Uploading and SharePoint persistence remain the consumer's responsibility. */
3
+ export function defineFileUpload() {
4
+ if (customElements.get("spui-file-upload"))
5
+ return;
6
+ class SpuiFileUpload extends HTMLElement {
7
+ static formAssociated = true;
8
+ static observedAttributes = ["accept", "multiple", "disabled", "required", "name", "max-files", "max-file-size", "label", "aria-label", "aria-labelledby", "aria-description", "aria-describedby", "aria-invalid", "browse-label", "remove-label", "drop-label", "selected-label", "invalid-message", "required-message"];
9
+ internals;
10
+ input;
11
+ list;
12
+ status;
13
+ selected = [];
14
+ formDisabled = false;
15
+ get form() { return this.internals?.form ?? null; }
16
+ get files() { return this.selected; }
17
+ constructor() { super(); try {
18
+ this.internals = this.attachInternals();
19
+ }
20
+ catch { /* Native picker continues to work where form-associated elements are unavailable. */ } }
21
+ connectedCallback() { if (!this.shadowRoot)
22
+ this.render(); this.sync(); }
23
+ attributeChangedCallback() { if (this.isConnected && this.input)
24
+ this.sync(); }
25
+ formDisabledCallback(disabled) { this.formDisabled = disabled; if (this.input)
26
+ this.sync(); }
27
+ formResetCallback() { if (this.status)
28
+ this.status.textContent = ""; this.setFiles([], false); }
29
+ formStateRestoreCallback(state) { if (state instanceof File)
30
+ this.setFiles([state], false);
31
+ else if (state instanceof FormData)
32
+ this.setFiles(state.getAll(this.getAttribute("name") || "").filter((value) => value instanceof File), false); }
33
+ render() {
34
+ const shadow = this.attachShadow({ mode: "open" });
35
+ const style = document.createElement("style");
36
+ style.textContent = `${base}:host{display:block;min-width:0;color:var(--spui-ui-text,var(--spui-semantic-body-text,#242424));font-family:var(--spui-font-family,inherit)}.label{display:block;margin-block-end:6px;font-size:14px;font-weight:600}.drop{box-sizing:border-box;display:grid;place-items:center;gap:6px;min-height:104px;padding:16px;border:1px dashed var(--spui-ui-stroke,var(--spui-semantic-input-border,#8a8886));border-radius:var(--spui-control-radius,6px);background:var(--spui-ui-surface,var(--spui-semantic-input-background,#fff));text-align:center;transition:border-color var(--spui-motion-duration-fast,120ms) var(--spui-motion-easing-standard,cubic-bezier(.2,0,0,1)),background-color var(--spui-motion-duration-fast,120ms) var(--spui-motion-easing-standard,cubic-bezier(.2,0,0,1))}.drop[data-dragging=true]{border-color:var(--spui-focus-ring,var(--spui-ui-accent,#0078d4));background:var(--spui-ui-surface-hover,var(--spui-ui-canvas,#f5f5f5))}.hint{color:var(--spui-ui-muted,var(--spui-semantic-body-subtext,#616161));font-size:12px}.browse{min-height:32px;padding:4px 12px;border:1px solid var(--spui-control-border,var(--spui-ui-stroke,#8a8886));border-radius:var(--spui-control-radius,4px);background:var(--spui-ui-surface,#fff);color:inherit;font:inherit;cursor:pointer}.browse:hover:not(:disabled){background:var(--spui-ui-surface-hover,var(--spui-ui-canvas,#f5f5f5))}.browse:focus-visible,.remove:focus-visible{outline:2px solid var(--spui-focus-ring,var(--spui-ui-accent,#0078d4));outline-offset:2px}.browse:disabled,.remove:disabled{opacity:.55;cursor:not-allowed}.input{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}.files{display:grid;gap:4px;margin:8px 0 0;padding:0;list-style:none}.files:empty{display:none}.file{display:flex;align-items:center;gap:8px;min-width:0;padding:6px 8px;border:1px solid var(--spui-ui-stroke,#d1d1d1);border-radius:var(--spui-control-radius,4px)}.filename{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.size{flex:none;color:var(--spui-ui-muted,#616161);font-size:12px;font-variant-numeric:tabular-nums}.remove{min-width:32px;min-height:32px;border:0;border-radius:var(--spui-control-radius,4px);background:transparent;color:inherit;font:inherit;cursor:pointer}.status{margin-block-start:6px;color:var(--spui-semantic-error-text,var(--spui-ui-danger,#b10e1c));font-size:12px}.status:empty{display:none}:host([data-spui-appearance=glass]) .drop{backdrop-filter:var(--spui-card-backdrop,blur(12px) saturate(1.2))}@media(prefers-reduced-motion:reduce){.drop{transition:none}}@media(forced-colors:active){.drop,.file{border-color:CanvasText;background:Canvas;color:CanvasText}.drop[data-dragging=true]{outline:2px solid Highlight}.browse{border-color:ButtonText;background:ButtonFace;color:ButtonText}}`;
37
+ const label = document.createElement("label");
38
+ label.className = "label";
39
+ label.part.add("label");
40
+ const drop = document.createElement("div");
41
+ drop.className = "drop";
42
+ drop.part.add("dropzone");
43
+ drop.tabIndex = -1;
44
+ const hint = document.createElement("span");
45
+ hint.className = "hint";
46
+ const browse = document.createElement("button");
47
+ browse.type = "button";
48
+ browse.className = "browse";
49
+ browse.part.add("browse-button");
50
+ this.input = document.createElement("input");
51
+ this.input.type = "file";
52
+ this.input.className = "input";
53
+ this.input.part.add("input");
54
+ browse.addEventListener("click", () => this.input.click());
55
+ this.input.addEventListener("change", () => { this.addFiles(Array.from(this.input.files ?? [])); this.input.value = ""; });
56
+ drop.addEventListener("dragover", event => { event.preventDefault(); if (!this.isDisabled())
57
+ drop.dataset.dragging = "true"; });
58
+ drop.addEventListener("dragleave", event => { if (!drop.contains(event.relatedTarget))
59
+ delete drop.dataset.dragging; });
60
+ drop.addEventListener("drop", event => { event.preventDefault(); delete drop.dataset.dragging; if (!this.isDisabled())
61
+ this.addFiles(Array.from(event.dataTransfer?.files ?? [])); });
62
+ drop.addEventListener("keydown", event => { if (event.key === "Enter" || event.key === " ") {
63
+ event.preventDefault();
64
+ this.input.click();
65
+ } });
66
+ this.input.addEventListener("focus", () => browse.focus());
67
+ this.list = document.createElement("ul");
68
+ this.list.className = "files";
69
+ this.list.part.add("file-list");
70
+ this.list.setAttribute("aria-label", "Selected files");
71
+ this.status = document.createElement("div");
72
+ this.status.className = "status";
73
+ this.status.part.add("status");
74
+ this.status.setAttribute("role", "status");
75
+ this.status.setAttribute("aria-live", "polite");
76
+ drop.append(hint, browse, this.input);
77
+ shadow.append(style, label, drop, this.list, this.status);
78
+ }
79
+ sync() {
80
+ const disabled = this.isDisabled();
81
+ this.input.accept = this.getAttribute("accept") || "";
82
+ this.input.multiple = this.hasAttribute("multiple");
83
+ this.input.disabled = disabled;
84
+ this.input.required = this.hasAttribute("required") && this.selected.length === 0;
85
+ const label = this.getAttribute("aria-label") || this.getAttribute("label") || "Add files";
86
+ const browse = this.getAttribute("browse-label") || "Browse files";
87
+ if (!this.hasAttribute("role"))
88
+ this.setAttribute("role", "group");
89
+ const labelElement = this.shadowRoot?.querySelector(".label");
90
+ if (labelElement) {
91
+ labelElement.textContent = this.getAttribute("label") || "";
92
+ labelElement.hidden = !this.hasAttribute("label");
93
+ }
94
+ this.input.setAttribute("aria-label", label);
95
+ syncHostAccessibleName(this, this.input);
96
+ const invalid = this.getAttribute("aria-invalid");
97
+ if (invalid === null)
98
+ this.input.removeAttribute("aria-invalid");
99
+ else
100
+ this.input.setAttribute("aria-invalid", invalid);
101
+ const hint = this.shadowRoot?.querySelector(".hint");
102
+ if (hint)
103
+ hint.textContent = this.getAttribute("drop-label") || "Drag files here or browse to select";
104
+ const button = this.shadowRoot?.querySelector(".browse");
105
+ if (button) {
106
+ button.textContent = browse;
107
+ button.disabled = disabled;
108
+ button.setAttribute("aria-label", browse);
109
+ }
110
+ this.list?.setAttribute("aria-label", `${label}: ${this.getAttribute("selected-label") || "selected files"}`);
111
+ this.renderFiles();
112
+ this.syncForm();
113
+ }
114
+ addFiles(files) {
115
+ if (!files.length || this.isDisabled())
116
+ return;
117
+ const max = Number(this.getAttribute("max-files"));
118
+ const limit = Number(this.getAttribute("max-file-size"));
119
+ const next = this.hasAttribute("multiple") ? [...this.selected] : [];
120
+ const errors = [];
121
+ for (const file of files) {
122
+ if (!this.accepts(file)) {
123
+ errors.push({ code: "file-type-not-accepted", file });
124
+ continue;
125
+ }
126
+ if (Number.isFinite(limit) && limit > 0 && file.size > limit) {
127
+ errors.push({ code: "file-too-large", file });
128
+ continue;
129
+ }
130
+ if (Number.isFinite(max) && max > 0 && next.length >= max) {
131
+ errors.push({ code: "too-many-files", file });
132
+ continue;
133
+ }
134
+ if (!next.some(existing => existing.name === file.name && existing.size === file.size && existing.lastModified === file.lastModified))
135
+ next.push(file);
136
+ }
137
+ this.status.textContent = errors.length ? (this.getAttribute("invalid-message") || this.getAttribute(errors[0].code === "file-too-large" ? "file-too-large-message" : errors[0].code === "too-many-files" ? "max-files-message" : "file-type-message") || this.describeError(errors[0])) : "";
138
+ this.setFiles(next, true);
139
+ if (errors.length)
140
+ this.dispatchEvent(new CustomEvent("spui-file-error", { detail: { errors }, bubbles: true, composed: true }));
141
+ }
142
+ describeError(error) {
143
+ if (error.code === "file-too-large")
144
+ return `${error.file?.name || "File"} exceeds the allowed size.`;
145
+ if (error.code === "too-many-files")
146
+ return "The maximum number of files has been reached.";
147
+ return "The selected file type is not accepted.";
148
+ }
149
+ accepts(file) {
150
+ const accepted = (this.getAttribute("accept") || "").split(",").map(value => value.trim().toLowerCase()).filter(Boolean);
151
+ if (!accepted.length)
152
+ return true;
153
+ const name = file.name.toLowerCase();
154
+ const type = file.type.toLowerCase();
155
+ return accepted.some(value => value.startsWith(".") ? name.endsWith(value) : value.endsWith("/*") ? type.startsWith(value.slice(0, -1)) : type === value);
156
+ }
157
+ setFiles(files, emit) {
158
+ this.selected = files;
159
+ this.renderFiles();
160
+ this.syncForm();
161
+ if (emit)
162
+ this.dispatchEvent(new CustomEvent("spui-file-change", { detail: { files: [...files] }, bubbles: true, composed: true }));
163
+ }
164
+ renderFiles() {
165
+ if (!this.list)
166
+ return;
167
+ this.list.replaceChildren(...this.selected.map((file, index) => {
168
+ const item = document.createElement("li");
169
+ item.className = "file";
170
+ item.part.add("file");
171
+ const name = document.createElement("span");
172
+ name.className = "filename";
173
+ name.textContent = file.name;
174
+ const size = document.createElement("span");
175
+ size.className = "size";
176
+ size.textContent = this.formatSize(file.size);
177
+ const remove = document.createElement("button");
178
+ remove.type = "button";
179
+ remove.className = "remove";
180
+ remove.textContent = "×";
181
+ remove.disabled = this.isDisabled();
182
+ remove.setAttribute("aria-label", `${this.getAttribute("remove-label") || "Remove"} ${file.name}`);
183
+ remove.addEventListener("click", () => {
184
+ this.status.textContent = "";
185
+ const next = this.selected.filter((_, i) => i !== index);
186
+ this.setFiles(next, true);
187
+ const nextRemove = this.list.querySelectorAll(".remove")[Math.min(index, next.length - 1)];
188
+ (nextRemove ?? this.shadowRoot?.querySelector(".browse"))?.focus();
189
+ });
190
+ item.append(name, size, remove);
191
+ return item;
192
+ }));
193
+ }
194
+ formatSize(size) { return size < 1024 ? `${size} B` : size < 1024 * 1024 ? `${(size / 1024).toFixed(1)} KB` : `${(size / (1024 * 1024)).toFixed(1)} MB`; }
195
+ syncForm() {
196
+ if (!this.internals)
197
+ return;
198
+ const name = this.getAttribute("name") || "";
199
+ const data = new FormData();
200
+ if (name)
201
+ this.selected.forEach(file => data.append(name, file, file.name));
202
+ this.internals.setFormValue(this.selected.length && name ? data : null, JSON.stringify(this.selected.map(file => ({ name: file.name, size: file.size, lastModified: file.lastModified }))));
203
+ if (this.hasAttribute("required") && !this.selected.length)
204
+ this.internals.setValidity({ valueMissing: true }, this.getAttribute("required-message") || "Select at least one file.", this.shadowRoot?.querySelector(".browse") ?? undefined);
205
+ else
206
+ this.internals.setValidity({});
207
+ }
208
+ isDisabled() { return this.formDisabled || this.hasAttribute("disabled"); }
209
+ }
210
+ customElements.define("spui-file-upload", SpuiFileUpload);
211
+ }
@@ -0,0 +1,26 @@
1
+ export type FloatingPlacement = "top" | "bottom" | "start" | "end" | "left" | "right";
2
+ export type FloatingPosition = {
3
+ top: number;
4
+ left: number;
5
+ placement: Exclude<FloatingPlacement, "start" | "end">;
6
+ align: FloatingAlign;
7
+ };
8
+ export type FloatingAlign = "start" | "center" | "end" | "top" | "bottom";
9
+ export type FloatingModifiers = {
10
+ crossAxisOffset?: number;
11
+ coverTarget?: boolean;
12
+ overflowBoundaryPadding?: {
13
+ top?: number;
14
+ right?: number;
15
+ bottom?: number;
16
+ left?: number;
17
+ };
18
+ };
19
+ export type FloatingAnchorRect = Pick<DOMRectReadOnly, "left" | "top" | "right" | "bottom" | "width" | "height">;
20
+ export declare function getFloatingPosition(anchor: FloatingAnchorRect, surface: Pick<DOMRectReadOnly, "width" | "height">, placement: FloatingPlacement, direction: "ltr" | "rtl", gap?: number, viewport?: {
21
+ width: number;
22
+ height: number;
23
+ }, align?: FloatingAlign, fallbackPlacements?: Array<{
24
+ placement: FloatingPlacement;
25
+ align?: FloatingAlign;
26
+ }>, pinned?: boolean, flipBoundary?: Pick<DOMRectReadOnly, "left" | "top" | "right" | "bottom">, overflowBoundary?: Pick<DOMRectReadOnly, "left" | "top" | "right" | "bottom">, modifiers?: FloatingModifiers): FloatingPosition;
@@ -0,0 +1,39 @@
1
+ export function getFloatingPosition(anchor, surface, placement, direction, gap = 8, viewport = { width: innerWidth, height: innerHeight }, align = "center", fallbackPlacements = [], pinned = false, flipBoundary, overflowBoundary, modifiers = {}) {
2
+ const viewportBoundary = { left: 0, top: 0, right: viewport.width, bottom: viewport.height };
3
+ const flipBounds = flipBoundary ?? viewportBoundary;
4
+ const overflowBounds = overflowBoundary ?? viewportBoundary;
5
+ const overflowPadding = modifiers.overflowBoundaryPadding ?? {};
6
+ const side = placement === "start" ? direction === "rtl" ? "right" : "left" : placement === "end" ? direction === "rtl" ? "left" : "right" : placement;
7
+ const opposite = { top: "bottom", bottom: "top", left: "right", right: "left" };
8
+ const coordinates = (candidate, candidateAlign = align) => {
9
+ const candidateInlineAlign = candidateAlign === "start" || candidateAlign === "top" ? "start" : candidateAlign === "end" || candidateAlign === "bottom" ? "end" : "center";
10
+ const candidateBlockAlign = candidateAlign === "start" || candidateAlign === "top" ? "start" : candidateAlign === "end" || candidateAlign === "bottom" ? "end" : "center";
11
+ const inlineStartLeft = direction === "rtl" ? anchor.right - surface.width : anchor.left;
12
+ const inlineEndLeft = direction === "rtl" ? anchor.left : anchor.right - surface.width;
13
+ return candidate === "top" || candidate === "bottom"
14
+ ? { top: candidate === "top" ? (modifiers.coverTarget ? anchor.bottom : anchor.top) - surface.height - gap : (modifiers.coverTarget ? anchor.top : anchor.bottom) + gap, left: (candidateInlineAlign === "start" ? inlineStartLeft : candidateInlineAlign === "end" ? inlineEndLeft : anchor.left + (anchor.width - surface.width) / 2) + (modifiers.crossAxisOffset ?? 0) }
15
+ : { top: (candidateBlockAlign === "start" ? anchor.top : candidateBlockAlign === "end" ? anchor.bottom - surface.height : anchor.top + (anchor.height - surface.height) / 2) + (modifiers.crossAxisOffset ?? 0), left: candidate === "left" ? (modifiers.coverTarget ? anchor.right : anchor.left) - surface.width - gap : (modifiers.coverTarget ? anchor.left : anchor.right) + gap };
16
+ };
17
+ const fits = (_candidate, point) => point.top >= flipBounds.top + gap && point.left >= flipBounds.left + gap && point.top + surface.height <= flipBounds.bottom - gap && point.left + surface.width <= flipBounds.right - gap;
18
+ const candidates = [
19
+ { placement: side, align },
20
+ ...(pinned ? [] : fallbackPlacements),
21
+ ...(!pinned ? [{ placement: opposite[side], align }] : []),
22
+ ].map(candidate => ({ placement: candidate.placement === "start" ? direction === "rtl" ? "right" : "left" : candidate.placement === "end" ? direction === "rtl" ? "left" : "right" : candidate.placement, align: candidate.align ?? align }));
23
+ const unique = candidates.filter((candidate, index) => candidates.findIndex(other => other.placement === candidate.placement && other.align === candidate.align) === index);
24
+ let selected = unique.map(candidate => ({ ...candidate, point: coordinates(candidate.placement, candidate.align) })).find(candidate => fits(candidate.placement, candidate.point));
25
+ if (!selected) {
26
+ selected = unique.map(candidate => ({ ...candidate, point: coordinates(candidate.placement, candidate.align) })).sort((a, b) => {
27
+ const overflow = (candidate) => Math.max(0, flipBounds.left + gap - candidate.point.left) + Math.max(0, candidate.point.left + surface.width - (flipBounds.right - gap)) + Math.max(0, flipBounds.top + gap - candidate.point.top) + Math.max(0, candidate.point.top + surface.height - (flipBounds.bottom - gap));
28
+ return overflow(a) - overflow(b);
29
+ })[0];
30
+ }
31
+ const actual = selected.placement;
32
+ const point = selected.point;
33
+ return {
34
+ placement: actual,
35
+ align: selected.align,
36
+ left: Math.max(overflowBounds.left + gap + (overflowPadding.left ?? 0), Math.min(point.left, overflowBounds.right - surface.width - gap - (overflowPadding.right ?? 0))),
37
+ top: Math.max(overflowBounds.top + gap + (overflowPadding.top ?? 0), Math.min(point.top, overflowBounds.bottom - surface.height - gap - (overflowPadding.bottom ?? 0))),
38
+ };
39
+ }
@@ -0,0 +1,28 @@
1
+ export type ImageFit = "none" | "center" | "contain" | "cover" | "default";
2
+ export type ImageShape = "square" | "rounded" | "circular";
3
+ export type ImageOptions = {
4
+ src: string;
5
+ alt: string;
6
+ srcSet?: string;
7
+ sizes?: string;
8
+ width?: number;
9
+ height?: number;
10
+ title?: string;
11
+ className?: string;
12
+ loading?: "eager" | "lazy";
13
+ decoding?: "async" | "sync" | "auto";
14
+ fetchPriority?: "high" | "low" | "auto";
15
+ fit?: ImageFit;
16
+ shape?: ImageShape;
17
+ block?: boolean;
18
+ bordered?: boolean;
19
+ shadow?: boolean;
20
+ };
21
+ type ImageStyleOptions = Pick<ImageOptions, "fit" | "shape" | "block" | "bordered" | "shadow"> & {
22
+ width?: number | string;
23
+ height?: number | string;
24
+ };
25
+ export declare function getImageStyle({ fit, shape, block, bordered, shadow, width, height }: ImageStyleOptions): Record<string, string>;
26
+ /** Create a native image with browser-native responsive loading and meaningful alt text. */
27
+ export declare function createImage(options: ImageOptions): HTMLImageElement;
28
+ export {};
@@ -0,0 +1,38 @@
1
+ export function getImageStyle({ fit = "default", shape = "square", block, bordered, shadow, width, height }) {
2
+ const fitFill = fit !== "default" && width == null && height == null;
3
+ return {
4
+ display: block ? "block" : "inline-block",
5
+ maxWidth: "100%",
6
+ ...(block ? { width: "100%" } : {}),
7
+ ...(fitFill ? { width: "100%", height: "100%" } : {}),
8
+ ...(fit === "default" ? {} : { objectFit: fit === "center" ? "none" : fit }),
9
+ ...(fit === "default" ? {} : { objectPosition: fit === "none" ? "left top" : "center" }),
10
+ borderRadius: shape === "circular" ? "50%" : shape === "rounded" ? "var(--spui-image-radius,var(--spui-control-radius,4px))" : "0",
11
+ ...(bordered ? { border: "1px solid var(--spui-image-border,var(--spui-ui-stroke,var(--spui-semantic-body-divider,#d1d1d1)))" } : {}),
12
+ ...(shadow ? { boxShadow: "var(--spui-image-shadow,var(--spui-card-shadow,0 1px 3px #0002))" } : {}),
13
+ };
14
+ }
15
+ /** Create a native image with browser-native responsive loading and meaningful alt text. */
16
+ export function createImage(options) {
17
+ const image = document.createElement("img");
18
+ image.alt = options.alt;
19
+ if (options.srcSet)
20
+ image.srcset = options.srcSet;
21
+ if (options.sizes)
22
+ image.sizes = options.sizes;
23
+ if (options.width !== undefined)
24
+ image.width = options.width;
25
+ if (options.height !== undefined)
26
+ image.height = options.height;
27
+ if (options.title)
28
+ image.title = options.title;
29
+ if (options.className)
30
+ image.className = options.className;
31
+ image.loading = options.loading ?? "lazy";
32
+ image.decoding = options.decoding ?? "async";
33
+ image.fetchPriority = options.fetchPriority ?? "auto";
34
+ Object.assign(image.style, getImageStyle(options));
35
+ // Configure the candidate set and intrinsic layout before starting any image request.
36
+ image.src = options.src;
37
+ return image;
38
+ }
@@ -0,0 +1,9 @@
1
+ import { type LabelOptions } from "./label.js";
2
+ export type InfoPlacement = "top" | "bottom" | "start" | "end";
3
+ export type InfoLabelOptions = LabelOptions & {
4
+ infoButtonLabel?: string;
5
+ placement?: InfoPlacement;
6
+ };
7
+ export declare function defineInfoButton(): void;
8
+ /** Build a native form label with an adjacent, separately focusable information button. */
9
+ export declare function createInfoLabel(labelContent: string | Node, info: string | Node, options?: InfoLabelOptions): HTMLSpanElement;
@@ -0,0 +1,102 @@
1
+ import { definePopover } from "./popover.js";
2
+ import { createLabel } from "./label.js";
3
+ export function defineInfoButton() {
4
+ if (customElements.get("spui-info-button"))
5
+ return;
6
+ class SpuiInfoButton extends HTMLElement {
7
+ static observedAttributes = ["label", "info", "placement", "open", "disabled", "size", "inline", "popover-placement", "popover-label", "popover-arrow", "popover-trap-focus"];
8
+ popoverElement;
9
+ trigger;
10
+ contentSlot;
11
+ get open() { return this.popoverElement?.open ?? this.hasAttribute("open"); }
12
+ set open(value) { if (this.popoverElement)
13
+ this.popoverElement.open = Boolean(value);
14
+ else
15
+ this.toggleAttribute("open", Boolean(value)); }
16
+ get inline() { return this.getAttribute("inline") !== "false"; }
17
+ set inline(value) { if (value)
18
+ this.removeAttribute("inline");
19
+ else
20
+ this.setAttribute("inline", "false"); }
21
+ connectedCallback() {
22
+ definePopover();
23
+ if (!this.shadowRoot)
24
+ this.render();
25
+ this.sync();
26
+ }
27
+ attributeChangedCallback() { if (this.isConnected)
28
+ this.sync(); }
29
+ render() {
30
+ const shadow = this.attachShadow({ mode: "open" });
31
+ const style = document.createElement("style");
32
+ style.textContent = `:host{display:inline-flex;vertical-align:middle;line-height:1}:host([hidden]){display:none}button{display:inline-grid;place-items:center;box-sizing:border-box;width:var(--spui-info-button-size,20px);height:var(--spui-info-button-size,20px);padding:2px;border:1px solid transparent;border-radius:50%;background:transparent;color:var(--spui-info-button-color,var(--spui-ui-muted,var(--spui-semantic-body-text,#484644)));cursor:pointer;font:inherit}button:hover:not(:disabled){background:var(--spui-info-button-hover,var(--spui-ui-surface-hover,#f3f2f1));color:var(--spui-ui-accent,var(--spui-semantic-link,#0f6cbd))}button:focus-visible{outline:2px solid var(--spui-focus-ring,var(--spui-ui-accent,#005a9e));outline-offset:2px}button:disabled{opacity:.55;cursor:not-allowed}svg{width:calc(var(--spui-info-button-size,20px) * .8);height:calc(var(--spui-info-button-size,20px) * .8);fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;pointer-events:none}:host([size=small]){--spui-info-button-size:16px}:host([size=large]){--spui-info-button-size:24px}:host([data-spui-appearance=glass]) button{backdrop-filter:blur(8px)}@media(forced-colors:active){button{border-color:ButtonText;color:ButtonText}button:focus-visible{outline-color:Highlight}}`;
33
+ const popover = document.createElement("spui-popover");
34
+ this.popoverElement = popover;
35
+ const trigger = document.createElement("button");
36
+ this.trigger = trigger;
37
+ trigger.type = "button";
38
+ trigger.slot = "trigger";
39
+ trigger.part.add("control");
40
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
41
+ svg.setAttribute("viewBox", "0 0 20 20");
42
+ svg.setAttribute("aria-hidden", "true");
43
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
44
+ path.setAttribute("d", "M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm0-9v5m0-8h.01");
45
+ svg.append(path);
46
+ trigger.append(svg);
47
+ const content = document.createElement("div");
48
+ content.slot = "content";
49
+ content.part.add("content");
50
+ const slot = document.createElement("slot");
51
+ this.contentSlot = slot;
52
+ slot.addEventListener("slotchange", () => this.syncContent());
53
+ content.append(slot);
54
+ popover.append(trigger, content);
55
+ popover.addEventListener("spui-open-change", event => {
56
+ if (event.detail.open)
57
+ requestAnimationFrame(() => {
58
+ const surface = popover.shadowRoot?.querySelector(".surface");
59
+ surface?.setAttribute("tabindex", "-1");
60
+ surface?.focus();
61
+ });
62
+ });
63
+ shadow.append(style, popover);
64
+ }
65
+ sync() {
66
+ if (!this.popoverElement || !this.trigger)
67
+ return;
68
+ const label = this.getAttribute("label") || "More information";
69
+ this.trigger.setAttribute("aria-label", label);
70
+ this.trigger.disabled = this.hasAttribute("disabled");
71
+ this.popoverElement.setAttribute("placement", this.getAttribute("popover-placement") || this.getAttribute("placement") || "top");
72
+ this.popoverElement.setAttribute("label", this.getAttribute("popover-label") || label);
73
+ this.popoverElement.inline = this.inline;
74
+ this.popoverElement.toggleAttribute("arrow", this.hasAttribute("popover-arrow"));
75
+ this.popoverElement.toggleAttribute("trap-focus", this.hasAttribute("popover-trap-focus"));
76
+ if (this.popoverElement.open !== this.hasAttribute("open"))
77
+ this.popoverElement.open = this.hasAttribute("open");
78
+ this.syncContent();
79
+ }
80
+ syncContent() {
81
+ if (!this.contentSlot)
82
+ return;
83
+ this.contentSlot.textContent = !this.childNodes.length && this.hasAttribute("info") ? this.getAttribute("info") ?? "" : "";
84
+ }
85
+ }
86
+ customElements.define("spui-info-button", SpuiInfoButton);
87
+ }
88
+ /** Build a native form label with an adjacent, separately focusable information button. */
89
+ export function createInfoLabel(labelContent, info, options = {}) {
90
+ defineInfoButton();
91
+ const wrapper = document.createElement("span");
92
+ Object.assign(wrapper.style, { display: "inline-flex", alignItems: "baseline", gap: "0.25em" });
93
+ const label = createLabel(labelContent, options);
94
+ wrapper.append(label);
95
+ const button = document.createElement("spui-info-button");
96
+ button.setAttribute("label", options.infoButtonLabel ?? "More information");
97
+ if (options.placement)
98
+ button.setAttribute("placement", options.placement);
99
+ button.append(typeof info === "string" ? document.createTextNode(info) : info);
100
+ wrapper.append(button);
101
+ return wrapper;
102
+ }
@@ -0,0 +1,5 @@
1
+ export type InputSize = "small" | "medium" | "large";
2
+ export type InputAppearance = "outline" | "underline" | "filled-darker" | "filled-lighter" | "filled-darker-shadow" | "filled-lighter-shadow";
3
+ export type InputType = "button" | "checkbox" | "color" | "date" | "datetime-local" | "email" | "file" | "hidden" | "image" | "month" | "number" | "password" | "radio" | "range" | "reset" | "search" | "submit" | "tel" | "text" | "time" | "url" | "week" | (string & {});
4
+ /** Bare, form-associated input; pair it with Label or Field for an accessible name. */
5
+ export declare function defineInput(): void;