@harborclient/sdk 0.6.2 → 0.6.4
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/dist/components/BusyIndicator/index.d.ts +3 -2
- package/dist/components/BusyIndicator/index.d.ts.map +1 -1
- package/dist/components/BusyIndicator/index.js +11 -3
- package/dist/components/Button/index.d.ts +23 -8
- package/dist/components/Button/index.d.ts.map +1 -1
- package/dist/components/Button/index.js +11 -9
- package/dist/components/CodeEditor/index.d.ts +9 -1
- package/dist/components/CodeEditor/index.d.ts.map +1 -1
- package/dist/components/CodeEditor/index.js +186 -62
- package/dist/components/FieldError/index.d.ts.map +1 -1
- package/dist/components/FieldError/index.js +1 -1
- package/dist/components/FooterButton/index.js +2 -2
- package/dist/components/FormDataEditor/index.d.ts.map +1 -1
- package/dist/components/FormDataEditor/index.js +2 -2
- package/dist/components/FormGroup/index.d.ts +6 -1
- package/dist/components/FormGroup/index.d.ts.map +1 -1
- package/dist/components/FormGroup/index.js +25 -31
- package/dist/components/KeyValueEditor/index.d.ts.map +1 -1
- package/dist/components/KeyValueEditor/index.js +3 -3
- package/dist/components/Modal/ModalFormLayout.d.ts.map +1 -1
- package/dist/components/Modal/ModalFormLayout.js +2 -1
- package/dist/components/Modal/ModalHeader.d.ts +5 -1
- package/dist/components/Modal/ModalHeader.d.ts.map +1 -1
- package/dist/components/Modal/ModalHeader.js +2 -2
- package/dist/components/Modal/index.d.ts.map +1 -1
- package/dist/components/Modal/index.js +4 -2
- package/dist/components/OverlayCloseButton/index.d.ts.map +1 -1
- package/dist/components/OverlayCloseButton/index.js +1 -2
- package/dist/components/Resizable/Resizable.d.ts.map +1 -1
- package/dist/components/Resizable/Resizable.js +1 -1
- package/dist/components/Resizable/ResizeHandle.js +2 -2
- package/dist/components/ResourceList/index.d.ts +11 -1
- package/dist/components/ResourceList/index.d.ts.map +1 -1
- package/dist/components/ResourceList/index.js +4 -2
- package/dist/components/RowActionsMenu/index.d.ts.map +1 -1
- package/dist/components/RowActionsMenu/index.js +150 -11
- package/dist/components/SegmentedTabs/index.d.ts.map +1 -1
- package/dist/components/SegmentedTabs/index.js +2 -2
- package/dist/components/TabCloseButton/index.d.ts +1 -1
- package/dist/components/TabCloseButton/index.js +2 -2
- package/dist/components/VariableInput/index.d.ts.map +1 -1
- package/dist/components/VariableInput/index.js +84 -10
- package/dist/components/VariableTable/index.d.ts.map +1 -1
- package/dist/components/VariableTable/index.js +1 -1
- package/dist/components/classes.d.ts +1 -1
- package/dist/components/classes.d.ts.map +1 -1
- package/dist/components/classes.js +3 -3
- package/dist/components/enhanceControl.d.ts +28 -0
- package/dist/components/enhanceControl.d.ts.map +1 -0
- package/dist/components/enhanceControl.js +84 -0
- package/dist/components/enhanceControl.test.d.ts +2 -0
- package/dist/components/enhanceControl.test.d.ts.map +1 -0
- package/dist/components/enhanceControl.test.js +83 -0
- package/dist/components/useDialogFocus.d.ts +3 -2
- package/dist/components/useDialogFocus.d.ts.map +1 -1
- package/dist/components/useDialogFocus.js +61 -4
- package/dist/components/utils.d.ts +25 -0
- package/dist/components/utils.d.ts.map +1 -1
- package/dist/components/utils.js +24 -0
- package/dist/components/utils.test.js +18 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/variables/index.d.ts +1 -1
- package/dist/variables/index.d.ts.map +1 -1
- package/dist/variables/index.js +1 -1
- package/dist/variables/tokens.d.ts +31 -0
- package/dist/variables/tokens.d.ts.map +1 -1
- package/dist/variables/tokens.js +38 -1
- package/dist/variables/tokens.test.js +46 -1
- package/package.json +2 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "@harborclient/sdk/jsx-runtime";
|
|
2
|
-
import { useEffect, useMemo, useRef, useState } from '@harborclient/sdk/react';
|
|
3
|
-
import { getDynamicVariableDescription, resolveVariable, tokenizeVariables } from '../../variables/index.js';
|
|
2
|
+
import { useEffect, useId, useMemo, useRef, useState } from '@harborclient/sdk/react';
|
|
3
|
+
import { getDynamicVariableDescription, getVariableTokenAtOffset, getVariableTooltipContent, resolveVariable, tokenizeVariables } from '../../variables/index.js';
|
|
4
4
|
import { Input } from '../forms/index.js';
|
|
5
5
|
/**
|
|
6
6
|
* Text input that highlights {{variable}} tokens and shows resolved values on hover.
|
|
@@ -14,6 +14,7 @@ export function VariableInput({ value, onChange, variables, placeholder, onKeyDo
|
|
|
14
14
|
const spanRefs = useRef(new Map());
|
|
15
15
|
const hideTimer = useRef(null);
|
|
16
16
|
const [tooltip, setTooltip] = useState(null);
|
|
17
|
+
const tooltipId = useId();
|
|
17
18
|
/**
|
|
18
19
|
* Splits the input value into plain text and {{variable}} token spans for highlighting.
|
|
19
20
|
*/
|
|
@@ -48,6 +49,53 @@ export function VariableInput({ value, onChange, variables, placeholder, onKeyDo
|
|
|
48
49
|
backdrop.scrollLeft = input.scrollLeft;
|
|
49
50
|
}
|
|
50
51
|
};
|
|
52
|
+
/**
|
|
53
|
+
* Shows a tooltip for a variable token at the given screen position.
|
|
54
|
+
*
|
|
55
|
+
* @param key - Variable name from the token.
|
|
56
|
+
* @param top - Top coordinate for tooltip placement.
|
|
57
|
+
* @param left - Horizontal center coordinate for tooltip placement.
|
|
58
|
+
*/
|
|
59
|
+
const showTooltipForKey = (key, top, left) => {
|
|
60
|
+
setTooltip({
|
|
61
|
+
key,
|
|
62
|
+
value: resolveVariable(key, variables),
|
|
63
|
+
dynamicDescription: getDynamicVariableDescription(key),
|
|
64
|
+
top,
|
|
65
|
+
left
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* Updates the tooltip based on the current text caret position.
|
|
70
|
+
*/
|
|
71
|
+
const updateTooltipFromCaret = () => {
|
|
72
|
+
const input = inputRef.current;
|
|
73
|
+
if (!input)
|
|
74
|
+
return;
|
|
75
|
+
const offset = input.selectionStart ?? 0;
|
|
76
|
+
const match = getVariableTokenAtOffset(value, offset);
|
|
77
|
+
if (!match) {
|
|
78
|
+
setTooltip(null);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
let tokenIndex = -1;
|
|
82
|
+
let position = 0;
|
|
83
|
+
for (const [index, token] of tokens.entries()) {
|
|
84
|
+
if (token.key && position === match.start) {
|
|
85
|
+
tokenIndex = index;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
position += token.text.length;
|
|
89
|
+
}
|
|
90
|
+
const span = tokenIndex >= 0 ? spanRefs.current.get(tokenIndex) : undefined;
|
|
91
|
+
if (span) {
|
|
92
|
+
const rect = span.getBoundingClientRect();
|
|
93
|
+
showTooltipForKey(match.key, rect.top, rect.left + rect.width / 2);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const rect = input.getBoundingClientRect();
|
|
97
|
+
showTooltipForKey(match.key, rect.top, rect.left + rect.width / 2);
|
|
98
|
+
};
|
|
51
99
|
/**
|
|
52
100
|
* Shows a tooltip when the pointer is over a variable token span.
|
|
53
101
|
*
|
|
@@ -66,24 +114,50 @@ export function VariableInput({ value, onChange, variables, placeholder, onKeyDo
|
|
|
66
114
|
e.clientX <= rect.right &&
|
|
67
115
|
e.clientY >= rect.top &&
|
|
68
116
|
e.clientY <= rect.bottom) {
|
|
69
|
-
|
|
70
|
-
key: token.key,
|
|
71
|
-
value: resolveVariable(token.key, variables),
|
|
72
|
-
dynamicDescription: getDynamicVariableDescription(token.key),
|
|
73
|
-
top: rect.top,
|
|
74
|
-
left: rect.left + rect.width / 2
|
|
75
|
-
});
|
|
117
|
+
showTooltipForKey(token.key, rect.top, rect.left + rect.width / 2);
|
|
76
118
|
return;
|
|
77
119
|
}
|
|
78
120
|
}
|
|
79
121
|
setTooltip(null);
|
|
80
122
|
};
|
|
123
|
+
/**
|
|
124
|
+
* Handles keyboard events on the input, including tooltip dismissal.
|
|
125
|
+
*
|
|
126
|
+
* @param event - Keyboard event from the input.
|
|
127
|
+
*/
|
|
128
|
+
const handleKeyDown = (event) => {
|
|
129
|
+
if (event.key === 'Escape' && tooltip) {
|
|
130
|
+
event.preventDefault();
|
|
131
|
+
setTooltip(null);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
onKeyDown?.(event);
|
|
135
|
+
};
|
|
136
|
+
/**
|
|
137
|
+
* Updates the tooltip after keyboard navigation moves the caret.
|
|
138
|
+
*
|
|
139
|
+
* @param event - Keyboard event from the input.
|
|
140
|
+
*/
|
|
141
|
+
const handleKeyUp = (event) => {
|
|
142
|
+
if (event.key === 'ArrowLeft' ||
|
|
143
|
+
event.key === 'ArrowRight' ||
|
|
144
|
+
event.key === 'ArrowUp' ||
|
|
145
|
+
event.key === 'ArrowDown' ||
|
|
146
|
+
event.key === 'Home' ||
|
|
147
|
+
event.key === 'End') {
|
|
148
|
+
updateTooltipFromCaret();
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
const tooltipContent = tooltip ? getVariableTooltipContent(tooltip.key, variables) : null;
|
|
81
152
|
return (_jsxs("div", { className: wrapperClassName ? `relative min-w-0 ${wrapperClassName}` : 'relative min-w-0 flex-1', children: [_jsx("div", { ref: backdropRef, "aria-hidden": true, className: "pointer-events-none absolute inset-0 overflow-hidden whitespace-nowrap px-2 py-1.5 text-[14px] text-inherit", children: value ? (tokens.map((token, index) => token.key ? (_jsx("span", { ref: (el) => {
|
|
82
153
|
if (el)
|
|
83
154
|
spanRefs.current.set(index, el);
|
|
84
155
|
else
|
|
85
156
|
spanRefs.current.delete(index);
|
|
86
|
-
}, className: "text-[#32D2E2]", children: token.text }, index)) : (_jsx("span", { children: token.text }, index)))) : (_jsx("span", { className: "text-muted", children: placeholder })) }), _jsx(Input, { ref: inputRef, id: id, variant: "plain", "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, className: `relative w-full min-w-0 border-none bg-transparent px-2 py-1.5 text-[14px] text-transparent caret-text focus-visible:shadow-none ${className}`, type: "text", placeholder: placeholder, value: value, onChange: (e) =>
|
|
157
|
+
}, className: "text-[#32D2E2]", children: token.text }, index)) : (_jsx("span", { children: token.text }, index)))) : (_jsx("span", { className: "text-muted", children: placeholder })) }), _jsx(Input, { ref: inputRef, id: id, variant: "plain", "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": tooltip ? tooltipId : undefined, className: `relative w-full min-w-0 border-none bg-transparent px-2 py-1.5 text-[14px] text-transparent caret-text focus-visible:shadow-none ${className}`, type: "text", placeholder: placeholder, value: value, onChange: (e) => {
|
|
158
|
+
onChange(e.target.value);
|
|
159
|
+
queueMicrotask(updateTooltipFromCaret);
|
|
160
|
+
}, onFocus: updateTooltipFromCaret, onKeyDown: handleKeyDown, onKeyUp: handleKeyUp, onSelect: updateTooltipFromCaret, onClick: updateTooltipFromCaret, onScroll: syncScroll, onMouseMove: handleMouseMove, onMouseLeave: scheduleHide }), tooltip && tooltipContent && (_jsxs("div", { id: tooltipId, role: "tooltip", className: "pointer-events-auto fixed z-50 flex max-w-sm -translate-x-1/2 -translate-y-full flex-col gap-1.5 rounded-md border border-separator bg-surface px-3 py-2 text-[14px] text-text shadow-md", style: { top: tooltip.top - 4, left: tooltip.left }, onMouseEnter: cancelHide, onMouseLeave: () => setTooltip(null), children: [_jsx("span", { className: tooltipContent.muted ? 'text-muted' : undefined, children: tooltipContent.text }), onEditVariable && (_jsx("button", { type: "button", className: "self-start text-[14px] text-accent hover:underline app-no-drag", "aria-label": `Edit value for ${tooltip.key}`, onClick: () => {
|
|
87
161
|
onEditVariable();
|
|
88
162
|
setTooltip(null);
|
|
89
163
|
}, children: "Edit value" }))] }))] }));
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/VariableTable/index.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,OAAO,CAAC;AACjC,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAa/C,UAAU,KAAK;IACb;;OAEG;IACH,SAAS,EAAE,QAAQ,EAAE,CAAC;IAEtB;;OAEG;IACH,QAAQ,EAAE,CAAC,SAAS,EAAE,QAAQ,EAAE,KAAK,IAAI,CAAC;IAE1C;;OAEG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;GAEG;AACH,wBAAgB,aAAa,CAAC,EAAE,SAAS,EAAE,QAAQ,EAAE,WAAW,EAAE,EAAE,KAAK,GAAG,GAAG,CAAC,OAAO,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/VariableTable/index.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,OAAO,CAAC;AACjC,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAa/C,UAAU,KAAK;IACb;;OAEG;IACH,SAAS,EAAE,QAAQ,EAAE,CAAC;IAEtB;;OAEG;IACH,QAAQ,EAAE,CAAC,SAAS,EAAE,QAAQ,EAAE,KAAK,IAAI,CAAC;IAE1C;;OAEG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;GAEG;AACH,wBAAgB,aAAa,CAAC,EAAE,SAAS,EAAE,QAAQ,EAAE,WAAW,EAAE,EAAE,KAAK,GAAG,GAAG,CAAC,OAAO,CAmHtF"}
|
|
@@ -39,5 +39,5 @@ export function VariableTable({ variables, onChange, description }) {
|
|
|
39
39
|
}
|
|
40
40
|
onChange(variables.filter((_, i) => i !== index));
|
|
41
41
|
};
|
|
42
|
-
return (_jsxs("div", { children: [description && _jsx("p", { className: "mb-3 text-[14px] text-muted", children: description }), _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsxs("table", { className: "w-full border-separate border-spacing-x-1.5 border-spacing-y-1.5", children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { className: thClass, children: "Key" }), _jsx("th", { className: thClass, children: "Value" }), _jsx("th", { className: thClass, children: "Default" }), _jsx("th", { className: `${thClass} w-14 text-center`, children: "Share" }), _jsx("th", { className: `${thClass} w-7 p-0 text-center` })] }) }), _jsx("tbody", { children: variables.map((variable, index) => (_jsxs("tr", {
|
|
42
|
+
return (_jsxs("div", { children: [description && _jsx("p", { className: "mb-3 text-[14px] text-muted", children: description }), _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsxs("table", { className: "w-full border-separate border-spacing-x-1.5 border-spacing-y-1.5", children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { className: thClass, children: "Key" }), _jsx("th", { className: thClass, children: "Value" }), _jsx("th", { className: thClass, children: "Default" }), _jsx("th", { className: `${thClass} w-14 text-center`, children: "Share" }), _jsx("th", { className: `${thClass} w-7 p-0 text-center` })] }) }), _jsx("tbody", { children: variables.map((variable, index) => (_jsxs("tr", { children: [_jsx("td", { children: _jsx(Input, { type: "text", className: "w-full", value: variable.key, placeholder: "variable", "aria-label": `Key, row ${index + 1}`, onChange: (e) => updateVariable(index, { key: e.target.value }) }) }), _jsx("td", { children: _jsx(Input, { type: "text", className: "w-full", value: variable.value, placeholder: "value", "aria-label": `Value, row ${index + 1}`, onChange: (e) => updateVariable(index, { value: e.target.value }) }) }), _jsx("td", { children: _jsx(Input, { type: "text", className: "w-full", value: variable.defaultValue, placeholder: "default", "aria-label": `Default, row ${index + 1}`, onChange: (e) => updateVariable(index, { defaultValue: e.target.value }) }) }), _jsx("td", { className: "w-14 text-center", children: _jsx(Input, { type: "checkbox", checked: variable.share, onChange: (e) => updateVariable(index, { share: e.target.checked }), "aria-label": `Include value in export, row ${index + 1}`, title: "Include value in export" }) }), _jsx("td", { className: "w-7 p-0 text-center", children: _jsx(Button, { type: "button", variant: "iconDanger", onClick: () => removeVariable(index), title: "Remove", "aria-label": `Remove row ${index + 1}`, children: _jsx(FaIcon, { icon: faXmark, className: "h-3.5 w-3.5" }) }) })] }, index))) })] }), _jsxs(Button, { type: "button", variant: "toolbar", className: "inline-flex items-center gap-1 self-start", onClick: addVariable, children: [_jsx(FaIcon, { icon: faPlus, className: "h-3 w-3" }), "Add variable"] })] })] }));
|
|
43
43
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared macOS-style Tailwind class strings for SDK UI components.
|
|
3
3
|
*/
|
|
4
|
-
export declare const segmentGroup = "inline-flex
|
|
4
|
+
export declare const segmentGroup = "inline-flex p-3 border-b border-separator w-full shadow-[inset_0_0.5px_1px_rgba(0,0,0,0.06)] app-no-drag";
|
|
5
5
|
/**
|
|
6
6
|
* Tailwind classes for a segmented control button.
|
|
7
7
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"classes.d.ts","sourceRoot":"","sources":["../../src/components/classes.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,eAAO,MAAM,YAAY,
|
|
1
|
+
{"version":3,"file":"classes.d.ts","sourceRoot":"","sources":["../../src/components/classes.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,eAAO,MAAM,YAAY,6GACmF,CAAC;AAE7G;;;;GAIG;AACH,wBAAgB,OAAO,CAAC,MAAM,EAAE,OAAO,GAAG,MAAM,CAI/C"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared macOS-style Tailwind class strings for SDK UI components.
|
|
3
3
|
*/
|
|
4
|
-
export const segmentGroup = 'inline-flex
|
|
4
|
+
export const segmentGroup = 'inline-flex p-3 border-b border-separator w-full shadow-[inset_0_0.5px_1px_rgba(0,0,0,0.06)] app-no-drag';
|
|
5
5
|
/**
|
|
6
6
|
* Tailwind classes for a segmented control button.
|
|
7
7
|
*
|
|
@@ -9,6 +9,6 @@ export const segmentGroup = 'inline-flex rounded-md p-0.5 shadow-[inset_0_0.5px_
|
|
|
9
9
|
*/
|
|
10
10
|
export function segment(active) {
|
|
11
11
|
return active
|
|
12
|
-
? 'cursor-pointer rounded-[5px] border-none bg-control px-3 py-1 text-[15px] text-text shadow-sm app-no-drag'
|
|
13
|
-
: 'cursor-pointer rounded-[5px] border-none bg-transparent px-3 py-1 text-[15px] text-muted hover:text-text app-no-drag';
|
|
12
|
+
? 'cursor-pointer rounded-[2.5px] border-none bg-control px-3 py-1 text-[15px] text-text shadow-sm app-no-drag'
|
|
13
|
+
: 'cursor-pointer rounded-[2.5px] border-none bg-transparent px-3 py-1 text-[15px] text-muted hover:text-text app-no-drag';
|
|
14
14
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Options for merging validation accessibility attributes onto a form control.
|
|
4
|
+
*/
|
|
5
|
+
export interface EnhanceControlOptions {
|
|
6
|
+
/**
|
|
7
|
+
* Space-separated ids merged into `aria-describedby` on the control.
|
|
8
|
+
*/
|
|
9
|
+
describedBy?: string;
|
|
10
|
+
/**
|
|
11
|
+
* When true, sets `aria-invalid="true"` on the control.
|
|
12
|
+
*/
|
|
13
|
+
invalid?: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Sets `id` on the leaf control when it does not already have one.
|
|
16
|
+
*/
|
|
17
|
+
id?: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Merges accessibility attributes onto a form control, unwrapping single-child
|
|
21
|
+
* fragments and DOM wrappers to reach the underlying input component.
|
|
22
|
+
*
|
|
23
|
+
* @param child - Form control element or wrapper to enhance.
|
|
24
|
+
* @param options - `aria-describedby` ids, invalid state, and optional `id` to apply.
|
|
25
|
+
* @returns The original node or a cloned tree with ARIA props on the leaf control.
|
|
26
|
+
*/
|
|
27
|
+
export declare function enhanceControl(child: ReactNode, options: EnhanceControlOptions): ReactNode;
|
|
28
|
+
//# sourceMappingURL=enhanceControl.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"enhanceControl.d.ts","sourceRoot":"","sources":["../../src/components/enhanceControl.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAgB,SAAS,EAAE,MAAM,OAAO,CAAC;AAMrD;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACpC;;OAEG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IAErB;;OAEG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAElB;;OAEG;IACH,EAAE,CAAC,EAAE,MAAM,CAAC;CACb;AA0DD;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,SAAS,EAAE,OAAO,EAAE,qBAAqB,GAAG,SAAS,CAmC1F"}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { cloneElement, isValidElement } from '@harborclient/sdk/react';
|
|
2
|
+
const REACT_FRAGMENT_TYPE = Symbol.for('react.fragment');
|
|
3
|
+
const FORM_CONTROL_TAGS = new Set(['button', 'input', 'select', 'textarea']);
|
|
4
|
+
/**
|
|
5
|
+
* Returns the sole child when `node` is a one-element array; otherwise `node`.
|
|
6
|
+
*
|
|
7
|
+
* @param node - React child node to normalize.
|
|
8
|
+
* @returns The single child, or undefined when zero or multiple children exist.
|
|
9
|
+
*/
|
|
10
|
+
function getSingleChild(node) {
|
|
11
|
+
if (node == null || typeof node === 'boolean')
|
|
12
|
+
return undefined;
|
|
13
|
+
if (Array.isArray(node)) {
|
|
14
|
+
const filtered = node.filter((n) => n != null && n !== false);
|
|
15
|
+
return filtered.length === 1 ? filtered[0] : undefined;
|
|
16
|
+
}
|
|
17
|
+
return node;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Clones a control element with merged accessibility attributes.
|
|
21
|
+
*
|
|
22
|
+
* @param child - Leaf form control element.
|
|
23
|
+
* @param options - Accessibility attributes to apply.
|
|
24
|
+
* @returns Cloned element with merged ARIA props.
|
|
25
|
+
*/
|
|
26
|
+
function applyAriaProps(child, options) {
|
|
27
|
+
const { describedBy, invalid, id } = options;
|
|
28
|
+
const props = {};
|
|
29
|
+
if (id && child.props.id == null) {
|
|
30
|
+
props.id = id;
|
|
31
|
+
}
|
|
32
|
+
if (describedBy) {
|
|
33
|
+
const existing = typeof child.props['aria-describedby'] === 'string'
|
|
34
|
+
? child.props['aria-describedby']
|
|
35
|
+
: undefined;
|
|
36
|
+
props['aria-describedby'] = existing ? `${existing} ${describedBy}` : describedBy;
|
|
37
|
+
}
|
|
38
|
+
if (invalid) {
|
|
39
|
+
props['aria-invalid'] = true;
|
|
40
|
+
}
|
|
41
|
+
if (Object.keys(props).length === 0)
|
|
42
|
+
return child;
|
|
43
|
+
return cloneElement(child, props);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Merges accessibility attributes onto a form control, unwrapping single-child
|
|
47
|
+
* fragments and DOM wrappers to reach the underlying input component.
|
|
48
|
+
*
|
|
49
|
+
* @param child - Form control element or wrapper to enhance.
|
|
50
|
+
* @param options - `aria-describedby` ids, invalid state, and optional `id` to apply.
|
|
51
|
+
* @returns The original node or a cloned tree with ARIA props on the leaf control.
|
|
52
|
+
*/
|
|
53
|
+
export function enhanceControl(child, options) {
|
|
54
|
+
const { describedBy, invalid, id } = options;
|
|
55
|
+
if (!describedBy && !invalid && !id)
|
|
56
|
+
return child;
|
|
57
|
+
const single = getSingleChild(child);
|
|
58
|
+
if (single !== undefined && single !== child) {
|
|
59
|
+
return enhanceControl(single, options);
|
|
60
|
+
}
|
|
61
|
+
if (!isValidElement(child))
|
|
62
|
+
return child;
|
|
63
|
+
if (child.type === REACT_FRAGMENT_TYPE) {
|
|
64
|
+
const inner = getSingleChild(child.props.children);
|
|
65
|
+
if (inner && isValidElement(inner)) {
|
|
66
|
+
return cloneElement(child, {}, enhanceControl(inner, options));
|
|
67
|
+
}
|
|
68
|
+
return child;
|
|
69
|
+
}
|
|
70
|
+
if (typeof child.type === 'string') {
|
|
71
|
+
if (FORM_CONTROL_TAGS.has(child.type)) {
|
|
72
|
+
return applyAriaProps(child, options);
|
|
73
|
+
}
|
|
74
|
+
const inner = getSingleChild(child.props.children);
|
|
75
|
+
if (inner && isValidElement(inner)) {
|
|
76
|
+
const enhanced = enhanceControl(inner, options);
|
|
77
|
+
if (enhanced !== inner) {
|
|
78
|
+
return cloneElement(child, {}, enhanced);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return child;
|
|
82
|
+
}
|
|
83
|
+
return applyAriaProps(child, options);
|
|
84
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"enhanceControl.test.d.ts","sourceRoot":"","sources":["../../src/components/enhanceControl.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals';
|
|
2
|
+
import { createElement, Fragment, isValidElement } from 'react';
|
|
3
|
+
import { enhanceControl } from './enhanceControl.js';
|
|
4
|
+
/** Stand-in for {@link Input} — function component that forwards props to a native input. */
|
|
5
|
+
function TextInput(props) {
|
|
6
|
+
return createElement('input', { type: 'text', ...props });
|
|
7
|
+
}
|
|
8
|
+
function propsOf(node) {
|
|
9
|
+
if (!isValidElement(node)) {
|
|
10
|
+
throw new Error('Expected a valid React element');
|
|
11
|
+
}
|
|
12
|
+
return node.props;
|
|
13
|
+
}
|
|
14
|
+
describe('enhanceControl', () => {
|
|
15
|
+
it('merges aria-describedby and aria-invalid on a form component', () => {
|
|
16
|
+
const result = enhanceControl(createElement(TextInput), {
|
|
17
|
+
describedBy: 'field-error',
|
|
18
|
+
invalid: true
|
|
19
|
+
});
|
|
20
|
+
expect(propsOf(result)['aria-describedby']).toBe('field-error');
|
|
21
|
+
expect(propsOf(result)['aria-invalid']).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
it('includes description id in aria-describedby without setting aria-invalid', () => {
|
|
24
|
+
const result = enhanceControl(createElement(TextInput), {
|
|
25
|
+
describedBy: 'api-key-description'
|
|
26
|
+
});
|
|
27
|
+
expect(propsOf(result)['aria-describedby']).toBe('api-key-description');
|
|
28
|
+
expect(propsOf(result)['aria-invalid']).toBeUndefined();
|
|
29
|
+
});
|
|
30
|
+
it('merges description and error ids in aria-describedby', () => {
|
|
31
|
+
const result = enhanceControl(createElement(TextInput), {
|
|
32
|
+
describedBy: 'api-key-description api-key-error',
|
|
33
|
+
invalid: true
|
|
34
|
+
});
|
|
35
|
+
expect(propsOf(result)['aria-describedby']).toBe('api-key-description api-key-error');
|
|
36
|
+
expect(propsOf(result)['aria-invalid']).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
it('merges with an existing aria-describedby value', () => {
|
|
39
|
+
const result = enhanceControl(createElement(TextInput, { 'aria-describedby': 'existing' }), {
|
|
40
|
+
describedBy: 'field-error'
|
|
41
|
+
});
|
|
42
|
+
expect(propsOf(result)['aria-describedby']).toBe('existing field-error');
|
|
43
|
+
});
|
|
44
|
+
it('enhances a control wrapped in a div', () => {
|
|
45
|
+
const wrapped = createElement('div', null, createElement(TextInput));
|
|
46
|
+
const result = enhanceControl(wrapped, { describedBy: 'field-error' });
|
|
47
|
+
const inner = propsOf(result).children;
|
|
48
|
+
expect(propsOf(inner)['aria-describedby']).toBe('field-error');
|
|
49
|
+
});
|
|
50
|
+
it('enhances a native input wrapped in a div', () => {
|
|
51
|
+
const wrapped = createElement('div', null, createElement('input', { type: 'text' }));
|
|
52
|
+
const result = enhanceControl(wrapped, { describedBy: 'field-error' });
|
|
53
|
+
const inner = propsOf(result).children;
|
|
54
|
+
expect(propsOf(inner)['aria-describedby']).toBe('field-error');
|
|
55
|
+
});
|
|
56
|
+
it('unwraps a single-element array', () => {
|
|
57
|
+
const result = enhanceControl([createElement(TextInput)], { describedBy: 'field-error' });
|
|
58
|
+
expect(propsOf(result)['aria-describedby']).toBe('field-error');
|
|
59
|
+
});
|
|
60
|
+
it('enhances a control wrapped in a fragment', () => {
|
|
61
|
+
const wrapped = createElement(Fragment, null, createElement(TextInput));
|
|
62
|
+
const result = enhanceControl(wrapped, { describedBy: 'field-error' });
|
|
63
|
+
const inner = propsOf(result).children;
|
|
64
|
+
expect(propsOf(inner)['aria-describedby']).toBe('field-error');
|
|
65
|
+
});
|
|
66
|
+
it('leaves multiple siblings unchanged', () => {
|
|
67
|
+
const child = [createElement(TextInput), createElement('span')];
|
|
68
|
+
const result = enhanceControl(child, { describedBy: 'field-error' });
|
|
69
|
+
expect(result).toBe(child);
|
|
70
|
+
});
|
|
71
|
+
it('sets id when the control has none', () => {
|
|
72
|
+
const result = enhanceControl(createElement('input', { type: 'checkbox' }), {
|
|
73
|
+
id: 'auto-id'
|
|
74
|
+
});
|
|
75
|
+
expect(propsOf(result).id).toBe('auto-id');
|
|
76
|
+
});
|
|
77
|
+
it('preserves an existing id', () => {
|
|
78
|
+
const result = enhanceControl(createElement('input', { id: 'existing-id', type: 'checkbox' }), {
|
|
79
|
+
id: 'auto-id'
|
|
80
|
+
});
|
|
81
|
+
expect(propsOf(result).id).toBe('existing-id');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -8,9 +8,10 @@ import type { RefObject } from 'react';
|
|
|
8
8
|
*/
|
|
9
9
|
export declare function getFocusableElements(container: HTMLElement): HTMLElement[];
|
|
10
10
|
/**
|
|
11
|
-
* Traps keyboard focus within a dialog panel
|
|
11
|
+
* Traps keyboard focus within a dialog panel, inerts background siblings,
|
|
12
|
+
* and restores focus on unmount.
|
|
12
13
|
*
|
|
13
|
-
* @param panelRef - Ref attached to the dialog
|
|
14
|
+
* @param panelRef - Ref attached to the dialog overlay element.
|
|
14
15
|
*/
|
|
15
16
|
export declare function useDialogFocus(panelRef: RefObject<HTMLElement | null>): void;
|
|
16
17
|
//# sourceMappingURL=useDialogFocus.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useDialogFocus.d.ts","sourceRoot":"","sources":["../../src/components/useDialogFocus.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"useDialogFocus.d.ts","sourceRoot":"","sources":["../../src/components/useDialogFocus.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AA8DvC;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,WAAW,GAAG,WAAW,EAAE,CAU1E;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,GAAG,IAAI,CA8D5E"}
|
|
@@ -1,5 +1,58 @@
|
|
|
1
1
|
import { useEffect } from '@harborclient/sdk/react';
|
|
2
2
|
const FOCUSABLE_SELECTOR = 'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
3
|
+
const inertLockCounts = new WeakMap();
|
|
4
|
+
/**
|
|
5
|
+
* Increments or decrements an inert lock on an element so nested dialogs
|
|
6
|
+
* do not prematurely restore interactivity on shared siblings.
|
|
7
|
+
*
|
|
8
|
+
* @param element - Element to lock or unlock.
|
|
9
|
+
* @param locked - Whether to apply or release an inert lock.
|
|
10
|
+
*/
|
|
11
|
+
function setInertLocked(element, locked) {
|
|
12
|
+
const count = inertLockCounts.get(element) ?? 0;
|
|
13
|
+
if (locked) {
|
|
14
|
+
inertLockCounts.set(element, count + 1);
|
|
15
|
+
element.inert = true;
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const next = count - 1;
|
|
19
|
+
if (next <= 0) {
|
|
20
|
+
inertLockCounts.delete(element);
|
|
21
|
+
element.inert = false;
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
inertLockCounts.set(element, next);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Marks sibling elements of the overlay container as inert while the dialog is open.
|
|
29
|
+
*
|
|
30
|
+
* @param overlay - Modal overlay root element.
|
|
31
|
+
* @returns Siblings that received an inert lock from this call.
|
|
32
|
+
*/
|
|
33
|
+
function lockOverlaySiblings(overlay) {
|
|
34
|
+
const parent = overlay.parentElement;
|
|
35
|
+
if (!parent)
|
|
36
|
+
return [];
|
|
37
|
+
const locked = [];
|
|
38
|
+
for (const child of parent.children) {
|
|
39
|
+
if (child === overlay)
|
|
40
|
+
continue;
|
|
41
|
+
setInertLocked(child, true);
|
|
42
|
+
locked.push(child);
|
|
43
|
+
}
|
|
44
|
+
return locked;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Releases inert locks previously applied to overlay siblings.
|
|
48
|
+
*
|
|
49
|
+
* @param siblings - Sibling elements to unlock.
|
|
50
|
+
*/
|
|
51
|
+
function unlockOverlaySiblings(siblings) {
|
|
52
|
+
for (const sibling of siblings) {
|
|
53
|
+
setInertLocked(sibling, false);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
3
56
|
/**
|
|
4
57
|
* Returns visible, enabled focusable descendants of a container, excluding
|
|
5
58
|
* elements inside `aria-hidden` subtrees.
|
|
@@ -19,19 +72,22 @@ export function getFocusableElements(container) {
|
|
|
19
72
|
});
|
|
20
73
|
}
|
|
21
74
|
/**
|
|
22
|
-
* Traps keyboard focus within a dialog panel
|
|
75
|
+
* Traps keyboard focus within a dialog panel, inerts background siblings,
|
|
76
|
+
* and restores focus on unmount.
|
|
23
77
|
*
|
|
24
|
-
* @param panelRef - Ref attached to the dialog
|
|
78
|
+
* @param panelRef - Ref attached to the dialog overlay element.
|
|
25
79
|
*/
|
|
26
80
|
export function useDialogFocus(panelRef) {
|
|
27
81
|
/**
|
|
28
|
-
* Moves initial focus into the panel, traps Tab/Shift+Tab,
|
|
29
|
-
* focus to the element that opened the dialog when
|
|
82
|
+
* Moves initial focus into the panel, traps Tab/Shift+Tab, inerts sibling
|
|
83
|
+
* content, and restores focus to the element that opened the dialog when
|
|
84
|
+
* the panel unmounts.
|
|
30
85
|
*/
|
|
31
86
|
useEffect(() => {
|
|
32
87
|
const panel = panelRef.current;
|
|
33
88
|
if (!panel)
|
|
34
89
|
return;
|
|
90
|
+
const inertedSiblings = lockOverlaySiblings(panel);
|
|
35
91
|
const previousFocus = document.activeElement;
|
|
36
92
|
if (!panel.contains(document.activeElement)) {
|
|
37
93
|
const focusables = getFocusableElements(panel);
|
|
@@ -73,6 +129,7 @@ export function useDialogFocus(panelRef) {
|
|
|
73
129
|
document.addEventListener('keydown', handleKeyDown);
|
|
74
130
|
return () => {
|
|
75
131
|
document.removeEventListener('keydown', handleKeyDown);
|
|
132
|
+
unlockOverlaySiblings(inertedSiblings);
|
|
76
133
|
if (previousFocus instanceof HTMLElement && document.contains(previousFocus)) {
|
|
77
134
|
previousFocus.focus();
|
|
78
135
|
}
|
|
@@ -26,4 +26,29 @@ export interface TabListKeyOptions {
|
|
|
26
26
|
* @returns Target index when the key is handled, or null otherwise.
|
|
27
27
|
*/
|
|
28
28
|
export declare function resolveTabListKeyAction(key: string, currentIndex: number, itemCount: number, options?: TabListKeyOptions): number | null;
|
|
29
|
+
/**
|
|
30
|
+
* Result of a menu typeahead key press.
|
|
31
|
+
*/
|
|
32
|
+
export interface MenuTypeaheadResult {
|
|
33
|
+
/**
|
|
34
|
+
* Index of the menu item to focus.
|
|
35
|
+
*/
|
|
36
|
+
index: number;
|
|
37
|
+
/**
|
|
38
|
+
* Updated typeahead buffer after appending the typed character.
|
|
39
|
+
*/
|
|
40
|
+
buffer: string;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Resolves a printable key to the next menu item whose label matches the
|
|
44
|
+
* accumulated typeahead prefix. Search starts at the item after the current
|
|
45
|
+
* index and wraps circularly.
|
|
46
|
+
*
|
|
47
|
+
* @param labels - Visible menu item labels.
|
|
48
|
+
* @param currentIndex - Index of the currently focused item.
|
|
49
|
+
* @param key - Keyboard event key value.
|
|
50
|
+
* @param buffer - Accumulated typeahead characters from recent key presses.
|
|
51
|
+
* @returns Target index and updated buffer when matched, or null when unhandled.
|
|
52
|
+
*/
|
|
53
|
+
export declare function resolveMenuTypeahead(labels: string[], currentIndex: number, key: string, buffer: string): MenuTypeaheadResult | null;
|
|
29
54
|
//# sourceMappingURL=utils.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../src/components/utils.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAE5C;;;;;GAKG;AACH,eAAO,MAAM,cAAc,GAAI,WAAW,QAAQ,EAAE,KAAG,QAAQ,EACmB,CAAC;AAEnF;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC;;OAEG;IACH,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;CAC5B;AAgED;;;;;;;;;GASG;AACH,wBAAgB,uBAAuB,CACrC,GAAG,EAAE,MAAM,EACX,YAAY,EAAE,MAAM,EACpB,SAAS,EAAE,MAAM,EACjB,OAAO,CAAC,EAAE,iBAAiB,GAC1B,MAAM,GAAG,IAAI,CAcf"}
|
|
1
|
+
{"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../src/components/utils.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAE5C;;;;;GAKG;AACH,eAAO,MAAM,cAAc,GAAI,WAAW,QAAQ,EAAE,KAAG,QAAQ,EACmB,CAAC;AAEnF;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC;;OAEG;IACH,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;CAC5B;AAgED;;;;;;;;;GASG;AACH,wBAAgB,uBAAuB,CACrC,GAAG,EAAE,MAAM,EACX,YAAY,EAAE,MAAM,EACpB,SAAS,EAAE,MAAM,EACjB,OAAO,CAAC,EAAE,iBAAiB,GAC1B,MAAM,GAAG,IAAI,CAcf;AAED;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC;;OAEG;IACH,KAAK,EAAE,MAAM,CAAC;IAEd;;OAEG;IACH,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,MAAM,EAAE,EAChB,YAAY,EAAE,MAAM,EACpB,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,MAAM,GACb,mBAAmB,GAAG,IAAI,CAc5B"}
|
package/dist/components/utils.js
CHANGED
|
@@ -87,3 +87,27 @@ export function resolveTabListKeyAction(key, currentIndex, itemCount, options) {
|
|
|
87
87
|
}
|
|
88
88
|
return null;
|
|
89
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* Resolves a printable key to the next menu item whose label matches the
|
|
92
|
+
* accumulated typeahead prefix. Search starts at the item after the current
|
|
93
|
+
* index and wraps circularly.
|
|
94
|
+
*
|
|
95
|
+
* @param labels - Visible menu item labels.
|
|
96
|
+
* @param currentIndex - Index of the currently focused item.
|
|
97
|
+
* @param key - Keyboard event key value.
|
|
98
|
+
* @param buffer - Accumulated typeahead characters from recent key presses.
|
|
99
|
+
* @returns Target index and updated buffer when matched, or null when unhandled.
|
|
100
|
+
*/
|
|
101
|
+
export function resolveMenuTypeahead(labels, currentIndex, key, buffer) {
|
|
102
|
+
if (labels.length === 0 || key.length !== 1 || key === ' ')
|
|
103
|
+
return null;
|
|
104
|
+
const newBuffer = buffer + key;
|
|
105
|
+
const prefix = newBuffer.toLowerCase();
|
|
106
|
+
for (let offset = 1; offset <= labels.length; offset++) {
|
|
107
|
+
const index = (currentIndex + offset) % labels.length;
|
|
108
|
+
if (labels[index].toLowerCase().startsWith(prefix)) {
|
|
109
|
+
return { index, buffer: newBuffer };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from '@jest/globals';
|
|
2
|
-
import { resolveTabListKeyAction } from './utils.js';
|
|
2
|
+
import { resolveMenuTypeahead, resolveTabListKeyAction } from './utils.js';
|
|
3
3
|
describe('resolveTabListKeyAction', () => {
|
|
4
4
|
it('returns null for unhandled keys', () => {
|
|
5
5
|
expect(resolveTabListKeyAction('Enter', 0, 3)).toBeNull();
|
|
@@ -25,3 +25,20 @@ describe('resolveTabListKeyAction', () => {
|
|
|
25
25
|
expect(resolveTabListKeyAction('End', 0, 3, { disabledIndices: [2] })).toBe(1);
|
|
26
26
|
});
|
|
27
27
|
});
|
|
28
|
+
describe('resolveMenuTypeahead', () => {
|
|
29
|
+
const labels = ['Rename', 'Duplicate', 'Delete'];
|
|
30
|
+
it('returns null for non-printable keys', () => {
|
|
31
|
+
expect(resolveMenuTypeahead(labels, 0, 'Enter', '')).toBeNull();
|
|
32
|
+
expect(resolveMenuTypeahead(labels, 0, ' ', 'd')).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
it('matches the next item whose label starts with the typed prefix', () => {
|
|
35
|
+
expect(resolveMenuTypeahead(labels, 0, 'd', '')).toEqual({ index: 1, buffer: 'd' });
|
|
36
|
+
expect(resolveMenuTypeahead(labels, 1, 'e', 'd')).toEqual({ index: 2, buffer: 'de' });
|
|
37
|
+
});
|
|
38
|
+
it('wraps the search from the current index', () => {
|
|
39
|
+
expect(resolveMenuTypeahead(labels, 2, 'r', '')).toEqual({ index: 0, buffer: 'r' });
|
|
40
|
+
});
|
|
41
|
+
it('returns null when no label matches the prefix', () => {
|
|
42
|
+
expect(resolveMenuTypeahead(labels, 0, 'z', '')).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
});
|