@scm-manager/ui-core 3.0.0-20240024-101702

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 (128) hide show
  1. package/.storybook/.babelrc +3 -0
  2. package/.storybook/RemoveThemesPlugin.js +57 -0
  3. package/.storybook/main.js +92 -0
  4. package/.storybook/preview-head.html +25 -0
  5. package/.storybook/preview.js +95 -0
  6. package/.storybook/withApiProvider.js +46 -0
  7. package/docs/introduction.stories.mdx +64 -0
  8. package/docs/usage.stories.mdx +22 -0
  9. package/package.json +81 -0
  10. package/src/base/buttons/Button.stories.tsx +89 -0
  11. package/src/base/buttons/Button.test.stories.mdx +74 -0
  12. package/src/base/buttons/Button.tsx +143 -0
  13. package/src/base/buttons/Icon.tsx +58 -0
  14. package/src/base/buttons/a11y.test.ts +34 -0
  15. package/src/base/buttons/docs/introduction.stories.mdx +64 -0
  16. package/src/base/buttons/docs/usage.stories.mdx +22 -0
  17. package/src/base/buttons/image-snapshot.test.ts +33 -0
  18. package/src/base/buttons/index.ts +26 -0
  19. package/src/base/forms/AddListEntryForm.tsx +127 -0
  20. package/src/base/forms/ConfigurationForm.tsx +59 -0
  21. package/src/base/forms/Form.stories.tsx +453 -0
  22. package/src/base/forms/Form.tsx +215 -0
  23. package/src/base/forms/FormPathContext.tsx +73 -0
  24. package/src/base/forms/FormRow.tsx +37 -0
  25. package/src/base/forms/ScmFormContext.tsx +43 -0
  26. package/src/base/forms/ScmFormListContext.tsx +65 -0
  27. package/src/base/forms/base/Control.tsx +34 -0
  28. package/src/base/forms/base/Field.tsx +35 -0
  29. package/src/base/forms/base/field-message/FieldMessage.tsx +34 -0
  30. package/src/base/forms/base/help/Help.tsx +34 -0
  31. package/src/base/forms/base/label/Label.tsx +35 -0
  32. package/src/base/forms/checkbox/Checkbox.stories.mdx +26 -0
  33. package/src/base/forms/checkbox/Checkbox.tsx +118 -0
  34. package/src/base/forms/checkbox/CheckboxField.tsx +39 -0
  35. package/src/base/forms/checkbox/ControlledCheckboxField.stories.mdx +36 -0
  36. package/src/base/forms/checkbox/ControlledCheckboxField.tsx +82 -0
  37. package/src/base/forms/chip-input/ChipInputField.stories.tsx +75 -0
  38. package/src/base/forms/chip-input/ChipInputField.tsx +169 -0
  39. package/src/base/forms/chip-input/ControlledChipInputField.tsx +111 -0
  40. package/src/base/forms/combobox/Combobox.stories.tsx +125 -0
  41. package/src/base/forms/combobox/Combobox.tsx +223 -0
  42. package/src/base/forms/combobox/ComboboxField.tsx +62 -0
  43. package/src/base/forms/combobox/ControlledComboboxField.tsx +96 -0
  44. package/src/base/forms/headless-chip-input/ChipInput.tsx +237 -0
  45. package/src/base/forms/helpers.ts +74 -0
  46. package/src/base/forms/index.ts +85 -0
  47. package/src/base/forms/input/ControlledInputField.stories.mdx +36 -0
  48. package/src/base/forms/input/ControlledInputField.tsx +87 -0
  49. package/src/base/forms/input/ControlledSecretConfirmationField.stories.mdx +39 -0
  50. package/src/base/forms/input/ControlledSecretConfirmationField.tsx +138 -0
  51. package/src/base/forms/input/Input.stories.mdx +22 -0
  52. package/src/base/forms/input/Input.tsx +46 -0
  53. package/src/base/forms/input/InputField.stories.mdx +22 -0
  54. package/src/base/forms/input/InputField.tsx +61 -0
  55. package/src/base/forms/input/Textarea.stories.mdx +28 -0
  56. package/src/base/forms/input/Textarea.tsx +46 -0
  57. package/src/base/forms/list/ControlledList.tsx +88 -0
  58. package/src/base/forms/radio-button/ControlledRadioGroupField.tsx +94 -0
  59. package/src/base/forms/radio-button/RadioButton.stories.tsx +226 -0
  60. package/src/base/forms/radio-button/RadioButton.tsx +116 -0
  61. package/src/base/forms/radio-button/RadioButtonContext.tsx +42 -0
  62. package/src/base/forms/radio-button/RadioGroup.tsx +49 -0
  63. package/src/base/forms/radio-button/RadioGroupField.tsx +58 -0
  64. package/src/base/forms/resourceHooks.ts +164 -0
  65. package/src/base/forms/select/ControlledSelectField.tsx +87 -0
  66. package/src/base/forms/select/Select.tsx +57 -0
  67. package/src/base/forms/select/SelectField.tsx +63 -0
  68. package/src/base/forms/table/ControlledColumn.tsx +49 -0
  69. package/src/base/forms/table/ControlledTable.tsx +99 -0
  70. package/src/base/forms/variants.ts +27 -0
  71. package/src/base/helpers/devbuild.ts +44 -0
  72. package/src/base/helpers/index.ts +26 -0
  73. package/src/base/helpers/useAriaId.tsx +31 -0
  74. package/src/base/index.ts +34 -0
  75. package/src/base/layout/_helpers/with-classes.tsx +52 -0
  76. package/src/base/layout/card/Card.stories.tsx +113 -0
  77. package/src/base/layout/card/Card.tsx +76 -0
  78. package/src/base/layout/card/CardDetail.tsx +196 -0
  79. package/src/base/layout/card/CardRow.tsx +46 -0
  80. package/src/base/layout/card/CardTitle.tsx +59 -0
  81. package/src/base/layout/card-list/CardList.stories.tsx +201 -0
  82. package/src/base/layout/card-list/CardList.tsx +76 -0
  83. package/src/base/layout/collapsible/Collapsible.stories.tsx +45 -0
  84. package/src/base/layout/collapsible/Collapsible.tsx +87 -0
  85. package/src/base/layout/index.ts +93 -0
  86. package/src/base/layout/tabs/TabTrigger.tsx +46 -0
  87. package/src/base/layout/tabs/Tabs.stories.tsx +48 -0
  88. package/src/base/layout/tabs/Tabs.tsx +52 -0
  89. package/src/base/layout/tabs/TabsContent.tsx +33 -0
  90. package/src/base/layout/tabs/TabsList.tsx +41 -0
  91. package/src/base/layout/templates/data-page/DataPage.stories.tsx +201 -0
  92. package/src/base/layout/templates/data-page/DataPageHeader.tsx +100 -0
  93. package/src/base/misc/Image.tsx +32 -0
  94. package/src/base/misc/Level.tsx +40 -0
  95. package/src/base/misc/Loading.tsx +64 -0
  96. package/src/base/misc/SubSubtitle.tsx +36 -0
  97. package/src/base/misc/Subtitle.tsx +37 -0
  98. package/src/base/misc/Title.tsx +56 -0
  99. package/src/base/misc/index.ts +30 -0
  100. package/src/base/notifications/BackendErrorNotification.tsx +160 -0
  101. package/src/base/notifications/ErrorNotification.tsx +73 -0
  102. package/src/base/notifications/Notification.tsx +48 -0
  103. package/src/base/notifications/index.tsx +27 -0
  104. package/src/base/overlays/dialog/Dialog.stories.tsx +64 -0
  105. package/src/base/overlays/dialog/Dialog.tsx +85 -0
  106. package/src/base/overlays/index.ts +44 -0
  107. package/src/base/overlays/menu/Menu.stories.tsx +78 -0
  108. package/src/base/overlays/menu/Menu.tsx +213 -0
  109. package/src/base/overlays/menu/MenuTrigger.tsx +63 -0
  110. package/src/base/overlays/popover/Popover.stories.tsx +69 -0
  111. package/src/base/overlays/popover/Popover.tsx +95 -0
  112. package/src/base/overlays/tooltip/Tooltip.examples.js +41 -0
  113. package/src/base/overlays/tooltip/Tooltip.stories.mdx +52 -0
  114. package/src/base/overlays/tooltip/Tooltip.tsx +96 -0
  115. package/src/base/shortcuts/index.ts +28 -0
  116. package/src/base/shortcuts/iterator/callbackIterator.ts +220 -0
  117. package/src/base/shortcuts/iterator/keyboardIterator.test.tsx +431 -0
  118. package/src/base/shortcuts/iterator/keyboardIterator.tsx +141 -0
  119. package/src/base/shortcuts/usePauseShortcuts.ts +44 -0
  120. package/src/base/shortcuts/useShortcut.ts +110 -0
  121. package/src/base/shortcuts/useShortcutDocs.tsx +54 -0
  122. package/src/base/text/SplitAndReplace.stories.tsx +83 -0
  123. package/src/base/text/SplitAndReplace.tsx +65 -0
  124. package/src/base/text/index.ts +25 -0
  125. package/src/base/text/textSplitAndReplace.test.ts +134 -0
  126. package/src/base/text/textSplitAndReplace.ts +86 -0
  127. package/src/index.ts +25 -0
  128. package/tsconfig.json +6 -0
