react-cheminfo 0.46.0 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/lib/chart/core/chartDomain.d.ts +14 -0
  2. package/lib/chart/core/chartDomain.d.ts.map +1 -1
  3. package/lib/chart/core/chartDomain.js +1 -0
  4. package/lib/chart/core/chartDomain.js.map +1 -1
  5. package/lib/chart/core/selectionGeometry.d.ts +4 -2
  6. package/lib/chart/core/selectionGeometry.d.ts.map +1 -1
  7. package/lib/chart/core/selectionGeometry.js +4 -2
  8. package/lib/chart/core/selectionGeometry.js.map +1 -1
  9. package/lib/chart/core/zoomDomain.d.ts +11 -1
  10. package/lib/chart/core/zoomDomain.d.ts.map +1 -1
  11. package/lib/chart/core/zoomDomain.js +25 -5
  12. package/lib/chart/core/zoomDomain.js.map +1 -1
  13. package/lib/chart/ui/chartGestures.d.ts +26 -1
  14. package/lib/chart/ui/chartGestures.d.ts.map +1 -1
  15. package/lib/chart/ui/chartGestures.js +21 -0
  16. package/lib/chart/ui/chartGestures.js.map +1 -1
  17. package/lib/chart/ui/index.d.ts +1 -1
  18. package/lib/chart/ui/index.d.ts.map +1 -1
  19. package/lib/chart/ui/index.js +1 -1
  20. package/lib/chart/ui/index.js.map +1 -1
  21. package/lib/chart/ui/useChartZoom.d.ts.map +1 -1
  22. package/lib/chart/ui/useChartZoom.js +17 -1
  23. package/lib/chart/ui/useChartZoom.js.map +1 -1
  24. package/lib/i18n/core/chromeCatalog.d.ts +1 -1
  25. package/lib/i18n/core/chromeCatalog.d.ts.map +1 -1
  26. package/lib/ir/core/irMode.d.ts.map +1 -1
  27. package/lib/ir/core/irMode.js +6 -1
  28. package/lib/ir/core/irMode.js.map +1 -1
  29. package/lib/locales/de.json +1 -0
  30. package/lib/locales/en.json +1 -0
  31. package/lib/locales/es.json +1 -0
  32. package/lib/locales/fr.json +1 -0
  33. package/lib/locales/it.json +1 -0
  34. package/lib/periodic/core/index.d.ts +1 -1
  35. package/lib/periodic/core/index.d.ts.map +1 -1
  36. package/lib/periodic/core/layout.d.ts +11 -0
  37. package/lib/periodic/core/layout.d.ts.map +1 -1
  38. package/lib/periodic/core/layout.js.map +1 -1
  39. package/lib/periodic/ui/ElementCell.d.ts +6 -2
  40. package/lib/periodic/ui/ElementCell.d.ts.map +1 -1
  41. package/lib/periodic/ui/ElementCell.js +2 -2
  42. package/lib/periodic/ui/ElementCell.js.map +1 -1
  43. package/lib/periodic/ui/PeriodicTable.d.ts +12 -3
  44. package/lib/periodic/ui/PeriodicTable.d.ts.map +1 -1
  45. package/lib/periodic/ui/PeriodicTable.js +5 -3
  46. package/lib/periodic/ui/PeriodicTable.js.map +1 -1
  47. package/lib/periodic/ui/PeriodicTableChrome.d.ts +13 -3
  48. package/lib/periodic/ui/PeriodicTableChrome.d.ts.map +1 -1
  49. package/lib/periodic/ui/PeriodicTableChrome.js +18 -6
  50. package/lib/periodic/ui/PeriodicTableChrome.js.map +1 -1
  51. package/lib/split/ui/SplitColumn.d.ts +30 -0
  52. package/lib/split/ui/SplitColumn.d.ts.map +1 -0
  53. package/lib/split/ui/SplitColumn.js +20 -0
  54. package/lib/split/ui/SplitColumn.js.map +1 -0
  55. package/lib/split/ui/SplitRow.d.ts +15 -53
  56. package/lib/split/ui/SplitRow.d.ts.map +1 -1
  57. package/lib/split/ui/SplitRow.js +9 -90
  58. package/lib/split/ui/SplitRow.js.map +1 -1
  59. package/lib/split/ui/index.d.ts +3 -0
  60. package/lib/split/ui/index.d.ts.map +1 -1
  61. package/lib/split/ui/index.js +1 -0
  62. package/lib/split/ui/index.js.map +1 -1
  63. package/lib/split/ui/splitPanes.d.ts +73 -0
  64. package/lib/split/ui/splitPanes.d.ts.map +1 -0
  65. package/lib/split/ui/splitPanes.js +128 -0
  66. package/lib/split/ui/splitPanes.js.map +1 -0
  67. package/package.json +1 -1
  68. package/src/chart/core/chartDomain.ts +15 -0
  69. package/src/chart/core/selectionGeometry.ts +4 -2
  70. package/src/chart/core/zoomDomain.ts +37 -5
  71. package/src/chart/ui/chartGestures.ts +41 -1
  72. package/src/chart/ui/index.ts +1 -1
  73. package/src/chart/ui/useChartZoom.ts +25 -1
  74. package/src/ir/core/irMode.ts +6 -1
  75. package/src/locales/de.json +1 -0
  76. package/src/locales/en.json +1 -0
  77. package/src/locales/es.json +1 -0
  78. package/src/locales/fr.json +1 -0
  79. package/src/locales/it.json +1 -0
  80. package/src/periodic/core/index.ts +1 -1
  81. package/src/periodic/core/layout.ts +12 -0
  82. package/src/periodic/ui/ElementCell.tsx +8 -4
  83. package/src/periodic/ui/PeriodicTable.tsx +23 -5
  84. package/src/periodic/ui/PeriodicTableChrome.tsx +28 -4
  85. package/src/split/ui/SplitColumn.tsx +36 -0
  86. package/src/split/ui/SplitRow.tsx +17 -208
  87. package/src/split/ui/index.ts +3 -0
  88. package/src/split/ui/splitPanes.tsx +267 -0
