@vygruppen/spor-react 1.3.3 → 2.0.0

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 (197) hide show
  1. package/.turbo/turbo-build.log +12 -10
  2. package/CHANGELOG.md +40 -0
  3. package/README.md +1 -1
  4. package/dist/index.d.ts +6718 -27
  5. package/dist/index.js +14139 -140
  6. package/dist/index.mjs +13818 -27
  7. package/package.json +19 -31
  8. package/src/accordion/Accordion.test.tsx +20 -0
  9. package/src/accordion/Accordion.tsx +62 -0
  10. package/src/accordion/AccordionContext.tsx +27 -0
  11. package/src/accordion/Expandable.tsx +157 -0
  12. package/src/accordion/index.tsx +2 -0
  13. package/src/alert/AlertIcon.tsx +75 -0
  14. package/src/alert/BaseAlert.test.tsx +37 -0
  15. package/src/alert/BaseAlert.tsx +21 -0
  16. package/src/alert/ClosableAlert.test.tsx +37 -0
  17. package/src/alert/ClosableAlert.tsx +75 -0
  18. package/src/alert/ExpandableAlert.test.tsx +84 -0
  19. package/src/alert/ExpandableAlert.tsx +84 -0
  20. package/src/alert/StaticAlert.tsx +25 -0
  21. package/src/alert/index.tsx +3 -0
  22. package/src/button/Button.test.tsx +23 -0
  23. package/src/button/Button.tsx +162 -0
  24. package/src/button/ButtonGroup.tsx +43 -0
  25. package/src/button/CloseButton.tsx +63 -0
  26. package/src/button/FloatingActionButton.tsx +113 -0
  27. package/src/button/IconButton.tsx +63 -0
  28. package/src/button/index.tsx +5 -0
  29. package/src/card/Card.tsx +59 -0
  30. package/src/card/index.tsx +1 -0
  31. package/src/datepicker/Calendar.tsx +32 -0
  32. package/src/datepicker/CalendarCell.tsx +74 -0
  33. package/src/datepicker/CalendarGrid.tsx +76 -0
  34. package/src/datepicker/CalendarHeader.tsx +153 -0
  35. package/src/datepicker/CalendarNavigationButton.tsx +26 -0
  36. package/src/datepicker/CalendarTriggerButton.tsx +36 -0
  37. package/src/datepicker/DateField.tsx +51 -0
  38. package/src/datepicker/DatePicker.tsx +153 -0
  39. package/src/datepicker/DateRangePicker.tsx +165 -0
  40. package/src/datepicker/DateTimeSegment.tsx +56 -0
  41. package/src/datepicker/RangeCalendar.tsx +35 -0
  42. package/src/datepicker/StyledField.tsx +31 -0
  43. package/src/datepicker/TimeField.tsx +46 -0
  44. package/src/datepicker/TimePicker.test.tsx +74 -0
  45. package/src/datepicker/TimePicker.tsx +196 -0
  46. package/src/datepicker/index.tsx +4 -0
  47. package/src/datepicker/utils.ts +33 -0
  48. package/src/i18n/index.tsx +38 -0
  49. package/src/image/index.tsx +2 -0
  50. package/src/index.tsx +25 -26
  51. package/src/input/CardSelect.tsx +165 -0
  52. package/src/input/Checkbox.tsx +24 -0
  53. package/src/input/CheckboxGroup.tsx +43 -0
  54. package/src/input/ChoiceChip.tsx +102 -0
  55. package/src/input/Dialog.tsx +29 -0
  56. package/src/input/FormControl.tsx +11 -0
  57. package/src/input/FormErrorMessage.tsx +91 -0
  58. package/src/input/FormLabel.tsx +11 -0
  59. package/src/input/InfoSelect.tsx +209 -0
  60. package/src/input/Input.tsx +59 -0
  61. package/src/input/InputElement.tsx +45 -0
  62. package/src/input/ListBox.tsx +123 -0
  63. package/src/input/NativeSelect.tsx +38 -0
  64. package/src/input/PasswordInput.tsx +70 -0
  65. package/src/input/Popover.tsx +70 -0
  66. package/src/input/Radio.tsx +34 -0
  67. package/src/input/RadioGroup.tsx +47 -0
  68. package/src/input/SearchInput.tsx +89 -0
  69. package/src/input/Switch.tsx +40 -0
  70. package/src/input/Textarea.tsx +98 -0
  71. package/src/input/index.tsx +20 -0
  72. package/src/layout/Divider.tsx +26 -0
  73. package/src/layout/Stack.tsx +42 -0
  74. package/src/layout/index.tsx +28 -0
  75. package/src/linjetag/InfoTag.tsx +54 -0
  76. package/src/linjetag/LineIcon.tsx +44 -0
  77. package/src/linjetag/TravelTag.tsx +121 -0
  78. package/src/linjetag/icons.tsx +80 -0
  79. package/src/linjetag/index.tsx +3 -0
  80. package/src/linjetag/types.d.ts +24 -0
  81. package/src/link/TextLink.tsx +45 -0
  82. package/src/link/index.tsx +1 -0
  83. package/src/loader/ClientOnly.tsx +29 -0
  84. package/src/loader/ColorInlineLoader.tsx +27 -0
  85. package/src/loader/ColorSpinner.tsx +44 -0
  86. package/src/loader/ContentLoader.tsx +27 -0
  87. package/src/loader/DarkFullScreenLoader.tsx +23 -0
  88. package/src/loader/DarkInlineLoader.tsx +25 -0
  89. package/src/loader/DarkSpinner.tsx +43 -0
  90. package/src/loader/LightFullScreenLoader.tsx +23 -0
  91. package/src/loader/LightInlineLoader.tsx +25 -0
  92. package/src/loader/LightSpinner.tsx +41 -0
  93. package/src/loader/Lottie.tsx +10 -0
  94. package/src/loader/ProgressBar.tsx +128 -0
  95. package/src/loader/ProgressLoader.tsx +140 -0
  96. package/src/loader/Skeleton.tsx +16 -0
  97. package/src/loader/SkeletonCircle.tsx +13 -0
  98. package/src/loader/SkeletonText.tsx +10 -0
  99. package/src/loader/index.tsx +14 -0
  100. package/src/loader/useHydrated.tsx +34 -0
  101. package/src/loader/useRotatingLabel.tsx +22 -0
  102. package/src/logo/VyLogo.tsx +101 -0
  103. package/src/logo/index.tsx +1 -0
  104. package/src/media-controller/JumpButton.tsx +69 -0
  105. package/src/media-controller/PlayPauseButton.tsx +67 -0
  106. package/src/media-controller/SkipButton.tsx +66 -0
  107. package/src/media-controller/icons.tsx +80 -0
  108. package/src/media-controller/index.test.tsx +59 -0
  109. package/src/media-controller/index.tsx +3 -0
  110. package/src/modal/Drawer.tsx +122 -0
  111. package/src/modal/Modal.tsx +15 -0
  112. package/src/modal/ModalHeader.tsx +31 -0
  113. package/src/modal/SimpleDrawer.tsx +44 -0
  114. package/src/modal/index.tsx +4 -0
  115. package/src/popover/PopoverWizardBody.tsx +91 -0
  116. package/src/popover/SimplePopover.tsx +75 -0
  117. package/src/popover/WizardPopover.tsx +61 -0
  118. package/src/popover/index.tsx +23 -0
  119. package/src/provider/SporProvider.tsx +67 -0
  120. package/src/provider/index.tsx +1 -0
  121. package/src/stepper/Stepper.tsx +115 -0
  122. package/src/stepper/StepperContext.tsx +55 -0
  123. package/src/stepper/StepperStep.tsx +48 -0
  124. package/src/stepper/index.tsx +2 -0
  125. package/src/tab/Tabs.tsx +20 -0
  126. package/src/tab/index.tsx +9 -0
  127. package/src/table/Table.tsx +58 -0
  128. package/src/table/index.tsx +19 -0
  129. package/src/theme/components/accordion.ts +143 -0
  130. package/src/theme/components/alert.ts +59 -0
  131. package/src/theme/components/badge.ts +109 -0
  132. package/src/theme/components/button.ts +217 -0
  133. package/src/theme/components/card-select.ts +158 -0
  134. package/src/theme/components/card.ts +174 -0
  135. package/src/theme/components/checkbox.ts +90 -0
  136. package/src/theme/components/choice-chip.ts +79 -0
  137. package/src/theme/components/close-button.ts +56 -0
  138. package/src/theme/components/code.ts +17 -0
  139. package/src/theme/components/datepicker.ts +194 -0
  140. package/src/theme/components/drawer.ts +92 -0
  141. package/src/theme/components/fab.ts +111 -0
  142. package/src/theme/components/form-label.ts +17 -0
  143. package/src/theme/components/form.ts +27 -0
  144. package/src/theme/components/index.ts +34 -0
  145. package/src/theme/components/info-select.ts +91 -0
  146. package/src/theme/components/info-tag.ts +49 -0
  147. package/src/theme/components/input.ts +97 -0
  148. package/src/theme/components/line-icon.ts +121 -0
  149. package/src/theme/components/link.ts +155 -0
  150. package/src/theme/components/listbox.ts +52 -0
  151. package/src/theme/components/media-controller-button.ts +134 -0
  152. package/src/theme/components/modal.ts +93 -0
  153. package/src/theme/components/popover.ts +63 -0
  154. package/src/theme/components/radio.ts +64 -0
  155. package/src/theme/components/select.ts +52 -0
  156. package/src/theme/components/skeleton.ts +40 -0
  157. package/src/theme/components/stepper.ts +230 -0
  158. package/src/theme/components/switch.ts +227 -0
  159. package/src/theme/components/table.ts +163 -0
  160. package/src/theme/components/tabs.ts +282 -0
  161. package/src/theme/components/textarea.ts +14 -0
  162. package/src/theme/components/toast.ts +28 -0
  163. package/src/theme/components/travel-tag.ts +267 -0
  164. package/src/theme/font-faces.ts +66 -0
  165. package/src/theme/foundations/borders.ts +11 -0
  166. package/src/theme/foundations/breakpoints.ts +9 -0
  167. package/src/theme/foundations/colors.ts +10 -0
  168. package/src/theme/foundations/config.ts +5 -0
  169. package/src/theme/foundations/fontSizes.ts +29 -0
  170. package/src/theme/foundations/fontWeights.ts +5 -0
  171. package/src/theme/foundations/fonts.ts +7 -0
  172. package/src/theme/foundations/index.ts +14 -0
  173. package/src/theme/foundations/lineHeights.ts +5 -0
  174. package/src/theme/foundations/radii.ts +12 -0
  175. package/src/theme/foundations/shadows.ts +8 -0
  176. package/src/theme/foundations/sizes.ts +34 -0
  177. package/src/theme/foundations/spacing.ts +30 -0
  178. package/src/theme/foundations/textStyles.ts +60 -0
  179. package/src/theme/foundations/zIndices.ts +17 -0
  180. package/src/theme/index.ts +14 -0
  181. package/src/theme/utils/box-shadow-utils.ts +44 -0
  182. package/src/theme/utils/focus-utils.ts +16 -0
  183. package/src/toast/ActionToast.test.tsx +22 -0
  184. package/src/toast/ActionToast.tsx +28 -0
  185. package/src/toast/BaseToast.test.tsx +27 -0
  186. package/src/toast/BaseToast.tsx +75 -0
  187. package/src/toast/ClosableToast.test.tsx +17 -0
  188. package/src/toast/ClosableToast.tsx +40 -0
  189. package/src/toast/index.tsx +1 -0
  190. package/src/toast/useToast.tsx +99 -0
  191. package/src/typography/Badge.tsx +68 -0
  192. package/src/typography/Code.tsx +32 -0
  193. package/src/typography/Heading.tsx +32 -0
  194. package/src/typography/Text.tsx +26 -0
  195. package/src/typography/index.tsx +4 -0
  196. package/src/util/externals.tsx +23 -0
  197. package/src/util/index.tsx +1 -0
