@pie-lib/graphing 4.0.15 → 4.0.17

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.
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { render } from '@pie-lib/test-utils';
2
+ import { act, fireEvent, render } from '@pie-lib/test-utils';
3
3
  import { graphProps } from './utils';
4
4
  import Graph, { removeBuildingToolIfCurrentToolDiffers } from '../graph';
5
5
  import { toolsArr } from '../tools';
@@ -719,3 +719,59 @@ describe('Graph', () => {
719
719
  });
720
720
  });
721
721
  });
722
+
723
+ describe('label focus', () => {
724
+ // the marks are owned by the host and come back from a model save asynchronously; a mark that
725
+ // shows up in front of the labelled one changes its key, so the tool that handled the click is
726
+ // remounted and the editingLabel it set is gone before the label input ever renders
727
+ const Host = ({ shiftMarks }) => {
728
+ const [marks, setMarks] = React.useState([{ type: 'point', x: 2, y: 2 }]);
729
+
730
+ return (
731
+ <Graph
732
+ tools={toolsArr}
733
+ marks={marks}
734
+ labelModeEnabled={true}
735
+ onChangeMarks={(next) =>
736
+ setTimeout(() => setMarks(shiftMarks ? [{ type: 'point', x: 5, y: 5 }, ...next] : next), 30)
737
+ }
738
+ domain={{ min: 0, max: 10, step: 1 }}
739
+ range={{ min: 0, max: 10, step: 1 }}
740
+ size={{ width: 400, height: 400 }}
741
+ {...graphProps(0, 10, 0, 10)}
742
+ />
743
+ );
744
+ };
745
+
746
+ const clickPoint = (container) => {
747
+ const circle = container.querySelector('circle');
748
+
749
+ fireEvent.mouseDown(circle, { button: 0 });
750
+ fireEvent.mouseUp(circle, { button: 0 });
751
+ fireEvent.click(circle, { button: 0 });
752
+ act(() => jest.advanceTimersByTime(200));
753
+ };
754
+
755
+ beforeEach(() => jest.useFakeTimers());
756
+ afterEach(() => jest.useRealTimers());
757
+
758
+ it('focuses the label that was just added', () => {
759
+ const { container } = render(<Host />);
760
+
761
+ clickPoint(container);
762
+
763
+ const input = container.querySelector('foreignObject input');
764
+ expect(input).not.toBe(null);
765
+ expect(document.activeElement).toBe(input);
766
+ });
767
+
768
+ it('focuses the label even if the tool was remounted before it rendered', () => {
769
+ const { container } = render(<Host shiftMarks={true} />);
770
+
771
+ clickPoint(container);
772
+
773
+ const input = container.querySelector('foreignObject input');
774
+ expect(input).not.toBe(null);
775
+ expect(document.activeElement).toBe(input);
776
+ });
777
+ });
@@ -1,4 +1,4 @@
1
- import { render } from '@pie-lib/test-utils';
1
+ import { act, fireEvent, render } from '@pie-lib/test-utils';
2
2
  import React from 'react';
3
3
  import { coordinates, MarkLabel, position } from '../mark-label';
4
4
  import { graphProps as getGraphProps } from './utils';
@@ -30,6 +30,265 @@ describe('MarkLabel', () => {
30
30
  });
31
31
  });
32
32
 
