react-cheminfo 0.31.0 → 0.33.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 (103) hide show
  1. package/README.md +1 -0
  2. package/lib/core.d.ts +1 -0
  3. package/lib/core.d.ts.map +1 -1
  4. package/lib/core.js +1 -0
  5. package/lib/core.js.map +1 -1
  6. package/lib/i18n/core/chromeCatalog.d.ts +2 -2
  7. package/lib/i18n/core/chromeCatalog.d.ts.map +1 -1
  8. package/lib/i18n/core/chromeCatalog.js +10 -4
  9. package/lib/i18n/core/chromeCatalog.js.map +1 -1
  10. package/lib/i18n/core/index.d.ts +2 -0
  11. package/lib/i18n/core/index.d.ts.map +1 -1
  12. package/lib/i18n/core/index.js +1 -0
  13. package/lib/i18n/core/index.js.map +1 -1
  14. package/lib/i18n/core/languages.d.ts +4 -4
  15. package/lib/i18n/core/languages.d.ts.map +1 -1
  16. package/lib/i18n/core/languages.js +5 -4
  17. package/lib/i18n/core/languages.js.map +1 -1
  18. package/lib/i18n/core/prose.d.ts +32 -0
  19. package/lib/i18n/core/prose.d.ts.map +1 -0
  20. package/lib/i18n/core/prose.js +45 -0
  21. package/lib/i18n/core/prose.js.map +1 -0
  22. package/lib/i18n/ui/Prose.d.ts +28 -0
  23. package/lib/i18n/ui/Prose.d.ts.map +1 -0
  24. package/lib/i18n/ui/Prose.js +14 -0
  25. package/lib/i18n/ui/Prose.js.map +1 -0
  26. package/lib/i18n/ui/index.d.ts +2 -0
  27. package/lib/i18n/ui/index.d.ts.map +1 -1
  28. package/lib/i18n/ui/index.js +1 -0
  29. package/lib/i18n/ui/index.js.map +1 -1
  30. package/lib/locales/de.json +7 -1
  31. package/lib/locales/en.json +7 -1
  32. package/lib/locales/es.json +7 -1
  33. package/lib/locales/fr.json +7 -1
  34. package/lib/locales/it.json +459 -0
  35. package/lib/number/core/index.d.ts +2 -0
  36. package/lib/number/core/index.d.ts.map +1 -0
  37. package/lib/number/core/index.js +2 -0
  38. package/lib/number/core/index.js.map +1 -0
  39. package/lib/number/core/numberText.d.ts +46 -0
  40. package/lib/number/core/numberText.d.ts.map +1 -0
  41. package/lib/number/core/numberText.js +88 -0
  42. package/lib/number/core/numberText.js.map +1 -0
  43. package/lib/number/ui/NumberInput.d.ts +113 -0
  44. package/lib/number/ui/NumberInput.d.ts.map +1 -0
  45. package/lib/number/ui/NumberInput.js +100 -0
  46. package/lib/number/ui/NumberInput.js.map +1 -0
  47. package/lib/number/ui/index.d.ts +3 -0
  48. package/lib/number/ui/index.d.ts.map +1 -0
  49. package/lib/number/ui/index.js +2 -0
  50. package/lib/number/ui/index.js.map +1 -0
  51. package/lib/orbital/ui/AtomicOrbitalCanvas.d.ts +12 -2
  52. package/lib/orbital/ui/AtomicOrbitalCanvas.d.ts.map +1 -1
  53. package/lib/orbital/ui/AtomicOrbitalCanvas.js +17 -3
  54. package/lib/orbital/ui/AtomicOrbitalCanvas.js.map +1 -1
  55. package/lib/orbital/ui/AtomicOrbitalControls.d.ts +10 -5
  56. package/lib/orbital/ui/AtomicOrbitalControls.d.ts.map +1 -1
  57. package/lib/orbital/ui/AtomicOrbitalControls.js +12 -7
  58. package/lib/orbital/ui/AtomicOrbitalControls.js.map +1 -1
  59. package/lib/orbital/ui/AtomicOrbitalViewer.d.ts +8 -1
  60. package/lib/orbital/ui/AtomicOrbitalViewer.d.ts.map +1 -1
  61. package/lib/orbital/ui/AtomicOrbitalViewer.js.map +1 -1
  62. package/lib/periodic/ui/ElementCell.d.ts +10 -0
  63. package/lib/periodic/ui/ElementCell.d.ts.map +1 -1
  64. package/lib/periodic/ui/ElementCell.js +124 -14
  65. package/lib/periodic/ui/ElementCell.js.map +1 -1
  66. package/lib/periodic/ui/PeriodicTable.d.ts.map +1 -1
  67. package/lib/periodic/ui/PeriodicTable.js +46 -19
  68. package/lib/periodic/ui/PeriodicTable.js.map +1 -1
  69. package/lib/periodic/ui/PeriodicTableChrome.js +2 -2
  70. package/lib/periodic/ui/PeriodicTableChrome.js.map +1 -1
  71. package/lib/spectra/ui/NumberField.d.ts +2 -5
  72. package/lib/spectra/ui/NumberField.d.ts.map +1 -1
  73. package/lib/spectra/ui/NumberField.js +4 -54
  74. package/lib/spectra/ui/NumberField.js.map +1 -1
  75. package/lib/ui.d.ts +1 -0
  76. package/lib/ui.d.ts.map +1 -1
  77. package/lib/ui.js +1 -0
  78. package/lib/ui.js.map +1 -1
  79. package/package.json +1 -1
  80. package/src/core.ts +1 -0
  81. package/src/i18n/core/chromeCatalog.ts +10 -4
  82. package/src/i18n/core/index.ts +2 -0
  83. package/src/i18n/core/languages.ts +5 -4
  84. package/src/i18n/core/prose.ts +53 -0
  85. package/src/i18n/ui/Prose.tsx +47 -0
  86. package/src/i18n/ui/index.ts +2 -0
  87. package/src/locales/de.json +7 -1
  88. package/src/locales/en.json +7 -1
  89. package/src/locales/es.json +7 -1
  90. package/src/locales/fr.json +7 -1
  91. package/src/locales/it.json +459 -0
  92. package/src/number/core/index.ts +6 -0
  93. package/src/number/core/numberText.ts +91 -0
  94. package/src/number/ui/NumberInput.tsx +300 -0
  95. package/src/number/ui/index.ts +7 -0
  96. package/src/orbital/ui/AtomicOrbitalCanvas.tsx +32 -5
  97. package/src/orbital/ui/AtomicOrbitalControls.tsx +26 -6
  98. package/src/orbital/ui/AtomicOrbitalViewer.tsx +8 -1
  99. package/src/periodic/ui/ElementCell.tsx +153 -14
  100. package/src/periodic/ui/PeriodicTable.tsx +51 -22
  101. package/src/periodic/ui/PeriodicTableChrome.tsx +2 -2
  102. package/src/spectra/ui/NumberField.tsx +11 -58
  103. package/src/ui.ts +1 -0
