decantr 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (382) hide show
  1. package/AGENTS.md +868 -0
  2. package/CHANGELOG.md +255 -0
  3. package/CLAUDE.md +178 -0
  4. package/LICENSE +21 -0
  5. package/README.md +229 -0
  6. package/cli/art.js +127 -0
  7. package/cli/commands/a11y.js +61 -0
  8. package/cli/commands/audit.js +225 -0
  9. package/cli/commands/build.js +38 -0
  10. package/cli/commands/dev.js +18 -0
  11. package/cli/commands/doctor.js +197 -0
  12. package/cli/commands/figma-sync.js +48 -0
  13. package/cli/commands/figma-tokens.js +55 -0
  14. package/cli/commands/generate.js +26 -0
  15. package/cli/commands/init.js +116 -0
  16. package/cli/commands/lint.js +209 -0
  17. package/cli/commands/mcp.js +530 -0
  18. package/cli/commands/migrate.js +175 -0
  19. package/cli/commands/test.js +38 -0
  20. package/cli/commands/validate.js +354 -0
  21. package/cli/index.js +113 -0
  22. package/package.json +95 -0
  23. package/reference/atoms.md +517 -0
  24. package/reference/behaviors.md +384 -0
  25. package/reference/build-tooling.md +275 -0
  26. package/reference/color-guidelines.md +965 -0
  27. package/reference/component-lifecycle.md +137 -0
  28. package/reference/compound-spacing.md +95 -0
  29. package/reference/decantation-process.md +499 -0
  30. package/reference/dev-server-routes.md +93 -0
  31. package/reference/form-system.md +253 -0
  32. package/reference/i18n.md +336 -0
  33. package/reference/icons.md +576 -0
  34. package/reference/llm-primer.md +953 -0
  35. package/reference/plugins.md +252 -0
  36. package/reference/registry-consumption.md +76 -0
  37. package/reference/router.md +217 -0
  38. package/reference/shells.md +116 -0
  39. package/reference/spatial-guidelines.md +541 -0
  40. package/reference/ssr.md +234 -0
  41. package/reference/state-data.md +215 -0
  42. package/reference/state-patterns.md +166 -0
  43. package/reference/state.md +194 -0
  44. package/reference/style-system.md +110 -0
  45. package/reference/tokens.md +460 -0
  46. package/src/app.js +19 -0
  47. package/src/chart/_animate.js +266 -0
  48. package/src/chart/_base.js +109 -0
  49. package/src/chart/_data.js +209 -0
  50. package/src/chart/_format.js +106 -0
  51. package/src/chart/_interact.js +364 -0
  52. package/src/chart/_palette.js +105 -0
  53. package/src/chart/_renderer.js +52 -0
  54. package/src/chart/_scene.js +262 -0
  55. package/src/chart/_shared.js +371 -0
  56. package/src/chart/index.js +637 -0
  57. package/src/chart/layouts/_layout-base.js +328 -0
  58. package/src/chart/layouts/cartesian.js +148 -0
  59. package/src/chart/layouts/hierarchy.js +562 -0
  60. package/src/chart/layouts/polar.js +101 -0
  61. package/src/chart/renderers/canvas.js +179 -0
  62. package/src/chart/renderers/svg.js +256 -0
  63. package/src/chart/renderers/webgpu.js +715 -0
  64. package/src/chart/types/_type-base.js +26 -0
  65. package/src/chart/types/area.js +134 -0
  66. package/src/chart/types/bar.js +173 -0
  67. package/src/chart/types/box-plot.js +125 -0
  68. package/src/chart/types/bubble.js +63 -0
  69. package/src/chart/types/candlestick.js +115 -0
  70. package/src/chart/types/chord.js +85 -0
  71. package/src/chart/types/combination.js +108 -0
  72. package/src/chart/types/funnel.js +68 -0
  73. package/src/chart/types/gauge.js +163 -0
  74. package/src/chart/types/heatmap.js +98 -0
  75. package/src/chart/types/histogram.js +71 -0
  76. package/src/chart/types/line.js +111 -0
  77. package/src/chart/types/org-chart.js +93 -0
  78. package/src/chart/types/pie.js +81 -0
  79. package/src/chart/types/radar.js +96 -0
  80. package/src/chart/types/radial.js +68 -0
  81. package/src/chart/types/range-area.js +55 -0
  82. package/src/chart/types/range-bar.js +61 -0
  83. package/src/chart/types/sankey.js +73 -0
  84. package/src/chart/types/scatter.js +66 -0
  85. package/src/chart/types/sparkline.js +81 -0
  86. package/src/chart/types/sunburst.js +69 -0
  87. package/src/chart/types/swimlane.js +88 -0
  88. package/src/chart/types/treemap.js +62 -0
  89. package/src/chart/types/waterfall.js +100 -0
  90. package/src/components/_base.js +1658 -0
  91. package/src/components/_behaviors.js +1140 -0
  92. package/src/components/_primitives.js +534 -0
  93. package/src/components/_qr-encoder.js +539 -0
  94. package/src/components/accordion.js +207 -0
  95. package/src/components/affix.js +62 -0
  96. package/src/components/alert-dialog.js +75 -0
  97. package/src/components/alert.js +47 -0
  98. package/src/components/aspect-ratio.js +24 -0
  99. package/src/components/avatar-group.js +55 -0
  100. package/src/components/avatar.js +38 -0
  101. package/src/components/back-top.js +75 -0
  102. package/src/components/badge.js +74 -0
  103. package/src/components/banner.js +68 -0
  104. package/src/components/breadcrumb.js +162 -0
  105. package/src/components/button.js +115 -0
  106. package/src/components/calendar.js +131 -0
  107. package/src/components/card.js +192 -0
  108. package/src/components/carousel.js +98 -0
  109. package/src/components/cascader.js +261 -0
  110. package/src/components/checkbox.js +80 -0
  111. package/src/components/chip.js +81 -0
  112. package/src/components/code-block.js +82 -0
  113. package/src/components/collapsible.js +50 -0
  114. package/src/components/color-palette.js +438 -0
  115. package/src/components/color-picker.js +314 -0
  116. package/src/components/combobox.js +181 -0
  117. package/src/components/command.js +174 -0
  118. package/src/components/comment.js +206 -0
  119. package/src/components/context-menu.js +76 -0
  120. package/src/components/data-table.js +724 -0
  121. package/src/components/date-picker.js +217 -0
  122. package/src/components/date-range-picker.js +244 -0
  123. package/src/components/datetime-picker.js +271 -0
  124. package/src/components/descriptions.js +68 -0
  125. package/src/components/drawer.js +179 -0
  126. package/src/components/dropdown.js +88 -0
  127. package/src/components/empty.js +41 -0
  128. package/src/components/float-button.js +90 -0
  129. package/src/components/form.js +106 -0
  130. package/src/components/hover-card.js +49 -0
  131. package/src/components/icon.js +87 -0
  132. package/src/components/image.js +97 -0
  133. package/src/components/index.js +117 -0
  134. package/src/components/input-group.js +75 -0
  135. package/src/components/input-number.js +155 -0
  136. package/src/components/input-otp.js +178 -0
  137. package/src/components/input.js +91 -0
  138. package/src/components/kbd.js +36 -0
  139. package/src/components/label.js +25 -0
  140. package/src/components/list.js +118 -0
  141. package/src/components/masked-input.js +236 -0
  142. package/src/components/mentions.js +165 -0
  143. package/src/components/menu.js +259 -0
  144. package/src/components/message.js +80 -0
  145. package/src/components/modal.js +147 -0
  146. package/src/components/navigation-menu.js +166 -0
  147. package/src/components/notification.js +84 -0
  148. package/src/components/pagination.js +104 -0
  149. package/src/components/placeholder.js +132 -0
  150. package/src/components/popconfirm.js +70 -0
  151. package/src/components/popover.js +58 -0
  152. package/src/components/progress.js +61 -0
  153. package/src/components/qrcode.js +251 -0
  154. package/src/components/radiogroup.js +120 -0
  155. package/src/components/range-slider.js +176 -0
  156. package/src/components/rate.js +186 -0
  157. package/src/components/resizable.js +83 -0
  158. package/src/components/result.js +57 -0
  159. package/src/components/scroll-area.js +43 -0
  160. package/src/components/segmented.js +97 -0
  161. package/src/components/select.js +165 -0
  162. package/src/components/separator.js +31 -0
  163. package/src/components/shell.js +407 -0
  164. package/src/components/skeleton.js +39 -0
  165. package/src/components/slider.js +141 -0
  166. package/src/components/sortable-list.js +176 -0
  167. package/src/components/space.js +42 -0
  168. package/src/components/spinner.js +112 -0
  169. package/src/components/splitter.js +147 -0
  170. package/src/components/statistic.js +136 -0
  171. package/src/components/steps.js +99 -0
  172. package/src/components/switch.js +95 -0
  173. package/src/components/table.js +44 -0
  174. package/src/components/tabs.js +216 -0
  175. package/src/components/tag.js +115 -0
  176. package/src/components/textarea.js +82 -0
  177. package/src/components/time-picker.js +153 -0
  178. package/src/components/time-range-picker.js +170 -0
  179. package/src/components/timeline.js +226 -0
  180. package/src/components/toast.js +71 -0
  181. package/src/components/toggle.js +213 -0
  182. package/src/components/tooltip.js +57 -0
  183. package/src/components/tour.js +159 -0
  184. package/src/components/transfer.js +163 -0
  185. package/src/components/tree-select.js +274 -0
  186. package/src/components/tree.js +141 -0
  187. package/src/components/typography.js +136 -0
  188. package/src/components/upload.js +118 -0
  189. package/src/components/visually-hidden.js +20 -0
  190. package/src/components/watermark.js +124 -0
  191. package/src/core/index.js +539 -0
  192. package/src/core/lifecycle.js +69 -0
  193. package/src/css/atoms.js +651 -0
  194. package/src/css/components.js +940 -0
  195. package/src/css/derive.js +1296 -0
  196. package/src/css/index.js +265 -0
  197. package/src/css/runtime.js +268 -0
  198. package/src/css/styles/addons/bioluminescent.js +93 -0
  199. package/src/css/styles/addons/clay.js +70 -0
  200. package/src/css/styles/addons/clean.js +57 -0
  201. package/src/css/styles/addons/command-center.js +143 -0
  202. package/src/css/styles/addons/dopamine.js +83 -0
  203. package/src/css/styles/addons/editorial.js +80 -0
  204. package/src/css/styles/addons/glassmorphism.js +99 -0
  205. package/src/css/styles/addons/liquid-glass.js +105 -0
  206. package/src/css/styles/addons/prismatic.js +100 -0
  207. package/src/css/styles/addons/retro.js +63 -0
  208. package/src/css/styles/auradecantism.js +96 -0
  209. package/src/css/theme-registry.js +444 -0
  210. package/src/data/entity.js +281 -0
  211. package/src/data/index.js +13 -0
  212. package/src/data/persist.js +225 -0
  213. package/src/data/query.js +839 -0
  214. package/src/data/realtime.js +299 -0
  215. package/src/data/url.js +177 -0
  216. package/src/data/worker.js +134 -0
  217. package/src/explorer/archetypes.js +243 -0
  218. package/src/explorer/atoms.js +228 -0
  219. package/src/explorer/charts.js +497 -0
  220. package/src/explorer/components.js +129 -0
  221. package/src/explorer/foundations.js +949 -0
  222. package/src/explorer/icons.js +178 -0
  223. package/src/explorer/patterns.js +247 -0
  224. package/src/explorer/recipes.js +194 -0
  225. package/src/explorer/shared/pattern-examples.js +1337 -0
  226. package/src/explorer/shared/showcase-renderer.js +958 -0
  227. package/src/explorer/shared/spec-table.js +41 -0
  228. package/src/explorer/shared/usage-links.js +87 -0
  229. package/src/explorer/shell-config.js +10 -0
  230. package/src/explorer/shells.js +551 -0
  231. package/src/explorer/styles.js +161 -0
  232. package/src/explorer/tokens.js +262 -0
  233. package/src/explorer/tools.js +525 -0
  234. package/src/form/index.js +804 -0
  235. package/src/i18n/index.js +251 -0
  236. package/src/icons/essential.js +479 -0
  237. package/src/icons/index.js +53 -0
  238. package/src/plugins/index.js +282 -0
  239. package/src/registry/archetypes/content-site.json +71 -0
  240. package/src/registry/archetypes/docs-explorer.json +23 -0
  241. package/src/registry/archetypes/ecommerce.json +104 -0
  242. package/src/registry/archetypes/financial-dashboard.json +77 -0
  243. package/src/registry/archetypes/index.json +41 -0
  244. package/src/registry/archetypes/portfolio.json +82 -0
  245. package/src/registry/archetypes/recipe-community.json +159 -0
  246. package/src/registry/archetypes/saas-dashboard.json +86 -0
  247. package/src/registry/architect/cross-cutting.json +45 -0
  248. package/src/registry/architect/domains/ecommerce.json +294 -0
  249. package/src/registry/architect/domains/financial-services.json +302 -0
  250. package/src/registry/architect/index.json +26 -0
  251. package/src/registry/architect/traits.json +379 -0
  252. package/src/registry/atoms.json +16 -0
  253. package/src/registry/chart-showcase.json +160 -0
  254. package/src/registry/chart.json +136 -0
  255. package/src/registry/components.json +8616 -0
  256. package/src/registry/core.json +216 -0
  257. package/src/registry/css.json +319 -0
  258. package/src/registry/data.json +135 -0
  259. package/src/registry/foundations.json +11 -0
  260. package/src/registry/icons.json +463 -0
  261. package/src/registry/index.json +101 -0
  262. package/src/registry/patterns/activity-feed.json +37 -0
  263. package/src/registry/patterns/article-content.json +27 -0
  264. package/src/registry/patterns/auth-form.json +37 -0
  265. package/src/registry/patterns/author-card.json +20 -0
  266. package/src/registry/patterns/card-grid.json +127 -0
  267. package/src/registry/patterns/category-nav.json +26 -0
  268. package/src/registry/patterns/chart-grid.json +36 -0
  269. package/src/registry/patterns/chat-interface.json +37 -0
  270. package/src/registry/patterns/checklist-card.json +55 -0
  271. package/src/registry/patterns/comparison-panel.json +27 -0
  272. package/src/registry/patterns/component-showcase.json +24 -0
  273. package/src/registry/patterns/contact-form.json +31 -0
  274. package/src/registry/patterns/cta-section.json +20 -0
  275. package/src/registry/patterns/data-table.json +37 -0
  276. package/src/registry/patterns/detail-header.json +83 -0
  277. package/src/registry/patterns/detail-panel.json +27 -0
  278. package/src/registry/patterns/explorer-shell.json +22 -0
  279. package/src/registry/patterns/filter-bar.json +33 -0
  280. package/src/registry/patterns/filter-sidebar.json +27 -0
  281. package/src/registry/patterns/form-sections.json +110 -0
  282. package/src/registry/patterns/goal-tracker.json +27 -0
  283. package/src/registry/patterns/hero.json +107 -0
  284. package/src/registry/patterns/index.json +47 -0
  285. package/src/registry/patterns/kpi-grid.json +36 -0
  286. package/src/registry/patterns/media-gallery.json +20 -0
  287. package/src/registry/patterns/order-history.json +20 -0
  288. package/src/registry/patterns/pagination.json +19 -0
  289. package/src/registry/patterns/photo-to-recipe.json +36 -0
  290. package/src/registry/patterns/pipeline-tracker.json +28 -0
  291. package/src/registry/patterns/post-list.json +27 -0
  292. package/src/registry/patterns/pricing-table.json +32 -0
  293. package/src/registry/patterns/scorecard.json +28 -0
  294. package/src/registry/patterns/search-bar.json +20 -0
  295. package/src/registry/patterns/specimen-grid.json +19 -0
  296. package/src/registry/patterns/stat-card.json +55 -0
  297. package/src/registry/patterns/stats-bar.json +55 -0
  298. package/src/registry/patterns/steps-card.json +55 -0
  299. package/src/registry/patterns/table-of-contents.json +19 -0
  300. package/src/registry/patterns/testimonials.json +21 -0
  301. package/src/registry/patterns/timeline.json +27 -0
  302. package/src/registry/patterns/token-inspector.json +21 -0
  303. package/src/registry/patterns/wizard.json +27 -0
  304. package/src/registry/recipe-auradecantism.json +69 -0
  305. package/src/registry/recipe-clean.json +65 -0
  306. package/src/registry/recipe-command-center.json +78 -0
  307. package/src/registry/router.json +73 -0
  308. package/src/registry/schema/README.md +197 -0
  309. package/src/registry/skeletons.json +259 -0
  310. package/src/registry/state.json +137 -0
  311. package/src/registry/tokens.json +40 -0
  312. package/src/router/hash.js +17 -0
  313. package/src/router/history.js +18 -0
  314. package/src/router/index.js +598 -0
  315. package/src/ssr/index.js +922 -0
  316. package/src/state/arrays.js +181 -0
  317. package/src/state/devtools.js +647 -0
  318. package/src/state/index.js +498 -0
  319. package/src/state/middleware.js +288 -0
  320. package/src/state/scheduler.js +206 -0
  321. package/src/state/store.js +300 -0
  322. package/src/tags/index.js +19 -0
  323. package/src/tannins/auth.js +396 -0
  324. package/src/test/dom.js +352 -0
  325. package/src/test/index.js +62 -0
  326. package/src/test/state.js +306 -0
  327. package/tools/a11y-audit.js +487 -0
  328. package/tools/analyzer.js +315 -0
  329. package/tools/audit.js +706 -0
  330. package/tools/builder.js +1422 -0
  331. package/tools/css-extract.js +188 -0
  332. package/tools/dev-server.js +316 -0
  333. package/tools/dts-gen.js +1260 -0
  334. package/tools/figma-components.js +329 -0
  335. package/tools/figma-patterns.js +516 -0
  336. package/tools/figma-plugin/code.js +453 -0
  337. package/tools/figma-plugin/manifest.json +14 -0
  338. package/tools/figma-plugin/ui.html +268 -0
  339. package/tools/figma-render.js +293 -0
  340. package/tools/figma-tokens.js +712 -0
  341. package/tools/figma-upload.js +318 -0
  342. package/tools/generate.js +738 -0
  343. package/tools/icons.js +133 -0
  344. package/tools/init-templates.js +265 -0
  345. package/tools/install-hooks.sh +5 -0
  346. package/tools/migrations/0.5.0.js +53 -0
  347. package/tools/migrations/0.6.0.js +95 -0
  348. package/tools/minify.js +170 -0
  349. package/tools/pre-commit +4 -0
  350. package/tools/registry.js +662 -0
  351. package/tools/reset-playground.js +61 -0
  352. package/tools/starter-templates/content-site/app.js +49 -0
  353. package/tools/starter-templates/content-site/essence.js +19 -0
  354. package/tools/starter-templates/content-site/pages.js +31 -0
  355. package/tools/starter-templates/ecommerce/app.js +50 -0
  356. package/tools/starter-templates/ecommerce/essence.js +19 -0
  357. package/tools/starter-templates/ecommerce/pages.js +31 -0
  358. package/tools/starter-templates/landing-page/app.js +38 -0
  359. package/tools/starter-templates/landing-page/essence.js +18 -0
  360. package/tools/starter-templates/landing-page/pages.js +21 -0
  361. package/tools/starter-templates/portfolio/app.js +45 -0
  362. package/tools/starter-templates/portfolio/essence.js +19 -0
  363. package/tools/starter-templates/portfolio/pages.js +33 -0
  364. package/tools/starter-templates/saas-dashboard/app.js +70 -0
  365. package/tools/starter-templates/saas-dashboard/essence.js +19 -0
  366. package/tools/starter-templates/saas-dashboard/pages.js +31 -0
  367. package/tools/verify-pack.js +203 -0
  368. package/types/chart.d.ts +77 -0
  369. package/types/components.d.ts +587 -0
  370. package/types/core.d.ts +89 -0
  371. package/types/css.d.ts +149 -0
  372. package/types/data.d.ts +238 -0
  373. package/types/form.d.ts +164 -0
  374. package/types/i18n.d.ts +51 -0
  375. package/types/icons.d.ts +27 -0
  376. package/types/index.d.ts +13 -0
  377. package/types/router.d.ts +116 -0
  378. package/types/ssr.d.ts +102 -0
  379. package/types/state.d.ts +83 -0
  380. package/types/tags.d.ts +62 -0
  381. package/types/tannins.d.ts +63 -0
  382. package/types/test.d.ts +48 -0
