Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/components/TimeSelection/TimeSelection.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
@use '../variables';

$block: '.#{variables.$ns}time-selection';

#{$block} {
&_disabled {
pointer-events: none;

opacity: 0.6;
}

&_read-only {
pointer-events: none;
}

&__divider {
display: flex;
align-items: center;
align-self: stretch;

width: 1px;

font-weight: 500;
user-select: none;

background-color: var(--g-color-line-generic);
}
}
82 changes: 82 additions & 0 deletions src/components/TimeSelection/TimeSelection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import React from 'react';

import {Flex} from '@gravity-ui/uikit';

import {block} from '../../utils/cn';

import type {TimeSelectionProps} from './TimeSelection.types';
import {Wheel} from './Wheel';
import {useTimeRanges, useTimeSelection} from './hooks';

import './TimeSelection.scss';

const b = block('time-selection');

const DEFAULT_VIEWS: TimeSelectionProps['views'] = ['hours', 'minutes'];

export const TimeSelection = ({
ampm = false,
views = DEFAULT_VIEWS,
defaultValue,
value,
readOnly = false,
disabled = false,
onUpdate,
timeSteps,
isTimeDisabled,
minValue,
maxValue,
focusedView,
onFocusViewUpdate,
timeZone,
}: TimeSelectionProps) => {
const {currentValue, activeWheel, order, handleChange, handleActivate, getCurrentValue} =
useTimeSelection({
defaultValue,
value,
readOnly,
disabled,
timeZone,
ampm,
views: views || DEFAULT_VIEWS,
focusedView,
onUpdate,
onFocusViewUpdate,
});

const ranges = useTimeRanges({
ampm,
timeSteps,
isTimeDisabled,
minValue,
maxValue,
value: currentValue,
});

const isNotLastWheel = (index: number): boolean => index !== order.length - 1;

return (
<Flex
className={b({disabled, 'read-only': readOnly})}
role="group"
aria-label="Time selection"
alignItems="flex-start"
gap={2}
>
{order.map((key, index) => (
<React.Fragment key={key}>
<Wheel
values={ranges[key] || []}
value={getCurrentValue(key)}
setValue={(val) => handleChange(key, val)}
isActive={activeWheel === key}
onActivate={() => handleActivate(key)}
isInfinite={false}
disabled={disabled || readOnly}
/>
{isNotLastWheel(index) && <div className={b('divider')} />}
</React.Fragment>
))}
</Flex>
);
};
54 changes: 54 additions & 0 deletions src/components/TimeSelection/TimeSelection.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type {DateTime} from '@gravity-ui/date-utils';

export type TimeSelectionView = 'hours' | 'minutes' | 'seconds';
export type TimeSelectionWheel = TimeSelectionView | 'ampm';

export interface TimeSelectionProps {
/** Использовать 12-часовой формат с AM/PM */
ampm?: boolean;
/** Секция в фокусе по умолчанию */
defaultFocusView?: TimeSelectionView;
/** Значение по умолчанию для неконтролируемого режима */
defaultValue?: DateTime;
/** Отключить компонент */
disabled?: boolean;
/** Контролируемая секция в фокусе */
focusedView?: TimeSelectionView;
/** Функция для определения недоступных значений времени */
isTimeDisabled?: (value: DateTime, view: TimeSelectionView) => boolean;
/** Callback при изменении фокуса */
onFocusViewUpdate?: (value: TimeSelectionView) => void;
/** Callback при изменении значения */
onUpdate?: (value: DateTime) => void;
/** Минимальное доступное значение */
minValue?: DateTime;
/** Максимальное доступное значение */
maxValue?: DateTime;
/** Режим только для чтения */
readOnly?: boolean;
/** Шаги для каждой секции времени */
timeSteps?: Partial<Record<TimeSelectionView, number>>;
/** Часовой пояс */
timeZone?: string;
/** Контролируемое значение */
value?: DateTime;
/** Отображаемые секции */
views?: TimeSelectionView[];
}

export interface WheelValue {
label: string;
value: string;
disabled?: boolean;
}

export interface WheelProps {
values: WheelValue[];
value: string;
setValue: (val: string) => void;
isInfinite?: boolean;
onChange?: (val: string) => void;
isActive?: boolean;
onActivate?: () => void;
disabled?: boolean;
}
61 changes: 61 additions & 0 deletions src/components/TimeSelection/Wheel/Wheel.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
@use '../../variables';

$block: '.#{variables.$ns}time-selection-wheel';

#{$block} {
position: relative;

display: flex;
overflow-y: auto;
flex-direction: column;

max-height: 198px;

user-select: none;

border-radius: var(--g-border-radius-m);

scrollbar-width: none;
-ms-overflow-style: none;

&::-webkit-scrollbar {
display: none;
}

&_disabled {
cursor: not-allowed;
pointer-events: none;

opacity: 0.6;
}

