@vtex/admin-ui
Version:
> VTEX admin component library
266 lines (231 loc) • 7.13 kB
text/typescript
import { useState, useCallback, useMemo } from 'react'
import { createComponent, useElement } from '@vtex/admin-ui-react'
import { CompositeItem } from 'reakit/Composite'
import { unstable_useId as useId } from 'reakit/Id'
import { callAllHandlers } from '@vtex/admin-ui-util'
import { useSpinButton } from '@react-aria/spinbutton'
import { mergeProps } from '@react-aria/utils'
import { useDateFormatter } from '../i18n'
import { isNumeric, parseNumber } from './util'
import type { DateSegment, SegmentStateReturn } from './segment.state'
import * as style from './segment.style'
export const Segment = createComponent<typeof CompositeItem, SegmentOptions>(
(props) => {
const {
isDisabled,
isReadOnly,
isRequired,
segment,
state,
onMouseDown: htmlOnMouseDown,
onKeyDown: htmlOnKeyDown,
onFocus: htmlOnFocus,
...htmlProps
} = props
const {
next,
dateFormatter,
increment,
decrement,
incrementPage,
decrementPage,
setSegment,
} = state
const disabled = useMemo(
() => isDisabled || isReadOnly || segment.type === 'literal',
[isDisabled, isReadOnly, segment.type]
)
const { id } = useId({ baseId: 'datepicker-segment' })
const [enteredKeys, setEnteredKeys] = useState('')
const monthFormatter = useDateFormatter({ month: 'long' })
const hourFormatter = useDateFormatter({
hour: 'numeric',
hour12: dateFormatter.resolvedOptions().hour12,
})
const { spinButtonProps } = useSpinButton({
value: segment.value,
textValue: getTextValue(segment, state, {
month: monthFormatter,
hour: hourFormatter,
}),
minValue: segment.minValue,
maxValue: segment.maxValue,
isDisabled,
isReadOnly,
isRequired,
onIncrement: () => increment(segment.type),
onDecrement: () => decrement(segment.type),
onIncrementPage: () => incrementPage(segment.type),
onDecrementPage: () => decrementPage(segment.type),
onIncrementToMax: () =>
setSegment(segment.type, segment.maxValue as number),
onDecrementToMin: () =>
setSegment(segment.type, segment.minValue as number),
})
const onNumericKeyDown = useCallback(
(key: string) => {
const newValue = enteredKeys + key
if (segment.type === 'dayPeriod') {
if (key === 'a') {
state.setSegment('dayPeriod', 0)
} else if (key === 'p') {
state.setSegment('dayPeriod', 12)
}
state.next()
} else {
if (!isNumeric(newValue)) {
return
}
const numberValue = parseNumber(newValue)
let segmentValue = numberValue
if (
segment.type === 'hour' &&
state.dateFormatter.resolvedOptions().hour12 &&
numberValue === 12
) {
segmentValue = 0
} else if (numberValue > (segment.maxValue as number)) {
segmentValue = parseNumber(key)
}
state.setSegment(segment.type, segmentValue)
if (Number(`${numberValue}0`) > (segment.maxValue as number)) {
setEnteredKeys('')
state.next()
} else {
setEnteredKeys(newValue)
}
}
},
[enteredKeys, next, segment.maxValue, segment.type]
)
const onKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) {
return
}
switch (e.key) {
case 'Enter':
e.preventDefault()
next()
break
case 'Tab':
break
case 'Backspace': {
e.preventDefault()
if (isNumeric(segment.text) && !isReadOnly) {
const newValue = segment.text.slice(0, -1)
setSegment(
segment.type,
newValue.length === 0
? (segment.minValue as number)
: parseNumber(newValue)
)
setEnteredKeys(newValue)
}
break
}
default:
e.preventDefault()
e.stopPropagation()
if ((isNumeric(e.key) || /^[ap]$/.test(e.key)) && !isReadOnly) {
// TODO: fix typing experience
// onNumericKeyDown(e.key)
}
}
},
[next, onNumericKeyDown, segment.minValue, segment.text, segment.type]
)
const onFocus = useCallback(() => {
setEnteredKeys('')
}, [])
const onMouseDown = useCallback(
(e: React.MouseEvent) => e.stopPropagation(),
[]
)
const elementProps = useMemo(() => {
const baseProps = {
state,
disabled,
tabIndex: disabled ? -1 : 0,
children: segment.text,
...htmlProps,
}
switch (segment.type) {
case 'literal':
return {
...baseProps,
baseStyle: {
...style.segment,
...style.segmentVariants({
literal: true,
}),
},
}
case 'era':
return {
...baseProps,
baseStyle: {
...style.segment,
...style.segmentVariants({
literal: false,
}),
},
}
default:
return mergeProps(spinButtonProps, {
...baseProps,
'aria-label': segment.type,
onKeyDown: callAllHandlers(htmlOnKeyDown, onKeyDown),
onFocus: callAllHandlers(htmlOnFocus, onFocus),
onMouseDown: callAllHandlers(htmlOnMouseDown, onMouseDown),
children: getTextValue(segment, state, {
month: monthFormatter,
hour: hourFormatter,
}),
baseStyle: {
...style.segment,
...style.segmentVariants({
literal: false,
}),
},
})
}
}, [segment, state, disabled])
return useElement(CompositeItem, {
...elementProps,
id,
'aria-labelledby': id,
})
}
)
function getTextValue(
segment: DateSegment,
state: SegmentStateReturn,
formatters: {
hour: ReturnType<typeof useDateFormatter>
month: ReturnType<typeof useDateFormatter>
}
) {
switch (segment.type) {
case 'hour': {
const hourFormattedValue = formatters.hour.format(state.fieldValue)
return hourFormattedValue.split(' ')[0]
}
case 'dayPeriod': {
const hourFormattedValue = formatters.hour.format(state.fieldValue)
return hourFormattedValue.split(' ')[1]
}
default: {
const displayPlaceholder =
state.showPlaceholder.current[segment.type] ?? false
return displayPlaceholder ? segment.placeholder : segment.text
}
}
}
export interface SegmentOptions {
state: SegmentStateReturn
segment: DateSegment
isDisabled?: boolean
isReadOnly?: boolean
isRequired?: boolean
}