@@ -23,7 +23,7 @@ import { useResizeObserver } from '../../hooks/ui/useResizeObserver.ts';
23
23
  import { useChromeT } from '../../i18n/ui/useT.ts';
24
24
  import { categorySwatch } from '../core/categories.ts';
25
25
  import type { PeriodicElement } from '../core/elements.ts';
26
- import type { ElementRange } from '../core/layout.ts';
26
+ import type { ElementPick, ElementRange } from '../core/layout.ts';
27
27
  import {
28
28
  COLUMN_COUNT,
29
29
  EMPTY_BLOCK,
@@ -55,10 +55,12 @@ export interface PeriodicTableProps {
55
55
  selected?: string;
56
56
  /**
57
57
  * Called with the symbol of the element that was clicked, and by the arrow
58
- * keys.
58
+ * keys. The pick says whether the click was additive — Cmd or Ctrl held — so
59
+ * a tool whose plain click already reads one element into a card can let the
60
+ * same table build a set of them.
59
61
  * @default undefined — the table is a figure rather than a control
60
62
  */
61
- onSelect?: (symbol: string) => void;
63
+ onSelect?: (symbol: string, pick: ElementPick) => void;
62
64
  /**
63
65
  * The colour each cell takes.
64
66
  * @default the family colour
@@ -112,6 +114,13 @@ export interface PeriodicTableProps {
112
114
  * @default undefined
113
115
  */
114
116
  onSelectRange?: (range: ElementRange) => void;
117
+ /**
118
+ * Called when the corner where the two header strips meet is clicked, which
119
+ * is how a reader takes the whole table back. Without it the corner stays
120
+ * empty.
121
+ * @default undefined
122
+ */
123
+ onSelectAll?: () => void;
115
124
  /**
116
125
  * Whether to draw the family legend under the grid.
117
126
  * @default false
@@ -158,6 +167,7 @@ export function PeriodicTable(props: PeriodicTableProps): ReactElement {
158
167
  inset,
159
168
  headers = false,
160
169
  onSelectRange,
170
+ onSelectAll,
161
171
  legend = false,
162
172
  markers = true,
163
173
  keyboard = true,
@@ -198,7 +208,7 @@ export function PeriodicTable(props: PeriodicTableProps): ReactElement {
198
208
  if (next === null) return;
199
209
  event.preventDefault();
200
210
  cameFromKeyRef.current = true;
201
- onSelect(next.symbol);
211
+ onSelect(next.symbol, PLAIN_PICK);
202
212
  }
203
213
 
204
214
  // One size for the whole table rather than one per cell: a column of values
@@ -228,7 +238,12 @@ export function PeriodicTable(props: PeriodicTableProps): ReactElement {
228
238
  style={gridStyle(headers)}
229
239
  onKeyDown={handleKeyDown}
230
240
  >
231
- {headers ? <HeaderStrips onSelectRange={onSelectRange} /> : null}
241
+ {headers ? (
242
+ <HeaderStrips
243
+ onSelectRange={onSelectRange}
244
+ onSelectAll={onSelectAll}
245
+ />
246
+ ) : null}
232
247
  {inset === undefined ? null : (
233
248
  <div style={insetStyle(offset)}>{inset}</div>
234
249
  )}
@@ -290,6 +305,9 @@ function defaultNameOf(element: PeriodicElement): string {
290
305
  return element.name;
291
306
  }
292
307
 
308
+ /** The arrow keys walk the table to read it; they never add to a set. */
309
+ const PLAIN_PICK: ElementPick = { additive: false };
310
+
293
311
  function noop(): void {
294
312
  // A table with no `onSelect` is a figure; its cells stay buttons so the
295
313
  // keyboard and a screen reader still reach every element.
@@ -24,18 +24,28 @@ interface HeaderStripsProps {
24
24
  * @default undefined
25
25
  */
26
26
  onSelectRange?: (range: ElementRange) => void;
27
+ /**
28
+ * Called when the corner where the two strips meet is clicked. The corner of
29
+ * a spreadsheet takes everything, and a table whose rows and columns are one
30
+ * click each owes the same for the whole of it. Without it the corner stays
31
+ * empty.
32
+ * @default undefined
33
+ */
34
+ onSelectAll?: () => void;
27
35
  }
28
36
 
29
37
  /**
30
- * The 1–18 strip along the top and the 1–7 strip down the left.
38
+ * The 1–18 strip along the top, the 1–7 strip down the left, and the corner
39
+ * where they meet.
31
40
  *
32
41
  * They are buttons when the table takes a range: "plot period 3" is one click
33
- * there and eight on the cells.
42
+ * there and eight on the cells. The corner takes the whole table, which is the
43
+ * gesture a spreadsheet has taught everybody.
34
44
  * @param props - See {@link HeaderStripsProps}.
35
- * @returns The two strips, placed on the grid.
45
+ * @returns The two strips, the corner, and the inner-transition period numbers.
36
46
  */
37
47
  export function HeaderStrips(props: HeaderStripsProps): ReactElement {
38
- const { onSelectRange } = props;
48
+ const { onSelectRange, onSelectAll } = props;
39
49
  const t = useChromeT();
40
50
  const groups: ReactElement[] = [];
41
51
  for (let group = 1; group <= COLUMN_COUNT; group++) {
@@ -84,6 +94,19 @@ export function HeaderStrips(props: HeaderStripsProps): ReactElement {
84
94
 
85
95
  return (
86
96
  <>
97
+ {onSelectAll === undefined ? null : (
98
+ <HeaderCell
99
+ // The glyph points into the grid it takes, as a spreadsheet's does.
100
+ // It is a mark rather than a word because the corner is a
101
+ // seventeenth of the table's width, and the word differs in every
102
+ // language the chrome speaks.
103
+ label="◢"
104
+ title={t('periodic.allElements')}
105
+ column={1}
106
+ row={1}
107
+ onClick={onSelectAll}
108
+ />
109
+ )}
87
110
  {groups}
88
111
  {periods}
89
112
  {INNER_TRANSITION_ROWS.map(({ row, period }) => (
@@ -159,6 +182,7 @@ function HeaderCell(props: HeaderCellProps): ReactElement {
159
182
  <button
160
183
  type="button"
161
184
  aria-label={title}
185
+ title={title}
162
186
  onClick={onClick}
163
187
  style={{ ...style, ...headerButtonStyle }}
164
188
  >
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Two figures one above the other, with a splitter between them and the share
3
+ * each takes written into the address.
4
+ *
5
+ * The body is `splitPanes.tsx`, which `<SplitRow>` shares; its header is where
6
+ * the design is written down.
7
+ */
8
+
9
+ import type { ReactElement } from 'react';
10
+
11
+ import type { SplitPanesProps } from './splitPanes.tsx';
12
+ import { SplitPanes } from './splitPanes.tsx';
13
+
14
+ /**
15
+ * Props of {@link SplitColumn}. `start` is the top pane, `end` the bottom one,
16
+ * and `stackBelow` a height.
17
+ */
18
+ export type SplitColumnProps = SplitPanesProps;
19
+
20
+ /**
21
+ * Two panes sharing a column, divided where the address says.
22
+ *
23
+ * **A share needs a height to be a share of.** Put this inside a box that has
24
+ * one — a workbench pane, a page that fills the viewport — not inside an
25
+ * ordinary page column, whose height is whatever its content comes to. Given
26
+ * no height to divide, it measures nothing, says nothing about it, and simply
27
+ * stacks the two panes with no splitter, which is what an ordinary page wanted
28
+ * anyway.
29
+ *
30
+ * @param props - See {@link SplitColumnProps}.
31
+ * @returns The two panes with the splitter between them, the two stacked where
32
+ * there is no height to divide, or whichever one a link left, alone.
33
+ */
34
+ export function SplitColumn(props: SplitColumnProps): ReactElement | null {
35
+ return <SplitPanes direction="vertical" {...props} />;
36
+ }
@@ -2,224 +2,33 @@
2
2
  * Two figures side by side, with a splitter between them and the share each
3
3
  * takes written into the address.
4
4
  *
5
- * How much room the tool deserves beside the figure it feeds is not ours to
6
- * decide: somebody embedding the chart wants it nearly whole, somebody
7
- * teaching off the picker wants the picker nearly whole, and both want the
8
- * link they hand out to open the way they left it. So the splitter is dragged,
9
- * the share it comes to rest at goes into `?split=`, and the address is the
10
- * figure.
11
- *
12
- * Two things it cannot do, and must not pretend to: on a narrow screen there
13
- * is no room for two columns at all, so the panes stack and no splitter is
14
- * drawn; and a link that hid everything one pane holds leaves the other the
15
- * whole width rather than a share of it.
16
- *
17
- * The bar itself is react-science's `SplitPane`, and it keeps react-science's
18
- * look — barely visible on purpose, found by the pointer that changes over it
19
- * rather than by the eye. All `chrome.css` says about it is that eleven pixels
20
- * is more gutter than two figures need.
5
+ * The body is `splitPanes.tsx`, which `<SplitColumn>` shares; its header is
6
+ * where the design is written down.
21
7
  */
22
8
 
23
- import type { CSSProperties, ReactElement, ReactNode } from 'react';
24
- import { useRef, useState } from 'react';
25
- import type { SplitPaneSize } from 'react-science/ui';
26
- import { SplitPane } from 'react-science/ui';
9
+ import type { ReactElement } from 'react';
27
10
 
28
- import { useResizeObserver } from '../../hooks/ui/useResizeObserver.ts';
29
- import type { SplitRange } from '../core/split.ts';
30
- import { clampSplit } from '../core/split.ts';
11
+ import type { SplitPanesProps } from './splitPanes.tsx';
12
+ import { SplitPanes } from './splitPanes.tsx';
31
13
 
32
- /** Props of {@link SplitRow}. */
33
- export interface SplitRowProps extends SplitRange {
34
- /**
35
- * Share of the row the first pane takes, as a whole percentage, or `null`
36
- * while the page sits at {@link SplitRowProps.defaultRatio}.
37
- */
38
- ratio: number | null;
39
- /** The share the row opens at when the address names none. */
40
- defaultRatio: number;
41
- /**
42
- * Take the share a drag came to rest at, or `null` when the splitter was
43
- * double-clicked back to the default.
44
- */
45
- onRatio: (ratio: number | null) => void;
46
- /** The left-hand pane, or `null` when a link left nothing in it. */
47
- start: ReactNode;
48
- /** The right-hand pane, or `null` when a link left nothing in it. */
49
- end: ReactNode;
50
- /**
51
- * The narrowest row that is still split; under it the two panes stack.
52
- * @default 860
53
- */
54
- stackBelow?: number;
55
- /**
56
- * The gutter each pane keeps beside the splitter, in pixels, so neither
57
- * figure is read against its edge.
58
- * @default 6
59
- */
60
- gutter?: number;
61
- /**
62
- * The gap between the two panes once they have stacked, in pixels.
63
- * @default 16
64
- */
65
- stackedGap?: number;
66
- }
14
+ /**
15
+ * Props of {@link SplitRow}. `start` is the left-hand pane, `end` the
16
+ * right-hand one, and `stackBelow` a width.
17
+ */
18
+ export type SplitRowProps = SplitPanesProps;
67
19
 
68
20
  /**
69
21
  * Two panes sharing a row, divided where the address says.
22
+ *
23
+ * **Set `max` where the first pane stops being able to use the room.** A pane
24
+ * of forms dragged past about half the row does not grow: the labels stay put
25
+ * and the fields stretch to a width nobody types into, so the room becomes
26
+ * empty card. The figure beside it is what the drag exists to enlarge.
27
+ *
70
28
  * @param props - See {@link SplitRowProps}.
71
29
  * @returns The two panes with the splitter between them, the two stacked on a
72
30
  * narrow screen, or whichever one a link left on the page, alone.
73
31
  */
74
32
  export function SplitRow(props: SplitRowProps): ReactElement | null {
75
- const {
76
- ratio,
77
- defaultRatio,
78
- onRatio,
79
- start,
80
- end,
81
- min,
82
- max,
83
- stackBelow = STACK_BELOW,
84
- gutter = GUTTER,
85
- stackedGap = STACKED_GAP,
86
- } = props;
87
- const row = useRef<HTMLDivElement>(null);
88
- const [width, setWidth] = useState(windowWidth);
89
- // The share under the pointer, which is not the one in the address: a drag
90
- // reports every pixel it crosses, and the address is rewritten once, where
91
- // the splitter is let go.
92
- const [dragged, setDragged] = useState<number | null>(null);
93
-
94
- useResizeObserver(row, (size) => {
95
- setWidth(size.width);
96
- });
97
-
98
- if (start === null && end === null) return null;
99
-
100
- const stacked = start === null || end === null || width < stackBelow;
101
- const startPane =
102
- start === null ? null : (
103
- <div
104
- data-testid="split-start"
105
- style={stacked ? STACKED_PANE : paneStyle('start', gutter)}
106
- >
107
- {start}
108
- </div>
109
- );
110
- const endPane =
111
- end === null ? null : (
112
- <div
113
- data-testid="split-end"
114
- style={stacked ? STACKED_PANE : paneStyle('end', gutter)}
115
- >
116
- {end}
117
- </div>
118
- );
119
-
120
- if (stacked) {
121
- return (
122
- <div ref={row} style={{ ...STACK, gap: stackedGap }}>
123
- {startPane}
124
- {endPane}
125
- </div>
126
- );
127
- }
128
-
129
- const share = clampSplit(dragged ?? ratio ?? defaultRatio, { min, max });
130
-
131
- return (
132
- // `split-row` is what `chrome.css` reaches the bar through: it is
133
- // react-science's own element and carries no class of ours.
134
- <div ref={row} className="split-row">
135
- <SplitPane
136
- direction="horizontal"
137
- controlledSide="start"
138
- size={`${share}%`}
139
- // Held open, so the only layout a visitor can reach is one the address
140
- // describes: left to itself a double click collapses a pane, and a link
141
- // shared from there would open with the pane back. The double click is
142
- // answered here instead, by returning to the page's own share.
143
- open
144
- onOpenChange={() => {
145
- setDragged(null);
146
- onRatio(null);
147
- }}
148
- onSizeChange={(asked) => {
149
- setDragged(shareOf(asked, share, { min, max }));
150
- }}
151
- onResize={(asked) => {
152
- const rested = shareOf(asked, share, { min, max });
153
- setDragged(null);
154
- onRatio(rested);
155
- }}
156
- >
157
- {startPane}
158
- {endPane}
159
- </SplitPane>
160
- </div>
161
- );
162
- }
163
-
164
- /**
165
- * The share a splitter asked for.
166
- * @param asked - What it reported, a percentage because that is what it was
167
- * given.
168
- * @param current - The share in force, kept when the report is not a number.
169
- * @param range - The range this row is divided in.
170
- * @returns A whole percentage inside it.
171
- */
172
- function shareOf(
173
- asked: SplitPaneSize,
174
- current: number,
175
- range: SplitRange,
176
- ): number {
177
- const value = Number.parseFloat(asked);
178
- return Number.isFinite(value) ? clampSplit(value, range) : current;
179
- }
180
-
181
- /**
182
- * How wide the window is, which is what the row is taken to be until it has
183
- * been measured. Reading it rather than starting at zero is what keeps a wide
184
- * page from laying itself out stacked and splitting a frame later, which would
185
- * build both figures twice on every load.
186
- * @returns The width in pixels, or the threshold itself where there is no
187
- * window to read — a test, or a page rendered to a string — which is the
188
- * narrowest a row is still split at.
189
- */
190
- function windowWidth(): number {
191
- const { innerWidth } = globalThis;
192
- return typeof innerWidth === 'number' && innerWidth > 0
193
- ? innerWidth
194
- : STACK_BELOW;
195
- }
196
-
197
- /** The narrowest row that is still split, in pixels. */
198
- const STACK_BELOW = 860;
199
-
200
- /** The gutter a pane keeps beside the splitter, in pixels. */
201
- const GUTTER = 6;
202
-
203
- /** The gap between the panes once they have stacked, in pixels. */
204
- const STACKED_GAP = 16;
205
-
206
- const STACK = {
207
- display: 'flex',
208
- flexDirection: 'column',
209
- } as const satisfies CSSProperties;
210
-
211
- const STACKED_PANE = {
212
- display: 'flex',
213
- flexDirection: 'column',
214
- minWidth: 0,
215
- } as const satisfies CSSProperties;
216
-
217
- function paneStyle(side: 'start' | 'end', gutter: number): CSSProperties {
218
- return {
219
- display: 'flex',
220
- flex: '1 1 0%',
221
- flexDirection: 'column',
222
- minWidth: 0,
223
- [side === 'start' ? 'paddingRight' : 'paddingLeft']: gutter,
224
- };
33
+ return <SplitPanes direction="horizontal" {...props} />;
225
34
  }
@@ -1,2 +1,5 @@
1
+ export type { SplitColumnProps } from './SplitColumn.tsx';
2
+ export { SplitColumn } from './SplitColumn.tsx';
1
3
  export type { SplitRowProps } from './SplitRow.tsx';
2
4
  export { SplitRow } from './SplitRow.tsx';
5
+ export type { SplitPanesProps } from './splitPanes.tsx';
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Two figures with a splitter between them, and the share each takes written
3
+ * into the address. The body of `<SplitRow>` and `<SplitColumn>` both.
4
+ *
5
+ * How much room one half deserves beside the other is not ours to decide:
6
+ * somebody embedding the chart wants it nearly whole, somebody teaching off the
7
+ * picker wants the picker nearly whole, and both want the link they hand out to
8
+ * open the way they left it. So the splitter is dragged, the share it comes to
9
+ * rest at goes into `?split=`, and the address is the figure.
10
+ *
11
+ * Two things it cannot do, and must not pretend to: on a screen with no room
12
+ * for two the panes stack and no splitter is drawn; and a link that hid
13
+ * everything one pane holds leaves the other the whole of it rather than a
14
+ * share.
15
+ *
16
+ * The bar itself is react-science's `SplitPane`, and it keeps react-science's
17
+ * look — barely visible on purpose, found by the pointer that changes over it
18
+ * rather than by the eye. All `chrome.css` says about it is that eleven pixels
19
+ * is more gutter than two figures need.
20
+ */
21
+
22
+ import type { CSSProperties, ReactElement, ReactNode } from 'react';
23
+ import { useRef, useState } from 'react';
24
+ import type { SplitPaneSize } from 'react-science/ui';
25
+ import { SplitPane } from 'react-science/ui';
26
+
27
+ import { useResizeObserver } from '../../hooks/ui/useResizeObserver.ts';
28
+ import type { SplitRange } from '../core/split.ts';
29
+ import { clampSplit } from '../core/split.ts';
30
+
31
+ /** What both splitters take. */
32
+ export interface SplitPanesProps extends SplitRange {
33
+ /**
34
+ * Share of the box the first pane takes, as a whole percentage, or `null`
35
+ * while the page sits at {@link SplitPanesProps.defaultRatio}.
36
+ */
37
+ ratio: number | null;
38
+ /** The share it opens at when the address names none. */
39
+ defaultRatio: number;
40
+ /**
41
+ * Take the share a drag came to rest at, or `null` when the splitter was
42
+ * double-clicked back to the default.
43
+ */
44
+ onRatio: (ratio: number | null) => void;
45
+ /** The first pane — left, or top — or `null` when a link left nothing in it. */
46
+ start: ReactNode;
47
+ /** The second pane, or `null` when a link left nothing in it. */
48
+ end: ReactNode;
49
+ /**
50
+ * The smallest box that is still split, in pixels — a width for a row, a
51
+ * height for a column. Under it the two panes stack and no splitter is drawn.
52
+ * @default 860 across, 420 down
53
+ */
54
+ stackBelow?: number;
55
+ /**
56
+ * The gutter each pane keeps beside the splitter, in pixels, so neither
57
+ * figure is read against its edge.
58
+ * @default 6
59
+ */
60
+ gutter?: number;
61
+ /**
62
+ * The gap between the two panes once they have stacked, in pixels.
63
+ * @default 16
64
+ */
65
+ stackedGap?: number;
66
+ }
67
+
68
+ /** What {@link SplitPanes} takes on top of the public props. */
69
+ interface InternalProps extends SplitPanesProps {
70
+ /** Across the box, or down it. */
71
+ direction: 'horizontal' | 'vertical';
72
+ }
73
+
74
+ /**
75
+ * The two panes, divided where the address says.
76
+ *
77
+ * @param props - See {@link SplitPanesProps}.
78
+ * @returns The panes with the splitter between them, the two stacked where
79
+ * there is no room for both, or whichever one a link left, alone.
80
+ */
81
+ export function SplitPanes(props: InternalProps): ReactElement | null {
82
+ const {
83
+ direction,
84
+ ratio,
85
+ defaultRatio,
86
+ onRatio,
87
+ start,
88
+ end,
89
+ min,
90
+ max,
91
+ stackBelow = direction === 'horizontal' ? STACK_ACROSS : STACK_DOWN,
92
+ gutter = GUTTER,
93
+ stackedGap = STACKED_GAP,
94
+ } = props;
95
+ const across = direction === 'horizontal';
96
+ const box = useRef<HTMLDivElement>(null);
97
+ const [size, setSize] = useState(() => viewportSize(across, stackBelow));
98
+ // The share under the pointer, which is not the one in the address: a drag
99
+ // reports every pixel it crosses, and the address is rewritten once, where
100
+ // the splitter is let go.
101
+ const [dragged, setDragged] = useState<number | null>(null);
102
+
103
+ useResizeObserver(box, (measured) => {
104
+ setSize(across ? measured.width : measured.height);
105
+ });
106
+
107
+ if (start === null && end === null) return null;
108
+
109
+ // A column divided by a share needs a box of a definite height to take a
110
+ // share of; in an ordinary page-height one it would measure zero and both
111
+ // panes would collapse, so it stacks instead and says nothing about it.
112
+ const stacked = start === null || end === null || size < stackBelow;
113
+ const startPane =
114
+ start === null ? null : (
115
+ <div
116
+ data-testid="split-start"
117
+ style={stacked ? STACKED_PANE : paneStyle(across, 'start', gutter)}
118
+ >
119
+ {start}
120
+ </div>
121
+ );
122
+ const endPane =
123
+ end === null ? null : (
124
+ <div
125
+ data-testid="split-end"
126
+ style={stacked ? STACKED_PANE : paneStyle(across, 'end', gutter)}
127
+ >
128
+ {end}
129
+ </div>
130
+ );
131
+
132
+ if (stacked) {
133
+ return (
134
+ <div ref={box} style={{ ...STACK, gap: stackedGap }}>
135
+ {startPane}
136
+ {endPane}
137
+ </div>
138
+ );
139
+ }
140
+
141
+ const share = clampSplit(dragged ?? ratio ?? defaultRatio, { min, max });
142
+
143
+ return (
144
+ // `split-row` is what `chrome.css` reaches the bar through: it is
145
+ // react-science's own element and carries no class of ours.
146
+ <div ref={box} className="split-row" style={across ? undefined : FILL}>
147
+ <SplitPane
148
+ direction={direction}
149
+ controlledSide="start"
150
+ size={`${share}%`}
151
+ // Held open, so the only layout a visitor can reach is one the address
152
+ // describes: left to itself a double click collapses a pane, and a link
153
+ // shared from there would open with the pane back. The double click is
154
+ // answered here instead, by returning to the page's own share.
155
+ open
156
+ onOpenChange={() => {
157
+ setDragged(null);
158
+ onRatio(null);
159
+ }}
160
+ onSizeChange={(asked) => {
161
+ setDragged(shareOf(asked, share, { min, max }));
162
+ }}
163
+ onResize={(asked) => {
164
+ const rested = shareOf(asked, share, { min, max });
165
+ setDragged(null);
166
+ onRatio(rested);
167
+ }}
168
+ >
169
+ {startPane}
170
+ {endPane}
171
+ </SplitPane>
172
+ </div>
173
+ );
174
+ }
175
+
176
+ /**
177
+ * The share a splitter asked for.
178
+ *
179
+ * @param asked - What it reported, a percentage because that is what it was
180
+ * given.
181
+ * @param current - The share in force, kept when the report is not a number.
182
+ * @param range - The range this box is divided in.
183
+ * @returns A whole percentage inside it.
184
+ */
185
+ function shareOf(
186
+ asked: SplitPaneSize,
187
+ current: number,
188
+ range: SplitRange,
189
+ ): number {
190
+ const value = Number.parseFloat(asked);
191
+ return Number.isFinite(value) ? clampSplit(value, range) : current;
192
+ }
193
+
194
+ /**
195
+ * How big the window is, which is what the box is taken to be until it has been
196
+ * measured. Reading it rather than starting at zero is what keeps a wide page
197
+ * from laying itself out stacked and splitting a frame later, which would build
198
+ * both figures twice on every load.
199
+ *
200
+ * @param across - Whether the box is divided across or down.
201
+ * @param fallback - What to answer where there is no window to read — a test,
202
+ * or a page rendered to a string.
203
+ * @returns The size in pixels.
204
+ */
205
+ function viewportSize(across: boolean, fallback: number): number {
206
+ const measure = across ? globalThis.innerWidth : globalThis.innerHeight;
207
+ return typeof measure === 'number' && measure > 0 ? measure : fallback;
208
+ }
209
+
210
+ /** The narrowest box still divided across, in pixels. */
211
+ const STACK_ACROSS = 860;
212
+
213
+ /**
214
+ * The shortest box still divided down, in pixels. Two figures sharing less than
215
+ * this have about two hundred pixels each, which is under what any of them
216
+ * reads at.
217
+ */
218
+ const STACK_DOWN = 420;
219
+
220
+ /** The gutter a pane keeps beside the splitter, in pixels. */
221
+ const GUTTER = 6;
222
+
223
+ /** The gap between the panes once they have stacked, in pixels. */
224
+ const STACKED_GAP = 16;
225
+
226
+ const STACK = {
227
+ display: 'flex',
228
+ flexDirection: 'column',
229
+ minHeight: 0,
230
+ } as const satisfies CSSProperties;
231
+
232
+ const STACKED_PANE = {
233
+ display: 'flex',
234
+ flexDirection: 'column',
235
+ minWidth: 0,
236
+ minHeight: 0,
237
+ } as const satisfies CSSProperties;
238
+
239
+ /** A column takes the height it is given, so its share has something to divide. */
240
+ const FILL = {
241
+ display: 'flex',
242
+ flex: '1 1 0%',
243
+ flexDirection: 'column',
244
+ minHeight: 0,
245
+ } as const satisfies CSSProperties;
246
+
247
+ function paneStyle(
248
+ across: boolean,
249
+ side: 'start' | 'end',
250
+ gutter: number,
251
+ ): CSSProperties {
252
+ const edge = across
253
+ ? side === 'start'
254
+ ? 'paddingRight'
255
+ : 'paddingLeft'
256
+ : side === 'start'
257
+ ? 'paddingBottom'
258
+ : 'paddingTop';
259
+ return {
260
+ display: 'flex',
261
+ flex: '1 1 0%',
262
+ flexDirection: 'column',
263
+ minWidth: 0,
264
+ minHeight: 0,
265
+ [edge]: gutter,
266
+ };
267
+ }