@kenlo/sds-design-system 0.9.9

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 (246) hide show
  1. package/README.md +129 -0
  2. package/components/Accordion/Accordion.css +186 -0
  3. package/components/Accordion/accordion-variants.js +17 -0
  4. package/components/Accordion/accordion.marko +264 -0
  5. package/components/AccordionGroup/AccordionGroup.css +35 -0
  6. package/components/AccordionGroup/accordion-group.marko +17 -0
  7. package/components/Alert/Alert.css +104 -0
  8. package/components/Alert/alert-variants.js +35 -0
  9. package/components/Alert/alert.marko +94 -0
  10. package/components/AttachmentList/AttachmentList.css +92 -0
  11. package/components/AttachmentList/attachment-list.marko +51 -0
  12. package/components/AttachmentList/attachment-row.marko +167 -0
  13. package/components/Avatar/Avatar.css +110 -0
  14. package/components/Avatar/avatar-variants.js +12 -0
  15. package/components/Avatar/avatar.marko +71 -0
  16. package/components/Badge/Badge.css +381 -0
  17. package/components/Badge/badge-dismissible.marko +143 -0
  18. package/components/Badge/badge-variants.js +35 -0
  19. package/components/Badge/badge.marko +134 -0
  20. package/components/BottomSheet/BottomSheet.css +300 -0
  21. package/components/BottomSheet/bottom-sheet.marko +340 -0
  22. package/components/BulkActionBar/BulkActionBar.css +88 -0
  23. package/components/BulkActionBar/bulk-action-bar.marko +186 -0
  24. package/components/Button/Button.css +340 -0
  25. package/components/Button/button-variants.js +25 -0
  26. package/components/Button/button.marko +139 -0
  27. package/components/Card/Card.css +391 -0
  28. package/components/Card/card-body.marko +5 -0
  29. package/components/Card/card-collapsible.marko +94 -0
  30. package/components/Card/card-footer.marko +12 -0
  31. package/components/Card/card-header.marko +30 -0
  32. package/components/Card/card-info-pair.marko +17 -0
  33. package/components/Card/card-info-row.marko +12 -0
  34. package/components/Card/card-section.marko +14 -0
  35. package/components/Card/card-variants.js +24 -0
  36. package/components/Card/card.marko +124 -0
  37. package/components/Checkbox/Checkbox.css +249 -0
  38. package/components/Checkbox/checkbox-variants.js +21 -0
  39. package/components/Checkbox/checkbox.marko +128 -0
  40. package/components/ColorPicker/ColorPicker.css +253 -0
  41. package/components/ColorPicker/color-maths.js +104 -0
  42. package/components/ColorPicker/color-picker.marko +474 -0
  43. package/components/DatePicker/DatePicker.css +429 -0
  44. package/components/DatePicker/date-logic.js +225 -0
  45. package/components/DatePicker/date-picker.marko +492 -0
  46. package/components/Drawer/Drawer.css +174 -0
  47. package/components/Drawer/drawer.marko +262 -0
  48. package/components/Floating/Floating.css +28 -0
  49. package/components/Floating/floating-position.js +251 -0
  50. package/components/Floating/floating.marko +291 -0
  51. package/components/Form/Form.css +15 -0
  52. package/components/Form/form-context.js +69 -0
  53. package/components/Form/form.marko +73 -0
  54. package/components/GradientPicker/GradientPicker.css +179 -0
  55. package/components/GradientPicker/gradient-picker.marko +398 -0
  56. package/components/Grid/Grid.css +161 -0
  57. package/components/Grid/col.marko +36 -0
  58. package/components/Grid/grid-variants.js +43 -0
  59. package/components/Grid/grid.marko +25 -0
  60. package/components/Header/Header.css +457 -0
  61. package/components/Header/header.marko +523 -0
  62. package/components/Icon/Icon.css +44 -0
  63. package/components/Icon/icon-colors.js +31 -0
  64. package/components/Icon/icon.marko +56 -0
  65. package/components/IconButton/IconButton.css +85 -0
  66. package/components/IconButton/icon-button-variants.js +18 -0
  67. package/components/IconButton/icon-button.marko +103 -0
  68. package/components/Incrementer/Incrementer.css +94 -0
  69. package/components/Incrementer/incrementer.marko +70 -0
  70. package/components/Input/Input.css +547 -0
  71. package/components/Input/input-countries.js +109 -0
  72. package/components/Input/input-masks.js +218 -0
  73. package/components/Input/input-types.js +194 -0
  74. package/components/Input/input.marko +530 -0
  75. package/components/Menu/Menu.css +651 -0
  76. package/components/Menu/menu-variants.js +89 -0
  77. package/components/Menu/menu.marko +560 -0
  78. package/components/Modal/Modal.css +160 -0
  79. package/components/Modal/modal.marko +296 -0
  80. package/components/Nav/Nav.css +298 -0
  81. package/components/Nav/nav-variants.js +23 -0
  82. package/components/Nav/nav.marko +164 -0
  83. package/components/Notification/Notification.css +78 -0
  84. package/components/Notification/notification-variants.js +30 -0
  85. package/components/Notification/notification.marko +55 -0
  86. package/components/Pagination/Pagination.css +78 -0
  87. package/components/Pagination/pagination-pages.js +66 -0
  88. package/components/Pagination/pagination.marko +141 -0
  89. package/components/Pill/Pill.css +193 -0
  90. package/components/Pill/pill-variants.js +16 -0
  91. package/components/Pill/pill.marko +165 -0
  92. package/components/Popover/Popover.css +121 -0
  93. package/components/Popover/popover.marko +96 -0
  94. package/components/Radio/Radio.css +230 -0
  95. package/components/Radio/radio-variants.js +16 -0
  96. package/components/Radio/radio.marko +93 -0
  97. package/components/RadioGroup/RadioGroup.css +32 -0
  98. package/components/RadioGroup/radio-group.marko +93 -0
  99. package/components/SearchBar/SearchBar.css +181 -0
  100. package/components/SearchBar/search-bar-variants.js +24 -0
  101. package/components/SearchBar/search-bar.marko +157 -0
  102. package/components/Select/Select.css +427 -0
  103. package/components/Select/select-options.js +163 -0
  104. package/components/Select/select.marko +449 -0
  105. package/components/Separator/Separator.css +22 -0
  106. package/components/Separator/separator.marko +14 -0
  107. package/components/Skeleton/Skeleton.css +100 -0
  108. package/components/Skeleton/skeleton-avatar.marko +28 -0
  109. package/components/Skeleton/skeleton-button.marko +22 -0
  110. package/components/Skeleton/skeleton-image.marko +20 -0
  111. package/components/Skeleton/skeleton-input.marko +22 -0
  112. package/components/Skeleton/skeleton-variants.js +16 -0
  113. package/components/Skeleton/skeleton.marko +53 -0
  114. package/components/Spinner/Spinner.css +173 -0
  115. package/components/Spinner/spinner-geometry.js +39 -0
  116. package/components/Spinner/spinner-overlay.marko +97 -0
  117. package/components/Spinner/spinner.marko +88 -0
  118. package/components/StatCard/StatCard.css +90 -0
  119. package/components/StatCard/stat-card-group.marko +5 -0
  120. package/components/StatCard/stat-card.marko +57 -0
  121. package/components/Switch/Switch.css +209 -0
  122. package/components/Switch/switch.marko +99 -0
  123. package/components/Table/Table.css +270 -0
  124. package/components/Table/table-cell.marko +28 -0
  125. package/components/Table/table-header.marko +27 -0
  126. package/components/Table/table-logic.js +129 -0
  127. package/components/Table/table.marko +372 -0
  128. package/components/Text/Text.css +79 -0
  129. package/components/Text/text-variants.js +25 -0
  130. package/components/Text/text.marko +48 -0
  131. package/components/TextEditor/TextEditor.css +78 -0
  132. package/components/TextEditor/safe-url.js +50 -0
  133. package/components/TextEditor/text-editor-commands.js +22 -0
  134. package/components/TextEditor/text-editor.marko +253 -0
  135. package/components/Toast/Toast.css +88 -0
  136. package/components/Toast/toast-api.js +19 -0
  137. package/components/Toast/toast-store.js +118 -0
  138. package/components/Toast/toast-variants.js +16 -0
  139. package/components/Toast/toast.marko +93 -0
  140. package/components/Toast/toaster.marko +70 -0
  141. package/components/Toggle/Toggle.css +169 -0
  142. package/components/Toggle/toggle.marko +93 -0
  143. package/components/Tooltip/Tooltip.css +115 -0
  144. package/components/Tooltip/tooltip.marko +77 -0
  145. package/components/Uploader/Uploader.css +180 -0
  146. package/components/Uploader/crop-geometry.js +161 -0
  147. package/components/Uploader/uploader.marko +269 -0
  148. package/foundations/Scrollbar/Scrollbar.css +26 -0
  149. package/foundations/attrs.js +27 -0
  150. package/foundations/breakpoints.js +27 -0
  151. package/foundations/contrast.js +74 -0
  152. package/foundations/custom-scheme.js +222 -0
  153. package/foundations/overlay.js +108 -0
  154. package/foundations/styles.css +284 -0
  155. package/foundations/text.js +22 -0
  156. package/foundations/theme-server.js +81 -0
  157. package/foundations/theme.js +42 -0
  158. package/foundations/tokens.css +1656 -0
  159. package/icons/custom/custom-video-horizontal-filled.marko +6 -0
  160. package/icons/custom/custom-video-horizontal-regular.marko +6 -0
  161. package/icons/custom/custom-video-tour-3d-filled.marko +7 -0
  162. package/icons/custom/custom-video-tour-3d-regular.marko +9 -0
  163. package/icons/custom/custom-video-vertical-filled.marko +6 -0
  164. package/icons/custom/custom-video-vertical-regular.marko +6 -0
  165. package/icons/generated/fluent-add-regular.marko +3 -0
  166. package/icons/generated/fluent-alert-regular.marko +3 -0
  167. package/icons/generated/fluent-apps-regular.marko +3 -0
  168. package/icons/generated/fluent-arrow-download-regular.marko +3 -0
  169. package/icons/generated/fluent-arrow-left-regular.marko +3 -0
  170. package/icons/generated/fluent-arrow-right-regular.marko +3 -0
  171. package/icons/generated/fluent-arrow-rotate-clockwise-regular.marko +3 -0
  172. package/icons/generated/fluent-arrow-rotate-counterclockwise-regular.marko +3 -0
  173. package/icons/generated/fluent-border-all-regular.marko +3 -0
  174. package/icons/generated/fluent-calendar-regular.marko +3 -0
  175. package/icons/generated/fluent-checkmark-circle-filled.marko +3 -0
  176. package/icons/generated/fluent-checkmark-regular.marko +3 -0
  177. package/icons/generated/fluent-chevron-down-regular.marko +3 -0
  178. package/icons/generated/fluent-chevron-left-regular.marko +3 -0
  179. package/icons/generated/fluent-chevron-right-regular.marko +3 -0
  180. package/icons/generated/fluent-chevron-up-regular.marko +3 -0
  181. package/icons/generated/fluent-circle-half-fill-regular.marko +3 -0
  182. package/icons/generated/fluent-clock-regular.marko +3 -0
  183. package/icons/generated/fluent-color-regular.marko +3 -0
  184. package/icons/generated/fluent-crop-regular.marko +3 -0
  185. package/icons/generated/fluent-dark-theme-regular.marko +3 -0
  186. package/icons/generated/fluent-delete-regular.marko +3 -0
  187. package/icons/generated/fluent-desktop-regular.marko +3 -0
  188. package/icons/generated/fluent-dismiss-circle-filled.marko +3 -0
  189. package/icons/generated/fluent-dismiss-regular.marko +3 -0
  190. package/icons/generated/fluent-document-pdf-regular.marko +3 -0
  191. package/icons/generated/fluent-document-regular.marko +3 -0
  192. package/icons/generated/fluent-eye-off-regular.marko +3 -0
  193. package/icons/generated/fluent-eye-regular.marko +3 -0
  194. package/icons/generated/fluent-eyedropper-regular.marko +3 -0
  195. package/icons/generated/fluent-grid-regular.marko +3 -0
  196. package/icons/generated/fluent-history-regular.marko +3 -0
  197. package/icons/generated/fluent-home-regular.marko +3 -0
  198. package/icons/generated/fluent-image-regular.marko +3 -0
  199. package/icons/generated/fluent-info-regular.marko +3 -0
  200. package/icons/generated/fluent-link-regular.marko +3 -0
  201. package/icons/generated/fluent-more-horizontal-regular.marko +3 -0
  202. package/icons/generated/fluent-navigation-regular.marko +3 -0
  203. package/icons/generated/fluent-paint-brush-regular.marko +3 -0
  204. package/icons/generated/fluent-question-circle-regular.marko +3 -0
  205. package/icons/generated/fluent-search-regular.marko +3 -0
  206. package/icons/generated/fluent-settings-regular.marko +3 -0
  207. package/icons/generated/fluent-square-hint-regular.marko +3 -0
  208. package/icons/generated/fluent-subtract-regular.marko +3 -0
  209. package/icons/generated/fluent-text-align-center-regular.marko +3 -0
  210. package/icons/generated/fluent-text-align-left-regular.marko +3 -0
  211. package/icons/generated/fluent-text-align-right-regular.marko +3 -0
  212. package/icons/generated/fluent-text-bold-regular.marko +3 -0
  213. package/icons/generated/fluent-text-bullet-list-regular.marko +3 -0
  214. package/icons/generated/fluent-text-font-regular.marko +3 -0
  215. package/icons/generated/fluent-text-font-size-regular.marko +3 -0
  216. package/icons/generated/fluent-text-italic-regular.marko +3 -0
  217. package/icons/generated/fluent-text-number-list-ltr-regular.marko +3 -0
  218. package/icons/generated/fluent-video-regular.marko +3 -0
  219. package/icons/generated/fluent-warning-filled.marko +3 -0
  220. package/icons/generated/fluent-weather-moon-regular.marko +3 -0
  221. package/icons/generated/fluent-weather-sunny-regular.marko +3 -0
  222. package/icons/generated/fluent-zoom-in-regular.marko +3 -0
  223. package/icons/generated/fluent-zoom-out-regular.marko +3 -0
  224. package/icons/manifest.json +61 -0
  225. package/icons/social/social-facebook-color.marko +7 -0
  226. package/icons/social/social-facebook.marko +6 -0
  227. package/icons/social/social-google-color.marko +20 -0
  228. package/icons/social/social-google.marko +6 -0
  229. package/icons/social/social-instagram-color.marko +16 -0
  230. package/icons/social/social-instagram.marko +6 -0
  231. package/icons/social/social-linkedin-color.marko +8 -0
  232. package/icons/social/social-linkedin.marko +8 -0
  233. package/icons/social/social-messenger-color.marko +15 -0
  234. package/icons/social/social-messenger.marko +6 -0
  235. package/icons/social/social-pinterest-color.marko +6 -0
  236. package/icons/social/social-pinterest.marko +6 -0
  237. package/icons/social/social-telegram-color.marko +7 -0
  238. package/icons/social/social-telegram.marko +6 -0
  239. package/icons/social/social-whatsapp-color.marko +8 -0
  240. package/icons/social/social-whatsapp.marko +7 -0
  241. package/icons/social/social-x-color.marko +7 -0
  242. package/icons/social/social-x.marko +6 -0
  243. package/icons/social/social-youtube-color.marko +7 -0
  244. package/icons/social/social-youtube.marko +6 -0
  245. package/marko.json +203 -0
  246. package/package.json +55 -0
