Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[@mantine/dates] TimeInput: Add format and clearable prop #607

Merged
merged 8 commits into from
Jan 5, 2022
Merged
4 changes: 4 additions & 0 deletions docs/src/docs/dates/time-input.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ To add seconds set `withSeconds` prop:

<Demo data={TimeInputDemos.withSeconds} />

## 12 hours format

<Demo data={TimeInputDemos.format} />

## Input props

Component supports all props from [Input](/core/input/) and [InputWrapper](/core/input-wrapper/) components:
Expand Down
49 changes: 49 additions & 0 deletions docs/src/docs/hooks/use-validated-state.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
group: 'mantine-hooks'
package: '@mantine/hooks'
title: 'use-validated-state'
category: 'state'
order: 1
slug: /hooks/use-validated-state/
description: 'Validate state and get last valid value'
import: "import { useValidatedState } from '@mantine/hooks';"
docs: 'hooks/use-validated-state.mdx'
source: 'mantine-hooks/src/use-validated-state/use-validated-state.ts'
---

## Usage

Hook validates state with a given rule each time state is set. It returns an object with current validation state, last valid value and current value:

```tsx
const [{ lastValidValue, value, valid }, setValue] = useValidatedState(
'valid',
(value) => value === 'valid'
);

lastValidValue; // -> valid
value; // -> valid
valid; // -> true

setValue('invalid');

lastValidValue; // -> valid
value; // -> invalid
valid; // -> false
```

## Definition

```tsx
function useValidatedState<T>(
initialValue: T,
validation: (value: T) => boolean
): readonly [
{
value: T;
lastValidValue: T;
valid: boolean;
},
(val: T) => void
];
```
11 changes: 10 additions & 1 deletion src/mantine-dates/src/components/TimeInput/TimeInput.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import React from 'react';
import { shallow } from 'enzyme';
import { render } from '@testing-library/react';
import { checkAccessibility, itSupportsSystemProps } from '@mantine/tests';
import { Input, InputWrapper } from '@mantine/core';
import { TimeField } from './TimeField/TimeField';
import { TimeField } from '../TimeInputBase/TimeField/TimeField';
import { TimeInput, TimeInputProps } from './TimeInput';

const defaultProps: TimeInputProps = {};
Expand Down Expand Up @@ -78,4 +79,12 @@ describe('@mantine/dates/TimeInput', () => {
expect(withSeconds.find(TimeField)).toHaveLength(3);
expect(withoutSeconds.find(TimeField)).toHaveLength(2);
});

it('shows the correct value based on format prop', () => {
const format12 = render(<TimeInput format="12" defaultValue={new Date(0, 0, 0, 15, 1)} />);
const format24 = render(<TimeInput format="24" defaultValue={new Date(0, 0, 0, 15, 1)} />);

expect(format12.container.querySelectorAll('input')[0].value).toBe('03');
expect(format24.container.querySelectorAll('input')[0].value).toBe('15');
});
});
150 changes: 115 additions & 35 deletions src/mantine-dates/src/components/TimeInput/TimeInput.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState, useRef, forwardRef } from 'react';
import React, { useState, useRef, forwardRef, useEffect } from 'react';
import {
InputBaseProps,
InputWrapperBaseProps,
Expand All @@ -9,14 +9,18 @@ import {
InputWrapper,
MantineSize,
ClassNames,
CloseButton,
extractMargins,
} from '@mantine/core';
import { useMergedRef, useUncontrolled, useDidUpdate, useUuid } from '@mantine/hooks';
import dayjs from 'dayjs';
import { TimeField } from './TimeField/TimeField';
import { createTimeHandler } from './create-time-handler/create-time-handler';
import { getTimeValues } from './get-time-values/get-time-value';
import { useMergedRef, useUuid } from '@mantine/hooks';
import { TimeField } from '../TimeInputBase/TimeField/TimeField';
import { createTimeHandler } from '../TimeInputBase/create-time-handler/create-time-handler';
import useStyles from './TimeInput.styles';
import { padTime } from '../TimeInputBase/pad-time/pad-time';
import { AmPmInput } from '../TimeInputBase/AmPmInput/AmPmInput';
import { createAmPmHandler } from '../TimeInputBase/create-amPm-handler/create-amPm-handler';
import { getDate } from '../TimeInputBase/get-date/get-date';
import { getTimeValues } from '../TimeInputBase/get-time-values/get-time-value';

export type TimeInputStylesNames =
| ClassNames<typeof useStyles>
Expand All @@ -43,6 +47,15 @@ export interface TimeInputProps
/** Display seconds input */
withSeconds?: boolean;

/** Allow to clear item */
clearable?: boolean;

/** aria-label for clear button */
clearButtonLabel?: string;

/** Time format */
format?: '12' | '24';

/** Uncontrolled input name */
name?: string;

Expand All @@ -55,10 +68,27 @@ export interface TimeInputProps
/** aria-label for seconds input */
secondsLabel?: string;

/** aria-label for am/pm input */
amPmLabel?: string;

/** Placeholder for hours/minutes/seconds inputs*/
timePlaceholder?: string;

/** Placeholder for am/pm input */
amPmPlaceholder?: string;

/** Disable field */
disabled?: boolean;
}

