Skip to content

Commit

Permalink
[@mantine/dates] TimeInput: Add format and clearable props support (#607
Browse files Browse the repository at this point in the history
)

* [@mantine/dates] TimeInput: Add `format` and `clearable` prop

Fix for #581 and #580

* [@mantine/core] use-valid-state: Rename `rule` to `validation`

* [@mantine/dates] TimeInput: Resolve conversations

* [@mantine/dates] TimeInput: Fixed the problems

* [@mantine/dates] TimeInput: Fixed the test

* [@mantine/dates] TimeInput: Resolved issues

* [@mantine/dates] TimeRangeInput: Updated usage demo
  • Loading branch information
jerebtw authored Jan 5, 2022
1 parent 13b866e commit 28f6050
Show file tree
Hide file tree
Showing 35 changed files with 720 additions and 120 deletions.
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

0 comments on commit 28f6050

Please sign in to comment.