@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.
Files changed (69) hide show
  1. package/dist/components/BusyIndicator/index.d.ts +3 -2
  2. package/dist/components/BusyIndicator/index.d.ts.map +1 -1
  3. package/dist/components/BusyIndicator/index.js +11 -3
  4. package/dist/components/Button/index.d.ts +23 -8
  5. package/dist/components/Button/index.d.ts.map +1 -1
  6. package/dist/components/Button/index.js +11 -9
  7. package/dist/components/CodeEditor/index.d.ts +9 -1
  8. package/dist/components/CodeEditor/index.d.ts.map +1 -1
  9. package/dist/components/CodeEditor/index.js +186 -62
  10. package/dist/components/FieldError/index.d.ts.map +1 -1
  11. package/dist/components/FieldError/index.js +1 -1
  12. package/dist/components/FooterButton/index.js +2 -2
  13. package/dist/components/FormDataEditor/index.d.ts.map +1 -1
  14. package/dist/components/FormDataEditor/index.js +2 -2
  15. package/dist/components/FormGroup/index.d.ts +6 -1
  16. package/dist/components/FormGroup/index.d.ts.map +1 -1
  17. package/dist/components/FormGroup/index.js +25 -31
  18. package/dist/components/KeyValueEditor/index.d.ts.map +1 -1
  19. package/dist/components/KeyValueEditor/index.js +3 -3
  20. package/dist/components/Modal/ModalFormLayout.d.ts.map +1 -1
  21. package/dist/components/Modal/ModalFormLayout.js +2 -1
  22. package/dist/components/Modal/ModalHeader.d.ts +5 -1
  23. package/dist/components/Modal/ModalHeader.d.ts.map +1 -1
  24. package/dist/components/Modal/ModalHeader.js +2 -2
  25. package/dist/components/Modal/index.d.ts.map +1 -1
  26. package/dist/components/Modal/index.js +4 -2
  27. package/dist/components/OverlayCloseButton/index.d.ts.map +1 -1
  28. package/dist/components/OverlayCloseButton/index.js +1 -2
  29. package/dist/components/Resizable/Resizable.d.ts.map +1 -1
  30. package/dist/components/Resizable/Resizable.js +1 -1
  31. package/dist/components/Resizable/ResizeHandle.js +2 -2
  32. package/dist/components/ResourceList/index.d.ts +11 -1
  33. package/dist/components/ResourceList/index.d.ts.map +1 -1
  34. package/dist/components/ResourceList/index.js +4 -2
  35. package/dist/components/RowActionsMenu/index.d.ts.map +1 -1
  36. package/dist/components/RowActionsMenu/index.js +150 -11
  37. package/dist/components/SegmentedTabs/index.d.ts.map +1 -1
  38. package/dist/components/SegmentedTabs/index.js +2 -2
  39. package/dist/components/TabCloseButton/index.d.ts +1 -1
  40. package/dist/components/TabCloseButton/index.js +2 -2
  41. package/dist/components/VariableInput/index.d.ts.map +1 -1
  42. package/dist/components/VariableInput/index.js +84 -10
  43. package/dist/components/VariableTable/index.d.ts.map +1 -1
  44. package/dist/components/VariableTable/index.js +1 -1
  45. package/dist/components/classes.d.ts +1 -1
  46. package/dist/components/classes.d.ts.map +1 -1
  47. package/dist/components/classes.js +3 -3
  48. package/dist/components/enhanceControl.d.ts +28 -0
  49. package/dist/components/enhanceControl.d.ts.map +1 -0
  50. package/dist/components/enhanceControl.js +84 -0
  51. package/dist/components/enhanceControl.test.d.ts +2 -0
  52. package/dist/components/enhanceControl.test.d.ts.map +1 -0
  53. package/dist/components/enhanceControl.test.js +83 -0
  54. package/dist/components/useDialogFocus.d.ts +3 -2
  55. package/dist/components/useDialogFocus.d.ts.map +1 -1
  56. package/dist/components/useDialogFocus.js +61 -4
  57. package/dist/components/utils.d.ts +25 -0
  58. package/dist/components/utils.d.ts.map +1 -1
  59. package/dist/components/utils.js +24 -0
  60. package/dist/components/utils.test.js +18 -1
  61. package/dist/types.d.ts.map +1 -1
  62. package/dist/variables/index.d.ts +1 -1
  63. package/dist/variables/index.d.ts.map +1 -1
  64. package/dist/variables/index.js +1 -1
  65. package/dist/variables/tokens.d.ts +31 -0
  66. package/dist/variables/tokens.d.ts.map +1 -1
  67. package/dist/variables/tokens.js +38 -1
  68. package/dist/variables/tokens.test.js +46 -1
  69. 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
- setTooltip({
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) => onChange(e.target.value), onKeyDown: onKeyDown, onScroll: syncScroll, onMouseMove: handleMouseMove, onMouseLeave: scheduleHide }), tooltip && (_jsxs("div", { 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: [tooltip.value !== undefined ? (tooltip.value) : tooltip.dynamicDescription ? (_jsxs("span", { className: "text-muted", children: ["Dynamic: ", tooltip.dynamicDescription] })) : (_jsx("span", { className: "text-muted", children: "Not defined" })), onEditVariable && (_jsx("button", { type: "button", className: "self-start text-[14px] text-accent hover:underline app-no-drag", onClick: () => {
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,CAkHtF"}
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", { className: "group", 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", 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"] })] })] }));
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 rounded-md p-0.5 shadow-[inset_0_0.5px_1px_rgba(0,0,0,0.06)] app-no-drag";
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,yFAC+D,CAAC;AAEzF;;;;GAIG;AACH,wBAAgB,OAAO,CAAC,MAAM,EAAE,OAAO,GAAG,MAAM,CAI/C"}
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 rounded-md p-0.5 shadow-[inset_0_0.5px_1px_rgba(0,0,0,0.06)] app-no-drag';
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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=enhanceControl.test.d.ts.map
@@ -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 and restores focus on unmount.
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 panel element.
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;AAKvC;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,WAAW,GAAG,WAAW,EAAE,CAU1E;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,GAAG,IAAI,CA2D5E"}
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 and restores focus on unmount.
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 panel element.
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, and restores
29
- * focus to the element that opened the dialog when the panel unmounts.
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"}
@@ -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
+ });