@allxsmith/bestax-bulma 5.27.0 → 5.27.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bestax.css +1 -1
- package/dist/bestax.css.map +1 -1
- package/dist/esm/columns/Column.js +48 -0
- package/dist/esm/columns/Column.js.map +1 -0
- package/dist/esm/columns/Columns.js +53 -0
- package/dist/esm/columns/Columns.js.map +1 -0
- package/dist/esm/components/Avatar.js +299 -0
- package/dist/esm/components/Avatar.js.map +1 -0
- package/dist/esm/components/Avatars.js +81 -0
- package/dist/esm/components/Avatars.js.map +1 -0
- package/dist/esm/components/Badge.js +105 -0
- package/dist/esm/components/Badge.js.map +1 -0
- package/dist/esm/components/Breadcrumb.js +36 -0
- package/dist/esm/components/Breadcrumb.js.map +1 -0
- package/dist/esm/components/Card.js +252 -0
- package/dist/esm/components/Card.js.map +1 -0
- package/dist/esm/components/Carousel.js +351 -0
- package/dist/esm/components/Carousel.js.map +1 -0
- package/dist/esm/components/Collapse.js +193 -0
- package/dist/esm/components/Collapse.js.map +1 -0
- package/dist/esm/components/Collapses.js +99 -0
- package/dist/esm/components/Collapses.js.map +1 -0
- package/dist/esm/components/Dialog.js +230 -0
- package/dist/esm/components/Dialog.js.map +1 -0
- package/dist/esm/components/Dropdown.js +362 -0
- package/dist/esm/components/Dropdown.js.map +1 -0
- package/dist/esm/components/Loading.js +87 -0
- package/dist/esm/components/Loading.js.map +1 -0
- package/dist/esm/components/Menu.js +117 -0
- package/dist/esm/components/Menu.js.map +1 -0
- package/dist/esm/components/Message.js +56 -0
- package/dist/esm/components/Message.js.map +1 -0
- package/dist/esm/components/Modal.js +354 -0
- package/dist/esm/components/Modal.js.map +1 -0
- package/dist/esm/components/Navbar.js +287 -0
- package/dist/esm/components/Navbar.js.map +1 -0
- package/dist/esm/components/Pagination.js +191 -0
- package/dist/esm/components/Pagination.js.map +1 -0
- package/dist/esm/components/Panel.js +100 -0
- package/dist/esm/components/Panel.js.map +1 -0
- package/dist/esm/components/Popover.js +298 -0
- package/dist/esm/components/Popover.js.map +1 -0
- package/dist/esm/components/Reveal.js +157 -0
- package/dist/esm/components/Reveal.js.map +1 -0
- package/dist/esm/components/Sidebar.js +209 -0
- package/dist/esm/components/Sidebar.js.map +1 -0
- package/dist/esm/components/Steps.js +128 -0
- package/dist/esm/components/Steps.js.map +1 -0
- package/dist/esm/components/Tabs.js +335 -0
- package/dist/esm/components/Tabs.js.map +1 -0
- package/dist/esm/components/Toast.js +322 -0
- package/dist/esm/components/Toast.js.map +1 -0
- package/dist/esm/components/Tooltip.js +202 -0
- package/dist/esm/components/Tooltip.js.map +1 -0
- package/dist/esm/components/collapsesContext.js +12 -0
- package/dist/esm/components/collapsesContext.js.map +1 -0
- package/dist/esm/elements/Block.js +29 -0
- package/dist/esm/elements/Block.js.map +1 -0
- package/dist/esm/elements/Box.js +32 -0
- package/dist/esm/elements/Box.js.map +1 -0
- package/dist/esm/elements/Button.js +139 -0
- package/dist/esm/elements/Button.js.map +1 -0
- package/dist/esm/elements/Buttons.js +42 -0
- package/dist/esm/elements/Buttons.js.map +1 -0
- package/dist/esm/elements/Code.js +25 -0
- package/dist/esm/elements/Code.js.map +1 -0
- package/dist/esm/elements/Content.js +33 -0
- package/dist/esm/elements/Content.js.map +1 -0
- package/dist/esm/elements/Delete.js +35 -0
- package/dist/esm/elements/Delete.js.map +1 -0
- package/dist/esm/elements/Divider.js +27 -0
- package/dist/esm/elements/Divider.js.map +1 -0
- package/dist/esm/elements/Emphasis.js +25 -0
- package/dist/esm/elements/Emphasis.js.map +1 -0
- package/dist/esm/elements/Figure.js +58 -0
- package/dist/esm/elements/Figure.js.map +1 -0
- package/dist/esm/elements/Icon.js +256 -0
- package/dist/esm/elements/Icon.js.map +1 -0
- package/dist/esm/elements/IconText.js +36 -0
- package/dist/esm/elements/IconText.js.map +1 -0
- package/dist/esm/elements/Image.js +46 -0
- package/dist/esm/elements/Image.js.map +1 -0
- package/dist/esm/elements/Link.js +36 -0
- package/dist/esm/elements/Link.js.map +1 -0
- package/dist/esm/elements/LinkButton.js +37 -0
- package/dist/esm/elements/LinkButton.js.map +1 -0
- package/dist/esm/elements/ListItem.js +28 -0
- package/dist/esm/elements/ListItem.js.map +1 -0
- package/dist/esm/elements/Loader.js +37 -0
- package/dist/esm/elements/Loader.js.map +1 -0
- package/dist/esm/elements/Notification.js +316 -0
- package/dist/esm/elements/Notification.js.map +1 -0
- package/dist/esm/elements/OrderedList.js +31 -0
- package/dist/esm/elements/OrderedList.js.map +1 -0
- package/dist/esm/elements/Paragraph.js +28 -0
- package/dist/esm/elements/Paragraph.js.map +1 -0
- package/dist/esm/elements/Pre.js +25 -0
- package/dist/esm/elements/Pre.js.map +1 -0
- package/dist/esm/elements/Progress.js +33 -0
- package/dist/esm/elements/Progress.js.map +1 -0
- package/dist/esm/elements/Skeleton.js +30 -0
- package/dist/esm/elements/Skeleton.js.map +1 -0
- package/dist/esm/elements/Span.js +28 -0
- package/dist/esm/elements/Span.js.map +1 -0
- package/dist/esm/elements/Strong.js +25 -0
- package/dist/esm/elements/Strong.js.map +1 -0
- package/dist/esm/elements/SubTitle.js +51 -0
- package/dist/esm/elements/SubTitle.js.map +1 -0
- package/dist/esm/elements/Table.js +51 -0
- package/dist/esm/elements/Table.js.map +1 -0
- package/dist/esm/elements/Tag.js +49 -0
- package/dist/esm/elements/Tag.js.map +1 -0
- package/dist/esm/elements/Tags.js +34 -0
- package/dist/esm/elements/Tags.js.map +1 -0
- package/dist/esm/elements/Tbody.js +26 -0
- package/dist/esm/elements/Tbody.js.map +1 -0
- package/dist/esm/elements/Td.js +61 -0
- package/dist/esm/elements/Td.js.map +1 -0
- package/dist/esm/elements/Tfoot.js +26 -0
- package/dist/esm/elements/Tfoot.js.map +1 -0
- package/dist/esm/elements/Th.js +34 -0
- package/dist/esm/elements/Th.js.map +1 -0
- package/dist/esm/elements/Thead.js +26 -0
- package/dist/esm/elements/Thead.js.map +1 -0
- package/dist/esm/elements/Title.js +44 -0
- package/dist/esm/elements/Title.js.map +1 -0
- package/dist/esm/elements/Tr.js +31 -0
- package/dist/esm/elements/Tr.js.map +1 -0
- package/dist/esm/elements/UnorderedList.js +31 -0
- package/dist/esm/elements/UnorderedList.js.map +1 -0
- package/dist/esm/elements/iconProps.js +49 -0
- package/dist/esm/elements/iconProps.js.map +1 -0
- package/dist/esm/form/Autocomplete.js +279 -0
- package/dist/esm/form/Autocomplete.js.map +1 -0
- package/dist/esm/form/Checkbox.js +111 -0
- package/dist/esm/form/Checkbox.js.map +1 -0
- package/dist/esm/form/Checkboxes.js +90 -0
- package/dist/esm/form/Checkboxes.js.map +1 -0
- package/dist/esm/form/Control.js +79 -0
- package/dist/esm/form/Control.js.map +1 -0
- package/dist/esm/form/DateInput.js +88 -0
- package/dist/esm/form/DateInput.js.map +1 -0
- package/dist/esm/form/DateInputBase.js +263 -0
- package/dist/esm/form/DateInputBase.js.map +1 -0
- package/dist/esm/form/DateRangeInput.js +77 -0
- package/dist/esm/form/DateRangeInput.js.map +1 -0
- package/dist/esm/form/DateRangeInputBase.js +301 -0
- package/dist/esm/form/DateRangeInputBase.js.map +1 -0
- package/dist/esm/form/DateTimeInput.js +88 -0
- package/dist/esm/form/DateTimeInput.js.map +1 -0
- package/dist/esm/form/DateTimeInputBase.js +333 -0
- package/dist/esm/form/DateTimeInputBase.js.map +1 -0
- package/dist/esm/form/Field.js +167 -0
- package/dist/esm/form/Field.js.map +1 -0
- package/dist/esm/form/File.js +112 -0
- package/dist/esm/form/File.js.map +1 -0
- package/dist/esm/form/FormContext.js +194 -0
- package/dist/esm/form/FormContext.js.map +1 -0
- package/dist/esm/form/Input.js +88 -0
- package/dist/esm/form/Input.js.map +1 -0
- package/dist/esm/form/InputBase.js +40 -0
- package/dist/esm/form/InputBase.js.map +1 -0
- package/dist/esm/form/Numberinput.js +201 -0
- package/dist/esm/form/Numberinput.js.map +1 -0
- package/dist/esm/form/Radio.js +108 -0
- package/dist/esm/form/Radio.js.map +1 -0
- package/dist/esm/form/Radios.js +92 -0
- package/dist/esm/form/Radios.js.map +1 -0
- package/dist/esm/form/Rate.js +280 -0
- package/dist/esm/form/Rate.js.map +1 -0
- package/dist/esm/form/Select.js +81 -0
- package/dist/esm/form/Select.js.map +1 -0
- package/dist/esm/form/SelectBase.js +54 -0
- package/dist/esm/form/SelectBase.js.map +1 -0
- package/dist/esm/form/Slider.js +461 -0
- package/dist/esm/form/Slider.js.map +1 -0
- package/dist/esm/form/Switch.js +96 -0
- package/dist/esm/form/Switch.js.map +1 -0
- package/dist/esm/form/Taginput.js +351 -0
- package/dist/esm/form/Taginput.js.map +1 -0
- package/dist/esm/form/TextArea.js +77 -0
- package/dist/esm/form/TextArea.js.map +1 -0
- package/dist/esm/form/TextAreaBase.js +45 -0
- package/dist/esm/form/TextAreaBase.js.map +1 -0
- package/dist/esm/form/TimeInput.js +84 -0
- package/dist/esm/form/TimeInput.js.map +1 -0
- package/dist/esm/form/TimeInputBase.js +246 -0
- package/dist/esm/form/TimeInputBase.js.map +1 -0
- package/dist/esm/form/_pickerInternals/Calendar.js +760 -0
- package/dist/esm/form/_pickerInternals/Calendar.js.map +1 -0
- package/dist/esm/form/_pickerInternals/PickerPopover.js +95 -0
- package/dist/esm/form/_pickerInternals/PickerPopover.js.map +1 -0
- package/dist/esm/form/_pickerInternals/TimeWheels.js +721 -0
- package/dist/esm/form/_pickerInternals/TimeWheels.js.map +1 -0
- package/dist/esm/form/_pickerInternals/audioTick.js +109 -0
- package/dist/esm/form/_pickerInternals/audioTick.js.map +1 -0
- package/dist/esm/form/_pickerInternals/dateUtils.js +267 -0
- package/dist/esm/form/_pickerInternals/dateUtils.js.map +1 -0
- package/dist/esm/form/_pickerInternals/formatters.js +322 -0
- package/dist/esm/form/_pickerInternals/formatters.js.map +1 -0
- package/dist/esm/form/_pickerInternals/haptics.js +38 -0
- package/dist/esm/form/_pickerInternals/haptics.js.map +1 -0
- package/dist/esm/form/_pickerInternals/nativeInputSupport.js +25 -0
- package/dist/esm/form/_pickerInternals/nativeInputSupport.js.map +1 -0
- package/dist/esm/form/_pickerInternals/pickerTypes.js +50 -0
- package/dist/esm/form/_pickerInternals/pickerTypes.js.map +1 -0
- package/dist/esm/form/_pickerInternals/segmentMap.js +315 -0
- package/dist/esm/form/_pickerInternals/segmentMap.js.map +1 -0
- package/dist/esm/form/_pickerInternals/useNativeMobilePicker.js +49 -0
- package/dist/esm/form/_pickerInternals/useNativeMobilePicker.js.map +1 -0
- package/dist/esm/form/_pickerInternals/useSegmentedEntry.js +427 -0
- package/dist/esm/form/_pickerInternals/useSegmentedEntry.js.map +1 -0
- package/dist/esm/form/controlLoading.js +18 -0
- package/dist/esm/form/controlLoading.js.map +1 -0
- package/dist/esm/form/useAutoLabelId.js +88 -0
- package/dist/esm/form/useAutoLabelId.js.map +1 -0
- package/dist/esm/grid/Cell.js +35 -0
- package/dist/esm/grid/Cell.js.map +1 -0
- package/dist/esm/grid/Grid.js +51 -0
- package/dist/esm/grid/Grid.js.map +1 -0
- package/dist/esm/helpers/ClientOnly.js +33 -0
- package/dist/esm/helpers/ClientOnly.js.map +1 -0
- package/dist/esm/helpers/Config.js +60 -0
- package/dist/esm/helpers/Config.js.map +1 -0
- package/dist/esm/helpers/Theme.js +947 -0
- package/dist/esm/helpers/Theme.js.map +1 -0
- package/dist/esm/helpers/anchorAttrs.js +72 -0
- package/dist/esm/helpers/anchorAttrs.js.map +1 -0
- package/dist/esm/helpers/bulmaClassHelpers.js +415 -0
- package/dist/esm/helpers/bulmaClassHelpers.js.map +1 -0
- package/dist/esm/helpers/buttonType.js +24 -0
- package/dist/esm/helpers/buttonType.js.map +1 -0
- package/dist/esm/helpers/classNames.js +137 -0
- package/dist/esm/helpers/classNames.js.map +1 -0
- package/dist/esm/helpers/colorDeprecations.js +50 -0
- package/dist/esm/helpers/colorDeprecations.js.map +1 -0
- package/dist/esm/helpers/devWarnings.js +27 -0
- package/dist/esm/helpers/devWarnings.js.map +1 -0
- package/dist/esm/helpers/inertProps.js +27 -0
- package/dist/esm/helpers/inertProps.js.map +1 -0
- package/dist/esm/helpers/mergeBulmaStyles.js +20 -0
- package/dist/esm/helpers/mergeBulmaStyles.js.map +1 -0
- package/dist/esm/helpers/polymorphic.js +18 -0
- package/dist/esm/helpers/polymorphic.js.map +1 -0
- package/dist/esm/helpers/portal.js +76 -0
- package/dist/esm/helpers/portal.js.map +1 -0
- package/dist/esm/helpers/positionStacks.js +45 -0
- package/dist/esm/helpers/positionStacks.js.map +1 -0
- package/dist/esm/helpers/scrollLock.js +38 -0
- package/dist/esm/helpers/scrollLock.js.map +1 -0
- package/dist/esm/helpers/shadowDom.js +71 -0
- package/dist/esm/helpers/shadowDom.js.map +1 -0
- package/dist/esm/helpers/statusRegion.js +282 -0
- package/dist/esm/helpers/statusRegion.js.map +1 -0
- package/dist/esm/helpers/useAnchoredPosition.js +86 -0
- package/dist/esm/helpers/useAnchoredPosition.js.map +1 -0
- package/dist/esm/helpers/useBulmaClasses.js +158 -0
- package/dist/esm/helpers/useBulmaClasses.js.map +1 -0
- package/dist/esm/helpers/useClientLayoutEffect.js +13 -0
- package/dist/esm/helpers/useClientLayoutEffect.js.map +1 -0
- package/dist/esm/helpers/useColorClasses.js +84 -0
- package/dist/esm/helpers/useColorClasses.js.map +1 -0
- package/dist/esm/helpers/useFlexboxClasses.js +92 -0
- package/dist/esm/helpers/useFlexboxClasses.js.map +1 -0
- package/dist/esm/helpers/useFocusTrap.js +470 -0
- package/dist/esm/helpers/useFocusTrap.js.map +1 -0
- package/dist/esm/helpers/useIsHydrated.js +31 -0
- package/dist/esm/helpers/useIsHydrated.js.map +1 -0
- package/dist/esm/helpers/useOtherClasses.js +115 -0
- package/dist/esm/helpers/useOtherClasses.js.map +1 -0
- package/dist/esm/helpers/useSpacingClasses.js +82 -0
- package/dist/esm/helpers/useSpacingClasses.js.map +1 -0
- package/dist/esm/helpers/useTypographyClasses.js +93 -0
- package/dist/esm/helpers/useTypographyClasses.js.map +1 -0
- package/dist/esm/helpers/useVisibilityClasses.js +134 -0
- package/dist/esm/helpers/useVisibilityClasses.js.map +1 -0
- package/dist/esm/helpers/withSubComponents.js +24 -0
- package/dist/esm/helpers/withSubComponents.js.map +1 -0
- package/dist/esm/layout/Container.js +48 -0
- package/dist/esm/layout/Container.js.map +1 -0
- package/dist/esm/layout/Footer.js +33 -0
- package/dist/esm/layout/Footer.js.map +1 -0
- package/dist/esm/layout/Hero.js +90 -0
- package/dist/esm/layout/Hero.js.map +1 -0
- package/dist/esm/layout/Level.js +110 -0
- package/dist/esm/layout/Level.js.map +1 -0
- package/dist/esm/layout/Media.js +86 -0
- package/dist/esm/layout/Media.js.map +1 -0
- package/dist/esm/layout/Section.js +30 -0
- package/dist/esm/layout/Section.js.map +1 -0
- package/dist/extras.css +1 -1
- package/dist/extras.css.map +1 -1
- package/dist/index.cjs +187 -57
- package/dist/index.cjs.map +1 -1
- package/dist/index.esm.js +115 -17516
- package/dist/index.esm.js.map +1 -1
- package/dist/types/elements/Icon.d.ts +10 -7
- package/dist/types/form/Autocomplete.d.ts +1 -1
- package/dist/types/form/Checkboxes.d.ts +1 -1
- package/dist/types/form/DateInput.d.ts +1 -1
- package/dist/types/form/DateInputBase.d.ts +3 -4
- package/dist/types/form/DateRangeInput.d.ts +1 -1
- package/dist/types/form/DateTimeInput.d.ts +1 -1
- package/dist/types/form/DateTimeInputBase.d.ts +3 -4
- package/dist/types/form/Field.d.ts +2 -2
- package/dist/types/form/File.d.ts +4 -4
- package/dist/types/form/FormContext.d.ts +24 -2
- package/dist/types/form/Numberinput.d.ts +1 -1
- package/dist/types/form/Radios.d.ts +1 -1
- package/dist/types/form/Rate.d.ts +1 -1
- package/dist/types/form/Slider.d.ts +1 -1
- package/dist/types/form/Taginput.d.ts +1 -1
- package/dist/types/form/TimeInput.d.ts +1 -1
- package/dist/types/form/useAutoLabelId.d.ts +25 -4
- package/dist/types/helpers/Config.d.ts +3 -4
- package/dist/types/helpers/Theme.d.ts +44 -40
- package/dist/types-cjs/elements/Icon.d.ts +10 -7
- package/dist/types-cjs/form/Autocomplete.d.ts +1 -1
- package/dist/types-cjs/form/Checkboxes.d.ts +1 -1
- package/dist/types-cjs/form/DateInput.d.ts +1 -1
- package/dist/types-cjs/form/DateInputBase.d.ts +3 -4
- package/dist/types-cjs/form/DateRangeInput.d.ts +1 -1
- package/dist/types-cjs/form/DateTimeInput.d.ts +1 -1
- package/dist/types-cjs/form/DateTimeInputBase.d.ts +3 -4
- package/dist/types-cjs/form/Field.d.ts +2 -2
- package/dist/types-cjs/form/File.d.ts +4 -4
- package/dist/types-cjs/form/FormContext.d.ts +24 -2
- package/dist/types-cjs/form/Numberinput.d.ts +1 -1
- package/dist/types-cjs/form/Radios.d.ts +1 -1
- package/dist/types-cjs/form/Rate.d.ts +1 -1
- package/dist/types-cjs/form/Slider.d.ts +1 -1
- package/dist/types-cjs/form/Taginput.d.ts +1 -1
- package/dist/types-cjs/form/TimeInput.d.ts +1 -1
- package/dist/types-cjs/form/useAutoLabelId.d.ts +25 -4
- package/dist/types-cjs/helpers/Config.d.ts +3 -4
- package/dist/types-cjs/helpers/Theme.d.ts +44 -40
- package/dist/versions/bestax-no-dark-mode.css +1 -1
- package/dist/versions/bestax-no-dark-mode.css.map +1 -1
- package/dist/versions/bestax-no-helpers-prefixed.css +1 -1
- package/dist/versions/bestax-no-helpers-prefixed.css.map +1 -1
- package/dist/versions/bestax-no-helpers.css +1 -1
- package/dist/versions/bestax-no-helpers.css.map +1 -1
- package/dist/versions/bestax-prefixed.css +1 -1
- package/dist/versions/bestax-prefixed.css.map +1 -1
- package/package.json +1 -1
- package/src/scss/form/_dateinput.scss +37 -6
- package/src/scss/form/_datetimeinput.scss +7 -2
- package/src/scss/form/_picker-popover.scss +1 -4
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scrollLock.js","sources":["../../../src/helpers/scrollLock.ts"],"sourcesContent":["import { useEffect } from 'react';\n\n// Ref-counted across every caller (e.g. a Dialog rendering its own Modal)\n// so overlapping/nested locks don't fight over which one restores `overflow`.\n// Every overlay that locks body scroll — Modal, Dialog, Sidebar, Loading —\n// goes through here; a component setting `document.body.style.overflow`\n// itself would clear a lock another overlay still needs.\nlet lockCount = 0;\nlet originalOverflow = '';\n\n/**\n * Locks (and ref-counted-ly unlocks) `document.body` scrolling while `active`\n * is true. Safe to call from multiple components at once — the body scroll\n * is only restored once every active caller has released its lock.\n *\n * @function useScrollLock\n * @param active - Whether this caller wants the body scroll locked.\n */\nexport function useScrollLock(active: boolean): void {\n useEffect(() => {\n if (!active) return undefined;\n\n lockCount++;\n if (lockCount === 1) {\n originalOverflow = document.body.style.overflow;\n document.body.style.overflow = 'hidden';\n }\n\n return () => {\n lockCount--;\n if (lockCount === 0) {\n document.body.style.overflow = originalOverflow;\n }\n };\n }, [active]);\n}\n"],"names":[],"mappings":";;;AAEA;AACA;AACA;AACA;AACA;AACA,IAAI,SAAS,GAAG,CAAC;AACjB,IAAI,gBAAgB,GAAG,EAAE;AAEzB;;;;;;;AAOG;AACG,SAAU,aAAa,CAAC,MAAe,EAAA;IAC3C,SAAS,CAAC,MAAK;AACb,QAAA,IAAI,CAAC,MAAM;AAAE,YAAA,OAAO,SAAS;AAE7B,QAAA,SAAS,EAAE;AACX,QAAA,IAAI,SAAS,KAAK,CAAC,EAAE;YACnB,gBAAgB,GAAG,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,QAAQ;YAC/C,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,QAAQ,GAAG,QAAQ;QACzC;AAEA,QAAA,OAAO,MAAK;AACV,YAAA,SAAS,EAAE;AACX,YAAA,IAAI,SAAS,KAAK,CAAC,EAAE;gBACnB,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,QAAQ,GAAG,gBAAgB;YACjD;AACF,QAAA,CAAC;AACH,IAAA,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC;AACd;;"}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/* @allxsmith/bestax-bulma — AI agents: see AGENTS.md in the package root, or https://bestax.io/llms.txt */
|
|
2
|
+
/**
|
|
3
|
+
* Event and focus checks that still hold when a component renders inside a
|
|
4
|
+
* shadow root.
|
|
5
|
+
*
|
|
6
|
+
* Code outside a shadow root cannot see into it. A listener on `document`
|
|
7
|
+
* reads an event from inside one with its `target` set to the shadow host,
|
|
8
|
+
* and `document.activeElement` names the host instead of the element that has
|
|
9
|
+
* focus. A component asking either question from `document` then takes a
|
|
10
|
+
* click on its own menu for an outside click, or cannot tell which of its
|
|
11
|
+
* items is focused. A web component or a sandboxed preview puts a component
|
|
12
|
+
* in exactly that position.
|
|
13
|
+
*
|
|
14
|
+
* Focus comes in two questions, and each has its own function here. "Is focus
|
|
15
|
+
* inside me, and on which of my elements?" is `getActiveElementInTree`. "Which
|
|
16
|
+
* element has focus, wherever it is?", the one to record before moving focus
|
|
17
|
+
* away and restore to later, is `getDeepestActiveElement`.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Whether `event` happened inside `node`. The event's composed path still
|
|
21
|
+
* holds the element that was really clicked, where a `document` listener
|
|
22
|
+
* reads the shadow host as the event's `target`.
|
|
23
|
+
*
|
|
24
|
+
* @param event - An event read by a listener outside `node`, usually on `document`.
|
|
25
|
+
* @param node - The component's element, or nothing while it is unmounted.
|
|
26
|
+
* @returns True when `node` is on the event's path.
|
|
27
|
+
*/
|
|
28
|
+
function isEventInside(event, node) {
|
|
29
|
+
return !!node && event.composedPath().includes(node);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The focused element as `node`'s own tree sees it: its shadow root's
|
|
33
|
+
* `activeElement` when `node` is inside one, `document.activeElement` when it
|
|
34
|
+
* is not. The result is comparable with `node` and its descendants, which
|
|
35
|
+
* live in that same tree, so use it to ask whether focus is inside `node`.
|
|
36
|
+
* Focus inside a shadow root nested within `node` reads as that root's host,
|
|
37
|
+
* which `node` contains.
|
|
38
|
+
*
|
|
39
|
+
* A shadow root with nothing focused inside reports `null`, and a detached
|
|
40
|
+
* node has no document above it. Both fall back to `document.activeElement`,
|
|
41
|
+
* so focus elsewhere on the page still reads as outside `node`.
|
|
42
|
+
*
|
|
43
|
+
* @param node - An element of the component, or nothing while it is unmounted.
|
|
44
|
+
* @returns The focused element in `node`'s tree, or the document's.
|
|
45
|
+
*/
|
|
46
|
+
function getActiveElementInTree(node) {
|
|
47
|
+
const root = node?.getRootNode();
|
|
48
|
+
return root?.activeElement ?? document.activeElement;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The element that really has focus, followed down from
|
|
52
|
+
* `document.activeElement` through every open shadow root on the way. Use it
|
|
53
|
+
* to record where focus was before moving it, so it can be restored to that
|
|
54
|
+
* element later: the tree a component renders into need not be the tree its
|
|
55
|
+
* opener sits in. A closed shadow root cannot be entered, so focus inside one
|
|
56
|
+
* reads as its host.
|
|
57
|
+
*
|
|
58
|
+
* @param doc - The document to start from: the component's own, when it may
|
|
59
|
+
* render into one other than the page's (an iframe's). Defaults to `document`.
|
|
60
|
+
* @returns The focused element, or `null` when the document has none.
|
|
61
|
+
*/
|
|
62
|
+
function getDeepestActiveElement(doc = document) {
|
|
63
|
+
let active = doc.activeElement;
|
|
64
|
+
while (active?.shadowRoot?.activeElement) {
|
|
65
|
+
active = active.shadowRoot.activeElement;
|
|
66
|
+
}
|
|
67
|
+
return active;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export { getActiveElementInTree, getDeepestActiveElement, isEventInside };
|
|
71
|
+
//# sourceMappingURL=shadowDom.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shadowDom.js","sources":["../../../src/helpers/shadowDom.ts"],"sourcesContent":["/**\n * Event and focus checks that still hold when a component renders inside a\n * shadow root.\n *\n * Code outside a shadow root cannot see into it. A listener on `document`\n * reads an event from inside one with its `target` set to the shadow host,\n * and `document.activeElement` names the host instead of the element that has\n * focus. A component asking either question from `document` then takes a\n * click on its own menu for an outside click, or cannot tell which of its\n * items is focused. A web component or a sandboxed preview puts a component\n * in exactly that position.\n *\n * Focus comes in two questions, and each has its own function here. \"Is focus\n * inside me, and on which of my elements?\" is `getActiveElementInTree`. \"Which\n * element has focus, wherever it is?\", the one to record before moving focus\n * away and restore to later, is `getDeepestActiveElement`.\n */\n\n/**\n * Whether `event` happened inside `node`. The event's composed path still\n * holds the element that was really clicked, where a `document` listener\n * reads the shadow host as the event's `target`.\n *\n * @param event - An event read by a listener outside `node`, usually on `document`.\n * @param node - The component's element, or nothing while it is unmounted.\n * @returns True when `node` is on the event's path.\n */\nexport function isEventInside(\n event: Event,\n node: Node | null | undefined\n): boolean {\n return !!node && event.composedPath().includes(node);\n}\n\n/**\n * The focused element as `node`'s own tree sees it: its shadow root's\n * `activeElement` when `node` is inside one, `document.activeElement` when it\n * is not. The result is comparable with `node` and its descendants, which\n * live in that same tree, so use it to ask whether focus is inside `node`.\n * Focus inside a shadow root nested within `node` reads as that root's host,\n * which `node` contains.\n *\n * A shadow root with nothing focused inside reports `null`, and a detached\n * node has no document above it. Both fall back to `document.activeElement`,\n * so focus elsewhere on the page still reads as outside `node`.\n *\n * @param node - An element of the component, or nothing while it is unmounted.\n * @returns The focused element in `node`'s tree, or the document's.\n */\nexport function getActiveElementInTree(\n node: Node | null | undefined\n): Element | null {\n const root = node?.getRootNode() as Partial<DocumentOrShadowRoot> | undefined;\n return root?.activeElement ?? document.activeElement;\n}\n\n/**\n * The element that really has focus, followed down from\n * `document.activeElement` through every open shadow root on the way. Use it\n * to record where focus was before moving it, so it can be restored to that\n * element later: the tree a component renders into need not be the tree its\n * opener sits in. A closed shadow root cannot be entered, so focus inside one\n * reads as its host.\n *\n * @param doc - The document to start from: the component's own, when it may\n * render into one other than the page's (an iframe's). Defaults to `document`.\n * @returns The focused element, or `null` when the document has none.\n */\nexport function getDeepestActiveElement(\n doc: Document = document\n): Element | null {\n let active = doc.activeElement;\n while (active?.shadowRoot?.activeElement) {\n active = active.shadowRoot.activeElement;\n }\n return active;\n}\n"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;AAgBG;AAEH;;;;;;;;AAQG;AACG,SAAU,aAAa,CAC3B,KAAY,EACZ,IAA6B,EAAA;AAE7B,IAAA,OAAO,CAAC,CAAC,IAAI,IAAI,KAAK,CAAC,YAAY,EAAE,CAAC,QAAQ,CAAC,IAAI,CAAC;AACtD;AAEA;;;;;;;;;;;;;;AAcG;AACG,SAAU,sBAAsB,CACpC,IAA6B,EAAA;AAE7B,IAAA,MAAM,IAAI,GAAG,IAAI,EAAE,WAAW,EAA+C;AAC7E,IAAA,OAAO,IAAI,EAAE,aAAa,IAAI,QAAQ,CAAC,aAAa;AACtD;AAEA;;;;;;;;;;;AAWG;AACG,SAAU,uBAAuB,CACrC,GAAA,GAAgB,QAAQ,EAAA;AAExB,IAAA,IAAI,MAAM,GAAG,GAAG,CAAC,aAAa;AAC9B,IAAA,OAAO,MAAM,EAAE,UAAU,EAAE,aAAa,EAAE;AACxC,QAAA,MAAM,GAAG,MAAM,CAAC,UAAU,CAAC,aAAa;IAC1C;AACA,IAAA,OAAO,MAAM;AACf;;"}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/* @allxsmith/bestax-bulma — AI agents: see AGENTS.md in the package root, or https://bestax.io/llms.txt */
|
|
2
|
+
import { jsx } from 'react/jsx-runtime';
|
|
3
|
+
import { useRef, useState, useEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* How long after an item appears its announcement is written, in ms. The wait
|
|
7
|
+
* lets a screen reader register a region the container has only just mounted,
|
|
8
|
+
* and items shown in quick succession are written, and read out, together.
|
|
9
|
+
*/
|
|
10
|
+
const announceDelay = 100;
|
|
11
|
+
/**
|
|
12
|
+
* How long an announcement stays in the region once written, in ms. That's
|
|
13
|
+
* long enough for a screen reader to pick it up. Clearing it after keeps a
|
|
14
|
+
* second copy of the item's text from sitting in the page, where someone
|
|
15
|
+
* reading through the page, or a test looking the text up, would find it
|
|
16
|
+
* again.
|
|
17
|
+
*/
|
|
18
|
+
const announcementLifetime = 1000;
|
|
19
|
+
const noAnnouncements = [];
|
|
20
|
+
const nothingWritten = [];
|
|
21
|
+
const noAnnouncementsYet = {
|
|
22
|
+
key: 0,
|
|
23
|
+
waiting: noAnnouncements,
|
|
24
|
+
written: nothingWritten,
|
|
25
|
+
};
|
|
26
|
+
// Hides the region with inline styles rather than a class, so it needs no
|
|
27
|
+
// stylesheet, including the helper classes a modular Bulma build can leave
|
|
28
|
+
// out. Clipping it, rather than using `display` or `visibility`, keeps it in
|
|
29
|
+
// the accessibility tree. The declarations match the `extras-sr-only` mixin,
|
|
30
|
+
// plus `clip-path`, which replaces the deprecated `clip`. The negative margin
|
|
31
|
+
// pulls the 1px box back inside the page, so it doesn't add scrollable
|
|
32
|
+
// overflow. Exported for other live regions that must work without a
|
|
33
|
+
// stylesheet, such as a range calendar's.
|
|
34
|
+
const visuallyHidden = {
|
|
35
|
+
position: 'absolute',
|
|
36
|
+
width: '1px',
|
|
37
|
+
height: '1px',
|
|
38
|
+
padding: 0,
|
|
39
|
+
margin: '-1px',
|
|
40
|
+
overflow: 'hidden',
|
|
41
|
+
clip: 'rect(0, 0, 0, 0)',
|
|
42
|
+
clipPath: 'inset(50%)',
|
|
43
|
+
whiteSpace: 'nowrap',
|
|
44
|
+
border: 0,
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Works out what a status region says. Each item is announced once. Its
|
|
48
|
+
* announcement is written `announceDelay` after it appears, or after the last
|
|
49
|
+
* item to join it when several appear in quick succession, alongside whatever
|
|
50
|
+
* the region still says. Once written, it stays for `announcementLifetime`,
|
|
51
|
+
* whatever is written after it and whether or not its item goes in the
|
|
52
|
+
* meantime. An item that goes before its announcement is written isn't
|
|
53
|
+
* announced.
|
|
54
|
+
*
|
|
55
|
+
* Nothing is written in the commit that mounts the region or the item. A
|
|
56
|
+
* screen reader reliably announces a polite live region whose content changes
|
|
57
|
+
* after the region is already in the page, and not always one that arrives
|
|
58
|
+
* with its content. By the time `describe` runs, an item's rendered text can
|
|
59
|
+
* be read.
|
|
60
|
+
*
|
|
61
|
+
* @function useAnnouncements
|
|
62
|
+
* @param items - The items to announce, in the order they were shown.
|
|
63
|
+
* @param describe - The text to announce for an item, or nothing to skip it.
|
|
64
|
+
* It runs once the item is on screen, and it must keep its identity between
|
|
65
|
+
* renders (a module-level function or a `useCallback`), or every render
|
|
66
|
+
* re-checks the items.
|
|
67
|
+
* @returns The announcements to render, in the order they were written.
|
|
68
|
+
*/
|
|
69
|
+
function useAnnouncements(items, describe) {
|
|
70
|
+
// The ids of the items on screen as of the last change, every one of which
|
|
71
|
+
// has been looked at for an announcement already.
|
|
72
|
+
const onScreenIdsRef = useRef(new Set());
|
|
73
|
+
// Clears that haven't run yet. They outlive the batch that started them, so
|
|
74
|
+
// they're only stopped when the region unmounts.
|
|
75
|
+
const clearTimersRef = useRef(new Set());
|
|
76
|
+
const [announcements, setAnnouncements] = useState(noAnnouncementsYet);
|
|
77
|
+
// Runs after the commit that puts an item on screen, which is the first
|
|
78
|
+
// point its rendered text can be read.
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
const announced = onScreenIdsRef.current;
|
|
81
|
+
const appeared = items.filter(item => !announced.has(item.id));
|
|
82
|
+
// Items that have gone drop out here too. Ids are never reused, so
|
|
83
|
+
// forgetting them can't bring an old item back.
|
|
84
|
+
onScreenIdsRef.current = new Set(items.map(item => item.id));
|
|
85
|
+
const next = [];
|
|
86
|
+
for (const item of appeared) {
|
|
87
|
+
const text = describe(item);
|
|
88
|
+
if (text)
|
|
89
|
+
next.push({ id: item.id, text });
|
|
90
|
+
}
|
|
91
|
+
if (next.length > 0) {
|
|
92
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect -- an item's rendered text can only be read once it's on screen
|
|
93
|
+
setAnnouncements(current => ({
|
|
94
|
+
...current,
|
|
95
|
+
key: current.key + 1,
|
|
96
|
+
// Announcements still waiting go out with the new ones. What's
|
|
97
|
+
// already written stays put.
|
|
98
|
+
waiting: [...current.waiting, ...next],
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
}, [items, describe]);
|
|
102
|
+
// The write only touches the batch it was started for: a newer batch can
|
|
103
|
+
// be set before the render that cancels this timer. Its clear is started
|
|
104
|
+
// by the write itself, so it's timed from the write without waiting on a
|
|
105
|
+
// render, and a newer batch arriving doesn't cut short what's written.
|
|
106
|
+
const { key } = announcements;
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
if (key === noAnnouncementsYet.key)
|
|
109
|
+
return undefined;
|
|
110
|
+
const clearTimers = clearTimersRef.current;
|
|
111
|
+
const write = setTimeout(() => {
|
|
112
|
+
// Whether an item is on screen is decided here, once. An item that
|
|
113
|
+
// went before its announcement was due isn't announced, and one that
|
|
114
|
+
// goes after keeps its announcement for the rest of its lifetime, when
|
|
115
|
+
// the region holds the only copy of its text rather than a second one.
|
|
116
|
+
const onScreen = onScreenIdsRef.current;
|
|
117
|
+
setAnnouncements(current => current.key === key
|
|
118
|
+
? {
|
|
119
|
+
...current,
|
|
120
|
+
waiting: noAnnouncements,
|
|
121
|
+
written: [
|
|
122
|
+
...current.written,
|
|
123
|
+
...current.waiting
|
|
124
|
+
.filter(announcement => onScreen.has(announcement.id))
|
|
125
|
+
.map(announcement => ({ ...announcement, batch: key })),
|
|
126
|
+
],
|
|
127
|
+
}
|
|
128
|
+
: current);
|
|
129
|
+
// eslint-disable-next-line @eslint-react/web-api-no-leaked-timeout -- it has to outlive this effect when a newer batch arrives, so the unmount effect below clears it
|
|
130
|
+
const clear = setTimeout(() => {
|
|
131
|
+
clearTimers.delete(clear);
|
|
132
|
+
setAnnouncements(current => {
|
|
133
|
+
const written = current.written.filter(announcement => announcement.batch !== key);
|
|
134
|
+
return written.length === current.written.length
|
|
135
|
+
? current
|
|
136
|
+
: { ...current, written };
|
|
137
|
+
});
|
|
138
|
+
}, announcementLifetime);
|
|
139
|
+
clearTimers.add(clear);
|
|
140
|
+
}, announceDelay);
|
|
141
|
+
return () => clearTimeout(write);
|
|
142
|
+
}, [key]);
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
const clearTimers = clearTimersRef.current;
|
|
145
|
+
return () => {
|
|
146
|
+
clearTimers.forEach(clearTimeout);
|
|
147
|
+
clearTimers.clear();
|
|
148
|
+
};
|
|
149
|
+
}, []);
|
|
150
|
+
return announcements.written;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Reads an element out roughly the way a screen reader would: its text, with
|
|
154
|
+
* an element's `aria-label`, or an image's `alt`, standing in for what's
|
|
155
|
+
* inside it, a break between block-level elements, and parts that are
|
|
156
|
+
* `aria-hidden`, `hidden` or inline-styled `display: none` left out. It isn't
|
|
157
|
+
* the full accessible name computation, so `aria-labelledby`, CSS-generated
|
|
158
|
+
* content, content hidden by a stylesheet and other ways of naming or hiding
|
|
159
|
+
* content aren't followed.
|
|
160
|
+
*
|
|
161
|
+
* @function spokenText
|
|
162
|
+
* @param element - The element to read.
|
|
163
|
+
* @returns Its text, with runs of whitespace collapsed and the ends trimmed.
|
|
164
|
+
*/
|
|
165
|
+
function spokenText(element) {
|
|
166
|
+
return readAloud(element).replace(/\s+/g, ' ').trim();
|
|
167
|
+
}
|
|
168
|
+
// Elements a browser displays as blocks by default, and `br`. A screen reader
|
|
169
|
+
// pauses between them, so their text shouldn't run into the text beside them.
|
|
170
|
+
// They're known by tag rather than by computed `display`, which would depend
|
|
171
|
+
// on a stylesheet and on layout.
|
|
172
|
+
const blockLevelTags = new Set([
|
|
173
|
+
'ADDRESS',
|
|
174
|
+
'ARTICLE',
|
|
175
|
+
'ASIDE',
|
|
176
|
+
'BLOCKQUOTE',
|
|
177
|
+
'BR',
|
|
178
|
+
'CAPTION',
|
|
179
|
+
'DD',
|
|
180
|
+
'DETAILS',
|
|
181
|
+
'DIALOG',
|
|
182
|
+
'DIV',
|
|
183
|
+
'DL',
|
|
184
|
+
'DT',
|
|
185
|
+
'FIELDSET',
|
|
186
|
+
'FIGCAPTION',
|
|
187
|
+
'FIGURE',
|
|
188
|
+
'FOOTER',
|
|
189
|
+
'FORM',
|
|
190
|
+
'H1',
|
|
191
|
+
'H2',
|
|
192
|
+
'H3',
|
|
193
|
+
'H4',
|
|
194
|
+
'H5',
|
|
195
|
+
'H6',
|
|
196
|
+
'HEADER',
|
|
197
|
+
'HGROUP',
|
|
198
|
+
'HR',
|
|
199
|
+
'LEGEND',
|
|
200
|
+
'LI',
|
|
201
|
+
'MAIN',
|
|
202
|
+
'MENU',
|
|
203
|
+
'NAV',
|
|
204
|
+
'OL',
|
|
205
|
+
'P',
|
|
206
|
+
'PRE',
|
|
207
|
+
'SEARCH',
|
|
208
|
+
'SECTION',
|
|
209
|
+
'SUMMARY',
|
|
210
|
+
'TABLE',
|
|
211
|
+
'TBODY',
|
|
212
|
+
'TD',
|
|
213
|
+
'TFOOT',
|
|
214
|
+
'TH',
|
|
215
|
+
'THEAD',
|
|
216
|
+
'TR',
|
|
217
|
+
'UL',
|
|
218
|
+
]);
|
|
219
|
+
/**
|
|
220
|
+
* Whether an element is left out of what a screen reader reads.
|
|
221
|
+
*
|
|
222
|
+
* @function isHidden
|
|
223
|
+
* @param element - The element to check.
|
|
224
|
+
* @returns True for `aria-hidden="true"`, the `hidden` attribute, and an
|
|
225
|
+
* inline `display: none`, on an HTML, SVG or any other element with an
|
|
226
|
+
* inline style.
|
|
227
|
+
*/
|
|
228
|
+
const isHidden = (element) => element.getAttribute('aria-hidden') === 'true' ||
|
|
229
|
+
element.hasAttribute('hidden') ||
|
|
230
|
+
element.style?.display === 'none';
|
|
231
|
+
/**
|
|
232
|
+
* The text `spokenText` reads for an element, before its whitespace is
|
|
233
|
+
* tidied.
|
|
234
|
+
*
|
|
235
|
+
* @function readAloud
|
|
236
|
+
* @param element - The element to read.
|
|
237
|
+
* @returns Its text.
|
|
238
|
+
*/
|
|
239
|
+
function readAloud(element) {
|
|
240
|
+
if (isHidden(element))
|
|
241
|
+
return '';
|
|
242
|
+
const name = element.getAttribute('aria-label') ||
|
|
243
|
+
(element.tagName === 'IMG' ? element.getAttribute('alt') : null);
|
|
244
|
+
const text = name ||
|
|
245
|
+
Array.from(element.childNodes, child => {
|
|
246
|
+
if (child instanceof Element)
|
|
247
|
+
return readAloud(child);
|
|
248
|
+
return child.nodeType === Node.TEXT_NODE ? child.textContent : '';
|
|
249
|
+
}).join('');
|
|
250
|
+
// A name stands in for the element's content, and a block's content stands
|
|
251
|
+
// apart from what's around it, so both are padded to keep them from running
|
|
252
|
+
// into the neighboring text. An inline run, like `<span>Up</span>` then
|
|
253
|
+
// `<span>loaded</span>`, still reads as one word.
|
|
254
|
+
return name || blockLevelTags.has(element.tagName) ? ` ${text} ` : text;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* A visually hidden, polite `status` live region that a programmatic
|
|
258
|
+
* container announces its items through. The container renders it from the
|
|
259
|
+
* moment it mounts, whether or not it has anything to show, so the region is
|
|
260
|
+
* already in the page when an announcement is written into it.
|
|
261
|
+
*
|
|
262
|
+
* The announcements are this component's own state, so writing and clearing
|
|
263
|
+
* them re-renders the region and leaves the container's items alone. Each one
|
|
264
|
+
* is its own keyed node, so a re-render that leaves the announcements alone
|
|
265
|
+
* leaves the region's content alone too, and nothing is read out twice.
|
|
266
|
+
*
|
|
267
|
+
* @function StatusRegion
|
|
268
|
+
* @param props - The items to announce, and the text to announce for each.
|
|
269
|
+
* @returns The region.
|
|
270
|
+
*/
|
|
271
|
+
function StatusRegion({ items, describe, }) {
|
|
272
|
+
const announcements = useAnnouncements(items, describe);
|
|
273
|
+
// `role="status"` makes a region atomic unless it says otherwise, which
|
|
274
|
+
// reads the whole region on every change. Each announcement is its own
|
|
275
|
+
// node, and only additions are relevant by default, so with atomic off a
|
|
276
|
+
// screen reader reads just what was written, and removing one announcement
|
|
277
|
+
// doesn't read out the ones still there.
|
|
278
|
+
return (jsx("div", { role: "status", "aria-live": "polite", "aria-atomic": "false", style: visuallyHidden, children: announcements.map(announcement => (jsx("div", { children: announcement.text }, announcement.id))) }));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export { StatusRegion, announceDelay, announcementLifetime, spokenText, visuallyHidden };
|
|
282
|
+
//# sourceMappingURL=statusRegion.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"statusRegion.js","sources":["../../../src/helpers/statusRegion.tsx"],"sourcesContent":["import React, { useEffect, useRef, useState } from 'react';\n\n/**\n * What a status region says for one of a container's items.\n */\ninterface Announcement {\n /** The id of the item it announces. */\n id: string;\n /** The text a screen reader reads out. */\n text: string;\n}\n\n/**\n * An announcement in the region.\n */\ninterface WrittenAnnouncement extends Announcement {\n /** The batch it was written with, which is what its clear removes. */\n batch: number;\n}\n\n/**\n * How long after an item appears its announcement is written, in ms. The wait\n * lets a screen reader register a region the container has only just mounted,\n * and items shown in quick succession are written, and read out, together.\n */\nexport const announceDelay = 100;\n\n/**\n * How long an announcement stays in the region once written, in ms. That's\n * long enough for a screen reader to pick it up. Clearing it after keeps a\n * second copy of the item's text from sitting in the page, where someone\n * reading through the page, or a test looking the text up, would find it\n * again.\n */\nexport const announcementLifetime = 1000;\n\n/**\n * What the region says, and what it's about to say.\n */\ninterface Announcements {\n /**\n * Changes whenever announcements join the wait, which restarts it. It also\n * names the batch they're written with.\n */\n key: number;\n /** Waiting to be written, in the order their items were shown. */\n waiting: Announcement[];\n /** In the region, in the order they were written. */\n written: WrittenAnnouncement[];\n}\n\nconst noAnnouncements: Announcement[] = [];\nconst nothingWritten: WrittenAnnouncement[] = [];\nconst noAnnouncementsYet: Announcements = {\n key: 0,\n waiting: noAnnouncements,\n written: nothingWritten,\n};\n\n// Hides the region with inline styles rather than a class, so it needs no\n// stylesheet, including the helper classes a modular Bulma build can leave\n// out. Clipping it, rather than using `display` or `visibility`, keeps it in\n// the accessibility tree. The declarations match the `extras-sr-only` mixin,\n// plus `clip-path`, which replaces the deprecated `clip`. The negative margin\n// pulls the 1px box back inside the page, so it doesn't add scrollable\n// overflow. Exported for other live regions that must work without a\n// stylesheet, such as a range calendar's.\nexport const visuallyHidden: React.CSSProperties = {\n position: 'absolute',\n width: '1px',\n height: '1px',\n padding: 0,\n margin: '-1px',\n overflow: 'hidden',\n clip: 'rect(0, 0, 0, 0)',\n clipPath: 'inset(50%)',\n whiteSpace: 'nowrap',\n border: 0,\n};\n\n/**\n * Works out what a status region says. Each item is announced once. Its\n * announcement is written `announceDelay` after it appears, or after the last\n * item to join it when several appear in quick succession, alongside whatever\n * the region still says. Once written, it stays for `announcementLifetime`,\n * whatever is written after it and whether or not its item goes in the\n * meantime. An item that goes before its announcement is written isn't\n * announced.\n *\n * Nothing is written in the commit that mounts the region or the item. A\n * screen reader reliably announces a polite live region whose content changes\n * after the region is already in the page, and not always one that arrives\n * with its content. By the time `describe` runs, an item's rendered text can\n * be read.\n *\n * @function useAnnouncements\n * @param items - The items to announce, in the order they were shown.\n * @param describe - The text to announce for an item, or nothing to skip it.\n * It runs once the item is on screen, and it must keep its identity between\n * renders (a module-level function or a `useCallback`), or every render\n * re-checks the items.\n * @returns The announcements to render, in the order they were written.\n */\nfunction useAnnouncements<Item extends { id: string }>(\n items: readonly Item[],\n describe: (item: Item) => string | null\n): WrittenAnnouncement[] {\n // The ids of the items on screen as of the last change, every one of which\n // has been looked at for an announcement already.\n const onScreenIdsRef = useRef(new Set<string>());\n // Clears that haven't run yet. They outlive the batch that started them, so\n // they're only stopped when the region unmounts.\n const clearTimersRef = useRef(new Set<ReturnType<typeof setTimeout>>());\n const [announcements, setAnnouncements] = useState(noAnnouncementsYet);\n\n // Runs after the commit that puts an item on screen, which is the first\n // point its rendered text can be read.\n useEffect(() => {\n const announced = onScreenIdsRef.current;\n const appeared = items.filter(item => !announced.has(item.id));\n // Items that have gone drop out here too. Ids are never reused, so\n // forgetting them can't bring an old item back.\n onScreenIdsRef.current = new Set(items.map(item => item.id));\n\n const next: Announcement[] = [];\n for (const item of appeared) {\n const text = describe(item);\n if (text) next.push({ id: item.id, text });\n }\n if (next.length > 0) {\n // eslint-disable-next-line react-hooks/set-state-in-effect -- an item's rendered text can only be read once it's on screen\n setAnnouncements(current => ({\n ...current,\n key: current.key + 1,\n // Announcements still waiting go out with the new ones. What's\n // already written stays put.\n waiting: [...current.waiting, ...next],\n }));\n }\n }, [items, describe]);\n\n // The write only touches the batch it was started for: a newer batch can\n // be set before the render that cancels this timer. Its clear is started\n // by the write itself, so it's timed from the write without waiting on a\n // render, and a newer batch arriving doesn't cut short what's written.\n const { key } = announcements;\n useEffect(() => {\n if (key === noAnnouncementsYet.key) return undefined;\n const clearTimers = clearTimersRef.current;\n const write = setTimeout(() => {\n // Whether an item is on screen is decided here, once. An item that\n // went before its announcement was due isn't announced, and one that\n // goes after keeps its announcement for the rest of its lifetime, when\n // the region holds the only copy of its text rather than a second one.\n const onScreen = onScreenIdsRef.current;\n setAnnouncements(current =>\n current.key === key\n ? {\n ...current,\n waiting: noAnnouncements,\n written: [\n ...current.written,\n ...current.waiting\n .filter(announcement => onScreen.has(announcement.id))\n .map(announcement => ({ ...announcement, batch: key })),\n ],\n }\n : current\n );\n // eslint-disable-next-line @eslint-react/web-api-no-leaked-timeout -- it has to outlive this effect when a newer batch arrives, so the unmount effect below clears it\n const clear = setTimeout(() => {\n clearTimers.delete(clear);\n setAnnouncements(current => {\n const written = current.written.filter(\n announcement => announcement.batch !== key\n );\n return written.length === current.written.length\n ? current\n : { ...current, written };\n });\n }, announcementLifetime);\n clearTimers.add(clear);\n }, announceDelay);\n return () => clearTimeout(write);\n }, [key]);\n\n useEffect(() => {\n const clearTimers = clearTimersRef.current;\n return () => {\n clearTimers.forEach(clearTimeout);\n clearTimers.clear();\n };\n }, []);\n\n return announcements.written;\n}\n\n/**\n * Reads an element out roughly the way a screen reader would: its text, with\n * an element's `aria-label`, or an image's `alt`, standing in for what's\n * inside it, a break between block-level elements, and parts that are\n * `aria-hidden`, `hidden` or inline-styled `display: none` left out. It isn't\n * the full accessible name computation, so `aria-labelledby`, CSS-generated\n * content, content hidden by a stylesheet and other ways of naming or hiding\n * content aren't followed.\n *\n * @function spokenText\n * @param element - The element to read.\n * @returns Its text, with runs of whitespace collapsed and the ends trimmed.\n */\nexport function spokenText(element: Element): string {\n return readAloud(element).replace(/\\s+/g, ' ').trim();\n}\n\n// Elements a browser displays as blocks by default, and `br`. A screen reader\n// pauses between them, so their text shouldn't run into the text beside them.\n// They're known by tag rather than by computed `display`, which would depend\n// on a stylesheet and on layout.\nconst blockLevelTags = new Set([\n 'ADDRESS',\n 'ARTICLE',\n 'ASIDE',\n 'BLOCKQUOTE',\n 'BR',\n 'CAPTION',\n 'DD',\n 'DETAILS',\n 'DIALOG',\n 'DIV',\n 'DL',\n 'DT',\n 'FIELDSET',\n 'FIGCAPTION',\n 'FIGURE',\n 'FOOTER',\n 'FORM',\n 'H1',\n 'H2',\n 'H3',\n 'H4',\n 'H5',\n 'H6',\n 'HEADER',\n 'HGROUP',\n 'HR',\n 'LEGEND',\n 'LI',\n 'MAIN',\n 'MENU',\n 'NAV',\n 'OL',\n 'P',\n 'PRE',\n 'SEARCH',\n 'SECTION',\n 'SUMMARY',\n 'TABLE',\n 'TBODY',\n 'TD',\n 'TFOOT',\n 'TH',\n 'THEAD',\n 'TR',\n 'UL',\n]);\n\n/**\n * Whether an element is left out of what a screen reader reads.\n *\n * @function isHidden\n * @param element - The element to check.\n * @returns True for `aria-hidden=\"true\"`, the `hidden` attribute, and an\n * inline `display: none`, on an HTML, SVG or any other element with an\n * inline style.\n */\nconst isHidden = (element: Element): boolean =>\n element.getAttribute('aria-hidden') === 'true' ||\n element.hasAttribute('hidden') ||\n (element as Partial<ElementCSSInlineStyle>).style?.display === 'none';\n\n/**\n * The text `spokenText` reads for an element, before its whitespace is\n * tidied.\n *\n * @function readAloud\n * @param element - The element to read.\n * @returns Its text.\n */\nfunction readAloud(element: Element): string {\n if (isHidden(element)) return '';\n const name =\n element.getAttribute('aria-label') ||\n (element.tagName === 'IMG' ? element.getAttribute('alt') : null);\n const text =\n name ||\n Array.from(element.childNodes, child => {\n if (child instanceof Element) return readAloud(child);\n return child.nodeType === Node.TEXT_NODE ? child.textContent : '';\n }).join('');\n // A name stands in for the element's content, and a block's content stands\n // apart from what's around it, so both are padded to keep them from running\n // into the neighboring text. An inline run, like `<span>Up</span>` then\n // `<span>loaded</span>`, still reads as one word.\n return name || blockLevelTags.has(element.tagName) ? ` ${text} ` : text;\n}\n\n/**\n * Props for StatusRegion.\n */\nexport interface StatusRegionProps<Item extends { id: string }> {\n /** The items to announce politely, in the order they were shown. */\n items: readonly Item[];\n /**\n * The text to announce for an item, or nothing to skip it, as\n * `useAnnouncements` takes it.\n */\n describe: (item: Item) => string | null;\n}\n\n/**\n * A visually hidden, polite `status` live region that a programmatic\n * container announces its items through. The container renders it from the\n * moment it mounts, whether or not it has anything to show, so the region is\n * already in the page when an announcement is written into it.\n *\n * The announcements are this component's own state, so writing and clearing\n * them re-renders the region and leaves the container's items alone. Each one\n * is its own keyed node, so a re-render that leaves the announcements alone\n * leaves the region's content alone too, and nothing is read out twice.\n *\n * @function StatusRegion\n * @param props - The items to announce, and the text to announce for each.\n * @returns The region.\n */\nexport function StatusRegion<Item extends { id: string }>({\n items,\n describe,\n}: StatusRegionProps<Item>): React.ReactElement {\n const announcements = useAnnouncements(items, describe);\n\n // `role=\"status\"` makes a region atomic unless it says otherwise, which\n // reads the whole region on every change. Each announcement is its own\n // node, and only additions are relevant by default, so with atomic off a\n // screen reader reads just what was written, and removing one announcement\n // doesn't read out the ones still there.\n return (\n <div\n role=\"status\"\n aria-live=\"polite\"\n aria-atomic=\"false\"\n style={visuallyHidden}\n >\n {announcements.map(announcement => (\n <div key={announcement.id}>{announcement.text}</div>\n ))}\n </div>\n );\n}\n"],"names":["_jsx"],"mappings":"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"}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/* @allxsmith/bestax-bulma — AI agents: see AGENTS.md in the package root, or https://bestax.io/llms.txt */
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { useClientLayoutEffect } from './useClientLayoutEffect.js';
|
|
4
|
+
|
|
5
|
+
// INTERNAL: deliberately not exported from src/index.ts.
|
|
6
|
+
/**
|
|
7
|
+
* A `styledGap` for a panel whose stylesheet puts the gap in a margin on the
|
|
8
|
+
* side facing the anchor, negative on a portaled panel above it, so the
|
|
9
|
+
* larger magnitude is the gap. In pixels.
|
|
10
|
+
*/
|
|
11
|
+
function marginGap(panel) {
|
|
12
|
+
const { marginTop, marginBottom } = getComputedStyle(panel);
|
|
13
|
+
return Math.max(Math.abs(parseFloat(marginTop)) || 0, Math.abs(parseFloat(marginBottom)) || 0);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The corner that keeps a panel of this size in the viewport, preferring
|
|
17
|
+
* below the anchor and lined up with its left edge.
|
|
18
|
+
*/
|
|
19
|
+
function resolveAuto(view, rect, panelWidth, panelHeight, gap) {
|
|
20
|
+
const fitsBelow = rect.bottom + panelHeight + gap <= view.innerHeight;
|
|
21
|
+
const fitsRight = rect.left + panelWidth <= view.innerWidth;
|
|
22
|
+
if (fitsBelow)
|
|
23
|
+
return fitsRight ? 'bottom-left' : 'bottom-right';
|
|
24
|
+
return fitsRight ? 'top-left' : 'top-right';
|
|
25
|
+
}
|
|
26
|
+
const samePosition = (a, b) => a.position === b.position && a.top === b.top && a.left === b.left;
|
|
27
|
+
/**
|
|
28
|
+
* Places a floating panel against its anchor: resolves `auto` to a corner
|
|
29
|
+
* and, for a panel fixed to the viewport, works out its coordinates. It
|
|
30
|
+
* measures again on resize and on any scroll of the anchor's window (an
|
|
31
|
+
* iframe's, when the anchor renders into one), so a fixed panel follows its
|
|
32
|
+
* anchor. A measurement that changes nothing doesn't render again.
|
|
33
|
+
*
|
|
34
|
+
* Measuring happens in a layout effect, so the panel is placed before it
|
|
35
|
+
* paints. It needs both elements on the page while `active` is on, and
|
|
36
|
+
* keeps the last placement when either is missing.
|
|
37
|
+
*
|
|
38
|
+
* @function useAnchoredPosition
|
|
39
|
+
* @param anchorRef - The element the panel opens from.
|
|
40
|
+
* @param panelRef - The floating panel.
|
|
41
|
+
* @param options - Whether to measure, the corner asked for, and how the panel is placed.
|
|
42
|
+
* @returns The corner the panel opens from, with coordinates when it is fixed.
|
|
43
|
+
*/
|
|
44
|
+
function useAnchoredPosition(anchorRef, panelRef, { active, position, fixed, offset = 4, styledGap }) {
|
|
45
|
+
const [resolved, setResolved] = useState({
|
|
46
|
+
position: position === 'auto' ? 'bottom-left' : position,
|
|
47
|
+
});
|
|
48
|
+
useClientLayoutEffect(() => {
|
|
49
|
+
if (!active)
|
|
50
|
+
return undefined;
|
|
51
|
+
const view = (anchorRef.current ?? panelRef.current)?.ownerDocument.defaultView ??
|
|
52
|
+
window;
|
|
53
|
+
const update = () => {
|
|
54
|
+
const anchor = anchorRef.current;
|
|
55
|
+
const panel = panelRef.current;
|
|
56
|
+
if (!anchor || !panel)
|
|
57
|
+
return;
|
|
58
|
+
const rect = anchor.getBoundingClientRect();
|
|
59
|
+
const { width, height } = panel.getBoundingClientRect();
|
|
60
|
+
const corner = position === 'auto'
|
|
61
|
+
? resolveAuto(view, rect, width, height, offset + (styledGap?.(panel) ?? 0))
|
|
62
|
+
: position;
|
|
63
|
+
const next = fixed
|
|
64
|
+
? {
|
|
65
|
+
position: corner,
|
|
66
|
+
top: corner.startsWith('bottom')
|
|
67
|
+
? rect.bottom + offset
|
|
68
|
+
: rect.top - height - offset,
|
|
69
|
+
left: corner.endsWith('left') ? rect.left : rect.right - width,
|
|
70
|
+
}
|
|
71
|
+
: { position: corner };
|
|
72
|
+
setResolved(prev => (samePosition(prev, next) ? prev : next));
|
|
73
|
+
};
|
|
74
|
+
update();
|
|
75
|
+
view.addEventListener('resize', update, { passive: true });
|
|
76
|
+
view.addEventListener('scroll', update, { passive: true, capture: true });
|
|
77
|
+
return () => {
|
|
78
|
+
view.removeEventListener('resize', update);
|
|
79
|
+
view.removeEventListener('scroll', update, { capture: true });
|
|
80
|
+
};
|
|
81
|
+
}, [active, anchorRef, panelRef, position, fixed, offset, styledGap]);
|
|
82
|
+
return resolved;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export { marginGap, useAnchoredPosition };
|
|
86
|
+
//# sourceMappingURL=useAnchoredPosition.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useAnchoredPosition.js","sources":["../../../src/helpers/useAnchoredPosition.ts"],"sourcesContent":["// INTERNAL: deliberately not exported from src/index.ts.\nimport { useState } from 'react';\nimport type { RefObject } from 'react';\nimport type { PickerPosition } from '../form/_pickerInternals/pickerTypes';\nimport { useClientLayoutEffect } from './useClientLayoutEffect';\n\n/** A corner of the anchor the panel opens from, once `auto` is resolved. */\nexport type AnchoredCorner = Exclude<PickerPosition, 'auto'>;\n\n/**\n * Where the panel goes: the corner it opens from and, when it is fixed to\n * the viewport, its coordinates there.\n */\nexport interface AnchoredPosition {\n position: AnchoredCorner;\n top?: number;\n left?: number;\n}\n\n/**\n * Options for `useAnchoredPosition`.\n */\nexport interface UseAnchoredPositionOptions {\n /** Whether the panel is on the page. Nothing is measured while it is not. */\n active: boolean;\n /** The corner asked for, or `auto` to pick one that keeps the panel in the viewport. */\n position: PickerPosition;\n /**\n * Whether the panel is fixed to the viewport (it was portaled away from the\n * anchor), which needs coordinates. Otherwise the panel's CSS places it\n * against the anchor from the corner alone.\n */\n fixed: boolean;\n /**\n * The gap between the anchor and the panel, in pixels, that the hook puts\n * there itself: added to the fixed coordinates and to the room `auto` asks\n * for below the anchor. Popover and the pickers' popover pass `0` and take\n * their gap from a margin in their stylesheet, read through `styledGap`, so\n * a theme's offset moves the panel and `auto` alike. A new panel should do\n * the same rather than rely on this default, which no stylesheet sees.\n * @defaultValue 4\n */\n offset?: number;\n /**\n * Reads the gap the panel's own stylesheet puts between it and the anchor,\n * in pixels, each time the panel is measured. `auto` leaves room for it as\n * well; the coordinates don't include it, because the stylesheet applies it.\n */\n styledGap?: (panel: HTMLElement) => number;\n}\n\n/**\n * A `styledGap` for a panel whose stylesheet puts the gap in a margin on the\n * side facing the anchor, negative on a portaled panel above it, so the\n * larger magnitude is the gap. In pixels.\n */\nexport function marginGap(panel: HTMLElement): number {\n const { marginTop, marginBottom } = getComputedStyle(panel);\n return Math.max(\n Math.abs(parseFloat(marginTop)) || 0,\n Math.abs(parseFloat(marginBottom)) || 0\n );\n}\n\n/**\n * The corner that keeps a panel of this size in the viewport, preferring\n * below the anchor and lined up with its left edge.\n */\nfunction resolveAuto(\n view: Window,\n rect: DOMRect,\n panelWidth: number,\n panelHeight: number,\n gap: number\n): AnchoredCorner {\n const fitsBelow = rect.bottom + panelHeight + gap <= view.innerHeight;\n const fitsRight = rect.left + panelWidth <= view.innerWidth;\n if (fitsBelow) return fitsRight ? 'bottom-left' : 'bottom-right';\n return fitsRight ? 'top-left' : 'top-right';\n}\n\nconst samePosition = (a: AnchoredPosition, b: AnchoredPosition): boolean =>\n a.position === b.position && a.top === b.top && a.left === b.left;\n\n/**\n * Places a floating panel against its anchor: resolves `auto` to a corner\n * and, for a panel fixed to the viewport, works out its coordinates. It\n * measures again on resize and on any scroll of the anchor's window (an\n * iframe's, when the anchor renders into one), so a fixed panel follows its\n * anchor. A measurement that changes nothing doesn't render again.\n *\n * Measuring happens in a layout effect, so the panel is placed before it\n * paints. It needs both elements on the page while `active` is on, and\n * keeps the last placement when either is missing.\n *\n * @function useAnchoredPosition\n * @param anchorRef - The element the panel opens from.\n * @param panelRef - The floating panel.\n * @param options - Whether to measure, the corner asked for, and how the panel is placed.\n * @returns The corner the panel opens from, with coordinates when it is fixed.\n */\nexport function useAnchoredPosition(\n anchorRef: RefObject<HTMLElement | null>,\n panelRef: RefObject<HTMLElement | null>,\n { active, position, fixed, offset = 4, styledGap }: UseAnchoredPositionOptions\n): AnchoredPosition {\n const [resolved, setResolved] = useState<AnchoredPosition>({\n position: position === 'auto' ? 'bottom-left' : position,\n });\n\n useClientLayoutEffect(() => {\n if (!active) return undefined;\n const view =\n (anchorRef.current ?? panelRef.current)?.ownerDocument.defaultView ??\n window;\n const update = () => {\n const anchor = anchorRef.current;\n const panel = panelRef.current;\n if (!anchor || !panel) return;\n const rect = anchor.getBoundingClientRect();\n const { width, height } = panel.getBoundingClientRect();\n const corner =\n position === 'auto'\n ? resolveAuto(\n view,\n rect,\n width,\n height,\n offset + (styledGap?.(panel) ?? 0)\n )\n : position;\n const next: AnchoredPosition = fixed\n ? {\n position: corner,\n top: corner.startsWith('bottom')\n ? rect.bottom + offset\n : rect.top - height - offset,\n left: corner.endsWith('left') ? rect.left : rect.right - width,\n }\n : { position: corner };\n setResolved(prev => (samePosition(prev, next) ? prev : next));\n };\n update();\n view.addEventListener('resize', update, { passive: true });\n view.addEventListener('scroll', update, { passive: true, capture: true });\n return () => {\n view.removeEventListener('resize', update);\n view.removeEventListener('scroll', update, { capture: true });\n };\n }, [active, anchorRef, panelRef, position, fixed, offset, styledGap]);\n\n return resolved;\n}\n"],"names":[],"mappings":"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"}
|