@primer/components 31.0.2-rc.1e80de40 → 31.0.2-rc.95622264
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/.changeset/tiny-ghosts-repeat.md +5 -0
- package/CHANGELOG.md +3 -1
- package/dist/browser.esm.js +11 -10
- package/dist/browser.esm.js.map +1 -1
- package/dist/browser.umd.js +31 -30
- package/dist/browser.umd.js.map +1 -1
- package/docs/content/TextInputWithTokens.mdx +114 -0
- package/lib/TextInputWithTokens.d.ts +4 -0
- package/lib/TextInputWithTokens.js +86 -20
- package/lib/_TextInputWrapper.js +1 -1
- package/lib/__tests__/TextInputWithTokens.test.js +197 -5
- package/lib/stories/TextInputWithTokens.stories.js +19 -2
- package/lib-esm/TextInputWithTokens.d.ts +4 -0
- package/lib-esm/TextInputWithTokens.js +85 -21
- package/lib-esm/_TextInputWrapper.js +1 -1
- package/lib-esm/__tests__/TextInputWithTokens.test.js +184 -5
- package/lib-esm/stories/TextInputWithTokens.stories.js +15 -1
- package/package.json +1 -1
- package/src/TextInputWithTokens.tsx +83 -12
- package/src/_TextInputWrapper.tsx +1 -0
- package/src/__tests__/TextInputWithTokens.test.tsx +171 -1
- package/src/__tests__/__snapshots__/Autocomplete.test.tsx.snap +7 -0
- package/src/__tests__/__snapshots__/TextInput.test.tsx.snap +6 -0
- package/src/__tests__/__snapshots__/TextInputWithTokens.test.tsx.snap +463 -0
- package/src/stories/TextInputWithTokens.stories.tsx +10 -1
- package/stats.html +1 -1
@@ -9,9 +9,17 @@ import Token from './Token/Token';
|
|
9
9
|
import { useProvidedRefOrCreate } from './hooks';
|
10
10
|
import UnstyledTextInput from './_UnstyledTextInput';
|
11
11
|
import TextInputWrapper from './_TextInputWrapper';
|
12
|
-
import Box from './Box';
|
12
|
+
import Box from './Box';
|
13
|
+
import Text from './Text';
|
14
|
+
import { isFocusable } from './utils/iterateFocusableElements'; // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
15
|
+
|
16
|
+
const overflowCountFontSizeMap = {
|
17
|
+
small: 0,
|
18
|
+
medium: 1,
|
19
|
+
large: 1,
|
20
|
+
extralarge: 2
|
21
|
+
}; // using forwardRef is important so that other components (ex. Autocomplete) can use the ref
|
13
22
|
|
14
|
-
// using forwardRef is important so that other components (ex. Autocomplete) can use the ref
|
15
23
|
function TextInputWithTokensInnerComponent({
|
16
24
|
icon: IconComponent,
|
17
25
|
contrast,
|
@@ -31,9 +39,11 @@ function TextInputWithTokensInnerComponent({
|
|
31
39
|
minWidth: minWidthProp,
|
32
40
|
maxWidth: maxWidthProp,
|
33
41
|
variant: variantProp,
|
42
|
+
visibleTokenCount,
|
34
43
|
...rest
|
35
44
|
}, externalRef) {
|
36
45
|
const {
|
46
|
+
onBlur,
|
37
47
|
onFocus,
|
38
48
|
onKeyDown,
|
39
49
|
...inputPropsRest
|
@@ -42,6 +52,7 @@ function TextInputWithTokensInnerComponent({
|
|
42
52
|
const localInputRef = useRef(null);
|
43
53
|
const combinedInputRef = useCombinedRefs(localInputRef, ref);
|
44
54
|
const [selectedTokenIndex, setSelectedTokenIndex] = useState();
|
55
|
+
const [tokensAreTruncated, setTokensAreTruncated] = useState(Boolean(visibleTokenCount));
|
45
56
|
const {
|
46
57
|
containerRef
|
47
58
|
} = useFocusZone({
|
@@ -76,14 +87,25 @@ function TextInputWithTokensInnerComponent({
|
|
76
87
|
}, [selectedTokenIndex]);
|
77
88
|
|
78
89
|
const handleTokenRemove = tokenId => {
|
79
|
-
onTokenRemove(tokenId);
|
90
|
+
onTokenRemove(tokenId); // HACK: wait a tick for the the token node to be removed from the DOM
|
80
91
|
|
81
|
-
|
82
|
-
var _containerRef$current2;
|
92
|
+
setTimeout(() => {
|
93
|
+
var _containerRef$current2, _containerRef$current3;
|
83
94
|
|
84
|
-
const nextElementToFocus = (_containerRef$current2 = containerRef.current) === null || _containerRef$current2 === void 0 ? void 0 : _containerRef$current2.children[selectedTokenIndex];
|
85
|
-
|
86
|
-
|
95
|
+
const nextElementToFocus = (_containerRef$current2 = containerRef.current) === null || _containerRef$current2 === void 0 ? void 0 : _containerRef$current2.children[selectedTokenIndex || 0]; // when removing the first token by keying "Backspace" or "Delete",
|
96
|
+
// `nextFocusableElement` is the div that wraps the input
|
97
|
+
|
98
|
+
const firstFocusable = nextElementToFocus && isFocusable(nextElementToFocus) ? nextElementToFocus : Array.from(((_containerRef$current3 = containerRef.current) === null || _containerRef$current3 === void 0 ? void 0 : _containerRef$current3.children) || []).find(el => isFocusable(el));
|
99
|
+
|
100
|
+
if (firstFocusable) {
|
101
|
+
firstFocusable.focus();
|
102
|
+
} else {
|
103
|
+
var _ref$current;
|
104
|
+
|
105
|
+
// if there are no tokens left, focus the input
|
106
|
+
(_ref$current = ref.current) === null || _ref$current === void 0 ? void 0 : _ref$current.focus();
|
107
|
+
}
|
108
|
+
}, 0);
|
87
109
|
};
|
88
110
|
|
89
111
|
const handleTokenFocus = tokenIndex => () => {
|
@@ -91,30 +113,55 @@ function TextInputWithTokensInnerComponent({
|
|
91
113
|
};
|
92
114
|
|
93
115
|
const handleTokenBlur = () => {
|
94
|
-
setSelectedTokenIndex(undefined);
|
116
|
+
setSelectedTokenIndex(undefined); // HACK: wait a tick and check the focused element before hiding truncated tokens
|
117
|
+
// this prevents the tokens from hiding when the user is moving focus between tokens,
|
118
|
+
// but still hides the tokens when the user blurs the token by tabbing out or clicking somewhere else on the page
|
119
|
+
|
120
|
+
setTimeout(() => {
|
121
|
+
var _containerRef$current4;
|
122
|
+
|
123
|
+
if (!((_containerRef$current4 = containerRef.current) !== null && _containerRef$current4 !== void 0 && _containerRef$current4.contains(document.activeElement)) && visibleTokenCount) {
|
124
|
+
setTokensAreTruncated(true);
|
125
|
+
}
|
126
|
+
}, 0);
|
95
127
|
};
|
96
128
|
|
97
|
-
const handleTokenKeyUp =
|
98
|
-
if (
|
99
|
-
var _ref$
|
129
|
+
const handleTokenKeyUp = event => {
|
130
|
+
if (event.key === 'Escape') {
|
131
|
+
var _ref$current2;
|
100
132
|
|
101
|
-
(_ref$
|
133
|
+
(_ref$current2 = ref.current) === null || _ref$current2 === void 0 ? void 0 : _ref$current2.focus();
|
102
134
|
}
|
103
135
|
};
|
104
136
|
|
105
|
-
const handleInputFocus =
|
106
|
-
onFocus && onFocus(
|
137
|
+
const handleInputFocus = event => {
|
138
|
+
onFocus && onFocus(event);
|
107
139
|
setSelectedTokenIndex(undefined);
|
140
|
+
visibleTokenCount && setTokensAreTruncated(false);
|
141
|
+
};
|
142
|
+
|
143
|
+
const handleInputBlur = event => {
|
144
|
+
onBlur && onBlur(event); // HACK: wait a tick and check the focused element before hiding truncated tokens
|
145
|
+
// this prevents the tokens from hiding when the user is moving focus from the input to a token,
|
146
|
+
// but still hides the tokens when the user blurs the input by tabbing out or clicking somewhere else on the page
|
147
|
+
|
148
|
+
setTimeout(() => {
|
149
|
+
var _containerRef$current5;
|
150
|
+
|
151
|
+
if (!((_containerRef$current5 = containerRef.current) !== null && _containerRef$current5 !== void 0 && _containerRef$current5.contains(document.activeElement)) && visibleTokenCount) {
|
152
|
+
setTokensAreTruncated(true);
|
153
|
+
}
|
154
|
+
}, 0);
|
108
155
|
};
|
109
156
|
|
110
157
|
const handleInputKeyDown = e => {
|
111
|
-
var _ref$
|
158
|
+
var _ref$current3;
|
112
159
|
|
113
160
|
if (onKeyDown) {
|
114
161
|
onKeyDown(e);
|
115
162
|
}
|
116
163
|
|
117
|
-
if ((_ref$
|
164
|
+
if ((_ref$current3 = ref.current) !== null && _ref$current3 !== void 0 && _ref$current3.value) {
|
118
165
|
return;
|
119
166
|
}
|
120
167
|
|
@@ -135,13 +182,24 @@ function TextInputWithTokensInnerComponent({
|
|
135
182
|
|
136
183
|
|
137
184
|
setTimeout(() => {
|
138
|
-
var _ref$
|
185
|
+
var _ref$current4;
|
139
186
|
|
140
|
-
(_ref$
|
187
|
+
(_ref$current4 = ref.current) === null || _ref$current4 === void 0 ? void 0 : _ref$current4.select();
|
141
188
|
}, 0);
|
142
189
|
}
|
143
190
|
};
|
144
191
|
|
192
|
+
const focusInput = () => {
|
193
|
+
var _combinedInputRef$cur;
|
194
|
+
|
195
|
+
(_combinedInputRef$cur = combinedInputRef.current) === null || _combinedInputRef$cur === void 0 ? void 0 : _combinedInputRef$cur.focus();
|
196
|
+
};
|
197
|
+
|
198
|
+
const preventTokenClickPropagation = event => {
|
199
|
+
event.stopPropagation();
|
200
|
+
};
|
201
|
+
|
202
|
+
const visibleTokens = tokensAreTruncated ? tokens.slice(0, visibleTokenCount) : tokens;
|
145
203
|
return /*#__PURE__*/React.createElement(TextInputWrapper, {
|
146
204
|
block: block,
|
147
205
|
className: className,
|
@@ -153,6 +211,7 @@ function TextInputWithTokensInnerComponent({
|
|
153
211
|
minWidth: minWidthProp,
|
154
212
|
maxWidth: maxWidthProp,
|
155
213
|
variant: variantProp,
|
214
|
+
onClick: focusInput,
|
156
215
|
sx: { ...(block ? {
|
157
216
|
display: 'flex',
|
158
217
|
width: '100%'
|
@@ -192,12 +251,13 @@ function TextInputWithTokensInnerComponent({
|
|
192
251
|
ref: combinedInputRef,
|
193
252
|
disabled: disabled,
|
194
253
|
onFocus: handleInputFocus,
|
254
|
+
onBlur: handleInputBlur,
|
195
255
|
onKeyDown: handleInputKeyDown,
|
196
256
|
type: "text",
|
197
257
|
sx: {
|
198
258
|
height: '100%'
|
199
259
|
}
|
200
|
-
}, inputPropsRest))),
|
260
|
+
}, inputPropsRest))), TokenComponent ? visibleTokens.map(({
|
201
261
|
id,
|
202
262
|
...tokenRest
|
203
263
|
}, i) => /*#__PURE__*/React.createElement(TokenComponent, _extends({
|
@@ -205,6 +265,7 @@ function TextInputWithTokensInnerComponent({
|
|
205
265
|
onFocus: handleTokenFocus(i),
|
206
266
|
onBlur: handleTokenBlur,
|
207
267
|
onKeyUp: handleTokenKeyUp,
|
268
|
+
onClick: preventTokenClickPropagation,
|
208
269
|
isSelected: selectedTokenIndex === i,
|
209
270
|
onRemove: () => {
|
210
271
|
handleTokenRemove(id);
|
@@ -212,7 +273,10 @@ function TextInputWithTokensInnerComponent({
|
|
212
273
|
hideRemoveButton: hideTokenRemoveButtons,
|
213
274
|
size: size,
|
214
275
|
tabIndex: 0
|
215
|
-
}, tokenRest))) : null
|
276
|
+
}, tokenRest))) : null, tokensAreTruncated ? /*#__PURE__*/React.createElement(Text, {
|
277
|
+
color: "fg.muted",
|
278
|
+
fontSize: size && overflowCountFontSizeMap[size]
|
279
|
+
}, "+", tokens.length - visibleTokens.length) : null));
|
216
280
|
}
|
217
281
|
|
218
282
|
TextInputWithTokensInnerComponent.displayName = "TextInputWithTokensInnerComponent";
|
@@ -21,7 +21,7 @@ const sizeVariants = variant({
|
|
21
21
|
const TextInputWrapper = styled.span.withConfig({
|
22
22
|
displayName: "_TextInputWrapper__TextInputWrapper",
|
23
23
|
componentId: "sc-5vfcis-0"
|
24
|
-
})(["display:inline-flex;align-items:stretch;min-height:34px;font-size:", ";line-height:20px;color:", ";vertical-align:middle;background-repeat:no-repeat;background-position:right 8px center;border:1px solid ", ";border-radius:", ";outline:none;box-shadow:", ";", " .TextInput-icon{align-self:center;color:", ";margin:0 ", ";flex-shrink:0;}&:focus-within{border-color:", ";box-shadow:", ";}", " ", " ", " @media (min-width:", "){font-size:", ";}", " ", " ", " ", " ", ";"], get('fontSizes.1'), get('colors.fg.default'), get('colors.border.default'), get('radii.2'), get('shadows.primer.shadow.inset'), props => {
|
24
|
+
})(["display:inline-flex;align-items:stretch;min-height:34px;font-size:", ";line-height:20px;color:", ";vertical-align:middle;background-repeat:no-repeat;background-position:right 8px center;border:1px solid ", ";border-radius:", ";outline:none;box-shadow:", ";cursor:text;", " .TextInput-icon{align-self:center;color:", ";margin:0 ", ";flex-shrink:0;}&:focus-within{border-color:", ";box-shadow:", ";}", " ", " ", " @media (min-width:", "){font-size:", ";}", " ", " ", " ", " ", ";"], get('fontSizes.1'), get('colors.fg.default'), get('colors.border.default'), get('radii.2'), get('shadows.primer.shadow.inset'), props => {
|
25
25
|
if (props.hasIcon) {
|
26
26
|
return css(["padding:0;"]);
|
27
27
|
} else {
|
@@ -2,7 +2,7 @@ function _extends() { _extends = Object.assign || function (target) { for (var i
|
|
2
2
|
|
3
3
|
import React from 'react';
|
4
4
|
import { render } from '../utils/testing';
|
5
|
-
import { render as HTMLRender,
|
5
|
+
import { render as HTMLRender, fireEvent, act, cleanup } from '@testing-library/react';
|
6
6
|
import { axe, toHaveNoViolations } from 'jest-axe';
|
7
7
|
import 'babel-polyfill';
|
8
8
|
import { tokenSizes } from '../Token/TokenBase';
|
@@ -46,8 +46,18 @@ const LabelledTextInputWithTokens = ({
|
|
46
46
|
tokens: tokens,
|
47
47
|
onTokenRemove: onTokenRemove,
|
48
48
|
id: "tokenInput"
|
49
|
-
}, rest)));
|
49
|
+
}, rest))); // describe('axe test', () => {
|
50
|
+
// it('should have no axe violations', async () => {
|
51
|
+
// const onRemoveMock = jest.fn()
|
52
|
+
// const {container} = HTMLRender(<LabelledTextInputWithTokens tokens={mockTokens} onTokenRemove={onRemoveMock} />)
|
53
|
+
// const results = await axe(container)
|
54
|
+
// expect(results).toHaveNoViolations()
|
55
|
+
// cleanup()
|
56
|
+
// })
|
57
|
+
// })
|
50
58
|
|
59
|
+
|
60
|
+
jest.useFakeTimers();
|
51
61
|
describe('TextInputWithTokens', () => {
|
52
62
|
it('renders without tokens', () => {
|
53
63
|
const onRemoveMock = jest.fn();
|
@@ -115,6 +125,14 @@ describe('TextInputWithTokens', () => {
|
|
115
125
|
onTokenRemove: onRemoveMock
|
116
126
|
}))).toMatchSnapshot();
|
117
127
|
});
|
128
|
+
it('renders a truncated set of tokens', () => {
|
129
|
+
const onRemoveMock = jest.fn();
|
130
|
+
expect(render( /*#__PURE__*/React.createElement(TextInputWithTokens, {
|
131
|
+
tokens: mockTokens,
|
132
|
+
onTokenRemove: onRemoveMock,
|
133
|
+
visibleTokenCount: 2
|
134
|
+
}))).toMatchSnapshot();
|
135
|
+
});
|
118
136
|
it('focuses the previous token when keying ArrowLeft', () => {
|
119
137
|
var _document$activeEleme, _document$activeEleme2;
|
120
138
|
|
@@ -220,8 +238,123 @@ describe('TextInputWithTokens', () => {
|
|
220
238
|
expect((_document$activeEleme8 = document.activeElement) === null || _document$activeEleme8 === void 0 ? void 0 : _document$activeEleme8.id).not.toEqual(lastTokenNode.id);
|
221
239
|
expect((_document$activeEleme9 = document.activeElement) === null || _document$activeEleme9 === void 0 ? void 0 : _document$activeEleme9.id).toEqual(inputNode.id);
|
222
240
|
});
|
241
|
+
it('does not focus the input when clicking a token', () => {
|
242
|
+
var _document$activeEleme10;
|
243
|
+
|
244
|
+
const onRemoveMock = jest.fn();
|
245
|
+
const {
|
246
|
+
getByLabelText,
|
247
|
+
getByText
|
248
|
+
} = HTMLRender( /*#__PURE__*/React.createElement(LabelledTextInputWithTokens, {
|
249
|
+
tokens: mockTokens,
|
250
|
+
onTokenRemove: onRemoveMock,
|
251
|
+
visibleTokenCount: 2
|
252
|
+
}));
|
253
|
+
const inputNode = getByLabelText('Tokens');
|
254
|
+
const tokenNode = getByText(mockTokens[0].text);
|
255
|
+
expect(document.activeElement).not.toEqual(inputNode.id);
|
256
|
+
fireEvent.click(tokenNode);
|
257
|
+
expect((_document$activeEleme10 = document.activeElement) === null || _document$activeEleme10 === void 0 ? void 0 : _document$activeEleme10.id).not.toEqual(inputNode.id);
|
258
|
+
});
|
259
|
+
it('focuses the input when clicking somewhere in the component besides the tokens', () => {
|
260
|
+
var _document$activeEleme11;
|
261
|
+
|
262
|
+
const onRemoveMock = jest.fn();
|
263
|
+
const {
|
264
|
+
getByLabelText,
|
265
|
+
getByText
|
266
|
+
} = HTMLRender( /*#__PURE__*/React.createElement(LabelledTextInputWithTokens, {
|
267
|
+
tokens: mockTokens,
|
268
|
+
onTokenRemove: onRemoveMock,
|
269
|
+
visibleTokenCount: 2
|
270
|
+
}));
|
271
|
+
const inputNode = getByLabelText('Tokens');
|
272
|
+
const truncatedTokenCount = getByText('+6');
|
273
|
+
expect(document.activeElement).not.toEqual(inputNode.id);
|
274
|
+
fireEvent.click(truncatedTokenCount);
|
275
|
+
expect((_document$activeEleme11 = document.activeElement) === null || _document$activeEleme11 === void 0 ? void 0 : _document$activeEleme11.id).toEqual(inputNode.id);
|
276
|
+
});
|
277
|
+
it('shows all tokens when the input is focused and hides them when it is blurred (when visibleTokenCount is set)', () => {
|
278
|
+
const onRemoveMock = jest.fn();
|
279
|
+
const visibleTokenCount = 2;
|
280
|
+
const {
|
281
|
+
getByLabelText,
|
282
|
+
getByText
|
283
|
+
} = HTMLRender( /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(LabelledTextInputWithTokens, {
|
284
|
+
tokens: mockTokens,
|
285
|
+
onTokenRemove: onRemoveMock,
|
286
|
+
visibleTokenCount: visibleTokenCount
|
287
|
+
}), /*#__PURE__*/React.createElement("button", {
|
288
|
+
id: "focusableOutsideComponent"
|
289
|
+
}, "Focus me")));
|
290
|
+
const inputNode = getByLabelText('Tokens');
|
291
|
+
const focusableOutsideComponentNode = getByText('Focus me');
|
292
|
+
const allTokenLabels = mockTokens.map(token => token.text);
|
293
|
+
const truncatedTokenCountNode = getByText('+6');
|
294
|
+
act(() => {
|
295
|
+
jest.runAllTimers();
|
296
|
+
fireEvent.focus(inputNode);
|
297
|
+
});
|
298
|
+
setTimeout(() => {
|
299
|
+
for (const tokenLabel of allTokenLabels) {
|
300
|
+
const tokenNode = getByText(tokenLabel);
|
301
|
+
expect(tokenNode).toBeDefined();
|
302
|
+
}
|
303
|
+
}, 0);
|
304
|
+
act(() => {
|
305
|
+
jest.runAllTimers(); // onBlur isn't called on input unless we specifically fire the "blur" event
|
306
|
+
// eslint-disable-next-line github/no-blur
|
307
|
+
|
308
|
+
fireEvent.blur(inputNode);
|
309
|
+
fireEvent.focus(focusableOutsideComponentNode);
|
310
|
+
});
|
311
|
+
setTimeout(() => {
|
312
|
+
expect(truncatedTokenCountNode).toBeDefined();
|
313
|
+
|
314
|
+
for (const tokenLabel of allTokenLabels) {
|
315
|
+
const tokenNode = getByText(tokenLabel);
|
316
|
+
|
317
|
+
if (allTokenLabels.indexOf(tokenLabel) > visibleTokenCount) {
|
318
|
+
// eslint-disable-next-line jest/no-conditional-expect
|
319
|
+
expect(tokenNode).toBeDefined();
|
320
|
+
} else {
|
321
|
+
// eslint-disable-next-line jest/no-conditional-expect
|
322
|
+
expect(tokenNode).not.toBeDefined();
|
323
|
+
}
|
324
|
+
}
|
325
|
+
}, 0);
|
326
|
+
jest.useRealTimers();
|
327
|
+
});
|
328
|
+
it('does not hide tokens when blurring the input to focus within the component (when visibleTokenCount is set)', () => {
|
329
|
+
const onRemoveMock = jest.fn();
|
330
|
+
const visibleTokenCount = 2;
|
331
|
+
const {
|
332
|
+
getByLabelText,
|
333
|
+
getByText
|
334
|
+
} = HTMLRender( /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(LabelledTextInputWithTokens, {
|
335
|
+
tokens: mockTokens,
|
336
|
+
onTokenRemove: onRemoveMock,
|
337
|
+
visibleTokenCount: visibleTokenCount
|
338
|
+
}), /*#__PURE__*/React.createElement("button", {
|
339
|
+
id: "focusableOutsideComponent"
|
340
|
+
}, "Focus me")));
|
341
|
+
const inputNode = getByLabelText('Tokens');
|
342
|
+
const firstTokenNode = getByText(mockTokens[visibleTokenCount - 1].text);
|
343
|
+
const allTokenLabels = mockTokens.map(token => token.text);
|
344
|
+
const truncatedTokenCountNode = getByText('+6');
|
345
|
+
act(() => {
|
346
|
+
fireEvent.focus(inputNode);
|
347
|
+
fireEvent.focus(firstTokenNode);
|
348
|
+
});
|
349
|
+
expect(truncatedTokenCountNode).toBeDefined();
|
350
|
+
|
351
|
+
for (const tokenLabel of allTokenLabels) {
|
352
|
+
const tokenNode = getByText(tokenLabel);
|
353
|
+
expect(tokenNode).toBeDefined();
|
354
|
+
}
|
355
|
+
});
|
223
356
|
it('focuses the first token when keying ArrowRight in the input', () => {
|
224
|
-
var _document$
|
357
|
+
var _document$activeEleme12, _document$activeEleme13;
|
225
358
|
|
226
359
|
const onRemoveMock = jest.fn();
|
227
360
|
const {
|
@@ -238,8 +371,8 @@ describe('TextInputWithTokens', () => {
|
|
238
371
|
fireEvent.keyDown(inputNode, {
|
239
372
|
key: 'ArrowRight'
|
240
373
|
});
|
241
|
-
expect((_document$
|
242
|
-
expect((_document$
|
374
|
+
expect((_document$activeEleme12 = document.activeElement) === null || _document$activeEleme12 === void 0 ? void 0 : _document$activeEleme12.id).not.toEqual(inputNode.id);
|
375
|
+
expect((_document$activeEleme13 = document.activeElement) === null || _document$activeEleme13 === void 0 ? void 0 : _document$activeEleme13.id).toEqual(firstTokenNode.id);
|
243
376
|
});
|
244
377
|
it('calls onTokenRemove on the last token when keying Backspace in an empty input', () => {
|
245
378
|
const onRemoveMock = jest.fn();
|
@@ -308,6 +441,52 @@ describe('TextInputWithTokens', () => {
|
|
308
441
|
});
|
309
442
|
expect(onRemoveMock).toHaveBeenCalledWith(mockTokens[4].id);
|
310
443
|
});
|
444
|
+
it('moves focus to the next token when removing the first token', () => {
|
445
|
+
jest.useFakeTimers();
|
446
|
+
const onRemoveMock = jest.fn();
|
447
|
+
const {
|
448
|
+
getByText
|
449
|
+
} = HTMLRender( /*#__PURE__*/React.createElement(TextInputWithTokens, {
|
450
|
+
tokens: [...mockTokens].slice(0, 2),
|
451
|
+
onTokenRemove: onRemoveMock
|
452
|
+
}));
|
453
|
+
const tokenNode = getByText(mockTokens[0].text);
|
454
|
+
fireEvent.focus(tokenNode);
|
455
|
+
fireEvent.keyDown(tokenNode, {
|
456
|
+
key: 'Backspace'
|
457
|
+
});
|
458
|
+
jest.runAllTimers();
|
459
|
+
setTimeout(() => {
|
460
|
+
var _document$activeEleme14;
|
461
|
+
|
462
|
+
expect((_document$activeEleme14 = document.activeElement) === null || _document$activeEleme14 === void 0 ? void 0 : _document$activeEleme14.textContent).toBe(mockTokens[1].text);
|
463
|
+
}, 0);
|
464
|
+
jest.useRealTimers();
|
465
|
+
});
|
466
|
+
it('moves focus to the input when the last token is removed', () => {
|
467
|
+
jest.useFakeTimers();
|
468
|
+
const onRemoveMock = jest.fn();
|
469
|
+
const {
|
470
|
+
getByText,
|
471
|
+
getByLabelText
|
472
|
+
} = HTMLRender( /*#__PURE__*/React.createElement(LabelledTextInputWithTokens, {
|
473
|
+
tokens: [mockTokens[0]],
|
474
|
+
onTokenRemove: onRemoveMock
|
475
|
+
}));
|
476
|
+
const tokenNode = getByText(mockTokens[0].text);
|
477
|
+
const inputNode = getByLabelText('Tokens');
|
478
|
+
fireEvent.focus(tokenNode);
|
479
|
+
fireEvent.keyDown(tokenNode, {
|
480
|
+
key: 'Backspace'
|
481
|
+
});
|
482
|
+
jest.runAllTimers();
|
483
|
+
setTimeout(() => {
|
484
|
+
var _document$activeEleme15;
|
485
|
+
|
486
|
+
expect((_document$activeEleme15 = document.activeElement) === null || _document$activeEleme15 === void 0 ? void 0 : _document$activeEleme15.id).toBe(inputNode.id);
|
487
|
+
}, 0);
|
488
|
+
jest.useRealTimers();
|
489
|
+
});
|
311
490
|
it('calls onKeyDown', () => {
|
312
491
|
const onRemoveMock = jest.fn();
|
313
492
|
const onKeyDownMock = jest.fn();
|
@@ -53,7 +53,7 @@ const mockTokens = [{
|
|
53
53
|
id: 21
|
54
54
|
}];
|
55
55
|
export const Default = () => {
|
56
|
-
const [tokens, setTokens] = useState([...mockTokens].slice(0,
|
56
|
+
const [tokens, setTokens] = useState([...mockTokens].slice(0, 3));
|
57
57
|
|
58
58
|
const onTokenRemove = tokenId => {
|
59
59
|
setTokens(tokens.filter(token => token.id !== tokenId));
|
@@ -171,6 +171,20 @@ export const TokenRemoveButtonsHidden = () => {
|
|
171
171
|
});
|
172
172
|
};
|
173
173
|
TokenRemoveButtonsHidden.displayName = "TokenRemoveButtonsHidden";
|
174
|
+
export const WithVisibleTokenCount = () => {
|
175
|
+
const [tokens, setTokens] = useState([...mockTokens].slice(0, 5));
|
176
|
+
|
177
|
+
const onTokenRemove = tokenId => {
|
178
|
+
setTokens(tokens.filter(token => token.id !== tokenId));
|
179
|
+
};
|
180
|
+
|
181
|
+
return /*#__PURE__*/React.createElement(TextInputWithTokens, {
|
182
|
+
tokens: tokens,
|
183
|
+
onTokenRemove: onTokenRemove,
|
184
|
+
visibleTokenCount: 2
|
185
|
+
});
|
186
|
+
};
|
187
|
+
WithVisibleTokenCount.displayName = "WithVisibleTokenCount";
|
174
188
|
export const Unstyled = () => {
|
175
189
|
const [tokens, setTokens] = useState([...mockTokens].slice(0, 2));
|
176
190
|
|
package/package.json
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
import React, {FocusEventHandler, KeyboardEventHandler, RefObject, useRef, useState} from 'react'
|
1
|
+
import React, {FocusEventHandler, KeyboardEventHandler, MouseEventHandler, RefObject, useRef, useState} from 'react'
|
2
2
|
import {omit} from '@styled-system/props'
|
3
3
|
import {FocusKeys} from './behaviors/focusZone'
|
4
4
|
import {useCombinedRefs} from './hooks/useCombinedRefs'
|
@@ -11,6 +11,8 @@ import {useProvidedRefOrCreate} from './hooks'
|
|
11
11
|
import UnstyledTextInput from './_UnstyledTextInput'
|
12
12
|
import TextInputWrapper from './_TextInputWrapper'
|
13
13
|
import Box from './Box'
|
14
|
+
import Text from './Text'
|
15
|
+
import {isFocusable} from './utils/iterateFocusableElements'
|
14
16
|
|
15
17
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
16
18
|
type AnyReactComponent = React.ComponentType<any>
|
@@ -47,8 +49,19 @@ type TextInputWithTokensInternalProps<TokenComponentType extends AnyReactCompone
|
|
47
49
|
* Whether the remove buttons should be rendered in the tokens
|
48
50
|
*/
|
49
51
|
hideTokenRemoveButtons?: boolean
|
52
|
+
/**
|
53
|
+
* The number of tokens to display before truncating
|
54
|
+
*/
|
55
|
+
visibleTokenCount?: number
|
50
56
|
} & TextInputProps
|
51
57
|
|
58
|
+
const overflowCountFontSizeMap: Record<TokenSizeKeys, number> = {
|
59
|
+
small: 0,
|
60
|
+
medium: 1,
|
61
|
+
large: 1,
|
62
|
+
extralarge: 2
|
63
|
+
}
|
64
|
+
|
52
65
|
// using forwardRef is important so that other components (ex. Autocomplete) can use the ref
|
53
66
|
function TextInputWithTokensInnerComponent<TokenComponentType extends AnyReactComponent>(
|
54
67
|
{
|
@@ -70,6 +83,7 @@ function TextInputWithTokensInnerComponent<TokenComponentType extends AnyReactCo
|
|
70
83
|
minWidth: minWidthProp,
|
71
84
|
maxWidth: maxWidthProp,
|
72
85
|
variant: variantProp,
|
86
|
+
visibleTokenCount,
|
73
87
|
...rest
|
74
88
|
}: TextInputWithTokensInternalProps<TokenComponentType> & {
|
75
89
|
selectedTokenIndex: number | undefined
|
@@ -77,11 +91,12 @@ function TextInputWithTokensInnerComponent<TokenComponentType extends AnyReactCo
|
|
77
91
|
},
|
78
92
|
externalRef: React.ForwardedRef<HTMLInputElement>
|
79
93
|
) {
|
80
|
-
const {onFocus, onKeyDown, ...inputPropsRest} = omit(rest)
|
94
|
+
const {onBlur, onFocus, onKeyDown, ...inputPropsRest} = omit(rest)
|
81
95
|
const ref = useProvidedRefOrCreate<HTMLInputElement>(externalRef as React.RefObject<HTMLInputElement>)
|
82
96
|
const localInputRef = useRef<HTMLInputElement>(null)
|
83
97
|
const combinedInputRef = useCombinedRefs(localInputRef, ref)
|
84
98
|
const [selectedTokenIndex, setSelectedTokenIndex] = useState<number | undefined>()
|
99
|
+
const [tokensAreTruncated, setTokensAreTruncated] = useState<boolean>(Boolean(visibleTokenCount))
|
85
100
|
const {containerRef} = useFocusZone(
|
86
101
|
{
|
87
102
|
focusOutBehavior: 'wrap',
|
@@ -117,10 +132,24 @@ function TextInputWithTokensInnerComponent<TokenComponentType extends AnyReactCo
|
|
117
132
|
const handleTokenRemove = (tokenId: string | number) => {
|
118
133
|
onTokenRemove(tokenId)
|
119
134
|
|
120
|
-
|
121
|
-
|
122
|
-
nextElementToFocus.
|
123
|
-
|
135
|
+
// HACK: wait a tick for the the token node to be removed from the DOM
|
136
|
+
setTimeout(() => {
|
137
|
+
const nextElementToFocus = containerRef.current?.children[selectedTokenIndex || 0] as HTMLElement | undefined
|
138
|
+
|
139
|
+
// when removing the first token by keying "Backspace" or "Delete",
|
140
|
+
// `nextFocusableElement` is the div that wraps the input
|
141
|
+
const firstFocusable =
|
142
|
+
nextElementToFocus && isFocusable(nextElementToFocus)
|
143
|
+
? nextElementToFocus
|
144
|
+
: (Array.from(containerRef.current?.children || []) as HTMLElement[]).find(el => isFocusable(el))
|
145
|
+
|
146
|
+
if (firstFocusable) {
|
147
|
+
firstFocusable.focus()
|
148
|
+
} else {
|
149
|
+
// if there are no tokens left, focus the input
|
150
|
+
ref.current?.focus()
|
151
|
+
}
|
152
|
+
}, 0)
|
124
153
|
}
|
125
154
|
|
126
155
|
const handleTokenFocus: (tokenIndex: number) => FocusEventHandler = tokenIndex => () => {
|
@@ -129,18 +158,42 @@ function TextInputWithTokensInnerComponent<TokenComponentType extends AnyReactCo
|
|
129
158
|
|
130
159
|
const handleTokenBlur: FocusEventHandler = () => {
|
131
160
|
setSelectedTokenIndex(undefined)
|
161
|
+
|
162
|
+
// HACK: wait a tick and check the focused element before hiding truncated tokens
|
163
|
+
// this prevents the tokens from hiding when the user is moving focus between tokens,
|
164
|
+
// but still hides the tokens when the user blurs the token by tabbing out or clicking somewhere else on the page
|
165
|
+
setTimeout(() => {
|
166
|
+
if (!containerRef.current?.contains(document.activeElement) && visibleTokenCount) {
|
167
|
+
setTokensAreTruncated(true)
|
168
|
+
}
|
169
|
+
}, 0)
|
132
170
|
}
|
133
171
|
|
134
|
-
const handleTokenKeyUp: KeyboardEventHandler =
|
135
|
-
if (
|
172
|
+
const handleTokenKeyUp: KeyboardEventHandler = event => {
|
173
|
+
if (event.key === 'Escape') {
|
136
174
|
ref.current?.focus()
|
137
175
|
}
|
138
176
|
}
|
139
177
|
|
140
|
-
const handleInputFocus: FocusEventHandler =
|
141
|
-
onFocus && onFocus(
|
178
|
+
const handleInputFocus: FocusEventHandler = event => {
|
179
|
+
onFocus && onFocus(event)
|
142
180
|
setSelectedTokenIndex(undefined)
|
181
|
+
visibleTokenCount && setTokensAreTruncated(false)
|
143
182
|
}
|
183
|
+
|
184
|
+
const handleInputBlur: FocusEventHandler = event => {
|
185
|
+
onBlur && onBlur(event)
|
186
|
+
|
187
|
+
// HACK: wait a tick and check the focused element before hiding truncated tokens
|
188
|
+
// this prevents the tokens from hiding when the user is moving focus from the input to a token,
|
189
|
+
// but still hides the tokens when the user blurs the input by tabbing out or clicking somewhere else on the page
|
190
|
+
setTimeout(() => {
|
191
|
+
if (!containerRef.current?.contains(document.activeElement) && visibleTokenCount) {
|
192
|
+
setTokensAreTruncated(true)
|
193
|
+
}
|
194
|
+
}, 0)
|
195
|
+
}
|
196
|
+
|
144
197
|
const handleInputKeyDown: KeyboardEventHandler = e => {
|
145
198
|
if (onKeyDown) {
|
146
199
|
onKeyDown(e)
|
@@ -172,6 +225,16 @@ function TextInputWithTokensInnerComponent<TokenComponentType extends AnyReactCo
|
|
172
225
|
}
|
173
226
|
}
|
174
227
|
|
228
|
+
const focusInput: MouseEventHandler = () => {
|
229
|
+
combinedInputRef.current?.focus()
|
230
|
+
}
|
231
|
+
|
232
|
+
const preventTokenClickPropagation: MouseEventHandler = event => {
|
233
|
+
event.stopPropagation()
|
234
|
+
}
|
235
|
+
|
236
|
+
const visibleTokens = tokensAreTruncated ? tokens.slice(0, visibleTokenCount) : tokens
|
237
|
+
|
175
238
|
return (
|
176
239
|
<TextInputWrapper
|
177
240
|
block={block}
|
@@ -184,6 +247,7 @@ function TextInputWithTokensInnerComponent<TokenComponentType extends AnyReactCo
|
|
184
247
|
minWidth={minWidthProp}
|
185
248
|
maxWidth={maxWidthProp}
|
186
249
|
variant={variantProp}
|
250
|
+
onClick={focusInput}
|
187
251
|
sx={{
|
188
252
|
...(block
|
189
253
|
? {
|
@@ -236,19 +300,21 @@ function TextInputWithTokensInnerComponent<TokenComponentType extends AnyReactCo
|
|
236
300
|
ref={combinedInputRef}
|
237
301
|
disabled={disabled}
|
238
302
|
onFocus={handleInputFocus}
|
303
|
+
onBlur={handleInputBlur}
|
239
304
|
onKeyDown={handleInputKeyDown}
|
240
305
|
type="text"
|
241
306
|
sx={{height: '100%'}}
|
242
307
|
{...inputPropsRest}
|
243
308
|
/>
|
244
309
|
</Box>
|
245
|
-
{
|
246
|
-
?
|
310
|
+
{TokenComponent
|
311
|
+
? visibleTokens.map(({id, ...tokenRest}, i) => (
|
247
312
|
<TokenComponent
|
248
313
|
key={id}
|
249
314
|
onFocus={handleTokenFocus(i)}
|
250
315
|
onBlur={handleTokenBlur}
|
251
316
|
onKeyUp={handleTokenKeyUp}
|
317
|
+
onClick={preventTokenClickPropagation}
|
252
318
|
isSelected={selectedTokenIndex === i}
|
253
319
|
onRemove={() => {
|
254
320
|
handleTokenRemove(id)
|
@@ -260,6 +326,11 @@ function TextInputWithTokensInnerComponent<TokenComponentType extends AnyReactCo
|
|
260
326
|
/>
|
261
327
|
))
|
262
328
|
: null}
|
329
|
+
{tokensAreTruncated ? (
|
330
|
+
<Text color="fg.muted" fontSize={size && overflowCountFontSizeMap[size]}>
|
331
|
+
+{tokens.length - visibleTokens.length}
|
332
|
+
</Text>
|
333
|
+
) : null}
|
263
334
|
</Box>
|
264
335
|
</TextInputWrapper>
|
265
336
|
)
|