@@ -0,0 +1,165 @@
1
+ import {
2
+ Box,
3
+ BoxProps,
4
+ FormLabel,
5
+ InputGroup,
6
+ Popover,
7
+ PopoverAnchor,
8
+ PopoverArrow,
9
+ PopoverBody,
10
+ PopoverContent,
11
+ Portal,
12
+ ResponsiveValue,
13
+ useBreakpointValue,
14
+ useFormControlContext,
15
+ useMultiStyleConfig,
16
+ } from "@chakra-ui/react";
17
+ import { DateValue } from "@internationalized/date";
18
+ import { useDateRangePickerState } from "@react-stately/datepicker";
19
+ import { CalendarOutline24Icon } from "@vygruppen/spor-icon-react";
20
+ import React, { useRef } from "react";
21
+ import {
22
+ AriaDateRangePickerProps,
23
+ I18nProvider,
24
+ useDateRangePicker,
25
+ } from "react-aria";
26
+ import { CalendarTriggerButton } from "./CalendarTriggerButton";
27
+ import { DateField } from "./DateField";
28
+ import { RangeCalendar } from "./RangeCalendar";
29
+ import { StyledField } from "./StyledField";
30
+ import { useCurrentLocale } from "./utils";
31
+
32
+ type DateRangePickerProps = AriaDateRangePickerProps<DateValue> &
33
+ Pick<BoxProps, "minHeight"> & {
34
+ startLabel?: string;
35
+ endLabel?: string;
36
+ variant: ResponsiveValue<"simple" | "with-trigger">;
37
+ };
38
+ /**
39
+ * A date range picker component.
40
+ *
41
+ * There are two versions of this component – a simple one, and one with a trigger button for showing the calendar. Use whatever fits your design.
42
+ *
43
+ * ```tsx
44
+ * <DateRangePicker startLabel="From" endLabel="To" variant="simple" />
45
+ * ```
46
+ */
47
+ export function DateRangePicker({
48
+ variant,
49
+ minHeight,
50
+ ...props
51
+ }: DateRangePickerProps) {
52
+ const formControlProps = useFormControlContext();
53
+ const state = useDateRangePickerState({
54
+ ...props,
55
+ shouldCloseOnSelect: true,
56
+ isRequired: props.isRequired ?? formControlProps?.isRequired,
57
+ validationState: formControlProps.isInvalid ? "invalid" : "valid",
58
+ });
59
+ const ref = useRef(null);
60
+ const {
61
+ groupProps,
62
+ labelProps,
63
+ startFieldProps,
64
+ endFieldProps,
65
+ buttonProps,
66
+ dialogProps,
67
+ calendarProps,
68
+ } = useDateRangePicker(props, state, ref);
69
+
70
+ const responsiveVariant =
71
+ useBreakpointValue(typeof variant === "string" ? [variant] : variant) ??
72
+ "simple";
73
+
74
+ const styles = useMultiStyleConfig("Datepicker", {
75
+ variant: responsiveVariant,
76
+ });
77
+ const locale = useCurrentLocale();
78
+
79
+ const handleEnterClick = (e: React.KeyboardEvent) => {
80
+ if (e.key === "Enter" && !state.isOpen && responsiveVariant === "simple") {
81
+ // Don't submit the form
82
+ e.stopPropagation();
83
+ state.setOpen(true);
84
+ }
85
+ };
86
+
87
+ const onFieldClick = () => {
88
+ if (!hasTrigger) {
89
+ state.setOpen(true);
90
+ }
91
+ };
92
+
93
+ const hasTrigger = responsiveVariant === "with-trigger";
94
+
95
+ return (
96
+ <I18nProvider locale={locale}>
97
+ <Box position="relative" display="inline-flex" flexDirection="column">
98
+ {props.label && (
99
+ <FormLabel {...labelProps} sx={styles.inputLabel}>
100
+ {props.label}
101
+ </FormLabel>
102
+ )}
103
+ <Popover
104
+ {...dialogProps}
105
+ isOpen={state.isOpen}
106
+ onClose={() => state.setOpen(false)}
107
+ closeOnBlur
108
+ closeOnEsc
109
+ returnFocusOnClose
110
+ >
111
+ <InputGroup
112
+ {...groupProps}
113
+ ref={ref}
114
+ width="auto"
115
+ display="inline-flex"
116
+ >
117
+ <PopoverAnchor>
118
+ <StyledField
119
+ alignItems="center"
120
+ paddingX={3}
121
+ variant={responsiveVariant}
122
+ onClick={onFieldClick}
123
+ onKeyPress={handleEnterClick}
124
+ minHeight={minHeight}
125
+ >
126
+ {!hasTrigger && (
127
+ <CalendarOutline24Icon mr={2} alignSelf="center" />
128
+ )}
129
+ <DateField
130
+ {...startFieldProps}
131
+ label={props.startLabel}
132
+ labelProps={labelProps}
133
+ />
134
+ <Box as="span" aria-hidden="true" px="2">
135
+
136
+ </Box>
137
+ <DateField
138
+ {...endFieldProps}
139
+ label={props.endLabel}
140
+ labelProps={labelProps}
141
+ />
142
+ </StyledField>
143
+ </PopoverAnchor>
144
+ {hasTrigger && <CalendarTriggerButton {...buttonProps} />}
145
+ </InputGroup>
146
+ {state.isOpen && (
147
+ <Portal>
148
+ <PopoverContent
149
+ backgroundColor="white"
150
+ color="darkGrey"
151
+ boxShadow="md"
152
+ maxWidth="none"
153
+ >
154
+ <PopoverArrow backgroundColor="white" />
155
+ <PopoverBody>
156
+ <RangeCalendar {...calendarProps} />
157
+ </PopoverBody>
158
+ </PopoverContent>
159
+ </Portal>
160
+ )}
161
+ </Popover>
162
+ </Box>
163
+ </I18nProvider>
164
+ );
165
+ }
@@ -0,0 +1,56 @@
1
+ import { Box } from "@chakra-ui/react";
2
+ import React, { useRef } from "react";
3
+ import { useDateSegment } from "react-aria";
4
+ import { DateFieldState, DateSegment } from "react-stately";
5
+
6
+ type DateTimeSegmentProps = {
7
+ segment: DateSegment;
8
+ state: DateFieldState;
9
+ };
10
+ /**
11
+ * A date time segment is a part of a date or a time stamp.
12
+ *
13
+ * Examples could be the day, month, year, hour, minute, second, etc.
14
+ *
15
+ * This component should be used with the react-aria library, and is not meant to be used directly.
16
+ * */
17
+ export const DateTimeSegment = ({ segment, state }: DateTimeSegmentProps) => {
18
+ const ref = useRef(null);
19
+
20
+ const { segmentProps } = useDateSegment(segment, state, ref);
21
+ return (
22
+ <Box
23
+ {...segmentProps}
24
+ ref={ref}
25
+ style={{
26
+ ...segmentProps.style,
27
+ fontVariantNumeric: "tabular-nums",
28
+ boxSizing: "content-box",
29
+ }}
30
+ paddingX="1px"
31
+ textAlign="end"
32
+ outline="none"
33
+ borderRadius="xs"
34
+ color={
35
+ segment.isPlaceholder
36
+ ? "dimGrey"
37
+ : segment.isEditable
38
+ ? "darkGrey"
39
+ : "osloGrey"
40
+ }
41
+ _focus={{
42
+ backgroundColor: "darkTeal",
43
+ color: "white",
44
+ }}
45
+ >
46
+ {isPaddable(segment.type) ? segment.text.padStart(2, "0") : segment.text}
47
+ </Box>
48
+ );
49
+ };
50
+
51
+ const isPaddable = (segmentType: DateSegment["type"]) =>
52
+ segmentType === "month" ||
53
+ segmentType === "day" ||
54
+ segmentType === "hour" ||
55
+ segmentType === "minute" ||
56
+ segmentType === "second";
@@ -0,0 +1,35 @@
1
+ import { Box } from "@chakra-ui/react";
2
+ import { createCalendar, DateValue } from "@internationalized/date";
3
+ import { useRangeCalendarState } from "@react-stately/calendar";
4
+ import React, { useRef } from "react";
5
+ import {
6
+ RangeCalendarProps as ReactAriaRangeCalendarProps,
7
+ useRangeCalendar,
8
+ } from "react-aria";
9
+ import { CalendarGrid } from "./CalendarGrid";
10
+ import { CalendarHeader } from "./CalendarHeader";
11
+ import { useCurrentLocale } from "./utils";
12
+
13
+ type RangeCalendarProps = ReactAriaRangeCalendarProps<DateValue>;
14
+ export function RangeCalendar(props: RangeCalendarProps) {
15
+ const locale = useCurrentLocale();
16
+ const state = useRangeCalendarState({
17
+ ...props,
18
+ visibleDuration: { months: 2 },
19
+ locale,
20
+ createCalendar,
21
+ });
22
+
23
+ const ref = useRef(null);
24
+ const { calendarProps, title } = useRangeCalendar(props, state, ref);
25
+
26
+ return (
27
+ <Box {...calendarProps} ref={ref}>
28
+ <CalendarHeader state={state} title={title} />
29
+ <Box display="flex" gap="8">
30
+ <CalendarGrid state={state} />
31
+ <CalendarGrid state={state} offset={{ months: 1 }} />
32
+ </Box>
33
+ </Box>
34
+ );
35
+ }
@@ -0,0 +1,31 @@
1
+ import {
2
+ As,
3
+ Box,
4
+ BoxProps,
5
+ forwardRef,
6
+ useFormControlContext,
7
+ useMultiStyleConfig,
8
+ } from "@chakra-ui/react";
9
+ import React from "react";
10
+
11
+ type StyledFieldProps = BoxProps & {
12
+ variant: "simple" | "with-trigger";
13
+ };
14
+ export const StyledField = forwardRef<StyledFieldProps, As>(
15
+ ({ children, variant, ...otherProps }, ref) => {
16
+ const { isInvalid } = useFormControlContext() ?? {
17
+ isInvalid: false,
18
+ };
19
+ const styles = useMultiStyleConfig("Datepicker", { variant });
20
+ return (
21
+ <Box
22
+ {...otherProps}
23
+ __css={styles.wrapper}
24
+ ref={ref}
25
+ aria-invalid={isInvalid}
26
+ >
27
+ {children}
28
+ </Box>
29
+ );
30
+ }
31
+ );
@@ -0,0 +1,46 @@
1
+ import { Box, Flex } from "@chakra-ui/react";
2
+ import { CalendarDateTime, Time } from "@internationalized/date";
3
+ import React, { useRef } from "react";
4
+ import { AriaTimeFieldProps, useTimeField } from "react-aria";
5
+ import { DateFieldState } from "react-stately";
6
+ import { FormLabel } from "..";
7
+ import { DateTimeSegment } from "./DateTimeSegment";
8
+ import { getTimestampFromTime } from "./utils";
9
+
10
+ type TimeFieldProps = AriaTimeFieldProps<Time> & {
11
+ state: DateFieldState;
12
+ label: string;
13
+ name?: string;
14
+ };
15
+ /** A time field component.
16
+ *
17
+ * This component lets the user choose a time based on regular user input.
18
+ * It shouldn't be used directly, but is used by the TimePicker component.
19
+ */
20
+ export const TimeField = ({ state, ...props }: TimeFieldProps) => {
21
+ const ref = useRef<HTMLDivElement>(null);
22
+ const { labelProps, fieldProps } = useTimeField(props, state, ref);
23
+
24
+ return (
25
+ <Box>
26
+ <FormLabel
27
+ {...labelProps}
28
+ htmlFor={fieldProps.id}
29
+ marginBottom={0}
30
+ fontSize="mobile.xs"
31
+ >
32
+ {props.label}
33
+ </FormLabel>
34
+ <Flex {...fieldProps} ref={ref}>
35
+ {state.segments.map((segment) => (
36
+ <DateTimeSegment key={segment.type} segment={segment} state={state} />
37
+ ))}
38
+ </Flex>
39
+ <input
40
+ type="hidden"
41
+ value={getTimestampFromTime(state.value as CalendarDateTime | null)}
42
+ name={props.name}
43
+ />
44
+ </Box>
45
+ );
46
+ };
@@ -0,0 +1,74 @@
1
+ import { Time } from "@internationalized/date";
2
+ import { act, render } from "@testing-library/react";
3
+ import React from "react";
4
+ import { axe } from "vitest-axe";
5
+ import { TimePicker } from "./TimePicker";
6
+
7
+ describe("<TimePicker />", () => {
8
+ it("is accessible", async () => {
9
+ const { container } = render(<TimePicker />);
10
+ expect(await axe(container)).toHaveNoViolations();
11
+ });
12
+
13
+ it("jumps backwards as expected", async () => {
14
+ const { getByLabelText, getByRole } = render(
15
+ <TimePicker defaultValue={new Time(13, 3)} />
16
+ );
17
+ const backwardsButton = getByLabelText("Bakover 30 minutter");
18
+ expect(getByRole("group")).toHaveTextContent("13:03");
19
+ act(() => {
20
+ backwardsButton.click();
21
+ });
22
+ expect(getByRole("group")).toHaveTextContent("13:00");
23
+ act(() => {
24
+ backwardsButton.click();
25
+ });
26
+ expect(getByRole("group")).toHaveTextContent("12:30");
27
+ });
28
+ it("jumps forwards as expected", async () => {
29
+ const { getByLabelText, getByRole } = render(
30
+ <TimePicker defaultValue={new Time(13, 53)} />
31
+ );
32
+ const forwardsButton = getByLabelText("Fremover 30 minutter");
33
+ expect(getByRole("group")).toHaveTextContent("13:53");
34
+ act(() => {
35
+ forwardsButton.click();
36
+ });
37
+ expect(getByRole("group")).toHaveTextContent("14:00");
38
+ act(() => {
39
+ forwardsButton.click();
40
+ });
41
+ expect(getByRole("group")).toHaveTextContent("14:30");
42
+ });
43
+ it("jumps backwards as expected when minuteInterval is set", async () => {
44
+ const { getByLabelText, getByRole } = render(
45
+ <TimePicker defaultValue={new Time(13, 3)} minuteInterval={15} />
46
+ );
47
+ const backwardsButton = getByLabelText("Bakover 15 minutter");
48
+ expect(getByRole("group")).toHaveTextContent("13:03");
49
+ act(() => {
50
+ backwardsButton.click();
51
+ });
52
+ expect(getByRole("group")).toHaveTextContent("13:00");
53
+ act(() => {
54
+ backwardsButton.click();
55
+ });
56
+ expect(getByRole("group")).toHaveTextContent("12:45");
57
+ });
58
+ it("jumps forwards as expected when minuteInterval is set", async () => {
59
+ const { getByLabelText, getByRole } = render(
60
+ <TimePicker defaultValue={new Time(13, 49)} minuteInterval={15} />
61
+ );
62
+
63
+ const forwardsButton = getByLabelText("Fremover 15 minutter");
64
+ expect(getByRole("group")).toHaveTextContent("13:49");
65
+ act(() => {
66
+ forwardsButton.click();
67
+ });
68
+ expect(getByRole("group")).toHaveTextContent("14:00");
69
+ act(() => {
70
+ forwardsButton.click();
71
+ });
72
+ expect(getByRole("group")).toHaveTextContent("14:15");
73
+ });
74
+ });
@@ -0,0 +1,196 @@
1
+ import { BoxProps, useFormControlContext } from "@chakra-ui/react";
2
+ import { CalendarDateTime } from "@internationalized/date";
3
+ import { TimeValue } from "@react-types/datepicker";
4
+ import {
5
+ DropdownLeftFill24Icon,
6
+ DropdownRightFill24Icon,
7
+ } from "@vygruppen/spor-icon-react";
8
+ import React from "react";
9
+ import { useTimeFieldState } from "react-stately";
10
+ import { IconButton, createTexts, useTranslation } from "..";
11
+ import { StyledField } from "./StyledField";
12
+ import { TimeField } from "./TimeField";
13
+ import { getCurrentTime, useCurrentLocale } from "./utils";
14
+
15
+ type TimePickerProps = Omit<BoxProps, "defaultValue"> & {
16
+ /** The label. Defaults to a localized version of "Time" */
17
+ label?: string;
18
+ /** The name of the form field, if used in a regular form */
19
+ name?: string;
20
+ /** The controlled value, if any.
21
+ *
22
+ * A `new Time(hours, minutes)` should be passed
23
+ **/
24
+ value?: TimeValue;
25
+ /** A default value, if any.
26
+ *
27
+ * A `new Time(hours, minutes)` should be passed. Defaults to the current time if not provided.
28
+ **/
29
+ defaultValue?: TimeValue;
30
+ /** Callback for when the time changes */
31
+ onChange?: (value: TimeValue) => void;
32
+ /** The maxiumum number of minutes to move when the step buttons are used.
33
+ *
34
+ * Defaults to 30 minutes.
35
+ *
36
+ * An example: If the time is at 13:37 and the minuteInterval is 15, clicking the step forwards button will move the time to 13:45. Next click will move it to 14:00.
37
+ */
38
+ minuteInterval?: number;
39
+ /** Whether or not the field is disabled */
40
+ isDisabled?: boolean;
41
+ };
42
+ /** A time picker component.
43
+ *
44
+ * This lets the user select a time of day, either through typing it in, using the up and down arrows to select the hour and minute, or by clicking the step buttons to move the time forwards or backwards in pre-defined increments.
45
+ *
46
+ * ```tsx
47
+ * <TimePicker />
48
+ * ```
49
+ *
50
+ * It can also be controlled:
51
+ *
52
+ * ```tsx
53
+ * <TimePicker value={new Time(13, 37)} onChange={setTime} />
54
+ * ```
55
+ *
56
+ * Note that the TimePicker uses the `Time` class to represent the time. This is a class that is part of the `@internationalized/date` package.
57
+ *
58
+ * @see https://spor.vy.no/komponenter/timepicker
59
+ */
60
+ export const TimePicker = ({
61
+ label: externalLabel,
62
+ value,
63
+ defaultValue = getCurrentTime(),
64
+ onChange = () => {},
65
+ minuteInterval = 30,
66
+ isDisabled: isDisabledExternally = false,
67
+ name,
68
+ ...boxProps
69
+ }: TimePickerProps) => {
70
+ const { isDisabled: isFormControlDisabled, isInvalid: isFormControlInvalid } =
71
+ useFormControlContext() ?? {};
72
+ const isDisabled = isDisabledExternally ?? isFormControlDisabled ?? false;
73
+ const { t } = useTranslation();
74
+ const locale = useCurrentLocale();
75
+ const label = externalLabel ?? t(texts.time);
76
+ const state = useTimeFieldState({
77
+ value,
78
+ defaultValue,
79
+ onChange,
80
+ locale,
81
+ isDisabled,
82
+ label,
83
+ validationState: isFormControlInvalid ? "invalid" : "valid",
84
+ });
85
+
86
+ const dateTime = state.value as CalendarDateTime | null;
87
+
88
+ const handleBackwardsClick = () => {
89
+ if (!dateTime) {
90
+ return;
91
+ }
92
+ const minutesToSubtract =
93
+ dateTime.minute % minuteInterval || minuteInterval;
94
+ state.setValue(
95
+ state.value.subtract({
96
+ minutes: minutesToSubtract,
97
+ })
98
+ );
99
+ };
100
+ const handleForwardClick = () => {
101
+ if (!dateTime) {
102
+ return;
103
+ }
104
+ const minutesToAdd =
105
+ minuteInterval - (dateTime.minute % minuteInterval) || minuteInterval;
106
+ state.setValue(
107
+ state.value.add({
108
+ minutes: minutesToAdd,
109
+ })
110
+ );
111
+ };
112
+ const backwardsLabel = `${t(texts.backwards)} ${minuteInterval} ${t(
113
+ texts.minutes
114
+ )}`;
115
+ const forwardsLabel = `${t(texts.forwards)} ${minuteInterval} ${t(
116
+ texts.minutes
117
+ )}`;
118
+ const inputLabel = label ?? t(texts.time);
119
+ const ariaLabel = `${inputLabel} – ${t(
120
+ texts.selectedTimeIs(`${dateTime?.hour ?? 0} ${dateTime?.minute ?? 0}`)
121
+ )}`;
122
+ return (
123
+ <StyledField
124
+ variant="simple"
125
+ width="fit-content"
126
+ paddingX={2}
127
+ paddingY={1}
128
+ alignItems="center"
129
+ justifyContent="space-between"
130
+ gap={2}
131
+ opacity={isDisabled ? 0.5 : 1}
132
+ pointerEvents={isDisabled ? "none" : "auto"}
133
+ aria-disabled={isDisabled}
134
+ aria-live="assertive"
135
+ aria-label={ariaLabel}
136
+ {...boxProps}
137
+ >
138
+ <IconButton
139
+ variant="ghost"
140
+ size="xs"
141
+ borderRadius="xs"
142
+ aria-label={backwardsLabel}
143
+ title={backwardsLabel}
144
+ icon={<DropdownLeftFill24Icon />}
145
+ onClick={handleBackwardsClick}
146
+ isDisabled={isDisabled}
147
+ style={isDisabled ? { backgroundColor: "transparent" } : {}}
148
+ />
149
+ <TimeField label={label} state={state} name={name} />
150
+ <IconButton
151
+ variant="ghost"
152
+ size="xs"
153
+ borderRadius="xs"
154
+ aria-label={forwardsLabel}
155
+ title={forwardsLabel}
156
+ icon={<DropdownRightFill24Icon />}
157
+ onClick={handleForwardClick}
158
+ isDisabled={isDisabled}
159
+ style={isDisabled ? { backgroundColor: "transparent" } : {}}
160
+ />
161
+ </StyledField>
162
+ );
163
+ };
164
+
165
+ const texts = createTexts({
166
+ selectedTimeIs: (time) => ({
167
+ nb: `Valgt tidspunkt er ${time}`,
168
+ nn: `Valt tidspunkt er ${time}`,
169
+ en: `Selected time is ${time}`,
170
+ sv: `Vald tid är ${time}`,
171
+ }),
172
+ time: {
173
+ nb: "Tid",
174
+ nn: "Tid",
175
+ en: "Time",
176
+ sv: "Tid",
177
+ },
178
+ backwards: {
179
+ nb: "Bakover",
180
+ nn: "Bakover",
181
+ en: "Backwards",
182
+ sv: "Bakåt",
183
+ },
184
+ forwards: {
185
+ nb: "Fremover",
186
+ nn: "Fremover",
187
+ en: "Forward",
188
+ sv: "Framåt",
189
+ },
190
+ minutes: {
191
+ nb: "minutter",
192
+ nn: "minuttar",
193
+ en: "minutes",
194
+ sv: "minuter",
195
+ },
196
+ });
@@ -0,0 +1,4 @@
1
+ export { Time } from "@internationalized/date";
2
+ export * from "./DatePicker";
3
+ export * from "./DateRangePicker";
4
+ export * from "./TimePicker";
@@ -0,0 +1,33 @@
1
+ import { CalendarDateTime, parseTime } from "@internationalized/date";
2
+ import { useTranslation } from "..";
3
+
4
+ /**
5
+ * Returns the currently selected language as a BCF47 language tag.
6
+ * This is useful for passing into the react-aria hooks
7
+ */
8
+ export const useCurrentLocale = () => {
9
+ const { language } = useTranslation();
10
+ switch (language) {
11
+ case "nb":
12
+ return "no";
13
+ case "nn":
14
+ return "no";
15
+ case "sv":
16
+ return "sv";
17
+ case "en":
18
+ return "en-GB";
19
+ default:
20
+ return "no";
21
+ }
22
+ };
23
+
24
+ /** Gets the current time as a Time object */
25
+ export const getCurrentTime = () => {
26
+ const now = new Date();
27
+ return parseTime(now.toTimeString().split(" ")[0]);
28
+ };
29
+
30
+ /** Gets a readable timestamp from a given time object */
31
+ export const getTimestampFromTime = (time: CalendarDateTime | null) => {
32
+ return `${time?.hour ?? 0}:${time?.minute ?? 0}`;
33
+ };
@@ -0,0 +1,38 @@
1
+ import { initLobot } from "@leile/lobo-t";
2
+
3
+ export enum Language {
4
+ NorwegianBokmal = "nb",
5
+ NorwegianNynorsk = "nn",
6
+ Swedish = "sv",
7
+ English = "en",
8
+ }
9
+
10
+ export const { LanguageProvider, useTranslation } = initLobot<typeof Language>(
11
+ Language.NorwegianBokmal
12
+ );
13
+
14
+ type LanguageObject = {
15
+ [key in Language]: string;
16
+ };
17
+ type LanguageFunction = (...args: (string | number)[]) => LanguageObject;
18
+
19
+ export type Translations = {
20
+ [key: string]: LanguageObject | LanguageFunction;
21
+ };
22
+
23
+ /** Utility function that creates type safe text objects with useTranslation
24
+ *
25
+ * ```tsx
26
+ * const texts = createTexts({
27
+ * example: {
28
+ * nb: "Eksempel",
29
+ * nn: "Døme",
30
+ * sv: "Exempel",
31
+ * en: "Example",
32
+ * }
33
+ * })
34
+ * ```
35
+ */
36
+ export function createTexts<T extends Translations>(texts: T) {
37
+ return texts;
38
+ }
@@ -0,0 +1,2 @@
1
+ export { Image, Img } from "@chakra-ui/react";
2
+ export type { ImageProps, ImgProps } from "@chakra-ui/react";