@dbcdk/react-components 0.0.208 → 0.0.209

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.
@@ -13,6 +13,36 @@ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
13
13
 
14
14
  var styles__default = /*#__PURE__*/_interopDefault(styles);
15
15
 
16
+ function getFocusable(container) {
17
+ var _a, _b;
18
+ const selector = [
19
+ 'a[href]:not([aria-disabled="true"])',
20
+ "button:not([disabled])",
21
+ "input:not([disabled])",
22
+ "select:not([disabled])",
23
+ "textarea:not([disabled])",
24
+ '[tabindex]:not([tabindex="-1"])'
25
+ ].join(",");
26
+ const focusables = [];
27
+ const addFocusableContent = (root) => {
28
+ const candidates = root.querySelectorAll(selector);
29
+ for (const element of candidates) {
30
+ if (element.hasAttribute("hidden") || element.getAttribute("aria-hidden") === "true" || element.getAttribute("aria-disabled") === "true") {
31
+ continue;
32
+ }
33
+ if (!focusables.includes(element)) focusables.push(element);
34
+ }
35
+ };
36
+ addFocusableContent(container);
37
+ for (const trigger of container.querySelectorAll("[aria-controls]")) {
38
+ const controlledIds = (_b = (_a = trigger.getAttribute("aria-controls")) == null ? void 0 : _a.split(/\s+/)) != null ? _b : [];
39
+ for (const controlledId of controlledIds) {
40
+ const controlledContent = document.getElementById(controlledId);
41
+ if (controlledContent) addFocusableContent(controlledContent);
42
+ }
43
+ }
44
+ return focusables;
45
+ }
16
46
  function SidePanel({
17
47
  isOpen,
18
48
  onClose,
@@ -36,23 +66,70 @@ function SidePanel({
36
66
  const [shouldRender, setShouldRender] = react.useState(isOpen);
37
67
  const [isActive, setIsActive] = react.useState(false);
38
68
  const panelRef = react.useRef(null);
69
+ const returnFocusRef = react.useRef(null);
70
+ const wasOpenRef = react.useRef(false);
39
71
  react.useEffect(() => setMounted(true), []);
40
72
  react.useEffect(() => {
41
73
  if (isOpen) setShouldRender(true);
42
74
  }, [isOpen]);
43
75
  react.useEffect(() => {
44
- if (!isOpen) return;
76
+ if (!isOpen || !shouldRender || !mounted) return;
77
+ if (!wasOpenRef.current) {
78
+ const activeElement = document.activeElement;
79
+ returnFocusRef.current = activeElement instanceof HTMLElement ? activeElement : null;
80
+ wasOpenRef.current = true;
81
+ }
82
+ const focusInitial = () => {
83
+ var _a;
84
+ const panel = panelRef.current;
85
+ if (!panel) return;
86
+ const closeButton = panel.querySelector(
87
+ '[data-cy="side-panel-close-button"]'
88
+ );
89
+ const focusables = getFocusable(panel);
90
+ ((_a = closeButton != null ? closeButton : focusables[0]) != null ? _a : panel).focus();
91
+ };
92
+ focusInitial();
93
+ const frame = requestAnimationFrame(focusInitial);
45
94
  const handleKeyDown = (e) => {
46
95
  if (e.key === "Escape") {
47
96
  e.stopPropagation();
48
97
  onClose();
98
+ return;
99
+ }
100
+ if (e.key !== "Tab") return;
101
+ const panel = panelRef.current;
102
+ if (!panel) return;
103
+ const focusables = getFocusable(panel);
104
+ if (focusables.length === 0) {
105
+ e.preventDefault();
106
+ panel.focus();
107
+ return;
108
+ }
109
+ const first = focusables[0];
110
+ const last = focusables[focusables.length - 1];
111
+ const active = document.activeElement;
112
+ if (e.shiftKey && (active === first || !panel.contains(active))) {
113
+ e.preventDefault();
114
+ last.focus();
115
+ } else if (!e.shiftKey && (active === last || !panel.contains(active))) {
116
+ e.preventDefault();
117
+ first.focus();
49
118
  }
50
119
  };
51
120
  document.addEventListener("keydown", handleKeyDown);
52
121
  return () => {
122
+ cancelAnimationFrame(frame);
53
123
  document.removeEventListener("keydown", handleKeyDown);
54
124
  };
55
- }, [isOpen, onClose]);
125
+ }, [isOpen, mounted, onClose, shouldRender]);
126
+ react.useEffect(() => {
127
+ var _a;
128
+ if (isOpen || !wasOpenRef.current) return;
129
+ wasOpenRef.current = false;
130
+ (_a = returnFocusRef.current) == null ? void 0 : _a.focus();
131
+ returnFocusRef.current = null;
132
+ }, [isOpen]);
56
133
  react.useEffect(() => {
57
134
  if (!shouldRender) return;
58
135
  if (!isOpen) {
@@ -103,6 +180,7 @@ function SidePanel({
103
180
  "data-cy": "details-panel",
104
181
  role: "dialog",
105
182
  "aria-modal": "true",
183
+ tabIndex: -1,
106
184
  children: [
107
185
  hasDetails ? /* @__PURE__ */ jsxRuntime.jsxs("aside", { className: styles__default.default.detailsCol, "data-cy": "details-panel-details", children: [
108
186
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: styles__default.default.detailsHeader, children: [
@@ -7,6 +7,36 @@ import { Headline } from '../../../components/headline/Headline';
7
7
  import { MOTION_MS } from '../../../styles/animation';
8
8
  import styles from './SidePanel.module.css';
9
9
 
10
+ function getFocusable(container) {
11
+ var _a, _b;
12
+ const selector = [
13
+ 'a[href]:not([aria-disabled="true"])',
14
+ "button:not([disabled])",
15
+ "input:not([disabled])",
16
+ "select:not([disabled])",
17
+ "textarea:not([disabled])",
18
+ '[tabindex]:not([tabindex="-1"])'
19
+ ].join(",");
20
+ const focusables = [];
21
+ const addFocusableContent = (root) => {
22
+ const candidates = root.querySelectorAll(selector);
23
+ for (const element of candidates) {
24
+ if (element.hasAttribute("hidden") || element.getAttribute("aria-hidden") === "true" || element.getAttribute("aria-disabled") === "true") {
25
+ continue;
26
+ }
27
+ if (!focusables.includes(element)) focusables.push(element);
28
+ }
29
+ };
30
+ addFocusableContent(container);
31
+ for (const trigger of container.querySelectorAll("[aria-controls]")) {
32
+ const controlledIds = (_b = (_a = trigger.getAttribute("aria-controls")) == null ? void 0 : _a.split(/\s+/)) != null ? _b : [];
33
+ for (const controlledId of controlledIds) {
34
+ const controlledContent = document.getElementById(controlledId);
35
+ if (controlledContent) addFocusableContent(controlledContent);
36
+ }
37
+ }
38
+ return focusables;
39
+ }
10
40
  function SidePanel({
11
41
  isOpen,
12
42
  onClose,
@@ -30,23 +60,70 @@ function SidePanel({
30
60
  const [shouldRender, setShouldRender] = useState(isOpen);
31
61
  const [isActive, setIsActive] = useState(false);
32
62
  const panelRef = useRef(null);
63
+ const returnFocusRef = useRef(null);
64
+ const wasOpenRef = useRef(false);
33
65
  useEffect(() => setMounted(true), []);
34
66
  useEffect(() => {
35
67
  if (isOpen) setShouldRender(true);
36
68
  }, [isOpen]);
37
69
  useEffect(() => {
38
- if (!isOpen) return;
70
+ if (!isOpen || !shouldRender || !mounted) return;
71
+ if (!wasOpenRef.current) {
72
+ const activeElement = document.activeElement;
73
+ returnFocusRef.current = activeElement instanceof HTMLElement ? activeElement : null;
74
+ wasOpenRef.current = true;
75
+ }
76
+ const focusInitial = () => {
77
+ var _a;
78
+ const panel = panelRef.current;
79
+ if (!panel) return;
80
+ const closeButton = panel.querySelector(
81
+ '[data-cy="side-panel-close-button"]'
82
+ );
83
+ const focusables = getFocusable(panel);
84
+ ((_a = closeButton != null ? closeButton : focusables[0]) != null ? _a : panel).focus();
85
+ };
86
+ focusInitial();
87
+ const frame = requestAnimationFrame(focusInitial);
39
88
  const handleKeyDown = (e) => {
40
89
  if (e.key === "Escape") {
41
90
  e.stopPropagation();
42
91
  onClose();
92
+ return;
93
+ }
94
+ if (e.key !== "Tab") return;
95
+ const panel = panelRef.current;
96
+ if (!panel) return;
97
+ const focusables = getFocusable(panel);
98
+ if (focusables.length === 0) {
99
+ e.preventDefault();
100
+ panel.focus();
101
+ return;
102
+ }
103
+ const first = focusables[0];
104
+ const last = focusables[focusables.length - 1];
105
+ const active = document.activeElement;
106
+ if (e.shiftKey && (active === first || !panel.contains(active))) {
107
+ e.preventDefault();
108
+ last.focus();
109
+ } else if (!e.shiftKey && (active === last || !panel.contains(active))) {
110
+ e.preventDefault();
111
+ first.focus();
43
112
  }
44
113
  };
45
114
  document.addEventListener("keydown", handleKeyDown);
46
115
  return () => {
116
+ cancelAnimationFrame(frame);
47
117
  document.removeEventListener("keydown", handleKeyDown);
48
118
  };
49
- }, [isOpen, onClose]);
119
+ }, [isOpen, mounted, onClose, shouldRender]);
120
+ useEffect(() => {
121
+ var _a;
122
+ if (isOpen || !wasOpenRef.current) return;
123
+ wasOpenRef.current = false;
124
+ (_a = returnFocusRef.current) == null ? void 0 : _a.focus();
125
+ returnFocusRef.current = null;
126
+ }, [isOpen]);
50
127
  useEffect(() => {
51
128
  if (!shouldRender) return;
52
129
  if (!isOpen) {
@@ -97,6 +174,7 @@ function SidePanel({
97
174
  "data-cy": "details-panel",
98
175
  role: "dialog",
99
176
  "aria-modal": "true",
177
+ tabIndex: -1,
100
178
  children: [
101
179
  hasDetails ? /* @__PURE__ */ jsxs("aside", { className: styles.detailsCol, "data-cy": "details-panel-details", children: [
102
180
  /* @__PURE__ */ jsxs("div", { className: styles.detailsHeader, children: [
@@ -22,7 +22,8 @@ function SplitPane({
22
22
  showDivider = "hover",
23
23
  gutterSize = 16,
24
24
  storageKey,
25
- fillViewport = false
25
+ fillViewport = false,
26
+ secondaryVisible = true
26
27
  }) {
27
28
  return /* @__PURE__ */ jsxRuntime.jsx(
28
29
  SplitPaneContext.SplitPaneProvider,
@@ -31,6 +32,7 @@ function SplitPane({
31
32
  initialPrimarySize,
32
33
  minPrimarySize,
33
34
  minSecondarySize,
35
+ gutterSize,
34
36
  storageKey,
35
37
  children: /* @__PURE__ */ jsxRuntime.jsx(
36
38
  SplitPaneContainer,
@@ -38,6 +40,7 @@ function SplitPane({
38
40
  showDivider,
39
41
  gutterSize,
40
42
  fillViewport,
43
+ secondaryVisible,
41
44
  children
42
45
  }
43
46
  )
@@ -48,7 +51,8 @@ function SplitPaneContainer({
48
51
  children,
49
52
  showDivider,
50
53
  gutterSize,
51
- fillViewport
54
+ fillViewport,
55
+ secondaryVisible
52
56
  }) {
53
57
  const { direction, primarySize, containerRef } = SplitPaneContext.useSplitPaneContext();
54
58
  const style = react.useMemo(
@@ -66,6 +70,7 @@ function SplitPaneContainer({
66
70
  "data-direction": direction,
67
71
  "data-divider": showDivider,
68
72
  "data-fill-viewport": fillViewport,
73
+ "data-secondary-visible": secondaryVisible,
69
74
  style,
70
75
  children
71
76
  }
@@ -77,13 +82,14 @@ function SplitPanePrimary({ children }) {
77
82
  function SplitPaneSecondary({ children }) {
78
83
  return /* @__PURE__ */ jsxRuntime.jsx("div", { className: styles__default.default.secondary, children });
79
84
  }
80
- function SplitPaneGutter() {
85
+ function SplitPaneGutter({ label = "Resize panes" }) {
81
86
  const {
82
87
  direction,
83
88
  primarySize,
84
89
  setPrimarySize,
85
90
  minPrimarySize,
86
91
  minSecondarySize,
92
+ gutterSize,
87
93
  containerRef,
88
94
  resetDefault
89
95
  } = SplitPaneContext.useSplitPaneContext();
@@ -101,9 +107,10 @@ function SplitPaneGutter() {
101
107
  if (!el) return { maxPrimary: Infinity, total: 0 };
102
108
  const rect = el.getBoundingClientRect();
103
109
  const total = direction === "horizontal" ? rect.width : rect.height;
104
- const maxPrimary2 = Math.max(minPrimarySize, total - minSecondarySize);
110
+ if (!Number.isFinite(total) || total <= 0) return { maxPrimary: Infinity, total };
111
+ const maxPrimary2 = Math.max(minPrimarySize, total - minSecondarySize - gutterSize);
105
112
  return { maxPrimary: maxPrimary2, total };
106
- }, [containerRef, direction, minPrimarySize, minSecondarySize]);
113
+ }, [containerRef, direction, gutterSize, minPrimarySize, minSecondarySize]);
107
114
  const onPointerDown = react.useCallback(
108
115
  (e) => {
109
116
  const el = containerRef.current;
@@ -163,13 +170,21 @@ function SplitPaneGutter() {
163
170
  const ariaOrientation = direction === "horizontal" ? "vertical" : "horizontal";
164
171
  const [maxPrimary, setMaxPrimary] = react.useState(Infinity);
165
172
  react.useEffect(() => {
166
- setMaxPrimary(computeClamp().maxPrimary);
167
- }, [computeClamp]);
173
+ const container = containerRef.current;
174
+ if (!container) return;
175
+ const updateMax = () => setMaxPrimary(computeClamp().maxPrimary);
176
+ updateMax();
177
+ if (typeof ResizeObserver === "undefined") return;
178
+ const observer = new ResizeObserver(updateMax);
179
+ observer.observe(container);
180
+ return () => observer.disconnect();
181
+ }, [computeClamp, containerRef]);
168
182
  return /* @__PURE__ */ jsxRuntime.jsx("div", { className: styles__default.default.gutter, children: /* @__PURE__ */ jsxRuntime.jsx(
169
183
  "div",
170
184
  {
171
185
  className: styles__default.default.resizer,
172
186
  role: "separator",
187
+ "aria-label": label,
173
188
  "aria-orientation": ariaOrientation,
174
189
  "aria-valuemin": Math.round(minPrimarySize),
175
190
  "aria-valuemax": Number.isFinite(maxPrimary) ? Math.round(maxPrimary) : void 0,
@@ -10,12 +10,15 @@ export interface SplitPaneProps {
10
10
  gutterSize?: number;
11
11
  storageKey?: string;
12
12
  fillViewport?: boolean;
13
+ secondaryVisible?: boolean;
13
14
  }
14
- export declare function SplitPane({ children, initialPrimarySize, minPrimarySize, minSecondarySize, direction, showDivider, gutterSize, storageKey, fillViewport, }: SplitPaneProps): JSX.Element;
15
+ export declare function SplitPane({ children, initialPrimarySize, minPrimarySize, minSecondarySize, direction, showDivider, gutterSize, storageKey, fillViewport, secondaryVisible, }: SplitPaneProps): JSX.Element;
15
16
  export declare function SplitPanePrimary({ children }: {
16
17
  children: ReactNode;
17
18
  }): JSX.Element;
18
19
  export declare function SplitPaneSecondary({ children }: {
19
20
  children: ReactNode;
20
21
  }): JSX.Element;
21
- export declare function SplitPaneGutter(): JSX.Element;
22
+ export declare function SplitPaneGutter({ label }: {
23
+ label?: string;
24
+ }): JSX.Element;
@@ -16,7 +16,8 @@ function SplitPane({
16
16
  showDivider = "hover",
17
17
  gutterSize = 16,
18
18
  storageKey,
19
- fillViewport = false
19
+ fillViewport = false,
20
+ secondaryVisible = true
20
21
  }) {
21
22
  return /* @__PURE__ */ jsx(
22
23
  SplitPaneProvider,
@@ -25,6 +26,7 @@ function SplitPane({
25
26
  initialPrimarySize,
26
27
  minPrimarySize,
27
28
  minSecondarySize,
29
+ gutterSize,
28
30
  storageKey,
29
31
  children: /* @__PURE__ */ jsx(
30
32
  SplitPaneContainer,
@@ -32,6 +34,7 @@ function SplitPane({
32
34
  showDivider,
33
35
  gutterSize,
34
36
  fillViewport,
37
+ secondaryVisible,
35
38
  children
36
39
  }
37
40
  )
@@ -42,7 +45,8 @@ function SplitPaneContainer({
42
45
  children,
43
46
  showDivider,
44
47
  gutterSize,
45
- fillViewport
48
+ fillViewport,
49
+ secondaryVisible
46
50
  }) {
47
51
  const { direction, primarySize, containerRef } = useSplitPaneContext();
48
52
  const style = useMemo(
@@ -60,6 +64,7 @@ function SplitPaneContainer({
60
64
  "data-direction": direction,
61
65
  "data-divider": showDivider,
62
66
  "data-fill-viewport": fillViewport,
67
+ "data-secondary-visible": secondaryVisible,
63
68
  style,
64
69
  children
65
70
  }
@@ -71,13 +76,14 @@ function SplitPanePrimary({ children }) {
71
76
  function SplitPaneSecondary({ children }) {
72
77
  return /* @__PURE__ */ jsx("div", { className: styles.secondary, children });
73
78
  }
74
- function SplitPaneGutter() {
79
+ function SplitPaneGutter({ label = "Resize panes" }) {
75
80
  const {
76
81
  direction,
77
82
  primarySize,
78
83
  setPrimarySize,
79
84
  minPrimarySize,
80
85
  minSecondarySize,
86
+ gutterSize,
81
87
  containerRef,
82
88
  resetDefault
83
89
  } = useSplitPaneContext();
@@ -95,9 +101,10 @@ function SplitPaneGutter() {
95
101
  if (!el) return { maxPrimary: Infinity, total: 0 };
96
102
  const rect = el.getBoundingClientRect();
97
103
  const total = direction === "horizontal" ? rect.width : rect.height;
98
- const maxPrimary2 = Math.max(minPrimarySize, total - minSecondarySize);
104
+ if (!Number.isFinite(total) || total <= 0) return { maxPrimary: Infinity, total };
105
+ const maxPrimary2 = Math.max(minPrimarySize, total - minSecondarySize - gutterSize);
99
106
  return { maxPrimary: maxPrimary2, total };
100
- }, [containerRef, direction, minPrimarySize, minSecondarySize]);
107
+ }, [containerRef, direction, gutterSize, minPrimarySize, minSecondarySize]);
101
108
  const onPointerDown = useCallback(
102
109
  (e) => {
103
110
  const el = containerRef.current;
@@ -157,13 +164,21 @@ function SplitPaneGutter() {
157
164
  const ariaOrientation = direction === "horizontal" ? "vertical" : "horizontal";
158
165
  const [maxPrimary, setMaxPrimary] = useState(Infinity);
159
166
  useEffect(() => {
160
- setMaxPrimary(computeClamp().maxPrimary);
161
- }, [computeClamp]);
167
+ const container = containerRef.current;
168
+ if (!container) return;
169
+ const updateMax = () => setMaxPrimary(computeClamp().maxPrimary);
170
+ updateMax();
171
+ if (typeof ResizeObserver === "undefined") return;
172
+ const observer = new ResizeObserver(updateMax);
173
+ observer.observe(container);
174
+ return () => observer.disconnect();
175
+ }, [computeClamp, containerRef]);
162
176
  return /* @__PURE__ */ jsx("div", { className: styles.gutter, children: /* @__PURE__ */ jsx(
163
177
  "div",
164
178
  {
165
179
  className: styles.resizer,
166
180
  role: "separator",
181
+ "aria-label": label,
167
182
  "aria-orientation": ariaOrientation,
168
183
  "aria-valuemin": Math.round(minPrimarySize),
169
184
  "aria-valuemax": Number.isFinite(maxPrimary) ? Math.round(maxPrimary) : void 0,
@@ -13,6 +13,15 @@
13
13
  max-height: 100%;
14
14
  }
15
15
 
16
+ .container[data-secondary-visible='false'] > .primary {
17
+ flex: 1 1 auto;
18
+ }
19
+
20
+ .container[data-secondary-visible='false'] > .gutter,
21
+ .container[data-secondary-visible='false'] > .secondary {
22
+ display: none;
23
+ }
24
+
16
25
  .container[data-direction='horizontal'] {
17
26
  flex-direction: row;
18
27
  }
@@ -44,6 +44,7 @@ function SplitPaneProvider({
44
44
  initialPrimarySize,
45
45
  minPrimarySize,
46
46
  minSecondarySize,
47
+ gutterSize,
47
48
  storageKey
48
49
  }) {
49
50
  const containerRef = React.useRef(null);
@@ -55,8 +56,8 @@ function SplitPaneProvider({
55
56
  const rect = el.getBoundingClientRect();
56
57
  const total = direction === "horizontal" ? rect.width : rect.height;
57
58
  if (!Number.isFinite(total) || total <= 0) return null;
58
- return Math.max(minPrimarySize, total - minSecondarySize);
59
- }, [direction, minPrimarySize, minSecondarySize]);
59
+ return Math.max(minPrimarySize, total - minSecondarySize - gutterSize);
60
+ }, [direction, gutterSize, minPrimarySize, minSecondarySize]);
60
61
  React.useEffect(() => {
61
62
  if (!storageKey) return;
62
63
  const stored = readStoredSize(storageKey);
@@ -108,6 +109,7 @@ function SplitPaneProvider({
108
109
  resetDefault,
109
110
  minPrimarySize,
110
111
  minSecondarySize,
112
+ gutterSize,
111
113
  containerRef,
112
114
  storageKey
113
115
  }),
@@ -118,6 +120,7 @@ function SplitPaneProvider({
118
120
  resetDefault,
119
121
  minPrimarySize,
120
122
  minSecondarySize,
123
+ gutterSize,
121
124
  storageKey
122
125
  ]
123
126
  );
@@ -6,6 +6,7 @@ export interface SplitPaneContextValue {
6
6
  setPrimarySize: (newSize: number) => void;
7
7
  minPrimarySize: number;
8
8
  minSecondarySize: number;
9
+ gutterSize: number;
9
10
  containerRef: React.RefObject<HTMLDivElement | null>;
10
11
  storageKey?: string;
11
12
  resetDefault: () => void;
@@ -18,7 +19,8 @@ type SplitPaneProviderProps = {
18
19
  initialPrimarySize: number;
19
20
  minPrimarySize: number;
20
21
  minSecondarySize: number;
22
+ gutterSize: number;
21
23
  storageKey?: string;
22
24
  };
23
- export declare function SplitPaneProvider({ children, direction, initialPrimarySize, minPrimarySize, minSecondarySize, storageKey, }: SplitPaneProviderProps): React.ReactNode;
25
+ export declare function SplitPaneProvider({ children, direction, initialPrimarySize, minPrimarySize, minSecondarySize, gutterSize, storageKey, }: SplitPaneProviderProps): React.ReactNode;
24
26
  export {};
@@ -38,6 +38,7 @@ function SplitPaneProvider({
38
38
  initialPrimarySize,
39
39
  minPrimarySize,
40
40
  minSecondarySize,
41
+ gutterSize,
41
42
  storageKey
42
43
  }) {
43
44
  const containerRef = useRef(null);
@@ -49,8 +50,8 @@ function SplitPaneProvider({
49
50
  const rect = el.getBoundingClientRect();
50
51
  const total = direction === "horizontal" ? rect.width : rect.height;
51
52
  if (!Number.isFinite(total) || total <= 0) return null;
52
- return Math.max(minPrimarySize, total - minSecondarySize);
53
- }, [direction, minPrimarySize, minSecondarySize]);
53
+ return Math.max(minPrimarySize, total - minSecondarySize - gutterSize);
54
+ }, [direction, gutterSize, minPrimarySize, minSecondarySize]);
54
55
  useEffect(() => {
55
56
  if (!storageKey) return;
56
57
  const stored = readStoredSize(storageKey);
@@ -102,6 +103,7 @@ function SplitPaneProvider({
102
103
  resetDefault,
103
104
  minPrimarySize,
104
105
  minSecondarySize,
106
+ gutterSize,
105
107
  containerRef,
106
108
  storageKey
107
109
  }),
@@ -112,6 +114,7 @@ function SplitPaneProvider({
112
114
  resetDefault,
113
115
  minPrimarySize,
114
116
  minSecondarySize,
117
+ gutterSize,
115
118
  storageKey
116
119
  ]
117
120
  );
@@ -16,12 +16,17 @@ function TreeNodeRow({
16
16
  wrapLabels,
17
17
  isExpanded,
18
18
  toggle,
19
+ expand,
20
+ expandOnSelect,
19
21
  resolvedChildren,
20
22
  loadingIds,
21
23
  errorIds,
22
24
  requestLoad,
23
25
  loadChildren,
24
26
  selectedId,
27
+ currentId,
28
+ focusedId,
29
+ onFocusNode,
25
30
  onSelectNode,
26
31
  renderLabel
27
32
  }) {
@@ -33,6 +38,7 @@ function TreeNodeRow({
33
38
  const loading = loadingIds.has(node.id);
34
39
  const error = errorIds[node.id];
35
40
  const selected = selectedId !== void 0 && selectedId === node.id;
41
+ const current = currentId !== void 0 && currentId === node.id;
36
42
  react.useEffect(() => {
37
43
  if (expanded && children === void 0 && !loading && !error && loadChildren) {
38
44
  requestLoad(node);
@@ -44,38 +50,67 @@ function TreeNodeRow({
44
50
  role: "treeitem",
45
51
  "aria-expanded": expandable ? expanded : void 0,
46
52
  "aria-selected": selected,
53
+ "aria-current": current ? "page" : void 0,
54
+ "aria-level": depth + 1,
55
+ "data-tree-node-id": node.id,
47
56
  style: { ["--tree-depth"]: depth },
48
57
  children: [
49
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: styles__default.default.row, "data-selected": selected || void 0, children: [
50
- /* @__PURE__ */ jsxRuntime.jsx(
51
- "button",
52
- {
53
- type: "button",
54
- className: styles__default.default.toggle,
55
- onClick: () => expandable && toggle(node.id),
56
- disabled: !expandable,
57
- "aria-hidden": !expandable,
58
- tabIndex: expandable ? 0 : -1,
59
- children: loading ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: `spin ${styles__default.default.spinner}`, children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.LoaderCircle, { "aria-hidden": "true" }) }) : expandable ? (
60
- // A resolved node's chevron means "this has children"; an
61
- // unresolved lazy node's dimmer chevron means "might have
62
- // children, click to check" - we genuinely don't know yet,
63
- // and shouldn't visually assert otherwise.
64
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: isResolved ? void 0 : styles__default.default.toggleUnresolved, children: expanded ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.ChevronDown, { "aria-hidden": "true" }) : /* @__PURE__ */ jsxRuntime.jsx(lucideReact.ChevronRight, { "aria-hidden": "true" }) })
65
- ) : /* @__PURE__ */ jsxRuntime.jsx("span", { className: styles__default.default.leafSpacer, "aria-hidden": "true" })
66
- }
67
- ),
68
- /* @__PURE__ */ jsxRuntime.jsxs("button", { type: "button", className: styles__default.default.labelButton, onClick: () => onSelectNode == null ? void 0 : onSelectNode(node), children: [
69
- node.icon ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: styles__default.default.icon, "aria-hidden": "true", children: node.icon }) : null,
70
- /* @__PURE__ */ jsxRuntime.jsx(
71
- "span",
72
- {
73
- className: [styles__default.default.label, wrapLabels ? styles__default.default.labelWrap : styles__default.default.labelTruncate].filter(Boolean).join(" "),
74
- children: renderLabel ? renderLabel(node) : node.label
75
- }
76
- )
77
- ] })
78
- ] }),
58
+ /* @__PURE__ */ jsxRuntime.jsxs(
59
+ "div",
60
+ {
61
+ className: styles__default.default.row,
62
+ "data-tree-row": "true",
63
+ "data-selected": selected || void 0,
64
+ "data-current": current || void 0,
65
+ children: [
66
+ /* @__PURE__ */ jsxRuntime.jsx(
67
+ "button",
68
+ {
69
+ type: "button",
70
+ className: styles__default.default.toggle,
71
+ "data-tree-toggle": "true",
72
+ onClick: () => expandable && toggle(node.id),
73
+ disabled: !expandable,
74
+ "aria-hidden": !expandable,
75
+ tabIndex: -1,
76
+ "aria-label": expanded ? "Luk relationer" : "Vis relationer",
77
+ children: loading ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: `spin ${styles__default.default.spinner}`, children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.LoaderCircle, { "aria-hidden": "true" }) }) : expandable ? (
78
+ // A resolved node's chevron means "this has children"; an
79
+ // unresolved lazy node's dimmer chevron means "might have
80
+ // children, click to check" - we genuinely don't know yet,
81
+ // and shouldn't visually assert otherwise.
82
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: isResolved ? void 0 : styles__default.default.toggleUnresolved, children: expanded ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.ChevronDown, { "aria-hidden": "true" }) : /* @__PURE__ */ jsxRuntime.jsx(lucideReact.ChevronRight, { "aria-hidden": "true" }) })
83
+ ) : /* @__PURE__ */ jsxRuntime.jsx("span", { className: styles__default.default.leafSpacer, "aria-hidden": "true" })
84
+ }
85
+ ),
86
+ /* @__PURE__ */ jsxRuntime.jsxs(
87
+ "button",
88
+ {
89
+ type: "button",
90
+ className: styles__default.default.labelButton,
91
+ "data-tree-focus-target": "true",
92
+ "data-tree-focus-node-id": node.id,
93
+ tabIndex: focusedId === node.id ? 0 : -1,
94
+ onFocus: () => onFocusNode(node.id),
95
+ onClick: () => {
96
+ if (expandOnSelect && expandable) expand(node.id);
97
+ onSelectNode == null ? void 0 : onSelectNode(node);
98
+ },
99
+ children: [
100
+ node.icon ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: styles__default.default.icon, "aria-hidden": "true", children: node.icon }) : null,
101
+ /* @__PURE__ */ jsxRuntime.jsx(
102
+ "span",
103
+ {
104
+ className: [styles__default.default.label, wrapLabels ? styles__default.default.labelWrap : styles__default.default.labelTruncate].filter(Boolean).join(" "),
105
+ children: renderLabel ? renderLabel(node) : node.label
106
+ }
107
+ )
108
+ ]
109
+ }
110
+ )
111
+ ]
112
+ }
113
+ ),
79
114
  error ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: styles__default.default.errorRow, children: [
80
115
  /* @__PURE__ */ jsxRuntime.jsx("span", { children: error }),
81
116
  /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", className: styles__default.default.retryButton, onClick: () => requestLoad(node), children: "Pr\xF8v igen" })
@@ -88,12 +123,17 @@ function TreeNodeRow({
88
123
  wrapLabels,
89
124
  isExpanded,
90
125
  toggle,
126
+ expand,
127
+ expandOnSelect,
91
128
  resolvedChildren,
92
129
  loadingIds,
93
130
  errorIds,
94
131
  requestLoad,
95
132
  loadChildren,
96
133
  selectedId,
134
+ currentId,
135
+ focusedId,
136
+ onFocusNode,
97
137
  onSelectNode,
98
138
  renderLabel
99
139
  },
@@ -6,14 +6,19 @@ type TreeNodeRowProps<T> = {
6
6
  wrapLabels: boolean;
7
7
  isExpanded: (id: string) => boolean;
8
8
  toggle: (id: string) => void;
9
+ expand: (id: string) => void;
10
+ expandOnSelect: boolean;
9
11
  resolvedChildren: Record<string, TreeNode<T>[]>;
10
12
  loadingIds: Set<string>;
11
13
  errorIds: Record<string, ReactNode>;
12
14
  requestLoad: (node: TreeNode<T>) => void;
13
15
  loadChildren?: LoadTreeChildren<T>;
14
16
  selectedId?: string;
17
+ currentId?: string;
18
+ focusedId?: string;
19
+ onFocusNode: (id: string) => void;
15
20
  onSelectNode?: (node: TreeNode<T>) => void;
16
21
  renderLabel?: (node: TreeNode<T>) => ReactNode;
17
22
  };
18
- export declare function TreeNodeRow<T>({ node, depth, wrapLabels, isExpanded, toggle, resolvedChildren, loadingIds, errorIds, requestLoad, loadChildren, selectedId, onSelectNode, renderLabel, }: TreeNodeRowProps<T>): JSX.Element;
23
+ export declare function TreeNodeRow<T>({ node, depth, wrapLabels, isExpanded, toggle, expand, expandOnSelect, resolvedChildren, loadingIds, errorIds, requestLoad, loadChildren, selectedId, currentId, focusedId, onFocusNode, onSelectNode, renderLabel, }: TreeNodeRowProps<T>): JSX.Element;
19
24
  export {};
@@ -10,12 +10,17 @@ function TreeNodeRow({
10
10
  wrapLabels,
11
11
  isExpanded,
12
12
  toggle,
13
+ expand,
14
+ expandOnSelect,
13
15
  resolvedChildren,
14
16
  loadingIds,
15
17
  errorIds,
16
18
  requestLoad,
17
19
  loadChildren,
18
20
  selectedId,
21
+ currentId,
22
+ focusedId,
23
+ onFocusNode,
19
24
  onSelectNode,
20
25
  renderLabel
21
26
  }) {
@@ -27,6 +32,7 @@ function TreeNodeRow({
27
32
  const loading = loadingIds.has(node.id);
28
33
  const error = errorIds[node.id];
29
34
  const selected = selectedId !== void 0 && selectedId === node.id;
35
+ const current = currentId !== void 0 && currentId === node.id;
30
36
  useEffect(() => {
31
37
  if (expanded && children === void 0 && !loading && !error && loadChildren) {
32
38
  requestLoad(node);
@@ -38,38 +44,67 @@ function TreeNodeRow({
38
44
  role: "treeitem",
39
45
  "aria-expanded": expandable ? expanded : void 0,
40
46
  "aria-selected": selected,
47
+ "aria-current": current ? "page" : void 0,
48
+ "aria-level": depth + 1,
49
+ "data-tree-node-id": node.id,
41
50
  style: { ["--tree-depth"]: depth },
42
51
  children: [
43
- /* @__PURE__ */ jsxs("div", { className: styles.row, "data-selected": selected || void 0, children: [
44
- /* @__PURE__ */ jsx(
45
- "button",
46
- {
47
- type: "button",
48
- className: styles.toggle,
49
- onClick: () => expandable && toggle(node.id),
50
- disabled: !expandable,
51
- "aria-hidden": !expandable,
52
- tabIndex: expandable ? 0 : -1,
53
- children: loading ? /* @__PURE__ */ jsx("span", { className: `spin ${styles.spinner}`, children: /* @__PURE__ */ jsx(LoaderCircle, { "aria-hidden": "true" }) }) : expandable ? (
54
- // A resolved node's chevron means "this has children"; an
55
- // unresolved lazy node's dimmer chevron means "might have
56
- // children, click to check" - we genuinely don't know yet,
57
- // and shouldn't visually assert otherwise.
58
- /* @__PURE__ */ jsx("span", { className: isResolved ? void 0 : styles.toggleUnresolved, children: expanded ? /* @__PURE__ */ jsx(ChevronDown, { "aria-hidden": "true" }) : /* @__PURE__ */ jsx(ChevronRight, { "aria-hidden": "true" }) })
59
- ) : /* @__PURE__ */ jsx("span", { className: styles.leafSpacer, "aria-hidden": "true" })
60
- }
61
- ),
62
- /* @__PURE__ */ jsxs("button", { type: "button", className: styles.labelButton, onClick: () => onSelectNode == null ? void 0 : onSelectNode(node), children: [
63
- node.icon ? /* @__PURE__ */ jsx("span", { className: styles.icon, "aria-hidden": "true", children: node.icon }) : null,
64
- /* @__PURE__ */ jsx(
65
- "span",
66
- {
67
- className: [styles.label, wrapLabels ? styles.labelWrap : styles.labelTruncate].filter(Boolean).join(" "),
68
- children: renderLabel ? renderLabel(node) : node.label
69
- }
70
- )
71
- ] })
72
- ] }),
52
+ /* @__PURE__ */ jsxs(
53
+ "div",
54
+ {
55
+ className: styles.row,
56
+ "data-tree-row": "true",
57
+ "data-selected": selected || void 0,
58
+ "data-current": current || void 0,
59
+ children: [
60
+ /* @__PURE__ */ jsx(
61
+ "button",
62
+ {
63
+ type: "button",
64
+ className: styles.toggle,
65
+ "data-tree-toggle": "true",
66
+ onClick: () => expandable && toggle(node.id),
67
+ disabled: !expandable,
68
+ "aria-hidden": !expandable,
69
+ tabIndex: -1,
70
+ "aria-label": expanded ? "Luk relationer" : "Vis relationer",
71
+ children: loading ? /* @__PURE__ */ jsx("span", { className: `spin ${styles.spinner}`, children: /* @__PURE__ */ jsx(LoaderCircle, { "aria-hidden": "true" }) }) : expandable ? (
72
+ // A resolved node's chevron means "this has children"; an
73
+ // unresolved lazy node's dimmer chevron means "might have
74
+ // children, click to check" - we genuinely don't know yet,
75
+ // and shouldn't visually assert otherwise.
76
+ /* @__PURE__ */ jsx("span", { className: isResolved ? void 0 : styles.toggleUnresolved, children: expanded ? /* @__PURE__ */ jsx(ChevronDown, { "aria-hidden": "true" }) : /* @__PURE__ */ jsx(ChevronRight, { "aria-hidden": "true" }) })
77
+ ) : /* @__PURE__ */ jsx("span", { className: styles.leafSpacer, "aria-hidden": "true" })
78
+ }
79
+ ),
80
+ /* @__PURE__ */ jsxs(
81
+ "button",
82
+ {
83
+ type: "button",
84
+ className: styles.labelButton,
85
+ "data-tree-focus-target": "true",
86
+ "data-tree-focus-node-id": node.id,
87
+ tabIndex: focusedId === node.id ? 0 : -1,
88
+ onFocus: () => onFocusNode(node.id),
89
+ onClick: () => {
90
+ if (expandOnSelect && expandable) expand(node.id);
91
+ onSelectNode == null ? void 0 : onSelectNode(node);
92
+ },
93
+ children: [
94
+ node.icon ? /* @__PURE__ */ jsx("span", { className: styles.icon, "aria-hidden": "true", children: node.icon }) : null,
95
+ /* @__PURE__ */ jsx(
96
+ "span",
97
+ {
98
+ className: [styles.label, wrapLabels ? styles.labelWrap : styles.labelTruncate].filter(Boolean).join(" "),
99
+ children: renderLabel ? renderLabel(node) : node.label
100
+ }
101
+ )
102
+ ]
103
+ }
104
+ )
105
+ ]
106
+ }
107
+ ),
73
108
  error ? /* @__PURE__ */ jsxs("div", { className: styles.errorRow, children: [
74
109
  /* @__PURE__ */ jsx("span", { children: error }),
75
110
  /* @__PURE__ */ jsx("button", { type: "button", className: styles.retryButton, onClick: () => requestLoad(node), children: "Pr\xF8v igen" })
@@ -82,12 +117,17 @@ function TreeNodeRow({
82
117
  wrapLabels,
83
118
  isExpanded,
84
119
  toggle,
120
+ expand,
121
+ expandOnSelect,
85
122
  resolvedChildren,
86
123
  loadingIds,
87
124
  errorIds,
88
125
  requestLoad,
89
126
  loadChildren,
90
127
  selectedId,
128
+ currentId,
129
+ focusedId,
130
+ onFocusNode,
91
131
  onSelectNode,
92
132
  renderLabel
93
133
  },
@@ -14,19 +14,29 @@ var styles__default = /*#__PURE__*/_interopDefault(styles);
14
14
 
15
15
  function TreeView({
16
16
  data,
17
+ size = "sm",
17
18
  loadChildren,
18
19
  defaultExpandedIds,
19
20
  expandedIds,
20
21
  onExpandedIdsChange,
21
22
  selectedId,
23
+ currentId,
22
24
  onSelectNode,
25
+ expandOnSelect = false,
23
26
  renderLabel,
24
27
  wrapLabels = true,
25
28
  emptyMessage = "Intet at vise.",
26
29
  loadErrorMessage = () => "Der skete en fejl under indl\xE6sning.",
27
30
  className,
31
+ "aria-label": ariaLabel,
28
32
  ...rest
29
33
  }) {
34
+ const [focusedId, setFocusedId] = react.useState(
35
+ () => {
36
+ var _a, _b;
37
+ return (_b = currentId != null ? currentId : selectedId) != null ? _b : (_a = data[0]) == null ? void 0 : _a.id;
38
+ }
39
+ );
30
40
  const { isExpanded, toggle, expand } = useTreeExpansion.useTreeExpansion(
31
41
  defaultExpandedIds,
32
42
  expandedIds,
@@ -60,28 +70,106 @@ function TreeView({
60
70
  },
61
71
  [loadChildren, loadingIds, expand, loadErrorMessage]
62
72
  );
73
+ const handleTreeKeyDown = react.useCallback((event) => {
74
+ var _a, _b, _c, _d;
75
+ const target = event.target;
76
+ const focusedLabel = target.closest('[data-tree-focus-target="true"]');
77
+ if (!focusedLabel) return;
78
+ const visibleItems = Array.from(
79
+ event.currentTarget.querySelectorAll('[data-tree-focus-target="true"]')
80
+ );
81
+ const treeItem = focusedLabel.closest('[role="treeitem"]');
82
+ if (!treeItem) return;
83
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
84
+ const index = visibleItems.indexOf(focusedLabel);
85
+ const nextIndex = Math.max(
86
+ 0,
87
+ Math.min(index + (event.key === "ArrowDown" ? 1 : -1), visibleItems.length - 1)
88
+ );
89
+ if (nextIndex !== index) {
90
+ event.preventDefault();
91
+ (_a = visibleItems[nextIndex]) == null ? void 0 : _a.focus();
92
+ }
93
+ return;
94
+ }
95
+ if (event.key === "ArrowRight") {
96
+ const expanded = treeItem.getAttribute("aria-expanded") === "true";
97
+ if (treeItem.getAttribute("aria-expanded") === "false") {
98
+ event.preventDefault();
99
+ (_b = treeItem.querySelector('[data-tree-toggle="true"]')) == null ? void 0 : _b.click();
100
+ } else if (expanded) {
101
+ const firstChild = treeItem.querySelector(
102
+ ':scope > [role="group"] [data-tree-focus-target="true"]'
103
+ );
104
+ if (firstChild) {
105
+ event.preventDefault();
106
+ firstChild.focus();
107
+ }
108
+ }
109
+ return;
110
+ }
111
+ if (event.key === "ArrowLeft") {
112
+ if (treeItem.getAttribute("aria-expanded") === "true") {
113
+ event.preventDefault();
114
+ (_c = treeItem.querySelector('[data-tree-toggle="true"]')) == null ? void 0 : _c.click();
115
+ return;
116
+ }
117
+ const parentTreeItem = (_d = treeItem.parentElement) == null ? void 0 : _d.closest('[role="treeitem"]');
118
+ const parentLabel = parentTreeItem == null ? void 0 : parentTreeItem.querySelector(
119
+ ':scope > [data-tree-row] [data-tree-focus-target="true"]'
120
+ );
121
+ if (parentLabel) {
122
+ event.preventDefault();
123
+ parentLabel.focus();
124
+ }
125
+ }
126
+ }, []);
63
127
  if (data.length === 0) {
64
- return /* @__PURE__ */ jsxRuntime.jsx("div", { className: [styles__default.default.emptyState, className].filter(Boolean).join(" "), ...rest, children: emptyMessage });
128
+ return /* @__PURE__ */ jsxRuntime.jsx(
129
+ "div",
130
+ {
131
+ className: [styles__default.default.emptyState, className].filter(Boolean).join(" "),
132
+ "aria-label": ariaLabel,
133
+ ...rest,
134
+ children: emptyMessage
135
+ }
136
+ );
65
137
  }
66
- return /* @__PURE__ */ jsxRuntime.jsx("div", { role: "tree", className: [styles__default.default.tree, className].filter(Boolean).join(" "), ...rest, children: data.map((node) => /* @__PURE__ */ jsxRuntime.jsx(
67
- TreeNodeRow.TreeNodeRow,
138
+ return /* @__PURE__ */ jsxRuntime.jsx(
139
+ "div",
68
140
  {
69
- node,
70
- depth: 0,
71
- wrapLabels,
72
- isExpanded,
73
- toggle,
74
- resolvedChildren,
75
- loadingIds,
76
- errorIds,
77
- requestLoad,
78
- loadChildren,
79
- selectedId,
80
- onSelectNode,
81
- renderLabel
82
- },
83
- node.id
84
- )) });
141
+ role: "tree",
142
+ "data-size": size,
143
+ "aria-label": ariaLabel,
144
+ className: [styles__default.default.tree, className].filter(Boolean).join(" "),
145
+ onKeyDown: handleTreeKeyDown,
146
+ ...rest,
147
+ children: data.map((node) => /* @__PURE__ */ jsxRuntime.jsx(
148
+ TreeNodeRow.TreeNodeRow,
149
+ {
150
+ node,
151
+ depth: 0,
152
+ wrapLabels,
153
+ isExpanded,
154
+ toggle,
155
+ expand,
156
+ expandOnSelect,
157
+ resolvedChildren,
158
+ loadingIds,
159
+ errorIds,
160
+ requestLoad,
161
+ loadChildren,
162
+ selectedId,
163
+ currentId,
164
+ focusedId,
165
+ onFocusNode: setFocusedId,
166
+ onSelectNode,
167
+ renderLabel
168
+ },
169
+ node.id
170
+ ))
171
+ }
172
+ );
85
173
  }
86
174
 
87
175
  Object.defineProperty(exports, "collectExpandableIds", {
@@ -17,4 +17,4 @@ export { collectExpandableIds, findAncestorIds } from './utils';
17
17
  * useTreeExpansion), and it can be left uncontrolled or owned by the
18
18
  * caller via `expandedIds`/`onExpandedIdsChange`.
19
19
  */
20
- export declare function TreeView<T = unknown>({ data, loadChildren, defaultExpandedIds, expandedIds, onExpandedIdsChange, selectedId, onSelectNode, renderLabel, wrapLabels, emptyMessage, loadErrorMessage, className, ...rest }: TreeViewProps<T>): JSX.Element;
20
+ export declare function TreeView<T = unknown>({ data, size, loadChildren, defaultExpandedIds, expandedIds, onExpandedIdsChange, selectedId, currentId, onSelectNode, expandOnSelect, renderLabel, wrapLabels, emptyMessage, loadErrorMessage, className, 'aria-label': ariaLabel, ...rest }: TreeViewProps<T>): JSX.Element;
@@ -8,19 +8,29 @@ export { collectExpandableIds, findAncestorIds } from './utils';
8
8
 
9
9
  function TreeView({
10
10
  data,
11
+ size = "sm",
11
12
  loadChildren,
12
13
  defaultExpandedIds,
13
14
  expandedIds,
14
15
  onExpandedIdsChange,
15
16
  selectedId,
17
+ currentId,
16
18
  onSelectNode,
19
+ expandOnSelect = false,
17
20
  renderLabel,
18
21
  wrapLabels = true,
19
22
  emptyMessage = "Intet at vise.",
20
23
  loadErrorMessage = () => "Der skete en fejl under indl\xE6sning.",
21
24
  className,
25
+ "aria-label": ariaLabel,
22
26
  ...rest
23
27
  }) {
28
+ const [focusedId, setFocusedId] = useState(
29
+ () => {
30
+ var _a, _b;
31
+ return (_b = currentId != null ? currentId : selectedId) != null ? _b : (_a = data[0]) == null ? void 0 : _a.id;
32
+ }
33
+ );
24
34
  const { isExpanded, toggle, expand } = useTreeExpansion(
25
35
  defaultExpandedIds,
26
36
  expandedIds,
@@ -54,28 +64,106 @@ function TreeView({
54
64
  },
55
65
  [loadChildren, loadingIds, expand, loadErrorMessage]
56
66
  );
67
+ const handleTreeKeyDown = useCallback((event) => {
68
+ var _a, _b, _c, _d;
69
+ const target = event.target;
70
+ const focusedLabel = target.closest('[data-tree-focus-target="true"]');
71
+ if (!focusedLabel) return;
72
+ const visibleItems = Array.from(
73
+ event.currentTarget.querySelectorAll('[data-tree-focus-target="true"]')
74
+ );
75
+ const treeItem = focusedLabel.closest('[role="treeitem"]');
76
+ if (!treeItem) return;
77
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
78
+ const index = visibleItems.indexOf(focusedLabel);
79
+ const nextIndex = Math.max(
80
+ 0,
81
+ Math.min(index + (event.key === "ArrowDown" ? 1 : -1), visibleItems.length - 1)
82
+ );
83
+ if (nextIndex !== index) {
84
+ event.preventDefault();
85
+ (_a = visibleItems[nextIndex]) == null ? void 0 : _a.focus();
86
+ }
87
+ return;
88
+ }
89
+ if (event.key === "ArrowRight") {
90
+ const expanded = treeItem.getAttribute("aria-expanded") === "true";
91
+ if (treeItem.getAttribute("aria-expanded") === "false") {
92
+ event.preventDefault();
93
+ (_b = treeItem.querySelector('[data-tree-toggle="true"]')) == null ? void 0 : _b.click();
94
+ } else if (expanded) {
95
+ const firstChild = treeItem.querySelector(
96
+ ':scope > [role="group"] [data-tree-focus-target="true"]'
97
+ );
98
+ if (firstChild) {
99
+ event.preventDefault();
100
+ firstChild.focus();
101
+ }
102
+ }
103
+ return;
104
+ }
105
+ if (event.key === "ArrowLeft") {
106
+ if (treeItem.getAttribute("aria-expanded") === "true") {
107
+ event.preventDefault();
108
+ (_c = treeItem.querySelector('[data-tree-toggle="true"]')) == null ? void 0 : _c.click();
109
+ return;
110
+ }
111
+ const parentTreeItem = (_d = treeItem.parentElement) == null ? void 0 : _d.closest('[role="treeitem"]');
112
+ const parentLabel = parentTreeItem == null ? void 0 : parentTreeItem.querySelector(
113
+ ':scope > [data-tree-row] [data-tree-focus-target="true"]'
114
+ );
115
+ if (parentLabel) {
116
+ event.preventDefault();
117
+ parentLabel.focus();
118
+ }
119
+ }
120
+ }, []);
57
121
  if (data.length === 0) {
58
- return /* @__PURE__ */ jsx("div", { className: [styles.emptyState, className].filter(Boolean).join(" "), ...rest, children: emptyMessage });
122
+ return /* @__PURE__ */ jsx(
123
+ "div",
124
+ {
125
+ className: [styles.emptyState, className].filter(Boolean).join(" "),
126
+ "aria-label": ariaLabel,
127
+ ...rest,
128
+ children: emptyMessage
129
+ }
130
+ );
59
131
  }
60
- return /* @__PURE__ */ jsx("div", { role: "tree", className: [styles.tree, className].filter(Boolean).join(" "), ...rest, children: data.map((node) => /* @__PURE__ */ jsx(
61
- TreeNodeRow,
132
+ return /* @__PURE__ */ jsx(
133
+ "div",
62
134
  {
63
- node,
64
- depth: 0,
65
- wrapLabels,
66
- isExpanded,
67
- toggle,
68
- resolvedChildren,
69
- loadingIds,
70
- errorIds,
71
- requestLoad,
72
- loadChildren,
73
- selectedId,
74
- onSelectNode,
75
- renderLabel
76
- },
77
- node.id
78
- )) });
135
+ role: "tree",
136
+ "data-size": size,
137
+ "aria-label": ariaLabel,
138
+ className: [styles.tree, className].filter(Boolean).join(" "),
139
+ onKeyDown: handleTreeKeyDown,
140
+ ...rest,
141
+ children: data.map((node) => /* @__PURE__ */ jsx(
142
+ TreeNodeRow,
143
+ {
144
+ node,
145
+ depth: 0,
146
+ wrapLabels,
147
+ isExpanded,
148
+ toggle,
149
+ expand,
150
+ expandOnSelect,
151
+ resolvedChildren,
152
+ loadingIds,
153
+ errorIds,
154
+ requestLoad,
155
+ loadChildren,
156
+ selectedId,
157
+ currentId,
158
+ focusedId,
159
+ onFocusNode: setFocusedId,
160
+ onSelectNode,
161
+ renderLabel
162
+ },
163
+ node.id
164
+ ))
165
+ }
166
+ );
79
167
  }
80
168
 
81
169
  export { TreeView };
@@ -21,8 +21,44 @@
21
21
  border-radius: var(--border-radius-default);
22
22
  }
23
23
 
24
+ .tree[data-size='md'] .row {
25
+ min-block-size: calc(var(--spacing-md) + var(--spacing-sm));
26
+ padding-inline-start: 0;
27
+ padding-inline-end: calc(var(--spacing-xs) - var(--spacing-2xs));
28
+ }
29
+
30
+ .tree[data-size='md'] .toggle,
31
+ .tree[data-size='md'] .labelButton {
32
+ box-sizing: border-box;
33
+ min-block-size: calc(var(--spacing-md) + var(--spacing-sm));
34
+ }
35
+
36
+ .tree[data-size='md'] .toggle {
37
+ justify-content: center;
38
+ align-self: stretch;
39
+ --tree-indent: calc(
40
+ var(--tree-depth) * (var(--spacing-md) + 2 * var(--spacing-2xs)) + var(--spacing-xs) -
41
+ var(--spacing-2xs)
42
+ );
43
+ inline-size: calc(var(--tree-indent) + var(--spacing-lg) + var(--spacing-sm));
44
+ padding-inline-start: var(--tree-indent);
45
+ padding-inline-end: 0;
46
+ }
47
+
48
+ .tree[data-size='md'] .errorRow {
49
+ padding-inline-start: calc(
50
+ (var(--tree-depth) + 1) * (var(--spacing-md) + 2 * var(--spacing-2xs)) + var(--spacing-xs) -
51
+ var(--spacing-2xs)
52
+ );
53
+ }
54
+
24
55
  .row[data-selected='true'] {
25
- background: color-mix(in oklab, var(--color-brand) 16%, transparent);
56
+ background: var(--color-status-info-bg);
57
+ }
58
+
59
+ .row[data-current='true'] {
60
+ background: var(--color-bg-surface-subtle);
61
+ font-weight: 600;
26
62
  }
27
63
 
28
64
  .toggle,
@@ -82,13 +118,21 @@
82
118
  padding-inline: var(--spacing-xs);
83
119
  }
84
120
 
85
- .row:hover .toggle:not(:disabled),
86
- .row:hover .labelButton,
121
+ .row:hover {
122
+ background: var(--color-bg-hover-subtle);
123
+ }
124
+
87
125
  .toggle:focus-visible,
88
126
  .labelButton:focus-visible {
89
127
  background: var(--color-bg-hover-subtle);
90
128
  }
91
129
 
130
+ .toggle:not(:disabled):hover,
131
+ .toggle:not(:disabled):focus-visible {
132
+ background: var(--color-bg-pressed-subtle);
133
+ color: var(--color-fg-default);
134
+ }
135
+
92
136
  .toggle:focus-visible,
93
137
  .labelButton:focus-visible {
94
138
  outline: none;
@@ -23,6 +23,7 @@ export type TreeNode<T = unknown> = {
23
23
  /** Fetches one node's children. Called once per node, the first time it's expanded (and again on retry after a failed load). Presence of this prop on TreeView is what switches it into lazy mode. */
24
24
  export type LoadTreeChildren<T = unknown> = (node: TreeNode<T>) => Promise<TreeNode<T>[]>;
25
25
  export type TreeViewProps<T = unknown> = {
26
+ size?: 'sm' | 'md';
26
27
  /** Top-level nodes. In eager mode every node's full subtree must already be present via `children`. In lazy mode only the nodes you already know about need to be here - deeper levels arrive via `loadChildren`. */
27
28
  data: TreeNode<T>[];
28
29
  /** Omit for eager mode. Provide for lazy mode. */
@@ -33,7 +34,11 @@ export type TreeViewProps<T = unknown> = {
33
34
  expandedIds?: string[];
34
35
  onExpandedIdsChange?: (ids: string[]) => void;
35
36
  selectedId?: string;
37
+ /** Marks the page's current record independently from the preview selection. */
38
+ currentId?: string;
36
39
  onSelectNode?: (node: TreeNode<T>) => void;
40
+ /** Expand branches when their labels are activated, without collapsing already-open branches. */
41
+ expandOnSelect?: boolean;
37
42
  /** Customize how a node's row content renders; defaults to `node.icon` + `node.label`. */
38
43
  renderLabel?: (node: TreeNode<T>) => ReactNode;
39
44
  /** Allows node labels to wrap onto multiple lines. Set to false to truncate them to one line. */
@@ -42,5 +47,6 @@ export type TreeViewProps<T = unknown> = {
42
47
  /** Formats the error shown under a node whose `loadChildren` call rejected. */
43
48
  loadErrorMessage?: (error: unknown, node: TreeNode<T>) => ReactNode;
44
49
  className?: string;
50
+ 'aria-label'?: string;
45
51
  'data-testid'?: string;
46
52
  };
@@ -6,6 +6,10 @@
6
6
  font-weight: var(--font-weight-semibold);
7
7
  }
8
8
 
9
+ .dbc-normal-text {
10
+ font-weight: var(--font-weight-default);
11
+ }
12
+
9
13
  .dbc-prose :is(p, ol, li) {
10
14
  line-height: var(--line-height-relaxed);
11
15
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dbcdk/react-components",
3
- "version": "0.0.208",
3
+ "version": "0.0.209",
4
4
  "description": "Reusable React components for DBC projects",
5
5
  "license": "ISC",
6
6
  "author": "",