const RIGHT_SECTION_WIDTH = {
xs: 24,
sm: 30,
md: 34,
lg: 40,
xl: 44,
};

export const TimeInput = forwardRef<HTMLInputElement, TimeInputProps>(
(
{
Expand All @@ -77,69 +107,103 @@ export const TimeInput = forwardRef<HTMLInputElement, TimeInputProps>(
defaultValue,
onChange,
withSeconds = false,
clearable = false,
clearButtonLabel,
format = '24',
name,
hoursLabel,
minutesLabel,
secondsLabel,
amPmLabel,
timePlaceholder = '--',
amPmPlaceholder = 'am',
disabled = false,
sx,
...others
}: TimeInputProps,
ref
) => {
const { classes, cx } = useStyles({ size }, { classNames, styles, name: 'TimeInput' });
const { classes, cx, theme } = useStyles({ size }, { classNames, styles, name: 'TimeInput' });
const { margins, rest } = extractMargins(others);
const uuid = useUuid(id);

const [_value, handleChange] = useUncontrolled({
value,
defaultValue,
finalValue: new Date(),
rule: (val) => val instanceof Date,
onChange,
});

const hoursRef = useRef<HTMLInputElement>();
const minutesRef = useRef<HTMLInputElement>();
const secondsRef = useRef<HTMLInputElement>();
const [time, setTime] = useState(getTimeValues(_value));
const amPmRef = useRef<HTMLInputElement>();
const [amPm, setAmPm] = useState('am');
const [time, setTime] = useState<{ hours: string; minutes: string; seconds: string }>(
getTimeValues(value || defaultValue)
);
const [_value, setValue] = useState<Date>(value ?? defaultValue);

useEffect(() => {
setValue(getDate(time.hours, time.minutes, time.seconds, format, amPm));
}, [time, format, amPm]);

useEffect(() => {
if (format === '12' && _value) {
const _hours = parseInt(time.hours, 10);
setAmPm(_hours >= 12 ? 'pm' : 'am');

useDidUpdate(() => {
setTime(getTimeValues(_value));
}, [_value]);
if (_hours >= 12) {
setTime({ ...time, hours: padTime((_hours - 12).toString()) });
}
}
}, [format]);

const handleHoursChange = createTimeHandler({
onChange: (val) => {
setTime((c) => ({ ...c, hours: val }));
handleChange(dayjs(_value).set('hours', parseInt(val, 10)).toDate());
setTime((current) => ({ ...current, hours: padTime(val) }));
},
min: 0,
max: 23,
maxValue: 2,
max: format === '12' ? 11 : 23,
maxValue: format === '12' ? 1 : 2,
nextRef: minutesRef,
});

const handleMinutesChange = createTimeHandler({
onChange: (val) => {
setTime((c) => ({ ...c, minutes: val }));
handleChange(dayjs(_value).set('minutes', parseInt(val, 10)).toDate());
setTime((current) => ({ ...current, minutes: padTime(val) }));
},
min: 0,
max: 59,
maxValue: 5,
nextRef: secondsRef,
nextRef: !withSeconds ? amPmRef : secondsRef,
});

const handleSecondsChange = createTimeHandler({
onChange: (val) => {
setTime((c) => ({ ...c, seconds: val }));
handleChange(dayjs(_value).set('seconds', parseInt(val, 10)).toDate());
setTime((current) => ({ ...current, seconds: padTime(val) }));
},
min: 0,
max: 59,
maxValue: 5,
nextRef: amPmRef,
});

const handleAmPmChange = createAmPmHandler({
onChange: (val) => {
setAmPm(val);
},
});

const handleClear = () => {
setTime({ hours: '', minutes: '', seconds: '' });
setAmPm('');
hoursRef.current.focus();
};

const rightSection =
clearable && _value ? (
<CloseButton
variant="transparent"
aria-label={clearButtonLabel}
onClick={handleClear}
size={size}
/>
) : null;

return (
<InputWrapper
required={required}
Expand Down Expand Up @@ -168,50 +232,66 @@ export const TimeInput = forwardRef<HTMLInputElement, TimeInputProps>(
classNames={classNames}
styles={styles}
disabled={disabled}
rightSection={rightSection}
rightSectionWidth={theme.fn.size({ size, sizes: RIGHT_SECTION_WIDTH })}
{...rest}
>
<div className={classes.controls}>
<TimeField
ref={useMergedRef(hoursRef, ref)}
value={time.hours}
onChange={handleHoursChange}
setValue={(val) => setTime((c) => ({ ...c, hours: val }))}
setValue={(val) => setTime((current) => ({ ...current, hours: val }))}
id={uuid}
className={classes.timeInput}
withSeparator
size={size}
max={23}
max={format === '12' ? 11 : 23}
placeholder={timePlaceholder}
aria-label={hoursLabel}
disabled={disabled}
/>

<TimeField
ref={minutesRef}
value={time.minutes}
onChange={handleMinutesChange}
setValue={(val) => setTime((c) => ({ ...c, minutes: val }))}
setValue={(val) => setTime((current) => ({ ...current, minutes: val }))}
className={classes.timeInput}
withSeparator={withSeconds}
size={size}
max={59}
placeholder={timePlaceholder}
aria-label={minutesLabel}
disabled={disabled}
/>

{withSeconds && (
<TimeField
ref={secondsRef}
value={time.seconds}
onChange={handleSecondsChange}
setValue={(val) => setTime((c) => ({ ...c, seconds: val }))}
setValue={(val) => setTime((current) => ({ ...current, seconds: val }))}
className={classes.timeInput}
size={size}
max={59}
placeholder={timePlaceholder}
aria-label={secondsLabel}
disabled={disabled}
/>
)}

{format === '12' && (
<AmPmInput
ref={amPmRef}
value={amPm}
onChange={handleAmPmChange}
setValue={(val) => {
setAmPm(val);
}}
placeholder={amPmPlaceholder}
size={size}
aria-label={amPmLabel}
disabled={disabled}
/>
)}
{name && <input type="hidden" name={name} value={_value.toISOString()} />}
</div>
</Input>
Expand Down
20 changes: 20 additions & 0 deletions src/mantine-dates/src/components/TimeInput/demos/clearable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import React from 'react';
import { TimeInput } from '../TimeInput';

const code = `
<TimeInput clearable defaultValue={new Date()} />
`;

function Demo() {
return (
<div style={{ maxWidth: 320, marginLeft: 'auto', marginRight: 'auto' }}>
<TimeInput label="With a clear button" clearable defaultValue={new Date()} />
</div>
);
}

export const clearable: MantineDemo = {
type: 'demo',
code,
component: Demo,
};
Loading