@vygruppen/spor-react 12.22.2 → 12.23.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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vygruppen/spor-react",
3
3
  "type": "module",
4
- "version": "12.22.2",
4
+ "version": "12.23.0",
5
5
  "exports": {
6
6
  ".": {
7
7
  "types": "./dist/index.d.ts",
@@ -47,8 +47,8 @@
47
47
  "react-swipeable": "^7.0.1",
48
48
  "usehooks-ts": "^3.1.0",
49
49
  "@vygruppen/spor-design-tokens": "4.3.2",
50
- "@vygruppen/spor-icon-react": "4.5.1",
51
- "@vygruppen/spor-loader": "0.7.0"
50
+ "@vygruppen/spor-loader": "0.7.0",
51
+ "@vygruppen/spor-icon-react": "4.5.1"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@react-types/datepicker": "^3.10.0",
@@ -62,6 +62,7 @@ export const CountryCodeSelect = forwardRef<
62
62
  lazyMount
63
63
  aria-label={t(texts.countryCode)}
64
64
  sideRadiusVariant="rightSideSquare"
65
+ role="combobox"
65
66
  >
66
67
  {filteredCallingCodes.items.map((code) => (
67
68
  <SelectItem key={code.label} item={code}>
@@ -11,6 +11,7 @@ import React, {
11
11
  ComponentProps,
12
12
  forwardRef,
13
13
  ReactNode,
14
+ useId,
14
15
  useImperativeHandle,
15
16
  useRef,
16
17
  } from "react";
@@ -84,6 +85,8 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
84
85
  const [recipeProps, restProps] = recipe.splitVariantProps(props);
85
86
  const styles = recipe(recipeProps);
86
87
 
88
+ const labelId = useId();
89
+
87
90
  const inputRef = useRef<HTMLInputElement>(null);
88
91
  useImperativeHandle(ref, () => inputRef.current as HTMLInputElement, []);
89
92
 
@@ -107,7 +110,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
107
110
  id={props.id}
108
111
  labelAsChild={labelAsChild}
109
112
  label={
110
- <Flex>
113
+ <Flex id={labelId}>
111
114
  <Box visibility="hidden">{startElement}</Box>
112
115
  {label}
113
116
  </Flex>
@@ -141,6 +144,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
141
144
  placeholder=""
142
145
  css={styles}
143
146
  fontSize={fontSize ?? "mobile.md"}
147
+ aria-labelledby={labelId}
144
148
  />
145
149
  {endElement && (
146
150
  <InputElement
@@ -1,6 +1,6 @@
1
1
  "use client";
2
-
3
2
  import {
3
+ Box,
4
4
  RecipeVariantProps,
5
5
  Textarea as ChakraTextarea,
6
6
  TextareaProps as ChakraTextareaProps,
@@ -10,6 +10,7 @@ import React, {
10
10
  forwardRef,
11
11
  PropsWithChildren,
12
12
  ReactNode,
13
+ useId,
13
14
  useImperativeHandle,
14
15
  useLayoutEffect,
15
16
  useRef,
@@ -18,9 +19,7 @@ import React, {
18
19
 
19
20
  import { textareaRecipe } from "../theme/recipes/textarea";
20
21
  import { Field, FieldProps } from "./Field";
21
- import { FloatingLabel } from "./FloatingLabel";
22
22
  import { useFloatingInputState } from "./useFLoatingInputState";
23
-
24
23
  type TextareaVariants = RecipeVariantProps<typeof textareaRecipe>;
25
24
  export type TextareaProps = Exclude<
26
25
  ChakraTextareaProps,
@@ -30,40 +29,33 @@ export type TextareaProps = Exclude<
30
29
  PropsWithChildren<TextareaVariants> & {
31
30
  label: ReactNode;
32
31
  };
33
-
34
32
  /**
35
33
  * Hook to calculate the height of the label element to adjust spacing for the input for floating label.
36
34
  */
37
35
  const useLabelHeight = (label: ReactNode | undefined) => {
38
36
  const labelRef = useRef<HTMLLabelElement>(null);
39
37
  const [labelHeight, setLabelHeight] = useState(0);
40
-
41
38
  useLayoutEffect(() => {
42
39
  const updateLabelHeight = () => {
43
40
  if (labelRef.current) {
44
41
  setLabelHeight(labelRef.current.offsetHeight);
45
42
  }
46
43
  };
47
-
48
44
  const observer = new ResizeObserver(updateLabelHeight);
49
45
  const currentLabelRef = labelRef.current;
50
46
  if (currentLabelRef) {
51
47
  observer.observe(currentLabelRef);
52
48
  }
53
-
54
49
  // Initial calculation with a slight delay to ensure CSS is applied
55
50
  setTimeout(updateLabelHeight, 0);
56
-
57
51
  return () => {
58
52
  if (currentLabelRef) {
59
53
  observer.unobserve(currentLabelRef);
60
54
  }
61
55
  };
62
56
  }, [label]);
63
-
64
57
  return { labelRef, labelHeight };
65
58
  };
66
-
67
59
  export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
68
60
  (props, ref) => {
69
61
  const {
@@ -74,17 +66,15 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
74
66
  errorText,
75
67
  readOnly,
76
68
  helperText,
77
- floatingLabel,
69
+ floatingLabel = true,
78
70
  ...restProps
79
71
  } = props;
80
72
  const recipe = useRecipe({ key: "textarea" });
81
73
  const styles = recipe({ variant });
82
-
83
74
  const { labelRef, labelHeight } = useLabelHeight(label);
84
75
 
85
76
  const inputRef = useRef<HTMLTextAreaElement>(null);
86
77
  useImperativeHandle(ref, () => inputRef.current as HTMLTextAreaElement, []);
87
-
88
78
  const { shouldFloat, handleFocus, handleBlur, handleChange, isControlled } =
89
79
  useFloatingInputState<HTMLTextAreaElement>({
90
80
  value: props.value,
@@ -95,6 +85,8 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
95
85
  inputRef,
96
86
  });
97
87
 
88
+ const labelId = useId();
89
+
98
90
  return (
99
91
  <Field
100
92
  errorText={errorText}
@@ -105,6 +97,12 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
105
97
  floatingLabel={floatingLabel}
106
98
  shouldFloat={shouldFloat}
107
99
  position="relative"
100
+ label={
101
+ <Box id={labelId} aria-hidden>
102
+ <label ref={labelRef}>{label}</label>
103
+ </Box>
104
+ }
105
+ id={restProps.id}
108
106
  >
109
107
  <ChakraTextarea
110
108
  {...restProps}
@@ -119,16 +117,10 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
119
117
  { "--label-height": `${labelHeight}px` } as React.CSSProperties
120
118
  }
121
119
  placeholder=" "
120
+ aria-labelledby={labelId}
122
121
  />
123
- <FloatingLabel
124
- ref={labelRef}
125
- data-float={shouldFloat ? true : undefined}
126
- >
127
- {label}
128
- </FloatingLabel>
129
122
  </Field>
130
123
  );
131
124
  },
132
125
  );
133
-
134
126
  Textarea.displayName = "Textarea";
@@ -1,21 +1,57 @@
1
1
  "use client";
2
2
  import {
3
+ HStack,
3
4
  RecipeVariantProps,
4
5
  Table as ChakraTable,
6
+ TableBodyProps as ChakraTableBodyProps,
7
+ TableColumnHeaderProps as ChakraTableColumnHeaderProps,
5
8
  TableRootProps as ChakraTableProps,
9
+ TableRowProps as ChakraTableRowProps,
6
10
  useSlotRecipe,
7
11
  } from "@chakra-ui/react";
8
- import { forwardRef, PropsWithChildren } from "react";
12
+ import {
13
+ DropdownDownFill18Icon,
14
+ DropdownUpFill18Icon,
15
+ } from "@vygruppen/spor-icon-react";
16
+ import {
17
+ createContext,
18
+ forwardRef,
19
+ PropsWithChildren,
20
+ useContext,
21
+ useMemo,
22
+ useState,
23
+ } from "react";
9
24
 
10
25
  import { tableSlotRecipe } from "../theme/slot-recipes/table";
26
+ import {
27
+ getColumnIndex,
28
+ getNextSortState,
29
+ getSortKey,
30
+ sortRows,
31
+ type SortState,
32
+ } from "./sort-utils";
11
33
 
12
34
  type TableVariantProps = RecipeVariantProps<typeof tableSlotRecipe>;
13
35
 
36
+ const SortContext = createContext<{
37
+ enabled: boolean;
38
+ sortState: SortState;
39
+ onSort: (key: string, columnIndex: number) => void;
40
+ }>({
41
+ enabled: false,
42
+ sortState: { key: null, direction: "asc", columnIndex: null },
43
+ onSort: () => {},
44
+ });
45
+
46
+ export const useTableSort = () => useContext(SortContext);
47
+
14
48
  export type TableProps = Exclude<ChakraTableProps, "variant" | "colorPalette"> &
15
49
  PropsWithChildren<TableVariantProps> & {
16
50
  variant?: "ghost" | "core";
17
51
  colorPalette?: "grey" | "green" | "white";
52
+ sortable?: boolean;
18
53
  };
54
+
19
55
  /**
20
56
  * The `Table` component has support for two different variants - `ghost` and `core`. The `ghost` variant has basic lines between rows, while the `core` variant has borders for each cell.
21
57
  *
@@ -32,22 +68,114 @@ export type TableProps = Exclude<ChakraTableProps, "variant" | "colorPalette"> &
32
68
  * </Table>
33
69
  * ```
34
70
  */
35
- export const Table = forwardRef<HTMLTableElement, TableProps>((props, ref) => {
36
- const { variant = "ghost", size, colorPalette = "green", children } = props;
71
+ export const Table = forwardRef<HTMLTableElement, TableProps>(
72
+ (
73
+ {
74
+ variant = "ghost",
75
+ size,
76
+ colorPalette = "green",
77
+ children,
78
+ sortable = false,
79
+ ...rest
80
+ },
81
+ ref,
82
+ ) => {
83
+ const [sortState, setSortState] = useState<SortState>({
84
+ key: null,
85
+ direction: "asc",
86
+ columnIndex: null,
87
+ });
88
+
89
+ const handleSort = (key: string, columnIndex: number) => {
90
+ if (!sortable) return;
91
+ setSortState(getNextSortState(sortState, key, columnIndex));
92
+ };
93
+
94
+ const recipe = useSlotRecipe({ key: "table" });
95
+ const styles = recipe({ variant, size });
96
+
97
+ return (
98
+ <ChakraTable.Root
99
+ variant={variant}
100
+ size={size}
101
+ colorPalette={colorPalette}
102
+ css={styles}
103
+ ref={ref}
104
+ {...rest}
105
+ >
106
+ <SortContext.Provider
107
+ value={{ enabled: sortable, sortState, onSort: handleSort }}
108
+ >
109
+ {children}
110
+ </SortContext.Provider>
111
+ </ChakraTable.Root>
112
+ );
113
+ },
114
+ );
115
+ Table.displayName = "Table";
116
+
117
+ export type TableColumnHeaderProps = ChakraTableColumnHeaderProps;
118
+
119
+ export const TableColumnHeader = forwardRef<
120
+ HTMLTableCellElement,
121
+ TableColumnHeaderProps
122
+ >(({ children, onClick, ...rest }, ref) => {
123
+ const { enabled, sortState, onSort } = useTableSort();
124
+ const key = getSortKey(children);
125
+ const isActive = enabled && key != null && key === sortState.key;
37
126
 
38
- const recipe = useSlotRecipe({ key: "table" });
39
- const styles = recipe({ variant, size });
40
127
  return (
41
- <ChakraTable.Root
42
- variant={variant}
43
- size={size}
44
- colorPalette={colorPalette}
45
- css={styles}
128
+ <ChakraTable.ColumnHeader
46
129
  ref={ref}
47
- {...props}
130
+ onClick={(event) => {
131
+ if (enabled && key) {
132
+ onSort(key, getColumnIndex(event.currentTarget));
133
+ }
134
+ onClick?.(event);
135
+ }}
136
+ cursor={enabled && key ? "pointer" : undefined}
137
+ {...rest}
48
138
  >
49
- {children}
50
- </ChakraTable.Root>
139
+ <HStack>
140
+ {children}
141
+ {isActive &&
142
+ (sortState.direction === "asc" ? (
143
+ <DropdownUpFill18Icon />
144
+ ) : (
145
+ <DropdownDownFill18Icon />
146
+ ))}
147
+ </HStack>
148
+ </ChakraTable.ColumnHeader>
51
149
  );
52
150
  });
53
- Table.displayName = "Table";
151
+ TableColumnHeader.displayName = "ColumnHeader";
152
+
153
+ export type TableRowProps = ChakraTableRowProps;
154
+
155
+ export const TableRow = forwardRef<HTMLTableRowElement, TableRowProps>(
156
+ (props, ref) => <ChakraTable.Row ref={ref} {...props} />,
157
+ );
158
+ TableRow.displayName = "TableRow";
159
+
160
+ export type TableBodyProps = ChakraTableBodyProps;
161
+
162
+ export const TableBody = forwardRef<HTMLTableSectionElement, TableBodyProps>(
163
+ ({ children, ...rest }, ref) => {
164
+ const { sortState } = useTableSort();
165
+
166
+ const sorted = useMemo(
167
+ () =>
168
+ sortState.columnIndex == null
169
+ ? children
170
+ : sortRows(children, sortState.columnIndex, sortState.direction),
171
+ [children, sortState],
172
+ );
173
+
174
+ return (
175
+ <ChakraTable.Body ref={ref} {...rest}>
176
+ {sorted}
177
+ </ChakraTable.Body>
178
+ );
179
+ },
180
+ );
181
+ TableBody.displayName = "TableBody";
@@ -1,24 +1,18 @@
1
1
  export * from "./Table";
2
2
  export type {
3
- TableBodyProps,
4
3
  TableCaptionProps,
5
4
  TableCellProps,
6
- TableColumnHeaderProps,
7
5
  TableColumnProps,
8
6
  TableFooterProps,
9
7
  TableHeaderProps,
10
8
  TableRootProps,
11
- TableRowProps,
12
9
  } from "@chakra-ui/react";
13
10
  export {
14
- TableBody,
15
11
  TableCaption,
16
12
  TableCell,
17
13
  TableColumn,
18
14
  TableColumnGroup,
19
- TableColumnHeader,
20
15
  TableFooter,
21
16
  TableHeader,
22
17
  TableRoot,
23
- TableRow,
24
18
  } from "@chakra-ui/react";
@@ -0,0 +1,51 @@
1
+ import { Children, isValidElement, type ReactNode } from "react";
2
+
3
+ export type SortDirection = "asc" | "desc";
4
+ export type SortState = {
5
+ key: string | null;
6
+ direction: SortDirection;
7
+ columnIndex: number | null;
8
+ };
9
+
10
+ export const getNextSortState = (
11
+ current: SortState,
12
+ key: string,
13
+ columnIndex: number,
14
+ ): SortState => ({
15
+ key,
16
+ columnIndex,
17
+ direction:
18
+ current.key === key && current.direction === "asc" ? "desc" : "asc",
19
+ });
20
+
21
+ export const getSortKey = (children: ReactNode) =>
22
+ typeof children === "string" ? children.trim() : null;
23
+
24
+ export const getColumnIndex = (element: HTMLElement) =>
25
+ Array.prototype.indexOf.call(element.parentElement?.children, element);
26
+
27
+ const getCellText = (row: React.ReactElement, columnIndex: number) => {
28
+ const cell = Children.toArray(
29
+ (row.props as { children?: ReactNode }).children,
30
+ )[columnIndex];
31
+ if (!isValidElement(cell)) return "";
32
+ const props = cell.props as Record<string, unknown>;
33
+ return (
34
+ (typeof props["data-sort"] === "string" && props["data-sort"]) ||
35
+ (typeof props.children === "string" && props.children.trim()) ||
36
+ ""
37
+ );
38
+ };
39
+
40
+ export const sortRows = (
41
+ children: ReactNode,
42
+ columnIndex: number,
43
+ direction: SortDirection,
44
+ ) =>
45
+ Children.toArray(children).toSorted((a, b) => {
46
+ if (!isValidElement(a) || !isValidElement(b)) return 0;
47
+ const cmp = getCellText(a, columnIndex).localeCompare(
48
+ getCellText(b, columnIndex),
49
+ );
50
+ return direction === "asc" ? cmp : -cmp;
51
+ });