@scm-manager/ui-core 3.7.5 → 3.8.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-typecheck.log +1 -1
- package/package.json +3 -3
- package/src/base/buttons/Button.test.stories.mdx +78 -65
- package/src/base/forms/Form.tsx +3 -3
- package/src/base/forms/checkbox/Checkbox.tsx +59 -48
- package/src/base/forms/checkbox/ControlledCheckboxField.tsx +4 -0
- package/src/base/forms/chip-input/ChipInputField.tsx +0 -1
- package/src/base/forms/index.ts +2 -0
- package/src/base/forms/input/ControlledInputField.tsx +7 -0
- package/src/base/forms/input/InputField.stories.mdx +4 -0
- package/src/base/forms/input/InputField.tsx +19 -5
- package/src/base/notifications/Notification.tsx +1 -1
- package/src/base/shortcuts/index.ts +6 -1
- package/src/base/shortcuts/iterator/callbackIterator.ts +74 -33
- package/src/base/shortcuts/iterator/keyboardIterator.test.tsx +0 -5
- package/src/base/shortcuts/iterator/keyboardIterator.tsx +54 -1
- package/src/index.ts +1 -1
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
@scm-manager/ui-core:typecheck: cache hit, replaying output
|
|
1
|
+
@scm-manager/ui-core:typecheck: cache hit, replaying output 0276ad30237bf6ba
|
|
2
2
|
@scm-manager/ui-core:typecheck: $ tsc
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scm-manager/ui-core",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.8.0",
|
|
4
4
|
"main": "./src/index.ts",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"scripts": {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"styled-components": "5"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@scm-manager/ui-api": "3.
|
|
23
|
+
"@scm-manager/ui-api": "3.8.0",
|
|
24
24
|
"@radix-ui/react-radio-group": "^1.1.3",
|
|
25
25
|
"@radix-ui/react-slot": "^1.0.1",
|
|
26
26
|
"@radix-ui/react-visually-hidden": "^1.0.3",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"@scm-manager/eslint-config": "^2.17.0",
|
|
38
38
|
"@scm-manager/tsconfig": "^2.12.0",
|
|
39
39
|
"@scm-manager/babel-preset": "^2.13.1",
|
|
40
|
-
"@scm-manager/ui-types": "3.
|
|
40
|
+
"@scm-manager/ui-types": "3.8.0",
|
|
41
41
|
"@types/mousetrap": "1.6.5",
|
|
42
42
|
"@testing-library/react-hooks": "8.0.1",
|
|
43
43
|
"@testing-library/react": "12.1.5",
|
|
@@ -1,74 +1,87 @@
|
|
|
1
1
|
import { Meta, Story } from "@storybook/addon-docs";
|
|
2
2
|
import { Button, ButtonVariantList } from "./Button";
|
|
3
3
|
|
|
4
|
-
<Meta title="
|
|
4
|
+
<Meta title="Buttons" />
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
6
|
+
export const renderTableContent = () => (
|
|
7
|
+
<>
|
|
8
|
+
{["Normal", "Hover", "Focus", "Active", "Disabled", "Loading"].map((state) => (
|
|
9
|
+
<tr>
|
|
10
|
+
<td>{state}</td>
|
|
11
|
+
{ButtonVariantList.map((variant) => (
|
|
12
|
+
<td>
|
|
13
|
+
<Button
|
|
14
|
+
id={`${variant}-${state}`}
|
|
15
|
+
disabled={state === "Disabled"}
|
|
16
|
+
isLoading={state === "Loading"}
|
|
17
|
+
className={
|
|
18
|
+
state === "Hover"
|
|
19
|
+
? "is-hovered"
|
|
20
|
+
: state === "Focus"
|
|
21
|
+
? "is-focused"
|
|
22
|
+
: state === "Active"
|
|
23
|
+
? "is-active"
|
|
24
|
+
: ""
|
|
25
|
+
}
|
|
26
|
+
variant={variant}
|
|
27
|
+
>
|
|
28
|
+
Button
|
|
29
|
+
</Button>
|
|
30
|
+
</td>
|
|
31
|
+
))}
|
|
32
|
+
</tr>
|
|
33
|
+
))}
|
|
34
|
+
</>
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
export const ButtonStates = (args) => {
|
|
38
|
+
return (
|
|
39
|
+
<>
|
|
40
|
+
<table className="table is-narrow">
|
|
41
|
+
<tr>
|
|
42
|
+
<th>STATE</th>
|
|
43
|
+
{ButtonVariantList.map((variant) => (
|
|
44
|
+
<th>{variant.toUpperCase()}</th>
|
|
45
|
+
))}
|
|
46
|
+
</tr>
|
|
47
|
+
{renderTableContent()}
|
|
48
|
+
</table>
|
|
49
|
+
<table className="table is-narrow has-background-dark has-text-white">
|
|
50
|
+
{renderTableContent()}
|
|
51
|
+
</table>
|
|
52
|
+
</>
|
|
53
|
+
);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
<Story
|
|
57
|
+
name="Light States"
|
|
58
|
+
parameters={{
|
|
59
|
+
themes: {
|
|
60
|
+
default: "light",
|
|
61
|
+
},
|
|
62
|
+
}}
|
|
63
|
+
>
|
|
64
|
+
<ButtonStates />
|
|
28
65
|
</Story>
|
|
29
66
|
|
|
30
|
-
<Story
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}}>
|
|
40
|
-
<table>
|
|
41
|
-
<tr>
|
|
42
|
-
<th>STATE</th>
|
|
43
|
-
{ButtonVariantList.map(variant => <th>{variant.toUpperCase()}</th>)}
|
|
44
|
-
</tr>
|
|
45
|
-
{["Normal", "Hover", "Active", "Focus", "Disabled"].map(state => <tr>
|
|
46
|
-
<td>{state}</td>
|
|
47
|
-
{ButtonVariantList.map(variant => <td><Button id={`${variant}-${state}`} disabled={state === "Disabled"}
|
|
48
|
-
variant={variant}>Button</Button></td>)}
|
|
49
|
-
</tr>)}
|
|
50
|
-
</table>
|
|
67
|
+
<Story
|
|
68
|
+
name="Dark States"
|
|
69
|
+
parameters={{
|
|
70
|
+
themes: {
|
|
71
|
+
default: "dark",
|
|
72
|
+
},
|
|
73
|
+
}}
|
|
74
|
+
>
|
|
75
|
+
<ButtonStates />
|
|
51
76
|
</Story>
|
|
52
77
|
|
|
53
|
-
<Story
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}}>
|
|
63
|
-
<table>
|
|
64
|
-
<tr>
|
|
65
|
-
<th>STATE</th>
|
|
66
|
-
{ButtonVariantList.map(variant => <th>{variant.toUpperCase()}</th>)}
|
|
67
|
-
</tr>
|
|
68
|
-
{["Normal", "Hover", "Active", "Focus", "Disabled"].map(state => <tr>
|
|
69
|
-
<td>{state}</td>
|
|
70
|
-
{ButtonVariantList.map(variant => <td><Button id={`${variant}-${state}`} disabled={state === "Disabled"}
|
|
71
|
-
variant={variant}>Button</Button></td>)}
|
|
72
|
-
</tr>)}
|
|
73
|
-
</table>
|
|
78
|
+
<Story
|
|
79
|
+
name="High-Contrast States"
|
|
80
|
+
parameters={{
|
|
81
|
+
themes: {
|
|
82
|
+
default: "highcontrast",
|
|
83
|
+
},
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
86
|
+
<ButtonStates />
|
|
74
87
|
</Story>
|
package/src/base/forms/Form.tsx
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import React, { FC, useCallback, useEffect, useState } from "react";
|
|
18
18
|
import { DeepPartial, SubmitHandler, useForm, UseFormReturn } from "react-hook-form";
|
|
19
19
|
import { ErrorNotification } from "../notifications";
|
|
20
|
-
import { Level } from
|
|
20
|
+
import { Level } from "../misc";
|
|
21
21
|
import { ScmFormContextProvider } from "./ScmFormContext";
|
|
22
22
|
import { useTranslation } from "react-i18next";
|
|
23
23
|
import { Button } from "../buttons";
|
|
@@ -135,10 +135,10 @@ function Form<FormType extends Record<string, unknown>, DefaultValues extends Fo
|
|
|
135
135
|
|
|
136
136
|
// See https://react-hook-form.com/api/useform/reset/
|
|
137
137
|
useEffect(() => {
|
|
138
|
-
if (isSubmitSuccessful) {
|
|
138
|
+
if (isSubmitSuccessful && !error) {
|
|
139
139
|
setShowSuccessNotification(true);
|
|
140
140
|
}
|
|
141
|
-
}, [isSubmitSuccessful]);
|
|
141
|
+
}, [isSubmitSuccessful, error]);
|
|
142
142
|
|
|
143
143
|
useEffect(() => reset(defaultValues as never), [defaultValues, reset]);
|
|
144
144
|
|
|
@@ -33,6 +33,7 @@ const StyledLabel = styled.label`
|
|
|
33
33
|
type InputFieldProps = {
|
|
34
34
|
label: string;
|
|
35
35
|
helpText?: string;
|
|
36
|
+
descriptionText?: string;
|
|
36
37
|
testId?: string;
|
|
37
38
|
labelClassName?: string;
|
|
38
39
|
} & Omit<InputHTMLAttributes<HTMLInputElement>, "type">;
|
|
@@ -45,6 +46,7 @@ const Checkbox = React.forwardRef<HTMLInputElement, InputFieldProps>(
|
|
|
45
46
|
{
|
|
46
47
|
readOnly,
|
|
47
48
|
label,
|
|
49
|
+
descriptionText,
|
|
48
50
|
className,
|
|
49
51
|
labelClassName,
|
|
50
52
|
value,
|
|
@@ -57,54 +59,63 @@ const Checkbox = React.forwardRef<HTMLInputElement, InputFieldProps>(
|
|
|
57
59
|
...props
|
|
58
60
|
},
|
|
59
61
|
ref
|
|
60
|
-
) =>
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
62
|
+
) => {
|
|
63
|
+
const descriptionId = descriptionText ? `checkbox-description-${name}` : undefined;
|
|
64
|
+
return (
|
|
65
|
+
<>
|
|
66
|
+
{descriptionText ? <p id={descriptionId}>{descriptionText}</p> : null}
|
|
67
|
+
<StyledLabel
|
|
68
|
+
className={classNames("checkbox is-align-items-center", labelClassName)}
|
|
69
|
+
// @ts-ignore bulma uses the disabled attribute on labels, although it is not part of the html spec
|
|
70
|
+
disabled={readOnly || props.disabled}
|
|
71
|
+
>
|
|
72
|
+
{readOnly ? (
|
|
73
|
+
<>
|
|
74
|
+
<input
|
|
75
|
+
type="hidden"
|
|
76
|
+
name={name}
|
|
77
|
+
value={value}
|
|
78
|
+
defaultValue={defaultValue}
|
|
79
|
+
checked={checked}
|
|
80
|
+
defaultChecked={defaultChecked}
|
|
81
|
+
aria-describedby={descriptionId}
|
|
82
|
+
readOnly
|
|
83
|
+
/>
|
|
84
|
+
<StyledInput
|
|
85
|
+
type="checkbox"
|
|
86
|
+
className={classNames("m-3", className)}
|
|
87
|
+
ref={ref}
|
|
88
|
+
value={value}
|
|
89
|
+
defaultValue={defaultValue}
|
|
90
|
+
checked={checked}
|
|
91
|
+
defaultChecked={defaultChecked}
|
|
92
|
+
aria-describedby={descriptionId}
|
|
93
|
+
{...props}
|
|
94
|
+
{...createAttributesForTesting(testId)}
|
|
95
|
+
disabled
|
|
96
|
+
/>
|
|
97
|
+
</>
|
|
98
|
+
) : (
|
|
99
|
+
<StyledInput
|
|
100
|
+
type="checkbox"
|
|
101
|
+
className={classNames("m-3", className)}
|
|
102
|
+
ref={ref}
|
|
103
|
+
name={name}
|
|
104
|
+
value={value}
|
|
105
|
+
defaultValue={defaultValue}
|
|
106
|
+
checked={checked}
|
|
107
|
+
defaultChecked={defaultChecked}
|
|
108
|
+
aria-describedby={descriptionId}
|
|
109
|
+
{...props}
|
|
110
|
+
{...createAttributesForTesting(testId)}
|
|
111
|
+
/>
|
|
112
|
+
)}
|
|
104
113
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
114
|
+
{label}
|
|
115
|
+
{helpText ? <Help className="ml-1" text={helpText} /> : null}
|
|
116
|
+
</StyledLabel>
|
|
117
|
+
</>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
109
120
|
);
|
|
110
121
|
export default Checkbox;
|
|
@@ -35,6 +35,7 @@ function ControlledInputField<T extends Record<string, unknown>>({
|
|
|
35
35
|
name,
|
|
36
36
|
label,
|
|
37
37
|
helpText,
|
|
38
|
+
descriptionText,
|
|
38
39
|
rules,
|
|
39
40
|
testId,
|
|
40
41
|
defaultChecked,
|
|
@@ -48,6 +49,8 @@ function ControlledInputField<T extends Record<string, unknown>>({
|
|
|
48
49
|
const prefixedNameWithoutIndices = prefixWithoutIndices(nameWithPrefix);
|
|
49
50
|
const labelTranslation = label || t(`${prefixedNameWithoutIndices}.label`) || "";
|
|
50
51
|
const helpTextTranslation = helpText || t(`${prefixedNameWithoutIndices}.helpText`);
|
|
52
|
+
const descriptionTextTranslation = descriptionText || t(`${prefixedNameWithoutIndices}.descriptionText`);
|
|
53
|
+
|
|
51
54
|
return (
|
|
52
55
|
<Controller
|
|
53
56
|
control={control}
|
|
@@ -64,6 +67,7 @@ function ControlledInputField<T extends Record<string, unknown>>({
|
|
|
64
67
|
{...field}
|
|
65
68
|
label={labelTranslation}
|
|
66
69
|
helpText={helpTextTranslation}
|
|
70
|
+
descriptionText={descriptionTextTranslation}
|
|
67
71
|
testId={testId ?? `checkbox-${nameWithPrefix}`}
|
|
68
72
|
/>
|
|
69
73
|
)}
|
|
@@ -28,7 +28,6 @@ import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
|
|
28
28
|
import { useTranslation } from "react-i18next";
|
|
29
29
|
import { withForwardRef } from "../helpers";
|
|
30
30
|
import { Option } from "@scm-manager/ui-types";
|
|
31
|
-
import { waitForRestartAfter } from "@scm-manager/ui-api";
|
|
32
31
|
|
|
33
32
|
const StyledChipInput: typeof ChipInput = styled(ChipInput)`
|
|
34
33
|
min-height: 40px;
|
package/src/base/forms/index.ts
CHANGED
|
@@ -36,12 +36,14 @@ import RadioButton from "./radio-button/RadioButton";
|
|
|
36
36
|
import RadioGroupFieldComponent from "./radio-button/RadioGroupField";
|
|
37
37
|
|
|
38
38
|
export { default as Field } from "./base/Field";
|
|
39
|
+
export { default as FieldMessage } from "./base/field-message/FieldMessage";
|
|
39
40
|
export { default as Checkbox } from "./checkbox/Checkbox";
|
|
40
41
|
export { default as Combobox } from "./combobox/Combobox";
|
|
41
42
|
export { default as ConfigurationForm } from "./ConfigurationForm";
|
|
42
43
|
export { default as SelectField } from "./select/SelectField";
|
|
43
44
|
export { default as ComboboxField } from "./combobox/ComboboxField";
|
|
44
45
|
export { default as Input } from "./input/Input";
|
|
46
|
+
export { default as InputField } from "./input/InputField";
|
|
45
47
|
export { default as Textarea } from "./input/Textarea";
|
|
46
48
|
export { default as Select } from "./select/Select";
|
|
47
49
|
export * from "./resourceHooks";
|
|
@@ -29,17 +29,20 @@ type Props<T extends Record<string, unknown>> = Omit<
|
|
|
29
29
|
rules?: ComponentProps<typeof Controller>["rules"];
|
|
30
30
|
name: Path<T>;
|
|
31
31
|
label?: string;
|
|
32
|
+
icon?: string;
|
|
32
33
|
};
|
|
33
34
|
|
|
34
35
|
function ControlledInputField<T extends Record<string, unknown>>({
|
|
35
36
|
name,
|
|
36
37
|
label,
|
|
37
38
|
helpText,
|
|
39
|
+
descriptionText,
|
|
38
40
|
rules,
|
|
39
41
|
testId,
|
|
40
42
|
defaultValue,
|
|
41
43
|
readOnly,
|
|
42
44
|
className,
|
|
45
|
+
icon,
|
|
43
46
|
...props
|
|
44
47
|
}: Props<T>) {
|
|
45
48
|
const { control, t, readOnly: formReadonly, formId } = useScmFormContext();
|
|
@@ -48,6 +51,8 @@ function ControlledInputField<T extends Record<string, unknown>>({
|
|
|
48
51
|
const prefixedNameWithoutIndices = prefixWithoutIndices(nameWithPrefix);
|
|
49
52
|
const labelTranslation = label || t(`${prefixedNameWithoutIndices}.label`) || "";
|
|
50
53
|
const helpTextTranslation = helpText || t(`${prefixedNameWithoutIndices}.helpText`);
|
|
54
|
+
const descriptionTextTranslation = descriptionText || t(`${prefixedNameWithoutIndices}.descriptionText`);
|
|
55
|
+
|
|
51
56
|
return (
|
|
52
57
|
<Controller
|
|
53
58
|
control={control}
|
|
@@ -63,7 +68,9 @@ function ControlledInputField<T extends Record<string, unknown>>({
|
|
|
63
68
|
{...field}
|
|
64
69
|
form={formId}
|
|
65
70
|
label={labelTranslation}
|
|
71
|
+
icon={icon}
|
|
66
72
|
helpText={helpTextTranslation}
|
|
73
|
+
descriptionText={descriptionTextTranslation}
|
|
67
74
|
error={
|
|
68
75
|
fieldState.error
|
|
69
76
|
? fieldState.error.message || t(`${prefixedNameWithoutIndices}.error.${fieldState.error.type}`)
|
|
@@ -23,27 +23,41 @@ import Input from "./Input";
|
|
|
23
23
|
import Help from "../base/help/Help";
|
|
24
24
|
import { useAriaId } from "../../helpers";
|
|
25
25
|
|
|
26
|
-
type InputFieldProps = {
|
|
26
|
+
export type InputFieldProps = {
|
|
27
27
|
label: string;
|
|
28
|
+
labelClassName?: string;
|
|
28
29
|
helpText?: string;
|
|
30
|
+
descriptionText?: string;
|
|
29
31
|
error?: string;
|
|
32
|
+
icon?: string;
|
|
30
33
|
} & React.ComponentProps<typeof Input>;
|
|
31
34
|
|
|
32
35
|
/**
|
|
33
36
|
* @see https://bulma.io/documentation/form/input/
|
|
34
37
|
*/
|
|
35
38
|
const InputField = React.forwardRef<HTMLInputElement, InputFieldProps>(
|
|
36
|
-
({ label, helpText, error, className, id, ...props }, ref) => {
|
|
39
|
+
({ name, label, helpText, descriptionText, error, icon, className, labelClassName, id, ...props }, ref) => {
|
|
37
40
|
const inputId = useAriaId(id ?? props.testId);
|
|
41
|
+
const descriptionId = descriptionText ? `input-description-${name}` : undefined;
|
|
38
42
|
const variant = error ? "danger" : undefined;
|
|
39
43
|
return (
|
|
40
44
|
<Field className={className}>
|
|
41
|
-
<Label htmlFor={inputId}>
|
|
45
|
+
<Label htmlFor={inputId} className={labelClassName}>
|
|
42
46
|
{label}
|
|
43
47
|
{helpText ? <Help className="ml-1" text={helpText} /> : null}
|
|
44
48
|
</Label>
|
|
45
|
-
|
|
46
|
-
<
|
|
49
|
+
{descriptionText ? (
|
|
50
|
+
<p className="mb-2" id={descriptionId}>
|
|
51
|
+
{descriptionText}
|
|
52
|
+
</p>
|
|
53
|
+
) : null}
|
|
54
|
+
<Control className="has-icons-left">
|
|
55
|
+
<Input variant={variant} ref={ref} id={inputId} aria-describedby={descriptionId} {...props}></Input>
|
|
56
|
+
{icon ? (
|
|
57
|
+
<span className="icon is-small is-left">
|
|
58
|
+
<i className={icon} />
|
|
59
|
+
</span>
|
|
60
|
+
) : null}
|
|
47
61
|
</Control>
|
|
48
62
|
{error ? <FieldMessage variant={variant}>{error}</FieldMessage> : null}
|
|
49
63
|
</Field>
|
|
@@ -30,7 +30,7 @@ const Notification = React.forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElem
|
|
|
30
30
|
const color = type !== "inherit" ? "is-" + type : "";
|
|
31
31
|
|
|
32
32
|
return (
|
|
33
|
-
<div className={classNames("notification", color, className)} role={role} ref={ref}>
|
|
33
|
+
<div className={classNames("notification", color, className)} role={role} ref={ref} {...props}>
|
|
34
34
|
{onClose ? <button className="delete" onClick={onClose} /> : null}
|
|
35
35
|
{children}
|
|
36
36
|
</div>
|
|
@@ -17,4 +17,9 @@
|
|
|
17
17
|
export { default as useShortcut } from "./useShortcut";
|
|
18
18
|
export { default as useShortcutDocs, ShortcutDocsContextProvider } from "./useShortcutDocs";
|
|
19
19
|
export { default as usePauseShortcuts } from "./usePauseShortcuts";
|
|
20
|
-
export {
|
|
20
|
+
export {
|
|
21
|
+
useKeyboardIteratorTarget,
|
|
22
|
+
KeyboardIterator,
|
|
23
|
+
KeyboardSubIterator,
|
|
24
|
+
useKeyboardIteratorTargetV2,
|
|
25
|
+
} from "./iterator/keyboardIterator";
|
|
@@ -20,6 +20,12 @@ const INACTIVE_INDEX = -1;
|
|
|
20
20
|
|
|
21
21
|
export type Callback = () => void;
|
|
22
22
|
|
|
23
|
+
export type IterableCallback = {
|
|
24
|
+
item: Callback | CallbackIterator;
|
|
25
|
+
expectedIndex: number;
|
|
26
|
+
cleanup?: Callback;
|
|
27
|
+
};
|
|
28
|
+
|
|
23
29
|
type Direction = "forward" | "backward";
|
|
24
30
|
|
|
25
31
|
/**
|
|
@@ -31,11 +37,18 @@ export type CallbackRegistry = {
|
|
|
31
37
|
* Registers the given item and returns its index to use in {@link deregister}.
|
|
32
38
|
*/
|
|
33
39
|
register: (item: Callback | CallbackIterator) => number;
|
|
34
|
-
|
|
35
40
|
/**
|
|
36
41
|
* Use the index returned from {@link register} to de-register.
|
|
37
42
|
*/
|
|
38
43
|
deregister: (index: number) => void;
|
|
44
|
+
/**
|
|
45
|
+
* Registers the given iterable item while maintaining the order of the expected index of each item.
|
|
46
|
+
*/
|
|
47
|
+
registerItem?: (iterable: IterableCallback) => void;
|
|
48
|
+
/**
|
|
49
|
+
* Removes the passed iterable item.
|
|
50
|
+
*/
|
|
51
|
+
deregisterItem?: (iterable: IterableCallback) => void;
|
|
39
52
|
};
|
|
40
53
|
|
|
41
54
|
const isSubiterator = (item?: Callback | CallbackIterator): item is CallbackIterator =>
|
|
@@ -51,6 +64,8 @@ const offset = (direction: Direction) => (direction === "forward" ? 1 : -1);
|
|
|
51
64
|
*
|
|
52
65
|
* ## Terminology
|
|
53
66
|
* - Item: Either a callback or a nested iterator
|
|
67
|
+
* - Cleanup: A callback that is called, if the corresponding item is removed
|
|
68
|
+
* - Iterable: A wrapper containing an item, a cleanup callback and the index that this iterable is expected to be at
|
|
54
69
|
* - Available: Item is a non-empty iterator OR a regular callback
|
|
55
70
|
* - Inactive: Current index is -1
|
|
56
71
|
* - Activate: Move iterator while in inactive state OR call regular callback
|
|
@@ -68,7 +83,7 @@ export class CallbackIterator implements CallbackRegistry {
|
|
|
68
83
|
|
|
69
84
|
constructor(
|
|
70
85
|
private readonly activeIndexRef: MutableRefObject<number>,
|
|
71
|
-
private readonly itemsRef: MutableRefObject<Array<
|
|
86
|
+
private readonly itemsRef: MutableRefObject<Array<IterableCallback>>
|
|
72
87
|
) {}
|
|
73
88
|
|
|
74
89
|
private get activeIndex() {
|
|
@@ -83,7 +98,7 @@ export class CallbackIterator implements CallbackRegistry {
|
|
|
83
98
|
return this.itemsRef.current;
|
|
84
99
|
}
|
|
85
100
|
|
|
86
|
-
private get currentItem():
|
|
101
|
+
private get currentItem(): IterableCallback | undefined {
|
|
87
102
|
return this.items[this.activeIndex];
|
|
88
103
|
}
|
|
89
104
|
|
|
@@ -101,9 +116,9 @@ export class CallbackIterator implements CallbackRegistry {
|
|
|
101
116
|
|
|
102
117
|
private firstAvailableIndex = (direction: Direction, fromIndex = this.firstIndex(direction)) => {
|
|
103
118
|
for (; direction === "forward" ? fromIndex < this.items.length : fromIndex >= 0; fromIndex += offset(direction)) {
|
|
104
|
-
const
|
|
105
|
-
if (
|
|
106
|
-
if (!isSubiterator(
|
|
119
|
+
const iterableCallback = this.items[fromIndex];
|
|
120
|
+
if (iterableCallback) {
|
|
121
|
+
if (!isSubiterator(iterableCallback.item) || iterableCallback.item.hasNext(direction)) {
|
|
107
122
|
return fromIndex;
|
|
108
123
|
}
|
|
109
124
|
}
|
|
@@ -116,14 +131,15 @@ export class CallbackIterator implements CallbackRegistry {
|
|
|
116
131
|
};
|
|
117
132
|
|
|
118
133
|
private activateCurrentItem = (direction: Direction) => {
|
|
119
|
-
if (isSubiterator(this.currentItem)) {
|
|
120
|
-
this.currentItem.move(direction);
|
|
134
|
+
if (isSubiterator(this.currentItem?.item)) {
|
|
135
|
+
this.currentItem?.item.move(direction);
|
|
121
136
|
} else if (this.currentItem) {
|
|
122
|
-
this.currentItem();
|
|
137
|
+
this.currentItem.item();
|
|
123
138
|
}
|
|
124
139
|
};
|
|
125
140
|
|
|
126
141
|
private setIndexAndActivateCurrentItem = (index: number | null, direction: Direction) => {
|
|
142
|
+
this.currentItem?.cleanup?.();
|
|
127
143
|
if (index !== null && index !== INACTIVE_INDEX) {
|
|
128
144
|
this.activeIndex = index;
|
|
129
145
|
this.activateCurrentItem(direction);
|
|
@@ -131,11 +147,11 @@ export class CallbackIterator implements CallbackRegistry {
|
|
|
131
147
|
};
|
|
132
148
|
|
|
133
149
|
private move = (direction: Direction) => {
|
|
134
|
-
if (isSubiterator(this.currentItem) && this.currentItem.hasNext(direction)) {
|
|
135
|
-
this.currentItem.move(direction);
|
|
150
|
+
if (isSubiterator(this.currentItem?.item) && this.currentItem?.item.hasNext(direction)) {
|
|
151
|
+
this.currentItem?.item.move(direction);
|
|
136
152
|
} else {
|
|
137
|
-
if (isSubiterator(this.currentItem)) {
|
|
138
|
-
this.currentItem.reset();
|
|
153
|
+
if (isSubiterator(this.currentItem?.item)) {
|
|
154
|
+
this.currentItem?.item.reset();
|
|
139
155
|
}
|
|
140
156
|
let nextIndex: number | null;
|
|
141
157
|
if (this.isInactive) {
|
|
@@ -151,7 +167,7 @@ export class CallbackIterator implements CallbackRegistry {
|
|
|
151
167
|
if (this.isInactive) {
|
|
152
168
|
return this.hasAvailableIndex(inDirection);
|
|
153
169
|
}
|
|
154
|
-
if (isSubiterator(this.currentItem) && this.currentItem.hasNext(inDirection)) {
|
|
170
|
+
if (isSubiterator(this.currentItem?.item) && this.currentItem?.item.hasNext(inDirection)) {
|
|
155
171
|
return true;
|
|
156
172
|
}
|
|
157
173
|
return this.hasAvailableIndex(inDirection, this.activeIndex + offset(inDirection));
|
|
@@ -172,41 +188,66 @@ export class CallbackIterator implements CallbackRegistry {
|
|
|
172
188
|
public reset = () => {
|
|
173
189
|
this.activeIndex = INACTIVE_INDEX;
|
|
174
190
|
for (const cb of this.items) {
|
|
175
|
-
if (isSubiterator(cb)) {
|
|
176
|
-
cb.reset();
|
|
191
|
+
if (isSubiterator(cb.item)) {
|
|
192
|
+
cb.item.reset();
|
|
177
193
|
}
|
|
178
194
|
}
|
|
179
195
|
};
|
|
180
196
|
|
|
181
197
|
public register = (item: Callback | CallbackIterator) => {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
return this.items.push(item) - 1;
|
|
198
|
+
const expectedIndex = this.items.length;
|
|
199
|
+
this.registerItem({ item, expectedIndex });
|
|
200
|
+
return expectedIndex;
|
|
186
201
|
};
|
|
187
202
|
|
|
188
203
|
public deregister = (index: number) => {
|
|
189
|
-
this.items
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
204
|
+
if (this.items[index]) {
|
|
205
|
+
this.deregisterItem(this.items[index]);
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
public deregisterItem = (iterable: IterableCallback) => {
|
|
210
|
+
const itemIndex = this.items.findIndex((value) => value.expectedIndex === iterable.expectedIndex);
|
|
211
|
+
if (itemIndex === -1) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const removedIterable = this.items[itemIndex];
|
|
216
|
+
removedIterable.cleanup?.();
|
|
217
|
+
|
|
218
|
+
this.items.splice(itemIndex, 1);
|
|
219
|
+
if (this.activeIndex >= itemIndex) {
|
|
220
|
+
if (this.hasAvailableIndex("backward")) {
|
|
221
|
+
this.setIndexAndActivateCurrentItem(this.firstAvailableIndex("backward", itemIndex), "backward");
|
|
222
|
+
} else if (this.hasAvailableIndex("forward")) {
|
|
223
|
+
this.setIndexAndActivateCurrentItem(this.firstAvailableIndex("forward", itemIndex), "forward");
|
|
224
|
+
} else if (this.parent?.hasNext("forward")) {
|
|
225
|
+
this.parent?.move("forward");
|
|
226
|
+
} else if (this.parent?.hasNext("backward")) {
|
|
227
|
+
this.parent?.move("backward");
|
|
201
228
|
} else {
|
|
202
229
|
this.reset();
|
|
203
230
|
}
|
|
204
231
|
}
|
|
205
232
|
};
|
|
233
|
+
|
|
234
|
+
public registerItem = (iterable: IterableCallback) => {
|
|
235
|
+
if (isSubiterator(iterable.item)) {
|
|
236
|
+
iterable.item.parent = this;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const insertAt = this.items.findIndex((value) => value.expectedIndex > iterable.expectedIndex);
|
|
240
|
+
|
|
241
|
+
if (insertAt === -1) {
|
|
242
|
+
this.items.push(iterable);
|
|
243
|
+
} else {
|
|
244
|
+
this.items.splice(insertAt, 0, iterable);
|
|
245
|
+
}
|
|
246
|
+
};
|
|
206
247
|
}
|
|
207
248
|
|
|
208
249
|
export const useCallbackIterator = (initialIndex = INACTIVE_INDEX) => {
|
|
209
|
-
const items = useRef<Array<
|
|
250
|
+
const items = useRef<Array<IterableCallback>>([]);
|
|
210
251
|
const activeIndex = useRef<number>(initialIndex);
|
|
211
252
|
return useMemo(() => new CallbackIterator(activeIndex, items), [activeIndex, items]);
|
|
212
253
|
};
|
|
@@ -277,9 +277,6 @@ describe("shortcutIterator", () => {
|
|
|
277
277
|
expect(callback).toHaveBeenCalledTimes(1);
|
|
278
278
|
expect(callback2).toHaveBeenCalledTimes(1);
|
|
279
279
|
expect(callback3).toHaveBeenCalledTimes(1);
|
|
280
|
-
|
|
281
|
-
expect(callback).toHaveBeenCalledBefore(callback2);
|
|
282
|
-
expect(callback2).toHaveBeenCalledBefore(callback3);
|
|
283
280
|
});
|
|
284
281
|
|
|
285
282
|
it("should call first target that is not an empty subiterator", () => {
|
|
@@ -378,8 +375,6 @@ describe("shortcutIterator", () => {
|
|
|
378
375
|
expect(callback3).not.toHaveBeenCalled();
|
|
379
376
|
expect(callback2).toHaveBeenCalledTimes(1);
|
|
380
377
|
expect(callback).toHaveBeenCalledTimes(1);
|
|
381
|
-
|
|
382
|
-
expect(callback2).toHaveBeenCalledBefore(callback);
|
|
383
378
|
});
|
|
384
379
|
|
|
385
380
|
it("should move subiterator if its active callback is de-registered", () => {
|
|
@@ -17,7 +17,13 @@
|
|
|
17
17
|
import React, { FC, useCallback, useContext, useEffect, useRef } from "react";
|
|
18
18
|
import { useTranslation } from "react-i18next";
|
|
19
19
|
import { useShortcut } from "../index";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
Callback,
|
|
22
|
+
CallbackIterator,
|
|
23
|
+
CallbackRegistry,
|
|
24
|
+
IterableCallback,
|
|
25
|
+
useCallbackIterator,
|
|
26
|
+
} from "./callbackIterator";
|
|
21
27
|
|
|
22
28
|
const KeyboardIteratorContext = React.createContext<CallbackRegistry>({
|
|
23
29
|
register: () => {
|
|
@@ -33,6 +39,18 @@ const KeyboardIteratorContext = React.createContext<CallbackRegistry>({
|
|
|
33
39
|
console.warn("Keyboard iterator targets have to be declared inside a KeyboardIterator");
|
|
34
40
|
}
|
|
35
41
|
},
|
|
42
|
+
deregisterItem: () => {
|
|
43
|
+
if (process.env.NODE_ENV === "development") {
|
|
44
|
+
// eslint-disable-next-line no-console
|
|
45
|
+
console.warn("Keyboard iterator targets have to be declared inside a KeyboardIterator");
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
registerItem: () => {
|
|
49
|
+
if (process.env.NODE_ENV === "development") {
|
|
50
|
+
// eslint-disable-next-line no-console
|
|
51
|
+
console.warn("Keyboard iterator targets have to be declared inside a KeyboardIterator");
|
|
52
|
+
}
|
|
53
|
+
},
|
|
36
54
|
});
|
|
37
55
|
|
|
38
56
|
export const useKeyboardIteratorItem = (item: Callback | CallbackIterator) => {
|
|
@@ -43,6 +61,14 @@ export const useKeyboardIteratorItem = (item: Callback | CallbackIterator) => {
|
|
|
43
61
|
}, [item, register, deregister]);
|
|
44
62
|
};
|
|
45
63
|
|
|
64
|
+
export const useKeyboardIteratorItemV2 = (iterable: IterableCallback) => {
|
|
65
|
+
const { registerItem, deregisterItem } = useContext(KeyboardIteratorContext);
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
registerItem?.(iterable);
|
|
68
|
+
return () => deregisterItem?.(iterable);
|
|
69
|
+
}, [iterable, registerItem, deregisterItem]);
|
|
70
|
+
};
|
|
71
|
+
|
|
46
72
|
export const KeyboardSubIteratorContextProvider: FC<{ initialIndex?: number }> = ({ children, initialIndex }) => {
|
|
47
73
|
const callbackIterator = useCallbackIterator(initialIndex);
|
|
48
74
|
|
|
@@ -73,6 +99,7 @@ export const KeyboardIteratorContextProvider: FC<{ initialIndex?: number }> = ({
|
|
|
73
99
|
};
|
|
74
100
|
|
|
75
101
|
/**
|
|
102
|
+
* @deprecated since version 3.8.0. Use {@link useKeyboardIteratorTargetV2} instead.
|
|
76
103
|
* Use the {@link React.RefObject} returned from this hook to register a target to the nearest enclosing {@link KeyboardIterator} or {@link KeyboardSubIterator}.
|
|
77
104
|
*
|
|
78
105
|
* @example
|
|
@@ -91,6 +118,32 @@ export function useKeyboardIteratorTarget(): React.RefCallback<HTMLElement> {
|
|
|
91
118
|
return refCallback;
|
|
92
119
|
}
|
|
93
120
|
|
|
121
|
+
/**
|
|
122
|
+
* @deprecated since version 3.8.0. Use {@link useKeyboardIteratorTargetV2} instead.
|
|
123
|
+
* Use the {@link React.RefObject} returned from this hook to register a target to the nearest enclosing {@link KeyboardIterator} or {@link KeyboardSubIterator},
|
|
124
|
+
* while respecting its expected index / position.
|
|
125
|
+
*
|
|
126
|
+
* @example
|
|
127
|
+
* const ref = useKeyboardIteratorTarget({ expectedIndex: 0});
|
|
128
|
+
* const target = <button ref={ref}>My Iteration Target</button>
|
|
129
|
+
*/
|
|
130
|
+
export function useKeyboardIteratorTargetV2({
|
|
131
|
+
expectedIndex,
|
|
132
|
+
}: {
|
|
133
|
+
expectedIndex: number;
|
|
134
|
+
}): React.RefCallback<HTMLElement> {
|
|
135
|
+
const ref = useRef<HTMLElement>();
|
|
136
|
+
const callback = useCallback(() => ref.current?.focus(), []);
|
|
137
|
+
const cleanup = useCallback(() => ref.current?.blur(), []);
|
|
138
|
+
const refCallback: React.RefCallback<HTMLElement> = useCallback((el) => {
|
|
139
|
+
if (el) {
|
|
140
|
+
ref.current = el;
|
|
141
|
+
}
|
|
142
|
+
}, []);
|
|
143
|
+
useKeyboardIteratorItemV2({ item: callback, cleanup, expectedIndex });
|
|
144
|
+
return refCallback;
|
|
145
|
+
}
|
|
146
|
+
|
|
94
147
|
/**
|
|
95
148
|
* Allows keyboard users to iterate through a list of items, defined by enclosed {@link useKeyboardIteratorTarget} invocations.
|
|
96
149
|
*
|
package/src/index.ts
CHANGED