@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.
@@ -1,2 +1,2 @@
1
- @scm-manager/ui-core:typecheck: cache hit, replaying output 5c54d242e7b49eb9
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.7.5",
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.7.5",
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.7.5",
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="Tests"/>
4
+ <Meta title="Buttons" />
5
5
 
6
- <Story name="Light States" parameters={{
7
- pseudo: {
8
- hover: ButtonVariantList.map(variant => `#${variant}-Hover`),
9
- focus: ButtonVariantList.map(variant => `#${variant}-Focus`),
10
- active: ButtonVariantList.map(variant => `#${variant}-Active`),
11
- },
12
- themes: {
13
- default: 'light',
14
- },
15
- }}>
16
- <table>
17
- <tr>
18
- <th>STATE</th>
19
- {ButtonVariantList.map(variant => <th>{variant.toUpperCase()}</th>)}
20
- </tr>
21
- {["Normal", "Hover", "Active", "Focus", "Disabled", "Loading"].map(state => <tr>
22
- <td>{state}</td>
23
- {ButtonVariantList.map(variant => <td><Button id={`${variant}-${state}`} disabled={state === "Disabled"}
24
- isLoading={state === "Loading"}
25
- variant={variant}>Button</Button></td>)}
26
- </tr>)}
27
- </table>
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 name="Dark States" parameters={{
31
- pseudo: {
32
- hover: ButtonVariantList.map(variant => `#${variant}-Hover`),
33
- focus: ButtonVariantList.map(variant => `#${variant}-Focus`),
34
- active: ButtonVariantList.map(variant => `#${variant}-Active`),
35
- },
36
- themes: {
37
- default: 'dark',
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 name="High-Contrast States" parameters={{
54
- pseudo: {
55
- hover: ButtonVariantList.map(variant => `#${variant}-Hover`),
56
- focus: ButtonVariantList.map(variant => `#${variant}-Focus`),
57
- active: ButtonVariantList.map(variant => `#${variant}-Active`),
58
- },
59
- themes: {
60
- default: 'highcontrast',
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>
@@ -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 "../misc";
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
- <StyledLabel
62
- className={classNames("checkbox is-align-items-center", labelClassName)}
63
- // @ts-ignore bulma uses the disabled attribute on labels, although it is not part of the html spec
64
- disabled={readOnly || props.disabled}
65
- >
66
- {readOnly ? (
67
- <>
68
- <input
69
- type="hidden"
70
- name={name}
71
- value={value}
72
- defaultValue={defaultValue}
73
- checked={checked}
74
- defaultChecked={defaultChecked}
75
- readOnly
76
- />
77
- <StyledInput
78
- type="checkbox"
79
- className={classNames("m-3", className)}
80
- ref={ref}
81
- value={value}
82
- defaultValue={defaultValue}
83
- checked={checked}
84
- defaultChecked={defaultChecked}
85
- {...props}
86
- {...createAttributesForTesting(testId)}
87
- disabled
88
- />
89
- </>
90
- ) : (
91
- <StyledInput
92
- type="checkbox"
93
- className={classNames("m-3", className)}
94
- ref={ref}
95
- name={name}
96
- value={value}
97
- defaultValue={defaultValue}
98
- checked={checked}
99
- defaultChecked={defaultChecked}
100
- {...props}
101
- {...createAttributesForTesting(testId)}
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
- {label}
106
- {helpText ? <Help className="ml-1" text={helpText} /> : null}
107
- </StyledLabel>
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;
@@ -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}`)
@@ -20,3 +20,7 @@ This will be our first form field molecule
20
20
  <Story name="WithWidth">
21
21
  <InputField label="MyInput" className="column is-half" />
22
22
  </Story>
23
+
24
+ <Story name="WithIcon">
25
+ <InputField icon="fas fa-filter" />
26
+ </Story>
@@ -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
- <Control>
46
- <Input variant={variant} ref={ref} id={inputId} {...props}></Input>
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 { useKeyboardIteratorTarget, KeyboardIterator, KeyboardSubIterator } from "./iterator/keyboardIterator";
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<Callback | CallbackIterator>>
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(): Callback | CallbackIterator | undefined {
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 callback = this.items[fromIndex];
105
- if (callback) {
106
- if (!isSubiterator(callback) || callback.hasNext(direction)) {
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
- if (isSubiterator(item)) {
183
- item.parent = this;
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.splice(index, 1);
190
- if (this.activeIndex === index || this.activeIndex >= this.items.length) {
191
- if (this.hasAvailableIndex("backward", index)) {
192
- this.setIndexAndActivateCurrentItem(this.firstAvailableIndex("backward", index), "backward");
193
- } else if (this.hasAvailableIndex("forward", index)) {
194
- this.setIndexAndActivateCurrentItem(this.firstAvailableIndex("forward", index), "backward");
195
- } else if (this.parent) {
196
- if (this.parent.hasNext("forward")) {
197
- this.parent.move("forward");
198
- } else if (this.parent.hasNext("backward")) {
199
- this.parent.move("backward");
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<Callback | CallbackIterator>>([]);
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 { Callback, CallbackIterator, CallbackRegistry, useCallbackIterator } from "./callbackIterator";
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
@@ -14,4 +14,4 @@
14
14
  * along with this program. If not, see https://www.gnu.org/licenses/.
15
15
  */
16
16
 
17
- export * from "./base"
17
+ export * from "./base";