@indico-data/design-system 2.54.0 → 2.55.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/lib/components/forms/date/inputDateTimePicker/SingleInputDateTimePicker.d.ts +24 -0
  2. package/lib/components/forms/date/inputDateTimePicker/SingleInputDateTimePicker.stories.d.ts +6 -0
  3. package/lib/components/forms/date/inputDateTimePicker/helpers.d.ts +1 -0
  4. package/lib/components/forms/date/inputDateTimePicker/index.d.ts +1 -0
  5. package/lib/components/forms/subcomponents/DisplayFormError.d.ts +2 -1
  6. package/lib/components/forms/timePicker/TimePicker.d.ts +3 -1
  7. package/lib/components/forms/timePicker/helpers.d.ts +2 -5
  8. package/lib/index.css +0 -90
  9. package/lib/index.d.ts +3 -1
  10. package/lib/index.esm.css +0 -90
  11. package/lib/index.esm.js +199 -178
  12. package/lib/index.esm.js.map +1 -1
  13. package/lib/index.js +199 -178
  14. package/lib/index.js.map +1 -1
  15. package/package.json +6 -6
  16. package/src/components/forms/date/datePicker/DatePicker.stories.tsx +4 -4
  17. package/src/components/forms/date/datePicker/DatePicker.tsx +0 -2
  18. package/src/components/forms/date/inputDateTimePicker/SingleInputDateTimePicker.mdx +20 -0
  19. package/src/components/forms/date/inputDateTimePicker/SingleInputDateTimePicker.stories.tsx +262 -0
  20. package/src/components/forms/date/inputDateTimePicker/SingleInputDateTimePicker.tsx +141 -0
  21. package/src/components/forms/date/inputDateTimePicker/helpers.ts +3 -0
  22. package/src/components/forms/date/inputDateTimePicker/index.ts +1 -0
  23. package/src/components/forms/input/Input.tsx +1 -1
  24. package/src/components/forms/passwordInput/PasswordInput.stories.tsx +1 -1
  25. package/src/components/forms/subcomponents/DisplayFormError.tsx +7 -2
  26. package/src/components/forms/timePicker/TimePicker.mdx +3 -27
  27. package/src/components/forms/timePicker/TimePicker.stories.tsx +19 -1
  28. package/src/components/forms/timePicker/TimePicker.tsx +37 -80
  29. package/src/components/forms/timePicker/__tests__/TimePicker.test.tsx +33 -11
  30. package/src/components/forms/timePicker/helpers.ts +61 -13
  31. package/src/styles/index.scss +0 -2
  32. package/lib/components/forms/timePicker/constants.d.ts +0 -13
  33. package/src/components/forms/timePicker/constants.ts +0 -21
  34. package/src/components/forms/timePicker/styles/TimePicker.scss +0 -51
@@ -1,104 +1,61 @@
1
- import { useState } from 'react';
2
- import { Select } from '../select/Select';
1
+ import { ChangeEvent, useState, FocusEvent } from 'react';
3
2
  import { Input } from '../input/Input';
4
- import { Col, Row } from '../../grid';
5
- import { FloatUI } from '../..';
6
- import { hourOptions, minuteOptions, periodOptions } from './constants';
7
- import { parseTimeValue } from './helpers';
8
- import { SelectOption } from '../select/types';
9
- import { SingleValue, MultiValue } from 'react-select';
3
+ import { formatTimeValue, validateInputValue } from './helpers';
10
4
 
11
5
  interface TimePickerProps {
12
6
  timeValue?: string;
13
7
  label?: string;
8
+ name?: string;
9
+ hasHiddenLabel?: boolean;
14
10
  onTimeChange?: (time: string) => void;
15
11
  }
16
12
 