@@ -0,0 +1,291 @@
1
+ <!-- sds-floating — ported from kdls-design-system v2.5.1 (0f3edf8) components/Floating/Floating.tsx.
2
+
3
+ A trigger and a panel that finds its own place: twelve placements, a flip when there
4
+ is no room, and four ways to open it. Popover, Tooltip, DatePicker and ColorPicker are
5
+ all this component with a different panel inside.
6
+
7
+ It hydrates, and that is not a concession — there is nothing here but interaction. The
8
+ panel is not rendered at all until it opens, so a page full of closed popovers ships
9
+ the triggers and a few bytes of state each, not the panels.
10
+
11
+ Divergences, and both come from the same place:
12
+
13
+ KDLS renders the panel through ReactDOM.createPortal into <body>. Marko 5 has no
14
+ portal, and the obvious substitute does not work: measured in the portal on
15
+ 2026-09-10, a node moved to <body> with appendChild goes back to where the template
16
+ says it belongs on the component's next re-render — and a floating panel re-renders
17
+ every time it opens. So the panel stays in the tree and is positioned with
18
+ `position: fixed`, which escapes `overflow: hidden` on its own.
19
+
20
+ What fixed does not escape is an ancestor with a transform, a filter, a perspective or
21
+ `contain: paint` — any of those becomes the containing block for fixed descendants, and
22
+ the panel is then positioned, and clipped, against that ancestor instead of the
23
+ viewport. That is a silent visual bug, so the component looks for one when it opens and
24
+ says so in the console rather than drawing the panel somewhere wrong with no
25
+ explanation. See findFixedContainingBlock in floating-position.js.
26
+
27
+ Four of KDLS's attributes are not here, and each for a reason rather than an oversight:
28
+
29
+ destroyOnClose always true. The panel is not rendered at all while closed, so
30
+ there is nothing to destroy and nothing to keep — a page of
31
+ closed popovers ships its triggers and a few bytes of state.
32
+ getPopupContainer names the element a portal renders into, and there is no portal.
33
+ openClassName is `openClass` here, because Marko has no className anywhere.
34
+ triggerClassName KDLS wraps the caller's trigger in a div to hang a class on. The
35
+ trigger is the body here and nothing wraps it; a caller who wants
36
+ a class puts it on their own element, which is one fewer element
37
+ in everyone's DOM.
38
+
39
+ The panel is measured before it is shown. A panel that has not been measured is 0x0,
40
+ 0x0 fits anywhere, and a flip decided on that basis puts the panel on the wrong side
41
+ in front of the visitor. So it renders hidden, gets measured, and is revealed in one
42
+ step — `positioned` is that step. -->
43
+
44
+ import {
45
+ parseFloatingPlacement, joinFloatingPlacement, camelToKebab, computeFloatingPosition,
46
+ getVisibleBounds, findFixedContainingBlock, assertPlacement, assertTrigger, DEFAULT_GAP,
47
+ } from "./floating-position.js";
48
+
49
+ class {
50
+ onCreate(input) {
51
+ this.state = {
52
+ open: !!input.defaultOpen,
53
+ positioned: false,
54
+ style: null,
55
+ side: parseFloatingPlacement(input.placement || "top").side,
56
+ };
57
+ this.openTimer = null;
58
+ this.closeTimer = null;
59
+ }
60
+
61
+ get isOpen() {
62
+ return this.input.open !== undefined ? this.input.open : this.state.open;
63
+ }
64
+
65
+ get modes() {
66
+ return [].concat(this.input.trigger || "click");
67
+ }
68
+
69
+ setOpen(next) {
70
+ if (next === this.isOpen) return;
71
+ if (this.input.open === undefined) this.state.open = next;
72
+ this.input.onOpenChange?.(next);
73
+ if (!next) this.state.positioned = false;
74
+ }
75
+
76
+ onMount() {
77
+ this.reposition = () => this.position();
78
+ // `true` for the capture phase: a panel anchored to something inside a scrolling list
79
+ // has to follow it, and a scroll event on that list does not bubble to window.
80
+ window.addEventListener("scroll", this.reposition, true);
81
+ window.addEventListener("resize", this.reposition);
82
+
83
+ // Closing on a pointer outside is what makes a floating panel feel like one. It is on
84
+ // the document because the press that should close it happens outside this tree, and
85
+ // on mousedown rather than click so a drag that starts outside closes it too.
86
+ this.onDocumentPointer = (event) => this.handleDocumentPointer(event);
87
+ document.addEventListener("mousedown", this.onDocumentPointer);
88
+
89
+ if (this.isOpen) this.afterOpen();
90
+ }
91
+
92
+ onUpdate() {
93
+ if (this.isOpen && !this.state.positioned) this.afterOpen();
94
+ }
95
+
96
+ onDestroy() {
97
+ window.removeEventListener("scroll", this.reposition, true);
98
+ window.removeEventListener("resize", this.reposition);
99
+ document.removeEventListener("mousedown", this.onDocumentPointer);
100
+ clearTimeout(this.openTimer);
101
+ clearTimeout(this.closeTimer);
102
+ }
103
+
104
+ afterOpen() {
105
+ // One frame, so the panel exists and has a size to measure. Position, then reveal.
106
+ requestAnimationFrame(() => {
107
+ if (!this.isOpen) return;
108
+ this.position();
109
+ this.state.positioned = true;
110
+ this.warnAboutContainingBlock();
111
+ });
112
+ }
113
+
114
+ position() {
115
+ const trigger = this.getEl("root");
116
+ const panel = this.getEl("panel");
117
+ if (!trigger || !panel) return;
118
+
119
+ const { side, align } = parseFloatingPlacement(this.input.placement || "top");
120
+ const gap = this.input.gap ?? DEFAULT_GAP;
121
+ const result = computeFloatingPosition(
122
+ trigger.getBoundingClientRect(),
123
+ { width: panel.offsetWidth, height: panel.offsetHeight },
124
+ side,
125
+ align,
126
+ gap,
127
+ getVisibleBounds(trigger, true),
128
+ );
129
+
130
+ this.state.side = result.effectiveSide;
131
+ this.state.style = this.input.matchTriggerWidth
132
+ ? { ...result.style, minWidth: trigger.getBoundingClientRect().width }
133
+ : result.style;
134
+ }
135
+
136
+ /** Said once per component, and only when there is something to say. */
137
+ warnAboutContainingBlock() {
138
+ if (this.warned) return;
139
+ const breaker = findFixedContainingBlock(this.getEl("root"));
140
+ if (!breaker) return;
141
+ this.warned = true;
142
+ console.error(
143
+ "[sds-floating] An ancestor with a transform, filter, perspective or contain:paint " +
144
+ "is the containing block for this panel, so it is positioned against that element " +
145
+ "instead of the viewport and may be clipped by it.",
146
+ breaker,
147
+ );
148
+ }
149
+
150
+ /**
151
+ * The click handler sits on the root because the trigger is whatever the caller put in
152
+ * the body — there is no element of ours to hang it on. But the panel is in that same
153
+ * root, so a click on anything inside it bubbles up here, and toggling on that would
154
+ * close the panel the moment the visitor used it: every option in a Select, every button
155
+ * in a Popover.
156
+ */
157
+ toggle(event) {
158
+ if (event && this.getEl("panel")?.contains(event.target)) return;
159
+ this.setOpen(!this.isOpen);
160
+ }
161
+
162
+ handleTriggerKey(event) {
163
+ if (this.getEl("panel")?.contains(event.target)) {
164
+ // Escape still closes from inside; Enter and Space belong to whatever has focus.
165
+ if (event.key === "Escape") this.setOpen(false);
166
+ return;
167
+ }
168
+ if (event.key === "Enter" || event.key === " ") {
169
+ event.preventDefault();
170
+ this.toggle();
171
+ return;
172
+ }
173
+ if (event.key === "Escape") this.setOpen(false);
174
+ }
175
+
176
+ handleContextMenu(event) {
177
+ event.preventDefault();
178
+ this.setOpen(true);
179
+ }
180
+
181
+ enter() {
182
+ clearTimeout(this.closeTimer);
183
+ this.openTimer = setTimeout(() => this.setOpen(true), this.input.mouseEnterDelay ?? 100);
184
+ }
185
+
186
+ leave() {
187
+ clearTimeout(this.openTimer);
188
+ this.closeTimer = setTimeout(() => this.setOpen(false), this.input.mouseLeaveDelay ?? 100);
189
+ }
190
+
191
+ /** Crossing the gap between trigger and panel must not close it. */
192
+ panelEnter() {
193
+ if (this.modes.includes("hover")) clearTimeout(this.closeTimer);
194
+ }
195
+
196
+ panelLeave(event) {
197
+ if (!this.modes.includes("hover")) return;
198
+ // Moving from the panel back onto the trigger is not leaving. The panel is a child of
199
+ // the root, so the root's `mouseenter` does not fire again — the pointer never left it
200
+ // — and nothing cancelled the close this scheduled. So a hover panel closed under a
201
+ // pointer that was sitting on the very thing that opens it, and reading a tooltip by
202
+ // moving back over its trigger was impossible.
203
+ const root = this.getEl("root");
204
+ if (root && event?.relatedTarget instanceof Node && root.contains(event.relatedTarget)) {
205
+ return;
206
+ }
207
+ this.leave();
208
+ }
209
+
210
+ handleFocus() { this.setOpen(true); }
211
+
212
+ handleBlur(event) {
213
+ if (this.getEl("panel")?.contains(event.relatedTarget)) return;
214
+ this.setOpen(false);
215
+ }
216
+
217
+ handleDocumentPointer(event) {
218
+ if (!this.isOpen) return;
219
+ const root = this.getEl("root");
220
+ const panel = this.getEl("panel");
221
+ if (root?.contains(event.target) || panel?.contains(event.target)) return;
222
+ this.setOpen(false);
223
+ }
224
+ }
225
+
226
+ $ {
227
+ const {
228
+ placement = "top",
229
+ trigger = "click",
230
+ zIndex,
231
+ class: extraClass = "",
232
+ openClass = "",
233
+ } = input;
234
+
235
+ assertPlacement(placement);
236
+ assertTrigger(trigger);
237
+
238
+ const { align } = parseFloatingPlacement(placement);
239
+ const open = component.isOpen;
240
+ const modes = [].concat(trigger);
241
+ const activePlacement = joinFloatingPlacement(state.side, align);
242
+
243
+ const rootClass = [
244
+ "sds-floating",
245
+ extraClass,
246
+ open ? openClass : "",
247
+ ].filter(Boolean).join(" ");
248
+
249
+ const panelClass = [
250
+ "sds-floating__panel",
251
+ `sds-floating__panel--${camelToKebab(activePlacement)}`,
252
+ state.positioned ? "sds-floating__panel--positioned" : "",
253
+ ].filter(Boolean).join(" ");
254
+
255
+ const px = (n) => (typeof n === "number" ? `${n}px` : n);
256
+ const panelStyle = [
257
+ "position:fixed",
258
+ state.style ? `top:${px(state.style.top)}` : "",
259
+ state.style ? `left:${px(state.style.left)}` : "",
260
+ state.style?.transform ? `transform:${state.style.transform}` : "",
261
+ state.style?.minWidth ? `min-width:${px(state.style.minWidth)}` : "",
262
+ zIndex !== undefined ? `z-index:${zIndex}` : "",
263
+ ].filter(Boolean).join(";");
264
+ }
265
+
266
+ <div
267
+ key="root"
268
+ class=rootClass
269
+ on-click(modes.includes("click") ? "toggle" : null)
270
+ on-keydown(modes.includes("click") ? "handleTriggerKey" : null)
271
+ on-mouseenter(modes.includes("hover") ? "enter" : null)
272
+ on-mouseleave(modes.includes("hover") ? "leave" : null)
273
+ on-focusin(modes.includes("focus") ? "handleFocus" : null)
274
+ on-focusout(modes.includes("focus") ? "handleBlur" : null)
275
+ on-contextmenu(modes.includes("contextMenu") ? "handleContextMenu" : null)
276
+ >
277
+ <${input.renderBody}/>
278
+
279
+ <if(open)>
280
+ <div
281
+ key="panel"
282
+ class=panelClass
283
+ style=panelStyle
284
+ data-placement=activePlacement
285
+ on-mouseenter("panelEnter")
286
+ on-mouseleave("panelLeave")
287
+ >
288
+ <${input.content}/>
289
+ </div>
290
+ </if>
291
+ </div>
@@ -0,0 +1,15 @@
1
+ /* SDS Form.
2
+ KDLS has no Form.css at all: its Form is a context provider around a bare <form>, and a
3
+ <form> has no styling of its own to speak of.
4
+
5
+ This file exists because the form is now also the thing that tells its fields when to
6
+ validate, and that needs a class to hang on — and because every component in this system
7
+ ships a stylesheet, which is a rule with a test behind it. The rules below are the two a
8
+ bare <form> actually needs: no margin from the user agent, and a layout that stacks. */
9
+
10
+ .sds-form {
11
+ margin: 0;
12
+ display: flex;
13
+ flex-direction: column;
14
+ gap: var(--spacing-4);
15
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * What a form tells the fields inside it, without React context.
3
+ *
4
+ * KDLS's Form is a context provider: it holds `validateOn` and `submitted`, and every Input
5
+ * reads them with useFormContext. Marko 5 has no context and no equivalent — see
6
+ * .claude/component-rules.md §2c, where the same gap was settled once for compound
7
+ * components by using attribute tags. That answer does not work here: a form's children are
8
+ * arbitrary markup at any depth, not a list of known slots.
9
+ *
10
+ * So the form says it on the element itself, and a field asks the nearest form when it
11
+ * mounts. That is what context does — a child looking up the tree — expressed in the one
12
+ * tree both of them already share.
13
+ *
14
+ * Three consequences worth stating rather than discovering:
15
+ *
16
+ * 1. The lookup happens on mount, not during render, so the server cannot know it. That
17
+ * costs nothing: validation is a client-side behaviour, and a field that has never
18
+ * been touched has nothing to say anyway.
19
+ *
20
+ * 2. A field can still be told directly. An explicit `validateOn` wins over the form's,
21
+ * which is KDLS's rule too (validateOnProp ?? contextValidateOn).
22
+ *
23
+ * 3. It works through any nesting, including a field inside a component inside a fieldset
24
+ * inside the form — closest() does not care how deep it is.
25
+ */
26
+
27
+ export const VALIDATE_ON = ['change', 'blur', 'submit'];
28
+
29
+ export const DEFAULT_VALIDATE_ON = 'change';
30
+
31
+ /** The attribute the form writes and the fields read. */
32
+ export const VALIDATE_ON_ATTRIBUTE = 'data-sds-validate-on';
33
+
34
+ /** The event a form fires at its fields when it is submitted. */
35
+ export const SUBMIT_EVENT = 'sds-form-submit';
36
+
37
+ export function assertValidateOn(value) {
38
+ if (!VALIDATE_ON.includes(value)) {
39
+ throw new Error(`Unknown validateOn: "${value}". Allowed: ${VALIDATE_ON.join(', ')}`);
40
+ }
41
+ return value;
42
+ }
43
+
44
+ /**
45
+ * When the form around `element` wants its fields validated.
46
+ *
47
+ * Returns null when there is no form, or the form said nothing — a field on its own page is
48
+ * not inside a form, and should keep its own behaviour rather than inherit a default it was
49
+ * never given.
50
+ */
51
+ export function validateOnFor(element) {
52
+ const form = element?.closest?.(`form[${VALIDATE_ON_ATTRIBUTE}]`);
53
+ if (!form) return null;
54
+ const value = form.getAttribute(VALIDATE_ON_ATTRIBUTE);
55
+ return VALIDATE_ON.includes(value) ? value : null;
56
+ }
57
+
58
+ /**
59
+ * Tells every field inside `form` that it was submitted.
60
+ *
61
+ * A bubbling event rather than a property, so a field added after the form rendered still
62
+ * hears it, and so nothing has to keep a list of who is listening.
63
+ */
64
+ export function announceSubmit(form) {
65
+ form?.dispatchEvent(new CustomEvent(SUBMIT_EVENT, { bubbles: false }));
66
+ for (const field of form?.querySelectorAll('[data-sds-field]') ?? []) {
67
+ field.dispatchEvent(new CustomEvent(SUBMIT_EVENT, { bubbles: false }));
68
+ }
69
+ }
@@ -0,0 +1,73 @@
1
+ <!-- sds-form — ported from kdls-design-system v2.5.1 (0f3edf8) components/Form/Form.tsx.
2
+
3
+ A <form> that tells the fields inside it when to validate, and tells them when it has
4
+ been submitted.
5
+
6
+ KDLS does that with a React context provider. Marko 5 has no context and no equivalent
7
+ — the gap was settled once for compound components by using attribute tags
8
+ (.claude/component-rules.md §2c), and that answer does not fit here: a form's children
9
+ are arbitrary markup at any depth, not a list of known slots.
10
+
11
+ So the form writes what it wants on its own element and fires an event at its fields
12
+ when it is submitted; a field asks the nearest form when it mounts and listens for that
13
+ event. That is what context is — a child looking up the tree — said in the one tree both
14
+ of them already share. The reasoning and its three consequences are in form-context.js.
15
+
16
+ This is the open question from Phase 1, answered rather than deferred. It is reversible:
17
+ nothing else depends on the mechanism, and if Marko ever grows a context the components
18
+ change in one place each. -->
19
+
20
+ import {
21
+ assertValidateOn, announceSubmit, VALIDATE_ON_ATTRIBUTE, DEFAULT_VALIDATE_ON,
22
+ } from "./form-context.js";
23
+ import { rehyphenate } from "../../foundations/attrs.js";
24
+
25
+ class {
26
+ handleSubmit(event) {
27
+ // The fields are told before the caller is, so a handler that reads the form's validity
28
+ // sees the result of this submission rather than the last one.
29
+ announceSubmit(this.getEl("form"));
30
+ this.input.onSubmit?.(event);
31
+ }
32
+ }
33
+
34
+ $ {
35
+ const {
36
+ validateOn = DEFAULT_VALIDATE_ON,
37
+ noValidate = true,
38
+ class: extraClass = "",
39
+ } = input;
40
+
41
+ assertValidateOn(validateOn);
42
+
43
+ // Everything else the caller wrote, forwarded.
44
+ //
45
+ // The form used to list six attributes and drop the rest in silence: `aria-labelledby`,
46
+ // `aria-describedby`, `role`, `target`, `enctype`, `autocomplete`, every `data-*`. A
47
+ // form named by a heading elsewhere on the page simply had no name, and nothing said so.
48
+ // KDLS spreads its rest props, which is the behaviour a caller expects from a component
49
+ // wrapping one native element.
50
+ //
51
+ // The four this component owns are held back: `class` is composed with the base class,
52
+ // and `validateOn`, `noValidate` and `onSubmit` are its own contract rather than the
53
+ // <form>'s. `renderBody` is Marko's.
54
+ const OWN = new Set(["validateOn", "noValidate", "class", "onSubmit", "renderBody"]);
55
+ const forwarded = rehyphenate(Object.fromEntries(
56
+ Object.entries(input).filter(([key]) => !OWN.has(key)),
57
+ ));
58
+ }
59
+
60
+ <!-- noValidate by default: the browser's own bubbles cannot be styled, cannot be themed,
61
+ and say things in the browser's language rather than the product's. The fields do the
62
+ validating and say it in the page. A caller who wants the native behaviour back passes
63
+ noValidate=false. -->
64
+ <form
65
+ key="form"
66
+ ...forwarded
67
+ class=`sds-form ${extraClass}`.trim()
68
+ novalidate=(noValidate || null)
69
+ ...{ [VALIDATE_ON_ATTRIBUTE]: validateOn }
70
+ on-submit("handleSubmit")
71
+ >
72
+ <${input.renderBody}/>
73
+ </form>
@@ -0,0 +1,179 @@
1
+ /* SDS GradientPicker.
2
+ Designed for SDS — KDLS has no counterpart, because its one gradient was a fixed brand
3
+ value rather than something a site chooses. Built to the design at
4
+ figma.com/design/HefwQxdw5h6wrhjzC9FHtf?node-id=11362-100508; every measurement below
5
+ is that frame's, expressed in tokens where a token holds the same number.
6
+ ============================================================ */
7
+
8
+ .sds-gradient-picker {
9
+ display: inline-block;
10
+ }
11
+
12
+ .sds-gradient-picker--fill {
13
+ display: block;
14
+ width: 100%;
15
+ }
16
+
17
+ /* ── Trigger ──
18
+ The ColorPicker's shape, so the two read as the same kind of control in a row. */
19
+ .sds-gradient-picker__trigger {
20
+ display: flex;
21
+ align-items: center;
22
+ gap: var(--spacing-3);
23
+ height: 56px;
24
+ padding: 0 var(--spacing-3);
25
+ background: var(--component-bg-color);
26
+ border: 1px solid var(--border-interactive-color);
27
+ border-radius: var(--radius-md);
28
+ cursor: pointer;
29
+ font-family: var(--font-family-base);
30
+ transition: border-color 120ms ease;
31
+ box-sizing: border-box;
32
+ outline: none;
33
+ }
34
+
35
+ .sds-gradient-picker__trigger:hover:not(:disabled),
36
+ .sds-gradient-picker__trigger:focus-visible {
37
+ border-color: var(--color-primary-50-solid);
38
+ }
39
+
40
+ .sds-gradient-picker__trigger:focus-visible {
41
+ box-shadow: var(--focus-ring);
42
+ }
43
+
44
+ .sds-gradient-picker__trigger:disabled {
45
+ opacity: 0.5;
46
+ cursor: not-allowed;
47
+ }
48
+
49
+ .sds-gradient-picker__swatch {
50
+ width: 24px;
51
+ height: 24px;
52
+ border-radius: var(--radius-sm);
53
+ flex-shrink: 0;
54
+ border: 1px solid var(--border-interactive-color);
55
+ }
56
+
57
+ .sds-gradient-picker__field {
58
+ display: flex;
59
+ flex-direction: column;
60
+ align-items: flex-start;
61
+ text-align: left;
62
+ }
63
+
64
+ /* ── Panel ──
65
+ 460 × 420 with a 16px inset, and 16px between every part of it. */
66
+ .sds-gradient-picker__panel {
67
+ display: flex;
68
+ flex-direction: column;
69
+ gap: var(--spacing-4);
70
+ width: 460px;
71
+ max-width: 100%;
72
+ padding: var(--spacing-4);
73
+ background: var(--component-bg-color);
74
+ border: 1px solid var(--border-static-color);
75
+ border-radius: var(--radius-md);
76
+ box-sizing: border-box;
77
+ }
78
+
79
+ .sds-gradient-picker__stops {
80
+ display: flex;
81
+ align-items: stretch;
82
+ gap: var(--spacing-4);
83
+ }
84
+
85
+ .sds-gradient-picker__stop {
86
+ display: flex;
87
+ flex-direction: column;
88
+ gap: var(--spacing-4);
89
+ flex: 1;
90
+ min-width: 0;
91
+ }
92
+
93
+ .sds-gradient-picker__divider {
94
+ width: 1px;
95
+ background: var(--border-static-color);
96
+ flex-shrink: 0;
97
+ }
98
+
99
+ /* ── Rails ──
100
+ A 20px track inside a 24px row, so the thumb overhangs by 2px top and bottom exactly as
101
+ it does in the design. The row is what takes the pointer, so the 4px of overhang stays
102
+ grabbable. */
103
+ .sds-gradient-picker__rail {
104
+ position: relative;
105
+ height: 24px;
106
+ border-radius: var(--radius-full);
107
+ cursor: pointer;
108
+ outline: none;
109
+ background-clip: content-box;
110
+ padding: 2px 0;
111
+ box-sizing: border-box;
112
+ }
113
+
114
+ /* The hue wheel, written as hue angles rather than as picked colours — the ColorPicker's
115
+ rail says it the same way. These are not palette values and could not come from tokens:
116
+ they are the axis this control moves along. */
117
+ .sds-gradient-picker__rail--hue {
118
+ background-image: linear-gradient(to right,
119
+ hsl(0, 100%, 50%), hsl(30, 100%, 50%), hsl(60, 100%, 50%),
120
+ hsl(120, 100%, 50%), hsl(180, 100%, 50%), hsl(240, 100%, 50%),
121
+ hsl(300, 100%, 50%), hsl(360, 100%, 50%)
122
+ );
123
+ }
124
+
125
+ .sds-gradient-picker__rail:focus-visible {
126
+ box-shadow: var(--focus-ring);
127
+ }
128
+
129
+ .sds-gradient-picker__thumb {
130
+ position: absolute;
131
+ top: 50%;
132
+ width: 24px;
133
+ height: 24px;
134
+ margin-left: -12px;
135
+ transform: translateY(-50%);
136
+ border-radius: var(--radius-full);
137
+ background: var(--component-bg-color);
138
+ box-shadow: 0 1px 4px var(--color-opacity-20);
139
+ pointer-events: none;
140
+ }
141
+
142
+ /* ── Preview ──
143
+ 40px. The Figma frame draws it at 164, and Rafael's call is 40: a band is enough to read
144
+ where the two hues meet, and 164 makes the panel taller than most places that open it.
145
+ Noted rather than silently diverged. */
146
+ .sds-gradient-picker__preview {
147
+ height: 40px;
148
+ border-radius: var(--radius-sm);
149
+ }
150
+
151
+ /* ── Overlay ──
152
+ The panel centred over the page rather than anchored to its trigger. Used where
153
+ anchoring cannot work: inside another panel, where it would land on top of the panel it
154
+ came from, and on a phone, where there is no room beside a trigger. */
155
+ .sds-gradient-picker__backdrop {
156
+ position: fixed;
157
+ inset: 0;
158
+ z-index: var(--z-overlay);
159
+ display: flex;
160
+ align-items: center;
161
+ justify-content: center;
162
+ padding: var(--spacing-4);
163
+ /* The scrim the Modal uses, and its dark-theme twin: --color-opacity-* inverts, so a
164
+ backdrop written with it goes white on a dark page. */
165
+ background: var(--color-opacity-50);
166
+ overflow-y: auto;
167
+ }
168
+
169
+ [data-theme="dark"] .sds-gradient-picker__backdrop {
170
+ background: var(--color-scrim);
171
+ }
172
+
173
+ /* Driven entirely from outside, with no trigger of its own: the root still has to be in
174
+ the tree — it is what holds the overlay — but it must not take up a line while the
175
+ overlay is closed. `display: contents` leaves the element addressable and gives its box
176
+ away. */
177
+ .sds-gradient-picker--headless {
178
+ display: contents;
179
+ }