uikit-react-public 0.11.24 → 0.14.21
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.
- package/dist/components/Badge/Badge.d.ts +6 -0
- package/dist/components/Badge/Badge.stories.d.ts +15 -0
- package/dist/components/Badge/index.d.ts +2 -0
- package/dist/components/Button/Button.d.ts +2 -1
- package/dist/components/CookieNotice/CookieNotice.d.ts +16 -0
- package/dist/components/CookieNotice/index.d.ts +2 -0
- package/dist/components/Dialog/BaseDialog.d.ts +7 -2
- package/dist/components/FileInput/FileInput.d.ts +8 -0
- package/dist/components/FileInput/FileInput.stories.d.ts +16 -0
- package/dist/components/FileInput/__tests__/FileInput.test.d.ts +1 -0
- package/dist/components/FileInput/index.d.ts +2 -0
- package/dist/components/Header/Header.d.ts +4 -1
- package/dist/components/Heading/Heading.d.ts +1 -1
- package/dist/components/Link/BaseLink.d.ts +10 -0
- package/dist/components/Link/Link.d.ts +5 -10
- package/dist/components/Link/Link.stories.d.ts +1 -1
- package/dist/components/Link/index.d.ts +1 -1
- package/dist/components/Menu/MenuContent.d.ts +1 -1
- package/dist/components/Menu/MenuItem.d.ts +2 -0
- package/dist/components/Menu/MenuSection.d.ts +1 -1
- package/dist/components/Search/Search.d.ts +16 -0
- package/dist/components/Search/Search.stories.d.ts +34 -0
- package/dist/components/Search/__tests__/Search.test.d.ts +1 -0
- package/dist/components/Search/index.d.ts +2 -0
- package/dist/components/Select/Select.d.ts +1 -1
- package/dist/components/Select/Select.stories.d.ts +3 -7
- package/dist/components/Select/Select.types.d.ts +19 -14
- package/dist/components/Select/subcomponents/CustomOption.d.ts +1 -1
- package/dist/components/Select/subcomponents/CustomSelect.d.ts +1 -2
- package/dist/components/Select/subcomponents/Panel.d.ts +1 -1
- package/dist/components/Select/subcomponents/VisibleField.d.ts +4 -4
- package/dist/components/StandaloneLink/StandaloneLink.d.ts +12 -0
- package/dist/components/StandaloneLink/StandaloneLink.stories.d.ts +13 -0
- package/dist/components/StandaloneLink/__tests__/StandaloneLink.test.d.ts +1 -0
- package/dist/components/StandaloneLink/index.d.ts +2 -0
- package/dist/components/Table/Table.d.ts +10 -8
- package/dist/components/Table/Table.stories.d.ts +21 -0
- package/dist/components/Table/Table.types.d.ts +11 -0
- package/dist/components/Table/__tests__/Table.test.d.ts +1 -0
- package/dist/components/Table/index.d.ts +2 -1
- package/dist/components/Table/subcomponents/Body.d.ts +4 -0
- package/dist/components/Table/subcomponents/Cell/Cell.d.ts +12 -0
- package/dist/components/Table/subcomponents/Cell/Cell.stories.d.ts +313 -0
- package/dist/components/Table/subcomponents/Cell/CellContent.d.ts +10 -0
- package/dist/components/Table/subcomponents/Cell/__tests__/Cell.test.d.ts +1 -0
- package/dist/components/Table/subcomponents/Head.d.ts +4 -0
- package/dist/components/Table/subcomponents/HeadCell/HeadCell.d.ts +13 -0
- package/dist/components/Table/subcomponents/HeadCell/HeadCell.stories.d.ts +312 -0
- package/dist/components/Table/subcomponents/HeadCell/HeadCellContent.d.ts +10 -0
- package/dist/components/Table/subcomponents/HeadCell/__tests__/HeadCell.test.d.ts +1 -0
- package/dist/components/Table/subcomponents/Row.d.ts +5 -0
- package/dist/components/Table/subcomponents/SortIcon.d.ts +7 -0
- package/dist/components/Table/subcomponents/index.d.ts +10 -0
- package/dist/components/Tabs/Tab.d.ts +1 -1
- package/dist/components/Tabs/TabContext.d.ts +1 -0
- package/dist/components/Tabs/Tabs.d.ts +3 -1
- package/dist/components/Tabs/Tabs.stories.d.ts +3 -0
- package/dist/components/Timepicker/Timepicker.d.ts +10 -0
- package/dist/components/Timepicker/Timepicker.stories.d.ts +7 -0
- package/dist/components/Timepicker/__tests__/Timepicker.test.d.ts +1 -0
- package/dist/components/Timepicker/index.d.ts +2 -0
- package/dist/components/Timepicker/utils/convertDateToTimeString.d.ts +2 -0
- package/dist/components/Timepicker/utils/convertDateToTimeString.test.d.ts +1 -0
- package/dist/components/Timepicker/utils/index.d.ts +1 -0
- package/dist/components/WeekPicker/WeekPicker.d.ts +3 -0
- package/dist/components/WeekPicker/index.d.ts +1 -0
- package/dist/components/WeekPicker/subcomponents/CustomDatepicker.d.ts +17 -0
- package/dist/components/WeekPicker/subcomponents/DatepickerInput.d.ts +13 -0
- package/dist/components/WeekPicker/subcomponents/VisibleField.d.ts +15 -0
- package/dist/components/WeekPicker/subcomponents/index.d.ts +3 -0
- package/dist/components/index.d.ts +11 -0
- package/dist/hooks/index.d.ts +2 -0
- package/dist/hooks/useFocusTrap.d.ts +9 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +5703 -4448
- package/dist/theme/defaultTheme.d.ts +7 -0
- package/dist/theme/useTheme.d.ts +14 -0
- package/dist/utils/__tests__/capitalise.test.d.ts +1 -0
- package/dist/utils/capitalise.d.ts +2 -0
- package/lib/components/Alert/Alert.tsx +7 -1
- package/lib/components/Alert/__tests__/__snapshots__/Alert.test.tsx.snap +4 -0
- package/lib/components/Badge/Badge.stories.tsx +19 -0
- package/lib/components/Badge/Badge.tsx +48 -0
- package/lib/components/Badge/index.ts +2 -0
- package/lib/components/Breadcrumbs/__tests__/__snapshots__/Breadcrumbs.test.tsx.snap +4 -4
- package/lib/components/Button/Button.tsx +5 -2
- package/lib/components/Calendar/subcomponents/Grid.tsx +0 -1
- package/lib/components/CookieNotice/CookieNotice.tsx +114 -0
- package/lib/components/CookieNotice/index.ts +2 -0
- package/lib/components/Dialog/BaseDialog.tsx +44 -4
- package/lib/components/Field/__tests__/Field.test.tsx +148 -148
- package/lib/components/FileInput/FileInput.stories.tsx +70 -0
- package/lib/components/FileInput/FileInput.tsx +68 -0
- package/lib/components/FileInput/__tests__/FileInput.test.tsx +99 -0
- package/lib/components/FileInput/__tests__/__snapshots__/FileInput.test.tsx.snap +91 -0
- package/lib/components/FileInput/index.ts +2 -0
- package/lib/components/Footer/__tests__/__snapshots__/Footer.test.tsx.snap +25 -25
- package/lib/components/Header/Header.tsx +19 -2
- package/lib/components/Header/__tests__/__snapshots__/Header.test.tsx.snap +4 -4
- package/lib/components/Heading/Documentation.mdx +1 -1
- package/lib/components/Heading/Heading.tsx +1 -1
- package/lib/components/Heading/__tests__/Heading.test.tsx +7 -19
- package/lib/components/Heading/__tests__/__snapshots__/Heading.test.tsx.snap +7 -7
- package/lib/components/Label/Label.tsx +0 -2
- package/lib/components/Label/__tests__/__snapshots__/Label.test.tsx.snap +7 -7
- package/lib/components/Link/BaseLink.tsx +84 -0
- package/lib/components/Link/Link.tsx +72 -32
- package/lib/components/Link/__tests__/__snapshots__/link.test.tsx.snap +3 -3
- package/lib/components/Link/__tests__/link.test.tsx +6 -13
- package/lib/components/Link/index.ts +1 -1
- package/lib/components/Menu/Menu.context.tsx +3 -1
- package/lib/components/Menu/Menu.tsx +2 -2
- package/lib/components/Menu/MenuContent.tsx +5 -5
- package/lib/components/Menu/MenuItem.tsx +20 -3
- package/lib/components/Menu/MenuSection.tsx +4 -3
- package/lib/components/Pagination/PaginationControls.tsx +1 -3
- package/lib/components/Search/Search.stories.tsx +41 -0
- package/lib/components/Search/Search.tsx +167 -0
- package/lib/components/Search/__tests__/Search.test.tsx +94 -0
- package/lib/components/Search/__tests__/__snapshots__/Search.test.tsx.snap +179 -0
- package/lib/components/Search/index.ts +2 -0
- package/lib/components/Select/Select.stories.tsx +8 -35
- package/lib/components/Select/Select.tsx +2 -2
- package/lib/components/Select/Select.types.ts +20 -15
- package/lib/components/Select/__tests__/__snapshots__/Select.test.tsx.snap +3 -3
- package/lib/components/Select/subcomponents/CustomOption.tsx +22 -9
- package/lib/components/Select/subcomponents/CustomSelect.tsx +31 -20
- package/lib/components/Select/subcomponents/Panel.tsx +4 -5
- package/lib/components/Select/subcomponents/VisibleField.tsx +26 -22
- package/lib/components/StandaloneLink/StandaloneLink.stories.tsx +32 -0
- package/lib/components/StandaloneLink/StandaloneLink.tsx +183 -0
- package/lib/components/StandaloneLink/__tests__/StandaloneLink.test.tsx +57 -0
- package/lib/components/StandaloneLink/__tests__/__snapshots__/StandaloneLink.test.tsx.snap +19 -0
- package/lib/components/StandaloneLink/index.ts +2 -0
- package/lib/components/Table/Table.stories.tsx +337 -0
- package/lib/components/Table/Table.tsx +42 -67
- package/lib/components/Table/Table.types.ts +14 -0
- package/lib/components/Table/__tests__/Table.test.tsx +121 -0
- package/lib/components/Table/__tests__/__snapshots__/Table.test.tsx.snap +210 -0
- package/lib/components/Table/index.ts +8 -1
- package/lib/components/Table/subcomponents/Body.tsx +18 -0
- package/lib/components/Table/subcomponents/Cell/Cell.stories.tsx +151 -0
- package/lib/components/Table/subcomponents/Cell/Cell.tsx +72 -0
- package/lib/components/Table/subcomponents/Cell/CellContent.tsx +91 -0
- package/lib/components/Table/subcomponents/Cell/__tests__/Cell.test.tsx +115 -0
- package/lib/components/Table/subcomponents/Cell/__tests__/__snapshots__/Cell.test.tsx.snap +107 -0
- package/lib/components/Table/subcomponents/Head.tsx +34 -0
- package/lib/components/Table/subcomponents/HeadCell/HeadCell.stories.tsx +85 -0
- package/lib/components/Table/subcomponents/HeadCell/HeadCell.tsx +99 -0
- package/lib/components/Table/subcomponents/HeadCell/HeadCellContent.tsx +61 -0
- package/lib/components/Table/subcomponents/HeadCell/__tests__/HeadCell.test.tsx +137 -0
- package/lib/components/Table/subcomponents/HeadCell/__tests__/__snapshots__/HeadCell.test.tsx.snap +110 -0
- package/lib/components/Table/subcomponents/Row.tsx +49 -0
- package/lib/components/Table/subcomponents/SortIcon.tsx +63 -0
- package/lib/components/Table/subcomponents/index.ts +14 -0
- package/lib/components/Tabs/Tab.tsx +3 -3
- package/lib/components/Tabs/TabContext.tsx +1 -0
- package/lib/components/Tabs/Tabs.stories.tsx +9 -3
- package/lib/components/Tabs/Tabs.tsx +10 -32
- package/lib/components/Tabs/__tests__/Tabs.test.tsx +10 -4
- package/lib/components/Tabs/__tests__/__snapshots__/Tabs.test.tsx.snap +32 -32
- package/lib/components/Timepicker/Timepicker.stories.tsx +43 -0
- package/lib/components/Timepicker/Timepicker.tsx +96 -0
- package/lib/components/Timepicker/__tests__/Timepicker.test.tsx +55 -0
- package/lib/components/Timepicker/__tests__/__snapshots__/Timepicker.test.tsx.snap +19 -0
- package/lib/components/Timepicker/index.tsx +2 -0
- package/lib/components/Timepicker/utils/convertDateToTimeString.test.ts +54 -0
- package/lib/components/Timepicker/utils/convertDateToTimeString.ts +10 -0
- package/lib/components/Timepicker/utils/index.ts +1 -0
- package/lib/components/WeekPicker/WeekPicker.tsx +26 -0
- package/lib/components/WeekPicker/index.ts +1 -0
- package/lib/components/WeekPicker/subcomponents/CustomDatepicker.tsx +298 -0
- package/lib/components/WeekPicker/subcomponents/DatepickerInput.tsx +111 -0
- package/lib/components/WeekPicker/subcomponents/VisibleField.tsx +126 -0
- package/lib/components/WeekPicker/subcomponents/index.ts +3 -0
- package/lib/components/index.ts +17 -0
- package/lib/hooks/index.ts +2 -0
- package/lib/hooks/useFocusTrap.ts +123 -0
- package/lib/index.ts +1 -0
- package/lib/theme/defaultTheme.ts +7 -0
- package/lib/utils/__tests__/capitalise.test.ts +40 -0
- package/lib/utils/capitalise.ts +4 -0
- package/package.json +1 -1
- package/lib/components/Field/__tests__/__snapshots__/Field.test.tsx.snap +0 -300
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import convertDatetoTimeString from './convertDateToTimeString';
|
|
3
|
+
|
|
4
|
+
describe('convertDatetoTimeString', () => {
|
|
5
|
+
it('should return a formatted time string for a valid Date object', () => {
|
|
6
|
+
const date = new Date();
|
|
7
|
+
date.setHours(9);
|
|
8
|
+
date.setMinutes(5);
|
|
9
|
+
|
|
10
|
+
const result = convertDatetoTimeString(date);
|
|
11
|
+
expect(result).toBe('09:05');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('should handle single-digit hours and minutes correctly', () => {
|
|
15
|
+
const date = new Date();
|
|
16
|
+
date.setHours(3);
|
|
17
|
+
date.setMinutes(7);
|
|
18
|
+
|
|
19
|
+
const result = convertDatetoTimeString(date);
|
|
20
|
+
expect(result).toBe('03:07');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('should handle double-digit hours and minutes correctly', () => {
|
|
24
|
+
const date = new Date();
|
|
25
|
+
date.setHours(12);
|
|
26
|
+
date.setMinutes(30);
|
|
27
|
+
|
|
28
|
+
const result = convertDatetoTimeString(date);
|
|
29
|
+
expect(result).toBe('12:30');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should return an empty string for null input', () => {
|
|
33
|
+
const result = convertDatetoTimeString(null);
|
|
34
|
+
expect(result).toBe('');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should handle midnight (00:00) correctly', () => {
|
|
38
|
+
const date = new Date();
|
|
39
|
+
date.setHours(0);
|
|
40
|
+
date.setMinutes(0);
|
|
41
|
+
|
|
42
|
+
const result = convertDatetoTimeString(date);
|
|
43
|
+
expect(result).toBe('00:00');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should handle the last minute of the day (23:59) correctly', () => {
|
|
47
|
+
const date = new Date();
|
|
48
|
+
date.setHours(23);
|
|
49
|
+
date.setMinutes(59);
|
|
50
|
+
|
|
51
|
+
const result = convertDatetoTimeString(date);
|
|
52
|
+
expect(result).toBe('23:59');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const convertDatetoTimeString = (time: Date | null): string => {
|
|
2
|
+
if (!time) {
|
|
3
|
+
return '';
|
|
4
|
+
}
|
|
5
|
+
const hours = time.getHours().toString().padStart(2, '0');
|
|
6
|
+
const minutes = time.getMinutes().toString().padStart(2, '0');
|
|
7
|
+
return `${hours}:${minutes}`;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export default convertDatetoTimeString;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as convertDatetoTimeString } from './convertDateToTimeString';
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { CustomDatepicker } from './subcomponents';
|
|
2
|
+
import type { DatepickerProps } from '../Datepicker';
|
|
3
|
+
|
|
4
|
+
const WeekPicker = ({
|
|
5
|
+
value,
|
|
6
|
+
onValueChange,
|
|
7
|
+
minDate,
|
|
8
|
+
maxDate,
|
|
9
|
+
disabled,
|
|
10
|
+
className,
|
|
11
|
+
...props
|
|
12
|
+
}: DatepickerProps) => {
|
|
13
|
+
return (
|
|
14
|
+
<CustomDatepicker
|
|
15
|
+
value={value}
|
|
16
|
+
onValueChange={onValueChange}
|
|
17
|
+
minDate={minDate}
|
|
18
|
+
maxDate={maxDate}
|
|
19
|
+
className={className}
|
|
20
|
+
disabled={disabled}
|
|
21
|
+
{...props}
|
|
22
|
+
/>
|
|
23
|
+
);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export default WeekPicker;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from './WeekPicker';
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { useRef, useEffect, useState, useCallback } from 'react';
|
|
2
|
+
import { css, cx } from '@emotion/css';
|
|
3
|
+
import { VisibleField } from './';
|
|
4
|
+
import { Panel } from '../../Datepicker/subcomponents';
|
|
5
|
+
import { Calendar, Icon, IconButton } from '../..';
|
|
6
|
+
import { parseInputValue } from '../../Datepicker/utils';
|
|
7
|
+
import type { DatepickerValue } from '../../Datepicker/Datepicker.types';
|
|
8
|
+
import type { CalendarEvent, AcademicWeek } from '../../Calendar';
|
|
9
|
+
|
|
10
|
+
interface CustomDatepickerProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
11
|
+
value?: DatepickerValue;
|
|
12
|
+
onValueChange?: (
|
|
13
|
+
value: DatepickerValue,
|
|
14
|
+
event?: React.SyntheticEvent
|
|
15
|
+
) => void;
|
|
16
|
+
minDate?: string | null; // ISO date string: YYYY-MM-DD
|
|
17
|
+
maxDate?: string | null; // ISO date string: YYYY-MM-DD
|
|
18
|
+
disabled?: boolean;
|
|
19
|
+
events?: CalendarEvent[];
|
|
20
|
+
showAcademicWeeks?: boolean;
|
|
21
|
+
academicWeeks?: AcademicWeek[];
|
|
22
|
+
testId?: string;
|
|
23
|
+
ref?: React.RefObject<HTMLDivElement>;
|
|
24
|
+
inputRef?: React.RefObject<HTMLInputElement>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const NAME = 'ucl-uikit-weekpicker';
|
|
28
|
+
|
|
29
|
+
const CustomDatepicker = ({
|
|
30
|
+
value = null,
|
|
31
|
+
onValueChange = () => {},
|
|
32
|
+
minDate,
|
|
33
|
+
maxDate,
|
|
34
|
+
disabled = false,
|
|
35
|
+
events,
|
|
36
|
+
showAcademicWeeks,
|
|
37
|
+
academicWeeks = [],
|
|
38
|
+
testId = NAME,
|
|
39
|
+
className,
|
|
40
|
+
ref,
|
|
41
|
+
inputRef,
|
|
42
|
+
...props
|
|
43
|
+
}: CustomDatepickerProps) => {
|
|
44
|
+
if (value && isNaN(value.getTime())) {
|
|
45
|
+
console.warn('CustomDatepicker: value is invalid, defaulting to null');
|
|
46
|
+
value = null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseDateFromInput(input: string): Date | null {
|
|
50
|
+
if (!input) return null;
|
|
51
|
+
const parts = input.split('/');
|
|
52
|
+
if (parts.length !== 3) return null;
|
|
53
|
+
|
|
54
|
+
const day = parseInt(parts[0]);
|
|
55
|
+
const month = parseInt(parts[1]) - 1;
|
|
56
|
+
const year = parseInt(parts[2]);
|
|
57
|
+
|
|
58
|
+
const date = new Date(year, month, day);
|
|
59
|
+
return isNaN(date.getTime()) ? null : date;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getWeekRange(date: Date, startOnMonday = true) {
|
|
63
|
+
const input = new Date(date);
|
|
64
|
+
input.setHours(0, 0, 0, 0);
|
|
65
|
+
|
|
66
|
+
const day = input.getDay();
|
|
67
|
+
|
|
68
|
+
const diffToMonday = startOnMonday ? (day === 0 ? -6 : 1 - day) : -day;
|
|
69
|
+
|
|
70
|
+
const start = new Date(input);
|
|
71
|
+
start.setDate(input.getDate() + diffToMonday);
|
|
72
|
+
|
|
73
|
+
const end = new Date(start);
|
|
74
|
+
end.setDate(start.getDate() + 6);
|
|
75
|
+
|
|
76
|
+
return { start, end };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const internalRef = useRef<HTMLDivElement>(null);
|
|
80
|
+
const effectiveRef = ref || internalRef;
|
|
81
|
+
const internalInputRef = useRef<HTMLInputElement>(null);
|
|
82
|
+
const effectiveInputRef = inputRef || internalInputRef;
|
|
83
|
+
|
|
84
|
+
const [panelOpen, setPanelOpen] = useState(false);
|
|
85
|
+
|
|
86
|
+
// Derived props (tidier than using `date?.getDate()`, etc, everywhere.)
|
|
87
|
+
const day = value?.getDate().toString().padStart(2, '0') ?? '';
|
|
88
|
+
const month = value ? (value.getMonth() + 1).toString().padStart(2, '0') : '';
|
|
89
|
+
const year = value?.getFullYear().toString() ?? '';
|
|
90
|
+
const formattedDateString = value ? `${day}/${month}/${year}` : '';
|
|
91
|
+
|
|
92
|
+
const [inputValue, setInputValue] = useState(formattedDateString);
|
|
93
|
+
|
|
94
|
+
const resetField = useCallback(() => {
|
|
95
|
+
setInputValue(formattedDateString);
|
|
96
|
+
}, [setInputValue, formattedDateString]);
|
|
97
|
+
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
// Reset the input field when the value changes.
|
|
100
|
+
resetField();
|
|
101
|
+
}, [value, resetField]);
|
|
102
|
+
|
|
103
|
+
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
104
|
+
setInputValue(event.target.value);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const handleParseInput = (event: React.SyntheticEvent) => {
|
|
108
|
+
// `parseInputValue` checks the date is valid and within min/max range.
|
|
109
|
+
const parseDate = parseInputValue(inputValue, minDate, maxDate);
|
|
110
|
+
if (parseDate) {
|
|
111
|
+
onValueChange(parseDate, event);
|
|
112
|
+
} else {
|
|
113
|
+
resetField();
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
118
|
+
if (event.key === 'Enter') {
|
|
119
|
+
handleParseInput(event);
|
|
120
|
+
} else if (event.key === 'Escape') {
|
|
121
|
+
resetField();
|
|
122
|
+
effectiveInputRef.current?.blur();
|
|
123
|
+
setPanelOpen(false);
|
|
124
|
+
} else if (event.key === 'Tab') {
|
|
125
|
+
resetField();
|
|
126
|
+
setPanelOpen(false);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// If `value` is out of range, automatically set it to null.
|
|
131
|
+
// This is validation to prevent parent components passing invalid dates.
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
const isDateOutOfRange = (date: Date | null): boolean => {
|
|
134
|
+
if (!date) return false;
|
|
135
|
+
|
|
136
|
+
const normaliseDate = (date: Date) => {
|
|
137
|
+
date.setHours(0, 0, 0, 0); // Normalize to UTC midnight to avoid time zone issues
|
|
138
|
+
return date;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const parsedMinDate = minDate ? normaliseDate(new Date(minDate)) : null;
|
|
142
|
+
const parsedMaxDate = maxDate ? normaliseDate(new Date(maxDate)) : null;
|
|
143
|
+
const normalisedDate = normaliseDate(date);
|
|
144
|
+
|
|
145
|
+
if (parsedMinDate && normalisedDate < parsedMinDate) return true;
|
|
146
|
+
if (parsedMaxDate && normalisedDate > parsedMaxDate) return true;
|
|
147
|
+
return false;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
if (value && isDateOutOfRange(value)) {
|
|
151
|
+
console.warn('CustomDatepicker: value is out of range, setting to null');
|
|
152
|
+
onValueChange(null);
|
|
153
|
+
}
|
|
154
|
+
}, [value, minDate, maxDate, onValueChange]);
|
|
155
|
+
|
|
156
|
+
// Close the panel & reset the input field if we click away.
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
159
|
+
if (
|
|
160
|
+
effectiveRef.current &&
|
|
161
|
+
!effectiveRef.current.contains(event.target as Node)
|
|
162
|
+
) {
|
|
163
|
+
setPanelOpen(false);
|
|
164
|
+
resetField();
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
168
|
+
return () => {
|
|
169
|
+
document.removeEventListener('mousedown', handleClickOutside);
|
|
170
|
+
};
|
|
171
|
+
}, [effectiveRef, setPanelOpen, resetField]);
|
|
172
|
+
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
// Close the panel if the <Datepicker> becomes disabled
|
|
175
|
+
if (disabled && panelOpen) setPanelOpen(false);
|
|
176
|
+
}, [disabled, panelOpen]);
|
|
177
|
+
|
|
178
|
+
const handlePickCalendarDate = (
|
|
179
|
+
date: Date | null,
|
|
180
|
+
event?: React.SyntheticEvent
|
|
181
|
+
) => {
|
|
182
|
+
if (onValueChange) {
|
|
183
|
+
onValueChange(date, event);
|
|
184
|
+
}
|
|
185
|
+
setPanelOpen(false);
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const handleShowPanel = () => {
|
|
189
|
+
if (disabled) return;
|
|
190
|
+
setPanelOpen(true);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const handleTogglePanel = () => {
|
|
194
|
+
if (disabled) return;
|
|
195
|
+
setPanelOpen((prev) => !prev);
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const baseStyle = css`
|
|
199
|
+
box-sizing: border-box;
|
|
200
|
+
position: relative;
|
|
201
|
+
display: flex;
|
|
202
|
+
align-items: center;
|
|
203
|
+
justify-content: space-between;
|
|
204
|
+
gap: 16px;
|
|
205
|
+
`;
|
|
206
|
+
|
|
207
|
+
const style = cx(NAME, className, baseStyle);
|
|
208
|
+
|
|
209
|
+
const parsedDate = parseDateFromInput(inputValue);
|
|
210
|
+
const weekRange = parsedDate ? getWeekRange(parsedDate) : null;
|
|
211
|
+
|
|
212
|
+
let academicWeekNumber: number | null = null;
|
|
213
|
+
if (parsedDate) {
|
|
214
|
+
for (let i = 0; i < academicWeeks.length; i++) {
|
|
215
|
+
const weekStart = new Date(academicWeeks[i].start);
|
|
216
|
+
const nextWeekStart =
|
|
217
|
+
i + 1 < academicWeeks.length
|
|
218
|
+
? new Date(academicWeeks[i + 1].start)
|
|
219
|
+
: null;
|
|
220
|
+
|
|
221
|
+
weekStart.setHours(0, 0, 0, 0);
|
|
222
|
+
if (nextWeekStart) nextWeekStart.setHours(0, 0, 0, 0);
|
|
223
|
+
|
|
224
|
+
if (
|
|
225
|
+
parsedDate >= weekStart &&
|
|
226
|
+
(!nextWeekStart || parsedDate < nextWeekStart)
|
|
227
|
+
) {
|
|
228
|
+
academicWeekNumber = academicWeeks[i].number;
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const handlePrevWeek = () => {
|
|
235
|
+
if (!parsedDate) return;
|
|
236
|
+
const newDate = new Date(parsedDate);
|
|
237
|
+
newDate.setDate(parsedDate.getDate() - 7);
|
|
238
|
+
onValueChange(newDate);
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const handleNextWeek = () => {
|
|
242
|
+
if (!parsedDate) return;
|
|
243
|
+
const newDate = new Date(parsedDate);
|
|
244
|
+
newDate.setDate(parsedDate.getDate() + 7);
|
|
245
|
+
onValueChange(newDate);
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
return (
|
|
249
|
+
<div
|
|
250
|
+
className={style}
|
|
251
|
+
data-testid={testId}
|
|
252
|
+
ref={effectiveRef}
|
|
253
|
+
{...props}
|
|
254
|
+
>
|
|
255
|
+
<IconButton
|
|
256
|
+
onClick={handlePrevWeek}
|
|
257
|
+
disabled={disabled}
|
|
258
|
+
aria-label='Go to previous week'
|
|
259
|
+
>
|
|
260
|
+
<Icon.ChevronLeft />
|
|
261
|
+
</IconButton>
|
|
262
|
+
<VisibleField
|
|
263
|
+
inputValue={inputValue}
|
|
264
|
+
onInputChange={handleInputChange}
|
|
265
|
+
onInputKeyDown={handleInputKeyDown}
|
|
266
|
+
onInputFocus={handleShowPanel}
|
|
267
|
+
onButtonClick={handleTogglePanel}
|
|
268
|
+
disabled={disabled}
|
|
269
|
+
inputRef={effectiveInputRef}
|
|
270
|
+
beginningOfWeek={weekRange ? weekRange.start.toDateString() : ''}
|
|
271
|
+
endOfWeek={weekRange ? weekRange.end.toDateString() : ''}
|
|
272
|
+
academicWeekNumber={academicWeekNumber ?? undefined}
|
|
273
|
+
/>
|
|
274
|
+
<IconButton
|
|
275
|
+
onClick={handleNextWeek}
|
|
276
|
+
disabled={disabled}
|
|
277
|
+
aria-label='Go to next week'
|
|
278
|
+
>
|
|
279
|
+
<Icon.ChevronRight />
|
|
280
|
+
</IconButton>
|
|
281
|
+
{panelOpen && (
|
|
282
|
+
<Panel>
|
|
283
|
+
<Calendar
|
|
284
|
+
pickedDate={value}
|
|
285
|
+
onDatePick={handlePickCalendarDate}
|
|
286
|
+
minDate={minDate}
|
|
287
|
+
maxDate={maxDate}
|
|
288
|
+
events={events}
|
|
289
|
+
showAcademicWeeks={showAcademicWeeks}
|
|
290
|
+
academicWeeks={academicWeeks}
|
|
291
|
+
/>
|
|
292
|
+
</Panel>
|
|
293
|
+
)}
|
|
294
|
+
</div>
|
|
295
|
+
);
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
export default CustomDatepicker;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { css, cx } from '@emotion/css';
|
|
2
|
+
import { useTheme } from '../../../theme';
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
|
|
5
|
+
interface DatepickerInputProps {
|
|
6
|
+
value: string;
|
|
7
|
+
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
|
8
|
+
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void;
|
|
9
|
+
onFocus: () => void;
|
|
10
|
+
disabled: boolean;
|
|
11
|
+
ref: React.RefObject<HTMLInputElement | null>;
|
|
12
|
+
beginningOfWeek?: string;
|
|
13
|
+
endOfWeek?: string;
|
|
14
|
+
academicWeekNumber?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const NAME = 'ucl-uikit-weekpicker__input';
|
|
18
|
+
|
|
19
|
+
const DatepickerInput = ({
|
|
20
|
+
value,
|
|
21
|
+
onChange,
|
|
22
|
+
onKeyDown,
|
|
23
|
+
onFocus,
|
|
24
|
+
disabled,
|
|
25
|
+
beginningOfWeek,
|
|
26
|
+
endOfWeek,
|
|
27
|
+
academicWeekNumber,
|
|
28
|
+
ref,
|
|
29
|
+
}: DatepickerInputProps) => {
|
|
30
|
+
const [theme] = useTheme();
|
|
31
|
+
|
|
32
|
+
const [isFocused, setIsFocused] = useState(false);
|
|
33
|
+
|
|
34
|
+
const baseStyle = css`
|
|
35
|
+
width: 100%;
|
|
36
|
+
height: 100%;
|
|
37
|
+
border: none;
|
|
38
|
+
color: ${theme.color.text.primary};
|
|
39
|
+
font-family: ${theme.font.family.primary};
|
|
40
|
+
font-size: ${theme.font.size.f16};
|
|
41
|
+
letter-spacing: 1px;
|
|
42
|
+
caret-color: ${theme.color.text.primary};
|
|
43
|
+
padding: 0px;
|
|
44
|
+
|
|
45
|
+
&:focus {
|
|
46
|
+
outline: none;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
&::placeholder {
|
|
50
|
+
color: #8c8c8c; // TODO: Needs a design token -- Figma says 'color/text/tertiary'
|
|
51
|
+
}
|
|
52
|
+
`;
|
|
53
|
+
|
|
54
|
+
const disabledStyle = css`
|
|
55
|
+
color: ${theme.color.text.disabled};
|
|
56
|
+
background-color: ${theme.color.neutral.white};
|
|
57
|
+
|
|
58
|
+
&::placeholder {
|
|
59
|
+
color: ${theme.color.text.disabled};
|
|
60
|
+
}
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
const style = cx(NAME, baseStyle, disabled && disabledStyle);
|
|
64
|
+
|
|
65
|
+
const formatStart = (dateString: string) => {
|
|
66
|
+
const date = new Date(dateString);
|
|
67
|
+
return date.toLocaleDateString('en-GB', {
|
|
68
|
+
day: '2-digit',
|
|
69
|
+
month: 'short',
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const formatEnd = (dateString: string) => {
|
|
74
|
+
const date = new Date(dateString);
|
|
75
|
+
return date.toLocaleDateString('en-GB', {
|
|
76
|
+
day: '2-digit',
|
|
77
|
+
month: 'short',
|
|
78
|
+
year: 'numeric',
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const formattedLabel =
|
|
83
|
+
beginningOfWeek && endOfWeek
|
|
84
|
+
? `${formatStart(beginningOfWeek)} - ${formatEnd(endOfWeek)}`
|
|
85
|
+
: '';
|
|
86
|
+
|
|
87
|
+
const displayValue = isFocused ? value : formattedLabel;
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<input
|
|
91
|
+
value={displayValue}
|
|
92
|
+
onChange={onChange}
|
|
93
|
+
onKeyDown={onKeyDown}
|
|
94
|
+
onFocus={() => {
|
|
95
|
+
setIsFocused(true);
|
|
96
|
+
onFocus();
|
|
97
|
+
}}
|
|
98
|
+
onBlur={() => setIsFocused(false)}
|
|
99
|
+
disabled={disabled}
|
|
100
|
+
type='text'
|
|
101
|
+
inputMode='numeric'
|
|
102
|
+
placeholder='DD/MM/YYYY'
|
|
103
|
+
className={style}
|
|
104
|
+
data-testid={NAME}
|
|
105
|
+
ref={ref}
|
|
106
|
+
aria-label={`Currently selected date: ${value} - Week from ${formattedLabel}. ${academicWeekNumber ? `(Academic Week ${academicWeekNumber})` : ''}`}
|
|
107
|
+
/>
|
|
108
|
+
);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export default DatepickerInput;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { css, cx } from '@emotion/css';
|
|
2
|
+
import { DatepickerInput } from './';
|
|
3
|
+
import { Icon } from '../../..';
|
|
4
|
+
import { useTheme } from '../../../theme';
|
|
5
|
+
import React from 'react';
|
|
6
|
+
|
|
7
|
+
interface VisibleFieldProps {
|
|
8
|
+
inputValue: string;
|
|
9
|
+
onInputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
|
10
|
+
onInputKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void;
|
|
11
|
+
onInputFocus: () => void;
|
|
12
|
+
onButtonClick: () => void;
|
|
13
|
+
disabled: boolean;
|
|
14
|
+
inputRef: React.RefObject<HTMLInputElement | null>;
|
|
15
|
+
beginningOfWeek?: string;
|
|
16
|
+
endOfWeek?: string;
|
|
17
|
+
academicWeekNumber?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const NAME = 'ucl-uikit-weekpicker__visible-field';
|
|
21
|
+
|
|
22
|
+
const VisibleField = ({
|
|
23
|
+
inputValue,
|
|
24
|
+
onInputChange,
|
|
25
|
+
onInputKeyDown,
|
|
26
|
+
onInputFocus,
|
|
27
|
+
onButtonClick,
|
|
28
|
+
disabled,
|
|
29
|
+
inputRef,
|
|
30
|
+
beginningOfWeek,
|
|
31
|
+
endOfWeek,
|
|
32
|
+
academicWeekNumber,
|
|
33
|
+
}: VisibleFieldProps) => {
|
|
34
|
+
const [theme] = useTheme();
|
|
35
|
+
|
|
36
|
+
const baseStyle = css`
|
|
37
|
+
display: flex;
|
|
38
|
+
align-items: center;
|
|
39
|
+
justify-content: space-between;
|
|
40
|
+
// width: 100%;
|
|
41
|
+
// height: 100%;
|
|
42
|
+
box-sizing: border-box;
|
|
43
|
+
border: 1px solid ${theme.color.neutral.grey60};
|
|
44
|
+
background-color: ${theme.color.neutral.white};
|
|
45
|
+
padding: 3px 16px;
|
|
46
|
+
|
|
47
|
+
&:focus-within {
|
|
48
|
+
box-shadow: ${theme.boxShadow.focus};
|
|
49
|
+
}
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
const disabledStyle = css`
|
|
53
|
+
color: ${theme.color.text.disabled};
|
|
54
|
+
background-color: ${theme.color.neutral.white};
|
|
55
|
+
border-color: ${theme.color.neutral.grey20};
|
|
56
|
+
|
|
57
|
+
cursor: not-allowed;
|
|
58
|
+
// And child elements
|
|
59
|
+
& * {
|
|
60
|
+
cursor: not-allowed;
|
|
61
|
+
}
|
|
62
|
+
`;
|
|
63
|
+
|
|
64
|
+
// The container for the <Icon.Calendar> is a <div> not an <IconButton>,
|
|
65
|
+
// so that the orange border only appears when the input has focus and the user can type.
|
|
66
|
+
// The container increases the clickable area of the icon.
|
|
67
|
+
const iconButtonStyle = css`
|
|
68
|
+
display: flex;
|
|
69
|
+
align-items: center;
|
|
70
|
+
justify-content: center;
|
|
71
|
+
height: 100%;
|
|
72
|
+
cursor: pointer;
|
|
73
|
+
padding-right: 8px;
|
|
74
|
+
`;
|
|
75
|
+
|
|
76
|
+
// `min-width` here accounts for a recurring problem,
|
|
77
|
+
// in which icons shrink when horizontal space is limited.
|
|
78
|
+
// TODO: This ought to be fixed in the <Icon> component itself.
|
|
79
|
+
const iconStyle = css`
|
|
80
|
+
min-width: 16px;
|
|
81
|
+
color: ${disabled
|
|
82
|
+
? theme.color.text.disabled
|
|
83
|
+
: theme.color.text.primary}; // TODO: Needs a design token
|
|
84
|
+
`;
|
|
85
|
+
|
|
86
|
+
const style = cx(NAME, baseStyle, disabled && disabledStyle);
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div
|
|
90
|
+
className={style}
|
|
91
|
+
data-testid={NAME}
|
|
92
|
+
>
|
|
93
|
+
<div
|
|
94
|
+
onClick={onButtonClick}
|
|
95
|
+
className={iconButtonStyle}
|
|
96
|
+
>
|
|
97
|
+
<Icon.Calendar
|
|
98
|
+
className={iconStyle}
|
|
99
|
+
size={16}
|
|
100
|
+
/>
|
|
101
|
+
</div>
|
|
102
|
+
<DatepickerInput
|
|
103
|
+
value={inputValue}
|
|
104
|
+
onChange={onInputChange}
|
|
105
|
+
onKeyDown={onInputKeyDown}
|
|
106
|
+
onFocus={onInputFocus}
|
|
107
|
+
disabled={disabled}
|
|
108
|
+
ref={inputRef}
|
|
109
|
+
beginningOfWeek={beginningOfWeek}
|
|
110
|
+
endOfWeek={endOfWeek}
|
|
111
|
+
academicWeekNumber={academicWeekNumber}
|
|
112
|
+
/>
|
|
113
|
+
{academicWeekNumber !== undefined && (
|
|
114
|
+
<span
|
|
115
|
+
className={css`
|
|
116
|
+
color: ${theme.color.text.secondary};
|
|
117
|
+
`}
|
|
118
|
+
>
|
|
119
|
+
W{academicWeekNumber}
|
|
120
|
+
</span>
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export default VisibleField;
|
package/lib/components/index.ts
CHANGED
|
@@ -7,6 +7,9 @@ export type { InputProps } from './Input';
|
|
|
7
7
|
export { default as Link } from './Link';
|
|
8
8
|
export type { LinkProps } from './Link';
|
|
9
9
|
|
|
10
|
+
export { default as StandaloneLink } from './StandaloneLink';
|
|
11
|
+
export type { StandaloneLinkProps } from './StandaloneLink';
|
|
12
|
+
|
|
10
13
|
export { default as Button } from './Button';
|
|
11
14
|
export type { ButtonProps } from './Button';
|
|
12
15
|
|
|
@@ -133,3 +136,17 @@ export type { DatepickerProps } from './Datepicker';
|
|
|
133
136
|
|
|
134
137
|
export { default as Calendar } from './Calendar';
|
|
135
138
|
export type { CalendarProps } from './Calendar';
|
|
139
|
+
|
|
140
|
+
export { default as WeekPicker } from './WeekPicker';
|
|
141
|
+
|
|
142
|
+
export { default as FileInput } from './FileInput';
|
|
143
|
+
export type { FileInputProps } from './FileInput';
|
|
144
|
+
|
|
145
|
+
export { default as Timepicker } from './Timepicker';
|
|
146
|
+
export type { TimepickerProps } from './Timepicker';
|
|
147
|
+
|
|
148
|
+
export { default as CookieNotice } from './CookieNotice';
|
|
149
|
+
export type { CookieNoticeProps } from './CookieNotice';
|
|
150
|
+
|
|
151
|
+
export { default as Search } from './Search';
|
|
152
|
+
export type { SearchProps } from './Search';
|