&__cell {
flex-shrink: 0;

width: 48px;
height: 24px;

cursor: pointer;

border-radius: var(--g-border-radius-s);

transition: background-color 0.1s ease;

&:hover:not(&_disabled):not(&_selected) {
background-color: var(--g-color-base-generic-hover);
}

&_selected {
color: var(--g-color-text-link);
background-color: var(--g-color-base-selection);
}

&_disabled {
cursor: not-allowed;

opacity: 0.4;
color: var(--g-color-text-hint);
background-color: var(--g-color-base-generic);
}
}
}
170 changes: 170 additions & 0 deletions src/components/TimeSelection/Wheel/Wheel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import React from 'react';

import {Flex, Text} from '@gravity-ui/uikit';

import {block} from '../../../utils/cn';
import type {WheelProps, WheelValue} from '../TimeSelection.types';

import './Wheel.scss';

const b = block('time-selection-wheel');

export const Wheel = ({
values,
value,
setValue,
isActive,
onActivate,
onChange,
disabled = false,
}: WheelProps) => {
const containerRef = React.useRef<HTMLDivElement>(null);
const selectedRef = React.useRef<HTMLDivElement>(null);
const [currentIndex, setCurrentIndex] = React.useState(() =>
Math.max(
values.findIndex((v) => v.value === value),
0,
),
);

React.useEffect(() => {
const newIndex = values.findIndex((v) => v.value === value);
if (newIndex !== -1 && newIndex !== currentIndex) {
setCurrentIndex(newIndex);
}

if (selectedRef.current && containerRef.current) {
const container = containerRef.current;
const selected = selectedRef.current;

const containerHeight = container.clientHeight;
const selectedHeight = selected.clientHeight;
const selectedTop = selected.offsetTop;

const scrollTop = selectedTop - containerHeight / 2 + selectedHeight / 2;

container.scrollTo({
top: scrollTop,
behavior: 'smooth',
});
}
}, [value, values, currentIndex]);

React.useEffect(() => {
if (selectedRef.current && containerRef.current) {
const container = containerRef.current;
const selected = selectedRef.current;

const containerHeight = container.clientHeight;
const selectedHeight = selected.clientHeight;
const selectedTop = selected.offsetTop;

const scrollTop = selectedTop - containerHeight / 2 + selectedHeight / 2;

container.scrollTo({
top: scrollTop,
behavior: 'instant' as ScrollBehavior,
});
}
}, []);

const isItemDisabled = (val: WheelValue): boolean => !!val.disabled || disabled;

const handleClick = (val: WheelValue, idx: number) => {
if (isItemDisabled(val)) return;
setCurrentIndex(idx);
setValue(val.value);
onChange?.(val.value);
onActivate?.();
};

const findNextEnabledIndex = (startIndex: number, direction: 1 | -1): number => {
let newIndex = startIndex;
let attempts = 0;

while (isItemDisabled(values[newIndex]) && attempts < values.length) {
newIndex =
direction === 1
? (newIndex + 1) % values.length
: (newIndex - 1 + values.length) % values.length;
attempts++;
}

return isItemDisabled(values[newIndex]) ? startIndex : newIndex;
};

const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (disabled || !isActive) return;

let newIndex = currentIndex;

switch (e.key) {
case 'ArrowUp':
e.preventDefault();
newIndex = findNextEnabledIndex(
currentIndex > 0 ? currentIndex - 1 : values.length - 1,
-1,
);
break;
case 'ArrowDown':
e.preventDefault();
newIndex = findNextEnabledIndex(
currentIndex < values.length - 1 ? currentIndex + 1 : 0,
1,
);
break;
case 'Home':
e.preventDefault();
newIndex = findNextEnabledIndex(0, 1);
break;
case 'End':
e.preventDefault();
newIndex = findNextEnabledIndex(values.length - 1, -1);
break;
default:
return;
}

if (newIndex !== currentIndex) {
handleClick(values[newIndex], newIndex);
}
};

return (
<div
className={b({active: isActive, disabled})}
role="listbox"
tabIndex={disabled ? -1 : 0}
aria-activedescendant={value}
aria-label="time-section"
ref={containerRef}
onClick={onActivate}
onKeyDown={handleKeyDown}
>
<Flex direction="column">
{values.map((val, i) => {
const selected = val.value === value;
const itemDisabled = isItemDisabled(val);

return (
<Flex
justifyContent="center"
alignItems="center"
id={val.value}
role="option"
aria-selected={selected}
aria-disabled={itemDisabled}
className={b('cell', {selected, disabled: itemDisabled})}
key={val.value}
ref={selected ? selectedRef : null}
onClick={() => handleClick(val, i)}
>
<Text variant="body-1">{val.label}</Text>
</Flex>
);
})}
</Flex>
<div className={b('highlight')} />
</div>
);
};
Loading
Loading