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.
- package/AGENTS.md +868 -0
- package/CHANGELOG.md +255 -0
- package/CLAUDE.md +178 -0
- package/LICENSE +21 -0
- package/README.md +229 -0
- package/cli/art.js +127 -0
- package/cli/commands/a11y.js +61 -0
- package/cli/commands/audit.js +225 -0
- package/cli/commands/build.js +38 -0
- package/cli/commands/dev.js +18 -0
- package/cli/commands/doctor.js +197 -0
- package/cli/commands/figma-sync.js +48 -0
- package/cli/commands/figma-tokens.js +55 -0
- package/cli/commands/generate.js +26 -0
- package/cli/commands/init.js +116 -0
- package/cli/commands/lint.js +209 -0
- package/cli/commands/mcp.js +530 -0
- package/cli/commands/migrate.js +175 -0
- package/cli/commands/test.js +38 -0
- package/cli/commands/validate.js +354 -0
- package/cli/index.js +113 -0
- package/package.json +95 -0
- package/reference/atoms.md +517 -0
- package/reference/behaviors.md +384 -0
- package/reference/build-tooling.md +275 -0
- package/reference/color-guidelines.md +965 -0
- package/reference/component-lifecycle.md +137 -0
- package/reference/compound-spacing.md +95 -0
- package/reference/decantation-process.md +499 -0
- package/reference/dev-server-routes.md +93 -0
- package/reference/form-system.md +253 -0
- package/reference/i18n.md +336 -0
- package/reference/icons.md +576 -0
- package/reference/llm-primer.md +953 -0
- package/reference/plugins.md +252 -0
- package/reference/registry-consumption.md +76 -0
- package/reference/router.md +217 -0
- package/reference/shells.md +116 -0
- package/reference/spatial-guidelines.md +541 -0
- package/reference/ssr.md +234 -0
- package/reference/state-data.md +215 -0
- package/reference/state-patterns.md +166 -0
- package/reference/state.md +194 -0
- package/reference/style-system.md +110 -0
- package/reference/tokens.md +460 -0
- package/src/app.js +19 -0
- package/src/chart/_animate.js +266 -0
- package/src/chart/_base.js +109 -0
- package/src/chart/_data.js +209 -0
- package/src/chart/_format.js +106 -0
- package/src/chart/_interact.js +364 -0
- package/src/chart/_palette.js +105 -0
- package/src/chart/_renderer.js +52 -0
- package/src/chart/_scene.js +262 -0
- package/src/chart/_shared.js +371 -0
- package/src/chart/index.js +637 -0
- package/src/chart/layouts/_layout-base.js +328 -0
- package/src/chart/layouts/cartesian.js +148 -0
- package/src/chart/layouts/hierarchy.js +562 -0
- package/src/chart/layouts/polar.js +101 -0
- package/src/chart/renderers/canvas.js +179 -0
- package/src/chart/renderers/svg.js +256 -0
- package/src/chart/renderers/webgpu.js +715 -0
- package/src/chart/types/_type-base.js +26 -0
- package/src/chart/types/area.js +134 -0
- package/src/chart/types/bar.js +173 -0
- package/src/chart/types/box-plot.js +125 -0
- package/src/chart/types/bubble.js +63 -0
- package/src/chart/types/candlestick.js +115 -0
- package/src/chart/types/chord.js +85 -0
- package/src/chart/types/combination.js +108 -0
- package/src/chart/types/funnel.js +68 -0
- package/src/chart/types/gauge.js +163 -0
- package/src/chart/types/heatmap.js +98 -0
- package/src/chart/types/histogram.js +71 -0
- package/src/chart/types/line.js +111 -0
- package/src/chart/types/org-chart.js +93 -0
- package/src/chart/types/pie.js +81 -0
- package/src/chart/types/radar.js +96 -0
- package/src/chart/types/radial.js +68 -0
- package/src/chart/types/range-area.js +55 -0
- package/src/chart/types/range-bar.js +61 -0
- package/src/chart/types/sankey.js +73 -0
- package/src/chart/types/scatter.js +66 -0
- package/src/chart/types/sparkline.js +81 -0
- package/src/chart/types/sunburst.js +69 -0
- package/src/chart/types/swimlane.js +88 -0
- package/src/chart/types/treemap.js +62 -0
- package/src/chart/types/waterfall.js +100 -0
- package/src/components/_base.js +1658 -0
- package/src/components/_behaviors.js +1140 -0
- package/src/components/_primitives.js +534 -0
- package/src/components/_qr-encoder.js +539 -0
- package/src/components/accordion.js +207 -0
- package/src/components/affix.js +62 -0
- package/src/components/alert-dialog.js +75 -0
- package/src/components/alert.js +47 -0
- package/src/components/aspect-ratio.js +24 -0
- package/src/components/avatar-group.js +55 -0
- package/src/components/avatar.js +38 -0
- package/src/components/back-top.js +75 -0
- package/src/components/badge.js +74 -0
- package/src/components/banner.js +68 -0
- package/src/components/breadcrumb.js +162 -0
- package/src/components/button.js +115 -0
- package/src/components/calendar.js +131 -0
- package/src/components/card.js +192 -0
- package/src/components/carousel.js +98 -0
- package/src/components/cascader.js +261 -0
- package/src/components/checkbox.js +80 -0
- package/src/components/chip.js +81 -0
- package/src/components/code-block.js +82 -0
- package/src/components/collapsible.js +50 -0
- package/src/components/color-palette.js +438 -0
- package/src/components/color-picker.js +314 -0
- package/src/components/combobox.js +181 -0
- package/src/components/command.js +174 -0
- package/src/components/comment.js +206 -0
- package/src/components/context-menu.js +76 -0
- package/src/components/data-table.js +724 -0
- package/src/components/date-picker.js +217 -0
- package/src/components/date-range-picker.js +244 -0
- package/src/components/datetime-picker.js +271 -0
- package/src/components/descriptions.js +68 -0
- package/src/components/drawer.js +179 -0
- package/src/components/dropdown.js +88 -0
- package/src/components/empty.js +41 -0
- package/src/components/float-button.js +90 -0
- package/src/components/form.js +106 -0
- package/src/components/hover-card.js +49 -0
- package/src/components/icon.js +87 -0
- package/src/components/image.js +97 -0
- package/src/components/index.js +117 -0
- package/src/components/input-group.js +75 -0
- package/src/components/input-number.js +155 -0
- package/src/components/input-otp.js +178 -0
- package/src/components/input.js +91 -0
- package/src/components/kbd.js +36 -0
- package/src/components/label.js +25 -0
- package/src/components/list.js +118 -0
- package/src/components/masked-input.js +236 -0
- package/src/components/mentions.js +165 -0
- package/src/components/menu.js +259 -0
- package/src/components/message.js +80 -0
- package/src/components/modal.js +147 -0
- package/src/components/navigation-menu.js +166 -0
- package/src/components/notification.js +84 -0
- package/src/components/pagination.js +104 -0
- package/src/components/placeholder.js +132 -0
- package/src/components/popconfirm.js +70 -0
- package/src/components/popover.js +58 -0
- package/src/components/progress.js +61 -0
- package/src/components/qrcode.js +251 -0
- package/src/components/radiogroup.js +120 -0
- package/src/components/range-slider.js +176 -0
- package/src/components/rate.js +186 -0
- package/src/components/resizable.js +83 -0
- package/src/components/result.js +57 -0
- package/src/components/scroll-area.js +43 -0
- package/src/components/segmented.js +97 -0
- package/src/components/select.js +165 -0
- package/src/components/separator.js +31 -0
- package/src/components/shell.js +407 -0
- package/src/components/skeleton.js +39 -0
- package/src/components/slider.js +141 -0
- package/src/components/sortable-list.js +176 -0
- package/src/components/space.js +42 -0
- package/src/components/spinner.js +112 -0
- package/src/components/splitter.js +147 -0
- package/src/components/statistic.js +136 -0
- package/src/components/steps.js +99 -0
- package/src/components/switch.js +95 -0
- package/src/components/table.js +44 -0
- package/src/components/tabs.js +216 -0
- package/src/components/tag.js +115 -0
- package/src/components/textarea.js +82 -0
- package/src/components/time-picker.js +153 -0
- package/src/components/time-range-picker.js +170 -0
- package/src/components/timeline.js +226 -0
- package/src/components/toast.js +71 -0
- package/src/components/toggle.js +213 -0
- package/src/components/tooltip.js +57 -0
- package/src/components/tour.js +159 -0
- package/src/components/transfer.js +163 -0
- package/src/components/tree-select.js +274 -0
- package/src/components/tree.js +141 -0
- package/src/components/typography.js +136 -0
- package/src/components/upload.js +118 -0
- package/src/components/visually-hidden.js +20 -0
- package/src/components/watermark.js +124 -0
- package/src/core/index.js +539 -0
- package/src/core/lifecycle.js +69 -0
- package/src/css/atoms.js +651 -0
- package/src/css/components.js +940 -0
- package/src/css/derive.js +1296 -0
- package/src/css/index.js +265 -0
- package/src/css/runtime.js +268 -0
- package/src/css/styles/addons/bioluminescent.js +93 -0
- package/src/css/styles/addons/clay.js +70 -0
- package/src/css/styles/addons/clean.js +57 -0
- package/src/css/styles/addons/command-center.js +143 -0
- package/src/css/styles/addons/dopamine.js +83 -0
- package/src/css/styles/addons/editorial.js +80 -0
- package/src/css/styles/addons/glassmorphism.js +99 -0
- package/src/css/styles/addons/liquid-glass.js +105 -0
- package/src/css/styles/addons/prismatic.js +100 -0
- package/src/css/styles/addons/retro.js +63 -0
- package/src/css/styles/auradecantism.js +96 -0
- package/src/css/theme-registry.js +444 -0
- package/src/data/entity.js +281 -0
- package/src/data/index.js +13 -0
- package/src/data/persist.js +225 -0
- package/src/data/query.js +839 -0
- package/src/data/realtime.js +299 -0
- package/src/data/url.js +177 -0
- package/src/data/worker.js +134 -0
- package/src/explorer/archetypes.js +243 -0
- package/src/explorer/atoms.js +228 -0
- package/src/explorer/charts.js +497 -0
- package/src/explorer/components.js +129 -0
- package/src/explorer/foundations.js +949 -0
- package/src/explorer/icons.js +178 -0
- package/src/explorer/patterns.js +247 -0
- package/src/explorer/recipes.js +194 -0
- package/src/explorer/shared/pattern-examples.js +1337 -0
- package/src/explorer/shared/showcase-renderer.js +958 -0
- package/src/explorer/shared/spec-table.js +41 -0
- package/src/explorer/shared/usage-links.js +87 -0
- package/src/explorer/shell-config.js +10 -0
- package/src/explorer/shells.js +551 -0
- package/src/explorer/styles.js +161 -0
- package/src/explorer/tokens.js +262 -0
- package/src/explorer/tools.js +525 -0
- package/src/form/index.js +804 -0
- package/src/i18n/index.js +251 -0
- package/src/icons/essential.js +479 -0
- package/src/icons/index.js +53 -0
- package/src/plugins/index.js +282 -0
- package/src/registry/archetypes/content-site.json +71 -0
- package/src/registry/archetypes/docs-explorer.json +23 -0
- package/src/registry/archetypes/ecommerce.json +104 -0
- package/src/registry/archetypes/financial-dashboard.json +77 -0
- package/src/registry/archetypes/index.json +41 -0
- package/src/registry/archetypes/portfolio.json +82 -0
- package/src/registry/archetypes/recipe-community.json +159 -0
- package/src/registry/archetypes/saas-dashboard.json +86 -0
- package/src/registry/architect/cross-cutting.json +45 -0
- package/src/registry/architect/domains/ecommerce.json +294 -0
- package/src/registry/architect/domains/financial-services.json +302 -0
- package/src/registry/architect/index.json +26 -0
- package/src/registry/architect/traits.json +379 -0
- package/src/registry/atoms.json +16 -0
- package/src/registry/chart-showcase.json +160 -0
- package/src/registry/chart.json +136 -0
- package/src/registry/components.json +8616 -0
- package/src/registry/core.json +216 -0
- package/src/registry/css.json +319 -0
- package/src/registry/data.json +135 -0
- package/src/registry/foundations.json +11 -0
- package/src/registry/icons.json +463 -0
- package/src/registry/index.json +101 -0
- package/src/registry/patterns/activity-feed.json +37 -0
- package/src/registry/patterns/article-content.json +27 -0
- package/src/registry/patterns/auth-form.json +37 -0
- package/src/registry/patterns/author-card.json +20 -0
- package/src/registry/patterns/card-grid.json +127 -0
- package/src/registry/patterns/category-nav.json +26 -0
- package/src/registry/patterns/chart-grid.json +36 -0
- package/src/registry/patterns/chat-interface.json +37 -0
- package/src/registry/patterns/checklist-card.json +55 -0
- package/src/registry/patterns/comparison-panel.json +27 -0
- package/src/registry/patterns/component-showcase.json +24 -0
- package/src/registry/patterns/contact-form.json +31 -0
- package/src/registry/patterns/cta-section.json +20 -0
- package/src/registry/patterns/data-table.json +37 -0
- package/src/registry/patterns/detail-header.json +83 -0
- package/src/registry/patterns/detail-panel.json +27 -0
- package/src/registry/patterns/explorer-shell.json +22 -0
- package/src/registry/patterns/filter-bar.json +33 -0
- package/src/registry/patterns/filter-sidebar.json +27 -0
- package/src/registry/patterns/form-sections.json +110 -0
- package/src/registry/patterns/goal-tracker.json +27 -0
- package/src/registry/patterns/hero.json +107 -0
- package/src/registry/patterns/index.json +47 -0
- package/src/registry/patterns/kpi-grid.json +36 -0
- package/src/registry/patterns/media-gallery.json +20 -0
- package/src/registry/patterns/order-history.json +20 -0
- package/src/registry/patterns/pagination.json +19 -0
- package/src/registry/patterns/photo-to-recipe.json +36 -0
- package/src/registry/patterns/pipeline-tracker.json +28 -0
- package/src/registry/patterns/post-list.json +27 -0
- package/src/registry/patterns/pricing-table.json +32 -0
- package/src/registry/patterns/scorecard.json +28 -0
- package/src/registry/patterns/search-bar.json +20 -0
- package/src/registry/patterns/specimen-grid.json +19 -0
- package/src/registry/patterns/stat-card.json +55 -0
- package/src/registry/patterns/stats-bar.json +55 -0
- package/src/registry/patterns/steps-card.json +55 -0
- package/src/registry/patterns/table-of-contents.json +19 -0
- package/src/registry/patterns/testimonials.json +21 -0
- package/src/registry/patterns/timeline.json +27 -0
- package/src/registry/patterns/token-inspector.json +21 -0
- package/src/registry/patterns/wizard.json +27 -0
- package/src/registry/recipe-auradecantism.json +69 -0
- package/src/registry/recipe-clean.json +65 -0
- package/src/registry/recipe-command-center.json +78 -0
- package/src/registry/router.json +73 -0
- package/src/registry/schema/README.md +197 -0
- package/src/registry/skeletons.json +259 -0
- package/src/registry/state.json +137 -0
- package/src/registry/tokens.json +40 -0
- package/src/router/hash.js +17 -0
- package/src/router/history.js +18 -0
- package/src/router/index.js +598 -0
- package/src/ssr/index.js +922 -0
- package/src/state/arrays.js +181 -0
- package/src/state/devtools.js +647 -0
- package/src/state/index.js +498 -0
- package/src/state/middleware.js +288 -0
- package/src/state/scheduler.js +206 -0
- package/src/state/store.js +300 -0
- package/src/tags/index.js +19 -0
- package/src/tannins/auth.js +396 -0
- package/src/test/dom.js +352 -0
- package/src/test/index.js +62 -0
- package/src/test/state.js +306 -0
- package/tools/a11y-audit.js +487 -0
- package/tools/analyzer.js +315 -0
- package/tools/audit.js +706 -0
- package/tools/builder.js +1422 -0
- package/tools/css-extract.js +188 -0
- package/tools/dev-server.js +316 -0
- package/tools/dts-gen.js +1260 -0
- package/tools/figma-components.js +329 -0
- package/tools/figma-patterns.js +516 -0
- package/tools/figma-plugin/code.js +453 -0
- package/tools/figma-plugin/manifest.json +14 -0
- package/tools/figma-plugin/ui.html +268 -0
- package/tools/figma-render.js +293 -0
- package/tools/figma-tokens.js +712 -0
- package/tools/figma-upload.js +318 -0
- package/tools/generate.js +738 -0
- package/tools/icons.js +133 -0
- package/tools/init-templates.js +265 -0
- package/tools/install-hooks.sh +5 -0
- package/tools/migrations/0.5.0.js +53 -0
- package/tools/migrations/0.6.0.js +95 -0
- package/tools/minify.js +170 -0
- package/tools/pre-commit +4 -0
- package/tools/registry.js +662 -0
- package/tools/reset-playground.js +61 -0
- package/tools/starter-templates/content-site/app.js +49 -0
- package/tools/starter-templates/content-site/essence.js +19 -0
- package/tools/starter-templates/content-site/pages.js +31 -0
- package/tools/starter-templates/ecommerce/app.js +50 -0
- package/tools/starter-templates/ecommerce/essence.js +19 -0
- package/tools/starter-templates/ecommerce/pages.js +31 -0
- package/tools/starter-templates/landing-page/app.js +38 -0
- package/tools/starter-templates/landing-page/essence.js +18 -0
- package/tools/starter-templates/landing-page/pages.js +21 -0
- package/tools/starter-templates/portfolio/app.js +45 -0
- package/tools/starter-templates/portfolio/essence.js +19 -0
- package/tools/starter-templates/portfolio/pages.js +33 -0
- package/tools/starter-templates/saas-dashboard/app.js +70 -0
- package/tools/starter-templates/saas-dashboard/essence.js +19 -0
- package/tools/starter-templates/saas-dashboard/pages.js +31 -0
- package/tools/verify-pack.js +203 -0
- package/types/chart.d.ts +77 -0
- package/types/components.d.ts +587 -0
- package/types/core.d.ts +89 -0
- package/types/css.d.ts +149 -0
- package/types/data.d.ts +238 -0
- package/types/form.d.ts +164 -0
- package/types/i18n.d.ts +51 -0
- package/types/icons.d.ts +27 -0
- package/types/index.d.ts +13 -0
- package/types/router.d.ts +116 -0
- package/types/ssr.d.ts +102 -0
- package/types/state.d.ts +83 -0
- package/types/tags.d.ts +62 -0
- package/types/tannins.d.ts +63 -0
- 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
|
+
}
|