17
13
  export const TimePicker = ({
18
14
  timeValue = '',
19
15
  label = 'Time Picker',
16
+ name = 'time-picker',
17
+ hasHiddenLabel = false,
20
18
  onTimeChange,
21
19
  }: TimePickerProps) => {
22
- const initialTime = parseTimeValue(timeValue);
23
- const [hours, setHours] = useState(initialTime.hours);
24
- const [minutes, setMinutes] = useState(initialTime.minutes);
25
- const [period, setPeriod] = useState(initialTime.period);
20
+ const [validationError, setValidationError] = useState('');
21
+ const [inputValue, setInputValue] = useState(timeValue);
26
22
 
27
- const handleHourChange = (option: SingleValue<SelectOption>) => {
28
- setHours(option?.value || '12');
29
- onTimeChange?.(`${option?.value || '12'}:${minutes} ${period.toUpperCase()}`);
30
- };
23
+ const handleTimeChange = (e: ChangeEvent<HTMLInputElement>) => {
24
+ const newValue = e.target.value;
25
+ const error = validateInputValue(newValue);
26
+ setInputValue(newValue);
27
+ setValidationError(error);
31
28
 
32
- const handleMinuteChange = (option: SelectOption) => {
33
- setMinutes(option?.value || '00');
34
- onTimeChange?.(`${hours}:${option?.value || '00'} ${period.toUpperCase()}`);
29
+ // Only send valid values to parent component
30
+ if (!error || error === '') {
31
+ onTimeChange?.(newValue);
32
+ }
35
33
  };
36
34
 
37
- const handlePeriodChange = (option: SelectOption) => {
38
- setPeriod(option?.value || 'am');
39
- onTimeChange?.(`${hours}:${minutes} ${(option?.value || 'am').toUpperCase()}`);
35
+ const handleBlur = (e: FocusEvent<HTMLInputElement>) => {
36
+ const currentValue = e.target.value;
37
+
38
+ // Only format if there's no validation error
39
+ if (validationError === '' && currentValue.trim() !== '') {
40
+ const formattedValue = formatTimeValue(currentValue);
41
+ setInputValue(formattedValue);
42
+ onTimeChange?.(formattedValue);
43
+ }
40
44
  };
41
45
 
42
46
  return (
43
47
  <div className="time-input-wrapper">
44
- <FloatUI ariaLabel={label}>
45
- <Input
46
- data-testid="time-picker-input"
47
- name="hours"
48
- label={label}
49
- hasHiddenLabel
50
- value={`${hours}:${minutes} ${period.toUpperCase()}`}
51
- readonly
52
- className="time-picker-input"
53
- />
54
- <Row gutterWidth={0} className="time-picker-row">
55
- <Col xs="content">
56
- <Select
57
- menuIsOpen
58
- className="hour-menu"
59
- components={{ DropdownIndicator: () => null, IndicatorSeparator: () => null }}
60
- name="hours"
61
- value={{ label: hours, value: hours }}
62
- onChange={
63
- handleHourChange as (
64
- newValue: SingleValue<SelectOption> | MultiValue<SelectOption>,
65
- ) => void
66
- }
67
- options={hourOptions}
68
- />
69
- </Col>
70
- <Col xs="content">
71
- <Select
72
- className="minute-menu"
73
- components={{ DropdownIndicator: () => null, IndicatorSeparator: () => null }}
74
- name="minutes"
75
- options={minuteOptions}
76
- menuIsOpen
77
- value={{ label: minutes.padStart(2, '0'), value: minutes }}
78
- onChange={
79
- handleMinuteChange as (
80
- newValue: SingleValue<SelectOption> | MultiValue<SelectOption>,
81
- ) => void
82
- }
83
- />
84
- </Col>
85
- <Col xs="content">
86
- <Select
87
- menuIsOpen
88
- className="period-menu"
89
- components={{ DropdownIndicator: () => null, IndicatorSeparator: () => null }}
90
- name="period"
91
- options={periodOptions}
92
- value={{ label: period.toUpperCase(), value: period }}
93
- onChange={
94
- handlePeriodChange as (
95
- newValue: SingleValue<SelectOption> | MultiValue<SelectOption>,
96
- ) => void
97
- }
98
- />
99
- </Col>
100
- </Row>
101
- </FloatUI>
48
+ <Input
49
+ data-testid={`${name}-input`}
50
+ label={label}
51
+ hasHiddenLabel={hasHiddenLabel}
52
+ value={inputValue}
53
+ maxLength={8}
54
+ onChange={handleTimeChange}
55
+ onBlur={handleBlur}
56
+ name={name}
57
+ errorMessage={validationError}
58
+ />
102
59
  </div>
103
60
  );
104
61
  };
@@ -3,24 +3,46 @@ import { TimePicker } from '@/components/forms/timePicker/TimePicker';
3
3
  import userEvent from '@testing-library/user-event';
4
4
 
5
5
  describe('TimePicker', () => {
6
- it('updates the time when the hour is changed', async () => {
7
- render(<TimePicker />);
6
+ it('renders an error when the input is invalid', async () => {
7
+ render(
8
+ <TimePicker
9
+ name="time-picker"
10
+ label="Time Picker"
11
+ hasHiddenLabel
12
+ data-testid="time-picker-input"
13
+ />,
14
+ );
15
+
8
16
  const timePickerInput = screen.getByTestId('time-picker-input');
9
17
  await userEvent.click(timePickerInput);
10
18
 
11
- const hourOption = screen.getByRole('option', { name: '1' });
12
- await userEvent.click(hourOption);
19
+ await userEvent.type(timePickerInput, '13:00');
20
+
21
+ const timePickerError = screen.getByTestId('time-picker-error');
22
+ expect(timePickerError).toBeInTheDocument();
23
+ });
24
+
25
+ it('formats the time input when the value is valid and a user clicks away from the input', async () => {
26
+ render(
27
+ <TimePicker
28
+ name="time-picker"
29
+ label="Time Picker"
30
+ hasHiddenLabel
31
+ data-testid="time-picker-input"
32
+ />,
33
+ );
13
34
 
14
- expect(timePickerInput).toHaveValue('1:00 AM');
35
+ const timePickerInput = screen.getByTestId('time-picker-input');
36
+ await userEvent.click(timePickerInput);
15
37
 
16
- const minuteOption = screen.getByRole('option', { name: '01' });
17
- await userEvent.click(minuteOption);
38
+ await userEvent.type(timePickerInput, '1:00pm');
18
39
 
19
- expect(timePickerInput).toHaveValue('1:01 AM');
40
+ const timePickerError = screen.queryByTestId('time-picker-error');
41
+ expect(timePickerError).not.toBeInTheDocument();
20
42
 
21
- const periodOption = screen.getByRole('option', { name: 'PM' });
22
- await userEvent.click(periodOption);
43
+ await userEvent.click(document.body);
23
44
 
24
- expect(timePickerInput).toHaveValue('1:01 PM');
45
+ expect(timePickerInput).toHaveValue('1:00 PM');
46
+ expect(timePickerError).not.toBeInTheDocument();
25
47
  });
26
48
  });
@@ -1,14 +1,62 @@
1
- // Parse initial time value
2
- export const parseTimeValue = (time: string) => {
3
- if (!time) return { hours: '12', minutes: '00', period: 'am' };
4
-
5
- const match = time.match(/(\d{1,2}):(\d{2})\s*(am|pm)/i);
6
- if (!match) return { hours: '12', minutes: '00', period: 'am' };
7
-
8
- const [, hours, minutes, period] = match;
9
- return {
10
- hours: hours,
11
- minutes: minutes,
12
- period: period.toLowerCase(),
13
- };
1
+ export const formatTimeValue = (value: string): string => {
2
+ if (!value || value.trim() === '') {
3
+ return '';
4
+ }
5
+
6
+ // Normalize the input
7
+ const normalizedValue = value.trim().toLowerCase();
8
+
9
+ // Extract time components using regex
10
+ const timeRegex = /^(0?[1-9]|1[0-2]):([0-5][0-9])(\s*)([ap]m)$/i;
11
+ const matches = normalizedValue.match(timeRegex);
12
+
13
+ if (matches) {
14
+ const hours = parseInt(matches[1], 10);
15
+ const minutes = parseInt(matches[2], 10);
16
+ const period = matches[4].toUpperCase(); // Convert am/pm to AM/PM
17
+
18
+ // Format as hh:mm AM/PM
19
+ return `${hours}:${minutes < 10 ? '0' + minutes : minutes} ${period}`;
20
+ }
21
+
22
+ return value; // Return original if no match (shouldn't happen with valid inputs)
23
+ };
24
+
25
+ export const validateInputValue = (value: string): string => {
26
+ if (!value || value.trim() === '') {
27
+ return ''; // Empty input is allowed
28
+ }
29
+
30
+ // Normalize the input (remove extra spaces, convert to lowercase)
31
+ const normalizedValue = value.trim().toLowerCase();
32
+
33
+ // Regular expression for valid time formats: 1:10 pm, 01:10 pm, 1:10pm, 01:10pm
34
+ const timeRegex = /^(0?[1-9]|1[0-2]):([0-5][0-9])(\s*)([ap]m)$/i;
35
+
36
+ if (!timeRegex.test(normalizedValue)) {
37
+ // Check if the issue might be a 24-hour format
38
+ const hour24Regex = /^([13-9]|1[3-9]|2[0-3]):/i;
39
+ if (hour24Regex.test(normalizedValue)) {
40
+ return 'Please enter time in 12-hour format (e.g., 1:10 pm)';
41
+ }
42
+
43
+ return 'Please enter a valid time format (e.g., 1:10 pm)';
44
+ }
45
+
46
+ // Extract hours and minutes for additional validation
47
+ const matches = normalizedValue.match(timeRegex);
48
+ if (matches) {
49
+ const hours = parseInt(matches[1], 10);
50
+ const minutes = parseInt(matches[2], 10);
51
+
52
+ if (hours < 1 || hours > 12) {
53
+ return 'Hours must be between 1 and 12';
54
+ }
55
+
56
+ if (minutes < 0 || minutes > 59) {
57
+ return 'Minutes must be between 0 and 59';
58
+ }
59
+ }
60
+
61
+ return ''; // Valid time format
14
62
  };
@@ -21,12 +21,10 @@
21
21
  @import '../components/menu/styles/Menu.scss';
22
22
  @import '../components/floatUI/styles/FloatUI.scss';
23
23
  @import '../components/forms/date/datePicker/styles/DatePicker.scss';
24
- @import '../components/forms/timePicker/styles/TimePicker.scss';
25
24
  @import '../components/badge/styles/Badge.scss';
26
25
  @import '../components/modal/styles/Modal.scss';
27
26
  @import '../components/pagination/styles/Pagination.scss';
28
27
  @import '../components/tanstackTable/styles/table.scss';
29
- @import '../components/forms/timePicker/styles/TimePicker.scss';
30
28
  @import '../components/pill/styles/Pill.scss';
31
29
  @import '../components/tooltip/styles/Tooltip.scss';
32
30
  @import '../components/loading-indicators/BarSpinner/styles/BarSpinner.scss';
@@ -1,13 +0,0 @@
1
- declare const hourOptions: {
2
- label: string;
3
- value: string;
4
- }[];
5
- declare const minuteOptions: {
6
- label: string;
7
- value: string;
8
- }[];
9
- declare const periodOptions: {
10
- label: string;
11
- value: string;
12
- }[];
13
- export { hourOptions, minuteOptions, periodOptions };
@@ -1,21 +0,0 @@
1
- const hourOptions = Array.from({ length: 12 }, (_, i) => {
2
- const num = `${i + 1}`;
3
- return {
4
- label: num,
5
- value: num,
6
- };
7
- });
8
-
9
- const minuteOptions = Array.from({ length: 60 }, (_, i) => {
10
- const num = `${i}`.padStart(2, '0');
11
- return {
12
- label: num,
13
- value: num,
14
- };
15
- });
16
-
17
- const periodOptions = [
18
- { label: 'AM', value: 'am' },
19
- { label: 'PM', value: 'pm' },
20
- ];
21
- export { hourOptions, minuteOptions, periodOptions };
@@ -1,51 +0,0 @@
1
- .time-picker-input {
2
- cursor: pointer;
3
- }
4
- .time-picker-row {
5
- width: 150px;
6
-
7
- .select__value-container {
8
- text-align: center;
9
- cursor: pointer;
10
- }
11
- .select-wrapper {
12
- width: 50px;
13
- .select__items {
14
- justify-content: center;
15
- }
16
- .select__menu {
17
- height: 230px;
18
- padding-bottom: 0;
19
- margin-bottom: 0;
20
- }
21
- }
22
- .select__menu-list {
23
- max-height: 100%;
24
- scrollbar-width: none; /* Firefox */
25
- -ms-overflow-style: none; /* IE and Edge */
26
- &::-webkit-scrollbar {
27
- display: none; /* Chrome, Safari and Opera */
28
- }
29
- overflow-y: scroll;
30
- }
31
-
32
- .hour-menu {
33
- .select__menu {
34
- border-top-right-radius: 0;
35
- border-bottom-right-radius: 0;
36
- }
37
- }
38
- .minute-menu {
39
- .select__menu {
40
- border-radius: 0;
41
- border-right: none;
42
- border-left: none;
43
- }
44
- }
45
- .period-menu {
46
- .select__menu {
47
- border-top-left-radius: 0;
48
- border-bottom-left-radius: 0;
49
- }
50
- }
51
- }