From a8ca6b0b4dff148386ebfb51dc5649891d68aa3e Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 27 Mar 2026 00:46:45 +0100 Subject: [PATCH] refactor: consolidate acel utilities, fix floating positioning bug, normalize types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Merge internal/ and shared/ into utilities/ with semantic subdirs: floating/, focus/, keyboard/, datetime/, presence/, typeahead/ - Fix tooltip/popover positioning: wrapperProps.style was a plain object, causing Svelte 5 spread to serialize it as "[object Object]" — wrapped with styleToString() so CSS is applied correctly - Fix WithChild producing Snippet<[{}]> instead of Snippet: use keyof check so empty SnippetProps yields plain Snippet (no args) - Fix TooltipContentProps.children: switch to WithChildNoChildrenSnippetProps so children is Snippet while child retains { open: boolean } props - Fix field component: move field.svelte.ts to root dir, add exports.ts, simplify index.ts, normalize import paths across all field components Co-Authored-By: Claude Sonnet 4.6 --- .claude/settings.json | 11 +- .claude/settings.local.json | 12 + src/routes/+layout.ts | 2 + src/routes/test/+page.svelte | 98 +- src/routes/test/aspect-ratio/+page.svelte | 249 +++ src/routes/test/avatar/+page.svelte | 364 ++++ src/routes/test/calendar/+page.svelte | 850 +++++++++ src/routes/test/checkbox/+page.svelte | 185 ++ src/routes/test/command/+page.svelte | 348 ++++ src/routes/test/context-menu/+page.svelte | 426 +++++ src/routes/test/dropdown-menu/+page.svelte | 411 +++++ src/routes/test/field/+page.svelte | 304 ++++ src/routes/test/label/+page.svelte | 124 ++ src/routes/test/menubar/+page.svelte | 416 +++++ src/routes/test/meter/+page.svelte | 123 ++ src/routes/test/pagination/+page.svelte | 215 +++ src/routes/test/pin-input/+page.svelte | 187 ++ src/routes/test/popover/+page.svelte | 267 +++ src/routes/test/progress/+page.svelte | 149 ++ src/routes/test/radio-group/+page.svelte | 187 ++ src/routes/test/rating-group/+page.svelte | 146 ++ src/routes/test/scroll-area/+page.svelte | 164 ++ src/routes/test/separator/+page.svelte | 119 ++ src/routes/test/slider/+page.svelte | 176 ++ src/routes/test/switch/+page.svelte | 204 +++ src/routes/test/toggle-group/+page.svelte | 145 ++ src/routes/test/toggle/+page.svelte | 188 ++ src/routes/test/toolbar/+page.svelte | 212 +++ src/routes/test/tooltip/+page.svelte | 219 +++ src/uix/acel/accordion/accordion.svelte.ts | 12 +- .../components/accordion-content.svelte | 2 +- .../components/accordion-header.svelte | 2 +- .../components/accordion-item.svelte | 2 +- .../components/accordion-trigger.svelte | 2 +- .../accordion/components/accordion.svelte | 4 +- src/uix/acel/accordion/types.ts | 6 +- .../components/alert-dialog-action.svelte | 2 +- .../components/alert-dialog-cancel.svelte | 2 +- .../components/alert-dialog-content.svelte | 4 +- .../components/alert-dialog.svelte | 2 +- src/uix/acel/alert-dialog/types.ts | 2 +- .../acel/aspect-ratio/aspect-ratio.svelte.ts | 37 + .../components/aspect-ratio.svelte | 38 + src/uix/acel/aspect-ratio/exports.ts | 2 + src/uix/acel/aspect-ratio/index.ts | 2 + src/uix/acel/aspect-ratio/types.ts | 15 + src/uix/acel/avatar/avatar.svelte.ts | 167 ++ .../avatar/components/avatar-fallback.svelte | 31 + .../avatar/components/avatar-image.svelte | 36 + src/uix/acel/avatar/components/avatar.svelte | 44 + src/uix/acel/avatar/exports.ts | 10 + src/uix/acel/avatar/index.ts | 2 + src/uix/acel/avatar/types.ts | 38 + src/uix/acel/calendar/calendar.svelte.ts | 1150 ++++++++++++ .../calendar/components/calendar-cell.svelte | 33 + .../calendar/components/calendar-day.svelte | 33 + .../components/calendar-grid-body.svelte | 29 + .../components/calendar-grid-head.svelte | 29 + .../components/calendar-grid-row.svelte | 29 + .../calendar/components/calendar-grid.svelte | 29 + .../components/calendar-head-cell.svelte | 29 + .../components/calendar-header.svelte | 29 + .../components/calendar-heading.svelte | 34 + .../components/calendar-month-select.svelte | 43 + .../components/calendar-next-button.svelte | 29 + .../components/calendar-prev-button.svelte | 29 + .../components/calendar-year-select.svelte | 41 + .../acel/calendar/components/calendar.svelte | 126 ++ src/uix/acel/calendar/exports.ts | 37 + src/uix/acel/calendar/index.ts | 2 + src/uix/acel/calendar/types.ts | 160 ++ src/uix/acel/checkbox/checkbox.svelte.ts | 271 +++ .../components/checkbox-group-label.svelte | 31 + .../checkbox/components/checkbox-group.svelte | 44 + .../acel/checkbox/components/checkbox.svelte | 61 + src/uix/acel/checkbox/exports.ts | 8 + src/uix/acel/checkbox/index.ts | 2 + src/uix/acel/checkbox/types.ts | 34 + .../acel/collapsible/collapsible.svelte.ts | 8 +- .../components/collapsible-content.svelte | 2 +- .../components/collapsible-trigger.svelte | 2 +- .../collapsible/components/collapsible.svelte | 4 +- src/uix/acel/collapsible/types.ts | 4 +- src/uix/acel/command/command.svelte.ts | 1128 ++++++++++++ .../command/components/_command-label.svelte | 28 + .../command/components/command-empty.svelte | 36 + .../components/command-group-heading.svelte | 32 + .../components/command-group-items.svelte | 34 + .../command/components/command-group.svelte | 36 + .../command/components/command-input.svelte | 36 + .../command/components/command-item.svelte | 49 + .../components/command-link-item.svelte | 49 + .../command/components/command-list.svelte | 36 + .../command/components/command-loading.svelte | 34 + .../components/command-separator.svelte | 36 + .../components/command-viewport.svelte | 32 + .../acel/command/components/command.svelte | 80 + src/uix/acel/command/compute-command-score.ts | 176 ++ src/uix/acel/command/exports.ts | 30 + src/uix/acel/command/index.ts | 1 + src/uix/acel/command/types.ts | 118 ++ src/uix/acel/command/utils.ts | 15 + src/uix/acel/comn/config/exports.ts | 2 +- src/uix/acel/comn/config/types.ts | 2 +- .../floating-layer-anchor.svelte | 31 + .../comn/floating-layer/floating-layer.svelte | 10 + .../comn/popper-layer/popper-layer.svelte | 119 ++ src/uix/acel/comn/portal/portal.svelte | 2 +- .../components/context-menu-content.svelte | 109 ++ .../components/context-menu-trigger.svelte | 47 + .../components/context-menu.svelte | 42 + src/uix/acel/context-menu/exports.ts | 37 + src/uix/acel/context-menu/index.ts | 1 + src/uix/acel/context-menu/types.ts | 44 + .../components/date-field-hidden-input.svelte | 10 + .../components/date-field-input.svelte | 39 + .../components/date-field-label.svelte | 34 + .../components/date-field-segment.svelte | 37 + .../date-field/components/date-field.svelte | 99 ++ src/uix/acel/date-field/date-field.svelte.ts | 1574 +++++++++++++++++ src/uix/acel/date-field/exports.ts | 12 + src/uix/acel/date-field/index.ts | 1 + src/uix/acel/date-field/types.ts | 176 ++ .../dialog/components/dialog-close.svelte | 2 +- .../dialog/components/dialog-content.svelte | 4 +- .../components/dialog-description.svelte | 2 +- .../dialog/components/dialog-overlay.svelte | 2 +- .../dialog/components/dialog-title.svelte | 2 +- .../dialog/components/dialog-trigger.svelte | 2 +- src/uix/acel/dialog/components/dialog.svelte | 2 +- src/uix/acel/dialog/dialog.svelte.ts | 8 +- src/uix/acel/dialog/types.ts | 2 +- .../components/dropdown-menu-content.svelte | 101 ++ .../components/dropdown-menu.svelte | 8 + src/uix/acel/dropdown-menu/exports.ts | 37 + src/uix/acel/dropdown-menu/index.ts | 1 + src/uix/acel/dropdown-menu/types.ts | 35 + .../field/components/Field.Control.svelte | 31 + .../acel/field/components/Field.Error.svelte | 36 + .../field/components/Field.HelperText.svelte | 30 + .../acel/field/components/Field.Input.svelte | 35 + .../acel/field/components/Field.Label.svelte | 30 + .../acel/field/components/Field.Prefix.svelte | 30 + .../acel/field/components/Field.Root.svelte | 39 + .../acel/field/components/Field.Suffix.svelte | 30 + src/uix/acel/field/exports.ts | 22 + src/uix/acel/field/field.svelte.ts | 340 ++++ src/uix/acel/field/index.ts | 1 + src/uix/acel/field/types.ts | 103 ++ src/uix/acel/index.ts | 30 + src/uix/acel/internal/types.ts | 94 - src/uix/acel/label/components/label.svelte | 31 + src/uix/acel/label/exports.ts | 2 + src/uix/acel/label/index.ts | 2 + src/uix/acel/label/label.svelte.ts | 35 + src/uix/acel/label/types.ts | 7 + .../acel/menu/components/menu-arrow.svelte | 18 + .../components/menu-checkbox-group.svelte | 45 + .../menu/components/menu-checkbox-item.svelte | 90 + .../acel/menu/components/menu-content.svelte | 99 ++ .../menu/components/menu-group-heading.svelte | 34 + .../acel/menu/components/menu-group.svelte | 34 + src/uix/acel/menu/components/menu-item.svelte | 41 + .../menu/components/menu-radio-group.svelte | 44 + .../menu/components/menu-radio-item.svelte | 49 + .../menu/components/menu-separator.svelte | 34 + .../menu/components/menu-sub-content.svelte | 103 ++ .../menu/components/menu-sub-trigger.svelte | 44 + src/uix/acel/menu/components/menu-sub.svelte | 29 + .../acel/menu/components/menu-trigger.svelte | 40 + src/uix/acel/menu/components/menu.svelte | 45 + src/uix/acel/menu/exports.ts | 35 + src/uix/acel/menu/index.ts | 1 + src/uix/acel/menu/menu.svelte.ts | 1278 +++++++++++++ src/uix/acel/menu/types.ts | 218 +++ src/uix/acel/menu/utils.ts | 31 + .../menubar/components/menubar-content.svelte | 34 + .../menubar/components/menubar-menu.svelte | 25 + .../menubar/components/menubar-trigger.svelte | 45 + .../acel/menubar/components/menubar.svelte | 46 + src/uix/acel/menubar/exports.ts | 36 + src/uix/acel/menubar/index.ts | 1 + src/uix/acel/menubar/menubar.svelte.ts | 332 ++++ src/uix/acel/menubar/types.ts | 94 + src/uix/acel/meter/components/meter.svelte | 37 + src/uix/acel/meter/exports.ts | 2 + src/uix/acel/meter/index.ts | 2 + src/uix/acel/meter/meter.svelte.ts | 48 + src/uix/acel/meter/types.ts | 23 + .../components/pagination-next-button.svelte | 31 + .../components/pagination-page.svelte | 35 + .../components/pagination-prev-button.svelte | 31 + .../pagination/components/pagination.svelte | 46 + src/uix/acel/pagination/exports.ts | 10 + src/uix/acel/pagination/index.ts | 2 + src/uix/acel/pagination/pagination.svelte.ts | 255 +++ src/uix/acel/pagination/types.ts | 48 + .../components/pin-input-cell.svelte | 33 + .../pin-input/components/pin-input.svelte | 63 + src/uix/acel/pin-input/exports.ts | 3 + src/uix/acel/pin-input/index.ts | 2 + src/uix/acel/pin-input/pin-input.svelte.ts | 559 ++++++ src/uix/acel/pin-input/types.ts | 48 + .../pin-input/usePasswordManager.svelte.ts | 101 ++ .../popover/components/popover-close.svelte | 34 + .../popover/components/popover-content.svelte | 84 + .../popover/components/popover-overlay.svelte | 37 + .../popover/components/popover-trigger.svelte | 46 + .../acel/popover/components/popover.svelte | 29 + src/uix/acel/popover/exports.ts | 5 + src/uix/acel/popover/index.ts | 2 + src/uix/acel/popover/popover.svelte.ts | 449 +++++ src/uix/acel/popover/types.ts | 67 + .../acel/progress/components/progress.svelte | 37 + src/uix/acel/progress/exports.ts | 2 + src/uix/acel/progress/index.ts | 2 + src/uix/acel/progress/progress.svelte.ts | 57 + src/uix/acel/progress/types.ts | 23 + .../components/radio-group-item.svelte | 35 + .../radio-group/components/radio-group.svelte | 53 + src/uix/acel/radio-group/exports.ts | 3 + src/uix/acel/radio-group/index.ts | 2 + .../acel/radio-group/radio-group.svelte.ts | 200 +++ src/uix/acel/radio-group/types.ts | 24 + .../components/rating-group-item.svelte | 35 + .../components/rating-group.svelte | 59 + src/uix/acel/rating-group/exports.ts | 3 + src/uix/acel/rating-group/index.ts | 2 + .../acel/rating-group/rating-group.svelte.ts | 259 +++ src/uix/acel/rating-group/types.ts | 28 + .../components/presence-layer.svelte | 21 + .../components/scroll-area-corner-impl.svelte | 30 + .../components/scroll-area-corner.svelte | 27 + .../scroll-area-scrollbar-auto.svelte | 21 + .../scroll-area-scrollbar-hover.svelte | 30 + .../scroll-area-scrollbar-scroll.svelte | 22 + .../scroll-area-scrollbar-shared.svelte | 19 + .../scroll-area-scrollbar-visible.svelte | 16 + .../components/scroll-area-scrollbar-x.svelte | 19 + .../components/scroll-area-scrollbar-y.svelte | 20 + .../components/scroll-area-scrollbar.svelte | 37 + .../components/scroll-area-thumb-impl.svelte | 42 + .../components/scroll-area-thumb.svelte | 24 + .../components/scroll-area-viewport.svelte | 50 + .../scroll-area/components/scroll-area.svelte | 37 + src/uix/acel/scroll-area/exports.ts | 5 + src/uix/acel/scroll-area/index.ts | 2 + .../acel/scroll-area/scroll-area.svelte.ts | 1018 +++++++++++ src/uix/acel/scroll-area/types.ts | 43 + .../separator/components/separator.svelte | 35 + src/uix/acel/separator/exports.ts | 2 + src/uix/acel/separator/index.ts | 2 + src/uix/acel/separator/separator.svelte.ts | 42 + src/uix/acel/separator/types.ts | 18 + src/uix/acel/shared/css.d.ts | 9 - src/uix/acel/shared/index.ts | 67 - src/uix/acel/shared/types.ts | 15 - .../slider/components/slider-range.svelte | 31 + .../components/slider-thumb-label.svelte | 35 + .../slider/components/slider-thumb.svelte | 36 + .../components/slider-tick-label.svelte | 35 + .../acel/slider/components/slider-tick.svelte | 33 + src/uix/acel/slider/components/slider.svelte | 57 + src/uix/acel/slider/exports.ts | 6 + src/uix/acel/slider/helpers.ts | 197 +++ src/uix/acel/slider/index.ts | 2 + src/uix/acel/slider/slider.svelte.ts | 809 +++++++++ src/uix/acel/slider/types.ts | 88 + .../switch/components/switch-thumb.svelte | 31 + src/uix/acel/switch/components/switch.svelte | 49 + src/uix/acel/switch/exports.ts | 3 + src/uix/acel/switch/index.ts | 2 + src/uix/acel/switch/switch.svelte.ts | 134 ++ src/uix/acel/switch/types.ts | 19 + .../acel/tabs/components/tabs-content.svelte | 2 +- src/uix/acel/tabs/components/tabs-list.svelte | 2 +- .../acel/tabs/components/tabs-trigger.svelte | 2 +- src/uix/acel/tabs/components/tabs.svelte | 4 +- src/uix/acel/tabs/tabs.svelte.ts | 8 +- src/uix/acel/tabs/types.ts | 4 +- .../components/toggle-group-item.svelte | 35 + .../components/toggle-group.svelte | 46 + src/uix/acel/toggle-group/exports.ts | 6 + src/uix/acel/toggle-group/index.ts | 2 + .../acel/toggle-group/toggle-group.svelte.ts | 179 ++ src/uix/acel/toggle-group/types.ts | 49 + src/uix/acel/toggle/components/toggle.svelte | 38 + src/uix/acel/toggle/exports.ts | 2 + src/uix/acel/toggle/index.ts | 2 + src/uix/acel/toggle/toggle.svelte.ts | 54 + src/uix/acel/toggle/types.ts | 22 + .../toolbar/components/toolbar-button.svelte | 33 + .../components/toolbar-group-item.svelte | 35 + .../toolbar/components/toolbar-group.svelte | 37 + .../toolbar/components/toolbar-link.svelte | 31 + .../acel/toolbar/components/toolbar.svelte | 35 + src/uix/acel/toolbar/exports.ts | 12 + src/uix/acel/toolbar/index.ts | 2 + src/uix/acel/toolbar/toolbar.svelte.ts | 276 +++ src/uix/acel/toolbar/types.ts | 78 + .../tooltip/components/tooltip-content.svelte | 88 + .../components/tooltip-provider.svelte | 26 + .../tooltip/components/tooltip-trigger.svelte | 47 + .../acel/tooltip/components/tooltip.svelte | 56 + src/uix/acel/tooltip/exports.ts | 5 + src/uix/acel/tooltip/index.ts | 2 + src/uix/acel/tooltip/tooltip.svelte.ts | 810 +++++++++ src/uix/acel/tooltip/types.ts | 86 + .../acel/{internal => utilities}/arrays.ts | 0 .../acel/{shared => utilities}/attributes.ts | 0 src/uix/acel/{internal => utilities}/attrs.ts | 0 .../box}/box-auto-reset.svelte.ts | 4 +- src/uix/acel/{internal => utilities}/clamp.ts | 0 .../acel/{internal => utilities}/create-id.ts | 0 .../{internal => utilities/css}/css-escape.ts | 0 .../datetime}/announcer.ts | 4 +- .../datetime}/calendar-helpers.svelte.ts | 26 +- .../datetime/date-types.ts} | 4 +- .../datetime}/field/helpers.ts | 12 +- .../datetime}/field/parts.ts | 0 .../datetime}/field/segments.ts | 4 +- .../datetime}/field/time-helpers.ts | 10 +- .../datetime}/field/types.ts | 0 .../datetime}/formatter.ts | 6 +- .../datetime}/placeholders.ts | 0 .../date-time => utilities/datetime}/utils.ts | 4 +- .../acel/{internal => utilities}/debounce.ts | 0 .../dom}/body-scroll-lock.svelte.ts | 10 +- .../dom.ts => utilities/dom/dom-internal.ts} | 0 .../{internal => utilities/dom}/elements.ts | 0 .../acel/{internal => utilities/dom}/focus.ts | 4 +- .../is.ts => utilities/dom/is-browser.ts} | 0 .../{internal => utilities/dom}/locale.ts | 2 +- .../dom}/svelte-resize-observer.svelte.ts | 2 +- .../events/event-dispatcher.ts} | 0 .../floating}/floating-utils.svelte.ts | 12 +- .../floating}/grace-area.svelte.ts | 6 +- .../floating}/safe-polygon.svelte.ts | 4 +- .../floating}/types.ts | 2 +- .../floating/use-floating-layer.svelte.ts | 421 +++++ .../floating}/use-floating.svelte.ts | 2 +- .../focus}/roving-focus-group.ts | 12 +- .../focus}/should-enable-focus-trap.ts | 0 .../{internal => utilities/focus}/tabbable.ts | 2 +- .../focus}/use-arrow-navigation.ts | 2 +- src/uix/acel/utilities/index.ts | 87 +- .../keyboard}/get-directional-keys.ts | 2 +- .../keyboard/is-using-keyboard.svelte.ts | 50 + .../keyboard}/kbd-constants.ts | 0 .../{internal => utilities/keyboard}/kbd.ts | 0 src/uix/acel/{internal => utilities}/math.ts | 0 src/uix/acel/{internal => utilities}/noop.ts | 0 .../presence}/animations-complete.ts | 2 +- .../presence}/presence-manager.svelte.ts | 2 +- .../shared-state.svelte.ts | 0 .../{internal => utilities}/state-machine.ts | 2 +- .../{internal => utilities}/timeout-fn.ts | 2 +- .../typeahead}/data-typeahead.svelte.ts | 6 +- .../typeahead}/dom-typeahead.svelte.ts | 6 +- src/uix/acel/utilities/types.ts | 114 +- .../acel/{internal => utilities}/use-id.ts | 0 src/uix/acel/{internal => utilities}/warn.ts | 0 362 files changed, 26611 insertions(+), 331 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 src/routes/test/aspect-ratio/+page.svelte create mode 100644 src/routes/test/avatar/+page.svelte create mode 100644 src/routes/test/calendar/+page.svelte create mode 100644 src/routes/test/checkbox/+page.svelte create mode 100644 src/routes/test/command/+page.svelte create mode 100644 src/routes/test/context-menu/+page.svelte create mode 100644 src/routes/test/dropdown-menu/+page.svelte create mode 100644 src/routes/test/field/+page.svelte create mode 100644 src/routes/test/label/+page.svelte create mode 100644 src/routes/test/menubar/+page.svelte create mode 100644 src/routes/test/meter/+page.svelte create mode 100644 src/routes/test/pagination/+page.svelte create mode 100644 src/routes/test/pin-input/+page.svelte create mode 100644 src/routes/test/popover/+page.svelte create mode 100644 src/routes/test/progress/+page.svelte create mode 100644 src/routes/test/radio-group/+page.svelte create mode 100644 src/routes/test/rating-group/+page.svelte create mode 100644 src/routes/test/scroll-area/+page.svelte create mode 100644 src/routes/test/separator/+page.svelte create mode 100644 src/routes/test/slider/+page.svelte create mode 100644 src/routes/test/switch/+page.svelte create mode 100644 src/routes/test/toggle-group/+page.svelte create mode 100644 src/routes/test/toggle/+page.svelte create mode 100644 src/routes/test/toolbar/+page.svelte create mode 100644 src/routes/test/tooltip/+page.svelte create mode 100644 src/uix/acel/aspect-ratio/aspect-ratio.svelte.ts create mode 100644 src/uix/acel/aspect-ratio/components/aspect-ratio.svelte create mode 100644 src/uix/acel/aspect-ratio/exports.ts create mode 100644 src/uix/acel/aspect-ratio/index.ts create mode 100644 src/uix/acel/aspect-ratio/types.ts create mode 100644 src/uix/acel/avatar/avatar.svelte.ts create mode 100644 src/uix/acel/avatar/components/avatar-fallback.svelte create mode 100644 src/uix/acel/avatar/components/avatar-image.svelte create mode 100644 src/uix/acel/avatar/components/avatar.svelte create mode 100644 src/uix/acel/avatar/exports.ts create mode 100644 src/uix/acel/avatar/index.ts create mode 100644 src/uix/acel/avatar/types.ts create mode 100644 src/uix/acel/calendar/calendar.svelte.ts create mode 100644 src/uix/acel/calendar/components/calendar-cell.svelte create mode 100644 src/uix/acel/calendar/components/calendar-day.svelte create mode 100644 src/uix/acel/calendar/components/calendar-grid-body.svelte create mode 100644 src/uix/acel/calendar/components/calendar-grid-head.svelte create mode 100644 src/uix/acel/calendar/components/calendar-grid-row.svelte create mode 100644 src/uix/acel/calendar/components/calendar-grid.svelte create mode 100644 src/uix/acel/calendar/components/calendar-head-cell.svelte create mode 100644 src/uix/acel/calendar/components/calendar-header.svelte create mode 100644 src/uix/acel/calendar/components/calendar-heading.svelte create mode 100644 src/uix/acel/calendar/components/calendar-month-select.svelte create mode 100644 src/uix/acel/calendar/components/calendar-next-button.svelte create mode 100644 src/uix/acel/calendar/components/calendar-prev-button.svelte create mode 100644 src/uix/acel/calendar/components/calendar-year-select.svelte create mode 100644 src/uix/acel/calendar/components/calendar.svelte create mode 100644 src/uix/acel/calendar/exports.ts create mode 100644 src/uix/acel/calendar/index.ts create mode 100644 src/uix/acel/calendar/types.ts create mode 100644 src/uix/acel/checkbox/checkbox.svelte.ts create mode 100644 src/uix/acel/checkbox/components/checkbox-group-label.svelte create mode 100644 src/uix/acel/checkbox/components/checkbox-group.svelte create mode 100644 src/uix/acel/checkbox/components/checkbox.svelte create mode 100644 src/uix/acel/checkbox/exports.ts create mode 100644 src/uix/acel/checkbox/index.ts create mode 100644 src/uix/acel/checkbox/types.ts create mode 100644 src/uix/acel/command/command.svelte.ts create mode 100644 src/uix/acel/command/components/_command-label.svelte create mode 100644 src/uix/acel/command/components/command-empty.svelte create mode 100644 src/uix/acel/command/components/command-group-heading.svelte create mode 100644 src/uix/acel/command/components/command-group-items.svelte create mode 100644 src/uix/acel/command/components/command-group.svelte create mode 100644 src/uix/acel/command/components/command-input.svelte create mode 100644 src/uix/acel/command/components/command-item.svelte create mode 100644 src/uix/acel/command/components/command-link-item.svelte create mode 100644 src/uix/acel/command/components/command-list.svelte create mode 100644 src/uix/acel/command/components/command-loading.svelte create mode 100644 src/uix/acel/command/components/command-separator.svelte create mode 100644 src/uix/acel/command/components/command-viewport.svelte create mode 100644 src/uix/acel/command/components/command.svelte create mode 100644 src/uix/acel/command/compute-command-score.ts create mode 100644 src/uix/acel/command/exports.ts create mode 100644 src/uix/acel/command/index.ts create mode 100644 src/uix/acel/command/types.ts create mode 100644 src/uix/acel/command/utils.ts create mode 100644 src/uix/acel/comn/floating-layer/floating-layer-anchor.svelte create mode 100644 src/uix/acel/comn/floating-layer/floating-layer.svelte create mode 100644 src/uix/acel/comn/popper-layer/popper-layer.svelte create mode 100644 src/uix/acel/context-menu/components/context-menu-content.svelte create mode 100644 src/uix/acel/context-menu/components/context-menu-trigger.svelte create mode 100644 src/uix/acel/context-menu/components/context-menu.svelte create mode 100644 src/uix/acel/context-menu/exports.ts create mode 100644 src/uix/acel/context-menu/index.ts create mode 100644 src/uix/acel/context-menu/types.ts create mode 100644 src/uix/acel/date-field/components/date-field-hidden-input.svelte create mode 100644 src/uix/acel/date-field/components/date-field-input.svelte create mode 100644 src/uix/acel/date-field/components/date-field-label.svelte create mode 100644 src/uix/acel/date-field/components/date-field-segment.svelte create mode 100644 src/uix/acel/date-field/components/date-field.svelte create mode 100644 src/uix/acel/date-field/date-field.svelte.ts create mode 100644 src/uix/acel/date-field/exports.ts create mode 100644 src/uix/acel/date-field/index.ts create mode 100644 src/uix/acel/date-field/types.ts create mode 100644 src/uix/acel/dropdown-menu/components/dropdown-menu-content.svelte create mode 100644 src/uix/acel/dropdown-menu/components/dropdown-menu.svelte create mode 100644 src/uix/acel/dropdown-menu/exports.ts create mode 100644 src/uix/acel/dropdown-menu/index.ts create mode 100644 src/uix/acel/dropdown-menu/types.ts create mode 100644 src/uix/acel/field/components/Field.Control.svelte create mode 100644 src/uix/acel/field/components/Field.Error.svelte create mode 100644 src/uix/acel/field/components/Field.HelperText.svelte create mode 100644 src/uix/acel/field/components/Field.Input.svelte create mode 100644 src/uix/acel/field/components/Field.Label.svelte create mode 100644 src/uix/acel/field/components/Field.Prefix.svelte create mode 100644 src/uix/acel/field/components/Field.Root.svelte create mode 100644 src/uix/acel/field/components/Field.Suffix.svelte create mode 100644 src/uix/acel/field/exports.ts create mode 100644 src/uix/acel/field/field.svelte.ts create mode 100644 src/uix/acel/field/index.ts create mode 100644 src/uix/acel/field/types.ts delete mode 100644 src/uix/acel/internal/types.ts create mode 100644 src/uix/acel/label/components/label.svelte create mode 100644 src/uix/acel/label/exports.ts create mode 100644 src/uix/acel/label/index.ts create mode 100644 src/uix/acel/label/label.svelte.ts create mode 100644 src/uix/acel/label/types.ts create mode 100644 src/uix/acel/menu/components/menu-arrow.svelte create mode 100644 src/uix/acel/menu/components/menu-checkbox-group.svelte create mode 100644 src/uix/acel/menu/components/menu-checkbox-item.svelte create mode 100644 src/uix/acel/menu/components/menu-content.svelte create mode 100644 src/uix/acel/menu/components/menu-group-heading.svelte create mode 100644 src/uix/acel/menu/components/menu-group.svelte create mode 100644 src/uix/acel/menu/components/menu-item.svelte create mode 100644 src/uix/acel/menu/components/menu-radio-group.svelte create mode 100644 src/uix/acel/menu/components/menu-radio-item.svelte create mode 100644 src/uix/acel/menu/components/menu-separator.svelte create mode 100644 src/uix/acel/menu/components/menu-sub-content.svelte create mode 100644 src/uix/acel/menu/components/menu-sub-trigger.svelte create mode 100644 src/uix/acel/menu/components/menu-sub.svelte create mode 100644 src/uix/acel/menu/components/menu-trigger.svelte create mode 100644 src/uix/acel/menu/components/menu.svelte create mode 100644 src/uix/acel/menu/exports.ts create mode 100644 src/uix/acel/menu/index.ts create mode 100644 src/uix/acel/menu/menu.svelte.ts create mode 100644 src/uix/acel/menu/types.ts create mode 100644 src/uix/acel/menu/utils.ts create mode 100644 src/uix/acel/menubar/components/menubar-content.svelte create mode 100644 src/uix/acel/menubar/components/menubar-menu.svelte create mode 100644 src/uix/acel/menubar/components/menubar-trigger.svelte create mode 100644 src/uix/acel/menubar/components/menubar.svelte create mode 100644 src/uix/acel/menubar/exports.ts create mode 100644 src/uix/acel/menubar/index.ts create mode 100644 src/uix/acel/menubar/menubar.svelte.ts create mode 100644 src/uix/acel/menubar/types.ts create mode 100644 src/uix/acel/meter/components/meter.svelte create mode 100644 src/uix/acel/meter/exports.ts create mode 100644 src/uix/acel/meter/index.ts create mode 100644 src/uix/acel/meter/meter.svelte.ts create mode 100644 src/uix/acel/meter/types.ts create mode 100644 src/uix/acel/pagination/components/pagination-next-button.svelte create mode 100644 src/uix/acel/pagination/components/pagination-page.svelte create mode 100644 src/uix/acel/pagination/components/pagination-prev-button.svelte create mode 100644 src/uix/acel/pagination/components/pagination.svelte create mode 100644 src/uix/acel/pagination/exports.ts create mode 100644 src/uix/acel/pagination/index.ts create mode 100644 src/uix/acel/pagination/pagination.svelte.ts create mode 100644 src/uix/acel/pagination/types.ts create mode 100644 src/uix/acel/pin-input/components/pin-input-cell.svelte create mode 100644 src/uix/acel/pin-input/components/pin-input.svelte create mode 100644 src/uix/acel/pin-input/exports.ts create mode 100644 src/uix/acel/pin-input/index.ts create mode 100644 src/uix/acel/pin-input/pin-input.svelte.ts create mode 100644 src/uix/acel/pin-input/types.ts create mode 100644 src/uix/acel/pin-input/usePasswordManager.svelte.ts create mode 100644 src/uix/acel/popover/components/popover-close.svelte create mode 100644 src/uix/acel/popover/components/popover-content.svelte create mode 100644 src/uix/acel/popover/components/popover-overlay.svelte create mode 100644 src/uix/acel/popover/components/popover-trigger.svelte create mode 100644 src/uix/acel/popover/components/popover.svelte create mode 100644 src/uix/acel/popover/exports.ts create mode 100644 src/uix/acel/popover/index.ts create mode 100644 src/uix/acel/popover/popover.svelte.ts create mode 100644 src/uix/acel/popover/types.ts create mode 100644 src/uix/acel/progress/components/progress.svelte create mode 100644 src/uix/acel/progress/exports.ts create mode 100644 src/uix/acel/progress/index.ts create mode 100644 src/uix/acel/progress/progress.svelte.ts create mode 100644 src/uix/acel/progress/types.ts create mode 100644 src/uix/acel/radio-group/components/radio-group-item.svelte create mode 100644 src/uix/acel/radio-group/components/radio-group.svelte create mode 100644 src/uix/acel/radio-group/exports.ts create mode 100644 src/uix/acel/radio-group/index.ts create mode 100644 src/uix/acel/radio-group/radio-group.svelte.ts create mode 100644 src/uix/acel/radio-group/types.ts create mode 100644 src/uix/acel/rating-group/components/rating-group-item.svelte create mode 100644 src/uix/acel/rating-group/components/rating-group.svelte create mode 100644 src/uix/acel/rating-group/exports.ts create mode 100644 src/uix/acel/rating-group/index.ts create mode 100644 src/uix/acel/rating-group/rating-group.svelte.ts create mode 100644 src/uix/acel/rating-group/types.ts create mode 100644 src/uix/acel/scroll-area/components/presence-layer.svelte create mode 100644 src/uix/acel/scroll-area/components/scroll-area-corner-impl.svelte create mode 100644 src/uix/acel/scroll-area/components/scroll-area-corner.svelte create mode 100644 src/uix/acel/scroll-area/components/scroll-area-scrollbar-auto.svelte create mode 100644 src/uix/acel/scroll-area/components/scroll-area-scrollbar-hover.svelte create mode 100644 src/uix/acel/scroll-area/components/scroll-area-scrollbar-scroll.svelte create mode 100644 src/uix/acel/scroll-area/components/scroll-area-scrollbar-shared.svelte create mode 100644 src/uix/acel/scroll-area/components/scroll-area-scrollbar-visible.svelte create mode 100644 src/uix/acel/scroll-area/components/scroll-area-scrollbar-x.svelte create mode 100644 src/uix/acel/scroll-area/components/scroll-area-scrollbar-y.svelte create mode 100644 src/uix/acel/scroll-area/components/scroll-area-scrollbar.svelte create mode 100644 src/uix/acel/scroll-area/components/scroll-area-thumb-impl.svelte create mode 100644 src/uix/acel/scroll-area/components/scroll-area-thumb.svelte create mode 100644 src/uix/acel/scroll-area/components/scroll-area-viewport.svelte create mode 100644 src/uix/acel/scroll-area/components/scroll-area.svelte create mode 100644 src/uix/acel/scroll-area/exports.ts create mode 100644 src/uix/acel/scroll-area/index.ts create mode 100644 src/uix/acel/scroll-area/scroll-area.svelte.ts create mode 100644 src/uix/acel/scroll-area/types.ts create mode 100644 src/uix/acel/separator/components/separator.svelte create mode 100644 src/uix/acel/separator/exports.ts create mode 100644 src/uix/acel/separator/index.ts create mode 100644 src/uix/acel/separator/separator.svelte.ts create mode 100644 src/uix/acel/separator/types.ts delete mode 100644 src/uix/acel/shared/css.d.ts delete mode 100644 src/uix/acel/shared/index.ts delete mode 100644 src/uix/acel/shared/types.ts create mode 100644 src/uix/acel/slider/components/slider-range.svelte create mode 100644 src/uix/acel/slider/components/slider-thumb-label.svelte create mode 100644 src/uix/acel/slider/components/slider-thumb.svelte create mode 100644 src/uix/acel/slider/components/slider-tick-label.svelte create mode 100644 src/uix/acel/slider/components/slider-tick.svelte create mode 100644 src/uix/acel/slider/components/slider.svelte create mode 100644 src/uix/acel/slider/exports.ts create mode 100644 src/uix/acel/slider/helpers.ts create mode 100644 src/uix/acel/slider/index.ts create mode 100644 src/uix/acel/slider/slider.svelte.ts create mode 100644 src/uix/acel/slider/types.ts create mode 100644 src/uix/acel/switch/components/switch-thumb.svelte create mode 100644 src/uix/acel/switch/components/switch.svelte create mode 100644 src/uix/acel/switch/exports.ts create mode 100644 src/uix/acel/switch/index.ts create mode 100644 src/uix/acel/switch/switch.svelte.ts create mode 100644 src/uix/acel/switch/types.ts create mode 100644 src/uix/acel/toggle-group/components/toggle-group-item.svelte create mode 100644 src/uix/acel/toggle-group/components/toggle-group.svelte create mode 100644 src/uix/acel/toggle-group/exports.ts create mode 100644 src/uix/acel/toggle-group/index.ts create mode 100644 src/uix/acel/toggle-group/toggle-group.svelte.ts create mode 100644 src/uix/acel/toggle-group/types.ts create mode 100644 src/uix/acel/toggle/components/toggle.svelte create mode 100644 src/uix/acel/toggle/exports.ts create mode 100644 src/uix/acel/toggle/index.ts create mode 100644 src/uix/acel/toggle/toggle.svelte.ts create mode 100644 src/uix/acel/toggle/types.ts create mode 100644 src/uix/acel/toolbar/components/toolbar-button.svelte create mode 100644 src/uix/acel/toolbar/components/toolbar-group-item.svelte create mode 100644 src/uix/acel/toolbar/components/toolbar-group.svelte create mode 100644 src/uix/acel/toolbar/components/toolbar-link.svelte create mode 100644 src/uix/acel/toolbar/components/toolbar.svelte create mode 100644 src/uix/acel/toolbar/exports.ts create mode 100644 src/uix/acel/toolbar/index.ts create mode 100644 src/uix/acel/toolbar/toolbar.svelte.ts create mode 100644 src/uix/acel/toolbar/types.ts create mode 100644 src/uix/acel/tooltip/components/tooltip-content.svelte create mode 100644 src/uix/acel/tooltip/components/tooltip-provider.svelte create mode 100644 src/uix/acel/tooltip/components/tooltip-trigger.svelte create mode 100644 src/uix/acel/tooltip/components/tooltip.svelte create mode 100644 src/uix/acel/tooltip/exports.ts create mode 100644 src/uix/acel/tooltip/index.ts create mode 100644 src/uix/acel/tooltip/tooltip.svelte.ts create mode 100644 src/uix/acel/tooltip/types.ts rename src/uix/acel/{internal => utilities}/arrays.ts (100%) rename src/uix/acel/{shared => utilities}/attributes.ts (100%) rename src/uix/acel/{internal => utilities}/attrs.ts (100%) rename src/uix/acel/{internal => utilities/box}/box-auto-reset.svelte.ts (92%) rename src/uix/acel/{internal => utilities}/clamp.ts (100%) rename src/uix/acel/{internal => utilities}/create-id.ts (100%) rename src/uix/acel/{internal => utilities/css}/css-escape.ts (100%) rename src/uix/acel/{internal/date-time => utilities/datetime}/announcer.ts (95%) rename src/uix/acel/{internal/date-time => utilities/datetime}/calendar-helpers.svelte.ts (96%) rename src/uix/acel/{shared/date/types.ts => utilities/datetime/date-types.ts} (98%) rename src/uix/acel/{internal/date-time => utilities/datetime}/field/helpers.ts (97%) rename src/uix/acel/{internal/date-time => utilities/datetime}/field/parts.ts (100%) rename src/uix/acel/{internal/date-time => utilities/datetime}/field/segments.ts (98%) rename src/uix/acel/{internal/date-time => utilities/datetime}/field/time-helpers.ts (97%) rename src/uix/acel/{internal/date-time => utilities/datetime}/field/types.ts (100%) rename src/uix/acel/{internal/date-time => utilities/datetime}/formatter.ts (96%) rename src/uix/acel/{internal/date-time => utilities/datetime}/placeholders.ts (100%) rename src/uix/acel/{internal/date-time => utilities/datetime}/utils.ts (99%) rename src/uix/acel/{internal => utilities}/debounce.ts (100%) rename src/uix/acel/{internal => utilities/dom}/body-scroll-lock.svelte.ts (97%) rename src/uix/acel/{internal/dom.ts => utilities/dom/dom-internal.ts} (100%) rename src/uix/acel/{internal => utilities/dom}/elements.ts (100%) rename src/uix/acel/{internal => utilities/dom}/focus.ts (98%) rename src/uix/acel/{internal/is.ts => utilities/dom/is-browser.ts} (100%) rename src/uix/acel/{internal => utilities/dom}/locale.ts (88%) rename src/uix/acel/{internal => utilities/dom}/svelte-resize-observer.svelte.ts (93%) rename src/uix/acel/{internal/events.ts => utilities/events/event-dispatcher.ts} (100%) rename src/uix/acel/{internal/floating-svelte => utilities/floating}/floating-utils.svelte.ts (63%) rename src/uix/acel/{internal => utilities/floating}/grace-area.svelte.ts (97%) rename src/uix/acel/{internal => utilities/floating}/safe-polygon.svelte.ts (99%) rename src/uix/acel/{internal/floating-svelte => utilities/floating}/types.ts (97%) create mode 100644 src/uix/acel/utilities/floating/use-floating-layer.svelte.ts rename src/uix/acel/{internal/floating-svelte => utilities/floating}/use-floating.svelte.ts (99%) rename src/uix/acel/{internal => utilities/focus}/roving-focus-group.ts (91%) rename src/uix/acel/{internal => utilities/focus}/should-enable-focus-trap.ts (100%) rename src/uix/acel/{internal => utilities/focus}/tabbable.ts (97%) rename src/uix/acel/{internal => utilities/focus}/use-arrow-navigation.ts (98%) rename src/uix/acel/{internal => utilities/keyboard}/get-directional-keys.ts (95%) create mode 100644 src/uix/acel/utilities/keyboard/is-using-keyboard.svelte.ts rename src/uix/acel/{internal => utilities/keyboard}/kbd-constants.ts (100%) rename src/uix/acel/{internal => utilities/keyboard}/kbd.ts (100%) rename src/uix/acel/{internal => utilities}/math.ts (100%) rename src/uix/acel/{internal => utilities}/noop.ts (100%) rename src/uix/acel/{internal => utilities/presence}/animations-complete.ts (98%) rename src/uix/acel/{internal => utilities/presence}/presence-manager.svelte.ts (95%) rename src/uix/acel/{internal => utilities}/shared-state.svelte.ts (100%) rename src/uix/acel/{internal => utilities}/state-machine.ts (94%) rename src/uix/acel/{internal => utilities}/timeout-fn.ts (93%) rename src/uix/acel/{internal => utilities/typeahead}/data-typeahead.svelte.ts (89%) rename src/uix/acel/{internal => utilities/typeahead}/dom-typeahead.svelte.ts (91%) rename src/uix/acel/{internal => utilities}/use-id.ts (100%) rename src/uix/acel/{internal => utilities}/warn.ts (100%) diff --git a/.claude/settings.json b/.claude/settings.json index b1fcd523a..1cd31019c 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -7,7 +7,16 @@ "Bash(npx tsc:*)", "Bash(grep -v \"^$\")", "Bash(grep -n export.*Mutable g:/dev/svelte/vicen/src/lib/glob/lib/date/utils.ts)", - "Bash(grep -rn export.*Mutable g:/dev/svelte/vicen/src/lib/glob/lib/date/ --include=*.ts)" + "Bash(grep -rn export.*Mutable g:/dev/svelte/vicen/src/lib/glob/lib/date/ --include=*.ts)", + "Bash(wc -l \"g:/dev/svelte/vicen/src/uix/uisv/lib/bits/calendar/components/\"*.svelte)", + "Bash(grep -n \"from \"\"@internationalized\\\\|from ''.*shared/date\\\\|from \"\".*shared/date\\\\|getDocument\\\\|isBrowser\\\\|styleToString\\\\|afterTick\" \"g:/dev/svelte/vicen/src/uix/acel/internal/date-time/utils.ts\" \"g:/dev/svelte/vicen/src/uix/acel/internal/date-time/formatter.ts\" \"g:/dev/svelte/vicen/src/uix/acel/internal/date-time/announcer.ts\" \"g:/dev/svelte/vicen/src/uix/acel/internal/date-time/calendar-helpers.svelte.ts\")", + "Bash(grep -r from.*[''].*internal/ g:/dev/svelte/vicen/src/uix/acel --include=*.ts --include=*.svelte -l)", + "Bash(grep -r from.*[''].*shared/ g:/dev/svelte/vicen/src/uix/acel --include=*.ts --include=*.svelte -l)", + "Bash(grep -rh \"from.*[''''].*\\\\\\(internal\\\\|shared\\\\\\)/\" g:/dev/svelte/vicen/src/uix/acel --include=*.ts --include=*.svelte)", + "Bash(grep -v \"^//\")", + "Bash(grep -r \"from.*[''''].*\\\\\\(internal\\\\|shared\\\\\\)/\" g:/dev/svelte/vicen/src/uix/acel --include=*.ts --include=*.svelte)", + "Bash(grep -v '^\\\\s*//')", + "Bash(git -C g:/dev/svelte/vicen status --short -- src/uix/acel/date-field/)" ] } } diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..840f6ea87 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,12 @@ +{ + "permissions": { + "allow": [ + "Bash(grep -v \"$lib/internal\")", + "Bash(grep \"from.*label\" g:/dev/svelte/vicen/src/uix/acel/field/components/*.svelte)", + "Bash(grep -n \"from\" g:/dev/svelte/vicen/src/uix/acel/field/components/*.svelte)", + "Bash(sed -i 's|from \"\"\\\\./field\\\\.svelte\"\"|from \"\"../field.svelte\"\"|g' *.svelte)", + "Bash(sed -i 's|from \"\"\\\\.\\\\.\\\\/\\\\.\\\\.\\\\/utilities\"\"|from \"\"../../utilities/index\"\"|g' *.svelte)", + "Bash(sed -i 's|from \"\"\\\\.\\\\/\\\\.\\\\.\\\\/types\"\"|from \"\"../types\"\"|g' *.svelte)" + ] + } +} diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts index 83addb7e9..e4fbaa741 100644 --- a/src/routes/+layout.ts +++ b/src/routes/+layout.ts @@ -1,2 +1,4 @@ +import "./layout.css" + export const ssr = false; export const prerender = false; diff --git a/src/routes/test/+page.svelte b/src/routes/test/+page.svelte index 229d43eda..bced3f94b 100644 --- a/src/routes/test/+page.svelte +++ b/src/routes/test/+page.svelte @@ -56,6 +56,74 @@
+ +
+

Componentes UI

+
+
+ Primitivas + +
+
+ Formulario + +
+
+ Compuestos + +
+
+ Floating + +
+
+ Overlays + +
+
+ Fechas + +
+
+
+
@@ -378,7 +446,35 @@ .theme-btn:hover { background: rgba(255,255,255,.1); } /* ── MAIN GRID ───────────────────────────────────────────────────────────── */ - main { max-width: 1400px; margin: 0 auto; padding: 2rem 1.5rem; } + main { max-width: 1400px; margin: 0 auto; padding: 2rem 1.5rem; display: flex; flex-direction: column; gap: 1.5rem; } + + /* ── COMPONENTS SECTION ──────────────────────────────────────────────────── */ + .comp-section { + background: #fff; + border: 1.5px solid #e0ddd6; + border-top: 3px solid #6366f1; + border-radius: 4px; + padding: 1.25rem; + } + .page.dark .comp-section { background: #16161d; border-color: #2a2a35; border-top-color: #6366f1; } + .comp-title { + font-size: .65rem; font-weight: 700; letter-spacing: .2em; + text-transform: uppercase; color: #999; margin-bottom: .85rem; + } + .comp-groups { display: flex; gap: 1.5rem; flex-wrap: wrap; } + .comp-group { display: flex; flex-direction: column; gap: .4rem; } + .comp-group-label { + font-size: .6rem; font-weight: 700; letter-spacing: .15em; + text-transform: uppercase; color: #bbb; + } + .comp-links { display: flex; flex-direction: column; gap: .2rem; } + .comp-link { + font-size: .78rem; font-weight: 500; + color: #6366f1; text-decoration: none; + padding: .1rem 0; + transition: opacity .15s; + } + .comp-link:hover { opacity: .7; text-decoration: underline; } .grid { display: grid; diff --git a/src/routes/test/aspect-ratio/+page.svelte b/src/routes/test/aspect-ratio/+page.svelte new file mode 100644 index 000000000..e6df6a7a6 --- /dev/null +++ b/src/routes/test/aspect-ratio/+page.svelte @@ -0,0 +1,249 @@ + + +
+ +
+
+
+ + aspect-ratio +
+ acel · proporción de aspecto +
+
+ +
+
+ + +
+
+ 01 + 16 / 9 — Panorámico +
+

+ Proporción estándar de vídeo HD. El contenedor reserva el espacio + exacto antes de que cargue el contenido. +

+ + +
+ 16 / 9 +
+
+
+ + +
+
+ 02 + 4 / 3 — Clásico +
+

+ Formato de pantalla anterior a la era HD. Usado en fotografía y + presentaciones tradicionales. +

+ + +
+ 4 / 3 +
+
+
+ + +
+
+ 03 + 1 / 1 — Cuadrado +
+

+ Proporción cuadrada. Ideal para avatares, miniaturas de producto + y grids de redes sociales. +

+ + +
+ 1 / 1 +
+
+
+ + +
+
+ 04 + 9 / 16 — Vertical +
+

+ Formato vertical para stories, reels y contenido móvil. + Panorámico rotado 90°. +

+ +
+ +
+ 9 / 16 +
+
+
+
+ + +
+
+ 05 + Con imagen real +
+

+ object-fit: cover garantiza que la imagen rellene + el espacio sin deformarse, independientemente de su resolución original. +

+ + + Montaña nevada + +
+ + +
+
+ 06 + Slot child +
+

+ Con el snippet child obtienes acceso directo a los + props del elemento raíz para composición avanzada. +

+ + + {#snippet child({ props })} +
+ 21 / 9 · CinemaScope + via child snippet +
+ {/snippet} +
+
+ +
+
+
+ + diff --git a/src/routes/test/avatar/+page.svelte b/src/routes/test/avatar/+page.svelte new file mode 100644 index 000000000..170a39eab --- /dev/null +++ b/src/routes/test/avatar/+page.svelte @@ -0,0 +1,364 @@ + + +
+ +
+
+
+ + avatar +
+ acel · imagen con fallback +
+
+ +
+
+ + +
+
+ 01 + Básico — imagen y fallback +
+

+ Mientras carga la imagen se muestra el fallback. + Una vez cargada, el fallback desaparece. +

+ +
+ + + US + + + + + U2 + + + + + U3 + +
+
+ + +
+
+ 02 + Error de carga +
+

+ Con una URL inválida el status pasa a "error" + y el fallback permanece visible indefinidamente. +

+ +
+ + + ! + + + + + + + + + + + + +
+
+ + +
+
+ 03 + Iniciales — solo fallback +
+

+ Sin Avatar.Image o con src nulo, + el fallback actúa como representación permanente. +

+ +
+ {#each INITIALS as user} + + + {user.init} + + + {/each} +
+
+ + +
+
+ 04 + delayMs + onLoadingStatusChange +
+

+ delayMs=800 retrasa la transición a "loaded" para evitar + parpadeos. El callback registra cada cambio de estado. +

+ + + + ... + + +
+ status: + {liveStatus} +
+ +
+ onLoadingStatusChange + {#each statusLog as entry} + {entry} + {:else} + — sin eventos aún + {/each} +
+
+ + +
+
+ 05 + src dinámico +
+

+ Cambia la URL en runtime. El estado vuelve a "loading" + y el fallback reaparece mientras carga la nueva imagen. +

+ + + + ↺ + + +
+ + + +
+

{dynamicSrc.slice(-30)}

+
+ + +
+
+ 06 + Tamaños +
+

+ El componente no impone tamaño. Se controla completamente por CSS. +

+ +
+ {#each [24, 32, 40, 56, 72] as size} + + + + U + + + {/each} +
+
+ +
+
+
+ + diff --git a/src/routes/test/calendar/+page.svelte b/src/routes/test/calendar/+page.svelte new file mode 100644 index 000000000..c551f38ff --- /dev/null +++ b/src/routes/test/calendar/+page.svelte @@ -0,0 +1,850 @@ + + +
+ +
+
+
+ + calendar +
+ acel · selección de fecha +
+
+ +
+
+ + +
+
+ 01 + Single — básico +
+

Selección de una fecha. Haz clic en un día para seleccionarlo.

+ + + {#snippet children({ months, weekdays })} + + ‹ + + › + + {#each months as month} + + + + {#each weekdays as day} + {day} + {/each} + + + + {#each month.weeks as week} + + {#each week as date} + + + + {/each} + + {/each} + + + {/each} + {/snippet} + + +
+ value: + {fmt(singleValue)} +
+
+ + +
+
+ 02 + Multiple — preseleccionado +
+

+ Selección de múltiples fechas. Máximo 5 días. + Pre-cargado con 3 fechas del mes actual. +

+ + + {#snippet children({ months, weekdays })} + + ‹ + + › + + {#each months as month} + + + + {#each weekdays as day} + {day} + {/each} + + + + {#each month.weeks as week} + + {#each week as date} + + + + {/each} + + {/each} + + + {/each} + {/snippet} + + +
+ values: + {fmtArr(multiPre)} +
+
+ + +
+
+ 03 + minValue / maxValue + isDateDisabled +
+

+ Rango permitido: ±1 mes. Fines de semana deshabilitados. + Días 10–15 marcados como no disponibles. +

+ + + {#snippet children({ months, weekdays })} + + ‹ + + › + + {#each months as month} + + + + {#each weekdays as day} + {day} + {/each} + + + + {#each month.weeks as week} + + {#each week as date} + + + + {/each} + + {/each} + + + {/each} + {/snippet} + + +
+ value: + {fmt(disabledValue)} +
+
+ + +
+
+ 04 + MonthSelect + YearSelect +
+

+ Navegación rápida usando selectores de mes y año. +

+ + + {#snippet children({ months, weekdays })} + + ‹ +
+ + +
+ › +
+ {#each months as month} + + + + {#each weekdays as day} + {day} + {/each} + + + + {#each month.weeks as week} + + {#each week as date} + + + + {/each} + + {/each} + + + {/each} + {/snippet} +
+ +
+ value: + {fmt(rangeValue)} +
+
+ + +
+
+ 05 + numberOfMonths=2 + fixedWeeks + weekStartsOn=1 +
+

+ Dos meses visibles simultáneamente. Semanas fijas (siempre 6 filas). + Semana empieza el lunes. +

+ + + {#snippet children({ months, weekdays })} +
+ {#each months as month, i} +
+
+ {#if i === 0} + ‹ + {:else} + + {/if} + + {#if i === months.length - 1} + › + {:else} + + {/if} +
+ + + + {#each weekdays as day} + {day} + {/each} + + + + {#each month.weeks as week} + + {#each week as date} + + + + {/each} + + {/each} + + +
+ {/each} +
+ {/snippet} +
+ +
+ value: + {fmt(twoMonthValue)} +
+
+ + +
+
+ 06 + readonly / disabled +
+

+ Modo readonly: navegable pero no seleccionable. + Modo disabled: completamente inactivo. +

+ +
+
+

readonly

+ + {#snippet children({ months, weekdays })} + + ‹ + + › + + {#each months as month} + + + + {#each weekdays as day} + {day} + {/each} + + + + {#each month.weeks as week} + + {#each week as date} + + + + {/each} + + {/each} + + + {/each} + {/snippet} + +
+ +
+

disabled

+ + {#snippet children({ months, weekdays })} + + ‹ + + › + + {#each months as month} + + + + {#each weekdays as day} + {day} + {/each} + + + + {#each month.weeks as week} + + {#each week as date} + + + + {/each} + + {/each} + + + {/each} + {/snippet} + +
+
+
+ + +
+
+ 07 + Intervalo de fechas — inicio / fin +
+

+ Dos calendarios vinculados: el fin no puede ser anterior al inicio y viceversa. + maxValue del inicio = fecha fin · minValue del fin = fecha inicio. +

+ +
+
+

inicio

+ + {#snippet children({ months, weekdays })} + + ‹ + + › + + {#each months as month} + + + + {#each weekdays as day} + {day} + {/each} + + + + {#each month.weeks as week} + + {#each week as date} + + + + {/each} + + {/each} + + + {/each} + {/snippet} + +
+ +
+ → + {#if rangeStart && rangeEnd} + {rangeDays}d + {/if} +
+ +
+

fin

+ + {#snippet children({ months, weekdays })} + + ‹ + + › + + {#each months as month} + + + + {#each weekdays as day} + {day} + {/each} + + + + {#each month.weeks as week} + + {#each week as date} + + + + {/each} + + {/each} + + + {/each} + {/snippet} + +
+
+ +
+ rango: + {fmt(rangeStart)} → {fmt(rangeEnd)} +
+
+ +
+
+
+ + diff --git a/src/routes/test/checkbox/+page.svelte b/src/routes/test/checkbox/+page.svelte new file mode 100644 index 000000000..527967615 --- /dev/null +++ b/src/routes/test/checkbox/+page.svelte @@ -0,0 +1,185 @@ + + +
+

Checkbox

+ +
+

Básico

+ + +

acepto={acepto} · recordar={recordar}

+
+ +
+

Indeterminado

+ +
+ + +
+

indeterminate={indeterminate}

+
+ +
+

Grupo (múltiple)

+ + + + +
+ {#each [['js','JavaScript'],['ts','TypeScript'],['py','Python'],['go','Go']] as [val, label]} + + {/each} +
+
+

seleccionados: [{groupValue.join(', ')}]

+
+ +
+

Desactivados

+
+ + +
+
+
+ + diff --git a/src/routes/test/command/+page.svelte b/src/routes/test/command/+page.svelte new file mode 100644 index 000000000..17f9e0275 --- /dev/null +++ b/src/routes/test/command/+page.svelte @@ -0,0 +1,348 @@ + + +
+

Command

+ + +
+

Básico — búsqueda con grupos

+
+ (lastSelected = v || '—')}> + + + Sin resultados para «{search}» + + Frutas + + {#each fruits as fruit} + (lastSelected = fruit)}> + {fruit} + + {/each} + + + + + Herramientas + + {#each tools as tool} + (lastSelected = tool)}> + {tool} + + {/each} + + + + +
+

Seleccionado: {lastSelected}

+
+ + +
+

Con Viewport (altura limitada)

+
+ + + + + Sin resultados + {#each ['Agenda', 'Ajustes', 'App Store', 'Archivos', 'Calculadora', 'Cámara', 'Correo', 'FaceTime', 'Finder', 'Fotos', 'Maps', 'Mensajes', 'Music', 'News', 'Notas', 'Podcasts', 'Safari', 'Salud', 'Wallet'] as app} + {app} + {/each} + + + +
+
+ + +
+

Items deshabilitados + keywords

+
+ + + + Sin resultados + Copiar + Pegar + Eliminar + + Archivar (deshabilitado) + + + Deshacer + + +
+
+ + +
+

shouldFilter=false — filtrado manual

+
+ + + + Sin resultados + {#each ['Rojo', 'Verde', 'Azul', 'Amarillo', 'Naranja', 'Morado'] as item} + {item} + {/each} + + +
+
+ + +
+

Loading state

+
+ + + + {#if loading} + +
+
+
+
+ {:else} + Pulsa el botón para cargar + Resultado 1 + Resultado 2 + {/if} +
+
+
+ +
+ + +
+

LinkItem (elementos <a>)

+
+ + + + Sin resultados + {#each [ + { label: 'GitHub', href: 'https://github.com', keywords: ['git', 'code', 'repo'] }, + { label: 'MDN Web Docs', href: 'https://developer.mozilla.org', keywords: ['docs', 'web', 'api'] }, + { label: 'SvelteKit', href: 'https://kit.svelte.dev', keywords: ['svelte', 'framework'] }, + ] as link} + + {link.label} ↗ + + {/each} + + +
+
+
+ + + diff --git a/src/routes/test/context-menu/+page.svelte b/src/routes/test/context-menu/+page.svelte new file mode 100644 index 000000000..605201ba8 --- /dev/null +++ b/src/routes/test/context-menu/+page.svelte @@ -0,0 +1,426 @@ + + +
+

ContextMenu

+ + +
+

Básico — clic derecho

+ + +
+ Haz clic derecho aquí +
+
+ + + +
+ {#if lastAction !== '—'} +

Última acción: {lastAction}

+ {/if} +
+ + +
+

CheckboxItem

+ + +
+ Clic derecho para opciones de visualización +
+
+ + + +
+

+ Miniaturas: {checkedA} · Barra lateral: {checkedB} +

+
+ + +
+

RadioGroup + RadioItem

+ + +
+ Clic derecho para cambiar vista +
+
+ + + +
+

Vista actual: {selectedView}

+
+ + +
+

Submenú anidado

+ + +
+ Clic derecho — menú con submenú +
+
+ + + +
+ {#if lastAction !== '—'} +

Última acción: {lastAction}

+ {/if} +
+ + +
+

Múltiples zonas independientes

+
+ {#each ['Zona A', 'Zona B', 'Zona C'] as zona} + + +
+ {zona}
+ clic derecho +
+
+ + + +
+ {/each} +
+ {#if lastAction !== '—'} +

Última acción: {lastAction}

+ {/if} +
+
+ + diff --git a/src/routes/test/dropdown-menu/+page.svelte b/src/routes/test/dropdown-menu/+page.svelte new file mode 100644 index 000000000..83eea7290 --- /dev/null +++ b/src/routes/test/dropdown-menu/+page.svelte @@ -0,0 +1,411 @@ + + +
+

DropdownMenu

+ + +
+

Básico

+
+ + Abrir menú + + + + + {#if lastAction !== '—'} + Acción: {lastAction} + {/if} +
+
+ + +
+

Controlado (bind:open)

+
+ + Toggle menú + + + + + + {openState ? 'Abierto' : 'Cerrado'} + +
+
+ + +
+

CheckboxItem

+
+ + Preferencias + + + + + A: {checkedA} · B: {checkedB} +
+
+ + +
+

RadioGroup + RadioItem

+
+ + Color: {selectedColor} + + + + + Seleccionado: {selectedColor} +
+
+ + +
+

Groups + Separators

+
+ + Cuenta + + + + +
+
+ + +
+

Submenú

+
+ + Editar + + + + +
+
+ + +
+

Posición (side + align)

+
+ {#each [['bottom','start'], ['bottom','center'], ['bottom','end'], ['top','start']] as [side, align]} + + {side}/{align} + + + + + {/each} +
+
+
+ + diff --git a/src/routes/test/field/+page.svelte b/src/routes/test/field/+page.svelte new file mode 100644 index 000000000..501b71345 --- /dev/null +++ b/src/routes/test/field/+page.svelte @@ -0,0 +1,304 @@ + + +
+
+ +

Field — Demo

+ + +
+

Validación

+ + + + Correo electrónico + + + + + + Nunca compartiremos tu email. + + + + + + {emailError} + + + + +
+ + +
+

Prefix / Suffix

+ + + + Búsqueda + + + + + + + + + + {#if searchValue} + + + + {/if} + + +
+ + +
+

Password

+ + + + Contraseña + + + + + + + + + Mínimo 8 caracteres, una mayúscula y un número. + + +
+ + +
+

Loading async

+ + + + Nombre de usuario + + + + + {#if usernameState === "loading"} + + + + {:else if usernameState === "error"} + + + + {:else if usernameValue} + + + + {/if} + + + + Prueba con "admin" para ver el error. + + + Ese nombre de usuario ya está en uso. + + +
+ + +
+

Disabled / Readonly

+ + + + Plan actual + + + + + + Para cambiar de plan contacta con soporte. + + + + + + + ID de usuario + + + + usr_ + + + + + + + +
+ + +
+

Render delegation

+ + + {#snippet child({ props, isError }: FieldRootSnippetProps & { props: Record })} +
+ + Nombre de usuario + + + + + {#if isError} + + Este nombre de usuario ya está en uso. + + {/if} +
+ {/snippet} +
+
+ +
+
\ No newline at end of file diff --git a/src/routes/test/label/+page.svelte b/src/routes/test/label/+page.svelte new file mode 100644 index 000000000..4e77c5e62 --- /dev/null +++ b/src/routes/test/label/+page.svelte @@ -0,0 +1,124 @@ + + +
+

Label

+ +
+

Básico — label + input

+
+ Nombre completo + +
+
+ Correo electrónico + +
+

Valor: nombre={name || '—'} email={email || '—'}

+
+ +
+

Label + Checkbox

+
+ + + Acepto los términos y condiciones + +
+

Checked: {checked}

+

El doble clic sobre el label NO selecciona texto (preventDefault en detail > 1)

+
+ +
+

Label + Radio group

+
+ {#each [['apple','Manzana'], ['banana','Plátano'], ['cherry','Cereza']] as [val, label]} +
+ + {label} +
+ {/each} +
+

Seleccionado: {fruit}

+
+ +
+

Label como wrapper (sin for)

+ + Suscribirse al boletín + + +

El click en cualquier parte del label activa el input.

+
+
+ + diff --git a/src/routes/test/menubar/+page.svelte b/src/routes/test/menubar/+page.svelte new file mode 100644 index 000000000..f0cad3486 --- /dev/null +++ b/src/routes/test/menubar/+page.svelte @@ -0,0 +1,416 @@ + + +
+

Menubar

+ + +
+

Básico — barra horizontal

+ + + Archivo + + + + + + + Editar + + + + + + + Vista + + + + + + + Ayuda + + + + + + +
+ Acción: {lastAction} + Barra: {checkedA} · Panel: {checkedB} + Fuente: {selectedFont} +
+
+ + +
+

Controlado (bind:value)

+ + {#each ['Archivo', 'Editar', 'Formato'] as name, i} + {@const id = ['file2', 'edit2', 'format2'][i]} + + {name} + + + + + {/each} + +

+ Menú activo: {openMenu || '(ninguno)'} +

+
+ + +
+

Trigger deshabilitado

+ + + Activo + + + + + + Deshabilitado + + + + + + Otro + + + + + +
+
+ + diff --git a/src/routes/test/meter/+page.svelte b/src/routes/test/meter/+page.svelte new file mode 100644 index 000000000..e2a0447e7 --- /dev/null +++ b/src/routes/test/meter/+page.svelte @@ -0,0 +1,123 @@ + + +
+

Meter

+ +
+

Nivel de batería

+ +
+
+
+ + {battery}% +
+
+ +
+

Recursos del sistema

+ {#each [['CPU', cpu, getMeterColor(cpu)], ['Memoria', memory, getMeterColor(memory)], ['Disco', disk, getMeterColor(disk)]] as [label, val, color]} +
+ {label} + +
+
+ {val}% +
+ {/each} +
+ +
+
+ +
+

Diferencia con Progress

+

Meter usa role="meter" (valor actual de algo medible) mientras que Progress usa role="progressbar" (progreso de una tarea). Meter no tiene estado indeterminado.

+
+ +
+

Con rango personalizado (pH 0–14)

+ +
+
+

Valor: 7.4 / 14 — rango min=0, max=14

+
+
+ + diff --git a/src/routes/test/pagination/+page.svelte b/src/routes/test/pagination/+page.svelte new file mode 100644 index 000000000..c223f8f82 --- /dev/null +++ b/src/routes/test/pagination/+page.svelte @@ -0,0 +1,215 @@ + + +
+

Pagination

+ +
+

Básica — 50 elementos, 10 por página

+ + {#snippet children({ pages, totalPages })} + ‹ + + {#each pages as item (item.type === 'page' ? item.value : `e-${pages.indexOf(item)}`)} + {#if item.type === 'ellipsis'} + … + {:else} + + {item.value} + + {/if} + {/each} + + › + + Página {page1} de {totalPages} + {/snippet} + +
+ +
+

Con muchas páginas (sibling=1)

+ + {#snippet children({ pages, totalPages })} + ‹ Anterior + + {#each pages as item (item.type === 'page' ? item.value : `e-${pages.indexOf(item)}`)} + {#if item.type === 'ellipsis'} + … + {:else} + + {item.value} + + {/if} + {/each} + + Siguiente › + {/snippet} + +

Página actual: {page2} / 50 páginas totales

+
+ +
+

Tabla con paginación real ({TOTAL_ITEMS} registros)

+
+ Mostrando {(page3 - 1) * perPage + 1}–{Math.min(page3 * perPage, TOTAL_ITEMS)} de {TOTAL_ITEMS} + +
+ + + + + + + + + + + + {#each records as rec} + + + + + + + {/each} + +
#NombreEmailEstado
{rec.id}{rec.name}{rec.email}{rec.status}
+ + + {#snippet children({ pages, totalPages })} + ‹ + {#each pages as item (item.type === 'page' ? item.value : `e-${pages.indexOf(item)}`)} + {#if item.type === 'ellipsis'} + … + {:else} + + {item.value} + + {/if} + {/each} + › + {/snippet} + +
+
+ + diff --git a/src/routes/test/pin-input/+page.svelte b/src/routes/test/pin-input/+page.svelte new file mode 100644 index 000000000..d867932d5 --- /dev/null +++ b/src/routes/test/pin-input/+page.svelte @@ -0,0 +1,187 @@ + + +
+ ← volver +

PinInput

+ + +
+

6 dígitos (value: "{value6}")

+ + {#snippet children({ cells })} +
+ {#each cells as cell} + + {#snippet children()} + + {#if cell.hasFakeCaret} + + {:else} + {cell.char ?? ''} + {/if} + + {/snippet} + + {/each} +
+ {/snippet} +
+
+ + +
+

4 dígitos con onComplete

+ {#if completed} +

¡Completado: {completed}!

+ {/if} + + {#snippet children({ cells })} +
+ {#each cells as cell} + + {#snippet children()} + + {#if cell.hasFakeCaret} + + {:else} + {cell.char ?? ''} + {/if} + + {/snippet} + + {/each} +
+ {/snippet} +
+
+ + +
+

Solo letras (A-Z)

+ + {#snippet children({ cells, isFocused })} +
+ {#each cells as cell} + + {#snippet children()} + + {#if cell.hasFakeCaret} + + {:else} + {(cell.char ?? '').toUpperCase()} + {/if} + + {/snippet} + + {/each} +
+ {/snippet} +
+
+ + +
+

Disabled

+ + {#snippet children({ cells })} +
+ {#each cells as cell} + + {#snippet children()} + {cell.char ?? ''} + {/snippet} + + {/each} +
+ {/snippet} +
+
+
+ + diff --git a/src/routes/test/popover/+page.svelte b/src/routes/test/popover/+page.svelte new file mode 100644 index 000000000..2cfa3ba85 --- /dev/null +++ b/src/routes/test/popover/+page.svelte @@ -0,0 +1,267 @@ + + +
+

Popover

+ + +
+

Básico

+
+ + Abrir popover + +
+

Contenido del popover

+ Cerrar ✕ +
+
+
+
+
+ + +
+

Posiciones

+
+ {#each ['top', 'right', 'bottom', 'left'] as side} + + {side} + +
+ Popover {side} + ✕ +
+
+
+ {/each} +
+
+ + +
+

Alineación

+
+ {#each ['start', 'center', 'end'] as align} + + align={align} + +
+

align: {align}

+ Cerrar +
+
+
+ {/each} +
+
+ + +
+

Controlado (bind:open)

+
+ + Toggle popover + +
+

Estado: {openState ? 'abierto' : 'cerrado'}

+ Cerrar +
+
+
+ + {openState ? 'Abierto' : 'Cerrado'} + +
+
+ + +
+

Contenido rico

+ + ⚙️ Configuración + +
+

Configuración

+ + + +
+ Cancelar + Guardar +
+
+
+
+
+
+ + diff --git a/src/routes/test/progress/+page.svelte b/src/routes/test/progress/+page.svelte new file mode 100644 index 000000000..7b29d2947 --- /dev/null +++ b/src/routes/test/progress/+page.svelte @@ -0,0 +1,149 @@ + + +
+

Progress

+ +
+

Controlado con slider

+ +
+
+
+ + {progress}% +
+

data-state: {progress === 100 ? 'loaded' : 'loading'}

+
+ +
+

Indeterminado (value=null)

+ +
+
+

data-state: indeterminate — sin valor conocido

+
+ +
+

Animado — simulación de carga

+ + {#if loadValue !== null} +
+ {:else} +
+ {/if} +
+
+ + {loadValue !== null ? `${loadValue}%` : '—'} +
+
+ +
+

Múltiples variantes

+ {#each [[20, '#6366f1'], [45, '#22c55e'], [70, '#f59e0b'], [100, '#ef4444']] as [val, color]} +
+ {val}% + +
+
+
+ {/each} +
+
+ + diff --git a/src/routes/test/radio-group/+page.svelte b/src/routes/test/radio-group/+page.svelte new file mode 100644 index 000000000..12c85654a --- /dev/null +++ b/src/routes/test/radio-group/+page.svelte @@ -0,0 +1,187 @@ + + +
+

Radio Group

+ +
+

Selección de plan

+ + {#each [ + { value: 'free', label: 'Free', desc: '1 usuario · 5 GB', price: '$0' }, + { value: 'pro', label: 'Pro', desc: '5 usuarios · 50 GB', price: '$12/mes' }, + { value: 'enterprise', label: 'Enterprise', desc: 'Ilimitado · SLA 99.99%', price: 'Contactar' }, + ] as item} + + {/each} + +

plan seleccionado: {plan}

+
+ +
+

Método de pago

+ + {#each [ + { value: 'card', label: 'Tarjeta' }, + { value: 'paypal', label: 'PayPal' }, + { value: 'crypto', label: 'Cripto' }, + ] as opt} + + {/each} + +

pago: {payment}

+
+ +
+

Con opción desactivada

+ + {#each [ + { value: 'sm', label: 'Pequeño', disabled: false }, + { value: 'md', label: 'Mediano', disabled: false }, + { value: 'lg', label: 'Grande', disabled: false }, + { value: 'xl', label: 'XL (agotado)', disabled: true }, + ] as opt} + + {/each} + +

talla: {size}

+
+ +
+

Grupo desactivado

+ + {#each ['a','b','c'] as v} + + {/each} + +
+
+ + diff --git a/src/routes/test/rating-group/+page.svelte b/src/routes/test/rating-group/+page.svelte new file mode 100644 index 000000000..538954732 --- /dev/null +++ b/src/routes/test/rating-group/+page.svelte @@ -0,0 +1,146 @@ + + +
+

Rating Group

+ +
+

Valoración de película

+ + {#snippet children({ items })} + {#each items as { index, state }} + + ★ + + {/each} + {/snippet} + +

{movieRating} / 5 estrellas

+
+ +
+

Media estrella (allowHalf)

+ + {#snippet children({ items })} + {#each items as { index, state }} + + {#if state === 'partial'} + ★ + {:else} + ★ + {/if} + + {/each} + {/snippet} + +

{halfRating} / 5

+
+ +
+

Solo lectura

+ + {#snippet children({ items })} + {#each items as { index, state }} + ★ + {/each} + {/snippet} + +

readonly · valor fijo: {readonlyRating}

+
+ +
+

Valoración de producto

+ + {#snippet children({ items, value, max })} +
+
+ {#each items as { index, state }} + ★ + {/each} +
+ + {value === 0 ? 'Sin valorar' : `${value} de ${max} estrellas`} + +
+ {/snippet} +
+
+ +
+

Desactivado

+ + {#snippet children({ items })} + {#each items as { index, state }} + ★ + {/each} + {/snippet} + +

disabled · no interactivo

+
+
+ + diff --git a/src/routes/test/scroll-area/+page.svelte b/src/routes/test/scroll-area/+page.svelte new file mode 100644 index 000000000..d2fd67712 --- /dev/null +++ b/src/routes/test/scroll-area/+page.svelte @@ -0,0 +1,164 @@ + + +
+ ← volver +

ScrollArea

+ + +
+

Scroll vertical (hover)

+ + +
    + {#each items as item} +
  • {item}
  • + {/each} +
+
+ + + + +
+
+ + +
+

Scroll horizontal (scroll)

+ + +
+ {#each items.slice(0, 20) as item} +
{item}
+ {/each} +
+
+ + + +
+
+ + +
+

Ambos ejes (auto)

+ + +
{longText}
+
+ + + + + + + +
+
+ + +
+

Siempre visible (always)

+ + +
    + {#each items.slice(0, 20) as item} +
  • {item}
  • + {/each} +
+
+ + + +
+
+
+ + diff --git a/src/routes/test/separator/+page.svelte b/src/routes/test/separator/+page.svelte new file mode 100644 index 000000000..bcc718f27 --- /dev/null +++ b/src/routes/test/separator/+page.svelte @@ -0,0 +1,119 @@ + + +
+

Separator

+ +
+

Horizontal (decorativo, default)

+

Elemento de arriba

+ +

Elemento de abajo

+
+ +
+

Vertical (decorativo)

+
+ Izquierda + + Centro + + Derecha +
+
+ +
+

No decorativo (semántico, role="separator")

+

Sección de navegación

+ +

Sección de contenido

+

Inspecciona el DOM: tiene role="separator" y aria-orientation

+
+ +
+

Estilos personalizados

+

Con color

+ +

Grueso

+ +

Punteado

+ +
+
+ + diff --git a/src/routes/test/slider/+page.svelte b/src/routes/test/slider/+page.svelte new file mode 100644 index 000000000..fd79994dd --- /dev/null +++ b/src/routes/test/slider/+page.svelte @@ -0,0 +1,176 @@ + + +
+ ← volver +

Slider

+ + +
+

Single (value: {singleValue})

+
+ + {#snippet children({ thumbs })} + + {#each thumbs as i} + + {/each} + {/snippet} + +
+
+ + +
+

Range (values: {rangeValue.join(' – ')})

+
+ + {#snippet children({ thumbs })} + + {#each thumbs as i} + + {/each} + {/snippet} + +
+
+ + +
+

Con ticks (step: 20, value: {stepValue})

+
+ + {#snippet children({ thumbs, ticks })} + + {#each thumbs as i} + + {/each} + {#each ticks as i} + + {#snippet children()} + + {/snippet} + + {/each} + {/snippet} + +
+
+ + +
+

Vertical (value: {verticalValue})

+
+ + {#snippet children({ thumbs })} + + {#each thumbs as i} + + {/each} + {/snippet} + +
+
+ + +
+

Disabled (value: {disabledValue})

+
+ + {#snippet children({ thumbs })} + + {#each thumbs as i} + + {/each} + {/snippet} + +
+
+
+ + diff --git a/src/routes/test/switch/+page.svelte b/src/routes/test/switch/+page.svelte new file mode 100644 index 000000000..07026e469 --- /dev/null +++ b/src/routes/test/switch/+page.svelte @@ -0,0 +1,204 @@ + + +
+

Switch

+ +
+

Preferencias de notificaciones

+
+
+
+ Notificaciones push + Recibir alertas en tiempo real +
+ + + +
+
+
+ Emails de marketing + Ofertas y novedades del producto +
+ + + +
+
+
+ Alertas de seguridad + Inicios de sesión y accesos nuevos +
+ + + +
+
+
+ Actualizaciones del sistema + Cambios en la versión del app +
+ + + +
+
+

+ notificaciones={notifications} · marketing={marketing} · seguridad={security} · actualizaciones={updates} +

+
+ +
+

Con Label

+
+ Modo oscuro + + + +
+

El label activa el switch al hacer clic

+
+ +
+

Estados

+
+
+ + + + on +
+
+ + + + off +
+
+ + + + on + disabled +
+
+ + + + off + disabled +
+
+
+ +
+

Controlado

+
+ + + + +
+

checked={controlled}

+
+
+ + diff --git a/src/routes/test/toggle-group/+page.svelte b/src/routes/test/toggle-group/+page.svelte new file mode 100644 index 000000000..93bc492db --- /dev/null +++ b/src/routes/test/toggle-group/+page.svelte @@ -0,0 +1,145 @@ + + +
+

Toggle Group

+ +
+

Single — Alineación de texto

+ + + ⬅ Izq + + + ↔ Centro + + + ➡ Der + + + ≡ Just + + +

+ El rápido zorro marrón salta sobre el perro perezoso. Este texto cambia de alineación según el botón seleccionado. +

+

value: {align ?? '(ninguno)'}

+
+ +
+

Multiple — Estilo de texto

+ + + B + + + I + + + U + + + S + + +

+ Texto de ejemplo con múltiples estilos combinables. +

+

value: [{textStyle.join(', ')}]

+
+ +
+

Single — Vista

+ + ☰ Lista + ⊞ Cuadrícula + ⊟ Kanban + +

Vista activa: {view ?? '(ninguna)'}

+
+ +
+

Multiple — Días laborables

+ + {#each [['L','lun'],['M','mar'],['X','mié'],['J','jue'],['V','vie'],['S','sáb'],['D','dom']] as [key, label]} + + {key} + + {/each} + +

Seleccionados: {days.length ? days.join(', ') : '(ninguno)'}

+
+ +
+

Con items desactivados

+ + XS + SM + MD + LG + XL + +

MD y XL están desactivados (sin stock)

+
+
+ + diff --git a/src/routes/test/toggle/+page.svelte b/src/routes/test/toggle/+page.svelte new file mode 100644 index 000000000..7ef2e95c3 --- /dev/null +++ b/src/routes/test/toggle/+page.svelte @@ -0,0 +1,188 @@ + + +
+

Toggle

+ +
+

Formato de texto

+
+ + B + + + I + + + U + +
+

+ El rápido zorro marrón salta sobre el perro perezoso. +

+

bold={bold} italic={italic} underline={underline}

+
+ +
+

Conectividad

+
+
+
+ Wi-Fi + {wifi ? 'Conectado a Red_Hogar' : 'Desactivado'} +
+ + + +
+
+
+ Bluetooth + {bluetooth ? 'Visible para otros' : 'Desactivado'} +
+ + + +
+
+
+ Modo avión + {airplane ? 'Todas las radios desactivadas' : 'Normal'} +
+ + + +
+
+
+ +
+

Desactivado

+
+ + Activado + + + Desactivado + +
+

data-disabled="" cuando disabled=true

+
+ +
+

Silenciar

+ + {muted ? '🔇 Silenciado' : '🔊 Sonido activo'} + +
+
+ + diff --git a/src/routes/test/toolbar/+page.svelte b/src/routes/test/toolbar/+page.svelte new file mode 100644 index 000000000..f15dc44b0 --- /dev/null +++ b/src/routes/test/toolbar/+page.svelte @@ -0,0 +1,212 @@ + + +
+

Toolbar

+ +
+

Editor de texto

+ + + + + + B + + + + + + + + + I + + + U + + + S + + + + + + + + {#each [['left','⬤◻◻'],['center','◻⬤◻'],['right','◻◻⬤']] as [val, icon]} + + {icon} + + {/each} + + + + + + alert('Compartir')}> + ↑ Compartir + + + + + + Ayuda + + +
+

+ El rápido zorro marrón salta sobre el perro perezoso. +

+ align: {align} + {#if lists.length > 0} + formatos: {lists.join(', ')} + {/if} +
+
+ +
+

Vertical + desactivado parcial

+ + + + Opción A + + + Opción B + + + Opción C (desactivada) + + + alert('Acción')}> + Acción + + +
+ +
+

Navegación con teclado

+

Usa Tab para entrar, flechas para navegar entre ítems, Space/Enter para activar.

+ + + {#each ['left','center','right','justify'] as val} + + {val.slice(0, 4)} + + {/each} + + Guardar + Descartar + +
+
+ + diff --git a/src/routes/test/tooltip/+page.svelte b/src/routes/test/tooltip/+page.svelte new file mode 100644 index 000000000..99af48993 --- /dev/null +++ b/src/routes/test/tooltip/+page.svelte @@ -0,0 +1,219 @@ + + +
+

Tooltip

+ +
+

Ejemplo básico

+ + + + "Hover sobre mí" + + +
+ Make some magic! +
+
+
+
+
+ + + + +
+

Básico (delay 700ms)

+ +
+ + Hover sobre mí + +
Soy un tooltip básico
+
+
+ + + Otro trigger + +
Tooltip a la derecha
+
+
+ + + Deshabilitado + +
No debería aparecer
+
+
+
+
+
+ + +
+

Sin delay + skipDelay

+ +
+ {#each ['top', 'right', 'bottom', 'left'] as side} + + {side} + +
Tooltip {side}
+
+
+ {/each} +
+
+
+ + +
+

Contenido rico

+ + + ℹ️ Más información + +
+ Información adicional +

Este tooltip contiene contenido más elaborado con múltiples líneas de texto.

+ data-side="top" +
+
+
+
+
+ + +
+

disableHoverableContent=true

+ + + Hover (cierra al salir del trigger) + +
Se cierra al salir del trigger
+
+
+
+
+
+ + diff --git a/src/uix/acel/accordion/accordion.svelte.ts b/src/uix/acel/accordion/accordion.svelte.ts index 099a1fca8..97882a986 100644 --- a/src/uix/acel/accordion/accordion.svelte.ts +++ b/src/uix/acel/accordion/accordion.svelte.ts @@ -12,13 +12,13 @@ import type { AcelMouseEvent, RefAttachment, WithRefOpts, -} from "../internal/types"; -import { boolToStr, boolToEmptyStrOrUndef, getDataOpenClosed, createAcelAttrs } from "../internal/attrs"; -import { kbd } from "../internal/kbd"; -import type { Orientation } from "../shared/index"; -import { RovingFocusGroup } from "../internal/roving-focus-group"; +} from "../utilities/types"; +import { boolToStr, boolToEmptyStrOrUndef, getDataOpenClosed, createAcelAttrs } from "../utilities/attrs"; +import { kbd } from "../utilities/keyboard/kbd"; +import type { Orientation } from "../utilities/index"; +import { RovingFocusGroup } from "../utilities/focus/roving-focus-group"; import { on } from "svelte/events"; -import { PresenceManager } from "../internal/presence-manager.svelte"; +import { PresenceManager } from "../utilities/presence/presence-manager.svelte"; const accordionAttrs = createAcelAttrs({ component: "accordion", diff --git a/src/uix/acel/accordion/components/accordion-content.svelte b/src/uix/acel/accordion/components/accordion-content.svelte index aad274b64..c0ecabd4d 100644 --- a/src/uix/acel/accordion/components/accordion-content.svelte +++ b/src/uix/acel/accordion/components/accordion-content.svelte @@ -2,7 +2,7 @@ import { mergeProps, boxWith } from "../../utilities/index"; import { AccordionContentState } from "../accordion.svelte"; import type { AccordionContentProps } from "../types"; - import { createId } from "../../internal/create-id"; + import { createId } from "../../utilities/create-id"; const uid = $props.id(); diff --git a/src/uix/acel/accordion/components/accordion-header.svelte b/src/uix/acel/accordion/components/accordion-header.svelte index adafdaa50..3e0799296 100644 --- a/src/uix/acel/accordion/components/accordion-header.svelte +++ b/src/uix/acel/accordion/components/accordion-header.svelte @@ -2,7 +2,7 @@ import { boxWith, mergeProps } from "../../utilities/index"; import type { AccordionHeaderProps } from "../types"; import { AccordionHeaderState } from "../accordion.svelte"; - import { createId } from "../../internal/create-id"; + import { createId } from "../../utilities/create-id"; const uid = $props.id(); diff --git a/src/uix/acel/accordion/components/accordion-item.svelte b/src/uix/acel/accordion/components/accordion-item.svelte index 367d5e17c..3e2f86d1e 100644 --- a/src/uix/acel/accordion/components/accordion-item.svelte +++ b/src/uix/acel/accordion/components/accordion-item.svelte @@ -2,7 +2,7 @@ import { boxWith, mergeProps } from "../../utilities/index"; import type { AccordionItemProps } from "../types"; import { AccordionItemState } from "../accordion.svelte"; - import { createId } from "../../internal/create-id"; + import { createId } from "../../utilities/create-id"; const uid = $props.id(); const defaultId = createId(uid); diff --git a/src/uix/acel/accordion/components/accordion-trigger.svelte b/src/uix/acel/accordion/components/accordion-trigger.svelte index c1d92d3c1..ce21cce70 100644 --- a/src/uix/acel/accordion/components/accordion-trigger.svelte +++ b/src/uix/acel/accordion/components/accordion-trigger.svelte @@ -2,7 +2,7 @@ import { boxWith, mergeProps } from "../../utilities/index"; import type { AccordionTriggerProps } from "../types"; import { AccordionTriggerState } from "../accordion.svelte"; - import { createId } from "../../internal/create-id"; + import { createId } from "../../utilities/create-id"; const uid = $props.id(); diff --git a/src/uix/acel/accordion/components/accordion.svelte b/src/uix/acel/accordion/components/accordion.svelte index 5377a465d..de9ac2c56 100644 --- a/src/uix/acel/accordion/components/accordion.svelte +++ b/src/uix/acel/accordion/components/accordion.svelte @@ -2,9 +2,9 @@ import { type WritableBox, boxWith, mergeProps } from "../../utilities/index"; import { AccordionRootState } from "../accordion.svelte"; import type { AccordionRootProps } from "../types"; - import { noop } from "../../internal/noop"; + import { noop } from "../../utilities/noop"; import { watch } from "runed"; - import { createId } from "../../internal/create-id"; + import { createId } from "../../utilities/create-id"; const uid = $props.id(); diff --git a/src/uix/acel/accordion/types.ts b/src/uix/acel/accordion/types.ts index b016265b2..ea9198fac 100644 --- a/src/uix/acel/accordion/types.ts +++ b/src/uix/acel/accordion/types.ts @@ -3,12 +3,12 @@ import type { WithChild, WithChildNoChildrenSnippetProps, Without, -} from "../internal/types"; +} from "../utilities/types"; import type { AcelPrimitiveButtonAttributes, AcelPrimitiveDivAttributes, -} from "../shared/attributes"; -import type { Orientation } from "../shared/index"; +} from "../utilities/attributes"; +import type { Orientation } from "../utilities/index"; export type BaseAccordionRootPropsWithoutHTML = { /** diff --git a/src/uix/acel/alert-dialog/components/alert-dialog-action.svelte b/src/uix/acel/alert-dialog/components/alert-dialog-action.svelte index 800550cab..51ba5672a 100644 --- a/src/uix/acel/alert-dialog/components/alert-dialog-action.svelte +++ b/src/uix/acel/alert-dialog/components/alert-dialog-action.svelte @@ -2,7 +2,7 @@ import { boxWith, mergeProps } from "../../utilities/index"; import { DialogActionState } from "../../dialog/dialog.svelte"; import type { AlertDialogActionProps } from "../types"; - import { createId } from "../../internal/create-id"; + import { createId } from "../../utilities/create-id"; const uid = $props.id(); diff --git a/src/uix/acel/alert-dialog/components/alert-dialog-cancel.svelte b/src/uix/acel/alert-dialog/components/alert-dialog-cancel.svelte index 83a3846d6..655adb3ac 100644 --- a/src/uix/acel/alert-dialog/components/alert-dialog-cancel.svelte +++ b/src/uix/acel/alert-dialog/components/alert-dialog-cancel.svelte @@ -2,7 +2,7 @@ import { boxWith, mergeProps } from "../../utilities/index"; import { AlertDialogCancelState } from "../../dialog/dialog.svelte"; import type { AlertDialogCancelProps } from "../types"; - import { createId } from "../../internal/create-id"; + import { createId } from "../../utilities/create-id"; const uid = $props.id(); diff --git a/src/uix/acel/alert-dialog/components/alert-dialog-content.svelte b/src/uix/acel/alert-dialog/components/alert-dialog-content.svelte index 63a4d1250..26a3f8a14 100644 --- a/src/uix/acel/alert-dialog/components/alert-dialog-content.svelte +++ b/src/uix/acel/alert-dialog/components/alert-dialog-content.svelte @@ -2,8 +2,8 @@ import { boxWith, mergeProps, afterSleep } from "../../utilities/index"; import { DialogContentState } from "../../dialog/dialog.svelte"; import type { AlertDialogContentProps } from "../types"; - import { createId } from "../../internal/create-id"; - import { noop } from "../../internal/noop"; + import { createId } from "../../utilities/create-id"; + import { noop } from "../../utilities/noop"; const uid = $props.id(); diff --git a/src/uix/acel/alert-dialog/components/alert-dialog.svelte b/src/uix/acel/alert-dialog/components/alert-dialog.svelte index 2b4a8b2c8..f26840980 100644 --- a/src/uix/acel/alert-dialog/components/alert-dialog.svelte +++ b/src/uix/acel/alert-dialog/components/alert-dialog.svelte @@ -2,7 +2,7 @@ import { boxWith } from "../../utilities/index"; import { DialogRootState } from "../../dialog/dialog.svelte"; import type { AlertDialogRootProps } from "../types"; - import { noop } from "../../internal/noop"; + import { noop } from "../../utilities/noop"; let { open = $bindable(false), diff --git a/src/uix/acel/alert-dialog/types.ts b/src/uix/acel/alert-dialog/types.ts index 072a8d6f6..69b794708 100644 --- a/src/uix/acel/alert-dialog/types.ts +++ b/src/uix/acel/alert-dialog/types.ts @@ -8,7 +8,7 @@ import type { DialogCloseProps, } from "../dialog/types"; import type { WithChild, Without } from "../utilities/types"; -import type { AcelPrimitiveButtonAttributes } from "../shared/attributes"; +import type { AcelPrimitiveButtonAttributes } from "../utilities/attributes"; // Alert-dialog re-usa los tipos del dialog en su mayoría diff --git a/src/uix/acel/aspect-ratio/aspect-ratio.svelte.ts b/src/uix/acel/aspect-ratio/aspect-ratio.svelte.ts new file mode 100644 index 000000000..7ece8add1 --- /dev/null +++ b/src/uix/acel/aspect-ratio/aspect-ratio.svelte.ts @@ -0,0 +1,37 @@ +import { attachRef, type ReadableBoxedValues } from "../utilities/index"; +import type { RefAttachment, WithRefOpts } from "../utilities/types"; +import { createAcelAttrs } from "../utilities/attrs"; + +const aspectRatioAttrs = createAcelAttrs({ + component: "aspect-ratio", + parts: ["root"] as const, +}); + +interface AspectRatioRootStateOpts extends WithRefOpts, ReadableBoxedValues<{ ratio: number }> {} + +export class AspectRatioRootState { + static create(opts: AspectRatioRootStateOpts) { + return new AspectRatioRootState(opts); + } + + readonly opts: AspectRatioRootStateOpts; + readonly attachment: RefAttachment; + + constructor(opts: AspectRatioRootStateOpts) { + this.opts = opts; + this.attachment = attachRef(this.opts.ref); + } + + readonly props = $derived.by(() => ({ + id: this.opts.id.current, + style: { + position: "absolute", + top: 0, + right: 0, + bottom: 0, + left: 0, + }, + [aspectRatioAttrs.root]: "", + ...this.attachment, + }) as const); +} diff --git a/src/uix/acel/aspect-ratio/components/aspect-ratio.svelte b/src/uix/acel/aspect-ratio/components/aspect-ratio.svelte new file mode 100644 index 000000000..1cc99ac7a --- /dev/null +++ b/src/uix/acel/aspect-ratio/components/aspect-ratio.svelte @@ -0,0 +1,38 @@ + + +
+ {#if child} + {@render child({ props: mergedProps })} + {:else} +
+ {@render children?.()} +
+ {/if} +
diff --git a/src/uix/acel/aspect-ratio/exports.ts b/src/uix/acel/aspect-ratio/exports.ts new file mode 100644 index 000000000..4887f0cf1 --- /dev/null +++ b/src/uix/acel/aspect-ratio/exports.ts @@ -0,0 +1,2 @@ +export { default as Root } from "./components/aspect-ratio.svelte"; +export type { AspectRatioRootProps as RootProps } from "./types"; diff --git a/src/uix/acel/aspect-ratio/index.ts b/src/uix/acel/aspect-ratio/index.ts new file mode 100644 index 000000000..366f4cebf --- /dev/null +++ b/src/uix/acel/aspect-ratio/index.ts @@ -0,0 +1,2 @@ +export * as AspectRatio from "./exports"; +export * from "./exports"; diff --git a/src/uix/acel/aspect-ratio/types.ts b/src/uix/acel/aspect-ratio/types.ts new file mode 100644 index 000000000..1a9194acc --- /dev/null +++ b/src/uix/acel/aspect-ratio/types.ts @@ -0,0 +1,15 @@ +import type { WithChild, Without } from "../utilities/types"; +import type { AcelPrimitiveDivAttributes } from "../utilities/attributes"; + +export type AspectRatioRootPropsWithoutHTML = WithChild<{ + /** + * La proporción ancho/alto del contenido. + * Por ejemplo: 16/9, 4/3, 1 (cuadrado). + * + * @defaultValue 1 + */ + ratio?: number; +}>; + +export type AspectRatioRootProps = AspectRatioRootPropsWithoutHTML & + Without; diff --git a/src/uix/acel/avatar/avatar.svelte.ts b/src/uix/acel/avatar/avatar.svelte.ts new file mode 100644 index 000000000..060c1f864 --- /dev/null +++ b/src/uix/acel/avatar/avatar.svelte.ts @@ -0,0 +1,167 @@ +import { attachRef, type ReadableBoxedValues, type WritableBoxedValues } from "../utilities/index"; +import type { HTMLImgAttributes } from "svelte/elements"; +import { Context, watch } from "runed"; +import type { AvatarImageLoadingStatus } from "./types"; +import type { RefAttachment, WithRefOpts } from "../utilities/types"; +import { createAcelAttrs } from "../utilities/attrs"; + +const avatarAttrs = createAcelAttrs({ + component: "avatar", + parts: ["root", "image", "fallback"] as const, +}); + +type CrossOrigin = HTMLImgAttributes["crossorigin"]; +type ReferrerPolicy = HTMLImgAttributes["referrerpolicy"]; +type AvatarImageSrc = string | null | undefined; + +interface AvatarRootStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ delayMs: number }>, + WritableBoxedValues<{ loadingStatus: AvatarImageLoadingStatus }> {} + +const AvatarRootContext = new Context("Avatar.Root"); + +export class AvatarRootState { + static create(opts: AvatarRootStateOpts) { + return AvatarRootContext.set(new AvatarRootState(opts)); + } + + readonly opts: AvatarRootStateOpts; + readonly attachment: RefAttachment; + + constructor(opts: AvatarRootStateOpts) { + this.opts = opts; + this.attachment = attachRef(this.opts.ref); + } + + /** + * Carga una imagen en segundo plano y actualiza loadingStatus. + * Retorna una función de limpieza para cancelar el timer pendiente. + * + * FIX vs uisv: se elimina el guard `if status === "loaded" return` + * para permitir recargar cuando cambia `src` en runtime. + */ + loadImage( + src: string, + crossorigin?: CrossOrigin, + referrerPolicy?: ReferrerPolicy + ): (() => void) | void { + // Guarda SSR: Image no existe en el servidor + if (typeof window === "undefined") return; + const image = new Image(); + let timerId: ReturnType | undefined; + + image.src = src; + if (crossorigin !== undefined) image.crossOrigin = crossorigin as string; + if (referrerPolicy) image.referrerPolicy = referrerPolicy; + + this.opts.loadingStatus.current = "loading"; + + image.onload = () => { + timerId = setTimeout(() => { + this.opts.loadingStatus.current = "loaded"; + }, this.opts.delayMs.current); + }; + + image.onerror = () => { + this.opts.loadingStatus.current = "error"; + }; + + // FIX vs uisv: el cleanup cancela el timer si src cambia antes de que expire + return () => { + image.onload = null; + image.onerror = null; + if (timerId !== undefined) clearTimeout(timerId); + }; + } + + readonly props = $derived.by(() => ({ + id: this.opts.id.current, + [avatarAttrs.root]: "", + "data-status": this.opts.loadingStatus.current, + ...this.attachment, + }) as const); +} + +interface AvatarImageStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ + src: AvatarImageSrc; + crossOrigin: CrossOrigin; + referrerPolicy: ReferrerPolicy; + }> {} + +export class AvatarImageState { + static create(opts: AvatarImageStateOpts) { + return new AvatarImageState(opts, AvatarRootContext.get()); + } + + readonly opts: AvatarImageStateOpts; + readonly root: AvatarRootState; + readonly attachment: RefAttachment; + + constructor(opts: AvatarImageStateOpts, root: AvatarRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(this.opts.ref); + + // FIX vs uisv: observa los 3 valores relevantes (src, crossOrigin, referrerPolicy) + // y retorna el cleanup de loadImage para cancelar timers pendientes al cambiar src. + watch.pre( + [ + () => this.opts.src.current, + () => this.opts.crossOrigin.current, + () => this.opts.referrerPolicy.current, + ], + ([src, crossOrigin, referrerPolicy]) => { + if (!src) { + this.root.opts.loadingStatus.current = "error"; + return; + } + return this.root.loadImage(src, crossOrigin, referrerPolicy); + } + ); + } + + readonly props = $derived.by(() => ({ + id: this.opts.id.current, + // La imagen solo es visible cuando está completamente cargada + style: { + display: this.root.opts.loadingStatus.current === "loaded" ? "block" : "none", + }, + "data-status": this.root.opts.loadingStatus.current, + [avatarAttrs.image]: "", + src: this.opts.src.current, + crossorigin: this.opts.crossOrigin.current, + referrerpolicy: this.opts.referrerPolicy.current, + ...this.attachment, + }) as const); +} + +interface AvatarFallbackStateOpts extends WithRefOpts {} + +export class AvatarFallbackState { + static create(opts: AvatarFallbackStateOpts) { + return new AvatarFallbackState(opts, AvatarRootContext.get()); + } + + readonly opts: AvatarFallbackStateOpts; + readonly root: AvatarRootState; + readonly attachment: RefAttachment; + + constructor(opts: AvatarFallbackStateOpts, root: AvatarRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(this.opts.ref); + } + + readonly props = $derived.by(() => ({ + // Oculto solo cuando la imagen está cargada, visible en "loading" y "error" + style: this.root.opts.loadingStatus.current === "loaded" + ? { display: "none" } + : undefined, + "data-status": this.root.opts.loadingStatus.current, + [avatarAttrs.fallback]: "", + ...this.attachment, + }) as const); +} diff --git a/src/uix/acel/avatar/components/avatar-fallback.svelte b/src/uix/acel/avatar/components/avatar-fallback.svelte new file mode 100644 index 000000000..8de974008 --- /dev/null +++ b/src/uix/acel/avatar/components/avatar-fallback.svelte @@ -0,0 +1,31 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + + {@render children?.()} + +{/if} diff --git a/src/uix/acel/avatar/components/avatar-image.svelte b/src/uix/acel/avatar/components/avatar-image.svelte new file mode 100644 index 000000000..2648c4168 --- /dev/null +++ b/src/uix/acel/avatar/components/avatar-image.svelte @@ -0,0 +1,36 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + {restProps.alt +{/if} diff --git a/src/uix/acel/avatar/components/avatar.svelte b/src/uix/acel/avatar/components/avatar.svelte new file mode 100644 index 000000000..413e8f16d --- /dev/null +++ b/src/uix/acel/avatar/components/avatar.svelte @@ -0,0 +1,44 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/acel/avatar/exports.ts b/src/uix/acel/avatar/exports.ts new file mode 100644 index 000000000..3a9c932d8 --- /dev/null +++ b/src/uix/acel/avatar/exports.ts @@ -0,0 +1,10 @@ +export { default as Root } from "./components/avatar.svelte"; +export { default as Image } from "./components/avatar-image.svelte"; +export { default as Fallback } from "./components/avatar-fallback.svelte"; + +export type { + AvatarRootProps as RootProps, + AvatarImageProps as ImageProps, + AvatarFallbackProps as FallbackProps, + AvatarImageLoadingStatus, +} from "./types"; diff --git a/src/uix/acel/avatar/index.ts b/src/uix/acel/avatar/index.ts new file mode 100644 index 000000000..55a980fc2 --- /dev/null +++ b/src/uix/acel/avatar/index.ts @@ -0,0 +1,2 @@ +export * as Avatar from "./exports"; +export * from "./exports"; diff --git a/src/uix/acel/avatar/types.ts b/src/uix/acel/avatar/types.ts new file mode 100644 index 000000000..26e1e76c2 --- /dev/null +++ b/src/uix/acel/avatar/types.ts @@ -0,0 +1,38 @@ +import type { OnChangeFn, WithChild, Without } from "../utilities/types"; +import type { + AcelPrimitiveDivAttributes, + AcelPrimitiveImgAttributes, + AcelPrimitiveSpanAttributes, +} from "../utilities/attributes"; + +export type AvatarImageLoadingStatus = "loading" | "loaded" | "error"; + +export type AvatarRootPropsWithoutHTML = WithChild<{ + /** + * Tiempo en ms a esperar antes de mostrar la imagen una vez cargada. + * Evita el parpadeo si la imagen carga muy rápido. + * @default 0 + */ + delayMs?: number; + + /** + * Estado de carga de la imagen. Puede usarse como bindable para observarlo + * externamente, o inicializarlo en `"loaded"` si se sabe que la imagen existe. + * @default "loading" + */ + loadingStatus?: AvatarImageLoadingStatus; + + /** Callback invocado cuando cambia el estado de carga. */ + onLoadingStatusChange?: OnChangeFn; +}>; + +export type AvatarRootProps = AvatarRootPropsWithoutHTML & + Without; + +export type AvatarImagePropsWithoutHTML = WithChild; +export type AvatarImageProps = AvatarImagePropsWithoutHTML & + Without; + +export type AvatarFallbackPropsWithoutHTML = WithChild; +export type AvatarFallbackProps = AvatarFallbackPropsWithoutHTML & + Without; diff --git a/src/uix/acel/calendar/calendar.svelte.ts b/src/uix/acel/calendar/calendar.svelte.ts new file mode 100644 index 000000000..d1a71bfce --- /dev/null +++ b/src/uix/acel/calendar/calendar.svelte.ts @@ -0,0 +1,1150 @@ +import { + type DateValue, + getLocalTimeZone, + isSameDay, + isSameMonth, + isToday, +} from "$lib/glob/lib/date"; +import { DEV } from "esm-env"; +import { onMount, untrack } from "svelte"; +import { + attachRef, + type ReadableBoxedValues, + type WritableBoxedValues, +} from "../utilities/index"; +import { Context, watch } from "runed"; +import { + boolToStr, + boolToStrTrueOrUndef, + boolToEmptyStrOrUndef, +} from "../utilities/attrs"; +import type { + AcelKeyboardEvent, + AcelMouseEvent, + RefAttachment, + WithRefOpts, +} from "../utilities/types"; +import { useId } from "../utilities/use-id"; +import type { DateMatcher, Month } from "../utilities/index"; +import { type Announcer, getAnnouncer } from "../utilities/datetime/announcer"; +import { type Formatter, createFormatter } from "../utilities/datetime/formatter"; +import { + calendarAttrs, + createAccessibleHeading, + createMonths, + getCalendarElementProps, + getCalendarHeadingValue, + getDateWithPreviousTime, + getDefaultYears, + getIsNextButtonDisabled, + getIsPrevButtonDisabled, + getWeekdays, + handleCalendarKeydown, + handleCalendarNextPage, + handleCalendarPrevPage, + shiftCalendarFocus, + useEnsureNonDisabledPlaceholder, + useMonthViewOptionsSync, + useMonthViewPlaceholderSync, +} from "../utilities/datetime/calendar-helpers.svelte"; +import { getDateValueType, isBefore, toDate } from "../utilities/datetime/utils"; +import type { WeekStartsOn } from "../utilities/datetime/date-types"; + +// ─── Root ───────────────────────────────────────────────────────────────────── + +interface CalendarRootStateOpts + extends WithRefOpts, + WritableBoxedValues<{ + value: DateValue | undefined | DateValue[]; + placeholder: DateValue; + }>, + ReadableBoxedValues<{ + preventDeselect: boolean; + minValue: DateValue | undefined; + maxValue: DateValue | undefined; + disabled: boolean; + pagedNavigation: boolean; + weekStartsOn: WeekStartsOn | undefined; + weekdayFormat: Intl.DateTimeFormatOptions["weekday"]; + isDateDisabled: DateMatcher; + isDateUnavailable: DateMatcher; + fixedWeeks: boolean; + numberOfMonths: number; + locale: string; + calendarLabel: string; + type: "single" | "multiple"; + readonly: boolean; + disableDaysOutsideMonth: boolean; + initialFocus: boolean; + maxDays: number | undefined; + onDateSelect: (() => void) | undefined; + monthFormat: Intl.DateTimeFormatOptions["month"] | ((month: number) => string); + yearFormat: Intl.DateTimeFormatOptions["year"] | ((year: number) => string); + }> { + defaultPlaceholder: DateValue; +} + +export const CalendarRootContext = new Context("Calendar.Root"); + +export class CalendarRootState { + static create(opts: CalendarRootStateOpts) { + return CalendarRootContext.set(new CalendarRootState(opts)); + } + + readonly opts: CalendarRootStateOpts; + readonly visibleMonths = $derived.by(() => this.months.map((month) => month.value)); + readonly formatter: Formatter; + readonly accessibleHeadingId = useId(); + readonly attachment: RefAttachment; + months: Month[] = $state([]); + announcer: Announcer; + + constructor(opts: CalendarRootStateOpts) { + this.opts = opts; + this.attachment = attachRef(this.opts.ref); + // SSR-safe: getAnnouncer(null) no toca el DOM + this.announcer = getAnnouncer(null); + this.formatter = createFormatter({ + initialLocale: this.opts.locale.current, + monthFormat: this.opts.monthFormat, + yearFormat: this.opts.yearFormat, + }); + + this.setMonths = this.setMonths.bind(this); + this.nextPage = this.nextPage.bind(this); + this.prevPage = this.prevPage.bind(this); + this.prevYear = this.prevYear.bind(this); + this.nextYear = this.nextYear.bind(this); + this.setYear = this.setYear.bind(this); + this.setMonth = this.setMonth.bind(this); + this.isOutsideVisibleMonths = this.isOutsideVisibleMonths.bind(this); + this.isDateDisabled = this.isDateDisabled.bind(this); + this.isDateSelected = this.isDateSelected.bind(this); + this.shiftFocus = this.shiftFocus.bind(this); + this.handleCellClick = this.handleCellClick.bind(this); + this.handleMultipleUpdate = this.handleMultipleUpdate.bind(this); + this.handleSingleUpdate = this.handleSingleUpdate.bind(this); + this.onkeydown = this.onkeydown.bind(this); + this.getAcelAttr = this.getAcelAttr.bind(this); + + // Inicializar meses (SSR-safe: solo usa DateValue, sin DOM) + this.months = createMonths({ + dateObj: this.opts.placeholder.current, + weekStartsOn: this.opts.weekStartsOn.current, + locale: this.opts.locale.current, + fixedWeeks: this.opts.fixedWeeks.current, + numberOfMonths: this.opts.numberOfMonths.current, + }); + + // Obtener announcer real en el browser + onMount(() => { + this.announcer = getAnnouncer(this.opts.ref.current?.ownerDocument ?? null); + }); + + this.#setupInitialFocusEffect(); + this.#setupAccessibleHeadingEffect(); + this.#setupFormatterEffect(); + + // Sincroniza meses cuando cambia el placeholder + useMonthViewPlaceholderSync({ + placeholder: this.opts.placeholder, + getVisibleMonths: () => this.visibleMonths, + weekStartsOn: this.opts.weekStartsOn, + locale: this.opts.locale, + fixedWeeks: this.opts.fixedWeeks, + numberOfMonths: this.opts.numberOfMonths, + setMonths: (months: Month[]) => (this.months = months), + }); + + // Sincroniza meses cuando cambian las opciones + useMonthViewOptionsSync({ + fixedWeeks: this.opts.fixedWeeks, + locale: this.opts.locale, + numberOfMonths: this.opts.numberOfMonths, + placeholder: this.opts.placeholder, + setMonths: this.setMonths, + weekStartsOn: this.opts.weekStartsOn, + }); + + // Actualiza el heading accesible cuando cambia el label + watch( + () => this.fullCalendarLabel, + (label) => { + const doc = this.opts.ref.current?.ownerDocument; + if (!doc) return; + const node = doc.getElementById(this.accessibleHeadingId); + if (!node) return; + node.textContent = label; + } + ); + + // Sincroniza el placeholder con el value seleccionado + watch( + () => this.opts.value.current, + () => { + const value = this.opts.value.current; + if (Array.isArray(value) && value.length) { + const lastValue = value[value.length - 1]; + if (lastValue && this.opts.placeholder.current !== lastValue) { + this.opts.placeholder.current = lastValue; + } + } else if ( + !Array.isArray(value) && + value && + this.opts.placeholder.current !== value + ) { + this.opts.placeholder.current = value; + } + } + ); + + useEnsureNonDisabledPlaceholder({ + placeholder: opts.placeholder, + defaultPlaceholder: opts.defaultPlaceholder, + isDateDisabled: opts.isDateDisabled, + maxValue: opts.maxValue, + minValue: opts.minValue, + ref: opts.ref, + }); + } + + setMonths(months: Month[]) { + this.months = months; + } + + readonly weekdays = $derived.by(() => { + return getWeekdays({ + months: this.months, + formatter: this.formatter, + weekdayFormat: this.opts.weekdayFormat.current, + }); + }); + + readonly initialPlaceholderYear = $derived.by(() => + untrack(() => this.opts.placeholder.current.year) + ); + + readonly defaultYears = $derived.by(() => { + return getDefaultYears({ + minValue: this.opts.minValue.current, + maxValue: this.opts.maxValue.current, + placeholderYear: this.initialPlaceholderYear, + }); + }); + + #setupInitialFocusEffect() { + $effect(() => { + const initialFocus = untrack(() => this.opts.initialFocus.current); + if (initialFocus) { + const firstFocusedDay = + this.opts.ref.current?.querySelector(`[data-focused]`); + if (firstFocusedDay) { + firstFocusedDay.focus(); + } + } + }); + } + + #setupAccessibleHeadingEffect() { + $effect(() => { + if (!this.opts.ref.current) return; + const removeHeading = createAccessibleHeading({ + calendarNode: this.opts.ref.current, + label: this.fullCalendarLabel, + accessibleHeadingId: this.accessibleHeadingId, + }); + return removeHeading; + }); + } + + #setupFormatterEffect() { + $effect.pre(() => { + if (this.formatter.getLocale() === this.opts.locale.current) return; + this.formatter.setLocale(this.opts.locale.current); + }); + } + + nextPage() { + handleCalendarNextPage({ + fixedWeeks: this.opts.fixedWeeks.current, + locale: this.opts.locale.current, + numberOfMonths: this.opts.numberOfMonths.current, + pagedNavigation: this.opts.pagedNavigation.current, + setMonths: this.setMonths, + setPlaceholder: (date: DateValue) => (this.opts.placeholder.current = date), + weekStartsOn: this.opts.weekStartsOn.current, + months: this.months, + }); + } + + prevPage() { + handleCalendarPrevPage({ + fixedWeeks: this.opts.fixedWeeks.current, + locale: this.opts.locale.current, + numberOfMonths: this.opts.numberOfMonths.current, + pagedNavigation: this.opts.pagedNavigation.current, + setMonths: this.setMonths, + setPlaceholder: (date: DateValue) => (this.opts.placeholder.current = date), + weekStartsOn: this.opts.weekStartsOn.current, + months: this.months, + }); + } + + nextYear() { + this.opts.placeholder.current = this.opts.placeholder.current.add({ years: 1 }); + } + + prevYear() { + this.opts.placeholder.current = this.opts.placeholder.current.subtract({ years: 1 }); + } + + setYear(year: number) { + this.opts.placeholder.current = this.opts.placeholder.current.set({ year }); + } + + setMonth(month: number) { + this.opts.placeholder.current = this.opts.placeholder.current.set({ month }); + } + + isNextButtonDisabled = $derived.by(() => { + return getIsNextButtonDisabled({ + maxValue: this.opts.maxValue.current, + months: this.months, + disabled: this.opts.disabled.current, + }); + }); + + isPrevButtonDisabled = $derived.by(() => { + return getIsPrevButtonDisabled({ + minValue: this.opts.minValue.current, + months: this.months, + disabled: this.opts.disabled.current, + }); + }); + + isInvalid = $derived.by(() => { + const value = this.opts.value.current; + const isDateDisabled = this.opts.isDateDisabled.current; + const isDateUnavailable = this.opts.isDateUnavailable.current; + if (Array.isArray(value)) { + if (!value.length) return false; + for (const date of value) { + if (isDateDisabled(date)) return true; + if (isDateUnavailable(date)) return true; + } + } else { + if (!value) return false; + if (isDateDisabled(value)) return true; + if (isDateUnavailable(value)) return true; + } + return false; + }); + + readonly headingValue = $derived.by(() => { + this.opts.monthFormat.current; + this.opts.yearFormat.current; + return getCalendarHeadingValue({ + months: this.months, + formatter: this.formatter, + locale: this.opts.locale.current, + }); + }); + + readonly fullCalendarLabel = $derived.by(() => { + return `${this.opts.calendarLabel.current} ${this.headingValue}`; + }); + + isOutsideVisibleMonths(date: DateValue) { + return !this.visibleMonths.some((month) => isSameMonth(date, month)); + } + + isDateDisabled(date: DateValue) { + if (this.opts.isDateDisabled.current(date) || this.opts.disabled.current) return true; + const minValue = this.opts.minValue.current; + const maxValue = this.opts.maxValue.current; + if (minValue && isBefore(date, minValue)) return true; + if (maxValue && isBefore(maxValue, date)) return true; + return false; + } + + isDateSelected(date: DateValue) { + const value = this.opts.value.current; + if (Array.isArray(value)) { + return value.some((d) => isSameDay(d, date)); + } else if (!value) { + return false; + } + return isSameDay(value, date); + } + + shiftFocus(node: HTMLElement, add: number) { + return shiftCalendarFocus({ + node, + add, + placeholder: this.opts.placeholder, + calendarNode: this.opts.ref.current, + isPrevButtonDisabled: this.isPrevButtonDisabled, + isNextButtonDisabled: this.isNextButtonDisabled, + months: this.months, + numberOfMonths: this.opts.numberOfMonths.current, + }); + } + + #isMultipleSelectionValid(selectedDates: DateValue[]): boolean { + if (this.opts.type.current !== "multiple") return true; + if (!this.opts.maxDays.current) return true; + if (this.opts.maxDays.current && selectedDates.length > this.opts.maxDays.current) + return false; + return true; + } + + handleCellClick(_: Event, date: DateValue) { + if ( + this.opts.readonly.current || + this.opts.isDateDisabled.current?.(date) || + this.opts.isDateUnavailable.current?.(date) + ) { + return; + } + + const prev = this.opts.value.current; + const multiple = this.opts.type.current === "multiple"; + if (multiple) { + if (Array.isArray(prev) || prev === undefined) { + this.opts.value.current = this.handleMultipleUpdate(prev, date); + } + } else if (!Array.isArray(prev)) { + const next = this.handleSingleUpdate(prev, date); + if (!next) { + this.announcer.announce("Selected date is now empty.", "polite", 5000); + } else { + this.announcer.announce( + `Selected Date: ${this.formatter.selectedDate(next, false)}`, + "polite" + ); + } + this.opts.value.current = getDateWithPreviousTime(next, prev); + if (next !== undefined) { + this.opts.onDateSelect?.current?.(); + } + } + } + + handleMultipleUpdate(prev: DateValue[] | undefined, date: DateValue) { + if (!prev) { + const newSelection = [date]; + return this.#isMultipleSelectionValid(newSelection) ? newSelection : [date]; + } + if (!Array.isArray(prev)) { + if (DEV) throw new Error("Invalid value for multiple prop."); + return; + } + const index = prev.findIndex((d) => isSameDay(d, date)); + const preventDeselect = this.opts.preventDeselect.current; + if (index === -1) { + const newSelection = [...prev, date]; + if (this.#isMultipleSelectionValid(newSelection)) { + return newSelection; + } else { + return [date]; + } + } else if (preventDeselect) { + return prev; + } else { + const next = prev.filter((d) => !isSameDay(d, date)); + if (!next.length) { + this.opts.placeholder.current = date; + return undefined; + } + return next; + } + } + + handleSingleUpdate(prev: DateValue | undefined, date: DateValue) { + if (Array.isArray(prev)) { + if (DEV) throw new Error("Invalid value for single prop."); + } + if (!prev) return date; + const preventDeselect = this.opts.preventDeselect.current; + if (!preventDeselect && isSameDay(prev, date)) { + this.opts.placeholder.current = date; + return undefined; + } + return date; + } + + onkeydown(event: AcelKeyboardEvent) { + handleCalendarKeydown({ + event, + handleCellClick: this.handleCellClick, + shiftFocus: this.shiftFocus, + placeholderValue: this.opts.placeholder.current, + }); + } + + readonly snippetProps = $derived.by(() => ({ + months: this.months, + weekdays: this.weekdays, + })); + + getAcelAttr: (typeof calendarAttrs)["getAttr"] = (part) => { + return calendarAttrs.getAttr(part); + }; + + readonly props = $derived.by( + () => + ({ + ...getCalendarElementProps({ + fullCalendarLabel: this.fullCalendarLabel, + id: this.opts.id.current, + isInvalid: this.isInvalid, + disabled: this.opts.disabled.current, + readonly: this.opts.readonly.current, + }), + [this.getAcelAttr("root")]: "", + onkeydown: this.onkeydown, + ...this.attachment, + }) as const + ); +} + +// ─── Heading ────────────────────────────────────────────────────────────────── + +interface CalendarHeadingStateOpts extends WithRefOpts {} + +export class CalendarHeadingState { + static create(opts: CalendarHeadingStateOpts) { + return new CalendarHeadingState(opts, CalendarRootContext.get()); + } + + readonly opts: CalendarHeadingStateOpts; + readonly root: CalendarRootState; + readonly attachment: RefAttachment; + + constructor(opts: CalendarHeadingStateOpts, root: CalendarRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(this.opts.ref); + } + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + "aria-hidden": boolToStrTrueOrUndef(true), + "data-disabled": boolToEmptyStrOrUndef(this.root.opts.disabled.current), + "data-readonly": boolToEmptyStrOrUndef(this.root.opts.readonly.current), + [this.root.getAcelAttr("heading")]: "", + ...this.attachment, + }) as const + ); +} + +// ─── Cell ───────────────────────────────────────────────────────────────────── + +const CalendarCellContext = new Context("Calendar.Cell"); + +interface CalendarCellStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ + date: DateValue; + month: DateValue; + }> {} + +export class CalendarCellState { + static create(opts: CalendarCellStateOpts) { + return CalendarCellContext.set( + new CalendarCellState(opts, CalendarRootContext.get()) + ); + } + + readonly opts: CalendarCellStateOpts; + readonly root: CalendarRootState; + readonly cellDate = $derived.by(() => toDate(this.opts.date.current)); + readonly isUnavailable = $derived.by(() => + this.root.opts.isDateUnavailable.current(this.opts.date.current) + ); + readonly isDateToday = $derived.by(() => isToday(this.opts.date.current, getLocalTimeZone())); + readonly isOutsideMonth = $derived.by( + () => !isSameMonth(this.opts.date.current, this.opts.month.current) + ); + readonly isOutsideVisibleMonths = $derived.by(() => + this.root.isOutsideVisibleMonths(this.opts.date.current) + ); + readonly isDisabled = $derived.by( + () => + this.root.isDateDisabled(this.opts.date.current) || + (this.isOutsideMonth && this.root.opts.disableDaysOutsideMonth.current) + ); + readonly isFocusedDate = $derived.by(() => + isSameDay(this.opts.date.current, this.root.opts.placeholder.current) + ); + readonly isSelectedDate = $derived.by(() => this.root.isDateSelected(this.opts.date.current)); + readonly labelText = $derived.by(() => + this.root.formatter.custom(this.cellDate, { + weekday: "long", + month: "long", + day: "numeric", + year: "numeric", + }) + ); + readonly attachment: RefAttachment; + + constructor(opts: CalendarCellStateOpts, root: CalendarRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(this.opts.ref); + } + + readonly snippetProps = $derived.by(() => ({ + disabled: this.isDisabled, + unavailable: this.isUnavailable, + selected: this.isSelectedDate, + day: `${this.opts.date.current.day}`, + })); + + readonly ariaDisabled = $derived.by(() => { + return ( + this.isDisabled || + (this.isOutsideMonth && this.root.opts.disableDaysOutsideMonth.current) || + this.isUnavailable + ); + }); + + readonly sharedDataAttrs = $derived.by( + () => + ({ + "data-unavailable": boolToEmptyStrOrUndef(this.isUnavailable), + "data-today": this.isDateToday ? "" : undefined, + "data-outside-month": this.isOutsideMonth ? "" : undefined, + "data-outside-visible-months": this.isOutsideVisibleMonths ? "" : undefined, + "data-focused": this.isFocusedDate ? "" : undefined, + "data-selected": boolToEmptyStrOrUndef(this.isSelectedDate), + "data-value": this.opts.date.current.toString(), + "data-type": getDateValueType(this.opts.date.current), + "data-disabled": boolToEmptyStrOrUndef( + this.isDisabled || + (this.isOutsideMonth && this.root.opts.disableDaysOutsideMonth.current) + ), + }) as const + ); + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + role: "gridcell", + "aria-selected": boolToStr(this.isSelectedDate), + "aria-disabled": boolToStr(this.ariaDisabled), + ...this.sharedDataAttrs, + [this.root.getAcelAttr("cell")]: "", + ...this.attachment, + }) as const + ); +} + +// ─── Day ────────────────────────────────────────────────────────────────────── + +interface CalendarDayStateOpts extends WithRefOpts {} + +export class CalendarDayState { + static create(opts: CalendarDayStateOpts) { + return new CalendarDayState(opts, CalendarCellContext.get()); + } + + readonly opts: CalendarDayStateOpts; + readonly cell: CalendarCellState; + readonly attachment: RefAttachment; + + constructor(opts: CalendarDayStateOpts, cell: CalendarCellState) { + this.opts = opts; + this.cell = cell; + this.onclick = this.onclick.bind(this); + this.attachment = attachRef(this.opts.ref); + } + + readonly #tabindex = $derived.by(() => + (this.cell.isOutsideMonth && this.cell.root.opts.disableDaysOutsideMonth.current) || + this.cell.isDisabled + ? undefined + : this.cell.isFocusedDate + ? 0 + : -1 + ); + + onclick(e: AcelMouseEvent) { + if (this.cell.isDisabled) return; + this.cell.root.handleCellClick(e, this.cell.opts.date.current); + } + + readonly snippetProps = $derived.by(() => ({ + disabled: this.cell.isDisabled, + unavailable: this.cell.isUnavailable, + selected: this.cell.isSelectedDate, + day: `${this.cell.opts.date.current.day}`, + })); + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + role: "button", + "aria-label": this.cell.labelText, + "aria-disabled": boolToStr(this.cell.ariaDisabled), + ...this.cell.sharedDataAttrs, + tabindex: this.#tabindex, + [this.cell.root.getAcelAttr("day")]: "", + // Atributo compartido para selección de celdas + "data-calendar-day": "", + onclick: this.onclick, + ...this.attachment, + }) as const + ); +} + +// ─── Next Button ────────────────────────────────────────────────────────────── + +interface CalendarNextButtonStateOpts extends WithRefOpts {} + +export class CalendarNextButtonState { + static create(opts: CalendarNextButtonStateOpts) { + return new CalendarNextButtonState(opts, CalendarRootContext.get()); + } + + readonly opts: CalendarNextButtonStateOpts; + readonly root: CalendarRootState; + readonly isDisabled = $derived.by(() => this.root.isNextButtonDisabled); + readonly attachment: RefAttachment; + + constructor(opts: CalendarNextButtonStateOpts, root: CalendarRootState) { + this.opts = opts; + this.root = root; + this.onclick = this.onclick.bind(this); + this.attachment = attachRef(this.opts.ref); + } + + onclick(_: AcelMouseEvent) { + if (this.isDisabled) return; + this.root.nextPage(); + } + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + role: "button", + type: "button", + "aria-label": "Next", + "aria-disabled": boolToStr(this.isDisabled), + "data-disabled": boolToEmptyStrOrUndef(this.isDisabled), + disabled: this.isDisabled, + [this.root.getAcelAttr("next-button")]: "", + onclick: this.onclick, + ...this.attachment, + }) as const + ); +} + +// ─── Prev Button ────────────────────────────────────────────────────────────── + +interface CalendarPrevButtonStateOpts extends WithRefOpts {} + +export class CalendarPrevButtonState { + static create(opts: CalendarPrevButtonStateOpts) { + return new CalendarPrevButtonState(opts, CalendarRootContext.get()); + } + + readonly opts: CalendarPrevButtonStateOpts; + readonly root: CalendarRootState; + readonly isDisabled = $derived.by(() => this.root.isPrevButtonDisabled); + readonly attachment: RefAttachment; + + constructor(opts: CalendarPrevButtonStateOpts, root: CalendarRootState) { + this.opts = opts; + this.root = root; + this.onclick = this.onclick.bind(this); + this.attachment = attachRef(this.opts.ref); + } + + onclick(_: AcelMouseEvent) { + if (this.isDisabled) return; + this.root.prevPage(); + } + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + role: "button", + type: "button", + "aria-label": "Previous", + "aria-disabled": boolToStr(this.isDisabled), + "data-disabled": boolToEmptyStrOrUndef(this.isDisabled), + disabled: this.isDisabled, + [this.root.getAcelAttr("prev-button")]: "", + onclick: this.onclick, + ...this.attachment, + }) as const + ); +} + +// ─── Grid ───────────────────────────────────────────────────────────────────── + +interface CalendarGridStateOpts extends WithRefOpts {} + +export class CalendarGridState { + static create(opts: CalendarGridStateOpts) { + return new CalendarGridState(opts, CalendarRootContext.get()); + } + + readonly opts: CalendarGridStateOpts; + readonly root: CalendarRootState; + readonly attachment: RefAttachment; + + constructor(opts: CalendarGridStateOpts, root: CalendarRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(this.opts.ref); + } + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + tabindex: -1, + role: "grid", + "aria-readonly": boolToStr(this.root.opts.readonly.current), + "aria-disabled": boolToStr(this.root.opts.disabled.current), + "data-readonly": boolToEmptyStrOrUndef(this.root.opts.readonly.current), + "data-disabled": boolToEmptyStrOrUndef(this.root.opts.disabled.current), + [this.root.getAcelAttr("grid")]: "", + ...this.attachment, + }) as const + ); +} + +// ─── Grid Body ──────────────────────────────────────────────────────────────── + +interface CalendarGridBodyStateOpts extends WithRefOpts {} + +export class CalendarGridBodyState { + static create(opts: CalendarGridBodyStateOpts) { + return new CalendarGridBodyState(opts, CalendarRootContext.get()); + } + + readonly opts: CalendarGridBodyStateOpts; + readonly root: CalendarRootState; + readonly attachment: RefAttachment; + + constructor(opts: CalendarGridBodyStateOpts, root: CalendarRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(this.opts.ref); + } + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + "data-disabled": boolToEmptyStrOrUndef(this.root.opts.disabled.current), + "data-readonly": boolToEmptyStrOrUndef(this.root.opts.readonly.current), + [this.root.getAcelAttr("grid-body")]: "", + ...this.attachment, + }) as const + ); +} + +// ─── Grid Head ──────────────────────────────────────────────────────────────── + +interface CalendarGridHeadStateOpts extends WithRefOpts {} + +export class CalendarGridHeadState { + static create(opts: CalendarGridHeadStateOpts) { + return new CalendarGridHeadState(opts, CalendarRootContext.get()); + } + + readonly opts: CalendarGridHeadStateOpts; + readonly root: CalendarRootState; + readonly attachment: RefAttachment; + + constructor(opts: CalendarGridHeadStateOpts, root: CalendarRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(this.opts.ref); + } + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + "data-disabled": boolToEmptyStrOrUndef(this.root.opts.disabled.current), + "data-readonly": boolToEmptyStrOrUndef(this.root.opts.readonly.current), + [this.root.getAcelAttr("grid-head")]: "", + ...this.attachment, + }) as const + ); +} + +// ─── Grid Row ───────────────────────────────────────────────────────────────── + +interface CalendarGridRowStateOpts extends WithRefOpts {} + +export class CalendarGridRowState { + static create(opts: CalendarGridRowStateOpts) { + return new CalendarGridRowState(opts, CalendarRootContext.get()); + } + + readonly opts: CalendarGridRowStateOpts; + readonly root: CalendarRootState; + readonly attachment: RefAttachment; + + constructor(opts: CalendarGridRowStateOpts, root: CalendarRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(this.opts.ref); + } + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + "data-disabled": boolToEmptyStrOrUndef(this.root.opts.disabled.current), + "data-readonly": boolToEmptyStrOrUndef(this.root.opts.readonly.current), + [this.root.getAcelAttr("grid-row")]: "", + ...this.attachment, + }) as const + ); +} + +// ─── Head Cell ──────────────────────────────────────────────────────────────── + +interface CalendarHeadCellStateOpts extends WithRefOpts {} + +export class CalendarHeadCellState { + static create(opts: CalendarHeadCellStateOpts) { + return new CalendarHeadCellState(opts, CalendarRootContext.get()); + } + + readonly opts: CalendarHeadCellStateOpts; + readonly root: CalendarRootState; + readonly attachment: RefAttachment; + + constructor(opts: CalendarHeadCellStateOpts, root: CalendarRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(this.opts.ref); + } + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + "data-disabled": boolToEmptyStrOrUndef(this.root.opts.disabled.current), + "data-readonly": boolToEmptyStrOrUndef(this.root.opts.readonly.current), + [this.root.getAcelAttr("head-cell")]: "", + ...this.attachment, + }) as const + ); +} + +// ─── Header ─────────────────────────────────────────────────────────────────── + +interface CalendarHeaderStateOpts extends WithRefOpts {} + +export class CalendarHeaderState { + static create(opts: CalendarHeaderStateOpts) { + return new CalendarHeaderState(opts, CalendarRootContext.get()); + } + + readonly opts: CalendarHeaderStateOpts; + readonly root: CalendarRootState; + readonly attachment: RefAttachment; + + constructor(opts: CalendarHeaderStateOpts, root: CalendarRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(this.opts.ref); + } + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + "data-disabled": boolToEmptyStrOrUndef(this.root.opts.disabled.current), + "data-readonly": boolToEmptyStrOrUndef(this.root.opts.readonly.current), + [this.root.getAcelAttr("header")]: "", + ...this.attachment, + }) as const + ); +} + +// ─── Month Select ───────────────────────────────────────────────────────────── + +interface CalendarMonthSelectStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ + months: number[]; + monthFormat: Intl.DateTimeFormatOptions["month"] | ((month: number) => string); + disabled: boolean; + }> {} + +export class CalendarMonthSelectState { + static create(opts: CalendarMonthSelectStateOpts) { + return new CalendarMonthSelectState(opts, CalendarRootContext.get()); + } + + readonly opts: CalendarMonthSelectStateOpts; + readonly root: CalendarRootState; + readonly attachment: RefAttachment; + + constructor(opts: CalendarMonthSelectStateOpts, root: CalendarRootState) { + this.opts = opts; + this.root = root; + this.onchange = this.onchange.bind(this); + this.attachment = attachRef(this.opts.ref); + } + + readonly monthItems = $derived.by(() => { + this.root.opts.locale.current; + const monthNumbers = this.opts.months.current; + const monthFormat = this.opts.monthFormat.current; + const months = []; + + for (const month of monthNumbers) { + const date = this.root.opts.placeholder.current.set({ month }); + let label: string; + if (typeof monthFormat === "function") { + label = monthFormat(month); + } else { + label = this.root.formatter.custom(toDate(date), { month: monthFormat }); + } + months.push({ value: month, label }); + } + + return months; + }); + + readonly currentMonth = $derived.by(() => this.root.opts.placeholder.current.month); + + readonly isDisabled = $derived.by( + () => this.root.opts.disabled.current || this.opts.disabled.current + ); + + readonly snippetProps = $derived.by(() => { + return { + monthItems: this.monthItems, + selectedMonthItem: this.monthItems.find( + (month) => month.value === this.currentMonth + ) as { value: number; label: string }, + }; + }); + + onchange(event: Event) { + if (this.isDisabled) return; + const target = event.target as HTMLSelectElement; + const month = parseInt(target.value, 10); + if (!isNaN(month)) { + this.root.opts.placeholder.current = this.root.opts.placeholder.current.set({ month }); + } + } + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + value: this.currentMonth, + disabled: this.isDisabled, + "data-disabled": boolToEmptyStrOrUndef(this.isDisabled), + [this.root.getAcelAttr("month-select")]: "", + onchange: this.onchange, + ...this.attachment, + }) as const + ); +} + +// ─── Year Select ────────────────────────────────────────────────────────────── + +interface CalendarYearSelectStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ + years: number[] | undefined; + yearFormat: Intl.DateTimeFormatOptions["year"] | ((year: number) => string); + disabled: boolean; + }> {} + +export class CalendarYearSelectState { + static create(opts: CalendarYearSelectStateOpts) { + return new CalendarYearSelectState(opts, CalendarRootContext.get()); + } + + readonly opts: CalendarYearSelectStateOpts; + readonly root: CalendarRootState; + readonly attachment: RefAttachment; + + constructor(opts: CalendarYearSelectStateOpts, root: CalendarRootState) { + this.opts = opts; + this.root = root; + this.onchange = this.onchange.bind(this); + this.attachment = attachRef(this.opts.ref); + } + + readonly years = $derived.by(() => { + if (this.opts.years.current && this.opts.years.current.length) + return this.opts.years.current; + return this.root.defaultYears; + }); + + readonly yearItems = $derived.by(() => { + this.root.opts.locale.current; + const yearFormat = this.opts.yearFormat.current; + const localYears = []; + for (const year of this.years) { + const date = this.root.opts.placeholder.current.set({ year }); + let label: string; + if (typeof yearFormat === "function") { + label = yearFormat(year); + } else { + label = this.root.formatter.custom(toDate(date), { year: yearFormat }); + } + localYears.push({ value: year, label }); + } + return localYears; + }); + + readonly currentYear = $derived.by(() => this.root.opts.placeholder.current.year); + + readonly isDisabled = $derived.by( + () => this.root.opts.disabled.current || this.opts.disabled.current + ); + + readonly snippetProps = $derived.by(() => { + return { + yearItems: this.yearItems, + selectedYearItem: this.yearItems.find( + (year) => year.value === this.currentYear + ) as { value: number; label: string }, + }; + }); + + onchange(event: Event) { + if (this.isDisabled) return; + const target = event.target as HTMLSelectElement; + const year = parseInt(target.value, 10); + if (!isNaN(year)) { + this.root.opts.placeholder.current = this.root.opts.placeholder.current.set({ year }); + } + } + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + value: this.currentYear, + disabled: this.isDisabled, + "data-disabled": boolToEmptyStrOrUndef(this.isDisabled), + [this.root.getAcelAttr("year-select")]: "", + onchange: this.onchange, + ...this.attachment, + }) as const + ); +} diff --git a/src/uix/acel/calendar/components/calendar-cell.svelte b/src/uix/acel/calendar/components/calendar-cell.svelte new file mode 100644 index 000000000..d51ad996b --- /dev/null +++ b/src/uix/acel/calendar/components/calendar-cell.svelte @@ -0,0 +1,33 @@ + + +{#if child} + {@render child({ props: mergedProps, ...cellState.snippetProps })} +{:else} + + {@render children?.(cellState.snippetProps)} + +{/if} diff --git a/src/uix/acel/calendar/components/calendar-day.svelte b/src/uix/acel/calendar/components/calendar-day.svelte new file mode 100644 index 000000000..e517a8ddf --- /dev/null +++ b/src/uix/acel/calendar/components/calendar-day.svelte @@ -0,0 +1,33 @@ + + +{#if child} + {@render child({ props: mergedProps, ...dayState.snippetProps })} +{:else} +
+ {#if children} + {@render children?.(dayState.snippetProps)} + {:else} + {dayState.cell.opts.date.current.day} + {/if} +
+{/if} diff --git a/src/uix/acel/calendar/components/calendar-grid-body.svelte b/src/uix/acel/calendar/components/calendar-grid-body.svelte new file mode 100644 index 000000000..7ff1d4b1b --- /dev/null +++ b/src/uix/acel/calendar/components/calendar-grid-body.svelte @@ -0,0 +1,29 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + + {@render children?.()} + +{/if} diff --git a/src/uix/acel/calendar/components/calendar-grid-head.svelte b/src/uix/acel/calendar/components/calendar-grid-head.svelte new file mode 100644 index 000000000..26f771ad8 --- /dev/null +++ b/src/uix/acel/calendar/components/calendar-grid-head.svelte @@ -0,0 +1,29 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + + {@render children?.()} + +{/if} diff --git a/src/uix/acel/calendar/components/calendar-grid-row.svelte b/src/uix/acel/calendar/components/calendar-grid-row.svelte new file mode 100644 index 000000000..c1dae6a7a --- /dev/null +++ b/src/uix/acel/calendar/components/calendar-grid-row.svelte @@ -0,0 +1,29 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + + {@render children?.()} + +{/if} diff --git a/src/uix/acel/calendar/components/calendar-grid.svelte b/src/uix/acel/calendar/components/calendar-grid.svelte new file mode 100644 index 000000000..a0976dc53 --- /dev/null +++ b/src/uix/acel/calendar/components/calendar-grid.svelte @@ -0,0 +1,29 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + + {@render children?.()} +
+{/if} diff --git a/src/uix/acel/calendar/components/calendar-head-cell.svelte b/src/uix/acel/calendar/components/calendar-head-cell.svelte new file mode 100644 index 000000000..69da3d00e --- /dev/null +++ b/src/uix/acel/calendar/components/calendar-head-cell.svelte @@ -0,0 +1,29 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + + {@render children?.()} + +{/if} diff --git a/src/uix/acel/calendar/components/calendar-header.svelte b/src/uix/acel/calendar/components/calendar-header.svelte new file mode 100644 index 000000000..b1b428c3a --- /dev/null +++ b/src/uix/acel/calendar/components/calendar-header.svelte @@ -0,0 +1,29 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/acel/calendar/components/calendar-heading.svelte b/src/uix/acel/calendar/components/calendar-heading.svelte new file mode 100644 index 000000000..cf020ee47 --- /dev/null +++ b/src/uix/acel/calendar/components/calendar-heading.svelte @@ -0,0 +1,34 @@ + + +{#if child} + {@render child({ props: mergedProps, ...snippetProps })} +{:else} +
+ {#if children} + {@render children?.(snippetProps)} + {:else} + {state.root.headingValue} + {/if} +
+{/if} diff --git a/src/uix/acel/calendar/components/calendar-month-select.svelte b/src/uix/acel/calendar/components/calendar-month-select.svelte new file mode 100644 index 000000000..1e9aef665 --- /dev/null +++ b/src/uix/acel/calendar/components/calendar-month-select.svelte @@ -0,0 +1,43 @@ + + +{#if child} + {@render child({ props: mergedProps, ...state.snippetProps })} +{:else} + +{/if} diff --git a/src/uix/acel/calendar/components/calendar-next-button.svelte b/src/uix/acel/calendar/components/calendar-next-button.svelte new file mode 100644 index 000000000..c98555af0 --- /dev/null +++ b/src/uix/acel/calendar/components/calendar-next-button.svelte @@ -0,0 +1,29 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/acel/calendar/components/calendar-prev-button.svelte b/src/uix/acel/calendar/components/calendar-prev-button.svelte new file mode 100644 index 000000000..ebba678c8 --- /dev/null +++ b/src/uix/acel/calendar/components/calendar-prev-button.svelte @@ -0,0 +1,29 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/acel/calendar/components/calendar-year-select.svelte b/src/uix/acel/calendar/components/calendar-year-select.svelte new file mode 100644 index 000000000..e9b3dd21f --- /dev/null +++ b/src/uix/acel/calendar/components/calendar-year-select.svelte @@ -0,0 +1,41 @@ + + +{#if child} + {@render child({ props: mergedProps, ...state.snippetProps })} +{:else} + +{/if} diff --git a/src/uix/acel/calendar/components/calendar.svelte b/src/uix/acel/calendar/components/calendar.svelte new file mode 100644 index 000000000..33135631d --- /dev/null +++ b/src/uix/acel/calendar/components/calendar.svelte @@ -0,0 +1,126 @@ + + +{#if child} + {@render child({ props: mergedProps, ...rootState.snippetProps })} +{:else} +
+ {@render children?.(rootState.snippetProps)} +
+{/if} diff --git a/src/uix/acel/calendar/exports.ts b/src/uix/acel/calendar/exports.ts new file mode 100644 index 000000000..f422d8b90 --- /dev/null +++ b/src/uix/acel/calendar/exports.ts @@ -0,0 +1,37 @@ +export { default as Root } from "./components/calendar.svelte"; +export { default as Cell } from "./components/calendar-cell.svelte"; +export { default as Day } from "./components/calendar-day.svelte"; +export { default as Grid } from "./components/calendar-grid.svelte"; +export { default as GridBody } from "./components/calendar-grid-body.svelte"; +export { default as GridHead } from "./components/calendar-grid-head.svelte"; +export { default as GridRow } from "./components/calendar-grid-row.svelte"; +export { default as HeadCell } from "./components/calendar-head-cell.svelte"; +export { default as Header } from "./components/calendar-header.svelte"; +export { default as Heading } from "./components/calendar-heading.svelte"; +export { default as NextButton } from "./components/calendar-next-button.svelte"; +export { default as PrevButton } from "./components/calendar-prev-button.svelte"; +export { default as MonthSelect } from "./components/calendar-month-select.svelte"; +export { default as YearSelect } from "./components/calendar-year-select.svelte"; + +export type { + CalendarRootProps as RootProps, + CalendarCellProps as CellProps, + CalendarDayProps as DayProps, + CalendarGridProps as GridProps, + CalendarGridBodyProps as GridBodyProps, + CalendarGridHeadProps as GridHeadProps, + CalendarGridRowProps as GridRowProps, + CalendarHeadCellProps as HeadCellProps, + CalendarHeaderProps as HeaderProps, + CalendarHeadingProps as HeadingProps, + CalendarNextButtonProps as NextButtonProps, + CalendarPrevButtonProps as PrevButtonProps, + CalendarMonthSelectProps as MonthSelectProps, + CalendarYearSelectProps as YearSelectProps, + CalendarRootSnippetProps, + CalendarCellSnippetProps, + CalendarDaySnippetProps, + CalendarHeadingSnippetProps, + CalendarMonthSelectSnippetProps, + CalendarYearSelectSnippetProps, +} from "./types"; diff --git a/src/uix/acel/calendar/index.ts b/src/uix/acel/calendar/index.ts new file mode 100644 index 000000000..1082be363 --- /dev/null +++ b/src/uix/acel/calendar/index.ts @@ -0,0 +1,2 @@ +export * as Calendar from "./exports"; +export * from "./exports"; diff --git a/src/uix/acel/calendar/types.ts b/src/uix/acel/calendar/types.ts new file mode 100644 index 000000000..7ae73285c --- /dev/null +++ b/src/uix/acel/calendar/types.ts @@ -0,0 +1,160 @@ +import type { DateValue } from "$lib/glob/lib/date"; +import type { OnChangeFn, WithChild, Without } from "../utilities/types"; +import type { + BitsPrimitiveButtonAttributes, + BitsPrimitiveDivAttributes, + BitsPrimitiveHeaderAttributes, + BitsPrimitiveSelectAttributes, + BitsPrimitiveTableAttributes, + BitsPrimitiveTbodyAttributes, + BitsPrimitiveTdAttributes, + BitsPrimitiveThAttributes, + BitsPrimitiveTheadAttributes, + BitsPrimitiveTrAttributes, +} from "../utilities/attributes"; +import type { DateMatcher, Month, WeekStartsOn } from "../utilities/datetime/date-types"; + +export type CalendarRootSnippetProps = { + months: Month[]; + weekdays: string[]; +}; + +export type CalendarCellSnippetProps = { + disabled: boolean; + unavailable: boolean; + selected: boolean; +}; + +export type CalendarDaySnippetProps = { + disabled: boolean; + unavailable: boolean; + selected: boolean; + day: string; +}; + +export type CalendarHeadingSnippetProps = { + headingValue: string; +}; + +export type CalendarMonthSelectSnippetProps = { + monthItems: Array<{ value: number; label: string }>; + selectedMonthItem: { value: number; label: string }; +}; + +export type CalendarYearSelectSnippetProps = { + yearItems: Array<{ value: number; label: string }>; + selectedYearItem: { value: number; label: string }; +}; + +// ─── Base props ─────────────────────────────────────────────────────────────── + +type CalendarBaseRootPropsWithoutHTML = { + placeholder?: DateValue; + onPlaceholderChange?: OnChangeFn; + preventDeselect?: boolean; + minValue?: DateValue; + maxValue?: DateValue; + disabled?: boolean; + pagedNavigation?: boolean; + weekStartsOn?: WeekStartsOn; + weekdayFormat?: Intl.DateTimeFormatOptions["weekday"]; + isDateDisabled?: DateMatcher; + isDateUnavailable?: DateMatcher; + fixedWeeks?: boolean; + numberOfMonths?: number; + calendarLabel?: string; + locale?: string; + readonly?: boolean; + initialFocus?: boolean; + disableDaysOutsideMonth?: boolean; + maxDays?: number; + monthFormat?: Intl.DateTimeFormatOptions["month"] | ((month: number) => string); + yearFormat?: Intl.DateTimeFormatOptions["year"] | ((year: number) => string); +}; + +// ─── Single ─────────────────────────────────────────────────────────────────── + +export type CalendarSingleRootPropsWithoutHTML = { + type: "single"; + value?: DateValue; + onValueChange?: OnChangeFn; +}; + +export type _CalendarSingleRootPropsWithoutHTML = CalendarBaseRootPropsWithoutHTML & + CalendarSingleRootPropsWithoutHTML; + +export type CalendarSingleRootProps = WithChild< + _CalendarSingleRootPropsWithoutHTML, + CalendarRootSnippetProps +> & + Without; + +// ─── Multiple ───────────────────────────────────────────────────────────────── + +export type CalendarMultipleRootPropsWithoutHTML = { + type: "multiple"; + value?: DateValue[]; + onValueChange?: OnChangeFn; +}; + +export type _CalendarMultipleRootPropsWithoutHTML = CalendarBaseRootPropsWithoutHTML & + CalendarMultipleRootPropsWithoutHTML; + +export type CalendarMultipleRootProps = WithChild< + _CalendarMultipleRootPropsWithoutHTML, + CalendarRootSnippetProps +> & + Without; + +export type CalendarRootProps = CalendarSingleRootProps | CalendarMultipleRootProps; + +// ─── Sub-components ─────────────────────────────────────────────────────────── + +export type CalendarCellPropsWithoutHTML = WithChild< + { date: DateValue; month: DateValue }, + CalendarCellSnippetProps +>; +export type CalendarCellProps = CalendarCellPropsWithoutHTML & + Without; + +export type CalendarDayPropsWithoutHTML = WithChild<{}, CalendarDaySnippetProps>; +export type CalendarDayProps = CalendarDayPropsWithoutHTML & + Without; + +export type CalendarGridProps = WithChild & Without; +export type CalendarGridBodyProps = WithChild & Without; +export type CalendarGridHeadProps = WithChild & Without; +export type CalendarGridRowProps = WithChild & Without; +export type CalendarHeadCellProps = WithChild & Without; +export type CalendarHeaderProps = WithChild & Without; + +export type CalendarHeadingPropsWithoutHTML = WithChild<{}, CalendarHeadingSnippetProps>; +export type CalendarHeadingProps = CalendarHeadingPropsWithoutHTML & + Without; + +export type CalendarNextButtonProps = WithChild & + Without; +export type CalendarPrevButtonProps = WithChild & + Without; + +export type CalendarMonthSelectPropsWithoutHTML = WithChild< + { + months?: number[]; + monthFormat?: Intl.DateTimeFormatOptions["month"] | ((month: number) => string); + disabled?: boolean; + }, + CalendarMonthSelectSnippetProps +>; +export type CalendarMonthSelectProps = CalendarMonthSelectPropsWithoutHTML & + Without; + +export type CalendarYearSelectPropsWithoutHTML = WithChild< + { + years?: number[]; + yearFormat?: Intl.DateTimeFormatOptions["year"] | ((year: number) => string); + disabled?: boolean; + }, + CalendarYearSelectSnippetProps +>; +export type CalendarYearSelectProps = CalendarYearSelectPropsWithoutHTML & + Without; diff --git a/src/uix/acel/checkbox/checkbox.svelte.ts b/src/uix/acel/checkbox/checkbox.svelte.ts new file mode 100644 index 000000000..259d97f3f --- /dev/null +++ b/src/uix/acel/checkbox/checkbox.svelte.ts @@ -0,0 +1,271 @@ +import { attachRef, type ReadableBoxedValues, type WritableBoxedValues } from "../utilities/index"; +import type { RefAttachment, WithRefOpts, AcelKeyboardEvent, AcelMouseEvent, AcelFocusEvent } from "../utilities/types"; +import { createAcelAttrs, getAriaChecked, boolToStr, boolToEmptyStrOrUndef } from "../utilities/attrs"; +import { Context, watch } from "runed"; +import { kbd } from "../utilities/keyboard/kbd"; +import { noop } from "../utilities/noop"; +import { isHTMLElement } from "../utilities/dom/is-browser"; + +const checkboxAttrs = createAcelAttrs({ + component: "checkbox", + parts: ["root", "group", "group-label", "input"] as const, +}); + +// ── Group ───────────────────────────────────────────────────────────────────── + +export const CheckboxGroupContext = new Context("Checkbox.Group"); + +interface CheckboxGroupStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ + name: string | undefined; + disabled: boolean; + required: boolean; + readonly: boolean; + onValueChange: (v: string[]) => void; + }>, + WritableBoxedValues<{ value: string[] }> {} + +export class CheckboxGroupState { + static create(opts: CheckboxGroupStateOpts) { + return CheckboxGroupContext.set(new CheckboxGroupState(opts)); + } + + readonly opts: CheckboxGroupStateOpts; + readonly attachment: RefAttachment; + labelId = $state(undefined); + + constructor(opts: CheckboxGroupStateOpts) { + this.opts = opts; + this.attachment = attachRef(opts.ref); + } + + addValue(checkboxValue: string | undefined) { + if (!checkboxValue) return; + if (!this.opts.value.current.includes(checkboxValue)) { + const newValue = [...this.opts.value.current, checkboxValue]; + this.opts.value.current = newValue; + this.opts.onValueChange.current(newValue); + } + } + + removeValue(checkboxValue: string | undefined) { + if (!checkboxValue) return; + const newValue = this.opts.value.current.filter((v) => v !== checkboxValue); + if (newValue.length === this.opts.value.current.length) return; + this.opts.value.current = newValue; + this.opts.onValueChange.current(newValue); + } + + readonly props = $derived.by(() => ({ + id: this.opts.id.current, + role: "group", + "aria-labelledby": this.labelId, + "data-disabled": boolToEmptyStrOrUndef(this.opts.disabled.current), + [checkboxAttrs.group]: "", + ...this.attachment, + }) as const); +} + +// ── Group Label ─────────────────────────────────────────────────────────────── + +interface CheckboxGroupLabelStateOpts extends WithRefOpts {} + +export class CheckboxGroupLabelState { + static create(opts: CheckboxGroupLabelStateOpts) { + return new CheckboxGroupLabelState(opts, CheckboxGroupContext.get()); + } + + readonly opts: CheckboxGroupLabelStateOpts; + readonly group: CheckboxGroupState; + readonly attachment: RefAttachment; + + constructor(opts: CheckboxGroupLabelStateOpts, group: CheckboxGroupState) { + this.opts = opts; + this.group = group; + this.group.labelId = opts.id.current; + this.attachment = attachRef(opts.ref); + + watch.pre( + () => this.opts.id.current, + (id) => { this.group.labelId = id; } + ); + } + + readonly props = $derived.by(() => ({ + id: this.opts.id.current, + "data-disabled": boolToEmptyStrOrUndef(this.group.opts.disabled.current), + [checkboxAttrs["group-label"]]: "", + ...this.attachment, + }) as const); +} + +// ── Root ────────────────────────────────────────────────────────────────────── + +const CheckboxRootContext = new Context("Checkbox.Root"); + +interface CheckboxRootStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ + disabled: boolean; + required: boolean; + readonly: boolean; + name: string | undefined; + value: string | undefined; + onCheckedChange: (v: boolean) => void; + onIndeterminateChange: (v: boolean) => void; + }>, + WritableBoxedValues<{ + checked: boolean; + indeterminate: boolean; + }> {} + +export class CheckboxRootState { + static create(opts: CheckboxRootStateOpts, group: CheckboxGroupState | null = null) { + return CheckboxRootContext.set(new CheckboxRootState(opts, group)); + } + + readonly opts: CheckboxRootStateOpts; + readonly group: CheckboxGroupState | null; + readonly attachment: RefAttachment; + + readonly trueName = $derived.by(() => + this.group?.opts.name.current ?? this.opts.name.current + ); + readonly trueRequired = $derived.by(() => + !!(this.group?.opts.required.current || this.opts.required.current) + ); + readonly trueDisabled = $derived.by(() => + !!(this.group?.opts.disabled.current || this.opts.disabled.current) + ); + readonly trueReadonly = $derived.by(() => + !!(this.group?.opts.readonly.current || this.opts.readonly.current) + ); + + constructor(opts: CheckboxRootStateOpts, group: CheckboxGroupState | null) { + this.opts = opts; + this.group = group; + this.attachment = attachRef(opts.ref); + this.onkeydown = this.onkeydown.bind(this); + this.onclick = this.onclick.bind(this); + + // Sync group value → checked + watch.pre( + [() => this.group?.opts.value.current, () => this.opts.value.current] as const, + ([groupValue, value]) => { + if (!groupValue || !value) return; + this.opts.checked.current = groupValue.includes(value); + } + ); + + // Sync checked → group value + watch.pre( + () => this.opts.checked.current, + (checked) => { + if (!this.group) return; + if (checked) { + this.group.addValue(this.opts.value.current); + } else { + this.group.removeValue(this.opts.value.current); + } + } + ); + } + + #toggle() { + if (this.opts.indeterminate.current) { + this.opts.indeterminate.current = false; + this.opts.onIndeterminateChange.current(false); + this.opts.checked.current = true; + this.opts.onCheckedChange.current(true); + } else { + const next = !this.opts.checked.current; + this.opts.checked.current = next; + this.opts.onCheckedChange.current(next); + } + } + + onkeydown(e: AcelKeyboardEvent) { + if (this.trueDisabled || this.trueReadonly) return; + if (e.key === kbd.ENTER) { + e.preventDefault(); + return; + } + if (e.key === kbd.SPACE) { + e.preventDefault(); + this.#toggle(); + } + } + + onclick(e: AcelMouseEvent) { + if (this.trueDisabled || this.trueReadonly) return; + e.preventDefault(); + this.#toggle(); + } + + readonly snippetProps = $derived.by(() => ({ + checked: this.opts.checked.current, + indeterminate: this.opts.indeterminate.current, + })); + + readonly props = $derived.by(() => ({ + id: this.opts.id.current, + role: "checkbox", + type: "button" as const, + disabled: this.trueDisabled, + "aria-checked": getAriaChecked(this.opts.checked.current, this.opts.indeterminate.current), + "aria-required": boolToStr(this.trueRequired), + "aria-readonly": boolToStr(this.trueReadonly), + "data-disabled": boolToEmptyStrOrUndef(this.trueDisabled), + "data-readonly": boolToEmptyStrOrUndef(this.trueReadonly), + "data-state": getCheckboxDataState(this.opts.checked.current, this.opts.indeterminate.current), + [checkboxAttrs.root]: "", + onclick: this.onclick, + onkeydown: this.onkeydown, + ...this.attachment, + }) as const); +} + +// ── Hidden Input ────────────────────────────────────────────────────────────── + +export class CheckboxInputState { + static create() { + return new CheckboxInputState(CheckboxRootContext.get()); + } + + readonly root: CheckboxRootState; + + readonly trueChecked = $derived.by(() => { + if (!this.root.group) return this.root.opts.checked.current; + const val = this.root.opts.value.current; + return val !== undefined && this.root.group.opts.value.current.includes(val); + }); + readonly shouldRender = $derived.by(() => Boolean(this.root.trueName)); + + constructor(root: CheckboxRootState) { + this.root = root; + this.onfocus = this.onfocus.bind(this); + } + + onfocus(_: AcelFocusEvent) { + if (isHTMLElement(this.root.opts.ref.current)) { + this.root.opts.ref.current.focus(); + } + } + + readonly props = $derived.by(() => ({ + type: "checkbox" as const, + checked: this.trueChecked, + disabled: this.root.trueDisabled, + required: this.root.trueRequired, + name: this.root.trueName, + value: this.root.opts.value.current, + readonly: this.root.trueReadonly, + onfocus: this.onfocus, + }) as const); +} + +function getCheckboxDataState(checked: boolean, indeterminate: boolean) { + if (indeterminate) return "indeterminate"; + return checked ? "checked" : "unchecked"; +} diff --git a/src/uix/acel/checkbox/components/checkbox-group-label.svelte b/src/uix/acel/checkbox/components/checkbox-group-label.svelte new file mode 100644 index 000000000..3760d690d --- /dev/null +++ b/src/uix/acel/checkbox/components/checkbox-group-label.svelte @@ -0,0 +1,31 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + + {@render children?.()} + +{/if} diff --git a/src/uix/acel/checkbox/components/checkbox-group.svelte b/src/uix/acel/checkbox/components/checkbox-group.svelte new file mode 100644 index 000000000..ddc54f101 --- /dev/null +++ b/src/uix/acel/checkbox/components/checkbox-group.svelte @@ -0,0 +1,44 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/acel/checkbox/components/checkbox.svelte b/src/uix/acel/checkbox/components/checkbox.svelte new file mode 100644 index 000000000..aebd9fd49 --- /dev/null +++ b/src/uix/acel/checkbox/components/checkbox.svelte @@ -0,0 +1,61 @@ + + +{#if child} + {@render child({ props: mergedProps, ...rootState.snippetProps })} +{:else} + +{/if} + +{#if inputState.shouldRender} + +{/if} diff --git a/src/uix/acel/checkbox/exports.ts b/src/uix/acel/checkbox/exports.ts new file mode 100644 index 000000000..9555920be --- /dev/null +++ b/src/uix/acel/checkbox/exports.ts @@ -0,0 +1,8 @@ +export { default as Root } from "./components/checkbox.svelte"; +export { default as Group } from "./components/checkbox-group.svelte"; +export { default as GroupLabel } from "./components/checkbox-group-label.svelte"; +export type { + CheckboxRootProps as RootProps, + CheckboxGroupProps as GroupProps, + CheckboxGroupLabelProps as GroupLabelProps, +} from "./types"; diff --git a/src/uix/acel/checkbox/index.ts b/src/uix/acel/checkbox/index.ts new file mode 100644 index 000000000..d66c4719e --- /dev/null +++ b/src/uix/acel/checkbox/index.ts @@ -0,0 +1,2 @@ +export * as Checkbox from "./exports"; +export * from "./exports"; diff --git a/src/uix/acel/checkbox/types.ts b/src/uix/acel/checkbox/types.ts new file mode 100644 index 000000000..222ffaae9 --- /dev/null +++ b/src/uix/acel/checkbox/types.ts @@ -0,0 +1,34 @@ +import type { WithChild, Without } from "../utilities/types"; +import type { AcelPrimitiveButtonAttributes, AcelPrimitiveDivAttributes } from "../utilities/attributes"; + +export type CheckboxRootPropsWithoutHTML = WithChild<{ + checked?: boolean; + indeterminate?: boolean; + onCheckedChange?: (checked: boolean) => void; + onIndeterminateChange?: (indeterminate: boolean) => void; + disabled?: boolean; + required?: boolean; + readonly?: boolean; + name?: string; + value?: string; +}, { checked: boolean; indeterminate: boolean }, HTMLButtonElement>; + +export type CheckboxRootProps = CheckboxRootPropsWithoutHTML & + Without; + +export type CheckboxGroupPropsWithoutHTML = WithChild<{ + value?: string[]; + onValueChange?: (value: string[]) => void; + name?: string; + disabled?: boolean; + required?: boolean; + readonly?: boolean; +}>; + +export type CheckboxGroupProps = CheckboxGroupPropsWithoutHTML & + Without; + +export type CheckboxGroupLabelPropsWithoutHTML = WithChild<{}>; + +export type CheckboxGroupLabelProps = CheckboxGroupLabelPropsWithoutHTML & + Without; diff --git a/src/uix/acel/collapsible/collapsible.svelte.ts b/src/uix/acel/collapsible/collapsible.svelte.ts index 94a1553aa..e75e5be26 100644 --- a/src/uix/acel/collapsible/collapsible.svelte.ts +++ b/src/uix/acel/collapsible/collapsible.svelte.ts @@ -11,17 +11,17 @@ import { boolToStr, boolToEmptyStrOrUndef, getDataOpenClosed, -} from "../internal/attrs"; -import { kbd } from "../internal/kbd"; +} from "../utilities/attrs"; +import { kbd } from "../utilities/keyboard/kbd"; import type { AcelKeyboardEvent, AcelMouseEvent, OnChangeFn, RefAttachment, WithRefOpts, -} from "../internal/types"; +} from "../utilities/types"; import { on } from "svelte/events"; -import { PresenceManager } from "../internal/presence-manager.svelte"; +import { PresenceManager } from "../utilities/presence/presence-manager.svelte"; const collapsibleAttrs = createAcelAttrs({ component: "collapsible", diff --git a/src/uix/acel/collapsible/components/collapsible-content.svelte b/src/uix/acel/collapsible/components/collapsible-content.svelte index 30d1412cc..d63d92dc4 100644 --- a/src/uix/acel/collapsible/components/collapsible-content.svelte +++ b/src/uix/acel/collapsible/components/collapsible-content.svelte @@ -2,7 +2,7 @@ import { mergeProps, boxWith } from "../../utilities/index"; import { CollapsibleContentState } from "../collapsible.svelte"; import type { CollapsibleContentProps } from "../types"; - import { createId } from "../../internal/create-id"; + import { createId } from "../../utilities/create-id"; const uid = $props.id(); diff --git a/src/uix/acel/collapsible/components/collapsible-trigger.svelte b/src/uix/acel/collapsible/components/collapsible-trigger.svelte index 8a3f00211..a2480c12d 100644 --- a/src/uix/acel/collapsible/components/collapsible-trigger.svelte +++ b/src/uix/acel/collapsible/components/collapsible-trigger.svelte @@ -2,7 +2,7 @@ import { boxWith, mergeProps } from "../../utilities/index"; import type { CollapsibleTriggerProps } from "../types"; import { CollapsibleTriggerState } from "../collapsible.svelte"; - import { createId } from "../../internal/create-id"; + import { createId } from "../../utilities/create-id"; const uid = $props.id(); diff --git a/src/uix/acel/collapsible/components/collapsible.svelte b/src/uix/acel/collapsible/components/collapsible.svelte index 50610c009..f9ca04550 100644 --- a/src/uix/acel/collapsible/components/collapsible.svelte +++ b/src/uix/acel/collapsible/components/collapsible.svelte @@ -2,8 +2,8 @@ import { type WritableBox, boxWith, mergeProps } from "../../utilities/index"; import { CollapsibleRootState } from "../collapsible.svelte"; import type { CollapsibleRootProps } from "../types"; - import { noop } from "../../internal/noop"; - import { createId } from "../../internal/create-id"; + import { noop } from "../../utilities/noop"; + import { createId } from "../../utilities/create-id"; const uid = $props.id(); diff --git a/src/uix/acel/collapsible/types.ts b/src/uix/acel/collapsible/types.ts index f6fb7ef3a..2195eb373 100644 --- a/src/uix/acel/collapsible/types.ts +++ b/src/uix/acel/collapsible/types.ts @@ -3,11 +3,11 @@ import type { WithChild, WithChildNoChildrenSnippetProps, Without, -} from "../internal/types"; +} from "../utilities/types"; import type { AcelPrimitiveButtonAttributes, AcelPrimitiveDivAttributes, -} from "../shared/attributes"; +} from "../utilities/attributes"; export type CollapsibleRootPropsWithoutHTML = WithChild<{ /** diff --git a/src/uix/acel/command/command.svelte.ts b/src/uix/acel/command/command.svelte.ts new file mode 100644 index 000000000..70c7b31dd --- /dev/null +++ b/src/uix/acel/command/command.svelte.ts @@ -0,0 +1,1128 @@ +import { + afterSleep, + afterTick, + srOnlyStyles, + attachRef, + type WritableBoxedValues, + type ReadableBoxedValues, +} from "../utilities/index"; +import { Context, watch } from "runed"; +import { findNextSibling, findPreviousSibling } from "./utils"; +import type { CommandState } from "./types"; +import type { + AcelKeyboardEvent, + AcelMouseEvent, + AcelPointerEvent, + RefAttachment, + WithRefOpts, +} from "../utilities/types"; +import { kbd } from "../utilities/keyboard/kbd"; +import { createAcelAttrs, boolToStr, boolToEmptyStrOrUndef } from "../utilities/attrs"; +import { getFirstNonCommentChild } from "../utilities/dom/dom-internal"; +import { computeCommandScore } from "./compute-command-score"; +import { cssEscape } from "../utilities/css/css-escape"; + +const COMMAND_VALUE_ATTR = "data-value"; + +const commandAttrs = createAcelAttrs({ + component: "command", + parts: [ + "root", + "list", + "input", + "separator", + "loading", + "empty", + "group", + "group-items", + "group-heading", + "item", + "viewport", + "input-label", + ], +}); + +// selectors +const COMMAND_GROUP_SELECTOR = commandAttrs.selector("group"); +const COMMAND_GROUP_ITEMS_SELECTOR = commandAttrs.selector("group-items"); +const COMMAND_GROUP_HEADING_SELECTOR = commandAttrs.selector("group-heading"); +const COMMAND_ITEM_SELECTOR = commandAttrs.selector("item"); +const COMMAND_VALID_ITEM_SELECTOR = `${commandAttrs.selector("item")}:not([aria-disabled="true"])`; + +const CommandRootContext = new Context("Command.Root"); +const CommandListContext = new Context("Command.List"); +const CommandGroupContainerContext = new Context("Command.Group"); + +interface GridItem { + index: number; + firstRowOfGroup: boolean; + ref: HTMLElement; +} + +type ItemsGrid = GridItem[][]; + +interface CommandRootStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ + filter: (value: string, search: string, keywords?: string[]) => number; + shouldFilter: boolean; + loop: boolean; + vimBindings: boolean; + columns: number | null; + disablePointerSelection: boolean; + disableInitialScroll: boolean; + onStateChange?: (state: Readonly) => void; + }>, + WritableBoxedValues<{ + value: string; + }> {} + +const defaultState = { + search: "", + value: "", + filtered: { + count: 0, + items: new Map(), + groups: new Set(), + }, +}; + +export class CommandRootState { + static create(opts: CommandRootStateOpts) { + return CommandRootContext.set(new CommandRootState(opts)); + } + readonly opts: CommandRootStateOpts; + readonly attachment: RefAttachment; + #updateScheduled = false; + #isInitialMount = true; + sortAfterTick = false; + sortAndFilterAfterTick = false; + allItems = new Set(); + allGroups = new Map>(); + allIds = new Map(); + key = $state(0); + viewportNode = $state(null); + inputNode = $state(null); + labelNode = $state(null); + commandState = $state.raw(defaultState); + _commandState = $state(defaultState); + + #snapshot() { + return $state.snapshot(this._commandState); + } + + #scheduleUpdate() { + if (this.#updateScheduled) return; + this.#updateScheduled = true; + afterTick(() => { + this.#updateScheduled = false; + const currentState = this.#snapshot(); + const hasStateChanged = !Object.is(this.commandState, currentState); + if (hasStateChanged) { + this.commandState = currentState; + this.opts.onStateChange?.current?.(currentState); + } + }); + } + + setState(key: K, value: CommandState[K], preventScroll?: boolean) { + if (Object.is(this._commandState[key], value)) return; + this._commandState[key] = value; + if (key === "search") { + this.#filterItems(); + this.#sort(); + } else if (key === "value") { + if (!preventScroll) this.#scrollSelectedIntoView(); + } + this.#scheduleUpdate(); + } + + constructor(opts: CommandRootStateOpts) { + this.opts = opts; + this.attachment = attachRef(this.opts.ref); + const defaults = { ...this._commandState, value: this.opts.value.current ?? "" }; + this._commandState = defaults; + this.commandState = defaults; + this.onkeydown = this.onkeydown.bind(this); + } + + #score(value: string, keywords?: string[]) { + const filter = this.opts.filter.current ?? computeCommandScore; + return value ? filter(value, this._commandState.search, keywords) : 0; + } + + #sort(): void { + if (!this._commandState.search || this.opts.shouldFilter.current === false) { + if (!this._commandState.value || !this.#isInitialMount) { + this.#selectFirstItem(); + } else if (this.#isInitialMount && this._commandState.value) { + this.#scrollInitialValue(); + } + return; + } + + const scores = this._commandState.filtered.items; + const groups: [string, number][] = []; + + for (const value of this._commandState.filtered.groups) { + const items = this.allGroups.get(value); + let max = 0; + if (!items) { groups.push([value, max]); continue; } + for (const item of items!) { + const score = scores.get(item); + max = Math.max(score ?? 0, max); + } + groups.push([value, max]); + } + + const listInsertionElement = this.viewportNode; + const sorted = this.getValidItems().sort((a, b) => { + const valueA = a.getAttribute("data-value"); + const valueB = b.getAttribute("data-value"); + return (scores.get(valueB!) ?? 0) - (scores.get(valueA!) ?? 0); + }); + + for (const item of sorted) { + const group = item.closest(COMMAND_GROUP_ITEMS_SELECTOR); + if (group) { + const itemToAppend = + item.parentElement === group + ? item + : item.closest(`${COMMAND_GROUP_ITEMS_SELECTOR} > *`); + if (itemToAppend) group.appendChild(itemToAppend); + } else { + const itemToAppend = + item.parentElement === listInsertionElement + ? item + : item.closest(`${COMMAND_GROUP_ITEMS_SELECTOR} > *`); + if (itemToAppend) listInsertionElement?.appendChild(itemToAppend); + } + } + + const sortedGroups = groups.sort((a, b) => b[1] - a[1]); + for (const group of sortedGroups) { + const element = listInsertionElement?.querySelector( + `${COMMAND_GROUP_SELECTOR}[${COMMAND_VALUE_ATTR}="${cssEscape(group[0])}"]` + ); + element?.parentElement?.appendChild(element); + } + + this.#selectFirstItem(); + } + + setValue(value: string, opts?: boolean) { + if (value !== this.opts.value.current && value === "") { + afterTick(() => { this.key++; }); + } + this.setState("value", value, opts); + this.opts.value.current = value; + } + + #selectFirstItem(): void { + afterTick(() => { + const item = this.getValidItems().find( + (item) => item.getAttribute("aria-disabled") !== "true" + ); + const value = item?.getAttribute(COMMAND_VALUE_ATTR); + const shouldPreventScroll = this.#isInitialMount && this.opts.disableInitialScroll.current; + this.setValue(value ?? "", shouldPreventScroll); + this.#isInitialMount = false; + }); + } + + #scrollInitialValue(): void { + afterTick(() => { + if (!this.opts.disableInitialScroll.current) { + this.#scrollSelectedIntoView(); + } + this.#isInitialMount = false; + }); + } + + #filterItems(): void { + if (!this._commandState.search || this.opts.shouldFilter.current === false) { + this._commandState.filtered.count = this.allItems.size; + return; + } + + this._commandState.filtered.groups = new Set(); + let itemCount = 0; + + for (const id of this.allItems) { + const value = this.allIds.get(id)?.value ?? ""; + const keywords = this.allIds.get(id)?.keywords ?? []; + const rank = this.#score(value, keywords); + this._commandState.filtered.items.set(id, rank); + if (rank > 0) itemCount++; + } + + for (const [groupId, group] of this.allGroups) { + for (const itemId of group) { + const currItem = this._commandState.filtered.items.get(itemId); + if (currItem && currItem > 0) { + this._commandState.filtered.groups.add(groupId); + break; + } + } + } + + this._commandState.filtered.count = itemCount; + } + + getValidItems(): HTMLElement[] { + const node = this.opts.ref.current; + if (!node) return []; + return Array.from(node.querySelectorAll(COMMAND_VALID_ITEM_SELECTOR)).filter( + (el): el is HTMLElement => !!el + ); + } + + getVisibleItems(): HTMLElement[] { + const node = this.opts.ref.current; + if (!node) return []; + return Array.from(node.querySelectorAll(COMMAND_ITEM_SELECTOR)).filter( + (el): el is HTMLElement => !!el + ); + } + + get itemsGrid(): ItemsGrid { + if (!this.isGrid) return []; + const columns = this.opts.columns.current ?? 1; + const items = this.getVisibleItems(); + const grid: ItemsGrid = [[]]; + let currentGroup = items[0]?.getAttribute("data-group"); + let column = 0; + let row = 0; + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + const itemGroup = item?.getAttribute("data-group"); + if (currentGroup !== itemGroup) { + currentGroup = itemGroup; + column = 1; + row++; + grid.push([{ index: i, firstRowOfGroup: true, ref: item! }]); + } else { + column++; + if (column > columns) { row++; column = 1; grid.push([]); } + grid[row]?.push({ + index: i, + firstRowOfGroup: grid[row]?.[0]?.firstRowOfGroup ?? i === 0, + ref: item!, + }); + } + } + return grid; + } + + #getSelectedItem(): HTMLElement | undefined { + const node = this.opts.ref.current; + if (!node) return; + return node.querySelector(`${COMMAND_VALID_ITEM_SELECTOR}[data-selected]`) ?? undefined; + } + + #scrollSelectedIntoView(): void { + afterTick(() => { + const item = this.#getSelectedItem(); + if (!item) return; + const grandparent = item.parentElement?.parentElement; + if (!grandparent) return; + + if (this.isGrid) { + item.scrollIntoView({ block: "nearest" }); + if (this.#itemIsFirstRowOfGroup(item)) { + item.closest(COMMAND_GROUP_SELECTOR) + ?.querySelector(COMMAND_GROUP_HEADING_SELECTOR) + ?.scrollIntoView({ block: "nearest" }); + } + } else { + const firstChildOfParent = getFirstNonCommentChild(grandparent) as HTMLElement | null; + if (firstChildOfParent && firstChildOfParent.dataset?.value === item.dataset?.value) { + item.closest(COMMAND_GROUP_SELECTOR) + ?.querySelector(COMMAND_GROUP_HEADING_SELECTOR) + ?.scrollIntoView({ block: "nearest" }); + return; + } + item.scrollIntoView({ block: "nearest" }); + } + }); + } + + #itemIsFirstRowOfGroup(item: HTMLElement) { + const grid = this.itemsGrid; + if (grid.length === 0) return false; + for (let r = 0; r < grid.length; r++) { + const row = grid[r]; + if (row === undefined) continue; + for (let c = 0; c < row.length; c++) { + const column = row[c]; + if (column === undefined || column.ref !== item) continue; + return column.firstRowOfGroup; + } + } + return false; + } + + updateSelectedToIndex(index: number) { + const item = this.getValidItems()[index]; + if (!item) return; + this.setValue(item.getAttribute(COMMAND_VALUE_ATTR) ?? ""); + } + + updateSelectedByItem(change: number): void { + const selected = this.#getSelectedItem(); + const items = this.getValidItems(); + const index = items.findIndex((item) => item === selected); + let newSelected = items[index + change]; + if (this.opts.loop.current) { + newSelected = + index + change < 0 + ? items[items.length - 1] + : index + change === items.length + ? items[0] + : items[index + change]; + } + if (newSelected) this.setValue(newSelected.getAttribute(COMMAND_VALUE_ATTR) ?? ""); + } + + updateSelectedByGroup(change: 1 | -1): void { + const selected = this.#getSelectedItem(); + let group = selected?.closest(COMMAND_GROUP_SELECTOR); + let item: HTMLElement | null | undefined; + while (group && !item) { + group = change > 0 + ? findNextSibling(group, COMMAND_GROUP_SELECTOR) + : findPreviousSibling(group, COMMAND_GROUP_SELECTOR); + item = group?.querySelector(COMMAND_VALID_ITEM_SELECTOR); + } + if (item) { + this.setValue(item.getAttribute(COMMAND_VALUE_ATTR) ?? ""); + } else { + this.updateSelectedByItem(change); + } + } + + registerValue(value: string, keywords?: string[]): () => void { + if (!(value && value === this.allIds.get(value)?.value)) { + this.allIds.set(value, { value, keywords }); + } + this._commandState.filtered.items.set(value, this.#score(value, keywords)); + if (!this.sortAfterTick) { + this.sortAfterTick = true; + afterTick(() => { this.#sort(); this.sortAfterTick = false; }); + } + return () => { this.allIds.delete(value); }; + } + + registerItem(id: string, groupId: string | undefined): () => void { + this.allItems.add(id); + if (groupId) { + if (!this.allGroups.has(groupId)) { + this.allGroups.set(groupId, new Set([id])); + } else { + this.allGroups.get(groupId)!.add(id); + } + } + if (!this.sortAndFilterAfterTick) { + this.sortAndFilterAfterTick = true; + afterTick(() => { + this.#filterItems(); + this.#sort(); + this.sortAndFilterAfterTick = false; + }); + } + this.#scheduleUpdate(); + return () => { + const selectedItem = this.#getSelectedItem(); + this.allItems.delete(id); + this.commandState.filtered.items.delete(id); + this.#filterItems(); + if (selectedItem?.getAttribute("id") === id) this.#selectFirstItem(); + this.#scheduleUpdate(); + }; + } + + registerGroup(id: string): () => void { + if (!this.allGroups.has(id)) this.allGroups.set(id, new Set()); + return () => { this.allIds.delete(id); this.allGroups.delete(id); }; + } + + get isGrid() { + return this.opts.columns.current !== null; + } + + #last() { + return this.updateSelectedToIndex(this.getValidItems().length - 1); + } + + #next(e: AcelKeyboardEvent) { + e.preventDefault(); + if (e.metaKey) this.#last(); + else if (e.altKey) this.updateSelectedByGroup(1); + else this.updateSelectedByItem(1); + } + + #down(e: AcelKeyboardEvent) { + if (this.opts.columns.current === null) return; + e.preventDefault(); + if (e.metaKey) this.updateSelectedByGroup(1); + else this.updateSelectedByItem(this.#nextRowColumnOffset(e)); + } + + #getColumn(item: HTMLElement, grid: ItemsGrid): { columnIndex: number; rowIndex: number } | null { + if (grid.length === 0) return null; + for (let r = 0; r < grid.length; r++) { + const row = grid[r]; + if (row === undefined) continue; + for (let c = 0; c < row.length; c++) { + const column = row[c]; + if (column === undefined || column.ref !== item) continue; + return { columnIndex: c, rowIndex: r }; + } + } + return null; + } + + #nextRowColumnOffset(e: AcelKeyboardEvent): number { + const grid = this.itemsGrid; + const selected = this.#getSelectedItem(); + if (!selected) return 0; + const column = this.#getColumn(selected, grid); + if (!column) return 0; + let newItem: HTMLElement | null = null; + const skipRows = e.altKey ? 1 : 0; + + if (e.altKey && column.rowIndex === grid.length - 2 && !this.opts.loop.current) { + newItem = this.#findNextNonDisabledItem({ start: grid.length - 1, end: grid.length, expectedColumnIndex: column.columnIndex, grid }); + } else if (column.rowIndex === grid.length - 1) { + if (!this.opts.loop.current) return 0; + newItem = this.#findNextNonDisabledItem({ start: 0 + skipRows, end: column.rowIndex, expectedColumnIndex: column.columnIndex, grid }); + } else { + newItem = this.#findNextNonDisabledItem({ start: column.rowIndex + 1 + skipRows, end: grid.length, expectedColumnIndex: column.columnIndex, grid }); + if (newItem === null && this.opts.loop.current) { + newItem = this.#findNextNonDisabledItem({ start: 0, end: column.rowIndex, expectedColumnIndex: column.columnIndex, grid }); + } + } + return this.#calculateOffset(selected, newItem); + } + + #findNextNonDisabledItem({ start, end, grid, expectedColumnIndex }: { start: number; end: number; grid: ItemsGrid; expectedColumnIndex: number; }) { + let newItem: HTMLElement | null = null; + for (let r = start; r < end; r++) { + const row = grid[r]!; + newItem = row[expectedColumnIndex]?.ref ?? null; + if (newItem !== null && itemIsDisabled(newItem)) { newItem = null; continue; } + if (newItem === null) { + for (let i = row.length - 1; i >= 0; i--) { + const item = row[row.length - 1]; + if (item === undefined || itemIsDisabled(item.ref)) continue; + newItem = item.ref; + break; + } + } + break; + } + return newItem; + } + + #calculateOffset(selected: HTMLElement, newSelected: HTMLElement | null): number { + if (newSelected === null) return 0; + const items = this.getValidItems(); + return items.findIndex((item) => item === newSelected) - items.findIndex((item) => item === selected); + } + + #up(e: AcelKeyboardEvent) { + if (this.opts.columns.current === null) return; + e.preventDefault(); + if (e.metaKey) this.updateSelectedByGroup(-1); + else this.updateSelectedByItem(this.#previousRowColumnOffset(e)); + } + + #previousRowColumnOffset(e: AcelKeyboardEvent) { + const grid = this.itemsGrid; + const selected = this.#getSelectedItem(); + if (selected === undefined) return 0; + const column = this.#getColumn(selected, grid); + if (column === null) return 0; + let newItem: HTMLElement | null = null; + const skipRows = e.altKey ? 1 : 0; + + if (e.altKey && column.rowIndex === 1 && this.opts.loop.current === false) { + newItem = this.#findNextNonDisabledItemDesc({ start: 0, end: 0, expectedColumnIndex: column.columnIndex, grid }); + } else if (column.rowIndex === 0) { + if (this.opts.loop.current === false) return 0; + newItem = this.#findNextNonDisabledItemDesc({ start: grid.length - 1 - skipRows, end: column.rowIndex + 1, expectedColumnIndex: column.columnIndex, grid }); + } else { + newItem = this.#findNextNonDisabledItemDesc({ start: column.rowIndex - 1 - skipRows, end: 0, expectedColumnIndex: column.columnIndex, grid }); + if (newItem === null && this.opts.loop.current) { + newItem = this.#findNextNonDisabledItemDesc({ start: grid.length - 1, end: column.rowIndex + 1, expectedColumnIndex: column.columnIndex, grid }); + } + } + return this.#calculateOffset(selected, newItem); + } + + #findNextNonDisabledItemDesc({ start, end, grid, expectedColumnIndex }: { start: number; end: number; grid: ItemsGrid; expectedColumnIndex: number; }) { + let newItem: HTMLElement | null = null; + for (let r = start; r >= end; r--) { + const row = grid[r]; + if (row === undefined) continue; + newItem = row[expectedColumnIndex]?.ref ?? null; + if (newItem !== null && itemIsDisabled(newItem)) { newItem = null; continue; } + if (newItem === null) { + for (let i = row.length - 1; i >= 0; i--) { + const item = row[row.length - 1]; + if (item === undefined || itemIsDisabled(item.ref)) continue; + newItem = item.ref; + break; + } + } + break; + } + return newItem; + } + + #prev(e: AcelKeyboardEvent) { + e.preventDefault(); + if (e.metaKey) this.updateSelectedToIndex(0); + else if (e.altKey) this.updateSelectedByGroup(-1); + else this.updateSelectedByItem(-1); + } + + onkeydown(e: AcelKeyboardEvent) { + const isVim = this.opts.vimBindings.current && e.ctrlKey; + switch (e.key) { + case kbd.n: + case kbd.j: + if (isVim) { this.isGrid ? this.#down(e) : this.#next(e); } + break; + case kbd.l: + if (isVim && this.isGrid) this.#next(e); + break; + case kbd.ARROW_DOWN: + this.isGrid ? this.#down(e) : this.#next(e); + break; + case kbd.ARROW_RIGHT: + if (this.isGrid) this.#next(e); + break; + case kbd.p: + case kbd.k: + if (isVim) { this.isGrid ? this.#up(e) : this.#prev(e); } + break; + case kbd.h: + if (isVim && this.isGrid) this.#prev(e); + break; + case kbd.ARROW_UP: + this.isGrid ? this.#up(e) : this.#prev(e); + break; + case kbd.ARROW_LEFT: + if (this.isGrid) this.#prev(e); + break; + case kbd.HOME: + e.preventDefault(); + this.updateSelectedToIndex(0); + break; + case kbd.END: + e.preventDefault(); + this.#last(); + break; + case kbd.ENTER: + if (!e.isComposing && e.keyCode !== 229) { + e.preventDefault(); + this.#getSelectedItem()?.click(); + } + } + } + + props = $derived.by( + () => + ({ + id: this.opts.id.current, + role: "application", + [commandAttrs.root]: "", + tabindex: -1, + onkeydown: this.onkeydown, + ...this.attachment, + }) as const + ); +} + +function itemIsDisabled(item: HTMLElement) { + return item.getAttribute("aria-disabled") === "true"; +} + +// ── CommandEmptyState ───────────────────────────────────────────────────────── + +interface CommandEmptyStateOpts extends WithRefOpts, ReadableBoxedValues<{ forceMount: boolean }> {} + +export class CommandEmptyState { + static create(opts: CommandEmptyStateOpts) { + return new CommandEmptyState(opts, CommandRootContext.get()); + } + readonly opts: CommandEmptyStateOpts; + readonly root: CommandRootState; + readonly attachment: RefAttachment; + readonly shouldRender = $derived.by(() => { + return ( + (this.root._commandState.filtered.count === 0 && this.#isInitialRender === false) || + this.opts.forceMount.current + ); + }); + #isInitialRender = true; + + constructor(opts: CommandEmptyStateOpts, root: CommandRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(this.opts.ref); + $effect.pre(() => { this.#isInitialRender = false; }); + } + + readonly props = $derived.by( + () => ({ id: this.opts.id.current, role: "presentation", [commandAttrs.empty]: "", ...this.attachment }) as const + ); +} + +// ── CommandGroupContainerState ──────────────────────────────────────────────── + +interface CommandGroupContainerStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ value: string; forceMount: boolean }> {} + +export class CommandGroupContainerState { + static create(opts: CommandGroupContainerStateOpts) { + return CommandGroupContainerContext.set( + new CommandGroupContainerState(opts, CommandRootContext.get()) + ); + } + readonly opts: CommandGroupContainerStateOpts; + readonly root: CommandRootState; + readonly attachment: RefAttachment; + readonly shouldRender = $derived.by(() => { + if (this.opts.forceMount.current) return true; + if (this.root.opts.shouldFilter.current === false) return true; + if (!this.root.commandState.search) return true; + return this.root._commandState.filtered.groups.has(this.trueValue); + }); + headingNode = $state(null); + trueValue = $state(""); + + constructor(opts: CommandGroupContainerStateOpts, root: CommandRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(this.opts.ref); + this.trueValue = opts.value.current ?? opts.id.current; + + watch( + () => this.trueValue, + () => this.root.registerGroup(this.trueValue) + ); + + $effect(() => { + if (this.opts.value.current) { + this.trueValue = this.opts.value.current; + return this.root.registerValue(this.opts.value.current); + } else if (this.headingNode && this.headingNode.textContent) { + this.trueValue = this.headingNode.textContent.trim().toLowerCase(); + return this.root.registerValue(this.trueValue); + } else { + this.trueValue = `-----${this.opts.id.current}`; + return this.root.registerValue(this.trueValue); + } + }); + } + + readonly props = $derived.by( + () => ({ + id: this.opts.id.current, + role: "presentation", + hidden: this.shouldRender ? undefined : true, + "data-value": this.trueValue, + [commandAttrs.group]: "", + ...this.attachment, + }) as const + ); +} + +// ── CommandGroupHeadingState ────────────────────────────────────────────────── + +interface CommandGroupHeadingStateOpts extends WithRefOpts {} + +export class CommandGroupHeadingState { + static create(opts: CommandGroupHeadingStateOpts) { + return new CommandGroupHeadingState(opts, CommandGroupContainerContext.get()); + } + readonly opts: CommandGroupHeadingStateOpts; + readonly group: CommandGroupContainerState; + readonly attachment: RefAttachment; + + constructor(opts: CommandGroupHeadingStateOpts, group: CommandGroupContainerState) { + this.opts = opts; + this.group = group; + this.attachment = attachRef(this.opts.ref, (v) => (this.group.headingNode = v)); + } + + readonly props = $derived.by( + () => ({ id: this.opts.id.current, [commandAttrs["group-heading"]]: "", ...this.attachment }) as const + ); +} + +// ── CommandGroupItemsState ──────────────────────────────────────────────────── + +interface CommandGroupItemsStateOpts extends WithRefOpts {} + +export class CommandGroupItemsState { + static create(opts: CommandGroupItemsStateOpts) { + return new CommandGroupItemsState(opts, CommandGroupContainerContext.get()); + } + readonly opts: CommandGroupItemsStateOpts; + readonly group: CommandGroupContainerState; + readonly attachment: RefAttachment; + + constructor(opts: CommandGroupItemsStateOpts, group: CommandGroupContainerState) { + this.opts = opts; + this.group = group; + this.attachment = attachRef(this.opts.ref); + } + + readonly props = $derived.by( + () => ({ + id: this.opts.id.current, + role: "group", + [commandAttrs["group-items"]]: "", + "aria-labelledby": this.group.headingNode?.id ?? undefined, + ...this.attachment, + }) as const + ); +} + +// ── CommandInputState ───────────────────────────────────────────────────────── + +interface CommandInputStateOpts + extends WithRefOpts, + WritableBoxedValues<{ value: string }>, + ReadableBoxedValues<{ autofocus: boolean }> {} + +export class CommandInputState { + static create(opts: CommandInputStateOpts) { + return new CommandInputState(opts, CommandRootContext.get()); + } + readonly opts: CommandInputStateOpts; + readonly root: CommandRootState; + readonly attachment: RefAttachment; + readonly #selectedItemId = $derived.by(() => { + const item = this.root.viewportNode?.querySelector( + `${COMMAND_ITEM_SELECTOR}[${COMMAND_VALUE_ATTR}="${cssEscape(this.root.opts.value.current)}"]` + ); + return item?.getAttribute("id") ?? undefined; + }); + + constructor(opts: CommandInputStateOpts, root: CommandRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(this.opts.ref, (v) => (this.root.inputNode = v)); + + watch( + () => this.opts.ref.current, + () => { + const node = this.opts.ref.current; + if (node && this.opts.autofocus.current) { + afterSleep(10, () => node.focus()); + } + } + ); + + watch( + () => this.opts.value.current, + () => { + if (this.root.commandState.search !== this.opts.value.current) { + this.root.setState("search", this.opts.value.current); + } + } + ); + } + + readonly props = $derived.by( + () => ({ + id: this.opts.id.current, + type: "text", + [commandAttrs.input]: "", + autocomplete: "off", + autocorrect: "off", + spellcheck: false, + "aria-autocomplete": "list", + role: "combobox", + "aria-expanded": boolToStr(true), + "aria-controls": this.root.viewportNode?.id ?? undefined, + "aria-labelledby": this.root.labelNode?.id ?? undefined, + "aria-activedescendant": this.#selectedItemId, + ...this.attachment, + }) as const + ); +} + +// ── CommandItemState ────────────────────────────────────────────────────────── + +interface CommandItemStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ + value: string; + disabled: boolean; + onSelect: () => void; + forceMount: boolean; + keywords: string[]; + }> { + group: CommandGroupContainerState | null; +} + +export class CommandItemState { + static create(opts: Omit) { + const group = CommandGroupContainerContext.getOr(null); + return new CommandItemState({ ...opts, group }, CommandRootContext.get()); + } + readonly opts: CommandItemStateOpts; + readonly root: CommandRootState; + readonly attachment: RefAttachment; + readonly #group: CommandGroupContainerState | null = null; + readonly #trueForceMount = $derived.by( + () => this.opts.forceMount.current || this.#group?.opts.forceMount.current === true + ); + readonly shouldRender = $derived.by(() => { + this.opts.ref.current; + if (this.#trueForceMount || this.root.opts.shouldFilter.current === false || !this.root.commandState.search) { + return true; + } + const currentScore = this.root.commandState.filtered.items.get(this.trueValue); + if (currentScore === undefined) return false; + return currentScore > 0; + }); + readonly isSelected = $derived.by( + () => this.root.opts.value.current === this.trueValue && this.trueValue !== "" + ); + trueValue = $state(""); + + constructor(opts: CommandItemStateOpts, root: CommandRootState) { + this.opts = opts; + this.root = root; + this.#group = CommandGroupContainerContext.getOr(null); + this.trueValue = opts.value.current; + this.attachment = attachRef(this.opts.ref); + + watch( + [() => this.trueValue, () => this.#group?.trueValue, () => this.opts.forceMount.current], + () => { + if (this.opts.forceMount.current || !this.trueValue) return; + return this.root.registerItem(this.trueValue, this.#group?.trueValue); + } + ); + + watch([() => this.opts.value.current, () => this.opts.ref.current], () => { + if (this.opts.value.current) { + this.trueValue = this.opts.value.current; + } else if (this.opts.ref.current?.textContent) { + this.trueValue = this.opts.ref.current.textContent.trim(); + } + if (this.trueValue) { + this.root.registerValue(this.trueValue, opts.keywords.current.map((kw) => kw.trim())); + this.opts.ref.current?.setAttribute(COMMAND_VALUE_ATTR, this.trueValue); + } + }); + + this.onclick = this.onclick.bind(this); + this.onpointermove = this.onpointermove.bind(this); + } + + #onSelect() { + if (this.opts.disabled.current) return; + this.#select(); + this.opts.onSelect?.current(); + } + + #select() { + if (this.opts.disabled.current) return; + this.root.setValue(this.trueValue, true); + } + + onpointermove(_: AcelPointerEvent) { + if (this.opts.disabled.current || this.root.opts.disablePointerSelection.current) return; + this.#select(); + } + + onclick(_: AcelMouseEvent) { + if (this.opts.disabled.current) return; + this.#onSelect(); + } + + readonly props = $derived.by( + () => ({ + id: this.opts.id.current, + "aria-disabled": boolToStr(this.opts.disabled.current), + "aria-selected": boolToStr(this.isSelected), + "data-disabled": boolToEmptyStrOrUndef(this.opts.disabled.current), + "data-selected": boolToEmptyStrOrUndef(this.isSelected), + "data-value": this.trueValue, + "data-group": this.#group?.trueValue, + [commandAttrs.item]: "", + role: "option", + onpointermove: this.onpointermove, + onclick: this.onclick, + ...this.attachment, + }) as const + ); +} + +// ── CommandLoadingState ─────────────────────────────────────────────────────── + +interface CommandLoadingStateOpts extends WithRefOpts, ReadableBoxedValues<{ progress: number }> {} + +export class CommandLoadingState { + static create(opts: CommandLoadingStateOpts) { + return new CommandLoadingState(opts); + } + readonly opts: CommandLoadingStateOpts; + readonly attachment: RefAttachment; + + constructor(opts: CommandLoadingStateOpts) { + this.opts = opts; + this.attachment = attachRef(this.opts.ref); + } + + readonly props = $derived.by( + () => ({ + id: this.opts.id.current, + role: "progressbar", + "aria-valuenow": this.opts.progress.current, + "aria-valuemin": 0, + "aria-valuemax": 100, + "aria-label": "Loading...", + [commandAttrs.loading]: "", + ...this.attachment, + }) as const + ); +} + +// ── CommandSeparatorState ───────────────────────────────────────────────────── + +interface CommandSeparatorStateOpts extends WithRefOpts, ReadableBoxedValues<{ forceMount: boolean }> {} + +export class CommandSeparatorState { + static create(opts: CommandSeparatorStateOpts) { + return new CommandSeparatorState(opts, CommandRootContext.get()); + } + readonly opts: CommandSeparatorStateOpts; + readonly root: CommandRootState; + readonly attachment: RefAttachment; + readonly shouldRender = $derived.by( + () => !this.root._commandState.search || this.opts.forceMount.current + ); + + constructor(opts: CommandSeparatorStateOpts, root: CommandRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(this.opts.ref); + } + + readonly props = $derived.by( + () => ({ + id: this.opts.id.current, + "aria-hidden": "true", + [commandAttrs.separator]: "", + ...this.attachment, + }) as const + ); +} + +// ── CommandListState ────────────────────────────────────────────────────────── + +interface CommandListStateOpts extends WithRefOpts, ReadableBoxedValues<{ ariaLabel: string }> {} + +export class CommandListState { + static create(opts: CommandListStateOpts) { + return CommandListContext.set(new CommandListState(opts, CommandRootContext.get())); + } + readonly opts: CommandListStateOpts; + readonly root: CommandRootState; + readonly attachment: RefAttachment; + + constructor(opts: CommandListStateOpts, root: CommandRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(this.opts.ref); + } + + readonly props = $derived.by( + () => ({ + id: this.opts.id.current, + role: "listbox", + "aria-label": this.opts.ariaLabel.current, + [commandAttrs.list]: "", + ...this.attachment, + }) as const + ); +} + +// ── CommandLabelState ───────────────────────────────────────────────────────── + +interface CommandLabelStateOpts extends WithRefOpts, ReadableBoxedValues<{ for?: string }> {} + +export class CommandLabelState { + static create(opts: CommandLabelStateOpts) { + return new CommandLabelState(opts, CommandRootContext.get()); + } + readonly opts: CommandLabelStateOpts; + readonly root: CommandRootState; + readonly attachment: RefAttachment; + + constructor(opts: CommandLabelStateOpts, root: CommandRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(this.opts.ref, (v) => (this.root.labelNode = v)); + } + + readonly props = $derived.by( + () => ({ + id: this.opts.id.current, + [commandAttrs["input-label"]]: "", + for: this.opts.for?.current, + style: srOnlyStyles, + ...this.attachment, + }) as const + ); +} + +// ── CommandViewportState ────────────────────────────────────────────────────── + +interface CommandViewportStateOpts extends WithRefOpts {} + +export class CommandViewportState { + static create(opts: CommandViewportStateOpts) { + return new CommandViewportState(opts, CommandListContext.get()); + } + readonly opts: CommandViewportStateOpts; + readonly list: CommandListState; + readonly attachment: RefAttachment; + + constructor(opts: CommandViewportStateOpts, list: CommandListState) { + this.opts = opts; + this.list = list; + this.attachment = attachRef(this.opts.ref, (v) => (this.list.root.viewportNode = v)); + + watch( + [() => this.opts.ref.current, () => this.list.opts.ref.current], + ([node, listNode]) => { + if (node === null || listNode === null) return; + let aF: number; + const observer = new ResizeObserver(() => { + aF = requestAnimationFrame(() => { + const height = node.offsetHeight; + listNode.style.setProperty("--acel-command-list-height", `${height.toFixed(1)}px`); + }); + }); + observer.observe(node); + return () => { cancelAnimationFrame(aF); observer.unobserve(node); }; + } + ); + } + + readonly props = $derived.by( + () => ({ id: this.opts.id.current, [commandAttrs.viewport]: "", ...this.attachment }) as const + ); +} diff --git a/src/uix/acel/command/components/_command-label.svelte b/src/uix/acel/command/components/_command-label.svelte new file mode 100644 index 000000000..7e6afe63f --- /dev/null +++ b/src/uix/acel/command/components/_command-label.svelte @@ -0,0 +1,28 @@ + + + diff --git a/src/uix/acel/command/components/command-empty.svelte b/src/uix/acel/command/components/command-empty.svelte new file mode 100644 index 000000000..003a7909c --- /dev/null +++ b/src/uix/acel/command/components/command-empty.svelte @@ -0,0 +1,36 @@ + + +{#if emptyState.shouldRender} + {#if child} + {@render child({ props: mergedProps })} + {:else} +
+ {@render children?.()} +
+ {/if} +{/if} diff --git a/src/uix/acel/command/components/command-group-heading.svelte b/src/uix/acel/command/components/command-group-heading.svelte new file mode 100644 index 000000000..ca4f9044c --- /dev/null +++ b/src/uix/acel/command/components/command-group-heading.svelte @@ -0,0 +1,32 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/acel/command/components/command-group-items.svelte b/src/uix/acel/command/components/command-group-items.svelte new file mode 100644 index 000000000..bba89f7a7 --- /dev/null +++ b/src/uix/acel/command/components/command-group-items.svelte @@ -0,0 +1,34 @@ + + +
+ {#if child} + {@render child({ props: mergedProps })} + {:else} +
+ {@render children?.()} +
+ {/if} +
diff --git a/src/uix/acel/command/components/command-group.svelte b/src/uix/acel/command/components/command-group.svelte new file mode 100644 index 000000000..2053ffece --- /dev/null +++ b/src/uix/acel/command/components/command-group.svelte @@ -0,0 +1,36 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/acel/command/components/command-input.svelte b/src/uix/acel/command/components/command-input.svelte new file mode 100644 index 000000000..b87a75659 --- /dev/null +++ b/src/uix/acel/command/components/command-input.svelte @@ -0,0 +1,36 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/acel/command/components/command-item.svelte b/src/uix/acel/command/components/command-item.svelte new file mode 100644 index 000000000..74905813e --- /dev/null +++ b/src/uix/acel/command/components/command-item.svelte @@ -0,0 +1,49 @@ + + +{#key itemState.root.key} +
+ {#if itemState.shouldRender} + {#if child} + {@render child({ props: mergedProps })} + {:else} +
+ {@render children?.()} +
+ {/if} + {/if} +
+{/key} diff --git a/src/uix/acel/command/components/command-link-item.svelte b/src/uix/acel/command/components/command-link-item.svelte new file mode 100644 index 000000000..4d7897d25 --- /dev/null +++ b/src/uix/acel/command/components/command-link-item.svelte @@ -0,0 +1,49 @@ + + +{#key itemState.root.key} +
+ {#if itemState.shouldRender} + {#if child} + {@render child({ props: mergedProps })} + {:else} + + {@render children?.()} + + {/if} + {/if} +
+{/key} diff --git a/src/uix/acel/command/components/command-list.svelte b/src/uix/acel/command/components/command-list.svelte new file mode 100644 index 000000000..e759e18ed --- /dev/null +++ b/src/uix/acel/command/components/command-list.svelte @@ -0,0 +1,36 @@ + + +{#key listState.root._commandState.search === ""} + {#if child} + {@render child({ props: mergedProps })} + {:else} +
+ {@render children?.()} +
+ {/if} +{/key} diff --git a/src/uix/acel/command/components/command-loading.svelte b/src/uix/acel/command/components/command-loading.svelte new file mode 100644 index 000000000..aa402f175 --- /dev/null +++ b/src/uix/acel/command/components/command-loading.svelte @@ -0,0 +1,34 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/acel/command/components/command-separator.svelte b/src/uix/acel/command/components/command-separator.svelte new file mode 100644 index 000000000..11f20b181 --- /dev/null +++ b/src/uix/acel/command/components/command-separator.svelte @@ -0,0 +1,36 @@ + + +{#if separatorState.shouldRender} + {#if child} + {@render child({ props: mergedProps })} + {:else} +
+ {@render children?.()} +
+ {/if} +{/if} diff --git a/src/uix/acel/command/components/command-viewport.svelte b/src/uix/acel/command/components/command-viewport.svelte new file mode 100644 index 000000000..f38652983 --- /dev/null +++ b/src/uix/acel/command/components/command-viewport.svelte @@ -0,0 +1,32 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/acel/command/components/command.svelte b/src/uix/acel/command/components/command.svelte new file mode 100644 index 000000000..b0ff747a9 --- /dev/null +++ b/src/uix/acel/command/components/command.svelte @@ -0,0 +1,80 @@ + + +{#snippet Label()} + + {label} + +{/snippet} + +{#if child} + {@render Label()} + {@render child({ props: mergedProps })} +{:else} +
+ {@render Label()} + {@render children?.()} +
+{/if} diff --git a/src/uix/acel/command/compute-command-score.ts b/src/uix/acel/command/compute-command-score.ts new file mode 100644 index 000000000..6f690b7e4 --- /dev/null +++ b/src/uix/acel/command/compute-command-score.ts @@ -0,0 +1,176 @@ +// oxlint-disable ban-ts-comment +// @ts-nocheck +// The scores are arranged so that a continuous match of characters will +// result in a total score of 1. +// +// The best case, this character is a match, and either this is the start +// of the string, or the previous character was also a match. +const SCORE_CONTINUE_MATCH = 1; +// A new match at the start of a word scores better than a new match +// elsewhere as it's more likely that the user will type the starts +// of fragments. +// NOTE: We score word jumps between spaces slightly higher than slashes, brackets +// hyphens, etc. +const SCORE_SPACE_WORD_JUMP = 0.9; +const SCORE_NON_SPACE_WORD_JUMP = 0.8; +// Any other match isn't ideal, but we include it for completeness. +const SCORE_CHARACTER_JUMP = 0.17; +// If the user transposed two letters, it should be significantly penalized. +// +// i.e. "ouch" is more likely than "curtain" when "uc" is typed. +const SCORE_TRANSPOSITION = 0.1; +// The goodness of a match should decay slightly with each missing +// character. +// +// i.e. "bad" is more likely than "bard" when "bd" is typed. +// +// This will not change the order of suggestions based on SCORE_* until +// 100 characters are inserted between matches. +const PENALTY_SKIPPED = 0.999; +// The goodness of an exact-case match should be higher than a +// case-insensitive match by a small amount. +// +// i.e. "HTML" is more likely than "haml" when "HM" is typed. +// +// This will not change the order of suggestions based on SCORE_* until +// 1000 characters are inserted between matches. +const PENALTY_CASE_MISMATCH = 0.9999; +// Match higher for letters closer to the beginning of the word +// If the word has more characters than the user typed, it should +// be penalized slightly. +// +// i.e. "html" is more likely than "html5" if I type "html". +// +// However, it may well be the case that there's a sensible secondary +// ordering (like alphabetical) that it makes sense to rely on when +// there are many prefix matches, so we don't make the penalty increase +// with the number of tokens. +const PENALTY_NOT_COMPLETE = 0.99; + +const IS_GAP_REGEXP = /[\\/_+.#"@[({&]/; +const COUNT_GAPS_REGEXP = /[\\/_+.#"@[({&]/g; +const IS_SPACE_REGEXP = /[\s-]/; +const COUNT_SPACE_REGEXP = /[\s-]/g; + +function computeCommandScoreInner( + string, + abbreviation, + lowerString, + lowerAbbreviation, + stringIndex, + abbreviationIndex, + memoizedResults +) { + if (abbreviationIndex === abbreviation.length) { + if (stringIndex === string.length) return SCORE_CONTINUE_MATCH; + return PENALTY_NOT_COMPLETE; + } + + const memoizeKey = `${stringIndex},${abbreviationIndex}`; + if (memoizedResults[memoizeKey] !== undefined) return memoizedResults[memoizeKey]; + + const abbreviationChar = lowerAbbreviation.charAt(abbreviationIndex); + let index = lowerString.indexOf(abbreviationChar, stringIndex); + let highScore = 0; + + let score, transposedScore, wordBreaks, spaceBreaks; + + while (index >= 0) { + score = computeCommandScoreInner( + string, + abbreviation, + lowerString, + lowerAbbreviation, + index + 1, + abbreviationIndex + 1, + memoizedResults + ); + if (score > highScore) { + if (index === stringIndex) { + score *= SCORE_CONTINUE_MATCH; + } else if (IS_GAP_REGEXP.test(string.charAt(index - 1))) { + score *= SCORE_NON_SPACE_WORD_JUMP; + wordBreaks = string.slice(stringIndex, index - 1).match(COUNT_GAPS_REGEXP); + if (wordBreaks && stringIndex > 0) { + score *= PENALTY_SKIPPED ** wordBreaks.length; + } + } else if (IS_SPACE_REGEXP.test(string.charAt(index - 1))) { + score *= SCORE_SPACE_WORD_JUMP; + spaceBreaks = string.slice(stringIndex, index - 1).match(COUNT_SPACE_REGEXP); + if (spaceBreaks && stringIndex > 0) { + score *= PENALTY_SKIPPED ** spaceBreaks.length; + } + } else { + score *= SCORE_CHARACTER_JUMP; + if (stringIndex > 0) { + score *= PENALTY_SKIPPED ** (index - stringIndex); + } + } + + if (string.charAt(index) !== abbreviation.charAt(abbreviationIndex)) { + score *= PENALTY_CASE_MISMATCH; + } + } + + if ( + (score < SCORE_TRANSPOSITION && + lowerString.charAt(index - 1) === + lowerAbbreviation.charAt(abbreviationIndex + 1)) || + (lowerAbbreviation.charAt(abbreviationIndex + 1) === + lowerAbbreviation.charAt(abbreviationIndex) && + lowerString.charAt(index - 1) !== lowerAbbreviation.charAt(abbreviationIndex)) + ) { + transposedScore = computeCommandScoreInner( + string, + abbreviation, + lowerString, + lowerAbbreviation, + index + 1, + abbreviationIndex + 2, + memoizedResults + ); + + if (transposedScore * SCORE_TRANSPOSITION > score) { + score = transposedScore * SCORE_TRANSPOSITION; + } + } + + if (score > highScore) { + highScore = score; + } + + index = lowerString.indexOf(abbreviationChar, index + 1); + } + + memoizedResults[memoizeKey] = highScore; + return highScore; +} + +function formatInput(string) { + return string.toLowerCase().replace(COUNT_SPACE_REGEXP, " "); +} + +/** + * Given a command, a search query, and (optionally) a list of keywords for the command, + * computes a score between 0 and 1 that represents how well the search query matches the + * command and keywords. 1 is a perfect match, 0 is no match. + */ +export function computeCommandScore( + command: string, + search: string, + commandKeywords?: string[] +): number { + command = + commandKeywords && commandKeywords.length > 0 + ? `${`${command} ${commandKeywords?.join(" ")}`}` + : command; + return computeCommandScoreInner( + command, + search, + formatInput(command), + formatInput(search), + 0, + 0, + {} + ); +} diff --git a/src/uix/acel/command/exports.ts b/src/uix/acel/command/exports.ts new file mode 100644 index 000000000..73d783b21 --- /dev/null +++ b/src/uix/acel/command/exports.ts @@ -0,0 +1,30 @@ +export { default as Root } from "./components/command.svelte"; +export { default as Empty } from "./components/command-empty.svelte"; +export { default as Group } from "./components/command-group.svelte"; +export { default as GroupHeading } from "./components/command-group-heading.svelte"; +export { default as GroupItems } from "./components/command-group-items.svelte"; +export { default as Input } from "./components/command-input.svelte"; +export { default as Item } from "./components/command-item.svelte"; +export { default as LinkItem } from "./components/command-link-item.svelte"; +export { default as List } from "./components/command-list.svelte"; +export { default as Viewport } from "./components/command-viewport.svelte"; +export { default as Loading } from "./components/command-loading.svelte"; +export { default as Separator } from "./components/command-separator.svelte"; + +export { computeCommandScore } from "./compute-command-score"; + +export type { + CommandRootProps as RootProps, + CommandEmptyProps as EmptyProps, + CommandGroupProps as GroupProps, + CommandGroupHeadingProps as GroupHeadingProps, + CommandGroupItemsProps as GroupItemsProps, + CommandItemProps as ItemProps, + CommandLinkItemProps as LinkItemProps, + CommandInputProps as InputProps, + CommandSeparatorProps as SeparatorProps, + CommandListProps as ListProps, + CommandLoadingProps as LoadingProps, + CommandViewportProps as ViewportProps, + CommandState, +} from "./types"; diff --git a/src/uix/acel/command/index.ts b/src/uix/acel/command/index.ts new file mode 100644 index 000000000..fd0b17987 --- /dev/null +++ b/src/uix/acel/command/index.ts @@ -0,0 +1 @@ +export * from "./exports"; diff --git a/src/uix/acel/command/types.ts b/src/uix/acel/command/types.ts new file mode 100644 index 000000000..18e4169d9 --- /dev/null +++ b/src/uix/acel/command/types.ts @@ -0,0 +1,118 @@ +import type { + AcelPrimitiveAnchorAttributes, + AcelPrimitiveDivAttributes, + AcelPrimitiveInputAttributes, +} from "../utilities/attributes"; +import type { WithChild, Without } from "../utilities/types"; + +export type CommandState = { + /** The value of the search query */ + search: string; + /** The value of the selected command menu item */ + value: string; + /** The filtered items */ + filtered: { + /** The count of all visible items. */ + count: number; + /** Map from visible item value to its search store. */ + items: Map; + /** Set of groups with at least one visible item. */ + groups: Set; + }; +}; + +export type CommandRootPropsWithoutHTML = WithChild<{ + /** An accessible label for the command menu (screen reader only). */ + label?: string; + /** Set to false to disable automatic filtering and sorting. */ + shouldFilter?: boolean; + /** Custom filter function returning 0-1 score. */ + filter?: (value: string, search: string, keywords?: string[]) => number; + /** Called when the command state changes. */ + onStateChange?: (state: Readonly) => void; + /** Currently selected item value (bindable). */ + value?: string; + /** Called when the selected item changes. */ + onValueChange?: (value: string) => void; + /** Enable looping through items at the end of the list. */ + loop?: boolean; + /** Disable selection via pointer events. */ + disablePointerSelection?: boolean; + /** Disable vim-style keyboard navigation (Ctrl+n/j/p/k). */ + vimBindings?: boolean; + /** Number of columns in a grid layout. */ + columns?: number | null; + /** Disable auto-scroll to selected item on initial mount. */ + disableInitialScroll?: boolean; +}>; + +export type CommandRootProps = CommandRootPropsWithoutHTML & + Without; + +export type CommandEmptyPropsWithoutHTML = WithChild<{ + forceMount?: boolean; +}>; + +export type CommandEmptyProps = CommandEmptyPropsWithoutHTML & + Without; + +export type CommandGroupPropsWithoutHTML = WithChild<{ + value?: string; + forceMount?: boolean; +}>; + +export type CommandGroupProps = CommandGroupPropsWithoutHTML & + Without; + +export type CommandGroupHeadingPropsWithoutHTML = WithChild; +export type CommandGroupHeadingProps = CommandGroupHeadingPropsWithoutHTML & + Without; + +export type CommandGroupItemsPropsWithoutHTML = WithChild; +export type CommandGroupItemsProps = CommandGroupItemsPropsWithoutHTML & + Without; + +export type CommandItemPropsWithoutHTML = WithChild<{ + disabled?: boolean; + onSelect?: () => void; + value?: string; + keywords?: string[]; + forceMount?: boolean; +}>; + +export type CommandItemProps = CommandItemPropsWithoutHTML & + Without; + +export type CommandLinkItemPropsWithoutHTML = CommandItemPropsWithoutHTML; +export type CommandLinkItemProps = CommandLinkItemPropsWithoutHTML & + Without; + +export type CommandInputPropsWithoutHTML = WithChild<{ + value?: string; + autofocus?: boolean; +}>; + +export type CommandInputProps = CommandInputPropsWithoutHTML & + Without; + +export type CommandListPropsWithoutHTML = WithChild; +export type CommandListProps = CommandListPropsWithoutHTML & + Without; + +export type CommandSeparatorPropsWithoutHTML = WithChild<{ + forceMount?: boolean; +}>; + +export type CommandSeparatorProps = CommandSeparatorPropsWithoutHTML & + Without; + +export type CommandLoadingPropsWithoutHTML = WithChild<{ + progress?: number; +}>; + +export type CommandLoadingProps = CommandLoadingPropsWithoutHTML & + Without; + +export type CommandViewportPropsWithoutHTML = WithChild; +export type CommandViewportProps = CommandViewportPropsWithoutHTML & + Without; diff --git a/src/uix/acel/command/utils.ts b/src/uix/acel/command/utils.ts new file mode 100644 index 000000000..4a1631abf --- /dev/null +++ b/src/uix/acel/command/utils.ts @@ -0,0 +1,15 @@ +export function findNextSibling(el: Element, selector: string) { + let sibling = el.nextElementSibling; + while (sibling) { + if (sibling.matches(selector)) return sibling; + sibling = sibling.nextElementSibling; + } +} + +export function findPreviousSibling(el: Element, selector: string) { + let sibling = el.previousElementSibling; + while (sibling) { + if (sibling.matches(selector)) return sibling; + sibling = sibling.previousElementSibling; + } +} diff --git a/src/uix/acel/comn/config/exports.ts b/src/uix/acel/comn/config/exports.ts index 2251599c2..fde21ccf0 100644 --- a/src/uix/acel/comn/config/exports.ts +++ b/src/uix/acel/comn/config/exports.ts @@ -1,2 +1,2 @@ export { default as BitsConfig } from "./components/config.svelte"; -export { getAcelConfig, AcelConfigState } from "./acel-config.js"; +export { getAcelConfig, AcelConfigState } from "./acel-config.ts"; diff --git a/src/uix/acel/comn/config/types.ts b/src/uix/acel/comn/config/types.ts index 1b4991eef..96a9f4e61 100644 --- a/src/uix/acel/comn/config/types.ts +++ b/src/uix/acel/comn/config/types.ts @@ -1,4 +1,4 @@ -import type { WithChildren } from "../../internal/types.ts"; +import type { WithChildren } from "../../utilities/types.ts"; import type { PortalTarget } from "../portal/types.ts"; export type AcelConfigPropsWithoutChildren = { diff --git a/src/uix/acel/comn/floating-layer/floating-layer-anchor.svelte b/src/uix/acel/comn/floating-layer/floating-layer-anchor.svelte new file mode 100644 index 000000000..8e60b97e2 --- /dev/null +++ b/src/uix/acel/comn/floating-layer/floating-layer-anchor.svelte @@ -0,0 +1,31 @@ + + +{@render children?.()} diff --git a/src/uix/acel/comn/floating-layer/floating-layer.svelte b/src/uix/acel/comn/floating-layer/floating-layer.svelte new file mode 100644 index 000000000..6cc39f89b --- /dev/null +++ b/src/uix/acel/comn/floating-layer/floating-layer.svelte @@ -0,0 +1,10 @@ + + +{@render children?.()} diff --git a/src/uix/acel/comn/popper-layer/popper-layer.svelte b/src/uix/acel/comn/popper-layer/popper-layer.svelte new file mode 100644 index 000000000..fba645c06 --- /dev/null +++ b/src/uix/acel/comn/popper-layer/popper-layer.svelte @@ -0,0 +1,119 @@ + + + + +{#if shouldRender} + {@render popper({ props: mergedContentProps, wrapperProps: contentState.wrapperProps })} +{/if} diff --git a/src/uix/acel/comn/portal/portal.svelte b/src/uix/acel/comn/portal/portal.svelte index 2628e6d92..6777db7f0 100644 --- a/src/uix/acel/comn/portal/portal.svelte +++ b/src/uix/acel/comn/portal/portal.svelte @@ -4,7 +4,7 @@ import { watch } from "runed"; import PortalConsumer from "./portal-consumer.svelte"; import type { PortalProps } from "./types.ts"; - import { isBrowser } from "../../internal/is.ts"; + import { isBrowser } from "../../utilities/dom/is-browser.ts"; import { resolvePortalToProp } from "../config/prop-resolvers.ts"; let { to: toProp, children, disabled }: PortalProps = $props(); diff --git a/src/uix/acel/context-menu/components/context-menu-content.svelte b/src/uix/acel/context-menu/components/context-menu-content.svelte new file mode 100644 index 000000000..2dc05b5a5 --- /dev/null +++ b/src/uix/acel/context-menu/components/context-menu-content.svelte @@ -0,0 +1,109 @@ + + + + {#snippet popper({ props, wrapperProps })} + {@const finalProps = mergeProps( + contentState.props, + props, + { style: getFloatingContentCSSVars("context-menu") }, + style ? { style } : {} + )} + {#if child} + {@render child({ props: finalProps, wrapperProps, ...contentState.snippetProps })} + {:else} +
+
+ {@render children?.()} +
+
+ {/if} + {/snippet} +
diff --git a/src/uix/acel/context-menu/components/context-menu-trigger.svelte b/src/uix/acel/context-menu/components/context-menu-trigger.svelte new file mode 100644 index 000000000..087ee70eb --- /dev/null +++ b/src/uix/acel/context-menu/components/context-menu-trigger.svelte @@ -0,0 +1,47 @@ + + + + {#if child} + {@render child({ props: mergedProps })} + {:else} +
+ {@render children?.()} +
+ {/if} +
diff --git a/src/uix/acel/context-menu/components/context-menu.svelte b/src/uix/acel/context-menu/components/context-menu.svelte new file mode 100644 index 000000000..21531b506 --- /dev/null +++ b/src/uix/acel/context-menu/components/context-menu.svelte @@ -0,0 +1,42 @@ + + + + {@render children?.()} + diff --git a/src/uix/acel/context-menu/exports.ts b/src/uix/acel/context-menu/exports.ts new file mode 100644 index 000000000..4b8c93f25 --- /dev/null +++ b/src/uix/acel/context-menu/exports.ts @@ -0,0 +1,37 @@ +export { default as Root } from "./components/context-menu.svelte"; +export { default as Trigger } from "./components/context-menu-trigger.svelte"; +export { default as Content } from "./components/context-menu-content.svelte"; + +// Shared menu components +export { default as Sub } from "../menu/components/menu-sub.svelte"; +export { default as Item } from "../menu/components/menu-item.svelte"; +export { default as Group } from "../menu/components/menu-group.svelte"; +export { default as GroupHeading } from "../menu/components/menu-group-heading.svelte"; +export { default as Arrow } from "../menu/components/menu-arrow.svelte"; +export { default as RadioItem } from "../menu/components/menu-radio-item.svelte"; +export { default as Separator } from "../menu/components/menu-separator.svelte"; +export { default as RadioGroup } from "../menu/components/menu-radio-group.svelte"; +export { default as SubContent } from "../menu/components/menu-sub-content.svelte"; +export { default as SubTrigger } from "../menu/components/menu-sub-trigger.svelte"; +export { default as CheckboxItem } from "../menu/components/menu-checkbox-item.svelte"; +export { default as CheckboxGroup } from "../menu/components/menu-checkbox-group.svelte"; + +export type { + ContextMenuArrowProps as ArrowProps, + ContextMenuCheckboxItemProps as CheckboxItemProps, + ContextMenuGroupProps as GroupProps, + ContextMenuItemProps as ItemProps, + ContextMenuGroupHeadingProps as GroupHeadingProps, + ContextMenuRootProps as RootProps, + ContextMenuRadioGroupProps as RadioGroupProps, + ContextMenuRadioItemProps as RadioItemProps, + ContextMenuSeparatorProps as SeparatorProps, + ContextMenuSubContentProps as SubContentProps, + ContextMenuSubContentStaticProps as SubContentStaticProps, + ContextMenuSubProps as SubProps, + ContextMenuSubTriggerProps as SubTriggerProps, + ContextMenuContentProps as ContentProps, + ContextMenuContentStaticProps as ContentStaticProps, + ContextMenuTriggerProps as TriggerProps, + ContextMenuCheckboxGroupProps as CheckboxGroupProps, +} from "./types"; diff --git a/src/uix/acel/context-menu/index.ts b/src/uix/acel/context-menu/index.ts new file mode 100644 index 000000000..fd0b17987 --- /dev/null +++ b/src/uix/acel/context-menu/index.ts @@ -0,0 +1 @@ +export * from "./exports"; diff --git a/src/uix/acel/context-menu/types.ts b/src/uix/acel/context-menu/types.ts new file mode 100644 index 000000000..556f3d65e --- /dev/null +++ b/src/uix/acel/context-menu/types.ts @@ -0,0 +1,44 @@ +import type { WithChild, Without } from "../utilities/types"; +import type { AcelPrimitiveDivAttributes } from "../utilities/attributes"; + +export type ContextMenuTriggerPropsWithoutHTML = WithChild<{ + disabled?: boolean; +}>; + +export type ContextMenuTriggerProps = ContextMenuTriggerPropsWithoutHTML & + Without; + +export type { + MenuArrowProps as ContextMenuArrowProps, + MenuContentProps as ContextMenuContentProps, + MenuContentStaticProps as ContextMenuContentStaticProps, + MenuCheckboxItemProps as ContextMenuCheckboxItemProps, + MenuGroupProps as ContextMenuGroupProps, + MenuItemProps as ContextMenuItemProps, + MenuGroupHeadingProps as ContextMenuGroupHeadingProps, + MenuRootProps as ContextMenuRootProps, + MenuRadioGroupProps as ContextMenuRadioGroupProps, + MenuRadioItemProps as ContextMenuRadioItemProps, + MenuSeparatorProps as ContextMenuSeparatorProps, + MenuSubContentProps as ContextMenuSubContentProps, + MenuSubContentStaticProps as ContextMenuSubContentStaticProps, + MenuSubProps as ContextMenuSubProps, + MenuSubTriggerProps as ContextMenuSubTriggerProps, + MenuCheckboxGroupProps as ContextMenuCheckboxGroupProps, + MenuArrowPropsWithoutHTML as ContextMenuArrowPropsWithoutHTML, + MenuContentPropsWithoutHTML as ContextMenuContentPropsWithoutHTML, + MenuContentStaticPropsWithoutHTML as ContextMenuContentStaticPropsWithoutHTML, + MenuCheckboxItemPropsWithoutHTML as ContextMenuCheckboxItemPropsWithoutHTML, + MenuGroupPropsWithoutHTML as ContextMenuGroupPropsWithoutHTML, + MenuItemPropsWithoutHTML as ContextMenuItemPropsWithoutHTML, + MenuGroupHeadingPropsWithoutHTML as ContextMenuGroupHeadingPropsWithoutHTML, + MenuRootPropsWithoutHTML as ContextMenuRootPropsWithoutHTML, + MenuRadioGroupPropsWithoutHTML as ContextMenuRadioGroupPropsWithoutHTML, + MenuRadioItemPropsWithoutHTML as ContextMenuRadioItemPropsWithoutHTML, + MenuSeparatorPropsWithoutHTML as ContextMenuSeparatorPropsWithoutHTML, + MenuSubPropsWithoutHTML as ContextMenuSubPropsWithoutHTML, + MenuSubTriggerPropsWithoutHTML as ContextMenuSubTriggerPropsWithoutHTML, + MenuSubContentPropsWithoutHTML as ContextMenuSubContentPropsWithoutHTML, + MenuSubContentStaticPropsWithoutHTML as ContextMenuSubContentStaticPropsWithoutHTML, + MenuCheckboxGroupPropsWithoutHTML as ContextMenuCheckboxGroupPropsWithoutHTML, +} from "../menu/types"; diff --git a/src/uix/acel/date-field/components/date-field-hidden-input.svelte b/src/uix/acel/date-field/components/date-field-hidden-input.svelte new file mode 100644 index 000000000..92d134460 --- /dev/null +++ b/src/uix/acel/date-field/components/date-field-hidden-input.svelte @@ -0,0 +1,10 @@ + + +{#if hiddenInputState.shouldRender} + +{/if} diff --git a/src/uix/acel/date-field/components/date-field-input.svelte b/src/uix/acel/date-field/components/date-field-input.svelte new file mode 100644 index 000000000..c72818305 --- /dev/null +++ b/src/uix/acel/date-field/components/date-field-input.svelte @@ -0,0 +1,39 @@ + + +{#if child} + {@render child({ props: mergedProps, segments: inputState.root.segmentContents })} +{:else} +
+ {@render children?.({ segments: inputState.root.segmentContents })} +
+{/if} + + diff --git a/src/uix/acel/date-field/components/date-field-label.svelte b/src/uix/acel/date-field/components/date-field-label.svelte new file mode 100644 index 000000000..f948493da --- /dev/null +++ b/src/uix/acel/date-field/components/date-field-label.svelte @@ -0,0 +1,34 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/acel/date-field/components/date-field-segment.svelte b/src/uix/acel/date-field/components/date-field-segment.svelte new file mode 100644 index 000000000..e57570c98 --- /dev/null +++ b/src/uix/acel/date-field/components/date-field-segment.svelte @@ -0,0 +1,37 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + + {@render children?.()} + +{/if} diff --git a/src/uix/acel/date-field/components/date-field.svelte b/src/uix/acel/date-field/components/date-field.svelte new file mode 100644 index 000000000..a72de9497 --- /dev/null +++ b/src/uix/acel/date-field/components/date-field.svelte @@ -0,0 +1,99 @@ + + +{@render children?.()} diff --git a/src/uix/acel/date-field/date-field.svelte.ts b/src/uix/acel/date-field/date-field.svelte.ts new file mode 100644 index 000000000..abd15dd88 --- /dev/null +++ b/src/uix/acel/date-field/date-field.svelte.ts @@ -0,0 +1,1574 @@ +import type { Updater } from "svelte/store"; +import type { DateValue } from "@internationalized/date"; +import { + type WritableBox, + boxWith, + onDestroyEffect, + attachRef, + DOMContext, + type ReadableBoxedValues, + type WritableBoxedValues, + simpleBox, +} from "svelte-toolbelt"; +import { onMount, untrack } from "svelte"; +import { Context, watch } from "runed"; +import type { DateRangeFieldRootState } from "../date-range-field/date-range-field.svelte.js"; +import type { + BitsFocusEvent, + BitsKeyboardEvent, + BitsMouseEvent, + WithRefOpts, + RefAttachment, +} from "$lib/internal/types.js"; +import { + createBitsAttrs, + boolToStr, + boolToStrTrueOrUndef, + boolToEmptyStrOrUndef, +} from "$lib/internal/attrs.js"; +import { isBrowser, isNumberString } from "$lib/internal/is.js"; +import { kbd } from "$lib/internal/kbd.js"; +import { useId } from "$lib/internal/use-id.js"; +import type { + DateAndTimeSegmentObj, + DateOnInvalid, + DateSegmentObj, + DateSegmentPart, + DateValidator, + Granularity, + HourCycle, + SegmentPart, + SegmentValueObj, + TimeSegmentObj, + EditableTimeSegmentPart, +} from "$lib/shared/date/types.js"; +import { type Formatter, createFormatter } from "$lib/internal/date-time/formatter.js"; +import { type Announcer, getAnnouncer } from "$lib/internal/date-time/announcer.js"; +import { + areAllSegmentsFilled, + createContent, + getDefaultHourCycle, + getValueFromSegments, + inferGranularity, + initSegmentStates, + initializeSegmentValues, + isAcceptableSegmentKey, + isDateAndTimeSegmentObj, + isDateSegmentPart, + isFirstSegment, + removeDescriptionElement, + setDescription, +} from "$lib/internal/date-time/field/helpers.js"; +import { + DATE_SEGMENT_PARTS, + EDITABLE_TIME_SEGMENT_PARTS, +} from "$lib/internal/date-time/field/parts.js"; +import { getDaysInMonth, isBefore, toDate } from "$lib/internal/date-time/utils.js"; +import { + getFirstSegment, + handleSegmentNavigation, + isSegmentNavigationKey, + moveToNextSegment, + moveToPrevSegment, +} from "$lib/internal/date-time/field/segments.js"; + +export const dateFieldAttrs = createBitsAttrs({ + component: "date-field", + parts: ["input", "label", "segment"], +}); + +// Common segment configuration +interface SegmentConfig { + min: number | ((root: DateFieldRootState) => number); + max: number | ((root: DateFieldRootState) => number); + cycle: number; + canBeZero?: boolean; + padZero?: boolean; + getAnnouncement?: (value: number, root: DateFieldRootState) => string | number; + updateLogic?: (props: { + root: DateFieldRootState; + prev: string | null; + num: number; + moveToNext: { value: boolean }; + }) => string | null; +} + +const SEGMENT_CONFIGS: Record< + "day" | "month" | "year" | "hour" | "minute" | "second", + SegmentConfig +> = { + day: { + min: 1, + max: (root) => { + const segmentMonthValue = root.segmentValues.month; + const placeholder = root.value.current ?? root.placeholder.current; + return segmentMonthValue + ? getDaysInMonth(placeholder.set({ month: Number.parseInt(segmentMonthValue) })) + : getDaysInMonth(placeholder); + }, + cycle: 1, + padZero: true, + }, + month: { + min: 1, + max: 12, + cycle: 1, + padZero: true, + getAnnouncement: (month, root) => { + if (!root.placeholder.current) return ""; + return `${month} - ${root.formatter.fullMonth(toDate(root.placeholder.current.set({ month })))}`; + }, + }, + year: { + min: 1, + max: 9999, + cycle: 1, + padZero: false, + }, + hour: { + min: (root) => (root.hourCycle.current === 12 ? 1 : 0), + max: (root) => { + if (root.hourCycle.current === 24) return 23; + if (root.hourCycle.current === 12) return 12; + // if hourCycle is undefined, infer from locale + const inferredHourCycle = getDefaultHourCycle(root.locale.current); + return inferredHourCycle === 12 ? 12 : 23; + }, + cycle: 1, + canBeZero: true, + padZero: true, + }, + minute: { + min: 0, + max: 59, + cycle: 1, + canBeZero: true, + padZero: true, + }, + second: { + min: 0, + max: 59, + cycle: 1, + canBeZero: true, + padZero: true, + }, +}; + +const DateFieldRootContext = new Context("DateField.Root"); + +interface DateFieldRootStateOpts + extends WritableBoxedValues<{ + value: DateValue | undefined; + placeholder: DateValue; + }>, + ReadableBoxedValues<{ + readonlySegments: SegmentPart[]; + validate: DateValidator | undefined; + onInvalid: DateOnInvalid | undefined; + minValue: DateValue | undefined; + maxValue: DateValue | undefined; + disabled: boolean; + readonly: boolean; + granularity: Granularity | undefined; + hourCycle: HourCycle | undefined; + locale: string; + hideTimeZone: boolean; + required: boolean; + errorMessageId: string | undefined; + isInvalidProp: boolean | undefined; + }> {} + +export class DateFieldRootState { + static create(opts: DateFieldRootStateOpts, rangeRoot?: DateRangeFieldRootState) { + return DateFieldRootContext.set(new DateFieldRootState(opts, rangeRoot)); + } + + value: DateFieldRootStateOpts["value"]; + placeholder: WritableBox; + validate: DateFieldRootStateOpts["validate"]; + minValue: DateFieldRootStateOpts["minValue"]; + maxValue: DateFieldRootStateOpts["maxValue"]; + disabled: DateFieldRootStateOpts["disabled"]; + readonly: DateFieldRootStateOpts["readonly"]; + granularity: DateFieldRootStateOpts["granularity"]; + readonlySegments: DateFieldRootStateOpts["readonlySegments"]; + hourCycle: DateFieldRootStateOpts["hourCycle"]; + locale: DateFieldRootStateOpts["locale"]; + hideTimeZone: DateFieldRootStateOpts["hideTimeZone"]; + required: DateFieldRootStateOpts["required"]; + onInvalid: DateFieldRootStateOpts["onInvalid"]; + errorMessageId: DateFieldRootStateOpts["errorMessageId"]; + isInvalidProp: DateFieldRootStateOpts["isInvalidProp"]; + descriptionId = useId(); + formatter: Formatter; + initialSegments: SegmentValueObj; + segmentValues = $state() as SegmentValueObj; + announcer: Announcer; + readonly readonlySegmentsSet = $derived.by(() => new Set(this.readonlySegments.current)); + segmentStates = initSegmentStates(); + #fieldNode = $state(null); + #labelNode = $state(null); + descriptionNode = $state(null); + validationNode = $state(null); + states = initSegmentStates(); + dayPeriodNode = $state(null); + rangeRoot: DateRangeFieldRootState | undefined = undefined; + name = $state(""); + domContext: DOMContext = new DOMContext(() => null); + + constructor(props: DateFieldRootStateOpts, rangeRoot?: DateRangeFieldRootState) { + this.rangeRoot = rangeRoot; + /** + * Since the `DateFieldRootState` can be used in two contexts, as a standalone + * field or as a field within a `DateRangeField` component, we handle assigning + * the props based on that context. + */ + this.value = props.value; + this.placeholder = rangeRoot ? rangeRoot.opts.placeholder : props.placeholder; + this.validate = rangeRoot ? simpleBox(undefined) : props.validate; + this.minValue = rangeRoot ? rangeRoot.opts.minValue : props.minValue; + this.maxValue = rangeRoot ? rangeRoot.opts.maxValue : props.maxValue; + this.disabled = rangeRoot ? rangeRoot.opts.disabled : props.disabled; + this.readonly = rangeRoot ? rangeRoot.opts.readonly : props.readonly; + this.granularity = rangeRoot ? rangeRoot.opts.granularity : props.granularity; + this.readonlySegments = rangeRoot + ? rangeRoot.opts.readonlySegments + : props.readonlySegments; + this.hourCycle = rangeRoot ? rangeRoot.opts.hourCycle : props.hourCycle; + this.locale = rangeRoot ? rangeRoot.opts.locale : props.locale; + this.hideTimeZone = rangeRoot ? rangeRoot.opts.hideTimeZone : props.hideTimeZone; + this.required = rangeRoot ? rangeRoot.opts.required : props.required; + this.onInvalid = rangeRoot ? rangeRoot.opts.onInvalid : props.onInvalid; + this.errorMessageId = rangeRoot ? rangeRoot.opts.errorMessageId : props.errorMessageId; + this.isInvalidProp = props.isInvalidProp; + this.formatter = createFormatter({ + initialLocale: this.locale.current, + monthFormat: boxWith(() => "long"), + yearFormat: boxWith(() => "numeric"), + }); + this.initialSegments = initializeSegmentValues(this.inferredGranularity); + this.segmentValues = this.initialSegments; + this.announcer = getAnnouncer(null); + this.getFieldNode = this.getFieldNode.bind(this); + this.updateSegment = this.updateSegment.bind(this); + this.handleSegmentClick = this.handleSegmentClick.bind(this); + this.getBaseSegmentAttrs = this.getBaseSegmentAttrs.bind(this); + + $effect(() => { + untrack(() => { + this.initialSegments = initializeSegmentValues(this.inferredGranularity); + }); + }); + + onMount(() => { + this.announcer = getAnnouncer(this.domContext.getDocument()); + }); + + onDestroyEffect(() => { + if (rangeRoot) return; + removeDescriptionElement(this.descriptionId, this.domContext.getDocument()); + }); + + $effect(() => { + if (rangeRoot) return; + if (this.formatter.getLocale() === this.locale.current) return; + this.formatter.setLocale(this.locale.current); + }); + + $effect(() => { + if (rangeRoot) return; + if (this.value.current) { + const descriptionId = untrack(() => this.descriptionId); + setDescription({ + id: descriptionId, + formatter: this.formatter, + value: this.value.current, + doc: this.domContext.getDocument(), + }); + } + const placeholder = untrack(() => this.placeholder.current); + if (this.value.current && placeholder !== this.value.current) { + untrack(() => { + if (this.value.current) { + this.placeholder.current = this.value.current; + } + }); + } + }); + + if (this.value.current) { + this.syncSegmentValues(this.value.current); + } + + $effect(() => { + this.locale.current; + if (this.value.current) { + this.syncSegmentValues(this.value.current); + } + + this.#clearUpdating(); + }); + + $effect(() => { + if (this.value.current === undefined) { + this.segmentValues = initializeSegmentValues(this.inferredGranularity); + } + }); + + watch( + () => this.validationStatus, + () => { + if (this.validationStatus !== false) { + this.onInvalid.current?.( + this.validationStatus.reason, + this.validationStatus.message + ); + } + } + ); + } + + setName(name: string) { + this.name = name; + } + + /** + * Sets the field node for the `DateFieldRootState` instance. We use this method so we can + * keep `#fieldNode` private to prevent accidental usage of the incorrect field node. + */ + setFieldNode(node: HTMLElement | null) { + this.#fieldNode = node; + } + + /** + * Gets the correct field node for the date field regardless of whether it's being + * used in a standalone context or within a `DateRangeField` component. + */ + getFieldNode() { + /** If we're not within a DateRangeField, we return this field. */ + if (!this.rangeRoot) { + return this.#fieldNode; + } else { + /** + * Otherwise, we return the rangeRoot's field node which + * contains both start and end fields. + */ + return this.rangeRoot.fieldNode; + } + } + + /** + * Sets the label node for the `DateFieldRootState` instance. We use this method so we can + * keep `#labelNode` private to prevent accidental usage of the incorrect label node. + */ + setLabelNode(node: HTMLElement | null) { + this.#labelNode = node; + } + + /** + * Gets the correct label node for the date field regardless of whether it's being used in + * a standalone context or within a `DateRangeField` component. + */ + getLabelNode() { + /** If we're not within a DateRangeField, we return this field. */ + if (!this.rangeRoot) { + return this.#labelNode; + } + /** Otherwise we return the rangeRoot's label node. */ + return this.rangeRoot.labelNode; + } + + #clearUpdating() { + this.states.day.updating = null; + this.states.month.updating = null; + this.states.year.updating = null; + this.states.hour.updating = null; + this.states.minute.updating = null; + this.states.dayPeriod.updating = null; + } + + setValue(value: DateValue | undefined) { + this.value.current = value; + } + + syncSegmentValues(value: DateValue) { + const dateValues = DATE_SEGMENT_PARTS.map((part) => { + const partValue = value[part]; + + if (part === "month") { + if (this.states.month.updating) { + return [part, this.states.month.updating]; + } + if (partValue < 10) { + return [part, `0${partValue}`]; + } + } + + if (part === "day") { + if (this.states.day.updating) { + return [part, this.states.day.updating]; + } + + if (partValue < 10) { + return [part, `0${partValue}`]; + } + } + + if (part === "year") { + if (this.states.year.updating) { + return [part, this.states.year.updating]; + } + const valueDigits = `${partValue}`.length; + const diff = 4 - valueDigits; + if (diff > 0) { + return [part, `${"0".repeat(diff)}${partValue}`]; + } + } + + return [part, `${partValue}`]; + }); + if ("hour" in value) { + const timeValues = EDITABLE_TIME_SEGMENT_PARTS.map((part) => { + if (part === "dayPeriod") { + if (this.states.dayPeriod.updating) { + return [part, this.states.dayPeriod.updating]; + } else { + return [part, this.formatter.dayPeriod(toDate(value))]; + } + } else if (part === "hour") { + if (this.states.hour.updating) { + return [part, this.states.hour.updating]; + } + + if (value[part] !== undefined && value[part] < 10) { + return [part, `0${value[part]}`]; + } + + if (value[part] === 0) { + /** + * If we're rendering a `dayPeriod` segment, we're operating in a + * 12-hour clock, so we never allow the displayed hour to be 0. + */ + if (this.dayPeriodNode) { + return [part, "12"]; + } + } + } else if (part === "minute") { + if (this.states.minute.updating) { + return [part, this.states.minute.updating]; + } + + if (value[part] !== undefined && value[part] < 10) { + return [part, `0${value[part]}`]; + } + } else if (part === "second") { + if (this.states.second.updating) { + return [part, this.states.second.updating]; + } + + if (value[part] !== undefined && value[part] < 10) { + return [part, `0${value[part]}`]; + } + } + return [part, `${value[part]}`]; + }); + + const mergedSegmentValues = [...dateValues, ...timeValues]; + this.segmentValues = Object.fromEntries(mergedSegmentValues); + this.#clearUpdating(); + return; + } + + this.segmentValues = Object.fromEntries(dateValues); + } + + readonly validationStatus = $derived.by(() => { + const value = this.value.current; + if (!value) return false as const; + + const msg = this.validate.current?.(value); + + if (msg) { + return { + reason: "custom", + message: msg, + } as const; + } + + const minValue = this.minValue.current; + if (minValue && isBefore(value, minValue)) { + return { + reason: "min", + } as const; + } + const maxValue = this.maxValue.current; + if (maxValue && isBefore(maxValue, value)) { + return { + reason: "max", + } as const; + } + return false; + }); + + readonly isInvalid = $derived.by(() => { + if (this.validationStatus === false) return false; + if (this.isInvalidProp.current) return true; + return true; + }); + + readonly inferredGranularity = $derived.by(() => { + const granularity = this.granularity.current; + if (granularity) return granularity; + const inferred = inferGranularity(this.placeholder.current, this.granularity.current); + return inferred; + }); + + readonly dateRef = $derived.by(() => + this.value.current !== undefined ? this.value.current : this.placeholder.current + ); + + readonly allSegmentContent = $derived.by(() => { + return createContent({ + segmentValues: this.segmentValues, + formatter: this.formatter, + locale: this.locale.current, + granularity: this.inferredGranularity, + dateRef: this.dateRef, + hideTimeZone: this.hideTimeZone.current, + hourCycle: this.hourCycle.current, + }); + }); + + readonly segmentContents = $derived.by(() => this.allSegmentContent.arr); + + readonly sharedSegmentAttrs = { + role: "spinbutton", + contenteditable: "true", + tabindex: 0, + spellcheck: false, + inputmode: "numeric", + autocorrect: "off", + enterkeyhint: "next", + style: { + caretColor: "transparent", + }, + }; + + #getLabelledBy(segmentId: string) { + return `${segmentId} ${this.getLabelNode()?.id ?? ""}`; + } + + updateSegment( + part: T, + cb: T extends DateSegmentPart + ? Updater + : T extends EditableTimeSegmentPart + ? Updater + : Updater + ) { + const disabled = this.disabled.current; + const readonly = this.readonly.current; + const readonlySegmentsSet = this.readonlySegmentsSet; + if (disabled || readonly || readonlySegmentsSet.has(part)) return; + + const prev = this.segmentValues; + + let newSegmentValues: SegmentValueObj = prev; + + const dateRef = this.placeholder.current; + if (isDateAndTimeSegmentObj(prev)) { + const pVal = prev[part]; + const castCb = cb as Updater; + if (part === "month") { + const next = castCb(pVal) as DateAndTimeSegmentObj["month"]; + this.states.month.updating = next; + if (next !== null && prev.day !== null) { + const date = dateRef.set({ month: Number.parseInt(next) }); + const daysInMonth = getDaysInMonth(toDate(date)); + const prevDay = Number.parseInt(prev.day); + if (prevDay > daysInMonth) { + prev.day = `${daysInMonth}`; + } + } + newSegmentValues = { ...prev, [part]: next }; + } else if (part === "dayPeriod") { + const next = castCb(pVal) as DateAndTimeSegmentObj["dayPeriod"]; + this.states.dayPeriod.updating = next; + const date = this.value.current; + if (date && "hour" in date) { + const trueHour = date.hour; + if (next === "AM") { + if (trueHour >= 12) { + prev.hour = `${trueHour - 12}`; + } + } else if (next === "PM") { + if (trueHour < 12) { + prev.hour = `${trueHour + 12}`; + } + } + } + newSegmentValues = { ...prev, [part]: next }; + } else if (part === "hour") { + const next = castCb(pVal) as DateAndTimeSegmentObj["hour"]; + this.states.hour.updating = next; + if (next !== null && prev.dayPeriod !== null) { + const dayPeriod = this.formatter.dayPeriod( + toDate(dateRef.set({ hour: Number.parseInt(next) })), + this.hourCycle.current + ); + if (dayPeriod === "AM" || dayPeriod === "PM") { + prev.dayPeriod = dayPeriod; + } + } + newSegmentValues = { ...prev, [part]: next }; + } else if (part === "minute") { + const next = castCb(pVal) as DateAndTimeSegmentObj["minute"]; + this.states.minute.updating = next; + newSegmentValues = { ...prev, [part]: next }; + } else if (part === "second") { + const next = castCb(pVal) as DateAndTimeSegmentObj["second"]; + this.states.second.updating = next; + newSegmentValues = { ...prev, [part]: next }; + } else if (part === "year") { + const next = castCb(pVal) as DateAndTimeSegmentObj["year"]; + this.states.year.updating = next; + newSegmentValues = { ...prev, [part]: next }; + } else if (part === "day") { + const next = castCb(pVal) as DateAndTimeSegmentObj["day"]; + this.states.day.updating = next; + newSegmentValues = { ...prev, [part]: next }; + } else { + const next = castCb(pVal); + newSegmentValues = { ...prev, [part]: next }; + } + } else if (isDateSegmentPart(part)) { + const pVal = prev[part]; + const castCb = cb as Updater; + const next = castCb(pVal); + if (part === "month" && next !== null && prev.day !== null) { + this.states.month.updating = next; + const date = dateRef.set({ month: Number.parseInt(next) }); + const daysInMonth = getDaysInMonth(toDate(date)); + if (Number.parseInt(prev.day) > daysInMonth) { + prev.day = `${daysInMonth}`; + } + newSegmentValues = { ...prev, [part]: next }; + } else if (part === "year") { + const next = castCb(pVal) as DateAndTimeSegmentObj["year"]; + this.states.year.updating = next; + newSegmentValues = { ...prev, [part]: next }; + } else if (part === "day") { + const next = castCb(pVal) as DateAndTimeSegmentObj["day"]; + this.states.day.updating = next; + newSegmentValues = { ...prev, [part]: next }; + } else { + newSegmentValues = { ...prev, [part]: next }; + } + } + this.segmentValues = newSegmentValues; + if (areAllSegmentsFilled(newSegmentValues, this.#fieldNode)) { + this.setValue( + getValueFromSegments({ + segmentObj: newSegmentValues, + fieldNode: this.#fieldNode, + dateRef: this.placeholder.current, + }) + ); + } else { + this.setValue(undefined); + this.segmentValues = newSegmentValues; + } + } + + handleSegmentClick(e: BitsMouseEvent) { + if (this.disabled.current) { + e.preventDefault(); + } + } + + getBaseSegmentAttrs(part: SegmentPart, segmentId: string) { + const inReadonlySegments = this.readonlySegmentsSet.has(part); + const defaultAttrs = { + "aria-invalid": boolToStrTrueOrUndef(this.isInvalid), + "aria-disabled": boolToStr(this.disabled.current), + "aria-readonly": boolToStr(this.readonly.current || inReadonlySegments), + "data-invalid": boolToEmptyStrOrUndef(this.isInvalid), + "data-disabled": boolToEmptyStrOrUndef(this.disabled.current), + "data-readonly": boolToEmptyStrOrUndef(this.readonly.current || inReadonlySegments), + "data-segment": `${part}`, + [dateFieldAttrs.segment]: "", + }; + + if (part === "literal") return defaultAttrs; + + const descriptionId = this.descriptionNode?.id; + const hasDescription = isFirstSegment(segmentId, this.#fieldNode) && descriptionId; + const errorMsgId = this.errorMessageId?.current; + + const describedBy = hasDescription + ? `${descriptionId} ${this.isInvalid && errorMsgId ? errorMsgId : ""}` + : undefined; + + const contenteditable = !( + this.readonly.current || + inReadonlySegments || + this.disabled.current + ); + + return { + ...defaultAttrs, + "aria-labelledby": this.#getLabelledBy(segmentId), + contenteditable: contenteditable ? "true" : undefined, + "aria-describedby": describedBy, + tabindex: this.disabled.current ? undefined : 0, + }; + } +} + +interface DateFieldInputStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ + name: string; + }> {} + +export class DateFieldInputState { + static create(opts: DateFieldInputStateOpts) { + return new DateFieldInputState(opts, DateFieldRootContext.get()); + } + + readonly opts: DateFieldInputStateOpts; + readonly root: DateFieldRootState; + readonly domContext: DOMContext; + readonly attachment: RefAttachment; + + constructor(opts: DateFieldInputStateOpts, root: DateFieldRootState) { + this.opts = opts; + this.root = root; + this.domContext = new DOMContext(opts.ref); + this.root.domContext = this.domContext; + this.attachment = attachRef(opts.ref, (v) => this.root.setFieldNode(v)); + + watch( + () => this.opts.name.current, + (v) => { + this.root.setName(v); + } + ); + } + + readonly #ariaDescribedBy = $derived.by(() => { + if (!isBrowser) return undefined; + const doesDescriptionExist = this.domContext.getElementById(this.root.descriptionId); + if (!doesDescriptionExist) return undefined; + return this.root.descriptionId; + }); + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + role: "group", + "aria-labelledby": this.root.getLabelNode()?.id ?? undefined, + "aria-describedby": this.#ariaDescribedBy, + "aria-disabled": boolToStr(this.root.disabled.current), + "data-invalid": this.root.isInvalid ? "" : undefined, + "data-disabled": boolToEmptyStrOrUndef(this.root.disabled.current), + [dateFieldAttrs.input]: "", + ...this.attachment, + }) as const + ); +} +export class DateFieldHiddenInputState { + static create() { + return new DateFieldHiddenInputState(DateFieldRootContext.get()); + } + + readonly root: DateFieldRootState; + readonly shouldRender = $derived.by(() => this.root.name !== ""); + readonly isoValue = $derived.by(() => + this.root.value.current ? this.root.value.current.toString() : "" + ); + + constructor(root: DateFieldRootState) { + this.root = root; + } + + readonly props = $derived.by(() => { + return { + name: this.root.name, + value: this.isoValue, + required: this.root.required.current, + }; + }); +} + +interface DateFieldLabelStateOpts extends WithRefOpts {} + +export class DateFieldLabelState { + static create(opts: DateFieldLabelStateOpts) { + return new DateFieldLabelState(opts, DateFieldRootContext.get()); + } + + readonly opts: DateFieldLabelStateOpts; + readonly root: DateFieldRootState; + readonly attachment: RefAttachment; + + constructor(opts: DateFieldLabelStateOpts, root: DateFieldRootState) { + this.opts = opts; + this.root = root; + this.onclick = this.onclick.bind(this); + this.attachment = attachRef(opts.ref, (v) => this.root.setLabelNode(v)); + } + + onclick(_: BitsMouseEvent) { + if (this.root.disabled.current) return; + const firstSegment = getFirstSegment(this.root.getFieldNode()); + if (!firstSegment) return; + firstSegment.focus(); + } + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + "data-invalid": boolToEmptyStrOrUndef(this.root.isInvalid), + "data-disabled": boolToEmptyStrOrUndef(this.root.disabled.current), + [dateFieldAttrs.label]: "", + onclick: this.onclick, + ...this.attachment, + }) as const + ); +} + +// Base class for numeric segments +abstract class BaseNumericSegmentState { + readonly opts: WithRefOpts; + readonly root: DateFieldRootState; + readonly announcer: Announcer; + readonly part: string; + readonly config: SegmentConfig; + readonly attachment: RefAttachment; + constructor(opts: WithRefOpts, root: DateFieldRootState, part: string, config: SegmentConfig) { + this.opts = opts; + this.root = root; + this.part = part; + this.config = config; + this.announcer = root.announcer; + this.onkeydown = this.onkeydown.bind(this); + this.onfocusout = this.onfocusout.bind(this); + this.attachment = attachRef(opts.ref); + } + + #getMax(): number { + return typeof this.config.max === "function" ? this.config.max(this.root) : this.config.max; + } + + #getMin(): number { + return typeof this.config.min === "function" ? this.config.min(this.root) : this.config.min; + } + + #getAnnouncement(value: number): string | number { + if (this.config.getAnnouncement) { + return this.config.getAnnouncement(value, this.root); + } + return value; + } + + #formatValue(value: number, forDisplay = true): string { + const str = String(value); + if (forDisplay && this.config.padZero && str.length === 1) { + return `0${value}`; + } + return str; + } + + onkeydown(e: BitsKeyboardEvent) { + const placeholder = this.root.value.current ?? this.root.placeholder.current; + if (e.ctrlKey || e.metaKey || this.root.disabled.current) return; + + // Special check for time segments + if ( + (this.part === "hour" || this.part === "minute" || this.part === "second") && + !(this.part in placeholder) + ) + return; + + if (e.key !== kbd.TAB) e.preventDefault(); + if (!isAcceptableSegmentKey(e.key)) return; + + if (isArrowUp(e.key)) { + this.#handleArrowUp(placeholder); + return; + } + + if (isArrowDown(e.key)) { + this.#handleArrowDown(placeholder); + return; + } + + if (isNumberString(e.key)) { + this.#handleNumberKey(e); + return; + } + + if (isBackspace(e.key)) { + this.#handleBackspace(e); + return; + } + + if (isSegmentNavigationKey(e.key)) { + handleSegmentNavigation(e, this.root.getFieldNode()); + } + } + + #handleArrowUp(placeholder: DateValue) { + const stateKey = this.part as keyof typeof this.root.states; + if (stateKey in this.root.states) { + this.root.states[stateKey].hasLeftFocus = false; + } + + // @ts-expect-error this is a part + this.root.updateSegment(this.part, (prev: string | null) => { + if (prev === null) { + const next = placeholder[this.part as keyof DateValue]; + this.announcer.announce(this.#getAnnouncement(next as number)); + return this.#formatValue(next as number); + } + const current = placeholder.set({ + [this.part]: Number.parseInt(prev), + }); + // @ts-expect-error this is a part + const next = current.cycle(this.part, this.config.cycle)[this.part as keyof DateValue]; + this.announcer.announce(this.#getAnnouncement(next as number)); + return this.#formatValue(next as number); + }); + } + + #handleArrowDown(placeholder: DateValue) { + const stateKey = this.part as keyof typeof this.root.states; + if (stateKey in this.root.states) { + this.root.states[stateKey].hasLeftFocus = false; + } + + // @ts-expect-error this is a part + this.root.updateSegment(this.part, (prev: string | null) => { + if (prev === null) { + const next = placeholder[this.part as keyof DateValue]; + this.announcer.announce(this.#getAnnouncement(next as number)); + return this.#formatValue(next as number); + } + const current = placeholder.set({ + [this.part]: Number.parseInt(prev), + }); + // @ts-expect-error this is a part + const next = current.cycle(this.part, -this.config.cycle)[this.part as keyof DateValue]; + this.announcer.announce(this.#getAnnouncement(next as number)); + return this.#formatValue(next as number); + }); + } + + #handleNumberKey(e: BitsKeyboardEvent) { + const num = Number.parseInt(e.key); + let moveToNext = false; + const max = this.#getMax(); + const maxStart = Math.floor(max / 10); + const numIsZero = num === 0; + const stateKey = this.part as keyof typeof this.root.states; + + // @ts-expect-error this is a part + this.root.updateSegment(this.part, (prev: string | null) => { + // Check if user has left focus + if (stateKey in this.root.states && this.root.states[stateKey].hasLeftFocus) { + prev = null; + this.root.states[stateKey].hasLeftFocus = false; + } + + // Starting fresh + if (prev === null) { + if (numIsZero) { + if (stateKey in this.root.states) { + this.root.states[stateKey].lastKeyZero = true; + } + this.announcer.announce("0"); + return "0"; + } + + if ( + stateKey in this.root.states && + (this.root.states[stateKey].lastKeyZero || num > maxStart) + ) { + moveToNext = true; + } + + if (stateKey in this.root.states) { + this.root.states[stateKey].lastKeyZero = false; + } + + if (moveToNext && String(num).length === 1) { + this.announcer.announce(num); + return `0${num}`; + } + + return `${num}`; + } + + // Handle special cases for segments with lastKeyZero tracking + if (stateKey in this.root.states && this.root.states[stateKey].lastKeyZero) { + if (num !== 0) { + moveToNext = true; + this.root.states[stateKey].lastKeyZero = false; + return `0${num}`; + } + + // Special handling for hour segment with 24-hour cycle + if (this.part === "hour" && num === 0 && this.root.hourCycle.current === 24) { + moveToNext = true; + this.root.states[stateKey].lastKeyZero = false; + return `00`; + } + + // Special handling for minute/second segments + if ((this.part === "minute" || this.part === "second") && num === 0) { + moveToNext = true; + this.root.states[stateKey].lastKeyZero = false; + return "00"; + } + + return prev; + } + + const total = Number.parseInt(prev + num.toString()); + + if (total > max) { + moveToNext = true; + return `0${num}`; + } + + moveToNext = true; + return `${total}`; + }); + + if (moveToNext) { + moveToNextSegment(e, this.root.getFieldNode()); + } + } + + #handleBackspace(e: BitsKeyboardEvent) { + const stateKey = this.part as keyof typeof this.root.states; + if (stateKey in this.root.states) { + this.root.states[stateKey].hasLeftFocus = false; + } + + let moveToPrev = false; + // @ts-expect-error this is a part + this.root.updateSegment(this.part, (prev: string | null) => { + if (prev === null) { + moveToPrev = true; + this.announcer.announce(null); + return null; + } + + if (prev.length === 2 && prev.startsWith("0")) { + this.announcer.announce(null); + return null; + } + + const str = prev.toString(); + if (str.length === 1) { + this.announcer.announce(null); + return null; + } + + const next = Number.parseInt(str.slice(0, -1)); + this.announcer.announce(this.#getAnnouncement(next)); + return `${next}`; + }); + + if (moveToPrev) { + moveToPrevSegment(e, this.root.getFieldNode()); + } + } + + onfocusout(_: BitsFocusEvent) { + const stateKey = this.part as keyof typeof this.root.states; + if (stateKey in this.root.states) { + this.root.states[stateKey].hasLeftFocus = true; + } + + // Pad with zero if needed + if (this.config.padZero) { + // @ts-expect-error this is a part + this.root.updateSegment(this.part, (prev: string | null) => { + if (prev && prev.length === 1) { + return `0${prev}`; + } + return prev; + }); + } + } + + getSegmentProps() { + const segmentValues = this.root.segmentValues; + const placeholder = this.root.placeholder.current; + const isEmpty = segmentValues[this.part as keyof SegmentValueObj] === null; + + let date = placeholder; + if (segmentValues[this.part as keyof SegmentValueObj]) { + date = placeholder.set({ + [this.part]: Number.parseInt( + segmentValues[this.part as keyof SegmentValueObj] as string + ), + }); + } + + const valueNow = date[this.part as keyof DateValue] as number; + const valueMin = this.#getMin(); + const valueMax = this.#getMax(); + let valueText = isEmpty ? "Empty" : `${valueNow}`; + + // Special handling for hour segment with dayPeriod + if (this.part === "hour" && "dayPeriod" in segmentValues && segmentValues.dayPeriod) { + valueText = isEmpty ? "Empty" : `${valueNow} ${segmentValues.dayPeriod}`; + } + + return { + "aria-label": `${this.part}, `, + "aria-valuemin": valueMin, + "aria-valuemax": valueMax, + "aria-valuenow": valueNow, + "aria-valuetext": valueText, + }; + } + + readonly props = $derived.by(() => { + return { + ...this.root.sharedSegmentAttrs, + id: this.opts.id.current, + ...this.getSegmentProps(), + onkeydown: this.onkeydown, + onfocusout: this.onfocusout, + onclick: this.root.handleSegmentClick, + ...this.root.getBaseSegmentAttrs(this.part as SegmentPart, this.opts.id.current), + ...this.attachment, + }; + }); +} + +class DateFieldYearSegmentState extends BaseNumericSegmentState { + #pressedKeys: string[] = []; + #backspaceCount = 0; + + constructor(opts: WithRefOpts, root: DateFieldRootState) { + super(opts, root, "year", SEGMENT_CONFIGS.year); + } + + onkeydown(e: BitsKeyboardEvent) { + if (e.ctrlKey || e.metaKey || this.root.disabled.current) return; + if (e.key !== kbd.TAB) e.preventDefault(); + if (!isAcceptableSegmentKey(e.key)) return; + + if (isArrowUp(e.key)) { + this.#resetBackspaceCount(); + super.onkeydown(e); + return; + } + + if (isArrowDown(e.key)) { + this.#resetBackspaceCount(); + super.onkeydown(e); + return; + } + + if (isNumberString(e.key)) { + this.#handleYearNumberKey(e); + return; + } + + if (isBackspace(e.key)) { + this.#handleYearBackspace(e); + return; + } + + if (isSegmentNavigationKey(e.key)) { + handleSegmentNavigation(e, this.root.getFieldNode()); + } + } + + #resetBackspaceCount() { + this.#backspaceCount = 0; + } + + #incrementBackspaceCount() { + this.#backspaceCount++; + } + + #handleYearNumberKey(e: BitsKeyboardEvent) { + this.#pressedKeys.push(e.key); + let moveToNext = false; + const num = Number.parseInt(e.key); + + this.root.updateSegment("year", (prev) => { + if (this.root.states.year.hasLeftFocus) { + prev = null; + this.root.states.year.hasLeftFocus = false; + } + + if (prev === null) { + this.announcer.announce(num); + return `000${num}`; + } + + const str = prev.toString() + num.toString(); + const mergedInt = Number.parseInt(str); + const mergedIntDigits = String(mergedInt).length; + + if (mergedIntDigits < 4) { + if ( + this.#backspaceCount > 0 && + this.#pressedKeys.length <= this.#backspaceCount && + str.length <= 4 + ) { + this.announcer.announce(mergedInt); + return str; + } + + this.announcer.announce(mergedInt); + return prependYearZeros(mergedInt); + } + + this.announcer.announce(mergedInt); + moveToNext = true; + + const mergedIntStr = `${mergedInt}`; + + if (mergedIntStr.length > 4) { + return mergedIntStr.slice(0, 4); + } + + return mergedIntStr; + }); + + if (this.#pressedKeys.length === 4 || this.#pressedKeys.length === this.#backspaceCount) { + moveToNext = true; + } + + if (moveToNext) { + moveToNextSegment(e, this.root.getFieldNode()); + } + } + + #handleYearBackspace(e: BitsKeyboardEvent) { + this.#pressedKeys = []; + this.#incrementBackspaceCount(); + let moveToPrev = false; + + this.root.updateSegment("year", (prev) => { + this.root.states.year.hasLeftFocus = false; + if (prev === null) { + moveToPrev = true; + this.announcer.announce(null); + return null; + } + const str = prev.toString(); + if (str.length === 1) { + this.announcer.announce(null); + return null; + } + const next = str.slice(0, -1); + this.announcer.announce(next); + + return `${next}`; + }); + + if (moveToPrev) { + moveToPrevSegment(e, this.root.getFieldNode()); + } + } + + onfocusout(_: BitsFocusEvent) { + this.root.states.year.hasLeftFocus = true; + this.#pressedKeys = []; + this.#resetBackspaceCount(); + this.root.updateSegment("year", (prev) => { + if (prev && prev.length !== 4) { + return prependYearZeros(Number.parseInt(prev)); + } + return prev; + }); + } +} + +class DateFieldDaySegmentState extends BaseNumericSegmentState { + constructor(opts: WithRefOpts, root: DateFieldRootState) { + super(opts, root, "day", SEGMENT_CONFIGS.day); + } +} + +class DateFieldMonthSegmentState extends BaseNumericSegmentState { + constructor(opts: WithRefOpts, root: DateFieldRootState) { + super(opts, root, "month", SEGMENT_CONFIGS.month); + } +} + +class DateFieldHourSegmentState extends BaseNumericSegmentState { + constructor(opts: WithRefOpts, root: DateFieldRootState) { + super(opts, root, "hour", SEGMENT_CONFIGS.hour); + } + + // Override to handle special hour logic + onkeydown(e: BitsKeyboardEvent) { + // Add special handling for hour display with dayPeriod + if (isNumberString(e.key)) { + const oldUpdateSegment = this.root.updateSegment.bind(this.root); + // oxlint-disable-next-line no-explicit-any + this.root.updateSegment = (part: any, cb: any) => { + const result = oldUpdateSegment(part, cb); + + // After updating hour, check if we need to display "12" instead of "0" + if (part === "hour" && "hour" in this.root.segmentValues) { + const hourValue = this.root.segmentValues.hour; + if ( + hourValue === "0" && + this.root.dayPeriodNode && + this.root.hourCycle.current !== 24 + ) { + this.root.segmentValues.hour = "12"; + } + } + + return result; + }; + } + + super.onkeydown(e); + + // Restore original updateSegment + this.root.updateSegment = this.root.updateSegment.bind(this.root); + } +} + +class DateFieldMinuteSegmentState extends BaseNumericSegmentState { + constructor(opts: WithRefOpts, root: DateFieldRootState) { + super(opts, root, "minute", SEGMENT_CONFIGS.minute); + } +} + +class DateFieldSecondSegmentState extends BaseNumericSegmentState { + constructor(opts: WithRefOpts, root: DateFieldRootState) { + super(opts, root, "second", SEGMENT_CONFIGS.second); + } +} + +// Special segments that don't extend the base class +interface DateFieldDayPeriodSegmentStateOpts extends WithRefOpts {} + +export class DateFieldDayPeriodSegmentState { + static create(opts: DateFieldDayPeriodSegmentStateOpts) { + return new DateFieldDayPeriodSegmentState(opts, DateFieldRootContext.get()); + } + + readonly opts: DateFieldDayPeriodSegmentStateOpts; + readonly root: DateFieldRootState; + readonly attachment: RefAttachment; + #announcer: Announcer; + + constructor(opts: DateFieldDayPeriodSegmentStateOpts, root: DateFieldRootState) { + this.opts = opts; + this.root = root; + this.#announcer = this.root.announcer; + this.onkeydown = this.onkeydown.bind(this); + this.attachment = attachRef(opts.ref, (v) => (this.root.dayPeriodNode = v)); + } + + onkeydown(e: BitsKeyboardEvent) { + if (e.ctrlKey || e.metaKey || this.root.disabled.current) return; + + if (e.key !== kbd.TAB) e.preventDefault(); + if (!isAcceptableDayPeriodKey(e.key)) return; + + if (isArrowUp(e.key) || isArrowDown(e.key)) { + this.root.updateSegment("dayPeriod", (prev) => { + if (prev === "AM") { + const next = "PM"; + this.#announcer.announce(next); + return next; + } + const next = "AM"; + this.#announcer.announce(next); + return next; + }); + return; + } + + if (isBackspace(e.key)) { + this.root.states.dayPeriod.hasLeftFocus = false; + this.root.updateSegment("dayPeriod", () => { + const next = "AM"; + this.#announcer.announce(next); + return next; + }); + } + + if (e.key === kbd.A || e.key === kbd.P || kbd.a || kbd.p) { + this.root.updateSegment("dayPeriod", () => { + const next = e.key === kbd.A || e.key === kbd.a ? "AM" : "PM"; + this.#announcer.announce(next); + return next; + }); + } + + if (isSegmentNavigationKey(e.key)) { + handleSegmentNavigation(e, this.root.getFieldNode()); + } + } + + readonly props = $derived.by(() => { + const segmentValues = this.root.segmentValues; + if (!("dayPeriod" in segmentValues)) return; + + const valueMin = 0; + const valueMax = 12; + const valueNow = segmentValues.dayPeriod === "AM" ? 0 : 12; + const valueText = segmentValues.dayPeriod ?? "AM"; + + return { + ...this.root.sharedSegmentAttrs, + id: this.opts.id.current, + inputmode: "text", + "aria-label": "AM/PM", + "aria-valuemin": valueMin, + "aria-valuemax": valueMax, + "aria-valuenow": valueNow, + "aria-valuetext": valueText, + onkeydown: this.onkeydown, + onclick: this.root.handleSegmentClick, + ...this.root.getBaseSegmentAttrs("dayPeriod", this.opts.id.current), + ...this.attachment, + }; + }); +} + +interface DateFieldLiteralSegmentStateOpts extends WithRefOpts {} + +export class DateFieldLiteralSegmentState { + static create(opts: DateFieldLiteralSegmentStateOpts) { + return new DateFieldLiteralSegmentState(opts, DateFieldRootContext.get()); + } + + readonly opts: DateFieldLiteralSegmentStateOpts; + readonly root: DateFieldRootState; + readonly attachment: RefAttachment; + + constructor(opts: DateFieldLiteralSegmentStateOpts, root: DateFieldRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(opts.ref); + } + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + "aria-hidden": boolToStrTrueOrUndef(true), + ...this.root.getBaseSegmentAttrs("literal", this.opts.id.current), + ...this.attachment, + }) as const + ); +} + +interface DateFieldTimeZoneSegmentStateOpts extends WithRefOpts {} + +export class DateFieldTimeZoneSegmentState { + static create(opts: DateFieldTimeZoneSegmentStateOpts) { + return new DateFieldTimeZoneSegmentState(opts, DateFieldRootContext.get()); + } + + readonly opts: DateFieldTimeZoneSegmentStateOpts; + readonly root: DateFieldRootState; + readonly attachment: RefAttachment; + + constructor(opts: DateFieldTimeZoneSegmentStateOpts, root: DateFieldRootState) { + this.opts = opts; + this.root = root; + this.onkeydown = this.onkeydown.bind(this); + this.attachment = attachRef(opts.ref); + } + + onkeydown(e: BitsKeyboardEvent) { + if (e.key !== kbd.TAB) e.preventDefault(); + if (this.root.disabled.current) return; + if (isSegmentNavigationKey(e.key)) { + handleSegmentNavigation(e, this.root.getFieldNode()); + } + } + + readonly props = $derived.by( + () => + ({ + role: "textbox", + id: this.opts.id.current, + "aria-label": "timezone, ", + style: { + caretColor: "transparent", + }, + onkeydown: this.onkeydown, + ...this.root.getBaseSegmentAttrs("timeZoneName", this.opts.id.current), + "data-readonly": boolToEmptyStrOrUndef(true), + ...this.attachment, + }) as const + ); +} + +export class DateFieldSegmentState { + static create(part: SegmentPart, opts: WithRefOpts) { + const root = DateFieldRootContext.get(); + switch (part) { + case "day": + return new DateFieldDaySegmentState(opts, root); + case "month": + return new DateFieldMonthSegmentState(opts, root); + case "year": + return new DateFieldYearSegmentState(opts, root); + case "hour": + return new DateFieldHourSegmentState(opts, root); + case "minute": + return new DateFieldMinuteSegmentState(opts, root); + case "second": + return new DateFieldSecondSegmentState(opts, root); + case "dayPeriod": + return new DateFieldDayPeriodSegmentState(opts, root); + case "literal": + return new DateFieldLiteralSegmentState(opts, root); + case "timeZoneName": + return new DateFieldTimeZoneSegmentState(opts, root); + } + } +} + +function isAcceptableDayPeriodKey(key: string) { + return ( + isAcceptableSegmentKey(key) || + key === kbd.A || + key === kbd.P || + key === kbd.a || + key === kbd.p + ); +} + +function isArrowUp(key: string) { + return key === kbd.ARROW_UP; +} + +function isArrowDown(key: string) { + return key === kbd.ARROW_DOWN; +} + +function isBackspace(key: string) { + return key === kbd.BACKSPACE; +} + +function prependYearZeros(year: number) { + const digits = String(year).length; + const diff = 4 - digits; + return `${"0".repeat(diff)}${year}`; +} diff --git a/src/uix/acel/date-field/exports.ts b/src/uix/acel/date-field/exports.ts new file mode 100644 index 000000000..de30c1b86 --- /dev/null +++ b/src/uix/acel/date-field/exports.ts @@ -0,0 +1,12 @@ +export { default as Root } from "./components/date-field.svelte"; +export { default as Input } from "./components/date-field-input.svelte"; +export { default as Label } from "./components/date-field-label.svelte"; +export { default as Segment } from "./components/date-field-segment.svelte"; + +export type { + DateFieldRootProps as RootProps, + DateFieldInputProps as InputProps, + DateFieldLabelProps as LabelProps, + DateFieldSegmentProps as SegmentProps, + // DateFieldDescriptionProps as DescriptionProps, +} from "./types.js"; diff --git a/src/uix/acel/date-field/index.ts b/src/uix/acel/date-field/index.ts new file mode 100644 index 000000000..6e742dba8 --- /dev/null +++ b/src/uix/acel/date-field/index.ts @@ -0,0 +1 @@ +export * as DateField from "./exports.js"; diff --git a/src/uix/acel/date-field/types.ts b/src/uix/acel/date-field/types.ts new file mode 100644 index 000000000..9ff7b4ffb --- /dev/null +++ b/src/uix/acel/date-field/types.ts @@ -0,0 +1,176 @@ +import type { DateValue } from "@internationalized/date"; +import type { + DateOnInvalid, + DateValidator, + EditableSegmentPart, + SegmentPart, + WithChildren, +} from "$lib/shared/index.js"; +import type { OnChangeFn, WithChild, Without } from "$lib/internal/types.js"; +import type { + BitsPrimitiveDivAttributes, + BitsPrimitiveSpanAttributes, +} from "$lib/shared/attributes.js"; +import type { Granularity } from "$lib/shared/date/types.js"; + +export type DateFieldRootPropsWithoutHTML = WithChildren<{ + /** + * The value of the date field. + * + * @bindable + */ + value?: DateValue; + + /** + * A callback that is called when the date field value changes. + * + */ + onValueChange?: OnChangeFn; + + /** + * The placeholder value of the date field. This determines the format + * and what date the field starts at when it is empty. + * + * @bindable + */ + placeholder?: DateValue; + + /** + * A callback that is called when the date field's placeholder value changes. + */ + onPlaceholderChange?: OnChangeFn; + + /** + * A function that returns a string or array of strings as validation errors if the date is + * invalid, or nothing if the date is valid + */ + validate?: DateValidator; + + /** + * A callback fired when the date field's value is invalid. Use this to display an error + * message to the user. + */ + onInvalid?: DateOnInvalid; + + /** + * The minimum acceptable date. When provided, the date field + * will be marked as invalid if the user enters a date before this date. + */ + minValue?: DateValue; + + /** + * The maximum acceptable date. When provided, the date field + * will be marked as invalid if the user enters a date after this date. + */ + maxValue?: DateValue; + + /** + * If true, the date field will be disabled and users will not be able + * to interact with it. This also disables the hidden input element if + * the date field is used in a form. + * + * @defaultValue false + */ + disabled?: boolean; + + /** + * If true, the date field will be readonly, and users will not be able to + * edit the values of any of the individual segments. + * + * @defaultValue false + */ + readonly?: boolean; + + /** + * An array of segment names that should be readonly. If provided, only the + * segments not in this array will be editable. + */ + readonlySegments?: EditableSegmentPart[]; + + /** + * The format to use for displaying the time in the input. + * If using a 12 hour clock, ensure you also include the `dayPeriod` + * segment in your input to ensure the user can select AM/PM. + * + * @defaultValue the locale's default time format + */ + hourCycle?: 12 | 24; + + /** + * The locale to use for formatting the date field. + * + * @defaultValue 'en' + */ + locale?: string; + + /** + * The granularity of the date field. This determines which + * segments will be includes in the segments array used to + * build the date field. + * + * By default, when a `CalendarDate` value is used, the granularity + * will default to `'day'`, and when a `CalendarDateTime` or `ZonedDateTime` + * value is used, the granularity will default to `'minute'`. + * + * Granularity is only used for visual purposes, and does not impact + * the value of the date field. You can have the same value synced + * between multiple date fields with different granularities and they + * will all contain the same value. + * + * @defaultValue 'day' + */ + granularity?: Granularity; + + /** + * Whether or not to hide the timeZoneName segment from the date field. + * + * @defaultValue false; + */ + hideTimeZone?: boolean; + + /** + * Whether or not the hidden input of the date field requires a value + * to be submitted. + * + * @defaultValue false + */ + required?: boolean; + + /** + * The `id` of the element which contains the error messages for the date field when the + * date is invalid. + */ + errorMessageId?: string; +}>; + +export type DateFieldRootProps = DateFieldRootPropsWithoutHTML; + +export type DateFieldInputSnippetProps = { segments: Array<{ part: SegmentPart; value: string }> }; + +export type DateFieldInputPropsWithoutHTML = WithChild< + { + /** + * The name to use for the hidden input element of the date field, + * which is used to submit the ISO string value of the date field + * to a server. If not provided, the hidden input element will not + * be rendered. + */ + name?: string; + }, + DateFieldInputSnippetProps +>; + +export type DateFieldInputProps = DateFieldInputPropsWithoutHTML & + Without; + +export type DateFieldSegmentPropsWithoutHTML = WithChild<{ + part: SegmentPart; +}>; + +export type DateFieldSegmentProps = DateFieldSegmentPropsWithoutHTML & + Without; + +export type DateFieldLabelPropsWithoutHTML = WithChild; + +export type DateFieldLabelProps = DateFieldLabelPropsWithoutHTML & + Without; diff --git a/src/uix/acel/dialog/components/dialog-close.svelte b/src/uix/acel/dialog/components/dialog-close.svelte index 1717d376b..b1877503d 100644 --- a/src/uix/acel/dialog/components/dialog-close.svelte +++ b/src/uix/acel/dialog/components/dialog-close.svelte @@ -2,7 +2,7 @@ import { boxWith, mergeProps } from "../../utilities/index"; import { DialogCloseState } from "../dialog.svelte"; import type { DialogCloseProps } from "../types"; - import { createId } from "../../internal/create-id"; + import { createId } from "../../utilities/create-id"; const uid = $props.id(); diff --git a/src/uix/acel/dialog/components/dialog-content.svelte b/src/uix/acel/dialog/components/dialog-content.svelte index 52c686c93..900eb2095 100644 --- a/src/uix/acel/dialog/components/dialog-content.svelte +++ b/src/uix/acel/dialog/components/dialog-content.svelte @@ -2,8 +2,8 @@ import { boxWith, mergeProps, afterSleep } from "../../utilities/index"; import { DialogContentState } from "../dialog.svelte"; import type { DialogContentProps } from "../types"; - import { createId } from "../../internal/create-id"; - import { noop } from "../../internal/noop"; + import { createId } from "../../utilities/create-id"; + import { noop } from "../../utilities/noop"; const uid = $props.id(); diff --git a/src/uix/acel/dialog/components/dialog-description.svelte b/src/uix/acel/dialog/components/dialog-description.svelte index d0199a444..7564f3b8a 100644 --- a/src/uix/acel/dialog/components/dialog-description.svelte +++ b/src/uix/acel/dialog/components/dialog-description.svelte @@ -2,7 +2,7 @@ import { boxWith, mergeProps } from "../../utilities/index"; import { DialogDescriptionState } from "../dialog.svelte"; import type { DialogDescriptionProps } from "../types"; - import { createId } from "../../internal/create-id"; + import { createId } from "../../utilities/create-id"; const uid = $props.id(); diff --git a/src/uix/acel/dialog/components/dialog-overlay.svelte b/src/uix/acel/dialog/components/dialog-overlay.svelte index ffed85a05..c6991ba09 100644 --- a/src/uix/acel/dialog/components/dialog-overlay.svelte +++ b/src/uix/acel/dialog/components/dialog-overlay.svelte @@ -2,7 +2,7 @@ import { boxWith, mergeProps } from "../../utilities/index"; import { DialogOverlayState } from "../dialog.svelte"; import type { DialogOverlayProps } from "../types"; - import { createId } from "../../internal/create-id"; + import { createId } from "../../utilities/create-id"; const uid = $props.id(); diff --git a/src/uix/acel/dialog/components/dialog-title.svelte b/src/uix/acel/dialog/components/dialog-title.svelte index 898cbc2ad..4edca5087 100644 --- a/src/uix/acel/dialog/components/dialog-title.svelte +++ b/src/uix/acel/dialog/components/dialog-title.svelte @@ -2,7 +2,7 @@ import { boxWith, mergeProps } from "../../utilities/index"; import { DialogTitleState } from "../dialog.svelte"; import type { DialogTitleProps } from "../types"; - import { createId } from "../../internal/create-id"; + import { createId } from "../../utilities/create-id"; const uid = $props.id(); diff --git a/src/uix/acel/dialog/components/dialog-trigger.svelte b/src/uix/acel/dialog/components/dialog-trigger.svelte index 30c2c09b6..bc82926e6 100644 --- a/src/uix/acel/dialog/components/dialog-trigger.svelte +++ b/src/uix/acel/dialog/components/dialog-trigger.svelte @@ -2,7 +2,7 @@ import { boxWith, mergeProps } from "../../utilities/index"; import { DialogTriggerState } from "../dialog.svelte"; import type { DialogTriggerProps } from "../types"; - import { createId } from "../../internal/create-id"; + import { createId } from "../../utilities/create-id"; const uid = $props.id(); diff --git a/src/uix/acel/dialog/components/dialog.svelte b/src/uix/acel/dialog/components/dialog.svelte index 80d7d12aa..1cdb20648 100644 --- a/src/uix/acel/dialog/components/dialog.svelte +++ b/src/uix/acel/dialog/components/dialog.svelte @@ -2,7 +2,7 @@ import { boxWith } from "../../utilities/index"; import { DialogRootState } from "../dialog.svelte"; import type { DialogRootProps } from "../types"; - import { noop } from "../../internal/noop"; + import { noop } from "../../utilities/noop"; let { open = $bindable(false), diff --git a/src/uix/acel/dialog/dialog.svelte.ts b/src/uix/acel/dialog/dialog.svelte.ts index b174616d7..3024d34b9 100644 --- a/src/uix/acel/dialog/dialog.svelte.ts +++ b/src/uix/acel/dialog/dialog.svelte.ts @@ -11,16 +11,16 @@ import { boolToStr, getDataOpenClosed, boolToEmptyStrOrUndef, -} from "../internal/attrs"; +} from "../utilities/attrs"; import type { BitsKeyboardEvent, BitsMouseEvent, OnChangeFn, RefAttachment, WithRefOpts, -} from "../internal/types"; -import { kbd } from "../internal/kbd"; -import { PresenceManager } from "../internal/presence-manager.svelte"; +} from "../utilities/types"; +import { kbd } from "../utilities/keyboard/kbd"; +import { PresenceManager } from "../utilities/presence/presence-manager.svelte"; type DialogVariant = "alert-dialog" | "dialog"; diff --git a/src/uix/acel/dialog/types.ts b/src/uix/acel/dialog/types.ts index 1be13cbd9..0f03c0e4b 100644 --- a/src/uix/acel/dialog/types.ts +++ b/src/uix/acel/dialog/types.ts @@ -8,7 +8,7 @@ import type { import type { AcelPrimitiveButtonAttributes, AcelPrimitiveDivAttributes, -} from "../shared/attributes"; +} from "../utilities/attributes"; import type { PortalProps } from "../comn/portal/types"; // ═══════════════════════════════════════════════════════════════════════════════ diff --git a/src/uix/acel/dropdown-menu/components/dropdown-menu-content.svelte b/src/uix/acel/dropdown-menu/components/dropdown-menu-content.svelte new file mode 100644 index 000000000..781933e80 --- /dev/null +++ b/src/uix/acel/dropdown-menu/components/dropdown-menu-content.svelte @@ -0,0 +1,101 @@ + + + + {#snippet popper({ props, wrapperProps })} + {@const finalProps = mergeProps( + contentState.props, + props, + { style: getFloatingContentCSSVars("dropdown-menu") }, + style ? { style } : {} + )} + {#if child} + {@render child({ props: finalProps, wrapperProps, ...contentState.snippetProps })} + {:else} +
+
+ {@render children?.()} +
+
+ {/if} + {/snippet} +
diff --git a/src/uix/acel/dropdown-menu/components/dropdown-menu.svelte b/src/uix/acel/dropdown-menu/components/dropdown-menu.svelte new file mode 100644 index 000000000..5f6a727b2 --- /dev/null +++ b/src/uix/acel/dropdown-menu/components/dropdown-menu.svelte @@ -0,0 +1,8 @@ + + + diff --git a/src/uix/acel/dropdown-menu/exports.ts b/src/uix/acel/dropdown-menu/exports.ts new file mode 100644 index 000000000..24a7e658e --- /dev/null +++ b/src/uix/acel/dropdown-menu/exports.ts @@ -0,0 +1,37 @@ +export { default as Root } from "./components/dropdown-menu.svelte"; +export { default as Content } from "./components/dropdown-menu-content.svelte"; + +// Shared menu components +export { default as Sub } from "../menu/components/menu-sub.svelte"; +export { default as Trigger } from "../menu/components/menu-trigger.svelte"; +export { default as Item } from "../menu/components/menu-item.svelte"; +export { default as Group } from "../menu/components/menu-group.svelte"; +export { default as GroupHeading } from "../menu/components/menu-group-heading.svelte"; +export { default as Arrow } from "../menu/components/menu-arrow.svelte"; +export { default as RadioItem } from "../menu/components/menu-radio-item.svelte"; +export { default as Separator } from "../menu/components/menu-separator.svelte"; +export { default as RadioGroup } from "../menu/components/menu-radio-group.svelte"; +export { default as SubContent } from "../menu/components/menu-sub-content.svelte"; +export { default as SubTrigger } from "../menu/components/menu-sub-trigger.svelte"; +export { default as CheckboxItem } from "../menu/components/menu-checkbox-item.svelte"; +export { default as CheckboxGroup } from "../menu/components/menu-checkbox-group.svelte"; + +export type { + DropdownMenuArrowProps as ArrowProps, + DropdownMenuCheckboxItemProps as CheckboxItemProps, + DropdownMenuContentProps as ContentProps, + DropdownMenuContentStaticProps as ContentStaticProps, + DropdownMenuGroupProps as GroupProps, + DropdownMenuItemProps as ItemProps, + DropdownMenuGroupHeadingProps as GroupHeadingProps, + DropdownMenuRootProps as RootProps, + DropdownMenuRadioGroupProps as RadioGroupProps, + DropdownMenuRadioItemProps as RadioItemProps, + DropdownMenuSeparatorProps as SeparatorProps, + DropdownMenuSubContentProps as SubContentProps, + DropdownMenuSubContentStaticProps as SubContentStaticProps, + DropdownMenuSubProps as SubProps, + DropdownMenuSubTriggerProps as SubTriggerProps, + DropdownMenuTriggerProps as TriggerProps, + DropdownMenuCheckboxGroupProps as CheckboxGroupProps, +} from "./types"; diff --git a/src/uix/acel/dropdown-menu/index.ts b/src/uix/acel/dropdown-menu/index.ts new file mode 100644 index 000000000..fd0b17987 --- /dev/null +++ b/src/uix/acel/dropdown-menu/index.ts @@ -0,0 +1 @@ +export * from "./exports"; diff --git a/src/uix/acel/dropdown-menu/types.ts b/src/uix/acel/dropdown-menu/types.ts new file mode 100644 index 000000000..93af3c2df --- /dev/null +++ b/src/uix/acel/dropdown-menu/types.ts @@ -0,0 +1,35 @@ +export type { + MenuArrowProps as DropdownMenuArrowProps, + MenuCheckboxItemProps as DropdownMenuCheckboxItemProps, + MenuContentProps as DropdownMenuContentProps, + MenuContentStaticProps as DropdownMenuContentStaticProps, + MenuGroupProps as DropdownMenuGroupProps, + MenuItemProps as DropdownMenuItemProps, + MenuGroupHeadingProps as DropdownMenuGroupHeadingProps, + MenuRootProps as DropdownMenuRootProps, + MenuRadioGroupProps as DropdownMenuRadioGroupProps, + MenuRadioItemProps as DropdownMenuRadioItemProps, + MenuSeparatorProps as DropdownMenuSeparatorProps, + MenuSubContentProps as DropdownMenuSubContentProps, + MenuSubContentStaticProps as DropdownMenuSubContentStaticProps, + MenuSubProps as DropdownMenuSubProps, + MenuSubTriggerProps as DropdownMenuSubTriggerProps, + MenuTriggerProps as DropdownMenuTriggerProps, + MenuCheckboxGroupProps as DropdownMenuCheckboxGroupProps, + MenuArrowPropsWithoutHTML as DropdownMenuArrowPropsWithoutHTML, + MenuCheckboxItemPropsWithoutHTML as DropdownMenuCheckboxItemPropsWithoutHTML, + MenuContentPropsWithoutHTML as DropdownMenuContentPropsWithoutHTML, + MenuContentStaticPropsWithoutHTML as DropdownMenuContentStaticPropsWithoutHTML, + MenuGroupPropsWithoutHTML as DropdownMenuGroupPropsWithoutHTML, + MenuItemPropsWithoutHTML as DropdownMenuItemPropsWithoutHTML, + MenuGroupHeadingPropsWithoutHTML as DropdownMenuGroupHeadingPropsWithoutHTML, + MenuRootPropsWithoutHTML as DropdownMenuRootPropsWithoutHTML, + MenuRadioGroupPropsWithoutHTML as DropdownMenuRadioGroupPropsWithoutHTML, + MenuRadioItemPropsWithoutHTML as DropdownMenuRadioItemPropsWithoutHTML, + MenuSeparatorPropsWithoutHTML as DropdownMenuSeparatorPropsWithoutHTML, + MenuSubPropsWithoutHTML as DropdownMenuSubPropsWithoutHTML, + MenuSubTriggerPropsWithoutHTML as DropdownMenuSubTriggerPropsWithoutHTML, + MenuSubContentPropsWithoutHTML as DropdownMenuSubContentPropsWithoutHTML, + MenuSubContentStaticPropsWithoutHTML as DropdownMenuSubContentStaticPropsWithoutHTML, + MenuCheckboxGroupPropsWithoutHTML as DropdownMenuCheckboxGroupPropsWithoutHTML, +} from "../menu/types"; diff --git a/src/uix/acel/field/components/Field.Control.svelte b/src/uix/acel/field/components/Field.Control.svelte new file mode 100644 index 000000000..593e59209 --- /dev/null +++ b/src/uix/acel/field/components/Field.Control.svelte @@ -0,0 +1,31 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/acel/field/components/Field.Error.svelte b/src/uix/acel/field/components/Field.Error.svelte new file mode 100644 index 000000000..af165a1f3 --- /dev/null +++ b/src/uix/acel/field/components/Field.Error.svelte @@ -0,0 +1,36 @@ + + +{#if errorState.root.isError} + {#if child} + {@render child({ props: mergedProps })} + {:else} +

+ {@render children?.()} +

+ {/if} +{:else} + + +{/if} diff --git a/src/uix/acel/field/components/Field.HelperText.svelte b/src/uix/acel/field/components/Field.HelperText.svelte new file mode 100644 index 000000000..820722545 --- /dev/null +++ b/src/uix/acel/field/components/Field.HelperText.svelte @@ -0,0 +1,30 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +

+ {@render children?.()} +

+{/if} diff --git a/src/uix/acel/field/components/Field.Input.svelte b/src/uix/acel/field/components/Field.Input.svelte new file mode 100644 index 000000000..d6a852ac5 --- /dev/null +++ b/src/uix/acel/field/components/Field.Input.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/acel/field/components/Field.Label.svelte b/src/uix/acel/field/components/Field.Label.svelte new file mode 100644 index 000000000..46ed7967e --- /dev/null +++ b/src/uix/acel/field/components/Field.Label.svelte @@ -0,0 +1,30 @@ + + + + {#if child} + {@render child({ props: restProps })} + {:else} + {@render children?.()} + {#if root.isRequired} + + {/if} + {/if} + \ No newline at end of file diff --git a/src/uix/acel/field/components/Field.Prefix.svelte b/src/uix/acel/field/components/Field.Prefix.svelte new file mode 100644 index 000000000..7f5b5d39d --- /dev/null +++ b/src/uix/acel/field/components/Field.Prefix.svelte @@ -0,0 +1,30 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + + {@render children?.()} + +{/if} diff --git a/src/uix/acel/field/components/Field.Root.svelte b/src/uix/acel/field/components/Field.Root.svelte new file mode 100644 index 000000000..35fb10da2 --- /dev/null +++ b/src/uix/acel/field/components/Field.Root.svelte @@ -0,0 +1,39 @@ + + +{#if child} + {@render child({ props: mergedProps, ...rootState.snippetProps })} +{:else} +
+ {@render children?.(rootState.snippetProps)} +
+{/if} diff --git a/src/uix/acel/field/components/Field.Suffix.svelte b/src/uix/acel/field/components/Field.Suffix.svelte new file mode 100644 index 000000000..5dbcec46c --- /dev/null +++ b/src/uix/acel/field/components/Field.Suffix.svelte @@ -0,0 +1,30 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + + {@render children?.()} + +{/if} diff --git a/src/uix/acel/field/exports.ts b/src/uix/acel/field/exports.ts new file mode 100644 index 000000000..6f3e82a6c --- /dev/null +++ b/src/uix/acel/field/exports.ts @@ -0,0 +1,22 @@ +export { default as Root } from "./components/Field.Root.svelte"; +export { default as Label } from "./components/Field.Label.svelte"; +export { default as Control } from "./components/Field.Control.svelte"; +export { default as Input } from "./components/Field.Input.svelte"; +export { default as HelperText } from "./components/Field.HelperText.svelte"; +export { default as Error } from "./components/Field.Error.svelte"; +export { default as Prefix } from "./components/Field.Prefix.svelte"; +export { default as Suffix } from "./components/Field.Suffix.svelte"; + +export type { + FieldRootProps as RootProps, + FieldLabelProps as LabelProps, + FieldControlProps as ControlProps, + FieldInputProps as InputProps, + FieldHelperTextProps as HelperTextProps, + FieldErrorProps as ErrorProps, + FieldPrefixProps as PrefixProps, + FieldSuffixProps as SuffixProps, + FieldState, + FieldInputType, + FieldRootSnippetProps, +} from "./types"; diff --git a/src/uix/acel/field/field.svelte.ts b/src/uix/acel/field/field.svelte.ts new file mode 100644 index 000000000..ace955193 --- /dev/null +++ b/src/uix/acel/field/field.svelte.ts @@ -0,0 +1,340 @@ +import { + attachRef, + type ReadableBoxedValues, + type WritableBoxedValues, +} from "../utilities/index"; +import { Context } from "runed"; +import { createAcelAttrs, boolToEmptyStrOrUndef } from "../utilities/attrs"; +import type { WithRefOpts, RefAttachment, OnChangeFn } from "../utilities/types.ts"; +import type { FieldState, FieldInputType } from "./types"; + +// ─── Attrs ──────────────────────────────────────────────────────────────────── + +const fieldAttrs = createAcelAttrs({ + component: "field", + parts: ["root", "label", "control", "input", "helper-text", "error", "prefix", "suffix"] as const, +}); + +// ─── Context ────────────────────────────────────────────────────────────────── + +export const FieldRootContext = new Context("Field.Root"); + +// ─── Root ───────────────────────────────────────────────────────────────────── + +interface FieldRootStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ + state: FieldState; + disabled: boolean; + readonly: boolean; + required: boolean; + }> {} + +export class FieldRootState { + static create(opts: FieldRootStateOpts): FieldRootState { + return FieldRootContext.set(new FieldRootState(opts)); + } + + readonly opts: FieldRootStateOpts; + readonly attachment: RefAttachment; + + // IDs derivados del id base + readonly inputId = $derived.by(() => `${this.opts.id.current}-input`); + readonly errorId = $derived.by(() => `${this.opts.id.current}-error`); + readonly helperId = $derived.by(() => `${this.opts.id.current}-helper`); + + // Estado derivado: props directas tienen precedencia sobre state + readonly isDisabled = $derived.by( + () => this.opts.disabled.current || this.opts.state.current === "disabled" + ); + readonly isReadonly = $derived.by( + () => this.opts.readonly.current || this.opts.state.current === "readonly" + ); + readonly isError = $derived.by(() => this.opts.state.current === "error"); + readonly isLoading = $derived.by(() => this.opts.state.current === "loading"); + readonly isRequired = $derived.by(() => this.opts.required.current); + + constructor(opts: FieldRootStateOpts) { + this.opts = opts; + this.attachment = attachRef(opts.ref); + } + + readonly snippetProps = $derived.by(() => ({ + state: this.opts.state.current, + isError: this.isError, + isDisabled: this.isDisabled, + isReadonly: this.isReadonly, + isLoading: this.isLoading, + isRequired: this.isRequired, + })); + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + "data-state": this.opts.state.current, + "data-disabled": boolToEmptyStrOrUndef(this.isDisabled), + "data-readonly": boolToEmptyStrOrUndef(this.isReadonly), + "data-required": boolToEmptyStrOrUndef(this.isRequired), + [fieldAttrs.root]: "", + ...this.attachment, + }) as const + ); +} + +// ─── Label ──────────────────────────────────────────────────────────────────── + +interface FieldLabelStateOpts extends WithRefOpts {} + +export class FieldLabelState { + static create(opts: FieldLabelStateOpts): FieldLabelState { + return new FieldLabelState(opts, FieldRootContext.get()); + } + + readonly opts: FieldLabelStateOpts; + readonly root: FieldRootState; + readonly attachment: RefAttachment; + + constructor(opts: FieldLabelStateOpts, root: FieldRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(opts.ref as any); + } + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + for: this.root.inputId, + "data-disabled": boolToEmptyStrOrUndef(this.root.isDisabled), + "data-error": boolToEmptyStrOrUndef(this.root.isError), + "data-required": boolToEmptyStrOrUndef(this.root.isRequired), + [fieldAttrs.label]: "", + ...this.attachment, + }) as const + ); +} + +// ─── Control ────────────────────────────────────────────────────────────────── + +interface FieldControlStateOpts extends WithRefOpts {} + +export class FieldControlState { + static create(opts: FieldControlStateOpts): FieldControlState { + return new FieldControlState(opts, FieldRootContext.get()); + } + + readonly opts: FieldControlStateOpts; + readonly root: FieldRootState; + readonly attachment: RefAttachment; + + constructor(opts: FieldControlStateOpts, root: FieldRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(opts.ref); + } + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + "data-state": this.root.opts.state.current, + "data-disabled": boolToEmptyStrOrUndef(this.root.isDisabled), + [fieldAttrs.control]: "", + ...this.attachment, + }) as const + ); +} + +// ─── Input ──────────────────────────────────────────────────────────────────── + +interface FieldInputStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ + type: FieldInputType; + onValueChange: OnChangeFn; + }>, + WritableBoxedValues<{ value: string }> {} + +export class FieldInputState { + static create(opts: FieldInputStateOpts): FieldInputState { + return new FieldInputState(opts, FieldRootContext.get()); + } + + readonly opts: FieldInputStateOpts; + readonly root: FieldRootState; + readonly attachment: RefAttachment; + + constructor(opts: FieldInputStateOpts, root: FieldRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(opts.ref as any); + } + + readonly #describedBy = $derived.by(() => + [ + this.root.helperId, + this.root.isError ? this.root.errorId : null, + ] + .filter(Boolean) + .join(" ") + ); + + oninput = (e: Event & { currentTarget: HTMLInputElement }) => { + if (this.root.isDisabled || this.root.isReadonly) return; + const value = e.currentTarget.value; + this.opts.value.current = value; + this.opts.onValueChange.current(value); + }; + + readonly props = $derived.by( + () => + ({ + id: this.root.inputId, + type: this.opts.type.current, + value: this.opts.value.current, + disabled: this.root.isDisabled, + readonly: this.root.isReadonly, + required: this.root.isRequired, + "aria-invalid": this.root.isError ? ("true" as const) : undefined, + "aria-describedby": this.#describedBy || undefined, + "aria-busy": this.root.isLoading ? ("true" as const) : undefined, + "data-state": this.root.opts.state.current, + oninput: this.oninput, + [fieldAttrs.input]: "", + ...this.attachment, + }) as const + ); +} + +// ─── HelperText ─────────────────────────────────────────────────────────────── + +interface FieldHelperTextStateOpts extends WithRefOpts {} + +export class FieldHelperTextState { + static create(opts: FieldHelperTextStateOpts): FieldHelperTextState { + return new FieldHelperTextState(opts, FieldRootContext.get()); + } + + readonly opts: FieldHelperTextStateOpts; + readonly root: FieldRootState; + readonly attachment: RefAttachment; + + constructor(opts: FieldHelperTextStateOpts, root: FieldRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(opts.ref); + } + + readonly props = $derived.by( + () => + ({ + id: this.root.helperId, + "data-disabled": boolToEmptyStrOrUndef(this.root.isDisabled), + [fieldAttrs["helper-text"]]: "", + ...this.attachment, + }) as const + ); +} + +// ─── Error ──────────────────────────────────────────────────────────────────── + +interface FieldErrorStateOpts extends WithRefOpts {} + +export class FieldErrorState { + static create(opts: FieldErrorStateOpts): FieldErrorState { + return new FieldErrorState(opts, FieldRootContext.get()); + } + + readonly opts: FieldErrorStateOpts; + readonly root: FieldRootState; + readonly attachment: RefAttachment; + + constructor(opts: FieldErrorStateOpts, root: FieldRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(opts.ref); + } + + readonly props = $derived.by( + () => + ({ + id: this.root.errorId, + role: "alert" as const, + "aria-live": "polite" as const, + "data-error": "", + [fieldAttrs.error]: "", + ...this.attachment, + }) as const + ); + + // El nodo fantasma mantiene el id en el DOM cuando no hay error + readonly ghostProps = $derived.by( + () => + ({ + id: this.root.errorId, + hidden: true, + "aria-hidden": "true" as const, + }) as const + ); +} + +// ─── Prefix ─────────────────────────────────────────────────────────────────── + +interface FieldPrefixStateOpts extends WithRefOpts {} + +export class FieldPrefixState { + static create(opts: FieldPrefixStateOpts): FieldPrefixState { + return new FieldPrefixState(opts, FieldRootContext.get()); + } + + readonly opts: FieldPrefixStateOpts; + readonly root: FieldRootState; + readonly attachment: RefAttachment; + + constructor(opts: FieldPrefixStateOpts, root: FieldRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(opts.ref); + } + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + "aria-hidden": "true" as const, + [fieldAttrs.prefix]: "", + ...this.attachment, + }) as const + ); +} + +// ─── Suffix ─────────────────────────────────────────────────────────────────── + +interface FieldSuffixStateOpts extends WithRefOpts {} + +export class FieldSuffixState { + static create(opts: FieldSuffixStateOpts): FieldSuffixState { + return new FieldSuffixState(opts, FieldRootContext.get()); + } + + readonly opts: FieldSuffixStateOpts; + readonly root: FieldRootState; + readonly attachment: RefAttachment; + + constructor(opts: FieldSuffixStateOpts, root: FieldRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(opts.ref); + } + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + "aria-hidden": "true" as const, + [fieldAttrs.suffix]: "", + ...this.attachment, + }) as const + ); +} diff --git a/src/uix/acel/field/index.ts b/src/uix/acel/field/index.ts new file mode 100644 index 000000000..fd0b17987 --- /dev/null +++ b/src/uix/acel/field/index.ts @@ -0,0 +1 @@ +export * from "./exports"; diff --git a/src/uix/acel/field/types.ts b/src/uix/acel/field/types.ts new file mode 100644 index 000000000..8ce4af4f3 --- /dev/null +++ b/src/uix/acel/field/types.ts @@ -0,0 +1,103 @@ +import type { WithChild, Without, OnChangeFn } from "../utilities/types"; +import type { + AcelPrimitiveDivAttributes, + AcelPrimitiveLabelAttributes, + AcelPrimitiveInputAttributes, + AcelPrimitiveParagraphAttributes, + AcelPrimitiveSpanAttributes, +} from "../utilities/attributes"; + +// ─── Shared ─────────────────────────────────────────────────────────────────── + +export type FieldState = "idle" | "error" | "disabled" | "readonly" | "loading"; + +export type FieldInputType = + | "text" + | "email" + | "password" + | "search" + | "tel" + | "url"; + +// ─── Root ───────────────────────────────────────────────────────────────────── + +export type FieldRootSnippetProps = { + state: FieldState; + isError: boolean; + isDisabled: boolean; + isReadonly: boolean; + isLoading: boolean; + isRequired: boolean; +}; + +type BaseFieldRootProps = { + state?: FieldState; + disabled?: boolean; + readonly?: boolean; + required?: boolean; +}; + +export type FieldRootPropsWithoutHTML = WithChild< + BaseFieldRootProps, + FieldRootSnippetProps, + HTMLDivElement +>; + +export type FieldRootProps = FieldRootPropsWithoutHTML & + Without; + +// ─── Label ──────────────────────────────────────────────────────────────────── + +export type FieldLabelPropsWithoutHTML = WithChild<{}, {}, HTMLLabelElement>; + +export type FieldLabelProps = FieldLabelPropsWithoutHTML & + Without; + +// ─── Control ────────────────────────────────────────────────────────────────── + +export type FieldControlPropsWithoutHTML = WithChild<{}, {}, HTMLDivElement>; + +export type FieldControlProps = FieldControlPropsWithoutHTML & + Without; + +// ─── Input ──────────────────────────────────────────────────────────────────── + +type BaseFieldInputProps = { + type?: FieldInputType; + onValueChange?: OnChangeFn; +}; + +export type FieldInputPropsWithoutHTML = WithChild< + BaseFieldInputProps, + {}, + HTMLInputElement +>; + +export type FieldInputProps = FieldInputPropsWithoutHTML & + Without; + +// ─── HelperText ─────────────────────────────────────────────────────────────── + +export type FieldHelperTextPropsWithoutHTML = WithChild<{}, {}, HTMLParagraphElement>; + +export type FieldHelperTextProps = FieldHelperTextPropsWithoutHTML & + Without; + +// ─── Error ──────────────────────────────────────────────────────────────────── + +export type FieldErrorPropsWithoutHTML = WithChild<{}, {}, HTMLParagraphElement>; + +export type FieldErrorProps = FieldErrorPropsWithoutHTML & + Without; + +// ─── Prefix / Suffix ────────────────────────────────────────────────────────── + +export type FieldPrefixPropsWithoutHTML = WithChild<{}, {}, HTMLSpanElement>; + +export type FieldPrefixProps = FieldPrefixPropsWithoutHTML & + Without; + +export type FieldSuffixPropsWithoutHTML = WithChild<{}, {}, HTMLSpanElement>; + +export type FieldSuffixProps = FieldSuffixPropsWithoutHTML & + Without; diff --git a/src/uix/acel/index.ts b/src/uix/acel/index.ts index 28a5bcf1b..afe85394f 100644 --- a/src/uix/acel/index.ts +++ b/src/uix/acel/index.ts @@ -22,11 +22,41 @@ export { ToastHost } from './toast'; export { Modal, ModalHost, Drawer } from './modal'; // Primitives +export * as AspectRatio from './aspect-ratio'; +export * as Avatar from './avatar'; export * as Accordion from './accordion'; export * as Button from './button'; +export * as Checkbox from './checkbox'; export * as Collapsible from './collapsible'; +export * as Label from './label'; +export * as Meter from './meter'; +export * as Pagination from './pagination'; +export * as Progress from './progress'; +export * as RadioGroup from './radio-group'; +export * as RatingGroup from './rating-group'; +export * as Separator from './separator'; +export * as Switch from './switch'; export * as Tabs from './tabs'; +export * as Toggle from './toggle'; +export * as ToggleGroup from './toggle-group'; +export * as PinInput from './pin-input'; +export * as ScrollArea from './scroll-area'; +export * as Slider from './slider'; +export * as Toolbar from './toolbar'; + +// Floating +export * as Tooltip from './tooltip'; +export * as Popover from './popover'; +export * as DropdownMenu from './dropdown-menu'; +export * as ContextMenu from './context-menu'; +export * as Menubar from './menubar'; + +// Command +export * as Command from './command'; // Overlay export * as AlertDialog from './alert-dialog'; export * as Dialog from './dialog'; + +// Date +export * as Calendar from './calendar'; diff --git a/src/uix/acel/internal/types.ts b/src/uix/acel/internal/types.ts deleted file mode 100644 index 818435b44..000000000 --- a/src/uix/acel/internal/types.ts +++ /dev/null @@ -1,94 +0,0 @@ -// oxlint-disable no-explicit-any -import type { Snippet } from "svelte"; - -import type { attachRef, Box, ReadableBoxedValues, WritableBoxedValues } from "../utilities"; -import type { StyleProperties } from "../shared/index.ts"; - -export type OnChangeFn = (value: T) => void; - -export type ElementRef = Box; - -export type WithChild< - /** - * The props that the component accepts. - */ - Props extends Record = {}, - /** - * The props that are passed to the `child` and `children` snippets. The `ElementProps` are - * merged with these props for the `child` snippet. - */ - SnippetProps extends Record = { _default: never }, - /** - * The underlying DOM element being rendered. You can bind to this prop to - * programmatically interact with the element. - */ - Ref = HTMLElement, -> = Omit & { - child?: SnippetProps extends { _default: never } - ? Snippet<[{ props: Record }]> - : Snippet<[SnippetProps & { props: Record }]>; - children?: SnippetProps extends { _default: never } ? Snippet : Snippet<[SnippetProps]>; - style?: StyleProperties | string | null | undefined; - ref?: Ref | null | undefined; -}; - -export type WithChildNoChildrenSnippetProps< - /** - * The props that the component accepts. - */ - Props extends Record = {}, - /** - * The props that are passed to the `child` and `children` snippets. The `ElementProps` are - * merged with these props for the `child` snippet. - */ - SnippetProps extends Record = { _default: never }, - /** - * The underlying DOM element being rendered. You can bind to this prop to - * programmatically interact with the element. - */ - Ref = HTMLElement, -> = Omit & { - child?: SnippetProps extends { _default: never } - ? Snippet<[{ props: Record }]> - : Snippet<[SnippetProps & { props: Record }]>; - children?: Snippet; - style?: StyleProperties | string | null | undefined; - ref?: Ref | null | undefined; -}; - -export type WithChildren = Props & { - children?: Snippet | undefined; -}; - -/** - * Constructs a new type by omitting properties from type - * 'T' that exist in type 'U'. - * - * @template T - The base object type from which properties will be omitted. - * @template U - The object type whose properties will be omitted from 'T'. - * @example - * type Result = Without<{ a: number; b: string; }, { b: string; }>; - * // Result type will be { a: number; } - */ -export type Without = Omit; - -export type Arrayable = T[] | T; - -export type Fn = () => void; -export type AnyFn = (...args: any[]) => any; - -export type WithRefOpts = T & - ReadableBoxedValues<{ id: string }> & - WritableBoxedValues<{ ref: HTMLElement | null }>; - -export type AcelEvent = T & { - currentTarget: U; -}; - -export type AcelPointerEvent = AcelEvent; -export type AcelKeyboardEvent = AcelEvent; -export type AcelMouseEvent = AcelEvent; -export type AcelFocusEvent = AcelEvent; -export type AcelInputEvent = AcelEvent; - -export type RefAttachment = ReturnType>; diff --git a/src/uix/acel/label/components/label.svelte b/src/uix/acel/label/components/label.svelte new file mode 100644 index 000000000..0c6a469ec --- /dev/null +++ b/src/uix/acel/label/components/label.svelte @@ -0,0 +1,31 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/acel/label/exports.ts b/src/uix/acel/label/exports.ts new file mode 100644 index 000000000..65488ac98 --- /dev/null +++ b/src/uix/acel/label/exports.ts @@ -0,0 +1,2 @@ +export { default as Root } from "./components/label.svelte"; +export type { LabelRootProps as RootProps } from "./types"; diff --git a/src/uix/acel/label/index.ts b/src/uix/acel/label/index.ts new file mode 100644 index 000000000..e568117f5 --- /dev/null +++ b/src/uix/acel/label/index.ts @@ -0,0 +1,2 @@ +export * as Label from "./exports"; +export * from "./exports"; diff --git a/src/uix/acel/label/label.svelte.ts b/src/uix/acel/label/label.svelte.ts new file mode 100644 index 000000000..9be5b3c2d --- /dev/null +++ b/src/uix/acel/label/label.svelte.ts @@ -0,0 +1,35 @@ +import { attachRef } from "../utilities/index"; +import type { RefAttachment, WithRefOpts } from "../utilities/types"; +import { createAcelAttrs } from "../utilities/attrs"; +import type { AcelMouseEvent } from "../utilities/types"; + +const labelAttrs = createAcelAttrs({ + component: "label", + parts: ["root"] as const, +}); + +export class LabelRootState { + static create(opts: WithRefOpts) { + return new LabelRootState(opts); + } + + readonly opts: WithRefOpts; + readonly attachment: RefAttachment; + + constructor(opts: WithRefOpts) { + this.opts = opts; + this.attachment = attachRef(this.opts.ref); + } + + readonly onmousedown = (e: AcelMouseEvent) => { + // Prevenir doble clic que selecciona texto + if (e.detail > 1) e.preventDefault(); + }; + + readonly props = $derived.by(() => ({ + id: this.opts.id.current, + onmousedown: this.onmousedown, + [labelAttrs.root]: "", + ...this.attachment, + }) as const); +} diff --git a/src/uix/acel/label/types.ts b/src/uix/acel/label/types.ts new file mode 100644 index 000000000..1922144c9 --- /dev/null +++ b/src/uix/acel/label/types.ts @@ -0,0 +1,7 @@ +import type { WithChild, Without } from "../utilities/types"; +import type { AcelPrimitiveLabelAttributes } from "../utilities/attributes"; + +export type LabelRootPropsWithoutHTML = WithChild<{}, {}, HTMLLabelElement>; + +export type LabelRootProps = LabelRootPropsWithoutHTML & + Without; diff --git a/src/uix/acel/menu/components/menu-arrow.svelte b/src/uix/acel/menu/components/menu-arrow.svelte new file mode 100644 index 000000000..4ce1adfd5 --- /dev/null +++ b/src/uix/acel/menu/components/menu-arrow.svelte @@ -0,0 +1,18 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/acel/menu/components/menu-checkbox-group.svelte b/src/uix/acel/menu/components/menu-checkbox-group.svelte new file mode 100644 index 000000000..1428e5d7b --- /dev/null +++ b/src/uix/acel/menu/components/menu-checkbox-group.svelte @@ -0,0 +1,45 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/acel/menu/components/menu-checkbox-item.svelte b/src/uix/acel/menu/components/menu-checkbox-item.svelte new file mode 100644 index 000000000..ed270deb7 --- /dev/null +++ b/src/uix/acel/menu/components/menu-checkbox-item.svelte @@ -0,0 +1,90 @@ + + +{#if child} + {@render child({ checked, indeterminate, props: mergedProps })} +{:else} +
+ {@render children?.({ checked, indeterminate })} +
+{/if} diff --git a/src/uix/acel/menu/components/menu-content.svelte b/src/uix/acel/menu/components/menu-content.svelte new file mode 100644 index 000000000..9e56f8f27 --- /dev/null +++ b/src/uix/acel/menu/components/menu-content.svelte @@ -0,0 +1,99 @@ + + + + {#snippet popper({ props, wrapperProps })} + {@const finalProps = mergeProps( + contentState.props, + props, + { style: getFloatingContentCSSVars("menu") }, + style ? { style } : {} + )} + {#if child} + {@render child({ props: finalProps, wrapperProps, ...contentState.snippetProps })} + {:else} +
+
+ {@render children?.()} +
+
+ {/if} + {/snippet} +
diff --git a/src/uix/acel/menu/components/menu-group-heading.svelte b/src/uix/acel/menu/components/menu-group-heading.svelte new file mode 100644 index 000000000..a924101fd --- /dev/null +++ b/src/uix/acel/menu/components/menu-group-heading.svelte @@ -0,0 +1,34 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/acel/menu/components/menu-group.svelte b/src/uix/acel/menu/components/menu-group.svelte new file mode 100644 index 000000000..8d1003997 --- /dev/null +++ b/src/uix/acel/menu/components/menu-group.svelte @@ -0,0 +1,34 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/acel/menu/components/menu-item.svelte b/src/uix/acel/menu/components/menu-item.svelte new file mode 100644 index 000000000..a1eb15812 --- /dev/null +++ b/src/uix/acel/menu/components/menu-item.svelte @@ -0,0 +1,41 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/acel/menu/components/menu-radio-group.svelte b/src/uix/acel/menu/components/menu-radio-group.svelte new file mode 100644 index 000000000..f4edd7e4c --- /dev/null +++ b/src/uix/acel/menu/components/menu-radio-group.svelte @@ -0,0 +1,44 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/acel/menu/components/menu-radio-item.svelte b/src/uix/acel/menu/components/menu-radio-item.svelte new file mode 100644 index 000000000..30bed71ee --- /dev/null +++ b/src/uix/acel/menu/components/menu-radio-item.svelte @@ -0,0 +1,49 @@ + + +{#if child} + {@render child({ props: mergedProps, checked: radioItemState.isChecked })} +{:else} +
+ {@render children?.({ checked: radioItemState.isChecked })} +
+{/if} diff --git a/src/uix/acel/menu/components/menu-separator.svelte b/src/uix/acel/menu/components/menu-separator.svelte new file mode 100644 index 000000000..cd3b4fcc8 --- /dev/null +++ b/src/uix/acel/menu/components/menu-separator.svelte @@ -0,0 +1,34 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/acel/menu/components/menu-sub-content.svelte b/src/uix/acel/menu/components/menu-sub-content.svelte new file mode 100644 index 000000000..6a26b48de --- /dev/null +++ b/src/uix/acel/menu/components/menu-sub-content.svelte @@ -0,0 +1,103 @@ + + + + {#snippet popper({ props, wrapperProps })} + {@const finalProps = mergeProps( + mergedProps, + props, + { style: getFloatingContentCSSVars("menu") }, + style ? { style } : {} + )} + {#if child} + {@render child({ props: finalProps, wrapperProps, ...subContentState.snippetProps })} + {:else} +
+
+ {@render children?.()} +
+
+ {/if} + {/snippet} +
diff --git a/src/uix/acel/menu/components/menu-sub-trigger.svelte b/src/uix/acel/menu/components/menu-sub-trigger.svelte new file mode 100644 index 000000000..2ed8243cb --- /dev/null +++ b/src/uix/acel/menu/components/menu-sub-trigger.svelte @@ -0,0 +1,44 @@ + + + + {#if child} + {@render child({ props: mergedProps })} + {:else} +
+ {@render children?.()} +
+ {/if} +
diff --git a/src/uix/acel/menu/components/menu-sub.svelte b/src/uix/acel/menu/components/menu-sub.svelte new file mode 100644 index 000000000..41d6aeed4 --- /dev/null +++ b/src/uix/acel/menu/components/menu-sub.svelte @@ -0,0 +1,29 @@ + + + + {@render children?.()} + diff --git a/src/uix/acel/menu/components/menu-trigger.svelte b/src/uix/acel/menu/components/menu-trigger.svelte new file mode 100644 index 000000000..7cf169f30 --- /dev/null +++ b/src/uix/acel/menu/components/menu-trigger.svelte @@ -0,0 +1,40 @@ + + + + {#if child} + {@render child({ props: mergedProps })} + {:else} + + {/if} + diff --git a/src/uix/acel/menu/components/menu.svelte b/src/uix/acel/menu/components/menu.svelte new file mode 100644 index 000000000..6b5bc2169 --- /dev/null +++ b/src/uix/acel/menu/components/menu.svelte @@ -0,0 +1,45 @@ + + + + {@render children?.()} + diff --git a/src/uix/acel/menu/exports.ts b/src/uix/acel/menu/exports.ts new file mode 100644 index 000000000..1ee7f19c0 --- /dev/null +++ b/src/uix/acel/menu/exports.ts @@ -0,0 +1,35 @@ +export { default as Root } from "./components/menu.svelte"; +export { default as Sub } from "./components/menu-sub.svelte"; +export { default as Trigger } from "./components/menu-trigger.svelte"; +export { default as Content } from "./components/menu-content.svelte"; +export { default as Item } from "./components/menu-item.svelte"; +export { default as Group } from "./components/menu-group.svelte"; +export { default as GroupHeading } from "./components/menu-group-heading.svelte"; +export { default as Arrow } from "./components/menu-arrow.svelte"; +export { default as RadioItem } from "./components/menu-radio-item.svelte"; +export { default as Separator } from "./components/menu-separator.svelte"; +export { default as RadioGroup } from "./components/menu-radio-group.svelte"; +export { default as SubContent } from "./components/menu-sub-content.svelte"; +export { default as SubTrigger } from "./components/menu-sub-trigger.svelte"; +export { default as CheckboxItem } from "./components/menu-checkbox-item.svelte"; +export { default as CheckboxGroup } from "./components/menu-checkbox-group.svelte"; + +export type { + MenuRootProps as RootProps, + MenuSubProps as SubProps, + MenuTriggerProps as TriggerProps, + MenuContentProps as ContentProps, + MenuContentStaticProps as ContentStaticProps, + MenuItemProps as ItemProps, + MenuGroupProps as GroupProps, + MenuGroupHeadingProps as GroupHeadingProps, + MenuArrowProps as ArrowProps, + MenuRadioItemProps as RadioItemProps, + MenuSeparatorProps as SeparatorProps, + MenuRadioGroupProps as RadioGroupProps, + MenuSubContentProps as SubContentProps, + MenuSubContentStaticProps as SubContentStaticProps, + MenuSubTriggerProps as SubTriggerProps, + MenuCheckboxItemProps as CheckboxItemProps, + MenuCheckboxGroupProps as CheckboxGroupProps, +} from "./types"; diff --git a/src/uix/acel/menu/index.ts b/src/uix/acel/menu/index.ts new file mode 100644 index 000000000..fd0b17987 --- /dev/null +++ b/src/uix/acel/menu/index.ts @@ -0,0 +1 @@ +export * from "./exports"; diff --git a/src/uix/acel/menu/menu.svelte.ts b/src/uix/acel/menu/menu.svelte.ts new file mode 100644 index 000000000..e0b3d7e10 --- /dev/null +++ b/src/uix/acel/menu/menu.svelte.ts @@ -0,0 +1,1278 @@ +import { + afterTick, + mergeProps, + onDestroyEffect, + attachRef, + DOMContext, + getWindow, + type ReadableBoxedValues, + type WritableBoxedValues, + simpleBox, + boxWith, + type ReadableBox, +} from "../utilities/index"; +import { Context, watch } from "runed"; +import { + FIRST_LAST_KEYS, + LAST_KEYS, + SELECTION_KEYS, + SUB_OPEN_KEYS, + getCheckedState, + isMouseEvent, +} from "./utils"; +import { focusFirst } from "../utilities/dom/focus"; +import { CustomEventDispatcher } from "../utilities/events/event-dispatcher"; +import type { + AnyFn, + AcelFocusEvent, + AcelKeyboardEvent, + AcelMouseEvent, + AcelPointerEvent, + OnChangeFn, + RefAttachment, + WithRefOpts, +} from "../utilities/types"; +import { isElement, isElementOrSVGElement, isHTMLElement } from "../utilities/dom/is-browser"; +import { kbd } from "../utilities/keyboard/kbd"; +import { + createAcelAttrs, + getAriaChecked, + boolToStr, + getDataOpenClosed, + boolToEmptyStrOrUndef, +} from "../utilities/attrs"; +import type { Direction } from "../utilities/index"; +import { IsUsingKeyboard } from "../utilities/keyboard/is-using-keyboard.svelte"; +import { getTabbableFrom } from "../utilities/focus/tabbable"; +import { isTabbable } from "tabbable"; +import type { KeyboardEventHandler, PointerEventHandler, MouseEventHandler } from "svelte/elements"; +import { DOMTypeahead } from "../utilities/typeahead/dom-typeahead.svelte"; +import { RovingFocusGroup } from "../utilities/focus/roving-focus-group"; +import { GraceArea } from "../utilities/floating/grace-area.svelte"; +import { PresenceManager } from "../utilities/presence/presence-manager.svelte"; +import { arraysAreEqual } from "../utilities/arrays"; + +export const CONTEXT_MENU_TRIGGER_ATTR = "data-context-menu-trigger"; +export const CONTEXT_MENU_CONTENT_ATTR = "data-context-menu-content"; + +const MenuRootContext = new Context("Menu.Root"); +const MenuMenuContext = new Context("Menu.Root | Menu.Sub"); +const MenuContentContext = new Context("Menu.Content"); +const MenuGroupContext = new Context( + "Menu.Group | Menu.RadioGroup" +); +const MenuRadioGroupContext = new Context("Menu.RadioGroup"); +export const MenuCheckboxGroupContext = new Context("Menu.CheckboxGroup"); + +type MenuVariant = "context-menu" | "dropdown-menu" | "menubar"; + +export interface MenuRootStateOpts + extends ReadableBoxedValues<{ + dir: Direction; + variant: MenuVariant; + }> { + onClose: AnyFn; +} + +export const MenuOpenEvent = new CustomEventDispatcher("acelmenuopen", { + bubbles: false, + cancelable: true, +}); + +export const menuAttrs = createAcelAttrs({ + component: "menu", + parts: [ + "trigger", + "content", + "sub-trigger", + "item", + "group", + "group-heading", + "checkbox-group", + "checkbox-item", + "radio-group", + "radio-item", + "separator", + "sub-content", + "arrow", + ] as const, +}); + +export class MenuRootState { + static create(opts: MenuRootStateOpts) { + const root = new MenuRootState(opts); + return MenuRootContext.set(root); + } + + readonly opts: MenuRootStateOpts; + readonly isUsingKeyboard = new IsUsingKeyboard(); + ignoreCloseAutoFocus = $state(false); + isPointerInTransit = $state(false); + + constructor(opts: MenuRootStateOpts) { + this.opts = opts; + } + + getBitsAttr(part: Parameters[0]): string { + return menuAttrs.getAttr(part, this.opts.variant.current); + } +} + +interface MenuMenuStateOpts + extends WritableBoxedValues<{ + open: boolean; + }>, + ReadableBoxedValues<{ + onOpenChangeComplete: OnChangeFn; + }> {} + +export class MenuMenuState { + static create(opts: MenuMenuStateOpts, root: MenuRootState) { + return MenuMenuContext.set(new MenuMenuState(opts, root, null)); + } + + readonly opts: MenuMenuStateOpts; + readonly root: MenuRootState; + readonly parentMenu: MenuMenuState | null; + contentId = boxWith(() => ""); + contentNode = $state(null); + contentPresence: PresenceManager; + triggerNode = $state(null); + + constructor(opts: MenuMenuStateOpts, root: MenuRootState, parentMenu: MenuMenuState | null) { + this.opts = opts; + this.root = root; + this.parentMenu = parentMenu; + + this.contentPresence = new PresenceManager({ + ref: boxWith(() => this.contentNode), + open: this.opts.open, + onComplete: () => { + this.opts.onOpenChangeComplete.current(this.opts.open.current); + }, + }); + + if (parentMenu) { + watch( + () => parentMenu.opts.open.current, + () => { + if (parentMenu.opts.open.current) return; + this.opts.open.current = false; + } + ); + } + } + + toggleOpen() { + this.opts.open.current = !this.opts.open.current; + } + + onOpen() { + this.opts.open.current = true; + } + + onClose() { + this.opts.open.current = false; + } +} + +interface MenuContentStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ + loop: boolean; + onCloseAutoFocus: (event: Event) => void; + }> { + isSub?: boolean; +} + +export class MenuContentState { + static create(opts: MenuContentStateOpts) { + return MenuContentContext.set(new MenuContentState(opts, MenuMenuContext.get())); + } + + readonly opts: MenuContentStateOpts; + readonly parentMenu: MenuMenuState; + readonly rovingFocusGroup: RovingFocusGroup; + readonly domContext: DOMContext; + readonly attachment: RefAttachment; + search = $state(""); + #timer = 0; + #handleTypeaheadSearch: DOMTypeahead["handleTypeaheadSearch"]; + mounted = $state(false); + #isSub: boolean; + + constructor(opts: MenuContentStateOpts, parentMenu: MenuMenuState) { + this.opts = opts; + this.parentMenu = parentMenu; + this.domContext = new DOMContext(opts.ref); + this.attachment = attachRef(this.opts.ref, (v) => { + if (this.parentMenu.contentNode !== v) { + this.parentMenu.contentNode = v; + } + }); + + parentMenu.contentId = opts.id; + + this.#isSub = opts.isSub ?? false; + this.onkeydown = this.onkeydown.bind(this); + this.onblur = this.onblur.bind(this); + this.onfocus = this.onfocus.bind(this); + this.handleInteractOutside = this.handleInteractOutside.bind(this); + + new GraceArea({ + contentNode: () => this.parentMenu.contentNode, + triggerNode: () => this.parentMenu.triggerNode, + enabled: () => + this.parentMenu.opts.open.current && + Boolean( + this.parentMenu.triggerNode?.hasAttribute( + this.parentMenu.root.getBitsAttr("sub-trigger") + ) + ), + onPointerExit: () => { + this.parentMenu.opts.open.current = false; + }, + setIsPointerInTransit: (value) => { + this.parentMenu.root.isPointerInTransit = value; + }, + }); + + this.#handleTypeaheadSearch = new DOMTypeahead({ + getActiveElement: () => this.domContext.getActiveElement(), + getWindow: () => this.domContext.getWindow(), + }).handleTypeaheadSearch; + + this.rovingFocusGroup = new RovingFocusGroup({ + rootNode: boxWith(() => this.parentMenu.contentNode), + candidateAttr: this.parentMenu.root.getBitsAttr("item"), + loop: this.opts.loop, + orientation: boxWith(() => "vertical"), + }); + + watch( + () => this.parentMenu.contentNode, + (contentNode) => { + if (!contentNode) return; + const handler = () => { + afterTick(() => { + if (!this.parentMenu.root.isUsingKeyboard.current) return; + this.rovingFocusGroup.focusFirstCandidate(); + }); + }; + return MenuOpenEvent.listen(contentNode, handler); + } + ); + + $effect(() => { + if (!this.parentMenu.opts.open.current) { + this.domContext.getWindow().clearTimeout(this.#timer); + } + }); + } + + #getCandidateNodes() { + const node = this.parentMenu.contentNode; + if (!node) return []; + return Array.from( + node.querySelectorAll( + `[${this.parentMenu.root.getBitsAttr("item")}]:not([data-disabled])` + ) + ); + } + + #isPointerMovingToSubmenu() { + return this.parentMenu.root.isPointerInTransit; + } + + onCloseAutoFocus = (e: Event) => { + this.opts.onCloseAutoFocus.current?.(e); + if (e.defaultPrevented || this.#isSub) return; + + if (this.parentMenu.triggerNode && isTabbable(this.parentMenu.triggerNode)) { + e.preventDefault(); + this.parentMenu.triggerNode.focus(); + } + }; + + handleTabKeyDown(e: AcelKeyboardEvent) { + let rootMenu = this.parentMenu; + while (rootMenu.parentMenu !== null) { + rootMenu = rootMenu.parentMenu; + } + if (!rootMenu.triggerNode) return; + e.preventDefault(); + + const nodeToFocus = getTabbableFrom(rootMenu.triggerNode, e.shiftKey ? "prev" : "next"); + if (nodeToFocus) { + this.parentMenu.root.ignoreCloseAutoFocus = true; + rootMenu.onClose(); + afterTick(() => { + nodeToFocus.focus(); + afterTick(() => { + this.parentMenu.root.ignoreCloseAutoFocus = false; + }); + }); + } else { + this.domContext.getDocument().body.focus(); + } + } + + onkeydown(e: AcelKeyboardEvent) { + if (e.defaultPrevented) return; + if (e.key === kbd.TAB) { + this.handleTabKeyDown(e); + return; + } + + const target = e.target; + const currentTarget = e.currentTarget; + if (!isHTMLElement(target) || !isHTMLElement(currentTarget)) return; + + const isKeydownInside = + target.closest(`[${this.parentMenu.root.getBitsAttr("content")}]`)?.id === + this.parentMenu.contentId.current; + + const isModifierKey = e.ctrlKey || e.altKey || e.metaKey; + const isCharacterKey = e.key.length === 1; + + const kbdFocusedEl = this.rovingFocusGroup.handleKeydown(target, e); + if (kbdFocusedEl) return; + + if (e.code === "Space") return; + + const candidateNodes = this.#getCandidateNodes(); + + if (isKeydownInside) { + if (!isModifierKey && isCharacterKey) { + this.#handleTypeaheadSearch(e.key, candidateNodes); + } + } + + if ((e.target as HTMLElement)?.id !== this.parentMenu.contentId.current) return; + if (!FIRST_LAST_KEYS.includes(e.key)) return; + e.preventDefault(); + + if (LAST_KEYS.includes(e.key)) { + candidateNodes.reverse(); + } + focusFirst(candidateNodes, { select: false }, () => this.domContext.getActiveElement()); + } + + onblur(e: AcelFocusEvent) { + if (!isElement(e.currentTarget)) return; + if (!isElement(e.target)) return; + if (!e.currentTarget.contains?.(e.target)) { + this.domContext.getWindow().clearTimeout(this.#timer); + this.search = ""; + } + } + + onfocus(_: AcelFocusEvent) { + if (!this.parentMenu.root.isUsingKeyboard.current) return; + afterTick(() => this.rovingFocusGroup.focusFirstCandidate()); + } + + onItemEnter() { + return this.#isPointerMovingToSubmenu(); + } + + onItemLeave(e: AcelPointerEvent) { + if (e.currentTarget.hasAttribute(this.parentMenu.root.getBitsAttr("sub-trigger"))) return; + if (this.#isPointerMovingToSubmenu() || this.parentMenu.root.isUsingKeyboard.current) + return; + const contentNode = this.parentMenu.contentNode; + contentNode?.focus(); + this.rovingFocusGroup.setCurrentTabStopId(""); + } + + onTriggerLeave() { + if (this.#isPointerMovingToSubmenu()) return true; + return false; + } + + handleInteractOutside(e: PointerEvent) { + if (!isElementOrSVGElement(e.target)) return; + const triggerId = this.parentMenu.triggerNode?.id; + if (e.target.id === triggerId) { + e.preventDefault(); + return; + } + if (e.target.closest(`#${triggerId}`)) { + e.preventDefault(); + } + } + + get shouldRender() { + return this.parentMenu.contentPresence.shouldRender; + } + + readonly snippetProps = $derived.by(() => ({ open: this.parentMenu.opts.open.current })); + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + role: "menu", + "aria-orientation": "vertical" as const, + [this.parentMenu.root.getBitsAttr("content")]: "", + "data-state": getDataOpenClosed(this.parentMenu.opts.open.current), + onkeydown: this.onkeydown, + onblur: this.onblur, + onfocus: this.onfocus, + style: { + pointerEvents: "auto", + contain: "layout style", + }, + ...this.attachment, + }) as const + ); + + readonly popperProps = { + onCloseAutoFocus: (e: Event) => this.onCloseAutoFocus(e), + }; +} + +// ─── Menu item shared state ─────────────────────────────────────────────── + +interface MenuItemSharedStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ + disabled: boolean; + }> {} + +class MenuItemSharedState { + readonly opts: MenuItemSharedStateOpts; + readonly content: MenuContentState; + readonly attachment: RefAttachment; + #isFocused = $state(false); + + constructor(opts: MenuItemSharedStateOpts, content: MenuContentState) { + this.opts = opts; + this.content = content; + this.attachment = attachRef(this.opts.ref); + this.onpointermove = this.onpointermove.bind(this); + this.onpointerleave = this.onpointerleave.bind(this); + this.onfocus = this.onfocus.bind(this); + this.onblur = this.onblur.bind(this); + } + + onpointermove(e: AcelPointerEvent) { + if (e.defaultPrevented) return; + if (!isMouseEvent(e)) return; + + if (this.opts.disabled.current) { + this.content.onItemLeave(e); + } else { + const defaultPrevented = this.content.onItemEnter(); + if (defaultPrevented) return; + const item = e.currentTarget; + if (!isHTMLElement(item)) return; + item.focus(); + } + } + + onpointerleave(e: AcelPointerEvent) { + if (e.defaultPrevented) return; + if (!isMouseEvent(e)) return; + this.content.onItemLeave(e); + } + + onfocus(e: AcelFocusEvent) { + afterTick(() => { + if (e.defaultPrevented || this.opts.disabled.current) return; + this.#isFocused = true; + }); + } + + onblur(e: AcelFocusEvent) { + afterTick(() => { + if (e.defaultPrevented) return; + this.#isFocused = false; + }); + } + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + tabindex: -1, + role: "menuitem", + "aria-disabled": boolToStr(this.opts.disabled.current), + "data-disabled": boolToEmptyStrOrUndef(this.opts.disabled.current), + "data-highlighted": this.#isFocused ? "" : undefined, + [this.content.parentMenu.root.getBitsAttr("item")]: "", + onpointermove: this.onpointermove, + onpointerleave: this.onpointerleave, + onfocus: this.onfocus, + onblur: this.onblur, + ...this.attachment, + }) as const + ); +} + +type MenuItemCombinedProps = MenuItemSharedStateOpts & MenuItemStateOpts; + +interface MenuItemStateOpts + extends ReadableBoxedValues<{ + onSelect: AnyFn; + closeOnSelect: boolean; + }> {} + +export class MenuItemState { + static create(opts: MenuItemCombinedProps) { + const item = new MenuItemSharedState(opts, MenuContentContext.get()); + return new MenuItemState(opts, item); + } + + readonly opts: MenuItemStateOpts; + readonly item: MenuItemSharedState; + readonly root: MenuRootState; + #isPointerDown = false; + + constructor(opts: MenuItemStateOpts, item: MenuItemSharedState) { + this.opts = opts; + this.item = item; + this.root = item.content.parentMenu.root; + this.onkeydown = this.onkeydown.bind(this); + this.onclick = this.onclick.bind(this); + this.onpointerdown = this.onpointerdown.bind(this); + this.onpointerup = this.onpointerup.bind(this); + } + + #handleSelect() { + if (this.item.opts.disabled.current) return; + const selectEvent = new CustomEvent("menuitemselect", { bubbles: true, cancelable: true }); + this.opts.onSelect.current(selectEvent); + if (selectEvent.defaultPrevented) { + this.item.content.parentMenu.root.isUsingKeyboard.current = false; + return; + } + if (this.opts.closeOnSelect.current) { + this.item.content.parentMenu.root.opts.onClose(); + } + } + + onkeydown(e: AcelKeyboardEvent) { + const isTypingAhead = this.item.content.search !== ""; + if (this.item.opts.disabled.current || (isTypingAhead && e.key === kbd.SPACE)) return; + if (SELECTION_KEYS.includes(e.key)) { + if (!isHTMLElement(e.currentTarget)) return; + e.currentTarget.click(); + e.preventDefault(); + } + } + + onclick(_: AcelMouseEvent) { + if (this.item.opts.disabled.current) return; + this.#handleSelect(); + } + + onpointerup(e: AcelPointerEvent) { + if (e.defaultPrevented) return; + if (!this.#isPointerDown) { + if (!isHTMLElement(e.currentTarget)) return; + e.currentTarget?.click(); + } + } + + onpointerdown(_: AcelPointerEvent) { + this.#isPointerDown = true; + } + + readonly props = $derived.by(() => + mergeProps(this.item.props, { + onclick: this.onclick, + onpointerdown: this.onpointerdown, + onpointerup: this.onpointerup, + onkeydown: this.onkeydown, + }) + ); +} + +// ─── Sub trigger ───────────────────────────────────────────────────────── + +interface MenuSubTriggerStateOpts + extends MenuItemSharedStateOpts, + Pick { + openDelay: ReadableBox; +} + +export class MenuSubTriggerState { + static create(opts: MenuSubTriggerStateOpts) { + const content = MenuContentContext.get(); + const item = new MenuItemSharedState(opts, content); + const submenu = MenuMenuContext.get(); + return new MenuSubTriggerState(opts, item, content, submenu); + } + + readonly opts: MenuSubTriggerStateOpts; + readonly item: MenuItemSharedState; + readonly content: MenuContentState; + readonly submenu: MenuMenuState; + readonly attachment: RefAttachment; + #openTimer: number | null = null; + + constructor( + opts: MenuSubTriggerStateOpts, + item: MenuItemSharedState, + content: MenuContentState, + submenu: MenuMenuState + ) { + this.opts = opts; + this.item = item; + this.content = content; + this.submenu = submenu; + this.attachment = attachRef(this.opts.ref, (v) => (this.submenu.triggerNode = v)); + this.onpointerleave = this.onpointerleave.bind(this); + this.onpointermove = this.onpointermove.bind(this); + this.onkeydown = this.onkeydown.bind(this); + this.onclick = this.onclick.bind(this); + + onDestroyEffect(() => { + this.#clearOpenTimer(); + }); + } + + #clearOpenTimer() { + if (this.#openTimer === null) return; + this.content.domContext.getWindow().clearTimeout(this.#openTimer); + this.#openTimer = null; + } + + onpointermove(e: AcelPointerEvent) { + if (!isMouseEvent(e)) return; + if ( + !this.item.opts.disabled.current && + !this.submenu.opts.open.current && + !this.#openTimer && + !this.content.parentMenu.root.isPointerInTransit + ) { + this.#openTimer = this.content.domContext.setTimeout(() => { + this.submenu.onOpen(); + this.#clearOpenTimer(); + }, this.opts.openDelay.current); + } + } + + onpointerleave(e: AcelPointerEvent) { + if (!isMouseEvent(e)) return; + this.#clearOpenTimer(); + } + + onkeydown(e: AcelKeyboardEvent) { + const isTypingAhead = this.content.search !== ""; + if (this.item.opts.disabled.current || (isTypingAhead && e.key === kbd.SPACE)) return; + if (SUB_OPEN_KEYS[this.submenu.root.opts.dir.current].includes(e.key)) { + e.currentTarget.click(); + e.preventDefault(); + } + } + + onclick(e: AcelMouseEvent) { + if (this.item.opts.disabled.current) return; + if (!isHTMLElement(e.currentTarget)) return; + e.currentTarget.focus(); + const selectEvent = new CustomEvent("menusubtriggerselect", { + bubbles: true, + cancelable: true, + }); + this.opts.onSelect.current(selectEvent); + if (!this.submenu.opts.open.current) { + this.submenu.onOpen(); + afterTick(() => { + const contentNode = this.submenu.contentNode; + if (!contentNode) return; + MenuOpenEvent.dispatch(contentNode); + }); + } + } + + readonly props = $derived.by(() => + mergeProps( + { + "aria-haspopup": "menu", + "aria-expanded": boolToStr(this.submenu.opts.open.current), + "data-state": getDataOpenClosed(this.submenu.opts.open.current), + "aria-controls": this.submenu.opts.open.current + ? this.submenu.contentId.current + : undefined, + [this.submenu.root.getBitsAttr("sub-trigger")]: "", + onclick: this.onclick, + onpointermove: this.onpointermove, + onpointerleave: this.onpointerleave, + onkeydown: this.onkeydown, + ...this.attachment, + }, + this.item.props + ) + ); +} + +// ─── Checkbox item ──────────────────────────────────────────────────────── + +interface MenuCheckboxItemStateOpts + extends WritableBoxedValues<{ + checked: boolean; + indeterminate: boolean; + }>, + ReadableBoxedValues<{ + value: string; + }> {} + +export class MenuCheckboxItemState { + static create( + opts: MenuItemCombinedProps & MenuCheckboxItemStateOpts, + checkboxGroup: MenuCheckboxGroupState | null + ) { + const item = new MenuItemState( + opts, + new MenuItemSharedState(opts, MenuContentContext.get()) + ); + return new MenuCheckboxItemState(opts, item, checkboxGroup); + } + + readonly opts: MenuCheckboxItemStateOpts; + readonly item: MenuItemState; + readonly group: MenuCheckboxGroupState | null; + + constructor( + opts: MenuCheckboxItemStateOpts, + item: MenuItemState, + group: MenuCheckboxGroupState | null = null + ) { + this.opts = opts; + this.item = item; + this.group = group; + + if (this.group) { + watch( + () => this.group!.opts.value.current, + (groupValues) => { + this.opts.checked.current = groupValues.includes(this.opts.value.current); + } + ); + watch( + () => this.opts.checked.current, + (checked) => { + if (checked) { + this.group!.addValue(this.opts.value.current); + } else { + this.group!.removeValue(this.opts.value.current); + } + } + ); + } + } + + toggleChecked() { + if (this.opts.indeterminate.current) { + this.opts.indeterminate.current = false; + this.opts.checked.current = true; + } else { + this.opts.checked.current = !this.opts.checked.current; + } + } + + readonly snippetProps = $derived.by(() => ({ + checked: this.opts.checked.current, + indeterminate: this.opts.indeterminate.current, + })); + + readonly props = $derived.by( + () => + ({ + ...this.item.props, + role: "menuitemcheckbox", + "aria-checked": getAriaChecked( + this.opts.checked.current, + this.opts.indeterminate.current + ), + "data-state": getCheckedState(this.opts.checked.current), + [this.item.root.getBitsAttr("checkbox-item")]: "", + }) as const + ); +} + +// ─── Group ──────────────────────────────────────────────────────────────── + +interface MenuGroupStateOpts extends WithRefOpts {} + +export class MenuGroupState { + static create(opts: MenuGroupStateOpts) { + return MenuGroupContext.set(new MenuGroupState(opts, MenuRootContext.get())); + } + + readonly opts: MenuGroupStateOpts; + readonly root: MenuRootState; + readonly attachment: RefAttachment; + groupHeadingId = $state(undefined); + + constructor(opts: MenuGroupStateOpts, root: MenuRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(this.opts.ref); + } + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + role: "group", + "aria-labelledby": this.groupHeadingId, + [this.root.getBitsAttr("group")]: "", + ...this.attachment, + }) as const + ); +} + +// ─── Group heading ──────────────────────────────────────────────────────── + +interface MenuGroupHeadingStateOpts extends WithRefOpts {} + +export class MenuGroupHeadingState { + static create(opts: MenuGroupHeadingStateOpts) { + const checkboxGroup = MenuCheckboxGroupContext.getOr(null); + if (checkboxGroup) return new MenuGroupHeadingState(opts, checkboxGroup); + const radioGroup = MenuRadioGroupContext.getOr(null); + if (radioGroup) return new MenuGroupHeadingState(opts, radioGroup); + return new MenuGroupHeadingState(opts, MenuGroupContext.get()); + } + + readonly opts: MenuGroupHeadingStateOpts; + readonly group: MenuGroupState | MenuRadioGroupState | MenuCheckboxGroupState; + readonly attachment: RefAttachment; + + constructor( + opts: MenuGroupHeadingStateOpts, + group: MenuGroupState | MenuRadioGroupState | MenuCheckboxGroupState + ) { + this.opts = opts; + this.group = group; + this.attachment = attachRef(this.opts.ref, (v) => (this.group.groupHeadingId = v?.id)); + } + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + role: "group", + [this.group.root.getBitsAttr("group-heading")]: "", + ...this.attachment, + }) as const + ); +} + +// ─── Separator ──────────────────────────────────────────────────────────── + +interface MenuSeparatorStateOpts extends WithRefOpts {} + +export class MenuSeparatorState { + static create(opts: MenuSeparatorStateOpts) { + return new MenuSeparatorState(opts, MenuRootContext.get()); + } + + readonly opts: MenuSeparatorStateOpts; + readonly root: MenuRootState; + readonly attachment: RefAttachment; + + constructor(opts: MenuSeparatorStateOpts, root: MenuRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(this.opts.ref); + } + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + role: "separator", + [this.root.getBitsAttr("separator")]: "", + ...this.attachment, + }) as const + ); +} + +// ─── Arrow ──────────────────────────────────────────────────────────────── + +export class MenuArrowState { + static create() { + return new MenuArrowState(MenuRootContext.get()); + } + + readonly root: MenuRootState; + + constructor(root: MenuRootState) { + this.root = root; + } + + readonly props = $derived.by( + () => + ({ + [this.root.getBitsAttr("arrow")]: "", + }) as const + ); +} + +// ─── Radio group ────────────────────────────────────────────────────────── + +interface MenuRadioGroupStateOpts + extends WithRefOpts, + WritableBoxedValues<{ + value: string; + }> {} + +export class MenuRadioGroupState { + static create(opts: MenuRadioGroupStateOpts) { + return MenuGroupContext.set( + MenuRadioGroupContext.set(new MenuRadioGroupState(opts, MenuContentContext.get())) + ); + } + + readonly opts: MenuRadioGroupStateOpts; + readonly content: MenuContentState; + readonly attachment: RefAttachment; + groupHeadingId = $state(null); + root: MenuRootState; + + constructor(opts: MenuRadioGroupStateOpts, content: MenuContentState) { + this.opts = opts; + this.content = content; + this.root = content.parentMenu.root; + this.attachment = attachRef(this.opts.ref); + } + + setValue(v: string) { + this.opts.value.current = v; + } + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + [this.root.getBitsAttr("radio-group")]: "", + role: "group", + "aria-labelledby": this.groupHeadingId, + ...this.attachment, + }) as const + ); +} + +// ─── Radio item ─────────────────────────────────────────────────────────── + +interface MenuRadioItemStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ + value: string; + closeOnSelect: boolean; + }> {} + +export class MenuRadioItemState { + static create(opts: MenuRadioItemStateOpts & MenuItemCombinedProps) { + const radioGroup = MenuRadioGroupContext.get(); + const sharedItem = new MenuItemSharedState(opts, radioGroup.content); + const item = new MenuItemState(opts, sharedItem); + return new MenuRadioItemState(opts, item, radioGroup); + } + + readonly opts: MenuRadioItemStateOpts; + readonly item: MenuItemState; + readonly group: MenuRadioGroupState; + readonly attachment: RefAttachment; + readonly isChecked = $derived.by( + () => this.group.opts.value.current === this.opts.value.current + ); + + constructor(opts: MenuRadioItemStateOpts, item: MenuItemState, group: MenuRadioGroupState) { + this.opts = opts; + this.item = item; + this.group = group; + this.attachment = attachRef(this.opts.ref); + } + + selectValue() { + this.group.setValue(this.opts.value.current); + } + + readonly props = $derived.by( + () => + ({ + [this.group.root.getBitsAttr("radio-item")]: "", + ...this.item.props, + role: "menuitemradio", + "aria-checked": getAriaChecked(this.isChecked, false), + "data-state": getCheckedState(this.isChecked), + ...this.attachment, + }) as const + ); +} + +// ─── Dropdown menu trigger ──────────────────────────────────────────────── + +interface DropdownMenuTriggerStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ + disabled: boolean; + }> {} + +export class DropdownMenuTriggerState { + static create(opts: DropdownMenuTriggerStateOpts) { + return new DropdownMenuTriggerState(opts, MenuMenuContext.get()); + } + + readonly opts: DropdownMenuTriggerStateOpts; + readonly parentMenu: MenuMenuState; + readonly attachment: RefAttachment; + + constructor(opts: DropdownMenuTriggerStateOpts, parentMenu: MenuMenuState) { + this.opts = opts; + this.parentMenu = parentMenu; + this.attachment = attachRef(this.opts.ref, (v) => (this.parentMenu.triggerNode = v)); + } + + onclick: MouseEventHandler = (e) => { + if (this.opts.disabled.current || e.detail !== 0) return; + this.parentMenu.toggleOpen(); + e.preventDefault(); + }; + + onpointerdown: PointerEventHandler = (e) => { + if (this.opts.disabled.current) return; + if (e.pointerType === "touch") return e.preventDefault(); + if (e.button === 0 && e.ctrlKey === false) { + this.parentMenu.toggleOpen(); + if (!this.parentMenu.opts.open.current) e.preventDefault(); + } + }; + + onpointerup: PointerEventHandler = (e) => { + if (this.opts.disabled.current) return; + if (e.pointerType === "touch") { + e.preventDefault(); + this.parentMenu.toggleOpen(); + } + }; + + onkeydown: KeyboardEventHandler = (e) => { + if (this.opts.disabled.current) return; + if (e.key === kbd.SPACE || e.key === kbd.ENTER) { + this.parentMenu.toggleOpen(); + e.preventDefault(); + return; + } + if (e.key === kbd.ARROW_DOWN) { + this.parentMenu.onOpen(); + e.preventDefault(); + } + }; + + readonly #ariaControls = $derived.by(() => { + if (this.parentMenu.opts.open.current && this.parentMenu.contentId.current) + return this.parentMenu.contentId.current; + return undefined; + }); + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + disabled: this.opts.disabled.current, + "aria-haspopup": "menu", + "aria-expanded": boolToStr(this.parentMenu.opts.open.current), + "aria-controls": this.#ariaControls, + "data-disabled": boolToEmptyStrOrUndef(this.opts.disabled.current), + "data-state": getDataOpenClosed(this.parentMenu.opts.open.current), + [this.parentMenu.root.getBitsAttr("trigger")]: "", + onclick: this.onclick, + onpointerdown: this.onpointerdown, + onpointerup: this.onpointerup, + onkeydown: this.onkeydown, + ...this.attachment, + }) as const + ); +} + +// ─── Context menu trigger ───────────────────────────────────────────────── + +interface ContextMenuTriggerStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ + disabled: boolean; + }> {} + +export class ContextMenuTriggerState { + static create(opts: ContextMenuTriggerStateOpts) { + return new ContextMenuTriggerState(opts, MenuMenuContext.get()); + } + + readonly opts: ContextMenuTriggerStateOpts; + readonly parentMenu: MenuMenuState; + readonly attachment: RefAttachment; + #point = $state({ x: 0, y: 0 }); + + virtualElement = simpleBox({ + getBoundingClientRect: () => DOMRect.fromRect({ width: 0, height: 0, ...this.#point }), + }); + #longPressTimer: number | null = null; + + constructor(opts: ContextMenuTriggerStateOpts, parentMenu: MenuMenuState) { + this.opts = opts; + this.parentMenu = parentMenu; + this.attachment = attachRef(this.opts.ref, (v) => (this.parentMenu.triggerNode = v)); + this.oncontextmenu = this.oncontextmenu.bind(this); + this.onpointerdown = this.onpointerdown.bind(this); + this.onpointermove = this.onpointermove.bind(this); + this.onpointercancel = this.onpointercancel.bind(this); + this.onpointerup = this.onpointerup.bind(this); + + watch( + () => this.#point, + (point) => { + this.virtualElement.current = { + getBoundingClientRect: () => + DOMRect.fromRect({ width: 0, height: 0, ...point }), + }; + } + ); + + watch( + () => this.opts.disabled.current, + (isDisabled) => { + if (isDisabled) this.#clearLongPressTimer(); + } + ); + + onDestroyEffect(() => this.#clearLongPressTimer()); + } + + #clearLongPressTimer() { + if (this.#longPressTimer === null) return; + getWindow(this.opts.ref.current).clearTimeout(this.#longPressTimer); + } + + #handleOpen(e: AcelMouseEvent | AcelPointerEvent) { + this.#point = { x: e.clientX, y: e.clientY }; + this.parentMenu.onOpen(); + } + + oncontextmenu(e: AcelMouseEvent) { + if (e.defaultPrevented || this.opts.disabled.current) return; + this.#clearLongPressTimer(); + this.#handleOpen(e); + e.preventDefault(); + this.parentMenu.contentNode?.focus(); + } + + onpointerdown(e: AcelPointerEvent) { + if (this.opts.disabled.current || isMouseEvent(e)) return; + this.#clearLongPressTimer(); + this.#longPressTimer = getWindow(this.opts.ref.current).setTimeout( + () => this.#handleOpen(e), + 700 + ); + } + + onpointermove(e: AcelPointerEvent) { + if (this.opts.disabled.current || isMouseEvent(e)) return; + this.#clearLongPressTimer(); + } + + onpointercancel(e: AcelPointerEvent) { + if (this.opts.disabled.current || isMouseEvent(e)) return; + this.#clearLongPressTimer(); + } + + onpointerup(e: AcelPointerEvent) { + if (this.opts.disabled.current || isMouseEvent(e)) return; + this.#clearLongPressTimer(); + } + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + disabled: this.opts.disabled.current, + "data-disabled": boolToEmptyStrOrUndef(this.opts.disabled.current), + "data-state": getDataOpenClosed(this.parentMenu.opts.open.current), + [CONTEXT_MENU_TRIGGER_ATTR]: "", + tabindex: -1, + onpointerdown: this.onpointerdown, + onpointermove: this.onpointermove, + onpointercancel: this.onpointercancel, + onpointerup: this.onpointerup, + oncontextmenu: this.oncontextmenu, + ...this.attachment, + }) as const + ); +} + +// ─── Checkbox group ─────────────────────────────────────────────────────── + +interface MenuCheckboxGroupStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ + onValueChange: (value: string[]) => void; + }>, + WritableBoxedValues<{ + value: string[]; + }> {} + +export class MenuCheckboxGroupState { + static create(opts: MenuCheckboxGroupStateOpts) { + return MenuCheckboxGroupContext.set( + new MenuCheckboxGroupState(opts, MenuContentContext.get()) + ); + } + + readonly opts: MenuCheckboxGroupStateOpts; + readonly content: MenuContentState; + readonly root: MenuRootState; + readonly attachment: RefAttachment; + groupHeadingId = $state(null); + + constructor(opts: MenuCheckboxGroupStateOpts, content: MenuContentState) { + this.opts = opts; + this.content = content; + this.root = content.parentMenu.root; + this.attachment = attachRef(this.opts.ref); + } + + addValue(checkboxValue: string | undefined) { + if (!checkboxValue) return; + if (!this.opts.value.current.includes(checkboxValue)) { + const newValue = [...$state.snapshot(this.opts.value.current), checkboxValue]; + this.opts.value.current = newValue; + if (arraysAreEqual(this.opts.value.current, newValue)) return; + this.opts.onValueChange.current(newValue); + } + } + + removeValue(checkboxValue: string | undefined) { + if (!checkboxValue) return; + const index = this.opts.value.current.indexOf(checkboxValue); + if (index === -1) return; + const newValue = this.opts.value.current.filter((v) => v !== checkboxValue); + this.opts.value.current = newValue; + if (arraysAreEqual(this.opts.value.current, newValue)) return; + this.opts.onValueChange.current(newValue); + } + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + [this.root.getBitsAttr("checkbox-group")]: "", + role: "group", + "aria-labelledby": this.groupHeadingId, + ...this.attachment, + }) as const + ); +} + +// ─── Submenu ────────────────────────────────────────────────────────────── + +export class MenuSubmenuState { + static create(opts: MenuMenuStateOpts) { + const menu = MenuMenuContext.get(); + return MenuMenuContext.set(new MenuMenuState(opts, menu.root, menu)); + } +} diff --git a/src/uix/acel/menu/types.ts b/src/uix/acel/menu/types.ts new file mode 100644 index 000000000..891b2783b --- /dev/null +++ b/src/uix/acel/menu/types.ts @@ -0,0 +1,218 @@ +import type { + OnChangeFn, + WithChild, + WithChildNoChildrenSnippetProps, + WithChildren, + Without, +} from "../utilities/types"; +import type { + AcelPrimitiveButtonAttributes, + AcelPrimitiveDivAttributes, +} from "../utilities/attributes"; +import type { Direction } from "../utilities/index"; +import type { Side, Align, Boundary } from "../utilities/floating/use-floating-layer.svelte"; +import type { Arrayable } from "../utilities/types"; +import type { StyleProperties } from "../utilities/index"; + +// ─── Floating content snippet props ─────────────────────────────────────── +export type FloatingContentSnippetProps = { open: boolean }; +export type StaticContentSnippetProps = { open: boolean }; + +// ─── Root ───────────────────────────────────────────────────────────────── +export type MenuRootPropsWithoutHTML = WithChildren<{ + open?: boolean; + onOpenChange?: OnChangeFn; + onOpenChangeComplete?: OnChangeFn; + /** @defaultValue "ltr" */ + dir?: Direction; +}>; + +export type MenuRootProps = MenuRootPropsWithoutHTML; + +// ─── Content ────────────────────────────────────────────────────────────── +export type _SharedMenuContentProps = { + /** @defaultValue false */ + loop?: boolean; +}; + +export type MenuContentPropsWithoutHTML = WithChildNoChildrenSnippetProps< + { + forceMount?: boolean; + side?: Side; + sideOffset?: number; + align?: Align; + alignOffset?: number; + arrowPadding?: number; + avoidCollisions?: boolean; + collisionBoundary?: Arrayable; + collisionPadding?: number | Partial>; + hideWhenDetached?: boolean; + sticky?: "partial" | "always"; + updatePositionStrategy?: "optimized" | "always"; + strategy?: "fixed" | "absolute"; + onInteractOutside?: (e: PointerEvent) => void; + onEscapeKeydown?: (e: KeyboardEvent) => void; + onCloseAutoFocus?: (e: Event) => void; + style?: StyleProperties | string | null | undefined; + } & _SharedMenuContentProps, + FloatingContentSnippetProps +>; + +export type MenuContentProps = MenuContentPropsWithoutHTML & + Without; + +export type MenuContentStaticPropsWithoutHTML = WithChildNoChildrenSnippetProps< + { style?: StyleProperties | string | null | undefined } & _SharedMenuContentProps, + StaticContentSnippetProps +>; + +export type MenuContentStaticProps = MenuContentStaticPropsWithoutHTML & + Without; + +// ─── Item ────────────────────────────────────────────────────────────────── +export type MenuItemPropsWithoutHTML< + U extends Record = { _default: never }, +> = WithChild< + { + /** @defaultValue false */ + disabled?: boolean; + textValue?: string; + onSelect?: (event: Event) => void; + /** @defaultValue true */ + closeOnSelect?: boolean; + }, + U +>; + +export type MenuItemProps = MenuItemPropsWithoutHTML & + Without; + +// ─── Checkbox item ──────────────────────────────────────────────────────── +export type MenuCheckboxItemSnippetProps = { checked: boolean; indeterminate: boolean }; + +export type MenuCheckboxItemPropsWithoutHTML = + MenuItemPropsWithoutHTML & { + /** @defaultValue false */ + checked?: boolean; + onCheckedChange?: OnChangeFn; + /** @defaultValue false */ + indeterminate?: boolean; + onIndeterminateChange?: OnChangeFn; + /** @defaultValue true */ + closeOnSelect?: boolean; + value?: string; + }; + +export type MenuCheckboxItemProps = MenuCheckboxItemPropsWithoutHTML & + Without; + +// ─── Checkbox group ─────────────────────────────────────────────────────── +export type MenuCheckboxGroupPropsWithoutHTML = WithChild<{ + value?: string[]; + onValueChange?: OnChangeFn; +}>; + +export type MenuCheckboxGroupProps = MenuCheckboxGroupPropsWithoutHTML & + Without; + +// ─── Trigger ────────────────────────────────────────────────────────────── +export type MenuTriggerPropsWithoutHTML = WithChild<{ + disabled?: boolean | null | undefined; +}>; + +export type MenuTriggerProps = MenuTriggerPropsWithoutHTML & + Without; + +// ─── Sub ────────────────────────────────────────────────────────────────── +export type MenuSubPropsWithoutHTML = WithChildren<{ + open?: boolean; + onOpenChange?: OnChangeFn; + onOpenChangeComplete?: OnChangeFn; +}>; + +export type MenuSubProps = MenuSubPropsWithoutHTML; + +// ─── Sub content ────────────────────────────────────────────────────────── +export type MenuSubContentPropsWithoutHTML = WithChildNoChildrenSnippetProps< + { + forceMount?: boolean; + side?: Side; + sideOffset?: number; + align?: Align; + alignOffset?: number; + arrowPadding?: number; + avoidCollisions?: boolean; + collisionBoundary?: Arrayable; + collisionPadding?: number | Partial>; + hideWhenDetached?: boolean; + sticky?: "partial" | "always"; + updatePositionStrategy?: "optimized" | "always"; + strategy?: "fixed" | "absolute"; + onInteractOutside?: (e: PointerEvent) => void; + onEscapeKeydown?: (e: KeyboardEvent) => void; + onCloseAutoFocus?: (e: Event) => void; + style?: StyleProperties | string | null | undefined; + } & _SharedMenuContentProps, + FloatingContentSnippetProps +>; + +export type MenuSubContentProps = MenuSubContentPropsWithoutHTML & + Without; + +export type MenuSubContentStaticPropsWithoutHTML = WithChildNoChildrenSnippetProps< + { style?: StyleProperties | string | null | undefined } & _SharedMenuContentProps, + StaticContentSnippetProps +>; + +export type MenuSubContentStaticProps = MenuSubContentStaticPropsWithoutHTML & + Without; + +// ─── Sub trigger ────────────────────────────────────────────────────────── +export type MenuSubTriggerPropsWithoutHTML = Omit & { + /** @defaultValue 100 */ + openDelay?: number; +}; + +export type MenuSubTriggerProps = MenuSubTriggerPropsWithoutHTML & + Without; + +// ─── Separator ──────────────────────────────────────────────────────────── +export type MenuSeparatorPropsWithoutHTML = WithChild; +export type MenuSeparatorProps = MenuSeparatorPropsWithoutHTML & + Without; + +// ─── Arrow ──────────────────────────────────────────────────────────────── +export type MenuArrowPropsWithoutHTML = WithChild; +export type MenuArrowProps = MenuArrowPropsWithoutHTML & + Without; + +// ─── Group ──────────────────────────────────────────────────────────────── +export type MenuGroupPropsWithoutHTML = WithChild; +export type MenuGroupProps = MenuGroupPropsWithoutHTML & + Without; + +// ─── Group heading ──────────────────────────────────────────────────────── +export type MenuGroupHeadingPropsWithoutHTML = WithChild; +export type MenuGroupHeadingProps = MenuGroupHeadingPropsWithoutHTML & + Without; + +// ─── Radio group ────────────────────────────────────────────────────────── +export type MenuRadioGroupPropsWithoutHTML = WithChild<{ + value?: string; + onValueChange?: OnChangeFn; +}>; + +export type MenuRadioGroupProps = MenuRadioGroupPropsWithoutHTML & + Without; + +// ─── Radio item ─────────────────────────────────────────────────────────── +export type MenuRadioItemSnippetProps = { checked: boolean }; + +export type MenuRadioItemPropsWithoutHTML = MenuItemPropsWithoutHTML & { + value: string; + /** @defaultValue true */ + closeOnSelect?: boolean; +}; + +export type MenuRadioItemProps = MenuRadioItemPropsWithoutHTML & + Without; diff --git a/src/uix/acel/menu/utils.ts b/src/uix/acel/menu/utils.ts new file mode 100644 index 000000000..2cb8ee202 --- /dev/null +++ b/src/uix/acel/menu/utils.ts @@ -0,0 +1,31 @@ +import { kbd } from "../utilities/keyboard/kbd"; +import type { Direction } from "../utilities/index"; + +export type CheckedState = boolean | "indeterminate"; + +export const SELECTION_KEYS = [kbd.ENTER, kbd.SPACE]; +export const FIRST_KEYS = [kbd.ARROW_DOWN, kbd.PAGE_UP, kbd.HOME]; +export const LAST_KEYS = [kbd.ARROW_UP, kbd.PAGE_DOWN, kbd.END]; +export const FIRST_LAST_KEYS = [...FIRST_KEYS, ...LAST_KEYS]; + +export const SUB_OPEN_KEYS: Record = { + ltr: [...SELECTION_KEYS, kbd.ARROW_RIGHT], + rtl: [...SELECTION_KEYS, kbd.ARROW_LEFT], +}; + +export const SUB_CLOSE_KEYS: Record = { + ltr: [kbd.ARROW_LEFT], + rtl: [kbd.ARROW_RIGHT], +}; + +export function isIndeterminate(checked?: CheckedState): checked is "indeterminate" { + return checked === "indeterminate"; +} + +export function getCheckedState(checked: CheckedState) { + return isIndeterminate(checked) ? "indeterminate" : checked ? "checked" : "unchecked"; +} + +export function isMouseEvent(event: PointerEvent) { + return event.pointerType === "mouse"; +} diff --git a/src/uix/acel/menubar/components/menubar-content.svelte b/src/uix/acel/menubar/components/menubar-content.svelte new file mode 100644 index 000000000..63f334624 --- /dev/null +++ b/src/uix/acel/menubar/components/menubar-content.svelte @@ -0,0 +1,34 @@ + + + diff --git a/src/uix/acel/menubar/components/menubar-menu.svelte b/src/uix/acel/menubar/components/menubar-menu.svelte new file mode 100644 index 000000000..c0e9dc92d --- /dev/null +++ b/src/uix/acel/menubar/components/menubar-menu.svelte @@ -0,0 +1,25 @@ + + + { + if (!open) menuState.root.onMenuClose(); + }} + dir={menuState.root.opts.dir.current} + _internal_variant="menubar" + {...restProps} +/> diff --git a/src/uix/acel/menubar/components/menubar-trigger.svelte b/src/uix/acel/menubar/components/menubar-trigger.svelte new file mode 100644 index 000000000..a805bfd83 --- /dev/null +++ b/src/uix/acel/menubar/components/menubar-trigger.svelte @@ -0,0 +1,45 @@ + + + + {#if child} + {@render child({ props: mergedProps })} + {:else} + + {/if} + diff --git a/src/uix/acel/menubar/components/menubar.svelte b/src/uix/acel/menubar/components/menubar.svelte new file mode 100644 index 000000000..06d8766ba --- /dev/null +++ b/src/uix/acel/menubar/components/menubar.svelte @@ -0,0 +1,46 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/acel/menubar/exports.ts b/src/uix/acel/menubar/exports.ts new file mode 100644 index 000000000..93a7f8331 --- /dev/null +++ b/src/uix/acel/menubar/exports.ts @@ -0,0 +1,36 @@ +export { default as Root } from "./components/menubar.svelte"; +export { default as Menu } from "./components/menubar-menu.svelte"; +export { default as Content } from "./components/menubar-content.svelte"; +export { default as Trigger } from "./components/menubar-trigger.svelte"; + +// Shared menu components +export { default as Sub } from "../menu/components/menu-sub.svelte"; +export { default as Item } from "../menu/components/menu-item.svelte"; +export { default as Group } from "../menu/components/menu-group.svelte"; +export { default as GroupHeading } from "../menu/components/menu-group-heading.svelte"; +export { default as Arrow } from "../menu/components/menu-arrow.svelte"; +export { default as RadioItem } from "../menu/components/menu-radio-item.svelte"; +export { default as Separator } from "../menu/components/menu-separator.svelte"; +export { default as RadioGroup } from "../menu/components/menu-radio-group.svelte"; +export { default as SubContent } from "../menu/components/menu-sub-content.svelte"; +export { default as SubTrigger } from "../menu/components/menu-sub-trigger.svelte"; +export { default as CheckboxItem } from "../menu/components/menu-checkbox-item.svelte"; +export { default as CheckboxGroup } from "../menu/components/menu-checkbox-group.svelte"; + +export type { + MenubarRootProps as RootProps, + MenubarMenuProps as MenuProps, + MenubarTriggerProps as TriggerProps, + MenubarContentProps as ContentProps, + MenubarItemProps as ItemProps, + MenubarGroupProps as GroupProps, + MenubarGroupHeadingProps as GroupHeadingProps, + MenubarCheckboxItemProps as CheckboxItemProps, + MenubarRadioGroupProps as RadioGroupProps, + MenubarRadioItemProps as RadioItemProps, + MenubarSeparatorProps as SeparatorProps, + MenubarSubContentProps as SubContentProps, + MenubarSubTriggerProps as SubTriggerProps, + MenubarArrowProps as ArrowProps, + MenubarCheckboxGroupProps as CheckboxGroupProps, +} from "./types"; diff --git a/src/uix/acel/menubar/index.ts b/src/uix/acel/menubar/index.ts new file mode 100644 index 000000000..fd0b17987 --- /dev/null +++ b/src/uix/acel/menubar/index.ts @@ -0,0 +1 @@ +export * from "./exports"; diff --git a/src/uix/acel/menubar/menubar.svelte.ts b/src/uix/acel/menubar/menubar.svelte.ts new file mode 100644 index 000000000..53b689988 --- /dev/null +++ b/src/uix/acel/menubar/menubar.svelte.ts @@ -0,0 +1,332 @@ +import { + type ReadableBox, + afterTick, + boxWith, + attachRef, + type ReadableBoxedValues, + type WritableBoxedValues, +} from "../utilities/index"; +import { Context, watch } from "runed"; +import type { Direction } from "../utilities/index"; +import { createAcelAttrs, boolToStr, boolToEmptyStrOrUndef, getDataOpenClosed } from "../utilities/attrs"; +import { kbd } from "../utilities/keyboard/kbd"; +import { wrapArray } from "../utilities/arrays"; +import type { OnChangeFn, RefAttachment, WithRefOpts } from "../utilities/types"; +import { onMount } from "svelte"; +import type { FocusEventHandler, KeyboardEventHandler, PointerEventHandler } from "svelte/elements"; +import { getFloatingContentCSSVars } from "../utilities/floating/floating-utils.svelte"; +import { RovingFocusGroup } from "../utilities/focus/roving-focus-group"; + +const menubarAttrs = createAcelAttrs({ + component: "menubar", + parts: ["root", "trigger", "content"], +}); + +const MenubarRootContext = new Context("Menubar.Root"); +const MenubarMenuContext = new Context("Menubar.Menu"); + +interface MenubarRootStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ + dir: Direction; + loop: boolean; + }>, + WritableBoxedValues<{ + value: string; + }> {} + +export class MenubarRootState { + static create(opts: MenubarRootStateOpts) { + return MenubarRootContext.set(new MenubarRootState(opts)); + } + readonly opts: MenubarRootStateOpts; + readonly rovingFocusGroup: RovingFocusGroup; + readonly attachment: RefAttachment; + wasOpenedByKeyboard = $state(false); + triggerIds = $state([]); + valueToChangeHandler = new Map>>(); + + constructor(opts: MenubarRootStateOpts) { + this.opts = opts; + this.attachment = attachRef(this.opts.ref); + this.rovingFocusGroup = new RovingFocusGroup({ + rootNode: this.opts.ref, + candidateAttr: menubarAttrs.trigger, + loop: this.opts.loop, + orientation: boxWith(() => "horizontal"), + }); + } + + registerTrigger = (id: string) => { + this.triggerIds.push(id); + return () => { this.triggerIds = this.triggerIds.filter((tid) => tid !== id); }; + }; + + registerMenu = (value: string, onOpenChange: ReadableBox>) => { + this.valueToChangeHandler.set(value, onOpenChange); + return () => { this.valueToChangeHandler.delete(value); }; + }; + + updateValue = (value: string) => { + const currValue = this.opts.value.current; + const currHandler = this.valueToChangeHandler.get(currValue)?.current; + const nextHandler = this.valueToChangeHandler.get(value)?.current; + this.opts.value.current = value; + if (currHandler && currValue !== value) currHandler(false); + if (nextHandler) nextHandler(true); + }; + + getTriggers = () => { + const node = this.opts.ref.current; + if (!node) return []; + return Array.from(node.querySelectorAll(menubarAttrs.selector("trigger"))); + }; + + onMenuOpen = (id: string, triggerId: string) => { + this.updateValue(id); + this.rovingFocusGroup.setCurrentTabStopId(triggerId); + }; + + onMenuClose = () => { this.updateValue(""); }; + + onMenuToggle = (id: string) => { this.updateValue(this.opts.value.current ? "" : id); }; + + readonly props = $derived.by( + () => ({ + id: this.opts.id.current, + role: "menubar", + [menubarAttrs.root]: "", + ...this.attachment, + }) as const + ); +} + +// ── MenubarMenuState ────────────────────────────────────────────────────────── + +interface MenubarMenuStateOpts + extends ReadableBoxedValues<{ + value: string; + onOpenChange: OnChangeFn; + }> {} + +export class MenubarMenuState { + static create(opts: MenubarMenuStateOpts) { + return MenubarMenuContext.set(new MenubarMenuState(opts, MenubarRootContext.get())); + } + readonly opts: MenubarMenuStateOpts; + readonly root: MenubarRootState; + open = $derived.by(() => this.root.opts.value.current === this.opts.value.current); + wasOpenedByKeyboard = false; + triggerNode = $state(null); + triggerId = $derived.by(() => this.triggerNode?.id); + contentId = $derived.by(() => this.contentNode?.id); + contentNode = $state(null); + + constructor(opts: MenubarMenuStateOpts, root: MenubarRootState) { + this.opts = opts; + this.root = root; + + watch( + () => this.open, + () => { if (!this.open) this.wasOpenedByKeyboard = false; } + ); + + onMount(() => this.root.registerMenu(this.opts.value.current, opts.onOpenChange)); + } + + getTriggerNode() { return this.triggerNode; } + + toggleMenu() { this.root.onMenuToggle(this.opts.value.current); } + + openMenu() { this.root.onMenuOpen(this.opts.value.current, this.triggerNode?.id ?? ""); } +} + +// ── MenubarTriggerState ─────────────────────────────────────────────────────── + +interface MenubarTriggerStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ disabled: boolean }> {} + +export class MenubarTriggerState { + static create(opts: MenubarTriggerStateOpts) { + return new MenubarTriggerState(opts, MenubarMenuContext.get()); + } + readonly opts: MenubarTriggerStateOpts; + readonly menu: MenubarMenuState; + readonly root: MenubarRootState; + readonly attachment: RefAttachment; + isFocused = $state(false); + #tabIndex = $state(0); + + constructor(opts: MenubarTriggerStateOpts, menu: MenubarMenuState) { + this.opts = opts; + this.menu = menu; + this.root = menu.root; + this.attachment = attachRef(this.opts.ref, (v) => (this.menu.triggerNode = v)); + + onMount(() => this.root.registerTrigger(opts.id.current)); + + $effect(() => { + if (this.root.triggerIds.length) { + this.#tabIndex = this.root.rovingFocusGroup.getTabIndex(this.menu.getTriggerNode()); + } + }); + } + + onpointerdown: PointerEventHandler = (e) => { + if (!this.opts.disabled.current && e.button === 0 && e.ctrlKey === false) { + if (!this.menu.open) e.preventDefault(); + this.menu.toggleMenu(); + } + }; + + onpointerenter: PointerEventHandler = () => { + const isMenubarOpen = Boolean(this.root.opts.value.current); + if (isMenubarOpen && !this.menu.open) { + this.menu.openMenu(); + this.menu.getTriggerNode()?.focus(); + } + }; + + onkeydown: KeyboardEventHandler = (e) => { + if (this.opts.disabled.current) return; + if (e.key === kbd.TAB) return; + if (e.key === kbd.ENTER || e.key === kbd.SPACE) { + this.root.onMenuToggle(this.menu.opts.value.current); + } + if (e.key === kbd.ARROW_DOWN) this.menu.openMenu(); + if (e.key === kbd.ENTER || e.key === kbd.SPACE || e.key === kbd.ARROW_DOWN) { + this.menu.wasOpenedByKeyboard = true; + e.preventDefault(); + } + this.root.rovingFocusGroup.handleKeydown(this.menu.getTriggerNode(), e); + }; + + onfocus: FocusEventHandler = () => { this.isFocused = true; }; + onblur: FocusEventHandler = () => { this.isFocused = false; }; + + readonly props = $derived.by( + () => ({ + type: "button", + role: "menuitem", + id: this.opts.id.current, + "aria-haspopup": "menu", + "aria-expanded": boolToStr(this.menu.open), + "aria-controls": this.menu.open ? this.menu.contentId : undefined, + "data-highlighted": this.isFocused ? "" : undefined, + "data-state": getDataOpenClosed(this.menu.open), + "data-disabled": boolToEmptyStrOrUndef(this.opts.disabled.current), + "data-menu-value": this.menu.opts.value.current, + disabled: this.opts.disabled.current ? true : undefined, + tabindex: this.#tabIndex, + [menubarAttrs.trigger]: "", + onpointerdown: this.onpointerdown, + onpointerenter: this.onpointerenter, + onkeydown: this.onkeydown, + onfocus: this.onfocus, + onblur: this.onblur, + ...this.attachment, + }) as const + ); +} + +// ── MenubarContentState ─────────────────────────────────────────────────────── + +interface MenubarContentStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ + onOpenAutoFocus: (e: Event) => void; + onCloseAutoFocus: (e: Event) => void; + onFocusOutside: (e: FocusEvent) => void; + onInteractOutside: (e: PointerEvent) => void; + }> {} + +export class MenubarContentState { + static create(opts: MenubarContentStateOpts) { + return new MenubarContentState(opts, MenubarMenuContext.get()); + } + readonly opts: MenubarContentStateOpts; + readonly menu: MenubarMenuState; + readonly root: MenubarRootState; + readonly attachment: RefAttachment; + + constructor(opts: MenubarContentStateOpts, menu: MenubarMenuState) { + this.opts = opts; + this.menu = menu; + this.root = menu.root; + this.attachment = attachRef(this.opts.ref, (v) => (this.menu.contentNode = v)); + } + + onCloseAutoFocus = (e: Event) => { + this.opts.onCloseAutoFocus.current?.(e); + if (e.defaultPrevented) return; + }; + + onFocusOutside = (e: FocusEvent) => { + const target = e.target as HTMLElement; + const isMenubarTrigger = this.root.getTriggers().some((trigger) => trigger.contains(target)); + if (isMenubarTrigger) e.preventDefault(); + this.opts.onFocusOutside.current(e); + }; + + onInteractOutside = (e: PointerEvent) => { + this.opts.onInteractOutside.current(e); + }; + + onOpenAutoFocus = (e: Event) => { + this.opts.onOpenAutoFocus.current(e); + if (e.defaultPrevented) return; + afterTick(() => this.opts.ref.current?.focus()); + }; + + onkeydown: KeyboardEventHandler = (e) => { + if (e.key !== kbd.ARROW_LEFT && e.key !== kbd.ARROW_RIGHT) return; + + const target = e.target as HTMLElement; + const targetIsSubTrigger = target.hasAttribute("data-menu-sub-trigger"); + const isKeydownInsideSubMenu = target.closest("[data-menu-content]") !== e.currentTarget; + + const prevMenuKey = this.root.opts.dir.current === "rtl" ? kbd.ARROW_RIGHT : kbd.ARROW_LEFT; + const isPrevKey = prevMenuKey === e.key; + const isNextKey = !isPrevKey; + + if (isNextKey && targetIsSubTrigger) return; + if (isKeydownInsideSubMenu && isPrevKey) return; + + const items = this.root.getTriggers().filter((trigger) => !trigger.disabled); + let candidates = items.map((item) => ({ + value: item.getAttribute("data-menu-value")!, + triggerId: item.id ?? "", + })); + if (isPrevKey) candidates.reverse(); + + const candidateValues = candidates.map(({ value }) => value); + const currentIndex = candidateValues.indexOf(this.menu.opts.value.current); + + candidates = this.root.opts.loop.current + ? wrapArray(candidates, currentIndex + 1) + : candidates.slice(currentIndex + 1); + + const [nextValue] = candidates; + if (nextValue) this.menu.root.onMenuOpen(nextValue.value, nextValue.triggerId); + }; + + props = $derived.by( + () => ({ + id: this.opts.id.current, + "aria-labelledby": this.menu.triggerId, + style: getFloatingContentCSSVars("menubar"), + onkeydown: this.onkeydown, + "data-menu-content": "", + [menubarAttrs.content]: "", + ...this.attachment, + }) as const + ); + + popperProps = { + onCloseAutoFocus: this.onCloseAutoFocus, + onFocusOutside: this.onFocusOutside, + onInteractOutside: this.onInteractOutside, + onOpenAutoFocus: this.onOpenAutoFocus, + }; +} diff --git a/src/uix/acel/menubar/types.ts b/src/uix/acel/menubar/types.ts new file mode 100644 index 000000000..a5873830b --- /dev/null +++ b/src/uix/acel/menubar/types.ts @@ -0,0 +1,94 @@ +import type { + AcelPrimitiveButtonAttributes, + AcelPrimitiveDivAttributes, +} from "../utilities/attributes"; +import type { OnChangeFn, WithChild, WithChildren, Without } from "../utilities/types"; +import type { Direction } from "../utilities/index"; +import type { + MenuArrowProps, + MenuCheckboxGroupProps, + MenuCheckboxGroupPropsWithoutHTML, + MenuCheckboxItemProps, + MenuCheckboxItemPropsWithoutHTML, + MenuContentProps, + MenuContentPropsWithoutHTML, + MenuGroupHeadingProps, + MenuGroupHeadingPropsWithoutHTML, + MenuGroupProps, + MenuGroupPropsWithoutHTML, + MenuItemProps, + MenuItemPropsWithoutHTML, + MenuRadioGroupProps, + MenuRadioGroupPropsWithoutHTML, + MenuRadioItemProps, + MenuRadioItemPropsWithoutHTML, + MenuSeparatorProps, + MenuSeparatorPropsWithoutHTML, + MenuSubContentProps, + MenuSubContentPropsWithoutHTML, + MenuSubProps, + MenuSubTriggerProps, + MenuSubTriggerPropsWithoutHTML, +} from "../menu/types"; + +export type MenubarRootPropsWithoutHTML = WithChild<{ + dir?: Direction; + loop?: boolean; + value?: string; + onValueChange?: OnChangeFn; +}>; + +export type MenubarRootProps = MenubarRootPropsWithoutHTML & + Without; + +export type MenubarMenuPropsWithoutHTML = WithChildren<{ + value?: string; + onOpenChange?: OnChangeFn; +}>; + +export type MenubarMenuProps = MenubarMenuPropsWithoutHTML; + +export type MenubarTriggerPropsWithoutHTML = WithChild<{ + disabled?: boolean | null | undefined; +}>; + +export type MenubarTriggerProps = MenubarTriggerPropsWithoutHTML & + Without; + +// Re-export menu types as Menubar* aliases +export type MenubarContentPropsWithoutHTML = MenuContentPropsWithoutHTML; +export type MenubarContentProps = MenuContentProps; + +export type MenubarItemPropsWithoutHTML = MenuItemPropsWithoutHTML; +export type MenubarItemProps = MenuItemProps; + +export type MenubarGroupPropsWithoutHTML = MenuGroupPropsWithoutHTML; +export type MenubarGroupProps = MenuGroupProps; + +export type MenubarGroupHeadingPropsWithoutHTML = MenuGroupHeadingPropsWithoutHTML; +export type MenubarGroupHeadingProps = MenuGroupHeadingProps; + +export type MenubarCheckboxItemPropsWithoutHTML = MenuCheckboxItemPropsWithoutHTML; +export type MenubarCheckboxItemProps = MenuCheckboxItemProps; + +export type MenubarRadioGroupPropsWithoutHTML = MenuRadioGroupPropsWithoutHTML; +export type MenubarRadioGroupProps = MenuRadioGroupProps; + +export type MenubarRadioItemPropsWithoutHTML = MenuRadioItemPropsWithoutHTML; +export type MenubarRadioItemProps = MenuRadioItemProps; + +export type MenubarSeparatorPropsWithoutHTML = MenuSeparatorPropsWithoutHTML; +export type MenubarSeparatorProps = MenuSeparatorProps; + +export type MenubarSubContentPropsWithoutHTML = MenuSubContentPropsWithoutHTML; +export type MenubarSubContentProps = MenuSubContentProps; + +export type MenubarSubPropsWithoutHTML = MenuSubProps; + +export type MenubarSubTriggerPropsWithoutHTML = MenuSubTriggerPropsWithoutHTML; +export type MenubarSubTriggerProps = MenuSubTriggerProps; + +export type MenubarArrowProps = MenuArrowProps; + +export type MenubarCheckboxGroupPropsWithoutHTML = MenuCheckboxGroupPropsWithoutHTML; +export type MenubarCheckboxGroupProps = MenuCheckboxGroupProps; diff --git a/src/uix/acel/meter/components/meter.svelte b/src/uix/acel/meter/components/meter.svelte new file mode 100644 index 000000000..786ab1e48 --- /dev/null +++ b/src/uix/acel/meter/components/meter.svelte @@ -0,0 +1,37 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/acel/meter/exports.ts b/src/uix/acel/meter/exports.ts new file mode 100644 index 000000000..d0a7c2229 --- /dev/null +++ b/src/uix/acel/meter/exports.ts @@ -0,0 +1,2 @@ +export { default as Root } from "./components/meter.svelte"; +export type { MeterRootProps as RootProps } from "./types"; diff --git a/src/uix/acel/meter/index.ts b/src/uix/acel/meter/index.ts new file mode 100644 index 000000000..2d996ed51 --- /dev/null +++ b/src/uix/acel/meter/index.ts @@ -0,0 +1,2 @@ +export * as Meter from "./exports"; +export * from "./exports"; diff --git a/src/uix/acel/meter/meter.svelte.ts b/src/uix/acel/meter/meter.svelte.ts new file mode 100644 index 000000000..10adb0b6f --- /dev/null +++ b/src/uix/acel/meter/meter.svelte.ts @@ -0,0 +1,48 @@ +import { attachRef, type ReadableBoxedValues } from "../utilities/index"; +import type { RefAttachment, WithRefOpts } from "../utilities/types"; +import { createAcelAttrs } from "../utilities/attrs"; + +const meterAttrs = createAcelAttrs({ + component: "meter", + parts: ["root"] as const, +}); + +interface MeterRootStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ + value: number; + max: number; + min: number; + }> {} + +export class MeterRootState { + static create(opts: MeterRootStateOpts) { + return new MeterRootState(opts); + } + + readonly opts: MeterRootStateOpts; + readonly attachment: RefAttachment; + + constructor(opts: MeterRootStateOpts) { + this.opts = opts; + this.attachment = attachRef(this.opts.ref); + } + + readonly props = $derived.by(() => { + const value = this.opts.value.current; + const max = this.opts.max.current; + const min = this.opts.min.current; + return { + id: this.opts.id.current, + role: "meter", + "aria-valuemin": min, + "aria-valuemax": max, + "aria-valuenow": value, + "data-value": value, + "data-max": max, + "data-min": min, + [meterAttrs.root]: "", + ...this.attachment, + } as const; + }); +} diff --git a/src/uix/acel/meter/types.ts b/src/uix/acel/meter/types.ts new file mode 100644 index 000000000..c40ff84a5 --- /dev/null +++ b/src/uix/acel/meter/types.ts @@ -0,0 +1,23 @@ +import type { WithChild, Without } from "../utilities/types"; +import type { AcelPrimitiveDivAttributes } from "../utilities/attributes"; + +export type MeterRootPropsWithoutHTML = WithChild<{ + /** + * El valor actual del medidor. + * @defaultValue 0 + */ + value?: number; + /** + * El valor máximo del medidor. + * @defaultValue 100 + */ + max?: number; + /** + * El valor mínimo del medidor. + * @defaultValue 0 + */ + min?: number; +}>; + +export type MeterRootProps = MeterRootPropsWithoutHTML & + Without; diff --git a/src/uix/acel/pagination/components/pagination-next-button.svelte b/src/uix/acel/pagination/components/pagination-next-button.svelte new file mode 100644 index 000000000..be9cb586b --- /dev/null +++ b/src/uix/acel/pagination/components/pagination-next-button.svelte @@ -0,0 +1,31 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/acel/pagination/components/pagination-page.svelte b/src/uix/acel/pagination/components/pagination-page.svelte new file mode 100644 index 000000000..0e705372e --- /dev/null +++ b/src/uix/acel/pagination/components/pagination-page.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/acel/pagination/components/pagination-prev-button.svelte b/src/uix/acel/pagination/components/pagination-prev-button.svelte new file mode 100644 index 000000000..e522c8b0e --- /dev/null +++ b/src/uix/acel/pagination/components/pagination-prev-button.svelte @@ -0,0 +1,31 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/acel/pagination/components/pagination.svelte b/src/uix/acel/pagination/components/pagination.svelte new file mode 100644 index 000000000..785535638 --- /dev/null +++ b/src/uix/acel/pagination/components/pagination.svelte @@ -0,0 +1,46 @@ + + +{#if child} + {@render child({ props: mergedProps, pages: rootState.pages, totalPages: rootState.totalPages })} +{:else} +
+ {@render children?.({ pages: rootState.pages, totalPages: rootState.totalPages })} +
+{/if} diff --git a/src/uix/acel/pagination/exports.ts b/src/uix/acel/pagination/exports.ts new file mode 100644 index 000000000..777560176 --- /dev/null +++ b/src/uix/acel/pagination/exports.ts @@ -0,0 +1,10 @@ +export { default as Root } from "./components/pagination.svelte"; +export { default as Page } from "./components/pagination-page.svelte"; +export { default as PrevButton } from "./components/pagination-prev-button.svelte"; +export { default as NextButton } from "./components/pagination-next-button.svelte"; +export type { + PaginationRootProps as RootProps, + PaginationPageProps as PageProps, + PaginationPrevButtonProps as PrevButtonProps, + PaginationNextButtonProps as NextButtonProps, +} from "./types"; diff --git a/src/uix/acel/pagination/index.ts b/src/uix/acel/pagination/index.ts new file mode 100644 index 000000000..81064ffdf --- /dev/null +++ b/src/uix/acel/pagination/index.ts @@ -0,0 +1,2 @@ +export * as Pagination from "./exports"; +export * from "./exports"; diff --git a/src/uix/acel/pagination/pagination.svelte.ts b/src/uix/acel/pagination/pagination.svelte.ts new file mode 100644 index 000000000..457190e24 --- /dev/null +++ b/src/uix/acel/pagination/pagination.svelte.ts @@ -0,0 +1,255 @@ +import { + attachRef, + type ReadableBoxedValues, + type WritableBoxedValues, +} from "../utilities/index"; +import type { RefAttachment, WithRefOpts, AcelKeyboardEvent, AcelMouseEvent } from "../utilities/types"; +import { createAcelAttrs } from "../utilities/attrs"; +import { Context } from "runed"; +import { kbd } from "../utilities/keyboard/kbd"; +import { getDirectionalKeys } from "../utilities/keyboard/get-directional-keys"; +import { getElemDirection } from "../utilities/dom/locale"; +import { noop } from "../utilities/noop"; + +const paginationAttrs = createAcelAttrs({ + component: "pagination", + parts: ["root", "page", "prev-button", "next-button"] as const, +}); + +// ── Types ───────────────────────────────────────────────────────────────────── + +export type PageItem = + | { type: "page"; value: number } + | { type: "ellipsis" }; + +// ── Root ────────────────────────────────────────────────────────────────────── + +const PaginationRootContext = new Context("Pagination.Root"); + +interface PaginationRootStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ + count: number; + perPage: number; + siblingCount: number; + orientation: "horizontal" | "vertical"; + loop: boolean; + onPageChange: (page: number) => void; + }>, + WritableBoxedValues<{ page: number }> {} + +export class PaginationRootState { + static create(opts: PaginationRootStateOpts) { + return PaginationRootContext.set(new PaginationRootState(opts)); + } + + readonly opts: PaginationRootStateOpts; + readonly attachment: RefAttachment; + + constructor(opts: PaginationRootStateOpts) { + this.opts = opts; + this.attachment = attachRef(opts.ref); + } + + readonly totalPages = $derived.by(() => + Math.max(1, Math.ceil(this.opts.count.current / this.opts.perPage.current)) + ); + + setPage(page: number) { + if (page < 1 || page > this.totalPages) return; + this.opts.page.current = page; + this.opts.onPageChange.current(page); + } + + prevPage() { + const cur = this.opts.page.current; + if (cur > 1) this.setPage(cur - 1); + else if (this.opts.loop.current) this.setPage(this.totalPages); + } + + nextPage() { + const cur = this.opts.page.current; + if (cur < this.totalPages) this.setPage(cur + 1); + else if (this.opts.loop.current) this.setPage(1); + } + + readonly pages = $derived.by((): PageItem[] => { + const total = this.totalPages; + const current = this.opts.page.current; + const sibling = this.opts.siblingCount.current; + + if (total <= 1) return [{ type: "page", value: 1 }]; + + const rangeStart = Math.max(2, current - sibling); + const rangeEnd = Math.min(total - 1, current + sibling); + + const items: PageItem[] = []; + items.push({ type: "page", value: 1 }); + + if (rangeStart > 2) items.push({ type: "ellipsis" }); + + for (let i = rangeStart; i <= rangeEnd; i++) { + items.push({ type: "page", value: i }); + } + + if (rangeEnd < total - 1) items.push({ type: "ellipsis" }); + + if (total > 1) items.push({ type: "page", value: total }); + + return items; + }); + + readonly props = $derived.by(() => ({ + id: this.opts.id.current, + "data-orientation": this.opts.orientation.current, + [paginationAttrs.root]: "", + ...this.attachment, + }) as const); +} + +// ── Page ────────────────────────────────────────────────────────────────────── + +interface PaginationPageStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ page: number; disabled: boolean }> {} + +export class PaginationPageState { + static create(opts: PaginationPageStateOpts) { + return new PaginationPageState(opts, PaginationRootContext.get()); + } + + readonly opts: PaginationPageStateOpts; + readonly root: PaginationRootState; + readonly attachment: RefAttachment; + + constructor(opts: PaginationPageStateOpts, root: PaginationRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(opts.ref); + this.onclick = this.onclick.bind(this); + this.onkeydown = this.onkeydown.bind(this); + } + + readonly #isSelected = $derived.by( + () => this.root.opts.page.current === this.opts.page.current + ); + + onclick(_: AcelMouseEvent) { + if (this.opts.disabled.current) return; + this.root.setPage(this.opts.page.current); + } + + onkeydown(e: AcelKeyboardEvent) { + if (this.opts.disabled.current) return; + if (e.key === kbd.ENTER || e.key === kbd.SPACE) { + e.preventDefault(); + this.root.setPage(this.opts.page.current); + return; + } + const dir = getElemDirection(e.currentTarget); + const { nextKey, prevKey } = getDirectionalKeys(dir, this.root.opts.orientation.current); + if (e.key === nextKey) { + e.preventDefault(); + this.root.nextPage(); + } else if (e.key === prevKey) { + e.preventDefault(); + this.root.prevPage(); + } + } + + readonly props = $derived.by(() => { + const isSelected = this.#isSelected; + return { + id: this.opts.id.current, + type: "button" as const, + "aria-label": `Page ${this.opts.page.current}`, + "aria-current": isSelected ? ("page" as const) : undefined, + "data-selected": isSelected ? "" : undefined, + "data-page": this.opts.page.current, + onclick: this.onclick, + onkeydown: this.onkeydown, + [paginationAttrs.page]: "", + ...this.attachment, + } as const; + }); +} + +// ── Prev Button ─────────────────────────────────────────────────────────────── + +interface PaginationButtonStateOpts extends WithRefOpts {} + +export class PaginationPrevButtonState { + static create(opts: PaginationButtonStateOpts) { + return new PaginationPrevButtonState(opts, PaginationRootContext.get()); + } + + readonly opts: PaginationButtonStateOpts; + readonly root: PaginationRootState; + readonly attachment: RefAttachment; + + constructor(opts: PaginationButtonStateOpts, root: PaginationRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(opts.ref); + this.onclick = this.onclick.bind(this); + } + + readonly #isDisabled = $derived.by( + () => this.root.opts.page.current <= 1 && !this.root.opts.loop.current + ); + + onclick(_: AcelMouseEvent) { + if (this.#isDisabled) return; + this.root.prevPage(); + } + + readonly props = $derived.by(() => ({ + id: this.opts.id.current, + type: "button" as const, + "aria-label": "Go to previous page", + disabled: this.#isDisabled, + "data-disabled": this.#isDisabled ? "" : undefined, + onclick: this.onclick, + [paginationAttrs["prev-button"]]: "", + ...this.attachment, + }) as const); +} + +// ── Next Button ─────────────────────────────────────────────────────────────── + +export class PaginationNextButtonState { + static create(opts: PaginationButtonStateOpts) { + return new PaginationNextButtonState(opts, PaginationRootContext.get()); + } + + readonly opts: PaginationButtonStateOpts; + readonly root: PaginationRootState; + readonly attachment: RefAttachment; + + constructor(opts: PaginationButtonStateOpts, root: PaginationRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(opts.ref); + this.onclick = this.onclick.bind(this); + } + + readonly #isDisabled = $derived.by( + () => this.root.opts.page.current >= this.root.totalPages && !this.root.opts.loop.current + ); + + onclick(_: AcelMouseEvent) { + if (this.#isDisabled) return; + this.root.nextPage(); + } + + readonly props = $derived.by(() => ({ + id: this.opts.id.current, + type: "button" as const, + "aria-label": "Go to next page", + disabled: this.#isDisabled, + "data-disabled": this.#isDisabled ? "" : undefined, + onclick: this.onclick, + [paginationAttrs["next-button"]]: "", + ...this.attachment, + }) as const); +} diff --git a/src/uix/acel/pagination/types.ts b/src/uix/acel/pagination/types.ts new file mode 100644 index 000000000..54fa74a37 --- /dev/null +++ b/src/uix/acel/pagination/types.ts @@ -0,0 +1,48 @@ +import type { WithChild, Without } from "../utilities/types"; +import type { + AcelPrimitiveDivAttributes, + AcelPrimitiveButtonAttributes, + AcelPrimitiveElementAttributes, +} from "../utilities/attributes"; + +// ── Root ────────────────────────────────────────────────────────────────────── + +export type PaginationRootPropsWithoutHTML = WithChild<{ + /** Número total de elementos. */ + count: number; + /** Elementos por página. @defaultValue 10 */ + perPage?: number; + /** Páginas hermanas a mostrar alrededor de la página actual. @defaultValue 1 */ + siblingCount?: number; + /** Página actual (bindable). @defaultValue 1 */ + page?: number; + /** Callback al cambiar de página. */ + onPageChange?: (page: number) => void; + /** Orientación para navegación con teclado. @defaultValue "horizontal" */ + orientation?: "horizontal" | "vertical"; + /** Si debe hacer loop al llegar al final. @defaultValue false */ + loop?: boolean; +}>; + +export type PaginationRootProps = PaginationRootPropsWithoutHTML & + Without; + +// ── Page ────────────────────────────────────────────────────────────────────── + +export type PaginationPagePropsWithoutHTML = WithChild<{ + page: number; + disabled?: boolean; +}, {}, HTMLButtonElement>; + +export type PaginationPageProps = PaginationPagePropsWithoutHTML & + Without; + +// ── Prev / Next Button ──────────────────────────────────────────────────────── + +export type PaginationButtonPropsWithoutHTML = WithChild<{}, {}, HTMLButtonElement>; + +export type PaginationPrevButtonProps = PaginationButtonPropsWithoutHTML & + Without; + +export type PaginationNextButtonProps = PaginationButtonPropsWithoutHTML & + Without; diff --git a/src/uix/acel/pin-input/components/pin-input-cell.svelte b/src/uix/acel/pin-input/components/pin-input-cell.svelte new file mode 100644 index 000000000..96b8d3922 --- /dev/null +++ b/src/uix/acel/pin-input/components/pin-input-cell.svelte @@ -0,0 +1,33 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/acel/pin-input/components/pin-input.svelte b/src/uix/acel/pin-input/components/pin-input.svelte new file mode 100644 index 000000000..ecded3cff --- /dev/null +++ b/src/uix/acel/pin-input/components/pin-input.svelte @@ -0,0 +1,63 @@ + + +
+ {@render children?.(rootState.snippetProps)} + +
+ +
+
diff --git a/src/uix/acel/pin-input/exports.ts b/src/uix/acel/pin-input/exports.ts new file mode 100644 index 000000000..3b20d4819 --- /dev/null +++ b/src/uix/acel/pin-input/exports.ts @@ -0,0 +1,3 @@ +export { default as Root } from "./components/pin-input.svelte"; +export { default as Cell } from "./components/pin-input-cell.svelte"; +export { REGEXP_ONLY_DIGITS, REGEXP_ONLY_CHARS, REGEXP_ONLY_DIGITS_AND_CHARS } from "./pin-input.svelte"; diff --git a/src/uix/acel/pin-input/index.ts b/src/uix/acel/pin-input/index.ts new file mode 100644 index 000000000..9a8ff24be --- /dev/null +++ b/src/uix/acel/pin-input/index.ts @@ -0,0 +1,2 @@ +export * from "./exports"; +export * from "./types"; diff --git a/src/uix/acel/pin-input/pin-input.svelte.ts b/src/uix/acel/pin-input/pin-input.svelte.ts new file mode 100644 index 000000000..680553d82 --- /dev/null +++ b/src/uix/acel/pin-input/pin-input.svelte.ts @@ -0,0 +1,559 @@ +import { Previous, watch } from "runed"; +import { onMount } from "svelte"; +import { + type WritableBox, + attachRef, + DOMContext, + type ReadableBoxedValues, + type WritableBoxedValues, + simpleBox, + getWindow, +} from "../utilities/index"; +import { usePasswordManagerBadge } from "./usePasswordManager.svelte"; +import type { PinInputCell, PinInputRootPropsWithoutHTML } from "./types"; +import type { + AcelEvent, + AcelFocusEvent, + AcelKeyboardEvent, + AcelMouseEvent, + RefAttachment, + WithRefOpts, +} from "../utilities/types"; +import { createAcelAttrs, boolToTrueOrUndef } from "../utilities/attrs"; +import { on } from "svelte/events"; + +export const REGEXP_ONLY_DIGITS = "^\\d+$"; +export const REGEXP_ONLY_CHARS = "^[a-zA-Z]+$"; +export const REGEXP_ONLY_DIGITS_AND_CHARS = "^[a-zA-Z0-9]+$"; + +const pinInputAttrs = createAcelAttrs({ + component: "pin-input", + parts: ["root", "cell"], +}); + +interface PinInputRootStateOpts + extends WithRefOpts, + WritableBoxedValues<{ + value: string; + }>, + ReadableBoxedValues<{ + inputId: string; + disabled: boolean; + // oxlint-disable-next-line no-explicit-any + onComplete: (...args: any[]) => void; + pasteTransformer?: (text: string) => string; + // oxlint-disable-next-line no-explicit-any + pattern: any; + maxLength: number; + pushPasswordManagerStrategy: "increase-width" | "none"; + textAlign: PinInputRootPropsWithoutHTML["textalign"]; + autocomplete: PinInputRootPropsWithoutHTML["autocomplete"]; + inputmode: PinInputRootPropsWithoutHTML["inputmode"]; + }> {} + +interface PrevInputMetadata { + prev: [number | null, number | null, "none" | "forward" | "backward"]; + willSyntheticBlur: boolean; +} +interface InitialLoad { + value: WritableBox; + isIOS: boolean; +} + +const KEYS_TO_IGNORE = [ + "Backspace", + "Delete", + "ArrowLeft", + "ArrowRight", + "ArrowUp", + "ArrowDown", + "Home", + "End", + "Escape", + "Enter", + "Tab", + "Shift", + "Control", + "Meta", +]; + +export class PinInputRootState { + static create(opts: PinInputRootStateOpts) { + return new PinInputRootState(opts); + } + + readonly opts: PinInputRootStateOpts; + readonly attachment: RefAttachment; + #inputRef = simpleBox(null); + #isHoveringInput = $state(false); + readonly inputAttachment: RefAttachment = attachRef(this.#inputRef); + #isFocused = simpleBox(false); + #mirrorSelectionStart = $state(null); + #mirrorSelectionEnd = $state(null); + + #previousValue = new Previous(() => this.opts.value.current ?? ""); + + readonly #regexPattern = $derived.by(() => { + if (typeof this.opts.pattern.current === "string") { + return new RegExp(this.opts.pattern.current); + } else { + return this.opts.pattern.current; + } + }); + #prevInputMetadata = $state({ + prev: [null, null, "none"], + willSyntheticBlur: false, + }); + #pwmb: ReturnType; + #initialLoad: InitialLoad; + domContext: DOMContext; + + constructor(opts: PinInputRootStateOpts) { + this.opts = opts; + this.attachment = attachRef(this.opts.ref); + this.domContext = new DOMContext(opts.ref); + + this.#initialLoad = { + value: this.opts.value, + isIOS: + typeof window !== "undefined" && + window?.CSS?.supports("-webkit-touch-callout", "none"), + }; + + this.#pwmb = usePasswordManagerBadge({ + containerRef: this.opts.ref, + inputRef: this.#inputRef, + isFocused: this.#isFocused, + pushPasswordManagerStrategy: this.opts.pushPasswordManagerStrategy, + domContext: this.domContext, + }); + + onMount(() => { + const input = this.#inputRef.current; + const container = this.opts.ref.current; + + if (!input || !container) return; + + if (this.#initialLoad.value.current !== input.value) { + this.opts.value.current = input.value; + } + + this.#prevInputMetadata.prev = [ + input.selectionStart, + input.selectionEnd, + input.selectionDirection ?? "none", + ]; + + const unsub = on( + this.domContext.getDocument(), + "selectionchange", + this.#onDocumentSelectionChange, + { capture: true } + ); + + this.#onDocumentSelectionChange(); + if (this.domContext.getActiveElement() === input) { + this.#isFocused.current = true; + } + + if (!this.domContext.getElementById("pin-input-style")) { + this.#applyStyles(); + } + + const updateRootHeight = () => { + if (container) { + container.style.setProperty( + "--acel-pin-input-root-height", + `${input.clientHeight}px` + ); + } + }; + updateRootHeight(); + + const resizeObserver = new ResizeObserver(updateRootHeight); + resizeObserver.observe(input); + + return () => { + unsub(); + resizeObserver.disconnect(); + }; + }); + + watch([() => this.opts.value.current, () => this.#inputRef.current], () => { + syncTimeouts(() => { + const input = this.#inputRef.current; + if (!input) return; + input.dispatchEvent(new Event("input")); + + const start = input.selectionStart; + const end = input.selectionEnd; + const dir = input.selectionDirection ?? "none"; + if (start !== null && end !== null) { + this.#mirrorSelectionStart = start; + this.#mirrorSelectionEnd = end; + this.#prevInputMetadata.prev = [start, end, dir]; + } + }, this.domContext); + }); + + $effect(() => { + const value = this.opts.value.current; + const prevValue = this.#previousValue.current; + const maxLength = this.opts.maxLength.current; + const onComplete = this.opts.onComplete.current; + + if (prevValue === undefined) return; + if (value !== prevValue && prevValue.length < maxLength && value.length === maxLength) { + onComplete(value); + } + }); + } + + onkeydown = (e: AcelKeyboardEvent) => { + const key = e.key; + if (KEYS_TO_IGNORE.includes(key)) return; + if (e.ctrlKey || e.metaKey) return; + if (key && this.#regexPattern && !this.#regexPattern.test(key)) { + e.preventDefault(); + } + }; + + readonly #rootStyles = $derived.by(() => ({ + position: "relative", + cursor: this.opts.disabled.current ? "default" : "text", + userSelect: "none", + WebkitUserSelect: "none", + pointerEvents: "none", + })); + + readonly rootProps = $derived.by( + () => + ({ + id: this.opts.id.current, + [pinInputAttrs.root]: "", + style: this.#rootStyles, + ...this.attachment, + }) as const + ); + + readonly inputWrapperProps = $derived.by( + () => + ({ + style: { + position: "absolute", + inset: 0, + pointerEvents: "none", + }, + }) as const + ); + + readonly #inputStyle = $derived.by(() => ({ + position: "absolute", + inset: 0, + width: this.#pwmb.willPushPwmBadge + ? `calc(100% + ${this.#pwmb.PWM_BADGE_SPACE_WIDTH})` + : "100%", + clipPath: this.#pwmb.willPushPwmBadge + ? `inset(0 ${this.#pwmb.PWM_BADGE_SPACE_WIDTH} 0 0)` + : undefined, + height: "100%", + display: "flex", + textAlign: this.opts.textAlign.current, + opacity: "1", + color: "transparent", + pointerEvents: "all", + background: "transparent", + caretColor: "transparent", + border: "0 solid transparent", + outline: "0 solid transparent", + boxShadow: "none", + lineHeight: "1", + letterSpacing: "-.5em", + fontSize: "var(--acel-pin-input-root-height)", + fontFamily: "monospace", + fontVariantNumeric: "tabular-nums", + })); + + #applyStyles() { + const doc = this.domContext.getDocument(); + const styleEl = doc.createElement("style"); + styleEl.id = "pin-input-style"; + doc.head.appendChild(styleEl); + + if (styleEl.sheet) { + const autoFillStyles = + "background: transparent !important; color: transparent !important; border-color: transparent !important; opacity: 0 !important; box-shadow: none !important; -webkit-box-shadow: none !important; -webkit-text-fill-color: transparent !important;"; + + safeInsertRule( + styleEl.sheet, + "[data-pin-input-input]::selection { background: transparent !important; color: transparent !important; }" + ); + safeInsertRule(styleEl.sheet, `[data-pin-input-input]:autofill { ${autoFillStyles} }`); + safeInsertRule( + styleEl.sheet, + `[data-pin-input-input]:-webkit-autofill { ${autoFillStyles} }` + ); + safeInsertRule( + styleEl.sheet, + `@supports (-webkit-touch-callout: none) { [data-pin-input-input] { letter-spacing: -.6em !important; font-weight: 100 !important; font-stretch: ultra-condensed; font-optical-sizing: none !important; left: -1px !important; right: 1px !important; } }` + ); + safeInsertRule( + styleEl.sheet, + `[data-pin-input-input] + * { pointer-events: all !important; }` + ); + } + } + + #onDocumentSelectionChange = () => { + const input = this.#inputRef.current; + const container = this.opts.ref.current; + if (!input || !container) return; + + if (this.domContext.getActiveElement() !== input) { + this.#mirrorSelectionStart = null; + this.#mirrorSelectionEnd = null; + return; + } + + const selStart = input.selectionStart; + const selEnd = input.selectionEnd; + const selDir = input.selectionDirection ?? "none"; + const maxLength = input.maxLength; + const val = input.value; + const prev = this.#prevInputMetadata.prev; + + let start = -1; + let end = -1; + let direction: "forward" | "backward" | "none" | undefined; + if (val.length !== 0 && selStart !== null && selEnd !== null) { + const isSingleCaret = selStart === selEnd; + const isInsertMode = selStart === val.length && val.length < maxLength; + + if (isSingleCaret && !isInsertMode) { + const c = selStart; + if (c === 0) { + start = 0; + end = 1; + direction = "forward"; + } else if (c === maxLength) { + start = c - 1; + end = c; + direction = "backward"; + } else if (maxLength > 1 && val.length > 1) { + let offset = 0; + if (prev[0] !== null && prev[1] !== null) { + direction = c < prev[1] ? "backward" : "forward"; + const wasPreviouslyInserting = prev[0] === prev[1] && prev[0] < maxLength; + if (direction === "backward" && !wasPreviouslyInserting) { + offset = -1; + } + } + start = offset + c; + end = offset + c + 1; + } + } + + if (start !== -1 && end !== -1 && start !== end) { + this.#inputRef.current?.setSelectionRange(start, end, direction); + } + } + + const s = start !== -1 ? start : selStart; + const e = end !== -1 ? end : selEnd; + const dir = direction ?? selDir; + this.#mirrorSelectionStart = s; + this.#mirrorSelectionEnd = e; + this.#prevInputMetadata.prev = [s, e, dir]; + }; + + oninput = (e: AcelEvent) => { + const newValue = e.currentTarget.value.slice(0, this.opts.maxLength.current); + if (newValue.length > 0 && this.#regexPattern && !this.#regexPattern.test(newValue)) { + e.preventDefault(); + return; + } + + const maybeHasDeleted = + typeof this.#previousValue.current === "string" && + newValue.length < this.#previousValue.current.length; + + if (maybeHasDeleted) { + this.domContext.getDocument().dispatchEvent(new Event("selectionchange")); + } + this.opts.value.current = newValue; + }; + + onfocus = (_: AcelFocusEvent) => { + const input = this.#inputRef.current; + if (input) { + const start = Math.min(input.value.length, this.opts.maxLength.current - 1); + const end = input.value.length; + input.setSelectionRange(start, end); + this.#mirrorSelectionStart = start; + this.#mirrorSelectionEnd = end; + } + this.#isFocused.current = true; + }; + + onpaste = (e: AcelEvent) => { + const input = this.#inputRef.current; + if (!input) return; + + const getNewValue = (finalContent: string | undefined) => { + const start = input.selectionStart === null ? undefined : input.selectionStart; + const end = input.selectionEnd === null ? undefined : input.selectionEnd; + const isReplacing = start !== end; + const initNewVal = this.opts.value.current; + const newValueUncapped = isReplacing + ? initNewVal.slice(0, start) + finalContent + initNewVal.slice(end) + : initNewVal.slice(0, start) + finalContent + initNewVal.slice(start); + return newValueUncapped.slice(0, this.opts.maxLength.current); + }; + + const isValueInvalid = (newValue: string) => { + return newValue.length > 0 && this.#regexPattern && !this.#regexPattern.test(newValue); + }; + + if ( + !this.opts.pasteTransformer?.current && + (!this.#initialLoad.isIOS || !e.clipboardData || !input) + ) { + const newValue = getNewValue(e.clipboardData?.getData("text/plain")); + if (isValueInvalid(newValue)) { + e.preventDefault(); + } + return; + } + + const _content = e.clipboardData?.getData("text/plain") ?? ""; + const content = this.opts.pasteTransformer?.current + ? this.opts.pasteTransformer.current(_content) + : _content; + e.preventDefault(); + + const newValue = getNewValue(content); + if (isValueInvalid(newValue)) return; + + input.value = newValue; + this.opts.value.current = newValue; + + const selStart = Math.min(newValue.length, this.opts.maxLength.current - 1); + const selEnd = newValue.length; + input.setSelectionRange(selStart, selEnd); + this.#mirrorSelectionStart = selStart; + this.#mirrorSelectionEnd = selEnd; + }; + + onmouseover = (_: AcelMouseEvent) => { + this.#isHoveringInput = true; + }; + + onmouseleave = (_: AcelMouseEvent) => { + this.#isHoveringInput = false; + }; + + onblur = (_: AcelFocusEvent) => { + if (this.#prevInputMetadata.willSyntheticBlur) { + this.#prevInputMetadata.willSyntheticBlur = false; + return; + } + this.#isFocused.current = false; + }; + + readonly inputProps = $derived.by(() => ({ + id: this.opts.inputId.current, + style: this.#inputStyle, + autocomplete: this.opts.autocomplete.current || "one-time-code", + "data-pin-input-input": "", + "data-pin-input-input-mss": this.#mirrorSelectionStart, + "data-pin-input-input-mse": this.#mirrorSelectionEnd, + inputmode: this.opts.inputmode.current, + pattern: this.#regexPattern?.source, + maxlength: this.opts.maxLength.current, + value: this.opts.value.current, + disabled: boolToTrueOrUndef(this.opts.disabled.current), + onpaste: this.onpaste, + oninput: this.oninput, + onkeydown: this.onkeydown, + onmouseover: this.onmouseover, + onmouseleave: this.onmouseleave, + onfocus: this.onfocus, + onblur: this.onblur, + ...this.inputAttachment, + })); + + readonly #cells = $derived.by(() => + Array.from({ length: this.opts.maxLength.current }).map((_, idx) => { + const isActive = + this.#isFocused.current && + this.#mirrorSelectionStart !== null && + this.#mirrorSelectionEnd !== null && + ((this.#mirrorSelectionStart === this.#mirrorSelectionEnd && + idx === this.#mirrorSelectionStart) || + (idx >= this.#mirrorSelectionStart && idx < this.#mirrorSelectionEnd)); + + const char = + this.opts.value.current[idx] !== undefined ? this.opts.value.current[idx] : null; + + return { + char, + isActive, + hasFakeCaret: isActive && char === null, + } satisfies PinInputCell; + }) + ); + + readonly snippetProps = $derived.by(() => ({ + cells: this.#cells, + isFocused: this.#isFocused.current, + isHovering: this.#isHoveringInput, + })); +} + +interface PinInputCellStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ + cell: PinInputCell; + }> {} + +export class PinInputCellState { + static create(opts: PinInputCellStateOpts) { + return new PinInputCellState(opts); + } + + readonly opts: PinInputCellStateOpts; + readonly attachment: RefAttachment; + + constructor(opts: PinInputCellStateOpts) { + this.opts = opts; + this.attachment = attachRef(this.opts.ref); + } + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + [pinInputAttrs.cell]: "", + "data-active": this.opts.cell.current.isActive ? "" : undefined, + "data-inactive": !this.opts.cell.current.isActive ? "" : undefined, + ...this.attachment, + }) as const + ); +} + +// oxlint-disable-next-line no-explicit-any +export function syncTimeouts(cb: (...args: any[]) => unknown, domContext: DOMContext): number[] { + const t1 = domContext.setTimeout(cb, 0); + const t2 = domContext.setTimeout(cb, 10); + const t3 = domContext.setTimeout(cb, 50); + return [t1, t2, t3]; +} + +function safeInsertRule(sheet: CSSStyleSheet, rule: string) { + try { + sheet.insertRule(rule); + } catch { + // oxlint-disable-next-line no-console + console.error("pin input could not insert CSS rule:", rule); + } +} diff --git a/src/uix/acel/pin-input/types.ts b/src/uix/acel/pin-input/types.ts new file mode 100644 index 000000000..e29ba475b --- /dev/null +++ b/src/uix/acel/pin-input/types.ts @@ -0,0 +1,48 @@ +import type { Snippet } from "svelte"; +import type { OnChangeFn, WithChild, Without } from "../utilities/types"; +import type { + AcelPrimitiveDivAttributes, + AcelPrimitiveInputAttributes, +} from "../utilities/attributes"; + +export type PinInputCell = { + char: string | null | undefined; + isActive: boolean; + hasFakeCaret: boolean; +}; + +export type PinInputRootSnippetProps = { + cells: PinInputCell[]; + isFocused: boolean; + isHovering: boolean; +}; + +export type PinInputRootPropsWithoutHTML = Omit< + WithChild< + { + value?: string; + onValueChange?: OnChangeFn; + pasteTransformer?: (text: string) => string; + maxlength: number; + textalign?: "left" | "center" | "right"; + // oxlint-disable-next-line no-explicit-any + onComplete?: (...args: any[]) => void; + pushPasswordManagerStrategy?: "increase-width" | "none"; + disabled?: boolean; + inputId?: string; + children: Snippet<[PinInputRootSnippetProps]>; + }, + PinInputRootSnippetProps + >, + "child" +>; + +export type PinInputRootProps = PinInputRootPropsWithoutHTML & + Without; + +export type PinInputCellPropsWithoutHTML = WithChild<{ + cell: PinInputCell; +}>; + +export type PinInputCellProps = PinInputCellPropsWithoutHTML & + Without; diff --git a/src/uix/acel/pin-input/usePasswordManager.svelte.ts b/src/uix/acel/pin-input/usePasswordManager.svelte.ts new file mode 100644 index 000000000..4566d1556 --- /dev/null +++ b/src/uix/acel/pin-input/usePasswordManager.svelte.ts @@ -0,0 +1,101 @@ +import { getWindow, type DOMContext, type ReadableBox, type WritableBox } from "../utilities/index"; +import type { PinInputRootPropsWithoutHTML } from "./types"; + +const PWM_BADGE_MARGIN_RIGHT = 18; +const PWM_BADGE_SPACE_WIDTH_PX = 40; +const PWM_BADGE_SPACE_WIDTH = `${PWM_BADGE_SPACE_WIDTH_PX}px` as const; + +const PASSWORD_MANAGER_SELECTORS = [ + "[data-lastpass-icon-root]", + "com-1password-button", + "[data-dashlanecreated]", + '[style$="2147483647 !important;"]', +].join(","); + +type UsePasswordManagerBadgeProps = { + containerRef: WritableBox; + inputRef: WritableBox; + pushPasswordManagerStrategy: ReadableBox< + PinInputRootPropsWithoutHTML["pushPasswordManagerStrategy"] + >; + isFocused: ReadableBox; + domContext: DOMContext; +}; + +export function usePasswordManagerBadge({ + containerRef, + inputRef, + pushPasswordManagerStrategy, + isFocused, + domContext, +}: UsePasswordManagerBadgeProps) { + let hasPwmBadge = $state(false); + let hasPwmBadgeSpace = $state(false); + let done = $state(false); + + function willPushPwmBadge() { + const strategy = pushPasswordManagerStrategy.current; + if (strategy === "none") return false; + return strategy === "increase-width" && hasPwmBadge && hasPwmBadgeSpace; + } + + function trackPwmBadge() { + const container = containerRef.current; + const input = inputRef.current; + if (!container || !input || done || pushPasswordManagerStrategy.current === "none") return; + + const rightCornerX = + container.getBoundingClientRect().left + container.offsetWidth; + const centeredY = + container.getBoundingClientRect().top + container.offsetHeight / 2; + const x = rightCornerX - PWM_BADGE_MARGIN_RIGHT; + const y = centeredY; + + const passwordManagerStrategy = domContext.querySelectorAll(PASSWORD_MANAGER_SELECTORS); + + if (passwordManagerStrategy.length === 0) { + const maybeBadgeEl = domContext.getDocument().elementFromPoint(x, y); + if (maybeBadgeEl === container) return; + } + + hasPwmBadge = true; + done = true; + } + + $effect(() => { + const container = containerRef.current; + if (!container || pushPasswordManagerStrategy.current === "none") return; + + function checkHasSpace() { + const viewportWidth = getWindow(container).innerWidth; + hasPwmBadgeSpace = viewportWidth - container!.getBoundingClientRect().right >= PWM_BADGE_SPACE_WIDTH_PX; + } + + checkHasSpace(); + const interval = setInterval(checkHasSpace, 1000); + return () => clearInterval(interval); + }); + + $effect(() => { + const focused = isFocused.current || domContext.getActiveElement() === inputRef.current; + if (pushPasswordManagerStrategy.current === "none" || !focused) return; + + const t1 = setTimeout(trackPwmBadge, 0); + const t2 = setTimeout(trackPwmBadge, 2000); + const t3 = setTimeout(trackPwmBadge, 5000); + const t4 = setTimeout(() => { done = true; }, 6000); + + return () => { + clearTimeout(t1); + clearTimeout(t2); + clearTimeout(t3); + clearTimeout(t4); + }; + }); + + return { + get hasPwmBadge() { return hasPwmBadge; }, + get willPushPwmBadge() { return willPushPwmBadge(); }, + PWM_BADGE_SPACE_WIDTH, + }; +} diff --git a/src/uix/acel/popover/components/popover-close.svelte b/src/uix/acel/popover/components/popover-close.svelte new file mode 100644 index 000000000..a71626919 --- /dev/null +++ b/src/uix/acel/popover/components/popover-close.svelte @@ -0,0 +1,34 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/acel/popover/components/popover-content.svelte b/src/uix/acel/popover/components/popover-content.svelte new file mode 100644 index 000000000..ccc0d7729 --- /dev/null +++ b/src/uix/acel/popover/components/popover-content.svelte @@ -0,0 +1,84 @@ + + + + {#snippet popper({ props, wrapperProps })} + {@const finalProps = mergeProps( + contentState.props, + props, + { style: floatingCSSVars }, + style ? { style } : {} + )} + {#if child} + {@render child({ props: finalProps, wrapperProps, ...contentState.snippetProps })} + {:else} +
+
+ {@render children?.()} +
+
+ {/if} + {/snippet} +
diff --git a/src/uix/acel/popover/components/popover-overlay.svelte b/src/uix/acel/popover/components/popover-overlay.svelte new file mode 100644 index 000000000..78409bd78 --- /dev/null +++ b/src/uix/acel/popover/components/popover-overlay.svelte @@ -0,0 +1,37 @@ + + +{#if forceMount || overlayState.shouldRender} + {#if child} + {@render child({ props: mergedProps, ...overlayState.snippetProps })} + {:else} +
+ {@render children?.()} +
+ {/if} +{/if} diff --git a/src/uix/acel/popover/components/popover-trigger.svelte b/src/uix/acel/popover/components/popover-trigger.svelte new file mode 100644 index 000000000..9c3b8535f --- /dev/null +++ b/src/uix/acel/popover/components/popover-trigger.svelte @@ -0,0 +1,46 @@ + + + + {#if child} + {@render child({ props: mergedProps })} + {:else} + + {/if} + diff --git a/src/uix/acel/popover/components/popover.svelte b/src/uix/acel/popover/components/popover.svelte new file mode 100644 index 000000000..2ad8c079f --- /dev/null +++ b/src/uix/acel/popover/components/popover.svelte @@ -0,0 +1,29 @@ + + + + {@render children?.()} + diff --git a/src/uix/acel/popover/exports.ts b/src/uix/acel/popover/exports.ts new file mode 100644 index 000000000..23a4deb81 --- /dev/null +++ b/src/uix/acel/popover/exports.ts @@ -0,0 +1,5 @@ +export { default as Root } from "./components/popover.svelte"; +export { default as Trigger } from "./components/popover-trigger.svelte"; +export { default as Content } from "./components/popover-content.svelte"; +export { default as Close } from "./components/popover-close.svelte"; +export { default as Overlay } from "./components/popover-overlay.svelte"; diff --git a/src/uix/acel/popover/index.ts b/src/uix/acel/popover/index.ts new file mode 100644 index 000000000..9a8ff24be --- /dev/null +++ b/src/uix/acel/popover/index.ts @@ -0,0 +1,2 @@ +export * from "./exports"; +export * from "./types"; diff --git a/src/uix/acel/popover/popover.svelte.ts b/src/uix/acel/popover/popover.svelte.ts new file mode 100644 index 000000000..476f6a988 --- /dev/null +++ b/src/uix/acel/popover/popover.svelte.ts @@ -0,0 +1,449 @@ +import { + type ReadableBoxedValues, + type WritableBoxedValues, + attachRef, + boxWith, + DOMContext, +} from "../utilities/index"; +import { Context, watch } from "runed"; +import { kbd } from "../utilities/keyboard/kbd"; +import { createAcelAttrs, boolToStr, getDataOpenClosed } from "../utilities/attrs"; +import type { OnChangeFn, RefAttachment, WithRefOpts } from "../utilities/types"; +import { isElement, isTouch } from "../utilities/dom/is-browser"; +import type { Measurable } from "../utilities/floating/types"; +import { PresenceManager } from "../utilities/presence/presence-manager.svelte"; +import { SafePolygon } from "../utilities/floating/safe-polygon.svelte"; +import { isTabbable } from "tabbable"; + +const popoverAttrs = createAcelAttrs({ + component: "popover", + parts: ["root", "trigger", "content", "close", "overlay"] as const, +}); + +const PopoverRootContext = new Context("Popover.Root"); + +interface PopoverRootStateOpts + extends WritableBoxedValues<{ open: boolean }>, + ReadableBoxedValues<{ onOpenChangeComplete: OnChangeFn }> {} + +export class PopoverRootState { + static create(opts: PopoverRootStateOpts) { + return PopoverRootContext.set(new PopoverRootState(opts)); + } + + readonly opts: PopoverRootStateOpts; + contentNode = $state(null); + contentPresence: PresenceManager; + triggerNode = $state(null); + overlayNode = $state(null); + overlayPresence: PresenceManager; + + openedViaHover = $state(false); + hasInteractedWithContent = $state(false); + hoverCooldown = $state(false); + closeDelay = $state(0); + #closeTimeout: number | null = null; + #domContext: DOMContext | null = null; + + constructor(opts: PopoverRootStateOpts) { + this.opts = opts; + + this.contentPresence = new PresenceManager({ + ref: boxWith(() => this.contentNode), + open: this.opts.open, + onComplete: () => { + this.opts.onOpenChangeComplete.current(this.opts.open.current); + }, + }); + + this.overlayPresence = new PresenceManager({ + ref: boxWith(() => this.overlayNode), + open: this.opts.open, + }); + + watch( + () => this.opts.open.current, + (isOpen) => { + if (!isOpen) { + this.openedViaHover = false; + this.hasInteractedWithContent = false; + this.#clearCloseTimeout(); + } + } + ); + } + + setDomContext(ctx: DOMContext) { + this.#domContext = ctx; + } + + #clearCloseTimeout() { + if (this.#closeTimeout !== null && this.#domContext) { + this.#domContext.clearTimeout(this.#closeTimeout); + this.#closeTimeout = null; + } + } + + toggleOpen() { + this.#clearCloseTimeout(); + this.opts.open.current = !this.opts.open.current; + } + + handleClose() { + this.#clearCloseTimeout(); + if (!this.opts.open.current) return; + this.opts.open.current = false; + } + + handleHoverOpen() { + this.#clearCloseTimeout(); + if (this.opts.open.current) return; + this.openedViaHover = true; + this.opts.open.current = true; + } + + handleHoverClose() { + if (!this.opts.open.current) return; + if (this.openedViaHover && !this.hasInteractedWithContent) { + this.opts.open.current = false; + } + } + + handleDelayedHoverClose() { + if (!this.opts.open.current) return; + if (!this.openedViaHover || this.hasInteractedWithContent) return; + this.#clearCloseTimeout(); + if (this.closeDelay <= 0) { + this.opts.open.current = false; + } else if (this.#domContext) { + this.#closeTimeout = this.#domContext.setTimeout(() => { + if (this.openedViaHover && !this.hasInteractedWithContent) { + this.opts.open.current = false; + } + this.#closeTimeout = null; + }, this.closeDelay); + } + } + + cancelDelayedClose() { + this.#clearCloseTimeout(); + } + + markInteraction() { + this.hasInteractedWithContent = true; + this.#clearCloseTimeout(); + } +} + +interface PopoverTriggerStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ + disabled: boolean; + openOnHover: boolean; + openDelay: number; + closeDelay: number; + }> {} + +export class PopoverTriggerState { + static create(opts: PopoverTriggerStateOpts) { + return new PopoverTriggerState(opts, PopoverRootContext.get()); + } + + readonly opts: PopoverTriggerStateOpts; + readonly root: PopoverRootState; + readonly attachment: RefAttachment; + readonly domContext: DOMContext; + + #openTimeout: number | null = null; + #isHovering = $state(false); + + constructor(opts: PopoverTriggerStateOpts, root: PopoverRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(this.opts.ref, (v) => (this.root.triggerNode = v)); + this.domContext = new DOMContext(opts.ref); + this.root.setDomContext(this.domContext); + + watch( + () => this.opts.closeDelay.current, + (delay) => { + this.root.closeDelay = delay; + } + ); + } + + #clearOpenTimeout() { + if (this.#openTimeout !== null) { + this.domContext.clearTimeout(this.#openTimeout); + this.#openTimeout = null; + } + } + + onpointerenter = (e: PointerEvent) => { + if (this.opts.disabled.current) return; + if (!this.opts.openOnHover.current) return; + if (isTouch(e)) return; + this.#isHovering = true; + this.#clearOpenTimeout(); + this.root.cancelDelayedClose(); + if (this.root.opts.open.current || this.root.hoverCooldown) return; + const delay = this.opts.openDelay.current; + if (delay <= 0) { + this.root.handleHoverOpen(); + } else { + this.#openTimeout = this.domContext.setTimeout(() => { + this.root.handleHoverOpen(); + this.#openTimeout = null; + }, delay); + } + }; + + onpointerleave = (e: PointerEvent) => { + if (this.opts.disabled.current) return; + if (!this.opts.openOnHover.current) return; + if (isTouch(e)) return; + this.#isHovering = false; + this.#clearOpenTimeout(); + this.root.hoverCooldown = false; + }; + + onclick = (e: MouseEvent) => { + if (this.opts.disabled.current) return; + if (e.button !== 0) return; + this.#clearOpenTimeout(); + if (this.#isHovering && this.root.opts.open.current && this.root.openedViaHover) { + this.root.openedViaHover = false; + this.root.hasInteractedWithContent = true; + return; + } + if (this.#isHovering && this.opts.openOnHover.current && this.root.opts.open.current) { + this.root.hoverCooldown = true; + } + if (this.root.hoverCooldown && !this.root.opts.open.current) { + this.root.hoverCooldown = false; + } + this.root.toggleOpen(); + }; + + onkeydown = (e: KeyboardEvent) => { + if (this.opts.disabled.current) return; + if (!(e.key === kbd.ENTER || e.key === kbd.SPACE)) return; + e.preventDefault(); + this.#clearOpenTimeout(); + this.root.toggleOpen(); + }; + + #getAriaControls(): string | undefined { + if (this.root.opts.open.current && this.root.contentNode?.id) { + return this.root.contentNode?.id; + } + } + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + "aria-haspopup": "dialog", + "aria-expanded": boolToStr(this.root.opts.open.current), + "data-state": getDataOpenClosed(this.root.opts.open.current), + "aria-controls": this.#getAriaControls(), + [popoverAttrs.trigger]: "", + disabled: this.opts.disabled.current, + onkeydown: this.onkeydown, + onclick: this.onclick, + onpointerenter: this.onpointerenter, + onpointerleave: this.onpointerleave, + ...this.attachment, + }) as const + ); +} + +interface PopoverContentStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ + onInteractOutside: (e: PointerEvent) => void; + onEscapeKeydown: (e: KeyboardEvent) => void; + customAnchor: string | HTMLElement | null | Measurable; + }> {} + +export class PopoverContentState { + static create(opts: PopoverContentStateOpts) { + return new PopoverContentState(opts, PopoverRootContext.get()); + } + + readonly opts: PopoverContentStateOpts; + readonly root: PopoverRootState; + readonly attachment: RefAttachment; + + constructor(opts: PopoverContentStateOpts, root: PopoverRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(this.opts.ref, (v) => (this.root.contentNode = v)); + + new SafePolygon({ + triggerNode: () => this.root.triggerNode, + contentNode: () => this.root.contentNode, + enabled: () => + this.root.opts.open.current && + this.root.openedViaHover && + !this.root.hasInteractedWithContent, + onPointerExit: () => { + this.root.handleDelayedHoverClose(); + }, + }); + } + + onpointerdown = (_: PointerEvent) => { + this.root.markInteraction(); + }; + + onfocusin = (e: FocusEvent) => { + const target = e.target; + if (isElement(target) && isTabbable(target as HTMLElement)) { + this.root.markInteraction(); + } + }; + + onpointerenter = (e: PointerEvent) => { + if (isTouch(e)) return; + this.root.cancelDelayedClose(); + }; + + onpointerleave = (e: PointerEvent) => { + if (isTouch(e)) return; + }; + + onInteractOutside = (e: PointerEvent) => { + this.opts.onInteractOutside.current(e); + if (e.defaultPrevented) return; + if (!isElement(e.target)) return; + const closestTrigger = (e.target as Element).closest(popoverAttrs.selector("trigger")); + if (closestTrigger && closestTrigger === this.root.triggerNode) return; + const anchor = this.opts.customAnchor.current; + if (anchor) { + if (isElement(anchor)) { + if ((anchor as Element).contains(e.target as Node)) return; + } else if (typeof anchor === "string") { + const el = document.querySelector(anchor); + if (el && el.contains(e.target as Node)) return; + } + } + this.root.handleClose(); + }; + + onEscapeKeydown = (e: KeyboardEvent) => { + this.opts.onEscapeKeydown.current(e); + if (e.defaultPrevented) return; + this.root.handleClose(); + }; + + get shouldRender(): boolean { + return this.root.contentPresence.shouldRender; + } + + get shouldTrapFocus(): boolean { + if (this.root.openedViaHover && !this.root.hasInteractedWithContent) return false; + return true; + } + + readonly snippetProps = $derived.by(() => ({ open: this.root.opts.open.current })); + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + tabindex: -1, + "data-state": getDataOpenClosed(this.root.opts.open.current), + [popoverAttrs.content]: "", + style: { + pointerEvents: "auto", + contain: "layout style", + }, + onpointerdown: this.onpointerdown, + onfocusin: this.onfocusin, + onpointerenter: this.onpointerenter, + onpointerleave: this.onpointerleave, + ...this.attachment, + }) as const + ); + + readonly popperProps = { + onInteractOutside: this.onInteractOutside, + onEscapeKeydown: this.onEscapeKeydown, + }; +} + +interface PopoverCloseStateOpts extends WithRefOpts {} + +export class PopoverCloseState { + static create(opts: PopoverCloseStateOpts) { + return new PopoverCloseState(opts, PopoverRootContext.get()); + } + + readonly opts: PopoverCloseStateOpts; + readonly root: PopoverRootState; + readonly attachment: RefAttachment; + + constructor(opts: PopoverCloseStateOpts, root: PopoverRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(this.opts.ref); + } + + onclick = (_: MouseEvent) => { + this.root.handleClose(); + }; + + onkeydown = (e: KeyboardEvent) => { + if (!(e.key === kbd.ENTER || e.key === kbd.SPACE)) return; + e.preventDefault(); + this.root.handleClose(); + }; + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + onclick: this.onclick, + onkeydown: this.onkeydown, + type: "button", + [popoverAttrs.close]: "", + ...this.attachment, + }) as const + ); +} + +interface PopoverOverlayStateOpts extends WithRefOpts {} + +export class PopoverOverlayState { + static create(opts: PopoverOverlayStateOpts) { + return new PopoverOverlayState(opts, PopoverRootContext.get()); + } + + readonly opts: PopoverOverlayStateOpts; + readonly root: PopoverRootState; + readonly attachment: RefAttachment; + + constructor(opts: PopoverOverlayStateOpts, root: PopoverRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(this.opts.ref, (v) => (this.root.overlayNode = v)); + } + + get shouldRender() { + return this.root.overlayPresence.shouldRender; + } + + readonly snippetProps = $derived.by(() => ({ open: this.root.opts.open.current })); + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + [popoverAttrs.overlay]: "", + style: { pointerEvents: "auto" }, + "data-state": getDataOpenClosed(this.root.opts.open.current), + ...this.attachment, + }) as const + ); +} diff --git a/src/uix/acel/popover/types.ts b/src/uix/acel/popover/types.ts new file mode 100644 index 000000000..d10679738 --- /dev/null +++ b/src/uix/acel/popover/types.ts @@ -0,0 +1,67 @@ +import type { WithChild, WithChildren, Without, OnChangeFn } from "../utilities/types"; +import type { AcelPrimitiveDivAttributes, AcelPrimitiveButtonAttributes } from "../utilities/attributes"; +import type { StyleProperties } from "../utilities/index"; +import type { Side, Align, Boundary } from "../utilities/floating/use-floating-layer.svelte"; +import type { Arrayable } from "../utilities/types"; +import type { Measurable } from "../utilities/floating/types"; + +export type PopoverRootPropsWithoutHTML = WithChildren<{ + open?: boolean; + onOpenChange?: OnChangeFn; + onOpenChangeComplete?: OnChangeFn; +}>; + +export type PopoverRootProps = PopoverRootPropsWithoutHTML; + +export type PopoverTriggerPropsWithoutHTML = WithChild<{ + disabled?: boolean; + openOnHover?: boolean; + openDelay?: number; + closeDelay?: number; +}>; + +export type PopoverTriggerProps = PopoverTriggerPropsWithoutHTML & + Without; + +export type PopoverContentPropsWithoutHTML = Omit< + WithChild< + { + forceMount?: boolean; + trapFocus?: boolean; + preventScroll?: boolean; + side?: Side; + sideOffset?: number; + align?: Align; + alignOffset?: number; + arrowPadding?: number; + avoidCollisions?: boolean; + collisionBoundary?: Arrayable; + collisionPadding?: number | Partial>; + hideWhenDetached?: boolean; + sticky?: "partial" | "always"; + updatePositionStrategy?: "optimized" | "always"; + strategy?: "fixed" | "absolute"; + customAnchor?: string | HTMLElement | null | Measurable; + onInteractOutside?: (e: PointerEvent) => void; + onEscapeKeydown?: (e: KeyboardEvent) => void; + onOpenAutoFocus?: (e: Event) => void; + onCloseAutoFocus?: (e: Event) => void; + style?: StyleProperties | string | null | undefined; + }, + { open: boolean } + >, + "child" +> & { + child?: import("svelte").Snippet<[{ props: Record; wrapperProps: Record; open: boolean }]>; +}; + +export type PopoverContentProps = PopoverContentPropsWithoutHTML & + Without; + +export type PopoverClosePropsWithoutHTML = WithChild<{}>; +export type PopoverCloseProps = PopoverClosePropsWithoutHTML & + Without; + +export type PopoverOverlayPropsWithoutHTML = WithChild<{ forceMount?: boolean }, { open: boolean }>; +export type PopoverOverlayProps = PopoverOverlayPropsWithoutHTML & + Without; diff --git a/src/uix/acel/progress/components/progress.svelte b/src/uix/acel/progress/components/progress.svelte new file mode 100644 index 000000000..27d688d14 --- /dev/null +++ b/src/uix/acel/progress/components/progress.svelte @@ -0,0 +1,37 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/acel/progress/exports.ts b/src/uix/acel/progress/exports.ts new file mode 100644 index 000000000..c50763ea0 --- /dev/null +++ b/src/uix/acel/progress/exports.ts @@ -0,0 +1,2 @@ +export { default as Root } from "./components/progress.svelte"; +export type { ProgressRootProps as RootProps } from "./types"; diff --git a/src/uix/acel/progress/index.ts b/src/uix/acel/progress/index.ts new file mode 100644 index 000000000..d910369d5 --- /dev/null +++ b/src/uix/acel/progress/index.ts @@ -0,0 +1,2 @@ +export * as Progress from "./exports"; +export * from "./exports"; diff --git a/src/uix/acel/progress/progress.svelte.ts b/src/uix/acel/progress/progress.svelte.ts new file mode 100644 index 000000000..0ffda6ee6 --- /dev/null +++ b/src/uix/acel/progress/progress.svelte.ts @@ -0,0 +1,57 @@ +import { attachRef, type ReadableBoxedValues } from "../utilities/index"; +import type { RefAttachment, WithRefOpts } from "../utilities/types"; +import { createAcelAttrs } from "../utilities/attrs"; + +const progressAttrs = createAcelAttrs({ + component: "progress", + parts: ["root"] as const, +}); + +type ProgressState = "indeterminate" | "loading" | "loaded"; + +function getProgressState(value: number | null | undefined, max: number): ProgressState { + if (value == null) return "indeterminate"; + if (value === max) return "loaded"; + return "loading"; +} + +interface ProgressRootStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ + value: number | null | undefined; + max: number; + min: number; + }> {} + +export class ProgressRootState { + static create(opts: ProgressRootStateOpts) { + return new ProgressRootState(opts); + } + + readonly opts: ProgressRootStateOpts; + readonly attachment: RefAttachment; + + constructor(opts: ProgressRootStateOpts) { + this.opts = opts; + this.attachment = attachRef(this.opts.ref); + } + + readonly props = $derived.by(() => { + const value = this.opts.value.current; + const max = this.opts.max.current; + const min = this.opts.min.current; + return { + id: this.opts.id.current, + role: "progressbar", + "aria-valuemin": min, + "aria-valuemax": max, + "aria-valuenow": value ?? undefined, + "data-value": value ?? undefined, + "data-max": max, + "data-min": min, + "data-state": getProgressState(value, max), + [progressAttrs.root]: "", + ...this.attachment, + } as const; + }); +} diff --git a/src/uix/acel/progress/types.ts b/src/uix/acel/progress/types.ts new file mode 100644 index 000000000..372d499ec --- /dev/null +++ b/src/uix/acel/progress/types.ts @@ -0,0 +1,23 @@ +import type { WithChild, Without } from "../utilities/types"; +import type { AcelPrimitiveDivAttributes } from "../utilities/attributes"; + +export type ProgressRootPropsWithoutHTML = WithChild<{ + /** + * El valor actual del progreso. `null` indica estado indeterminado. + * @defaultValue null + */ + value?: number | null; + /** + * El valor máximo del progreso. + * @defaultValue 100 + */ + max?: number; + /** + * El valor mínimo del progreso. + * @defaultValue 0 + */ + min?: number; +}>; + +export type ProgressRootProps = ProgressRootPropsWithoutHTML & + Without; diff --git a/src/uix/acel/radio-group/components/radio-group-item.svelte b/src/uix/acel/radio-group/components/radio-group-item.svelte new file mode 100644 index 000000000..245b28abd --- /dev/null +++ b/src/uix/acel/radio-group/components/radio-group-item.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps, ...itemState.snippetProps })} +{:else} + +{/if} diff --git a/src/uix/acel/radio-group/components/radio-group.svelte b/src/uix/acel/radio-group/components/radio-group.svelte new file mode 100644 index 000000000..1d52d9706 --- /dev/null +++ b/src/uix/acel/radio-group/components/radio-group.svelte @@ -0,0 +1,53 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} + +{#if inputState.shouldRender} + +{/if} diff --git a/src/uix/acel/radio-group/exports.ts b/src/uix/acel/radio-group/exports.ts new file mode 100644 index 000000000..a76583a96 --- /dev/null +++ b/src/uix/acel/radio-group/exports.ts @@ -0,0 +1,3 @@ +export { default as Root } from "./components/radio-group.svelte"; +export { default as Item } from "./components/radio-group-item.svelte"; +export type { RadioGroupRootProps as RootProps, RadioGroupItemProps as ItemProps } from "./types"; diff --git a/src/uix/acel/radio-group/index.ts b/src/uix/acel/radio-group/index.ts new file mode 100644 index 000000000..cd49c8afc --- /dev/null +++ b/src/uix/acel/radio-group/index.ts @@ -0,0 +1,2 @@ +export * as RadioGroup from "./exports"; +export * from "./exports"; diff --git a/src/uix/acel/radio-group/radio-group.svelte.ts b/src/uix/acel/radio-group/radio-group.svelte.ts new file mode 100644 index 000000000..b9fa099f2 --- /dev/null +++ b/src/uix/acel/radio-group/radio-group.svelte.ts @@ -0,0 +1,200 @@ +import { attachRef, type ReadableBoxedValues, type WritableBoxedValues } from "../utilities/index"; +import type { RefAttachment, WithRefOpts, AcelKeyboardEvent, AcelMouseEvent, AcelFocusEvent } from "../utilities/types"; +import { createAcelAttrs, getAriaChecked, boolToStr, boolToEmptyStrOrUndef } from "../utilities/attrs"; +import { Context, watch } from "runed"; +import { kbd } from "../utilities/keyboard/kbd"; +import { RovingFocusGroup } from "../utilities/focus/roving-focus-group"; +import { noop } from "../utilities/noop"; + +const radioGroupAttrs = createAcelAttrs({ + component: "radio-group", + parts: ["root", "item"] as const, +}); + +// ── Root ────────────────────────────────────────────────────────────────────── + +const RadioGroupRootContext = new Context("RadioGroup.Root"); + +interface RadioGroupRootStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ + disabled: boolean; + required: boolean; + loop: boolean; + orientation: "horizontal" | "vertical"; + name: string | undefined; + readonly: boolean; + onValueChange: (v: string) => void; + }>, + WritableBoxedValues<{ value: string }> {} + +export class RadioGroupRootState { + static create(opts: RadioGroupRootStateOpts) { + return RadioGroupRootContext.set(new RadioGroupRootState(opts)); + } + + readonly opts: RadioGroupRootStateOpts; + readonly attachment: RefAttachment; + readonly rovingFocusGroup: RovingFocusGroup; + readonly hasValue = $derived.by(() => this.opts.value.current !== ""); + + constructor(opts: RadioGroupRootStateOpts) { + this.opts = opts; + this.attachment = attachRef(opts.ref); + this.rovingFocusGroup = new RovingFocusGroup({ + rootNode: opts.ref, + candidateAttr: radioGroupAttrs.item, + loop: opts.loop, + orientation: opts.orientation, + }); + } + + isChecked(value: string) { + return this.opts.value.current === value; + } + + setValue(value: string) { + this.opts.value.current = value; + this.opts.onValueChange.current(value); + } + + readonly props = $derived.by(() => ({ + id: this.opts.id.current, + role: "radiogroup", + "aria-required": boolToStr(this.opts.required.current), + "aria-disabled": boolToStr(this.opts.disabled.current), + "aria-readonly": this.opts.readonly.current ? "true" : undefined, + "data-disabled": boolToEmptyStrOrUndef(this.opts.disabled.current), + "data-readonly": boolToEmptyStrOrUndef(this.opts.readonly.current), + "data-orientation": this.opts.orientation.current, + [radioGroupAttrs.root]: "", + ...this.attachment, + }) as const); +} + +// ── Item ────────────────────────────────────────────────────────────────────── + +interface RadioGroupItemStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ + disabled: boolean; + value: string; + }> {} + +export class RadioGroupItemState { + static create(opts: RadioGroupItemStateOpts) { + return new RadioGroupItemState(opts, RadioGroupRootContext.get()); + } + + readonly opts: RadioGroupItemStateOpts; + readonly root: RadioGroupRootState; + readonly attachment: RefAttachment; + #tabIndex = $state(-1); + + readonly #isDisabled = $derived.by(() => + this.opts.disabled.current || this.root.opts.disabled.current + ); + readonly #isReadonly = $derived.by(() => this.root.opts.readonly.current); + readonly #isChecked = $derived.by(() => this.root.isChecked(this.opts.value.current)); + + constructor(opts: RadioGroupItemStateOpts, root: RadioGroupRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(opts.ref); + + // Initial tabindex + if (opts.value.current === root.opts.value.current) { + root.rovingFocusGroup.setCurrentTabStopId(opts.id.current); + this.#tabIndex = 0; + } else if (!root.opts.value.current) { + this.#tabIndex = 0; + } + + $effect(() => { + this.#tabIndex = this.root.rovingFocusGroup.getTabIndex(this.opts.ref.current); + }); + + watch( + [() => this.opts.value.current, () => this.root.opts.value.current] as const, + () => { + if (this.opts.value.current === this.root.opts.value.current) { + this.root.rovingFocusGroup.setCurrentTabStopId(this.opts.id.current); + this.#tabIndex = 0; + } + } + ); + + this.onclick = this.onclick.bind(this); + this.onkeydown = this.onkeydown.bind(this); + this.onfocus = this.onfocus.bind(this); + } + + onclick(_: AcelMouseEvent) { + if (this.#isDisabled || this.#isReadonly) return; + this.root.setValue(this.opts.value.current); + } + + onfocus(_: AcelFocusEvent) { + if (!this.root.hasValue || this.#isReadonly) return; + this.root.setValue(this.opts.value.current); + } + + onkeydown(e: AcelKeyboardEvent) { + if (this.#isDisabled) return; + if (e.key === kbd.SPACE) { + e.preventDefault(); + if (!this.#isReadonly) this.root.setValue(this.opts.value.current); + return; + } + this.root.rovingFocusGroup.handleKeydown(this.opts.ref.current, e, true); + } + + readonly snippetProps = $derived.by(() => ({ checked: this.#isChecked })); + + readonly props = $derived.by(() => ({ + id: this.opts.id.current, + type: "button" as const, + role: "radio", + disabled: this.#isDisabled ? true : undefined, + "data-value": this.opts.value.current, + "data-orientation": this.root.opts.orientation.current, + "data-disabled": boolToEmptyStrOrUndef(this.#isDisabled), + "data-readonly": boolToEmptyStrOrUndef(this.#isReadonly), + "data-state": this.#isChecked ? "checked" : "unchecked", + "aria-checked": getAriaChecked(this.#isChecked, false), + tabindex: this.#tabIndex, + [radioGroupAttrs.item]: "", + onkeydown: this.onkeydown, + onfocus: this.onfocus, + onclick: this.onclick, + ...this.attachment, + }) as const); +} + +// ── Hidden Input ────────────────────────────────────────────────────────────── + +export class RadioGroupInputState { + static create() { + return new RadioGroupInputState(RadioGroupRootContext.get()); + } + + readonly root: RadioGroupRootState; + readonly shouldRender = $derived.by(() => this.root.opts.name.current !== undefined); + + constructor(root: RadioGroupRootState) { + this.root = root; + this.onfocus = this.onfocus.bind(this); + } + + onfocus(_: AcelFocusEvent) { + this.root.rovingFocusGroup.focusCurrentTabStop(); + } + + readonly props = $derived.by(() => ({ + name: this.root.opts.name.current, + value: this.root.opts.value.current, + required: this.root.opts.required.current, + disabled: this.root.opts.disabled.current, + onfocus: this.onfocus, + }) as const); +} diff --git a/src/uix/acel/radio-group/types.ts b/src/uix/acel/radio-group/types.ts new file mode 100644 index 000000000..f1aae80aa --- /dev/null +++ b/src/uix/acel/radio-group/types.ts @@ -0,0 +1,24 @@ +import type { WithChild, Without } from "../utilities/types"; +import type { AcelPrimitiveDivAttributes, AcelPrimitiveButtonAttributes } from "../utilities/attributes"; + +export type RadioGroupRootPropsWithoutHTML = WithChild<{ + value?: string; + onValueChange?: (value: string) => void; + disabled?: boolean; + required?: boolean; + readonly?: boolean; + loop?: boolean; + orientation?: "horizontal" | "vertical"; + name?: string; +}>; + +export type RadioGroupRootProps = RadioGroupRootPropsWithoutHTML & + Without; + +export type RadioGroupItemPropsWithoutHTML = WithChild<{ + value: string; + disabled?: boolean; +}, { checked: boolean }, HTMLButtonElement>; + +export type RadioGroupItemProps = RadioGroupItemPropsWithoutHTML & + Without; diff --git a/src/uix/acel/rating-group/components/rating-group-item.svelte b/src/uix/acel/rating-group/components/rating-group-item.svelte new file mode 100644 index 000000000..797bee6e3 --- /dev/null +++ b/src/uix/acel/rating-group/components/rating-group-item.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps, ...itemState.snippetProps })} +{:else} + + {@render children?.(itemState.snippetProps)} + +{/if} diff --git a/src/uix/acel/rating-group/components/rating-group.svelte b/src/uix/acel/rating-group/components/rating-group.svelte new file mode 100644 index 000000000..fc38948cc --- /dev/null +++ b/src/uix/acel/rating-group/components/rating-group.svelte @@ -0,0 +1,59 @@ + + +{#if child} + {@render child({ props: mergedProps, ...rootState.snippetProps })} +{:else} +
+ {@render children?.(rootState.snippetProps)} +
+{/if} + +{#if inputState.shouldRender} + +{/if} diff --git a/src/uix/acel/rating-group/exports.ts b/src/uix/acel/rating-group/exports.ts new file mode 100644 index 000000000..aad4d8e2d --- /dev/null +++ b/src/uix/acel/rating-group/exports.ts @@ -0,0 +1,3 @@ +export { default as Root } from "./components/rating-group.svelte"; +export { default as Item } from "./components/rating-group-item.svelte"; +export type { RatingGroupRootProps as RootProps, RatingGroupItemProps as ItemProps } from "./types"; diff --git a/src/uix/acel/rating-group/index.ts b/src/uix/acel/rating-group/index.ts new file mode 100644 index 000000000..fd525cdb9 --- /dev/null +++ b/src/uix/acel/rating-group/index.ts @@ -0,0 +1,2 @@ +export * as RatingGroup from "./exports"; +export * from "./exports"; diff --git a/src/uix/acel/rating-group/rating-group.svelte.ts b/src/uix/acel/rating-group/rating-group.svelte.ts new file mode 100644 index 000000000..9c9a8581e --- /dev/null +++ b/src/uix/acel/rating-group/rating-group.svelte.ts @@ -0,0 +1,259 @@ +import { attachRef, type ReadableBoxedValues, type WritableBoxedValues, DOMContext } from "../utilities/index"; +import type { RefAttachment, WithRefOpts, AcelKeyboardEvent, AcelMouseEvent, AcelPointerEvent } from "../utilities/types"; +import { createAcelAttrs, boolToStr, boolToEmptyStrOrUndef } from "../utilities/attrs"; +import { Context } from "runed"; +import { kbd } from "../utilities/keyboard/kbd"; +import { noop } from "../utilities/noop"; + +const ratingGroupAttrs = createAcelAttrs({ + component: "rating-group", + parts: ["root", "item"] as const, +}); + +export type RatingItemState = "active" | "partial" | "inactive"; + +// ── Root ────────────────────────────────────────────────────────────────────── + +const RatingGroupRootContext = new Context("RatingGroup.Root"); + +interface RatingGroupRootStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ + disabled: boolean; + required: boolean; + orientation: "horizontal" | "vertical"; + name: string | undefined; + min: number; + max: number; + allowHalf: boolean; + readonly: boolean; + hoverPreview: boolean; + onValueChange: (v: number) => void; + }>, + WritableBoxedValues<{ value: number }> {} + +export class RatingGroupRootState { + static create(opts: RatingGroupRootStateOpts) { + return RatingGroupRootContext.set(new RatingGroupRootState(opts)); + } + + readonly opts: RatingGroupRootStateOpts; + readonly attachment: RefAttachment; + readonly domContext: DOMContext; + + #hoverValue = $state(null); + #keySequence = $state(""); + #keySequenceTimeout: number | null = null; + + readonly hasValue = $derived.by(() => this.opts.value.current > 0); + readonly valueToUse = $derived.by(() => this.#hoverValue ?? this.opts.value.current); + + readonly items = $derived.by(() => { + const value = this.valueToUse; + return Array.from({ length: this.opts.max.current }, (_, i) => { + const itemValue = i + 1; + const state: RatingItemState = + value >= itemValue + ? "active" + : this.opts.allowHalf.current && value >= itemValue - 0.5 + ? "partial" + : "inactive"; + return { index: i, state }; + }); + }); + + constructor(opts: RatingGroupRootStateOpts) { + this.opts = opts; + this.attachment = attachRef(opts.ref); + this.domContext = new DOMContext(opts.ref); + this.onkeydown = this.onkeydown.bind(this); + this.onpointerleave = this.onpointerleave.bind(this); + } + + isActive(index: number) { return this.valueToUse >= index + 1; } + isPartial(index: number) { + if (!this.opts.allowHalf.current) return false; + const v = index + 1; + return this.valueToUse >= v - 0.5 && this.valueToUse < v; + } + + setHoverValue(v: number | null) { + if (this.opts.readonly.current || this.opts.disabled.current || !this.opts.hoverPreview.current) return; + this.#hoverValue = v === null ? null : Math.max(this.opts.min.current, Math.min(this.opts.max.current, v)); + } + + setValue(v: number) { + if (this.opts.readonly.current || this.opts.disabled.current) return; + const clamped = Math.max(this.opts.min.current, Math.min(this.opts.max.current, v)); + this.opts.value.current = clamped; + this.opts.onValueChange.current(clamped); + } + + calculateRatingFromPointer(index: number, e: { clientX: number; clientY: number; currentTarget: HTMLElement }) { + const v = index + 1; + if (!this.opts.allowHalf.current) return v; + const rect = e.currentTarget.getBoundingClientRect(); + const isH = this.opts.orientation.current === "horizontal"; + const pos = isH ? (e.clientX - rect.left) / rect.width : (e.clientY - rect.top) / rect.height; + return pos < 0.5 ? v - 0.5 : v; + } + + onpointerleave() { this.setHoverValue(null); } + + onkeydown(e: AcelKeyboardEvent) { + if (this.opts.disabled.current || this.opts.readonly.current) return; + const step = this.opts.allowHalf.current ? 0.5 : 1; + switch (e.key) { + case kbd.ARROW_UP: + case kbd.ARROW_RIGHT: + e.preventDefault(); + this.setHoverValue(null); + this.setValue(this.opts.value.current + step); + break; + case kbd.ARROW_DOWN: + case kbd.ARROW_LEFT: + e.preventDefault(); + this.setHoverValue(null); + this.setValue(this.opts.value.current - step); + break; + case kbd.HOME: + e.preventDefault(); + this.setValue(this.opts.min.current); + break; + case kbd.END: + e.preventDefault(); + this.setValue(this.opts.max.current); + break; + default: { + const num = parseInt(e.key || ""); + if (!isNaN(num) && num >= this.opts.min.current && num <= this.opts.max.current) { + e.preventDefault(); + this.setValue(num); + } + } + } + } + + readonly snippetProps = $derived.by(() => ({ + items: this.items, + value: this.opts.value.current, + max: this.opts.max.current, + })); + + readonly props = $derived.by(() => ({ + id: this.opts.id.current, + role: "slider", + "aria-valuenow": this.opts.value.current, + "aria-valuemin": this.opts.min.current, + "aria-valuemax": this.opts.max.current, + "aria-orientation": this.opts.orientation.current, + "aria-required": boolToStr(this.opts.required.current), + "aria-disabled": this.opts.disabled.current ? "true" : undefined, + "aria-label": "Rating", + "data-disabled": boolToEmptyStrOrUndef(this.opts.disabled.current), + "data-readonly": this.opts.readonly.current ? "" : undefined, + "data-orientation": this.opts.orientation.current, + tabindex: this.opts.disabled.current ? -1 : 0, + [ratingGroupAttrs.root]: "", + onkeydown: this.onkeydown, + onpointerleave: this.onpointerleave, + ...this.attachment, + }) as const); +} + +// ── Item ────────────────────────────────────────────────────────────────────── + +interface RatingGroupItemStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ disabled: boolean; index: number }> {} + +export class RatingGroupItemState { + static create(opts: RatingGroupItemStateOpts) { + return new RatingGroupItemState(opts, RatingGroupRootContext.get()); + } + + readonly opts: RatingGroupItemStateOpts; + readonly root: RatingGroupRootState; + readonly attachment: RefAttachment; + + readonly #isDisabled = $derived.by(() => + this.opts.disabled.current || this.root.opts.disabled.current + ); + readonly #state: RatingItemState = $derived.by(() => { + if (this.root.isActive(this.opts.index.current)) return "active"; + if (this.root.isPartial(this.opts.index.current)) return "partial"; + return "inactive"; + }); + + constructor(opts: RatingGroupItemStateOpts, root: RatingGroupRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(opts.ref); + this.onclick = this.onclick.bind(this); + this.onpointermove = this.onpointermove.bind(this); + } + + onclick(e: AcelMouseEvent) { + if (this.#isDisabled || this.root.opts.readonly.current) return; + if ( + this.opts.index.current === 0 && + this.root.opts.min.current === 0 && + this.root.opts.value.current > 0 + ) { + const newValue = this.root.calculateRatingFromPointer(this.opts.index.current, e as any); + if (newValue === this.root.opts.value.current) { + this.root.setValue(0); + this.root.opts.ref.current?.focus(); + return; + } + } + const newValue = this.root.calculateRatingFromPointer(this.opts.index.current, e as any); + this.root.setValue(newValue); + this.root.opts.ref.current?.focus(); + } + + onpointermove(e: AcelPointerEvent) { + if (this.#isDisabled || this.root.opts.readonly.current || !this.root.opts.hoverPreview.current) return; + if ((e as any).pointerType === "touch") return; + const hoverValue = this.root.calculateRatingFromPointer(this.opts.index.current, e as any); + this.root.setHoverValue(hoverValue); + } + + readonly snippetProps = $derived.by(() => ({ state: this.#state })); + + readonly props = $derived.by(() => ({ + id: this.opts.id.current, + role: "presentation", + "data-value": this.opts.index.current + 1, + "data-orientation": this.root.opts.orientation.current, + "data-disabled": boolToEmptyStrOrUndef(this.#isDisabled), + "data-readonly": this.root.opts.readonly.current ? "" : undefined, + "data-state": this.#state, + [ratingGroupAttrs.item]: "", + onclick: this.onclick, + onpointermove: this.onpointermove, + ...this.attachment, + }) as const); +} + +// ── Hidden Input ────────────────────────────────────────────────────────────── + +export class RatingGroupInputState { + static create() { + return new RatingGroupInputState(RatingGroupRootContext.get()); + } + + readonly root: RatingGroupRootState; + readonly shouldRender = $derived.by(() => this.root.opts.name.current !== undefined); + + constructor(root: RatingGroupRootState) { + this.root = root; + } + + readonly props = $derived.by(() => ({ + name: this.root.opts.name.current, + value: this.root.opts.value.current, + required: this.root.opts.required.current, + disabled: this.root.opts.disabled.current, + }) as const); +} diff --git a/src/uix/acel/rating-group/types.ts b/src/uix/acel/rating-group/types.ts new file mode 100644 index 000000000..4c5ad1a6b --- /dev/null +++ b/src/uix/acel/rating-group/types.ts @@ -0,0 +1,28 @@ +import type { WithChild, Without } from "../utilities/types"; +import type { AcelPrimitiveDivAttributes, AcelPrimitiveSpanAttributes } from "../utilities/attributes"; +import type { RatingItemState } from "./rating-group.svelte"; + +export type RatingGroupRootPropsWithoutHTML = WithChild<{ + value?: number; + onValueChange?: (value: number) => void; + min?: number; + max?: number; + disabled?: boolean; + required?: boolean; + readonly?: boolean; + allowHalf?: boolean; + hoverPreview?: boolean; + orientation?: "horizontal" | "vertical"; + name?: string; +}, { items: { index: number; state: RatingItemState }[]; value: number; max: number }>; + +export type RatingGroupRootProps = RatingGroupRootPropsWithoutHTML & + Without; + +export type RatingGroupItemPropsWithoutHTML = WithChild<{ + index: number; + disabled?: boolean; +}, { state: RatingItemState }>; + +export type RatingGroupItemProps = RatingGroupItemPropsWithoutHTML & + Without; diff --git a/src/uix/acel/scroll-area/components/presence-layer.svelte b/src/uix/acel/scroll-area/components/presence-layer.svelte new file mode 100644 index 000000000..053f54a5c --- /dev/null +++ b/src/uix/acel/scroll-area/components/presence-layer.svelte @@ -0,0 +1,21 @@ + + +{#if open || manager.shouldRender} + {@render presence?.({ present: open })} +{/if} diff --git a/src/uix/acel/scroll-area/components/scroll-area-corner-impl.svelte b/src/uix/acel/scroll-area/components/scroll-area-corner-impl.svelte new file mode 100644 index 000000000..168a1b872 --- /dev/null +++ b/src/uix/acel/scroll-area/components/scroll-area-corner-impl.svelte @@ -0,0 +1,30 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/acel/scroll-area/components/scroll-area-corner.svelte b/src/uix/acel/scroll-area/components/scroll-area-corner.svelte new file mode 100644 index 000000000..51e50d7b1 --- /dev/null +++ b/src/uix/acel/scroll-area/components/scroll-area-corner.svelte @@ -0,0 +1,27 @@ + + +{#if hasCorner} + +{/if} diff --git a/src/uix/acel/scroll-area/components/scroll-area-scrollbar-auto.svelte b/src/uix/acel/scroll-area/components/scroll-area-scrollbar-auto.svelte new file mode 100644 index 000000000..3166d97d6 --- /dev/null +++ b/src/uix/acel/scroll-area/components/scroll-area-scrollbar-auto.svelte @@ -0,0 +1,21 @@ + + + + {#snippet presence()} + + {/snippet} + diff --git a/src/uix/acel/scroll-area/components/scroll-area-scrollbar-hover.svelte b/src/uix/acel/scroll-area/components/scroll-area-scrollbar-hover.svelte new file mode 100644 index 000000000..fda9c67f9 --- /dev/null +++ b/src/uix/acel/scroll-area/components/scroll-area-scrollbar-hover.svelte @@ -0,0 +1,30 @@ + + + + {#snippet presence()} + + {/snippet} + diff --git a/src/uix/acel/scroll-area/components/scroll-area-scrollbar-scroll.svelte b/src/uix/acel/scroll-area/components/scroll-area-scrollbar-scroll.svelte new file mode 100644 index 000000000..27033c840 --- /dev/null +++ b/src/uix/acel/scroll-area/components/scroll-area-scrollbar-scroll.svelte @@ -0,0 +1,22 @@ + + + + {#snippet presence()} + + {/snippet} + diff --git a/src/uix/acel/scroll-area/components/scroll-area-scrollbar-shared.svelte b/src/uix/acel/scroll-area/components/scroll-area-scrollbar-shared.svelte new file mode 100644 index 000000000..c2e845986 --- /dev/null +++ b/src/uix/acel/scroll-area/components/scroll-area-scrollbar-shared.svelte @@ -0,0 +1,19 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/acel/scroll-area/components/scroll-area-scrollbar-visible.svelte b/src/uix/acel/scroll-area/components/scroll-area-scrollbar-visible.svelte new file mode 100644 index 000000000..c0bb8c338 --- /dev/null +++ b/src/uix/acel/scroll-area/components/scroll-area-scrollbar-visible.svelte @@ -0,0 +1,16 @@ + + +{#if scrollbarVisibleState.scrollbar.opts.orientation.current === "horizontal"} + +{:else} + +{/if} diff --git a/src/uix/acel/scroll-area/components/scroll-area-scrollbar-x.svelte b/src/uix/acel/scroll-area/components/scroll-area-scrollbar-x.svelte new file mode 100644 index 000000000..e59bf8a00 --- /dev/null +++ b/src/uix/acel/scroll-area/components/scroll-area-scrollbar-x.svelte @@ -0,0 +1,19 @@ + + + diff --git a/src/uix/acel/scroll-area/components/scroll-area-scrollbar-y.svelte b/src/uix/acel/scroll-area/components/scroll-area-scrollbar-y.svelte new file mode 100644 index 000000000..5ad8ab6ae --- /dev/null +++ b/src/uix/acel/scroll-area/components/scroll-area-scrollbar-y.svelte @@ -0,0 +1,20 @@ + + + diff --git a/src/uix/acel/scroll-area/components/scroll-area-scrollbar.svelte b/src/uix/acel/scroll-area/components/scroll-area-scrollbar.svelte new file mode 100644 index 000000000..1407ab74b --- /dev/null +++ b/src/uix/acel/scroll-area/components/scroll-area-scrollbar.svelte @@ -0,0 +1,37 @@ + + +{#if type === "hover"} + +{:else if type === "scroll"} + +{:else if type === "auto"} + +{:else if type === "always"} + +{/if} diff --git a/src/uix/acel/scroll-area/components/scroll-area-thumb-impl.svelte b/src/uix/acel/scroll-area/components/scroll-area-thumb-impl.svelte new file mode 100644 index 000000000..106ae8d6e --- /dev/null +++ b/src/uix/acel/scroll-area/components/scroll-area-thumb-impl.svelte @@ -0,0 +1,42 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/acel/scroll-area/components/scroll-area-thumb.svelte b/src/uix/acel/scroll-area/components/scroll-area-thumb.svelte new file mode 100644 index 000000000..de1eb5a17 --- /dev/null +++ b/src/uix/acel/scroll-area/components/scroll-area-thumb.svelte @@ -0,0 +1,24 @@ + + + + {#snippet presence({ present })} + + {/snippet} + diff --git a/src/uix/acel/scroll-area/components/scroll-area-viewport.svelte b/src/uix/acel/scroll-area/components/scroll-area-viewport.svelte new file mode 100644 index 000000000..cea165d62 --- /dev/null +++ b/src/uix/acel/scroll-area/components/scroll-area-viewport.svelte @@ -0,0 +1,50 @@ + + +
+
+ {@render children?.()} +
+
+ + diff --git a/src/uix/acel/scroll-area/components/scroll-area.svelte b/src/uix/acel/scroll-area/components/scroll-area.svelte new file mode 100644 index 000000000..7c8b1dbcf --- /dev/null +++ b/src/uix/acel/scroll-area/components/scroll-area.svelte @@ -0,0 +1,37 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/acel/scroll-area/exports.ts b/src/uix/acel/scroll-area/exports.ts new file mode 100644 index 000000000..5ef346fc3 --- /dev/null +++ b/src/uix/acel/scroll-area/exports.ts @@ -0,0 +1,5 @@ +export { default as Root } from "./components/scroll-area.svelte"; +export { default as Viewport } from "./components/scroll-area-viewport.svelte"; +export { default as Scrollbar } from "./components/scroll-area-scrollbar.svelte"; +export { default as Thumb } from "./components/scroll-area-thumb.svelte"; +export { default as Corner } from "./components/scroll-area-corner.svelte"; diff --git a/src/uix/acel/scroll-area/index.ts b/src/uix/acel/scroll-area/index.ts new file mode 100644 index 000000000..9a8ff24be --- /dev/null +++ b/src/uix/acel/scroll-area/index.ts @@ -0,0 +1,2 @@ +export * from "./exports"; +export * from "./types"; diff --git a/src/uix/acel/scroll-area/scroll-area.svelte.ts b/src/uix/acel/scroll-area/scroll-area.svelte.ts new file mode 100644 index 000000000..c4c03825f --- /dev/null +++ b/src/uix/acel/scroll-area/scroll-area.svelte.ts @@ -0,0 +1,1018 @@ +/** + * This logic is adapted from Radix UI ScrollArea component. + * https://github.com/radix-ui/primitives/blob/main/packages/react/scroll-area/src/ScrollArea.tsx + * Credit to Jenna Smith (@jjenzz) for the original implementation. + */ + +import { Context, useDebounce, watch } from "runed"; +import { untrack } from "svelte"; +import { + simpleBox, + executeCallbacks, + attachRef, + DOMContext, + getWindow, + type ReadableBoxedValues, +} from "../utilities/index"; +import type { ScrollAreaType } from "./types"; +import type { AcelPointerEvent, RefAttachment, WithRefOpts } from "../utilities/types"; +import type { Direction, Orientation } from "../utilities/index"; +import { mergeProps } from "../utilities/index"; +import { clamp } from "../utilities/clamp"; +import { on } from "svelte/events"; +import { createAcelAttrs } from "../utilities/attrs"; +import { StateMachine } from "../utilities/state-machine"; +import { SvelteResizeObserver } from "../utilities/dom/svelte-resize-observer.svelte"; +import { linearScale } from "../utilities/math"; + +const scrollAreaAttrs = createAcelAttrs({ + component: "scroll-area", + parts: ["root", "viewport", "corner", "thumb", "scrollbar"], +}); + +export const ScrollAreaRootContext = new Context("ScrollArea.Root"); +export const ScrollAreaScrollbarContext = new Context( + "ScrollArea.Scrollbar" +); +export const ScrollAreaScrollbarVisibleContext = new Context( + "ScrollArea.ScrollbarVisible" +); +export const ScrollAreaScrollbarAxisContext = new Context( + "ScrollArea.ScrollbarAxis" +); +export const ScrollAreaScrollbarSharedContext = new Context( + "ScrollArea.ScrollbarShared" +); + +interface Sizes { + content: number; + viewport: number; + scrollbar: { + size: number; + paddingStart: number; + paddingEnd: number; + }; +} + +interface ScrollAreaRootStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ + dir: Direction; + type: ScrollAreaType; + scrollHideDelay: number; + }> {} + +export class ScrollAreaRootState { + static create(opts: ScrollAreaRootStateOpts) { + return ScrollAreaRootContext.set(new ScrollAreaRootState(opts)); + } + + readonly opts: ScrollAreaRootStateOpts; + readonly attachment: RefAttachment; + scrollAreaNode = $state(null); + viewportNode = $state(null); + contentNode = $state(null); + scrollbarXNode = $state(null); + scrollbarYNode = $state(null); + cornerWidth = $state(0); + cornerHeight = $state(0); + scrollbarXEnabled = $state(false); + scrollbarYEnabled = $state(false); + domContext: DOMContext; + + constructor(opts: ScrollAreaRootStateOpts) { + this.opts = opts; + this.attachment = attachRef(opts.ref, (v) => (this.scrollAreaNode = v)); + this.domContext = new DOMContext(opts.ref); + } + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + dir: this.opts.dir.current, + style: { + position: "relative", + "--acel-scroll-area-corner-height": `${this.cornerHeight}px`, + "--acel-scroll-area-corner-width": `${this.cornerWidth}px`, + }, + [scrollAreaAttrs.root]: "", + ...this.attachment, + }) as const + ); +} + +interface ScrollAreaViewportStateOpts extends WithRefOpts {} + +export class ScrollAreaViewportState { + static create(opts: ScrollAreaViewportStateOpts) { + return new ScrollAreaViewportState(opts, ScrollAreaRootContext.get()); + } + + readonly opts: ScrollAreaViewportStateOpts; + readonly root: ScrollAreaRootState; + readonly attachment: RefAttachment; + #contentId = simpleBox(crypto.randomUUID()); + #contentRef = simpleBox(null); + readonly contentAttachment: RefAttachment = attachRef( + this.#contentRef, + (v) => (this.root.contentNode = v) + ); + + constructor(opts: ScrollAreaViewportStateOpts, root: ScrollAreaRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(opts.ref, (v) => (this.root.viewportNode = v)); + } + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + style: { + overflowX: this.root.scrollbarXEnabled ? "scroll" : "hidden", + overflowY: this.root.scrollbarYEnabled ? "scroll" : "hidden", + }, + [scrollAreaAttrs.viewport]: "", + ...this.attachment, + }) as const + ); + + readonly contentProps = $derived.by( + () => + ({ + id: this.#contentId.current, + "data-scroll-area-content": "", + style: { minWidth: this.root.scrollbarXEnabled ? "fit-content" : undefined }, + ...this.contentAttachment, + }) as const + ); +} + +interface ScrollAreaScrollbarStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ + orientation: Orientation; + }> {} + +export class ScrollAreaScrollbarState { + static create(opts: ScrollAreaScrollbarStateOpts) { + return ScrollAreaScrollbarContext.set( + new ScrollAreaScrollbarState(opts, ScrollAreaRootContext.get()) + ); + } + readonly opts: ScrollAreaScrollbarStateOpts; + readonly root: ScrollAreaRootState; + readonly isHorizontal = $derived.by(() => this.opts.orientation.current === "horizontal"); + hasThumb = $state(false); + + constructor(opts: ScrollAreaScrollbarStateOpts, root: ScrollAreaRootState) { + this.opts = opts; + this.root = root; + + watch( + () => this.isHorizontal, + (isHorizontal) => { + if (isHorizontal) { + this.root.scrollbarXEnabled = true; + return () => { + this.root.scrollbarXEnabled = false; + }; + } else { + this.root.scrollbarYEnabled = true; + return () => { + this.root.scrollbarYEnabled = false; + }; + } + } + ); + } +} + +export class ScrollAreaScrollbarHoverState { + static create() { + return new ScrollAreaScrollbarHoverState(ScrollAreaScrollbarContext.get()); + } + readonly scrollbar: ScrollAreaScrollbarState; + root: ScrollAreaRootState; + isVisible = $state(false); + + constructor(scrollbar: ScrollAreaScrollbarState) { + this.scrollbar = scrollbar; + this.root = scrollbar.root; + + $effect(() => { + const scrollAreaNode = this.root.scrollAreaNode; + const hideDelay = this.root.opts.scrollHideDelay.current; + let hideTimer = 0; + if (!scrollAreaNode) return; + const handlePointerEnter = () => { + this.root.domContext.clearTimeout(hideTimer); + untrack(() => (this.isVisible = true)); + }; + + const handlePointerLeave = () => { + if (hideTimer) this.root.domContext.clearTimeout(hideTimer); + hideTimer = this.root.domContext.setTimeout(() => { + untrack(() => { + this.scrollbar.hasThumb = false; + this.isVisible = false; + }); + }, hideDelay); + }; + + const unsubListeners = executeCallbacks( + on(scrollAreaNode, "pointerenter", handlePointerEnter), + on(scrollAreaNode, "pointerleave", handlePointerLeave) + ); + + return () => { + this.root.domContext.getWindow().clearTimeout(hideTimer); + unsubListeners(); + }; + }); + } + + readonly props = $derived.by( + () => + ({ + "data-state": this.isVisible ? "visible" : "hidden", + }) as const + ); +} + +export class ScrollAreaScrollbarScrollState { + static create() { + return new ScrollAreaScrollbarScrollState(ScrollAreaScrollbarContext.get()); + } + readonly scrollbar: ScrollAreaScrollbarState; + readonly root: ScrollAreaRootState; + readonly machine = new StateMachine("hidden", { + hidden: { + SCROLL: "scrolling", + }, + scrolling: { + SCROLL_END: "idle", + POINTER_ENTER: "interacting", + }, + interacting: { + SCROLL: "interacting", + POINTER_LEAVE: "idle", + }, + idle: { + HIDE: "hidden", + SCROLL: "scrolling", + POINTER_ENTER: "interacting", + }, + }); + readonly isHidden = $derived.by(() => this.machine.state.current === "hidden"); + + constructor(scrollbar: ScrollAreaScrollbarState) { + this.scrollbar = scrollbar; + this.root = scrollbar.root; + + const debounceScrollend = useDebounce(() => this.machine.dispatch("SCROLL_END"), 100); + + $effect(() => { + const _state = this.machine.state.current; + const scrollHideDelay = this.root.opts.scrollHideDelay.current; + if (_state === "idle") { + const hideTimer = this.root.domContext.setTimeout( + () => this.machine.dispatch("HIDE"), + scrollHideDelay + ); + return () => this.root.domContext.clearTimeout(hideTimer); + } + }); + + $effect(() => { + const viewportNode = this.root.viewportNode; + if (!viewportNode) return; + const scrollDirection = this.scrollbar.isHorizontal ? "scrollLeft" : "scrollTop"; + + let prevScrollPos = viewportNode[scrollDirection]; + const handleScroll = () => { + const scrollPos = viewportNode[scrollDirection]; + const hasScrollInDirectionChanged = prevScrollPos !== scrollPos; + if (hasScrollInDirectionChanged) { + this.machine.dispatch("SCROLL"); + debounceScrollend(); + } + prevScrollPos = scrollPos; + }; + + return on(viewportNode, "scroll", handleScroll); + }); + + this.onpointerenter = this.onpointerenter.bind(this); + this.onpointerleave = this.onpointerleave.bind(this); + } + + onpointerenter(_: AcelPointerEvent) { + this.machine.dispatch("POINTER_ENTER"); + } + + onpointerleave(_: AcelPointerEvent) { + this.machine.dispatch("POINTER_LEAVE"); + } + + readonly props = $derived.by( + () => + ({ + "data-state": this.machine.state.current === "hidden" ? "hidden" : "visible", + onpointerenter: this.onpointerenter, + onpointerleave: this.onpointerleave, + }) as const + ); +} + +export class ScrollAreaScrollbarAutoState { + static create() { + return new ScrollAreaScrollbarAutoState(ScrollAreaScrollbarContext.get()); + } + readonly scrollbar: ScrollAreaScrollbarState; + readonly root: ScrollAreaRootState; + isVisible = $state(false); + + constructor(scrollbar: ScrollAreaScrollbarState) { + this.scrollbar = scrollbar; + this.root = scrollbar.root; + + const handleResize = useDebounce(() => { + const viewportNode = this.root.viewportNode; + if (!viewportNode) return; + const isOverflowX = viewportNode.offsetWidth < viewportNode.scrollWidth; + const isOverflowY = viewportNode.offsetHeight < viewportNode.scrollHeight; + this.isVisible = this.scrollbar.isHorizontal ? isOverflowX : isOverflowY; + }, 10); + + new SvelteResizeObserver(() => this.root.viewportNode, handleResize); + new SvelteResizeObserver(() => this.root.contentNode, handleResize); + } + + readonly props = $derived.by( + () => + ({ + "data-state": this.isVisible ? "visible" : "hidden", + }) as const + ); +} + +export class ScrollAreaScrollbarVisibleState { + static create() { + return ScrollAreaScrollbarVisibleContext.set( + new ScrollAreaScrollbarVisibleState(ScrollAreaScrollbarContext.get()) + ); + } + readonly scrollbar: ScrollAreaScrollbarState; + readonly root: ScrollAreaRootState; + thumbNode = $state(null); + pointerOffset = $state(0); + sizes = $state.raw({ + content: 0, + viewport: 0, + scrollbar: { size: 0, paddingStart: 0, paddingEnd: 0 }, + }); + readonly thumbRatio = $derived.by(() => getThumbRatio(this.sizes.viewport, this.sizes.content)); + readonly hasThumb = $derived.by(() => Boolean(this.thumbRatio > 0 && this.thumbRatio < 1)); + prevTransformStyle = $state(""); + + constructor(scrollbar: ScrollAreaScrollbarState) { + this.scrollbar = scrollbar; + this.root = scrollbar.root; + + $effect(() => { + this.scrollbar.hasThumb = this.hasThumb; + }); + + $effect(() => { + if (!this.scrollbar.hasThumb && this.thumbNode) { + this.prevTransformStyle = this.thumbNode.style.transform; + } + }); + } + + setSizes(sizes: Sizes) { + this.sizes = sizes; + } + + getScrollPosition(pointerPos: number, dir?: Direction) { + return getScrollPositionFromPointer({ + pointerPos, + pointerOffset: this.pointerOffset, + sizes: this.sizes, + dir, + }); + } + + onThumbPointerUp() { + this.pointerOffset = 0; + } + + onThumbPointerDown(pointerPos: number) { + this.pointerOffset = pointerPos; + } + + xOnThumbPositionChange() { + if (!(this.root.viewportNode && this.thumbNode)) return; + const scrollPos = this.root.viewportNode.scrollLeft; + const offset = getThumbOffsetFromScroll({ + scrollPos, + sizes: this.sizes, + dir: this.root.opts.dir.current, + }); + const transformStyle = `translate3d(${offset}px, 0, 0)`; + this.thumbNode.style.transform = transformStyle; + this.prevTransformStyle = transformStyle; + } + + xOnWheelScroll(scrollPos: number) { + if (!this.root.viewportNode) return; + this.root.viewportNode.scrollLeft = scrollPos; + } + + xOnDragScroll(pointerPos: number) { + if (!this.root.viewportNode) return; + this.root.viewportNode.scrollLeft = this.getScrollPosition( + pointerPos, + this.root.opts.dir.current + ); + } + + yOnThumbPositionChange() { + if (!(this.root.viewportNode && this.thumbNode)) return; + const scrollPos = this.root.viewportNode.scrollTop; + const offset = getThumbOffsetFromScroll({ scrollPos, sizes: this.sizes }); + const transformStyle = `translate3d(0, ${offset}px, 0)`; + this.thumbNode.style.transform = transformStyle; + this.prevTransformStyle = transformStyle; + } + + yOnWheelScroll(scrollPos: number) { + if (!this.root.viewportNode) return; + this.root.viewportNode.scrollTop = scrollPos; + } + + yOnDragScroll(pointerPos: number) { + if (!this.root.viewportNode) return; + this.root.viewportNode.scrollTop = this.getScrollPosition( + pointerPos, + this.root.opts.dir.current + ); + } +} + +interface ScrollbarAxisStateOpts extends ReadableBoxedValues<{ mounted: boolean }> {} + +interface ScrollbarAxisState { + onThumbPointerDown: (pointerPos: { x: number; y: number }) => void; + onDragScroll: (pointerPos: { x: number; y: number }) => void; + onWheelScroll: (e: WheelEvent, maxScrollPos: number) => void; + onResize: () => void; + onThumbPositionChange: () => void; + onThumbPointerUp: () => void; + props: { + id: string; + "data-orientation": "horizontal" | "vertical"; + style: Record; + }; +} + +export class ScrollAreaScrollbarXState implements ScrollbarAxisState { + static create(opts: ScrollbarAxisStateOpts) { + return ScrollAreaScrollbarAxisContext.set( + new ScrollAreaScrollbarXState(opts, ScrollAreaScrollbarVisibleContext.get()) + ); + } + readonly opts: ScrollbarAxisStateOpts; + readonly scrollbarVis: ScrollAreaScrollbarVisibleState; + readonly root: ScrollAreaRootState; + readonly scrollbar: ScrollAreaScrollbarState; + readonly attachment: RefAttachment; + computedStyle = $state(); + + constructor(opts: ScrollbarAxisStateOpts, scrollbarVis: ScrollAreaScrollbarVisibleState) { + this.opts = opts; + this.scrollbarVis = scrollbarVis; + this.root = scrollbarVis.root; + this.scrollbar = scrollbarVis.scrollbar; + this.attachment = attachRef(this.scrollbar.opts.ref, (v) => (this.root.scrollbarXNode = v)); + + $effect(() => { + if (!this.scrollbar.opts.ref.current) return; + if (this.opts.mounted.current) { + this.computedStyle = getComputedStyle(this.scrollbar.opts.ref.current!); + } + }); + + $effect(() => { + this.onResize(); + }); + } + + onThumbPointerDown = (pointerPos: { x: number; y: number }) => { + this.scrollbarVis.onThumbPointerDown(pointerPos.x); + }; + + onDragScroll = (pointerPos: { x: number; y: number }) => { + this.scrollbarVis.xOnDragScroll(pointerPos.x); + }; + + onThumbPointerUp = () => { + this.scrollbarVis.onThumbPointerUp(); + }; + + onThumbPositionChange = () => { + this.scrollbarVis.xOnThumbPositionChange(); + }; + + onWheelScroll = (e: WheelEvent, maxScrollPos: number) => { + if (!this.root.viewportNode) return; + const scrollPos = this.root.viewportNode.scrollLeft + e.deltaX; + this.scrollbarVis.xOnWheelScroll(scrollPos); + if (isScrollingWithinScrollbarBounds(scrollPos, maxScrollPos)) { + e.preventDefault(); + } + }; + + onResize = () => { + if (!(this.scrollbar.opts.ref.current && this.root.viewportNode && this.computedStyle)) + return; + this.scrollbarVis.setSizes({ + content: this.root.viewportNode.scrollWidth, + viewport: this.root.viewportNode.offsetWidth, + scrollbar: { + size: this.scrollbar.opts.ref.current.clientWidth, + paddingStart: toInt(this.computedStyle.paddingLeft), + paddingEnd: toInt(this.computedStyle.paddingRight), + }, + }); + }; + + readonly thumbSize = $derived.by(() => getThumbSize(this.scrollbarVis.sizes)); + + readonly props = $derived.by( + () => + ({ + id: this.scrollbar.opts.id.current, + "data-orientation": "horizontal" as const, + style: { + bottom: 0, + left: + this.root.opts.dir.current === "rtl" + ? "var(--acel-scroll-area-corner-width)" + : 0, + right: + this.root.opts.dir.current === "ltr" + ? "var(--acel-scroll-area-corner-width)" + : 0, + "--acel-scroll-area-thumb-width": `${this.thumbSize}px`, + }, + ...this.attachment, + }) as const + ); +} + +export class ScrollAreaScrollbarYState implements ScrollbarAxisState { + static create(opts: ScrollbarAxisStateOpts) { + return ScrollAreaScrollbarAxisContext.set( + new ScrollAreaScrollbarYState(opts, ScrollAreaScrollbarVisibleContext.get()) + ); + } + readonly opts: ScrollbarAxisStateOpts; + readonly scrollbarVis: ScrollAreaScrollbarVisibleState; + readonly root: ScrollAreaRootState; + readonly scrollbar: ScrollAreaScrollbarState; + readonly attachment: RefAttachment; + computedStyle = $state(); + + constructor(opts: ScrollbarAxisStateOpts, scrollbarVis: ScrollAreaScrollbarVisibleState) { + this.opts = opts; + this.scrollbarVis = scrollbarVis; + this.root = scrollbarVis.root; + this.scrollbar = scrollbarVis.scrollbar; + this.attachment = attachRef(this.scrollbar.opts.ref, (v) => (this.root.scrollbarYNode = v)); + + $effect(() => { + if (!this.scrollbar.opts.ref.current) return; + if (this.opts.mounted.current) { + this.computedStyle = getComputedStyle(this.scrollbar.opts.ref.current!); + } + }); + + $effect(() => { + this.onResize(); + }); + + this.onThumbPointerDown = this.onThumbPointerDown.bind(this); + this.onDragScroll = this.onDragScroll.bind(this); + this.onThumbPointerUp = this.onThumbPointerUp.bind(this); + this.onThumbPositionChange = this.onThumbPositionChange.bind(this); + this.onWheelScroll = this.onWheelScroll.bind(this); + this.onResize = this.onResize.bind(this); + } + + onThumbPointerDown(pointerPos: { x: number; y: number }) { + this.scrollbarVis.onThumbPointerDown(pointerPos.y); + } + + onDragScroll(pointerPos: { x: number; y: number }) { + this.scrollbarVis.yOnDragScroll(pointerPos.y); + } + + onThumbPointerUp() { + this.scrollbarVis.onThumbPointerUp(); + } + + onThumbPositionChange() { + this.scrollbarVis.yOnThumbPositionChange(); + } + + onWheelScroll(e: WheelEvent, maxScrollPos: number) { + if (!this.root.viewportNode) return; + const scrollPos = this.root.viewportNode.scrollTop + e.deltaY; + this.scrollbarVis.yOnWheelScroll(scrollPos); + if (isScrollingWithinScrollbarBounds(scrollPos, maxScrollPos)) { + e.preventDefault(); + } + } + + onResize() { + if (!(this.scrollbar.opts.ref.current && this.root.viewportNode && this.computedStyle)) + return; + this.scrollbarVis.setSizes({ + content: this.root.viewportNode.scrollHeight, + viewport: this.root.viewportNode.offsetHeight, + scrollbar: { + size: this.scrollbar.opts.ref.current.clientHeight, + paddingStart: toInt(this.computedStyle.paddingTop), + paddingEnd: toInt(this.computedStyle.paddingBottom), + }, + }); + } + + readonly thumbSize = $derived.by(() => getThumbSize(this.scrollbarVis.sizes)); + + readonly props = $derived.by( + () => + ({ + id: this.scrollbar.opts.id.current, + "data-orientation": "vertical" as const, + style: { + top: 0, + right: this.root.opts.dir.current === "ltr" ? 0 : undefined, + left: this.root.opts.dir.current === "rtl" ? 0 : undefined, + bottom: "var(--acel-scroll-area-corner-height)", + "--acel-scroll-area-thumb-height": `${this.thumbSize}px`, + }, + ...this.attachment, + }) as const + ); +} + +type ScrollbarAxis = ScrollAreaScrollbarXState | ScrollAreaScrollbarYState; + +export class ScrollAreaScrollbarSharedState { + static create() { + return ScrollAreaScrollbarSharedContext.set( + new ScrollAreaScrollbarSharedState(ScrollAreaScrollbarAxisContext.get()) + ); + } + readonly scrollbarState: ScrollbarAxis; + readonly root: ScrollAreaRootState; + readonly scrollbarVis: ScrollAreaScrollbarVisibleState; + readonly scrollbar: ScrollAreaScrollbarState; + rect = $state.raw(null); + prevWebkitUserSelect = $state(""); + handleResize: () => void; + handleThumbPositionChange: () => void; + handleWheelScroll: (e: WheelEvent, maxScrollPos: number) => void; + handleThumbPointerDown: (pointerPos: { x: number; y: number }) => void; + handleThumbPointerUp: () => void; + readonly maxScrollPos = $derived.by( + () => this.scrollbarVis.sizes.content - this.scrollbarVis.sizes.viewport + ); + + constructor(scrollbarState: ScrollbarAxis) { + this.scrollbarState = scrollbarState; + this.root = scrollbarState.root; + this.scrollbarVis = scrollbarState.scrollbarVis; + this.scrollbar = scrollbarState.scrollbarVis.scrollbar; + this.handleResize = useDebounce(() => this.scrollbarState.onResize(), 10); + this.handleThumbPositionChange = this.scrollbarState.onThumbPositionChange; + this.handleWheelScroll = this.scrollbarState.onWheelScroll; + this.handleThumbPointerDown = this.scrollbarState.onThumbPointerDown; + this.handleThumbPointerUp = this.scrollbarState.onThumbPointerUp; + + $effect(() => { + const maxScrollPos = this.maxScrollPos; + const scrollbarNode = this.scrollbar.opts.ref.current; + this.root.viewportNode; + const handleWheel = (e: WheelEvent) => { + const node = e.target as HTMLElement; + const isScrollbarWheel = scrollbarNode?.contains(node); + if (isScrollbarWheel) this.handleWheelScroll(e, maxScrollPos); + }; + + return on(this.root.domContext.getDocument(), "wheel", handleWheel, { + passive: false, + }); + }); + + $effect.pre(() => { + this.scrollbarVis.sizes; + untrack(() => this.handleThumbPositionChange()); + }); + + new SvelteResizeObserver(() => this.scrollbar.opts.ref.current, this.handleResize); + new SvelteResizeObserver(() => this.root.contentNode, this.handleResize); + + this.onpointerdown = this.onpointerdown.bind(this); + this.onpointermove = this.onpointermove.bind(this); + this.onpointerup = this.onpointerup.bind(this); + this.onlostpointercapture = this.onlostpointercapture.bind(this); + } + + handleDragScroll(e: PointerEvent) { + if (!this.rect) return; + const x = e.clientX - this.rect.left; + const y = e.clientY - this.rect.top; + this.scrollbarState.onDragScroll({ x, y }); + } + + #cleanupPointerState(): void { + if (this.rect === null) return; + this.root.domContext.getDocument().body.style.webkitUserSelect = this.prevWebkitUserSelect; + if (this.root.viewportNode) this.root.viewportNode.style.scrollBehavior = ""; + this.rect = null; + } + + onpointerdown(e: AcelPointerEvent) { + if (e.button !== 0) return; + const target = e.target as HTMLElement; + target.setPointerCapture(e.pointerId); + this.rect = this.scrollbar.opts.ref.current?.getBoundingClientRect() ?? null; + this.prevWebkitUserSelect = this.root.domContext.getDocument().body.style.webkitUserSelect; + this.root.domContext.getDocument().body.style.webkitUserSelect = "none"; + if (this.root.viewportNode) this.root.viewportNode.style.scrollBehavior = "auto"; + this.handleDragScroll(e); + } + + onpointermove(e: AcelPointerEvent) { + this.handleDragScroll(e); + } + + onpointerup(e: AcelPointerEvent) { + const target = e.target as HTMLElement; + if (target.hasPointerCapture(e.pointerId)) { + target.releasePointerCapture(e.pointerId); + } + this.#cleanupPointerState(); + } + + onlostpointercapture(_: AcelPointerEvent) { + this.#cleanupPointerState(); + } + + readonly props = $derived.by(() => + mergeProps({ + ...this.scrollbarState.props, + style: { + position: "absolute", + ...this.scrollbarState.props.style, + }, + [scrollAreaAttrs.scrollbar]: "", + onpointerdown: this.onpointerdown, + onpointermove: this.onpointermove, + onpointerup: this.onpointerup, + onlostpointercapture: this.onlostpointercapture, + }) + ); +} + +interface ScrollAreaThumbImplStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ + mounted: boolean; + }> {} + +export class ScrollAreaThumbImplState { + static create(opts: ScrollAreaThumbImplStateOpts) { + return new ScrollAreaThumbImplState(opts, ScrollAreaScrollbarSharedContext.get()); + } + readonly opts: ScrollAreaThumbImplStateOpts; + readonly scrollbarState: ScrollAreaScrollbarSharedState; + readonly attachment: RefAttachment; + readonly #root: ScrollAreaRootState; + #removeUnlinkedScrollListener = $state<() => void>(); + readonly #debounceScrollEnd = useDebounce(() => { + if (this.#removeUnlinkedScrollListener) { + this.#removeUnlinkedScrollListener(); + this.#removeUnlinkedScrollListener = undefined; + } + }, 100); + + constructor( + opts: ScrollAreaThumbImplStateOpts, + scrollbarState: ScrollAreaScrollbarSharedState + ) { + this.opts = opts; + this.scrollbarState = scrollbarState; + this.#root = scrollbarState.root; + this.attachment = attachRef( + this.opts.ref, + (v) => (this.scrollbarState.scrollbarVis.thumbNode = v) + ); + + $effect(() => { + const viewportNode = this.#root.viewportNode; + if (!viewportNode) return; + const handleScroll = () => { + this.#debounceScrollEnd(); + if (!this.#removeUnlinkedScrollListener) { + const listener = addUnlinkedScrollListener( + viewportNode, + this.scrollbarState.handleThumbPositionChange + ); + this.#removeUnlinkedScrollListener = listener; + this.scrollbarState.handleThumbPositionChange(); + } + }; + untrack(() => this.scrollbarState.handleThumbPositionChange()); + return on(viewportNode, "scroll", handleScroll); + }); + + this.onpointerdowncapture = this.onpointerdowncapture.bind(this); + this.onpointerup = this.onpointerup.bind(this); + } + + onpointerdowncapture(e: AcelPointerEvent) { + const thumb = e.target as HTMLElement; + if (!thumb) return; + const thumbRect = thumb.getBoundingClientRect(); + const x = e.clientX - thumbRect.left; + const y = e.clientY - thumbRect.top; + this.scrollbarState.handleThumbPointerDown({ x, y }); + } + + onpointerup(_: AcelPointerEvent) { + this.scrollbarState.handleThumbPointerUp(); + } + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + "data-state": this.scrollbarState.scrollbarVis.hasThumb ? "visible" : "hidden", + style: { + width: "var(--acel-scroll-area-thumb-width)", + height: "var(--acel-scroll-area-thumb-height)", + transform: this.scrollbarState.scrollbarVis.prevTransformStyle, + }, + onpointerdowncapture: this.onpointerdowncapture, + onpointerup: this.onpointerup, + [scrollAreaAttrs.thumb]: "", + ...this.attachment, + }) as const + ); +} + +interface ScrollAreaCornerImplStateOpts extends WithRefOpts {} + +export class ScrollAreaCornerImplState { + static create(opts: ScrollAreaCornerImplStateOpts) { + return new ScrollAreaCornerImplState(opts, ScrollAreaRootContext.get()); + } + + readonly opts: ScrollAreaCornerImplStateOpts; + readonly root: ScrollAreaRootState; + readonly attachment: RefAttachment; + #width = $state(0); + #height = $state(0); + readonly hasSize = $derived(Boolean(this.#width && this.#height)); + + constructor(opts: ScrollAreaCornerImplStateOpts, root: ScrollAreaRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(this.opts.ref); + + new SvelteResizeObserver( + () => this.root.scrollbarXNode, + () => { + const height = this.root.scrollbarXNode?.offsetHeight || 0; + this.root.cornerHeight = height; + this.#height = height; + } + ); + + new SvelteResizeObserver( + () => this.root.scrollbarYNode, + () => { + const width = this.root.scrollbarYNode?.offsetWidth || 0; + this.root.cornerWidth = width; + this.#width = width; + } + ); + } + + readonly props = $derived.by(() => ({ + id: this.opts.id.current, + style: { + width: this.#width, + height: this.#height, + position: "absolute", + right: this.root.opts.dir.current === "ltr" ? 0 : undefined, + left: this.root.opts.dir.current === "rtl" ? 0 : undefined, + bottom: 0, + }, + [scrollAreaAttrs.corner]: "", + ...this.attachment, + })); +} + +function toInt(value?: string) { + return value ? Number.parseInt(value, 10) : 0; +} + +function getThumbRatio(viewportSize: number, contentSize: number) { + const ratio = viewportSize / contentSize; + return Number.isNaN(ratio) ? 0 : ratio; +} + +function getThumbSize(sizes: Sizes) { + const ratio = getThumbRatio(sizes.viewport, sizes.content); + const scrollbarPadding = sizes.scrollbar.paddingStart + sizes.scrollbar.paddingEnd; + const thumbSize = (sizes.scrollbar.size - scrollbarPadding) * ratio; + return Math.max(thumbSize, 18); +} + +type GetScrollPositionFromPointerProps = { + pointerPos: number; + pointerOffset: number; + sizes: Sizes; + dir?: Direction; +}; + +function getScrollPositionFromPointer({ + pointerPos, + pointerOffset, + sizes, + dir = "ltr", +}: GetScrollPositionFromPointerProps) { + const thumbSizePx = getThumbSize(sizes); + const thumbCenter = thumbSizePx / 2; + const offset = pointerOffset || thumbCenter; + const thumbOffsetFromEnd = thumbSizePx - offset; + const minPointerPos = sizes.scrollbar.paddingStart + offset; + const maxPointerPos = sizes.scrollbar.size - sizes.scrollbar.paddingEnd - thumbOffsetFromEnd; + const maxScrollPos = sizes.content - sizes.viewport; + const scrollRange = dir === "ltr" ? [0, maxScrollPos] : [maxScrollPos * -1, 0]; + const interpolate = linearScale( + [minPointerPos, maxPointerPos], + scrollRange as [number, number] + ); + return interpolate(pointerPos); +} + +type GetThumbOffsetFromScrollProps = { + scrollPos: number; + sizes: Sizes; + dir?: Direction; +}; + +function getThumbOffsetFromScroll({ + scrollPos, + sizes, + dir = "ltr", +}: GetThumbOffsetFromScrollProps) { + const thumbSizePx = getThumbSize(sizes); + const scrollbarPadding = sizes.scrollbar.paddingStart + sizes.scrollbar.paddingEnd; + const scrollbar = sizes.scrollbar.size - scrollbarPadding; + const maxScrollPos = sizes.content - sizes.viewport; + const maxThumbPos = scrollbar - thumbSizePx; + const scrollClampRange = dir === "ltr" ? [0, maxScrollPos] : [maxScrollPos * -1, 0]; + const scrollWithoutMomentum = clamp(scrollPos, scrollClampRange[0]!, scrollClampRange[1]!); + const interpolate = linearScale([0, maxScrollPos], [0, maxThumbPos]); + return interpolate(scrollWithoutMomentum); +} + +function isScrollingWithinScrollbarBounds(scrollPos: number, maxScrollPos: number) { + return scrollPos > 0 && scrollPos < maxScrollPos; +} + +function addUnlinkedScrollListener(node: HTMLElement, handler: () => void) { + let prevPosition = { left: node.scrollLeft, top: node.scrollTop }; + let rAF = 0; + const win = getWindow(node); + + (function loop() { + const position = { left: node.scrollLeft, top: node.scrollTop }; + const isHorizontalScroll = prevPosition.left !== position.left; + const isVerticalScroll = prevPosition.top !== position.top; + if (isHorizontalScroll || isVerticalScroll) handler(); + prevPosition = position; + rAF = win.requestAnimationFrame(loop); + })(); + + return () => win.cancelAnimationFrame(rAF); +} diff --git a/src/uix/acel/scroll-area/types.ts b/src/uix/acel/scroll-area/types.ts new file mode 100644 index 000000000..1eaa3bd5d --- /dev/null +++ b/src/uix/acel/scroll-area/types.ts @@ -0,0 +1,43 @@ +import type { AcelPrimitiveDivAttributes } from "../utilities/attributes"; +import type { WithChild, Without } from "../utilities/types"; +import type { Direction, Orientation } from "../utilities/index"; + +export type ScrollAreaType = "hover" | "scroll" | "auto" | "always"; + +export type ScrollAreaRootPropsWithoutHTML = WithChild<{ + type?: ScrollAreaType; + dir?: Direction; + scrollHideDelay?: number; +}>; + +export type ScrollAreaRootProps = ScrollAreaRootPropsWithoutHTML & + Without; + +export type ScrollAreaViewportPropsWithoutHTML = Omit; + +export type ScrollAreaViewportProps = ScrollAreaViewportPropsWithoutHTML & + Without; + +export type ScrollAreaScrollbarPropsWithoutHTML = WithChild<{ + orientation: Orientation; + forceMount?: boolean; +}>; + +export type ScrollAreaScrollbarProps = ScrollAreaScrollbarPropsWithoutHTML & + Without; + +export type ScrollAreaThumbPropsWithoutHTML = WithChild<{ + forceMount?: boolean; +}>; + +export type ScrollAreaThumbProps = ScrollAreaThumbPropsWithoutHTML & + Without; + +export type ScrollAreaCornerPropsWithoutHTML = WithChild; + +export type ScrollAreaCornerProps = ScrollAreaCornerPropsWithoutHTML & + Without; + +export type _ScrollbarStubProps = Omit & { + id: string; +}; diff --git a/src/uix/acel/separator/components/separator.svelte b/src/uix/acel/separator/components/separator.svelte new file mode 100644 index 000000000..c930afc75 --- /dev/null +++ b/src/uix/acel/separator/components/separator.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/acel/separator/exports.ts b/src/uix/acel/separator/exports.ts new file mode 100644 index 000000000..00e33a693 --- /dev/null +++ b/src/uix/acel/separator/exports.ts @@ -0,0 +1,2 @@ +export { default as Root } from "./components/separator.svelte"; +export type { SeparatorRootProps as RootProps } from "./types"; diff --git a/src/uix/acel/separator/index.ts b/src/uix/acel/separator/index.ts new file mode 100644 index 000000000..72052df50 --- /dev/null +++ b/src/uix/acel/separator/index.ts @@ -0,0 +1,2 @@ +export * as Separator from "./exports"; +export * from "./exports"; diff --git a/src/uix/acel/separator/separator.svelte.ts b/src/uix/acel/separator/separator.svelte.ts new file mode 100644 index 000000000..08062767f --- /dev/null +++ b/src/uix/acel/separator/separator.svelte.ts @@ -0,0 +1,42 @@ +import { attachRef, type ReadableBoxedValues } from "../utilities/index"; +import type { RefAttachment, WithRefOpts } from "../utilities/types"; +import { createAcelAttrs } from "../utilities/attrs"; + +const separatorAttrs = createAcelAttrs({ + component: "separator", + parts: ["root"] as const, +}); + +type Orientation = "horizontal" | "vertical"; + +interface SeparatorRootStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ orientation: Orientation; decorative: boolean }> {} + +export class SeparatorRootState { + static create(opts: SeparatorRootStateOpts) { + return new SeparatorRootState(opts); + } + + readonly opts: SeparatorRootStateOpts; + readonly attachment: RefAttachment; + + constructor(opts: SeparatorRootStateOpts) { + this.opts = opts; + this.attachment = attachRef(this.opts.ref); + } + + readonly props = $derived.by(() => { + const orientation = this.opts.orientation.current; + const decorative = this.opts.decorative.current; + return { + id: this.opts.id.current, + role: decorative ? undefined : "separator", + "aria-orientation": decorative ? undefined : orientation, + "aria-hidden": decorative ? "true" : undefined, + "data-orientation": orientation, + [separatorAttrs.root]: "", + ...this.attachment, + } as const; + }); +} diff --git a/src/uix/acel/separator/types.ts b/src/uix/acel/separator/types.ts new file mode 100644 index 000000000..25e2c9318 --- /dev/null +++ b/src/uix/acel/separator/types.ts @@ -0,0 +1,18 @@ +import type { WithChild, Without } from "../utilities/types"; +import type { AcelPrimitiveDivAttributes } from "../utilities/attributes"; + +export type SeparatorRootPropsWithoutHTML = WithChild<{ + /** + * La orientación del separador. + * @defaultValue "horizontal" + */ + orientation?: "horizontal" | "vertical"; + /** + * Si es decorativo (solo visual), no expone semántica de accesibilidad. + * @defaultValue true + */ + decorative?: boolean; +}>; + +export type SeparatorRootProps = SeparatorRootPropsWithoutHTML & + Without; diff --git a/src/uix/acel/shared/css.d.ts b/src/uix/acel/shared/css.d.ts deleted file mode 100644 index 07f03393a..000000000 --- a/src/uix/acel/shared/css.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type * as CSS from "csstype"; - -declare module "csstype" { - interface Properties { - // Allow any CSS Custom Properties - // oxlint-disable-next-line no-explicit-any - [index: `--${string}`]: any; - } -} diff --git a/src/uix/acel/shared/index.ts b/src/uix/acel/shared/index.ts deleted file mode 100644 index e18a1d6c9..000000000 --- a/src/uix/acel/shared/index.ts +++ /dev/null @@ -1,67 +0,0 @@ -// oxlint-disable no-explicit-any -import type * as CSS from "csstype"; - -// Regex patterns for pin-input (copied to avoid dependency) -export const REGEXP_ONLY_DIGITS = /^\d+$/; -export const REGEXP_ONLY_CHARS = /^[a-zA-Z]+$/; -export const REGEXP_ONLY_DIGITS_AND_CHARS = /^[a-zA-Z0-9]+$/; - -export type Selected = { - value: Value; - label?: string; -}; - -export type SegmentPart = - | "month" - | "day" - | "year" - | "hour" - | "minute" - | "second" - | "dayPeriod" - | "timeZoneName" - | "literal"; - -export type FocusTarget = string | HTMLElement | SVGElement | null; -export type FocusProp = FocusTarget | ((defaultEl?: HTMLElement | null) => FocusTarget); - -export type StyleProperties = CSS.Properties & { - // Allow any CSS Custom Properties - [str: `--${string}`]: any; -}; - -export type Orientation = "horizontal" | "vertical"; -export type Direction = "ltr" | "rtl"; - -/** - * Controls positioning of the slider thumb. - * - * - `exact`: The thumb is centered exactly at the value of the slider. - * - `contain`: The thumb is centered exactly at the value of the slider, but will be contained within the slider track at the ends. - */ -export type SliderThumbPositioning = "exact" | "contain"; - -export type WithoutChildrenOrChild = WithoutChildren>; -export type WithoutChildren = T extends { children?: any } ? Omit : T; -export type WithoutChild = T extends { child?: any } ? Omit : T; -export type WithElementRef = T & { ref?: U | null }; -export type { EditableSegmentPart, EditableTimeSegmentPart } from "./date/types.ts"; -export type { - Month, - DateMatcher, - DateOnInvalid, - DateRangeValidator, - DateValidator, - DateRange, - TimeValue, - TimeSegmentPart, - TimeRange, - TimeValidator, - TimeRangeValidator, - TimeOnInvalid, -} from "./date/types.ts"; -export type { WithChild, Without, WithChildren } from "../internal"; -export { mergeProps } from "../utilities"; -export { useId } from "../internal/use-id.ts"; -export * from "./attributes.ts"; -export * from "./types.ts"; diff --git a/src/uix/acel/shared/types.ts b/src/uix/acel/shared/types.ts deleted file mode 100644 index fd3ee6fa1..000000000 --- a/src/uix/acel/shared/types.ts +++ /dev/null @@ -1,15 +0,0 @@ -export type FloatingContentSnippetProps = { - /** - * Whether the content is open or closed. Used alongside the `forceMount` prop to - * conditionally render the content using Svelte transitions. - */ - open: boolean; - - /** - * Attributes to spread onto a wrapper element around the content. - * Do not style the wrapper element, its styles are computed by Floating UI. - */ - wrapperProps: Record; -}; - -export type StaticContentSnippetProps = Omit; diff --git a/src/uix/acel/slider/components/slider-range.svelte b/src/uix/acel/slider/components/slider-range.svelte new file mode 100644 index 000000000..369e7e563 --- /dev/null +++ b/src/uix/acel/slider/components/slider-range.svelte @@ -0,0 +1,31 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + + {@render children?.()} + +{/if} diff --git a/src/uix/acel/slider/components/slider-thumb-label.svelte b/src/uix/acel/slider/components/slider-thumb-label.svelte new file mode 100644 index 000000000..f1c78904a --- /dev/null +++ b/src/uix/acel/slider/components/slider-thumb-label.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + + {@render children?.()} + +{/if} diff --git a/src/uix/acel/slider/components/slider-thumb.svelte b/src/uix/acel/slider/components/slider-thumb.svelte new file mode 100644 index 000000000..ba90d7516 --- /dev/null +++ b/src/uix/acel/slider/components/slider-thumb.svelte @@ -0,0 +1,36 @@ + + +{#if child} + {@render child({ props: mergedProps, ...snippetProps })} +{:else} + + {@render children?.(snippetProps)} + +{/if} diff --git a/src/uix/acel/slider/components/slider-tick-label.svelte b/src/uix/acel/slider/components/slider-tick-label.svelte new file mode 100644 index 000000000..6d54f15c5 --- /dev/null +++ b/src/uix/acel/slider/components/slider-tick-label.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + + {@render children?.()} + +{/if} diff --git a/src/uix/acel/slider/components/slider-tick.svelte b/src/uix/acel/slider/components/slider-tick.svelte new file mode 100644 index 000000000..1dfccde8c --- /dev/null +++ b/src/uix/acel/slider/components/slider-tick.svelte @@ -0,0 +1,33 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + + {@render children?.()} + +{/if} diff --git a/src/uix/acel/slider/components/slider.svelte b/src/uix/acel/slider/components/slider.svelte new file mode 100644 index 000000000..68316634c --- /dev/null +++ b/src/uix/acel/slider/components/slider.svelte @@ -0,0 +1,57 @@ + + +{#if child} + {@render child({ props: mergedProps, ...rootState.snippetProps })} +{:else} + + {@render children?.(rootState.snippetProps)} + +{/if} diff --git a/src/uix/acel/slider/exports.ts b/src/uix/acel/slider/exports.ts new file mode 100644 index 000000000..ffe1bda0f --- /dev/null +++ b/src/uix/acel/slider/exports.ts @@ -0,0 +1,6 @@ +export { default as Root } from "./components/slider.svelte"; +export { default as Range } from "./components/slider-range.svelte"; +export { default as Thumb } from "./components/slider-thumb.svelte"; +export { default as Tick } from "./components/slider-tick.svelte"; +export { default as TickLabel } from "./components/slider-tick-label.svelte"; +export { default as ThumbLabel } from "./components/slider-thumb-label.svelte"; diff --git a/src/uix/acel/slider/helpers.ts b/src/uix/acel/slider/helpers.ts new file mode 100644 index 000000000..c00f30f7b --- /dev/null +++ b/src/uix/acel/slider/helpers.ts @@ -0,0 +1,197 @@ +import type { StyleProperties } from "../utilities/index"; + +export function getRangeStyles(direction: "lr" | "rl" | "tb" | "bt", min: number, max: number) { + const styles: StyleProperties = { + position: "absolute", + }; + if (direction === "lr") { + styles.left = `${min}%`; + styles.right = `${max}%`; + } else if (direction === "rl") { + styles.right = `${min}%`; + styles.left = `${max}%`; + } else if (direction === "bt") { + styles.bottom = `${min}%`; + styles.top = `${max}%`; + } else { + styles.top = `${min}%`; + styles.bottom = `${max}%`; + } + return styles; +} + +export function getThumbStyles(direction: "lr" | "rl" | "tb" | "bt", thumbPos: number) { + const styles: StyleProperties = { + position: "absolute", + }; + if (direction === "lr") { + styles.left = `${thumbPos}%`; + styles.translate = "-50% 0"; + } else if (direction === "rl") { + styles.right = `${thumbPos}%`; + styles.translate = "50% 0"; + } else if (direction === "bt") { + styles.bottom = `${thumbPos}%`; + styles.translate = "0 50%"; + } else { + styles.top = `${thumbPos}%`; + styles.translate = "0 -50%"; + } + return styles; +} + +export function getTickStyles( + direction: "lr" | "rl" | "tb" | "bt", + tickPosition: number, + offsetPercentage: number +) { + const style: StyleProperties = { + position: "absolute", + }; + if (direction === "lr") { + style.left = `${tickPosition}%`; + style.translate = `${offsetPercentage}% 0`; + } else if (direction === "rl") { + style.right = `${tickPosition}%`; + style.translate = `${-offsetPercentage}% 0`; + } else if (direction === "bt") { + style.bottom = `${tickPosition}%`; + style.translate = `0 ${-offsetPercentage}%`; + } else { + style.top = `${tickPosition}%`; + style.translate = `0 ${offsetPercentage}%`; + } + return style; +} + +export function getTickLabelStyles( + direction: "lr" | "rl" | "tb" | "bt", + tickPosition: number, + labelPosition: "top" | "bottom" | "left" | "right" = "top" +) { + const style: StyleProperties = { + position: "absolute", + }; + if (direction === "lr" || direction === "rl") { + style.left = direction === "lr" ? `${tickPosition}%` : undefined; + style.right = direction === "rl" ? `${tickPosition}%` : undefined; + style.translate = "-50% 0"; + if (labelPosition === "top") { + style.bottom = "100%"; + } else if (labelPosition === "bottom") { + style.top = "100%"; + } + } else { + if (direction === "tb") { + style.top = `${tickPosition}%`; + } else { + style.bottom = `${tickPosition}%`; + } + style.translate = "0 50%"; + if (labelPosition === "left") { + style.right = "100%"; + } else if (labelPosition === "right") { + style.left = "100%"; + } + } + return style; +} + +export function getThumbLabelStyles( + direction: "lr" | "rl" | "tb" | "bt", + thumbPosition: number, + labelPosition: "top" | "bottom" | "left" | "right" = "top" +) { + const style: StyleProperties = { + position: "absolute", + }; + if (direction === "lr" || direction === "rl") { + style.left = direction === "lr" ? `${thumbPosition}%` : undefined; + style.right = direction === "rl" ? `${thumbPosition}%` : undefined; + style.translate = "-50% 0"; + if (labelPosition === "top") { + style.bottom = "100%"; + } else if (labelPosition === "bottom") { + style.top = "100%"; + } + } else { + if (direction === "tb") { + style.top = `${thumbPosition}%`; + } else { + style.bottom = `${thumbPosition}%`; + } + style.translate = "0 -50%"; + if (labelPosition === "left") { + style.right = "100%"; + } else if (labelPosition === "right") { + style.left = "100%"; + } + } + return style; +} + +function getDecimalPlaces(num: number): number { + if (Math.floor(num) === num) return 0; + const str = num.toString(); + if (str.indexOf(".") !== -1 && str.indexOf("e-") === -1) { + return str.split(".")[1]!.length; + } else if (str.indexOf("e-") !== -1) { + const parts = str.split("e-"); + return parseInt(parts[1]!, 10); + } + return 0; +} + +function roundToPrecision(num: number, precision: number): number { + const factor = Math.pow(10, precision); + return Math.round(num * factor) / factor; +} + +export function normalizeSteps(step: number | number[], min: number, max: number): number[] { + if (typeof step === "number") { + const difference = max - min; + let count = Math.ceil(difference / step); + const precision = getDecimalPlaces(step); + const factor = Math.pow(10, precision); + const intDifference = Math.round(difference * factor); + const intStep = Math.round(step * factor); + if (intDifference % intStep === 0) { + count++; + } + const steps: number[] = []; + for (let i = 0; i < count; i++) { + const value = min + i * step; + steps.push(roundToPrecision(value, precision)); + } + return steps; + } + return [...new Set(step)].filter((value) => value >= min && value <= max).sort((a, b) => a - b); +} + +export function snapValueToCustomSteps(value: number, steps: number[]): number { + if (steps.length === 0) return value; + let closest = steps[0]!; + let minDistance = Math.abs(value - closest); + for (const step of steps) { + const distance = Math.abs(value - step); + if (distance < minDistance) { + minDistance = distance; + closest = step; + } + } + return closest; +} + +export function getAdjacentStepValue( + currentValue: number, + steps: number[], + direction: "next" | "prev" +): number { + const currentIndex = steps.indexOf(currentValue); + if (currentIndex === -1) return snapValueToCustomSteps(currentValue, steps); + if (direction === "next") { + return currentIndex < steps.length - 1 ? steps[currentIndex + 1]! : currentValue; + } else { + return currentIndex > 0 ? steps[currentIndex - 1]! : currentValue; + } +} diff --git a/src/uix/acel/slider/index.ts b/src/uix/acel/slider/index.ts new file mode 100644 index 000000000..9a8ff24be --- /dev/null +++ b/src/uix/acel/slider/index.ts @@ -0,0 +1,2 @@ +export * from "./exports"; +export * from "./types"; diff --git a/src/uix/acel/slider/slider.svelte.ts b/src/uix/acel/slider/slider.svelte.ts new file mode 100644 index 000000000..9bdf2e914 --- /dev/null +++ b/src/uix/acel/slider/slider.svelte.ts @@ -0,0 +1,809 @@ +import { untrack } from "svelte"; +import { + executeCallbacks, + onMountEffect, + attachRef, + type ReadableBox, + DOMContext, + type ReadableBoxedValues, + type WritableBoxedValues, +} from "../utilities/index"; +import { on } from "svelte/events"; +import { Context, watch } from "runed"; +import { + getRangeStyles, + getThumbStyles, + getTickStyles, + normalizeSteps, + snapValueToCustomSteps, + getAdjacentStepValue, + getTickLabelStyles, + getThumbLabelStyles, +} from "./helpers"; +import { createAcelAttrs, boolToStr, boolToEmptyStrOrUndef } from "../utilities/attrs"; +import { kbd } from "../utilities/keyboard/kbd"; +import { isElementOrSVGElement } from "../utilities/dom/is-browser"; +import { isValidIndex } from "../utilities/arrays"; +import type { AcelKeyboardEvent, OnChangeFn, RefAttachment, WithRefOpts } from "../utilities/types"; +import type { Direction, Orientation, SliderThumbPositioning } from "../utilities/index"; +import { linearScale } from "../utilities/math"; +import type { SliderLabelPosition } from "./types"; + +const sliderAttrs = createAcelAttrs({ + component: "slider", + parts: ["root", "thumb", "range", "tick", "tick-label", "thumb-label"] as const, +}); + +export const SliderRootContext = new Context("Slider.Root"); + +interface SliderBaseRootStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ + disabled: boolean; + orientation: Orientation; + min: number; + max: number; + step: number | number[]; + dir: Direction; + autoSort: boolean; + thumbPositioning: SliderThumbPositioning; + trackPadding?: number; + }> {} + +abstract class SliderBaseRootState { + readonly opts: SliderBaseRootStateOpts; + readonly attachment: RefAttachment; + isActive = $state(false); + readonly direction: "rl" | "lr" | "tb" | "bt" = $derived.by(() => { + if (this.opts.orientation.current === "horizontal") { + return this.opts.dir.current === "rtl" ? "rl" : "lr"; + } else { + return this.opts.dir.current === "rtl" ? "tb" : "bt"; + } + }); + + readonly normalizedSteps = $derived.by(() => { + return normalizeSteps(this.opts.step.current, this.opts.min.current, this.opts.max.current); + }); + domContext: DOMContext; + + constructor(opts: SliderBaseRootStateOpts) { + this.opts = opts; + this.attachment = attachRef(opts.ref); + this.domContext = new DOMContext(this.opts.ref); + } + + isThumbActive(_index: number) { + return this.isActive; + } + + readonly #touchAction = $derived.by(() => { + if (this.opts.disabled.current) return undefined; + return this.opts.orientation.current === "horizontal" ? "pan-y" : "pan-x"; + }); + + getAllThumbs = () => { + const node = this.opts.ref.current; + if (!node) return []; + return Array.from(node.querySelectorAll(sliderAttrs.selector("thumb"))); + }; + + getThumbScale = (): [number, number] => { + const trackPadding = this.opts.trackPadding?.current; + if (trackPadding !== undefined && trackPadding > 0) { + return [trackPadding, 100 - trackPadding]; + } + if (this.opts.thumbPositioning.current === "exact") { + return [0, 100]; + } + const isVertical = this.opts.orientation.current === "vertical"; + const activeThumb = this.getAllThumbs()[0]; + const thumbSize = isVertical ? activeThumb?.offsetHeight : activeThumb?.offsetWidth; + if (thumbSize === undefined || Number.isNaN(thumbSize) || thumbSize === 0) return [0, 100]; + const trackSize = isVertical + ? this.opts.ref.current?.offsetHeight + : this.opts.ref.current?.offsetWidth; + if (trackSize === undefined || Number.isNaN(trackSize) || trackSize === 0) return [0, 100]; + const percentPadding = (thumbSize / 2 / trackSize) * 100; + return [percentPadding, 100 - percentPadding]; + }; + + getPositionFromValue = (thumbValue: number) => { + const thumbScale = this.getThumbScale(); + const scale = linearScale([this.opts.min.current, this.opts.max.current], thumbScale); + return scale(thumbValue); + }; + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + "data-orientation": this.opts.orientation.current, + "data-disabled": boolToEmptyStrOrUndef(this.opts.disabled.current), + style: { + touchAction: this.#touchAction, + }, + [sliderAttrs.root]: "", + ...this.attachment, + }) as const + ); +} + +interface SliderSingleRootStateOpts + extends SliderBaseRootStateOpts, + ReadableBoxedValues<{ onValueCommit: OnChangeFn }>, + WritableBoxedValues<{ value: number }> {} + +class SliderSingleRootState extends SliderBaseRootState { + readonly opts: SliderSingleRootStateOpts; + isMulti = false as const; + + constructor(opts: SliderSingleRootStateOpts) { + super(opts); + this.opts = opts; + + onMountEffect(() => { + return executeCallbacks( + on(this.domContext.getDocument(), "pointerdown", this.handlePointerDown), + on(this.domContext.getDocument(), "pointerup", this.handlePointerUp), + on(this.domContext.getDocument(), "pointermove", this.handlePointerMove), + on(this.domContext.getDocument(), "pointerleave", this.handlePointerUp) + ); + }); + + watch( + [ + () => this.opts.step.current, + () => this.opts.min.current, + () => this.opts.max.current, + () => this.opts.value.current, + ], + ([step, min, max, value]) => { + const steps = normalizeSteps(step, min, max); + if (!steps.includes(value)) { + this.opts.value.current = snapValueToCustomSteps(value, steps); + } + } + ); + } + + isTickValueSelected = (tickValue: number) => this.opts.value.current === tickValue; + + applyPosition({ clientXY, start, end }: { clientXY: number; start: number; end: number }) { + const min = this.opts.min.current; + const max = this.opts.max.current; + const percent = (clientXY - start) / (end - start); + const val = percent * (max - min) + min; + if (val < min) { + this.updateValue(min); + } else if (val > max) { + this.updateValue(max); + } else { + this.updateValue(snapValueToCustomSteps(val, this.normalizedSteps)); + } + } + + updateValue = (newValue: number) => { + this.opts.value.current = snapValueToCustomSteps(newValue, this.normalizedSteps); + }; + + handlePointerMove = (e: PointerEvent) => { + if (!this.isActive || this.opts.disabled.current) return; + e.preventDefault(); + e.stopPropagation(); + const sliderNode = this.opts.ref.current; + const activeThumb = this.getAllThumbs()[0]; + if (!sliderNode || !activeThumb) return; + activeThumb.focus(); + const { left, right, top, bottom } = sliderNode.getBoundingClientRect(); + if (this.direction === "lr") { + this.applyPosition({ clientXY: e.clientX, start: left, end: right }); + } else if (this.direction === "rl") { + this.applyPosition({ clientXY: e.clientX, start: right, end: left }); + } else if (this.direction === "bt") { + this.applyPosition({ clientXY: e.clientY, start: bottom, end: top }); + } else if (this.direction === "tb") { + this.applyPosition({ clientXY: e.clientY, start: top, end: bottom }); + } + }; + + handlePointerDown = (e: PointerEvent) => { + if (e.button !== 0 || this.opts.disabled.current) return; + const sliderNode = this.opts.ref.current; + const closestThumb = this.getAllThumbs()[0]; + if (!closestThumb || !sliderNode) return; + const target = e.composedPath()[0] ?? e.target; + if (!isElementOrSVGElement(target) || !sliderNode.contains(target)) return; + e.preventDefault(); + closestThumb.focus(); + this.isActive = true; + this.handlePointerMove(e); + }; + + handlePointerUp = () => { + if (this.opts.disabled.current) return; + if (this.isActive) { + this.opts.onValueCommit.current(untrack(() => this.opts.value.current)); + } + this.isActive = false; + }; + + readonly thumbsPropsArr = $derived.by(() => { + const thumbValue = this.opts.value.current; + const thumbPosition = this.getPositionFromValue(thumbValue); + const style = getThumbStyles(this.direction, thumbPosition); + return [ + { + role: "slider", + "aria-valuemin": this.opts.min.current, + "aria-valuemax": this.opts.max.current, + "aria-valuenow": thumbValue, + "aria-disabled": boolToStr(this.opts.disabled.current), + "aria-orientation": this.opts.orientation.current, + "data-value": thumbValue, + "data-orientation": this.opts.orientation.current, + style, + [sliderAttrs.thumb]: "", + } as const, + ]; + }); + + readonly thumbsRenderArr = $derived.by(() => this.thumbsPropsArr.map((_, i) => i)); + + readonly ticksPropsArr = $derived.by(() => { + const steps = this.normalizedSteps; + const currValue = this.opts.value.current; + return steps.map((tickValue, i) => { + const tickPosition = this.getPositionFromValue(tickValue); + const isFirst = i === 0; + const isLast = i === steps.length - 1; + const offsetPercentage = isFirst ? 0 : isLast ? -100 : -50; + const style = getTickStyles(this.direction, tickPosition, offsetPercentage); + return { + "data-disabled": boolToEmptyStrOrUndef(this.opts.disabled.current), + "data-orientation": this.opts.orientation.current, + "data-bounded": tickValue <= currValue ? "" : undefined, + "data-value": tickValue, + "data-selected": this.isTickValueSelected(tickValue) ? "" : undefined, + style, + [sliderAttrs.tick]: "", + } as const; + }); + }); + + readonly ticksRenderArr = $derived.by(() => this.ticksPropsArr.map((_, i) => i)); + readonly tickItemsArr = $derived.by(() => + this.ticksPropsArr.map((tick, i) => ({ value: tick["data-value"], index: i })) + ); + readonly thumbItemsArr = $derived.by(() => [{ value: this.opts.value.current, index: 0 }]); + + readonly snippetProps = $derived.by(() => ({ + ticks: this.ticksRenderArr, + thumbs: this.thumbsRenderArr, + tickItems: this.tickItemsArr, + thumbItems: this.thumbItemsArr, + })); +} + +interface SliderMultiRootStateOpts + extends SliderBaseRootStateOpts, + ReadableBoxedValues<{ onValueCommit: OnChangeFn }>, + WritableBoxedValues<{ value: number[] }> {} + +class SliderMultiRootState extends SliderBaseRootState { + readonly opts: SliderMultiRootStateOpts; + isMulti = true as const; + activeThumb = $state<{ node: HTMLElement; idx: number } | null>(null); + currentThumbIdx = $state(0); + + constructor(opts: SliderMultiRootStateOpts) { + super(opts); + this.opts = opts; + + onMountEffect(() => { + return executeCallbacks( + on(this.domContext.getDocument(), "pointerdown", this.handlePointerDown), + on(this.domContext.getDocument(), "pointerup", this.handlePointerUp), + on(this.domContext.getDocument(), "pointermove", this.handlePointerMove), + on(this.domContext.getDocument(), "pointerleave", this.handlePointerUp) + ); + }); + + watch( + [ + () => this.opts.step.current, + () => this.opts.min.current, + () => this.opts.max.current, + () => this.opts.value.current, + ], + ([step, min, max, value]) => { + const steps = normalizeSteps(step, min, max); + if (value.some((v) => !steps.includes(v))) { + this.opts.value.current = value.map((v) => snapValueToCustomSteps(v, steps)); + } + } + ); + } + + isTickValueSelected = (tickValue: number) => this.opts.value.current.includes(tickValue); + + isThumbActive(index: number): boolean { + return this.isActive && this.activeThumb?.idx === index; + } + + applyPosition({ + clientXY, + activeThumbIdx, + start, + end, + }: { + clientXY: number; + activeThumbIdx: number; + start: number; + end: number; + }) { + const min = this.opts.min.current; + const max = this.opts.max.current; + const percent = (clientXY - start) / (end - start); + const val = percent * (max - min) + min; + if (val < min) { + this.updateValue(min, activeThumbIdx); + } else if (val > max) { + this.updateValue(max, activeThumbIdx); + } else { + this.updateValue(snapValueToCustomSteps(val, this.normalizedSteps), activeThumbIdx); + } + } + + #getClosestThumb = (e: PointerEvent) => { + const thumbs = this.getAllThumbs(); + if (!thumbs.length) return; + for (const thumb of thumbs) thumb.blur(); + const distances = thumbs.map((thumb) => { + if (this.opts.orientation.current === "horizontal") { + const { left, right } = thumb.getBoundingClientRect(); + return Math.abs(e.clientX - (left + right) / 2); + } else { + const { top, bottom } = thumb.getBoundingClientRect(); + return Math.abs(e.clientY - (top + bottom) / 2); + } + }); + const node = thumbs[distances.indexOf(Math.min(...distances))]!; + return { node, idx: thumbs.indexOf(node) }; + }; + + handlePointerMove = (e: PointerEvent) => { + if (!this.isActive || this.opts.disabled.current) return; + e.preventDefault(); + e.stopPropagation(); + const sliderNode = this.opts.ref.current; + const activeThumb = this.activeThumb; + if (!sliderNode || !activeThumb) return; + activeThumb.node.focus(); + const { left, right, top, bottom } = sliderNode.getBoundingClientRect(); + if (this.direction === "lr") { + this.applyPosition({ clientXY: e.clientX, activeThumbIdx: activeThumb.idx, start: left, end: right }); + } else if (this.direction === "rl") { + this.applyPosition({ clientXY: e.clientX, activeThumbIdx: activeThumb.idx, start: right, end: left }); + } else if (this.direction === "bt") { + this.applyPosition({ clientXY: e.clientY, activeThumbIdx: activeThumb.idx, start: bottom, end: top }); + } else if (this.direction === "tb") { + this.applyPosition({ clientXY: e.clientY, activeThumbIdx: activeThumb.idx, start: top, end: bottom }); + } + }; + + handlePointerDown = (e: PointerEvent) => { + if (e.button !== 0 || this.opts.disabled.current) return; + const sliderNode = this.opts.ref.current; + const closestThumb = this.#getClosestThumb(e); + if (!closestThumb || !sliderNode) return; + const target = e.composedPath()[0] ?? e.target; + if (!isElementOrSVGElement(target) || !sliderNode.contains(target)) return; + e.preventDefault(); + this.activeThumb = closestThumb; + closestThumb.node.focus(); + this.isActive = true; + this.handlePointerMove(e); + }; + + handlePointerUp = () => { + if (this.opts.disabled.current) return; + if (this.isActive) { + this.opts.onValueCommit.current(untrack(() => this.opts.value.current)); + } + this.isActive = false; + }; + + updateValue = (thumbValue: number, idx: number) => { + const currValue = this.opts.value.current; + if (!currValue.length) { + this.opts.value.current.push(thumbValue); + return; + } + const valueAtIndex = currValue[idx]; + if (valueAtIndex === thumbValue) return; + const newValue = [...currValue]; + if (!isValidIndex(idx, newValue)) return; + const direction = newValue[idx]! > thumbValue ? -1 : +1; + const swap = () => { + const diffIndex = idx + direction; + newValue[idx] = newValue[diffIndex]!; + newValue[diffIndex] = thumbValue; + const thumbs = this.getAllThumbs(); + if (!thumbs.length) return; + thumbs[diffIndex]?.focus(); + this.activeThumb = { node: thumbs[diffIndex]!, idx: diffIndex }; + }; + if ( + this.opts.autoSort.current && + ((direction === -1 && thumbValue < newValue[idx - 1]!) || + (direction === 1 && thumbValue > newValue[idx + 1]!)) + ) { + swap(); + this.opts.value.current = newValue; + return; + } + newValue[idx] = snapValueToCustomSteps(thumbValue, this.normalizedSteps); + this.opts.value.current = newValue; + }; + + readonly thumbsPropsArr = $derived.by(() => { + const currValue = this.opts.value.current; + return Array.from({ length: currValue.length || 1 }, (_, i) => { + const currThumb = untrack(() => this.currentThumbIdx); + if (currThumb < currValue.length) { + untrack(() => { this.currentThumbIdx = currThumb + 1; }); + } + const thumbValue = currValue[i]; + const thumbPosition = this.getPositionFromValue(thumbValue ?? 0); + const style = getThumbStyles(this.direction, thumbPosition); + return { + role: "slider", + "aria-valuemin": this.opts.min.current, + "aria-valuemax": this.opts.max.current, + "aria-valuenow": thumbValue, + "aria-disabled": boolToStr(this.opts.disabled.current), + "aria-orientation": this.opts.orientation.current, + "data-value": thumbValue, + "data-orientation": this.opts.orientation.current, + style, + [sliderAttrs.thumb]: "", + } as const; + }); + }); + + readonly thumbsRenderArr = $derived.by(() => this.thumbsPropsArr.map((_, i) => i)); + + readonly ticksPropsArr = $derived.by(() => { + const steps = this.normalizedSteps; + const currValue = this.opts.value.current; + return steps.map((tickValue, i) => { + const tickPosition = this.getPositionFromValue(tickValue); + const isFirst = i === 0; + const isLast = i === steps.length - 1; + const offsetPercentage = isFirst ? 0 : isLast ? -100 : -50; + const style = getTickStyles(this.direction, tickPosition, offsetPercentage); + const bounded = + currValue.length === 1 + ? tickValue <= currValue[0]! + : currValue[0]! <= tickValue && tickValue <= currValue[currValue.length - 1]!; + return { + "data-disabled": boolToEmptyStrOrUndef(this.opts.disabled.current), + "data-orientation": this.opts.orientation.current, + "data-bounded": bounded ? "" : undefined, + "data-value": tickValue, + style, + [sliderAttrs.tick]: "", + } as const; + }); + }); + + readonly ticksRenderArr = $derived.by(() => this.ticksPropsArr.map((_, i) => i)); + readonly tickItemsArr = $derived.by(() => + this.ticksPropsArr.map((tick, i) => ({ value: tick["data-value"], index: i })) + ); + readonly thumbItemsArr = $derived.by(() => + this.opts.value.current.map((value, index) => ({ value, index })) + ); + + readonly snippetProps = $derived.by(() => ({ + ticks: this.ticksRenderArr, + thumbs: this.thumbsRenderArr, + tickItems: this.tickItemsArr, + thumbItems: this.thumbItemsArr, + })); +} + +type SliderRoot = SliderSingleRootState | SliderMultiRootState; + +interface SliderRootStateOpts extends SliderBaseRootStateOpts { + type: "single" | "multiple"; + value: import("../utilities/index").WritableBox | import("../utilities/index").WritableBox; + onValueCommit: ReadableBox> | ReadableBox>; +} + +export class SliderRootState { + static create(opts: SliderRootStateOpts): SliderRoot { + const { type, ...rest } = opts; + const rootState = + type === "single" + ? new SliderSingleRootState(rest as SliderSingleRootStateOpts) + : new SliderMultiRootState(rest as SliderMultiRootStateOpts); + return SliderRootContext.set(rootState); + } +} + +const VALID_SLIDER_KEYS = [ + kbd.ARROW_LEFT, + kbd.ARROW_RIGHT, + kbd.ARROW_UP, + kbd.ARROW_DOWN, + kbd.HOME, + kbd.END, +]; + +interface SliderRangeStateOpts extends WithRefOpts {} + +export class SliderRangeState { + static create(opts: SliderRangeStateOpts) { + return new SliderRangeState(opts, SliderRootContext.get()); + } + + readonly opts: SliderRangeStateOpts; + readonly root: SliderRoot; + readonly attachment: RefAttachment; + + constructor(opts: SliderRangeStateOpts, root: SliderRoot) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(opts.ref); + } + + readonly rangeStyles = $derived.by(() => { + if (Array.isArray(this.root.opts.value.current)) { + const vals = this.root.opts.value.current; + const min = vals.length > 1 ? this.root.getPositionFromValue(Math.min(...vals) ?? 0) : 0; + const max = 100 - this.root.getPositionFromValue(Math.max(...vals) ?? 0); + return { position: "absolute", ...getRangeStyles(this.root.direction, min, max) }; + } else { + const trackPadding = this.root.opts.trackPadding?.current; + const currentValue = this.root.opts.value.current; + const maxValue = this.root.opts.max.current; + const min = 0; + const max = + trackPadding !== undefined && trackPadding > 0 && currentValue === maxValue + ? 0 + : 100 - this.root.getPositionFromValue(currentValue); + return { position: "absolute", ...getRangeStyles(this.root.direction, min, max) }; + } + }); + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + "data-orientation": this.root.opts.orientation.current, + "data-disabled": boolToEmptyStrOrUndef(this.root.opts.disabled.current), + style: this.rangeStyles, + [sliderAttrs.range]: "", + ...this.attachment, + }) as const + ); +} + +interface SliderThumbStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ index: number; disabled: boolean }> {} + +export class SliderThumbState { + static create(opts: SliderThumbStateOpts) { + return new SliderThumbState(opts, SliderRootContext.get()); + } + + readonly opts: SliderThumbStateOpts; + readonly root: SliderRoot; + readonly attachment: RefAttachment; + readonly #isDisabled = $derived.by( + () => this.root.opts.disabled.current || this.opts.disabled.current + ); + + constructor(opts: SliderThumbStateOpts, root: SliderRoot) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(opts.ref); + this.onkeydown = this.onkeydown.bind(this); + } + + #updateValue(newValue: number) { + if (this.root.isMulti) { + this.root.updateValue(newValue, this.opts.index.current); + } else { + this.root.updateValue(newValue); + } + } + + onkeydown(e: AcelKeyboardEvent) { + if (this.#isDisabled) return; + const currNode = this.opts.ref.current; + if (!currNode) return; + const thumbs = this.root.getAllThumbs(); + if (!thumbs.length) return; + const idx = thumbs.indexOf(currNode); + if (this.root.isMulti) { + this.root.currentThumbIdx = idx; + } + if (!VALID_SLIDER_KEYS.includes(e.key)) return; + e.preventDefault(); + + const min = this.root.opts.min.current; + const max = this.root.opts.max.current; + const value = this.root.opts.value.current; + const thumbValue = Array.isArray(value) ? value[idx]! : value; + const orientation = this.root.opts.orientation.current; + const direction = this.root.direction; + const steps = this.root.normalizedSteps; + + switch (e.key) { + case kbd.HOME: + this.#updateValue(min); + break; + case kbd.END: + this.#updateValue(max); + break; + case kbd.ARROW_LEFT: + if (orientation !== "horizontal") break; + if (e.metaKey) { + this.#updateValue(direction === "rl" ? max : min); + } else { + this.#updateValue(getAdjacentStepValue(thumbValue, steps, direction === "rl" ? "next" : "prev")); + } + break; + case kbd.ARROW_RIGHT: + if (orientation !== "horizontal") break; + if (e.metaKey) { + this.#updateValue(direction === "rl" ? min : max); + } else { + this.#updateValue(getAdjacentStepValue(thumbValue, steps, direction === "rl" ? "prev" : "next")); + } + break; + case kbd.ARROW_UP: + if (e.metaKey) { + this.#updateValue(direction === "tb" ? min : max); + } else { + this.#updateValue(getAdjacentStepValue(thumbValue, steps, direction === "tb" ? "prev" : "next")); + } + break; + case kbd.ARROW_DOWN: + if (e.metaKey) { + this.#updateValue(direction === "tb" ? max : min); + } else { + this.#updateValue(getAdjacentStepValue(thumbValue, steps, direction === "tb" ? "next" : "prev")); + } + break; + } + // @ts-expect-error - union type + this.root.opts.onValueCommit.current(this.root.opts.value.current); + } + + readonly props = $derived.by( + () => + ({ + ...this.root.thumbsPropsArr[this.opts.index.current]!, + id: this.opts.id.current, + onkeydown: this.onkeydown, + "data-active": this.root.isThumbActive(this.opts.index.current) ? "" : undefined, + "data-disabled": boolToEmptyStrOrUndef(this.#isDisabled), + tabindex: this.#isDisabled ? -1 : 0, + ...this.attachment, + }) as const + ); +} + +interface SliderTickStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ index: number }> {} + +export class SliderTickState { + static create(opts: SliderTickStateOpts) { + return new SliderTickState(opts, SliderRootContext.get()); + } + + readonly opts: SliderTickStateOpts; + readonly root: SliderRoot; + readonly attachment: RefAttachment; + + constructor(opts: SliderTickStateOpts, root: SliderRoot) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(opts.ref); + } + + readonly props = $derived.by( + () => + ({ + ...this.root.ticksPropsArr[this.opts.index.current]!, + id: this.opts.id.current, + ...this.attachment, + }) as const + ); +} + +interface SliderTickLabelStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ index: number; position?: SliderLabelPosition }> {} + +export class SliderTickLabelState { + static create(opts: SliderTickLabelStateOpts) { + return new SliderTickLabelState(opts, SliderRootContext.get()); + } + + readonly opts: SliderTickLabelStateOpts; + readonly root: SliderRoot; + readonly attachment: RefAttachment; + + constructor(opts: SliderTickLabelStateOpts, root: SliderRoot) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(opts.ref); + } + + readonly props = $derived.by(() => { + const tickProps = this.root.ticksPropsArr[this.opts.index.current]!; + const steps = this.root.normalizedSteps; + const tickValue = steps[this.opts.index.current]!; + const tickPosition = this.root.getPositionFromValue(tickValue); + const labelPosition = this.opts.position?.current ?? "top"; + const style = getTickLabelStyles(this.root.direction, tickPosition, labelPosition); + return { + id: this.opts.id.current, + "data-orientation": this.root.opts.orientation.current, + "data-disabled": boolToEmptyStrOrUndef(this.root.opts.disabled.current), + "data-bounded": tickProps["data-bounded"], + "data-value": tickValue, + "data-selected": this.root.isTickValueSelected(tickValue) ? "" : undefined, + "data-position": labelPosition, + style, + [sliderAttrs["tick-label"]]: "", + ...this.attachment, + } as const; + }); +} + +interface SliderThumbLabelStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ index: number; position?: SliderLabelPosition }> {} + +export class SliderThumbLabelState { + static create(opts: SliderThumbLabelStateOpts) { + return new SliderThumbLabelState(opts, SliderRootContext.get()); + } + + readonly opts: SliderThumbLabelStateOpts; + readonly root: SliderRoot; + readonly attachment: RefAttachment; + + constructor(opts: SliderThumbLabelStateOpts, root: SliderRoot) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(opts.ref); + } + + readonly props = $derived.by(() => { + const value = this.root.opts.value.current; + const thumbValue = Array.isArray(value) ? value[this.opts.index.current]! : value; + const thumbPosition = this.root.getPositionFromValue(thumbValue); + const labelPosition = this.opts.position?.current ?? "top"; + const style = getThumbLabelStyles(this.root.direction, thumbPosition, labelPosition); + return { + id: this.opts.id.current, + "data-orientation": this.root.opts.orientation.current, + "data-disabled": boolToEmptyStrOrUndef(this.root.opts.disabled.current), + "data-value": thumbValue, + "data-active": this.root.isThumbActive(this.opts.index.current) ? "" : undefined, + "data-position": labelPosition, + style, + [sliderAttrs["thumb-label"]]: "", + ...this.attachment, + } as const; + }); +} diff --git a/src/uix/acel/slider/types.ts b/src/uix/acel/slider/types.ts new file mode 100644 index 000000000..5dc6f962a --- /dev/null +++ b/src/uix/acel/slider/types.ts @@ -0,0 +1,88 @@ +import type { OnChangeFn, WithChild, Without } from "../utilities/types"; +import type { AcelPrimitiveSpanAttributes } from "../utilities/attributes"; +import type { Direction, Orientation, SliderThumbPositioning } from "../utilities/index"; + +export type SliderLabelPosition = "top" | "bottom" | "left" | "right"; + +export type TickItem = { value: number; index: number }; +export type ThumbItem = { value: number; index: number }; + +export type SliderRootSnippetProps = { + ticks: number[]; + thumbs: number[]; + tickItems: TickItem[]; + thumbItems: ThumbItem[]; +}; + +type BaseSliderRootProps = { + autoSort?: boolean; + min?: number; + max?: number; + step?: number | number[]; + dir?: Direction; + orientation?: Orientation; + disabled?: boolean; + thumbPositioning?: SliderThumbPositioning; + trackPadding?: number; +}; + +export type SliderSingleRootPropsWithoutHTML = WithChild< + BaseSliderRootProps & { + type: "single"; + value?: number; + onValueChange?: OnChangeFn; + onValueCommit?: OnChangeFn; + }, + SliderRootSnippetProps +>; + +export type SliderMultiRootPropsWithoutHTML = WithChild< + BaseSliderRootProps & { + type: "multiple"; + value?: number[]; + onValueChange?: OnChangeFn; + onValueCommit?: OnChangeFn; + }, + SliderRootSnippetProps +>; + +export type SliderRootPropsWithoutHTML = + | SliderSingleRootPropsWithoutHTML + | SliderMultiRootPropsWithoutHTML; + +export type SliderSingleRootProps = SliderSingleRootPropsWithoutHTML & + Without; + +export type SliderMultipleRootProps = SliderMultiRootPropsWithoutHTML & + Without; + +export type SliderRootProps = SliderSingleRootProps | SliderMultipleRootProps; + +export type SliderRangePropsWithoutHTML = WithChild; +export type SliderRangeProps = SliderRangePropsWithoutHTML & + Without; + +export type SliderThumbPropsWithoutHTML = WithChild< + { disabled?: boolean; index: number }, + { active: boolean } +>; +export type SliderThumbProps = SliderThumbPropsWithoutHTML & + Without; + +export type SliderTickPropsWithoutHTML = WithChild<{ index: number }>; +export type SliderTickProps = SliderTickPropsWithoutHTML & + Without; + +export type SliderTickLabelPropsWithoutHTML = WithChild<{ + index: number; + position?: SliderLabelPosition; +}>; +export type SliderTickLabelProps = SliderTickLabelPropsWithoutHTML & + Without; + +export type SliderThumbLabelPropsWithoutHTML = WithChild<{ + index: number; + position?: SliderLabelPosition; +}>; +export type SliderThumbLabelProps = SliderThumbLabelPropsWithoutHTML & + Without; diff --git a/src/uix/acel/switch/components/switch-thumb.svelte b/src/uix/acel/switch/components/switch-thumb.svelte new file mode 100644 index 000000000..24f9fc97d --- /dev/null +++ b/src/uix/acel/switch/components/switch-thumb.svelte @@ -0,0 +1,31 @@ + + +{#if child} + {@render child({ props: mergedProps, checked: thumbState.snippetProps.checked })} +{:else} + + {@render children?.(thumbState.snippetProps)} + +{/if} diff --git a/src/uix/acel/switch/components/switch.svelte b/src/uix/acel/switch/components/switch.svelte new file mode 100644 index 000000000..def3653fd --- /dev/null +++ b/src/uix/acel/switch/components/switch.svelte @@ -0,0 +1,49 @@ + + +{#if child} + {@render child({ props: mergedProps, checked })} +{:else} + +{/if} + +{#if inputState.shouldRender} + +{/if} diff --git a/src/uix/acel/switch/exports.ts b/src/uix/acel/switch/exports.ts new file mode 100644 index 000000000..d495af130 --- /dev/null +++ b/src/uix/acel/switch/exports.ts @@ -0,0 +1,3 @@ +export { default as Root } from "./components/switch.svelte"; +export { default as Thumb } from "./components/switch-thumb.svelte"; +export type { SwitchRootProps as RootProps, SwitchThumbProps as ThumbProps } from "./types"; diff --git a/src/uix/acel/switch/index.ts b/src/uix/acel/switch/index.ts new file mode 100644 index 000000000..e39b65f27 --- /dev/null +++ b/src/uix/acel/switch/index.ts @@ -0,0 +1,2 @@ +export * as Switch from "./exports"; +export * from "./exports"; diff --git a/src/uix/acel/switch/switch.svelte.ts b/src/uix/acel/switch/switch.svelte.ts new file mode 100644 index 000000000..fd3fe5ca3 --- /dev/null +++ b/src/uix/acel/switch/switch.svelte.ts @@ -0,0 +1,134 @@ +import { attachRef, type ReadableBoxedValues, type WritableBoxedValues } from "../utilities/index"; +import type { RefAttachment, WithRefOpts, AcelKeyboardEvent, AcelPointerEvent } from "../utilities/types"; +import { createAcelAttrs, getAriaChecked, boolToStr, getDataChecked, boolToTrueOrUndef, boolToEmptyStrOrUndef } from "../utilities/attrs"; +import { Context } from "runed"; +import { kbd } from "../utilities/keyboard/kbd"; +import { noop } from "../utilities/noop"; + +const switchAttrs = createAcelAttrs({ + component: "switch", + parts: ["root", "thumb"] as const, +}); + +// ── Root ────────────────────────────────────────────────────────────────────── + +const SwitchRootContext = new Context("Switch.Root"); + +interface SwitchRootStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ + disabled: boolean; + required: boolean; + name: string | undefined; + value: string; + onCheckedChange: (checked: boolean) => void; + }>, + WritableBoxedValues<{ checked: boolean }> {} + +export class SwitchRootState { + static create(opts: SwitchRootStateOpts) { + return SwitchRootContext.set(new SwitchRootState(opts)); + } + + readonly opts: SwitchRootStateOpts; + readonly attachment: RefAttachment; + + constructor(opts: SwitchRootStateOpts) { + this.opts = opts; + this.attachment = attachRef(opts.ref); + this.onkeydown = this.onkeydown.bind(this); + this.onclick = this.onclick.bind(this); + } + + #toggle() { + const next = !this.opts.checked.current; + this.opts.checked.current = next; + this.opts.onCheckedChange.current(next); + } + + onkeydown(e: AcelKeyboardEvent) { + if (!(e.key === kbd.ENTER || e.key === kbd.SPACE) || this.opts.disabled.current) return; + e.preventDefault(); + this.#toggle(); + } + + onclick(_: AcelPointerEvent) { + if (this.opts.disabled.current) return; + this.#toggle(); + } + + readonly sharedProps = $derived.by(() => ({ + "data-disabled": boolToEmptyStrOrUndef(this.opts.disabled.current), + "data-state": getDataChecked(this.opts.checked.current), + "data-required": boolToEmptyStrOrUndef(this.opts.required.current), + })); + + readonly snippetProps = $derived.by(() => ({ checked: this.opts.checked.current })); + + readonly props = $derived.by(() => ({ + ...this.sharedProps, + id: this.opts.id.current, + role: "switch", + type: "button" as const, + disabled: boolToTrueOrUndef(this.opts.disabled.current), + "aria-checked": getAriaChecked(this.opts.checked.current, false), + "aria-required": boolToStr(this.opts.required.current), + [switchAttrs.root]: "", + onclick: this.onclick, + onkeydown: this.onkeydown, + ...this.attachment, + }) as const); +} + +// ── Thumb ───────────────────────────────────────────────────────────────────── + +interface SwitchThumbStateOpts extends WithRefOpts {} + +export class SwitchThumbState { + static create(opts: SwitchThumbStateOpts) { + return new SwitchThumbState(opts, SwitchRootContext.get()); + } + + readonly opts: SwitchThumbStateOpts; + readonly root: SwitchRootState; + readonly attachment: RefAttachment; + + constructor(opts: SwitchThumbStateOpts, root: SwitchRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(opts.ref); + } + + readonly snippetProps = $derived.by(() => ({ checked: this.root.opts.checked.current })); + + readonly props = $derived.by(() => ({ + ...this.root.sharedProps, + id: this.opts.id.current, + [switchAttrs.thumb]: "", + ...this.attachment, + }) as const); +} + +// ── Hidden Input ────────────────────────────────────────────────────────────── + +export class SwitchInputState { + static create() { + return new SwitchInputState(SwitchRootContext.get()); + } + + readonly root: SwitchRootState; + readonly shouldRender = $derived.by(() => this.root.opts.name.current !== undefined); + + constructor(root: SwitchRootState) { + this.root = root; + } + + readonly props = $derived.by(() => ({ + type: "checkbox" as const, + name: this.root.opts.name.current, + value: this.root.opts.value.current, + checked: this.root.opts.checked.current, + disabled: this.root.opts.disabled.current, + required: this.root.opts.required.current, + }) as const); +} diff --git a/src/uix/acel/switch/types.ts b/src/uix/acel/switch/types.ts new file mode 100644 index 000000000..4f4a737da --- /dev/null +++ b/src/uix/acel/switch/types.ts @@ -0,0 +1,19 @@ +import type { WithChild, Without } from "../utilities/types"; +import type { AcelPrimitiveButtonAttributes, AcelPrimitiveDivAttributes } from "../utilities/attributes"; + +export type SwitchRootPropsWithoutHTML = WithChild<{ + checked?: boolean; + onCheckedChange?: (checked: boolean) => void; + disabled?: boolean; + required?: boolean; + name?: string; + value?: string; +}, { checked: boolean }, HTMLButtonElement>; + +export type SwitchRootProps = SwitchRootPropsWithoutHTML & + Without; + +export type SwitchThumbPropsWithoutHTML = WithChild<{}, { checked: boolean }>; + +export type SwitchThumbProps = SwitchThumbPropsWithoutHTML & + Without; diff --git a/src/uix/acel/tabs/components/tabs-content.svelte b/src/uix/acel/tabs/components/tabs-content.svelte index d72f9929b..cde5e2ec3 100644 --- a/src/uix/acel/tabs/components/tabs-content.svelte +++ b/src/uix/acel/tabs/components/tabs-content.svelte @@ -2,7 +2,7 @@ import { boxWith, mergeProps } from "../../utilities/index"; import type { TabsContentProps } from "../types"; import { TabsContentState } from "../tabs.svelte"; - import { createId } from "../../internal/create-id"; + import { createId } from "../../utilities/create-id"; const uid = $props.id(); diff --git a/src/uix/acel/tabs/components/tabs-list.svelte b/src/uix/acel/tabs/components/tabs-list.svelte index 13fcc2ae1..27c800785 100644 --- a/src/uix/acel/tabs/components/tabs-list.svelte +++ b/src/uix/acel/tabs/components/tabs-list.svelte @@ -2,7 +2,7 @@ import { boxWith, mergeProps } from "../../utilities/index"; import type { TabsListProps } from "../types"; import { TabsListState } from "../tabs.svelte"; - import { createId } from "../../internal/create-id"; + import { createId } from "../../utilities/create-id"; const uid = $props.id(); diff --git a/src/uix/acel/tabs/components/tabs-trigger.svelte b/src/uix/acel/tabs/components/tabs-trigger.svelte index f7f20913d..939cbe0c6 100644 --- a/src/uix/acel/tabs/components/tabs-trigger.svelte +++ b/src/uix/acel/tabs/components/tabs-trigger.svelte @@ -2,7 +2,7 @@ import { boxWith, mergeProps } from "../../utilities/index"; import type { TabsTriggerProps } from "../types"; import { TabsTriggerState } from "../tabs.svelte"; - import { createId } from "../../internal/create-id"; + import { createId } from "../../utilities/create-id"; const uid = $props.id(); diff --git a/src/uix/acel/tabs/components/tabs.svelte b/src/uix/acel/tabs/components/tabs.svelte index f08398d59..a4ead0fc9 100644 --- a/src/uix/acel/tabs/components/tabs.svelte +++ b/src/uix/acel/tabs/components/tabs.svelte @@ -2,8 +2,8 @@ import { boxWith, mergeProps } from "../../utilities/index"; import type { TabsRootProps } from "../types"; import { TabsRootState } from "../tabs.svelte"; - import { createId } from "../../internal/create-id"; - import { noop } from "../../internal/noop"; + import { createId } from "../../utilities/create-id"; + import { noop } from "../../utilities/noop"; const uid = $props.id(); diff --git a/src/uix/acel/tabs/tabs.svelte.ts b/src/uix/acel/tabs/tabs.svelte.ts index 3e29dfdb7..420e153dc 100644 --- a/src/uix/acel/tabs/tabs.svelte.ts +++ b/src/uix/acel/tabs/tabs.svelte.ts @@ -12,16 +12,16 @@ import { boolToStr, boolToEmptyStrOrUndef, boolToTrueOrUndef, -} from "../internal/attrs"; -import { kbd } from "../internal/kbd"; +} from "../utilities/attrs"; +import { kbd } from "../utilities/keyboard/kbd"; import type { AcelFocusEvent, AcelKeyboardEvent, AcelMouseEvent, RefAttachment, WithRefOpts, -} from "../internal/types"; -import { RovingFocusGroup } from "../internal/roving-focus-group"; +} from "../utilities/types"; +import { RovingFocusGroup } from "../utilities/focus/roving-focus-group"; const tabsAttrs = createAcelAttrs({ component: "tabs", diff --git a/src/uix/acel/tabs/types.ts b/src/uix/acel/tabs/types.ts index afd0d95bf..db73dde86 100644 --- a/src/uix/acel/tabs/types.ts +++ b/src/uix/acel/tabs/types.ts @@ -2,11 +2,11 @@ import type { OnChangeFn, WithChild, Without -} from "../internal/types"; +} from "../utilities/types"; import type { AcelPrimitiveButtonAttributes, AcelPrimitiveDivAttributes -} from "../shared/attributes"; +} from "../utilities/attributes"; export type TabsActivationMode = "manual" | "automatic"; diff --git a/src/uix/acel/toggle-group/components/toggle-group-item.svelte b/src/uix/acel/toggle-group/components/toggle-group-item.svelte new file mode 100644 index 000000000..f35270f40 --- /dev/null +++ b/src/uix/acel/toggle-group/components/toggle-group-item.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/acel/toggle-group/components/toggle-group.svelte b/src/uix/acel/toggle-group/components/toggle-group.svelte new file mode 100644 index 000000000..7501cc28a --- /dev/null +++ b/src/uix/acel/toggle-group/components/toggle-group.svelte @@ -0,0 +1,46 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/acel/toggle-group/exports.ts b/src/uix/acel/toggle-group/exports.ts new file mode 100644 index 000000000..799267cb8 --- /dev/null +++ b/src/uix/acel/toggle-group/exports.ts @@ -0,0 +1,6 @@ +export { default as Root } from "./components/toggle-group.svelte"; +export { default as Item } from "./components/toggle-group-item.svelte"; +export type { + ToggleGroupRootProps as RootProps, + ToggleGroupItemProps as ItemProps, +} from "./types"; diff --git a/src/uix/acel/toggle-group/index.ts b/src/uix/acel/toggle-group/index.ts new file mode 100644 index 000000000..201b02d6e --- /dev/null +++ b/src/uix/acel/toggle-group/index.ts @@ -0,0 +1,2 @@ +export * as ToggleGroup from "./exports"; +export * from "./exports"; diff --git a/src/uix/acel/toggle-group/toggle-group.svelte.ts b/src/uix/acel/toggle-group/toggle-group.svelte.ts new file mode 100644 index 000000000..3ed952a07 --- /dev/null +++ b/src/uix/acel/toggle-group/toggle-group.svelte.ts @@ -0,0 +1,179 @@ +import { + attachRef, + type ReadableBoxedValues, + type WritableBoxedValues, +} from "../utilities/index"; +import type { RefAttachment, WithRefOpts, AcelKeyboardEvent, AcelMouseEvent } from "../utilities/types"; +import { createAcelAttrs } from "../utilities/attrs"; +import { Context } from "runed"; +import { RovingFocusGroup } from "../utilities/focus/roving-focus-group"; +import { kbd } from "../utilities/keyboard/kbd"; +import { noop } from "../utilities/noop"; + +const toggleGroupAttrs = createAcelAttrs({ + component: "toggle-group", + parts: ["root", "item"] as const, +}); + +// ── Root ────────────────────────────────────────────────────────────────────── + +const ToggleGroupRootContext = new Context("ToggleGroup.Root"); + +type ToggleGroupType = "single" | "multiple"; + +interface ToggleGroupRootStateSingleOpts + extends WithRefOpts, + ReadableBoxedValues<{ + type: "single"; + orientation: "horizontal" | "vertical"; + loop: boolean; + disabled: boolean; + rovingFocus: boolean; + }>, + WritableBoxedValues<{ + value: string | undefined; + }> { + onValueChange: ReadableBoxedValues<{ onValueChange: (v: string | undefined) => void }>["onValueChange"]; +} + +interface ToggleGroupRootStateMultipleOpts + extends WithRefOpts, + ReadableBoxedValues<{ + type: "multiple"; + orientation: "horizontal" | "vertical"; + loop: boolean; + disabled: boolean; + rovingFocus: boolean; + }>, + WritableBoxedValues<{ + value: string[]; + }> { + onValueChange: ReadableBoxedValues<{ onValueChange: (v: string[]) => void }>["onValueChange"]; +} + +type ToggleGroupRootStateOpts = + | ToggleGroupRootStateSingleOpts + | ToggleGroupRootStateMultipleOpts; + +export class ToggleGroupRootState { + static create(opts: ToggleGroupRootStateOpts) { + return ToggleGroupRootContext.set(new ToggleGroupRootState(opts)); + } + + readonly opts: ToggleGroupRootStateOpts; + readonly attachment: RefAttachment; + readonly rovingFocusGroup: RovingFocusGroup; + + constructor(opts: ToggleGroupRootStateOpts) { + this.opts = opts; + this.attachment = attachRef(opts.ref); + this.rovingFocusGroup = new RovingFocusGroup({ + candidateAttr: toggleGroupAttrs.item, + rootNode: opts.ref, + loop: opts.loop, + orientation: opts.orientation, + }); + } + + isItemActive(itemValue: string): boolean { + const v = this.opts.value.current; + if (this.opts.type.current === "single") { + return v === itemValue; + } else { + return Array.isArray(v) && v.includes(itemValue); + } + } + + toggleItem(itemValue: string) { + if (this.opts.type.current === "single") { + const current = this.opts.value.current as string | undefined; + const next = current === itemValue ? undefined : itemValue; + (this.opts as ToggleGroupRootStateSingleOpts).value.current = next; + (this.opts as ToggleGroupRootStateSingleOpts).onValueChange.current(next); + } else { + const current = (this.opts.value.current as string[]) ?? []; + const next = current.includes(itemValue) + ? current.filter((v) => v !== itemValue) + : [...current, itemValue]; + (this.opts as ToggleGroupRootStateMultipleOpts).value.current = next; + (this.opts as ToggleGroupRootStateMultipleOpts).onValueChange.current(next); + } + } + + readonly props = $derived.by(() => ({ + id: this.opts.id.current, + role: "group", + "data-orientation": this.opts.orientation.current, + "data-disabled": this.opts.disabled.current ? "" : undefined, + [toggleGroupAttrs.root]: "", + ...this.attachment, + }) as const); +} + +// ── Item ────────────────────────────────────────────────────────────────────── + +interface ToggleGroupItemStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ + value: string; + disabled: boolean; + }> {} + +export class ToggleGroupItemState { + static create(opts: ToggleGroupItemStateOpts) { + return new ToggleGroupItemState(opts, ToggleGroupRootContext.get()); + } + + readonly opts: ToggleGroupItemStateOpts; + readonly root: ToggleGroupRootState; + readonly attachment: RefAttachment; + + constructor(opts: ToggleGroupItemStateOpts, root: ToggleGroupRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(opts.ref); + this.onclick = this.onclick.bind(this); + this.onkeydown = this.onkeydown.bind(this); + } + + readonly #isDisabled = $derived.by( + () => this.opts.disabled.current || this.root.opts.disabled.current + ); + + readonly #isActive = $derived.by(() => this.root.isItemActive(this.opts.value.current)); + + onclick(_: AcelMouseEvent) { + if (this.#isDisabled) return; + this.root.toggleItem(this.opts.value.current); + } + + onkeydown(e: AcelKeyboardEvent) { + if (this.#isDisabled) return; + if (e.key === kbd.ENTER || e.key === kbd.SPACE) { + e.preventDefault(); + this.root.toggleItem(this.opts.value.current); + return; + } + if (!this.root.opts.rovingFocus.current) return; + this.root.rovingFocusGroup.handleKeydown(e.currentTarget, e); + } + + readonly props = $derived.by(() => { + const isDisabled = this.#isDisabled; + const isActive = this.#isActive; + return { + id: this.opts.id.current, + type: "button" as const, + role: "radio", + disabled: isDisabled, + "aria-pressed": isActive, + "data-state": isActive ? "on" : "off", + "data-disabled": isDisabled ? "" : undefined, + "data-orientation": this.root.opts.orientation.current, + onclick: this.onclick, + onkeydown: this.onkeydown, + [toggleGroupAttrs.item]: "", + ...this.attachment, + } as const; + }); +} diff --git a/src/uix/acel/toggle-group/types.ts b/src/uix/acel/toggle-group/types.ts new file mode 100644 index 000000000..5897b9acb --- /dev/null +++ b/src/uix/acel/toggle-group/types.ts @@ -0,0 +1,49 @@ +import type { WithChild, Without } from "../utilities/types"; +import type { AcelPrimitiveDivAttributes, AcelPrimitiveButtonAttributes } from "../utilities/attributes"; + +// ── Root ────────────────────────────────────────────────────────────────────── + +type ToggleGroupRootPropsBase = { + orientation?: "horizontal" | "vertical"; + loop?: boolean; + disabled?: boolean; + rovingFocus?: boolean; +}; + +export type ToggleGroupRootSinglePropsWithoutHTML = WithChild< + ToggleGroupRootPropsBase & { + type: "single"; + value?: string; + onValueChange?: (value: string | undefined) => void; + } +>; + +export type ToggleGroupRootMultiplePropsWithoutHTML = WithChild< + ToggleGroupRootPropsBase & { + type: "multiple"; + value?: string[]; + onValueChange?: (value: string[]) => void; + } +>; + +export type ToggleGroupRootPropsWithoutHTML = + | ToggleGroupRootSinglePropsWithoutHTML + | ToggleGroupRootMultiplePropsWithoutHTML; + +export type ToggleGroupRootSingleProps = ToggleGroupRootSinglePropsWithoutHTML & + Without; + +export type ToggleGroupRootMultipleProps = ToggleGroupRootMultiplePropsWithoutHTML & + Without; + +export type ToggleGroupRootProps = ToggleGroupRootSingleProps | ToggleGroupRootMultipleProps; + +// ── Item ────────────────────────────────────────────────────────────────────── + +export type ToggleGroupItemPropsWithoutHTML = WithChild<{ + value: string; + disabled?: boolean; +}, {}, HTMLButtonElement>; + +export type ToggleGroupItemProps = ToggleGroupItemPropsWithoutHTML & + Without; diff --git a/src/uix/acel/toggle/components/toggle.svelte b/src/uix/acel/toggle/components/toggle.svelte new file mode 100644 index 000000000..d91e43a65 --- /dev/null +++ b/src/uix/acel/toggle/components/toggle.svelte @@ -0,0 +1,38 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/acel/toggle/exports.ts b/src/uix/acel/toggle/exports.ts new file mode 100644 index 000000000..9708f9bb9 --- /dev/null +++ b/src/uix/acel/toggle/exports.ts @@ -0,0 +1,2 @@ +export { default as Root } from "./components/toggle.svelte"; +export type { ToggleRootProps as RootProps } from "./types"; diff --git a/src/uix/acel/toggle/index.ts b/src/uix/acel/toggle/index.ts new file mode 100644 index 000000000..3a739c17f --- /dev/null +++ b/src/uix/acel/toggle/index.ts @@ -0,0 +1,2 @@ +export * as Toggle from "./exports"; +export * from "./exports"; diff --git a/src/uix/acel/toggle/toggle.svelte.ts b/src/uix/acel/toggle/toggle.svelte.ts new file mode 100644 index 000000000..640de1a89 --- /dev/null +++ b/src/uix/acel/toggle/toggle.svelte.ts @@ -0,0 +1,54 @@ +import { attachRef, type ReadableBoxedValues, type WritableBoxedValues } from "../utilities/index"; +import type { RefAttachment, WithRefOpts, AcelMouseEvent } from "../utilities/types"; +import { createAcelAttrs } from "../utilities/attrs"; +import { noop } from "../utilities/noop"; + +const toggleAttrs = createAcelAttrs({ + component: "toggle", + parts: ["root"] as const, +}); + +interface ToggleRootStateOpts + extends WithRefOpts, + WritableBoxedValues<{ pressed: boolean }>, + ReadableBoxedValues<{ + disabled: boolean; + onPressedChange: (pressed: boolean) => void; + }> {} + +export class ToggleRootState { + static create(opts: ToggleRootStateOpts) { + return new ToggleRootState(opts); + } + + readonly opts: ToggleRootStateOpts; + readonly attachment: RefAttachment; + + constructor(opts: ToggleRootStateOpts) { + this.opts = opts; + this.attachment = attachRef(this.opts.ref); + } + + readonly onclick = (e: AcelMouseEvent) => { + if (this.opts.disabled.current) return; + const next = !this.opts.pressed.current; + this.opts.pressed.current = next; + this.opts.onPressedChange.current(next); + }; + + readonly props = $derived.by(() => { + const pressed = this.opts.pressed.current; + const disabled = this.opts.disabled.current; + return { + id: this.opts.id.current, + type: "button" as const, + disabled, + "aria-pressed": pressed, + "data-state": pressed ? "on" : "off", + "data-disabled": disabled ? "" : undefined, + onclick: this.onclick, + [toggleAttrs.root]: "", + ...this.attachment, + } as const; + }); +} diff --git a/src/uix/acel/toggle/types.ts b/src/uix/acel/toggle/types.ts new file mode 100644 index 000000000..d1c014713 --- /dev/null +++ b/src/uix/acel/toggle/types.ts @@ -0,0 +1,22 @@ +import type { WithChild, Without } from "../utilities/types"; +import type { AcelPrimitiveButtonAttributes } from "../utilities/attributes"; + +export type ToggleRootPropsWithoutHTML = WithChild<{ + /** + * Si el toggle está activado. + * @defaultValue false + */ + pressed?: boolean; + /** + * Callback que se ejecuta cuando cambia el estado. + */ + onPressedChange?: (pressed: boolean) => void; + /** + * Si el toggle está desactivado. + * @defaultValue false + */ + disabled?: boolean; +}, {}, HTMLButtonElement>; + +export type ToggleRootProps = ToggleRootPropsWithoutHTML & + Without; diff --git a/src/uix/acel/toolbar/components/toolbar-button.svelte b/src/uix/acel/toolbar/components/toolbar-button.svelte new file mode 100644 index 000000000..07b68c05c --- /dev/null +++ b/src/uix/acel/toolbar/components/toolbar-button.svelte @@ -0,0 +1,33 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/acel/toolbar/components/toolbar-group-item.svelte b/src/uix/acel/toolbar/components/toolbar-group-item.svelte new file mode 100644 index 000000000..f5e71fe3b --- /dev/null +++ b/src/uix/acel/toolbar/components/toolbar-group-item.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/acel/toolbar/components/toolbar-group.svelte b/src/uix/acel/toolbar/components/toolbar-group.svelte new file mode 100644 index 000000000..14b832605 --- /dev/null +++ b/src/uix/acel/toolbar/components/toolbar-group.svelte @@ -0,0 +1,37 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/acel/toolbar/components/toolbar-link.svelte b/src/uix/acel/toolbar/components/toolbar-link.svelte new file mode 100644 index 000000000..85dd10cb2 --- /dev/null +++ b/src/uix/acel/toolbar/components/toolbar-link.svelte @@ -0,0 +1,31 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + + {@render children?.()} + +{/if} diff --git a/src/uix/acel/toolbar/components/toolbar.svelte b/src/uix/acel/toolbar/components/toolbar.svelte new file mode 100644 index 000000000..c7498f245 --- /dev/null +++ b/src/uix/acel/toolbar/components/toolbar.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/acel/toolbar/exports.ts b/src/uix/acel/toolbar/exports.ts new file mode 100644 index 000000000..2dd42314e --- /dev/null +++ b/src/uix/acel/toolbar/exports.ts @@ -0,0 +1,12 @@ +export { default as Root } from "./components/toolbar.svelte"; +export { default as Group } from "./components/toolbar-group.svelte"; +export { default as GroupItem } from "./components/toolbar-group-item.svelte"; +export { default as Button } from "./components/toolbar-button.svelte"; +export { default as Link } from "./components/toolbar-link.svelte"; +export type { + ToolbarRootProps as RootProps, + ToolbarGroupProps as GroupProps, + ToolbarGroupItemProps as GroupItemProps, + ToolbarButtonProps as ButtonProps, + ToolbarLinkProps as LinkProps, +} from "./types"; diff --git a/src/uix/acel/toolbar/index.ts b/src/uix/acel/toolbar/index.ts new file mode 100644 index 000000000..cfdb06cf7 --- /dev/null +++ b/src/uix/acel/toolbar/index.ts @@ -0,0 +1,2 @@ +export * as Toolbar from "./exports"; +export * from "./exports"; diff --git a/src/uix/acel/toolbar/toolbar.svelte.ts b/src/uix/acel/toolbar/toolbar.svelte.ts new file mode 100644 index 000000000..8e39efc11 --- /dev/null +++ b/src/uix/acel/toolbar/toolbar.svelte.ts @@ -0,0 +1,276 @@ +import { attachRef, type ReadableBoxedValues, type WritableBoxedValues } from "../utilities/index"; +import type { RefAttachment, WithRefOpts, AcelKeyboardEvent, AcelMouseEvent } from "../utilities/types"; +import { createAcelAttrs, boolToStr, boolToEmptyStrOrUndef, boolToTrueOrUndef, getAriaChecked } from "../utilities/attrs"; +import { Context } from "runed"; +import { kbd } from "../utilities/keyboard/kbd"; +import { RovingFocusGroup } from "../utilities/focus/roving-focus-group"; + +export const toolbarAttrs = createAcelAttrs({ + component: "toolbar", + parts: ["root", "item", "group", "group-item", "link", "button"] as const, +}); + +// ── Root ────────────────────────────────────────────────────────────────────── + +const ToolbarRootContext = new Context("Toolbar.Root"); + +interface ToolbarRootStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ + orientation: "horizontal" | "vertical"; + loop: boolean; + }> {} + +export class ToolbarRootState { + static create(opts: ToolbarRootStateOpts) { + return ToolbarRootContext.set(new ToolbarRootState(opts)); + } + + readonly opts: ToolbarRootStateOpts; + readonly attachment: RefAttachment; + readonly rovingFocusGroup: RovingFocusGroup; + + constructor(opts: ToolbarRootStateOpts) { + this.opts = opts; + this.attachment = attachRef(opts.ref); + this.rovingFocusGroup = new RovingFocusGroup({ + orientation: opts.orientation, + loop: opts.loop, + rootNode: opts.ref, + candidateAttr: toolbarAttrs.item, + }); + } + + readonly props = $derived.by(() => ({ + id: this.opts.id.current, + role: "toolbar", + "data-orientation": this.opts.orientation.current, + [toolbarAttrs.root]: "", + ...this.attachment, + }) as const); +} + +// ── Group ───────────────────────────────────────────────────────────────────── + +const ToolbarGroupContext = new Context("Toolbar.Group"); + +interface ToolbarGroupStateSingleOpts + extends WithRefOpts, + ReadableBoxedValues<{ type: "single"; disabled: boolean }>, + WritableBoxedValues<{ value: string }> {} + +interface ToolbarGroupStateMultipleOpts + extends WithRefOpts, + ReadableBoxedValues<{ type: "multiple"; disabled: boolean }>, + WritableBoxedValues<{ value: string[] }> {} + +type ToolbarGroupStateOpts = ToolbarGroupStateSingleOpts | ToolbarGroupStateMultipleOpts; + +export class ToolbarGroupState { + static create(opts: ToolbarGroupStateOpts) { + const root = ToolbarRootContext.get(); + return ToolbarGroupContext.set(new ToolbarGroupState(opts, root)); + } + + readonly opts: ToolbarGroupStateOpts; + readonly root: ToolbarRootState; + readonly attachment: RefAttachment; + + get isMulti() { return this.opts.type.current === "multiple"; } + get anyPressed() { + const v = this.opts.value.current; + return Array.isArray(v) ? v.length > 0 : v !== ""; + } + + constructor(opts: ToolbarGroupStateOpts, root: ToolbarRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(opts.ref); + } + + includesItem(item: string): boolean { + const v = this.opts.value.current; + return Array.isArray(v) ? v.includes(item) : v === item; + } + + toggleItem(item: string) { + if (this.opts.type.current === "single") { + const current = this.opts.value.current as string; + (this.opts as ToolbarGroupStateSingleOpts).value.current = current === item ? "" : item; + } else { + const current = this.opts.value.current as string[]; + (this.opts as ToolbarGroupStateMultipleOpts).value.current = current.includes(item) + ? current.filter((v) => v !== item) + : [...current, item]; + } + } + + readonly props = $derived.by(() => ({ + id: this.opts.id.current, + role: "group", + "data-orientation": this.root.opts.orientation.current, + "data-disabled": boolToEmptyStrOrUndef(this.opts.disabled.current), + [toolbarAttrs.group]: "", + ...this.attachment, + }) as const); +} + +// ── Group Item ──────────────────────────────────────────────────────────────── + +interface ToolbarGroupItemStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ value: string; disabled: boolean }> {} + +export class ToolbarGroupItemState { + static create(opts: ToolbarGroupItemStateOpts) { + const group = ToolbarGroupContext.get(); + return new ToolbarGroupItemState(opts, group, group.root); + } + + readonly opts: ToolbarGroupItemStateOpts; + readonly group: ToolbarGroupState; + readonly root: ToolbarRootState; + readonly attachment: RefAttachment; + #tabIndex = $state(0); + + readonly #isDisabled = $derived.by(() => + this.opts.disabled.current || this.group.opts.disabled.current + ); + readonly isPressed = $derived.by(() => this.group.includesItem(this.opts.value.current)); + + constructor(opts: ToolbarGroupItemStateOpts, group: ToolbarGroupState, root: ToolbarRootState) { + this.opts = opts; + this.group = group; + this.root = root; + this.attachment = attachRef(opts.ref); + + $effect(() => { + this.#tabIndex = this.root.rovingFocusGroup.getTabIndex(this.opts.ref.current); + }); + + this.onclick = this.onclick.bind(this); + this.onkeydown = this.onkeydown.bind(this); + } + + onclick(_: AcelMouseEvent) { + if (this.#isDisabled) return; + this.group.toggleItem(this.opts.value.current); + } + + onkeydown(e: AcelKeyboardEvent) { + if (this.#isDisabled) return; + if (e.key === kbd.ENTER || e.key === kbd.SPACE) { + e.preventDefault(); + this.group.toggleItem(this.opts.value.current); + return; + } + this.root.rovingFocusGroup.handleKeydown(this.opts.ref.current, e); + } + + readonly props = $derived.by(() => { + const isMulti = this.group.isMulti; + return { + id: this.opts.id.current, + role: isMulti ? undefined : "radio", + tabindex: this.#tabIndex, + "data-orientation": this.root.opts.orientation.current, + "data-disabled": boolToEmptyStrOrUndef(this.#isDisabled), + "data-state": this.isPressed ? "on" : "off", + "data-value": this.opts.value.current, + "aria-pressed": isMulti ? boolToStr(this.isPressed) : undefined, + "aria-checked": isMulti ? undefined : getAriaChecked(this.isPressed, false), + [toolbarAttrs.item]: "", + [toolbarAttrs["group-item"]]: "", + disabled: boolToTrueOrUndef(this.#isDisabled), + onclick: this.onclick, + onkeydown: this.onkeydown, + ...this.attachment, + } as const; + }); +} + +// ── Button ──────────────────────────────────────────────────────────────────── + +interface ToolbarButtonStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ disabled: boolean }> {} + +export class ToolbarButtonState { + static create(opts: ToolbarButtonStateOpts) { + return new ToolbarButtonState(opts, ToolbarRootContext.get()); + } + + readonly opts: ToolbarButtonStateOpts; + readonly root: ToolbarRootState; + readonly attachment: RefAttachment; + #tabIndex = $state(0); + + constructor(opts: ToolbarButtonStateOpts, root: ToolbarRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(opts.ref); + + $effect(() => { + this.#tabIndex = this.root.rovingFocusGroup.getTabIndex(this.opts.ref.current); + }); + + this.onkeydown = this.onkeydown.bind(this); + } + + onkeydown(e: AcelKeyboardEvent) { + this.root.rovingFocusGroup.handleKeydown(this.opts.ref.current, e); + } + + readonly props = $derived.by(() => ({ + id: this.opts.id.current, + tabindex: this.#tabIndex, + "data-disabled": boolToEmptyStrOrUndef(this.opts.disabled.current), + "data-orientation": this.root.opts.orientation.current, + disabled: boolToTrueOrUndef(this.opts.disabled.current), + [toolbarAttrs.item]: "", + [toolbarAttrs.button]: "", + onkeydown: this.onkeydown, + ...this.attachment, + }) as const); +} + +// ── Link ────────────────────────────────────────────────────────────────────── + +interface ToolbarLinkStateOpts extends WithRefOpts {} + +export class ToolbarLinkState { + static create(opts: ToolbarLinkStateOpts) { + return new ToolbarLinkState(opts, ToolbarRootContext.get()); + } + + readonly opts: ToolbarLinkStateOpts; + readonly root: ToolbarRootState; + readonly attachment: RefAttachment; + #tabIndex = $state(0); + + constructor(opts: ToolbarLinkStateOpts, root: ToolbarRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(opts.ref); + + $effect(() => { + this.#tabIndex = this.root.rovingFocusGroup.getTabIndex(this.opts.ref.current); + }); + + this.onkeydown = this.onkeydown.bind(this); + } + + onkeydown(e: AcelKeyboardEvent) { + this.root.rovingFocusGroup.handleKeydown(this.opts.ref.current, e); + } + + readonly props = $derived.by(() => ({ + id: this.opts.id.current, + tabindex: this.#tabIndex, + "data-orientation": this.root.opts.orientation.current, + [toolbarAttrs.item]: "", + [toolbarAttrs.link]: "", + onkeydown: this.onkeydown, + ...this.attachment, + }) as const); +} diff --git a/src/uix/acel/toolbar/types.ts b/src/uix/acel/toolbar/types.ts new file mode 100644 index 000000000..6976ef67b --- /dev/null +++ b/src/uix/acel/toolbar/types.ts @@ -0,0 +1,78 @@ +import type { WithChild, Without } from "../utilities/types"; +import type { + AcelPrimitiveDivAttributes, + AcelPrimitiveButtonAttributes, + AcelPrimitiveAnchorAttributes, +} from "../utilities/attributes"; + +// ── Root ────────────────────────────────────────────────────────────────────── + +export type ToolbarRootPropsWithoutHTML = WithChild<{ + orientation?: "horizontal" | "vertical"; + loop?: boolean; +}>; + +export type ToolbarRootProps = ToolbarRootPropsWithoutHTML & + Without; + +// ── Group ───────────────────────────────────────────────────────────────────── + +type ToolbarGroupBaseProps = { disabled?: boolean }; + +export type ToolbarGroupSinglePropsWithoutHTML = WithChild< + ToolbarGroupBaseProps & { + type: "single"; + value?: string; + } +>; + +export type ToolbarGroupMultiplePropsWithoutHTML = WithChild< + ToolbarGroupBaseProps & { + type: "multiple"; + value?: string[]; + } +>; + +export type ToolbarGroupPropsWithoutHTML = + | ToolbarGroupSinglePropsWithoutHTML + | ToolbarGroupMultiplePropsWithoutHTML; + +export type ToolbarGroupSingleProps = ToolbarGroupSinglePropsWithoutHTML & + Without; + +export type ToolbarGroupMultipleProps = ToolbarGroupMultiplePropsWithoutHTML & + Without; + +export type ToolbarGroupProps = ToolbarGroupSingleProps | ToolbarGroupMultipleProps; + +// ── Group Item ──────────────────────────────────────────────────────────────── + +export type ToolbarGroupItemPropsWithoutHTML = WithChild< + { + value: string; + disabled?: boolean; + }, + {}, + HTMLButtonElement +>; + +export type ToolbarGroupItemProps = ToolbarGroupItemPropsWithoutHTML & + Without; + +// ── Button ──────────────────────────────────────────────────────────────────── + +export type ToolbarButtonPropsWithoutHTML = WithChild< + { disabled?: boolean }, + {}, + HTMLButtonElement +>; + +export type ToolbarButtonProps = ToolbarButtonPropsWithoutHTML & + Without; + +// ── Link ────────────────────────────────────────────────────────────────────── + +export type ToolbarLinkPropsWithoutHTML = WithChild<{}, {}, HTMLAnchorElement>; + +export type ToolbarLinkProps = ToolbarLinkPropsWithoutHTML & + Without; diff --git a/src/uix/acel/tooltip/components/tooltip-content.svelte b/src/uix/acel/tooltip/components/tooltip-content.svelte new file mode 100644 index 000000000..c61655532 --- /dev/null +++ b/src/uix/acel/tooltip/components/tooltip-content.svelte @@ -0,0 +1,88 @@ + + + + {#snippet popper({ props, wrapperProps })} + {@const finalProps = mergeProps( + contentState.props, + props, + { style: floatingCSSVars }, + style ? { style } : {} + ) as Record} + {#if child} + {@render child({ props: finalProps, wrapperProps, ...contentState.snippetProps })} + {:else} +
+
+ {@render children?.()} +
+
+ {/if} + {/snippet} +
diff --git a/src/uix/acel/tooltip/components/tooltip-provider.svelte b/src/uix/acel/tooltip/components/tooltip-provider.svelte new file mode 100644 index 000000000..942a0c4ab --- /dev/null +++ b/src/uix/acel/tooltip/components/tooltip-provider.svelte @@ -0,0 +1,26 @@ + + +{@render children?.()} diff --git a/src/uix/acel/tooltip/components/tooltip-trigger.svelte b/src/uix/acel/tooltip/components/tooltip-trigger.svelte new file mode 100644 index 000000000..a13fd8809 --- /dev/null +++ b/src/uix/acel/tooltip/components/tooltip-trigger.svelte @@ -0,0 +1,47 @@ + + + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/acel/tooltip/components/tooltip.svelte b/src/uix/acel/tooltip/components/tooltip.svelte new file mode 100644 index 000000000..bc691bbc7 --- /dev/null +++ b/src/uix/acel/tooltip/components/tooltip.svelte @@ -0,0 +1,56 @@ + + + + + + {@render children?.({ + open: rootState.opts.open.current, + triggerId: rootState.activeTriggerId, + payload: rootState.activePayload as [T] extends [never] ? null : T | null, + })} + diff --git a/src/uix/acel/tooltip/exports.ts b/src/uix/acel/tooltip/exports.ts new file mode 100644 index 000000000..ef6bf5d74 --- /dev/null +++ b/src/uix/acel/tooltip/exports.ts @@ -0,0 +1,5 @@ +export { default as Provider } from "./components/tooltip-provider.svelte"; +export { default as Root } from "./components/tooltip.svelte"; +export { default as Trigger } from "./components/tooltip-trigger.svelte"; +export { default as Content } from "./components/tooltip-content.svelte"; +export { createTooltipTether } from "./tooltip.svelte"; diff --git a/src/uix/acel/tooltip/index.ts b/src/uix/acel/tooltip/index.ts new file mode 100644 index 000000000..9a8ff24be --- /dev/null +++ b/src/uix/acel/tooltip/index.ts @@ -0,0 +1,2 @@ +export * from "./exports"; +export * from "./types"; diff --git a/src/uix/acel/tooltip/tooltip.svelte.ts b/src/uix/acel/tooltip/tooltip.svelte.ts new file mode 100644 index 000000000..fc65d85b5 --- /dev/null +++ b/src/uix/acel/tooltip/tooltip.svelte.ts @@ -0,0 +1,810 @@ +import { + onMountEffect, + attachRef, + DOMContext, + type WritableBoxedValues, + type ReadableBoxedValues, + simpleBox, + boxWith, +} from "../utilities/index"; +import { on } from "svelte/events"; +import { Context, watch } from "runed"; +import { isElement, isFocusVisible } from "../utilities/dom/is-browser"; +import { createAcelAttrs, boolToEmptyStrOrUndef } from "../utilities/attrs"; +import type { OnChangeFn, RefAttachment, WithRefOpts } from "../utilities/types"; +import { TimeoutFn } from "../utilities/timeout-fn"; +import { SafePolygon } from "../utilities/floating/safe-polygon.svelte"; +import { PresenceManager } from "../utilities/presence/presence-manager.svelte"; + +export const tooltipAttrs = createAcelAttrs({ + component: "tooltip", + parts: ["content", "trigger"] as const, +}); + +const TooltipProviderContext = new Context("Tooltip.Provider"); +const TooltipRootContext = new Context("Tooltip.Root"); + +type TooltipTriggerRecord = { + id: string; + node: HTMLElement | null; + payload: unknown; + disabled: boolean; +}; + +class TooltipTriggerRegistryState { + triggers = $state(new Map()); + activeTriggerId = $state(null); + activeTriggerNode = $derived.by(() => { + const activeTriggerId = this.activeTriggerId; + if (activeTriggerId === null) return null; + return this.triggers.get(activeTriggerId)?.node ?? null; + }); + activePayload = $derived.by(() => { + const activeTriggerId = this.activeTriggerId; + if (activeTriggerId === null) return null; + return this.triggers.get(activeTriggerId)?.payload ?? null; + }); + + register = (record: TooltipTriggerRecord) => { + const next = new Map(this.triggers); + next.set(record.id, record); + this.triggers = next; + this.#coerceActiveTrigger(); + }; + + update = (record: TooltipTriggerRecord) => { + const next = new Map(this.triggers); + next.set(record.id, record); + this.triggers = next; + this.#coerceActiveTrigger(); + }; + + unregister = (id: string) => { + if (!this.triggers.has(id)) return; + const next = new Map(this.triggers); + next.delete(id); + this.triggers = next; + if (this.activeTriggerId === id) { + this.activeTriggerId = null; + } + }; + + setActiveTrigger = (id: string | null) => { + if (id === null) { + this.activeTriggerId = null; + return; + } + if (!this.triggers.has(id)) { + this.activeTriggerId = null; + return; + } + this.activeTriggerId = id; + }; + + get = (id: string) => { + return this.triggers.get(id); + }; + + has = (id: string) => { + return this.triggers.has(id); + }; + + getFirstTriggerId = () => { + const firstEntry = this.triggers.entries().next(); + if (firstEntry.done) return null; + return firstEntry.value[0]; + }; + + #coerceActiveTrigger = () => { + const activeTriggerId = this.activeTriggerId; + if (activeTriggerId === null) return; + if (!this.triggers.has(activeTriggerId)) { + this.activeTriggerId = null; + } + }; +} + +class TooltipTetherState { + readonly registry = new TooltipTriggerRegistryState(); + root = $state(null); +} + +export class TooltipTether { + readonly #state = new TooltipTetherState(); + + get state() { + return this.#state; + } + + open(triggerId: string) { + if (!this.#state.registry.has(triggerId)) { + return; + } + this.#state.registry.setActiveTrigger(triggerId); + this.#state.root?.setActiveTrigger(triggerId); + this.#state.root?.handleOpen(); + } + + close() { + this.#state.root?.handleClose(); + } + + get isOpen() { + return this.#state.root?.opts.open.current ?? false; + } +} + +export function createTooltipTether() { + return new TooltipTether(); +} + +interface TooltipProviderStateOpts + extends ReadableBoxedValues<{ + delayDuration: number; + disableHoverableContent: boolean; + disableCloseOnTriggerClick: boolean; + disabled: boolean; + ignoreNonKeyboardFocus: boolean; + skipDelayDuration: number; + }> {} + +export class TooltipProviderState { + static create(opts: TooltipProviderStateOpts) { + return TooltipProviderContext.set(new TooltipProviderState(opts)); + } + readonly opts: TooltipProviderStateOpts; + isOpenDelayed = $state(true); + isPointerInTransit = simpleBox(false); + #timerFn: TimeoutFn<() => void>; + #openTooltip = $state(null); + + constructor(opts: TooltipProviderStateOpts) { + this.opts = opts; + this.#timerFn = new TimeoutFn(() => { + this.isOpenDelayed = true; + }, this.opts.skipDelayDuration.current); + + onMountEffect(() => + on(window, "scroll", (e) => { + const activeTooltip = this.#openTooltip; + if (!activeTooltip) return; + const triggerNode = activeTooltip.triggerNode; + if (!triggerNode) return; + + const target = e.target; + if (!(target instanceof Element || target instanceof Document)) return; + + if (target.contains(triggerNode)) { + activeTooltip.handleClose(); + } + }) + ); + } + + #startTimer = () => { + const skipDuration = this.opts.skipDelayDuration.current; + if (skipDuration === 0) { + this.isOpenDelayed = true; + return; + } else { + this.#timerFn.start(); + } + }; + + #clearTimer = () => { + this.#timerFn.stop(); + }; + + onOpen = (tooltip: TooltipRootState) => { + if (this.#openTooltip && this.#openTooltip !== tooltip) { + this.#openTooltip.handleClose(); + } + this.#clearTimer(); + this.isOpenDelayed = false; + this.#openTooltip = tooltip; + }; + + onClose = (tooltip: TooltipRootState) => { + if (this.#openTooltip === tooltip) { + this.#openTooltip = null; + } + this.#startTimer(); + }; + + isTooltipOpen = (tooltip: TooltipRootState) => { + return this.#openTooltip === tooltip; + }; +} + +interface TooltipRootStateOpts + extends ReadableBoxedValues<{ + delayDuration: number | undefined; + disableHoverableContent: boolean | undefined; + disableCloseOnTriggerClick: boolean | undefined; + disabled: boolean | undefined; + ignoreNonKeyboardFocus: boolean | undefined; + onOpenChangeComplete: OnChangeFn; + tether: TooltipTether | undefined; + }>, + WritableBoxedValues<{ + open: boolean; + triggerId: string | null; + }> {} + +export class TooltipRootState { + static create(opts: TooltipRootStateOpts) { + return TooltipRootContext.set(new TooltipRootState(opts, TooltipProviderContext.get())); + } + readonly opts: TooltipRootStateOpts; + readonly provider: TooltipProviderState; + readonly delayDuration = $derived.by( + () => this.opts.delayDuration.current ?? this.provider.opts.delayDuration.current + ); + readonly disableHoverableContent = $derived.by( + () => + this.opts.disableHoverableContent.current ?? + this.provider.opts.disableHoverableContent.current + ); + readonly disableCloseOnTriggerClick = $derived.by( + () => + this.opts.disableCloseOnTriggerClick.current ?? + this.provider.opts.disableCloseOnTriggerClick.current + ); + readonly disabled = $derived.by( + () => this.opts.disabled.current ?? this.provider.opts.disabled.current + ); + readonly ignoreNonKeyboardFocus = $derived.by( + () => + this.opts.ignoreNonKeyboardFocus.current ?? + this.provider.opts.ignoreNonKeyboardFocus.current + ); + readonly registry: TooltipTriggerRegistryState; + readonly tether: TooltipTetherState | null; + contentNode = $state(null); + contentPresence: PresenceManager; + #wasOpenDelayed = $state(false); + #timerFn: TimeoutFn<() => void>; + readonly stateAttr = $derived.by(() => { + if (!this.opts.open.current) return "closed"; + return this.#wasOpenDelayed ? "delayed-open" : "instant-open"; + }); + + constructor(opts: TooltipRootStateOpts, provider: TooltipProviderState) { + this.opts = opts; + this.provider = provider; + this.tether = opts.tether.current?.state ?? null; + this.registry = this.tether?.registry ?? new TooltipTriggerRegistryState(); + this.#timerFn = new TimeoutFn(() => { + this.#wasOpenDelayed = true; + this.opts.open.current = true; + }, this.delayDuration ?? 0); + + if (this.tether) { + this.tether.root = this; + onMountEffect(() => { + return () => { + if (this.tether?.root === this) { + this.tether.root = null; + } + }; + }); + } + + this.contentPresence = new PresenceManager({ + open: this.opts.open, + ref: boxWith(() => this.contentNode), + onComplete: () => { + this.opts.onOpenChangeComplete.current(this.opts.open.current); + }, + }); + + watch( + () => this.delayDuration, + () => { + if (this.delayDuration === undefined) return; + this.#timerFn = new TimeoutFn(() => { + this.#wasOpenDelayed = true; + this.opts.open.current = true; + }, this.delayDuration); + } + ); + + watch( + () => this.opts.open.current, + (isOpen) => { + if (isOpen) { + this.ensureActiveTrigger(); + this.provider.onOpen(this); + } else { + this.provider.onClose(this); + } + }, + { lazy: true } + ); + + watch( + () => this.opts.triggerId.current, + (triggerId) => { + if (triggerId === this.registry.activeTriggerId) return; + this.registry.setActiveTrigger(triggerId); + } + ); + + watch( + () => this.registry.activeTriggerId, + (activeTriggerId) => { + if (this.opts.triggerId.current === activeTriggerId) return; + this.opts.triggerId.current = activeTriggerId; + } + ); + } + + handleOpen = () => { + this.#timerFn.stop(); + this.#wasOpenDelayed = false; + this.ensureActiveTrigger(); + this.opts.open.current = true; + }; + + handleClose = () => { + this.#timerFn.stop(); + this.opts.open.current = false; + }; + + #handleDelayedOpen = () => { + this.#timerFn.stop(); + + const shouldSkipDelay = !this.provider.isOpenDelayed; + const delayDuration = this.delayDuration ?? 0; + + if (shouldSkipDelay || delayDuration === 0) { + this.#wasOpenDelayed = false; + this.opts.open.current = true; + } else { + this.#timerFn.start(); + } + }; + + onTriggerEnter = (triggerId: string) => { + this.setActiveTrigger(triggerId); + this.#handleDelayedOpen(); + }; + + onTriggerLeave = () => { + if (this.disableHoverableContent) { + this.handleClose(); + } else { + this.#timerFn.stop(); + } + }; + + ensureActiveTrigger = () => { + if ( + this.registry.activeTriggerId !== null && + this.registry.has(this.registry.activeTriggerId) + ) { + return; + } + + if ( + this.opts.triggerId.current !== null && + this.registry.has(this.opts.triggerId.current) + ) { + this.registry.setActiveTrigger(this.opts.triggerId.current); + return; + } + + const firstTriggerId = this.registry.getFirstTriggerId(); + this.registry.setActiveTrigger(firstTriggerId); + }; + + setActiveTrigger = (triggerId: string | null) => { + this.registry.setActiveTrigger(triggerId); + }; + + registerTrigger = (trigger: TooltipTriggerRecord) => { + this.registry.register(trigger); + if ( + trigger.disabled && + this.registry.activeTriggerId === trigger.id && + this.opts.open.current + ) { + this.handleClose(); + } + }; + + updateTrigger = (trigger: TooltipTriggerRecord) => { + this.registry.update(trigger); + if ( + trigger.disabled && + this.registry.activeTriggerId === trigger.id && + this.opts.open.current + ) { + this.handleClose(); + } + }; + + unregisterTrigger = (id: string) => { + const isActive = this.registry.activeTriggerId === id; + this.registry.unregister(id); + if (isActive && this.opts.open.current) { + this.handleClose(); + } + }; + + isActiveTrigger = (triggerId: string) => { + return this.registry.activeTriggerId === triggerId; + }; + + get triggerNode() { + return this.registry.activeTriggerNode; + } + + get activePayload() { + return this.registry.activePayload; + } + + get activeTriggerId() { + return this.registry.activeTriggerId; + } +} + +interface TooltipTriggerStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ + disabled: boolean; + tabindex: number; + payload: unknown; + tether: TooltipTether | undefined; + }> {} + +export class TooltipTriggerState { + static create(opts: TooltipTriggerStateOpts) { + if (opts.tether.current) { + return new TooltipTriggerState(opts, null, opts.tether.current.state); + } + return new TooltipTriggerState(opts, TooltipRootContext.get(), null); + } + readonly opts: TooltipTriggerStateOpts; + readonly root: TooltipRootState | null; + readonly tether: TooltipTetherState | null; + readonly attachment: RefAttachment; + #isPointerDown = simpleBox(false); + #hasPointerMoveOpened = $state(false); + domContext: DOMContext; + #transitCheckTimeout: number | null = null; + #mounted = false; + #lastRegisteredId: string | null = null; + + constructor( + opts: TooltipTriggerStateOpts, + root: TooltipRootState | null, + tether: TooltipTetherState | null + ) { + this.opts = opts; + this.root = root; + this.tether = tether; + this.domContext = new DOMContext(opts.ref); + this.attachment = attachRef(this.opts.ref, (v) => this.#register(v)); + + watch( + () => this.opts.id.current, + () => this.#register(this.opts.ref.current) + ); + watch( + () => this.opts.payload.current, + () => this.#register(this.opts.ref.current) + ); + watch( + () => this.opts.disabled.current, + () => this.#register(this.opts.ref.current) + ); + + onMountEffect(() => { + this.#mounted = true; + this.#register(this.opts.ref.current); + + return () => { + const root = this.#getRoot(); + const id = this.#lastRegisteredId; + if (id) { + if (this.tether) { + this.tether.registry.unregister(id); + } else { + root?.unregisterTrigger(id); + } + } + this.#lastRegisteredId = null; + this.#mounted = false; + }; + }); + } + + #getRoot = () => { + return this.tether?.root ?? this.root; + }; + + #isDisabled = () => { + const root = this.#getRoot(); + return this.opts.disabled.current || Boolean(root?.disabled); + }; + + #register = (node: HTMLElement | null) => { + if (!this.#mounted) return; + const id = this.opts.id.current; + const payload = this.opts.payload.current; + const disabled = this.opts.disabled.current; + + if (this.#lastRegisteredId && this.#lastRegisteredId !== id) { + const root = this.#getRoot(); + if (this.tether) { + this.tether.registry.unregister(this.#lastRegisteredId); + } else { + root?.unregisterTrigger(this.#lastRegisteredId); + } + } + + const triggerRecord: TooltipTriggerRecord = { id, node, payload, disabled }; + + const root = this.#getRoot(); + if (this.tether) { + if (this.tether.registry.has(id)) { + this.tether.registry.update(triggerRecord); + } else { + this.tether.registry.register(triggerRecord); + } + if (disabled && this.tether.registry.activeTriggerId === id && root?.opts.open.current) { + root.handleClose(); + } + } else { + if (root?.registry.has(id)) { + root.updateTrigger(triggerRecord); + } else { + root?.registerTrigger(triggerRecord); + } + } + + this.#lastRegisteredId = id; + }; + + #clearTransitCheck = () => { + if (this.#transitCheckTimeout !== null) { + clearTimeout(this.#transitCheckTimeout); + this.#transitCheckTimeout = null; + } + }; + + handlePointerUp = () => { + this.#isPointerDown.current = false; + }; + + #onpointerup = () => { + if (this.#isDisabled()) return; + this.#isPointerDown.current = false; + }; + + #onpointerdown = () => { + if (this.#isDisabled()) return; + this.#isPointerDown.current = true; + this.domContext.getDocument().addEventListener("pointerup", () => this.handlePointerUp(), { + once: true, + }); + }; + + #onpointerenter = (e: PointerEvent) => { + const root = this.#getRoot(); + if (!root) return; + if (this.#isDisabled()) { + if (root.opts.open.current) root.handleClose(); + return; + } + if (e.pointerType === "touch") return; + + if (root.provider.isPointerInTransit.current) { + this.#clearTransitCheck(); + this.#transitCheckTimeout = window.setTimeout(() => { + if (root.provider.isPointerInTransit.current) { + root.provider.isPointerInTransit.current = false; + root.onTriggerEnter(this.opts.id.current); + this.#hasPointerMoveOpened = true; + } + }, 250); + return; + } + + root.onTriggerEnter(this.opts.id.current); + this.#hasPointerMoveOpened = true; + }; + + #onpointermove = (e: PointerEvent) => { + const root = this.#getRoot(); + if (!root) return; + if (this.#isDisabled()) { + if (root.opts.open.current) root.handleClose(); + return; + } + if (e.pointerType === "touch") return; + if (this.#hasPointerMoveOpened) return; + this.#clearTransitCheck(); + root.provider.isPointerInTransit.current = false; + root.onTriggerEnter(this.opts.id.current); + this.#hasPointerMoveOpened = true; + }; + + #onpointerleave = (e: PointerEvent) => { + const root = this.#getRoot(); + if (!root) return; + if (this.#isDisabled()) return; + this.#clearTransitCheck(); + if (!root.isActiveTrigger(this.opts.id.current)) { + this.#hasPointerMoveOpened = false; + return; + } + const relatedTarget = e.relatedTarget; + + if (isElement(relatedTarget)) { + for (const record of root.registry.triggers.values()) { + if (record.node !== relatedTarget) continue; + if (root.provider.opts.skipDelayDuration.current > 0) { + this.#hasPointerMoveOpened = false; + return; + } + root.handleClose(); + this.#hasPointerMoveOpened = false; + return; + } + } + + root.onTriggerLeave(); + this.#hasPointerMoveOpened = false; + }; + + #onfocus = (e: FocusEvent) => { + const root = this.#getRoot(); + if (!root) return; + if (this.#isPointerDown.current) return; + if (this.#isDisabled()) { + if (root.opts.open.current) root.handleClose(); + return; + } + if (root.ignoreNonKeyboardFocus && !isFocusVisible(e.currentTarget as Element)) return; + root.setActiveTrigger(this.opts.id.current); + root.handleOpen(); + }; + + #onblur = () => { + const root = this.#getRoot(); + if (!root || this.#isDisabled()) return; + root.handleClose(); + }; + + #onclick = () => { + const root = this.#getRoot(); + if (!root || root.disableCloseOnTriggerClick || this.#isDisabled()) return; + root.handleClose(); + }; + + readonly props = $derived.by(() => { + const root = this.#getRoot(); + const isOpenForTrigger = Boolean( + root?.opts.open.current && root.isActiveTrigger(this.opts.id.current) + ); + const isDisabled = this.#isDisabled(); + + return { + id: this.opts.id.current, + "aria-describedby": isOpenForTrigger ? root?.contentNode?.id : undefined, + "data-state": isOpenForTrigger ? root?.stateAttr : "closed", + "data-disabled": boolToEmptyStrOrUndef(isDisabled), + "data-delay-duration": `${root?.delayDuration ?? 0}`, + [tooltipAttrs.trigger]: "", + tabindex: isDisabled ? undefined : this.opts.tabindex.current, + disabled: this.opts.disabled.current, + onpointerup: this.#onpointerup, + onpointerdown: this.#onpointerdown, + onpointerenter: this.#onpointerenter, + onpointermove: this.#onpointermove, + onpointerleave: this.#onpointerleave, + onfocus: this.#onfocus, + onblur: this.#onblur, + onclick: this.#onclick, + ...this.attachment, + } as const; + }); +} + +interface TooltipContentStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ + onInteractOutside: (e: PointerEvent) => void; + onEscapeKeydown: (e: KeyboardEvent) => void; + }> {} + +export class TooltipContentState { + static create(opts: TooltipContentStateOpts) { + return new TooltipContentState(opts, TooltipRootContext.get()); + } + readonly opts: TooltipContentStateOpts; + readonly root: TooltipRootState; + readonly attachment: RefAttachment; + + constructor(opts: TooltipContentStateOpts, root: TooltipRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(this.opts.ref, (v) => (this.root.contentNode = v)); + + new SafePolygon({ + triggerNode: () => this.root.triggerNode, + contentNode: () => this.root.contentNode, + enabled: () => this.root.opts.open.current && !this.root.disableHoverableContent, + transitIntentTimeout: 180, + ignoredTargets: () => { + if (this.root.provider.opts.skipDelayDuration.current === 0) return []; + const nodes: HTMLElement[] = []; + const activeTriggerNode = this.root.triggerNode; + for (const record of this.root.registry.triggers.values()) { + if (record.node && record.node !== activeTriggerNode) { + nodes.push(record.node); + } + } + return nodes; + }, + onPointerExit: () => { + if (this.root.provider.isTooltipOpen(this.root)) { + this.root.handleClose(); + } + }, + }); + } + + onInteractOutside = (e: PointerEvent) => { + if ( + isElement(e.target) && + this.root.triggerNode?.contains(e.target as Node) && + this.root.disableCloseOnTriggerClick + ) { + e.preventDefault(); + return; + } + this.opts.onInteractOutside.current(e); + if (e.defaultPrevented) return; + this.root.handleClose(); + }; + + onEscapeKeydown = (e: KeyboardEvent) => { + this.opts.onEscapeKeydown.current?.(e); + if (e.defaultPrevented) return; + this.root.handleClose(); + }; + + onOpenAutoFocus = (e: Event) => { + e.preventDefault(); + }; + + onCloseAutoFocus = (e: Event) => { + e.preventDefault(); + }; + + get shouldRender() { + return this.root.contentPresence.shouldRender; + } + + readonly snippetProps = $derived.by(() => ({ open: this.root.opts.open.current })); + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + "data-state": this.root.stateAttr, + "data-disabled": boolToEmptyStrOrUndef(this.root.disabled), + style: { outline: "none" }, + [tooltipAttrs.content]: "", + ...this.attachment, + }) as const + ); + + readonly popperProps = { + onInteractOutside: this.onInteractOutside, + onEscapeKeydown: this.onEscapeKeydown, + }; +} diff --git a/src/uix/acel/tooltip/types.ts b/src/uix/acel/tooltip/types.ts new file mode 100644 index 000000000..ceab81e91 --- /dev/null +++ b/src/uix/acel/tooltip/types.ts @@ -0,0 +1,86 @@ +import type { Snippet } from "svelte"; +import type { WithChild, WithChildNoChildrenSnippetProps, WithChildren, Without, OnChangeFn } from "../utilities/types"; +import type { AcelPrimitiveDivAttributes, AcelPrimitiveButtonAttributes } from "../utilities/attributes"; +import type { TooltipTether as TooltipTetherImpl } from "./tooltip.svelte"; +import type { StyleProperties } from "../utilities/index"; +import type { Side, Align, Boundary } from "../utilities/floating/use-floating-layer.svelte"; +import type { Arrayable } from "../utilities/types"; + +export type TooltipTether = TooltipTetherImpl; + +export type TooltipProviderPropsWithoutHTML = WithChildren<{ + delayDuration?: number; + skipDelayDuration?: number; + disableHoverableContent?: boolean; + disableCloseOnTriggerClick?: boolean; + disabled?: boolean; + ignoreNonKeyboardFocus?: boolean; +}>; + +export type TooltipProviderProps = TooltipProviderPropsWithoutHTML; + +export type TooltipRootSnippetProps = { + open: boolean; + triggerId: string | null; + payload: [Payload] extends [never] ? null : Payload | null; +}; + +export type TooltipRootPropsWithoutHTML = Omit< + WithChildren<{ + open?: boolean; + onOpenChange?: OnChangeFn; + onOpenChangeComplete?: OnChangeFn; + delayDuration?: number; + disableHoverableContent?: boolean; + disableCloseOnTriggerClick?: boolean; + disabled?: boolean; + ignoreNonKeyboardFocus?: boolean; + triggerId?: string | null; + tether?: TooltipTether | undefined; + }>, + "children" +> & { + children?: Snippet | Snippet<[TooltipRootSnippetProps]>; +}; + +export type TooltipRootProps = TooltipRootPropsWithoutHTML; + +export type TooltipContentPropsWithoutHTML = Omit< + WithChildNoChildrenSnippetProps< + { + forceMount?: boolean; + side?: Side; + sideOffset?: number; + align?: Align; + alignOffset?: number; + arrowPadding?: number; + avoidCollisions?: boolean; + collisionBoundary?: Arrayable; + collisionPadding?: number | Partial>; + hideWhenDetached?: boolean; + sticky?: "partial" | "always"; + updatePositionStrategy?: "optimized" | "always"; + strategy?: "fixed" | "absolute"; + customAnchor?: string | HTMLElement | null; + onInteractOutside?: (e: PointerEvent) => void; + onEscapeKeydown?: (e: KeyboardEvent) => void; + style?: StyleProperties | string | null | undefined; + }, + { open: boolean } + >, + "child" +> & { + child?: Snippet<[{ props: Record; wrapperProps: Record; open: boolean }]>; +}; + +export type TooltipContentProps = TooltipContentPropsWithoutHTML & + Without; + +export type TooltipTriggerPropsWithoutHTML = WithChild<{ + disabled?: boolean | null | undefined; + payload?: [Payload] extends [never] ? unknown : Payload; + tether?: TooltipTether | undefined; +}>; + +export type TooltipTriggerProps = TooltipTriggerPropsWithoutHTML & + Without>; diff --git a/src/uix/acel/internal/arrays.ts b/src/uix/acel/utilities/arrays.ts similarity index 100% rename from src/uix/acel/internal/arrays.ts rename to src/uix/acel/utilities/arrays.ts diff --git a/src/uix/acel/shared/attributes.ts b/src/uix/acel/utilities/attributes.ts similarity index 100% rename from src/uix/acel/shared/attributes.ts rename to src/uix/acel/utilities/attributes.ts diff --git a/src/uix/acel/internal/attrs.ts b/src/uix/acel/utilities/attrs.ts similarity index 100% rename from src/uix/acel/internal/attrs.ts rename to src/uix/acel/utilities/attrs.ts diff --git a/src/uix/acel/internal/box-auto-reset.svelte.ts b/src/uix/acel/utilities/box/box-auto-reset.svelte.ts similarity index 92% rename from src/uix/acel/internal/box-auto-reset.svelte.ts rename to src/uix/acel/utilities/box/box-auto-reset.svelte.ts index 558e24eaf..92e7f44b6 100644 --- a/src/uix/acel/internal/box-auto-reset.svelte.ts +++ b/src/uix/acel/utilities/box/box-auto-reset.svelte.ts @@ -1,5 +1,5 @@ -import { type WritableBox, boxWith } from "../utilities"; -import { noop } from "./noop.ts"; +import { type WritableBox, boxWith } from "../index"; +import { noop } from "../noop.ts"; type BoxAutoResetOptions = { afterMs?: number; diff --git a/src/uix/acel/internal/clamp.ts b/src/uix/acel/utilities/clamp.ts similarity index 100% rename from src/uix/acel/internal/clamp.ts rename to src/uix/acel/utilities/clamp.ts diff --git a/src/uix/acel/internal/create-id.ts b/src/uix/acel/utilities/create-id.ts similarity index 100% rename from src/uix/acel/internal/create-id.ts rename to src/uix/acel/utilities/create-id.ts diff --git a/src/uix/acel/internal/css-escape.ts b/src/uix/acel/utilities/css/css-escape.ts similarity index 100% rename from src/uix/acel/internal/css-escape.ts rename to src/uix/acel/utilities/css/css-escape.ts diff --git a/src/uix/acel/internal/date-time/announcer.ts b/src/uix/acel/utilities/datetime/announcer.ts similarity index 95% rename from src/uix/acel/internal/date-time/announcer.ts rename to src/uix/acel/utilities/datetime/announcer.ts index 4deced7b3..3103041af 100644 --- a/src/uix/acel/internal/date-time/announcer.ts +++ b/src/uix/acel/utilities/datetime/announcer.ts @@ -1,5 +1,5 @@ -import { srOnlyStylesString } from "../../utilities"; -import { isBrowser, isHTMLElement } from "../is.ts"; +import { srOnlyStylesString } from "../index"; +import { isBrowser, isHTMLElement } from "../dom/is-browser.ts"; /** diff --git a/src/uix/acel/internal/date-time/calendar-helpers.svelte.ts b/src/uix/acel/utilities/datetime/calendar-helpers.svelte.ts similarity index 96% rename from src/uix/acel/internal/date-time/calendar-helpers.svelte.ts rename to src/uix/acel/utilities/datetime/calendar-helpers.svelte.ts index 3b0da0926..85f92aa57 100644 --- a/src/uix/acel/internal/date-time/calendar-helpers.svelte.ts +++ b/src/uix/acel/utilities/datetime/calendar-helpers.svelte.ts @@ -4,14 +4,14 @@ import { isSameDay, isSameMonth, startOfMonth, -} from "@internationalized/date"; +} from "$lib/glob/lib/date"; import { type ReadableBox, type WritableBox, afterTick, getDocument, styleToString, -} from "../../utilities"; +} from "../index"; import { untrack } from "svelte"; import { getDaysInMonth, @@ -25,11 +25,11 @@ import { toDate, } from "./utils.ts"; import type { Formatter } from "./formatter.ts"; -import { createBitsAttrs, boolToEmptyStrOrUndef } from "./attrs.ts"; -import { chunk, isValidIndex } from "./arrays.ts"; -import { isBrowser, isHTMLElement } from "./is.ts"; -import { kbd } from "./kbd.ts"; -import type { DateMatcher, Month } from "../shared/index.ts"; +import { createAcelAttrs, boolToEmptyStrOrUndef } from "../attrs"; +import { chunk, isValidIndex } from "../arrays"; +import { isBrowser, isHTMLElement } from "../dom/is-browser"; +import { kbd } from "../keyboard/kbd"; +import type { DateMatcher, Month } from "../types"; import { watch } from "runed"; /** @@ -39,7 +39,7 @@ import { watch } from "runed"; */ export function isCalendarDayNode(node: unknown): node is HTMLElement { if (!isHTMLElement(node)) return false; - if (!node.hasAttribute("data-bits-day")) return false; + if (!node.hasAttribute("data-calendar-day")) return false; return true; } @@ -193,7 +193,7 @@ export function createMonths(props: SetMonthProps) { export function getSelectableCells(calendarNode: HTMLElement | null) { if (!calendarNode) return []; - const selectableSelector = `[data-bits-day]:not([data-disabled]):not([data-outside-visible-months])`; + const selectableSelector = `[data-calendar-day]:not([data-disabled]):not([data-outside-visible-months])`; return Array.from(calendarNode.querySelectorAll(selectableSelector)).filter( (el): el is HTMLElement => isHTMLElement(el) @@ -746,7 +746,7 @@ export type CalendarParts = export function pickerOpenFocus(e: Event) { const doc = getDocument(e.target as HTMLElement); - const nodeToFocus = doc.querySelector("[data-bits-day][data-focused]"); + const nodeToFocus = doc.querySelector("[data-calendar-day][data-focused]"); if (nodeToFocus) { e.preventDefault(); nodeToFocus?.focus(); @@ -756,7 +756,7 @@ export function pickerOpenFocus(e: Event) { export function getFirstNonDisabledDateInView(calendarRef: HTMLElement): DateValue | undefined { if (!isBrowser) return; const daysInView = Array.from( - calendarRef.querySelectorAll("[data-bits-day]:not([aria-disabled=true])") + calendarRef.querySelectorAll("[data-calendar-day]:not([aria-disabled=true])") ); if (daysInView.length === 0) return; const element = daysInView[0]; @@ -836,7 +836,7 @@ export function getDateWithPreviousTime(date: DateValue | undefined, prev: DateV return date; } -export const calendarAttrs = createBitsAttrs({ +export const calendarAttrs = createAcelAttrs({ component: "calendar", parts: [ "root", @@ -853,7 +853,7 @@ export const calendarAttrs = createBitsAttrs({ "heading", "month-select", "year-select", - ], + ] as const, }); type GetDefaultYearsProps = { diff --git a/src/uix/acel/shared/date/types.ts b/src/uix/acel/utilities/datetime/date-types.ts similarity index 98% rename from src/uix/acel/shared/date/types.ts rename to src/uix/acel/utilities/datetime/date-types.ts index f674bc054..c80aef05f 100644 --- a/src/uix/acel/shared/date/types.ts +++ b/src/uix/acel/utilities/datetime/date-types.ts @@ -3,14 +3,14 @@ import type { DateValue, Time, ZonedDateTime -} from "@/glob/lib"; +} from "$lib/glob/lib/date"; import type { DATE_SEGMENT_PARTS, EDITABLE_SEGMENT_PARTS, NON_EDITABLE_SEGMENT_PARTS, EDITABLE_TIME_SEGMENT_PARTS, -} from "../../internal/date-time/field/parts.ts"; +} from "./field/parts.ts"; export type Granularity = "day" | "hour" | "minute" | "second"; export type TimeGranularity = "hour" | "minute" | "second"; diff --git a/src/uix/acel/internal/date-time/field/helpers.ts b/src/uix/acel/utilities/datetime/field/helpers.ts similarity index 97% rename from src/uix/acel/internal/date-time/field/helpers.ts rename to src/uix/acel/utilities/datetime/field/helpers.ts index 63fece872..bc736835e 100644 --- a/src/uix/acel/internal/date-time/field/helpers.ts +++ b/src/uix/acel/utilities/datetime/field/helpers.ts @@ -1,5 +1,5 @@ -import type { DateValue } from "@internationalized/date"; -import { styleToString } from "../../utilities"; +import type { DateValue } from "$lib/glob/lib/date"; +import { styleToString } from "../../index"; import type { Formatter } from "../formatter.ts"; import { getPlaceholder } from "../placeholders.ts"; import { hasTime, isZonedDateTime } from "../utils.ts"; @@ -20,10 +20,10 @@ import { EDITABLE_TIME_SEGMENT_PARTS, } from "./parts.ts"; import { getSegments } from "./segments.ts"; -import { isBrowser, isNull, isNumberString } from "./is.ts"; -import { useId } from "./use-id.ts"; -import { kbd } from "./kbd.ts"; -import type { Granularity, HourCycle } from "../shared/date/types.ts"; +import { isBrowser, isNull, isNumberString } from "../../dom/is-browser"; +import { useId } from "../../use-id"; +import { kbd } from "../../keyboard/kbd.ts"; +import type { Granularity, HourCycle } from "../date-types"; export function initializeSegmentValues(granularity: Granularity) { const calendarDateTimeGranularities = ["hour", "minute", "second"]; diff --git a/src/uix/acel/internal/date-time/field/parts.ts b/src/uix/acel/utilities/datetime/field/parts.ts similarity index 100% rename from src/uix/acel/internal/date-time/field/parts.ts rename to src/uix/acel/utilities/datetime/field/parts.ts diff --git a/src/uix/acel/internal/date-time/field/segments.ts b/src/uix/acel/utilities/datetime/field/segments.ts similarity index 98% rename from src/uix/acel/internal/date-time/field/segments.ts rename to src/uix/acel/utilities/datetime/field/segments.ts index f8a5ce14a..5b1c98117 100644 --- a/src/uix/acel/internal/date-time/field/segments.ts +++ b/src/uix/acel/utilities/datetime/field/segments.ts @@ -1,6 +1,6 @@ import { isAnySegmentPart } from "./helpers.ts"; -import { isHTMLElement } from "../../is.ts"; -import { kbd } from "../../kbd.ts"; +import { isHTMLElement } from "../../dom/is-browser.ts"; +import { kbd } from "../../keyboard/kbd.ts"; /** * Handles segment navigation based on the provided keyboard event and field ID. diff --git a/src/uix/acel/internal/date-time/field/time-helpers.ts b/src/uix/acel/utilities/datetime/field/time-helpers.ts similarity index 97% rename from src/uix/acel/internal/date-time/field/time-helpers.ts rename to src/uix/acel/utilities/datetime/field/time-helpers.ts index 9cf9be55e..5f63c4749 100644 --- a/src/uix/acel/internal/date-time/field/time-helpers.ts +++ b/src/uix/acel/utilities/datetime/field/time-helpers.ts @@ -1,4 +1,4 @@ -import { isBrowser, isNull } from "./is.ts"; +import { isBrowser, isNull } from "../../dom/is-browser"; import type { EditableTimeSegmentPart, HourCycle, @@ -7,14 +7,14 @@ import type { TimeSegmentStateMap, TimeSegmentValueObj, TimeValue, -} from "../shared/date/types.ts"; -import { CalendarDateTime, Time, ZonedDateTime } from "@internationalized/date"; +} from "../date-types"; +import { CalendarDateTime, Time, ZonedDateTime } from "$lib/glob/lib/date"; import type { TimeFormatter } from "../formatter.ts"; import { ALL_TIME_SEGMENT_PARTS, EDITABLE_TIME_SEGMENT_PARTS } from "./parts.ts"; import { getTimeSegments } from "./segments.ts"; import type { TimeSegmentPart } from "./types.ts"; -import { styleToString } from "../../utilities"; -import { useId } from "./use-id.ts"; +import { styleToString } from "../../index"; +import { useId } from "../../use-id"; import { getPlaceholder } from "../placeholders.ts"; import { isZonedDateTime } from "../utils.ts"; import { getDefaultHourCycle } from "./helpers.ts"; diff --git a/src/uix/acel/internal/date-time/field/types.ts b/src/uix/acel/utilities/datetime/field/types.ts similarity index 100% rename from src/uix/acel/internal/date-time/field/types.ts rename to src/uix/acel/utilities/datetime/field/types.ts diff --git a/src/uix/acel/internal/date-time/formatter.ts b/src/uix/acel/utilities/datetime/formatter.ts similarity index 96% rename from src/uix/acel/internal/date-time/formatter.ts rename to src/uix/acel/utilities/datetime/formatter.ts index 5801243c1..614dc7358 100644 --- a/src/uix/acel/internal/date-time/formatter.ts +++ b/src/uix/acel/utilities/datetime/formatter.ts @@ -1,8 +1,8 @@ -import { DateFormatter, type DateValue } from "@internationalized/date"; +import { DateFormatter, type DateValue } from "$lib/glob/lib/date"; import { hasTime, isZonedDateTime, toDate } from "./utils.ts"; -import type { HourCycle, TimeValue } from "../shared/date/types.ts"; +import type { HourCycle, TimeValue } from "./date-types"; import { convertTimeValueToDateValue } from "./field/time-helpers.ts"; -import type { ReadableBox } from "../../utilities"; +import type { ReadableBox } from "../index"; export type Formatter = ReturnType; export type TimeFormatter = ReturnType; diff --git a/src/uix/acel/internal/date-time/placeholders.ts b/src/uix/acel/utilities/datetime/placeholders.ts similarity index 100% rename from src/uix/acel/internal/date-time/placeholders.ts rename to src/uix/acel/utilities/datetime/placeholders.ts diff --git a/src/uix/acel/internal/date-time/utils.ts b/src/uix/acel/utilities/datetime/utils.ts similarity index 99% rename from src/uix/acel/internal/date-time/utils.ts rename to src/uix/acel/utilities/datetime/utils.ts index bf93f4ffa..3734cd736 100644 --- a/src/uix/acel/internal/date-time/utils.ts +++ b/src/uix/acel/utilities/datetime/utils.ts @@ -10,13 +10,13 @@ import { parseDateTime, parseZonedDateTime, toCalendar, -} from "@internationalized/date"; +} from "$lib/glob/lib/date"; import type { DateMatcher, Granularity, TimeGranularity, TimeValue, -} from "../shared/date/types.ts"; +} from "./date-types"; type GetDefaultDateProps = { defaultValue?: DateValue | DateValue[] | undefined; diff --git a/src/uix/acel/internal/debounce.ts b/src/uix/acel/utilities/debounce.ts similarity index 100% rename from src/uix/acel/internal/debounce.ts rename to src/uix/acel/utilities/debounce.ts diff --git a/src/uix/acel/internal/body-scroll-lock.svelte.ts b/src/uix/acel/utilities/dom/body-scroll-lock.svelte.ts similarity index 97% rename from src/uix/acel/internal/body-scroll-lock.svelte.ts rename to src/uix/acel/utilities/dom/body-scroll-lock.svelte.ts index b4df36607..5f7d16a02 100644 --- a/src/uix/acel/internal/body-scroll-lock.svelte.ts +++ b/src/uix/acel/utilities/dom/body-scroll-lock.svelte.ts @@ -5,12 +5,12 @@ import { afterTick, boxWith, onDestroyEffect, -} from "../utilities"; -import type { Fn } from "./types.ts"; -import { isIOS } from "./is.ts"; -import { useId } from "./use-id.ts"; +} from "../index"; +import type { Fn } from "../types.ts"; +import { isIOS } from "./is-browser.ts"; +import { useId } from "../use-id.ts"; import { watch } from "runed"; -import { SharedState } from "./shared-state.svelte.ts"; +import { SharedState } from "../shared-state.svelte.ts"; import { BROWSER } from "esm-env"; import { on } from "svelte/events"; diff --git a/src/uix/acel/internal/dom.ts b/src/uix/acel/utilities/dom/dom-internal.ts similarity index 100% rename from src/uix/acel/internal/dom.ts rename to src/uix/acel/utilities/dom/dom-internal.ts diff --git a/src/uix/acel/internal/elements.ts b/src/uix/acel/utilities/dom/elements.ts similarity index 100% rename from src/uix/acel/internal/elements.ts rename to src/uix/acel/utilities/dom/elements.ts diff --git a/src/uix/acel/internal/focus.ts b/src/uix/acel/utilities/dom/focus.ts similarity index 98% rename from src/uix/acel/internal/focus.ts rename to src/uix/acel/utilities/dom/focus.ts index 58b245d99..f375f246f 100644 --- a/src/uix/acel/internal/focus.ts +++ b/src/uix/acel/utilities/dom/focus.ts @@ -1,5 +1,5 @@ -import { getDocument, getWindow } from "../utilities"; -import { isBrowser, isElementHidden, isSelectableInput } from "./is.ts"; +import { getDocument, getWindow } from "../index"; +import { isBrowser, isElementHidden, isSelectableInput } from "./is-browser.ts"; export type FocusableTarget = HTMLElement | { focus: () => void }; diff --git a/src/uix/acel/internal/is.ts b/src/uix/acel/utilities/dom/is-browser.ts similarity index 100% rename from src/uix/acel/internal/is.ts rename to src/uix/acel/utilities/dom/is-browser.ts diff --git a/src/uix/acel/internal/locale.ts b/src/uix/acel/utilities/dom/locale.ts similarity index 88% rename from src/uix/acel/internal/locale.ts rename to src/uix/acel/utilities/dom/locale.ts index 274fc0d8a..bda6906c0 100644 --- a/src/uix/acel/internal/locale.ts +++ b/src/uix/acel/utilities/dom/locale.ts @@ -1,5 +1,5 @@ // https://github.com/melt-ui/melt-ui -import type { Direction } from "../shared/index.ts"; +import type { Direction } from "../types.ts"; /** * Detects the text direction in the element. diff --git a/src/uix/acel/internal/svelte-resize-observer.svelte.ts b/src/uix/acel/utilities/dom/svelte-resize-observer.svelte.ts similarity index 93% rename from src/uix/acel/internal/svelte-resize-observer.svelte.ts rename to src/uix/acel/utilities/dom/svelte-resize-observer.svelte.ts index d34ced4e4..5a8e3ac86 100644 --- a/src/uix/acel/internal/svelte-resize-observer.svelte.ts +++ b/src/uix/acel/utilities/dom/svelte-resize-observer.svelte.ts @@ -1,4 +1,4 @@ -import type { Getter } from "../utilities"; +import type { Getter } from "../index"; export class SvelteResizeObserver { #node: Getter; diff --git a/src/uix/acel/internal/events.ts b/src/uix/acel/utilities/events/event-dispatcher.ts similarity index 100% rename from src/uix/acel/internal/events.ts rename to src/uix/acel/utilities/events/event-dispatcher.ts diff --git a/src/uix/acel/internal/floating-svelte/floating-utils.svelte.ts b/src/uix/acel/utilities/floating/floating-utils.svelte.ts similarity index 63% rename from src/uix/acel/internal/floating-svelte/floating-utils.svelte.ts rename to src/uix/acel/utilities/floating/floating-utils.svelte.ts index c7077b142..5e6271b98 100644 --- a/src/uix/acel/internal/floating-svelte/floating-utils.svelte.ts +++ b/src/uix/acel/utilities/floating/floating-utils.svelte.ts @@ -1,4 +1,4 @@ -import type { Getter, MaybeGetter } from "../../utilities"; +import type { Getter, MaybeGetter } from "../index"; export function get(valueOrGetValue: MaybeGetter): T { return typeof valueOrGetValue === "function" @@ -19,10 +19,10 @@ export function roundByDPR(element: Element, value: number) { export function getFloatingContentCSSVars(name: string) { return { - [`--bits-${name}-content-transform-origin`]: `var(--bits-floating-transform-origin)`, - [`--bits-${name}-content-available-width`]: `var(--bits-floating-available-width)`, - [`--bits-${name}-content-available-height`]: `var(--bits-floating-available-height)`, - [`--bits-${name}-anchor-width`]: `var(--bits-floating-anchor-width)`, - [`--bits-${name}-anchor-height`]: `var(--bits-floating-anchor-height)`, + [`--acel-${name}-content-transform-origin`]: `var(--acel-floating-transform-origin)`, + [`--acel-${name}-content-available-width`]: `var(--acel-floating-available-width)`, + [`--acel-${name}-content-available-height`]: `var(--acel-floating-available-height)`, + [`--acel-${name}-anchor-width`]: `var(--acel-floating-anchor-width)`, + [`--acel-${name}-anchor-height`]: `var(--acel-floating-anchor-height)`, }; } diff --git a/src/uix/acel/internal/grace-area.svelte.ts b/src/uix/acel/utilities/floating/grace-area.svelte.ts similarity index 97% rename from src/uix/acel/internal/grace-area.svelte.ts rename to src/uix/acel/utilities/floating/grace-area.svelte.ts index 65ccee094..ce424fd1e 100644 --- a/src/uix/acel/internal/grace-area.svelte.ts +++ b/src/uix/acel/utilities/floating/grace-area.svelte.ts @@ -4,11 +4,11 @@ import { executeCallbacks, getDocument, getWindow, -} from "../utilities"; +} from "../index"; import { on } from "svelte/events"; import { watch } from "runed"; -import { boxAutoReset } from "./box-auto-reset.svelte.ts"; -import { isElement, isHTMLElement } from "./is.ts"; +import { boxAutoReset } from "../box/box-auto-reset.svelte.ts"; +import { isElement, isHTMLElement } from "../dom/is-browser.ts"; import type { Side } from "@floating-ui/dom"; interface GraceAreaOptions { diff --git a/src/uix/acel/internal/safe-polygon.svelte.ts b/src/uix/acel/utilities/floating/safe-polygon.svelte.ts similarity index 99% rename from src/uix/acel/internal/safe-polygon.svelte.ts rename to src/uix/acel/utilities/floating/safe-polygon.svelte.ts index 4947f6598..143f8eedc 100644 --- a/src/uix/acel/internal/safe-polygon.svelte.ts +++ b/src/uix/acel/utilities/floating/safe-polygon.svelte.ts @@ -1,7 +1,7 @@ -import { type Getter, getDocument } from "../utilities"; +import { type Getter, getDocument } from "../index"; import { on } from "svelte/events"; import { watch } from "runed"; -import { isElement } from "./is.ts"; +import { isElement } from "../dom/is-browser.ts"; import type { Side } from "@floating-ui/dom"; type Point = [number, number]; diff --git a/src/uix/acel/internal/floating-svelte/types.ts b/src/uix/acel/utilities/floating/types.ts similarity index 97% rename from src/uix/acel/internal/floating-svelte/types.ts rename to src/uix/acel/utilities/floating/types.ts index ac2fd762f..c426b93b7 100644 --- a/src/uix/acel/internal/floating-svelte/types.ts +++ b/src/uix/acel/utilities/floating/types.ts @@ -6,7 +6,7 @@ import type { ReferenceElement, Strategy, } from "@floating-ui/dom"; -import type { ReadableBox, WritableBox } from "../../utilities"; +import type { ReadableBox, WritableBox } from "../index"; type ValueOrGetValue = T | (() => T); diff --git a/src/uix/acel/utilities/floating/use-floating-layer.svelte.ts b/src/uix/acel/utilities/floating/use-floating-layer.svelte.ts new file mode 100644 index 000000000..6fb1e9894 --- /dev/null +++ b/src/uix/acel/utilities/floating/use-floating-layer.svelte.ts @@ -0,0 +1,421 @@ +import { + type Middleware, + type Placement, + arrow, + autoUpdate, + flip, + hide, + limitShift, + offset, + shift, + size, +} from "@floating-ui/dom"; +import { + attachRef, + cssToStyleObj, + getWindow, + styleToString, + type ReadableBoxedValues, + type ReadableBox, + simpleBox, + boxFrom, +} from "../index"; +import { Context, ElementSize, watch } from "runed"; +import type { Arrayable, WithRefOpts } from "../types"; +import { useId } from "../use-id"; +import { useFloating } from "./use-floating.svelte"; +import type { Measurable, UseFloatingReturn } from "./types"; +import type { Direction, StyleProperties } from "../types"; + +export const SIDE_OPTIONS = ["top", "right", "bottom", "left"] as const; +export const ALIGN_OPTIONS = ["start", "center", "end"] as const; + +const OPPOSITE_SIDE: Record = { + top: "bottom", + right: "left", + bottom: "top", + left: "right", +}; + +const FloatingRootContext = new Context("Floating.Root"); +const FloatingContentContext = new Context("Floating.Content"); +const FloatingTooltipRootContext = new Context("Floating.Root.Tooltip"); + +export type Side = (typeof SIDE_OPTIONS)[number]; +export type Align = (typeof ALIGN_OPTIONS)[number]; +export type Boundary = Element | null; + +function isNotNull(value: T | null): value is T { + return value !== null; +} + +export class FloatingRootState { + static create(tooltip = false) { + return tooltip + ? FloatingTooltipRootContext.set(new FloatingRootState()) + : FloatingRootContext.set(new FloatingRootState()); + } + anchorNode = simpleBox(null); + customAnchorNode = simpleBox(null); + triggerNode: ReadableBox = simpleBox(null); + + constructor() { + $effect(() => { + if (this.customAnchorNode.current) { + if (typeof this.customAnchorNode.current === "string") { + this.anchorNode.current = document.querySelector(this.customAnchorNode.current); + } else { + this.anchorNode.current = this.customAnchorNode.current; + } + } else { + this.anchorNode.current = this.triggerNode.current; + } + }); + } +} + +export interface FloatingContentStateOpts + extends ReadableBoxedValues<{ + id: string; + wrapperId: string; + side: Side; + sideOffset: number; + align: Align; + alignOffset: number; + arrowPadding: number; + avoidCollisions: boolean; + collisionBoundary: Arrayable; + collisionPadding: number | Partial>; + sticky: "partial" | "always"; + hideWhenDetached: boolean; + updatePositionStrategy: "optimized" | "always"; + strategy: "fixed" | "absolute"; + onPlaced: () => void; + dir: Direction; + style: StyleProperties | null | undefined | string; + enabled: boolean; + customAnchor: string | HTMLElement | null | Measurable; + }> {} + +export class FloatingContentState { + static create(opts: FloatingContentStateOpts, tooltip = false) { + return tooltip + ? FloatingContentContext.set( + new FloatingContentState(opts, FloatingTooltipRootContext.get()) + ) + : FloatingContentContext.set(new FloatingContentState(opts, FloatingRootContext.get())); + } + readonly opts: FloatingContentStateOpts; + readonly root: FloatingRootState; + + contentRef = simpleBox(null); + wrapperRef = simpleBox(null); + arrowRef = simpleBox(null); + readonly contentAttachment = attachRef(this.contentRef); + readonly wrapperAttachment = attachRef(this.wrapperRef); + readonly arrowAttachment = attachRef(this.arrowRef); + + arrowId = simpleBox(useId()); + + #transformedStyle = $derived.by(() => { + if (typeof this.opts.style.current === "string") return cssToStyleObj(this.opts.style.current); + if (!this.opts.style.current) return {}; + return this.opts.style.current as Record; + }); + + #updatePositionStrategy = + undefined as unknown as FloatingContentStateOpts["updatePositionStrategy"]; + #arrowSize = new ElementSize(() => this.arrowRef.current ?? undefined); + #arrowWidth = $derived(this.#arrowSize?.width ?? 0); + #arrowHeight = $derived(this.#arrowSize?.height ?? 0); + #desiredPlacement = $derived.by( + () => + (this.opts.side.current + + (this.opts.align.current !== "center" + ? `-${this.opts.align.current}` + : "")) as Placement + ); + #boundary = $derived.by(() => + Array.isArray(this.opts.collisionBoundary.current) + ? this.opts.collisionBoundary.current + : [this.opts.collisionBoundary.current] + ); + hasExplicitBoundaries = $derived(this.#boundary.length > 0); + detectOverflowOptions = $derived.by(() => ({ + padding: this.opts.collisionPadding.current, + boundary: this.#boundary.filter(isNotNull), + altBoundary: this.hasExplicitBoundaries, + })); + #availableWidth = $state(undefined); + #availableHeight = $state(undefined); + #anchorWidth = $state(undefined); + #anchorHeight = $state(undefined); + middleware: Middleware[] = $derived.by( + () => + [ + offset({ + mainAxis: this.opts.sideOffset.current + this.#arrowHeight, + alignmentAxis: this.opts.alignOffset.current, + }), + this.opts.avoidCollisions.current && + shift({ + mainAxis: true, + crossAxis: false, + limiter: this.opts.sticky.current === "partial" ? limitShift() : undefined, + ...this.detectOverflowOptions, + }), + this.opts.avoidCollisions.current && flip({ ...this.detectOverflowOptions }), + size({ + ...this.detectOverflowOptions, + apply: ({ rects, availableWidth, availableHeight }) => { + const { width: anchorWidth, height: anchorHeight } = rects.reference; + this.#availableWidth = availableWidth; + this.#availableHeight = availableHeight; + this.#anchorWidth = anchorWidth; + this.#anchorHeight = anchorHeight; + }, + }), + this.arrowRef.current && + arrow({ + element: this.arrowRef.current, + padding: this.opts.arrowPadding.current, + }), + transformOrigin({ arrowWidth: this.#arrowWidth, arrowHeight: this.#arrowHeight }), + this.opts.hideWhenDetached.current && + hide({ strategy: "referenceHidden", ...this.detectOverflowOptions }), + ].filter(Boolean) as Middleware[] + ); + floating: UseFloatingReturn; + placedSide = $derived.by(() => getSideFromPlacement(this.floating.placement)); + placedAlign = $derived.by(() => getAlignFromPlacement(this.floating.placement)); + arrowX = $derived.by(() => this.floating.middlewareData.arrow?.x ?? 0); + arrowY = $derived.by(() => this.floating.middlewareData.arrow?.y ?? 0); + cannotCenterArrow = $derived.by(() => this.floating.middlewareData.arrow?.centerOffset !== 0); + contentZIndex = $state(); + arrowBaseSide = $derived(OPPOSITE_SIDE[this.placedSide]); + + wrapperProps = $derived.by( + () => + ({ + id: this.opts.wrapperId.current, + "data-acel-floating-content-wrapper": "", + style: styleToString({ + ...this.floating.floatingStyles, + transform: this.floating.isPositioned + ? this.floating.floatingStyles.transform + : "translate(0, -200%)", + minWidth: "max-content", + zIndex: this.contentZIndex, + "--acel-floating-transform-origin": `${this.floating.middlewareData.transformOrigin?.x} ${this.floating.middlewareData.transformOrigin?.y}`, + "--acel-floating-available-width": `${this.#availableWidth}px`, + "--acel-floating-available-height": `${this.#availableHeight}px`, + "--acel-floating-anchor-width": `${this.#anchorWidth}px`, + "--acel-floating-anchor-height": `${this.#anchorHeight}px`, + ...(this.floating.middlewareData.hide?.referenceHidden && { + visibility: "hidden", + "pointer-events": "none", + }), + ...this.#transformedStyle, + } as StyleProperties), + dir: this.opts.dir.current, + ...this.wrapperAttachment, + }) as const + ); + + props = $derived.by( + () => + ({ + "data-side": this.placedSide, + "data-align": this.placedAlign, + style: styleToString({ + ...this.#transformedStyle, + } as StyleProperties), + ...this.contentAttachment, + }) as const + ); + + arrowStyle = $derived( + styleToString({ + position: "absolute", + left: this.arrowX ? `${this.arrowX}px` : undefined, + top: this.arrowY ? `${this.arrowY}px` : undefined, + [this.arrowBaseSide]: "0", + "transform-origin": { + top: "", + right: "0 0", + bottom: "center 0", + left: "100% 0", + }[this.placedSide], + transform: { + top: "translateY(100%)", + right: "translateY(50%) rotate(90deg) translateX(-50%)", + bottom: "rotate(180deg)", + left: "translateY(50%) rotate(-90deg) translateX(50%)", + }[this.placedSide], + visibility: this.cannotCenterArrow ? "hidden" : undefined, + } as StyleProperties) + ); + + constructor(opts: FloatingContentStateOpts, root: FloatingRootState) { + this.opts = opts; + this.root = root; + this.#updatePositionStrategy = opts.updatePositionStrategy; + + // $effect runs on mount AND on every change (unlike watch which only runs on change) + $effect(() => { + this.root.customAnchorNode.current = opts.customAnchor.current; + }); + + this.floating = useFloating({ + strategy: () => this.opts.strategy.current, + placement: () => this.#desiredPlacement, + middleware: () => this.middleware, + reference: this.root.anchorNode, + whileElementsMounted: (...args) => { + const cleanup = autoUpdate(...args, { + animationFrame: this.#updatePositionStrategy?.current === "always", + }); + return cleanup; + }, + open: () => this.opts.enabled.current, + sideOffset: () => this.opts.sideOffset.current, + alignOffset: () => this.opts.alignOffset.current, + }); + + $effect(() => { + if (!this.floating.isPositioned) return; + this.opts.onPlaced?.current(); + }); + + watch( + () => this.contentRef.current, + (contentNode) => { + if (!contentNode || !this.opts.enabled.current) return; + const win = getWindow(contentNode); + const rafId = win.requestAnimationFrame(() => { + if (this.contentRef.current !== contentNode || !this.opts.enabled.current) return; + const zIndex = win.getComputedStyle(contentNode).zIndex; + if (zIndex !== this.contentZIndex) { + this.contentZIndex = zIndex; + } + }); + return () => { + win.cancelAnimationFrame(rafId); + }; + } + ); + + $effect(() => { + this.floating.floating.current = this.wrapperRef.current; + }); + } +} + +interface FloatingArrowStateOpts extends WithRefOpts {} + +export class FloatingArrowState { + static create(opts: FloatingArrowStateOpts) { + return new FloatingArrowState(opts, FloatingContentContext.get()); + } + readonly opts: FloatingArrowStateOpts; + readonly content: FloatingContentState; + + constructor(opts: FloatingArrowStateOpts, content: FloatingContentState) { + this.opts = opts; + this.content = content; + } + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + style: this.content.arrowStyle, + "data-side": this.content.placedSide, + ...this.content.arrowAttachment, + }) as const + ); +} + +interface FloatingAnchorStateOpts + extends ReadableBoxedValues<{ + id: string; + virtualEl?: Measurable | null; + ref: Measurable | HTMLElement | null; + }> {} + +export class FloatingAnchorState { + static create(opts: FloatingAnchorStateOpts, tooltip = false) { + return tooltip + ? new FloatingAnchorState(opts, FloatingTooltipRootContext.get()) + : new FloatingAnchorState(opts, FloatingRootContext.get()); + } + readonly opts: FloatingAnchorStateOpts; + readonly root: FloatingRootState; + + constructor(opts: FloatingAnchorStateOpts, root: FloatingRootState) { + this.opts = opts; + this.root = root; + + if (opts.virtualEl && opts.virtualEl.current) { + root.triggerNode = boxFrom(opts.virtualEl.current); + } else { + root.triggerNode = opts.ref as ReadableBox; + } + } +} + +// +// HELPERS +// + +function transformOrigin(options: { arrowWidth: number; arrowHeight: number }): Middleware { + return { + name: "transformOrigin", + options, + fn(data) { + const { placement, rects, middlewareData } = data; + + const cannotCenterArrow = middlewareData.arrow?.centerOffset !== 0; + const isArrowHidden = cannotCenterArrow; + const arrowWidth = isArrowHidden ? 0 : options.arrowWidth; + const arrowHeight = isArrowHidden ? 0 : options.arrowHeight; + + const [placedSide, placedAlign] = getSideAndAlignFromPlacement(placement); + const noArrowAlign = { start: "0%", center: "50%", end: "100%" }[placedAlign]; + + const arrowXCenter = (middlewareData.arrow?.x ?? 0) + arrowWidth / 2; + const arrowYCenter = (middlewareData.arrow?.y ?? 0) + arrowHeight / 2; + + let x = ""; + let y = ""; + + if (placedSide === "bottom") { + x = isArrowHidden ? noArrowAlign : `${arrowXCenter}px`; + y = `${-arrowHeight}px`; + } else if (placedSide === "top") { + x = isArrowHidden ? noArrowAlign : `${arrowXCenter}px`; + y = `${rects.floating.height + arrowHeight}px`; + } else if (placedSide === "right") { + x = `${-arrowHeight}px`; + y = isArrowHidden ? noArrowAlign : `${arrowYCenter}px`; + } else if (placedSide === "left") { + x = `${rects.floating.width + arrowHeight}px`; + y = isArrowHidden ? noArrowAlign : `${arrowYCenter}px`; + } + return { data: { x, y } }; + }, + }; +} + +function getSideAndAlignFromPlacement(placement: Placement) { + const [side, align = "center"] = placement.split("-"); + return [side as Side, align as Align] as const; +} + +export function getSideFromPlacement(placement: Placement) { + return getSideAndAlignFromPlacement(placement)[0]; +} + +export function getAlignFromPlacement(placement: Placement) { + return getSideAndAlignFromPlacement(placement)[1]; +} diff --git a/src/uix/acel/internal/floating-svelte/use-floating.svelte.ts b/src/uix/acel/utilities/floating/use-floating.svelte.ts similarity index 99% rename from src/uix/acel/internal/floating-svelte/use-floating.svelte.ts rename to src/uix/acel/utilities/floating/use-floating.svelte.ts index 2f79cdb4b..bd47bc193 100644 --- a/src/uix/acel/internal/floating-svelte/use-floating.svelte.ts +++ b/src/uix/acel/utilities/floating/use-floating.svelte.ts @@ -1,5 +1,5 @@ import { computePosition } from "@floating-ui/dom"; -import { simpleBox } from "../../utilities"; +import { simpleBox } from "../index"; import type { UseFloatingOptions, UseFloatingReturn } from "./types.ts"; import { get, getDPR, roundByDPR } from "./floating-utils.svelte.ts"; diff --git a/src/uix/acel/internal/roving-focus-group.ts b/src/uix/acel/utilities/focus/roving-focus-group.ts similarity index 91% rename from src/uix/acel/internal/roving-focus-group.ts rename to src/uix/acel/utilities/focus/roving-focus-group.ts index a74103880..a94c54d89 100644 --- a/src/uix/acel/internal/roving-focus-group.ts +++ b/src/uix/acel/utilities/focus/roving-focus-group.ts @@ -1,10 +1,10 @@ -import { type Box, type ReadableBox, box } from "../utilities"; -import { getElemDirection } from "./locale.ts"; -import { getDirectionalKeys } from "./get-directional-keys.ts"; -import { kbd } from "./kbd.ts"; -import type { Orientation } from "../shared/index.ts"; +import { type Box, type ReadableBox, box } from "../index"; +import { getElemDirection } from "../dom/locale.ts"; +import { getDirectionalKeys } from "../keyboard/get-directional-keys.ts"; +import { kbd } from "../keyboard/kbd.ts"; +import type { Orientation } from "../types.ts"; import { BROWSER } from "esm-env"; -import { isHTMLElement } from "./is.ts"; +import { isHTMLElement } from "../dom/is-browser.ts"; type RovingFocusGroupOptions = ( | { diff --git a/src/uix/acel/internal/should-enable-focus-trap.ts b/src/uix/acel/utilities/focus/should-enable-focus-trap.ts similarity index 100% rename from src/uix/acel/internal/should-enable-focus-trap.ts rename to src/uix/acel/utilities/focus/should-enable-focus-trap.ts diff --git a/src/uix/acel/internal/tabbable.ts b/src/uix/acel/utilities/focus/tabbable.ts similarity index 97% rename from src/uix/acel/internal/tabbable.ts rename to src/uix/acel/utilities/focus/tabbable.ts index 02641bda7..9a339acc3 100644 --- a/src/uix/acel/internal/tabbable.ts +++ b/src/uix/acel/utilities/focus/tabbable.ts @@ -1,5 +1,5 @@ import { focusable, isFocusable, isTabbable, tabbable } from "tabbable"; -import { getDocument } from "../utilities"; +import { getDocument } from "../index"; function getTabbableOptions() { return { diff --git a/src/uix/acel/internal/use-arrow-navigation.ts b/src/uix/acel/utilities/focus/use-arrow-navigation.ts similarity index 98% rename from src/uix/acel/internal/use-arrow-navigation.ts rename to src/uix/acel/utilities/focus/use-arrow-navigation.ts index 12bfb2d11..942f4abab 100644 --- a/src/uix/acel/internal/use-arrow-navigation.ts +++ b/src/uix/acel/utilities/focus/use-arrow-navigation.ts @@ -1,4 +1,4 @@ -import type { Direction } from "../shared/index.ts"; +import type { Direction } from "../types.ts"; type ArrowKeyOptions = "horizontal" | "vertical" | "both"; diff --git a/src/uix/acel/utilities/index.ts b/src/uix/acel/utilities/index.ts index 49ba50ecd..86eedcdcb 100644 --- a/src/uix/acel/utilities/index.ts +++ b/src/uix/acel/utilities/index.ts @@ -11,6 +11,7 @@ export { type ReadableBox } from "./box/box-extras.svelte.ts"; export { unbox } from "./box/unbox.svelte.ts"; +export { boxAutoReset } from "./box/box-auto-reset.svelte.ts"; export * from "./types.ts"; export { composeHandlers } from "./events/compose-handlers.ts"; export { cssToStyleObj } from "./css/css.ts"; @@ -28,7 +29,6 @@ export { afterTick } from "./after/after.ts"; export { useOnChange } from "./svelte/use-on-change.svelte.ts"; export { styleToCSS } from "./css/css.ts"; export { - isHTMLElement, isDocument, isWindow, getNodeName, @@ -41,5 +41,90 @@ export { getActiveElement, getParentNode } from "./dom/dom.ts"; +// isHTMLElement is exported from dom/is-browser.ts below export { DOMContext } from "./dom/dom-context.svelte.ts"; export { attachRef } from "./svelte/attach-ref.ts"; + +// New exports from merged internal/ and shared/ + +// arrays +export * from "./arrays.ts"; + +// attrs +export * from "./attrs.ts"; + +// clamp +export { clamp } from "./clamp.ts"; + +// create-id +export { createId } from "./create-id.ts"; + +// debounce +export { debounce } from "./debounce.ts"; + +// math +export * from "./math.ts"; + +// noop +export { noop } from "./noop.ts"; + +// shared-state +export { SharedState } from "./shared-state.svelte.ts"; + +// state-machine +export { StateMachine } from "./state-machine.ts"; + +// timeout-fn +export { TimeoutFn } from "./timeout-fn.ts"; + +// use-id +export { useId } from "./use-id.ts"; + +// attributes (from shared/) +export * from "./attributes.ts"; + +// is.ts (existing) +export * from "./is.ts"; + +// css/css-escape +export { cssEscape } from "./css/css-escape.ts"; + +// dom/ +export * from "./dom/dom-internal.ts"; +export * from "./dom/elements.ts"; +export * from "./dom/focus.ts"; +// isFunction also lives in is.ts; export dom-specific ones from is-browser.ts +export { isBrowser, isIOS, isHTMLElement, isElement, isElementOrSVGElement, isNumberString, isNull, isTouch, isFocusVisible, isNotNull, isElementHidden, isSelectableInput } from "./dom/is-browser.ts"; +export { getElemDirection } from "./dom/locale.ts"; +export { BodyScrollLock, type ScrollBodyOption } from "./dom/body-scroll-lock.svelte.ts"; +export { SvelteResizeObserver } from "./dom/svelte-resize-observer.svelte.ts"; + +// events/ +export { CustomEventDispatcher } from "./events/event-dispatcher.ts"; + +// floating/ +export * from "./floating/floating-utils.svelte.ts"; +export * from "./floating/use-floating-layer.svelte.ts"; +export { useFloating } from "./floating/use-floating.svelte.ts"; +export type { UseFloatingOptions, UseFloatingReturn, Measurable } from "./floating/types.ts"; +export { GraceArea } from "./floating/grace-area.svelte.ts"; +export { SafePolygon } from "./floating/safe-polygon.svelte.ts"; + +// focus/ +export { RovingFocusGroup } from "./focus/roving-focus-group.ts"; +export { shouldEnableFocusTrap } from "./focus/should-enable-focus-trap.ts"; +export * from "./focus/tabbable.ts"; +export { useArrowNavigation } from "./focus/use-arrow-navigation.ts"; + +// keyboard/ +export { kbd } from "./keyboard/kbd.ts"; +export * from "./keyboard/get-directional-keys.ts"; +export { IsUsingKeyboard } from "./keyboard/is-using-keyboard.svelte.ts"; + +// presence/ +export { AnimationsComplete } from "./presence/animations-complete.ts"; +export { PresenceManager } from "./presence/presence-manager.svelte.ts"; + +// typeahead/ +export { DataTypeahead } from "./typeahead/data-typeahead.svelte.ts"; +export { DOMTypeahead } from "./typeahead/dom-typeahead.svelte.ts"; diff --git a/src/uix/acel/internal/get-directional-keys.ts b/src/uix/acel/utilities/keyboard/get-directional-keys.ts similarity index 95% rename from src/uix/acel/internal/get-directional-keys.ts rename to src/uix/acel/utilities/keyboard/get-directional-keys.ts index 0f0933252..34181aaad 100644 --- a/src/uix/acel/internal/get-directional-keys.ts +++ b/src/uix/acel/utilities/keyboard/get-directional-keys.ts @@ -1,5 +1,5 @@ import { kbd } from "./kbd.ts"; -import type { Direction, Orientation } from "../shared/index.ts"; +import type { Direction, Orientation } from "../types.ts"; export const FIRST_KEYS = [kbd.ARROW_DOWN, kbd.PAGE_UP, kbd.HOME]; export const LAST_KEYS = [kbd.ARROW_UP, kbd.PAGE_DOWN, kbd.END]; diff --git a/src/uix/acel/utilities/keyboard/is-using-keyboard.svelte.ts b/src/uix/acel/utilities/keyboard/is-using-keyboard.svelte.ts new file mode 100644 index 000000000..092a18180 --- /dev/null +++ b/src/uix/acel/utilities/keyboard/is-using-keyboard.svelte.ts @@ -0,0 +1,50 @@ +import { executeCallbacks } from "../index"; +import { on } from "svelte/events"; +import type { AnyFn } from "../types"; + +let isUsingKeyboard = $state(false); + +/** + * Detecta si el usuario está usando el teclado o el ratón. + * Usa estado global compartido para evitar múltiples listeners. + */ +export class IsUsingKeyboard { + static _refs = 0; + static _cleanup?: AnyFn; + + constructor() { + $effect(() => { + if (IsUsingKeyboard._refs === 0) { + IsUsingKeyboard._cleanup = $effect.root(() => { + const toDispose: AnyFn[] = []; + + toDispose.push( + on(document, "pointerdown", () => { isUsingKeyboard = false; }, { capture: true }), + on(document, "pointermove", () => { isUsingKeyboard = false; }, { capture: true }), + on(document, "keydown", () => { isUsingKeyboard = true; }, { capture: true }) + ); + + return executeCallbacks(...toDispose); + }); + } + + IsUsingKeyboard._refs++; + + return () => { + IsUsingKeyboard._refs--; + if (IsUsingKeyboard._refs === 0) { + isUsingKeyboard = false; + IsUsingKeyboard._cleanup?.(); + } + }; + }); + } + + get current() { + return isUsingKeyboard; + } + + set current(value: boolean) { + isUsingKeyboard = value; + } +} diff --git a/src/uix/acel/internal/kbd-constants.ts b/src/uix/acel/utilities/keyboard/kbd-constants.ts similarity index 100% rename from src/uix/acel/internal/kbd-constants.ts rename to src/uix/acel/utilities/keyboard/kbd-constants.ts diff --git a/src/uix/acel/internal/kbd.ts b/src/uix/acel/utilities/keyboard/kbd.ts similarity index 100% rename from src/uix/acel/internal/kbd.ts rename to src/uix/acel/utilities/keyboard/kbd.ts diff --git a/src/uix/acel/internal/math.ts b/src/uix/acel/utilities/math.ts similarity index 100% rename from src/uix/acel/internal/math.ts rename to src/uix/acel/utilities/math.ts diff --git a/src/uix/acel/internal/noop.ts b/src/uix/acel/utilities/noop.ts similarity index 100% rename from src/uix/acel/internal/noop.ts rename to src/uix/acel/utilities/noop.ts diff --git a/src/uix/acel/internal/animations-complete.ts b/src/uix/acel/utilities/presence/animations-complete.ts similarity index 98% rename from src/uix/acel/internal/animations-complete.ts rename to src/uix/acel/utilities/presence/animations-complete.ts index 293825573..c04ca763d 100644 --- a/src/uix/acel/internal/animations-complete.ts +++ b/src/uix/acel/utilities/presence/animations-complete.ts @@ -1,4 +1,4 @@ -import { afterTick, onDestroyEffect, type ReadableBoxedValues } from "../utilities"; +import { afterTick, onDestroyEffect, type ReadableBoxedValues } from "../index"; interface AnimationsCompleteOpts extends ReadableBoxedValues<{ diff --git a/src/uix/acel/internal/presence-manager.svelte.ts b/src/uix/acel/utilities/presence/presence-manager.svelte.ts similarity index 95% rename from src/uix/acel/internal/presence-manager.svelte.ts rename to src/uix/acel/utilities/presence/presence-manager.svelte.ts index 6070d5e91..0ad6aa6c9 100644 --- a/src/uix/acel/internal/presence-manager.svelte.ts +++ b/src/uix/acel/utilities/presence/presence-manager.svelte.ts @@ -1,5 +1,5 @@ import { watch } from "runed"; -import type { ReadableBoxedValues } from "../utilities"; +import type { ReadableBoxedValues } from "../index"; import { AnimationsComplete } from "./animations-complete.ts"; interface PresenceManagerOpts diff --git a/src/uix/acel/internal/shared-state.svelte.ts b/src/uix/acel/utilities/shared-state.svelte.ts similarity index 100% rename from src/uix/acel/internal/shared-state.svelte.ts rename to src/uix/acel/utilities/shared-state.svelte.ts diff --git a/src/uix/acel/internal/state-machine.ts b/src/uix/acel/utilities/state-machine.ts similarity index 94% rename from src/uix/acel/internal/state-machine.ts rename to src/uix/acel/utilities/state-machine.ts index 432baa52d..1ec5c0019 100644 --- a/src/uix/acel/internal/state-machine.ts +++ b/src/uix/acel/utilities/state-machine.ts @@ -1,4 +1,4 @@ -import { simpleBox, type WritableBox } from "../utilities"; +import { simpleBox, type WritableBox } from "./index"; interface Machine { [k: string]: { [k: string]: S }; diff --git a/src/uix/acel/internal/timeout-fn.ts b/src/uix/acel/utilities/timeout-fn.ts similarity index 93% rename from src/uix/acel/internal/timeout-fn.ts rename to src/uix/acel/utilities/timeout-fn.ts index 51b6771b3..8a86ee1e2 100644 --- a/src/uix/acel/internal/timeout-fn.ts +++ b/src/uix/acel/utilities/timeout-fn.ts @@ -1,4 +1,4 @@ -import { onDestroyEffect } from "../utilities"; +import { onDestroyEffect } from "./index"; import type { AnyFn } from "./types.ts"; export class TimeoutFn { diff --git a/src/uix/acel/internal/data-typeahead.svelte.ts b/src/uix/acel/utilities/typeahead/data-typeahead.svelte.ts similarity index 89% rename from src/uix/acel/internal/data-typeahead.svelte.ts rename to src/uix/acel/utilities/typeahead/data-typeahead.svelte.ts index 703d2270f..2ae3dcb4d 100644 --- a/src/uix/acel/internal/data-typeahead.svelte.ts +++ b/src/uix/acel/utilities/typeahead/data-typeahead.svelte.ts @@ -1,6 +1,6 @@ -import type { Getter, WritableBox } from "../utilities"; -import { getNextMatch } from "./arrays.ts"; -import { boxAutoReset } from "./box-auto-reset.svelte.ts"; +import type { Getter, WritableBox } from "../index"; +import { getNextMatch } from "../arrays.ts"; +import { boxAutoReset } from "../box/box-auto-reset.svelte.ts"; interface DataTypeaheadOpts { onMatch: (value: string) => void; diff --git a/src/uix/acel/internal/dom-typeahead.svelte.ts b/src/uix/acel/utilities/typeahead/dom-typeahead.svelte.ts similarity index 91% rename from src/uix/acel/internal/dom-typeahead.svelte.ts rename to src/uix/acel/utilities/typeahead/dom-typeahead.svelte.ts index 52939f6bc..df4780a79 100644 --- a/src/uix/acel/internal/dom-typeahead.svelte.ts +++ b/src/uix/acel/utilities/typeahead/dom-typeahead.svelte.ts @@ -1,6 +1,6 @@ -import type { WritableBox } from "../utilities"; -import { getNextMatch } from "./arrays.ts"; -import { boxAutoReset } from "./box-auto-reset.svelte.ts"; +import type { WritableBox } from "../index"; +import { getNextMatch } from "../arrays.ts"; +import { boxAutoReset } from "../box/box-auto-reset.svelte.ts"; type DOMTypeaheadOptions = { onMatch?: (item: HTMLElement) => void; diff --git a/src/uix/acel/utilities/types.ts b/src/uix/acel/utilities/types.ts index f18fd70b0..51c90ae30 100644 --- a/src/uix/acel/utilities/types.ts +++ b/src/uix/acel/utilities/types.ts @@ -1,7 +1,9 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +// oxlint-disable no-explicit-any import type { Snippet } from "svelte"; import type * as CSS from "csstype"; import type { ReadableBox, WritableBox } from "./box/box-extras.svelte.ts"; +import type { attachRef } from "./svelte/attach-ref.ts"; export type FunctionArgs = (...args: Args) => Return; @@ -42,15 +44,47 @@ export type WithChild< SnippetProps extends Record = { _default: never }, /** * The underlying DOM element being rendered. You can bind to this prop to - * programatically interact with the element. + * programmatically interact with the element. */ - Ref = HTMLElement + Ref = HTMLElement, > = Omit & { - child?: SnippetProps extends { _default: never } + child?: [keyof SnippetProps] extends [never] ? Snippet<[{ props: Record }]> - : Snippet<[SnippetProps & { props: Record }]>; - children?: SnippetProps extends { _default: never } ? Snippet : Snippet<[SnippetProps]>; - style?: string | null | undefined; + : SnippetProps extends { _default: never } + ? Snippet<[{ props: Record }]> + : Snippet<[SnippetProps & { props: Record }]>; + children?: [keyof SnippetProps] extends [never] + ? Snippet + : SnippetProps extends { _default: never } + ? Snippet + : Snippet<[SnippetProps]>; + style?: StyleProperties | string | null | undefined; + ref?: Ref | null | undefined; +}; + +export type WithChildNoChildrenSnippetProps< + /** + * The props that the component accepts. + */ + Props extends Record = {}, + /** + * The props that are passed to the `child` and `children` snippets. The `ElementProps` are + * merged with these props for the `child` snippet. + */ + SnippetProps extends Record = { _default: never }, + /** + * The underlying DOM element being rendered. You can bind to this prop to + * programmatically interact with the element. + */ + Ref = HTMLElement, +> = Omit & { + child?: [keyof SnippetProps] extends [never] + ? Snippet<[{ props: Record }]> + : SnippetProps extends { _default: never } + ? Snippet<[{ props: Record }]> + : Snippet<[SnippetProps & { props: Record }]>; + children?: Snippet; + style?: StyleProperties | string | null | undefined; ref?: Ref | null | undefined; }; @@ -70,10 +104,20 @@ export type WithChildren = Props & { */ export type Without = Omit; +export type Arrayable = T[] | T; + +export type Fn = () => void; +export type AnyFn = (...args: any[]) => any; + export type WithRefProps = T & ReadableBoxedValues<{ id: string }> & WritableBoxedValues<{ ref: HTMLElement | null }>; +/** @alias WithRefProps */ +export type WithRefOpts = T & + ReadableBoxedValues<{ id: string }> & + WritableBoxedValues<{ ref: HTMLElement | null }>; + export type WithoutChild = T extends { child?: any } ? Omit : T; export type WithoutChildrenOrChild = WithoutChildren>; export type WithoutChildren = T extends { children?: any } ? Omit : T; @@ -84,4 +128,60 @@ export type StyleProperties = CSS.Properties & { [str: `--${string}`]: any; }; -export type AnyFn = (...args: any[]) => any; +export type OnChangeFn = (value: T) => void; + +export type ElementRef = Box; + +export type AcelEvent = T & { + currentTarget: U; +}; + +export type AcelPointerEvent = AcelEvent; +export type AcelKeyboardEvent = AcelEvent; +export type AcelMouseEvent = AcelEvent; +export type AcelFocusEvent = AcelEvent; +export type AcelInputEvent = AcelEvent; + +export type RefAttachment = ReturnType>; + +// From shared/types.ts +export type FloatingContentSnippetProps = { + /** + * Whether the content is open or closed. Used alongside the `forceMount` prop to + * conditionally render the content using Svelte transitions. + */ + open: boolean; + + /** + * Attributes to spread onto a wrapper element around the content. + * Do not style the wrapper element, its styles are computed by Floating UI. + */ + wrapperProps: Record; +}; + +export type StaticContentSnippetProps = Omit; + +// Shared types: Orientation, Direction, misc +export type Orientation = "horizontal" | "vertical"; +export type Direction = "ltr" | "rtl"; + +export type SliderThumbPositioning = "exact" | "contain"; + +export type FocusTarget = string | HTMLElement | SVGElement | null; +export type FocusProp = FocusTarget | ((defaultEl?: HTMLElement | null) => FocusTarget); + +export type Selected = { + value: Value; + label?: string; +}; + +export type SegmentPart = + | "month" + | "day" + | "year" + | "hour" + | "minute" + | "second" + | "dayPeriod" + | "timeZoneName" + | "literal"; diff --git a/src/uix/acel/internal/use-id.ts b/src/uix/acel/utilities/use-id.ts similarity index 100% rename from src/uix/acel/internal/use-id.ts rename to src/uix/acel/utilities/use-id.ts diff --git a/src/uix/acel/internal/warn.ts b/src/uix/acel/utilities/warn.ts similarity index 100% rename from src/uix/acel/internal/warn.ts rename to src/uix/acel/utilities/warn.ts