@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.
- package/CHANGELOG.md +12 -0
- package/lib/mark-label.js +61 -5
- package/lib/mark-label.js.map +1 -1
- package/lib/tools/circle/component.js +34 -3
- package/lib/tools/circle/component.js.map +1 -1
- package/lib/tools/point/component.js +29 -8
- package/lib/tools/point/component.js.map +1 -1
- package/lib/tools/polygon/component.js +31 -6
- package/lib/tools/polygon/component.js.map +1 -1
- package/lib/tools/shared/line/index.js +34 -3
- package/lib/tools/shared/line/index.js.map +1 -1
- package/package.json +6 -6
- package/src/__tests__/graph.test.jsx +57 -1
- package/src/__tests__/mark-label.test.jsx +260 -1
- package/src/mark-label.jsx +68 -6
- package/src/tools/circle/component.jsx +26 -3
- package/src/tools/point/__tests__/component.test.jsx +112 -1
- package/src/tools/point/component.jsx +21 -8
- package/src/tools/polygon/component.jsx +24 -6
- package/src/tools/shared/line/index.jsx +26 -3
|
@@ -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}`, () => {
|
package/src/mark-label.jsx
CHANGED
|
@@ -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) =>
|
|
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
|
-
|
|
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
|
|
126
|
-
|
|
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
|
});
|