@dbcdk/react-components 0.0.12 → 0.0.13

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 (75) hide show
  1. package/dist/components/accordion/Accordion.d.ts +2 -2
  2. package/dist/components/accordion/Accordion.js +34 -41
  3. package/dist/components/accordion/Accordion.module.css +13 -72
  4. package/dist/components/accordion/components/AccordionRow.d.ts +10 -0
  5. package/dist/components/accordion/components/AccordionRow.js +51 -0
  6. package/dist/components/accordion/components/AccordionRow.module.css +82 -0
  7. package/dist/components/breadcrumbs/Breadcrumbs.module.css +0 -1
  8. package/dist/components/button/Button.module.css +7 -7
  9. package/dist/components/card/Card.d.ts +9 -18
  10. package/dist/components/card/Card.js +34 -23
  11. package/dist/components/card/Card.module.css +22 -87
  12. package/dist/components/card/components/CardMeta.d.ts +15 -0
  13. package/dist/components/card/components/CardMeta.js +20 -0
  14. package/dist/components/card/components/CardMeta.module.css +51 -0
  15. package/dist/components/card-container/CardContainer.js +1 -1
  16. package/dist/components/card-container/CardContainer.module.css +3 -1
  17. package/dist/components/chip/Chip.module.css +7 -2
  18. package/dist/components/datetime-picker/DateTimePicker.d.ts +33 -8
  19. package/dist/components/datetime-picker/DateTimePicker.js +119 -78
  20. package/dist/components/datetime-picker/DateTimePicker.module.css +2 -0
  21. package/dist/components/datetime-picker/dateTimeHelpers.d.ts +15 -3
  22. package/dist/components/datetime-picker/dateTimeHelpers.js +137 -23
  23. package/dist/components/filter-field/FilterField.module.css +5 -5
  24. package/dist/components/forms/form-select/FormSelect.d.ts +35 -0
  25. package/dist/components/forms/form-select/FormSelect.js +86 -0
  26. package/dist/components/forms/form-select/FormSelect.module.css +236 -0
  27. package/dist/components/forms/input/Input.d.ts +0 -3
  28. package/dist/components/forms/input/Input.js +0 -3
  29. package/dist/components/forms/input/Input.module.css +7 -7
  30. package/dist/components/forms/radio-buttons/RadioButtons.module.css +1 -0
  31. package/dist/components/forms/select/Select.js +55 -16
  32. package/dist/components/interval-select/IntervalSelect.d.ts +9 -2
  33. package/dist/components/interval-select/IntervalSelect.js +21 -6
  34. package/dist/components/menu/Menu.d.ts +11 -14
  35. package/dist/components/menu/Menu.js +18 -33
  36. package/dist/components/menu/Menu.module.css +2 -2
  37. package/dist/components/overlay/modal/Modal.module.css +2 -1
  38. package/dist/components/overlay/modal/provider/ModalProvider.js +1 -3
  39. package/dist/components/overlay/side-panel/SidePanel.js +1 -1
  40. package/dist/components/overlay/side-panel/SidePanel.module.css +1 -1
  41. package/dist/components/page-layout/PageLayout.d.ts +16 -4
  42. package/dist/components/page-layout/PageLayout.js +57 -28
  43. package/dist/components/page-layout/PageLayout.module.css +153 -33
  44. package/dist/components/popover/Popover.d.ts +17 -4
  45. package/dist/components/popover/Popover.js +147 -65
  46. package/dist/components/popover/Popover.module.css +5 -0
  47. package/dist/components/split-pane/SplitPane.d.ts +10 -24
  48. package/dist/components/split-pane/SplitPane.js +83 -54
  49. package/dist/components/split-pane/SplitPane.module.css +11 -6
  50. package/dist/components/split-pane/provider/SplitPaneContext.js +5 -11
  51. package/dist/components/sticky-footer-layout/StickyFooterLayout.d.ts +3 -8
  52. package/dist/components/sticky-footer-layout/StickyFooterLayout.js +57 -20
  53. package/dist/components/table/Table.d.ts +3 -8
  54. package/dist/components/table/Table.js +37 -76
  55. package/dist/components/table/Table.module.css +45 -42
  56. package/dist/components/table/{tanstack.d.ts → TanstackTable.d.ts} +5 -12
  57. package/dist/components/table/TanstackTable.js +84 -0
  58. package/dist/components/table/components/column-resizer/ColumnResizer.js +1 -1
  59. package/dist/components/table/components/column-resizer/ColumnResizer.module.css +17 -7
  60. package/dist/components/table/table.utils.d.ts +17 -0
  61. package/dist/components/table/table.utils.js +61 -0
  62. package/dist/components/table/tanstackTable.utils.d.ts +22 -0
  63. package/dist/components/table/tanstackTable.utils.js +104 -0
  64. package/dist/components/tabs/Tabs.d.ts +35 -12
  65. package/dist/components/tabs/Tabs.js +114 -26
  66. package/dist/components/tabs/Tabs.module.css +158 -71
  67. package/dist/index.d.ts +1 -1
  68. package/dist/index.js +1 -1
  69. package/dist/src/styles/styles.css +0 -1
  70. package/dist/styles/styles.css +0 -1
  71. package/dist/styles/themes/dbc/base.css +136 -0
  72. package/dist/styles/themes/dbc/dark.css +39 -202
  73. package/dist/styles/themes/dbc/light.css +17 -174
  74. package/package.json +4 -4
  75. package/dist/components/table/tanstack.js +0 -214
