@faststore/components 2.0.53-alpha.0 → 2.0.56-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/atoms/Slider/Slider.d.ts +71 -0
- package/dist/atoms/Slider/Slider.js +57 -0
- package/dist/atoms/Slider/Slider.js.map +1 -0
- package/dist/atoms/Slider/index.d.ts +2 -0
- package/dist/atoms/Slider/index.js +2 -0
- package/dist/atoms/Slider/index.js.map +1 -0
- package/dist/hooks/UIProvider.d.ts +33 -0
- package/dist/hooks/UIProvider.js +74 -0
- package/dist/hooks/UIProvider.js.map +1 -0
- package/dist/hooks/index.d.ts +3 -0
- package/dist/hooks/index.js +4 -0
- package/dist/hooks/index.js.map +1 -0
- package/dist/hooks/useFadeEffect.d.ts +5 -0
- package/dist/hooks/useFadeEffect.js +18 -0
- package/dist/hooks/useFadeEffect.js.map +1 -0
- package/dist/hooks/useTrapFocus.d.ts +8 -0
- package/dist/hooks/useTrapFocus.js +75 -0
- package/dist/hooks/useTrapFocus.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/molecules/Modal/Modal.d.ts +34 -0
- package/dist/molecules/Modal/Modal.js +38 -0
- package/dist/molecules/Modal/Modal.js.map +1 -0
- package/dist/molecules/Modal/ModalBody.d.ts +6 -0
- package/dist/molecules/Modal/ModalBody.js +4 -0
- package/dist/molecules/Modal/ModalBody.js.map +1 -0
- package/dist/molecules/Modal/ModalContent.d.ts +10 -0
- package/dist/molecules/Modal/ModalContent.js +23 -0
- package/dist/molecules/Modal/ModalContent.js.map +1 -0
- package/dist/molecules/Modal/ModalHeader.d.ts +19 -0
- package/dist/molecules/Modal/ModalHeader.js +11 -0
- package/dist/molecules/Modal/ModalHeader.js.map +1 -0
- package/dist/molecules/Modal/index.d.ts +5 -0
- package/dist/molecules/Modal/index.js +4 -0
- package/dist/molecules/Modal/index.js.map +1 -0
- package/dist/molecules/Table/Table.d.ts +1 -1
- package/dist/organisms/PriceRange/PriceRange.d.ts +38 -0
- package/dist/organisms/PriceRange/PriceRange.js +75 -0
- package/dist/organisms/PriceRange/PriceRange.js.map +1 -0
- package/dist/organisms/PriceRange/index.d.ts +2 -0
- package/dist/organisms/PriceRange/index.js +2 -0
- package/dist/organisms/PriceRange/index.js.map +1 -0
- package/package.json +2 -2
- package/src/atoms/Slider/Slider.tsx +220 -0
- package/src/atoms/Slider/index.ts +2 -0
- package/src/hooks/UIProvider.tsx +152 -0
- package/src/hooks/index.ts +3 -0
- package/src/hooks/useFadeEffect.ts +21 -0
- package/src/hooks/useTrapFocus.ts +108 -0
- package/src/index.ts +10 -0
- package/src/molecules/Modal/Modal.tsx +106 -0
- package/src/molecules/Modal/ModalBody.tsx +13 -0
- package/src/molecules/Modal/ModalContent.tsx +90 -0
- package/src/molecules/Modal/ModalHeader.tsx +47 -0
- package/src/molecules/Modal/index.tsx +5 -0
- package/src/organisms/PriceRange/PriceRange.tsx +188 -0
- package/src/organisms/PriceRange/index.ts +2 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react'
|
|
2
|
+
import type { RefObject } from 'react'
|
|
3
|
+
import type { FocusableElement } from 'tabbable'
|
|
4
|
+
import { tabbable } from 'tabbable'
|
|
5
|
+
|
|
6
|
+
interface TrapFocusParams {
|
|
7
|
+
beforeElementRef: RefObject<HTMLElement>
|
|
8
|
+
trapFocusRef: RefObject<HTMLElement>
|
|
9
|
+
afterElementRef: RefObject<HTMLElement>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/*
|
|
13
|
+
* Element that will maintain the focus inside trapFocusRef, focus the first element,
|
|
14
|
+
* and focus back on the element that was in focus when useTrapFocus was triggered.
|
|
15
|
+
*
|
|
16
|
+
* Inspired by Reakit useTrapFocus https://github.com/reakit/reakit/blob/a211d94da9f3b683182568a56479b91afb1b85ae/packages/reakit/src/Dialog/__utils/useFocusTrap.ts
|
|
17
|
+
*/
|
|
18
|
+
export const useTrapFocus = ({
|
|
19
|
+
trapFocusRef,
|
|
20
|
+
beforeElementRef,
|
|
21
|
+
afterElementRef,
|
|
22
|
+
}: TrapFocusParams) => {
|
|
23
|
+
const tabbableNodesRef = useRef<FocusableElement[]>()
|
|
24
|
+
const nodeToRestoreRef = useRef<HTMLElement | null>(
|
|
25
|
+
document.hasFocus() ? (document.activeElement as HTMLElement) : null
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
// Focus back on the element that was focused when useTrapFocus is triggered.
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
const nodeToRestore = nodeToRestoreRef.current
|
|
31
|
+
|
|
32
|
+
return () => {
|
|
33
|
+
nodeToRestore?.focus()
|
|
34
|
+
}
|
|
35
|
+
}, [nodeToRestoreRef])
|
|
36
|
+
|
|
37
|
+
// Set focus on first tabbable element
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!trapFocusRef.current) {
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!tabbableNodesRef.current) {
|
|
44
|
+
tabbableNodesRef.current = tabbable(trapFocusRef.current)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const [firstTabbable] = tabbableNodesRef.current
|
|
48
|
+
|
|
49
|
+
if (!firstTabbable) {
|
|
50
|
+
trapFocusRef.current.focus()
|
|
51
|
+
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
firstTabbable.focus()
|
|
56
|
+
}, [trapFocusRef])
|
|
57
|
+
|
|
58
|
+
// Handle loop focus. Set keydown and focusin event listeners
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (
|
|
61
|
+
!trapFocusRef.current ||
|
|
62
|
+
!beforeElementRef.current ||
|
|
63
|
+
!afterElementRef.current
|
|
64
|
+
) {
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const beforeElement = beforeElementRef.current
|
|
69
|
+
const afterElement = afterElementRef.current
|
|
70
|
+
const trapFocus = trapFocusRef.current
|
|
71
|
+
|
|
72
|
+
const handleLoopFocus = (nativeEvent: FocusEvent) => {
|
|
73
|
+
if (!document.hasFocus()) {
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
tabbableNodesRef.current = tabbable(trapFocusRef.current!)
|
|
78
|
+
|
|
79
|
+
if (!tabbableNodesRef.current.length) {
|
|
80
|
+
trapFocus.focus()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/*
|
|
84
|
+
* Handle loop focus from beforeElementRef. This node can only be focused if the user press shift tab.
|
|
85
|
+
* It will focus the last element of the trapFocusRef.
|
|
86
|
+
*/
|
|
87
|
+
if (nativeEvent.target === beforeElement) {
|
|
88
|
+
tabbableNodesRef.current[tabbableNodesRef.current.length - 1]?.focus()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/*
|
|
92
|
+
* Handle loop focus from afterElementRef. This node can only be focused if the user press tab.
|
|
93
|
+
* It will focus the first element of the trapFocusRef.
|
|
94
|
+
*/
|
|
95
|
+
if (nativeEvent.target === afterElement) {
|
|
96
|
+
tabbableNodesRef.current[0]?.focus()
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
beforeElement?.addEventListener('focusin', handleLoopFocus)
|
|
101
|
+
afterElement?.addEventListener('focusin', handleLoopFocus)
|
|
102
|
+
|
|
103
|
+
return () => {
|
|
104
|
+
beforeElement?.removeEventListener('focusin', handleLoopFocus)
|
|
105
|
+
afterElement?.removeEventListener('focusin', handleLoopFocus)
|
|
106
|
+
}
|
|
107
|
+
}, [tabbableNodesRef, afterElementRef, beforeElementRef, trapFocusRef])
|
|
108
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// Assets
|
|
2
2
|
export * from './assets'
|
|
3
3
|
|
|
4
|
+
// Hooks
|
|
5
|
+
export * from './hooks'
|
|
6
|
+
|
|
4
7
|
// Atoms
|
|
5
8
|
export { default as Badge } from './atoms/Badge'
|
|
6
9
|
export type { BadgeProps } from './atoms/Badge'
|
|
@@ -28,6 +31,8 @@ export { default as Radio } from './atoms/Radio'
|
|
|
28
31
|
export type { RadioProps } from './atoms/Radio'
|
|
29
32
|
export { default as Select } from './atoms/Select'
|
|
30
33
|
export type { SelectProps } from './atoms/Select'
|
|
34
|
+
export { default as Slider } from './atoms/Slider'
|
|
35
|
+
export type { SliderProps } from './atoms/Slider'
|
|
31
36
|
export { default as SROnly } from './atoms/SROnly'
|
|
32
37
|
|
|
33
38
|
// Molecules
|
|
@@ -78,6 +83,8 @@ export { default as InputField } from './molecules/InputField'
|
|
|
78
83
|
export type { InputFieldProps } from './molecules/InputField'
|
|
79
84
|
export { default as LinkButton } from './molecules/LinkButton'
|
|
80
85
|
export type { LinkButtonProps } from './molecules/LinkButton'
|
|
86
|
+
export { default as Modal, ModalHeader, ModalBody } from './molecules/Modal'
|
|
87
|
+
export type { ModalProps, ModalHeaderProps } from './molecules/Modal'
|
|
81
88
|
export { default as RadioField } from './molecules/RadioField'
|
|
82
89
|
export type { RadioFieldProps } from './molecules/RadioField'
|
|
83
90
|
export {
|
|
@@ -124,3 +131,6 @@ export type {
|
|
|
124
131
|
HeroImageProps,
|
|
125
132
|
HeroHeadingProps,
|
|
126
133
|
} from './organisms/Hero'
|
|
134
|
+
|
|
135
|
+
export { default as PriceRange } from './organisms/PriceRange'
|
|
136
|
+
export type { PriceRangeProps } from './organisms/PriceRange'
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AriaAttributes,
|
|
3
|
+
KeyboardEvent,
|
|
4
|
+
MouseEvent,
|
|
5
|
+
ReactNode,
|
|
6
|
+
} from 'react'
|
|
7
|
+
import React from 'react'
|
|
8
|
+
import { createPortal } from 'react-dom'
|
|
9
|
+
|
|
10
|
+
import { Overlay } from '../..'
|
|
11
|
+
import { useFadeEffect, useUI } from '../../hooks'
|
|
12
|
+
import type { ModalContentProps } from './ModalContent'
|
|
13
|
+
import ModalContent from './ModalContent'
|
|
14
|
+
|
|
15
|
+
export type ModalChildrenProps = {
|
|
16
|
+
fade: 'in' | 'out'
|
|
17
|
+
fadeOut: () => void
|
|
18
|
+
fadeIn: () => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type ModalChildrenFunction = (props: ModalChildrenProps) => ReactNode
|
|
22
|
+
|
|
23
|
+
export interface ModalProps extends Omit<ModalContentProps, 'children'> {
|
|
24
|
+
/**
|
|
25
|
+
* ID to find this component in testing tools (e.g.: cypress, testing library, and jest).
|
|
26
|
+
*/
|
|
27
|
+
testId?: string
|
|
28
|
+
/**
|
|
29
|
+
* Identifies the element (or elements) that labels the current element.
|
|
30
|
+
* @see aria-labelledby https://www.w3.org/TR/wai-aria-1.1/#aria-labelledby
|
|
31
|
+
*/
|
|
32
|
+
'aria-labelledby'?: AriaAttributes['aria-label']
|
|
33
|
+
/**
|
|
34
|
+
* A boolean value that represents the state of the Modal
|
|
35
|
+
*/
|
|
36
|
+
isOpen?: boolean
|
|
37
|
+
/**
|
|
38
|
+
* Event emitted when the modal is closed
|
|
39
|
+
*/
|
|
40
|
+
onDismiss?: () => void
|
|
41
|
+
/**
|
|
42
|
+
* Children or function as a children
|
|
43
|
+
*/
|
|
44
|
+
children: ModalChildrenFunction | ReactNode
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/*
|
|
48
|
+
* This component is based on @reach/dialog.
|
|
49
|
+
* https://github.com/reach/reach-ui/blob/main/packages/dialog/src/index.tsx
|
|
50
|
+
* https://reach.tech/dialog
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
const Modal = ({
|
|
54
|
+
children,
|
|
55
|
+
testId = 'fs-modal',
|
|
56
|
+
isOpen = true,
|
|
57
|
+
onDismiss,
|
|
58
|
+
...otherProps
|
|
59
|
+
}: ModalProps) => {
|
|
60
|
+
const { closeModal } = useUI()
|
|
61
|
+
const { fade, fadeOut, fadeIn } = useFadeEffect()
|
|
62
|
+
|
|
63
|
+
const handleBackdropClick = (event: MouseEvent) => {
|
|
64
|
+
if (event.defaultPrevented) {
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
event.stopPropagation()
|
|
69
|
+
fadeOut?.()
|
|
70
|
+
onDismiss?.()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const handleBackdropKeyDown = (event: KeyboardEvent) => {
|
|
74
|
+
if (event.key !== 'Escape' || event.defaultPrevented) {
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
event.stopPropagation()
|
|
79
|
+
fadeOut?.()
|
|
80
|
+
onDismiss?.()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return isOpen
|
|
84
|
+
? createPortal(
|
|
85
|
+
<Overlay
|
|
86
|
+
onClick={handleBackdropClick}
|
|
87
|
+
onKeyDown={handleBackdropKeyDown}
|
|
88
|
+
>
|
|
89
|
+
<ModalContent
|
|
90
|
+
onTransitionEnd={() => fade === 'out' && closeModal()}
|
|
91
|
+
data-fs-modal
|
|
92
|
+
data-fs-modal-state={fade}
|
|
93
|
+
testId={testId}
|
|
94
|
+
{...otherProps}
|
|
95
|
+
>
|
|
96
|
+
{typeof children === 'function'
|
|
97
|
+
? children({ fade, fadeOut, fadeIn })
|
|
98
|
+
: children}
|
|
99
|
+
</ModalContent>
|
|
100
|
+
</Overlay>,
|
|
101
|
+
document.body
|
|
102
|
+
)
|
|
103
|
+
: null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export default Modal
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import React, { ReactNode, HTMLAttributes } from 'react'
|
|
2
|
+
|
|
3
|
+
export interface ModalBodyProps extends HTMLAttributes<HTMLDivElement> {
|
|
4
|
+
children: ReactNode
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const ModalBody = ({ children, ...otherProps }: ModalBodyProps) => (
|
|
8
|
+
<div data-fs-modal-body {...otherProps}>
|
|
9
|
+
{children}
|
|
10
|
+
</div>
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
export default ModalBody
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DetailedHTMLProps,
|
|
3
|
+
HTMLAttributes,
|
|
4
|
+
MouseEvent,
|
|
5
|
+
RefObject,
|
|
6
|
+
} from 'react'
|
|
7
|
+
import React, { useRef } from 'react'
|
|
8
|
+
|
|
9
|
+
import { useTrapFocus } from '../../hooks'
|
|
10
|
+
|
|
11
|
+
interface ModalContentPureProps
|
|
12
|
+
extends Omit<
|
|
13
|
+
DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>,
|
|
14
|
+
'ref'
|
|
15
|
+
> {
|
|
16
|
+
beforeElementRef: RefObject<HTMLDivElement>
|
|
17
|
+
trapFocusRef: RefObject<HTMLDivElement>
|
|
18
|
+
afterElementRef: RefObject<HTMLDivElement>
|
|
19
|
+
testId?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const ModalContentPure = ({
|
|
23
|
+
beforeElementRef,
|
|
24
|
+
trapFocusRef,
|
|
25
|
+
afterElementRef,
|
|
26
|
+
testId = 'store-modal-content',
|
|
27
|
+
children,
|
|
28
|
+
...otherProps
|
|
29
|
+
}: ModalContentPureProps) => {
|
|
30
|
+
return (
|
|
31
|
+
<>
|
|
32
|
+
<div
|
|
33
|
+
ref={beforeElementRef}
|
|
34
|
+
data-testid="beforeElement"
|
|
35
|
+
tabIndex={0}
|
|
36
|
+
aria-hidden="true"
|
|
37
|
+
/>
|
|
38
|
+
<div
|
|
39
|
+
data-fs-modal-content
|
|
40
|
+
data-testid={testId}
|
|
41
|
+
ref={trapFocusRef}
|
|
42
|
+
aria-modal="true"
|
|
43
|
+
role="dialog"
|
|
44
|
+
tabIndex={-1}
|
|
45
|
+
{...otherProps}
|
|
46
|
+
>
|
|
47
|
+
{children}
|
|
48
|
+
</div>
|
|
49
|
+
<div
|
|
50
|
+
ref={afterElementRef}
|
|
51
|
+
data-testid="afterElement"
|
|
52
|
+
tabIndex={0}
|
|
53
|
+
aria-hidden="true"
|
|
54
|
+
/>
|
|
55
|
+
</>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type ModalContentProps = Omit<
|
|
60
|
+
ModalContentPureProps,
|
|
61
|
+
'trapFocusRef' | 'onClick' | 'beforeElementRef' | 'afterElementRef'
|
|
62
|
+
>
|
|
63
|
+
|
|
64
|
+
const ModalContent = ({ children, ...otherProps }: ModalContentProps) => {
|
|
65
|
+
const trapFocusRef = useRef<HTMLDivElement>(null)
|
|
66
|
+
const beforeElementRef = useRef<HTMLDivElement>(null)
|
|
67
|
+
const afterElementRef = useRef<HTMLDivElement>(null)
|
|
68
|
+
|
|
69
|
+
useTrapFocus({
|
|
70
|
+
beforeElementRef,
|
|
71
|
+
trapFocusRef,
|
|
72
|
+
afterElementRef,
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<ModalContentPure
|
|
77
|
+
{...otherProps}
|
|
78
|
+
trapFocusRef={trapFocusRef}
|
|
79
|
+
beforeElementRef={beforeElementRef}
|
|
80
|
+
afterElementRef={afterElementRef}
|
|
81
|
+
onClick={(event: MouseEvent) => {
|
|
82
|
+
event.stopPropagation()
|
|
83
|
+
}}
|
|
84
|
+
>
|
|
85
|
+
{children}
|
|
86
|
+
</ModalContentPure>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export default ModalContent
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import React, { HTMLAttributes } from 'react'
|
|
2
|
+
import { X } from '../../assets'
|
|
3
|
+
import IconButton, { IconButtonProps } from '../IconButton'
|
|
4
|
+
|
|
5
|
+
export interface ModalHeaderProps extends HTMLAttributes<HTMLDivElement> {
|
|
6
|
+
/**
|
|
7
|
+
* Title for header modal.
|
|
8
|
+
*/
|
|
9
|
+
title: string
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Description for header modal.
|
|
13
|
+
*/
|
|
14
|
+
description?: string
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Props for the Close Button component.
|
|
18
|
+
*/
|
|
19
|
+
closeButtonProps?: Partial<Omit<IconButtonProps, 'onClick'>>
|
|
20
|
+
|
|
21
|
+
onClose?: () => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const ModalHeader = ({
|
|
25
|
+
onClose,
|
|
26
|
+
title,
|
|
27
|
+
closeButtonProps = {},
|
|
28
|
+
description,
|
|
29
|
+
}: ModalHeaderProps) => {
|
|
30
|
+
return (
|
|
31
|
+
<header data-fs-modal-header>
|
|
32
|
+
{onClose && (
|
|
33
|
+
<IconButton
|
|
34
|
+
onClick={() => onClose?.()}
|
|
35
|
+
data-fs-modal-header-close-button
|
|
36
|
+
icon={<X />}
|
|
37
|
+
aria-label="Close modal"
|
|
38
|
+
{...closeButtonProps}
|
|
39
|
+
/>
|
|
40
|
+
)}
|
|
41
|
+
<p data-fs-modal-header-title>{title}</p>
|
|
42
|
+
{description && <p data-fs-modal-header-description>{description}</p>}
|
|
43
|
+
</header>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default ModalHeader
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import React, { useRef, useState, useImperativeHandle, forwardRef } from 'react'
|
|
2
|
+
import type { AriaAttributes } from 'react'
|
|
3
|
+
|
|
4
|
+
import { Price, Slider, InputField } from '../../index'
|
|
5
|
+
import type { PriceProps, SliderProps } from '../../index'
|
|
6
|
+
|
|
7
|
+
export type PriceRangeProps = Omit<SliderProps, 'absoluteValuesLabel'> & {
|
|
8
|
+
/**
|
|
9
|
+
* The current use case variant for prices.
|
|
10
|
+
*/
|
|
11
|
+
variant?: PriceProps['variant']
|
|
12
|
+
/**
|
|
13
|
+
* Formatter function that transforms the raw price value and render the result.
|
|
14
|
+
*/
|
|
15
|
+
formatter: PriceProps['formatter']
|
|
16
|
+
/**
|
|
17
|
+
* Defines a string value that labels the current element.
|
|
18
|
+
*/
|
|
19
|
+
'aria-label'?: AriaAttributes['aria-label']
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type PriceRangeRefType = {
|
|
23
|
+
setPriceRangeValues: (values: { min: number; max: number }) => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const PriceRange = forwardRef<PriceRangeRefType | undefined, PriceRangeProps>(
|
|
27
|
+
function PriceRange(
|
|
28
|
+
{
|
|
29
|
+
formatter,
|
|
30
|
+
max,
|
|
31
|
+
min,
|
|
32
|
+
step = 1,
|
|
33
|
+
onChange,
|
|
34
|
+
onEnd,
|
|
35
|
+
testId = 'fs-price-range',
|
|
36
|
+
variant,
|
|
37
|
+
'aria-label': ariaLabel,
|
|
38
|
+
...otherProps
|
|
39
|
+
},
|
|
40
|
+
ref
|
|
41
|
+
) {
|
|
42
|
+
const sliderRef = useRef<{
|
|
43
|
+
setSliderValues: (values: { min: number; max: number }) => void
|
|
44
|
+
}>()
|
|
45
|
+
|
|
46
|
+
useImperativeHandle(ref, () => ({
|
|
47
|
+
setPriceRangeValues: (values: { min: number; max: number }) => {
|
|
48
|
+
onChange?.(values)
|
|
49
|
+
sliderRef.current?.setSliderValues(values)
|
|
50
|
+
},
|
|
51
|
+
}))
|
|
52
|
+
|
|
53
|
+
const inputMinRef = useRef<HTMLInputElement>(null)
|
|
54
|
+
const inputMaxRef = useRef<HTMLInputElement>(null)
|
|
55
|
+
|
|
56
|
+
const [inputMinError, setInputMinError] = useState<string>()
|
|
57
|
+
const [inputMaxError, setInputMaxError] = useState<string>()
|
|
58
|
+
const [priceRange, setPriceRange] = useState<{ min: number; max: number }>({
|
|
59
|
+
min: Math.floor(min.selected),
|
|
60
|
+
max: Math.round(max.selected),
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
function onChangePriceRange(value: { min: number; max: number }) {
|
|
64
|
+
setInputMinError(undefined)
|
|
65
|
+
setInputMaxError(undefined)
|
|
66
|
+
setPriceRange({ min: value.min, max: value.max })
|
|
67
|
+
|
|
68
|
+
if (inputMinRef.current?.value) {
|
|
69
|
+
inputMinRef.current.value = String(value.min)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (inputMaxRef.current?.value) {
|
|
73
|
+
inputMaxRef.current.value = String(value.max)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function onChangeInputMin(value: string) {
|
|
78
|
+
setInputMinError(undefined)
|
|
79
|
+
|
|
80
|
+
if (Number(value) < Math.floor(min.absolute)) {
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (Number(value) > Math.floor(priceRange.max)) {
|
|
85
|
+
setInputMinError(`Min price can't be greater than max`)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
setPriceRange({ ...priceRange, min: Number(value) })
|
|
89
|
+
sliderRef.current?.setSliderValues({
|
|
90
|
+
...priceRange,
|
|
91
|
+
min: Number(value),
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function onChangeInputMax(value: string) {
|
|
96
|
+
setInputMaxError(undefined)
|
|
97
|
+
|
|
98
|
+
if (Number(value) > Math.round(max.absolute)) {
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (Number(value) < Math.round(priceRange.min)) {
|
|
103
|
+
setInputMaxError(`Max price can't be smaller than min`)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
setPriceRange({ ...priceRange, max: Number(value) })
|
|
107
|
+
sliderRef.current?.setSliderValues({
|
|
108
|
+
...priceRange,
|
|
109
|
+
max: Number(value),
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<div data-fs-price-range data-testid={testId} {...otherProps}>
|
|
115
|
+
<Slider
|
|
116
|
+
ref={sliderRef}
|
|
117
|
+
min={min}
|
|
118
|
+
max={max}
|
|
119
|
+
step={step}
|
|
120
|
+
onEnd={(value) => {
|
|
121
|
+
onEnd?.(value)
|
|
122
|
+
onChangePriceRange(value)
|
|
123
|
+
}}
|
|
124
|
+
aria-label={ariaLabel}
|
|
125
|
+
onChange={(value) => onChange?.(value)}
|
|
126
|
+
absoluteValuesLabel={{
|
|
127
|
+
min: (
|
|
128
|
+
<Price
|
|
129
|
+
value={Math.floor(min.absolute)}
|
|
130
|
+
variant={variant}
|
|
131
|
+
formatter={formatter}
|
|
132
|
+
/>
|
|
133
|
+
),
|
|
134
|
+
max: (
|
|
135
|
+
<Price
|
|
136
|
+
value={Math.round(max.absolute)}
|
|
137
|
+
variant={variant}
|
|
138
|
+
formatter={formatter}
|
|
139
|
+
/>
|
|
140
|
+
),
|
|
141
|
+
}}
|
|
142
|
+
minValueLabelComponent={(minValue) => {
|
|
143
|
+
return (
|
|
144
|
+
<Price value={minValue} variant={variant} formatter={formatter} />
|
|
145
|
+
)
|
|
146
|
+
}}
|
|
147
|
+
maxValueLabelComponent={(maxValue) => {
|
|
148
|
+
return (
|
|
149
|
+
<Price value={maxValue} variant={variant} formatter={formatter} />
|
|
150
|
+
)
|
|
151
|
+
}}
|
|
152
|
+
/>
|
|
153
|
+
<div data-fs-price-range-inputs>
|
|
154
|
+
<InputField
|
|
155
|
+
id="price-range-min"
|
|
156
|
+
step={step}
|
|
157
|
+
label="Min"
|
|
158
|
+
type="number"
|
|
159
|
+
inputMode="numeric"
|
|
160
|
+
error={inputMinError}
|
|
161
|
+
inputRef={inputMinRef}
|
|
162
|
+
min={Math.floor(min.absolute)}
|
|
163
|
+
max={priceRange.max}
|
|
164
|
+
value={priceRange.min}
|
|
165
|
+
onChange={(e) => onChangeInputMin(e.target.value)}
|
|
166
|
+
onBlur={() => !inputMinError && onEnd?.(priceRange)}
|
|
167
|
+
/>
|
|
168
|
+
<InputField
|
|
169
|
+
id="price-range-max"
|
|
170
|
+
label="Max"
|
|
171
|
+
step={step}
|
|
172
|
+
type="number"
|
|
173
|
+
inputMode="numeric"
|
|
174
|
+
error={inputMaxError}
|
|
175
|
+
inputRef={inputMaxRef}
|
|
176
|
+
max={Math.round(max.absolute)}
|
|
177
|
+
min={priceRange.min}
|
|
178
|
+
value={priceRange.max}
|
|
179
|
+
onChange={(e) => onChangeInputMax(e.target.value)}
|
|
180
|
+
onBlur={() => !inputMaxError && onEnd?.(priceRange)}
|
|
181
|
+
/>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
export default PriceRange
|