33
+ describe('MarkLabel - editing', () => {
34
+ let onChange;
35
+ let onBlur;
36
+
37
+ beforeEach(() => {
38
+ onChange = jest.fn();
39
+ onBlur = jest.fn();
40
+ });
41
+
42
+ // the label input is the only input MarkLabel renders (react-input-autosize)
43
+ const input = (container) => container.querySelector('input');
44
+
45
+ const renderComponent = (extras) => {
46
+ const defaults = {
47
+ onChange,
48
+ onBlur,
49
+ inputRef: jest.fn(),
50
+ mark: { x: 1, y: 1, label: '' },
51
+ graphProps: getGraphProps(0, 10, 0, 10),
52
+ };
53
+ const props = { ...defaults, ...extras };
54
+ const result = render(<MarkLabel {...props} />);
55
+
56
+ return {
57
+ ...result,
58
+ input: input(result.container),
59
+ rerenderWith: (next) => result.rerender(<MarkLabel {...props} {...next} />),
60
+ };
61
+ };
62
+
63
+ describe('autoFocus', () => {
64
+ it('focuses the input so a new label can be typed into right away', () => {
65
+ const { input } = renderComponent({ autoFocus: true });
66
+
67
+ expect(document.activeElement).toBe(input);
68
+ });
69
+
70
+ it('leaves the caret at the end of an existing label', () => {
71
+ const { input } = renderComponent({ autoFocus: true, mark: { x: 1, y: 1, label: 'AB' } });
72
+
73
+ expect(document.activeElement).toBe(input);
74
+ expect(input.selectionStart).toBe(2);
75
+ expect(input.selectionEnd).toBe(2);
76
+ });
77
+
78
+ it('does not focus the input of an existing label when autoFocus is not set', () => {
79
+ const { input } = renderComponent({ mark: { x: 1, y: 1, label: 'A' } });
80
+
81
+ expect(document.activeElement).not.toBe(input);
82
+ });
83
+
84
+ it('does not focus the input when disabled', () => {
85
+ const { input } = renderComponent({ autoFocus: true, disabled: true });
86
+
87
+ expect(document.activeElement).not.toBe(input);
88
+ });
89
+
90
+ it('does not focus the input of a disabled mark', () => {
91
+ const { input } = renderComponent({ autoFocus: true, mark: { x: 1, y: 1, label: '', disabled: true } });
92
+
93
+ expect(document.activeElement).not.toBe(input);
94
+ });
95
+
96
+ // the focus can go away without this component hearing about it - the input node gets replaced
97
+ // by a render higher up, so no blur is fired and autoFocus is still on
98
+ it('takes the focus back on the next render while it is still being edited', () => {
99
+ const { input, rerenderWith } = renderComponent({ autoFocus: true, mark: { x: 1, y: 1, label: 'A' } });
100
+
101
+ expect(document.activeElement).toBe(input);
102
+
103
+ input.blur();
104
+ expect(document.activeElement).not.toBe(input);
105
+
106
+ rerenderWith({ mark: { x: 1, y: 1, label: 'A' } });
107
+
108
+ expect(document.activeElement).toBe(input);
109
+ });
110
+
111
+ it('leaves the caret where the user put it while the input has the focus', () => {
112
+ const { input, rerenderWith } = renderComponent({ autoFocus: true, mark: { x: 1, y: 1, label: 'ABC' } });
113
+
114
+ input.setSelectionRange(1, 1);
115
+
116
+ rerenderWith({ mark: { x: 1, y: 1, label: 'ABC' } });
117
+
118
+ expect(document.activeElement).toBe(input);
119
+ expect(input.selectionStart).toBe(1);
120
+ });
121
+
122
+ it('focuses the input once it is no longer disabled', () => {
123
+ const { rerenderWith, container } = renderComponent({ autoFocus: true, disabled: true });
124
+
125
+ rerenderWith({ disabled: false });
126
+
127
+ expect(document.activeElement).toBe(input(container));
128
+ });
129
+
130
+ // the host saves the model with an api call and the model that comes back can replace the
131
+ // input node - the label has to keep the focus across that remount
132
+ it('re-focuses the input when it is remounted while being edited', () => {
133
+ const props = {
134
+ autoFocus: true,
135
+ onChange,
136
+ inputRef: jest.fn(),
137
+ mark: { x: 1, y: 1, label: 'A' },
138
+ graphProps: getGraphProps(0, 10, 0, 10),
139
+ };
140
+ const { container, rerender } = render(<MarkLabel key="first" {...props} />);
141
+ const first = input(container);
142
+
143
+ expect(document.activeElement).toBe(first);
144
+
145
+ rerender(<MarkLabel key="second" {...props} />);
146
+ const second = input(container);
147
+
148
+ expect(second).not.toBe(first);
149
+ expect(document.activeElement).toBe(second);
150
+ });
151
+ });
152
+
153
+ // the tool asks for the focus with autoFocus, but its state can be gone before the input renders:
154
+ // the marks come back from the host asynchronously and can remount the tool. An empty label in
155
+ // label mode is a label the user has just added, so the input can tell on its own.
156
+ describe('a label that was just added', () => {
157
+ it('focuses itself without the tool asking', () => {
158
+ const { input } = renderComponent({ mark: { x: 1, y: 1, label: '' } });
159
+
160
+ expect(document.activeElement).toBe(input);
161
+ });
162
+
163
+ it('focuses itself again when it is remounted before it was typed into', () => {
164
+ const props = {
165
+ onChange,
166
+ onBlur,
167
+ inputRef: jest.fn(),
168
+ mark: { x: 1, y: 1, label: '' },
169
+ graphProps: getGraphProps(0, 10, 0, 10),
170
+ };
171
+ const { container, rerender } = render(<MarkLabel key="first" {...props} />);
172
+ const first = input(container);
173
+
174
+ rerender(<MarkLabel key="second" {...props} />);
175
+ const second = input(container);
176
+
177
+ expect(second).not.toBe(first);
178
+ expect(document.activeElement).toBe(second);
179
+ });
180
+
181
+ it('stops chasing the focus once it has been left', () => {
182
+ const { input, rerenderWith } = renderComponent({ mark: { x: 1, y: 1, label: '' } });
183
+
184
+ expect(document.activeElement).toBe(input);
185
+
186
+ input.blur();
187
+ rerenderWith({ mark: { x: 1, y: 1, label: '' } });
188
+
189
+ expect(document.activeElement).not.toBe(input);
190
+ });
191
+
192
+ // outside of label mode the input is disabled, which is how an empty label that was loaded with
193
+ // the item is told apart from one the user has just added - label mode always starts off
194
+ it('is not focused while label mode is off', () => {
195
+ const { input } = renderComponent({ disabled: true, mark: { x: 1, y: 1, label: '' } });
196
+
197
+ expect(document.activeElement).not.toBe(input);
198
+ });
199
+
200
+ it('is not focused when label mode is turned on afterwards', () => {
201
+ const { rerenderWith, container } = renderComponent({ disabled: true, mark: { x: 1, y: 1, label: '' } });
202
+
203
+ rerenderWith({ disabled: false });
204
+
205
+ expect(document.activeElement).not.toBe(input(container));
206
+ });
207
+
208
+ it('is not focused when the mark is disabled', () => {
209
+ const { input } = renderComponent({ mark: { x: 1, y: 1, label: '', disabled: true } });
210
+
211
+ expect(document.activeElement).not.toBe(input);
212
+ });
213
+ });
214
+
215
+ describe('syncing mark.label into the input', () => {
216
+ it('picks up a new mark.label while the input does not have the focus', () => {
217
+ const { rerenderWith, container } = renderComponent({ mark: { x: 1, y: 1, label: 'A' } });
218
+
219
+ rerenderWith({ mark: { x: 1, y: 1, label: 'updated' } });
220
+
221
+ expect(input(container).value).toBe('updated');
222
+ });
223
+
224
+ // a mark coming back from an (async) model save can carry an older label - applying it would
225
+ // wipe out the characters typed in the meantime
226
+ it('keeps what the user typed while the input has the focus', () => {
227
+ const { input, rerenderWith } = renderComponent({ autoFocus: true });
228
+
229
+ expect(document.activeElement).toBe(input);
230
+
231
+ fireEvent.change(input, { target: { value: 'AB' } });
232
+ expect(input.value).toBe('AB');
233
+
234
+ // the host echoes back the label as it was one keystroke ago
235
+ rerenderWith({ mark: { x: 1, y: 1, label: 'A' } });
236
+
237
+ expect(input.value).toBe('AB');
238
+ });
239
+ });
240
+
241
+ describe('onBlur', () => {
242
+ it('is called so the tool stops auto focusing the label', () => {
243
+ const { input } = renderComponent({ autoFocus: true, mark: { x: 1, y: 1, label: 'A' } });
244
+
245
+ fireEvent.blur(input);
246
+
247
+ expect(onBlur).toHaveBeenCalled();
248
+ });
249
+
250
+ it('reports an empty label on blur', () => {
251
+ const { input } = renderComponent({ mark: { x: 1, y: 1, label: 'A' } });
252
+
253
+ fireEvent.change(input, { target: { value: '' } });
254
+ fireEvent.blur(input);
255
+
256
+ expect(onChange).toHaveBeenCalledWith('');
257
+ });
258
+ });
259
+
260
+ describe('debounce', () => {
261
+ beforeEach(() => jest.useFakeTimers());
262
+ afterEach(() => jest.useRealTimers());
263
+
264
+ it('does not report the typed label before the debounce delay elapses', () => {
265
+ const { input } = renderComponent({ autoFocus: true });
266
+
267
+ fireEvent.change(input, { target: { value: 'A' } });
268
+
269
+ act(() => jest.advanceTimersByTime(499));
270
+ expect(onChange).not.toHaveBeenCalled();
271
+
272
+ act(() => jest.advanceTimersByTime(1));
273
+ expect(onChange).toHaveBeenCalledWith('A');
274
+ });
275
+
276
+ it('reports the label once for a burst of keystrokes', () => {
277
+ const { input } = renderComponent({ autoFocus: true });
278
+
279
+ fireEvent.change(input, { target: { value: 'A' } });
280
+ act(() => jest.advanceTimersByTime(300));
281
+ fireEvent.change(input, { target: { value: 'AB' } });
282
+ act(() => jest.advanceTimersByTime(300));
283
+ fireEvent.change(input, { target: { value: 'ABC' } });
284
+ act(() => jest.advanceTimersByTime(500));
285
+
286
+ expect(onChange).toHaveBeenCalledTimes(1);
287
+ expect(onChange).toHaveBeenCalledWith('ABC');
288
+ });
289
+ });
290
+ });
291
+
33
292
  describe('position', () => {
34
293
  const assertPosition = (mark, rect, expected) => {
35
294
  it(`${mark.x},${mark.y} + ${rect.width},${rect.height} => ${expected}`, () => {
@@ -1,4 +1,4 @@
1
- import React, { useCallback, useEffect, useState } from 'react';
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import PropTypes from 'prop-types';
3
3
  import { styled, useTheme } from '@mui/material/styles';
4
4
  import AutosizeInput from 'react-input-autosize';
@@ -7,6 +7,8 @@ import { types } from '@pie-lib/plot';
7
7
  import { color } from '@pie-lib/render-ui';
8
8
  import SvgIcon from './label-svg-icon';
9
9
 
10
+ const DEBOUNCE_DELAY = 500;
11
+
10
12
  const StyledInputCorrect = styled('div')(({ theme }) => ({
11
13
  float: 'right',
12
14
  padding: theme.spacing(0.5),
@@ -128,27 +130,56 @@ LabelInput.propTypes = {
128
130
  };
129
131
 
130
132
  export const MarkLabel = (props) => {
133
+ // the node is kept in a ref as well as in the state: the state is needed to reposition the label
134
+ // once it has a size, the ref is what the focus is asserted on - it is never a render behind
135
+ const inputNode = useRef(null);
131
136
  const [input, setInput] = useState(null);
132
- const _ref = useCallback((node) => setInput(node));
137
+ const _ref = useCallback((node) => {
138
+ inputNode.current = node;
139
+ setInput(node);
140
+ }, []);
133
141
  const theme = useTheme();
134
142
 
135
- const { mark, graphProps, disabled, inputRef: externalInputRef } = props;
143
+ const { mark, graphProps, disabled, autoFocus, inputRef: externalInputRef } = props;
136
144
 
137
145
  const [label, setLabel] = useState(mark.label);
138
146
  const { correctness, correctnesslabel, correctlabel } = mark;
139
147
 
148
+ const isFocused = () =>
149
+ !!inputNode.current && typeof document !== 'undefined' && document.activeElement === inputNode.current;
150
+
151
+ // A label that has just been added is empty and its input is enabled - label mode has to be on
152
+ // for that to happen and it always starts off, so a mark that was loaded with an empty label
153
+ // cannot grab the focus. This is read from the mark rather than taken from the autoFocus prop
154
+ // because the tool that asked for the label can be remounted by the marks coming back from the
155
+ // host (async model save) before the input ever renders, and its autoFocus goes with it.
156
+ const isNewLabel = useRef(mark.label === '' && !disabled && !mark.disabled);
157
+
140
158
  const onChange = (e) => setLabel(e.target.value);
141
159
 
142
160
  const handleBlur = useCallback(() => {
161
+ isNewLabel.current = false;
162
+
143
163
  if (label === '') {
144
164
  props.onChange('');
145
165
  }
146
- }, [label, props.onChange]);
147
166
 
148
- const debouncedLabel = useDebounce(label, 200);
167
+ // lets the tool know that this label is not being edited anymore, so it stops auto focusing it
168
+ if (props.onBlur) {
169
+ props.onBlur();
170
+ }
171
+ }, [label, props.onChange, props.onBlur]);
172
+
173
+ const debouncedLabel = useDebounce(label, DEBOUNCE_DELAY);
149
174
 
150
- // useState only sets the value once, to synch props to state need useEffect
175
+ // useState only sets the value once, to synch props to state need useEffect.
176
+ // While this input has the focus we keep what the user typed: the mark can come back from an
177
+ // (async) model save with an older label and that would overwrite the characters typed meanwhile.
151
178
  useEffect(() => {
179
+ if (isFocused()) {
180
+ return;
181
+ }
182
+
152
183
  setLabel(mark.label);
153
184
  }, [mark.label]);
154
185
 
@@ -159,6 +190,35 @@ export const MarkLabel = (props) => {
159
190
  }
160
191
  }, [debouncedLabel]);
161
192
 
193
+ // A label that has just been added has to be focused so that it can be typed into right away.
194
+ // This runs after every render on purpose - there is no reliable single moment to focus at:
195
+ // the input shows up whenever the mark comes back from the (async) model save, and a remount
196
+ // of the input (same save, replaced node) drops the focus without this component being told.
197
+ // Asserting the focus on every render is what makes it stick; it is a no-op once the input has
198
+ // it, so the caret the user placed is left alone.
199
+ useEffect(() => {
200
+ const node = inputNode.current;
201
+
202
+ // node.disabled, not the disabled prop: a disabled input silently ignores focus()
203
+ if ((!autoFocus && !isNewLabel.current) || !node || node.disabled || isFocused()) {
204
+ return;
205
+ }
206
+
207
+ node.focus();
208
+
209
+ // a new label is focused once, so it cannot fight over the focus with another one
210
+ if (isFocused()) {
211
+ isNewLabel.current = false;
212
+ }
213
+
214
+ // keep the caret at the end, otherwise typing continues in front of the existing label
215
+ const caret = (node.value || '').length;
216
+
217
+ if (node.setSelectionRange) {
218
+ node.setSelectionRange(caret, caret);
219
+ }
220
+ });
221
+
162
222
  const rect = input ? input.getBoundingClientRect() : { width: 0, height: 0 };
163
223
  const pos = position(graphProps, mark, rect);
164
224
  const leftTop = coordinates(graphProps, mark, rect, pos);
@@ -234,7 +294,9 @@ export const MarkLabel = (props) => {
234
294
  };
235
295
 
236
296
  MarkLabel.propTypes = {
297
+ autoFocus: PropTypes.bool,
237
298
  disabled: PropTypes.bool,
299
+ onBlur: PropTypes.func,
238
300
  onChange: PropTypes.func,
239
301
  graphProps: types.GraphPropsType,
240
302
  inputRef: PropTypes.func,
@@ -49,6 +49,16 @@ export class RawBaseCircle extends React.Component {
49
49
  onClick: () => ({}),
50
50
  };
51
51
 
52
+ // which of from/to/middle has its label edited, null if none
53
+ state = { editingLabel: null };
54
+
55
+ componentDidUpdate(prevProps) {
56
+ // leaving label mode closes any label that is still being edited
57
+ if (prevProps.labelModeEnabled && !this.props.labelModeEnabled && this.state.editingLabel !== null) {
58
+ this.setState({ editingLabel: null });
59
+ }
60
+ }
61
+
52
62
  onChangePoint = (point) => {
53
63
  const { middle, onChange } = this.props;
54
64
  const { from, to } = point;
@@ -102,6 +112,12 @@ export class RawBaseCircle extends React.Component {
102
112
  changeMarkProps({ [type]: update });
103
113
  };
104
114
 
115
+ stopEditingLabel = () => {
116
+ if (this.state.editingLabel !== null) {
117
+ this.setState({ editingLabel: null });
118
+ }
119
+ };
120
+
105
121
  clickPoint = (point, type, data) => {
106
122
  const { changeMarkProps, disabled, from, to, middle, labelModeEnabled, limitLabeling, onClick } = this.props;
107
123
 
@@ -122,9 +138,9 @@ export class RawBaseCircle extends React.Component {
122
138
  [type]: { label: '', ...point },
123
139
  });
124
140
 
125
- if (this.input[type]) {
126
- this.input[type].focus();
127
- }
141
+ // MarkLabel focuses itself once it is rendered - it also holds on to the focus if the model
142
+ // that comes back from the host (api save) remounts the input.
143
+ this.setState({ editingLabel: type });
128
144
  };
129
145
 
130
146
  input = {};
@@ -145,6 +161,7 @@ export class RawBaseCircle extends React.Component {
145
161
  labelNode,
146
162
  labelModeEnabled,
147
163
  } = this.props;
164
+ const { editingLabel } = this.state;
148
165
 
149
166
  const common = { onDragStart, onDragStop, graphProps, onClick };
150
167
  to = to || from;
@@ -159,9 +176,11 @@ export class RawBaseCircle extends React.Component {
159
176
  fromLabelNode = ReactDOM.createPortal(
160
177
  <MarkLabel
161
178
  inputRef={(r) => (this.input.from = r)}
179
+ autoFocus={editingLabel === 'from'}
162
180
  disabled={!labelModeEnabled}
163
181
  mark={from}
164
182
  graphProps={graphProps}
183
+ onBlur={this.stopEditingLabel}
165
184
  onChange={(label) => this.labelChange({ ...from, label }, 'from')}
166
185
  />,
167
186
  labelNode,
@@ -172,9 +191,11 @@ export class RawBaseCircle extends React.Component {
172
191
  toLabelNode = ReactDOM.createPortal(
173
192
  <MarkLabel
174
193
  inputRef={(r) => (this.input.to = r)}
194
+ autoFocus={editingLabel === 'to'}
175
195
  disabled={!labelModeEnabled}
176
196
  mark={to}
177
197
  graphProps={graphProps}
198
+ onBlur={this.stopEditingLabel}
178
199
  onChange={(label) => this.labelChange({ ...to, label }, 'to')}
179
200
  />,
180
201
  labelNode,
@@ -185,9 +206,11 @@ export class RawBaseCircle extends React.Component {
185
206
  circleLabelNode = ReactDOM.createPortal(
186
207
  <MarkLabel
187
208
  inputRef={(r) => (this.input.middle = r)}
209
+ autoFocus={editingLabel === 'middle'}
188
210
  disabled={!labelModeEnabled}
189
211
  mark={middle}
190
212
  graphProps={graphProps}
213
+ onBlur={this.stopEditingLabel}
191
214
  onChange={(label) => this.labelChange({ ...middle, label }, 'middle')}
192
215
  />,
193
216
  labelNode,
@@ -1,4 +1,4 @@
1
- import { render } from '@pie-lib/test-utils';
1
+ import { fireEvent, render } from '@pie-lib/test-utils';
2
2
  import React from 'react';
3
3
  import { graphProps, xy } from '../../../__tests__/utils';
4
4
 
@@ -346,4 +346,115 @@ describe('Component', () => {
346
346
  expect(container.firstChild).toBeInTheDocument();
347
347
  });
348
348
  });
349
+
350
+ describe('label focus', () => {
351
+ let labelNode;
352
+
353
+ // the label is rendered into a portal - it has to be in the document for it to take the focus
354
+ beforeEach(() => {
355
+ labelNode = document.createElement('div');
356
+ document.body.appendChild(labelNode);
357
+ });
358
+
359
+ afterEach(() => {
360
+ document.body.removeChild(labelNode);
361
+ });
362
+
363
+ // the mark is owned by the container, so the tool only sees a new label once it is handed back
364
+ const Controlled = ({ initialMark, labelModeEnabled }) => {
365
+ const [mark, setMark] = React.useState(initialMark);
366
+
367
+ return (
368
+ <Component
369
+ mark={mark}
370
+ onChange={(current, update) => setMark(update)}
371
+ onClick={onClick}
372
+ graphProps={graphProps()}
373
+ labelNode={labelNode}
374
+ labelModeEnabled={labelModeEnabled}
375
+ />
376
+ );
377
+ };
378
+
379
+ const clickPoint = (container) => fireEvent.click(container.querySelector('circle'));
380
+
381
+ it('focuses the new label when a point is clicked in label mode', () => {
382
+ const { container } = render(<Controlled initialMark={xy(1, 1)} labelModeEnabled={true} />);
383
+
384
+ expect(labelNode.querySelector('input')).toBe(null);
385
+
386
+ clickPoint(container);
387
+
388
+ const input = labelNode.querySelector('input');
389
+ expect(input).not.toBe(null);
390
+ expect(document.activeElement).toBe(input);
391
+ });
392
+
393
+ it('does not focus the label when a point is clicked outside of label mode', () => {
394
+ const { container } = render(<Controlled initialMark={{ ...xy(1, 1), label: 'A' }} labelModeEnabled={false} />);
395
+
396
+ clickPoint(container);
397
+
398
+ expect(onClick).toHaveBeenCalled();
399
+ expect(document.activeElement).not.toBe(labelNode.querySelector('input'));
400
+ });
401
+
402
+ it('does not focus the label of a disabled mark', () => {
403
+ const { container } = render(
404
+ <Controlled initialMark={{ ...xy(1, 1), label: 'A', disabled: true }} labelModeEnabled={true} />,
405
+ );
406
+
407
+ clickPoint(container);
408
+
409
+ expect(document.activeElement).not.toBe(labelNode.querySelector('input'));
410
+ });
411
+
412
+ it('stops auto focusing the label once it is blurred', () => {
413
+ const { container } = render(<Controlled initialMark={xy(1, 1)} labelModeEnabled={true} />);
414
+
415
+ clickPoint(container);
416
+ const input = labelNode.querySelector('input');
417
+ expect(document.activeElement).toBe(input);
418
+
419
+ fireEvent.blur(input);
420
+
421
+ // a re-render must not pull the focus back into the label the user just left
422
+ fireEvent.change(input, { target: { value: 'A' } });
423
+ expect(document.activeElement).not.toBe(input);
424
+ });
425
+
426
+ it('closes the label that is being edited when label mode is left', () => {
427
+ const ref = React.createRef();
428
+ const { container, rerender } = render(
429
+ <Component
430
+ ref={ref}
431
+ mark={xy(1, 1)}
432
+ onChange={onChange}
433
+ onClick={onClick}
434
+ graphProps={graphProps()}
435
+ labelNode={labelNode}
436
+ labelModeEnabled={true}
437
+ />,
438
+ );
439
+
440
+ clickPoint(container);
441
+ expect(ref.current.state.editingLabel).toBe('label');
442
+
443
+ rerender(
444
+ <Component
445
+ ref={ref}
446
+ mark={xy(1, 1)}
447
+ onChange={onChange}
448
+ onClick={onClick}
449
+ graphProps={graphProps()}
450
+ labelNode={labelNode}
451
+ labelModeEnabled={false}
452
+ />,
453
+ );
454
+
455
+ // there is no user visible path for this one: a real blur resets it through onBlur, this is
456
+ // the safety net for a label that is still marked as edited when label mode goes away
457
+ expect(ref.current.state.editingLabel).toBe(null);
458
+ });
459
+ });
349
460
  });