@@ -1,12 +1,26 @@
1
1
  import * as React from 'react';
2
- interface PopoverProps {
3
- trigger: (event: (e: React.MouseEvent<HTMLElement> | React.FocusEvent<HTMLElement>) => void, icon: React.ReactNode) => React.ReactNode;
4
- children: ((close?: () => void) => React.ReactNode) | React.ReactNode;
2
+ export interface PopoverProps {
3
+ trigger: (toggle: (e: React.MouseEvent<HTMLElement> | React.FocusEvent<HTMLElement>) => void, icon: React.ReactNode, open?: boolean) => React.ReactNode;
4
+ children: ((close: () => void) => React.ReactNode) | React.ReactNode;
5
+ open?: boolean;
6
+ defaultOpen?: boolean;
7
+ onOpenChange?: (open: boolean) => void;
8
+ contentId?: string;
9
+ /**
10
+ * CSS length, recommended "NNpx" for predictability.
11
+ * Used as a minimum, not as a forced width unless matchTriggerWidth=true.
12
+ */
5
13
  minWidth?: string;
14
+ /**
15
+ * If true, force the overlay width to at least the trigger width.
16
+ * If false, overlay width is content-driven (calendar-friendly).
17
+ */
6
18
  matchTriggerWidth?: boolean;
7
19
  viewportPadding?: number;
8
20
  edgeBuffer?: number;
9
21
  dataCy?: string;
22
+ autoFocusContent?: boolean;
23
+ returnFocus?: boolean;
10
24
  }
11
25
  export interface PopoverHandle {
12
26
  close: () => void;
@@ -14,4 +28,3 @@ export interface PopoverHandle {
14
28
  isOpen: () => boolean;
15
29
  }
16
30
  export declare const Popover: React.ForwardRefExoticComponent<PopoverProps & React.RefAttributes<PopoverHandle>>;
17
- export {};
@@ -1,43 +1,121 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { ChevronDown, ChevronUp } from 'lucide-react';
4
- import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState, } from 'react';
4
+ import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useRef, useState, } from 'react';
5
5
  import { createPortal } from 'react-dom';
6
6
  import styles from './Popover.module.css';