@@ -0,0 +1,1140 @@
1
+ /**
2
+ * Shared behavioral primitives for Decantr components.
3
+ * These composable systems are the foundation for 70+ components.
4
+ * Each behavior wires up event listeners, ARIA, and state management
5
+ * so individual components stay thin and focused.
6
+ */
7
+ import { createEffect, createSignal } from '../state/index.js';
8
+ import { h } from '../core/index.js';
9
+ import { icon } from './icon.js';
10
+
11
+ /**
12
+ * Shared caret (chevron arrow) using the icon system.
13
+ * Replaces inconsistent Unicode arrows across all components.
14
+ * @param {'down'|'up'|'right'|'left'} [direction='down']
15
+ * @param {Object} [opts] - Passed to icon(), plus optional `class`
16
+ * @returns {HTMLElement}
17
+ */
18
+ export function caret(direction = 'down', opts = {}) {
19
+ const cls = opts.class ? `d-caret ${opts.class}` : 'd-caret';
20
+ return icon(`chevron-${direction}`, { size: '1em', ...opts, class: cls });
21
+ }
22
+
23
+ // ─── OVERLAY SYSTEM ──────────────────────────────────────────────
24
+ // Used by: Tooltip, Popover, HoverCard, Dropdown, Select, Combobox,
25
+ // DatePicker, TimePicker, ColorPicker, Cascader, TreeSelect,
26
+ // Mentions, Command, NavigationMenu, ContextMenu, Popconfirm, Tour
27
+
28
+ /**
29
+ * Creates a managed overlay (floating layer) attached to a trigger element.
30
+ * Handles show/hide, click-outside, escape, and ARIA state.
31
+ *
32
+ * @param {HTMLElement} triggerEl - The element that triggers the overlay
33
+ * @param {HTMLElement} contentEl - The floating content element
34
+ * @param {Object} opts
35
+ * @param {'click'|'hover'|'manual'} [opts.trigger='click']
36
+ * @param {boolean} [opts.closeOnEscape=true]
37
+ * @param {boolean} [opts.closeOnOutside=true]
38
+ * @param {number} [opts.hoverDelay=200]
39
+ * @param {number} [opts.hoverCloseDelay=150]
40
+ * @param {Function} [opts.onOpen]
41
+ * @param {Function} [opts.onClose]
42
+ * @param {boolean} [opts.usePopover=false] - Use Popover API
43
+ * @returns {{ open: Function, close: Function, toggle: Function, isOpen: () => boolean, destroy: Function }}
44
+ */
45
+ export function createOverlay(triggerEl, contentEl, opts = {}) {
46
+ const {
47
+ trigger = 'click',
48
+ closeOnEscape = true,
49
+ closeOnOutside = true,
50
+ hoverDelay = 200,
51
+ hoverCloseDelay = 150,
52
+ onOpen,
53
+ onClose,
54
+ usePopover = false,
55
+ portal = false,
56
+ placement = 'bottom',
57
+ align = 'start',
58
+ offset = 2,
59
+ matchWidth = false,
60
+ } = opts;
61
+
62
+ let _open = false;
63
+ let _hoverTimer = null;
64
+ let _closeTimer = null;
65
+ const _cleanups = [];
66
+ let _posHandle = null;
67
+
68
+ if (portal) {
69
+ _posHandle = positionPanel(triggerEl, contentEl, { placement, align, offset, matchWidth });
70
+ }
71
+
72
+ function isOpen() { return _open; }
73
+
74
+ // Resolve portal target at open time — if the trigger lives inside a
75
+ // <dialog> shown via showModal() (top-layer), portal into that dialog
76
+ // so the dropdown isn't hidden behind the top-layer backdrop.
77
+ function portalTarget() {
78
+ const dlg = triggerEl.closest('dialog');
79
+ return dlg || document.body;
80
+ }
81
+
82
+ function open() {
83
+ if (_open) return;
84
+ _open = true;
85
+ if (portal) {
86
+ const target = portalTarget();
87
+ if (contentEl.parentNode !== target) target.appendChild(contentEl);
88
+ }
89
+ if (usePopover && contentEl.showPopover) {
90
+ contentEl.showPopover();
91
+ } else {
92
+ contentEl.style.display = '';
93
+ }
94
+ if (_posHandle) _posHandle.reposition();
95
+ triggerEl.setAttribute('aria-expanded', 'true');
96
+ if (onOpen) onOpen();
97
+ }
98
+
99
+ function close() {
100
+ if (!_open) return;
101
+ _open = false;
102
+ if (usePopover && contentEl.hidePopover) {
103
+ try { contentEl.hidePopover(); } catch (_) {}
104
+ } else {
105
+ contentEl.style.display = 'none';
106
+ }
107
+ triggerEl.setAttribute('aria-expanded', 'false');
108
+ if (onClose) onClose();
109
+ }
110
+
111
+ function toggle() { _open ? close() : open(); }
112
+
113
+ // --- Wire up triggers ---
114
+ if (trigger === 'click') {
115
+ const onClick = (e) => { e.stopPropagation(); toggle(); };
116
+ triggerEl.addEventListener('click', onClick);
117
+ _cleanups.push(() => triggerEl.removeEventListener('click', onClick));
118
+ }
119
+
120
+ if (trigger === 'hover') {
121
+ const onEnter = () => {
122
+ clearTimeout(_closeTimer);
123
+ _hoverTimer = setTimeout(open, hoverDelay);
124
+ };
125
+ const onLeave = () => {
126
+ clearTimeout(_hoverTimer);
127
+ _closeTimer = setTimeout(close, hoverCloseDelay);
128
+ };
129
+ triggerEl.addEventListener('mouseenter', onEnter);
130
+ triggerEl.addEventListener('mouseleave', onLeave);
131
+ contentEl.addEventListener('mouseenter', () => clearTimeout(_closeTimer));
132
+ contentEl.addEventListener('mouseleave', onLeave);
133
+ _cleanups.push(
134
+ () => triggerEl.removeEventListener('mouseenter', onEnter),
135
+ () => triggerEl.removeEventListener('mouseleave', onLeave)
136
+ );
137
+ }
138
+
139
+ // Escape to close
140
+ if (closeOnEscape) {
141
+ const onKey = (e) => { if (e.key === 'Escape' && _open) { close(); triggerEl.focus(); } };
142
+ document.addEventListener('keydown', onKey, true);
143
+ _cleanups.push(() => document.removeEventListener('keydown', onKey, true));
144
+ }
145
+
146
+ // Click outside to close
147
+ if (closeOnOutside && trigger !== 'hover') {
148
+ const onDoc = (e) => {
149
+ if (_open && !triggerEl.contains(e.target) && !contentEl.contains(e.target)) close();
150
+ };
151
+ document.addEventListener('mousedown', onDoc);
152
+ _cleanups.push(() => document.removeEventListener('mousedown', onDoc));
153
+ }
154
+
155
+ // Popover API toggle sync
156
+ if (usePopover) {
157
+ const onToggle = (e) => {
158
+ _open = e.newState === 'open';
159
+ triggerEl.setAttribute('aria-expanded', String(_open));
160
+ if (!_open && onClose) onClose();
161
+ };
162
+ contentEl.addEventListener('toggle', onToggle);
163
+ _cleanups.push(() => contentEl.removeEventListener('toggle', onToggle));
164
+ }
165
+
166
+ // Initial state: hidden
167
+ if (!usePopover) contentEl.style.display = 'none';
168
+
169
+ function destroy() {
170
+ _cleanups.forEach(fn => fn());
171
+ if (_posHandle) _posHandle.destroy();
172
+ if (portal && contentEl.parentNode) {
173
+ contentEl.parentNode.removeChild(contentEl);
174
+ }
175
+ }
176
+
177
+ return { open, close, toggle, isOpen, destroy };
178
+ }
179
+
180
+
181
+ // ─── PANEL POSITIONING ──────────────────────────────────────────
182
+ // Used by: Select, Combobox, DatePicker, Cascader, TreeSelect,
183
+ // Mentions — any dropdown that must escape overflow/stacking contexts
184
+
185
+ /**
186
+ * Positions a panel element relative to a trigger using position:fixed
187
+ * + getBoundingClientRect(). Escapes all overflow containers and
188
+ * stacking contexts by computing coordinates in viewport space.
189
+ *
190
+ * @param {HTMLElement} triggerEl
191
+ * @param {HTMLElement} panelEl
192
+ * @param {Object} [opts]
193
+ * @param {'bottom'|'top'} [opts.placement='bottom']
194
+ * @param {'start'|'center'|'end'} [opts.align='start']
195
+ * @param {number} [opts.offset=2] - Gap in px between trigger and panel
196
+ * @param {boolean} [opts.matchWidth=false] - Set panel width to trigger width
197
+ * @param {boolean} [opts.flip=true] - Flip placement if panel overflows viewport
198
+ * @returns {{ reposition: Function, destroy: Function }}
199
+ */
200
+ export function positionPanel(triggerEl, panelEl, opts = {}) {
201
+ const {
202
+ placement = 'bottom',
203
+ align = 'start',
204
+ offset = 2,
205
+ matchWidth = false,
206
+ flip = true,
207
+ } = opts;
208
+
209
+ let _rafId = null;
210
+ let _listening = false;
211
+ const EDGE_PAD = 8;
212
+
213
+ function reposition() {
214
+ if (!triggerEl.isConnected) {
215
+ panelEl.style.display = 'none';
216
+ return;
217
+ }
218
+
219
+ const tr = triggerEl.getBoundingClientRect();
220
+ const panelEl_display = panelEl.style.display;
221
+ // Ensure panel is measurable
222
+ if (panelEl.style.display === 'none') panelEl.style.display = '';
223
+ const pr = panelEl.getBoundingClientRect();
224
+ panelEl.style.display = panelEl_display === 'none' ? panelEl_display : '';
225
+
226
+ panelEl.style.position = 'fixed';
227
+ panelEl.style.right = 'auto';
228
+ panelEl.style.margin = '0';
229
+
230
+ if (matchWidth) panelEl.style.width = `${tr.width}px`;
231
+
232
+ // Determine vertical placement
233
+ let usePlacement = placement;
234
+ if (flip) {
235
+ const spaceBelow = window.innerHeight - tr.bottom - offset;
236
+ const spaceAbove = tr.top - offset;
237
+ if (usePlacement === 'bottom' && pr.height > spaceBelow && spaceAbove > spaceBelow) {
238
+ usePlacement = 'top';
239
+ } else if (usePlacement === 'top' && pr.height > spaceAbove && spaceBelow > spaceAbove) {
240
+ usePlacement = 'bottom';
241
+ }
242
+ }
243
+
244
+ let top;
245
+ if (usePlacement === 'bottom') {
246
+ top = tr.bottom + offset;
247
+ } else {
248
+ top = tr.top - pr.height - offset;
249
+ }
250
+
251
+ // Horizontal alignment
252
+ let left;
253
+ if (align === 'start') left = tr.left;
254
+ else if (align === 'end') left = tr.right - pr.width;
255
+ else left = tr.left + (tr.width - pr.width) / 2;
256
+
257
+ // Clamp to viewport edges
258
+ const pw = matchWidth ? tr.width : pr.width;
259
+ if (left + pw > window.innerWidth - EDGE_PAD) left = window.innerWidth - EDGE_PAD - pw;
260
+ if (left < EDGE_PAD) left = EDGE_PAD;
261
+ if (top + pr.height > window.innerHeight - EDGE_PAD) top = window.innerHeight - EDGE_PAD - pr.height;
262
+ if (top < EDGE_PAD) top = EDGE_PAD;
263
+
264
+ panelEl.style.top = `${top}px`;
265
+ panelEl.style.left = `${left}px`;
266
+ }
267
+
268
+ function onScrollOrResize() {
269
+ if (_rafId) return;
270
+ _rafId = requestAnimationFrame(() => {
271
+ _rafId = null;
272
+ reposition();
273
+ });
274
+ }
275
+
276
+ function startListening() {
277
+ if (_listening) return;
278
+ _listening = true;
279
+ window.addEventListener('scroll', onScrollOrResize, true);
280
+ window.addEventListener('resize', onScrollOrResize);
281
+ }
282
+
283
+ function stopListening() {
284
+ if (!_listening) return;
285
+ _listening = false;
286
+ window.removeEventListener('scroll', onScrollOrResize, true);
287
+ window.removeEventListener('resize', onScrollOrResize);
288
+ if (_rafId) { cancelAnimationFrame(_rafId); _rafId = null; }
289
+ }
290
+
291
+ startListening();
292
+
293
+ function destroy() {
294
+ stopListening();
295
+ }
296
+
297
+ return { reposition, destroy };
298
+ }
299
+
300
+
301
+ // ─── LISTBOX SYSTEM ──────────────────────────────────────────────
302
+ // Used by: Select, Combobox, Command, Cascader, TreeSelect,
303
+ // Transfer, Mentions, AutoComplete, ContextMenu, Dropdown
304
+
305
+ /**
306
+ * Keyboard navigation + selection for a list of options.
307
+ * Manages active-descendant, arrow keys, enter/space selection,
308
+ * type-ahead search, and multi-select.
309
+ *
310
+ * @param {HTMLElement} containerEl - The listbox container element
311
+ * @param {Object} opts
312
+ * @param {string} [opts.itemSelector='.d-option'] - CSS selector for option elements
313
+ * @param {string} [opts.activeClass='d-option-active'] - Class for highlighted item
314
+ * @param {string} [opts.disabledSelector='.d-option-disabled']
315
+ * @param {boolean} [opts.loop=true] - Loop navigation
316
+ * @param {'vertical'|'horizontal'} [opts.orientation='vertical']
317
+ * @param {boolean} [opts.multiSelect=false]
318
+ * @param {boolean} [opts.typeAhead=false]
319
+ * @param {Function} [opts.onSelect] - Called with (element, index) on selection
320
+ * @param {Function} [opts.onHighlight] - Called with (element, index) when highlight changes
321
+ * @returns {{ highlight: Function, getActiveIndex: () => number, setItems: Function, reset: Function, handleKeydown: Function, destroy: Function }}
322
+ */
323
+ export function createListbox(containerEl, opts = {}) {
324
+ const {
325
+ itemSelector = '.d-option',
326
+ activeClass = 'd-option-active',
327
+ disabledSelector = '.d-option-disabled',
328
+ loop = true,
329
+ orientation = 'vertical',
330
+ multiSelect = false,
331
+ typeAhead = false,
332
+ onSelect,
333
+ onHighlight,
334
+ } = opts;
335
+
336
+ let activeIndex = -1;
337
+ let _typeBuffer = '';
338
+ let _typeTimer = null;
339
+
340
+ function getItems() {
341
+ return [...containerEl.querySelectorAll(itemSelector)];
342
+ }
343
+
344
+ function getSelectableItems() {
345
+ return getItems().filter(el => !el.matches(disabledSelector));
346
+ }
347
+
348
+ function highlight(index) {
349
+ const items = getItems();
350
+ items.forEach((el, i) => {
351
+ el.classList.toggle(activeClass, i === index);
352
+ el.setAttribute('aria-selected', i === index ? 'true' : 'false');
353
+ });
354
+ activeIndex = index;
355
+ // Scroll into view
356
+ if (items[index]) items[index].scrollIntoView?.({ block: 'nearest' });
357
+ if (onHighlight && items[index]) onHighlight(items[index], index);
358
+ }
359
+
360
+ function highlightNext() {
361
+ const items = getItems();
362
+ if (!items.length) return;
363
+ let next = activeIndex + 1;
364
+ // Skip disabled
365
+ while (next < items.length && items[next]?.matches(disabledSelector)) next++;
366
+ if (next >= items.length) next = loop ? 0 : items.length - 1;
367
+ highlight(next);
368
+ }
369
+
370
+ function highlightPrev() {
371
+ const items = getItems();
372
+ if (!items.length) return;
373
+ let prev = activeIndex - 1;
374
+ while (prev >= 0 && items[prev]?.matches(disabledSelector)) prev--;
375
+ if (prev < 0) prev = loop ? items.length - 1 : 0;
376
+ highlight(prev);
377
+ }
378
+
379
+ function selectCurrent() {
380
+ const items = getItems();
381
+ if (activeIndex >= 0 && items[activeIndex] && !items[activeIndex].matches(disabledSelector)) {
382
+ if (onSelect) onSelect(items[activeIndex], activeIndex);
383
+ }
384
+ }
385
+
386
+ function handleTypeAhead(char) {
387
+ if (!typeAhead) return;
388
+ clearTimeout(_typeTimer);
389
+ _typeBuffer += char.toLowerCase();
390
+ _typeTimer = setTimeout(() => { _typeBuffer = ''; }, 500);
391
+ const items = getItems();
392
+ const idx = items.findIndex(el =>
393
+ el.textContent.trim().toLowerCase().startsWith(_typeBuffer) && !el.matches(disabledSelector)
394
+ );
395
+ if (idx >= 0) highlight(idx);
396
+ }
397
+
398
+ const downKey = orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight';
399
+ const upKey = orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft';
400
+
401
+ function handleKeydown(e) {
402
+ if (e.key === downKey) { e.preventDefault(); highlightNext(); }
403
+ else if (e.key === upKey) { e.preventDefault(); highlightPrev(); }
404
+ else if (e.key === 'Home') { e.preventDefault(); highlight(0); }
405
+ else if (e.key === 'End') { e.preventDefault(); highlight(getItems().length - 1); }
406
+ else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); selectCurrent(); }
407
+ else if (e.key.length === 1 && typeAhead) { handleTypeAhead(e.key); }
408
+ }
409
+
410
+ containerEl.addEventListener('keydown', handleKeydown);
411
+
412
+ function reset() { activeIndex = -1; highlight(-1); }
413
+ function getActiveIndex() { return activeIndex; }
414
+ function destroy() { containerEl.removeEventListener('keydown', handleKeydown); }
415
+
416
+ return { highlight, highlightNext, highlightPrev, selectCurrent, getActiveIndex, reset, handleKeydown, destroy };
417
+ }
418
+
419
+
420
+ // ─── DISCLOSURE SYSTEM ───────────────────────────────────────────
421
+ // Used by: Accordion, Collapsible, Tree, NavigationMenu sections
422
+
423
+ /**
424
+ * Expand/collapse with smooth height animation.
425
+ *
426
+ * @param {HTMLElement} triggerEl
427
+ * @param {HTMLElement} contentEl
428
+ * @param {Object} opts
429
+ * @param {boolean} [opts.defaultOpen=false]
430
+ * @param {boolean} [opts.animate=true]
431
+ * @param {Function} [opts.onToggle]
432
+ * @returns {{ open: Function, close: Function, toggle: Function, isOpen: () => boolean }}
433
+ */
434
+ export function createDisclosure(triggerEl, contentEl, opts = {}) {
435
+ const { defaultOpen = false, animate = true, onToggle } = opts;
436
+ let _open = defaultOpen;
437
+
438
+ // Wrapper for height animation
439
+ const region = contentEl.parentElement?.classList.contains('d-disclosure-region')
440
+ ? contentEl.parentElement
441
+ : contentEl;
442
+
443
+ function syncState() {
444
+ triggerEl.setAttribute('aria-expanded', String(_open));
445
+ if (_open) {
446
+ if (animate && region !== contentEl) {
447
+ region.style.height = '0';
448
+ region.style.overflow = 'hidden';
449
+ region.style.display = '';
450
+ const h = contentEl.scrollHeight;
451
+ region.style.height = h + 'px';
452
+ const onEnd = () => { region.style.height = 'auto'; region.style.overflow = ''; region.removeEventListener('transitionend', onEnd); };
453
+ region.addEventListener('transitionend', onEnd);
454
+ } else {
455
+ region.style.display = '';
456
+ region.style.height = 'auto';
457
+ }
458
+ } else {
459
+ if (animate && region !== contentEl) {
460
+ region.style.height = region.scrollHeight + 'px';
461
+ region.offsetHeight; // force reflow
462
+ region.style.overflow = 'hidden';
463
+ region.style.height = '0';
464
+ const onEnd = () => { region.style.display = 'none'; region.removeEventListener('transitionend', onEnd); };
465
+ region.addEventListener('transitionend', onEnd);
466
+ } else {
467
+ region.style.display = 'none';
468
+ }
469
+ }
470
+ if (onToggle) onToggle(_open);
471
+ }
472
+
473
+ function open() { _open = true; syncState(); }
474
+ function close() { _open = false; syncState(); }
475
+ function toggle() { _open = !_open; syncState(); }
476
+ function isOpen() { return _open; }
477
+
478
+ triggerEl.addEventListener('click', toggle);
479
+ triggerEl.addEventListener('keydown', (e) => {
480
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); }
481
+ });
482
+
483
+ // Initial state
484
+ if (!_open) { region.style.display = 'none'; region.style.height = '0'; }
485
+ triggerEl.setAttribute('aria-expanded', String(_open));
486
+
487
+ return { open, close, toggle, isOpen };
488
+ }
489
+
490
+
491
+ // ─── ROVING TABINDEX ─────────────────────────────────────────────
492
+ // Used by: Tabs, RadioGroup, ToggleGroup, Segmented, Menu, Menubar,
493
+ // ButtonGroup, Toolbar
494
+
495
+ /**
496
+ * Manages keyboard navigation within a group via roving tabindex pattern.
497
+ * Only one element in the group has tabindex=0; the rest have tabindex=-1.
498
+ *
499
+ * @param {HTMLElement} containerEl
500
+ * @param {Object} opts
501
+ * @param {string} [opts.itemSelector='[role="tab"]'] - Selector for navigable items
502
+ * @param {'horizontal'|'vertical'|'both'} [opts.orientation='horizontal']
503
+ * @param {boolean} [opts.loop=true]
504
+ * @param {Function} [opts.onFocus] - Called with (element, index) when focus changes
505
+ * @returns {{ focus: Function, setActive: Function, getActive: () => number, destroy: Function }}
506
+ */
507
+ export function createRovingTabindex(containerEl, opts = {}) {
508
+ const {
509
+ itemSelector = '[role="tab"]',
510
+ orientation = 'horizontal',
511
+ loop = true,
512
+ onFocus,
513
+ } = opts;
514
+
515
+ let activeIdx = 0;
516
+
517
+ function getItems() {
518
+ return [...containerEl.querySelectorAll(itemSelector)];
519
+ }
520
+
521
+ function setActive(index) {
522
+ const items = getItems();
523
+ items.forEach((el, i) => {
524
+ el.setAttribute('tabindex', i === index ? '0' : '-1');
525
+ });
526
+ activeIdx = index;
527
+ }
528
+
529
+ function focus(index) {
530
+ const items = getItems();
531
+ if (index < 0 || index >= items.length) return;
532
+ setActive(index);
533
+ items[index].focus();
534
+ if (onFocus) onFocus(items[index], index);
535
+ }
536
+
537
+ function move(delta) {
538
+ const items = getItems();
539
+ if (!items.length) return;
540
+ let next = activeIdx + delta;
541
+ if (loop) {
542
+ next = (next + items.length) % items.length;
543
+ } else {
544
+ next = Math.max(0, Math.min(next, items.length - 1));
545
+ }
546
+ focus(next);
547
+ }
548
+
549
+ const hKeys = { next: 'ArrowRight', prev: 'ArrowLeft' };
550
+ const vKeys = { next: 'ArrowDown', prev: 'ArrowUp' };
551
+
552
+ function onKeydown(e) {
553
+ const horiz = orientation === 'horizontal' || orientation === 'both';
554
+ const vert = orientation === 'vertical' || orientation === 'both';
555
+
556
+ if (horiz && e.key === hKeys.next) { e.preventDefault(); move(1); }
557
+ else if (horiz && e.key === hKeys.prev) { e.preventDefault(); move(-1); }
558
+ else if (vert && e.key === vKeys.next) { e.preventDefault(); move(1); }
559
+ else if (vert && e.key === vKeys.prev) { e.preventDefault(); move(-1); }
560
+ else if (e.key === 'Home') { e.preventDefault(); focus(0); }
561
+ else if (e.key === 'End') { e.preventDefault(); focus(getItems().length - 1); }
562
+ }
563
+
564
+ containerEl.addEventListener('keydown', onKeydown);
565
+
566
+ // Initialize tabindex
567
+ setActive(activeIdx);
568
+
569
+ function destroy() { containerEl.removeEventListener('keydown', onKeydown); }
570
+ function getActive() { return activeIdx; }
571
+
572
+ return { focus, setActive, getActive, destroy };
573
+ }
574
+
575
+
576
+ // ─── FOCUS TRAP ──────────────────────────────────────────────────
577
+ // Used by: Modal, Drawer, AlertDialog, Command
578
+
579
+ /**
580
+ * Traps focus within a container. Tab/Shift+Tab cycle within focusable elements.
581
+ *
582
+ * @param {HTMLElement} containerEl
583
+ * @returns {{ activate: Function, deactivate: Function }}
584
+ */
585
+ export function createFocusTrap(containerEl) {
586
+ const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
587
+ let _active = false;
588
+
589
+ function getFocusable() {
590
+ return [...containerEl.querySelectorAll(FOCUSABLE)].filter(el => el.offsetParent !== null);
591
+ }
592
+
593
+ function onKeydown(e) {
594
+ if (!_active || e.key !== 'Tab') return;
595
+ const focusable = getFocusable();
596
+ if (!focusable.length) return;
597
+ const first = focusable[0];
598
+ const last = focusable[focusable.length - 1];
599
+ if (e.shiftKey) {
600
+ if (document.activeElement === first) { e.preventDefault(); last.focus(); }
601
+ } else {
602
+ if (document.activeElement === last) { e.preventDefault(); first.focus(); }
603
+ }
604
+ }
605
+
606
+ function activate() {
607
+ _active = true;
608
+ containerEl.addEventListener('keydown', onKeydown);
609
+ // Focus first focusable element
610
+ const first = getFocusable()[0];
611
+ if (first) requestAnimationFrame(() => first.focus());
612
+ }
613
+
614
+ function deactivate() {
615
+ _active = false;
616
+ containerEl.removeEventListener('keydown', onKeydown);
617
+ }
618
+
619
+ return { activate, deactivate };
620
+ }
621
+
622
+
623
+ // ─── FORM FIELD WRAPPER ──────────────────────────────────────────
624
+ // Used by: ALL form inputs (Input, Select, Checkbox, etc.)
625
+
626
+ /**
627
+ * Wraps a form control with label, help text, error message, and required indicator.
628
+ * Returns the wrapper element; the control is placed inside.
629
+ *
630
+ * @param {HTMLElement} controlEl - The actual input/select/textarea element
631
+ * @param {Object} opts
632
+ * @param {string} [opts.label]
633
+ * @param {string|Function} [opts.error]
634
+ * @param {string} [opts.help]
635
+ * @param {boolean} [opts.required]
636
+ * @param {string} [opts.class]
637
+ * @returns {HTMLElement}
638
+ */
639
+ export function createFormField(controlEl, opts = {}) {
640
+ const { label, error, success, help, required, variant, size, class: cls } = opts;
641
+
642
+ const id = controlEl.id || `d-form-field-${_fieldId++}`;
643
+ controlEl.id = id;
644
+
645
+ const wrapCls = ['d-form-field'];
646
+ if (variant) wrapCls.push(`d-form-field-${variant}`);
647
+ if (size) wrapCls.push(`d-form-field-${size}`);
648
+ if (cls) wrapCls.push(cls);
649
+
650
+ const wrapper = h('div', { class: wrapCls.join(' ') });
651
+
652
+ if (label) {
653
+ const labelEl = h('label', { class: 'd-form-field-label', for: id });
654
+ labelEl.textContent = label;
655
+ if (required) {
656
+ labelEl.appendChild(h('span', { class: 'd-form-field-required', 'aria-hidden': 'true' }, ' *'));
657
+ }
658
+ wrapper.appendChild(labelEl);
659
+ }
660
+
661
+ wrapper.appendChild(controlEl);
662
+
663
+ if (help) {
664
+ const helpId = `${id}-help`;
665
+ const helpEl = h('div', { class: 'd-form-field-help', id: helpId }, help);
666
+ controlEl.setAttribute('aria-describedby', helpId);
667
+ wrapper.appendChild(helpEl);
668
+ }
669
+
670
+ const errId = `${id}-error`;
671
+ const errEl = h('div', { class: 'd-form-field-error', id: errId, role: 'alert' });
672
+ wrapper.appendChild(errEl);
673
+ errEl.style.display = 'none';
674
+
675
+ if (error) {
676
+ if (typeof error === 'function') {
677
+ createEffect(() => {
678
+ const msg = error();
679
+ errEl.textContent = msg || '';
680
+ errEl.style.display = msg ? '' : 'none';
681
+ controlEl.setAttribute('aria-invalid', msg ? 'true' : 'false');
682
+ wrapper.toggleAttribute('data-error', !!msg);
683
+ if (msg) controlEl.setAttribute('aria-errormessage', errId);
684
+ else controlEl.removeAttribute('aria-errormessage');
685
+ });
686
+ } else {
687
+ errEl.textContent = typeof error === 'string' ? error : '';
688
+ errEl.style.display = '';
689
+ controlEl.setAttribute('aria-invalid', 'true');
690
+ controlEl.setAttribute('aria-errormessage', errId);
691
+ wrapper.setAttribute('data-error', '');
692
+ }
693
+ }
694
+
695
+ // Reactive success
696
+ if (success) {
697
+ if (typeof success === 'function') {
698
+ createEffect(() => {
699
+ const v = success();
700
+ wrapper.toggleAttribute('data-success', !!v);
701
+ });
702
+ } else {
703
+ wrapper.setAttribute('data-success', '');
704
+ }
705
+ }
706
+
707
+ function setError(msg) {
708
+ errEl.textContent = msg || '';
709
+ errEl.style.display = msg ? '' : 'none';
710
+ controlEl.setAttribute('aria-invalid', msg ? 'true' : 'false');
711
+ wrapper.toggleAttribute('data-error', !!msg);
712
+ if (msg) controlEl.setAttribute('aria-errormessage', errId);
713
+ else controlEl.removeAttribute('aria-errormessage');
714
+ }
715
+
716
+ function setSuccess(v) {
717
+ wrapper.toggleAttribute('data-success', !!v);
718
+ }
719
+
720
+ function destroy() {}
721
+
722
+ return { wrapper, setError, setSuccess, destroy };
723
+ }
724
+
725
+ let _fieldId = 0;
726
+
727
+
728
+ // ─── DRAG SYSTEM ─────────────────────────────────────────────────
729
+ // Used by: Slider, Resizable, Transfer, DnD sorting
730
+
731
+ /**
732
+ * Lightweight drag handler for pointer-based interactions.
733
+ *
734
+ * @param {HTMLElement} el - The element to make draggable
735
+ * @param {Object} opts
736
+ * @param {Function} opts.onMove - Called with (x, y, dx, dy, event)
737
+ * @param {Function} [opts.onStart]
738
+ * @param {Function} [opts.onEnd]
739
+ * @returns {{ destroy: Function }}
740
+ */
741
+ export function createDrag(el, opts) {
742
+ const { onMove, onStart, onEnd } = opts;
743
+ let startX, startY;
744
+
745
+ function onPointerDown(e) {
746
+ if (e.button !== 0) return;
747
+ startX = e.clientX;
748
+ startY = e.clientY;
749
+ e.preventDefault();
750
+ if (onStart) onStart(startX, startY, e);
751
+ document.addEventListener('pointermove', onPointerMove);
752
+ document.addEventListener('pointerup', onPointerUp);
753
+ }
754
+
755
+ function onPointerMove(e) {
756
+ onMove(e.clientX, e.clientY, e.clientX - startX, e.clientY - startY, e);
757
+ }
758
+
759
+ function onPointerUp(e) {
760
+ document.removeEventListener('pointermove', onPointerMove);
761
+ document.removeEventListener('pointerup', onPointerUp);
762
+ if (onEnd) onEnd(e.clientX, e.clientY, e);
763
+ }
764
+
765
+ el.addEventListener('pointerdown', onPointerDown);
766
+
767
+ return { destroy: () => el.removeEventListener('pointerdown', onPointerDown) };
768
+ }
769
+
770
+
771
+ // ─── VIRTUAL SCROLL (Large lists) ────────────────────────────────
772
+ // Used by: DataTable, Tree (large), Transfer, Select (many options)
773
+
774
+ /**
775
+ * Simple virtual scroller for rendering large lists efficiently.
776
+ * Only renders items visible in the viewport + buffer.
777
+ *
778
+ * @param {HTMLElement} containerEl - The scrollable container
779
+ * @param {Object} opts
780
+ * @param {number} opts.itemHeight - Fixed item height in px
781
+ * @param {number} opts.totalItems - Total number of items
782
+ * @param {number} [opts.buffer=5] - Extra items to render above/below
783
+ * @param {Function} opts.renderItem - (index) => HTMLElement
784
+ * @returns {{ refresh: Function, setTotal: Function, destroy: Function }}
785
+ */
786
+ export function createVirtualScroll(containerEl, opts) {
787
+ let { itemHeight, totalItems, buffer = 5, renderItem } = opts;
788
+
789
+ const spacer = h('div', { style: { height: `${totalItems * itemHeight}px`, position: 'relative' } });
790
+ const content = h('div', { style: { position: 'absolute', top: '0', left: '0', right: '0' } });
791
+ spacer.appendChild(content);
792
+ containerEl.appendChild(spacer);
793
+
794
+ let _lastStart = -1, _lastEnd = -1;
795
+
796
+ function render() {
797
+ const scrollTop = containerEl.scrollTop;
798
+ const viewportH = containerEl.clientHeight;
799
+ const start = Math.max(0, Math.floor(scrollTop / itemHeight) - buffer);
800
+ const end = Math.min(totalItems, Math.ceil((scrollTop + viewportH) / itemHeight) + buffer);
801
+
802
+ if (start === _lastStart && end === _lastEnd) return;
803
+ _lastStart = start;
804
+ _lastEnd = end;
805
+
806
+ content.style.top = `${start * itemHeight}px`;
807
+ content.replaceChildren();
808
+ for (let i = start; i < end; i++) {
809
+ content.appendChild(renderItem(i));
810
+ }
811
+ }
812
+
813
+ containerEl.addEventListener('scroll', render, { passive: true });
814
+ render();
815
+
816
+ function refresh() { _lastStart = -1; render(); }
817
+ function setTotal(n) { totalItems = n; spacer.style.height = `${n * itemHeight}px`; refresh(); }
818
+ function destroy() { containerEl.removeEventListener('scroll', render); }
819
+
820
+ return { refresh, setTotal, destroy };
821
+ }
822
+
823
+
824
+ // ─── HOTKEY SYSTEM ────────────────────────────────────────────────
825
+ // Used by: Command, Modal, custom app shortcuts
826
+
827
+ /**
828
+ * Registers keyboard shortcuts on an element (or document).
829
+ * Handles modifier normalization (Meta=Ctrl on Mac), chord sequences,
830
+ * and cleanup on destroy.
831
+ *
832
+ * @param {HTMLElement|Document} el - Scope element for key events
833
+ * @param {Object<string, Function>} bindings - Map of shortcut string to handler.
834
+ * Shortcut format: 'ctrl+k', 'shift+alt+n', 'meta+enter', 'g g' (chord).
835
+ * Modifiers: ctrl, shift, alt, meta. On Mac, 'ctrl' matches both Ctrl and Meta.
836
+ * @returns {{ destroy: Function, update: Function }}
837
+ */
838
+ export function createHotkey(el, bindings) {
839
+ const isMac = typeof navigator !== 'undefined' && /mac|ipod|iphone|ipad/i.test(navigator.userAgentData?.platform || navigator.userAgent || '');
840
+ let _chordKey = null;
841
+ let _chordTimer = null;
842
+
843
+ function parseCombo(str) {
844
+ const parts = str.toLowerCase().trim().split('+');
845
+ const key = parts.pop();
846
+ const mods = { ctrl: false, shift: false, alt: false, meta: false };
847
+ for (const p of parts) {
848
+ if (p === 'ctrl' || p === 'control') mods.ctrl = true;
849
+ else if (p === 'shift') mods.shift = true;
850
+ else if (p === 'alt' || p === 'option') mods.alt = true;
851
+ else if (p === 'meta' || p === 'cmd' || p === 'command') mods.meta = true;
852
+ }
853
+ return { key, mods };
854
+ }
855
+
856
+ function matchMods(e, mods) {
857
+ const ctrl = mods.ctrl ? (isMac ? (e.ctrlKey || e.metaKey) : e.ctrlKey) : (!e.ctrlKey && !e.metaKey);
858
+ const shift = mods.shift ? e.shiftKey : !e.shiftKey;
859
+ const alt = mods.alt ? e.altKey : !e.altKey;
860
+ // If meta was explicitly required but we already matched via ctrl on Mac, skip separate meta check
861
+ if (mods.meta && !isMac) return ctrl && shift && alt && e.metaKey;
862
+ return ctrl && shift && alt;
863
+ }
864
+
865
+ function matchKey(e, key) {
866
+ if (key === 'enter') return e.key === 'Enter';
867
+ if (key === 'escape' || key === 'esc') return e.key === 'Escape';
868
+ if (key === 'space') return e.key === ' ';
869
+ if (key === 'tab') return e.key === 'Tab';
870
+ if (key === 'backspace') return e.key === 'Backspace';
871
+ if (key === 'delete') return e.key === 'Delete';
872
+ if (key === 'up') return e.key === 'ArrowUp';
873
+ if (key === 'down') return e.key === 'ArrowDown';
874
+ if (key === 'left') return e.key === 'ArrowLeft';
875
+ if (key === 'right') return e.key === 'ArrowRight';
876
+ return e.key.toLowerCase() === key;
877
+ }
878
+
879
+ const parsed = [];
880
+ let _bindings = bindings;
881
+
882
+ function rebuild() {
883
+ parsed.length = 0;
884
+ for (const [shortcut, handler] of Object.entries(_bindings)) {
885
+ const chordParts = shortcut.split(/\s+/);
886
+ if (chordParts.length === 2) {
887
+ parsed.push({ type: 'chord', first: parseCombo(chordParts[0]), second: parseCombo(chordParts[1]), handler });
888
+ } else {
889
+ parsed.push({ type: 'single', combo: parseCombo(shortcut), handler });
890
+ }
891
+ }
892
+ }
893
+ rebuild();
894
+
895
+ function onKeydown(e) {
896
+ // Check chords first
897
+ if (_chordKey) {
898
+ const chord = _chordKey;
899
+ _chordKey = null;
900
+ clearTimeout(_chordTimer);
901
+ for (const entry of parsed) {
902
+ if (entry.type === 'chord' && entry.first.key === chord && matchKey(e, entry.second.key) && matchMods(e, entry.second.mods)) {
903
+ e.preventDefault();
904
+ entry.handler(e);
905
+ return;
906
+ }
907
+ }
908
+ }
909
+
910
+ // Check chord starters
911
+ for (const entry of parsed) {
912
+ if (entry.type === 'chord' && matchKey(e, entry.first.key) && matchMods(e, entry.first.mods)) {
913
+ e.preventDefault();
914
+ _chordKey = entry.first.key;
915
+ _chordTimer = setTimeout(() => { _chordKey = null; }, 1000);
916
+ return;
917
+ }
918
+ }
919
+
920
+ // Check single shortcuts
921
+ for (const entry of parsed) {
922
+ if (entry.type === 'single' && matchKey(e, entry.combo.key) && matchMods(e, entry.combo.mods)) {
923
+ e.preventDefault();
924
+ entry.handler(e);
925
+ return;
926
+ }
927
+ }
928
+ }
929
+
930
+ el.addEventListener('keydown', onKeydown, true);
931
+
932
+ function destroy() {
933
+ el.removeEventListener('keydown', onKeydown, true);
934
+ clearTimeout(_chordTimer);
935
+ }
936
+
937
+ function update(newBindings) {
938
+ _bindings = newBindings;
939
+ rebuild();
940
+ }
941
+
942
+ return { destroy, update };
943
+ }
944
+
945
+
946
+ // ─── INFINITE SCROLL ──────────────────────────────────────────────
947
+ // Used by: List (infinite mode), feeds, search results
948
+
949
+ /**
950
+ * Triggers a callback when a sentinel element enters the viewport,
951
+ * enabling infinite scroll / load-more patterns.
952
+ *
953
+ * @param {HTMLElement} containerEl - The scrollable container
954
+ * @param {Object} opts
955
+ * @param {Function} opts.loadMore - Called when more data is needed. Can return a Promise.
956
+ * @param {number} [opts.threshold=200] - Distance in px from bottom to trigger
957
+ * @param {HTMLElement} [opts.sentinel] - Custom sentinel element (auto-created if omitted)
958
+ * @returns {{ destroy: Function, loading: () => boolean }}
959
+ */
960
+ export function createInfiniteScroll(containerEl, opts) {
961
+ const { loadMore, threshold = 200, sentinel: customSentinel } = opts;
962
+ let _loading = false;
963
+ let _destroyed = false;
964
+
965
+ const sentinel = customSentinel || h('div', { style: { height: '1px', width: '100%' }, 'aria-hidden': 'true' });
966
+ if (!customSentinel) containerEl.appendChild(sentinel);
967
+
968
+ const observer = new IntersectionObserver(async (entries) => {
969
+ if (_destroyed || _loading) return;
970
+ for (const entry of entries) {
971
+ if (entry.isIntersecting) {
972
+ _loading = true;
973
+ try { await loadMore(); }
974
+ finally { _loading = false; }
975
+ }
976
+ }
977
+ }, {
978
+ root: containerEl,
979
+ rootMargin: `0px 0px ${threshold}px 0px`,
980
+ });
981
+
982
+ observer.observe(sentinel);
983
+
984
+ function destroy() {
985
+ _destroyed = true;
986
+ observer.disconnect();
987
+ if (!customSentinel && sentinel.parentNode) sentinel.remove();
988
+ }
989
+
990
+ function loading() { return _loading; }
991
+
992
+ return { destroy, loading };
993
+ }
994
+
995
+
996
+ // ─── MASONRY LAYOUT ───────────────────────────────────────────────
997
+ // Used by: Image galleries, card grids, Pinterest-style layouts
998
+
999
+ /**
1000
+ * Applies masonry layout to child elements of a container.
1001
+ * Calculates shortest-column placement. Responsive via ResizeObserver.
1002
+ *
1003
+ * @param {HTMLElement} containerEl - The container whose children are laid out
1004
+ * @param {Object} [opts]
1005
+ * @param {number} [opts.columns=3] - Number of columns
1006
+ * @param {number} [opts.gap=16] - Gap between items in px
1007
+ * @returns {{ refresh: Function, setColumns: Function, destroy: Function }}
1008
+ */
1009
+ export function createMasonry(containerEl, opts = {}) {
1010
+ let { columns = 3, gap = 16 } = opts;
1011
+
1012
+ containerEl.style.position = 'relative';
1013
+
1014
+ function layout() {
1015
+ const children = [...containerEl.children];
1016
+ if (!children.length) { containerEl.style.height = '0'; return; }
1017
+
1018
+ const containerWidth = containerEl.clientWidth;
1019
+ const colWidth = (containerWidth - gap * (columns - 1)) / columns;
1020
+ const colHeights = new Array(columns).fill(0);
1021
+
1022
+ for (const child of children) {
1023
+ // Find shortest column
1024
+ const minCol = colHeights.indexOf(Math.min(...colHeights));
1025
+ const x = minCol * (colWidth + gap);
1026
+ const y = colHeights[minCol];
1027
+
1028
+ child.style.position = 'absolute';
1029
+ child.style.left = `${x}px`;
1030
+ child.style.top = `${y}px`;
1031
+ child.style.width = `${colWidth}px`;
1032
+
1033
+ // Measure after positioning to get correct height
1034
+ colHeights[minCol] += child.offsetHeight + gap;
1035
+ }
1036
+
1037
+ containerEl.style.height = `${Math.max(...colHeights) - gap}px`;
1038
+ }
1039
+
1040
+ const ro = new ResizeObserver(() => layout());
1041
+ ro.observe(containerEl);
1042
+
1043
+ // Initial layout
1044
+ layout();
1045
+
1046
+ function refresh() { layout(); }
1047
+ function setColumns(n) { columns = n; layout(); }
1048
+ function destroy() { ro.disconnect(); }
1049
+
1050
+ return { refresh, setColumns, destroy };
1051
+ }
1052
+
1053
+ // ─── SCROLL SPY ─────────────────────────────────────────────────
1054
+ // Used by: TableOfContents, workbench navigation, documentation layouts
1055
+
1056
+ /**
1057
+ * Tracks which observed elements are visible in a scroll container.
1058
+ * Calls onActiveChange when the topmost visible section changes.
1059
+ *
1060
+ * @param {HTMLElement|null} root - Scroll container (null = viewport)
1061
+ * @param {Object} opts
1062
+ * @param {string} [opts.rootMargin='-20% 0px -60% 0px'] - IntersectionObserver margin
1063
+ * @param {number} [opts.threshold=0]
1064
+ * @param {Function} opts.onActiveChange - Called with (element) when active section changes
1065
+ * @returns {{ observe: Function, unobserve: Function, disconnect: Function }}
1066
+ */
1067
+ export function createScrollSpy(root, opts = {}) {
1068
+ const {
1069
+ rootMargin = '-20% 0px -60% 0px',
1070
+ threshold = 0,
1071
+ onActiveChange
1072
+ } = opts;
1073
+
1074
+ let currentEl = null;
1075
+
1076
+ const observer = new IntersectionObserver(
1077
+ (entries) => {
1078
+ let topEntry = null;
1079
+ for (const entry of entries) {
1080
+ if (entry.isIntersecting) {
1081
+ if (!topEntry || entry.boundingClientRect.top < topEntry.boundingClientRect.top) {
1082
+ topEntry = entry;
1083
+ }
1084
+ }
1085
+ }
1086
+ if (topEntry && topEntry.target !== currentEl) {
1087
+ currentEl = topEntry.target;
1088
+ onActiveChange(currentEl);
1089
+ }
1090
+ },
1091
+ { root, rootMargin, threshold }
1092
+ );
1093
+
1094
+ function observe(el) { observer.observe(el); }
1095
+ function unobserve(el) { observer.unobserve(el); }
1096
+ function disconnect() { observer.disconnect(); currentEl = null; }
1097
+
1098
+ return { observe, unobserve, disconnect };
1099
+ }
1100
+
1101
+ /**
1102
+ * Shared checkbox control for embedding styled checkboxes inside
1103
+ * compound components (Transfer, Tree, TreeSelect, DataTable).
1104
+ * Returns the same d-checkbox-native + d-checkbox-check structure
1105
+ * used by the Checkbox component, wrapped in d-checkbox-inline.
1106
+ * @param {Object} [opts] - Attributes for the <input type="checkbox">
1107
+ * @returns {{ wrap: HTMLElement, input: HTMLInputElement }}
1108
+ */
1109
+ export function createCheckControl(opts = {}) {
1110
+ const input = h('input', { type: 'checkbox', class: 'd-checkbox-native', ...opts });
1111
+ const check = h('span', { class: 'd-checkbox-check' });
1112
+ const wrap = h('span', { class: 'd-checkbox-inline' }, input, check);
1113
+ return { wrap, input };
1114
+ }
1115
+
1116
+ /**
1117
+ * Scroll-reveal — adds 'd-visible' class when element enters viewport.
1118
+ * @param {HTMLElement} el - Element to observe
1119
+ * @param {Object} [options]
1120
+ * @param {number} [options.threshold=0.1] - Intersection threshold (0-1)
1121
+ * @param {string} [options.rootMargin='0px 0px -50px 0px'] - Observer root margin
1122
+ * @param {boolean} [options.once=true] - Unobserve after first intersection
1123
+ * @returns {Function} Cleanup function for onDestroy
1124
+ */
1125
+ export function createScrollReveal(el, options = {}) {
1126
+ const { threshold = 0.1, rootMargin = '0px 0px -50px 0px', once = true } = options;
1127
+ if (typeof IntersectionObserver === 'undefined') return () => {};
1128
+ const observer = new IntersectionObserver((entries) => {
1129
+ for (const entry of entries) {
1130
+ if (entry.isIntersecting) {
1131
+ entry.target.classList.add('d-visible');
1132
+ if (once) observer.unobserve(entry.target);
1133
+ } else if (!once) {
1134
+ entry.target.classList.remove('d-visible');
1135
+ }
1136
+ }
1137
+ }, { threshold, rootMargin });
1138
+ observer.observe(el);
1139
+ return () => observer.disconnect();
1140
+ }