@allxsmith/bestax-bulma 5.27.0 → 5.27.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bestax.css +1 -1
- package/dist/bestax.css.map +1 -1
- package/dist/esm/columns/Column.js +48 -0
- package/dist/esm/columns/Column.js.map +1 -0
- package/dist/esm/columns/Columns.js +53 -0
- package/dist/esm/columns/Columns.js.map +1 -0
- package/dist/esm/components/Avatar.js +299 -0
- package/dist/esm/components/Avatar.js.map +1 -0
- package/dist/esm/components/Avatars.js +81 -0
- package/dist/esm/components/Avatars.js.map +1 -0
- package/dist/esm/components/Badge.js +105 -0
- package/dist/esm/components/Badge.js.map +1 -0
- package/dist/esm/components/Breadcrumb.js +36 -0
- package/dist/esm/components/Breadcrumb.js.map +1 -0
- package/dist/esm/components/Card.js +252 -0
- package/dist/esm/components/Card.js.map +1 -0
- package/dist/esm/components/Carousel.js +351 -0
- package/dist/esm/components/Carousel.js.map +1 -0
- package/dist/esm/components/Collapse.js +193 -0
- package/dist/esm/components/Collapse.js.map +1 -0
- package/dist/esm/components/Collapses.js +99 -0
- package/dist/esm/components/Collapses.js.map +1 -0
- package/dist/esm/components/Dialog.js +230 -0
- package/dist/esm/components/Dialog.js.map +1 -0
- package/dist/esm/components/Dropdown.js +362 -0
- package/dist/esm/components/Dropdown.js.map +1 -0
- package/dist/esm/components/Loading.js +87 -0
- package/dist/esm/components/Loading.js.map +1 -0
- package/dist/esm/components/Menu.js +117 -0
- package/dist/esm/components/Menu.js.map +1 -0
- package/dist/esm/components/Message.js +56 -0
- package/dist/esm/components/Message.js.map +1 -0
- package/dist/esm/components/Modal.js +354 -0
- package/dist/esm/components/Modal.js.map +1 -0
- package/dist/esm/components/Navbar.js +287 -0
- package/dist/esm/components/Navbar.js.map +1 -0
- package/dist/esm/components/Pagination.js +191 -0
- package/dist/esm/components/Pagination.js.map +1 -0
- package/dist/esm/components/Panel.js +100 -0
- package/dist/esm/components/Panel.js.map +1 -0
- package/dist/esm/components/Popover.js +298 -0
- package/dist/esm/components/Popover.js.map +1 -0
- package/dist/esm/components/Reveal.js +157 -0
- package/dist/esm/components/Reveal.js.map +1 -0
- package/dist/esm/components/Sidebar.js +209 -0
- package/dist/esm/components/Sidebar.js.map +1 -0
- package/dist/esm/components/Steps.js +128 -0
- package/dist/esm/components/Steps.js.map +1 -0
- package/dist/esm/components/Tabs.js +335 -0
- package/dist/esm/components/Tabs.js.map +1 -0
- package/dist/esm/components/Toast.js +322 -0
- package/dist/esm/components/Toast.js.map +1 -0
- package/dist/esm/components/Tooltip.js +202 -0
- package/dist/esm/components/Tooltip.js.map +1 -0
- package/dist/esm/components/collapsesContext.js +12 -0
- package/dist/esm/components/collapsesContext.js.map +1 -0
- package/dist/esm/elements/Block.js +29 -0
- package/dist/esm/elements/Block.js.map +1 -0
- package/dist/esm/elements/Box.js +32 -0
- package/dist/esm/elements/Box.js.map +1 -0
- package/dist/esm/elements/Button.js +139 -0
- package/dist/esm/elements/Button.js.map +1 -0
- package/dist/esm/elements/Buttons.js +42 -0
- package/dist/esm/elements/Buttons.js.map +1 -0
- package/dist/esm/elements/Code.js +25 -0
- package/dist/esm/elements/Code.js.map +1 -0
- package/dist/esm/elements/Content.js +33 -0
- package/dist/esm/elements/Content.js.map +1 -0
- package/dist/esm/elements/Delete.js +35 -0
- package/dist/esm/elements/Delete.js.map +1 -0
- package/dist/esm/elements/Divider.js +27 -0
- package/dist/esm/elements/Divider.js.map +1 -0
- package/dist/esm/elements/Emphasis.js +25 -0
- package/dist/esm/elements/Emphasis.js.map +1 -0
- package/dist/esm/elements/Figure.js +58 -0
- package/dist/esm/elements/Figure.js.map +1 -0
- package/dist/esm/elements/Icon.js +256 -0
- package/dist/esm/elements/Icon.js.map +1 -0
- package/dist/esm/elements/IconText.js +36 -0
- package/dist/esm/elements/IconText.js.map +1 -0
- package/dist/esm/elements/Image.js +46 -0
- package/dist/esm/elements/Image.js.map +1 -0
- package/dist/esm/elements/Link.js +36 -0
- package/dist/esm/elements/Link.js.map +1 -0
- package/dist/esm/elements/LinkButton.js +37 -0
- package/dist/esm/elements/LinkButton.js.map +1 -0
- package/dist/esm/elements/ListItem.js +28 -0
- package/dist/esm/elements/ListItem.js.map +1 -0
- package/dist/esm/elements/Loader.js +37 -0
- package/dist/esm/elements/Loader.js.map +1 -0
- package/dist/esm/elements/Notification.js +316 -0
- package/dist/esm/elements/Notification.js.map +1 -0
- package/dist/esm/elements/OrderedList.js +31 -0
- package/dist/esm/elements/OrderedList.js.map +1 -0
- package/dist/esm/elements/Paragraph.js +28 -0
- package/dist/esm/elements/Paragraph.js.map +1 -0
- package/dist/esm/elements/Pre.js +25 -0
- package/dist/esm/elements/Pre.js.map +1 -0
- package/dist/esm/elements/Progress.js +33 -0
- package/dist/esm/elements/Progress.js.map +1 -0
- package/dist/esm/elements/Skeleton.js +30 -0
- package/dist/esm/elements/Skeleton.js.map +1 -0
- package/dist/esm/elements/Span.js +28 -0
- package/dist/esm/elements/Span.js.map +1 -0
- package/dist/esm/elements/Strong.js +25 -0
- package/dist/esm/elements/Strong.js.map +1 -0
- package/dist/esm/elements/SubTitle.js +51 -0
- package/dist/esm/elements/SubTitle.js.map +1 -0
- package/dist/esm/elements/Table.js +51 -0
- package/dist/esm/elements/Table.js.map +1 -0
- package/dist/esm/elements/Tag.js +49 -0
- package/dist/esm/elements/Tag.js.map +1 -0
- package/dist/esm/elements/Tags.js +34 -0
- package/dist/esm/elements/Tags.js.map +1 -0
- package/dist/esm/elements/Tbody.js +26 -0
- package/dist/esm/elements/Tbody.js.map +1 -0
- package/dist/esm/elements/Td.js +61 -0
- package/dist/esm/elements/Td.js.map +1 -0
- package/dist/esm/elements/Tfoot.js +26 -0
- package/dist/esm/elements/Tfoot.js.map +1 -0
- package/dist/esm/elements/Th.js +34 -0
- package/dist/esm/elements/Th.js.map +1 -0
- package/dist/esm/elements/Thead.js +26 -0
- package/dist/esm/elements/Thead.js.map +1 -0
- package/dist/esm/elements/Title.js +44 -0
- package/dist/esm/elements/Title.js.map +1 -0
- package/dist/esm/elements/Tr.js +31 -0
- package/dist/esm/elements/Tr.js.map +1 -0
- package/dist/esm/elements/UnorderedList.js +31 -0
- package/dist/esm/elements/UnorderedList.js.map +1 -0
- package/dist/esm/elements/iconProps.js +49 -0
- package/dist/esm/elements/iconProps.js.map +1 -0
- package/dist/esm/form/Autocomplete.js +279 -0
- package/dist/esm/form/Autocomplete.js.map +1 -0
- package/dist/esm/form/Checkbox.js +111 -0
- package/dist/esm/form/Checkbox.js.map +1 -0
- package/dist/esm/form/Checkboxes.js +90 -0
- package/dist/esm/form/Checkboxes.js.map +1 -0
- package/dist/esm/form/Control.js +79 -0
- package/dist/esm/form/Control.js.map +1 -0
- package/dist/esm/form/DateInput.js +88 -0
- package/dist/esm/form/DateInput.js.map +1 -0
- package/dist/esm/form/DateInputBase.js +263 -0
- package/dist/esm/form/DateInputBase.js.map +1 -0
- package/dist/esm/form/DateRangeInput.js +77 -0
- package/dist/esm/form/DateRangeInput.js.map +1 -0
- package/dist/esm/form/DateRangeInputBase.js +301 -0
- package/dist/esm/form/DateRangeInputBase.js.map +1 -0
- package/dist/esm/form/DateTimeInput.js +88 -0
- package/dist/esm/form/DateTimeInput.js.map +1 -0
- package/dist/esm/form/DateTimeInputBase.js +333 -0
- package/dist/esm/form/DateTimeInputBase.js.map +1 -0
- package/dist/esm/form/Field.js +167 -0
- package/dist/esm/form/Field.js.map +1 -0
- package/dist/esm/form/File.js +112 -0
- package/dist/esm/form/File.js.map +1 -0
- package/dist/esm/form/FormContext.js +194 -0
- package/dist/esm/form/FormContext.js.map +1 -0
- package/dist/esm/form/Input.js +88 -0
- package/dist/esm/form/Input.js.map +1 -0
- package/dist/esm/form/InputBase.js +40 -0
- package/dist/esm/form/InputBase.js.map +1 -0
- package/dist/esm/form/Numberinput.js +201 -0
- package/dist/esm/form/Numberinput.js.map +1 -0
- package/dist/esm/form/Radio.js +108 -0
- package/dist/esm/form/Radio.js.map +1 -0
- package/dist/esm/form/Radios.js +92 -0
- package/dist/esm/form/Radios.js.map +1 -0
- package/dist/esm/form/Rate.js +280 -0
- package/dist/esm/form/Rate.js.map +1 -0
- package/dist/esm/form/Select.js +81 -0
- package/dist/esm/form/Select.js.map +1 -0
- package/dist/esm/form/SelectBase.js +54 -0
- package/dist/esm/form/SelectBase.js.map +1 -0
- package/dist/esm/form/Slider.js +461 -0
- package/dist/esm/form/Slider.js.map +1 -0
- package/dist/esm/form/Switch.js +96 -0
- package/dist/esm/form/Switch.js.map +1 -0
- package/dist/esm/form/Taginput.js +351 -0
- package/dist/esm/form/Taginput.js.map +1 -0
- package/dist/esm/form/TextArea.js +77 -0
- package/dist/esm/form/TextArea.js.map +1 -0
- package/dist/esm/form/TextAreaBase.js +45 -0
- package/dist/esm/form/TextAreaBase.js.map +1 -0
- package/dist/esm/form/TimeInput.js +84 -0
- package/dist/esm/form/TimeInput.js.map +1 -0
- package/dist/esm/form/TimeInputBase.js +246 -0
- package/dist/esm/form/TimeInputBase.js.map +1 -0
- package/dist/esm/form/_pickerInternals/Calendar.js +760 -0
- package/dist/esm/form/_pickerInternals/Calendar.js.map +1 -0
- package/dist/esm/form/_pickerInternals/PickerPopover.js +95 -0
- package/dist/esm/form/_pickerInternals/PickerPopover.js.map +1 -0
- package/dist/esm/form/_pickerInternals/TimeWheels.js +721 -0
- package/dist/esm/form/_pickerInternals/TimeWheels.js.map +1 -0
- package/dist/esm/form/_pickerInternals/audioTick.js +109 -0
- package/dist/esm/form/_pickerInternals/audioTick.js.map +1 -0
- package/dist/esm/form/_pickerInternals/dateUtils.js +267 -0
- package/dist/esm/form/_pickerInternals/dateUtils.js.map +1 -0
- package/dist/esm/form/_pickerInternals/formatters.js +322 -0
- package/dist/esm/form/_pickerInternals/formatters.js.map +1 -0
- package/dist/esm/form/_pickerInternals/haptics.js +38 -0
- package/dist/esm/form/_pickerInternals/haptics.js.map +1 -0
- package/dist/esm/form/_pickerInternals/nativeInputSupport.js +25 -0
- package/dist/esm/form/_pickerInternals/nativeInputSupport.js.map +1 -0
- package/dist/esm/form/_pickerInternals/pickerTypes.js +50 -0
- package/dist/esm/form/_pickerInternals/pickerTypes.js.map +1 -0
- package/dist/esm/form/_pickerInternals/segmentMap.js +315 -0
- package/dist/esm/form/_pickerInternals/segmentMap.js.map +1 -0
- package/dist/esm/form/_pickerInternals/useNativeMobilePicker.js +49 -0
- package/dist/esm/form/_pickerInternals/useNativeMobilePicker.js.map +1 -0
- package/dist/esm/form/_pickerInternals/useSegmentedEntry.js +427 -0
- package/dist/esm/form/_pickerInternals/useSegmentedEntry.js.map +1 -0
- package/dist/esm/form/controlLoading.js +18 -0
- package/dist/esm/form/controlLoading.js.map +1 -0
- package/dist/esm/form/useAutoLabelId.js +88 -0
- package/dist/esm/form/useAutoLabelId.js.map +1 -0
- package/dist/esm/grid/Cell.js +35 -0
- package/dist/esm/grid/Cell.js.map +1 -0
- package/dist/esm/grid/Grid.js +51 -0
- package/dist/esm/grid/Grid.js.map +1 -0
- package/dist/esm/helpers/ClientOnly.js +33 -0
- package/dist/esm/helpers/ClientOnly.js.map +1 -0
- package/dist/esm/helpers/Config.js +60 -0
- package/dist/esm/helpers/Config.js.map +1 -0
- package/dist/esm/helpers/Theme.js +947 -0
- package/dist/esm/helpers/Theme.js.map +1 -0
- package/dist/esm/helpers/anchorAttrs.js +72 -0
- package/dist/esm/helpers/anchorAttrs.js.map +1 -0
- package/dist/esm/helpers/bulmaClassHelpers.js +415 -0
- package/dist/esm/helpers/bulmaClassHelpers.js.map +1 -0
- package/dist/esm/helpers/buttonType.js +24 -0
- package/dist/esm/helpers/buttonType.js.map +1 -0
- package/dist/esm/helpers/classNames.js +137 -0
- package/dist/esm/helpers/classNames.js.map +1 -0
- package/dist/esm/helpers/colorDeprecations.js +50 -0
- package/dist/esm/helpers/colorDeprecations.js.map +1 -0
- package/dist/esm/helpers/devWarnings.js +27 -0
- package/dist/esm/helpers/devWarnings.js.map +1 -0
- package/dist/esm/helpers/inertProps.js +27 -0
- package/dist/esm/helpers/inertProps.js.map +1 -0
- package/dist/esm/helpers/mergeBulmaStyles.js +20 -0
- package/dist/esm/helpers/mergeBulmaStyles.js.map +1 -0
- package/dist/esm/helpers/polymorphic.js +18 -0
- package/dist/esm/helpers/polymorphic.js.map +1 -0
- package/dist/esm/helpers/portal.js +76 -0
- package/dist/esm/helpers/portal.js.map +1 -0
- package/dist/esm/helpers/positionStacks.js +45 -0
- package/dist/esm/helpers/positionStacks.js.map +1 -0
- package/dist/esm/helpers/scrollLock.js +38 -0
- package/dist/esm/helpers/scrollLock.js.map +1 -0
- package/dist/esm/helpers/shadowDom.js +71 -0
- package/dist/esm/helpers/shadowDom.js.map +1 -0
- package/dist/esm/helpers/statusRegion.js +282 -0
- package/dist/esm/helpers/statusRegion.js.map +1 -0
- package/dist/esm/helpers/useAnchoredPosition.js +86 -0
- package/dist/esm/helpers/useAnchoredPosition.js.map +1 -0
- package/dist/esm/helpers/useBulmaClasses.js +158 -0
- package/dist/esm/helpers/useBulmaClasses.js.map +1 -0
- package/dist/esm/helpers/useClientLayoutEffect.js +13 -0
- package/dist/esm/helpers/useClientLayoutEffect.js.map +1 -0
- package/dist/esm/helpers/useColorClasses.js +84 -0
- package/dist/esm/helpers/useColorClasses.js.map +1 -0
- package/dist/esm/helpers/useFlexboxClasses.js +92 -0
- package/dist/esm/helpers/useFlexboxClasses.js.map +1 -0
- package/dist/esm/helpers/useFocusTrap.js +470 -0
- package/dist/esm/helpers/useFocusTrap.js.map +1 -0
- package/dist/esm/helpers/useIsHydrated.js +31 -0
- package/dist/esm/helpers/useIsHydrated.js.map +1 -0
- package/dist/esm/helpers/useOtherClasses.js +115 -0
- package/dist/esm/helpers/useOtherClasses.js.map +1 -0
- package/dist/esm/helpers/useSpacingClasses.js +82 -0
- package/dist/esm/helpers/useSpacingClasses.js.map +1 -0
- package/dist/esm/helpers/useTypographyClasses.js +93 -0
- package/dist/esm/helpers/useTypographyClasses.js.map +1 -0
- package/dist/esm/helpers/useVisibilityClasses.js +134 -0
- package/dist/esm/helpers/useVisibilityClasses.js.map +1 -0
- package/dist/esm/helpers/withSubComponents.js +24 -0
- package/dist/esm/helpers/withSubComponents.js.map +1 -0
- package/dist/esm/layout/Container.js +48 -0
- package/dist/esm/layout/Container.js.map +1 -0
- package/dist/esm/layout/Footer.js +33 -0
- package/dist/esm/layout/Footer.js.map +1 -0
- package/dist/esm/layout/Hero.js +90 -0
- package/dist/esm/layout/Hero.js.map +1 -0
- package/dist/esm/layout/Level.js +110 -0
- package/dist/esm/layout/Level.js.map +1 -0
- package/dist/esm/layout/Media.js +86 -0
- package/dist/esm/layout/Media.js.map +1 -0
- package/dist/esm/layout/Section.js +30 -0
- package/dist/esm/layout/Section.js.map +1 -0
- package/dist/extras.css +1 -1
- package/dist/extras.css.map +1 -1
- package/dist/index.cjs +187 -57
- package/dist/index.cjs.map +1 -1
- package/dist/index.esm.js +115 -17516
- package/dist/index.esm.js.map +1 -1
- package/dist/types/elements/Icon.d.ts +10 -7
- package/dist/types/form/Autocomplete.d.ts +1 -1
- package/dist/types/form/Checkboxes.d.ts +1 -1
- package/dist/types/form/DateInput.d.ts +1 -1
- package/dist/types/form/DateInputBase.d.ts +3 -4
- package/dist/types/form/DateRangeInput.d.ts +1 -1
- package/dist/types/form/DateTimeInput.d.ts +1 -1
- package/dist/types/form/DateTimeInputBase.d.ts +3 -4
- package/dist/types/form/Field.d.ts +2 -2
- package/dist/types/form/File.d.ts +4 -4
- package/dist/types/form/FormContext.d.ts +24 -2
- package/dist/types/form/Numberinput.d.ts +1 -1
- package/dist/types/form/Radios.d.ts +1 -1
- package/dist/types/form/Rate.d.ts +1 -1
- package/dist/types/form/Slider.d.ts +1 -1
- package/dist/types/form/Taginput.d.ts +1 -1
- package/dist/types/form/TimeInput.d.ts +1 -1
- package/dist/types/form/useAutoLabelId.d.ts +25 -4
- package/dist/types/helpers/Config.d.ts +3 -4
- package/dist/types/helpers/Theme.d.ts +44 -40
- package/dist/types-cjs/elements/Icon.d.ts +10 -7
- package/dist/types-cjs/form/Autocomplete.d.ts +1 -1
- package/dist/types-cjs/form/Checkboxes.d.ts +1 -1
- package/dist/types-cjs/form/DateInput.d.ts +1 -1
- package/dist/types-cjs/form/DateInputBase.d.ts +3 -4
- package/dist/types-cjs/form/DateRangeInput.d.ts +1 -1
- package/dist/types-cjs/form/DateTimeInput.d.ts +1 -1
- package/dist/types-cjs/form/DateTimeInputBase.d.ts +3 -4
- package/dist/types-cjs/form/Field.d.ts +2 -2
- package/dist/types-cjs/form/File.d.ts +4 -4
- package/dist/types-cjs/form/FormContext.d.ts +24 -2
- package/dist/types-cjs/form/Numberinput.d.ts +1 -1
- package/dist/types-cjs/form/Radios.d.ts +1 -1
- package/dist/types-cjs/form/Rate.d.ts +1 -1
- package/dist/types-cjs/form/Slider.d.ts +1 -1
- package/dist/types-cjs/form/Taginput.d.ts +1 -1
- package/dist/types-cjs/form/TimeInput.d.ts +1 -1
- package/dist/types-cjs/form/useAutoLabelId.d.ts +25 -4
- package/dist/types-cjs/helpers/Config.d.ts +3 -4
- package/dist/types-cjs/helpers/Theme.d.ts +44 -40
- package/dist/versions/bestax-no-dark-mode.css +1 -1
- package/dist/versions/bestax-no-dark-mode.css.map +1 -1
- package/dist/versions/bestax-no-helpers-prefixed.css +1 -1
- package/dist/versions/bestax-no-helpers-prefixed.css.map +1 -1
- package/dist/versions/bestax-no-helpers.css +1 -1
- package/dist/versions/bestax-no-helpers.css.map +1 -1
- package/dist/versions/bestax-prefixed.css +1 -1
- package/dist/versions/bestax-prefixed.css.map +1 -1
- package/package.json +1 -1
- package/src/scss/form/_dateinput.scss +37 -6
- package/src/scss/form/_datetimeinput.scss +7 -2
- package/src/scss/form/_picker-popover.scss +1 -4
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
/* @allxsmith/bestax-bulma — AI agents: see AGENTS.md in the package root, or https://bestax.io/llms.txt */
|
|
2
|
+
import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
|
|
3
|
+
import React, { useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
|
4
|
+
import { prefixedClassNames, usePrefixedClassNames, classNames } from '../../helpers/classNames.js';
|
|
5
|
+
import { useConfig } from '../../helpers/Config.js';
|
|
6
|
+
import { isPopoverToggleKey, mergeLabels } from './pickerTypes.js';
|
|
7
|
+
import { setTimeOfDay } from './dateUtils.js';
|
|
8
|
+
import { tickHaptic } from './haptics.js';
|
|
9
|
+
import { playAudioTick, unlockAudioTick } from './audioTick.js';
|
|
10
|
+
|
|
11
|
+
const wrap = (n, max) => ((n % max) + max) % max;
|
|
12
|
+
/**
|
|
13
|
+
* Walk forward (or backward) from `start` in `step` steps until `test` returns
|
|
14
|
+
* true or we've cycled all `max` candidates. Used to skip past unselectable
|
|
15
|
+
* values without freezing the wheel.
|
|
16
|
+
*/
|
|
17
|
+
function nextValid(start, step, max, test) {
|
|
18
|
+
let candidate = wrap(start, max);
|
|
19
|
+
for (let i = 0; i < max; i++) {
|
|
20
|
+
if (test(candidate))
|
|
21
|
+
return candidate;
|
|
22
|
+
candidate = wrap(candidate + step, max);
|
|
23
|
+
}
|
|
24
|
+
return start;
|
|
25
|
+
}
|
|
26
|
+
const pad2 = (n) => String(n).padStart(2, '0');
|
|
27
|
+
const WheelInner = ({ values, index, onChange, formatLabel, formatAriaText, ariaLabel, visibleCount, itemHeight, disabled, size, color, onFocusPrev, onFocusNext, onCommit, wrap = false, disabledFor, audioTick = false, }, ref) => {
|
|
28
|
+
const { classPrefix } = useConfig();
|
|
29
|
+
const N = values.length;
|
|
30
|
+
const half = Math.floor(visibleCount / 2);
|
|
31
|
+
const containerHeight = visibleCount * itemHeight;
|
|
32
|
+
// Buffer around the visible window so adjacent items animate in/out smoothly.
|
|
33
|
+
const BUFFER = half + 2;
|
|
34
|
+
// Continuous wheel position in units of items. Math.round(position) is the
|
|
35
|
+
// "active" virtual index; the fractional part is the drag/momentum offset
|
|
36
|
+
// within an item slot. Using a single float (rather than virtualIdx +
|
|
37
|
+
// dragOffset) keeps drag and tick-update in lockstep so items move with the
|
|
38
|
+
// finger without a 40px overshoot when crossing item boundaries.
|
|
39
|
+
const [position, setPosition] = useState(index);
|
|
40
|
+
const positionRef = useRef(index);
|
|
41
|
+
positionRef.current = position;
|
|
42
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
43
|
+
const rootRef = useRef(null);
|
|
44
|
+
// Ref to the stationary selection band — populated by the JSX below. The
|
|
45
|
+
// band briefly scales + brightens on each item tick so users perceive a
|
|
46
|
+
// body-felt "thunk" even on iOS where no haptic API exists.
|
|
47
|
+
const bandRef = useRef(null);
|
|
48
|
+
// Cached MediaQueryList for `prefers-reduced-motion`. We read this on
|
|
49
|
+
// every tick to decide whether to fire the band pulse; caching the MQL
|
|
50
|
+
// saves a `matchMedia` call per tick (which can fire at ~20Hz during a
|
|
51
|
+
// momentum flick).
|
|
52
|
+
const reducedMotionMqlRef = useRef(null);
|
|
53
|
+
if (reducedMotionMqlRef.current === null &&
|
|
54
|
+
typeof window !== 'undefined' &&
|
|
55
|
+
typeof window.matchMedia === 'function') {
|
|
56
|
+
reducedMotionMqlRef.current = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
57
|
+
}
|
|
58
|
+
// In-flight band pulse animation (Web Animations API). Tracked so we can
|
|
59
|
+
// cancel on unmount; the WAI's `composite: 'replace'` semantics handle
|
|
60
|
+
// re-trigger automatically without us needing to cancel between ticks.
|
|
61
|
+
const pulseAnimRef = useRef(null);
|
|
62
|
+
// True while the external `index` prop sync useEffect runs, so we don't
|
|
63
|
+
// fire a haptic blip when the parent updates the value programmatically.
|
|
64
|
+
const isSyncingRef = useRef(false);
|
|
65
|
+
// Tiny haptic/audio/visual blip per item tick. Haptic uses
|
|
66
|
+
// `navigator.vibrate` where available (Android Chrome / Firefox Android /
|
|
67
|
+
// Samsung Internet); iOS has no web-accessible haptic path as of May
|
|
68
|
+
// 2026, so the optional `audioTick` plays a low-frequency thunk as the
|
|
69
|
+
// closest UX substitute. The visual band-pulse fires on every tick on
|
|
70
|
+
// every platform (gated only by `prefers-reduced-motion`) so the
|
|
71
|
+
// selection band feels reactive — visual reinforcement of the tactile
|
|
72
|
+
// feedback. We skip all three during programmatic prop sync so a parent
|
|
73
|
+
// re-render doesn't blip.
|
|
74
|
+
const audioTickEnabledRef = useRef(audioTick);
|
|
75
|
+
audioTickEnabledRef.current = audioTick;
|
|
76
|
+
const playBandPulse = useCallback(() => {
|
|
77
|
+
const el = bandRef.current;
|
|
78
|
+
if (!el || typeof el.animate !== 'function')
|
|
79
|
+
return;
|
|
80
|
+
if (reducedMotionMqlRef.current?.matches)
|
|
81
|
+
return;
|
|
82
|
+
// Compound scale + brightness pulse, peaking at 40% through the 110ms
|
|
83
|
+
// duration. `composite: 'replace'` means a re-trigger mid-pulse cleanly
|
|
84
|
+
// restarts rather than queueing — important at fling rates where ticks
|
|
85
|
+
// can fire every ~50ms.
|
|
86
|
+
pulseAnimRef.current = el.animate([
|
|
87
|
+
{ transform: 'scale(1)', filter: 'brightness(1)' },
|
|
88
|
+
{
|
|
89
|
+
transform: 'scale(1.04)',
|
|
90
|
+
filter: 'brightness(1.15)',
|
|
91
|
+
offset: 0.4,
|
|
92
|
+
},
|
|
93
|
+
{ transform: 'scale(1)', filter: 'brightness(1)' },
|
|
94
|
+
], { duration: 110, easing: 'ease-out', composite: 'replace' });
|
|
95
|
+
}, []);
|
|
96
|
+
const tickFeedback = useCallback(() => {
|
|
97
|
+
/* istanbul ignore next: the sync flag is only true synchronously inside the prop-sync effect, which sets position directly and never routes through commitPosition */
|
|
98
|
+
if (isSyncingRef.current)
|
|
99
|
+
return;
|
|
100
|
+
tickHaptic();
|
|
101
|
+
if (audioTickEnabledRef.current)
|
|
102
|
+
playAudioTick();
|
|
103
|
+
playBandPulse();
|
|
104
|
+
}, [playBandPulse]);
|
|
105
|
+
const dragStateRef = useRef({
|
|
106
|
+
pointerId: null,
|
|
107
|
+
startY: 0,
|
|
108
|
+
startPosition: 0,
|
|
109
|
+
captured: false,
|
|
110
|
+
samples: [],
|
|
111
|
+
});
|
|
112
|
+
// RAF id for the in-flight momentum / snap animation, so we can cancel when
|
|
113
|
+
// the user grabs the wheel again or the component unmounts.
|
|
114
|
+
const rafIdRef = useRef(null);
|
|
115
|
+
const virtualIdx = Math.round(position);
|
|
116
|
+
const cancelRaf = useCallback(() => {
|
|
117
|
+
if (rafIdRef.current !== null) {
|
|
118
|
+
cancelAnimationFrame(rafIdRef.current);
|
|
119
|
+
rafIdRef.current = null;
|
|
120
|
+
}
|
|
121
|
+
}, []);
|
|
122
|
+
useEffect(() => () => {
|
|
123
|
+
cancelRaf();
|
|
124
|
+
// Cancel any in-flight band pulse so it doesn't keep a reference to
|
|
125
|
+
// a detached DOM node. WAI Animation.cancel() is safe to call even
|
|
126
|
+
// if the animation has already finished.
|
|
127
|
+
pulseAnimRef.current?.cancel();
|
|
128
|
+
pulseAnimRef.current = null;
|
|
129
|
+
}, [cancelRaf]);
|
|
130
|
+
React.useImperativeHandle(ref, () => ({
|
|
131
|
+
focus: () => rootRef.current?.focus(),
|
|
132
|
+
startDrag: (pointerId, clientY) => {
|
|
133
|
+
/* istanbul ignore next: the empty-space forwarder (sole caller) already returns on the same disabled prop */
|
|
134
|
+
if (disabled)
|
|
135
|
+
return;
|
|
136
|
+
cancelRaf();
|
|
137
|
+
if (audioTickEnabledRef.current)
|
|
138
|
+
unlockAudioTick();
|
|
139
|
+
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
140
|
+
dragStateRef.current = {
|
|
141
|
+
pointerId,
|
|
142
|
+
startY: clientY,
|
|
143
|
+
startPosition: positionRef.current,
|
|
144
|
+
// Skip the drag-threshold gate: the parent forwarder has already
|
|
145
|
+
// decided this is a drag (a pointerdown landed on empty space,
|
|
146
|
+
// which has no click target).
|
|
147
|
+
captured: true,
|
|
148
|
+
samples: [{ t: now, y: clientY }],
|
|
149
|
+
};
|
|
150
|
+
try {
|
|
151
|
+
rootRef.current?.setPointerCapture(pointerId);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// setPointerCapture can throw if the pointer isn't currently
|
|
155
|
+
// active for this element; the existing onPointerMove handler
|
|
156
|
+
// still matches on pointerId so the drag will work either way.
|
|
157
|
+
}
|
|
158
|
+
setIsDragging(true);
|
|
159
|
+
},
|
|
160
|
+
}), [disabled, cancelRaf]);
|
|
161
|
+
// Resolve a virtualIdx into an actual values[] index (modulo wrap if `wrap`).
|
|
162
|
+
const toActual = useCallback((v) => {
|
|
163
|
+
if (wrap)
|
|
164
|
+
return ((v % N) + N) % N;
|
|
165
|
+
return Math.max(0, Math.min(N - 1, v));
|
|
166
|
+
}, [wrap, N]);
|
|
167
|
+
const clampPosition = useCallback((p) => {
|
|
168
|
+
if (wrap)
|
|
169
|
+
return p;
|
|
170
|
+
return Math.max(0, Math.min(N - 1, p));
|
|
171
|
+
}, [wrap, N]);
|
|
172
|
+
// Update position and fire onChange if the rounded value changed.
|
|
173
|
+
const commitPosition = useCallback((next) => {
|
|
174
|
+
const clamped = clampPosition(next);
|
|
175
|
+
const prevRound = Math.round(positionRef.current);
|
|
176
|
+
const nextRound = Math.round(clamped);
|
|
177
|
+
positionRef.current = clamped;
|
|
178
|
+
setPosition(clamped);
|
|
179
|
+
if (nextRound !== prevRound) {
|
|
180
|
+
onChange(toActual(nextRound));
|
|
181
|
+
tickFeedback();
|
|
182
|
+
}
|
|
183
|
+
}, [clampPosition, onChange, toActual, tickFeedback]);
|
|
184
|
+
// When the external `index` changes (e.g. from typed input), re-sync
|
|
185
|
+
// position to the closest virtual position that maps to it.
|
|
186
|
+
//
|
|
187
|
+
// Crucial subtlety: this effect re-runs every time the parent value
|
|
188
|
+
// changes — including the round-trip case where the user's own scroll
|
|
189
|
+
// commits a tick → fires onChange → parent re-renders with the new
|
|
190
|
+
// index → here. If we cancelled the in-flight momentum/snap RAF on
|
|
191
|
+
// every such echo, the snap would never get to land on an integer
|
|
192
|
+
// (the user's wheel would stop wherever the last tick fired). So we
|
|
193
|
+
// bail out early when the rounded position already matches the
|
|
194
|
+
// incoming index — the echo case — and only cancel + apply an actual
|
|
195
|
+
// jump when the parent has moved us somewhere we don't already round
|
|
196
|
+
// to.
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
const prev = positionRef.current;
|
|
199
|
+
const wrappedRoundPrev = ((Math.round(prev) % N) + N) % N;
|
|
200
|
+
if (wrappedRoundPrev === index)
|
|
201
|
+
return;
|
|
202
|
+
cancelRaf();
|
|
203
|
+
isSyncingRef.current = true;
|
|
204
|
+
setPosition(prevPos => {
|
|
205
|
+
if (!wrap) {
|
|
206
|
+
positionRef.current = index;
|
|
207
|
+
return index;
|
|
208
|
+
}
|
|
209
|
+
const wrappedPrev = ((prevPos % N) + N) % N;
|
|
210
|
+
const forward = (index - wrappedPrev + N) % N;
|
|
211
|
+
const backward = forward - N;
|
|
212
|
+
const delta = Math.abs(forward) <= Math.abs(backward) ? forward : backward;
|
|
213
|
+
const next = prevPos + delta;
|
|
214
|
+
positionRef.current = next;
|
|
215
|
+
return next;
|
|
216
|
+
});
|
|
217
|
+
isSyncingRef.current = false;
|
|
218
|
+
}, [index, N, wrap, cancelRaf]);
|
|
219
|
+
const moveBy = useCallback((delta) => {
|
|
220
|
+
if (disabled)
|
|
221
|
+
return;
|
|
222
|
+
cancelRaf();
|
|
223
|
+
commitPosition(Math.round(positionRef.current) + delta);
|
|
224
|
+
}, [disabled, cancelRaf, commitPosition]);
|
|
225
|
+
// The keys are a spinbutton's: up raises the value and down lowers it. The
|
|
226
|
+
// values run down the wheel, so up brings the one below the band into it,
|
|
227
|
+
// the way a drag or a scroll that moves the items up does.
|
|
228
|
+
const onKeyDown = useCallback((e) => {
|
|
229
|
+
if (isPopoverToggleKey(e))
|
|
230
|
+
return;
|
|
231
|
+
switch (e.key) {
|
|
232
|
+
case 'ArrowUp':
|
|
233
|
+
e.preventDefault();
|
|
234
|
+
moveBy(1);
|
|
235
|
+
break;
|
|
236
|
+
case 'ArrowDown':
|
|
237
|
+
e.preventDefault();
|
|
238
|
+
moveBy(-1);
|
|
239
|
+
break;
|
|
240
|
+
case 'PageUp':
|
|
241
|
+
e.preventDefault();
|
|
242
|
+
moveBy(5);
|
|
243
|
+
break;
|
|
244
|
+
case 'PageDown':
|
|
245
|
+
e.preventDefault();
|
|
246
|
+
moveBy(-5);
|
|
247
|
+
break;
|
|
248
|
+
case 'Home':
|
|
249
|
+
e.preventDefault();
|
|
250
|
+
if (disabled)
|
|
251
|
+
break;
|
|
252
|
+
cancelRaf();
|
|
253
|
+
commitPosition(0);
|
|
254
|
+
break;
|
|
255
|
+
case 'End':
|
|
256
|
+
e.preventDefault();
|
|
257
|
+
if (disabled)
|
|
258
|
+
break;
|
|
259
|
+
cancelRaf();
|
|
260
|
+
commitPosition(N - 1);
|
|
261
|
+
break;
|
|
262
|
+
case 'ArrowLeft':
|
|
263
|
+
e.preventDefault();
|
|
264
|
+
onFocusPrev?.();
|
|
265
|
+
break;
|
|
266
|
+
case 'ArrowRight':
|
|
267
|
+
e.preventDefault();
|
|
268
|
+
onFocusNext?.();
|
|
269
|
+
break;
|
|
270
|
+
case 'Enter':
|
|
271
|
+
e.preventDefault();
|
|
272
|
+
onCommit?.();
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
}, [
|
|
276
|
+
moveBy,
|
|
277
|
+
disabled,
|
|
278
|
+
commitPosition,
|
|
279
|
+
cancelRaf,
|
|
280
|
+
N,
|
|
281
|
+
onFocusPrev,
|
|
282
|
+
onFocusNext,
|
|
283
|
+
onCommit,
|
|
284
|
+
]);
|
|
285
|
+
// Animate position to the nearest integer with ease-out cubic. Used after a
|
|
286
|
+
// drag / momentum settles, so the wheel always rests aligned to an item.
|
|
287
|
+
const animateSnap = useCallback(() => {
|
|
288
|
+
cancelRaf();
|
|
289
|
+
const start = positionRef.current;
|
|
290
|
+
const target = Math.round(start);
|
|
291
|
+
if (start === target)
|
|
292
|
+
return;
|
|
293
|
+
const duration = 180;
|
|
294
|
+
const startTime = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
295
|
+
const tick = (now) => {
|
|
296
|
+
const t = Math.min(1, (now - startTime) / duration);
|
|
297
|
+
const eased = 1 - Math.pow(1 - t, 3);
|
|
298
|
+
const next = start + (target - start) * eased;
|
|
299
|
+
commitPosition(next);
|
|
300
|
+
if (t < 1) {
|
|
301
|
+
rafIdRef.current = requestAnimationFrame(tick);
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
rafIdRef.current = null;
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
rafIdRef.current = requestAnimationFrame(tick);
|
|
308
|
+
}, [cancelRaf, commitPosition]);
|
|
309
|
+
// Kinetic momentum after a flick. Uses exponential friction so the velocity
|
|
310
|
+
// tapers smoothly. Updates `position` in items/ms; commitPosition handles
|
|
311
|
+
// the onChange ticks as virtualIdx crosses integers.
|
|
312
|
+
const startMomentum = useCallback((initialPxPerMs) => {
|
|
313
|
+
cancelRaf();
|
|
314
|
+
// Drag-up (negative dy/dt in screen coords) should INCREASE position
|
|
315
|
+
// (later values come into view), so flip the sign.
|
|
316
|
+
let velocity = -initialPxPerMs / itemHeight; // items / ms
|
|
317
|
+
// Cap initial velocity so a frantic swipe doesn't blast through the
|
|
318
|
+
// whole list. Empirically tuned to match iOS feel.
|
|
319
|
+
const MAX_VEL = 0.05; // items/ms (≈ 50 items/sec)
|
|
320
|
+
if (velocity > MAX_VEL)
|
|
321
|
+
velocity = MAX_VEL;
|
|
322
|
+
if (velocity < -MAX_VEL)
|
|
323
|
+
velocity = -MAX_VEL;
|
|
324
|
+
const STOP_VEL = 0.0015; // items/ms (≈ 1.5 items/sec)
|
|
325
|
+
// Friction coefficient per millisecond — chosen so velocity halves
|
|
326
|
+
// every ~120ms.
|
|
327
|
+
const FRICTION_PER_MS = Math.pow(0.5, 1 / 120);
|
|
328
|
+
let lastTime = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
329
|
+
const tick = (now) => {
|
|
330
|
+
const dt = Math.max(1, now - lastTime);
|
|
331
|
+
lastTime = now;
|
|
332
|
+
let next = positionRef.current + velocity * dt;
|
|
333
|
+
// Boundary handling for non-wrapping wheels: clamp and zero the
|
|
334
|
+
// velocity so we don't keep ticking against the wall.
|
|
335
|
+
if (!wrap) {
|
|
336
|
+
if (next <= 0) {
|
|
337
|
+
next = 0;
|
|
338
|
+
velocity = 0;
|
|
339
|
+
}
|
|
340
|
+
else if (next >= N - 1) {
|
|
341
|
+
next = N - 1;
|
|
342
|
+
velocity = 0;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
commitPosition(next);
|
|
346
|
+
velocity *= Math.pow(FRICTION_PER_MS, dt);
|
|
347
|
+
if (Math.abs(velocity) < STOP_VEL) {
|
|
348
|
+
animateSnap();
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
rafIdRef.current = requestAnimationFrame(tick);
|
|
352
|
+
};
|
|
353
|
+
rafIdRef.current = requestAnimationFrame(tick);
|
|
354
|
+
}, [cancelRaf, commitPosition, itemHeight, wrap, N, animateSnap]);
|
|
355
|
+
// Mouse wheel: accumulate deltaY across events and step when the
|
|
356
|
+
// accumulator crosses SCROLL_STEP_PX. Trackpads emit dozens of small
|
|
357
|
+
// events per swipe; without accumulation a single gesture flies the wheel.
|
|
358
|
+
useEffect(() => {
|
|
359
|
+
const el = rootRef.current;
|
|
360
|
+
if (!el || disabled)
|
|
361
|
+
return undefined;
|
|
362
|
+
const SCROLL_STEP_PX = 60;
|
|
363
|
+
const QUIET_MS = 250;
|
|
364
|
+
let accumulated = 0;
|
|
365
|
+
let resetTimer = null;
|
|
366
|
+
const resetAccumulator = () => {
|
|
367
|
+
accumulated = 0;
|
|
368
|
+
};
|
|
369
|
+
const onWheel = (e) => {
|
|
370
|
+
e.preventDefault();
|
|
371
|
+
// Reset accumulator if scroll direction reversed mid-gesture.
|
|
372
|
+
if (accumulated !== 0 && Math.sign(accumulated) !== Math.sign(e.deltaY)) {
|
|
373
|
+
accumulated = 0;
|
|
374
|
+
}
|
|
375
|
+
accumulated += e.deltaY;
|
|
376
|
+
while (Math.abs(accumulated) >= SCROLL_STEP_PX) {
|
|
377
|
+
if (accumulated > 0) {
|
|
378
|
+
moveBy(1);
|
|
379
|
+
accumulated -= SCROLL_STEP_PX;
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
moveBy(-1);
|
|
383
|
+
accumulated += SCROLL_STEP_PX;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
if (resetTimer)
|
|
387
|
+
clearTimeout(resetTimer);
|
|
388
|
+
resetTimer = setTimeout(resetAccumulator, QUIET_MS);
|
|
389
|
+
};
|
|
390
|
+
el.addEventListener('wheel', onWheel, { passive: false });
|
|
391
|
+
return () => {
|
|
392
|
+
el.removeEventListener('wheel', onWheel);
|
|
393
|
+
if (resetTimer)
|
|
394
|
+
clearTimeout(resetTimer);
|
|
395
|
+
};
|
|
396
|
+
}, [moveBy, disabled]);
|
|
397
|
+
// Pointer drag.
|
|
398
|
+
// We DELAY capturing the pointer until the user moves more than
|
|
399
|
+
// DRAG_THRESHOLD_PX. Without this, capturing on pointerdown would redirect
|
|
400
|
+
// mouseup → wheel root, which suppresses the click event on the item button
|
|
401
|
+
// the user pressed (so single-clicks on neighbouring items would no-op).
|
|
402
|
+
const DRAG_THRESHOLD_PX = 4;
|
|
403
|
+
const onPointerDown = (e) => {
|
|
404
|
+
if (disabled)
|
|
405
|
+
return;
|
|
406
|
+
cancelRaf();
|
|
407
|
+
if (audioTickEnabledRef.current) {
|
|
408
|
+
// Resume the AudioContext from inside the user gesture so the first
|
|
409
|
+
// tick of this drag can play (iOS Safari requires this; other browsers
|
|
410
|
+
// are forgiving but it's a cheap idempotent call).
|
|
411
|
+
unlockAudioTick();
|
|
412
|
+
}
|
|
413
|
+
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
414
|
+
dragStateRef.current = {
|
|
415
|
+
pointerId: e.pointerId,
|
|
416
|
+
startY: e.clientY,
|
|
417
|
+
startPosition: positionRef.current,
|
|
418
|
+
captured: false,
|
|
419
|
+
samples: [{ t: now, y: e.clientY }],
|
|
420
|
+
};
|
|
421
|
+
};
|
|
422
|
+
const onPointerMove = (e) => {
|
|
423
|
+
const s = dragStateRef.current;
|
|
424
|
+
if (s.pointerId !== e.pointerId)
|
|
425
|
+
return;
|
|
426
|
+
const dy = e.clientY - s.startY;
|
|
427
|
+
if (!s.captured) {
|
|
428
|
+
if (Math.abs(dy) < DRAG_THRESHOLD_PX)
|
|
429
|
+
return;
|
|
430
|
+
rootRef.current?.setPointerCapture(e.pointerId);
|
|
431
|
+
s.captured = true;
|
|
432
|
+
setIsDragging(true);
|
|
433
|
+
}
|
|
434
|
+
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
435
|
+
s.samples.push({ t: now, y: e.clientY });
|
|
436
|
+
// Keep the velocity sample window short so the post-flick velocity
|
|
437
|
+
// reflects the *recent* finger speed, not the whole-drag average.
|
|
438
|
+
const VELOCITY_WINDOW_MS = 100;
|
|
439
|
+
while (s.samples.length > 2 && now - s.samples[0].t > VELOCITY_WINDOW_MS) {
|
|
440
|
+
s.samples.shift();
|
|
441
|
+
}
|
|
442
|
+
// Continuous position: every itemHeight worth of finger drag advances
|
|
443
|
+
// the wheel by 1 unit. Pulling the finger DOWN (positive dy) shows
|
|
444
|
+
// EARLIER values, which means position decreases.
|
|
445
|
+
commitPosition(s.startPosition - dy / itemHeight);
|
|
446
|
+
};
|
|
447
|
+
const onPointerUp = (e) => {
|
|
448
|
+
const s = dragStateRef.current;
|
|
449
|
+
if (s.pointerId !== e.pointerId)
|
|
450
|
+
return;
|
|
451
|
+
if (s.captured) {
|
|
452
|
+
rootRef.current?.releasePointerCapture(e.pointerId);
|
|
453
|
+
// Drags don't fire click → focus would be left on whichever child
|
|
454
|
+
// received pointerdown. Pull it back to the wheel root so :focus
|
|
455
|
+
// emphasis still reads correctly after a drag.
|
|
456
|
+
rootRef.current?.focus();
|
|
457
|
+
// Compute fling velocity from the recent sample window.
|
|
458
|
+
const samples = s.samples;
|
|
459
|
+
let pxPerMs = 0;
|
|
460
|
+
if (samples.length >= 2) {
|
|
461
|
+
const first = samples[0];
|
|
462
|
+
const last = samples[samples.length - 1];
|
|
463
|
+
const dt = last.t - first.t;
|
|
464
|
+
if (dt > 0)
|
|
465
|
+
pxPerMs = (last.y - first.y) / dt;
|
|
466
|
+
}
|
|
467
|
+
// Threshold below which we just snap to the nearest item; above which
|
|
468
|
+
// we kick off momentum.
|
|
469
|
+
const FLING_THRESHOLD = 0.25; // px/ms ≈ 250 px/sec
|
|
470
|
+
if (Math.abs(pxPerMs) > FLING_THRESHOLD) {
|
|
471
|
+
startMomentum(pxPerMs);
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
animateSnap();
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
s.pointerId = null;
|
|
478
|
+
s.captured = false;
|
|
479
|
+
s.samples = [];
|
|
480
|
+
setIsDragging(false);
|
|
481
|
+
};
|
|
482
|
+
const rootClass = prefixedClassNames(classPrefix, 'timeinput-wheel', {
|
|
483
|
+
[`is-${color}`]: !!color,
|
|
484
|
+
[`is-${size}`]: !!size,
|
|
485
|
+
'is-disabled': disabled,
|
|
486
|
+
'is-dragging': isDragging,
|
|
487
|
+
});
|
|
488
|
+
const itemClass = (selected) => prefixedClassNames(classPrefix, 'timeinput-wheel-item', {
|
|
489
|
+
'is-selected': selected,
|
|
490
|
+
[`is-${color}`]: selected && !!color,
|
|
491
|
+
});
|
|
492
|
+
const bandClass = prefixedClassNames(classPrefix, 'timeinput-wheel-band', {
|
|
493
|
+
[`is-${color}`]: !!color,
|
|
494
|
+
});
|
|
495
|
+
// Build the buffered window of items to render around the current position.
|
|
496
|
+
const items = [];
|
|
497
|
+
for (let off = -BUFFER; off <= BUFFER; off++) {
|
|
498
|
+
const vIdx = virtualIdx + off;
|
|
499
|
+
if (!wrap && (vIdx < 0 || vIdx >= N))
|
|
500
|
+
continue;
|
|
501
|
+
const aIdx = toActual(vIdx);
|
|
502
|
+
items.push({ vIdx, aIdx });
|
|
503
|
+
}
|
|
504
|
+
const ariaText = formatAriaText
|
|
505
|
+
? formatAriaText(values[toActual(virtualIdx)])
|
|
506
|
+
: formatLabel(values[toActual(virtualIdx)]);
|
|
507
|
+
// Center of the wheel viewport, in px from the top.
|
|
508
|
+
const centreTop = containerHeight / 2 - itemHeight / 2;
|
|
509
|
+
return (jsxs("div", { ref: rootRef, role: "spinbutton", "aria-label": ariaLabel, "aria-valuemin": 0, "aria-valuemax": N - 1, "aria-valuenow": toActual(virtualIdx), "aria-valuetext": ariaText, tabIndex: disabled ? -1 : 0, className: rootClass, style: { height: containerHeight }, onKeyDown: onKeyDown, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: onPointerUp, onPointerCancel: onPointerUp, children: [jsx("div", { ref: bandRef, className: bandClass, "aria-hidden": "true", style: {
|
|
510
|
+
position: 'absolute',
|
|
511
|
+
left: 0,
|
|
512
|
+
right: 0,
|
|
513
|
+
top: centreTop,
|
|
514
|
+
height: itemHeight,
|
|
515
|
+
pointerEvents: 'none',
|
|
516
|
+
} }), items.map(({ vIdx, aIdx }) => {
|
|
517
|
+
// Continuous offset in items: items at the centre have offset ≈ 0;
|
|
518
|
+
// top of viewport is negative, bottom positive.
|
|
519
|
+
const offCentreItems = vIdx - position;
|
|
520
|
+
const top = centreTop + offCentreItems * itemHeight;
|
|
521
|
+
const selected = vIdx === virtualIdx;
|
|
522
|
+
const itemDisabled = disabled || (disabledFor ? disabledFor(values[aIdx]) : false);
|
|
523
|
+
return (jsx("button", { type: "button", "aria-hidden": "true", tabIndex: -1, className: itemClass(selected), style: {
|
|
524
|
+
position: 'absolute',
|
|
525
|
+
left: 0,
|
|
526
|
+
right: 0,
|
|
527
|
+
height: itemHeight,
|
|
528
|
+
top: `${top}px`,
|
|
529
|
+
}, disabled: itemDisabled,
|
|
530
|
+
// Keep a press from focusing the item, which assistive technology
|
|
531
|
+
// can't see; the click below hands focus to the wheel instead.
|
|
532
|
+
onMouseDown: e => e.preventDefault(), onClick: () => {
|
|
533
|
+
moveBy(vIdx - virtualIdx);
|
|
534
|
+
// Click focuses the <button> child by default; pull focus back
|
|
535
|
+
// to the wheel root so the column emphasis (:focus rules) match.
|
|
536
|
+
rootRef.current?.focus();
|
|
537
|
+
}, children: formatLabel(values[aIdx]) }, vIdx));
|
|
538
|
+
})] }));
|
|
539
|
+
};
|
|
540
|
+
const Wheel = React.forwardRef(WheelInner);
|
|
541
|
+
const TimeWheels = ({ value, onChange, hourFormat = '24', enableSeconds = false, incrementHours = 1, incrementMinutes = 1, incrementSeconds = 1, unselectableTimes, size, color, disabled, className, id, labels, visibleCount = 5, itemHeight = 32, audioTick = false, onCommit, }) => {
|
|
542
|
+
const t = mergeLabels(labels);
|
|
543
|
+
const rootClass = usePrefixedClassNames('timeinput', {
|
|
544
|
+
[`is-${color}`]: !!color,
|
|
545
|
+
[`is-${size}`]: !!size,
|
|
546
|
+
'is-disabled': disabled,
|
|
547
|
+
});
|
|
548
|
+
const wheelsClass = usePrefixedClassNames('timeinput-wheels');
|
|
549
|
+
const sepClass = usePrefixedClassNames('timeinput-separator');
|
|
550
|
+
// Use the prefixed class through the same helper the wheels themselves do,
|
|
551
|
+
// so the selector matches the actual rendered class regardless of whether
|
|
552
|
+
// the consumer configured `classPrefix` via ConfigProvider. Building the
|
|
553
|
+
// selector with `${classPrefix}timeinput-wheel` is wrong when classPrefix
|
|
554
|
+
// is undefined (defaults to the empty prefix) — it would stringify to
|
|
555
|
+
// "undefinedtimeinput-wheel".
|
|
556
|
+
const wheelSingleClass = usePrefixedClassNames('timeinput-wheel');
|
|
557
|
+
const wheelsContainerRef = useRef(null);
|
|
558
|
+
const isPm = value.hours >= 12;
|
|
559
|
+
const isTimeAllowed = useCallback((h, m, s) => {
|
|
560
|
+
if (!unselectableTimes)
|
|
561
|
+
return true;
|
|
562
|
+
const probe = setTimeOfDay(new Date(), {
|
|
563
|
+
hours: h,
|
|
564
|
+
minutes: m,
|
|
565
|
+
seconds: s,
|
|
566
|
+
milliseconds: 0,
|
|
567
|
+
});
|
|
568
|
+
return !unselectableTimes(probe);
|
|
569
|
+
}, [unselectableTimes]);
|
|
570
|
+
// Per-column "is this value disabled?" predicates. We compute them against
|
|
571
|
+
// the current value's other parts, so e.g. a predicate that only blocks
|
|
572
|
+
// 12:00–12:59 leaves all minutes selectable when the user moves the hour
|
|
573
|
+
// off 12. Returning `undefined` (rather than a function) when there is no
|
|
574
|
+
// unselectableTimes lets the Wheel skip the disabled-attribute branch.
|
|
575
|
+
const isHourDisabled = useCallback((display) => {
|
|
576
|
+
let h24 = display;
|
|
577
|
+
if (hourFormat === '12') {
|
|
578
|
+
const base = display % 12;
|
|
579
|
+
h24 = isPm ? base + 12 : base;
|
|
580
|
+
}
|
|
581
|
+
return !isTimeAllowed(h24, value.minutes, value.seconds ?? 0);
|
|
582
|
+
}, [hourFormat, isPm, isTimeAllowed, value.minutes, value.seconds]);
|
|
583
|
+
const isMinuteDisabled = useCallback((m) => !isTimeAllowed(value.hours, m, value.seconds ?? 0), [isTimeAllowed, value.hours, value.seconds]);
|
|
584
|
+
const isSecondDisabled = useCallback((s) => !isTimeAllowed(value.hours, value.minutes, s), [isTimeAllowed, value.hours, value.minutes]);
|
|
585
|
+
const hourDisabledFor = unselectableTimes ? isHourDisabled : undefined;
|
|
586
|
+
const minuteDisabledFor = unselectableTimes ? isMinuteDisabled : undefined;
|
|
587
|
+
const secondDisabledFor = unselectableTimes ? isSecondDisabled : undefined;
|
|
588
|
+
const commit = useCallback((next) => {
|
|
589
|
+
const merged = {
|
|
590
|
+
hours: next.hours ?? value.hours,
|
|
591
|
+
minutes: next.minutes ?? value.minutes,
|
|
592
|
+
seconds: enableSeconds
|
|
593
|
+
? (next.seconds ?? value.seconds ?? 0)
|
|
594
|
+
: undefined,
|
|
595
|
+
};
|
|
596
|
+
if (!isTimeAllowed(merged.hours, merged.minutes, merged.seconds ?? 0)) {
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
onChange(merged);
|
|
600
|
+
}, [value, enableSeconds, isTimeAllowed, onChange]);
|
|
601
|
+
// Build the value lists per column.
|
|
602
|
+
const hourValues = useMemo(() => {
|
|
603
|
+
const step = Math.max(1, incrementHours);
|
|
604
|
+
if (hourFormat === '12') {
|
|
605
|
+
const out = [];
|
|
606
|
+
for (let h = 1; h <= 12; h += step)
|
|
607
|
+
out.push(h);
|
|
608
|
+
return out;
|
|
609
|
+
}
|
|
610
|
+
const out = [];
|
|
611
|
+
for (let h = 0; h < 24; h += step)
|
|
612
|
+
out.push(h);
|
|
613
|
+
return out;
|
|
614
|
+
}, [hourFormat, incrementHours]);
|
|
615
|
+
const minuteValues = useMemo(() => {
|
|
616
|
+
const step = Math.max(1, incrementMinutes);
|
|
617
|
+
const out = [];
|
|
618
|
+
for (let m = 0; m < 60; m += step)
|
|
619
|
+
out.push(m);
|
|
620
|
+
return out;
|
|
621
|
+
}, [incrementMinutes]);
|
|
622
|
+
const secondValues = useMemo(() => {
|
|
623
|
+
const step = Math.max(1, incrementSeconds);
|
|
624
|
+
const out = [];
|
|
625
|
+
for (let s = 0; s < 60; s += step)
|
|
626
|
+
out.push(s);
|
|
627
|
+
return out;
|
|
628
|
+
}, [incrementSeconds]);
|
|
629
|
+
// Map current value → wheel index.
|
|
630
|
+
const displayedHours = hourFormat === '12'
|
|
631
|
+
? value.hours % 12 === 0
|
|
632
|
+
? 12
|
|
633
|
+
: value.hours % 12
|
|
634
|
+
: value.hours;
|
|
635
|
+
const hourIndex = Math.max(0, hourValues.indexOf(displayedHours));
|
|
636
|
+
const minuteIndex = Math.max(0, minuteValues.indexOf(value.minutes));
|
|
637
|
+
const secondIndex = Math.max(0, secondValues.indexOf(value.seconds ?? 0));
|
|
638
|
+
const ampmIndex = isPm ? 1 : 0;
|
|
639
|
+
// Refs for column focus management.
|
|
640
|
+
const hoursRef = useRef(null);
|
|
641
|
+
const minutesRef = useRef(null);
|
|
642
|
+
const secondsRef = useRef(null);
|
|
643
|
+
const ampmRef = useRef(null);
|
|
644
|
+
// Build the focus order at runtime.
|
|
645
|
+
const columnOrder = [];
|
|
646
|
+
columnOrder.push(hoursRef);
|
|
647
|
+
columnOrder.push(minutesRef);
|
|
648
|
+
if (enableSeconds)
|
|
649
|
+
columnOrder.push(secondsRef);
|
|
650
|
+
if (hourFormat === '12')
|
|
651
|
+
columnOrder.push(ampmRef);
|
|
652
|
+
const focusAt = (i) => columnOrder[i]?.current?.focus();
|
|
653
|
+
const indexOf = (r) => columnOrder.indexOf(r);
|
|
654
|
+
const focusPrevOf = (r) => () => focusAt(indexOf(r) - 1);
|
|
655
|
+
const focusNextOf = (r) => () => focusAt(indexOf(r) + 1);
|
|
656
|
+
// Hour change — translate displayed value back into 24h.
|
|
657
|
+
const onHourIndex = (next) => {
|
|
658
|
+
const display = hourValues[Math.max(0, Math.min(hourValues.length - 1, next))];
|
|
659
|
+
let next24 = display;
|
|
660
|
+
if (hourFormat === '12') {
|
|
661
|
+
const baseHour = display % 12;
|
|
662
|
+
next24 = isPm ? baseHour + 12 : baseHour;
|
|
663
|
+
}
|
|
664
|
+
// Skip past any blocked hour using nextValid.
|
|
665
|
+
const allowed = nextValid(next24, next24 >= value.hours ? 1 : -1, 24, h => isTimeAllowed(h, value.minutes, value.seconds ?? 0));
|
|
666
|
+
commit({ hours: allowed });
|
|
667
|
+
};
|
|
668
|
+
const onMinuteIndex = (next) => {
|
|
669
|
+
const m = minuteValues[Math.max(0, Math.min(minuteValues.length - 1, next))];
|
|
670
|
+
const allowed = nextValid(m, m >= value.minutes ? 1 : -1, 60, mm => isTimeAllowed(value.hours, mm, value.seconds ?? 0));
|
|
671
|
+
commit({ minutes: allowed });
|
|
672
|
+
};
|
|
673
|
+
const onSecondIndex = (next) => {
|
|
674
|
+
const s = secondValues[Math.max(0, Math.min(secondValues.length - 1, next))];
|
|
675
|
+
const allowed = nextValid(s, s >= (value.seconds ?? 0) ? 1 : -1, 60, ss => isTimeAllowed(value.hours, value.minutes, ss));
|
|
676
|
+
commit({ seconds: allowed });
|
|
677
|
+
};
|
|
678
|
+
const onAmpmIndex = (next) => {
|
|
679
|
+
if (next === ampmIndex)
|
|
680
|
+
return;
|
|
681
|
+
commit({ hours: (value.hours + 12) % 24 });
|
|
682
|
+
};
|
|
683
|
+
// Forward drags that originate on the empty space inside the wheels row
|
|
684
|
+
// (left of the leftmost wheel, right of the rightmost wheel, on the
|
|
685
|
+
// separator) to whichever wheel column is nearest to the pointer X — so
|
|
686
|
+
// the picker behaves like the iOS native one, where the whole picker
|
|
687
|
+
// surface is grabbable, not just the column itself.
|
|
688
|
+
const onWheelsContainerPointerDown = (e) => {
|
|
689
|
+
if (disabled)
|
|
690
|
+
return;
|
|
691
|
+
// If the pointerdown landed on a real wheel column, the wheel's own
|
|
692
|
+
// handler takes over — don't double-dispatch.
|
|
693
|
+
if (e.target.closest(`.${wheelSingleClass}`))
|
|
694
|
+
return;
|
|
695
|
+
const container = wheelsContainerRef.current;
|
|
696
|
+
/* istanbul ignore next: the handler is attached to the very element this ref points to, so it can't be null while events fire */
|
|
697
|
+
if (!container)
|
|
698
|
+
return;
|
|
699
|
+
const wheels = container.querySelectorAll(`.${wheelSingleClass}`);
|
|
700
|
+
if (wheels.length === 0)
|
|
701
|
+
return;
|
|
702
|
+
let nearestIdx = 0;
|
|
703
|
+
let nearestDist = Infinity;
|
|
704
|
+
const x = e.clientX;
|
|
705
|
+
wheels.forEach((el, i) => {
|
|
706
|
+
const r = el.getBoundingClientRect();
|
|
707
|
+
const cx = (r.left + r.right) / 2;
|
|
708
|
+
const d = Math.abs(x - cx);
|
|
709
|
+
if (d < nearestDist) {
|
|
710
|
+
nearestDist = d;
|
|
711
|
+
nearestIdx = i;
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
columnOrder[nearestIdx]?.current?.startDrag(e.pointerId, e.clientY);
|
|
715
|
+
};
|
|
716
|
+
return (jsx("div", { id: id, className: classNames(rootClass, className), "aria-disabled": disabled, children: jsxs("div", { className: wheelsClass, ref: wheelsContainerRef, onPointerDown: onWheelsContainerPointerDown, children: [jsx(Wheel, { ref: hoursRef, values: hourValues, index: hourIndex, onChange: onHourIndex, formatLabel: pad2, ariaLabel: t.hours, visibleCount: visibleCount, itemHeight: itemHeight, disabled: disabled, size: size, color: color, wrap: true, onCommit: onCommit, onFocusPrev: focusPrevOf(hoursRef), onFocusNext: focusNextOf(hoursRef), disabledFor: hourDisabledFor, audioTick: audioTick }), jsx("div", { className: sepClass, "aria-hidden": "true", children: ":" }), jsx(Wheel, { ref: minutesRef, values: minuteValues, index: minuteIndex, onChange: onMinuteIndex, formatLabel: pad2, ariaLabel: t.minutes, visibleCount: visibleCount, itemHeight: itemHeight, disabled: disabled, size: size, color: color, wrap: true, onCommit: onCommit, onFocusPrev: focusPrevOf(minutesRef), onFocusNext: focusNextOf(minutesRef), disabledFor: minuteDisabledFor, audioTick: audioTick }), enableSeconds && (jsxs(Fragment, { children: [jsx("div", { className: sepClass, "aria-hidden": "true", children: ":" }), jsx(Wheel, { ref: secondsRef, values: secondValues, index: secondIndex, onChange: onSecondIndex, formatLabel: pad2, ariaLabel: t.seconds, visibleCount: visibleCount, itemHeight: itemHeight, disabled: disabled, size: size, color: color, wrap: true, onCommit: onCommit, onFocusPrev: focusPrevOf(secondsRef), onFocusNext: focusNextOf(secondsRef), disabledFor: secondDisabledFor, audioTick: audioTick })] })), hourFormat === '12' && (jsx(Wheel, { ref: ampmRef, values: ['AM', 'PM'], index: ampmIndex, onChange: onAmpmIndex, formatLabel: v => v, ariaLabel: t.ampm, visibleCount: visibleCount, itemHeight: itemHeight, disabled: disabled, size: size, color: color, onCommit: onCommit, onFocusPrev: focusPrevOf(ampmRef), onFocusNext: focusNextOf(ampmRef), audioTick: audioTick }))] }) }));
|
|
717
|
+
};
|
|
718
|
+
TimeWheels.displayName = 'TimeWheels';
|
|
719
|
+
|
|
720
|
+
export { TimeWheels };
|
|
721
|
+
//# sourceMappingURL=TimeWheels.js.map
|