@@ -0,0 +1,220 @@
1
+ /*
2
+ * MIT License
3
+ *
4
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
5
+ *
6
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ * of this software and associated documentation files (the "Software"), to deal
8
+ * in the Software without restriction, including without limitation the rights
9
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ * copies of the Software, and to permit persons to whom the Software is
11
+ * furnished to do so, subject to the following conditions:
12
+ *
13
+ * The above copyright notice and this permission notice shall be included in all
14
+ * copies or substantial portions of the Software.
15
+ *
16
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ * SOFTWARE.
23
+ */
24
+
25
+ import { MutableRefObject, useMemo, useRef } from "react";
26
+
27
+ const INACTIVE_INDEX = -1;
28
+
29
+ export type Callback = () => void;
30
+
31
+ type Direction = "forward" | "backward";
32
+
33
+ /**
34
+ * Restricts the api surface exposed by {@link CallbackIterator} so that we do not have to implement
35
+ * the whole class when providing a default context.
36
+ */
37
+ export type CallbackRegistry = {
38
+ /**
39
+ * Registers the given item and returns its index to use in {@link deregister}.
40
+ */
41
+ register: (item: Callback | CallbackIterator) => number;
42
+
43
+ /**
44
+ * Use the index returned from {@link register} to de-register.
45
+ */
46
+ deregister: (index: number) => void;
47
+ };
48
+
49
+ const isSubiterator = (item?: Callback | CallbackIterator): item is CallbackIterator =>
50
+ item instanceof CallbackIterator;
51
+
52
+ const offset = (direction: Direction) => (direction === "forward" ? 1 : -1);
53
+
54
+ /**
55
+ * ## Definition
56
+ * - A list of callback functions and/or recursively nested iterators
57
+ * - The iterator can move in either direction
58
+ * - New items can be added/removed on-the-fly
59
+ *
60
+ * ## Terminology
61
+ * - Item: Either a callback or a nested iterator
62
+ * - Available: Item is a non-empty iterator OR a regular callback
63
+ * - Inactive: Current index is -1
64
+ * - Activate: Move iterator while in inactive state OR call regular callback
65
+ *
66
+ * ## Moving
67
+ * When an iterator is moved in either direction, there are 4 cases:
68
+ *
69
+ * 1. The iterator is inactive => activate item at first available index from given direction
70
+ * 1. The current item is a sub-iterator with more items in the given direction => move the sub-iterator
71
+ * 1. The current item is a sub-iterator that has reached its bounds in the given direction => reset sub-iterator & activate item at next available index
72
+ * 1. The current item is not a sub-iterator => activate item at next available index
73
+ */
74
+ export class CallbackIterator implements CallbackRegistry {
75
+ private parent?: CallbackIterator;
76
+
77
+ constructor(
78
+ private readonly activeIndexRef: MutableRefObject<number>,
79
+ private readonly itemsRef: MutableRefObject<Array<Callback | CallbackIterator>>
80
+ ) {}
81
+
82
+ private get activeIndex() {
83
+ return this.activeIndexRef.current;
84
+ }
85
+
86
+ private set activeIndex(newValue: number) {
87
+ this.activeIndexRef.current = newValue;
88
+ }
89
+
90
+ private get items() {
91
+ return this.itemsRef.current;
92
+ }
93
+
94
+ private get currentItem(): Callback | CallbackIterator | undefined {
95
+ return this.items[this.activeIndex];
96
+ }
97
+
98
+ private get isInactive() {
99
+ return this.activeIndex === INACTIVE_INDEX;
100
+ }
101
+
102
+ private get lastIndex() {
103
+ return this.items.length - 1;
104
+ }
105
+
106
+ private firstIndex = (direction: "forward" | "backward") => {
107
+ return direction === "forward" ? 0 : this.lastIndex;
108
+ };
109
+
110
+ private firstAvailableIndex = (direction: Direction, fromIndex = this.firstIndex(direction)) => {
111
+ for (; direction === "forward" ? fromIndex < this.items.length : fromIndex >= 0; fromIndex += offset(direction)) {
112
+ const callback = this.items[fromIndex];
113
+ if (callback) {
114
+ if (!isSubiterator(callback) || callback.hasNext(direction)) {
115
+ return fromIndex;
116
+ }
117
+ }
118
+ }
119
+ return null;
120
+ };
121
+
122
+ private hasAvailableIndex = (direction: Direction, fromIndex?: number) => {
123
+ return this.firstAvailableIndex(direction, fromIndex) !== null;
124
+ };
125
+
126
+ private activateCurrentItem = (direction: Direction) => {
127
+ if (isSubiterator(this.currentItem)) {
128
+ this.currentItem.move(direction);
129
+ } else if (this.currentItem) {
130
+ this.currentItem();
131
+ }
132
+ };
133
+
134
+ private setIndexAndActivateCurrentItem = (index: number | null, direction: Direction) => {
135
+ if (index !== null && index !== INACTIVE_INDEX) {
136
+ this.activeIndex = index;
137
+ this.activateCurrentItem(direction);
138
+ }
139
+ };
140
+
141
+ private move = (direction: Direction) => {
142
+ if (isSubiterator(this.currentItem) && this.currentItem.hasNext(direction)) {
143
+ this.currentItem.move(direction);
144
+ } else {
145
+ if (isSubiterator(this.currentItem)) {
146
+ this.currentItem.reset();
147
+ }
148
+ let nextIndex: number | null;
149
+ if (this.isInactive) {
150
+ nextIndex = this.firstAvailableIndex(direction);
151
+ } else {
152
+ nextIndex = this.firstAvailableIndex(direction, this.activeIndex + offset(direction));
153
+ }
154
+ this.setIndexAndActivateCurrentItem(nextIndex, direction);
155
+ }
156
+ };
157
+
158
+ private hasNext = (inDirection: Direction): boolean => {
159
+ if (this.isInactive) {
160
+ return this.hasAvailableIndex(inDirection);
161
+ }
162
+ if (isSubiterator(this.currentItem) && this.currentItem.hasNext(inDirection)) {
163
+ return true;
164
+ }
165
+ return this.hasAvailableIndex(inDirection, this.activeIndex + offset(inDirection));
166
+ };
167
+
168
+ public next = () => {
169
+ if (this.hasNext("forward")) {
170
+ return this.move("forward");
171
+ }
172
+ };
173
+
174
+ public previous = () => {
175
+ if (this.hasNext("backward")) {
176
+ return this.move("backward");
177
+ }
178
+ };
179
+
180
+ public reset = () => {
181
+ this.activeIndex = INACTIVE_INDEX;
182
+ for (const cb of this.items) {
183
+ if (isSubiterator(cb)) {
184
+ cb.reset();
185
+ }
186
+ }
187
+ };
188
+
189
+ public register = (item: Callback | CallbackIterator) => {
190
+ if (isSubiterator(item)) {
191
+ item.parent = this;
192
+ }
193
+ return this.items.push(item) - 1;
194
+ };
195
+
196
+ public deregister = (index: number) => {
197
+ this.items.splice(index, 1);
198
+ if (this.activeIndex === index || this.activeIndex >= this.items.length) {
199
+ if (this.hasAvailableIndex("backward", index)) {
200
+ this.setIndexAndActivateCurrentItem(this.firstAvailableIndex("backward", index), "backward");
201
+ } else if (this.hasAvailableIndex("forward", index)) {
202
+ this.setIndexAndActivateCurrentItem(this.firstAvailableIndex("forward", index), "backward");
203
+ } else if (this.parent) {
204
+ if (this.parent.hasNext("forward")) {
205
+ this.parent.move("forward");
206
+ } else if (this.parent.hasNext("backward")) {
207
+ this.parent.move("backward");
208
+ }
209
+ } else {
210
+ this.reset();
211
+ }
212
+ }
213
+ };
214
+ }
215
+
216
+ export const useCallbackIterator = (initialIndex = INACTIVE_INDEX) => {
217
+ const items = useRef<Array<Callback | CallbackIterator>>([]);
218
+ const activeIndex = useRef<number>(initialIndex);
219
+ return useMemo(() => new CallbackIterator(activeIndex, items), [activeIndex, items]);
220
+ };
@@ -0,0 +1,431 @@
1
+ /*
2
+ * MIT License
3
+ *
4
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
5
+ *
6
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ * of this software and associated documentation files (the "Software"), to deal
8
+ * in the Software without restriction, including without limitation the rights
9
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ * copies of the Software, and to permit persons to whom the Software is
11
+ * furnished to do so, subject to the following conditions:
12
+ *
13
+ * The above copyright notice and this permission notice shall be included in all
14
+ * copies or substantial portions of the Software.
15
+ *
16
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ * SOFTWARE.
23
+ */
24
+
25
+ import { renderHook } from "@testing-library/react-hooks";
26
+ import React, { FC } from "react";
27
+ import {
28
+ KeyboardIteratorContextProvider,
29
+ KeyboardSubIterator,
30
+ KeyboardSubIteratorContextProvider,
31
+ useKeyboardIteratorItem,
32
+ } from "./keyboardIterator";
33
+ import { render } from "@testing-library/react";
34
+ import { ShortcutDocsContextProvider } from "../useShortcutDocs";
35
+ import Mousetrap from "mousetrap";
36
+ import "jest-extended";
37
+
38
+ jest.mock("react-i18next", () => ({
39
+ useTranslation: () => [jest.fn()],
40
+ }));
41
+
42
+ const Wrapper: FC<{ initialIndex?: number }> = ({ children, initialIndex }) => {
43
+ return (
44
+ <ShortcutDocsContextProvider>
45
+ <KeyboardIteratorContextProvider initialIndex={initialIndex}>{children}</KeyboardIteratorContextProvider>
46
+ </ShortcutDocsContextProvider>
47
+ );
48
+ };
49
+
50
+ const DocsWrapper: FC = ({ children }) => <ShortcutDocsContextProvider>{children}</ShortcutDocsContextProvider>;
51
+
52
+ const createWrapper =
53
+ (initialIndex?: number): FC =>
54
+ ({ children }) =>
55
+ <Wrapper initialIndex={initialIndex}>{children}</Wrapper>;
56
+
57
+ const Item: FC<{ callback: () => void }> = ({ callback }) => {
58
+ useKeyboardIteratorItem(callback);
59
+ return <li>example</li>;
60
+ };
61
+
62
+ const List: FC<{ callbacks: Array<() => void> }> = ({ callbacks }) => {
63
+ return (
64
+ <ul data-testid="list">
65
+ {callbacks.map((cb, idx) => (
66
+ <Item key={idx} callback={cb} />
67
+ ))}
68
+ </ul>
69
+ );
70
+ };
71
+
72
+ describe("shortcutIterator", () => {
73
+ beforeEach(() => Mousetrap.reset());
74
+
75
+ it("should not call callback upon registration", () => {
76
+ const callback = jest.fn();
77
+
78
+ renderHook(() => useKeyboardIteratorItem(callback), {
79
+ wrapper: Wrapper,
80
+ });
81
+
82
+ expect(callback).not.toHaveBeenCalled();
83
+ });
84
+
85
+ it("should not throw if not inside keyboard iterator context", () => {
86
+ const callback = jest.fn();
87
+
88
+ const { result, unmount } = renderHook(() => useKeyboardIteratorItem(callback), {
89
+ wrapper: DocsWrapper,
90
+ });
91
+
92
+ unmount();
93
+
94
+ expect(result.error).toBeUndefined();
95
+ });
96
+
97
+ it("should call first callback upon pressing forward in initial state", async () => {
98
+ const callback = jest.fn();
99
+ const callback2 = jest.fn();
100
+ const callback3 = jest.fn();
101
+
102
+ render(
103
+ <Wrapper>
104
+ <List callbacks={[callback, callback2, callback3]} />
105
+ </Wrapper>
106
+ );
107
+
108
+ Mousetrap.trigger("j");
109
+
110
+ expect(callback).toHaveBeenCalledTimes(1);
111
+ expect(callback2).not.toHaveBeenCalled();
112
+ expect(callback3).not.toHaveBeenCalled();
113
+ });
114
+
115
+ it("should call last callback once upon pressing backward in initial state", async () => {
116
+ const callback = jest.fn();
117
+ const callback2 = jest.fn();
118
+ const callback3 = jest.fn();
119
+
120
+ render(
121
+ <Wrapper>
122
+ <List callbacks={[callback, callback2, callback3]} />
123
+ </Wrapper>
124
+ );
125
+
126
+ Mousetrap.trigger("k");
127
+
128
+ expect(callback).not.toHaveBeenCalled();
129
+ expect(callback2).not.toHaveBeenCalled();
130
+ expect(callback3).toHaveBeenCalledTimes(1);
131
+ });
132
+
133
+ it("should not allow moving past the end of the callback array", async () => {
134
+ const callback = jest.fn();
135
+ const callback2 = jest.fn();
136
+ const callback3 = jest.fn();
137
+
138
+ render(
139
+ <Wrapper initialIndex={1}>
140
+ <List callbacks={[callback, callback2, callback3]} />
141
+ </Wrapper>
142
+ );
143
+
144
+ Mousetrap.trigger("j");
145
+ Mousetrap.trigger("j");
146
+
147
+ expect(callback).not.toHaveBeenCalled();
148
+ expect(callback2).not.toHaveBeenCalled();
149
+ expect(callback3).toHaveBeenCalledTimes(1);
150
+ });
151
+
152
+ it("should move to existing index when active index is at the end and last callback is deregistered", async () => {
153
+ const callback = jest.fn();
154
+ const callback2 = jest.fn();
155
+ const callback3 = jest.fn();
156
+
157
+ const { rerender } = render(<List callbacks={[callback, callback2, callback3]} />, {
158
+ wrapper: createWrapper(2),
159
+ });
160
+
161
+ expect(callback).not.toHaveBeenCalled();
162
+ expect(callback2).not.toHaveBeenCalled();
163
+ expect(callback3).not.toHaveBeenCalled();
164
+
165
+ rerender(<List callbacks={[callback, callback2]} />);
166
+
167
+ expect(callback).not.toHaveBeenCalled();
168
+ expect(callback2).toHaveBeenCalledTimes(1);
169
+ expect(callback3).not.toHaveBeenCalled();
170
+ });
171
+
172
+ it("should move to existing index when active index is at the beginning and first callback is deregistered", async () => {
173
+ const callback = jest.fn();
174
+ const callback2 = jest.fn();
175
+ const callback3 = jest.fn();
176
+
177
+ const { rerender } = render(<List callbacks={[callback, callback2, callback3]} />, {
178
+ wrapper: createWrapper(0),
179
+ });
180
+
181
+ expect(callback).not.toHaveBeenCalled();
182
+ expect(callback2).not.toHaveBeenCalled();
183
+ expect(callback3).not.toHaveBeenCalled();
184
+
185
+ rerender(<List callbacks={[callback2, callback3]} />);
186
+
187
+ expect(callback).not.toHaveBeenCalled();
188
+ expect(callback2).toHaveBeenCalledTimes(1);
189
+ expect(callback3).not.toHaveBeenCalled();
190
+ });
191
+
192
+ it("should move to existing index when active index is at the end and first callback is deregistered", async () => {
193
+ const callback = jest.fn();
194
+ const callback2 = jest.fn();
195
+ const callback3 = jest.fn();
196
+
197
+ const { rerender } = render(<List callbacks={[callback, callback2, callback3]} />, {
198
+ wrapper: createWrapper(2),
199
+ });
200
+
201
+ expect(callback).not.toHaveBeenCalled();
202
+ expect(callback2).not.toHaveBeenCalled();
203
+ expect(callback3).not.toHaveBeenCalled();
204
+
205
+ rerender(<List callbacks={[callback, callback2]} />);
206
+
207
+ expect(callback).not.toHaveBeenCalled();
208
+ expect(callback2).toHaveBeenCalledTimes(1);
209
+ expect(callback3).not.toHaveBeenCalled();
210
+ });
211
+
212
+ it("should move to existing index when active index in the middle is deregistered", async () => {
213
+ const callback = jest.fn();
214
+ const callback2 = jest.fn();
215
+ const callback3 = jest.fn();
216
+
217
+ const { rerender } = render(<List callbacks={[callback, callback2, callback3]} />, {
218
+ wrapper: createWrapper(1),
219
+ });
220
+
221
+ expect(callback).not.toHaveBeenCalled();
222
+ expect(callback2).not.toHaveBeenCalled();
223
+ expect(callback3).not.toHaveBeenCalled();
224
+
225
+ rerender(<List callbacks={[callback, callback3]} />);
226
+
227
+ expect(callback).toHaveBeenCalledTimes(1);
228
+ expect(callback2).not.toHaveBeenCalled();
229
+ expect(callback3).not.toHaveBeenCalled();
230
+ });
231
+
232
+ it("should not move on deregistration if iterator is not active", async () => {
233
+ const callback = jest.fn();
234
+ const callback2 = jest.fn();
235
+ const callback3 = jest.fn();
236
+
237
+ const { rerender } = render(<List callbacks={[callback, callback2, callback3]} />, {
238
+ wrapper: createWrapper(),
239
+ });
240
+
241
+ expect(callback).not.toHaveBeenCalled();
242
+ expect(callback2).not.toHaveBeenCalled();
243
+ expect(callback3).not.toHaveBeenCalled();
244
+
245
+ rerender(<List callbacks={[callback, callback2]} />);
246
+
247
+ expect(callback).not.toHaveBeenCalled();
248
+ expect(callback2).not.toHaveBeenCalled();
249
+ expect(callback3).not.toHaveBeenCalled();
250
+ });
251
+
252
+ it("should not explode if the last item in the list is removed", async () => {
253
+ const callback = jest.fn();
254
+
255
+ const { rerender } = render(<List callbacks={[callback]} />, {
256
+ wrapper: createWrapper(),
257
+ });
258
+
259
+ expect(callback).not.toHaveBeenCalled();
260
+
261
+ rerender(<List callbacks={[]} />);
262
+
263
+ expect(callback).not.toHaveBeenCalled();
264
+ });
265
+
266
+ describe("With Subiterator", () => {
267
+ it("should call in correct order", () => {
268
+ const callback = jest.fn();
269
+ const callback2 = jest.fn();
270
+ const callback3 = jest.fn();
271
+
272
+ render(
273
+ <Wrapper>
274
+ <KeyboardSubIterator>
275
+ <List callbacks={[callback, callback2]} />
276
+ </KeyboardSubIterator>
277
+ <List callbacks={[callback3]} />
278
+ </Wrapper>
279
+ );
280
+
281
+ Mousetrap.trigger("j");
282
+ Mousetrap.trigger("j");
283
+ Mousetrap.trigger("j");
284
+
285
+ expect(callback).toHaveBeenCalledTimes(1);
286
+ expect(callback2).toHaveBeenCalledTimes(1);
287
+ expect(callback3).toHaveBeenCalledTimes(1);
288
+
289
+ expect(callback).toHaveBeenCalledBefore(callback2);
290
+ expect(callback2).toHaveBeenCalledBefore(callback3);
291
+ });
292
+
293
+ it("should call first target that is not an empty subiterator", () => {
294
+ const callback = jest.fn();
295
+ const callback2 = jest.fn();
296
+ const callback3 = jest.fn();
297
+
298
+ render(
299
+ <Wrapper>
300
+ <KeyboardSubIterator>
301
+ <List callbacks={[]} />
302
+ </KeyboardSubIterator>
303
+ <KeyboardSubIterator>
304
+ <List callbacks={[]} />
305
+ </KeyboardSubIterator>
306
+ <List callbacks={[callback, callback2, callback3]} />
307
+ </Wrapper>
308
+ );
309
+
310
+ Mousetrap.trigger("j");
311
+
312
+ expect(callback).toHaveBeenCalledTimes(1);
313
+ expect(callback2).not.toHaveBeenCalled();
314
+ expect(callback3).not.toHaveBeenCalled();
315
+ });
316
+
317
+ it("should skip empty sub-iterators during navigation", () => {
318
+ const callback = jest.fn();
319
+ const callback2 = jest.fn();
320
+ const callback3 = jest.fn();
321
+
322
+ render(
323
+ <Wrapper>
324
+ <List callbacks={[callback]} />
325
+ <KeyboardSubIterator>
326
+ <List callbacks={[]} />
327
+ </KeyboardSubIterator>
328
+ <KeyboardSubIterator>
329
+ <List callbacks={[]} />
330
+ </KeyboardSubIterator>
331
+ <List callbacks={[callback2, callback3]} />
332
+ </Wrapper>
333
+ );
334
+
335
+ Mousetrap.trigger("j");
336
+ Mousetrap.trigger("j");
337
+ Mousetrap.trigger("j");
338
+
339
+ expect(callback).toHaveBeenCalledTimes(1);
340
+ expect(callback2).toHaveBeenCalledTimes(1);
341
+ expect(callback3).toHaveBeenCalledTimes(1);
342
+ });
343
+
344
+ it("should not enter subiterator if its empty", () => {
345
+ const callback = jest.fn();
346
+ const callback2 = jest.fn();
347
+ const callback3 = jest.fn();
348
+
349
+ render(
350
+ <Wrapper initialIndex={1}>
351
+ <KeyboardSubIterator>
352
+ <List callbacks={[]} />
353
+ </KeyboardSubIterator>
354
+ <List callbacks={[callback, callback2, callback3]} />
355
+ </Wrapper>
356
+ );
357
+
358
+ Mousetrap.trigger("k");
359
+
360
+ expect(callback).not.toHaveBeenCalled();
361
+ expect(callback2).not.toHaveBeenCalled();
362
+ expect(callback3).not.toHaveBeenCalled();
363
+ });
364
+
365
+ it("should not loop", () => {
366
+ const callback = jest.fn();
367
+ const callback2 = jest.fn();
368
+ const callback3 = jest.fn();
369
+
370
+ render(
371
+ <Wrapper initialIndex={1}>
372
+ <KeyboardSubIterator>
373
+ <List callbacks={[callback, callback2]} />
374
+ </KeyboardSubIterator>
375
+ <List callbacks={[callback3]} />
376
+ </Wrapper>
377
+ );
378
+
379
+ Mousetrap.trigger("k");
380
+ Mousetrap.trigger("k");
381
+ Mousetrap.trigger("k");
382
+ Mousetrap.trigger("k");
383
+ Mousetrap.trigger("k");
384
+ Mousetrap.trigger("k");
385
+
386
+ expect(callback3).not.toHaveBeenCalled();
387
+ expect(callback2).toHaveBeenCalledTimes(1);
388
+ expect(callback).toHaveBeenCalledTimes(1);
389
+
390
+ expect(callback2).toHaveBeenCalledBefore(callback);
391
+ });
392
+
393
+ it("should move subiterator if its active callback is de-registered", () => {
394
+ const callback = jest.fn();
395
+ const callback2 = jest.fn();
396
+ const callback3 = jest.fn();
397
+ const callback4 = jest.fn();
398
+
399
+ const { rerender } = render(
400
+ <>
401
+ <KeyboardSubIteratorContextProvider initialIndex={1}>
402
+ <List callbacks={[callback, callback2, callback3]} />
403
+ </KeyboardSubIteratorContextProvider>
404
+ <List callbacks={[callback4]} />
405
+ </>,
406
+ {
407
+ wrapper: createWrapper(0),
408
+ }
409
+ );
410
+
411
+ expect(callback).not.toHaveBeenCalled();
412
+ expect(callback2).not.toHaveBeenCalled();
413
+ expect(callback3).not.toHaveBeenCalled();
414
+ expect(callback4).not.toHaveBeenCalled();
415
+
416
+ rerender(
417
+ <>
418
+ <KeyboardSubIteratorContextProvider initialIndex={1}>
419
+ <List callbacks={[callback, callback3]} />
420
+ </KeyboardSubIteratorContextProvider>
421
+ <List callbacks={[callback4]} />
422
+ </>
423
+ );
424
+
425
+ expect(callback).toHaveBeenCalledTimes(1);
426
+ expect(callback2).not.toHaveBeenCalled();
427
+ expect(callback3).not.toHaveBeenCalled();
428
+ expect(callback4).not.toHaveBeenCalled();
429
+ });
430
+ });
431
+ });