diff --git a/src/components/DateField/hooks/useDateFieldProps.ts b/src/components/DateField/hooks/useDateFieldProps.ts index 8642d7e..4f4bb6b 100644 --- a/src/components/DateField/hooks/useDateFieldProps.ts +++ b/src/components/DateField/hooks/useDateFieldProps.ts @@ -103,13 +103,15 @@ export function useDateFieldProps( if (state.selectedSectionIndexes !== null) { return; } - const input = inputRef.current; + const input = e.target; + const isAutofocus = !inputRef.current; setTimeout(() => { if (!input || input !== inputRef.current) { return; } - - if ( + if (isAutofocus) { + state.focusSectionInPosition(0); + } else if ( // avoid selecting all sections when focusing empty field without value input.value.length && Number(input.selectionEnd) - Number(input.selectionStart) === diff --git a/src/components/DateField/hooks/useDateFieldState.ts b/src/components/DateField/hooks/useDateFieldState.ts index 0fbb781..b239101 100644 --- a/src/components/DateField/hooks/useDateFieldState.ts +++ b/src/components/DateField/hooks/useDateFieldState.ts @@ -156,9 +156,7 @@ export function useDateFieldState(props: DateFieldStateOptions): DateFieldState : placeholderDate; const sectionsState = useSectionsState(sections, displayValue, validSegments); - const [selectedSections, setSelectedSections] = React.useState(() => { - return sectionsState.editableSections[0]?.previousEditableSection ?? -1; - }); + const [selectedSections, setSelectedSections] = React.useState(-1); const selectedSectionIndexes = React.useMemo<{ startIndex: number; diff --git a/src/components/DatePicker/DatePicker.scss b/src/components/DatePicker/DatePicker.scss index 5cb134b..d22ca87 100644 --- a/src/components/DatePicker/DatePicker.scss +++ b/src/components/DatePicker/DatePicker.scss @@ -7,6 +7,8 @@ $block: '.#{variables.$ns}date-picker'; display: inline-block; + outline: none; + &__field { width: 100%; @@ -15,6 +17,12 @@ $block: '.#{variables.$ns}date-picker'; } } + &__popup-anchor { + position: absolute; + z-index: -1; + inset: 0; + } + &__popup-content { outline: none; } diff --git a/src/components/DatePicker/DatePicker.tsx b/src/components/DatePicker/DatePicker.tsx index a0e5e67..ab22e10 100644 --- a/src/components/DatePicker/DatePicker.tsx +++ b/src/components/DatePicker/DatePicker.tsx @@ -1,9 +1,10 @@ import React from 'react'; -import {TextInput, useFocusWithin, useMobile} from '@gravity-ui/uikit'; +import {Calendar as CalendarIcon} from '@gravity-ui/icons'; +import {Button, Icon, Popup, TextInput, useMobile} from '@gravity-ui/uikit'; -import type {CalendarProps} from '../Calendar'; -import {useDateFieldProps, useDateFieldState} from '../DateField'; +import {Calendar, type CalendarProps} from '../Calendar'; +import {DateField} from '../DateField'; import type { AccessibilityProps, DateFieldBase, @@ -14,8 +15,8 @@ import type { TextInputProps, } from '../types'; -import {DesktopCalendar, DesktopCalendarButton} from './DesktopCalendar'; import {MobileCalendar, MobileCalendarIcon} from './MobileCalendar'; +import {useDatePickerProps} from './hooks/useDatePickerProps'; import {useDatePickerState} from './hooks/useDatePickerState'; import {b} from './utils'; @@ -32,16 +33,7 @@ export interface DatePickerProps children?: (props: CalendarProps) => React.ReactNode; } -export function DatePicker({ - value, - defaultValue, - onUpdate, - className, - onFocus, - onBlur, - children, - ...props -}: DatePickerProps) { +export function DatePicker({value, defaultValue, onUpdate, className, ...props}: DatePickerProps) { const anchorRef = React.useRef(null); const state = useDatePickerState({ @@ -51,70 +43,44 @@ export function DatePicker({ onUpdate, }); - const [isMobile] = useMobile(); - - const [isActive, setActive] = React.useState(false); - const {focusWithinProps} = useFocusWithin({ - onFocusWithin: onFocus, - onBlurWithin: onBlur, - onFocusWithinChange(isFocusWithin) { - setActive(isFocusWithin); - }, - }); + const {groupProps, fieldProps, calendarButtonProps, popupProps, calendarProps, timeInputProps} = + useDatePickerProps(state, props); - const fieldState = useDateFieldState({ - value: state.value, - onUpdate: state.setValue, - disabled: state.disabled, - readOnly: state.readOnly, - validationState: props.validationState, - minValue: props.minValue, - maxValue: props.maxValue, - isDateUnavailable: props.isDateUnavailable, - format: state.format, - placeholderValue: props.placeholderValue, - timeZone: props.timeZone, - }); - - const {inputProps} = useDateFieldProps(fieldState, props); + const [isMobile] = useMobile(); return ( - // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -
{ - if (e.altKey && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) { - e.preventDefault(); - e.stopPropagation(); - state.setOpen(true); - } - }} - > +
{isMobile ? ( ) : ( - +
+ +
+ {typeof props.children === 'function' ? ( + props.children(calendarProps) + ) : ( + + )} + {state.hasTime && ( +
+ +
+ )} +
+
+
)} ) : ( - + ) } /> diff --git a/src/components/DatePicker/DesktopCalendar.tsx b/src/components/DatePicker/DesktopCalendar.tsx deleted file mode 100644 index eb4dba0..0000000 --- a/src/components/DatePicker/DesktopCalendar.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import React from 'react'; - -import {Calendar as CalendarIcon} from '@gravity-ui/icons'; -import {Button, Icon, Popup, useFocusWithin} from '@gravity-ui/uikit'; - -import {Calendar, type CalendarProps} from '../Calendar'; -import {DateField} from '../DateField'; -import {getButtonSizeForInput} from '../utils/getButtonSizeForInput'; - -import type {DatePickerProps} from './DatePicker'; -import type {DatePickerState} from './hooks/useDatePickerState'; -import {i18n} from './i18n'; -import {b} from './utils'; - -interface DesktopCalendarProps { - anchorRef: React.RefObject; - props: DatePickerProps; - state: DatePickerState; - renderCalendar?: (props: CalendarProps) => React.ReactNode; -} -export function DesktopCalendar({anchorRef, props, state, renderCalendar}: DesktopCalendarProps) { - const {focusWithinProps} = useFocusWithin({ - isDisabled: !state.isOpen, - onBlurWithin: () => { - state.setOpen(false); - }, - }); - - if (!state.hasDate) { - return null; - } - - const calendarProps: CalendarProps = { - autoFocus: true, - size: props.size === 's' ? 'm' : props.size, - disabled: props.disabled, - readOnly: props.readOnly, - onUpdate: (d) => { - state.setDateValue(d); - }, - defaultFocusedValue: state.dateValue ?? undefined, - value: state.dateValue, - minValue: props.minValue, - maxValue: props.maxValue, - isDateUnavailable: props.isDateUnavailable, - timeZone: props.timeZone, - }; - - return ( - { - state.setOpen(false); - }} - restoreFocus - > -
- {typeof renderCalendar === 'function' ? ( - renderCalendar(calendarProps) - ) : ( - - )} - {state.hasTime && ( -
- -
- )} -
-
- ); -} - -interface DesktopCalendarButtonProps { - props: DatePickerProps; - state: DatePickerState; -} -export function DesktopCalendarButton({props, state}: DesktopCalendarButtonProps) { - const wasOpenBeforeClickRef = React.useRef(false); - if (!state.hasDate) { - return null; - } - - return ( - - ); -} diff --git a/src/components/DatePicker/hooks/useDatePickerProps.ts b/src/components/DatePicker/hooks/useDatePickerProps.ts new file mode 100644 index 0000000..71a1844 --- /dev/null +++ b/src/components/DatePicker/hooks/useDatePickerProps.ts @@ -0,0 +1,152 @@ +import React from 'react'; + +import {useFocusWithin, useForkRef} from '@gravity-ui/uikit'; +import type {ButtonProps, PopupProps, TextInputProps} from '@gravity-ui/uikit'; + +import type {Calendar, CalendarInstance} from '../../Calendar'; +import {useDateFieldProps} from '../../DateField'; +import type {DateFieldProps} from '../../DateField'; +import {getButtonSizeForInput} from '../../utils/getButtonSizeForInput'; +import {mergeProps} from '../../utils/mergeProps'; +import type {DatePickerProps} from '../DatePicker'; +import {i18n} from '../i18n'; + +import type {DatePickerState} from './useDatePickerState'; + +interface InnerRelativeDatePickerProps { + groupProps: React.HTMLAttributes & {ref: React.Ref}; + fieldProps: TextInputProps; + calendarButtonProps: ButtonProps & {ref: React.Ref}; + popupProps: PopupProps; + calendarProps: React.ComponentProps; + timeInputProps: DateFieldProps; +} + +export function useDatePickerProps( + state: DatePickerState, + {onFocus, onBlur, ...props}: DatePickerProps, +): InnerRelativeDatePickerProps { + const [isActive, setActive] = React.useState(false); + + const {focusWithinProps} = useFocusWithin({ + onFocusWithin: onFocus, + onBlurWithin: onBlur, + onFocusWithinChange(isFocusWithin) { + setActive(isFocusWithin); + if (!isFocusWithin) { + state.setOpen(false); + } + }, + }); + + const {inputProps} = useDateFieldProps(state.dateFieldState, props); + + let error: string | boolean | undefined; + let validationState = props.validationState; + if (validationState) { + error = validationState === 'invalid' ? (props.errorMessage as string) || true : undefined; + } else { + validationState = state.dateFieldState.validationState; + error = validationState === 'invalid'; + } + + const inputRef = React.useRef(null); + + const handleRef = useForkRef(inputRef, inputProps.controlRef); + + const calendarRef = React.useRef(null); + const calendarButtonRef = React.useRef(null); + const groupRef = React.useRef(null); + + function focusInput() { + setTimeout(() => { + inputRef.current?.focus(); + }); + } + + return { + groupProps: { + ref: groupRef, + tabIndex: -1, + role: 'group', + ...focusWithinProps, + style: props.style, + 'aria-disabled': state.disabled || undefined, + onKeyDown: (e) => { + if (e.altKey && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) { + e.preventDefault(); + e.stopPropagation(); + state.setOpen(true); + } + }, + }, + fieldProps: mergeProps( + inputProps, + state.dateFieldState.isEmpty && !isActive && props.placeholder + ? {value: ''} + : undefined, + {controlRef: handleRef, error}, + ), + calendarButtonProps: { + ref: calendarButtonRef, + size: getButtonSizeForInput(props.size), + disabled: state.disabled, + extraProps: { + 'aria-label': i18n('Calendar'), + 'aria-haspopup': 'dialog', + 'aria-expanded': state.isOpen, + }, + view: 'flat-secondary', + onClick: () => { + setActive(true); + state.setOpen(!state.isOpen); + }, + }, + popupProps: { + open: state.isOpen, + onEscapeKeyDown: () => { + state.setOpen(false); + focusInput(); + }, + onOutsideClick: (e) => { + if (e.target !== calendarButtonRef.current) { + state.setOpen(false); + } + if (e.target && groupRef.current?.contains(e.target as Node)) { + focusInput(); + } + }, + // @ts-expect-error focusTrap in popup was introduced in a newer version of uikit + focusTrap: true, + }, + calendarProps: { + ref: calendarRef, + autoFocus: true, + size: props.size === 's' ? 'm' : props.size, + disabled: props.disabled, + readOnly: props.readOnly, + onUpdate: (d) => { + state.setDateValue(d); + if (!state.hasTime) { + focusInput(); + } + }, + defaultFocusedValue: state.dateValue ?? undefined, + value: state.dateValue, + minValue: props.minValue, + maxValue: props.maxValue, + isDateUnavailable: props.isDateUnavailable, + timeZone: props.timeZone, + }, + timeInputProps: { + value: state.timeValue, + onUpdate: state.setTimeValue, + format: state.timeFormat, + readOnly: state.readOnly, + disabled: state.disabled, + timeZone: props.timeZone, + hasClear: props.hasClear, + size: props.size, + }, + }; +} diff --git a/src/components/DatePicker/hooks/useDatePickerState.ts b/src/components/DatePicker/hooks/useDatePickerState.ts index c065766..f5a8b4d 100644 --- a/src/components/DatePicker/hooks/useDatePickerState.ts +++ b/src/components/DatePicker/hooks/useDatePickerState.ts @@ -2,9 +2,11 @@ import React from 'react'; import type {DateTime} from '@gravity-ui/date-utils'; +import {useDateFieldState} from '../../DateField'; +import type {DateFieldState} from '../../DateField'; import {splitFormatIntoSections} from '../../DateField/utils'; import {useControlledState} from '../../hooks/useControlledState'; -import type {InputBase, ValueBase} from '../../types'; +import type {DateFieldBase} from '../../types'; import {createPlaceholderValue, mergeDateTime} from '../../utils/dates'; export type Granularity = 'day' | 'hour' | 'minute' | 'second'; @@ -43,13 +45,10 @@ export interface DatePickerState { isOpen: boolean; /** Sets whether the calendar popover is open. */ setOpen(isOpen: boolean): void; + dateFieldState: DateFieldState; } -export interface DatePickerStateOptions extends ValueBase, InputBase { - placeholderValue?: DateTime; - timeZone?: string; - format?: string; -} +export interface DatePickerStateOptions extends DateFieldBase {} export function useDatePickerState(props: DatePickerStateOptions): DatePickerState { const {disabled, readOnly} = props; @@ -135,6 +134,21 @@ export function useDatePickerState(props: DatePickerStateOptions): DatePickerSta setSelectedTime(newValue); } }; + + const dateFieldState = useDateFieldState({ + value: value ?? null, + onUpdate: setValue, + disabled, + readOnly, + validationState: props.validationState, + minValue: props.minValue, + maxValue: props.maxValue, + isDateUnavailable: props.isDateUnavailable, + format, + placeholderValue: props.placeholderValue, + timeZone: props.timeZone, + }); + return { value: value ?? null, setValue, @@ -159,6 +173,7 @@ export function useDatePickerState(props: DatePickerStateOptions): DatePickerSta setOpen(newIsOpen); }, + dateFieldState, }; } diff --git a/src/components/DatePicker/index.ts b/src/components/DatePicker/index.ts index f02708d..6722436 100644 --- a/src/components/DatePicker/index.ts +++ b/src/components/DatePicker/index.ts @@ -1,3 +1,4 @@ export * from './DatePicker'; export * from './hooks/useDatePickerState'; +export * from './hooks/useDatePickerProps'; diff --git a/src/components/RelativeDatePicker/RelativeDatePicker.scss b/src/components/RelativeDatePicker/RelativeDatePicker.scss index 5af49d4..d8d8cd7 100644 --- a/src/components/RelativeDatePicker/RelativeDatePicker.scss +++ b/src/components/RelativeDatePicker/RelativeDatePicker.scss @@ -7,6 +7,8 @@ $block: '.#{variables.$ns}relative-date-picker'; display: inline-flex; + outline: none; + &__field { width: 100%; } diff --git a/src/components/RelativeDatePicker/hooks/useRelativeDatePickerProps.ts b/src/components/RelativeDatePicker/hooks/useRelativeDatePickerProps.ts index c6a9a5e..c9a9ec2 100644 --- a/src/components/RelativeDatePicker/hooks/useRelativeDatePickerProps.ts +++ b/src/components/RelativeDatePicker/hooks/useRelativeDatePickerProps.ts @@ -49,7 +49,7 @@ export function useRelativeDatePickerProps( onBlurWithin: onBlur, onFocusWithinChange(isFocusWithin) { if (!isFocusWithin) { - state.setActive(isFocusWithin); + state.setActive(false); } }, }); @@ -59,17 +59,12 @@ export function useRelativeDatePickerProps( setOpen(false); } - const [prevActive, setPrevActive] = React.useState(state.isActive); - if (prevActive !== state.isActive) { - setPrevActive(state.isActive); - if (state.isActive && !isOpen) { - setOpen(true); - } - } - const commonInputProps: TextInputProps = { onFocus: () => { - state.setActive(true); + if (!state.isActive) { + state.setActive(true); + setOpen(true); + } }, }; @@ -85,6 +80,7 @@ export function useRelativeDatePickerProps( value: relativeDateState.text, onUpdate: relativeDateState.setText, hasClear: props.hasClear && !relativeDateState.readOnly, + placeholder: props.placeholder, size: props.size, }; @@ -100,7 +96,6 @@ export function useRelativeDatePickerProps( error = validationState === 'invalid'; } - const wasActiveBeforeClickRef = React.useRef(state.isActive); const inputRef = React.useRef(null); const handleRef = useForkRef( @@ -110,8 +105,21 @@ export function useRelativeDatePickerProps( const calendarRef = React.useRef(null); + function focusCalendar() { + setTimeout(() => { + calendarRef.current?.focus(); + }); + } + + function focusInput() { + setTimeout(() => { + inputRef.current?.focus(); + }); + } + return { groupProps: { + tabIndex: -1, role: 'group', ...focusWithinProps, onKeyDown: (e) => { @@ -119,12 +127,16 @@ export function useRelativeDatePickerProps( e.preventDefault(); e.stopPropagation(); setOpen(true); + focusCalendar(); } }, }, fieldProps: mergeProps( commonInputProps, mode === 'relative' ? relativeDateProps : inputProps, + mode === 'absolute' && dateFieldState.isEmpty && !state.isActive && props.placeholder + ? {value: ''} + : undefined, {controlRef: handleRef, error}, ), modeSwitcherProps: { @@ -146,7 +158,7 @@ export function useRelativeDatePickerProps( } else if (relativeDateState.parsedDate) { setFocusedDate(relativeDateState.parsedDate); } - setTimeout(() => inputRef.current?.focus()); + focusInput(); }, }, calendarButtonProps: { @@ -158,28 +170,20 @@ export function useRelativeDatePickerProps( 'aria-expanded': isOpen, }, view: 'flat-secondary', - onFocus: () => { - wasActiveBeforeClickRef.current = state.isActive; - }, onClick: () => { state.setActive(true); - if (wasActiveBeforeClickRef.current) { - setOpen(!isOpen); - if (!isOpen) { - setTimeout(() => { - calendarRef.current?.focus(); - }); - } + setOpen(!isOpen); + if (!isOpen) { + focusCalendar(); } - wasActiveBeforeClickRef.current = state.isActive; }, }, popupProps: { open: isOpen, onEscapeKeyDown: () => { setOpen(false); + focusInput(); }, - restoreFocus: true, }, calendarProps: { ref: calendarRef, @@ -190,7 +194,7 @@ export function useRelativeDatePickerProps( datePickerState.setDateValue(v); if (!state.datePickerState.hasTime) { setOpen(false); - setTimeout(() => inputRef.current?.focus()); + focusInput(); } }, focusedValue: focusedDate,