@vygruppen/spor-react 12.22.1 → 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/.turbo/turbo-build.log +10 -10
- package/.turbo/turbo-postinstall.log +2 -2
- package/CHANGELOG.md +25 -0
- package/dist/index.cjs +209 -130
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +26 -4
- package/dist/index.d.ts +26 -4
- package/dist/index.mjs +222 -135
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -5
- package/src/alert/AlertIcon.tsx +5 -1
- package/src/input/AttachedInputs.tsx +2 -2
- package/src/input/Autocomplete.tsx +42 -22
- package/src/input/CountryCodeSelect.tsx +1 -0
- package/src/input/Field.tsx +3 -1
- package/src/input/FloatingLabel.tsx +2 -2
- package/src/input/Input.tsx +6 -1
- package/src/input/NumericStepper.tsx +1 -4
- package/src/input/Textarea.tsx +12 -20
- package/src/table/Table.tsx +142 -14
- package/src/table/index.tsx +0 -6
- package/src/table/sort-utils.ts +51 -0
- package/src/theme/recipes/attached-inputs.ts +3 -2
- package/src/theme/slot-recipes/autocomplete.ts +0 -1
- package/src/theme/tokens/text-styles.ts +0 -18
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vygruppen/spor-react",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "12.
|
|
4
|
+
"version": "12.23.0",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
7
7
|
"types": "./dist/index.d.ts",
|
|
@@ -46,9 +46,9 @@
|
|
|
46
46
|
"react-stately": "^3.31.1",
|
|
47
47
|
"react-swipeable": "^7.0.1",
|
|
48
48
|
"usehooks-ts": "^3.1.0",
|
|
49
|
-
"@vygruppen/spor-
|
|
49
|
+
"@vygruppen/spor-design-tokens": "4.3.2",
|
|
50
50
|
"@vygruppen/spor-loader": "0.7.0",
|
|
51
|
-
"@vygruppen/spor-
|
|
51
|
+
"@vygruppen/spor-icon-react": "4.5.1"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@react-types/datepicker": "^3.10.0",
|
|
@@ -68,8 +68,8 @@
|
|
|
68
68
|
"vitest": "^0.26.3",
|
|
69
69
|
"vitest-axe": "^0.1.0",
|
|
70
70
|
"vitest-canvas-mock": "^0.2.2",
|
|
71
|
-
"@vygruppen/
|
|
72
|
-
"@vygruppen/
|
|
71
|
+
"@vygruppen/tsconfig": "0.1.1",
|
|
72
|
+
"@vygruppen/eslint-config": "2.1.0"
|
|
73
73
|
},
|
|
74
74
|
"peerDependencies": {
|
|
75
75
|
"react": ">=18.0.0 <19.0.0",
|
package/src/alert/AlertIcon.tsx
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
WarningFill24Icon,
|
|
14
14
|
} from "@vygruppen/spor-icon-react";
|
|
15
15
|
import { forwardRef } from "react";
|
|
16
|
+
import { VisuallyHidden } from "react-aria";
|
|
16
17
|
|
|
17
18
|
import { createTexts, useTranslation } from "../i18n";
|
|
18
19
|
import { AlertProps } from "./Alert";
|
|
@@ -30,7 +31,10 @@ export const AlertIcon = forwardRef<SVGSVGElement, AlertIconProps>(
|
|
|
30
31
|
const { t } = useTranslation();
|
|
31
32
|
|
|
32
33
|
return (
|
|
33
|
-
<Box ref={ref}
|
|
34
|
+
<Box ref={ref}>
|
|
35
|
+
<VisuallyHidden>
|
|
36
|
+
{t(texts[variant as keyof typeof texts])}
|
|
37
|
+
</VisuallyHidden>
|
|
34
38
|
{CustomAlertIcon ? (
|
|
35
39
|
<CustomAlertIcon color={`alert.${variant}.icon`} />
|
|
36
40
|
) : (
|
|
@@ -90,7 +90,7 @@ const SwitchButton = chakra(
|
|
|
90
90
|
defineRecipe({
|
|
91
91
|
base: {
|
|
92
92
|
position: "absolute !important",
|
|
93
|
-
zIndex: "
|
|
93
|
+
zIndex: "101 !important",
|
|
94
94
|
// eslint-disable-next-line spor/use-semantic-tokens
|
|
95
95
|
bg: "bg !important",
|
|
96
96
|
outlineWidth: "1px !important",
|
|
@@ -107,7 +107,7 @@ const SwitchButton = chakra(
|
|
|
107
107
|
},
|
|
108
108
|
vertical: {
|
|
109
109
|
top: "calc(50% - 15px)",
|
|
110
|
-
right: "
|
|
110
|
+
right: "3rem",
|
|
111
111
|
transform: "rotate(90deg)",
|
|
112
112
|
},
|
|
113
113
|
},
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
Combobox,
|
|
3
3
|
ComboboxItemProps,
|
|
4
4
|
ComboboxRootProps,
|
|
5
|
+
useCombobox,
|
|
5
6
|
useComboboxContext,
|
|
6
7
|
useFilter,
|
|
7
8
|
useListCollection,
|
|
@@ -24,6 +25,8 @@ type Props = {
|
|
|
24
25
|
leftIcon?: React.ReactNode;
|
|
25
26
|
filteredExternally?: boolean;
|
|
26
27
|
loading?: boolean;
|
|
28
|
+
emptyLabel?: React.ReactNode;
|
|
29
|
+
openOnFocus?: boolean;
|
|
27
30
|
} & Omit<ComboboxRootProps, "collection"> &
|
|
28
31
|
FieldProps;
|
|
29
32
|
|
|
@@ -40,6 +43,9 @@ export const Autocomplete = ({
|
|
|
40
43
|
filteredExternally,
|
|
41
44
|
loading,
|
|
42
45
|
disabled,
|
|
46
|
+
emptyLabel,
|
|
47
|
+
openOnClick = true,
|
|
48
|
+
openOnFocus = true,
|
|
43
49
|
...rest
|
|
44
50
|
}: Props) => {
|
|
45
51
|
const { contains } = useFilter({ sensitivity: "base" });
|
|
@@ -64,26 +70,29 @@ export const Autocomplete = ({
|
|
|
64
70
|
[children, collection.items],
|
|
65
71
|
);
|
|
66
72
|
|
|
73
|
+
const combobox = useCombobox({
|
|
74
|
+
collection,
|
|
75
|
+
openOnClick,
|
|
76
|
+
onInputValueChange: (event) => {
|
|
77
|
+
if (!filteredExternally) {
|
|
78
|
+
filter(event.inputValue);
|
|
79
|
+
}
|
|
80
|
+
onInputValueChange?.(event);
|
|
81
|
+
},
|
|
82
|
+
positioning: {
|
|
83
|
+
placement: "bottom",
|
|
84
|
+
offset: {
|
|
85
|
+
mainAxis: 3,
|
|
86
|
+
crossAxis: -1,
|
|
87
|
+
},
|
|
88
|
+
flip: false,
|
|
89
|
+
},
|
|
90
|
+
disabled,
|
|
91
|
+
...rest,
|
|
92
|
+
});
|
|
93
|
+
|
|
67
94
|
return (
|
|
68
|
-
<Combobox.
|
|
69
|
-
{...rest}
|
|
70
|
-
collection={collection}
|
|
71
|
-
onInputValueChange={(event) => {
|
|
72
|
-
if (!filteredExternally) {
|
|
73
|
-
filter(event.inputValue);
|
|
74
|
-
}
|
|
75
|
-
onInputValueChange?.(event);
|
|
76
|
-
}}
|
|
77
|
-
positioning={{
|
|
78
|
-
placement: "bottom",
|
|
79
|
-
offset: {
|
|
80
|
-
mainAxis: 3,
|
|
81
|
-
crossAxis: -1,
|
|
82
|
-
},
|
|
83
|
-
flip: false,
|
|
84
|
-
}}
|
|
85
|
-
disabled={disabled}
|
|
86
|
-
>
|
|
95
|
+
<Combobox.RootProvider value={combobox}>
|
|
87
96
|
<Combobox.Control>
|
|
88
97
|
<Combobox.Input asChild>
|
|
89
98
|
<Input
|
|
@@ -95,17 +104,22 @@ export const Autocomplete = ({
|
|
|
95
104
|
helperText={helperText}
|
|
96
105
|
errorText={errorText}
|
|
97
106
|
required={required}
|
|
107
|
+
onFocus={() => {
|
|
108
|
+
if (openOnFocus) combobox.setOpen(true);
|
|
109
|
+
}}
|
|
98
110
|
/>
|
|
99
111
|
</Combobox.Input>
|
|
100
112
|
<Combobox.IndicatorGroup>
|
|
101
|
-
<Combobox.ClearTrigger asChild>
|
|
113
|
+
<Combobox.ClearTrigger asChild aria-label={t(texts.clearValue)}>
|
|
102
114
|
<CloseButton size="xs" />
|
|
103
115
|
</Combobox.ClearTrigger>
|
|
104
116
|
</Combobox.IndicatorGroup>
|
|
105
117
|
</Combobox.Control>
|
|
106
118
|
<Combobox.Positioner>
|
|
107
119
|
<Combobox.Content>
|
|
108
|
-
<Combobox.Empty>
|
|
120
|
+
<Combobox.Empty>
|
|
121
|
+
{!loading && (emptyLabel ?? t(texts.noItemsFound))}
|
|
122
|
+
</Combobox.Empty>
|
|
109
123
|
{loading ? (
|
|
110
124
|
<ColorSpinner width="1.5rem" p="2" />
|
|
111
125
|
) : (
|
|
@@ -113,7 +127,7 @@ export const Autocomplete = ({
|
|
|
113
127
|
)}
|
|
114
128
|
</Combobox.Content>
|
|
115
129
|
</Combobox.Positioner>
|
|
116
|
-
</Combobox.
|
|
130
|
+
</Combobox.RootProvider>
|
|
117
131
|
);
|
|
118
132
|
};
|
|
119
133
|
|
|
@@ -206,4 +220,10 @@ const texts = createTexts({
|
|
|
206
220
|
sv: "Inga resultat",
|
|
207
221
|
en: "No results found",
|
|
208
222
|
},
|
|
223
|
+
clearValue: {
|
|
224
|
+
nb: "Tøm verdi",
|
|
225
|
+
nn: "Tøm verdi",
|
|
226
|
+
sv: "Rensa värde",
|
|
227
|
+
en: "Clear value",
|
|
228
|
+
},
|
|
209
229
|
});
|
package/src/input/Field.tsx
CHANGED
|
@@ -129,7 +129,9 @@ export const Field = React.forwardRef<HTMLDivElement, FieldProps>(
|
|
|
129
129
|
</FloatingLabel>
|
|
130
130
|
)}
|
|
131
131
|
{errorText && (
|
|
132
|
-
<ChakraField.ErrorText>
|
|
132
|
+
<ChakraField.ErrorText aria-live="polite">
|
|
133
|
+
{errorText}
|
|
134
|
+
</ChakraField.ErrorText>
|
|
133
135
|
)}
|
|
134
136
|
</ChakraField.Root>
|
|
135
137
|
{helperText && (
|
|
@@ -19,14 +19,14 @@ const floatingLabelStyles = defineStyle({
|
|
|
19
19
|
},
|
|
20
20
|
|
|
21
21
|
pos: "absolute",
|
|
22
|
-
transition: "
|
|
22
|
+
transition: "top 160ms ease, font-size 160ms ease",
|
|
23
23
|
|
|
24
24
|
top: "0.9rem",
|
|
25
25
|
color: "text",
|
|
26
26
|
fontSize: ["mobile.sm", "desktop.sm"],
|
|
27
27
|
|
|
28
28
|
"&[data-float]": {
|
|
29
|
-
fontSize: ["mobile.
|
|
29
|
+
fontSize: ["mobile.2xs", "desktop.2xs"],
|
|
30
30
|
color: "text",
|
|
31
31
|
top: "0.3rem",
|
|
32
32
|
},
|
package/src/input/Input.tsx
CHANGED
|
@@ -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>
|
|
@@ -119,6 +122,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
|
|
|
119
122
|
<InputElement
|
|
120
123
|
pointerEvents="none"
|
|
121
124
|
paddingX={2}
|
|
125
|
+
aria-hidden="true"
|
|
122
126
|
fontSize={fontSize ?? "mobile.md"}
|
|
123
127
|
>
|
|
124
128
|
{startElement}
|
|
@@ -140,6 +144,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
|
|
|
140
144
|
placeholder=""
|
|
141
145
|
css={styles}
|
|
142
146
|
fontSize={fontSize ?? "mobile.md"}
|
|
147
|
+
aria-labelledby={labelId}
|
|
143
148
|
/>
|
|
144
149
|
{endElement && (
|
|
145
150
|
<InputElement
|
|
@@ -107,7 +107,6 @@ export const NumericStepper = React.forwardRef<
|
|
|
107
107
|
<Field
|
|
108
108
|
css={styles.root}
|
|
109
109
|
width="auto"
|
|
110
|
-
id={idProperty}
|
|
111
110
|
ref={ref}
|
|
112
111
|
label={label}
|
|
113
112
|
helperText={helperText}
|
|
@@ -133,7 +132,6 @@ export const NumericStepper = React.forwardRef<
|
|
|
133
132
|
}
|
|
134
133
|
}}
|
|
135
134
|
disabled={disabled || value <= minValue}
|
|
136
|
-
id={value <= minValue ? undefined : idProperty}
|
|
137
135
|
/>
|
|
138
136
|
{withInput ? (
|
|
139
137
|
<Input
|
|
@@ -142,7 +140,7 @@ export const NumericStepper = React.forwardRef<
|
|
|
142
140
|
name={nameProperty}
|
|
143
141
|
value={value}
|
|
144
142
|
disabled={disabled}
|
|
145
|
-
id={
|
|
143
|
+
id={idProperty}
|
|
146
144
|
css={styles.input}
|
|
147
145
|
width={`${Math.max(value.toString().length + 1, 3)}ch`}
|
|
148
146
|
aria-live="assertive"
|
|
@@ -189,7 +187,6 @@ export const NumericStepper = React.forwardRef<
|
|
|
189
187
|
)}
|
|
190
188
|
onClick={() => onChange(Math.min(value + clampedStepSize, maxValue))}
|
|
191
189
|
disabled={disabled || value >= maxValue}
|
|
192
|
-
id={value >= maxValue ? undefined : idProperty}
|
|
193
190
|
/>
|
|
194
191
|
</Field>
|
|
195
192
|
);
|
package/src/input/Textarea.tsx
CHANGED
|
@@ -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";
|
package/src/table/Table.tsx
CHANGED
|
@@ -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 {
|
|
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>(
|
|
36
|
-
|
|
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.
|
|
42
|
-
variant={variant}
|
|
43
|
-
size={size}
|
|
44
|
-
colorPalette={colorPalette}
|
|
45
|
-
css={styles}
|
|
128
|
+
<ChakraTable.ColumnHeader
|
|
46
129
|
ref={ref}
|
|
47
|
-
{
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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";
|
package/src/table/index.tsx
CHANGED
|
@@ -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
|
+
});
|