7
- export const Popover = forwardRef(function Popover({ trigger: Trigger, children, minWidth = '200px', matchTriggerWidth = true, viewportPadding = 8, edgeBuffer = 100, dataCy, }, ref) {
8
- const [pos, setPos] = useState({ top: 0, left: 0, width: 0, visible: false });
7
+ function getFocusable(container) {
8
+ const els = container.querySelectorAll([
9
+ 'a[href]',
10
+ 'button:not([disabled])',
11
+ 'input:not([disabled])',
12
+ 'select:not([disabled])',
13
+ 'textarea:not([disabled])',
14
+ '[tabindex]:not([tabindex="-1"])',
15
+ ].join(','));
16
+ return Array.from(els).filter(el => !el.hasAttribute('disabled') && !el.getAttribute('aria-hidden'));
17
+ }
18
+ function parseMinWidthPx(minWidth, elForEm) {
19
+ const v = minWidth.trim();
20
+ if (v.endsWith('px')) {
21
+ const n = Number.parseFloat(v);
22
+ return Number.isFinite(n) ? n : 0;
23
+ }
24
+ if (typeof window !== 'undefined' && v.endsWith('rem')) {
25
+ const n = Number.parseFloat(v);
26
+ if (!Number.isFinite(n))
27
+ return 0;
28
+ const rootFont = Number.parseFloat(getComputedStyle(document.documentElement).fontSize || '16');
29
+ return n * (Number.isFinite(rootFont) ? rootFont : 16);
30
+ }
31
+ if (typeof window !== 'undefined' && v.endsWith('em')) {
32
+ const n = Number.parseFloat(v);
33
+ if (!Number.isFinite(n))
34
+ return 0;
35
+ const font = elForEm ? Number.parseFloat(getComputedStyle(elForEm).fontSize || '16') : 16;
36
+ return n * (Number.isFinite(font) ? font : 16);
37
+ }
38
+ return 0;
39
+ }
40
+ export const Popover = forwardRef(function Popover({ trigger: Trigger, children, open, defaultOpen = false, onOpenChange, contentId, minWidth = '200px', matchTriggerWidth = true, viewportPadding = 8, edgeBuffer = 100, dataCy, autoFocusContent = false, returnFocus = true, }, ref) {
41
+ const internalId = useId();
42
+ const resolvedContentId = contentId !== null && contentId !== void 0 ? contentId : `popover-${internalId}`;
43
+ const isControlled = open !== undefined;
44
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
45
+ const isOpen = isControlled ? !!open : uncontrolledOpen;
46
+ const [pos, setPos] = useState({ top: 0, left: 0 });
47
+ const [positioned, setPositioned] = useState(false);
48
+ const [triggerWidth, setTriggerWidth] = useState(null);
9
49
  const containerRef = useRef(null);
10
50
  const contentRef = useRef(null);
11
- // avoid SSR/hydration mismatch
51
+ const triggerElRef = useRef(null);
52
+ const lastCloseReasonRef = useRef('unknown');
12
53
  const [mounted, setMounted] = useState(false);
13
54
  useEffect(() => setMounted(true), []);
14
- const computeAndSetPosition = useCallback((show) => {
15
- const container = containerRef.current;
55
+ const setOpen = useCallback((next) => {
56
+ if (!isControlled)
57
+ setUncontrolledOpen(next);
58
+ onOpenChange === null || onOpenChange === void 0 ? void 0 : onOpenChange(next);
59
+ }, [isControlled, onOpenChange]);
60
+ const openPopover = useCallback(() => {
61
+ setPositioned(false);
62
+ setOpen(true);
63
+ }, [setOpen]);
64
+ const closePopover = useCallback((reason = 'api') => {
65
+ lastCloseReasonRef.current = reason;
66
+ setOpen(false);
67
+ }, [setOpen]);
68
+ const togglePopover = useCallback((e) => {
69
+ triggerElRef.current = e.currentTarget;
70
+ if (isOpen)
71
+ closePopover('trigger');
72
+ else
73
+ openPopover();
74
+ }, [isOpen, closePopover, openPopover]);
75
+ useImperativeHandle(ref, () => ({
76
+ close: () => closePopover('api'),
77
+ open: openPopover,
78
+ isOpen: () => isOpen,
79
+ }), [closePopover, openPopover, isOpen]);
80
+ const computeAndSetPosition = useCallback(() => {
81
+ var _a;
16
82
  const content = contentRef.current;
17
- if (!container || !content)
83
+ if (!content)
84
+ return;
85
+ const triggerEl = (_a = triggerElRef.current) !== null && _a !== void 0 ? _a : containerRef.current;
86
+ if (!triggerEl)
18
87
  return;
19
- const triggerRect = container.getBoundingClientRect();
20
- // Temporarily measure content size by forcing it into the layout.
88
+ const triggerRect = triggerEl.getBoundingClientRect();
89
+ // Only compute a forced width when requested.
90
+ let forcedWidthPx = null;
91
+ if (matchTriggerWidth) {
92
+ const minWidthPx = parseMinWidthPx(minWidth, triggerEl);
93
+ forcedWidthPx = Math.max(triggerRect.width, minWidthPx || 0);
94
+ setTriggerWidth(forcedWidthPx);
95
+ }
96
+ else {
97
+ setTriggerWidth(null);
98
+ }
99
+ // Measure height/width for collision using a temporary sizing that reflects our final sizing:
100
+ const prevHidden = content.hidden;
21
101
  const prevVis = content.style.visibility;
22
102
  const prevDisp = content.style.display;
23
103
  const prevMinWidth = content.style.minWidth;
24
104
  const prevWidth = content.style.width;
25
105
  const prevTop = content.style.top;
26
106
  const prevLeft = content.style.left;
107
+ content.hidden = false;
27
108
  content.style.visibility = 'hidden';
28
109
  content.style.display = 'block';
29
110
  content.style.top = '0px';
30
111
  content.style.left = '0px';
112
+ // Apply minWidth always; apply width only if matchTriggerWidth.
31
113
  content.style.minWidth = minWidth;
32
- content.style.width = 'auto';
33
- const minWidthPx = content.offsetWidth;
34
- const desiredWidthPx = Math.max(matchTriggerWidth ? triggerRect.width : 0, minWidthPx);
35
- // Apply desired width and re-measure final size (height may depend on width).
36
- content.style.minWidth = `${desiredWidthPx}px`;
37
- content.style.width = `${desiredWidthPx}px`;
114
+ content.style.width = forcedWidthPx != null ? `${forcedWidthPx}px` : 'auto';
38
115
  const contentWidth = content.offsetWidth;
39
116
  const contentHeight = content.offsetHeight;
40
- // Restore previous inline styles
117
+ // Restore
118
+ content.hidden = prevHidden;
41
119
  content.style.visibility = prevVis;
42
120
  content.style.display = prevDisp;
43
121
  content.style.minWidth = prevMinWidth;
@@ -58,80 +136,84 @@ export const Popover = forwardRef(function Popover({ trigger: Trigger, children,
58
136
  const placeRightOfLeftEdge = spaceRight >= contentWidth + edgeBuffer;
59
137
  const placeLeftOfRightEdge = spaceLeft >= contentWidth + edgeBuffer;
60
138
  let rawLeft;
61
- if (placeRightOfLeftEdge) {
62
- rawLeft = triggerRect.left; // align left edges
63
- }
64
- else if (placeLeftOfRightEdge) {
65
- rawLeft = triggerRect.right - contentWidth; // align right edges
66
- }
67
- else {
139
+ if (placeRightOfLeftEdge)
140
+ rawLeft = triggerRect.left;
141
+ else if (placeLeftOfRightEdge)
142
+ rawLeft = triggerRect.right - contentWidth;
143
+ else
68
144
  rawLeft = triggerRect.left + (triggerRect.width - contentWidth) / 2;
69
- }
70
145
  const clampedLeft = Math.max(viewportPadding, Math.min(rawLeft, vw - contentWidth - viewportPadding));
71
146
  const clampedTop = Math.max(viewportPadding, Math.min(rawTop, vh - contentHeight - viewportPadding));
72
- setPos({ top: clampedTop, left: clampedLeft, width: desiredWidthPx, visible: show });
147
+ setPos({ top: clampedTop, left: clampedLeft });
148
+ setPositioned(true);
73
149
  }, [edgeBuffer, viewportPadding, minWidth, matchTriggerWidth]);
74
- const openPopover = useCallback((e) => {
75
- if (pos.visible) {
76
- setPos(p => ({ ...p, visible: false }));
77
- return;
78
- }
79
- computeAndSetPosition(true);
80
- e === null || e === void 0 ? void 0 : e.stopPropagation();
81
- }, [pos.visible, computeAndSetPosition]);
82
- const closePopover = useCallback(() => {
83
- setPos(p => ({ ...p, visible: false }));
84
- }, []);
85
- useImperativeHandle(ref, () => ({
86
- close: closePopover,
87
- open: () => computeAndSetPosition(true),
88
- isOpen: () => !!pos.visible,
89
- }), [closePopover, computeAndSetPosition, pos.visible]);
90
- // Recompute position after open to account for content becoming visible / measured.
91
150
  useLayoutEffect(() => {
92
- if (pos.visible)
93
- computeAndSetPosition(true);
151
+ if (!isOpen)
152
+ return;
153
+ computeAndSetPosition();
94
154
  // eslint-disable-next-line react-hooks/exhaustive-deps
95
- }, [pos.visible]);
155
+ }, [isOpen]);
96
156
  useEffect(() => {
97
- if (!pos.visible)
157
+ var _a;
158
+ if (!isOpen)
159
+ return;
160
+ const content = contentRef.current;
161
+ if (!content)
98
162
  return;
99
- const handleClickOutside = (e) => {
100
- if (containerRef.current &&
101
- contentRef.current &&
102
- !containerRef.current.contains(e.target) &&
103
- !contentRef.current.contains(e.target)) {
104
- closePopover();
163
+ if (autoFocusContent) {
164
+ const focusables = getFocusable(content);
165
+ (_a = focusables[0]) === null || _a === void 0 ? void 0 : _a.focus();
166
+ }
167
+ const handlePointerDownCapture = (e) => {
168
+ const container = containerRef.current;
169
+ const contentEl = contentRef.current;
170
+ if (!container || !contentEl)
171
+ return;
172
+ const target = e.target;
173
+ if (!container.contains(target) && !contentEl.contains(target)) {
174
+ closePopover('outside');
105
175
  }
106
176
  };
107
177
  const handleEscape = (e) => {
108
178
  if (e.key === 'Escape')
109
- closePopover();
179
+ closePopover('escape');
110
180
  };
111
- const handleReposition = () => computeAndSetPosition(true);
112
- document.addEventListener('click', handleClickOutside);
181
+ const handleReposition = () => computeAndSetPosition();
182
+ document.addEventListener('pointerdown', handlePointerDownCapture, true);
113
183
  document.addEventListener('keydown', handleEscape, true);
114
184
  window.addEventListener('resize', handleReposition);
115
185
  window.addEventListener('scroll', handleReposition, true);
116
186
  return () => {
117
- document.removeEventListener('click', handleClickOutside);
187
+ document.removeEventListener('pointerdown', handlePointerDownCapture, true);
118
188
  document.removeEventListener('keydown', handleEscape, true);
119
189
  window.removeEventListener('resize', handleReposition);
120
190
  window.removeEventListener('scroll', handleReposition, true);
121
191
  };
122
- }, [pos.visible, closePopover, computeAndSetPosition]);
123
- return (_jsxs("div", { className: styles.container, ref: containerRef, children: [Trigger(openPopover, pos.visible ? _jsx(ChevronUp, { size: 20 }) : _jsx(ChevronDown, { size: 20 })), mounted &&
124
- createPortal(_jsx("div", { ref: contentRef, className: styles.content, style: {
192
+ }, [isOpen, closePopover, computeAndSetPosition, autoFocusContent]);
193
+ useEffect(() => {
194
+ var _a, _b;
195
+ if (isOpen)
196
+ return;
197
+ if (!returnFocus)
198
+ return;
199
+ if (lastCloseReasonRef.current === 'outside')
200
+ return;
201
+ (_b = (_a = triggerElRef.current) === null || _a === void 0 ? void 0 : _a.focus) === null || _b === void 0 ? void 0 : _b.call(_a);
202
+ }, [isOpen, returnFocus]);
203
+ const icon = isOpen ? _jsx(ChevronUp, { size: 20 }) : _jsx(ChevronDown, { size: 20 });
204
+ return (_jsxs("div", { className: styles.container, ref: containerRef, children: [Trigger(togglePopover, icon, isOpen), mounted &&
205
+ isOpen &&
206
+ createPortal(_jsx("div", { id: resolvedContentId, ref: contentRef, className: styles.content, style: {
125
207
  top: pos.top,
126
208
  left: pos.left,
127
- visibility: pos.visible ? 'visible' : 'hidden',
128
- minWidth: pos.width ? `${pos.width}px` : minWidth,
129
- width: pos.width ? `${pos.width}px` : undefined,
209
+ // Content-driven sizing by default.
210
+ minWidth,
211
+ width: triggerWidth != null ? `${triggerWidth}px` : undefined,
130
212
  maxWidth: `calc(100vw - ${viewportPadding * 2}px)`,
131
213
  maxHeight: `clamp(100px, calc(100vh - ${viewportPadding * 2}px), 400px)`,
132
- overflow: 'auto',
133
- }, role: "dialog", "aria-hidden": !pos.visible, "data-cy": dataCy !== null && dataCy !== void 0 ? dataCy : 'popover-content', children: typeof children === 'function'
134
- ? children(closePopover)
214
+ visibility: positioned ? undefined : 'hidden',
215
+ }, "data-cy": dataCy !== null && dataCy !== void 0 ? dataCy : 'popover-content', children: typeof children === 'function'
216
+ ? children(() => closePopover('api'))
135
217
  : children }), document.body)] }));
136
218
  });
137
219
  Popover.displayName = 'Popover';
@@ -1,5 +1,6 @@
1
1
  .container {
2
2
  position: relative;
3
+ display: inline-block;
3
4
  }
4
5
 
5
6
  .content {
@@ -13,6 +14,10 @@
13
14
  box-shadow: var(--shadow-md);
14
15
  }
15
16
 
17
+ .content[hidden] {
18
+ display: none;
19
+ }
20
+
16
21
  .content svg {
17
22
  height: 20px;
18
23
  width: 20px;
@@ -1,34 +1,20 @@
1
- import React from 'react';
2
- import { SplitDirection } from './provider/SplitPaneContext';
3
- interface SplitPaneProps {
4
- children: React.ReactNode;
1
+ import type { JSX, ReactNode } from 'react';
2
+ import type { SplitDirection } from './provider/SplitPaneContext';
3
+ export interface SplitPaneProps {
4
+ children: ReactNode;
5
5
  initialPrimarySize?: number;
6
6
  minPrimarySize?: number;
7
7
  minSecondarySize?: number;
8
8
  direction?: SplitDirection;
9
9
  showDivider?: 'hover' | 'always' | 'never';
10
- /**
11
- * Gutter size (px). This is the space between panes and contains the resizer hit area.
12
- * Example: 8 => 4px breathing room on each side if the divider line is centered.
13
- */
14
10
  gutterSize?: number;
15
- /**
16
- * If provided, primary size is persisted per key in localStorage.
17
- * Only SplitPanes sharing the same key will share size.
18
- */
19
11
  storageKey?: string;
20
12
  }
21
- export declare function SplitPane({ children, initialPrimarySize, minPrimarySize, minSecondarySize, direction, showDivider, gutterSize, storageKey, }: SplitPaneProps): React.ReactNode;
22
- /**
23
- * IMPORTANT:
24
- * This component now renders ONLY the primary content (scrollable).
25
- * The resizer lives in a dedicated <SplitPaneGutter /> so it never overlaps scrollbars.
26
- */
13
+ export declare function SplitPane({ children, initialPrimarySize, minPrimarySize, minSecondarySize, direction, showDivider, gutterSize, storageKey, }: SplitPaneProps): JSX.Element;
27
14
  export declare function SplitPanePrimary({ children }: {
28
- children: React.ReactNode;
29
- }): React.ReactNode;
30
- export declare function SplitPaneGutter(): React.ReactNode;
15
+ children: ReactNode;
16
+ }): JSX.Element;
31
17
  export declare function SplitPaneSecondary({ children }: {
32
- children: React.ReactNode;
33
- }): React.ReactNode;
34
- export {};
18
+ children: ReactNode;
19
+ }): JSX.Element;
20
+ export declare function SplitPaneGutter(): JSX.Element;
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
- import { useEffect, useRef } from 'react';
3
+ import { useCallback, useMemo, useRef } from 'react';
4
4
  import { SplitPaneProvider, useSplitPaneContext } from './provider/SplitPaneContext';
5
5
  import styles from './SplitPane.module.css';
6
6
  function clamp(n, min, max) {
@@ -11,68 +11,97 @@ export function SplitPane({ children, initialPrimarySize = 300, minPrimarySize =
11
11
  }
12
12
  function SplitPaneContainer({ children, showDivider, gutterSize, }) {
13
13
  const { direction, primarySize, containerRef } = useSplitPaneContext();
14
- return (_jsx("div", { ref: containerRef, className: styles.container, "data-direction": direction, "data-divider": showDivider, style: {
15
- '--split-pane-primary-size': `${primarySize}px`,
16
- '--split-pane-gutter': `${gutterSize}px`,
17
- }, children: children }));
14
+ const style = useMemo(() => ({
15
+ '--split-pane-primary-size': `${primarySize}px`,
16
+ '--split-pane-gutter': `${gutterSize}px`,
17
+ }), [primarySize, gutterSize]);
18
+ return (_jsx("div", { ref: containerRef, className: styles.container, "data-direction": direction, "data-divider": showDivider, style: style, children: children }));
18
19
  }
19
- /**
20
- * IMPORTANT:
21
- * This component now renders ONLY the primary content (scrollable).
22
- * The resizer lives in a dedicated <SplitPaneGutter /> so it never overlaps scrollbars.
23
- */
24
20
  export function SplitPanePrimary({ children }) {
25
21
  return _jsx("div", { className: styles.primary, children: children });
26
22
  }
23
+ export function SplitPaneSecondary({ children }) {
24
+ return _jsx("div", { className: styles.secondary, children: children });
25
+ }
27
26
  export function SplitPaneGutter() {
28
27
  const { direction, primarySize, setPrimarySize, minPrimarySize, minSecondarySize, containerRef } = useSplitPaneContext();
29
- const isDraggingRef = useRef(false);
28
+ const draggingRef = useRef(false);
29
+ const pointerIdRef = useRef(null);
30
30
  const startPosRef = useRef(0);
31
31
  const startSizeRef = useRef(primarySize);
32
- useEffect(() => {
33
- if (!window) {
32
+ const maxPrimaryRef = useRef(Infinity);
33
+ const getClientPos = useCallback((e) => (direction === 'horizontal' ? e.clientX : e.clientY), [direction]);
34
+ const computeClamp = useCallback(() => {
35
+ const el = containerRef.current;
36
+ if (!el)
37
+ return { maxPrimary: Infinity, total: 0 };
38
+ const rect = el.getBoundingClientRect();
39
+ const total = direction === 'horizontal' ? rect.width : rect.height;
40
+ const maxPrimary = Math.max(minPrimarySize, total - minSecondarySize);
41
+ return { maxPrimary, total };
42
+ }, [containerRef, direction, minPrimarySize, minSecondarySize]);
43
+ const onPointerDown = useCallback((e) => {
44
+ const el = containerRef.current;
45
+ if (!el)
34
46
  return;
35
- }
36
- const onMove = (e) => {
37
- if (!isDraggingRef.current)
38
- return;
39
- const el = containerRef.current;
40
- if (!el)
41
- return;
42
- const rect = el.getBoundingClientRect();
43
- const total = direction === 'horizontal' ? rect.width : rect.height;
44
- const clientPos = direction === 'horizontal' ? e.clientX : e.clientY;
45
- const delta = clientPos - startPosRef.current;
46
- // Note: total includes gutter. That's fine because max clamps against secondary min.
47
- const next = startSizeRef.current + delta;
48
- const maxPrimary = Math.max(minPrimarySize, total - minSecondarySize);
49
- setPrimarySize(clamp(next, minPrimarySize, maxPrimary));
50
- };
51
- const onUp = () => {
52
- if (!isDraggingRef.current)
53
- return;
54
- isDraggingRef.current = false;
55
- document.body.style.cursor = '';
56
- document.body.style.userSelect = '';
57
- };
58
- window.addEventListener('mousemove', onMove);
59
- window.addEventListener('mouseup', onUp);
60
- return () => {
61
- if (window) {
62
- window.removeEventListener('mousemove', onMove);
63
- window.removeEventListener('mouseup', onUp);
64
- }
65
- };
66
- }, [containerRef, direction, minPrimarySize, minSecondarySize, setPrimarySize]);
67
- const handleMouseDown = (event) => {
68
- isDraggingRef.current = true;
69
- startPosRef.current = direction === 'horizontal' ? event.clientX : event.clientY;
47
+ e.currentTarget.setPointerCapture(e.pointerId);
48
+ const { maxPrimary } = computeClamp();
49
+ maxPrimaryRef.current = maxPrimary;
50
+ draggingRef.current = true;
51
+ pointerIdRef.current = e.pointerId;
52
+ startPosRef.current = getClientPos(e);
70
53
  startSizeRef.current = primarySize;
71
- document.body.style.cursor = direction === 'horizontal' ? 'col-resize' : 'row-resize';
54
+ // UX: prevent text selection during drag
72
55
  document.body.style.userSelect = 'none';
73
- };
74
- return (_jsx("div", { className: styles.gutter, "aria-hidden": "true", children: _jsx("span", { className: styles.resizer, onMouseDown: handleMouseDown, role: "separator", "aria-orientation": direction === 'horizontal' ? 'vertical' : 'horizontal', "aria-valuenow": Math.round(primarySize), tabIndex: 0 }) }));
75
- }
76
- export function SplitPaneSecondary({ children }) {
77
- return _jsx("div", { className: styles.secondary, children: children });
56
+ document.body.style.cursor = direction === 'horizontal' ? 'col-resize' : 'row-resize';
57
+ }, [computeClamp, containerRef, direction, getClientPos, primarySize]);
58
+ const onPointerMove = useCallback((e) => {
59
+ if (!draggingRef.current)
60
+ return;
61
+ if (pointerIdRef.current !== null && e.pointerId !== pointerIdRef.current)
62
+ return;
63
+ const delta = getClientPos(e) - startPosRef.current;
64
+ const next = startSizeRef.current + delta;
65
+ const maxPrimary = maxPrimaryRef.current;
66
+ setPrimarySize(clamp(next, minPrimarySize, maxPrimary));
67
+ }, [getClientPos, minPrimarySize, setPrimarySize]);
68
+ const endDrag = useCallback(() => {
69
+ if (!draggingRef.current)
70
+ return;
71
+ draggingRef.current = false;
72
+ pointerIdRef.current = null;
73
+ document.body.style.cursor = '';
74
+ document.body.style.userSelect = '';
75
+ }, []);
76
+ const onPointerUp = useCallback(() => endDrag(), [endDrag]);
77
+ const onPointerCancel = useCallback(() => endDrag(), [endDrag]);
78
+ // Keyboard: arrows adjust size; Shift = bigger step; Home/End = min/max
79
+ const onKeyDown = useCallback((e) => {
80
+ const { maxPrimary } = computeClamp();
81
+ const step = e.shiftKey ? 32 : 8;
82
+ let next = null;
83
+ if (direction === 'horizontal') {
84
+ if (e.key === 'ArrowLeft')
85
+ next = primarySize - step;
86
+ if (e.key === 'ArrowRight')
87
+ next = primarySize + step;
88
+ }
89
+ else {
90
+ if (e.key === 'ArrowUp')
91
+ next = primarySize - step;
92
+ if (e.key === 'ArrowDown')
93
+ next = primarySize + step;
94
+ }
95
+ if (e.key === 'Home')
96
+ next = minPrimarySize;
97
+ if (e.key === 'End')
98
+ next = maxPrimary;
99
+ if (next === null)
100
+ return;
101
+ e.preventDefault();
102
+ setPrimarySize(clamp(next, minPrimarySize, maxPrimary));
103
+ }, [computeClamp, direction, minPrimarySize, primarySize, setPrimarySize]);
104
+ const ariaOrientation = direction === 'horizontal' ? 'vertical' : 'horizontal';
105
+ const { maxPrimary } = computeClamp();
106
+ return (_jsx("div", { className: styles.gutter, children: _jsx("div", { className: styles.resizer, role: "separator", "aria-orientation": ariaOrientation, "aria-valuemin": Math.round(minPrimarySize), "aria-valuemax": Number.isFinite(maxPrimary) ? Math.round(maxPrimary) : undefined, "aria-valuenow": Math.round(primarySize), tabIndex: 0, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: onPointerUp, onPointerCancel: onPointerCancel, onKeyDown: onKeyDown }) }));
78
107
  }
@@ -20,8 +20,8 @@
20
20
  min-inline-size: 0;
21
21
  min-block-size: 0;
22
22
  overflow: auto;
23
- flex-direction: column;
24
23
  display: flex;
24
+ flex-direction: column;
25
25
  }
26
26
 
27
27
  /* ===== Secondary pane ===== */
@@ -34,7 +34,7 @@
34
34
  flex-direction: column;
35
35
  }
36
36
 
37
- /* ===== Gutter (spacing + hit area) ===== */
37
+ /* ===== Gutter (spacing + hit area wrapper) ===== */
38
38
  .gutter {
39
39
  position: relative;
40
40
  flex: 0 0 var(--split-pane-gutter, 8px);
@@ -42,32 +42,37 @@
42
42
  z-index: 1;
43
43
  }
44
44
 
45
- /* Vertical mode gutter */
46
45
  .container[data-direction='vertical'] .gutter {
47
46
  inline-size: 100%;
48
47
  block-size: var(--split-pane-gutter, 8px);
49
48
  }
50
49
 
51
- /* ===== Resizer (interaction only) ===== */
50
+ /* ===== Resizer (interaction element) ===== */
52
51
  .resizer {
53
52
  position: absolute;
54
53
  inset: 0;
55
54
  cursor: col-resize;
56
55
  user-select: none;
57
56
  touch-action: none;
57
+ outline: none;
58
58
  }
59
59
 
60
60
  .container[data-direction='vertical'] .resizer {
61
61
  cursor: row-resize;
62
62
  }
63
63
 
64
+ /* Focus ring for keyboard resizing */
65
+ .resizer:focus-visible {
66
+ box-shadow: var(--focus-ring);
67
+ }
68
+
64
69
  /* ===== Divider line ===== */
65
70
  .resizer::after {
66
71
  content: '';
67
72
  position: absolute;
68
73
  inset-block: 0;
69
74
  inset-inline: 50%;
70
- inline-size: var(--border-width-hairline);
75
+ inline-size: var(--border-width-thin);
71
76
  background-color: var(--color-border-subtle);
72
77
  opacity: 0;
73
78
  transform: translateX(-50%);
@@ -81,7 +86,7 @@
81
86
  inset-inline: 0;
82
87
  inset-block: 50%;
83
88
  inline-size: 100%;
84
- block-size: var(--border-width-hairline);
89
+ block-size: var(--border-width-thin);
85
90
  transform: translateY(-50%);
86
91
  }
87
92
 
@@ -1,4 +1,3 @@
1
- 'use client';
2
1
  import { jsx as _jsx } from "react/jsx-runtime";
3
2
  import React, { useEffect, useMemo, useRef, useState } from 'react';
4
3
  export const SplitPaneContext = React.createContext(null);
@@ -22,7 +21,7 @@ function writeStoredSize(key, value) {
22
21
  localStorage.setItem(key, String(Math.round(value)));
23
22
  }
24
23
  catch {
25
- // ignore (private mode, disabled storage, etc.)
24
+ // ignore
26
25
  }
27
26
  }
28
27
  export function useSplitPaneContext() {
@@ -33,13 +32,8 @@ export function useSplitPaneContext() {
33
32
  }
34
33
  export function SplitPaneProvider({ children, direction, initialPrimarySize, minPrimarySize, minSecondarySize, storageKey, }) {
35
34
  const containerRef = useRef(null);
36
- /**
37
- * IMPORTANT (Next.js hydration):
38
- * Always start with initialPrimarySize so server HTML and first client render match.
39
- * Then, after hydration, read localStorage and update.
40
- */
35
+ // Start with initial to avoid SSR mismatch, then hydrate from storage
41
36
  const [primarySize, setPrimarySize] = useState(initialPrimarySize);
42
- // Apply persisted size AFTER hydration (prevents SSR/client mismatch warnings)
43
37
  useEffect(() => {
44
38
  if (!storageKey)
45
39
  return;
@@ -48,11 +42,12 @@ export function SplitPaneProvider({ children, direction, initialPrimarySize, min
48
42
  return;
49
43
  setPrimarySize(stored);
50
44
  }, [storageKey]);
51
- // Clamp after mount / when container is measurable; re-clamp on resize
52
45
  useEffect(() => {
53
46
  const el = containerRef.current;
54
47
  if (!el)
55
48
  return;
49
+ if (typeof ResizeObserver === 'undefined')
50
+ return;
56
51
  const clampToContainer = () => {
57
52
  const rect = el.getBoundingClientRect();
58
53
  const total = direction === 'horizontal' ? rect.width : rect.height;
@@ -62,11 +57,10 @@ export function SplitPaneProvider({ children, direction, initialPrimarySize, min
62
57
  setPrimarySize(prev => clamp(prev, minPrimarySize, maxPrimary));
63
58
  };
64
59
  clampToContainer();
65
- const ro = new ResizeObserver(() => clampToContainer());
60
+ const ro = new ResizeObserver(clampToContainer);
66
61
  ro.observe(el);
67
62
  return () => ro.disconnect();
68
63
  }, [direction, minPrimarySize, minSecondarySize]);
69
- // Persist on change
70
64
  useEffect(() => {
71
65
  if (!storageKey)
72
66
  return;