@astryxdesign/core 0.4.5 → 0.4.6-canary.cea9ecd
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/CHANGELOG.md +134 -0
- package/dist/Avatar/Avatar.d.ts +10 -5
- package/dist/Avatar/Avatar.d.ts.map +1 -1
- package/dist/Avatar/Avatar.js +139 -30
- package/dist/Avatar/AvatarStatusDot.d.ts +2 -1
- package/dist/Avatar/AvatarStatusDot.d.ts.map +1 -1
- package/dist/Avatar/AvatarStatusDot.js +30 -4
- package/dist/Avatar/AvatarStatusLabelContext.d.ts +39 -0
- package/dist/Avatar/AvatarStatusLabelContext.d.ts.map +1 -0
- package/dist/Avatar/AvatarStatusLabelContext.js +41 -0
- package/dist/AvatarGroup/AvatarGroup.d.ts +3 -1
- package/dist/AvatarGroup/AvatarGroup.d.ts.map +1 -1
- package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
- package/dist/AvatarGroup/AvatarGroupOverflow.js +15 -9
- package/dist/Blockquote/Blockquote.d.ts +4 -3
- package/dist/Blockquote/Blockquote.d.ts.map +1 -1
- package/dist/Blockquote/Blockquote.js +9 -10
- package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheetPanel.js +21 -5
- package/dist/Breadcrumbs/BreadcrumbItem.d.ts.map +1 -1
- package/dist/Breadcrumbs/BreadcrumbItem.js +47 -34
- package/dist/Button/Button.d.ts.map +1 -1
- package/dist/Button/Button.js +7 -7
- package/dist/Calendar/Calendar.d.ts.map +1 -1
- package/dist/Calendar/Calendar.js +21 -12
- package/dist/Calendar/hooks/useCalendarNavigation.d.ts.map +1 -1
- package/dist/Calendar/hooks/useCalendarNavigation.js +8 -4
- package/dist/Calendar/styles.d.ts +2 -2
- package/dist/Calendar/styles.d.ts.map +1 -1
- package/dist/Calendar/styles.js +5 -5
- package/dist/Center/Center.d.ts +23 -1
- package/dist/Center/Center.d.ts.map +1 -1
- package/dist/Center/Center.js +12 -6
- package/dist/Chat/ChatComposer.d.ts.map +1 -1
- package/dist/Chat/ChatComposer.js +3 -3
- package/dist/Chat/ChatComposerDrawer.d.ts.map +1 -1
- package/dist/Chat/ChatComposerDrawer.js +2 -2
- package/dist/Chat/ChatToolCalls.d.ts.map +1 -1
- package/dist/Chat/ChatToolCalls.js +2 -2
- package/dist/Chat/useChatDictation.d.ts +1 -1
- package/dist/Chat/useChatDictation.d.ts.map +1 -1
- package/dist/Chat/useSpeechRecognition.d.ts +1 -1
- package/dist/Chat/useSpeechRecognition.d.ts.map +1 -1
- package/dist/Chat/useSpeechRecognition.js +4 -3
- package/dist/Chat/useTriggerMenu.d.ts.map +1 -1
- package/dist/Chat/useTriggerMenu.js +2 -2
- package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
- package/dist/CheckboxInput/CheckboxInput.js +2 -2
- package/dist/Citation/Citation.d.ts.map +1 -1
- package/dist/Citation/Citation.js +5 -5
- package/dist/ClickableCard/ClickableCard.d.ts.map +1 -1
- package/dist/ClickableCard/ClickableCard.js +4 -4
- package/dist/CodeBlock/CodeBlock.d.ts.map +1 -1
- package/dist/CodeBlock/CodeBlock.js +1 -1
- package/dist/Collapsible/Collapsible.d.ts.map +1 -1
- package/dist/Collapsible/Collapsible.js +2 -2
- package/dist/CommandPalette/CommandPaletteItem.d.ts.map +1 -1
- package/dist/CommandPalette/CommandPaletteItem.js +3 -3
- package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
- package/dist/ComplexSelector/ComplexSelector.js +6 -6
- package/dist/DateInput/DateInput.d.ts +31 -2
- package/dist/DateInput/DateInput.d.ts.map +1 -1
- package/dist/DateInput/DateInput.js +90 -22
- package/dist/DateInput/MonthScroller.d.ts +35 -0
- package/dist/DateInput/MonthScroller.d.ts.map +1 -0
- package/dist/DateInput/MonthScroller.js +658 -0
- package/dist/DateInput/MonthYearWheels.d.ts +24 -0
- package/dist/DateInput/MonthYearWheels.d.ts.map +1 -0
- package/dist/DateInput/MonthYearWheels.js +114 -0
- package/dist/DateInput/TouchDateField.d.ts +10 -0
- package/dist/DateInput/TouchDateField.d.ts.map +1 -0
- package/dist/DateInput/TouchDateField.js +810 -0
- package/dist/DateInput/Wheel.d.ts +41 -0
- package/dist/DateInput/Wheel.d.ts.map +1 -0
- package/dist/DateInput/Wheel.js +315 -0
- package/dist/DateInput/monthGeometry.d.ts +65 -0
- package/dist/DateInput/monthGeometry.d.ts.map +1 -0
- package/dist/DateInput/monthGeometry.js +98 -0
- package/dist/DateInput/tokens.stylex.d.ts +63 -0
- package/dist/DateInput/tokens.stylex.d.ts.map +1 -0
- package/dist/DateInput/tokens.stylex.js +74 -0
- package/dist/DateInput/useOwnScrollGesture.d.ts +41 -0
- package/dist/DateInput/useOwnScrollGesture.d.ts.map +1 -0
- package/dist/DateInput/useOwnScrollGesture.js +202 -0
- package/dist/DateInput/usePointerDragScroll.d.ts +15 -0
- package/dist/DateInput/usePointerDragScroll.d.ts.map +1 -0
- package/dist/DateInput/usePointerDragScroll.js +216 -0
- package/dist/DateInput/useScrollSettle.d.ts +26 -0
- package/dist/DateInput/useScrollSettle.d.ts.map +1 -0
- package/dist/DateInput/useScrollSettle.js +149 -0
- package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
- package/dist/DateRangeInput/DateRangeInput.js +17 -14
- package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
- package/dist/DateTimeInput/DateTimeInput.js +17 -14
- package/dist/Dialog/Dialog.d.ts.map +1 -1
- package/dist/Dialog/Dialog.js +3 -3
- package/dist/DropdownMenu/DropdownMenuCheckboxItem.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuCheckboxItem.js +2 -2
- package/dist/DropdownMenu/DropdownMenuItem.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuItem.js +2 -2
- package/dist/DropdownMenu/DropdownMenuRadioItem.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuRadioItem.js +2 -2
- package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuSubMenu.js +2 -2
- package/dist/Field/FieldLabel.d.ts.map +1 -1
- package/dist/Field/FieldLabel.js +4 -4
- package/dist/Field/InputClearButton.d.ts.map +1 -1
- package/dist/Field/InputClearButton.js +6 -0
- package/dist/Field/inputStyles.stylex.d.ts +1 -1
- package/dist/Field/inputStyles.stylex.d.ts.map +1 -1
- package/dist/Field/inputStyles.stylex.js +5 -5
- package/dist/FileInput/FileInput.d.ts.map +1 -1
- package/dist/FileInput/FileInput.js +6 -6
- package/dist/HoverCard/HoverCard.d.ts +13 -3
- package/dist/HoverCard/HoverCard.d.ts.map +1 -1
- package/dist/HoverCard/HoverCard.js +2 -0
- package/dist/HoverCard/index.d.ts +1 -1
- package/dist/HoverCard/index.d.ts.map +1 -1
- package/dist/HoverCard/useHoverCard.d.ts +17 -2
- package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
- package/dist/HoverCard/useHoverCard.js +78 -9
- package/dist/InputGroup/InputGroup.js +1 -1
- package/dist/Item/Item.d.ts +9 -1
- package/dist/Item/Item.d.ts.map +1 -1
- package/dist/Item/Item.js +52 -14
- package/dist/Layer/index.d.ts +4 -2
- package/dist/Layer/index.d.ts.map +1 -1
- package/dist/Layer/index.js +2 -0
- package/dist/Layer/useLayer.d.ts.map +1 -1
- package/dist/Layer/useLayer.js +3 -2
- package/dist/Layer/useTouchTrigger.d.ts +113 -0
- package/dist/Layer/useTouchTrigger.d.ts.map +1 -0
- package/dist/Layer/useTouchTrigger.js +241 -0
- package/dist/Layout/container.stylex.d.ts +4 -2
- package/dist/Layout/container.stylex.d.ts.map +1 -1
- package/dist/Layout/container.stylex.js +19 -11
- package/dist/Layout/index.d.ts +1 -0
- package/dist/Layout/index.d.ts.map +1 -1
- package/dist/Layout/index.js +4 -0
- package/dist/Layout/padding.stylex.d.ts +315 -12
- package/dist/Layout/padding.stylex.d.ts.map +1 -1
- package/dist/Layout/padding.stylex.js +389 -12
- package/dist/Lightbox/Lightbox.d.ts.map +1 -1
- package/dist/Lightbox/Lightbox.js +5 -4
- package/dist/Link/Link.d.ts.map +1 -1
- package/dist/Link/Link.js +6 -6
- package/dist/MetadataList/MetadataList.d.ts.map +1 -1
- package/dist/MetadataList/MetadataList.js +1 -1
- package/dist/MobileNav/MobileNav.d.ts +1 -0
- package/dist/MobileNav/MobileNav.d.ts.map +1 -1
- package/dist/MobileNav/MobileNav.js +24 -6
- package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
- package/dist/MultiSelector/MultiSelector.js +8 -8
- package/dist/NavItem/navItemStyles.stylex.d.ts +4 -4
- package/dist/NavItem/navItemStyles.stylex.d.ts.map +1 -1
- package/dist/NavItem/navItemStyles.stylex.js +4 -4
- package/dist/NavMenu/NavHeadingMenuItem.d.ts.map +1 -1
- package/dist/NavMenu/NavHeadingMenuItem.js +3 -3
- package/dist/NumberInput/NumberInput.d.ts.map +1 -1
- package/dist/NumberInput/NumberInput.js +36 -8
- package/dist/Outline/Outline.d.ts.map +1 -1
- package/dist/Outline/Outline.js +3 -3
- package/dist/Pagination/Pagination.d.ts.map +1 -1
- package/dist/Pagination/Pagination.js +2 -2
- package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
- package/dist/PowerSearch/PowerSearch.js +11 -12
- package/dist/PowerSearch/PowerSearchToken.d.ts.map +1 -1
- package/dist/PowerSearch/PowerSearchToken.js +3 -1
- package/dist/PowerSearch/formatFilterValue.d.ts +11 -2
- package/dist/PowerSearch/formatFilterValue.d.ts.map +1 -1
- package/dist/PowerSearch/formatFilterValue.js +23 -8
- package/dist/RadioList/RadioListItem.d.ts +2 -2
- package/dist/RadioList/RadioListItem.d.ts.map +1 -1
- package/dist/RadioList/RadioListItem.js +70 -40
- package/dist/Resizable/ResizeHandle.d.ts.map +1 -1
- package/dist/Resizable/ResizeHandle.js +7 -7
- package/dist/Section/Section.d.ts +36 -1
- package/dist/Section/Section.d.ts.map +1 -1
- package/dist/Section/Section.js +12 -2
- package/dist/SegmentedControl/SegmentedControlItem.d.ts.map +1 -1
- package/dist/SegmentedControl/SegmentedControlItem.js +7 -2
- package/dist/SelectableCard/SelectableCard.d.ts.map +1 -1
- package/dist/SelectableCard/SelectableCard.js +3 -3
- package/dist/Selector/Selector.d.ts +26 -1
- package/dist/Selector/Selector.d.ts.map +1 -1
- package/dist/Selector/Selector.js +93 -20
- package/dist/Selector/SelectorOption.d.ts +14 -1
- package/dist/Selector/SelectorOption.d.ts.map +1 -1
- package/dist/Selector/SelectorOption.js +6 -0
- package/dist/Selector/SelectorRowLayoutContext.d.ts +8 -0
- package/dist/Selector/SelectorRowLayoutContext.d.ts.map +1 -0
- package/dist/Selector/SelectorRowLayoutContext.js +27 -0
- package/dist/Selector/types.d.ts +1 -0
- package/dist/Selector/types.d.ts.map +1 -1
- package/dist/SideNav/SideNavHeading.d.ts.map +1 -1
- package/dist/SideNav/SideNavHeading.js +4 -4
- package/dist/SideNav/SideNavItem.d.ts.map +1 -1
- package/dist/SideNav/SideNavItem.js +3 -3
- package/dist/Slider/Slider.d.ts.map +1 -1
- package/dist/Slider/Slider.js +7 -7
- package/dist/Stack/Stack.d.ts +23 -1
- package/dist/Stack/Stack.d.ts.map +1 -1
- package/dist/Stack/Stack.js +12 -6
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +2 -2
- package/dist/TabList/Tab.d.ts.map +1 -1
- package/dist/TabList/Tab.js +1 -1
- package/dist/TabList/TabMenu.d.ts.map +1 -1
- package/dist/TabList/TabMenu.js +3 -3
- package/dist/Table/TableRow.js +4 -4
- package/dist/Table/plugins/columnResize/useTableColumnResize.d.ts.map +1 -1
- package/dist/Table/plugins/columnResize/useTableColumnResize.js +1 -1
- package/dist/Table/plugins/filtering/useTableFiltering.d.ts.map +1 -1
- package/dist/Table/plugins/filtering/useTableFiltering.js +2 -2
- package/dist/Table/plugins/groupedRows/useTableGroupedRows.d.ts.map +1 -1
- package/dist/Table/plugins/groupedRows/useTableGroupedRows.js +2 -2
- package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
- package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +2 -2
- package/dist/Table/plugins/sortable/useTableSortable.d.ts.map +1 -1
- package/dist/Table/plugins/sortable/useTableSortable.js +1 -1
- package/dist/Table/plugins/sortable/useTableSortableState.d.ts +5 -3
- package/dist/Table/plugins/sortable/useTableSortableState.d.ts.map +1 -1
- package/dist/Table/plugins/sortable/useTableSortableState.js +12 -9
- package/dist/Table/plugins/tree/useTableTreeData.d.ts.map +1 -1
- package/dist/Table/plugins/tree/useTableTreeData.js +3 -3
- package/dist/TextArea/TextArea.js +1 -1
- package/dist/TextInput/TextInput.js +1 -1
- package/dist/Thumbnail/Thumbnail.d.ts.map +1 -1
- package/dist/Thumbnail/Thumbnail.js +3 -3
- package/dist/TimeInput/TimeInput.js +2 -2
- package/dist/Timestamp/Timestamp.d.ts.map +1 -1
- package/dist/Timestamp/Timestamp.js +6 -4
- package/dist/Timestamp/formatInstant.d.ts +3 -2
- package/dist/Timestamp/formatInstant.d.ts.map +1 -1
- package/dist/Timestamp/formatInstant.js +8 -8
- package/dist/Timestamp/tooltipEntries.d.ts +2 -1
- package/dist/Timestamp/tooltipEntries.d.ts.map +1 -1
- package/dist/Timestamp/tooltipEntries.js +5 -3
- package/dist/Toast/Toast.d.ts +4 -3
- package/dist/Toast/Toast.d.ts.map +1 -1
- package/dist/Toast/Toast.js +10 -7
- package/dist/Token/Token.d.ts.map +1 -1
- package/dist/Token/Token.js +6 -6
- package/dist/Tokenizer/Tokenizer.d.ts.map +1 -1
- package/dist/Tokenizer/Tokenizer.js +1 -1
- package/dist/Tooltip/Tooltip.d.ts +16 -3
- package/dist/Tooltip/Tooltip.d.ts.map +1 -1
- package/dist/Tooltip/Tooltip.js +2 -0
- package/dist/Tooltip/index.d.ts +1 -1
- package/dist/Tooltip/index.d.ts.map +1 -1
- package/dist/Tooltip/useTooltip.d.ts +21 -2
- package/dist/Tooltip/useTooltip.d.ts.map +1 -1
- package/dist/Tooltip/useTooltip.js +71 -13
- package/dist/TopNav/TopNavHeading.d.ts.map +1 -1
- package/dist/TopNav/TopNavHeading.js +4 -4
- package/dist/TopNav/TopNavItem.d.ts.map +1 -1
- package/dist/TopNav/TopNavItem.js +3 -3
- package/dist/TopNav/TopNavMegaMenu.d.ts.map +1 -1
- package/dist/TopNav/TopNavMegaMenu.js +2 -2
- package/dist/TopNav/TopNavMegaMenuItem.d.ts.map +1 -1
- package/dist/TopNav/TopNavMegaMenuItem.js +2 -2
- package/dist/TopNav/TopNavMenu.d.ts.map +1 -1
- package/dist/TopNav/TopNavMenu.js +4 -4
- package/dist/TreeList/TreeListItem.d.ts.map +1 -1
- package/dist/TreeList/TreeListItem.js +7 -7
- package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
- package/dist/Typeahead/BaseTypeahead.js +2 -2
- package/dist/Typeahead/Typeahead.d.ts.map +1 -1
- package/dist/Typeahead/Typeahead.js +1 -1
- package/dist/astryx.css +124 -62
- package/dist/hooks/containerReveal.stylex.d.ts.map +1 -1
- package/dist/hooks/containerReveal.stylex.js +18 -18
- package/dist/hooks/scrollbarGutter.d.ts +28 -0
- package/dist/hooks/scrollbarGutter.d.ts.map +1 -0
- package/dist/hooks/scrollbarGutter.js +103 -0
- package/dist/hooks/useAutoMediaMode.d.ts +31 -0
- package/dist/hooks/useAutoMediaMode.d.ts.map +1 -0
- package/dist/hooks/useAutoMediaMode.js +201 -0
- package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
- package/dist/hooks/useInputStatusIcon.js +1 -1
- package/dist/hooks/useListFocus.d.ts +5 -2
- package/dist/hooks/useListFocus.d.ts.map +1 -1
- package/dist/hooks/useListFocus.js +12 -6
- package/dist/hooks/useScrollLock.d.ts +4 -0
- package/dist/hooks/useScrollLock.d.ts.map +1 -1
- package/dist/hooks/useScrollLock.js +13 -1
- package/dist/i18n/InternationalizationContext.d.ts.map +1 -1
- package/dist/i18n/InternationalizationContext.js +2 -0
- package/dist/i18n/index.d.ts +6 -0
- package/dist/i18n/index.d.ts.map +1 -1
- package/dist/i18n/index.js +6 -0
- package/dist/i18n/useCollator.d.ts +12 -0
- package/dist/i18n/useCollator.d.ts.map +1 -0
- package/dist/i18n/useCollator.js +40 -0
- package/dist/i18n/useLocale.d.ts +3 -0
- package/dist/i18n/useLocale.d.ts.map +1 -0
- package/dist/i18n/useLocale.js +31 -0
- package/dist/theme/MediaTheme.d.ts +25 -5
- package/dist/theme/MediaTheme.d.ts.map +1 -1
- package/dist/theme/MediaTheme.js +31 -6
- package/dist/theme/defineTheme.d.ts +5 -0
- package/dist/theme/defineTheme.d.ts.map +1 -1
- package/dist/theme/defineTheme.js +5 -0
- package/dist/theme/derivedVarRegistry.d.ts.map +1 -1
- package/dist/theme/derivedVarRegistry.js +7 -0
- package/dist/theme/generateThemeRules.d.ts.map +1 -1
- package/dist/theme/generateThemeRules.js +66 -5
- package/dist/theme/index.d.ts +1 -1
- package/dist/theme/index.d.ts.map +1 -1
- package/dist/theme/syntax/index.d.ts +6 -0
- package/dist/theme/syntax/index.d.ts.map +1 -1
- package/dist/theme/syntax/index.js +7 -2
- package/dist/utils/dateParser.d.ts +4 -3
- package/dist/utils/dateParser.d.ts.map +1 -1
- package/dist/utils/dateParser.js +8 -8
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +1 -1
- package/dist/utils/plainDate.d.ts +5 -2
- package/dist/utils/plainDate.d.ts.map +1 -1
- package/dist/utils/plainDate.js +20 -4
- package/locales/en.json +28 -0
- package/locales/pseudo.json +21 -0
- package/package.json +3 -3
- package/src/Avatar/Avatar.doc.mjs +10 -6
- package/src/Avatar/Avatar.test.tsx +219 -1
- package/src/Avatar/Avatar.tsx +179 -37
- package/src/Avatar/AvatarStatusDot.tsx +30 -4
- package/src/Avatar/AvatarStatusLabelContext.ts +47 -0
- package/src/AvatarGroup/AvatarGroup.doc.mjs +10 -6
- package/src/AvatarGroup/AvatarGroup.test.tsx +62 -0
- package/src/AvatarGroup/AvatarGroup.tsx +3 -1
- package/src/AvatarGroup/AvatarGroupOverflow.tsx +31 -8
- package/src/Banner/Banner.doc.mjs +2 -0
- package/src/Blockquote/Blockquote.doc.mjs +30 -7
- package/src/Blockquote/Blockquote.test.tsx +34 -3
- package/src/Blockquote/Blockquote.tsx +11 -9
- package/src/BottomSheet/BottomSheet.doc.mjs +3 -3
- package/src/BottomSheet/BottomSheet.test.tsx +24 -0
- package/src/BottomSheet/BottomSheetPanel.test.tsx +91 -0
- package/src/BottomSheet/BottomSheetPanel.tsx +58 -3
- package/src/Breadcrumbs/BreadcrumbItem.tsx +36 -23
- package/src/Breadcrumbs/Breadcrumbs.doc.mjs +26 -2
- package/src/Breadcrumbs/Breadcrumbs.test.tsx +31 -0
- package/src/Button/Button.tsx +12 -9
- package/src/Calendar/Calendar.tsx +15 -9
- package/src/Calendar/hooks/useCalendarNavigation.ts +6 -4
- package/src/Calendar/styles.ts +8 -5
- package/src/Center/Center.doc.mjs +48 -0
- package/src/Center/Center.test.tsx +158 -0
- package/src/Center/Center.tsx +50 -9
- package/src/Chat/ChatComposer.tsx +11 -5
- package/src/Chat/ChatComposerDrawer.tsx +4 -1
- package/src/Chat/ChatToolCalls.tsx +13 -4
- package/src/Chat/{useChatDictation.test.ts → useChatDictation.test.tsx} +29 -14
- package/src/Chat/useChatDictation.ts +1 -1
- package/src/Chat/useSpeechRecognition.ts +5 -3
- package/src/Chat/useTriggerMenu.tsx +4 -1
- package/src/CheckboxInput/CheckboxInput.tsx +5 -2
- package/src/Citation/Citation.test.tsx +6 -1
- package/src/Citation/Citation.tsx +11 -5
- package/src/ClickableCard/ClickableCard.tsx +7 -4
- package/src/CodeBlock/CodeBlock.tsx +4 -1
- package/src/Collapsible/Collapsible.tsx +5 -2
- package/src/CommandPalette/CommandPaletteItem.tsx +6 -3
- package/src/ComplexSelector/ComplexSelector.doc.mjs +10 -0
- package/src/ComplexSelector/ComplexSelector.tsx +14 -7
- package/src/DateInput/DateInput.test.tsx +17 -1
- package/src/DateInput/DateInput.tsx +93 -21
- package/src/DateInput/DateInputTouch.test.tsx +2582 -0
- package/src/DateInput/MonthScroller.tsx +878 -0
- package/src/DateInput/MonthYearWheels.tsx +147 -0
- package/src/DateInput/TouchDateField.tsx +1223 -0
- package/src/DateInput/Wheel.tsx +473 -0
- package/src/DateInput/monthGeometry.ts +118 -0
- package/src/DateInput/tokens.stylex.ts +99 -0
- package/src/DateInput/useOwnScrollGesture.ts +227 -0
- package/src/DateInput/usePointerDragScroll.ts +220 -0
- package/src/DateInput/useScrollSettle.ts +164 -0
- package/src/DateRangeInput/DateRangeInput.doc.mjs +1 -1
- package/src/DateRangeInput/DateRangeInput.tsx +31 -13
- package/src/DateTimeInput/DateTimeInput.tsx +25 -9
- package/src/Dialog/Dialog.test.tsx +24 -0
- package/src/Dialog/Dialog.tsx +3 -0
- package/src/DropdownMenu/DropdownMenuCheckboxItem.tsx +5 -2
- package/src/DropdownMenu/DropdownMenuItem.tsx +5 -2
- package/src/DropdownMenu/DropdownMenuRadioItem.tsx +5 -2
- package/src/DropdownMenu/DropdownMenuSubMenu.tsx +5 -2
- package/src/Field/Field.doc.mjs +2 -0
- package/src/Field/FieldLabel.tsx +9 -3
- package/src/Field/InputClearButton.test.tsx +110 -3
- package/src/Field/InputClearButton.tsx +38 -0
- package/src/Field/inputStyles.stylex.ts +17 -13
- package/src/FileInput/FileInput.tsx +15 -8
- package/src/FormLayout/FormLayout.doc.mjs +1 -1
- package/src/HoverCard/HoverCard.doc.mjs +18 -0
- package/src/HoverCard/HoverCard.test.tsx +189 -0
- package/src/HoverCard/HoverCard.tsx +22 -2
- package/src/HoverCard/index.ts +1 -0
- package/src/HoverCard/useHoverCard.doc.mjs +2 -0
- package/src/HoverCard/useHoverCard.tsx +103 -7
- package/src/InputGroup/InputGroup.tsx +1 -1
- package/src/Item/Item.doc.mjs +1 -0
- package/src/Item/Item.test.tsx +50 -0
- package/src/Item/Item.tsx +54 -6
- package/src/Layer/index.ts +10 -0
- package/src/Layer/useLayer.tsx +13 -2
- package/src/Layer/useTouchTrigger.test.ts +70 -0
- package/src/Layer/useTouchTrigger.ts +366 -0
- package/src/Layout/Layout.doc.mjs +18 -0
- package/src/Layout/container.stylex.ts +11 -3
- package/src/Layout/index.ts +4 -0
- package/src/Layout/overlayPaddingReset.test.tsx +222 -0
- package/src/Layout/padding.stylex.ts +192 -12
- package/src/Lightbox/Lightbox.tsx +11 -3
- package/src/Link/Link.tsx +9 -6
- package/src/MetadataList/MetadataList.tsx +4 -1
- package/src/MobileNav/MobileNav.tsx +61 -5
- package/src/MobileNav/MobileNavEntryAnimation.test.tsx +212 -0
- package/src/MobileNav/MobileNavScrollbarGutter.test.tsx +109 -0
- package/src/MultiSelector/MultiSelector.test.tsx +1 -1
- package/src/MultiSelector/MultiSelector.tsx +26 -10
- package/src/NavItem/navItemStyles.stylex.ts +7 -4
- package/src/NavMenu/NavHeadingMenuItem.tsx +6 -3
- package/src/NumberInput/NumberInput.doc.mjs +24 -0
- package/src/NumberInput/NumberInput.test.tsx +138 -0
- package/src/NumberInput/NumberInput.tsx +47 -8
- package/src/Outline/Outline.tsx +5 -2
- package/src/Pagination/Pagination.tsx +5 -2
- package/src/Popover/Popover.test.tsx +27 -1
- package/src/PowerSearch/PowerSearch.tsx +17 -8
- package/src/PowerSearch/PowerSearchToken.tsx +3 -0
- package/src/PowerSearch/formatFilterValue.test.ts +22 -2
- package/src/PowerSearch/formatFilterValue.ts +31 -6
- package/src/RadioList/RadioList.doc.mjs +1 -1
- package/src/RadioList/RadioList.test.tsx +170 -2
- package/src/RadioList/RadioListItem.tsx +88 -53
- package/src/Resizable/ResizeHandle.test.tsx +22 -0
- package/src/Resizable/ResizeHandle.tsx +25 -7
- package/src/Section/Section.doc.mjs +55 -0
- package/src/Section/Section.test.tsx +201 -4
- package/src/Section/Section.tsx +69 -0
- package/src/SegmentedControl/SegmentedControl.test.tsx +31 -0
- package/src/SegmentedControl/SegmentedControlItem.tsx +15 -3
- package/src/SelectableCard/SelectableCard.tsx +6 -3
- package/src/Selector/Selector.doc.mjs +16 -2
- package/src/Selector/Selector.test.tsx +525 -1
- package/src/Selector/Selector.tsx +180 -25
- package/src/Selector/SelectorOption.doc.mjs +7 -0
- package/src/Selector/SelectorOption.tsx +21 -0
- package/src/Selector/SelectorRowLayoutContext.ts +32 -0
- package/src/Selector/types.ts +4 -4
- package/src/SideNav/SideNavHeading.tsx +14 -5
- package/src/SideNav/SideNavItem.tsx +9 -3
- package/src/Slider/Slider.tsx +25 -6
- package/src/Stack/Stack.doc.mjs +72 -0
- package/src/Stack/Stack.test.tsx +156 -0
- package/src/Stack/Stack.tsx +50 -8
- package/src/Switch/Switch.tsx +5 -2
- package/src/TabList/Tab.tsx +4 -1
- package/src/TabList/TabMenu.tsx +9 -3
- package/src/Table/TableRow.tsx +4 -4
- package/src/Table/plugins/columnResize/useTableColumnResize.tsx +6 -2
- package/src/Table/plugins/filtering/useTableFiltering.tsx +4 -1
- package/src/Table/plugins/groupedRows/useTableGroupedRows.tsx +10 -3
- package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +6 -3
- package/src/Table/plugins/sortable/useTableSortable.tsx +4 -1
- package/src/Table/plugins/sortable/useTableSortableState.test.tsx +34 -0
- package/src/Table/plugins/sortable/useTableSortableState.tsx +26 -23
- package/src/Table/plugins/tree/useTableTreeData.tsx +10 -4
- package/src/TextArea/TextArea.doc.mjs +1 -1
- package/src/TextArea/TextArea.tsx +1 -1
- package/src/TextInput/TextInput.tsx +1 -1
- package/src/Thumbnail/Thumbnail.tsx +9 -3
- package/src/TimeInput/TimeInput.tsx +1 -1
- package/src/Timestamp/Timestamp.test.tsx +27 -7
- package/src/Timestamp/Timestamp.tsx +8 -4
- package/src/Timestamp/formatInstant.ts +9 -7
- package/src/Timestamp/tooltipEntries.test.ts +11 -6
- package/src/Timestamp/tooltipEntries.ts +6 -2
- package/src/Toast/Toast.tsx +9 -7
- package/src/Token/Token.tsx +14 -5
- package/src/Tokenizer/Tokenizer.tsx +4 -1
- package/src/Tooltip/Tooltip.doc.mjs +18 -0
- package/src/Tooltip/Tooltip.test.tsx +254 -1
- package/src/Tooltip/Tooltip.tsx +22 -2
- package/src/Tooltip/index.ts +1 -0
- package/src/Tooltip/useTooltip.doc.mjs +2 -0
- package/src/Tooltip/useTooltip.tsx +99 -21
- package/src/TopNav/TopNavHeading.tsx +13 -4
- package/src/TopNav/TopNavItem.tsx +6 -3
- package/src/TopNav/TopNavMegaMenu.tsx +5 -2
- package/src/TopNav/TopNavMegaMenuItem.tsx +5 -2
- package/src/TopNav/TopNavMenu.tsx +10 -4
- package/src/TreeList/TreeListItem.tsx +22 -7
- package/src/Typeahead/BaseTypeahead.tsx +5 -2
- package/src/Typeahead/Typeahead.tsx +4 -1
- package/src/hooks/containerReveal.stylex.ts +54 -18
- package/src/hooks/scrollbarGutter.test.ts +167 -0
- package/src/hooks/scrollbarGutter.ts +121 -0
- package/src/hooks/useAutoMediaMode.test.ts +109 -0
- package/src/hooks/useAutoMediaMode.ts +233 -0
- package/src/hooks/useInputStatusIcon.tsx +4 -1
- package/src/hooks/useListFocus.doc.mjs +2 -2
- package/src/hooks/useListFocus.test.tsx +65 -3
- package/src/hooks/useListFocus.ts +15 -7
- package/src/hooks/useScrollLock.doc.mjs +4 -4
- package/src/hooks/useScrollLock.test.ts +73 -0
- package/src/hooks/useScrollLock.ts +14 -0
- package/src/i18n/InternationalizationContext.ts +2 -0
- package/src/i18n/__tests__/e2e-powersearch.test.tsx +37 -1
- package/src/i18n/__tests__/useCollator.test.tsx +115 -0
- package/src/i18n/__tests__/useLocale.test.tsx +52 -0
- package/src/i18n/index.ts +6 -0
- package/src/i18n/useCollator.doc.mjs +84 -0
- package/src/i18n/useCollator.ts +44 -0
- package/src/i18n/useLocale.doc.mjs +70 -0
- package/src/i18n/useLocale.ts +34 -0
- package/src/i18n/useTranslator.doc.mjs +1 -1
- package/src/reset.css +9 -2
- package/src/reset.test.ts +22 -0
- package/src/theme/MediaTheme.doc.mjs +23 -4
- package/src/theme/MediaTheme.dom.test.tsx +97 -0
- package/src/theme/MediaTheme.tsx +42 -7
- package/src/theme/defineTheme.test.ts +9 -3
- package/src/theme/defineTheme.ts +5 -0
- package/src/theme/derivedVarRegistry.test.ts +19 -5
- package/src/theme/derivedVarRegistry.ts +4 -0
- package/src/theme/generateThemeRules.test.ts +118 -0
- package/src/theme/generateThemeRules.ts +83 -5
- package/src/theme/index.ts +1 -1
- package/src/theme/syntax/index.ts +6 -2
- package/src/theme/syntax/serverSafeSyntax.test.ts +68 -0
- package/src/utils/dateParser.test.ts +20 -0
- package/src/utils/dateParser.ts +14 -7
- package/src/utils/index.ts +2 -0
- package/src/utils/plainDate.test.ts +6 -0
- package/src/utils/plainDate.ts +21 -4
|
@@ -0,0 +1,2582 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file DateInputTouch.test.tsx
|
|
5
|
+
* @input Uses vitest, @testing-library/react, DateInput and its helpers
|
|
6
|
+
* @output Behavior coverage for the responsive date picker
|
|
7
|
+
* @position Test file for /packages/core/src/DateInput/
|
|
8
|
+
*
|
|
9
|
+
* What jsdom can and cannot see here matters, and the split is deliberate:
|
|
10
|
+
*
|
|
11
|
+
* - The month math is pure, so it is tested directly and exhaustively.
|
|
12
|
+
* - Which surface is chosen, and the field contract each one honors, are
|
|
13
|
+
* tested through the DOM with `matchMedia` stubbed per test.
|
|
14
|
+
* - Snapping, momentum and the scroll-driven falloff are CSS the browser
|
|
15
|
+
* resolves and jsdom does not implement at all. Those are asserted on the
|
|
16
|
+
* style DEFINITION instead (the last describe block), which at least fails
|
|
17
|
+
* loudly if someone deletes the property; the real verification is a
|
|
18
|
+
* browser, and the values it produced are recorded in the doc file.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
describe,
|
|
23
|
+
it,
|
|
24
|
+
expect,
|
|
25
|
+
vi,
|
|
26
|
+
beforeAll,
|
|
27
|
+
afterAll,
|
|
28
|
+
beforeEach,
|
|
29
|
+
afterEach,
|
|
30
|
+
} from 'vitest';
|
|
31
|
+
import {
|
|
32
|
+
render,
|
|
33
|
+
screen,
|
|
34
|
+
fireEvent,
|
|
35
|
+
within,
|
|
36
|
+
waitFor,
|
|
37
|
+
} from '@testing-library/react';
|
|
38
|
+
import {readFileSync} from 'node:fs';
|
|
39
|
+
import path from 'node:path';
|
|
40
|
+
import {fileURLToPath} from 'node:url';
|
|
41
|
+
import {useState} from 'react';
|
|
42
|
+
import type {ISODateString} from '../utils';
|
|
43
|
+
import {InputGroup} from '../InputGroup';
|
|
44
|
+
import {stableClassName} from '../naming';
|
|
45
|
+
import {DateInput} from './DateInput';
|
|
46
|
+
import {
|
|
47
|
+
toMonthIndex,
|
|
48
|
+
monthIndexOf,
|
|
49
|
+
fromMonthIndex,
|
|
50
|
+
clampIndex,
|
|
51
|
+
rowAtScrollOffset,
|
|
52
|
+
scrollOffsetForRow,
|
|
53
|
+
paneWindow,
|
|
54
|
+
rowsIn,
|
|
55
|
+
DEFAULT_MONTH_REACH,
|
|
56
|
+
} from './monthGeometry';
|
|
57
|
+
import {SWIPE_DISTANCE} from './useOwnScrollGesture';
|
|
58
|
+
import {DRAG_SLOP} from './usePointerDragScroll';
|
|
59
|
+
import {SCROLL_QUIET_MS} from './useScrollSettle';
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// jsdom scaffolding
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
class MockResizeObserver {
|
|
66
|
+
observe() {}
|
|
67
|
+
unobserve() {}
|
|
68
|
+
disconnect() {}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** An arbitrary pane size for the pure geometry tests. */
|
|
72
|
+
const PANE = 264;
|
|
73
|
+
/**
|
|
74
|
+
* What the month scrollport measures as. The scroller pages along the INLINE
|
|
75
|
+
* axis, so its pane size is a WIDTH — a phone's worth, since the value only
|
|
76
|
+
* has to be non-zero and consistent for the virtualization to mount panes.
|
|
77
|
+
*/
|
|
78
|
+
const SCROLLPORT_WIDTH = 360;
|
|
79
|
+
|
|
80
|
+
/** Matches the repo-wide setup polyfill, so hover-gated behavior still works. */
|
|
81
|
+
const HOVER_CAPABLE = /\(\s*hover\s*:\s*hover\s*\)/;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Answer media queries the way a given device would.
|
|
85
|
+
*
|
|
86
|
+
* Width queries are answered HONESTLY against `width`, so a width bound
|
|
87
|
+
* creeping back into the surface switch fails a test rather than passing
|
|
88
|
+
* silently on a stub that ignores it.
|
|
89
|
+
*/
|
|
90
|
+
function stubMedia({
|
|
91
|
+
pointer,
|
|
92
|
+
anyPointer,
|
|
93
|
+
width,
|
|
94
|
+
}: {
|
|
95
|
+
pointer: 'coarse' | 'fine';
|
|
96
|
+
anyPointer?: 'coarse' | 'fine';
|
|
97
|
+
width: number;
|
|
98
|
+
}): void {
|
|
99
|
+
vi.stubGlobal('matchMedia', (query: string) => {
|
|
100
|
+
let matches: boolean;
|
|
101
|
+
const maxWidth = /\(\s*max-width:\s*(\d+)px\s*\)/.exec(query);
|
|
102
|
+
const minWidth = /\(\s*min-width:\s*(\d+)px\s*\)/.exec(query);
|
|
103
|
+
if (/any-pointer:\s*coarse/.test(query)) {
|
|
104
|
+
matches = (anyPointer ?? pointer) === 'coarse';
|
|
105
|
+
} else if (/pointer:\s*coarse/.test(query)) {
|
|
106
|
+
matches = pointer === 'coarse';
|
|
107
|
+
} else if (/pointer:\s*fine/.test(query)) {
|
|
108
|
+
matches = pointer === 'fine';
|
|
109
|
+
} else if (maxWidth) {
|
|
110
|
+
matches = width <= Number(maxWidth[1]);
|
|
111
|
+
} else if (minWidth) {
|
|
112
|
+
matches = width >= Number(minWidth[1]);
|
|
113
|
+
} else {
|
|
114
|
+
matches = HOVER_CAPABLE.test(query);
|
|
115
|
+
}
|
|
116
|
+
// A compound query is only true when every part of it is.
|
|
117
|
+
if (matches && maxWidth && query.includes('pointer:')) {
|
|
118
|
+
matches = width <= Number(maxWidth[1]);
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
matches,
|
|
122
|
+
media: query,
|
|
123
|
+
onchange: null,
|
|
124
|
+
addListener: () => {},
|
|
125
|
+
removeListener: () => {},
|
|
126
|
+
addEventListener: () => {},
|
|
127
|
+
removeEventListener: () => {},
|
|
128
|
+
dispatchEvent: () => false,
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function setViewport(kind: 'mobile' | 'desktop'): void {
|
|
134
|
+
stubMedia(
|
|
135
|
+
kind === 'mobile'
|
|
136
|
+
? {pointer: 'coarse', width: 393}
|
|
137
|
+
: {pointer: 'fine', width: 1280},
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Run `fn` with the month scrollport reporting the height its CSS gives it.
|
|
143
|
+
*
|
|
144
|
+
* jsdom lays nothing out, so `clientHeight` is 0 and the scroller mounts no
|
|
145
|
+
* panes at all. The override is installed only around the work that needs it
|
|
146
|
+
* and torn down immediately: a getter left on `HTMLElement.prototype` makes
|
|
147
|
+
* EVERY later DOM read take a slow path, measured at ~2.4s per test here.
|
|
148
|
+
*/
|
|
149
|
+
/** One animation frame, for the rAF-throttled scroll handlers. */
|
|
150
|
+
async function frame(): Promise<void> {
|
|
151
|
+
return new Promise(resolve => requestAnimationFrame(() => resolve()));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function withLayout<T>(fn: () => T): T {
|
|
155
|
+
// jsdom defines clientWidth on Element.prototype; shadowing it on
|
|
156
|
+
// HTMLElement.prototype and deleting the shadow afterwards restores the
|
|
157
|
+
// original getter without having to copy its descriptor.
|
|
158
|
+
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
|
|
159
|
+
configurable: true,
|
|
160
|
+
get(this: HTMLElement) {
|
|
161
|
+
return this.dataset?.scroller === 'months' ? SCROLLPORT_WIDTH : 0;
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
try {
|
|
165
|
+
return fn();
|
|
166
|
+
} finally {
|
|
167
|
+
delete (HTMLElement.prototype as {clientWidth?: unknown}).clientWidth;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
beforeAll(() => {
|
|
172
|
+
Element.prototype.scrollTo = vi.fn();
|
|
173
|
+
// jsdom's <dialog> implements none of these.
|
|
174
|
+
HTMLDialogElement.prototype.showModal = vi.fn(function (
|
|
175
|
+
this: HTMLDialogElement,
|
|
176
|
+
) {
|
|
177
|
+
this.open = true;
|
|
178
|
+
});
|
|
179
|
+
HTMLDialogElement.prototype.show = vi.fn(function (this: HTMLDialogElement) {
|
|
180
|
+
this.open = true;
|
|
181
|
+
});
|
|
182
|
+
HTMLDialogElement.prototype.close = vi.fn(function (this: HTMLDialogElement) {
|
|
183
|
+
this.open = false;
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
afterAll(() => {
|
|
188
|
+
vi.useRealTimers();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
beforeEach(() => {
|
|
192
|
+
vi.setSystemTime(new Date(2026, 2, 15)); // 15 March 2026, local
|
|
193
|
+
// Re-stubbed per test, not once at module scope: the afterEach below clears
|
|
194
|
+
// every stub, and matchMedia has to be re-pointed for each test anyway.
|
|
195
|
+
vi.stubGlobal('ResizeObserver', MockResizeObserver);
|
|
196
|
+
setViewport('mobile');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
afterEach(() => {
|
|
200
|
+
vi.unstubAllGlobals();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Bounded by default. Unbounded, the scroller mounts seven month panes — 294
|
|
205
|
+
* day buttons — for every render, and under jsdom with StyleX's dev runtime
|
|
206
|
+
* that is seconds a test. Three months exercises the same paths; the tests
|
|
207
|
+
* that are actually about the range pass their own.
|
|
208
|
+
*/
|
|
209
|
+
const DEFAULT_RANGE = {min: '2026-02-01', max: '2026-04-30'} as const;
|
|
210
|
+
|
|
211
|
+
function Controlled({
|
|
212
|
+
initial,
|
|
213
|
+
onChange,
|
|
214
|
+
...props
|
|
215
|
+
}: {initial?: ISODateString} & Partial<
|
|
216
|
+
React.ComponentProps<typeof DateInput>
|
|
217
|
+
>) {
|
|
218
|
+
const [value, setValue] = useState<ISODateString | undefined>(initial);
|
|
219
|
+
return (
|
|
220
|
+
<DateInput
|
|
221
|
+
label="Event date"
|
|
222
|
+
{...DEFAULT_RANGE}
|
|
223
|
+
{...props}
|
|
224
|
+
value={value}
|
|
225
|
+
// Composed, not overridden: spreading a test's `onChange` over this one
|
|
226
|
+
// used to replace the state setter, so the field silently stopped
|
|
227
|
+
// updating in exactly the tests that were watching it most closely.
|
|
228
|
+
onChange={next => {
|
|
229
|
+
setValue(next);
|
|
230
|
+
onChange?.(next);
|
|
231
|
+
}}
|
|
232
|
+
/>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The closed field — a real input on both surfaces. */
|
|
237
|
+
const field = (): HTMLInputElement => screen.getByRole('combobox');
|
|
238
|
+
|
|
239
|
+
/** Render, then open the picker. Both mount panes, so both need the layout. */
|
|
240
|
+
function renderAndOpen(
|
|
241
|
+
ui: React.ReactElement = <Controlled initial="2026-03-21" />,
|
|
242
|
+
): void {
|
|
243
|
+
withLayout(() => {
|
|
244
|
+
render(ui);
|
|
245
|
+
fireEvent.click(field());
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* One month's grid. Panes for neighbouring months are mounted too, so every
|
|
251
|
+
* day query has to say which month it means.
|
|
252
|
+
*/
|
|
253
|
+
function pane(label: string): HTMLElement {
|
|
254
|
+
const grid = screen
|
|
255
|
+
.getAllByRole('grid')
|
|
256
|
+
.find(g => g.getAttribute('aria-label') === label);
|
|
257
|
+
if (grid == null) {
|
|
258
|
+
throw new Error(`no pane for ${label}`);
|
|
259
|
+
}
|
|
260
|
+
return grid;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** The decorative weekday row: the only 7-cell aria-hidden block. */
|
|
264
|
+
function weekdayRow(): HTMLElement {
|
|
265
|
+
const row = [...document.querySelectorAll('[aria-hidden="true"]')].find(
|
|
266
|
+
el => el.children.length === 7,
|
|
267
|
+
);
|
|
268
|
+
if (row == null) {
|
|
269
|
+
throw new Error('no weekday row');
|
|
270
|
+
}
|
|
271
|
+
return row as HTMLElement;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
// Which surface, and why
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
describe('DateInput — surface selection', () => {
|
|
279
|
+
it('switches on the pointer alone, so a tablet gets the picker too', () => {
|
|
280
|
+
// `pointer` is the PRIMARY device, which is what makes it the whole test:
|
|
281
|
+
// a touchscreen laptop reports `fine` (its trackpad) and keeps the
|
|
282
|
+
// typable field, and a narrowed desktop window is still a mouse. A width
|
|
283
|
+
// bound would only re-exclude tablets — the clearest case for a thumb
|
|
284
|
+
// picker there is — so there deliberately is not one.
|
|
285
|
+
//
|
|
286
|
+
// An 1194px tablet in landscape: coarse pointer, far wider than any
|
|
287
|
+
// handset breakpoint. It answers width queries honestly, so a width bound
|
|
288
|
+
// creeping back in would fail here rather than pass silently.
|
|
289
|
+
stubMedia({pointer: 'coarse', width: 1194});
|
|
290
|
+
render(<Controlled initial="2026-03-21" />);
|
|
291
|
+
expect(field()).toHaveAttribute('readonly');
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('keeps the typable field for a touchscreen laptop', () => {
|
|
295
|
+
// Touch available, but the trackpad is primary — so `pointer` is `fine`
|
|
296
|
+
// and only `any-pointer` is coarse. The keyboard is right there, and
|
|
297
|
+
// typing a date beats scrolling to it.
|
|
298
|
+
stubMedia({pointer: 'fine', anyPointer: 'coarse', width: 1366});
|
|
299
|
+
render(<Controlled initial="2026-03-21" />);
|
|
300
|
+
expect(field()).not.toHaveAttribute('readonly');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('keeps the typable field in a narrowed desktop window', () => {
|
|
304
|
+
stubMedia({pointer: 'fine', width: 500});
|
|
305
|
+
render(<Controlled initial="2026-03-21" />);
|
|
306
|
+
expect(field()).not.toHaveAttribute('readonly');
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('renders the desktop DateInput when the query does not match', () => {
|
|
310
|
+
setViewport('desktop');
|
|
311
|
+
render(<Controlled initial="2026-03-21" />);
|
|
312
|
+
// The desktop control is a text field you can type into...
|
|
313
|
+
expect(field()).not.toHaveAttribute('readonly');
|
|
314
|
+
expect(field()).not.toHaveAttribute('inputmode');
|
|
315
|
+
// ...and it opens a popover, not a sheet.
|
|
316
|
+
expect(document.querySelector('dialog')).toBeNull();
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('accepts typed input on the desktop surface', () => {
|
|
320
|
+
setViewport('desktop');
|
|
321
|
+
const onChange = vi.fn();
|
|
322
|
+
render(
|
|
323
|
+
<DateInput label="Event date" onChange={onChange} min={undefined} />,
|
|
324
|
+
);
|
|
325
|
+
fireEvent.change(field(), {target: {value: '2026-03-25'}});
|
|
326
|
+
expect(onChange).toHaveBeenCalledWith('2026-03-25');
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('renders the touch field when the query matches', () => {
|
|
330
|
+
render(<Controlled initial="2026-03-21" />);
|
|
331
|
+
const input = field();
|
|
332
|
+
// Still an input — same element, same role, so `ref` stays honest and the
|
|
333
|
+
// label associates natively — but the picker is the only way to change it.
|
|
334
|
+
expect(input.tagName).toBe('INPUT');
|
|
335
|
+
expect(input).toHaveAttribute('readonly');
|
|
336
|
+
// What actually keeps the virtual keyboard from covering the sheet.
|
|
337
|
+
expect(input).toHaveAttribute('inputmode', 'none');
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('forwards ref to the input on both surfaces', () => {
|
|
341
|
+
const desktopRef = {current: null as HTMLInputElement | null};
|
|
342
|
+
setViewport('desktop');
|
|
343
|
+
const {unmount} = render(
|
|
344
|
+
<Controlled initial="2026-03-21" ref={desktopRef} />,
|
|
345
|
+
);
|
|
346
|
+
expect(desktopRef.current).toBeInstanceOf(HTMLInputElement);
|
|
347
|
+
unmount();
|
|
348
|
+
|
|
349
|
+
const mobileRef = {current: null as HTMLInputElement | null};
|
|
350
|
+
setViewport('mobile');
|
|
351
|
+
render(<Controlled initial="2026-03-21" ref={mobileRef} />);
|
|
352
|
+
expect(mobileRef.current).toBeInstanceOf(HTMLInputElement);
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
// The field contract, honored identically on the touch surface
|
|
358
|
+
// ---------------------------------------------------------------------------
|
|
359
|
+
|
|
360
|
+
describe('DateInput — field parity', () => {
|
|
361
|
+
it('shows a placeholder until a date is chosen, then the formatted value', () => {
|
|
362
|
+
const {rerender} = render(
|
|
363
|
+
<DateInput label="Ship date" onChange={() => {}} />,
|
|
364
|
+
);
|
|
365
|
+
expect(field()).toHaveValue('');
|
|
366
|
+
expect(field()).toHaveAttribute('placeholder', 'Select a date');
|
|
367
|
+
rerender(
|
|
368
|
+
<DateInput label="Ship date" value="2026-03-21" onChange={() => {}} />,
|
|
369
|
+
);
|
|
370
|
+
expect(field()).toHaveValue('March 21, 2026');
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('honors a named format', () => {
|
|
374
|
+
render(
|
|
375
|
+
<DateInput
|
|
376
|
+
label="Ship date"
|
|
377
|
+
value="2026-03-21"
|
|
378
|
+
format="system_date"
|
|
379
|
+
onChange={() => {}}
|
|
380
|
+
/>,
|
|
381
|
+
);
|
|
382
|
+
expect(field()).toHaveValue('2026-03-21');
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('honors a function format', () => {
|
|
386
|
+
render(
|
|
387
|
+
<DateInput
|
|
388
|
+
label="Ship date"
|
|
389
|
+
value="2026-03-21"
|
|
390
|
+
format={iso => `ISO:${iso}`}
|
|
391
|
+
onChange={() => {}}
|
|
392
|
+
/>,
|
|
393
|
+
);
|
|
394
|
+
expect(field()).toHaveValue('ISO:2026-03-21');
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('honors a custom placeholder', () => {
|
|
398
|
+
render(
|
|
399
|
+
<DateInput
|
|
400
|
+
label="Ship date"
|
|
401
|
+
placeholder="Pick a day"
|
|
402
|
+
onChange={() => {}}
|
|
403
|
+
/>,
|
|
404
|
+
);
|
|
405
|
+
expect(field()).toHaveAttribute('placeholder', 'Pick a day');
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('clears from the field', () => {
|
|
409
|
+
const onChange = vi.fn();
|
|
410
|
+
render(
|
|
411
|
+
<DateInput
|
|
412
|
+
label="Ship date"
|
|
413
|
+
value="2026-03-21"
|
|
414
|
+
hasClear
|
|
415
|
+
onChange={onChange}
|
|
416
|
+
/>,
|
|
417
|
+
);
|
|
418
|
+
fireEvent.click(screen.getByRole('button', {name: /Clear Ship date/}));
|
|
419
|
+
expect(onChange).toHaveBeenCalledWith(undefined);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it('does not open the picker until the field is tapped', () => {
|
|
423
|
+
withLayout(() => {
|
|
424
|
+
render(<DateInput label="Ship date" onChange={() => {}} />);
|
|
425
|
+
expect(field()).toHaveAttribute('aria-expanded', 'false');
|
|
426
|
+
expect(screen.queryByRole('grid')).not.toBeInTheDocument();
|
|
427
|
+
fireEvent.click(field());
|
|
428
|
+
expect(field()).toHaveAttribute('aria-expanded', 'true');
|
|
429
|
+
expect(screen.getAllByRole('grid').length).toBeGreaterThan(0);
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it('opens from the keyboard, APG combobox style', () => {
|
|
434
|
+
withLayout(() => {
|
|
435
|
+
render(<Controlled initial="2026-03-21" />);
|
|
436
|
+
fireEvent.keyDown(field(), {key: 'ArrowDown'});
|
|
437
|
+
expect(field()).toHaveAttribute('aria-expanded', 'true');
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it('is not openable while disabled', () => {
|
|
442
|
+
render(<DateInput label="Ship date" isDisabled onChange={() => {}} />);
|
|
443
|
+
expect(field()).toBeDisabled();
|
|
444
|
+
fireEvent.click(field());
|
|
445
|
+
expect(screen.queryByRole('grid')).not.toBeInTheDocument();
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it('stays focusable and explains itself when disabled with a reason', () => {
|
|
449
|
+
render(
|
|
450
|
+
<DateInput
|
|
451
|
+
label="Ship date"
|
|
452
|
+
isDisabled
|
|
453
|
+
disabledMessage="You need the Editor role"
|
|
454
|
+
onChange={() => {}}
|
|
455
|
+
/>,
|
|
456
|
+
);
|
|
457
|
+
// aria-disabled, not disabled: the reason has to be reachable by keyboard.
|
|
458
|
+
expect(field()).not.toBeDisabled();
|
|
459
|
+
expect(field()).toHaveAttribute('aria-disabled', 'true');
|
|
460
|
+
fireEvent.click(field());
|
|
461
|
+
expect(screen.queryByRole('grid')).not.toBeInTheDocument();
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it('renders label, description and status through Field', () => {
|
|
465
|
+
render(
|
|
466
|
+
<DateInput
|
|
467
|
+
label="Ship date"
|
|
468
|
+
description="When it leaves the warehouse"
|
|
469
|
+
status={{type: 'error', message: 'Pick a date'}}
|
|
470
|
+
onChange={() => {}}
|
|
471
|
+
/>,
|
|
472
|
+
);
|
|
473
|
+
expect(screen.getByText('Ship date')).toBeInTheDocument();
|
|
474
|
+
expect(
|
|
475
|
+
screen.getByText('When it leaves the warehouse'),
|
|
476
|
+
).toBeInTheDocument();
|
|
477
|
+
expect(screen.getByText('Pick a date')).toBeInTheDocument();
|
|
478
|
+
expect(field()).toHaveAttribute('aria-invalid', 'true');
|
|
479
|
+
expect(field().getAttribute('aria-describedby')).toBeTruthy();
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it('marks required for assistive technology', () => {
|
|
483
|
+
render(<DateInput label="Ship date" isRequired onChange={() => {}} />);
|
|
484
|
+
expect(field()).toHaveAttribute('aria-required', 'true');
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it('associates the label natively, so the field is named without ARIA', () => {
|
|
488
|
+
render(<DateInput label="Ship date" onChange={() => {}} />);
|
|
489
|
+
expect(screen.getByLabelText('Ship date')).toBe(field());
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it('runs changeAction and shows a busy state', async () => {
|
|
493
|
+
let resolve: () => void = () => {};
|
|
494
|
+
const changeAction = vi.fn(
|
|
495
|
+
async () =>
|
|
496
|
+
new Promise<void>(r => {
|
|
497
|
+
resolve = r;
|
|
498
|
+
}),
|
|
499
|
+
);
|
|
500
|
+
withLayout(() => {
|
|
501
|
+
render(
|
|
502
|
+
<DateInput
|
|
503
|
+
label="Ship date"
|
|
504
|
+
value="2026-03-10"
|
|
505
|
+
min="2026-03-01"
|
|
506
|
+
max="2026-03-31"
|
|
507
|
+
onChange={() => {}}
|
|
508
|
+
changeAction={changeAction}
|
|
509
|
+
/>,
|
|
510
|
+
);
|
|
511
|
+
fireEvent.click(field());
|
|
512
|
+
fireEvent.click(
|
|
513
|
+
within(pane('March 2026')).getByRole('button', {
|
|
514
|
+
name: /March 25, 2026/,
|
|
515
|
+
}),
|
|
516
|
+
);
|
|
517
|
+
});
|
|
518
|
+
expect(changeAction).toHaveBeenCalledWith('2026-03-25');
|
|
519
|
+
resolve();
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it('drops the Field wrapper inside an InputGroup', () => {
|
|
523
|
+
render(
|
|
524
|
+
<InputGroup label="Range">
|
|
525
|
+
<DateInput label="Start" onChange={() => {}} />
|
|
526
|
+
</InputGroup>,
|
|
527
|
+
);
|
|
528
|
+
// Named by the group label plus its own, the way core's inputs are.
|
|
529
|
+
expect(field().getAttribute('aria-labelledby')).toBeTruthy();
|
|
530
|
+
});
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
// ---------------------------------------------------------------------------
|
|
534
|
+
// The picker surface
|
|
535
|
+
// ---------------------------------------------------------------------------
|
|
536
|
+
|
|
537
|
+
describe('DateInput — calendar surface', () => {
|
|
538
|
+
it('opens on the selected month', () => {
|
|
539
|
+
renderAndOpen();
|
|
540
|
+
expect(pane('March 2026')).toBeInTheDocument();
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it('opens on the current month when there is no value', () => {
|
|
544
|
+
renderAndOpen(<Controlled />);
|
|
545
|
+
expect(pane('March 2026')).toBeInTheDocument();
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it('mounts a window of months around the visible one, not the whole century', () => {
|
|
549
|
+
// The one test that wants the full default reach.
|
|
550
|
+
renderAndOpen(
|
|
551
|
+
<Controlled initial="2026-03-21" min={undefined} max={undefined} />,
|
|
552
|
+
);
|
|
553
|
+
const grids = screen.getAllByRole('grid');
|
|
554
|
+
expect(grids.length).toBeGreaterThan(1);
|
|
555
|
+
expect(grids.length).toBeLessThanOrEqual(7);
|
|
556
|
+
expect(grids.map(g => g.getAttribute('aria-label'))).toContain(
|
|
557
|
+
'March 2026',
|
|
558
|
+
);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it('renders every month as six rows, so panes cannot differ in height', () => {
|
|
562
|
+
renderAndOpen(<Controlled initial="2026-02-01" />);
|
|
563
|
+
// February 2026 needs only five rows; the pane still has six.
|
|
564
|
+
const february = within(pane('February 2026'));
|
|
565
|
+
expect(february.getAllByRole('row')).toHaveLength(6);
|
|
566
|
+
expect(february.getAllByRole('gridcell')).toHaveLength(42);
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
it('commits the tapped day and LEAVES the sheet open', () => {
|
|
570
|
+
const onChange = vi.fn();
|
|
571
|
+
renderAndOpen(<Controlled initial="2026-03-21" onChange={onChange} />);
|
|
572
|
+
fireEvent.click(
|
|
573
|
+
within(pane('March 2026')).getByRole('button', {name: /March 25, 2026/}),
|
|
574
|
+
);
|
|
575
|
+
// The tap is the commit. Staying open lets a mistake be corrected in
|
|
576
|
+
// place, and a nearby date reconsidered, without reopening.
|
|
577
|
+
expect(onChange).toHaveBeenCalledWith('2026-03-25');
|
|
578
|
+
expect(field()).toHaveAttribute('aria-expanded', 'true');
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it('lets a second tap correct the first, still without closing', () => {
|
|
582
|
+
const onChange = vi.fn();
|
|
583
|
+
renderAndOpen(<Controlled initial="2026-03-21" onChange={onChange} />);
|
|
584
|
+
const march = within(pane('March 2026'));
|
|
585
|
+
fireEvent.click(march.getByRole('button', {name: /March 25, 2026/}));
|
|
586
|
+
fireEvent.click(march.getByRole('button', {name: /March 26, 2026/}));
|
|
587
|
+
expect(onChange).toHaveBeenLastCalledWith('2026-03-26');
|
|
588
|
+
expect(field()).toHaveAttribute('aria-expanded', 'true');
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
it('Save closes the sheet without touching the value', () => {
|
|
592
|
+
const onChange = vi.fn();
|
|
593
|
+
renderAndOpen(<Controlled initial="2026-03-21" onChange={onChange} />);
|
|
594
|
+
fireEvent.click(
|
|
595
|
+
within(pane('March 2026')).getByRole('button', {name: /March 25, 2026/}),
|
|
596
|
+
);
|
|
597
|
+
onChange.mockClear();
|
|
598
|
+
fireEvent.click(screen.getByRole('button', {name: 'Save'}));
|
|
599
|
+
expect(field()).toHaveAttribute('aria-expanded', 'false');
|
|
600
|
+
// Named for what it means to someone finishing a form, not for what it
|
|
601
|
+
// does internally: the value was already committed by the tap that chose
|
|
602
|
+
// it, so this fires nothing of its own.
|
|
603
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
604
|
+
expect(field()).toHaveValue('March 25, 2026');
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it('Save closes even with no date chosen, committing nothing', () => {
|
|
608
|
+
const onChange = vi.fn();
|
|
609
|
+
withLayout(() => {
|
|
610
|
+
render(<DateInput label="Ship date" onChange={onChange} />);
|
|
611
|
+
fireEvent.click(field());
|
|
612
|
+
fireEvent.click(screen.getByRole('button', {name: 'Save'}));
|
|
613
|
+
});
|
|
614
|
+
expect(field()).toHaveAttribute('aria-expanded', 'false');
|
|
615
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
it('has Save in the footer and no Today button', () => {
|
|
619
|
+
renderAndOpen();
|
|
620
|
+
expect(screen.getByRole('button', {name: 'Save'})).toBeInTheDocument();
|
|
621
|
+
// "Today" moved the calendar to the current month WITHOUT selecting it,
|
|
622
|
+
// which read as broken — the one thing the name promises is the thing it
|
|
623
|
+
// did not do. Removed until it can be navigate-or-select on purpose.
|
|
624
|
+
expect(screen.queryByRole('button', {name: 'Today'})).toBeNull();
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
it('marks the selection and today', () => {
|
|
628
|
+
renderAndOpen();
|
|
629
|
+
const march = within(pane('March 2026'));
|
|
630
|
+
expect(
|
|
631
|
+
march
|
|
632
|
+
.getByRole('button', {name: /March 21, 2026/})
|
|
633
|
+
.closest('[role="gridcell"]'),
|
|
634
|
+
).toHaveAttribute('aria-selected', 'true');
|
|
635
|
+
expect(march.getByRole('button', {name: /March 15, 2026/})).toHaveAttribute(
|
|
636
|
+
'aria-current',
|
|
637
|
+
'date',
|
|
638
|
+
);
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
it('leaves exactly one day per month tab-reachable', () => {
|
|
642
|
+
renderAndOpen();
|
|
643
|
+
const tabbable = within(pane('March 2026'))
|
|
644
|
+
.getAllByRole('button')
|
|
645
|
+
.filter(b => b.getAttribute('tabindex') === '0');
|
|
646
|
+
expect(tabbable).toHaveLength(1);
|
|
647
|
+
// The selected day, when the month holds one.
|
|
648
|
+
expect(tabbable[0]).toHaveAttribute('data-date', '2026-03-21');
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
it('pages a month with the header arrows', () => {
|
|
652
|
+
renderAndOpen();
|
|
653
|
+
const header = () =>
|
|
654
|
+
document.querySelector<HTMLElement>('[data-title="month-year"]')!;
|
|
655
|
+
expect(header()).toHaveTextContent('March 2026');
|
|
656
|
+
fireEvent.click(screen.getByRole('button', {name: 'Next month'}));
|
|
657
|
+
expect(header()).toHaveTextContent('April 2026');
|
|
658
|
+
fireEvent.click(screen.getByRole('button', {name: 'Previous month'}));
|
|
659
|
+
expect(header()).toHaveTextContent('March 2026');
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* A programmatic scroll must not report its own arrival as if the user had
|
|
664
|
+
* scrolled there. Nothing needs the report — whatever asked for the scroll
|
|
665
|
+
* already knows the month — and trusting it turns a steer into a cycle.
|
|
666
|
+
*
|
|
667
|
+
* The case that bit: a wheel commit steers this scroller while it is hidden
|
|
668
|
+
* behind the wheels, and a hidden scroller does not reliably stay put. On
|
|
669
|
+
* iOS the position is re-snapped when the panel becomes visible again,
|
|
670
|
+
* firing a scroll just as the wheels close and reports start being trusted
|
|
671
|
+
* again — so the month drifted on the way back to the calendar.
|
|
672
|
+
*/
|
|
673
|
+
it('does not report a scroll it was told to make', async () => {
|
|
674
|
+
await withLayout(async () => {
|
|
675
|
+
render(
|
|
676
|
+
<Controlled initial="2026-03-21" min="2026-01-01" max="2026-12-31" />,
|
|
677
|
+
);
|
|
678
|
+
fireEvent.click(field());
|
|
679
|
+
const header = () =>
|
|
680
|
+
document.querySelector<HTMLElement>('[data-title="month-year"]')!;
|
|
681
|
+
const scroller = document.querySelector<HTMLElement>(
|
|
682
|
+
'[data-scroller="months"]',
|
|
683
|
+
)!;
|
|
684
|
+
|
|
685
|
+
// An arrow steers it to April. The scroll that lands there is our own
|
|
686
|
+
// doing, so whatever it reports must not move the month again.
|
|
687
|
+
fireEvent.click(screen.getByRole('button', {name: 'Next month'}));
|
|
688
|
+
expect(header()).toHaveTextContent('April 2026');
|
|
689
|
+
// Row 3 of a range starting in January is April: the month the steer
|
|
690
|
+
// was aiming at, arriving.
|
|
691
|
+
scroller.scrollLeft = 3 * SCROLLPORT_WIDTH;
|
|
692
|
+
fireEvent.scroll(scroller);
|
|
693
|
+
await frame();
|
|
694
|
+
expect(header()).toHaveTextContent('April 2026');
|
|
695
|
+
|
|
696
|
+
// A finger ends the steering: from here the months it passes are the
|
|
697
|
+
// user's, and every one of them counts.
|
|
698
|
+
// jsdom has no constructible TouchEvent, and a bare Event carries no
|
|
699
|
+
// `touches` — which crashes the gesture hook that reads `touches[0]`.
|
|
700
|
+
// That crash is the fake event's fault, not the hook's: a browser's
|
|
701
|
+
// touchstart always has the list.
|
|
702
|
+
const touchStart = new Event('touchstart', {bubbles: true});
|
|
703
|
+
const point = {identifier: 1, clientX: 100, clientY: 100};
|
|
704
|
+
Object.defineProperties(touchStart, {
|
|
705
|
+
touches: {value: [point]},
|
|
706
|
+
targetTouches: {value: [point]},
|
|
707
|
+
changedTouches: {value: [point]},
|
|
708
|
+
});
|
|
709
|
+
scroller.dispatchEvent(touchStart);
|
|
710
|
+
scroller.scrollLeft = 5 * SCROLLPORT_WIDTH;
|
|
711
|
+
fireEvent.scroll(scroller);
|
|
712
|
+
// The report is made from inside a rAF callback, so the state update
|
|
713
|
+
// lands outside the act() that fireEvent wraps.
|
|
714
|
+
await waitFor(() => expect(header()).toHaveTextContent('June 2026'));
|
|
715
|
+
});
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* An arrow with nowhere to go is hidden, not greyed. A disabled control
|
|
720
|
+
* still says "this is a thing you could do", and at the edge of a range it
|
|
721
|
+
* is not — there is no state the user can reach where it becomes
|
|
722
|
+
* available, so a permanently greyed chevron just reads as broken.
|
|
723
|
+
*
|
|
724
|
+
* It keeps its box, though: `visibility: hidden` rather than unmounting,
|
|
725
|
+
* so the remaining arrow does not slide sideways as an edge is reached.
|
|
726
|
+
*/
|
|
727
|
+
it('hides an arrow at the end of the reachable range', () => {
|
|
728
|
+
renderAndOpen(
|
|
729
|
+
<Controlled initial="2026-03-10" min="2026-03-01" max="2026-03-31" />,
|
|
730
|
+
);
|
|
731
|
+
// Queried by attribute, not by role: `visibility: hidden` is exactly what
|
|
732
|
+
// strips an element of its accessible name, so a role query cannot see
|
|
733
|
+
// these — which is the point of the assertion below.
|
|
734
|
+
const arrow = (name: string) =>
|
|
735
|
+
document.querySelector<HTMLElement>(
|
|
736
|
+
`dialog[open] button[aria-label="${name}"]`,
|
|
737
|
+
);
|
|
738
|
+
for (const name of ['Previous month', 'Next month']) {
|
|
739
|
+
// Still mounted, so the header cannot reflow...
|
|
740
|
+
expect(arrow(name)).toBeInTheDocument();
|
|
741
|
+
expect(arrow(name)).toBeDisabled();
|
|
742
|
+
// ...but gone from the accessibility tree, and so unreachable.
|
|
743
|
+
expect(screen.queryByRole('button', {name})).toBeNull();
|
|
744
|
+
}
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
it('shows both arrows when there is somewhere to go in each direction', () => {
|
|
748
|
+
renderAndOpen(
|
|
749
|
+
<Controlled initial="2026-03-10" min="2026-01-01" max="2026-12-31" />,
|
|
750
|
+
);
|
|
751
|
+
expect(
|
|
752
|
+
screen.getByRole('button', {name: 'Previous month'}),
|
|
753
|
+
).toBeInTheDocument();
|
|
754
|
+
expect(
|
|
755
|
+
screen.getByRole('button', {name: 'Next month'}),
|
|
756
|
+
).toBeInTheDocument();
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
it('does not change the selection when paging', () => {
|
|
760
|
+
const onChange = vi.fn();
|
|
761
|
+
renderAndOpen(<Controlled initial="2026-03-21" onChange={onChange} />);
|
|
762
|
+
fireEvent.click(screen.getByRole('button', {name: 'Next month'}));
|
|
763
|
+
// Navigating is not selecting — the mistake the old Today button made.
|
|
764
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
765
|
+
expect(field()).toHaveValue('March 21, 2026');
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
it('disables days outside min/max and refuses to commit them', () => {
|
|
769
|
+
const onChange = vi.fn();
|
|
770
|
+
renderAndOpen(
|
|
771
|
+
<Controlled
|
|
772
|
+
initial="2026-03-10"
|
|
773
|
+
min="2026-03-05"
|
|
774
|
+
max="2026-03-20"
|
|
775
|
+
onChange={onChange}
|
|
776
|
+
/>,
|
|
777
|
+
);
|
|
778
|
+
const outOfRange = within(pane('March 2026')).getByRole('button', {
|
|
779
|
+
name: /March 25, 2026/,
|
|
780
|
+
});
|
|
781
|
+
expect(outOfRange).toHaveAttribute('aria-disabled', 'true');
|
|
782
|
+
fireEvent.click(outOfRange);
|
|
783
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
it('honors custom date constraints', () => {
|
|
787
|
+
renderAndOpen(
|
|
788
|
+
<Controlled
|
|
789
|
+
initial="2026-03-10"
|
|
790
|
+
dateConstraints={[date => date.getDay() !== 0]}
|
|
791
|
+
/>,
|
|
792
|
+
);
|
|
793
|
+
// 2026-03-01 is a Sunday.
|
|
794
|
+
expect(
|
|
795
|
+
within(pane('March 2026')).getByRole('button', {name: /March 1, 2026/}),
|
|
796
|
+
).toHaveAttribute('aria-disabled', 'true');
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Spill days, the way the desktop calendar has them.
|
|
801
|
+
*
|
|
802
|
+
* These were left out at first, on the theory that a horizontal scroller
|
|
803
|
+
* would show the same date twice — greyed at the foot of one pane and
|
|
804
|
+
* again at the head of the next. Measured, that cannot happen: both panes
|
|
805
|
+
* are exactly the scrollport wide and share one 7-column grid, so a given
|
|
806
|
+
* weekday column is only ever visible in ONE pane at a time, and a date
|
|
807
|
+
* always sits in its weekday's column. Swept across a full pane boundary
|
|
808
|
+
* in 10% steps — 42 dates on screen throughout, zero duplicated.
|
|
809
|
+
*/
|
|
810
|
+
it('renders adjacent-month days, muted, like the desktop calendar', () => {
|
|
811
|
+
renderAndOpen();
|
|
812
|
+
const march = within(pane('March 2026'));
|
|
813
|
+
// Every cell in the 6x7 grid is now a real day.
|
|
814
|
+
expect(march.getAllByRole('gridcell')).toHaveLength(42);
|
|
815
|
+
expect(march.getAllByRole('button')).toHaveLength(42);
|
|
816
|
+
// March 2026 begins on a Sunday, so it spills only forwards.
|
|
817
|
+
expect(
|
|
818
|
+
march.getByRole('button', {name: /April 1, 2026/}),
|
|
819
|
+
).toBeInTheDocument();
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
it('spills backwards too, on a month that does not start the week', () => {
|
|
823
|
+
renderAndOpen(
|
|
824
|
+
<Controlled initial="2026-04-15" min="2026-01-01" max="2026-12-31" />,
|
|
825
|
+
);
|
|
826
|
+
const april = within(pane('April 2026'));
|
|
827
|
+
// April 2026 starts on a Wednesday: the first row opens with late March.
|
|
828
|
+
expect(
|
|
829
|
+
april.getByRole('button', {name: /March 29, 2026/}),
|
|
830
|
+
).toBeInTheDocument();
|
|
831
|
+
expect(april.getAllByRole('button')).toHaveLength(42);
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* A spilled day is shown, not offered.
|
|
836
|
+
*
|
|
837
|
+
* It was pickable at first, on the reasoning that a date you can see is a
|
|
838
|
+
* date you should be able to tap. The desktop calendar disagrees — it makes
|
|
839
|
+
* its own outside days unselectable — and on a pane that IS the month, so
|
|
840
|
+
* does the interaction: committing April 1 from March's pane would move the
|
|
841
|
+
* calendar out from under the thumb that just tapped it. The swipe and the
|
|
842
|
+
* arrows say "next month" without that ambiguity.
|
|
843
|
+
*/
|
|
844
|
+
it('does not commit an adjacent-month day', () => {
|
|
845
|
+
const onChange = vi.fn();
|
|
846
|
+
renderAndOpen(<Controlled initial="2026-03-21" onChange={onChange} />);
|
|
847
|
+
const april1 = within(pane('March 2026')).getByRole('button', {
|
|
848
|
+
name: /April 1, 2026/,
|
|
849
|
+
});
|
|
850
|
+
expect(april1).toHaveAttribute('aria-disabled', 'true');
|
|
851
|
+
fireEvent.click(april1);
|
|
852
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Being outside is enough on its own — a spill day inside the allowed range
|
|
857
|
+
* is still not a choice. Worth its own case because `isDateDisabled` says
|
|
858
|
+
* nothing about April 1 here, so only the outside test can be what disables
|
|
859
|
+
* it.
|
|
860
|
+
*/
|
|
861
|
+
it('disables a spilled day the range would otherwise allow', () => {
|
|
862
|
+
renderAndOpen(
|
|
863
|
+
<Controlled initial="2026-03-21" min="2026-01-01" max="2026-12-31" />,
|
|
864
|
+
);
|
|
865
|
+
const march = within(pane('March 2026'));
|
|
866
|
+
// In range, and in its own pane it is perfectly pickable.
|
|
867
|
+
expect(
|
|
868
|
+
within(pane('April 2026')).getByRole('button', {name: /April 1, 2026/}),
|
|
869
|
+
).not.toHaveAttribute('aria-disabled');
|
|
870
|
+
// Borrowed by March, it is not.
|
|
871
|
+
expect(march.getByRole('button', {name: /April 1, 2026/})).toHaveAttribute(
|
|
872
|
+
'aria-disabled',
|
|
873
|
+
'true',
|
|
874
|
+
);
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* The selection and today's ring belong to the month that owns the date.
|
|
879
|
+
* Calendar guards both on `!isOutside`; without the same guard a date would
|
|
880
|
+
* wear its puck twice, once in its own pane and once in the neighbour that
|
|
881
|
+
* only borrows it.
|
|
882
|
+
*/
|
|
883
|
+
it('leaves the puck and the today ring with the month that owns the day', () => {
|
|
884
|
+
renderAndOpen(
|
|
885
|
+
<Controlled initial="2026-04-01" min="2026-01-01" max="2026-12-31" />,
|
|
886
|
+
);
|
|
887
|
+
const spilled = within(pane('March 2026')).getByRole('button', {
|
|
888
|
+
name: /April 1, 2026/,
|
|
889
|
+
});
|
|
890
|
+
const owned = within(pane('April 2026')).getByRole('button', {
|
|
891
|
+
name: /April 1, 2026/,
|
|
892
|
+
});
|
|
893
|
+
// Same date, same label — and only one of them is dressed as selected.
|
|
894
|
+
expect(owned.className).not.toBe(spilled.className);
|
|
895
|
+
expect(
|
|
896
|
+
within(pane('April 2026')).getAllByRole('gridcell', {selected: true}),
|
|
897
|
+
).toHaveLength(1);
|
|
898
|
+
expect(
|
|
899
|
+
within(pane('March 2026')).queryAllByRole('gridcell', {selected: true}),
|
|
900
|
+
).toHaveLength(0);
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* A date shown in two panes must not be two tab stops — the pane that owns
|
|
905
|
+
* it, and the neighbour that merely spills it. That holds because
|
|
906
|
+
* `tabbableISO` is resolved per pane and only names dates in that pane's
|
|
907
|
+
* own month, so it is worth pinning: the case below is the one that would
|
|
908
|
+
* break if it ever became global, since April 1 is both the selection and
|
|
909
|
+
* a spill day in March's pane.
|
|
910
|
+
*/
|
|
911
|
+
it('gives a spilled date no tab stop in the pane that borrows it', () => {
|
|
912
|
+
renderAndOpen(
|
|
913
|
+
<Controlled initial="2026-04-01" min="2026-01-01" max="2026-12-31" />,
|
|
914
|
+
);
|
|
915
|
+
const tabbableIn = (label: string) =>
|
|
916
|
+
within(pane(label))
|
|
917
|
+
.getAllByRole('button')
|
|
918
|
+
.filter(b => b.getAttribute('tabindex') === '0')
|
|
919
|
+
.map(b => b.getAttribute('aria-label'));
|
|
920
|
+
|
|
921
|
+
// April owns the date, so its stop is the date itself.
|
|
922
|
+
expect(tabbableIn('April 2026')).toHaveLength(1);
|
|
923
|
+
expect(tabbableIn('April 2026')[0]).toMatch(/April 1, 2026/);
|
|
924
|
+
// March shows the same date, but its stop falls back to a March date —
|
|
925
|
+
// every pane keeps exactly one, and never on a day it does not own.
|
|
926
|
+
expect(tabbableIn('March 2026')).toHaveLength(1);
|
|
927
|
+
expect(tabbableIn('March 2026')[0]).toMatch(/March/);
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
const weekdayNames = () =>
|
|
931
|
+
[...weekdayRow().children].map(c => c.textContent?.trim());
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* Three letters, where Calendar's own header uses two.
|
|
935
|
+
*
|
|
936
|
+
* The sheet is full width — ~51px a column against Calendar's popover — so
|
|
937
|
+
* there is room for the form people actually read, and a picker driven by
|
|
938
|
+
* thumb should not make anyone decode "Tu" against "Th".
|
|
939
|
+
*
|
|
940
|
+
* Exact equality, not `toHaveTextContent`: that matches substrings, so it
|
|
941
|
+
* passes against "Sun" while asserting "Su" and would not have noticed this
|
|
942
|
+
* change at all.
|
|
943
|
+
*/
|
|
944
|
+
it('labels the columns with three-letter weekday names', () => {
|
|
945
|
+
renderAndOpen();
|
|
946
|
+
expect(weekdayNames()).toEqual([
|
|
947
|
+
'Sun',
|
|
948
|
+
'Mon',
|
|
949
|
+
'Tue',
|
|
950
|
+
'Wed',
|
|
951
|
+
'Thu',
|
|
952
|
+
'Fri',
|
|
953
|
+
'Sat',
|
|
954
|
+
]);
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
it('rotates the weekday header with weekStartsOn', () => {
|
|
958
|
+
renderAndOpen(<Controlled initial="2026-03-21" weekStartsOn={1} />);
|
|
959
|
+
expect(weekdayNames()).toEqual([
|
|
960
|
+
'Mon',
|
|
961
|
+
'Tue',
|
|
962
|
+
'Wed',
|
|
963
|
+
'Thu',
|
|
964
|
+
'Fri',
|
|
965
|
+
'Sat',
|
|
966
|
+
'Sun',
|
|
967
|
+
]);
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
it('accepts weekStartsOn as a day name, like Calendar and DateInput', () => {
|
|
971
|
+
renderAndOpen(<Controlled initial="2026-03-21" weekStartsOn="sat" />);
|
|
972
|
+
expect(weekdayNames()[0]).toBe('Sat');
|
|
973
|
+
// The columns rotate with the header, or the grid would be mislabelled.
|
|
974
|
+
expect(
|
|
975
|
+
within(pane('March 2026'))
|
|
976
|
+
.getAllByRole('button')[0]
|
|
977
|
+
.getAttribute('aria-label'),
|
|
978
|
+
).toMatch(/February 28, 2026|March/);
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
it('moves keyboard focus by date, across the month boundary', () => {
|
|
982
|
+
renderAndOpen(<Controlled initial="2026-03-31" />);
|
|
983
|
+
const last = within(pane('March 2026')).getByRole('button', {
|
|
984
|
+
name: /March 31, 2026/,
|
|
985
|
+
});
|
|
986
|
+
last.focus();
|
|
987
|
+
fireEvent.keyDown(last, {key: 'ArrowRight'});
|
|
988
|
+
expect(document.activeElement).toHaveAttribute('data-date', '2026-04-01');
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
it('moves a week at a time with the vertical arrows', () => {
|
|
992
|
+
renderAndOpen(<Controlled initial="2026-03-10" />);
|
|
993
|
+
const day = within(pane('March 2026')).getByRole('button', {
|
|
994
|
+
name: /March 10, 2026/,
|
|
995
|
+
});
|
|
996
|
+
day.focus();
|
|
997
|
+
fireEvent.keyDown(day, {key: 'ArrowDown'});
|
|
998
|
+
expect(document.activeElement).toHaveAttribute('data-date', '2026-03-17');
|
|
999
|
+
});
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
// ---------------------------------------------------------------------------
|
|
1003
|
+
// Resting between two months — the iOS snap failure
|
|
1004
|
+
// ---------------------------------------------------------------------------
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* `scroll-snap-type: mandatory` is supposed to make all of this unnecessary,
|
|
1008
|
+
* and on a static list it does. This list is virtualized: seven panes exist
|
|
1009
|
+
* out of twelve hundred, and THE PANES ARE THE SNAP AREAS — so every month
|
|
1010
|
+
* the finger crosses mounts one and unmounts another, mid-fling.
|
|
1011
|
+
*
|
|
1012
|
+
* iOS scrolls off the main thread. It picks a landing place from the snap
|
|
1013
|
+
* points it knows about at the time, and a React re-render that lands after
|
|
1014
|
+
* that decision moves them; the scroller comes to rest where no snap point
|
|
1015
|
+
* exists any more and nothing re-snaps it. Reported from a device as the
|
|
1016
|
+
* calendar sitting between two months with the weekday header still square —
|
|
1017
|
+
* which is exactly the shape of it: the grid is not skewed, the scrollport is
|
|
1018
|
+
* parked a couple of columns into a pane, so the left of March and the right
|
|
1019
|
+
* of April are on screen under one Sun-to-Sat header.
|
|
1020
|
+
*
|
|
1021
|
+
* Chrome never shows it, because it snaps again after the mutation. jsdom
|
|
1022
|
+
* implements no snapping at all, which makes it a fine place to test the
|
|
1023
|
+
* correction: the scroller can simply be PUT at a bad offset, exactly as iOS
|
|
1024
|
+
* leaves it, and the settle has to fix it.
|
|
1025
|
+
*/
|
|
1026
|
+
describe('DateInput — a rest position between two months', () => {
|
|
1027
|
+
/** The row March 2026 occupies in a range that opens in January 2024. */
|
|
1028
|
+
const MARCH_2026_ROW = 26;
|
|
1029
|
+
const FIVE_YEARS = {min: '2024-01-01', max: '2028-12-31'} as const;
|
|
1030
|
+
|
|
1031
|
+
/** A touchstart carrying the list a real one always has. */
|
|
1032
|
+
const touchEvent = (type: string) => {
|
|
1033
|
+
const event = new Event(type, {bubbles: true});
|
|
1034
|
+
const point = {identifier: 1, clientX: 100, clientY: 100};
|
|
1035
|
+
Object.defineProperties(event, {
|
|
1036
|
+
touches: {value: type === 'touchend' ? [] : [point]},
|
|
1037
|
+
targetTouches: {value: type === 'touchend' ? [] : [point]},
|
|
1038
|
+
changedTouches: {value: [point]},
|
|
1039
|
+
});
|
|
1040
|
+
return event;
|
|
1041
|
+
};
|
|
1042
|
+
|
|
1043
|
+
/** Drag, release, and let the quiet period elapse. */
|
|
1044
|
+
const swipeTo = async (scroller: HTMLElement, offset: number) => {
|
|
1045
|
+
scroller.dispatchEvent(touchEvent('touchstart'));
|
|
1046
|
+
scroller.scrollLeft = offset;
|
|
1047
|
+
fireEvent.scroll(scroller);
|
|
1048
|
+
scroller.dispatchEvent(touchEvent('touchend'));
|
|
1049
|
+
};
|
|
1050
|
+
|
|
1051
|
+
const openCalendar = () => {
|
|
1052
|
+
render(<Controlled initial="2026-03-21" {...FIVE_YEARS} />);
|
|
1053
|
+
fireEvent.click(screen.getByLabelText('Event date'));
|
|
1054
|
+
const scroller = document.querySelector<HTMLElement>(
|
|
1055
|
+
'[data-scroller="months"]',
|
|
1056
|
+
)!;
|
|
1057
|
+
const scrollTo = vi.mocked(Element.prototype.scrollTo);
|
|
1058
|
+
scrollTo.mockClear();
|
|
1059
|
+
return {scroller, scrollTo};
|
|
1060
|
+
};
|
|
1061
|
+
|
|
1062
|
+
it('puts the calendar back on a pane once the swipe is over', async () => {
|
|
1063
|
+
await withLayout(async () => {
|
|
1064
|
+
const {scroller, scrollTo} = openCalendar();
|
|
1065
|
+
// Two columns in — what the device screenshot showed.
|
|
1066
|
+
const stray = Math.round((SCROLLPORT_WIDTH * 2) / 7);
|
|
1067
|
+
await swipeTo(scroller, MARCH_2026_ROW * SCROLLPORT_WIDTH + stray);
|
|
1068
|
+
|
|
1069
|
+
await waitFor(() =>
|
|
1070
|
+
expect(scrollTo).toHaveBeenCalledWith(
|
|
1071
|
+
expect.objectContaining({left: MARCH_2026_ROW * SCROLLPORT_WIDTH}),
|
|
1072
|
+
),
|
|
1073
|
+
);
|
|
1074
|
+
});
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
it('goes to the nearer pane, not back the way it came', async () => {
|
|
1078
|
+
await withLayout(async () => {
|
|
1079
|
+
const {scroller, scrollTo} = openCalendar();
|
|
1080
|
+
// Three quarters of the way to April: April is the honest answer, and a
|
|
1081
|
+
// correction that always rounded down would drag the user backwards.
|
|
1082
|
+
await swipeTo(
|
|
1083
|
+
scroller,
|
|
1084
|
+
MARCH_2026_ROW * SCROLLPORT_WIDTH + SCROLLPORT_WIDTH * 0.75,
|
|
1085
|
+
);
|
|
1086
|
+
|
|
1087
|
+
await waitFor(() =>
|
|
1088
|
+
expect(scrollTo).toHaveBeenCalledWith(
|
|
1089
|
+
expect.objectContaining({
|
|
1090
|
+
left: (MARCH_2026_ROW + 1) * SCROLLPORT_WIDTH,
|
|
1091
|
+
}),
|
|
1092
|
+
),
|
|
1093
|
+
);
|
|
1094
|
+
});
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* A scroller the browser snapped for itself must be left alone. Correcting
|
|
1099
|
+
* it anyway would mean every swipe on Chrome — where snapping works — ended
|
|
1100
|
+
* with a second, pointless scroll.
|
|
1101
|
+
*/
|
|
1102
|
+
it('leaves a scroller that snapped properly alone', async () => {
|
|
1103
|
+
await withLayout(async () => {
|
|
1104
|
+
const {scroller, scrollTo} = openCalendar();
|
|
1105
|
+
await swipeTo(scroller, (MARCH_2026_ROW + 2) * SCROLLPORT_WIDTH);
|
|
1106
|
+
|
|
1107
|
+
// Long enough that the settle has certainly run.
|
|
1108
|
+
await new Promise(resolve => setTimeout(resolve, SCROLL_QUIET_MS * 2));
|
|
1109
|
+
expect(scrollTo).not.toHaveBeenCalled();
|
|
1110
|
+
});
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* Sub-pixel drift is the browser's own rounding on a fractional viewport,
|
|
1115
|
+
* not a failed snap. Correcting it would fire a scroll after every gesture
|
|
1116
|
+
* on any device whose width is not a whole number of pixels.
|
|
1117
|
+
*/
|
|
1118
|
+
it('ignores sub-pixel drift', async () => {
|
|
1119
|
+
await withLayout(async () => {
|
|
1120
|
+
const {scroller, scrollTo} = openCalendar();
|
|
1121
|
+
await swipeTo(scroller, MARCH_2026_ROW * SCROLLPORT_WIDTH + 0.4);
|
|
1122
|
+
|
|
1123
|
+
await new Promise(resolve => setTimeout(resolve, SCROLL_QUIET_MS * 2));
|
|
1124
|
+
expect(scrollTo).not.toHaveBeenCalled();
|
|
1125
|
+
});
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
/**
|
|
1129
|
+
* The correction waits for the finger. Firing it mid-drag would fight the
|
|
1130
|
+
* hand that is still moving the scroller — the same mistake that made the
|
|
1131
|
+
* wheels climb a month at a time on iOS, and the reason `useScrollSettle`
|
|
1132
|
+
* waits for a release rather than for quiet alone.
|
|
1133
|
+
*/
|
|
1134
|
+
it('does not correct while the finger is still down', async () => {
|
|
1135
|
+
await withLayout(async () => {
|
|
1136
|
+
const {scroller, scrollTo} = openCalendar();
|
|
1137
|
+
scroller.dispatchEvent(touchEvent('touchstart'));
|
|
1138
|
+
scroller.scrollLeft = MARCH_2026_ROW * SCROLLPORT_WIDTH + 120;
|
|
1139
|
+
fireEvent.scroll(scroller);
|
|
1140
|
+
|
|
1141
|
+
await new Promise(resolve => setTimeout(resolve, SCROLL_QUIET_MS * 2));
|
|
1142
|
+
expect(scrollTo).not.toHaveBeenCalled();
|
|
1143
|
+
|
|
1144
|
+
// And on release it does the correction it was holding back.
|
|
1145
|
+
scroller.dispatchEvent(touchEvent('touchend'));
|
|
1146
|
+
await waitFor(() => expect(scrollTo).toHaveBeenCalled());
|
|
1147
|
+
});
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* The one that actually reverses a swipe, and the reason the correction
|
|
1152
|
+
* re-checks stillness rather than trusting the quiet period.
|
|
1153
|
+
*
|
|
1154
|
+
* iOS runs its own snap animation for ~150-300ms after the finger lifts,
|
|
1155
|
+
* and fires scroll events irregularly while it does — a gap longer than the
|
|
1156
|
+
* quiet period is routine in the slow tail. The settle lands mid-animation,
|
|
1157
|
+
* reads an offset still travelling toward April, rounds THAT to the nearest
|
|
1158
|
+
* pane (still March, since the animation is not yet halfway), and drags the
|
|
1159
|
+
* calendar back where it came from. Swipe forward, get pulled backward.
|
|
1160
|
+
*
|
|
1161
|
+
* Simulated by moving the scroller between the two samples, which is what
|
|
1162
|
+
* an animation in flight looks like from here.
|
|
1163
|
+
*/
|
|
1164
|
+
it('does not correct a scroller that is still travelling', async () => {
|
|
1165
|
+
await withLayout(async () => {
|
|
1166
|
+
const {scroller, scrollTo} = openCalendar();
|
|
1167
|
+
// A quarter of the way to April, and still going.
|
|
1168
|
+
let offset = MARCH_2026_ROW * SCROLLPORT_WIDTH + SCROLLPORT_WIDTH * 0.25;
|
|
1169
|
+
Object.defineProperty(scroller, 'scrollLeft', {
|
|
1170
|
+
configurable: true,
|
|
1171
|
+
get: () => {
|
|
1172
|
+
// Every read advances it, the way an in-flight animation does.
|
|
1173
|
+
offset += 8;
|
|
1174
|
+
return offset;
|
|
1175
|
+
},
|
|
1176
|
+
set: (value: number) => {
|
|
1177
|
+
offset = value;
|
|
1178
|
+
},
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
await swipeTo(scroller, offset);
|
|
1182
|
+
await new Promise(resolve => setTimeout(resolve, SCROLL_QUIET_MS * 3));
|
|
1183
|
+
// Nothing. Correcting here would have sent it back to March, undoing a
|
|
1184
|
+
// swipe the browser was already completing.
|
|
1185
|
+
expect(scrollTo).not.toHaveBeenCalled();
|
|
1186
|
+
});
|
|
1187
|
+
});
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
// ---------------------------------------------------------------------------
|
|
1191
|
+
// Month / year wheels
|
|
1192
|
+
// ---------------------------------------------------------------------------
|
|
1193
|
+
|
|
1194
|
+
describe('DateInput — month/year wheels', () => {
|
|
1195
|
+
/**
|
|
1196
|
+
* The header title. Queried by attribute rather than by role and accessible
|
|
1197
|
+
* name: every role query in here walks a tree of ~150 elements and computes
|
|
1198
|
+
* a name for each, which dominates the runtime of these tests.
|
|
1199
|
+
*/
|
|
1200
|
+
const title = () =>
|
|
1201
|
+
document.querySelector<HTMLElement>('[data-title="month-year"]')!;
|
|
1202
|
+
|
|
1203
|
+
const openWheels = () => fireEvent.click(title());
|
|
1204
|
+
|
|
1205
|
+
/** The swap panel holding the calendar, or the one holding the wheels. */
|
|
1206
|
+
const panel = (which: 'calendar' | 'wheels') =>
|
|
1207
|
+
document.querySelector(`[data-panel="${which}"]`)!;
|
|
1208
|
+
|
|
1209
|
+
const ONE_YEAR = {min: '2026-01-01', max: '2026-12-31'} as const;
|
|
1210
|
+
const FIVE_YEARS = {min: '2024-01-01', max: '2028-12-31'} as const;
|
|
1211
|
+
|
|
1212
|
+
it('the header title opens them, and says so', () => {
|
|
1213
|
+
renderAndOpen();
|
|
1214
|
+
expect(title()).toHaveAttribute('aria-expanded', 'false');
|
|
1215
|
+
expect(panel('wheels')).toHaveAttribute('inert');
|
|
1216
|
+
expect(panel('calendar')).not.toHaveAttribute('inert');
|
|
1217
|
+
openWheels();
|
|
1218
|
+
expect(title()).toHaveAttribute('aria-expanded', 'true');
|
|
1219
|
+
expect(panel('wheels')).not.toHaveAttribute('inert');
|
|
1220
|
+
expect(panel('calendar')).toHaveAttribute('inert');
|
|
1221
|
+
expect(screen.getByRole('listbox', {name: 'Month'})).toBeInTheDocument();
|
|
1222
|
+
expect(screen.getByRole('listbox', {name: 'Year'})).toBeInTheDocument();
|
|
1223
|
+
});
|
|
1224
|
+
|
|
1225
|
+
/**
|
|
1226
|
+
* The arrows step the calendar, and the calendar is not on screen. They
|
|
1227
|
+
* keep their layout box rather than unmounting: they are the tallest thing
|
|
1228
|
+
* in the header on a coarse pointer (44px against the title's 36), so
|
|
1229
|
+
* dropping them would shorten it and shift the sheet mid-cross-fade.
|
|
1230
|
+
*/
|
|
1231
|
+
it('hides the month arrows while the wheels are up', () => {
|
|
1232
|
+
renderAndOpen();
|
|
1233
|
+
const arrows = document.querySelector<HTMLElement>(
|
|
1234
|
+
'[data-arrows="months"]',
|
|
1235
|
+
)!;
|
|
1236
|
+
expect(arrows).not.toHaveAttribute('inert');
|
|
1237
|
+
|
|
1238
|
+
openWheels();
|
|
1239
|
+
expect(arrows).toHaveAttribute('inert');
|
|
1240
|
+
// Still in the layout, so the header cannot change height.
|
|
1241
|
+
expect(arrows).toBeInTheDocument();
|
|
1242
|
+
|
|
1243
|
+
fireEvent.click(title());
|
|
1244
|
+
expect(arrows).not.toHaveAttribute('inert');
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
it('offers twelve months, with the current one selected', () => {
|
|
1248
|
+
renderAndOpen();
|
|
1249
|
+
openWheels();
|
|
1250
|
+
const options = within(
|
|
1251
|
+
screen.getByRole('listbox', {name: 'Month'}),
|
|
1252
|
+
).getAllByRole('option');
|
|
1253
|
+
expect(options).toHaveLength(12);
|
|
1254
|
+
expect(options[2]).toHaveTextContent('March');
|
|
1255
|
+
expect(options[2]).toHaveAttribute('aria-selected', 'true');
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
it('tapping a wheel row moves the calendar to that month', () => {
|
|
1259
|
+
renderAndOpen(<Controlled initial="2026-03-21" {...ONE_YEAR} />);
|
|
1260
|
+
openWheels();
|
|
1261
|
+
fireEvent.click(
|
|
1262
|
+
within(screen.getByRole('listbox', {name: 'Month'})).getByText(
|
|
1263
|
+
'September',
|
|
1264
|
+
),
|
|
1265
|
+
);
|
|
1266
|
+
expect(title()).toHaveTextContent('September 2026');
|
|
1267
|
+
});
|
|
1268
|
+
|
|
1269
|
+
/**
|
|
1270
|
+
* A wheel commit steers the hidden calendar, and the calendar reports the
|
|
1271
|
+
* month it lands on. Taking that report back while the wheels are open
|
|
1272
|
+
* closes a cycle: commit -> echo -> the echo moves the wheel's selected row
|
|
1273
|
+
* -> the wheel is repositioned -> that scroll reads as another commit.
|
|
1274
|
+
*
|
|
1275
|
+
* Whether it converges comes down to how precisely a browser says
|
|
1276
|
+
* "scrolling stopped". Chrome has `scrollend` and settles at once; iOS
|
|
1277
|
+
* below Safari 26 has none, and its momentum runs on after the finger
|
|
1278
|
+
* lifts, so each lap committed the next month along and the month climbed
|
|
1279
|
+
* on its own — reported from a device, invisible in Chrome.
|
|
1280
|
+
*
|
|
1281
|
+
* Driven here through the calendar's real scroll listener, which needs a
|
|
1282
|
+
* measured scrollport (`withLayout`) and a `scrollLeft` far enough to land
|
|
1283
|
+
* on another pane, then a frame for the rAF throttle. No timing assumptions:
|
|
1284
|
+
* the echo either reaches the month or it does not.
|
|
1285
|
+
*/
|
|
1286
|
+
it('ignores the calendar echo while the wheels are steering it', async () => {
|
|
1287
|
+
await withLayout(async () => {
|
|
1288
|
+
render(<Controlled initial="2026-03-21" {...FIVE_YEARS} />);
|
|
1289
|
+
fireEvent.click(field());
|
|
1290
|
+
openWheels();
|
|
1291
|
+
fireEvent.click(
|
|
1292
|
+
within(screen.getByRole('listbox', {name: 'Month'})).getByText(
|
|
1293
|
+
'January',
|
|
1294
|
+
),
|
|
1295
|
+
);
|
|
1296
|
+
expect(title()).toHaveTextContent('January 2026');
|
|
1297
|
+
|
|
1298
|
+
// The calendar, scrolling to January behind the wheels, reports each
|
|
1299
|
+
// month it passes. None of it may move the month the wheels just set.
|
|
1300
|
+
const scroller = document.querySelector<HTMLElement>(
|
|
1301
|
+
'[data-scroller="months"]',
|
|
1302
|
+
)!;
|
|
1303
|
+
for (const row of [5, 6, 7, 8]) {
|
|
1304
|
+
scroller.scrollLeft = row * SCROLLPORT_WIDTH;
|
|
1305
|
+
fireEvent.scroll(scroller);
|
|
1306
|
+
await frame();
|
|
1307
|
+
}
|
|
1308
|
+
expect(title()).toHaveTextContent('January 2026');
|
|
1309
|
+
});
|
|
1310
|
+
// The other direction — the calendar reporting its month when it IS the
|
|
1311
|
+
// surface — is what every test in the calendar-surface block above
|
|
1312
|
+
// depends on, so it is covered rather than restated here.
|
|
1313
|
+
});
|
|
1314
|
+
|
|
1315
|
+
/**
|
|
1316
|
+
* The reveal case, which is the one that was reported from a device.
|
|
1317
|
+
*
|
|
1318
|
+
* A wheel commit steers the calendar while it is hidden behind the wheels,
|
|
1319
|
+
* and `visibility: hidden` keeps the layout box but does not guarantee the
|
|
1320
|
+
* scroll position survives: iOS re-snaps the scroller when it becomes
|
|
1321
|
+
* visible again, and not necessarily onto the pane it was put on. That
|
|
1322
|
+
* fires a scroll exactly as the wheels close and reports start being
|
|
1323
|
+
* trusted again — so the month drifted on the way back to the dates.
|
|
1324
|
+
*
|
|
1325
|
+
* Simulated here by moving the hidden scroller somewhere it was never sent,
|
|
1326
|
+
* which is what the re-snap amounts to.
|
|
1327
|
+
*/
|
|
1328
|
+
it('holds the month when the hidden calendar is re-snapped on reveal', async () => {
|
|
1329
|
+
await withLayout(async () => {
|
|
1330
|
+
render(<Controlled initial="2026-03-21" {...FIVE_YEARS} />);
|
|
1331
|
+
fireEvent.click(field());
|
|
1332
|
+
openWheels();
|
|
1333
|
+
fireEvent.click(
|
|
1334
|
+
within(screen.getByRole('listbox', {name: 'Month'})).getByText(
|
|
1335
|
+
'January',
|
|
1336
|
+
),
|
|
1337
|
+
);
|
|
1338
|
+
expect(title()).toHaveTextContent('January 2026');
|
|
1339
|
+
|
|
1340
|
+
// iOS moves the hidden scroller off the pane it was steered to.
|
|
1341
|
+
const scroller = document.querySelector<HTMLElement>(
|
|
1342
|
+
'[data-scroller="months"]',
|
|
1343
|
+
)!;
|
|
1344
|
+
scroller.scrollLeft += 4 * SCROLLPORT_WIDTH;
|
|
1345
|
+
|
|
1346
|
+
// Back to the dates. Two things have to hold: the stray position must
|
|
1347
|
+
// not become the month, and the calendar must be put back on the pane
|
|
1348
|
+
// the month names — or the header would say January over a different
|
|
1349
|
+
// month's grid.
|
|
1350
|
+
const scrollTo = vi.mocked(Element.prototype.scrollTo);
|
|
1351
|
+
scrollTo.mockClear();
|
|
1352
|
+
fireEvent.click(title());
|
|
1353
|
+
fireEvent.scroll(scroller);
|
|
1354
|
+
await frame();
|
|
1355
|
+
await frame();
|
|
1356
|
+
expect(title()).toHaveTextContent('January 2026');
|
|
1357
|
+
|
|
1358
|
+
// January 2026 is row 24 of a range that starts in January 2024.
|
|
1359
|
+
expect(scrollTo).toHaveBeenCalledWith(
|
|
1360
|
+
expect.objectContaining({left: 24 * SCROLLPORT_WIDTH}),
|
|
1361
|
+
);
|
|
1362
|
+
});
|
|
1363
|
+
});
|
|
1364
|
+
|
|
1365
|
+
/**
|
|
1366
|
+
* The wheels are a detour to reach a far month, not a mode. Reopening into
|
|
1367
|
+
* them would answer a question the user has not asked yet, and hide the
|
|
1368
|
+
* dates they came back for behind another tap.
|
|
1369
|
+
*/
|
|
1370
|
+
it('always reopens on the calendar, whatever was showing last time', () => {
|
|
1371
|
+
renderAndOpen();
|
|
1372
|
+
openWheels();
|
|
1373
|
+
expect(title()).toHaveAttribute('aria-expanded', 'true');
|
|
1374
|
+
|
|
1375
|
+
// Dismissed from the wheels, where Done is deliberately not offered —
|
|
1376
|
+
// the handle, the scrim and Escape are the ways out.
|
|
1377
|
+
fireEvent.keyDown(document.querySelector('dialog[open]')!, {key: 'Escape'});
|
|
1378
|
+
expect(field()).toHaveAttribute('aria-expanded', 'false');
|
|
1379
|
+
|
|
1380
|
+
fireEvent.click(field());
|
|
1381
|
+
expect(title()).toHaveAttribute('aria-expanded', 'false');
|
|
1382
|
+
expect(panel('calendar')).not.toHaveAttribute('inert');
|
|
1383
|
+
expect(panel('wheels')).toHaveAttribute('inert');
|
|
1384
|
+
});
|
|
1385
|
+
|
|
1386
|
+
it('the year wheel keeps the month', () => {
|
|
1387
|
+
renderAndOpen(<Controlled initial="2026-03-21" {...FIVE_YEARS} />);
|
|
1388
|
+
openWheels();
|
|
1389
|
+
fireEvent.click(
|
|
1390
|
+
within(screen.getByRole('listbox', {name: 'Year'})).getByText('2025'),
|
|
1391
|
+
);
|
|
1392
|
+
expect(title()).toHaveTextContent('March 2025');
|
|
1393
|
+
});
|
|
1394
|
+
|
|
1395
|
+
it('is a single tab stop driven by the arrow keys', () => {
|
|
1396
|
+
renderAndOpen();
|
|
1397
|
+
openWheels();
|
|
1398
|
+
const months = screen.getByRole('listbox', {name: 'Month'});
|
|
1399
|
+
expect(months).toHaveAttribute('tabindex', '0');
|
|
1400
|
+
fireEvent.keyDown(months, {key: 'ArrowDown'});
|
|
1401
|
+
expect(title()).toHaveTextContent('April 2026');
|
|
1402
|
+
fireEvent.keyDown(months, {key: 'ArrowUp'});
|
|
1403
|
+
expect(title()).toHaveTextContent('March 2026');
|
|
1404
|
+
});
|
|
1405
|
+
|
|
1406
|
+
it('will not commit a row outside min/max', () => {
|
|
1407
|
+
renderAndOpen(<Controlled initial="2026-03-10" />);
|
|
1408
|
+
openWheels();
|
|
1409
|
+
const december = within(screen.getByRole('listbox', {name: 'Month'}))
|
|
1410
|
+
.getByText('December')
|
|
1411
|
+
.closest('[role="option"]')!;
|
|
1412
|
+
expect(december).toHaveAttribute('aria-disabled', 'true');
|
|
1413
|
+
fireEvent.click(december);
|
|
1414
|
+
expect(title()).toHaveTextContent('March 2026');
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
it('bounds the year wheel to the reachable range', () => {
|
|
1418
|
+
renderAndOpen(
|
|
1419
|
+
<Controlled initial="2026-03-10" min="2025-01-01" max="2027-12-31" />,
|
|
1420
|
+
);
|
|
1421
|
+
openWheels();
|
|
1422
|
+
expect(
|
|
1423
|
+
within(screen.getByRole('listbox', {name: 'Year'}))
|
|
1424
|
+
.getAllByRole('option')
|
|
1425
|
+
.map(o => o.textContent),
|
|
1426
|
+
).toEqual(['2025', '2026', '2027']);
|
|
1427
|
+
});
|
|
1428
|
+
|
|
1429
|
+
it('the title is what closes them again', () => {
|
|
1430
|
+
renderAndOpen();
|
|
1431
|
+
openWheels();
|
|
1432
|
+
expect(title()).toHaveAttribute('aria-expanded', 'true');
|
|
1433
|
+
fireEvent.click(title());
|
|
1434
|
+
expect(title()).toHaveAttribute('aria-expanded', 'false');
|
|
1435
|
+
expect(panel('wheels')).toHaveAttribute('inert');
|
|
1436
|
+
});
|
|
1437
|
+
|
|
1438
|
+
it('Reset empties the field and brings the calendar home', () => {
|
|
1439
|
+
const onChange = vi.fn();
|
|
1440
|
+
// Today is 15 March 2026 in these tests; open on a month away from it.
|
|
1441
|
+
renderAndOpen(
|
|
1442
|
+
<Controlled
|
|
1443
|
+
initial="2026-08-21"
|
|
1444
|
+
onChange={onChange}
|
|
1445
|
+
min="2026-01-01"
|
|
1446
|
+
max="2026-12-31"
|
|
1447
|
+
/>,
|
|
1448
|
+
);
|
|
1449
|
+
expect(title()).toHaveTextContent('August 2026');
|
|
1450
|
+
|
|
1451
|
+
fireEvent.click(screen.getByRole('button', {name: 'Reset'}));
|
|
1452
|
+
expect(onChange).toHaveBeenCalledWith(undefined);
|
|
1453
|
+
expect(field()).toHaveValue('');
|
|
1454
|
+
// Clearing the date and leaving the calendar on the month of the date you
|
|
1455
|
+
// just cleared is a half-finished action.
|
|
1456
|
+
expect(title()).toHaveTextContent('March 2026');
|
|
1457
|
+
// And it does not dismiss — the sheet is still there to pick again.
|
|
1458
|
+
expect(field()).toHaveAttribute('aria-expanded', 'true');
|
|
1459
|
+
});
|
|
1460
|
+
|
|
1461
|
+
/**
|
|
1462
|
+
* "If possible" is the whole subtlety. A range can exclude the current
|
|
1463
|
+
* month entirely — a booking window that opens next quarter — and there is
|
|
1464
|
+
* then no honest month to go to. Clamping would land on the nearest edge
|
|
1465
|
+
* and present a different month as though it were today's, so the move is
|
|
1466
|
+
* skipped and the calendar stays put. The value clears either way.
|
|
1467
|
+
*/
|
|
1468
|
+
it('clears without moving when the current month is out of range', () => {
|
|
1469
|
+
const onChange = vi.fn();
|
|
1470
|
+
renderAndOpen(
|
|
1471
|
+
<Controlled
|
|
1472
|
+
initial="2027-05-10"
|
|
1473
|
+
onChange={onChange}
|
|
1474
|
+
min="2027-01-01"
|
|
1475
|
+
max="2027-12-31"
|
|
1476
|
+
/>,
|
|
1477
|
+
);
|
|
1478
|
+
expect(title()).toHaveTextContent('May 2027');
|
|
1479
|
+
|
|
1480
|
+
fireEvent.click(screen.getByRole('button', {name: 'Reset'}));
|
|
1481
|
+
expect(onChange).toHaveBeenCalledWith(undefined);
|
|
1482
|
+
expect(field()).toHaveValue('');
|
|
1483
|
+
expect(title()).toHaveTextContent('May 2027');
|
|
1484
|
+
});
|
|
1485
|
+
|
|
1486
|
+
it('offers Reset only on the calendar, beside Save', () => {
|
|
1487
|
+
renderAndOpen();
|
|
1488
|
+
expect(screen.getByRole('button', {name: 'Reset'})).toBeInTheDocument();
|
|
1489
|
+
openWheels();
|
|
1490
|
+
// The wheels choose a month; there is no date there to clear.
|
|
1491
|
+
expect(screen.queryByRole('button', {name: 'Reset'})).toBeNull();
|
|
1492
|
+
});
|
|
1493
|
+
|
|
1494
|
+
it('Save closes the whole picker; Done only leaves the wheels', () => {
|
|
1495
|
+
// Two finishes, deliberately different: Save ends the task, Done ends a
|
|
1496
|
+
// step. They never appear together — each belongs to the surface it is
|
|
1497
|
+
// shown on — so neither has to carry two meanings by position.
|
|
1498
|
+
renderAndOpen();
|
|
1499
|
+
openWheels();
|
|
1500
|
+
fireEvent.click(screen.getByRole('button', {name: 'Done'}));
|
|
1501
|
+
// Back on the calendar, still open.
|
|
1502
|
+
expect(field()).toHaveAttribute('aria-expanded', 'true');
|
|
1503
|
+
expect(title()).toHaveAttribute('aria-expanded', 'false');
|
|
1504
|
+
|
|
1505
|
+
fireEvent.click(screen.getByRole('button', {name: 'Save'}));
|
|
1506
|
+
expect(field()).toHaveAttribute('aria-expanded', 'false');
|
|
1507
|
+
});
|
|
1508
|
+
|
|
1509
|
+
/**
|
|
1510
|
+
* The two actions share one grid cell and take turns, so the footer is one
|
|
1511
|
+
* button tall whichever is showing and the sheet never changes height
|
|
1512
|
+
* mid-swap. `inert` is what keeps the hidden one out of the accessibility
|
|
1513
|
+
* tree, rather than merely out of sight.
|
|
1514
|
+
*/
|
|
1515
|
+
/**
|
|
1516
|
+
* `inert` disables everything INSIDE it, so an inert ancestor is enough to
|
|
1517
|
+
* kill a button that looks perfectly fine on its own.
|
|
1518
|
+
*
|
|
1519
|
+
* This is not hypothetical. The footer kept an `inert` from an earlier
|
|
1520
|
+
* version where it was hidden wholesale on the wheels; once the wheels grew
|
|
1521
|
+
* their own Done button inside that same footer, the button rendered,
|
|
1522
|
+
* looked right, passed every attribute assertion — and did nothing when
|
|
1523
|
+
* tapped.
|
|
1524
|
+
*
|
|
1525
|
+
* Nothing above caught it: `inert` has no behavioural effect in jsdom, so
|
|
1526
|
+
* role queries still found the button, and the cell's own attribute was
|
|
1527
|
+
* correct. The only honest check is to walk the ancestors, which is what
|
|
1528
|
+
* this does.
|
|
1529
|
+
*/
|
|
1530
|
+
it('leaves no inert ancestor over whichever action is showing', () => {
|
|
1531
|
+
const inertAncestorsOf = (label: string) => {
|
|
1532
|
+
const el = [...document.querySelectorAll('dialog[open] button')].find(
|
|
1533
|
+
b => b.textContent?.trim() === label,
|
|
1534
|
+
)!;
|
|
1535
|
+
const blocking: string[] = [];
|
|
1536
|
+
// Start above the button's own cell, which is legitimately inert for
|
|
1537
|
+
// the action that is currently hidden.
|
|
1538
|
+
let node = el.parentElement?.parentElement ?? null;
|
|
1539
|
+
while (node != null && node.tagName !== 'BODY') {
|
|
1540
|
+
if (node.hasAttribute('inert')) {
|
|
1541
|
+
blocking.push(node.tagName.toLowerCase());
|
|
1542
|
+
}
|
|
1543
|
+
node = node.parentElement;
|
|
1544
|
+
}
|
|
1545
|
+
return blocking;
|
|
1546
|
+
};
|
|
1547
|
+
|
|
1548
|
+
renderAndOpen();
|
|
1549
|
+
expect(inertAncestorsOf('Save')).toEqual([]);
|
|
1550
|
+
expect(inertAncestorsOf('Reset')).toEqual([]);
|
|
1551
|
+
|
|
1552
|
+
openWheels();
|
|
1553
|
+
expect(inertAncestorsOf('Done')).toEqual([]);
|
|
1554
|
+
});
|
|
1555
|
+
|
|
1556
|
+
it('shows exactly one footer action, and only the visible one is reachable', () => {
|
|
1557
|
+
renderAndOpen();
|
|
1558
|
+
// Queried off the DOM rather than by role: every role-with-name query
|
|
1559
|
+
// walks this tree computing accessible names, and there are ~150
|
|
1560
|
+
// elements in it. Same reason the title helper above does it this way.
|
|
1561
|
+
const button = (label: string) =>
|
|
1562
|
+
[...document.querySelectorAll('dialog[open] button')].find(
|
|
1563
|
+
el => el.textContent?.trim() === label,
|
|
1564
|
+
);
|
|
1565
|
+
// Which ANCESTOR carries `inert`, not which parent — the wheels' action
|
|
1566
|
+
// sits inside a fading wrapper, and asserting on `parentElement` would
|
|
1567
|
+
// pass or fail on that nesting rather than on reachability.
|
|
1568
|
+
const isBlocked = (label: string) =>
|
|
1569
|
+
button(label)?.closest('[inert]') != null;
|
|
1570
|
+
|
|
1571
|
+
expect(isBlocked('Save')).toBe(false);
|
|
1572
|
+
expect(isBlocked('Done')).toBe(true);
|
|
1573
|
+
expect(screen.queryByRole('button', {name: 'Done'})).toBeNull();
|
|
1574
|
+
|
|
1575
|
+
openWheels();
|
|
1576
|
+
expect(isBlocked('Save')).toBe(true);
|
|
1577
|
+
expect(isBlocked('Done')).toBe(false);
|
|
1578
|
+
expect(screen.queryByRole('button', {name: 'Save'})).toBeNull();
|
|
1579
|
+
|
|
1580
|
+
// Both stay mounted throughout, which is what keeps the row's height
|
|
1581
|
+
// fixed across the swap.
|
|
1582
|
+
expect(button('Save')).toBeInTheDocument();
|
|
1583
|
+
expect(button('Done')).toBeInTheDocument();
|
|
1584
|
+
});
|
|
1585
|
+
|
|
1586
|
+
it('the year wheel keeps the month', () => {
|
|
1587
|
+
renderAndOpen(<Controlled initial="2026-03-21" {...FIVE_YEARS} />);
|
|
1588
|
+
openWheels();
|
|
1589
|
+
fireEvent.click(
|
|
1590
|
+
within(screen.getByRole('listbox', {name: 'Year'})).getByText('2025'),
|
|
1591
|
+
);
|
|
1592
|
+
expect(title()).toHaveTextContent('March 2025');
|
|
1593
|
+
});
|
|
1594
|
+
|
|
1595
|
+
it('is a single tab stop driven by the arrow keys', () => {
|
|
1596
|
+
renderAndOpen();
|
|
1597
|
+
openWheels();
|
|
1598
|
+
const months = screen.getByRole('listbox', {name: 'Month'});
|
|
1599
|
+
expect(months).toHaveAttribute('tabindex', '0');
|
|
1600
|
+
fireEvent.keyDown(months, {key: 'ArrowDown'});
|
|
1601
|
+
expect(title()).toHaveTextContent('April 2026');
|
|
1602
|
+
fireEvent.keyDown(months, {key: 'ArrowUp'});
|
|
1603
|
+
expect(title()).toHaveTextContent('March 2026');
|
|
1604
|
+
});
|
|
1605
|
+
|
|
1606
|
+
it('will not commit a row outside min/max', () => {
|
|
1607
|
+
renderAndOpen(<Controlled initial="2026-03-10" />);
|
|
1608
|
+
openWheels();
|
|
1609
|
+
const december = within(screen.getByRole('listbox', {name: 'Month'}))
|
|
1610
|
+
.getByText('December')
|
|
1611
|
+
.closest('[role="option"]')!;
|
|
1612
|
+
expect(december).toHaveAttribute('aria-disabled', 'true');
|
|
1613
|
+
fireEvent.click(december);
|
|
1614
|
+
expect(title()).toHaveTextContent('March 2026');
|
|
1615
|
+
});
|
|
1616
|
+
|
|
1617
|
+
it('bounds the year wheel to the reachable range', () => {
|
|
1618
|
+
renderAndOpen(
|
|
1619
|
+
<Controlled initial="2026-03-10" min="2025-01-01" max="2027-12-31" />,
|
|
1620
|
+
);
|
|
1621
|
+
openWheels();
|
|
1622
|
+
expect(
|
|
1623
|
+
within(screen.getByRole('listbox', {name: 'Year'}))
|
|
1624
|
+
.getAllByRole('option')
|
|
1625
|
+
.map(o => o.textContent),
|
|
1626
|
+
).toEqual(['2025', '2026', '2027']);
|
|
1627
|
+
});
|
|
1628
|
+
|
|
1629
|
+
it('the title is what closes them again', () => {
|
|
1630
|
+
renderAndOpen();
|
|
1631
|
+
openWheels();
|
|
1632
|
+
expect(title()).toHaveAttribute('aria-expanded', 'true');
|
|
1633
|
+
fireEvent.click(title());
|
|
1634
|
+
expect(title()).toHaveAttribute('aria-expanded', 'false');
|
|
1635
|
+
expect(panel('wheels')).toHaveAttribute('inert');
|
|
1636
|
+
});
|
|
1637
|
+
});
|
|
1638
|
+
|
|
1639
|
+
// ---------------------------------------------------------------------------
|
|
1640
|
+
// Gesture ownership — the nested scrollers vs. the sheet's swipe-to-dismiss
|
|
1641
|
+
// ---------------------------------------------------------------------------
|
|
1642
|
+
|
|
1643
|
+
describe('DateInput — nested scrollers keep their own touch gesture', () => {
|
|
1644
|
+
/**
|
|
1645
|
+
* Stand-in for `BottomSheet`'s swipe-to-dismiss listener: a NATIVE listener
|
|
1646
|
+
* on an ancestor, in the bubble phase, which is exactly how the sheet
|
|
1647
|
+
* attaches its own (`addEventListener('touchstart', …, {passive: true})` on
|
|
1648
|
+
* its scrolling body). If an event reaches this, the sheet would have read
|
|
1649
|
+
* it as a drag.
|
|
1650
|
+
*/
|
|
1651
|
+
function watchAncestor(): {seen: string[]; stop: () => void} {
|
|
1652
|
+
const seen: string[] = [];
|
|
1653
|
+
const listener = (event: Event) => seen.push(event.type);
|
|
1654
|
+
for (const type of ['touchstart', 'touchmove', 'touchend']) {
|
|
1655
|
+
document.body.addEventListener(type, listener);
|
|
1656
|
+
}
|
|
1657
|
+
return {
|
|
1658
|
+
seen,
|
|
1659
|
+
stop: () => {
|
|
1660
|
+
for (const type of ['touchstart', 'touchmove', 'touchend']) {
|
|
1661
|
+
document.body.removeEventListener(type, listener);
|
|
1662
|
+
}
|
|
1663
|
+
},
|
|
1664
|
+
};
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
/**
|
|
1668
|
+
* A touch event realistic enough for a real listener to read.
|
|
1669
|
+
*
|
|
1670
|
+
* jsdom has no constructible `TouchEvent`, and a bare `Event` carries no
|
|
1671
|
+
* `changedTouches` — which crashes any handler that reads one, including
|
|
1672
|
+
* BottomSheet's (`event.changedTouches[0]`). That crash is an artifact of
|
|
1673
|
+
* the fake event, not a bug: a browser's touchstart always has the list.
|
|
1674
|
+
* The stand-in below carries it, so an event that DOES reach the sheet
|
|
1675
|
+
* exercises the sheet rather than blowing up inside it.
|
|
1676
|
+
*/
|
|
1677
|
+
const touch = (
|
|
1678
|
+
el: Element,
|
|
1679
|
+
type: string,
|
|
1680
|
+
at: {x: number; y: number} = {x: 100, y: 200},
|
|
1681
|
+
) => {
|
|
1682
|
+
const event = new Event(type, {
|
|
1683
|
+
bubbles: true,
|
|
1684
|
+
cancelable: type !== 'touchend',
|
|
1685
|
+
});
|
|
1686
|
+
const point = {identifier: 1, clientX: at.x, clientY: at.y, target: el};
|
|
1687
|
+
Object.defineProperties(event, {
|
|
1688
|
+
changedTouches: {value: [point]},
|
|
1689
|
+
touches: {value: type === 'touchend' ? [] : [point]},
|
|
1690
|
+
targetTouches: {value: type === 'touchend' ? [] : [point]},
|
|
1691
|
+
});
|
|
1692
|
+
return el.dispatchEvent(event);
|
|
1693
|
+
};
|
|
1694
|
+
|
|
1695
|
+
/**
|
|
1696
|
+
* A whole gesture: down at the origin, drag by (dx, dy), lift. Returns the
|
|
1697
|
+
* scroller's stubbed `scrollBy`, which is how the paging fallback shows up
|
|
1698
|
+
* (jsdom implements neither scrolling nor `scrollBy`).
|
|
1699
|
+
*/
|
|
1700
|
+
const swipe = (
|
|
1701
|
+
el: Element,
|
|
1702
|
+
dx: number,
|
|
1703
|
+
dy: number,
|
|
1704
|
+
{scrollsBy = 0}: {scrollsBy?: number} = {},
|
|
1705
|
+
) => {
|
|
1706
|
+
// jsdom has no layout, so `scrollLeft` never moves on its own. Shadow it
|
|
1707
|
+
// on this instance for the gesture: a native pan is the browser moving
|
|
1708
|
+
// the scroller mid-drag, and watching for exactly that is how the hook
|
|
1709
|
+
// knows whether its claim was honoured. Instance-only and deleted after,
|
|
1710
|
+
// per the prototype-getter cost noted at the top of this file.
|
|
1711
|
+
let offset = 0;
|
|
1712
|
+
const scrollBy = vi.fn();
|
|
1713
|
+
Object.defineProperties(el, {
|
|
1714
|
+
scrollLeft: {
|
|
1715
|
+
configurable: true,
|
|
1716
|
+
get: () => offset,
|
|
1717
|
+
set: (value: number) => {
|
|
1718
|
+
offset = value;
|
|
1719
|
+
},
|
|
1720
|
+
},
|
|
1721
|
+
scrollBy: {configurable: true, value: scrollBy},
|
|
1722
|
+
});
|
|
1723
|
+
const origin = {x: 150, y: 200};
|
|
1724
|
+
touch(el, 'touchstart', origin);
|
|
1725
|
+
for (const step of [0.5, 1]) {
|
|
1726
|
+
if (step === 0.5) {
|
|
1727
|
+
offset += scrollsBy;
|
|
1728
|
+
}
|
|
1729
|
+
touch(el, 'touchmove', {
|
|
1730
|
+
x: origin.x + dx * step,
|
|
1731
|
+
y: origin.y + dy * step,
|
|
1732
|
+
});
|
|
1733
|
+
}
|
|
1734
|
+
touch(el, 'touchend');
|
|
1735
|
+
// @ts-expect-error - removing the shadows restores the prototype's
|
|
1736
|
+
delete el.scrollLeft;
|
|
1737
|
+
// @ts-expect-error - same
|
|
1738
|
+
delete el.scrollBy;
|
|
1739
|
+
return scrollBy;
|
|
1740
|
+
};
|
|
1741
|
+
|
|
1742
|
+
it('lets a touch on the calendar reach the sheet, now that it pages sideways', () => {
|
|
1743
|
+
renderAndOpen();
|
|
1744
|
+
const scroller = document.querySelector('[data-scroller="months"]')!;
|
|
1745
|
+
const ancestor = watchAncestor();
|
|
1746
|
+
touch(scroller, 'touchstart');
|
|
1747
|
+
touch(scroller, 'touchmove');
|
|
1748
|
+
// The calendar used to claim the gesture, because it scrolled vertically
|
|
1749
|
+
// and the sheet read every downward drag as a dismiss. Paging sideways
|
|
1750
|
+
// removes the conflict: horizontal pans stay here and vertical ones go to
|
|
1751
|
+
// the sheet, so a downward drag can go back to meaning swipe-to-dismiss.
|
|
1752
|
+
// A move with no direction yet (the same point twice) is nobody's.
|
|
1753
|
+
expect(ancestor.seen).toEqual(['touchstart', 'touchmove']);
|
|
1754
|
+
ancestor.stop();
|
|
1755
|
+
});
|
|
1756
|
+
|
|
1757
|
+
it('claims a horizontal drag on the calendar', () => {
|
|
1758
|
+
renderAndOpen();
|
|
1759
|
+
const scroller = document.querySelector('[data-scroller="months"]')!;
|
|
1760
|
+
const ancestor = watchAncestor();
|
|
1761
|
+
swipe(scroller, -80, 0);
|
|
1762
|
+
// touchstart still propagates — 'inline' cannot know the direction yet —
|
|
1763
|
+
// but every move after the axis locks is ours.
|
|
1764
|
+
expect(ancestor.seen).not.toContain('touchmove');
|
|
1765
|
+
ancestor.stop();
|
|
1766
|
+
});
|
|
1767
|
+
|
|
1768
|
+
it('leaves a downward drag on the calendar to the sheet', () => {
|
|
1769
|
+
renderAndOpen();
|
|
1770
|
+
const scroller = document.querySelector('[data-scroller="months"]')!;
|
|
1771
|
+
const ancestor = watchAncestor();
|
|
1772
|
+
swipe(scroller, 0, 80);
|
|
1773
|
+
expect(ancestor.seen).toContain('touchmove');
|
|
1774
|
+
ancestor.stop();
|
|
1775
|
+
});
|
|
1776
|
+
|
|
1777
|
+
it('keeps a diagonal drag, because a thumb arcs as it swipes', () => {
|
|
1778
|
+
renderAndOpen();
|
|
1779
|
+
const scroller = document.querySelector('[data-scroller="months"]')!;
|
|
1780
|
+
const ancestor = watchAncestor();
|
|
1781
|
+
// ~50° off horizontal: past the browser's own pan-x cone, still ours.
|
|
1782
|
+
swipe(scroller, -60, 72);
|
|
1783
|
+
expect(ancestor.seen).not.toContain('touchmove');
|
|
1784
|
+
ancestor.stop();
|
|
1785
|
+
});
|
|
1786
|
+
|
|
1787
|
+
/**
|
|
1788
|
+
* The band between our claim and the browser's `pan-x` cone. The sheet has
|
|
1789
|
+
* been told to keep off, and the compositor refuses to pan, so without the
|
|
1790
|
+
* fallback these gestures would do nothing at all — measured as a dead zone
|
|
1791
|
+
* from 45° to 60° on an iPhone 15 profile.
|
|
1792
|
+
*/
|
|
1793
|
+
it('pages a month itself when the browser refuses to pan a claimed swipe', () => {
|
|
1794
|
+
renderAndOpen();
|
|
1795
|
+
const scroller = document.querySelector('[data-scroller="months"]')!;
|
|
1796
|
+
|
|
1797
|
+
const forward = swipe(scroller, -60, 72);
|
|
1798
|
+
expect(forward).toHaveBeenCalledTimes(1);
|
|
1799
|
+
expect(forward.mock.calls[0][0]).toMatchObject({behavior: 'smooth'});
|
|
1800
|
+
// Swiping left advances: the offset moves towards the end of the line.
|
|
1801
|
+
expect(forward.mock.calls[0][0].left).toBeGreaterThan(0);
|
|
1802
|
+
|
|
1803
|
+
const back = swipe(scroller, 60, 72);
|
|
1804
|
+
expect(back.mock.calls[0][0].left).toBeLessThan(0);
|
|
1805
|
+
});
|
|
1806
|
+
|
|
1807
|
+
it('stays out of the way when the browser did pan', () => {
|
|
1808
|
+
renderAndOpen();
|
|
1809
|
+
const scroller = document.querySelector('[data-scroller="months"]')!;
|
|
1810
|
+
// Native momentum and snapping own this one; a second nudge would fight
|
|
1811
|
+
// them. `scrollsBy` stands in for the compositor moving the scroller.
|
|
1812
|
+
expect(swipe(scroller, -120, 0, {scrollsBy: 40})).not.toHaveBeenCalled();
|
|
1813
|
+
});
|
|
1814
|
+
|
|
1815
|
+
it('ignores a claimed gesture too short to be a swipe', () => {
|
|
1816
|
+
renderAndOpen();
|
|
1817
|
+
const scroller = document.querySelector('[data-scroller="months"]')!;
|
|
1818
|
+
expect(swipe(scroller, -SWIPE_DISTANCE + 4, 0)).not.toHaveBeenCalled();
|
|
1819
|
+
});
|
|
1820
|
+
|
|
1821
|
+
it('never pages from a drag it gave to the sheet', () => {
|
|
1822
|
+
renderAndOpen();
|
|
1823
|
+
const scroller = document.querySelector('[data-scroller="months"]')!;
|
|
1824
|
+
expect(swipe(scroller, -40, 200)).not.toHaveBeenCalled();
|
|
1825
|
+
});
|
|
1826
|
+
|
|
1827
|
+
it('stops a touch on a wheel from reaching the sheet', () => {
|
|
1828
|
+
renderAndOpen();
|
|
1829
|
+
fireEvent.click(
|
|
1830
|
+
document.querySelector<HTMLElement>('[data-title="month-year"]')!,
|
|
1831
|
+
);
|
|
1832
|
+
const wheel = screen.getByRole('listbox', {name: 'Month'});
|
|
1833
|
+
const ancestor = watchAncestor();
|
|
1834
|
+
touch(wheel, 'touchstart');
|
|
1835
|
+
touch(wheel, 'touchmove');
|
|
1836
|
+
expect(ancestor.seen).not.toContain('touchstart');
|
|
1837
|
+
expect(ancestor.seen).not.toContain('touchmove');
|
|
1838
|
+
ancestor.stop();
|
|
1839
|
+
});
|
|
1840
|
+
|
|
1841
|
+
it('lets touchend through, so the sheet can reset its own bookkeeping', () => {
|
|
1842
|
+
renderAndOpen();
|
|
1843
|
+
const scroller = document.querySelector('[data-scroller="months"]')!;
|
|
1844
|
+
const ancestor = watchAncestor();
|
|
1845
|
+
touch(scroller, 'touchend');
|
|
1846
|
+
expect(ancestor.seen).toContain('touchend');
|
|
1847
|
+
ancestor.stop();
|
|
1848
|
+
});
|
|
1849
|
+
|
|
1850
|
+
it('leaves the rest of the picker to the sheet', () => {
|
|
1851
|
+
renderAndOpen();
|
|
1852
|
+
// The header is not a scroller: a drag there is the sheet's to interpret,
|
|
1853
|
+
// and it is one of the two places a dismiss can still start from.
|
|
1854
|
+
const title = document.querySelector<HTMLElement>(
|
|
1855
|
+
'[data-title="month-year"]',
|
|
1856
|
+
)!;
|
|
1857
|
+
const ancestor = watchAncestor();
|
|
1858
|
+
touch(title, 'touchstart');
|
|
1859
|
+
touch(title, 'touchmove');
|
|
1860
|
+
expect(ancestor.seen).toEqual(['touchstart', 'touchmove']);
|
|
1861
|
+
ancestor.stop();
|
|
1862
|
+
});
|
|
1863
|
+
|
|
1864
|
+
it('does not swallow taps — stopPropagation must not reach click', () => {
|
|
1865
|
+
const onChange = vi.fn();
|
|
1866
|
+
renderAndOpen(<Controlled initial="2026-03-21" onChange={onChange} />);
|
|
1867
|
+
fireEvent.click(
|
|
1868
|
+
within(pane('March 2026')).getByRole('button', {name: /March 25, 2026/}),
|
|
1869
|
+
);
|
|
1870
|
+
expect(onChange).toHaveBeenCalledWith('2026-03-25');
|
|
1871
|
+
});
|
|
1872
|
+
});
|
|
1873
|
+
|
|
1874
|
+
// ---------------------------------------------------------------------------
|
|
1875
|
+
// Dragging a wheel with a mouse
|
|
1876
|
+
// ---------------------------------------------------------------------------
|
|
1877
|
+
|
|
1878
|
+
/**
|
|
1879
|
+
* A wheel is a scroll container, so a finger pans it for free. A mouse gets
|
|
1880
|
+
* nothing: browsers do not drag-scroll an overflow container, so pressing and
|
|
1881
|
+
* pulling on the one control shaped like a thing you spin did nothing at all.
|
|
1882
|
+
*
|
|
1883
|
+
* It matters on desktop specifically because that is where this surface is
|
|
1884
|
+
* reviewed, themed and screenshotted.
|
|
1885
|
+
*/
|
|
1886
|
+
describe('DateInput — a mouse can drag a wheel', () => {
|
|
1887
|
+
const title = () =>
|
|
1888
|
+
document.querySelector<HTMLElement>('[data-title="month-year"]')!;
|
|
1889
|
+
|
|
1890
|
+
/** jsdom has no layout: give the rows a height and the wheel a scrollTop. */
|
|
1891
|
+
function withWheelLayout<T>(fn: () => T): T {
|
|
1892
|
+
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
|
|
1893
|
+
configurable: true,
|
|
1894
|
+
get(this: HTMLElement) {
|
|
1895
|
+
return this.getAttribute('role') === 'option' ? 28 : 0;
|
|
1896
|
+
},
|
|
1897
|
+
});
|
|
1898
|
+
try {
|
|
1899
|
+
return fn();
|
|
1900
|
+
} finally {
|
|
1901
|
+
// @ts-expect-error - deleting the shadow restores Element's own getter
|
|
1902
|
+
delete HTMLElement.prototype.offsetHeight;
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
const wheel = () => screen.getByRole('listbox', {name: 'Month'});
|
|
1907
|
+
|
|
1908
|
+
const pointer = (
|
|
1909
|
+
type: string,
|
|
1910
|
+
{y = 0, pointerType = 'mouse', button = 0, id = 1} = {},
|
|
1911
|
+
) => {
|
|
1912
|
+
const event = new Event(type, {bubbles: true, cancelable: true});
|
|
1913
|
+
Object.defineProperties(event, {
|
|
1914
|
+
clientY: {value: y},
|
|
1915
|
+
pointerId: {value: id},
|
|
1916
|
+
pointerType: {value: pointerType},
|
|
1917
|
+
button: {value: button},
|
|
1918
|
+
});
|
|
1919
|
+
return event;
|
|
1920
|
+
};
|
|
1921
|
+
|
|
1922
|
+
/** Stubs jsdom's missing capture API and records scrollTop as it moves. */
|
|
1923
|
+
function trackable(el: HTMLElement) {
|
|
1924
|
+
let top = 0;
|
|
1925
|
+
Object.defineProperty(el, 'scrollTop', {
|
|
1926
|
+
configurable: true,
|
|
1927
|
+
get: () => top,
|
|
1928
|
+
set: (v: number) => {
|
|
1929
|
+
top = v;
|
|
1930
|
+
},
|
|
1931
|
+
});
|
|
1932
|
+
el.setPointerCapture = () => {};
|
|
1933
|
+
el.releasePointerCapture = () => {};
|
|
1934
|
+
el.hasPointerCapture = () => true;
|
|
1935
|
+
el.scrollTo = ((opts: ScrollToOptions) => {
|
|
1936
|
+
top = opts.top ?? top;
|
|
1937
|
+
}) as HTMLElement['scrollTo'];
|
|
1938
|
+
return {
|
|
1939
|
+
get scrollTop() {
|
|
1940
|
+
return top;
|
|
1941
|
+
},
|
|
1942
|
+
};
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
it('scrolls the wheel when a mouse drags it', () => {
|
|
1946
|
+
renderAndOpen();
|
|
1947
|
+
fireEvent.click(title());
|
|
1948
|
+
withWheelLayout(() => {
|
|
1949
|
+
const el = wheel();
|
|
1950
|
+
const track = trackable(el);
|
|
1951
|
+
el.dispatchEvent(pointer('pointerdown', {y: 200}));
|
|
1952
|
+
el.dispatchEvent(pointer('pointermove', {y: 160}));
|
|
1953
|
+
// Content follows the hand: pulling up scrolls further down the list.
|
|
1954
|
+
expect(track.scrollTop).toBe(40);
|
|
1955
|
+
el.dispatchEvent(pointer('pointerup', {y: 160}));
|
|
1956
|
+
// And the release lands on a row boundary rather than between two.
|
|
1957
|
+
expect(track.scrollTop % 28).toBe(0);
|
|
1958
|
+
});
|
|
1959
|
+
});
|
|
1960
|
+
|
|
1961
|
+
it('ignores movement too small to be a drag, so a click is still a click', () => {
|
|
1962
|
+
renderAndOpen();
|
|
1963
|
+
fireEvent.click(title());
|
|
1964
|
+
withWheelLayout(() => {
|
|
1965
|
+
const el = wheel();
|
|
1966
|
+
const track = trackable(el);
|
|
1967
|
+
el.dispatchEvent(pointer('pointerdown', {y: 200}));
|
|
1968
|
+
el.dispatchEvent(pointer('pointermove', {y: 200 - (DRAG_SLOP - 1)}));
|
|
1969
|
+
expect(track.scrollTop).toBe(0);
|
|
1970
|
+
});
|
|
1971
|
+
});
|
|
1972
|
+
|
|
1973
|
+
it('leaves touch and pen alone — they pan natively, and better', () => {
|
|
1974
|
+
renderAndOpen();
|
|
1975
|
+
fireEvent.click(title());
|
|
1976
|
+
withWheelLayout(() => {
|
|
1977
|
+
for (const pointerType of ['touch', 'pen']) {
|
|
1978
|
+
const el = wheel();
|
|
1979
|
+
const track = trackable(el);
|
|
1980
|
+
el.dispatchEvent(pointer('pointerdown', {y: 200, pointerType}));
|
|
1981
|
+
el.dispatchEvent(pointer('pointermove', {y: 120, pointerType}));
|
|
1982
|
+
expect(track.scrollTop).toBe(0);
|
|
1983
|
+
el.dispatchEvent(pointer('pointerup', {y: 120, pointerType}));
|
|
1984
|
+
}
|
|
1985
|
+
});
|
|
1986
|
+
});
|
|
1987
|
+
|
|
1988
|
+
it('ignores a secondary button, which belongs to the context menu', () => {
|
|
1989
|
+
renderAndOpen();
|
|
1990
|
+
fireEvent.click(title());
|
|
1991
|
+
withWheelLayout(() => {
|
|
1992
|
+
const el = wheel();
|
|
1993
|
+
const track = trackable(el);
|
|
1994
|
+
el.dispatchEvent(pointer('pointerdown', {y: 200, button: 2}));
|
|
1995
|
+
el.dispatchEvent(pointer('pointermove', {y: 120}));
|
|
1996
|
+
expect(track.scrollTop).toBe(0);
|
|
1997
|
+
});
|
|
1998
|
+
});
|
|
1999
|
+
|
|
2000
|
+
/**
|
|
2001
|
+
* `scroll-snap-type: y mandatory` re-snaps after every scroll, programmatic
|
|
2002
|
+
* ones included. Measured with it left on: 7 of 8 five-pixel drag steps were
|
|
2003
|
+
* yanked back to a snap position, so the wheel stuck to a row and then
|
|
2004
|
+
* jumped a whole one.
|
|
2005
|
+
*/
|
|
2006
|
+
it('suspends snapping for the drag, and restores it after', () => {
|
|
2007
|
+
renderAndOpen();
|
|
2008
|
+
fireEvent.click(title());
|
|
2009
|
+
withWheelLayout(() => {
|
|
2010
|
+
const el = wheel();
|
|
2011
|
+
trackable(el);
|
|
2012
|
+
expect(el.style.scrollSnapType).toBe('');
|
|
2013
|
+
el.dispatchEvent(pointer('pointerdown', {y: 200}));
|
|
2014
|
+
el.dispatchEvent(pointer('pointermove', {y: 160}));
|
|
2015
|
+
expect(el.style.scrollSnapType).toBe('none');
|
|
2016
|
+
el.dispatchEvent(pointer('pointerup', {y: 160}));
|
|
2017
|
+
el.dispatchEvent(new Event('scrollend'));
|
|
2018
|
+
expect(el.style.scrollSnapType).toBe('');
|
|
2019
|
+
});
|
|
2020
|
+
});
|
|
2021
|
+
|
|
2022
|
+
/**
|
|
2023
|
+
* BottomSheet starts its own drag-to-dismiss from a `pointerdown` on its
|
|
2024
|
+
* body and CAPTURES the pointer for it, which retargets every later pointer
|
|
2025
|
+
* event — including the click. Measured before this: a click on a wheel row
|
|
2026
|
+
* that wobbled more than a pixel selected nothing at all.
|
|
2027
|
+
*/
|
|
2028
|
+
it('keeps the press away from the sheet, which would capture the pointer', () => {
|
|
2029
|
+
renderAndOpen();
|
|
2030
|
+
fireEvent.click(title());
|
|
2031
|
+
withWheelLayout(() => {
|
|
2032
|
+
const el = wheel();
|
|
2033
|
+
trackable(el);
|
|
2034
|
+
const seen: string[] = [];
|
|
2035
|
+
const listener = (event: Event) => seen.push(event.type);
|
|
2036
|
+
document.body.addEventListener('pointerdown', listener);
|
|
2037
|
+
el.dispatchEvent(pointer('pointerdown', {y: 200}));
|
|
2038
|
+
expect(seen).not.toContain('pointerdown');
|
|
2039
|
+
document.body.removeEventListener('pointerdown', listener);
|
|
2040
|
+
});
|
|
2041
|
+
});
|
|
2042
|
+
});
|
|
2043
|
+
|
|
2044
|
+
// ---------------------------------------------------------------------------
|
|
2045
|
+
// Pure month arithmetic
|
|
2046
|
+
// ---------------------------------------------------------------------------
|
|
2047
|
+
|
|
2048
|
+
describe('monthGeometry', () => {
|
|
2049
|
+
it('round-trips a month through its index', () => {
|
|
2050
|
+
for (const [year, month] of [
|
|
2051
|
+
[2026, 1],
|
|
2052
|
+
[2026, 12],
|
|
2053
|
+
[1976, 7],
|
|
2054
|
+
[2100, 2],
|
|
2055
|
+
] as const) {
|
|
2056
|
+
expect(fromMonthIndex(toMonthIndex(year, month))).toEqual({year, month});
|
|
2057
|
+
}
|
|
2058
|
+
});
|
|
2059
|
+
|
|
2060
|
+
it('orders months across a year boundary', () => {
|
|
2061
|
+
expect(toMonthIndex(2027, 1) - toMonthIndex(2026, 12)).toBe(1);
|
|
2062
|
+
});
|
|
2063
|
+
|
|
2064
|
+
it('reads a month index off a PlainDate, ignoring the day', () => {
|
|
2065
|
+
expect(monthIndexOf({year: 2026, month: 3, day: 1})).toBe(
|
|
2066
|
+
monthIndexOf({year: 2026, month: 3, day: 31}),
|
|
2067
|
+
);
|
|
2068
|
+
});
|
|
2069
|
+
|
|
2070
|
+
it('clamps to the reachable range', () => {
|
|
2071
|
+
expect(clampIndex(5, 10, 20)).toBe(10);
|
|
2072
|
+
expect(clampIndex(25, 10, 20)).toBe(20);
|
|
2073
|
+
expect(clampIndex(15, 10, 20)).toBe(15);
|
|
2074
|
+
});
|
|
2075
|
+
|
|
2076
|
+
describe('rowAtScrollOffset', () => {
|
|
2077
|
+
it('maps an exact offset to its row', () => {
|
|
2078
|
+
expect(rowAtScrollOffset(0, PANE, 100)).toBe(0);
|
|
2079
|
+
expect(rowAtScrollOffset(PANE * 7, PANE, 100)).toBe(7);
|
|
2080
|
+
});
|
|
2081
|
+
|
|
2082
|
+
it('rounds to the nearest row mid-scroll', () => {
|
|
2083
|
+
expect(rowAtScrollOffset(PANE * 7 + 10, PANE, 100)).toBe(7);
|
|
2084
|
+
expect(rowAtScrollOffset(PANE * 7 - 10, PANE, 100)).toBe(7);
|
|
2085
|
+
expect(rowAtScrollOffset(PANE * 6.6, PANE, 100)).toBe(7);
|
|
2086
|
+
});
|
|
2087
|
+
|
|
2088
|
+
it('never leaves the list', () => {
|
|
2089
|
+
expect(rowAtScrollOffset(-500, PANE, 100)).toBe(0);
|
|
2090
|
+
expect(rowAtScrollOffset(PANE * 1000, PANE, 100)).toBe(99);
|
|
2091
|
+
});
|
|
2092
|
+
|
|
2093
|
+
it('is 0 before the pane size is known, rather than dividing by zero', () => {
|
|
2094
|
+
expect(rowAtScrollOffset(1234, 0, 100)).toBe(0);
|
|
2095
|
+
});
|
|
2096
|
+
|
|
2097
|
+
it('reads RTL scrollLeft, which counts down from zero', () => {
|
|
2098
|
+
// The spec puts the inline start at 0 and runs negative from there, so
|
|
2099
|
+
// an unsigned read would pin an RTL calendar to month zero forever.
|
|
2100
|
+
expect(rowAtScrollOffset(-PANE * 7, PANE, 100, true)).toBe(7);
|
|
2101
|
+
expect(rowAtScrollOffset(-PANE * 7 - 10, PANE, 100, true)).toBe(7);
|
|
2102
|
+
expect(rowAtScrollOffset(0, PANE, 100, true)).toBe(0);
|
|
2103
|
+
});
|
|
2104
|
+
});
|
|
2105
|
+
|
|
2106
|
+
describe('scrollOffsetForRow', () => {
|
|
2107
|
+
it('is the inverse of rowAtScrollOffset, in both directions', () => {
|
|
2108
|
+
for (const isRTL of [false, true]) {
|
|
2109
|
+
for (const row of [0, 1, 7, 99]) {
|
|
2110
|
+
const offset = scrollOffsetForRow(row, PANE, isRTL);
|
|
2111
|
+
expect(rowAtScrollOffset(offset, PANE, 100, isRTL)).toBe(row);
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
});
|
|
2115
|
+
|
|
2116
|
+
it('runs negative under RTL', () => {
|
|
2117
|
+
expect(scrollOffsetForRow(3, PANE, false)).toBe(PANE * 3);
|
|
2118
|
+
expect(scrollOffsetForRow(3, PANE, true)).toBe(-PANE * 3);
|
|
2119
|
+
});
|
|
2120
|
+
});
|
|
2121
|
+
|
|
2122
|
+
describe('paneWindow', () => {
|
|
2123
|
+
it('mounts the overscan on both sides', () => {
|
|
2124
|
+
expect(paneWindow(50, 100, 3)).toEqual({start: 47, end: 53});
|
|
2125
|
+
expect(rowsIn(paneWindow(50, 100, 1))).toEqual([49, 50, 51]);
|
|
2126
|
+
});
|
|
2127
|
+
|
|
2128
|
+
it('truncates at the ends of the list instead of going out of bounds', () => {
|
|
2129
|
+
expect(paneWindow(0, 100, 3)).toEqual({start: 0, end: 3});
|
|
2130
|
+
expect(paneWindow(99, 100, 3)).toEqual({start: 96, end: 99});
|
|
2131
|
+
});
|
|
2132
|
+
});
|
|
2133
|
+
|
|
2134
|
+
it('reaches a century in each direction by default', () => {
|
|
2135
|
+
expect(DEFAULT_MONTH_REACH).toBe(600);
|
|
2136
|
+
});
|
|
2137
|
+
});
|
|
2138
|
+
|
|
2139
|
+
// ---------------------------------------------------------------------------
|
|
2140
|
+
// Styles jsdom cannot resolve — assert the definition, not the effect
|
|
2141
|
+
// ---------------------------------------------------------------------------
|
|
2142
|
+
|
|
2143
|
+
describe('DateInput — scroll CSS (definition-level)', () => {
|
|
2144
|
+
const dir = path.dirname(fileURLToPath(import.meta.url));
|
|
2145
|
+
const read = (file: string) => readFileSync(path.join(dir, file), 'utf8');
|
|
2146
|
+
|
|
2147
|
+
/**
|
|
2148
|
+
* The declarations inside one named style object, comments stripped and
|
|
2149
|
+
* trailing commas removed.
|
|
2150
|
+
*
|
|
2151
|
+
* Crude on purpose — a nested value object contributes its own inner lines
|
|
2152
|
+
* too. Every caller looks for a specific property prefix, so the noise is
|
|
2153
|
+
* harmless, and a real parser here would be more machinery than the
|
|
2154
|
+
* question deserves.
|
|
2155
|
+
*/
|
|
2156
|
+
const declarations = (source: string, object: string) => {
|
|
2157
|
+
const open = source.indexOf(` ${object}: {`);
|
|
2158
|
+
expect(open).toBeGreaterThan(-1);
|
|
2159
|
+
return source
|
|
2160
|
+
.slice(open, source.indexOf('\n },', open))
|
|
2161
|
+
.replace(/\/\/.*$/gm, '')
|
|
2162
|
+
.split('\n')
|
|
2163
|
+
.map(line => line.trim())
|
|
2164
|
+
.filter(line => line.endsWith(','))
|
|
2165
|
+
.map(line => line.slice(0, -1));
|
|
2166
|
+
};
|
|
2167
|
+
|
|
2168
|
+
/** The same, scoped to one `export const <group> = stylex.create({...})`. */
|
|
2169
|
+
const declarationsIn = (source: string, group: string, object: string) => {
|
|
2170
|
+
const at = source.indexOf(`export const ${group}`);
|
|
2171
|
+
expect(at).toBeGreaterThan(-1);
|
|
2172
|
+
return declarations(source.slice(at), object);
|
|
2173
|
+
};
|
|
2174
|
+
|
|
2175
|
+
it('pages the month scroller one whole pane at a time, horizontally', () => {
|
|
2176
|
+
const source = read('MonthScroller.tsx');
|
|
2177
|
+
expect(source).toContain("scrollSnapType: 'x mandatory'");
|
|
2178
|
+
expect(source).toContain("scrollSnapAlign: 'start'");
|
|
2179
|
+
// pan-x is what splits the gesture by axis: horizontal pans stay with the
|
|
2180
|
+
// calendar, vertical ones reach the sheet as swipe-to-dismiss. Without it
|
|
2181
|
+
// the two would fight again, the way they did when this scrolled down.
|
|
2182
|
+
expect(source).toContain("touchAction: 'pan-x'");
|
|
2183
|
+
// The pane and the scrollport must come from the same expression, or a
|
|
2184
|
+
// pane stops being exactly one screen and every snap offset drifts.
|
|
2185
|
+
expect(
|
|
2186
|
+
source.match(/blockSize: dateInputTouchGeometry\.paneBlockSize/g),
|
|
2187
|
+
).toHaveLength(2);
|
|
2188
|
+
});
|
|
2189
|
+
|
|
2190
|
+
it('positions panes with logical properties, so RTL mirrors', () => {
|
|
2191
|
+
const source = read('MonthScroller.tsx');
|
|
2192
|
+
// Scoped to the style objects: `scrollTo({left})` is the DOM API and is
|
|
2193
|
+
// supposed to say left — it is the CSS that must stay logical, because
|
|
2194
|
+
// physical `left` would lay the months out identically in both directions
|
|
2195
|
+
// while the scroll math mirrored, and the two would disagree under RTL.
|
|
2196
|
+
const styles = source
|
|
2197
|
+
.slice(
|
|
2198
|
+
source.indexOf('const styles = stylex.create('),
|
|
2199
|
+
source.indexOf('export interface MonthScrollerHandle'),
|
|
2200
|
+
)
|
|
2201
|
+
// Comments explain the rule ("insetInlineStart, not left") and would
|
|
2202
|
+
// otherwise trip it.
|
|
2203
|
+
.replace(/\/\/.*$/gm, '');
|
|
2204
|
+
expect(styles).toContain('insetInlineStart');
|
|
2205
|
+
expect(styles).not.toMatch(/\bleft:/);
|
|
2206
|
+
expect(styles).not.toMatch(/\bright:/);
|
|
2207
|
+
});
|
|
2208
|
+
|
|
2209
|
+
/**
|
|
2210
|
+
* Adjacent days take the desktop calendar's exact treatment, and the point
|
|
2211
|
+
* of reading BOTH files is that "exact" stays true if the desktop's changes.
|
|
2212
|
+
*
|
|
2213
|
+
* Calendar splits the treatment across two style objects — `dayCellTheme`
|
|
2214
|
+
* carries the colour, `dayCellStyles` the opacity — so copying it by eye
|
|
2215
|
+
* gets you one half and not the other. This pane took only the colour at
|
|
2216
|
+
* first, and the spill days came out visibly heavier than the desktop's.
|
|
2217
|
+
*
|
|
2218
|
+
* Both surfaces now render the same three tiers, measured in a browser on
|
|
2219
|
+
* the same story: 23 for a day you can pick, 185 for one the range rules
|
|
2220
|
+
* out, 203 for a spilled day (which is outside AND therefore disabled, so
|
|
2221
|
+
* it takes both). The enabled-adjacent tier that used to sit at 169 is gone
|
|
2222
|
+
* with the tap that produced it.
|
|
2223
|
+
*/
|
|
2224
|
+
it('mutes adjacent days exactly as the desktop calendar does', () => {
|
|
2225
|
+
// The desktop's two halves, read out of its own source rather than
|
|
2226
|
+
// restated here — so the check follows the desktop if its treatment
|
|
2227
|
+
// moves, instead of freezing today's values into this file.
|
|
2228
|
+
const desktop = read('../Calendar/styles.ts');
|
|
2229
|
+
const structural = declarationsIn(desktop, 'dayCellStyles', 'dayOutside');
|
|
2230
|
+
const theme = declarationsIn(desktop, 'dayCellTheme', 'dayOutside');
|
|
2231
|
+
// Both halves must have found something, or the parity check below is
|
|
2232
|
+
// `arrayContaining([])` and passes on anything at all.
|
|
2233
|
+
expect(structural.length).toBeGreaterThan(0);
|
|
2234
|
+
expect(theme.length).toBeGreaterThan(0);
|
|
2235
|
+
|
|
2236
|
+
// And the touch pane carries all of it, in its one object.
|
|
2237
|
+
const touch = declarations(read('MonthScroller.tsx'), 'dayOutside');
|
|
2238
|
+
expect(touch).toEqual(expect.arrayContaining([...structural, ...theme]));
|
|
2239
|
+
});
|
|
2240
|
+
|
|
2241
|
+
/**
|
|
2242
|
+
* Disabled days follow the desktop too, and this is the half that actually
|
|
2243
|
+
* answered "the disabled dates and the adjacent ones look the same".
|
|
2244
|
+
*
|
|
2245
|
+
* The desktop FADES a disabled day rather than recolouring it: `opacity:
|
|
2246
|
+
* 0.3` over whatever colour the day already had. This pane used to paint a
|
|
2247
|
+
* flat `--color-text-disabled` instead, which on white put a disabled
|
|
2248
|
+
* in-month day at ~163 and an ENABLED adjacent day at ~168 — five levels
|
|
2249
|
+
* apart, indistinguishable, and with the disabled one the darker of the
|
|
2250
|
+
* two, so the unpickable date read as the more solid one. Fading instead
|
|
2251
|
+
* puts every disabled day lighter than every enabled one, which is the
|
|
2252
|
+
* ordering a calendar needs.
|
|
2253
|
+
*/
|
|
2254
|
+
it('fades disabled days as the desktop does, rather than recolouring them', () => {
|
|
2255
|
+
const desktop = read('../Calendar/styles.ts');
|
|
2256
|
+
const theme = declarationsIn(desktop, 'dayCellTheme', 'dayDisabled');
|
|
2257
|
+
const touch = declarations(read('MonthScroller.tsx'), 'dayDisabled');
|
|
2258
|
+
|
|
2259
|
+
// Whatever opacity the desktop fades to, this pane fades to the same one.
|
|
2260
|
+
const fade = theme.find(d => d.startsWith('opacity:'));
|
|
2261
|
+
expect(fade).toBeDefined();
|
|
2262
|
+
expect(touch).toContain(fade);
|
|
2263
|
+
|
|
2264
|
+
// And no colour of its own: a colour would override the secondary one a
|
|
2265
|
+
// spilled day carries, collapsing "disabled" and "disabled and adjacent"
|
|
2266
|
+
// into a single shade.
|
|
2267
|
+
expect(touch.some(d => d.startsWith('color:'))).toBe(false);
|
|
2268
|
+
// Parity, not a rule of this pane's own — if the desktop ever starts
|
|
2269
|
+
// recolouring a disabled day, this fires and says to follow it.
|
|
2270
|
+
expect(theme.some(d => d.startsWith('color:'))).toBe(false);
|
|
2271
|
+
});
|
|
2272
|
+
|
|
2273
|
+
/**
|
|
2274
|
+
* Order matters as much as the values: `dayDisabled` is applied AFTER
|
|
2275
|
+
* `dayOutside`, so a spilled day beyond min/max paints disabled rather than
|
|
2276
|
+
* merely outside. Reversed, an unselectable date would look more available
|
|
2277
|
+
* than the selectable ones beside it.
|
|
2278
|
+
*/
|
|
2279
|
+
it('lets disabled win over outside on a spilled day past min/max', () => {
|
|
2280
|
+
const applied = read('MonthScroller.tsx');
|
|
2281
|
+
const outside = applied.indexOf('day.isOutside && styles.dayOutside');
|
|
2282
|
+
const disabled = applied.indexOf('isDisabled && styles.dayDisabled');
|
|
2283
|
+
expect(outside).toBeGreaterThan(-1);
|
|
2284
|
+
expect(outside).toBeLessThan(disabled);
|
|
2285
|
+
});
|
|
2286
|
+
|
|
2287
|
+
it('keeps the month scroller and the wheels on border-box', () => {
|
|
2288
|
+
// Load-bearing: clientHeight is the pane height, the snap offsets and the
|
|
2289
|
+
// virtualization all at once.
|
|
2290
|
+
expect(read('MonthScroller.tsx')).toContain("boxSizing: 'border-box'");
|
|
2291
|
+
expect(read('Wheel.tsx')).toContain("boxSizing: 'border-box'");
|
|
2292
|
+
});
|
|
2293
|
+
|
|
2294
|
+
it('sizes the wheel row tighter than a day cell, with larger text', () => {
|
|
2295
|
+
const tokens = read('tokens.stylex.ts');
|
|
2296
|
+
// Scroll-first rows: closer together than day cells and closer to the
|
|
2297
|
+
// text they hold, the way a platform picker packs them.
|
|
2298
|
+
expect(tokens).toContain("wheelItemSize: '28px'");
|
|
2299
|
+
expect(read('Wheel.tsx')).toContain(
|
|
2300
|
+
"fontSize: typeScaleVars['--text-large-size']",
|
|
2301
|
+
);
|
|
2302
|
+
});
|
|
2303
|
+
|
|
2304
|
+
it('centers wheel rows and pads both ends so either extreme can reach the band', () => {
|
|
2305
|
+
const source = read('Wheel.tsx');
|
|
2306
|
+
expect(source).toContain("scrollSnapType: 'y mandatory'");
|
|
2307
|
+
expect(source).toContain("scrollSnapAlign: 'center'");
|
|
2308
|
+
expect(source).toContain(
|
|
2309
|
+
'paddingBlock: dateInputTouchGeometry.wheelEdgePadding',
|
|
2310
|
+
);
|
|
2311
|
+
});
|
|
2312
|
+
|
|
2313
|
+
it('guards the scroll-driven falloff behind @supports', () => {
|
|
2314
|
+
const source = read('Wheel.tsx');
|
|
2315
|
+
// Without the guard, a browser that does not understand animation-timeline
|
|
2316
|
+
// runs these keyframes once on the document timeline instead.
|
|
2317
|
+
expect(source).toContain("'@supports (animation-timeline: view())'");
|
|
2318
|
+
expect(source).toContain("animationTimeline: 'view(y)'");
|
|
2319
|
+
expect(source).toContain("'@media (prefers-reduced-motion: reduce)'");
|
|
2320
|
+
});
|
|
2321
|
+
|
|
2322
|
+
it('keeps the falloff off the snap area itself', () => {
|
|
2323
|
+
// A transform moves the snap area, and the wheel then settles a few
|
|
2324
|
+
// pixels off the row it is showing.
|
|
2325
|
+
const source = read('Wheel.tsx');
|
|
2326
|
+
const itemInner = source.slice(source.indexOf('itemInner: {'));
|
|
2327
|
+
expect(itemInner).toContain("animationTimeline: 'view(y)'");
|
|
2328
|
+
const item = source.slice(
|
|
2329
|
+
source.indexOf(' item: {'),
|
|
2330
|
+
source.indexOf('itemInner: {'),
|
|
2331
|
+
);
|
|
2332
|
+
expect(item).not.toContain('animationTimeline');
|
|
2333
|
+
expect(item).not.toContain("overflow: 'hidden'");
|
|
2334
|
+
});
|
|
2335
|
+
|
|
2336
|
+
it('insets the sheet content equally on every edge', () => {
|
|
2337
|
+
const source = read('TouchDateField.tsx');
|
|
2338
|
+
// One inset, and the header/footer must not add their own on top of it —
|
|
2339
|
+
// that is what put the title and Done 4px off the day grid's line.
|
|
2340
|
+
expect(source).toContain("paddingInline: spacingVars['--spacing-4']");
|
|
2341
|
+
expect(source).toContain("paddingBlockEnd: spacingVars['--spacing-4']");
|
|
2342
|
+
// The block-start is the documented exception: the grab handle floats out
|
|
2343
|
+
// of flow, so the content wrapper owes it the height it occupies.
|
|
2344
|
+
expect(source).toContain("paddingBlockStart: spacingVars['--spacing-6']");
|
|
2345
|
+
const header = source.slice(
|
|
2346
|
+
source.indexOf(' header: {'),
|
|
2347
|
+
source.indexOf(' title: {'),
|
|
2348
|
+
);
|
|
2349
|
+
expect(header).not.toContain('paddingInline');
|
|
2350
|
+
const footer = source.slice(
|
|
2351
|
+
source.indexOf(' footer: {'),
|
|
2352
|
+
source.indexOf(' sheetBody: {'),
|
|
2353
|
+
);
|
|
2354
|
+
expect(footer).not.toContain('paddingInline');
|
|
2355
|
+
});
|
|
2356
|
+
|
|
2357
|
+
it('floors the touch target without discarding the size prop', () => {
|
|
2358
|
+
const source = read('TouchDateField.tsx');
|
|
2359
|
+
// Each size keeps its own height AND cannot render below a thumb's reach.
|
|
2360
|
+
const sizeMap = source.slice(
|
|
2361
|
+
source.indexOf('const sizeStyles = stylex.create('),
|
|
2362
|
+
source.indexOf('const styles = stylex.create('),
|
|
2363
|
+
);
|
|
2364
|
+
expect(
|
|
2365
|
+
sizeMap.match(
|
|
2366
|
+
/minBlockSize: \{default: null, '@media \(pointer: coarse\)': TOUCH_TARGET\}/g,
|
|
2367
|
+
),
|
|
2368
|
+
).toHaveLength(3);
|
|
2369
|
+
});
|
|
2370
|
+
|
|
2371
|
+
it('floors the month arrows too — Button tops out at 36px', () => {
|
|
2372
|
+
const source = read('TouchDateField.tsx');
|
|
2373
|
+
const arrow = source.slice(
|
|
2374
|
+
source.indexOf(' monthArrow: {'),
|
|
2375
|
+
source.indexOf(' monthArrowIcon: {'),
|
|
2376
|
+
);
|
|
2377
|
+
expect(arrow).toContain(
|
|
2378
|
+
"minBlockSize: {default: null, '@media (pointer: coarse)': TOUCH_TARGET}",
|
|
2379
|
+
);
|
|
2380
|
+
expect(arrow).toContain(
|
|
2381
|
+
"minInlineSize: {default: null, '@media (pointer: coarse)': TOUCH_TARGET}",
|
|
2382
|
+
);
|
|
2383
|
+
});
|
|
2384
|
+
|
|
2385
|
+
/**
|
|
2386
|
+
* A bare inline wrapper puts the glyph on the text baseline, a few px above
|
|
2387
|
+
* the button's optical centre. Core's Calendar carries the same rule on its
|
|
2388
|
+
* own nav icons.
|
|
2389
|
+
*/
|
|
2390
|
+
it('keeps the mirrored arrow glyph centred', () => {
|
|
2391
|
+
const source = read('TouchDateField.tsx');
|
|
2392
|
+
const icon = source.slice(source.indexOf(' monthArrowIcon: {'));
|
|
2393
|
+
expect(icon.slice(0, icon.indexOf('}'))).toContain(
|
|
2394
|
+
"display: 'inline-flex'",
|
|
2395
|
+
);
|
|
2396
|
+
});
|
|
2397
|
+
|
|
2398
|
+
/**
|
|
2399
|
+
* The wheels are a layer that fades in and out on top; the calendar under
|
|
2400
|
+
* them is covered and uncovered, and never seen to move.
|
|
2401
|
+
*
|
|
2402
|
+
* The swap used to take turns — the outgoing surface faded out over a first
|
|
2403
|
+
* leg, the incoming one waited and faded in over a second — because the
|
|
2404
|
+
* wheels' panel was transparent. Overlapping them put the wheels'
|
|
2405
|
+
* translucent selection band over the live calendar grid and tinted one
|
|
2406
|
+
* band-shaped strip of it: "the grey area animates differently from the
|
|
2407
|
+
* content".
|
|
2408
|
+
*
|
|
2409
|
+
* An opaque plate INSIDE the fading layer fixes that at the root. The layer
|
|
2410
|
+
* renders as one finished opaque image and the fade applies to the image,
|
|
2411
|
+
* so the band crosses at the same rate as everything beside it. And once it
|
|
2412
|
+
* is opaque, the calendar needs no fade of its own — animating it too would
|
|
2413
|
+
* be animating the date picker rather than the thing arriving over it.
|
|
2414
|
+
*
|
|
2415
|
+
* What this pins is that split: the layer above gets opacity and a
|
|
2416
|
+
* background, the layer beneath gets visibility and nothing else.
|
|
2417
|
+
*/
|
|
2418
|
+
it('fades the wheels in and out over a calendar that does not animate', () => {
|
|
2419
|
+
const source = read('TouchDateField.tsx');
|
|
2420
|
+
const overlay = declarations(source, 'panelOverlay');
|
|
2421
|
+
const beneath = declarations(source, 'panelBeneath');
|
|
2422
|
+
|
|
2423
|
+
// The layer fades, both directions — the transition is on the shown
|
|
2424
|
+
// state, so entering and leaving it both animate.
|
|
2425
|
+
expect(overlay).toContain("transitionProperty: 'opacity, visibility'");
|
|
2426
|
+
expect(overlay).toContain('transitionDuration: SWAP_DURATION');
|
|
2427
|
+
expect(declarations(source, 'panelOverlayHidden')).toContain('opacity: 0');
|
|
2428
|
+
|
|
2429
|
+
// And it is opaque while it does, in the token the sheet paints itself
|
|
2430
|
+
// with, so the fade is uniform across the band and the text alike.
|
|
2431
|
+
expect(overlay).toContain(
|
|
2432
|
+
"backgroundColor: colorVars['--color-background-surface']",
|
|
2433
|
+
);
|
|
2434
|
+
|
|
2435
|
+
// The layer beneath moves nothing that can be SEEN moving: visibility
|
|
2436
|
+
// only, no opacity, so it is covered and uncovered rather than faded.
|
|
2437
|
+
expect(beneath).toContain("transitionProperty: 'visibility'");
|
|
2438
|
+
expect(beneath.some(d => d.startsWith('opacity'))).toBe(false);
|
|
2439
|
+
expect(
|
|
2440
|
+
declarations(source, 'panelBeneathHidden').some(d =>
|
|
2441
|
+
d.startsWith('opacity'),
|
|
2442
|
+
),
|
|
2443
|
+
).toBe(false);
|
|
2444
|
+
|
|
2445
|
+
// Two of each, and never the same one twice: the calendar panel and the
|
|
2446
|
+
// calendar's footer actions are beneath, the wheels and their Done above.
|
|
2447
|
+
const count = (name: string) =>
|
|
2448
|
+
source.match(new RegExp(`styles\\.${name},`, 'g'))?.length ?? 0;
|
|
2449
|
+
expect(count('panelBeneath')).toBe(2);
|
|
2450
|
+
expect(count('panelOverlay')).toBe(2);
|
|
2451
|
+
});
|
|
2452
|
+
|
|
2453
|
+
/**
|
|
2454
|
+
* The layer paints as a unit, which is what makes "opaque" mean "covers".
|
|
2455
|
+
*
|
|
2456
|
+
* Backgrounds and text paint in separate phases, so without a stacking
|
|
2457
|
+
* context a later sibling's background lands UNDER an earlier sibling's
|
|
2458
|
+
* text — the plate went in opaque and the calendar's day numbers showed
|
|
2459
|
+
* straight through it. Measured before and after: ink inside the grid's box
|
|
2460
|
+
* at full cover went from the calendar's 2.5% of pixels to the wheels' own.
|
|
2461
|
+
*
|
|
2462
|
+
* Easy to lose, because any opacity below 1 makes a stacking context by
|
|
2463
|
+
* accident. It only breaks at the two ends of the fade, where opacity is
|
|
2464
|
+
* exactly 1 — which is to say, whenever anyone is actually looking.
|
|
2465
|
+
*/
|
|
2466
|
+
it('paints the layer as a unit, so the plate really covers', () => {
|
|
2467
|
+
expect(declarations(read('TouchDateField.tsx'), 'panelOverlay')).toContain(
|
|
2468
|
+
"isolation: 'isolate'",
|
|
2469
|
+
);
|
|
2470
|
+
});
|
|
2471
|
+
|
|
2472
|
+
/**
|
|
2473
|
+
* The weekday row and the header arrows are the one part of the calendar
|
|
2474
|
+
* the layer cannot cover — the plate starts below the header. They fade on
|
|
2475
|
+
* the layer's own timing rather than clearing instantly, so the change
|
|
2476
|
+
* reads as one motion instead of chrome blinking out a beat ahead of the
|
|
2477
|
+
* grid being covered.
|
|
2478
|
+
*/
|
|
2479
|
+
it('fades the uncovered chrome on the same timing as the layer', () => {
|
|
2480
|
+
const source = read('TouchDateField.tsx');
|
|
2481
|
+
for (const name of ['weekdays', 'monthArrows']) {
|
|
2482
|
+
expect(declarations(source, name)).toContain(
|
|
2483
|
+
'transitionDuration: SWAP_DURATION',
|
|
2484
|
+
);
|
|
2485
|
+
expect(declarations(source, `${name}Hidden`)).toContain('opacity: 0');
|
|
2486
|
+
}
|
|
2487
|
+
});
|
|
2488
|
+
|
|
2489
|
+
/**
|
|
2490
|
+
* Both footer actions span the sheet. A full-width primary is the shape a
|
|
2491
|
+
* phone form ends with, and it puts the target under the thumb wherever
|
|
2492
|
+
* the hand is.
|
|
2493
|
+
*/
|
|
2494
|
+
it('spans the footer with its actions', () => {
|
|
2495
|
+
const source = read('TouchDateField.tsx');
|
|
2496
|
+
// Reset + Save share the calendar's cell; Done is the wheels'. Each
|
|
2497
|
+
// fills the space it is given, so the row divides evenly rather than by
|
|
2498
|
+
// label length.
|
|
2499
|
+
expect(source.match(/width="100%"/g)).toHaveLength(3);
|
|
2500
|
+
});
|
|
2501
|
+
|
|
2502
|
+
/**
|
|
2503
|
+
* The selection band has to be visible enough that fading it reads as a
|
|
2504
|
+
* fade. At `--color-background-muted` (4.7% alpha) the whole plate sat 17
|
|
2505
|
+
* units of colour from the sheet behind it, so its animation had 17 units
|
|
2506
|
+
* to happen in while the text beside it travelled 412 — it did not look
|
|
2507
|
+
* like it was fading, it looked like it appeared at the end.
|
|
2508
|
+
* `--color-neutral` (10%) doubles that to 36 and is still quiet enough to
|
|
2509
|
+
* sit under text.
|
|
2510
|
+
*/
|
|
2511
|
+
it('gives the wheel band enough contrast for its fade to read', () => {
|
|
2512
|
+
const source = read('Wheel.tsx');
|
|
2513
|
+
const band = source.slice(
|
|
2514
|
+
source.indexOf(' band: {'),
|
|
2515
|
+
source.indexOf('});', source.indexOf(' band: {')),
|
|
2516
|
+
);
|
|
2517
|
+
expect(band).toContain("backgroundColor: colorVars['--color-neutral']");
|
|
2518
|
+
});
|
|
2519
|
+
|
|
2520
|
+
/**
|
|
2521
|
+
* `--ease-standard` is `cubic-bezier(0.24, 1, 0.4, 1)`: right for something
|
|
2522
|
+
* travelling a distance, wrong for a fade. Measured with it, opacity hit
|
|
2523
|
+
* 50% in 91ms and 95% in 241ms of a 410ms transition — the fade was over
|
|
2524
|
+
* long before the duration was, so lengthening the duration bought an
|
|
2525
|
+
* imperceptible tail rather than a slower fade. A fade covers no distance,
|
|
2526
|
+
* so its progress should simply be its progress.
|
|
2527
|
+
*
|
|
2528
|
+
* The title chevron is the exception and keeps the token: it rotates, and
|
|
2529
|
+
* rotation is travel.
|
|
2530
|
+
*/
|
|
2531
|
+
it('fades linearly, and eases only the thing that travels', () => {
|
|
2532
|
+
const source = read('TouchDateField.tsx');
|
|
2533
|
+
const styles = source.slice(
|
|
2534
|
+
source.indexOf('const styles = stylex.create('),
|
|
2535
|
+
);
|
|
2536
|
+
const chevron = styles.slice(
|
|
2537
|
+
styles.indexOf(' titleChevron: {'),
|
|
2538
|
+
styles.indexOf(' titleChevronOpen: {'),
|
|
2539
|
+
);
|
|
2540
|
+
expect(chevron).toContain("transitionProperty: 'transform'");
|
|
2541
|
+
expect(chevron).toContain(
|
|
2542
|
+
"transitionTimingFunction: easeVars['--ease-standard']",
|
|
2543
|
+
);
|
|
2544
|
+
// The chevron is the only eased transition; every fade is linear.
|
|
2545
|
+
expect(
|
|
2546
|
+
styles.match(/transitionTimingFunction: easeVars\['--ease-standard'\]/g),
|
|
2547
|
+
).toHaveLength(1);
|
|
2548
|
+
expect(styles.match(/transitionTimingFunction: 'linear'/g)).toHaveLength(3);
|
|
2549
|
+
});
|
|
2550
|
+
|
|
2551
|
+
/**
|
|
2552
|
+
* Everything in the swap runs for the same time, so it reads as one change
|
|
2553
|
+
* rather than several. The chevron included — it used to run
|
|
2554
|
+
* `--duration-medium` against a 220ms swap and was still turning well after
|
|
2555
|
+
* the wheels had settled.
|
|
2556
|
+
*
|
|
2557
|
+
* A token rather than a literal, so a consumer's motion scale carries: the
|
|
2558
|
+
* Storybook theme resolves `--duration-fast` to 125ms, not the default
|
|
2559
|
+
* 175ms, and the swap follows it without knowing.
|
|
2560
|
+
*/
|
|
2561
|
+
it('runs the whole swap on one duration', () => {
|
|
2562
|
+
const source = read('TouchDateField.tsx');
|
|
2563
|
+
const styles = source.slice(
|
|
2564
|
+
source.indexOf('const styles = stylex.create('),
|
|
2565
|
+
);
|
|
2566
|
+
// The arrows, the weekday row, the layer beneath, the layer above, and
|
|
2567
|
+
// the chevron.
|
|
2568
|
+
expect(styles.match(/transitionDuration: SWAP_DURATION/g)).toHaveLength(5);
|
|
2569
|
+
// And no leftover hand-rolled timing beside them.
|
|
2570
|
+
expect(styles).not.toContain('PANEL_FADE_MS');
|
|
2571
|
+
expect(source).toContain(
|
|
2572
|
+
"const SWAP_DURATION = durationVars['--duration-fast']",
|
|
2573
|
+
);
|
|
2574
|
+
});
|
|
2575
|
+
|
|
2576
|
+
it('keeps the virtual keyboard down on the touch field', () => {
|
|
2577
|
+
const source = read('TouchDateField.tsx');
|
|
2578
|
+
// readOnly alone still opens the keyboard on some Android browsers.
|
|
2579
|
+
expect(source).toContain('readOnly');
|
|
2580
|
+
expect(source).toContain('inputMode="none"');
|
|
2581
|
+
});
|
|
2582
|
+
});
|