diff --git a/package.json b/package.json index 446e545a..7ec34ea1 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,16 @@ "types": "./dist/cjs/index.d.ts", "default": "./dist/cjs/index.js" } + }, + "./uikit": { + "import": { + "types": "./dist/esm/uikit.d.ts", + "default": "./dist/esm/uikit.js" + }, + "require": { + "types": "./dist/cjs/uikit.d.ts", + "default": "./dist/cjs/uikit.js" + } } }, "sideEffects": [ diff --git a/src/components/Calendar/Calendar.tsx b/src/components/Calendar/Calendar.tsx index 1c800cc3..86038094 100644 --- a/src/components/Calendar/Calendar.tsx +++ b/src/components/Calendar/Calendar.tsx @@ -4,31 +4,51 @@ import React from 'react'; import type {DateTime} from '@gravity-ui/date-utils'; -import {CalendarView} from '../CalendarView/CalendarView'; -import type {CalendarInstance, CalendarSize} from '../CalendarView/CalendarView'; +import {Provider, useContextProps} from '../../utils/providers'; +import type {RenderProps, SlotProps} from '../../utils/providers'; +import {CalendarContext, CalendarStateContext, CalendarView} from '../CalendarView/CalendarView'; +import type {CalendarSize} from '../CalendarView/CalendarView'; import {useCalendarState} from '../CalendarView/hooks/useCalendarState'; -import type {CalendarStateOptions} from '../CalendarView/hooks/useCalendarState'; -import type {AccessibilityProps, DomProps, FocusEvents, StyleProps} from '../types'; +import type {CalendarState, CalendarStateOptions} from '../CalendarView/hooks/useCalendarState'; +import type {AccessibilityProps, DomProps, FocusEvents} from '../types'; import '../CalendarView/Calendar.scss'; -export interface CalendarProps +export interface CalendarRenderProps { + /** + * State of the calendar. + */ + state: CalendarState; +} + +export interface CalendarProps extends CalendarStateOptions, + RenderProps, DomProps, - StyleProps, FocusEvents, - AccessibilityProps { + AccessibilityProps, + SlotProps { /** * The size of the element. * @default m */ size?: CalendarSize; } -export const Calendar = React.forwardRef(function Calendar( +export const Calendar = React.forwardRef(function Calendar( props: CalendarProps, - ref, + forwardedRef, ) { - const state = useCalendarState(props); + const [mergedProps, ref] = useContextProps(props, forwardedRef, CalendarContext); + const state = useCalendarState(mergedProps as any); - return ; + return ( + + + + ); }); diff --git a/src/components/Calendar/README.md b/src/components/Calendar/README.md index f4a831ac..6427acb2 100644 --- a/src/components/Calendar/README.md +++ b/src/components/Calendar/README.md @@ -10,7 +10,7 @@ import {Calendar} from '@gravity-ui/date-components'; `Calendar` is a flexible, user-friendly calendar component for React applications. It allows users to view, select, and manage dates with ease. Ideal for event scheduling, booking systems, and any application where date selection is essential. It can be controlled if you set `value` property. Or it can be uncontrolled if you don't set any value, but in this case you can manage the initial state with optional property `defaultValue`. Component is uncontrolled by default. -### Useful addition +## Useful addition To set dates in the right format you may need to include additional helpers from [Date Utils library](https://gravity-ui.com/libraries/date-utils) @@ -174,6 +174,49 @@ LANDING_BLOCK--> `timeZone` is the property to set the time zone of the value in the input. [Learn more about time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List) +## Customization + +A calendar consists of a grouping element containing date / month / quarter / year grid, a previous and next button for navigating between date ranges, heading for displaying current date range and layout switch button for switching layout mode e.g. days / months / quarters / years. Calendar grid consists of cells containing button elements that can be pressed and navigated to using the arrow keys to select a date. + +```jsx +import {Flex} from '@gravity-ui/uikit'; +import {Button, Text} from '@gravity-ui/date-components/uikit'; +import { + Calendar, + CalendarGrid, + CalendarGridHeader, + CalendarGridHeaderCells, + CalendarGridHeaderCell, + CalendarGridBody, + CalendarGridRow, + CalendarGridRowHeader, + CalendarGridRowCells, + CalendarGridRowCell, +} from '@gravity-ui/date-components'; + + + + -
- - -
- - +
+ +

{calendarProps['aria-label']}

+ {renderProps.children} +
); }); -interface CalendarGridProps { - state: CalendarState | RangeCalendarState; +function CalendarLayoutSwitcher(_props: {}, ref: React.ForwardedRef) { + const state = useGuardedContext(CalendarStateContext); + return ( + + ); +} + +interface CalendarGridProps extends StyleProps { + disableAnimation?: boolean; + children?: React.ReactElement | React.ReactElement[] | ((date: DateTime) => React.ReactElement); } -function CalendarGrid({state}: CalendarGridProps) { - const [prevState, setPrevState] = React.useState(() => ({...state, isFocused: false})); +function CalendarGrid(props: CalendarGridProps, ref: React.ForwardedRef) { + const state = useGuardedContext(CalendarStateContext); + const [prevState, setPrevState] = React.useState(() => ({ + ...state, + isFocused: false, + })); + + const {gridProps} = useCalendarGridProps(state); + const children = props.children ?? ((date) => ); + + if (props.disableAnimation) { + return ( +
+ {children} +
+ ); + } const modeChanged = state.mode !== prevState.mode; const startDateChanged = !state.startDate.isSame(prevState.startDate, 'days'); @@ -117,101 +229,245 @@ function CalendarGrid({state}: CalendarGridProps) { } } - const {gridProps} = useCalendarGridProps(state); - return ( -
+
{animation && ( - + + + {children} + + )} { setPrevState({...state, isFocused: false}); }} - /> + > + {children} +
); } -interface ContentProps { - className?: string; +interface ContentProps extends StyleProps { + children?: React.ReactElement | React.ReactElement[] | ((date: DateTime) => React.ReactElement); animation?: string; onAnimationEnd?: () => void; - state: CalendarState | RangeCalendarState; } -function Content({className, state, animation, onAnimationEnd}: ContentProps) { +function Content({className, animation, onAnimationEnd, children}: ContentProps) { return (
- {state.mode === 'days' && } - + {typeof children === 'function' ? ( + + + {(date) => } + + {children} + + ) : ( + children + )}
); } -interface WeekdaysProps { - state: CalendarState | RangeCalendarState; +interface CalendarGridHeaderProps extends StyleProps { + children?: React.ReactElement | React.ReactElement[] | ((day: DateTime) => React.ReactElement); } -function Weekdays({state}: WeekdaysProps) { +function CalendarGridHeader( + {children, className, style}: CalendarGridHeaderProps, + ref: React.ForwardedRef, +) { + const state = useGuardedContext(CalendarStateContext); + if (state.mode !== 'days') { + return null; + } + return ( +
+ {typeof children === 'function' ? ( + {children} + ) : ( + children + )} +
+ ); +} + +export function CalendarGridHeaderCells({ + children, +}: { + children: (day: DateTime) => React.ReactElement; +}) { + const state = useGuardedContext(CalendarStateContext); const weekdays = getWeekDays(state); + return weekdays.map((date) => React.cloneElement(children(date), {key: date.unix()})); +} +interface CalendarGridHeaderCellRenderProps { + date: DateTime; +} + +interface CalendarGridHeaderCellProps extends RenderProps { + date: DateTime; +} +function CalendarGridHeaderCell( + {date, ...props}: CalendarGridHeaderCellProps, + ref: React.ForwardedRef, +) { + const state = useGuardedContext(CalendarStateContext); + const renderProps = useRenderProps({ + ...props, + values: { + date, + }, + defaultChildren: formatDateTime(date, 'dd', state.timeZone), + defaultClassName: b('weekday', {weekend: state.isWeekend(date)}), + }); return ( -
- {weekdays.map((date) => { - return ( -
- {formatDateTime(date, 'dd', state.timeZone)} -
- ); - })} -
+
); } -interface CalendarGridProps { - state: CalendarState | RangeCalendarState; +const calendarGridRowContext = React.createContext<{days: DateTime[]} | null | undefined>(null); +interface CalendarGridCellsProps extends StyleProps { + children: React.ReactNode | ((date: DateTime) => React.ReactElement); } -function CalendarGridCells({state}: CalendarGridProps) { +function CalendarGridCells( + {className, style, children}: CalendarGridCellsProps, + ref: React.ForwardedRef, +) { + const state = useGuardedContext(CalendarStateContext); const rowsInPeriod = state.mode === 'days' ? 6 : 4; const columnsInRow = state.mode === 'days' ? 7 : 3 + (state.mode === 'quarters' ? 1 : 0); const days = getDaysInPeriod(state); return ( -
+
{[...new Array(rowsInPeriod).keys()].map((rowIndex) => ( -
- {state.mode === 'quarters' ? ( - - {formatDateTime(days[rowIndex * columnsInRow], 'YYYY', state.timeZone)} - - ) : null} - {days - .slice(rowIndex * columnsInRow, (rowIndex + 1) * columnsInRow) - .map((date) => { - return ; - })} -
+ + {children} + ))}
); } -interface CalendarCellProps { +interface CalendarGridRowProps { + children: React.ReactNode | ((date: DateTime) => React.ReactElement); +} +function CalendarGridRow( + {children}: CalendarGridRowProps, + ref: React.ForwardedRef, +) { + const state = useGuardedContext(CalendarStateContext); + return ( +
+ {typeof children === 'function' ? ( + + + {({days}) => { + if (state.mode !== 'quarters') { + return null; + } + return formatDateTime(days[0], 'YYYY', state.timeZone); + }} + + {children} + + ) : ( + children + )} +
+ ); +} + +interface CalendarGridRowCellsProps { + children: (date: DateTime) => React.ReactElement; +} + +export function CalendarGridRowCells({children}: CalendarGridRowCellsProps) { + const {days} = useGuardedContext(calendarGridRowContext); + return days.map((date) => { + return React.cloneElement(children(date), {key: date.unix()}); + }); +} + +interface CalendarGridRowHeaderProps extends StyleProps { + children: (props: {days: DateTime[]}) => React.ReactNode; +} +function CalendarGridRowHeader( + {children, className, style}: CalendarGridRowHeaderProps, + ref: React.ForwardedRef, +) { + const {days} = useGuardedContext(calendarGridRowContext); + const content = children({days}); + if (!content) { + return null; + } + return ( +
+ {content} +
+ ); +} + +interface CalendarCellRenderProps { date: DateTime; - state: CalendarState | RangeCalendarState; + mode: CalendarLayout; + formattedDate: string; + isDisabled: boolean; + isSelected: boolean; + isFocused: boolean; + isRangeSelection: boolean; + isSelectionStart: boolean; + isSelectionEnd: boolean; + isOutsideCurrentRange: boolean; + isUnavailable: boolean; + isCurrent: boolean; + isWeekend: boolean; } -function CalendarCell({date, state}: CalendarCellProps) { +interface CalendarCellProps extends RenderProps { + date: DateTime; +} +function CalendarCell( + {date, ...props}: CalendarCellProps, + ref: React.ForwardedRef, +) { + const state = useGuardedContext(CalendarStateContext); const { cellProps, buttonProps, @@ -225,26 +481,44 @@ function CalendarCell({date, state}: CalendarCellProps) { isUnavailable, isCurrent, isWeekend, + isFocused, } = useCalendarCellProps(date, state); + const defaultClassName = b('button', { + disabled: isDisabled, + selected: isSelected, + 'range-selection': isRangeSelection, + 'selection-start': isSelectionStart, + 'selection-end': isSelectionEnd, + 'out-of-boundary': isOutsideCurrentRange, + unavailable: isUnavailable, + current: isCurrent, + weekend: isWeekend, + }); + const renderProps = useRenderProps({ + ...props, + defaultChildren: formattedDate, + defaultClassName, + values: { + date, + mode: state.mode, + formattedDate, + isDisabled, + isSelected, + isRangeSelection, + isSelectionStart, + isSelectionEnd, + isOutsideCurrentRange, + isUnavailable, + isCurrent, + isWeekend, + isFocused, + }, + }); + return ( -
-
- {formattedDate} -
+
+
); } diff --git a/src/components/CalendarView/hooks/useCalendarCellProps.ts b/src/components/CalendarView/hooks/useCalendarCellProps.ts index 64ebf4ee..bea205db 100644 --- a/src/components/CalendarView/hooks/useCalendarCellProps.ts +++ b/src/components/CalendarView/hooks/useCalendarCellProps.ts @@ -21,10 +21,12 @@ export function useCalendarCellProps(date: DateTime, state: CalendarState | Rang const isSelected = state.isSelected(date); const highlightedRange = 'highlightedRange' in state && state.highlightedRange; const isRangeSelection = Boolean(highlightedRange && isSelected); - const isSelectionStart = - isSelected && highlightedRange && date.isSame(highlightedRange.start, state.mode); - const isSelectionEnd = - isSelected && highlightedRange && date.isSame(highlightedRange.end, state.mode); + const isSelectionStart = Boolean( + isSelected && highlightedRange && date.isSame(highlightedRange.start, state.mode), + ); + const isSelectionEnd = Boolean( + isSelected && highlightedRange && date.isSame(highlightedRange.end, state.mode), + ); const isOutsideCurrentRange = state.mode === 'days' ? !state.focusedDate.isSame(date, 'month') : false; const isUnavailable = state.isCellUnavailable(date); @@ -81,6 +83,7 @@ export function useCalendarCellProps(date: DateTime, state: CalendarState | Rang formattedDate, isDisabled, isSelected, + isFocused, isRangeSelection, isSelectionStart, isSelectionEnd, diff --git a/src/components/CalendarView/hooks/useCalendarProps.ts b/src/components/CalendarView/hooks/useCalendarProps.tsx similarity index 72% rename from src/components/CalendarView/hooks/useCalendarProps.ts rename to src/components/CalendarView/hooks/useCalendarProps.tsx index b9e3720c..fe27e196 100644 --- a/src/components/CalendarView/hooks/useCalendarProps.ts +++ b/src/components/CalendarView/hooks/useCalendarProps.tsx @@ -1,15 +1,19 @@ import React from 'react'; -import {useFocusWithin} from '@gravity-ui/uikit'; -import type {ButtonButtonProps} from '@gravity-ui/uikit'; +import {ChevronLeft, ChevronRight} from '@gravity-ui/icons'; +import {ButtonIcon, useFocusWithin} from '@gravity-ui/uikit'; +import {block} from '../../../utils/cn'; import type {CalendarProps} from '../../Calendar/Calendar'; +import type {ButtonProps} from '../../common/Button'; import {formatDateTime} from '../../utils/dates'; +import {mergeProps} from '../../utils/mergeProps'; import {i18n} from '../i18n'; import type {CalendarLayout, CalendarState, RangeCalendarState} from './types'; const buttonDisabledClassName = 'yc-button_disabled g-button_disabled'; +const b = block('calendar'); // eslint-disable-next-line complexity export function useCalendarProps(props: CalendarProps, state: CalendarState | RangeCalendarState) { @@ -27,23 +31,36 @@ export function useCalendarProps(props: CalendarProps, state: CalendarState | Ra onBlurWithin: props.onBlur, }); - const calendarProps: React.HTMLAttributes = { - role: 'group', - id: props.id, - 'aria-label': [props['aria-label'], title].filter(Boolean).join(', '), - 'aria-labelledby': props['aria-labelledby'] || undefined, - 'aria-describedby': props['aria-describedby'] || undefined, - 'aria-details': props['aria-details'] || undefined, - 'aria-disabled': state.disabled || undefined, - ...focusWithinProps, - }; + const calendarProps: React.HTMLAttributes = mergeProps( + { + role: 'application', + id: props.id, + 'aria-label': [props['aria-label'], title].filter(Boolean).join(', '), + 'aria-labelledby': props['aria-labelledby'] || undefined, + 'aria-describedby': props['aria-describedby'] || undefined, + 'aria-details': props['aria-details'] || undefined, + 'aria-disabled': state.disabled || undefined, + tabIndex: -1, + onFocus: (event) => { + if (state.disabled) { + return; + } + if (event.target === event.currentTarget) { + state.setFocused(true); + } + }, + } satisfies React.HTMLAttributes, + focusWithinProps, + ); const modeIndex = state.availableModes.indexOf(state.mode); const isModeLast = modeIndex + 1 === state.availableModes.length; const isNextModeLast = modeIndex + 2 === state.availableModes.length; const modeDisabled = state.disabled || isModeLast; - const modeButtonProps: ButtonButtonProps = { + const modeButtonProps: ButtonProps = { + view: 'flat', + size: props.size, disabled: state.disabled, // FIXME: do not use button class name className: modeDisabled ? buttonDisabledClassName : undefined, @@ -71,7 +88,9 @@ export function useCalendarProps(props: CalendarProps, state: CalendarState | Ra } }); - const previousButtonProps: ButtonButtonProps = { + const previousButtonProps: ButtonProps = { + view: 'flat', + size: props.size, disabled: state.disabled, // FIXME: do not use button class name className: previousDisabled ? buttonDisabledClassName : undefined, @@ -92,6 +111,11 @@ export function useCalendarProps(props: CalendarProps, state: CalendarState | Ra }, 'aria-label': i18n('Previous'), 'aria-disabled': previousDisabled ? 'true' : undefined, + children: ( + + + + ), }; const nextFocused = React.useRef(false); @@ -104,7 +128,9 @@ export function useCalendarProps(props: CalendarProps, state: CalendarState | Ra } }); - const nextButtonProps: ButtonButtonProps = { + const nextButtonProps: ButtonProps = { + view: 'flat', + size: props.size, disabled: state.disabled, // FIXME: do not use button class name className: nextDisabled ? buttonDisabledClassName : undefined, @@ -125,6 +151,11 @@ export function useCalendarProps(props: CalendarProps, state: CalendarState | Ra }, 'aria-label': i18n('Next'), 'aria-disabled': previousDisabled ? 'true' : undefined, + children: ( + + + + ), }; return { diff --git a/src/components/DatePicker/DatePicker.tsx b/src/components/DatePicker/DatePicker.tsx index 51e34b79..9b32fd49 100644 --- a/src/components/DatePicker/DatePicker.tsx +++ b/src/components/DatePicker/DatePicker.tsx @@ -7,7 +7,7 @@ import {Calendar as CalendarIcon, Clock as ClockIcon} from '@gravity-ui/icons'; import {Button, Icon, Popup, TextInput, useMobile} from '@gravity-ui/uikit'; import {Calendar} from '../Calendar'; -import type {CalendarProps} from '../Calendar'; +import type {CalendarProps, CalendarRenderProps} from '../Calendar'; import {DateField} from '../DateField'; import {HiddenInput} from '../HiddenInput/HiddenInput'; import type { @@ -30,7 +30,7 @@ import {b} from './utils'; import './DatePicker.scss'; -export interface DatePickerProps +export interface DatePickerProps extends DatePickerStateOptions, TextInputProps, FocusableProps, @@ -40,7 +40,7 @@ export interface DatePickerProps StyleProps, AccessibilityProps, PopupStyleProps { - children?: (props: CalendarProps) => React.ReactNode; + children?: (props: CalendarProps) => React.ReactNode; disablePortal?: boolean; disableFocusTrap?: boolean; } diff --git a/src/components/DatePicker/hooks/useDatePickerProps.ts b/src/components/DatePicker/hooks/useDatePickerProps.ts index 27f94642..710b2281 100644 --- a/src/components/DatePicker/hooks/useDatePickerProps.ts +++ b/src/components/DatePicker/hooks/useDatePickerProps.ts @@ -5,7 +5,7 @@ import type {DateTime} from '@gravity-ui/date-utils'; import {useFocusWithin, useForkRef} from '@gravity-ui/uikit'; import type {ButtonButtonProps, PopupProps, TextInputProps} from '@gravity-ui/uikit'; -import type {CalendarInstance, CalendarProps} from '../../Calendar'; +import type {CalendarProps, CalendarRenderProps} from '../../Calendar'; import {useDateFieldProps} from '../../DateField'; import type {DateFieldProps} from '../../DateField'; import type {RangeValue} from '../../types'; @@ -17,20 +17,20 @@ import {getCalendarModes, getDateTimeValue} from '../utils'; import type {DatePickerState} from './useDatePickerState'; -interface InnerDatePickerProps { +interface InnerDatePickerProps { // eslint-disable-next-line @typescript-eslint/no-explicit-any groupProps: React.HTMLAttributes & {ref: React.Ref}; fieldProps: TextInputProps; calendarButtonProps: ButtonButtonProps & {ref: React.Ref}; popupProps: PopupProps; - calendarProps: CalendarProps & {ref: React.Ref}; + calendarProps: CalendarProps & {ref?: React.Ref}; timeInputProps: DateFieldProps; } -export function useDatePickerProps>( +export function useDatePickerProps, RT>( state: DatePickerState, - {onFocus, onBlur, ...props}: DatePickerProps, -): InnerDatePickerProps { + {onFocus, onBlur, ...props}: DatePickerProps, +): InnerDatePickerProps { const [isActive, setActive] = React.useState(false); const [focusedDate, setFocusedDate] = React.useState( @@ -72,7 +72,6 @@ export function useDatePickerProps>( const handleRef = useForkRef(inputRef, inputProps.controlRef); - const calendarRef = React.useRef(null); const calendarButtonRef = React.useRef(null); const groupRef = React.useRef(null); @@ -157,7 +156,6 @@ export function useDatePickerProps>( style: props.popupStyle, }, calendarProps: { - ref: calendarRef, autoFocus: true, size: props.size === 's' ? 'm' : props.size, disabled: props.disabled, diff --git a/src/components/RangeCalendar/RangeCalendar.tsx b/src/components/RangeCalendar/RangeCalendar.tsx index 6f973131..5c802ed3 100644 --- a/src/components/RangeCalendar/RangeCalendar.tsx +++ b/src/components/RangeCalendar/RangeCalendar.tsx @@ -4,18 +4,27 @@ import React from 'react'; import type {DateTime} from '@gravity-ui/date-utils'; +import {Provider, useContextProps} from '../../utils/providers'; import type {CalendarProps} from '../Calendar/Calendar'; -import {CalendarView} from '../CalendarView/CalendarView'; -import type {CalendarInstance} from '../CalendarView/CalendarView'; +import {CalendarContext, CalendarStateContext, CalendarView} from '../CalendarView/CalendarView'; import {useRangeCalendarState} from '../CalendarView/hooks/useRangeCalendarState'; +import type {RangeCalendarState} from '../CalendarView/hooks/useRangeCalendarState'; import type {RangeValue} from '../types'; import '../CalendarView/Calendar.scss'; -export type RangeCalendarProps = CalendarProps>; +export interface RangeCalendarRenderProps { + /** + * State of the calendar. + */ + state: RangeCalendarState; +} -export const RangeCalendar = React.forwardRef( - function Calendar(props: RangeCalendarProps, ref) { +export type RangeCalendarProps = CalendarProps, RangeCalendarRenderProps>; + +export const RangeCalendar = React.forwardRef( + function Calendar(props, forwardedRef) { + const [mergedProps, ref] = useContextProps(props, forwardedRef, CalendarContext); const state = useRangeCalendarState(props); const handleBlur = (e: React.FocusEvent) => { @@ -24,6 +33,15 @@ export const RangeCalendar = React.forwardRef; + return ( + + + + ); }, ); diff --git a/src/components/RangeDatePicker/RangeDatePicker.tsx b/src/components/RangeDatePicker/RangeDatePicker.tsx index bdff6804..4a01bb39 100644 --- a/src/components/RangeDatePicker/RangeDatePicker.tsx +++ b/src/components/RangeDatePicker/RangeDatePicker.tsx @@ -8,6 +8,7 @@ import {Button, Icon, Popup, TextInput} from '@gravity-ui/uikit'; import {block} from '../../utils/cn'; import {RangeCalendar} from '../Calendar'; +import type {RangeCalendarRenderProps} from '../Calendar'; import {useDatePickerProps} from '../DatePicker'; import type {DatePickerProps} from '../DatePicker'; import {StubButton} from '../DatePicker/StubButton'; @@ -21,7 +22,7 @@ import './RangeDatePicker.scss'; const b = block('range-date-picker'); -export type RangeDatePickerProps = DatePickerProps>; +export type RangeDatePickerProps = DatePickerProps, RangeCalendarRenderProps>; export function RangeDatePicker({className, ...props}: RangeDatePickerProps) { const [anchor, setAnchor] = React.useState(null); diff --git a/src/components/RelativeDatePicker/hooks/useRelativeDatePickerProps.ts b/src/components/RelativeDatePicker/hooks/useRelativeDatePickerProps.ts index 5fcdd1de..2b00c798 100644 --- a/src/components/RelativeDatePicker/hooks/useRelativeDatePickerProps.ts +++ b/src/components/RelativeDatePicker/hooks/useRelativeDatePickerProps.ts @@ -3,7 +3,7 @@ import React from 'react'; import {useControlledState, useFocusWithin, useForkRef} from '@gravity-ui/uikit'; import type {ButtonButtonProps, PopupProps, TextInputProps} from '@gravity-ui/uikit'; -import type {Calendar, CalendarInstance} from '../../Calendar'; +import type {Calendar} from '../../Calendar'; import {useDateFieldProps} from '../../DateField'; import type {DateFieldProps} from '../../DateField'; import {getCalendarModes} from '../../DatePicker/utils'; @@ -118,7 +118,7 @@ export function useRelativeDatePickerProps( mode === 'relative' ? relativeDateProps.controlRef : inputProps.controlRef, ); - const calendarRef = React.useRef(null); + const calendarRef = React.useRef(null); function focusCalendar() { setTimeout(() => { diff --git a/src/components/common/Button.tsx b/src/components/common/Button.tsx new file mode 100644 index 00000000..69080c5b --- /dev/null +++ b/src/components/common/Button.tsx @@ -0,0 +1,21 @@ +import React from 'react'; + +import {Button as ButtonBase} from '@gravity-ui/uikit'; +import type {ButtonButtonProps, DOMProps} from '@gravity-ui/uikit'; + +import {useContextProps} from '../../utils/providers'; +import type {ContextValue, SlotProps} from '../../utils/providers'; + +export interface ButtonProps + extends Omit, + DOMProps, + SlotProps {} + +export const ButtonContext = React.createContext>({}); + +export const Button = React.forwardRef( + function Button(props, forwardedRef) { + const [mergedProps, ref] = useContextProps(props, forwardedRef, ButtonContext); + return ; + }, +); diff --git a/src/components/common/Text.tsx b/src/components/common/Text.tsx new file mode 100644 index 00000000..0834f32d --- /dev/null +++ b/src/components/common/Text.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +import {Text as TextBase} from '@gravity-ui/uikit'; +import type {TextProps as TextPropsBase} from '@gravity-ui/uikit'; + +import {useContextProps} from '../../utils/providers'; +import type {ContextValue, SlotProps} from '../../utils/providers'; + +type TextPropsName = + | 'color' + | 'ellipsis' + | 'ellipsisLines' + | 'variant' + | 'whiteSpace' + | 'wordBreak' + | 'style'; + +export interface TextProps + extends Pick, + Omit, TextPropsName | 'slot'>, + SlotProps { + as?: React.ElementType; +} + +export const TextContext = React.createContext>({}); + +export const Text = React.forwardRef(function Text(props, forwardedRef) { + const [mergedProps, ref] = useContextProps(props, forwardedRef, TextContext); + return ; +}); diff --git a/src/uikit.ts b/src/uikit.ts new file mode 100644 index 00000000..3e9fd354 --- /dev/null +++ b/src/uikit.ts @@ -0,0 +1,2 @@ +export * from './components/common/Button'; +export * from './components/common/Text'; diff --git a/src/utils/providers.tsx b/src/utils/providers.tsx new file mode 100644 index 00000000..b02736f6 --- /dev/null +++ b/src/utils/providers.tsx @@ -0,0 +1,225 @@ +import React from 'react'; + +import {useForkRef} from '@gravity-ui/uikit'; +import type {CSSProperties} from '@gravity-ui/uikit'; + +import {mergeProps} from '../components/utils/mergeProps'; + +export interface SlotProps { + /** + * A slot name for the component. Slots allow the component to receive props from a parent component. + * An explicit `null` value indicates that the local props completely override all props received from a parent. + */ + slot?: string | null; +} + +export const DEFAULT_SLOT = Symbol('default'); + +interface SlottedValue { + slots?: Record; +} + +export type SlottedContextValue = SlottedValue | T | null | undefined; +export type ContextValue = SlottedContextValue>; +export type WithRef = T & {ref?: React.ForwardedRef}; + +type ProviderValue = [React.Context, T]; +type ProviderValues = + | [ProviderValue] + | [ProviderValue, ProviderValue] + | [ProviderValue, ProviderValue, ProviderValue] + | [ProviderValue, ProviderValue, ProviderValue, ProviderValue] + | [ProviderValue, ProviderValue, ProviderValue, ProviderValue, ProviderValue] + | [ + ProviderValue, + ProviderValue, + ProviderValue, + ProviderValue, + ProviderValue, + ProviderValue, + ] + | [ + ProviderValue, + ProviderValue, + ProviderValue, + ProviderValue, + ProviderValue, + ProviderValue, + ProviderValue, + ] + | [ + ProviderValue, + ProviderValue, + ProviderValue, + ProviderValue, + ProviderValue, + ProviderValue, + ProviderValue, + ProviderValue, + ] + | [ + ProviderValue, + ProviderValue, + ProviderValue, + ProviderValue, + ProviderValue, + ProviderValue, + ProviderValue, + ProviderValue, + ProviderValue, + ] + | [ + ProviderValue, + ProviderValue, + ProviderValue, + ProviderValue, + ProviderValue, + ProviderValue, + ProviderValue, + ProviderValue, + ProviderValue, + ProviderValue, + ] + | [ + ProviderValue, + ProviderValue, + ProviderValue, + ProviderValue, + ProviderValue, + ProviderValue, + ProviderValue, + ProviderValue, + ProviderValue, + ProviderValue, + ProviderValue, + ]; +interface ProviderProps { + values: ProviderValues; + children: React.ReactNode; +} + +export function Provider({ + values, + children, +}: ProviderProps) { + let result: JSX.Element = {children}; + for (const [Context, value] of values) { + result = {result}; + } + + return result; +} + +export function useGuardedContext(context: React.Context) { + const value = React.useContext(context); + if (value === undefined || value === null) { + throw new Error( + `Component must be used inside ${context.displayName || 'context'} provider`, + ); + } + return value; +} + +export function useSlottedContext( + context: React.Context>, + slot?: string | null, +): T | null | undefined { + const ctx = React.useContext(context); + if (slot === null) { + // An explicit `null` slot means don't use context. + return null; + } + if (ctx && typeof ctx === 'object' && 'slots' in ctx && ctx.slots) { + const availableSlots = Object.keys(ctx.slots) + .map((p) => `"${p}"`) + .join(', '); + + if (!slot && !ctx.slots[DEFAULT_SLOT]) { + throw new Error(`A slot prop is required. Valid slot names are ${availableSlots}.`); + } + const slotKey = slot || DEFAULT_SLOT; + if (!ctx.slots[slotKey]) { + throw new Error(`Invalid slot "${slot}". Valid slot names are ${availableSlots}.`); + } + return ctx.slots[slotKey]; + } + + return ctx as T; +} + +export function useContextProps( + props: T & SlotProps, + ref: React.ForwardedRef, + context: React.Context>, +): [T, React.Ref] { + const ctx = useSlottedContext(context, props.slot); + const {ref: contextRef, ...contextProps} = ctx ?? {}; + + const mergedRefs = useForkRef(ref, contextRef); + const mergedProps = mergeProps(contextProps, props) as T; + + return [mergedProps, mergedRefs]; +} + +export interface StyleRenderProps { + /** The CSS className for the element. A function may be provided to compute the class based on component state. */ + className?: string | ((values: T & {defaultClassName: string | undefined}) => string); + /** The inline style for the element. A function may be provided to compute the style based on component state. */ + style?: + | CSSProperties + | ((values: T & {defaultStyle: CSSProperties}) => CSSProperties | undefined); +} + +export interface RenderProps extends StyleRenderProps { + children?: + | React.ReactNode + | ((values: T & {defaultChildren: React.ReactNode}) => React.ReactNode); +} + +export interface RenderPropsOptions extends RenderProps { + values: T; + defaultChildren?: React.ReactNode; + defaultClassName?: string; + defaultStyle?: CSSProperties; +} + +export function useRenderProps({ + values, + children, + defaultChildren, + className, + defaultClassName, + style, + defaultStyle, +}: RenderPropsOptions) { + return React.useMemo(() => { + let computedChildren: React.ReactNode; + if (typeof children === 'function') { + computedChildren = children({...values, defaultChildren}); + } else if (children === null) { + computedChildren = defaultChildren; + } else { + computedChildren = children; + } + + let computedClassName: string | undefined; + if (typeof className === 'function') { + computedClassName = className({...values, defaultClassName}); + } else { + computedClassName = className; + } + + let computedStyle: React.CSSProperties | undefined; + if (typeof style === 'function') { + computedStyle = style({...values, defaultStyle: defaultStyle ?? {}}); + } else { + computedStyle = style; + } + + return { + children: computedChildren ?? defaultChildren, + className: computedClassName ?? defaultClassName, + style: computedStyle || defaultStyle ? {...defaultStyle, ...computedStyle} : undefined, + }; + }, [values, children, defaultChildren, className, defaultClassName, style, defaultStyle]); +}