@@ -4,9 +4,14 @@
4
4
  *
5
5
  * A cell is a real `<button>`, so Tab reaches it, Enter and Space activate it,
6
6
  * and a screen reader reads the element's name rather than its symbol.
7
+ *
8
+ * Everything written in it is sized in container-query units, so a cell shows
9
+ * the same proportions at 280px beside a chart and across a lecture-hall
10
+ * screen — the table a class reads is the same drawing, larger.
7
11
  */
8
12
 
9
13
  import type { CSSProperties, ReactElement } from 'react';
14
+ import { useState } from 'react';
10
15
 
11
16
  import type { Swatch } from '../../color/core/interpolate.ts';
12
17
  import { useChromeT } from '../../i18n/ui/useT.ts';
@@ -32,6 +37,12 @@ export interface ElementCellProps {
32
37
  * @default '' — nothing is written
33
38
  */
34
39
  detail?: string;
40
+ /**
41
+ * Whether the table this cell belongs to writes a third line at all. A table
42
+ * that writes none gives the room to the symbol, and its rows are shorter.
43
+ * @default whether this cell has one
44
+ */
45
+ writesDetail?: boolean;
35
46
  /**
36
47
  * Whether the cell is the one the tools are pointed at.
37
48
  * @default false
@@ -71,11 +82,13 @@ export function ElementCell(props: ElementCellProps): ReactElement {
71
82
  row,
72
83
  onSelect,
73
84
  detail = '',
85
+ writesDetail = detail !== '',
74
86
  isSelected = false,
75
87
  isIncluded = true,
76
88
  onHover,
77
89
  } = props;
78
90
  const t = useChromeT();
91
+ const [isHovered, setIsHovered] = useState(false);
79
92
 
80
93
  return (
81
94
  <button
@@ -83,6 +96,7 @@ export function ElementCell(props: ElementCellProps): ReactElement {
83
96
  className={className}
84
97
  data-testid={`element-${symbol}`}
85
98
  data-symbol={symbol}
99
+ title={detail === '' ? name : `${name} · ${detail}`}
86
100
  aria-label={t('periodic.cellLabel', {
87
101
  name,
88
102
  symbol,
@@ -92,8 +106,14 @@ export function ElementCell(props: ElementCellProps): ReactElement {
92
106
  onClick={() => {
93
107
  onSelect(symbol);
94
108
  }}
95
- onPointerEnter={() => onHover?.(symbol)}
96
- onPointerLeave={() => onHover?.(null)}
109
+ onPointerEnter={() => {
110
+ setIsHovered(true);
111
+ onHover?.(symbol);
112
+ }}
113
+ onPointerLeave={() => {
114
+ setIsHovered(false);
115
+ onHover?.(null);
116
+ }}
97
117
  style={{
98
118
  ...cellStyle,
99
119
  gridColumn: column,
@@ -103,17 +123,134 @@ export function ElementCell(props: ElementCellProps): ReactElement {
103
123
  opacity: isIncluded ? 1 : 0.28,
104
124
  // An outline rather than a fill: a table coloured by a property must
105
125
  // keep saying what the value is while a cell is selected.
106
- outline: isSelected ? `2px solid ${swatch.foreground}` : 'none',
107
- outlineOffset: -3,
126
+ outlineStyle: isSelected ? 'solid' : 'none',
127
+ outlineColor: swatch.foreground,
128
+ // Thick enough to be seen from the back of a room, and drawn inside
129
+ // the cell so a neighbour never covers it.
130
+ outlineWidth: 'max(2px, 0.3cqw)',
131
+ outlineOffset: 'max(-3px, -0.4cqw)',
132
+ // The cell the pointer is on, for a class following a demonstration.
133
+ filter: isHovered ? 'brightness(1.08)' : undefined,
108
134
  }}
109
135
  >
110
136
  <span style={numberStyle}>{atomicNumber}</span>
111
- <span style={symbolStyle}>{symbol}</span>
112
- {detail === '' ? null : <span style={detailStyle}>{detail}</span>}
137
+ <span
138
+ style={{
139
+ ...symbolStyle,
140
+ fontSize: symbolFontSize(symbol, writesDetail),
141
+ }}
142
+ >
143
+ {symbol}
144
+ </span>
145
+ {detail === '' ? null : (
146
+ <span style={{ ...detailStyle, fontSize: detailFontSize(detail) }}>
147
+ {detail}
148
+ </span>
149
+ )}
113
150
  </button>
114
151
  );
115
152
  }
116
153
 
154
+ /**
155
+ * The size the symbol is written at.
156
+ *
157
+ * Every symbol is written at the same size, except the widest of them — the
158
+ * ones whose second letter is an `m` — which are written a tenth smaller
159
+ * rather than run into the edges of their cell.
160
+ * @param symbol - The chemical symbol.
161
+ * @param writesDetail - Whether the table writes a third line under it.
162
+ * @returns A CSS length.
163
+ */
164
+ function symbolFontSize(symbol: string, writesDetail: boolean): string {
165
+ const cap = writesDetail ? SYMBOL_CQW : SYMBOL_ALONE_CQW;
166
+ return fitted(symbolWidthInEm(symbol), cap, '0.5rem');
167
+ }
168
+
169
+ /**
170
+ * The size the third line takes so the whole of it is written.
171
+ *
172
+ * A value cut short by an ellipsis is a wrong value — `0.0899` read as `0.0…`
173
+ * is worse than not writing it at all — so a long string is written smaller
174
+ * rather than trimmed.
175
+ * @param detail - What the third line says.
176
+ * @returns A CSS length.
177
+ */
178
+ function detailFontSize(detail: string): string {
179
+ return fitted(detailWidthInEm(detail), DETAIL_CQW, '0.25rem');
180
+ }
181
+
182
+ /**
183
+ * The largest size a string of the given width fits its cell at.
184
+ *
185
+ * The room a cell leaves is a share of the table's width less its border and
186
+ * its padding, which are the same few pixels at every size — hence the two
187
+ * units in one expression.
188
+ * @param widthInEm - How wide the string is, in ems of its own size.
189
+ * @param cap - The size it is written at when it has room to spare.
190
+ * @param floor - The size under which it is not shrunk any further.
191
+ * @returns A CSS length.
192
+ */
193
+ function fitted(widthInEm: number, cap: string, floor: string): string {
194
+ const room = `calc((${CELL_CQW} - ${CELL_CHROME}) / ${widthInEm.toFixed(2)})`;
195
+ return `max(${floor}, min(${cap}, ${room}))`;
196
+ }
197
+
198
+ /**
199
+ * How wide a symbol is, in ems of its own size.
200
+ * @param symbol - The chemical symbol.
201
+ * @returns Its width, never zero.
202
+ */
203
+ function symbolWidthInEm(symbol: string): number {
204
+ let width = 0;
205
+ for (const character of symbol) {
206
+ if (character === 'm' || character === 'w') {
207
+ width += 0.95;
208
+ } else if (character >= 'a' && character <= 'z') {
209
+ width += 0.63;
210
+ } else {
211
+ width += 0.85;
212
+ }
213
+ }
214
+ return Math.max(width, 0.85);
215
+ }
216
+
217
+ /**
218
+ * How wide the third line is, in ems of its own size.
219
+ *
220
+ * It is written with tabular figures, where every glyph — the decimal point
221
+ * and the minus of an exponent included — advances the same width, so counting
222
+ * the characters is the measurement.
223
+ * @param detail - What the third line says.
224
+ * @returns Its width, never zero.
225
+ */
226
+ function detailWidthInEm(detail: string): number {
227
+ return Math.max(detail.length, 1) * TABULAR_EM;
228
+ }
229
+
230
+ /** What one tabular glyph advances, as a share of its own size. */
231
+ const TABULAR_EM = 0.6;
232
+
233
+ /** The size the symbol is written at wherever it fits, as a share of the table. */
234
+ const SYMBOL_CQW = '2.4cqw';
235
+
236
+ /** The same, in a table that writes nothing under the symbol. */
237
+ const SYMBOL_ALONE_CQW = '2.8cqw';
238
+
239
+ /**
240
+ * The tallest the third line is ever written, as a share of the table.
241
+ *
242
+ * Close to three quarters of the symbol: the symbol is how a cell is found,
243
+ * but the value is what the table is being read for, and a value half the size
244
+ * of the symbol is the one thing on the cell a class cannot make out.
245
+ */
246
+ const DETAIL_CQW = '1.75cqw';
247
+
248
+ /** How wide one column is, as a share of the table's width. */
249
+ const CELL_CQW = '4.85cqw';
250
+
251
+ /** What the border and the padding of a cell take off that width. */
252
+ const CELL_CHROME = '4px';
253
+
117
254
  const cellStyle = {
118
255
  alignItems: 'center',
119
256
  border: '1px solid rgb(255 255 255 / 0.55)',
@@ -123,29 +260,31 @@ const cellStyle = {
123
260
  flexDirection: 'column',
124
261
  font: 'inherit',
125
262
  justifyContent: 'center',
126
- lineHeight: 1.05,
127
263
  minWidth: 0,
128
264
  overflow: 'hidden',
129
- padding: 1,
130
- transition: 'opacity 120ms ease',
265
+ padding: '1px',
266
+ transition: 'opacity 120ms ease, filter 120ms ease',
131
267
  } as const satisfies CSSProperties;
132
268
 
133
269
  const numberStyle = {
134
270
  alignSelf: 'flex-start',
135
- fontSize: 'clamp(0.34rem, 1.6cqw, 0.5rem)',
271
+ fontSize: 'max(0.33rem, 1.2cqw)',
272
+ fontVariantNumeric: 'tabular-nums',
273
+ lineHeight: 1,
136
274
  opacity: 0.8,
137
- paddingLeft: 2,
275
+ paddingLeft: '0.15cqw',
138
276
  } as const satisfies CSSProperties;
139
277
 
140
278
  const symbolStyle = {
141
- fontSize: 'clamp(0.55rem, 3.3cqw, 1.05rem)',
142
279
  fontWeight: 700,
280
+ letterSpacing: '-0.02em',
281
+ lineHeight: 1.02,
143
282
  } as const satisfies CSSProperties;
144
283
 
145
284
  const detailStyle = {
146
- fontSize: 'clamp(0.34rem, 1.6cqw, 0.5rem)',
285
+ fontVariantNumeric: 'tabular-nums',
286
+ lineHeight: 1,
147
287
  maxWidth: '100%',
148
288
  overflow: 'hidden',
149
- textOverflow: 'ellipsis',
150
289
  whiteSpace: 'nowrap',
151
290
  } as const satisfies CSSProperties;
@@ -129,6 +129,7 @@ export function PeriodicTable(props: PeriodicTableProps): ReactElement {
129
129
  } = props;
130
130
 
131
131
  const t = useChromeT();
132
+ const writesDetail = detailOf !== undefined;
132
133
  const gridRef = useRef<HTMLDivElement>(null);
133
134
  const cameFromKeyRef = useRef(false);
134
135
  const offset = headers ? 1 : 0;
@@ -159,7 +160,7 @@ export function PeriodicTable(props: PeriodicTableProps): ReactElement {
159
160
  role="grid"
160
161
  aria-label={t('periodic.table')}
161
162
  data-testid="periodic-table"
162
- style={headers ? gridWithHeadersStyle : gridStyle}
163
+ style={gridStyle(headers, writesDetail)}
163
164
  onKeyDown={handleKeyDown}
164
165
  >
165
166
  {headers ? <HeaderStrips onSelectRange={onSelectRange} /> : null}
@@ -171,6 +172,7 @@ export function PeriodicTable(props: PeriodicTableProps): ReactElement {
171
172
  symbol={element.symbol}
172
173
  name={nameOf(element)}
173
174
  detail={detailOf?.(element)}
175
+ writesDetail={writesDetail}
174
176
  swatch={swatchOf(element)}
175
177
  isSelected={element.symbol === selected}
176
178
  isIncluded={isIncluded?.(element)}
@@ -199,37 +201,64 @@ function noop(): void {
199
201
  // keyboard and a screen reader still reach every element.
200
202
  }
201
203
 
204
+ /** Narrowest the table is ever drawn; under it the type stops being readable. */
205
+ const MIN_WIDTH = 280;
206
+
207
+ /**
208
+ * How tall one row of elements is, as a share of the table's width.
209
+ *
210
+ * A column is about 5.4% of that width, so a cell is a seventh taller than it
211
+ * is wide — the proportion of a wall chart, and the room the three lines of a
212
+ * cell need to be written at a size a class can read.
213
+ */
214
+ const ROW_HEIGHT = '6.1cqw';
215
+
216
+ /** The same, in a table that writes nothing under the symbol. */
217
+ const ROW_HEIGHT_ALONE = '4.9cqw';
218
+
219
+ /** The band the two inner-transition series were lifted out across. */
220
+ const SERIES_GAP = 'max(6px, 1.1cqw)';
221
+
202
222
  const rootStyle = {
203
223
  display: 'flex',
204
224
  flexDirection: 'column',
205
225
  gap: 8,
226
+ minWidth: MIN_WIDTH,
227
+ // Everything inside — the height of a row as much as the type in a cell — is
228
+ // a share of this box rather than of the page, so the same table reads at
229
+ // 280px beside a chart and fills a lecture-hall screen at twice that.
230
+ containerType: 'inline-size',
206
231
  } as const satisfies CSSProperties;
207
232
 
208
233
  const baseGridStyle = {
209
234
  display: 'grid',
210
235
  gap: 2,
236
+ minWidth: MIN_WIDTH,
211
237
  width: '100%',
212
- // The cells size their type against this box rather than against the page,
213
- // so the same table reads at 320px beside a chart and at 900px on its own.
214
- containerType: 'inline-size',
215
238
  } as const satisfies CSSProperties;
216
239
 
217
- const gridStyle = {
218
- ...baseGridStyle,
240
+ /**
241
+ * The grid the cells are placed on.
242
+ * @param headers - Whether a leading column and row hold the period and group
243
+ * numbers.
244
+ * @param writesDetail - Whether a cell writes a third line, which is what a
245
+ * row is tall enough for.
246
+ * @returns The style of the grid.
247
+ */
248
+ function gridStyle(headers: boolean, writesDetail: boolean): CSSProperties {
249
+ const row = writesDetail ? ROW_HEIGHT : ROW_HEIGHT_ALONE;
219
250
  // The eighth row is the gap the inner-transition series are lifted out into.
220
- gridTemplateColumns: `repeat(${String(COLUMN_COUNT)}, minmax(0, 1fr))`,
221
- gridTemplateRows:
222
- 'repeat(7, minmax(0, 1fr)) 0.5rem repeat(2, minmax(0, 1fr))',
223
- aspectRatio: `${String(COLUMN_COUNT)} / 9.7`,
224
- minWidth: 280,
225
- } as const satisfies CSSProperties;
226
-
227
- const gridWithHeadersStyle = {
228
- ...baseGridStyle,
229
- // A leading column and a leading row hold the period and group numbers.
230
- gridTemplateColumns: `1.4rem repeat(${String(COLUMN_COUNT)}, minmax(0, 1fr))`,
231
- gridTemplateRows:
232
- '1rem repeat(7, minmax(0, 1fr)) 0.5rem repeat(2, minmax(0, 1fr))',
233
- aspectRatio: `${String(COLUMN_COUNT + 1.2)} / 10.6`,
234
- minWidth: 280,
235
- } as const satisfies CSSProperties;
251
+ const rows = `repeat(7, ${row}) ${SERIES_GAP} repeat(2, ${row})`;
252
+ if (!headers) {
253
+ return {
254
+ ...baseGridStyle,
255
+ gridTemplateColumns: `repeat(${String(COLUMN_COUNT)}, minmax(0, 1fr))`,
256
+ gridTemplateRows: rows,
257
+ };
258
+ }
259
+ return {
260
+ ...baseGridStyle,
261
+ gridTemplateColumns: `max(14px, 2.4cqw) repeat(${String(COLUMN_COUNT)}, minmax(0, 1fr))`,
262
+ gridTemplateRows: `max(12px, 2cqw) ${rows}`,
263
+ };
264
+ }
@@ -150,7 +150,7 @@ const headerStyle = {
150
150
  alignItems: 'center',
151
151
  color: TOKEN.textMuted,
152
152
  display: 'flex',
153
- fontSize: 'clamp(0.4rem, 1.9cqw, 0.62rem)',
153
+ fontSize: 'max(0.45rem, 1.35cqw)',
154
154
  justifyContent: 'center',
155
155
  padding: 0,
156
156
  } as const satisfies CSSProperties;
@@ -168,6 +168,6 @@ const markerStyle = {
168
168
  borderRadius: 3,
169
169
  color: TOKEN.textMuted,
170
170
  display: 'flex',
171
- fontSize: 'clamp(0.36rem, 1.7cqw, 0.55rem)',
171
+ fontSize: 'max(0.4rem, 1.15cqw)',
172
172
  justifyContent: 'center',
173
173
  } as const satisfies CSSProperties;
@@ -1,6 +1,6 @@
1
- import { InputGroup } from '@blueprintjs/core';
2
1
  import type { ReactElement } from 'react';
3
- import { useState } from 'react';
2
+
3
+ import { NumberInput } from '../../number/ui/NumberInput.tsx';
4
4
 
5
5
  import { HELP_STYLE, LABEL_STYLE, sizedFieldStyle } from './fieldStyles.ts';
6
6
 
@@ -35,77 +35,30 @@ interface NumberFieldProps {
35
35
  /**
36
36
  * One number of the settings.
37
37
  *
38
- * The box keeps what was typed rather than what parsed, because a reader
39
- * halfway through `1e-` has typed something no number can hold yet; clearing it
40
- * under them would make the field unusable. Nothing is handed up until the text
41
- * reads as a number, and emptying the box hands up nothing at all — which is
42
- * how a reader gets back to upstream's default after typing over it.
38
+ * Emptying the box hands up nothing at all, which is how a reader gets back to
39
+ * upstream's default after typing over it.
43
40
  * @param props - See {@link NumberFieldProps}.
44
41
  * @returns The labelled box.
45
42
  */
46
43
  export function NumberField(props: NumberFieldProps): ReactElement {
47
44
  const { label, value, placeholder, onChange, integer = false, help } = props;
48
- const [draft, setDraft] = useState(() => ({
49
- text: asText(value),
50
- held: value,
51
- }));
52
-
53
- // The settings changed from somewhere else — a preset, a reset, a pasted
54
- // configuration — so the box has to follow rather than hold the old text.
55
- // `held` is what this box last handed up, not the prop it was rendered with:
56
- // comparing against the prop would make the box's own edit look external and
57
- // rewrite `-0.` as `0` under the caret before the reader reaches the digits.
58
- if (draft.held !== value) setDraft({ text: asText(value), held: value });
59
45
 
60
46
  return (
61
47
  <label style={FIELD_STYLE}>
62
48
  <span style={LABEL_STYLE}>{label}</span>
63
- <InputGroup
64
- size="small"
49
+ <NumberInput
50
+ allowEmpty
51
+ buttons={false}
65
52
  fill
66
- inputMode={integer ? 'numeric' : 'decimal'}
67
- value={draft.text}
53
+ size="small"
54
+ value={value}
68
55
  placeholder={placeholder}
69
- spellCheck={false}
70
- autoComplete="off"
71
- onValueChange={(text) => {
72
- const parsed = read(text, integer);
73
- const emptied = text.trim() === '';
74
- setDraft({ text, held: emptied ? undefined : (parsed ?? value) });
75
- if (emptied) {
76
- onChange(undefined);
77
- } else if (parsed !== undefined) {
78
- onChange(parsed);
79
- }
80
- }}
56
+ integer={integer}
57
+ onChange={onChange}
81
58
  />
82
59
  {help === undefined ? null : <span style={HELP_STYLE}>{help}</span>}
83
60
  </label>
84
61
  );
85
62
  }
86
63
 
87
- /**
88
- * The number as the box shows it.
89
- * @param value - What the settings hold.
90
- * @returns Its text, empty when nothing is held.
91
- */
92
- function asText(value: number | undefined): string {
93
- return value === undefined ? '' : String(value);
94
- }
95
-
96
- /**
97
- * The text as a number, when it reads as one.
98
- * @param text - What was typed.
99
- * @param integer - Whether only whole numbers make sense.
100
- * @returns The number, or undefined while the text is not one yet.
101
- */
102
- function read(text: string, integer: boolean): number | undefined {
103
- const trimmed = text.trim();
104
- if (trimmed === '') return undefined;
105
- const parsed = Number(trimmed);
106
- if (!Number.isFinite(parsed)) return undefined;
107
- if (integer && !Number.isInteger(parsed)) return undefined;
108
- return parsed;
109
- }
110
-
111
64
  const FIELD_STYLE = sizedFieldStyle(120);
package/src/ui.ts CHANGED
@@ -16,6 +16,7 @@ export * from './help/ui/index.ts';
16
16
  export * from './hooks/ui/index.ts';
17
17
  export * from './i18n/ui/index.ts';
18
18
  export * from './language/ui/index.ts';
19
+ export * from './number/ui/index.ts';
19
20
  export * from './overlay/ui/index.ts';
20
21
  export * from './panel/ui/index.ts';
21
22
  export * from './parallel/ui/index.ts';