@noya-app/noya-designsystem 0.0.2
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/.turbo/turbo-build.log +17 -0
- package/CHANGELOG.md +7 -0
- package/README.md +1 -0
- package/dist/index.d.mts +1178 -0
- package/dist/index.d.ts +1178 -0
- package/dist/index.js +15651 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +15684 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +49 -0
- package/src/__tests__/__snapshots__/fuzzyScorer.test.ts.snap +41 -0
- package/src/__tests__/fuzzyScorer.test.ts +85 -0
- package/src/components/ActivityIndicator.tsx +44 -0
- package/src/components/Avatar.tsx +64 -0
- package/src/components/Button.tsx +209 -0
- package/src/components/Chip.tsx +214 -0
- package/src/components/ContextMenu.tsx +224 -0
- package/src/components/Dialog.tsx +143 -0
- package/src/components/Divider.tsx +40 -0
- package/src/components/DropdownMenu.tsx +208 -0
- package/src/components/FillInputField.tsx +43 -0
- package/src/components/FillPreviewBackground.tsx +132 -0
- package/src/components/GradientPicker.tsx +88 -0
- package/src/components/Grid.tsx +54 -0
- package/src/components/GridView.tsx +468 -0
- package/src/components/IconButton.tsx +46 -0
- package/src/components/InputField.tsx +568 -0
- package/src/components/InputFieldWithCompletions.tsx +477 -0
- package/src/components/Label.tsx +62 -0
- package/src/components/LabeledElementView.tsx +185 -0
- package/src/components/ListView.tsx +950 -0
- package/src/components/Popover.tsx +113 -0
- package/src/components/Progress.tsx +57 -0
- package/src/components/RadioGroup.tsx +163 -0
- package/src/components/ScrollArea.tsx +70 -0
- package/src/components/Select.tsx +152 -0
- package/src/components/Slider.tsx +82 -0
- package/src/components/Sortable.tsx +296 -0
- package/src/components/Spacer.tsx +29 -0
- package/src/components/Stack.tsx +142 -0
- package/src/components/Switch.tsx +67 -0
- package/src/components/Text.tsx +164 -0
- package/src/components/Toast.tsx +86 -0
- package/src/components/Tooltip.tsx +43 -0
- package/src/components/TreeView.tsx +85 -0
- package/src/components/internal/Menu.tsx +222 -0
- package/src/components/internal/TextInput.tsx +296 -0
- package/src/components/internal/__tests__/TextInput.test.tsx +144 -0
- package/src/contexts/DesignSystemConfiguration.tsx +57 -0
- package/src/contexts/DialogContext.tsx +190 -0
- package/src/contexts/GlobalInputBlurContext.tsx +61 -0
- package/src/contexts/ImageDataContext.tsx +44 -0
- package/src/hooks/__tests__/mergeEventHandlers.test.ts +47 -0
- package/src/hooks/mergeEventHandlers.ts +55 -0
- package/src/hooks/useHover.ts +173 -0
- package/src/hooks/useObjectURL.ts +22 -0
- package/src/hooks/usePlatform.ts +13 -0
- package/src/index.tsx +77 -0
- package/src/mediaQuery.ts +13 -0
- package/src/theme/dark.ts +37 -0
- package/src/theme/index.ts +17 -0
- package/src/theme/light.ts +178 -0
- package/src/utils/breakpoints.ts +45 -0
- package/src/utils/completions.ts +21 -0
- package/src/utils/createSectionedMenu.ts +38 -0
- package/src/utils/fuzzyScorer.ts +105 -0
- package/src/utils/getGradientBackground.tsx +33 -0
- package/src/utils/handleNudge.ts +30 -0
- package/src/utils/mouseEvent.ts +7 -0
- package/src/utils/sketchColor.ts +34 -0
- package/src/utils/sketchPattern.ts +29 -0
- package/src/utils/withSeparatorElements.ts +31 -0
- package/tsconfig.json +3 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { composeRefs } from "@radix-ui/react-compose-refs";
|
|
2
|
+
import React, {
|
|
3
|
+
AriaRole,
|
|
4
|
+
FocusEventHandler,
|
|
5
|
+
ForwardedRef,
|
|
6
|
+
forwardRef,
|
|
7
|
+
InputHTMLAttributes,
|
|
8
|
+
KeyboardEventHandler,
|
|
9
|
+
MouseEventHandler,
|
|
10
|
+
PointerEventHandler,
|
|
11
|
+
useCallback,
|
|
12
|
+
useEffect,
|
|
13
|
+
useLayoutEffect,
|
|
14
|
+
useRef,
|
|
15
|
+
useState,
|
|
16
|
+
} from "react";
|
|
17
|
+
import { useGlobalInputBlurListener } from "../../contexts/GlobalInputBlurContext";
|
|
18
|
+
|
|
19
|
+
type Props = {
|
|
20
|
+
id?: string;
|
|
21
|
+
style?: any;
|
|
22
|
+
className?: string;
|
|
23
|
+
type?: "text" | "search";
|
|
24
|
+
disabled?: boolean;
|
|
25
|
+
value: string;
|
|
26
|
+
placeholder?: string;
|
|
27
|
+
role?: AriaRole;
|
|
28
|
+
name?: HTMLInputElement["name"];
|
|
29
|
+
onKeyDown?: KeyboardEventHandler;
|
|
30
|
+
onClick?: MouseEventHandler;
|
|
31
|
+
onDoubleClick?: MouseEventHandler;
|
|
32
|
+
onPointerDown?: PointerEventHandler;
|
|
33
|
+
onFocusCapture?: FocusEventHandler;
|
|
34
|
+
onFocusChange?: (isFocused: boolean) => void;
|
|
35
|
+
onBlur?: FocusEventHandler;
|
|
36
|
+
} & Pick<
|
|
37
|
+
InputHTMLAttributes<HTMLInputElement>,
|
|
38
|
+
"autoComplete" | "autoCapitalize" | "autoCorrect" | "spellCheck" | "autoFocus"
|
|
39
|
+
>;
|
|
40
|
+
|
|
41
|
+
type ReadOnlyProps = Props & {
|
|
42
|
+
readOnly: true;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const ReadOnlyTextInput = forwardRef(function ReadOnlyTextInput(
|
|
46
|
+
{ onKeyDown, onFocusChange, value, ...rest }: ReadOnlyProps,
|
|
47
|
+
forwardedRef: ForwardedRef<HTMLInputElement>
|
|
48
|
+
) {
|
|
49
|
+
return (
|
|
50
|
+
<input
|
|
51
|
+
ref={forwardedRef}
|
|
52
|
+
{...rest}
|
|
53
|
+
value={value}
|
|
54
|
+
onKeyDown={onKeyDown}
|
|
55
|
+
readOnly
|
|
56
|
+
/>
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
type ControlledProps = Props & {
|
|
61
|
+
onChange: (value: string) => void;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const ControlledTextInput = forwardRef(function ControlledTextInput(
|
|
65
|
+
{
|
|
66
|
+
onKeyDown,
|
|
67
|
+
value,
|
|
68
|
+
onChange,
|
|
69
|
+
onFocusChange,
|
|
70
|
+
onBlur,
|
|
71
|
+
onFocusCapture,
|
|
72
|
+
...rest
|
|
73
|
+
}: ControlledProps,
|
|
74
|
+
forwardedRef: ForwardedRef<HTMLInputElement>
|
|
75
|
+
) {
|
|
76
|
+
const handleBlur = useCallback(
|
|
77
|
+
(event: React.FocusEvent<HTMLInputElement>) => {
|
|
78
|
+
onBlur?.(event);
|
|
79
|
+
|
|
80
|
+
onFocusChange?.(false);
|
|
81
|
+
},
|
|
82
|
+
[onBlur, onFocusChange]
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const handleFocusCapture = useCallback(
|
|
86
|
+
(event: React.FocusEvent<HTMLInputElement>) => {
|
|
87
|
+
onFocusCapture?.(event);
|
|
88
|
+
|
|
89
|
+
onFocusChange?.(true);
|
|
90
|
+
},
|
|
91
|
+
[onFocusCapture, onFocusChange]
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<input
|
|
96
|
+
ref={forwardedRef}
|
|
97
|
+
{...rest}
|
|
98
|
+
value={value}
|
|
99
|
+
onKeyDown={onKeyDown}
|
|
100
|
+
onChange={useCallback(
|
|
101
|
+
(event: React.ChangeEvent<HTMLInputElement>) =>
|
|
102
|
+
onChange(event.target.value),
|
|
103
|
+
[onChange]
|
|
104
|
+
)}
|
|
105
|
+
onBlur={handleBlur}
|
|
106
|
+
onFocusCapture={handleFocusCapture}
|
|
107
|
+
/>
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
type SubmittableProps = Props & {
|
|
112
|
+
onSubmit: (value: string) => void;
|
|
113
|
+
allowSubmittingWithSameValue?: boolean;
|
|
114
|
+
submitAutomaticallyAfterDelay?: number;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const SubmittableTextInput = forwardRef(function SubmittableTextInput(
|
|
118
|
+
{
|
|
119
|
+
onKeyDown,
|
|
120
|
+
value,
|
|
121
|
+
onSubmit,
|
|
122
|
+
onBlur,
|
|
123
|
+
onFocusChange,
|
|
124
|
+
onFocusCapture,
|
|
125
|
+
allowSubmittingWithSameValue = false,
|
|
126
|
+
submitAutomaticallyAfterDelay,
|
|
127
|
+
...rest
|
|
128
|
+
}: SubmittableProps,
|
|
129
|
+
forwardedRef: ForwardedRef<HTMLInputElement>
|
|
130
|
+
) {
|
|
131
|
+
const ref = React.useRef<HTMLInputElement>(null);
|
|
132
|
+
|
|
133
|
+
const latestValue = useRef(value);
|
|
134
|
+
latestValue.current = value;
|
|
135
|
+
|
|
136
|
+
// Track whether the user has edited the input since the last submission.
|
|
137
|
+
// We use this to support automatically submitting the input after a delay.
|
|
138
|
+
const userDidEdit = useRef(false);
|
|
139
|
+
|
|
140
|
+
const isSubmitTriggeredByEscapeKey = useRef(false);
|
|
141
|
+
|
|
142
|
+
const [internalValue, setInternalValue] = useState("");
|
|
143
|
+
|
|
144
|
+
useLayoutEffect(() => {
|
|
145
|
+
setInternalValue(value);
|
|
146
|
+
}, [value]);
|
|
147
|
+
|
|
148
|
+
const handleSubmit = useCallback(() => {
|
|
149
|
+
// If this submission was triggered with Escape, we attempt to submit the original value.
|
|
150
|
+
// This will only actually call `onSubmit` if `allowSubmittingWithSameValue` is also true.
|
|
151
|
+
const submissionValue = isSubmitTriggeredByEscapeKey.current
|
|
152
|
+
? value
|
|
153
|
+
: internalValue;
|
|
154
|
+
|
|
155
|
+
isSubmitTriggeredByEscapeKey.current = false;
|
|
156
|
+
|
|
157
|
+
if (submissionValue === value && !allowSubmittingWithSameValue) return;
|
|
158
|
+
|
|
159
|
+
onSubmit(submissionValue);
|
|
160
|
+
|
|
161
|
+
userDidEdit.current = false;
|
|
162
|
+
|
|
163
|
+
setInternalValue(latestValue.current);
|
|
164
|
+
}, [allowSubmittingWithSameValue, value, internalValue, onSubmit]);
|
|
165
|
+
|
|
166
|
+
useGlobalInputBlurListener(
|
|
167
|
+
useCallback(() => {
|
|
168
|
+
if (ref.current !== document.activeElement) return;
|
|
169
|
+
|
|
170
|
+
handleSubmit();
|
|
171
|
+
}, [handleSubmit])
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const handleKeyDown = useCallback(
|
|
175
|
+
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
176
|
+
if (event.key === "Enter") {
|
|
177
|
+
handleSubmit();
|
|
178
|
+
|
|
179
|
+
event.preventDefault();
|
|
180
|
+
event.stopPropagation();
|
|
181
|
+
} else if (event.key === "Escape") {
|
|
182
|
+
isSubmitTriggeredByEscapeKey.current = true;
|
|
183
|
+
|
|
184
|
+
ref.current?.blur();
|
|
185
|
+
} else {
|
|
186
|
+
onKeyDown?.(event);
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
[onKeyDown, handleSubmit]
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const handleChange = useCallback(
|
|
193
|
+
(event: React.ChangeEvent<HTMLInputElement>) => {
|
|
194
|
+
userDidEdit.current = true;
|
|
195
|
+
|
|
196
|
+
setInternalValue(event.target.value);
|
|
197
|
+
},
|
|
198
|
+
[]
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
const handleBlur = useCallback(
|
|
202
|
+
(event: React.FocusEvent<HTMLInputElement>) => {
|
|
203
|
+
onBlur?.(event);
|
|
204
|
+
|
|
205
|
+
onFocusChange?.(false);
|
|
206
|
+
|
|
207
|
+
handleSubmit();
|
|
208
|
+
},
|
|
209
|
+
[onBlur, onFocusChange, handleSubmit]
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const handleFocusCapture = useCallback(
|
|
213
|
+
(event: React.FocusEvent<HTMLInputElement>) => {
|
|
214
|
+
onFocusCapture?.(event);
|
|
215
|
+
|
|
216
|
+
onFocusChange?.(true);
|
|
217
|
+
},
|
|
218
|
+
[onFocusCapture, onFocusChange]
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
const autoSubmitTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
|
222
|
+
null
|
|
223
|
+
);
|
|
224
|
+
const latestHandleSubmit = useRef(handleSubmit);
|
|
225
|
+
latestHandleSubmit.current = handleSubmit;
|
|
226
|
+
|
|
227
|
+
useEffect(() => {
|
|
228
|
+
if (submitAutomaticallyAfterDelay) {
|
|
229
|
+
if (autoSubmitTimeoutRef.current) {
|
|
230
|
+
clearTimeout(autoSubmitTimeoutRef.current);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
autoSubmitTimeoutRef.current = setTimeout(() => {
|
|
234
|
+
if (userDidEdit.current === false) return;
|
|
235
|
+
|
|
236
|
+
latestHandleSubmit.current();
|
|
237
|
+
}, submitAutomaticallyAfterDelay);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Clear the timeout when the component is unmounted or if the value changes
|
|
241
|
+
return () => {
|
|
242
|
+
if (autoSubmitTimeoutRef.current) {
|
|
243
|
+
clearTimeout(autoSubmitTimeoutRef.current);
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
// Track `internalValue` to explicitly reset the timer
|
|
247
|
+
}, [internalValue, submitAutomaticallyAfterDelay]);
|
|
248
|
+
|
|
249
|
+
return (
|
|
250
|
+
<input
|
|
251
|
+
ref={composeRefs(ref, forwardedRef)}
|
|
252
|
+
{...rest}
|
|
253
|
+
value={internalValue}
|
|
254
|
+
onKeyDown={handleKeyDown}
|
|
255
|
+
onChange={handleChange}
|
|
256
|
+
onBlur={handleBlur}
|
|
257
|
+
onFocusCapture={handleFocusCapture}
|
|
258
|
+
/>
|
|
259
|
+
);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
export type TextInputProps = ReadOnlyProps | ControlledProps | SubmittableProps;
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* This component shouldn't be used directly. Instead use the InputField components.
|
|
266
|
+
*/
|
|
267
|
+
export default forwardRef(function TextInput(
|
|
268
|
+
props: TextInputProps,
|
|
269
|
+
forwardedRef: ForwardedRef<HTMLInputElement>
|
|
270
|
+
) {
|
|
271
|
+
const commonProps: TextInputProps = {
|
|
272
|
+
onPointerDown: useCallback(
|
|
273
|
+
(event: React.PointerEvent) => event.stopPropagation(),
|
|
274
|
+
[]
|
|
275
|
+
),
|
|
276
|
+
onClick: useCallback(
|
|
277
|
+
(event: React.MouseEvent) => event.stopPropagation(),
|
|
278
|
+
[]
|
|
279
|
+
),
|
|
280
|
+
autoComplete: "off",
|
|
281
|
+
autoCapitalize: "off",
|
|
282
|
+
autoCorrect: "off",
|
|
283
|
+
spellCheck: false,
|
|
284
|
+
type: "text",
|
|
285
|
+
disabled: false,
|
|
286
|
+
...props,
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
if ("readOnly" in commonProps) {
|
|
290
|
+
return <ReadOnlyTextInput ref={forwardedRef} {...commonProps} readOnly />;
|
|
291
|
+
} else if ("onChange" in commonProps) {
|
|
292
|
+
return <ControlledTextInput ref={forwardedRef} {...commonProps} />;
|
|
293
|
+
} else {
|
|
294
|
+
return <SubmittableTextInput ref={forwardedRef} {...commonProps} />;
|
|
295
|
+
}
|
|
296
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { fireEvent, render } from '@testing-library/react';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import TextInput from '../TextInput';
|
|
4
|
+
|
|
5
|
+
describe('submittable text input', () => {
|
|
6
|
+
test('change and blur', () => {
|
|
7
|
+
let text = '';
|
|
8
|
+
const handleSubmit = (value: string) => {
|
|
9
|
+
text = value;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const { container, rerender } = render(
|
|
13
|
+
<TextInput value={text} onSubmit={handleSubmit} />,
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
const input = container.querySelector('input')!;
|
|
17
|
+
fireEvent.change(input, { target: { value: 'abc' } });
|
|
18
|
+
fireEvent.blur(input);
|
|
19
|
+
|
|
20
|
+
expect(text).toEqual('abc');
|
|
21
|
+
expect(input.value).toEqual('');
|
|
22
|
+
|
|
23
|
+
rerender(<TextInput value={text} onSubmit={handleSubmit} />);
|
|
24
|
+
|
|
25
|
+
expect(input.value).toEqual('abc');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('change and blur, reset', () => {
|
|
29
|
+
const handleSubmit = (value: string) => {};
|
|
30
|
+
|
|
31
|
+
const { container, rerender } = render(
|
|
32
|
+
<TextInput value="" onSubmit={handleSubmit} />,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const input = container.querySelector('input')!;
|
|
36
|
+
fireEvent.change(input, { target: { value: 'abc' } });
|
|
37
|
+
fireEvent.blur(input);
|
|
38
|
+
|
|
39
|
+
expect(input.value).toEqual('');
|
|
40
|
+
|
|
41
|
+
rerender(<TextInput value="def" onSubmit={handleSubmit} />);
|
|
42
|
+
|
|
43
|
+
expect(input.value).toEqual('def');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('submit without changing', () => {
|
|
47
|
+
const handleSubmit = jest.fn();
|
|
48
|
+
|
|
49
|
+
const { container } = render(
|
|
50
|
+
<TextInput value="" onSubmit={handleSubmit} />,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const input = container.querySelector('input')!;
|
|
54
|
+
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
|
55
|
+
|
|
56
|
+
expect(handleSubmit).not.toBeCalled();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('change and submit', () => {
|
|
60
|
+
let text = '';
|
|
61
|
+
const handleSubmit = (value: string) => {
|
|
62
|
+
text = value;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const { container, rerender } = render(
|
|
66
|
+
<TextInput value={text} onSubmit={handleSubmit} />,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const input = container.querySelector('input')!;
|
|
70
|
+
fireEvent.change(input, { target: { value: 'abc' } });
|
|
71
|
+
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
|
72
|
+
|
|
73
|
+
expect(text).toEqual('abc');
|
|
74
|
+
expect(input.value).toEqual('');
|
|
75
|
+
|
|
76
|
+
rerender(<TextInput value={text} onSubmit={handleSubmit} />);
|
|
77
|
+
|
|
78
|
+
expect(input.value).toEqual('abc');
|
|
79
|
+
|
|
80
|
+
fireEvent.change(input, { target: { value: 'abcdef' } });
|
|
81
|
+
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
|
82
|
+
|
|
83
|
+
expect(text).toEqual('abcdef');
|
|
84
|
+
expect(input.value).toEqual('abc');
|
|
85
|
+
|
|
86
|
+
rerender(<TextInput value={text} onSubmit={handleSubmit} />);
|
|
87
|
+
|
|
88
|
+
expect(input.value).toEqual('abcdef');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('change and submit, reset', () => {
|
|
92
|
+
const handleSubmit = (value: string) => {};
|
|
93
|
+
|
|
94
|
+
const { container, rerender } = render(
|
|
95
|
+
<TextInput value="" onSubmit={handleSubmit} />,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const input = container.querySelector('input')!;
|
|
99
|
+
fireEvent.change(input, { target: { value: 'abc' } });
|
|
100
|
+
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
|
101
|
+
|
|
102
|
+
expect(input.value).toEqual('');
|
|
103
|
+
|
|
104
|
+
rerender(<TextInput value="" onSubmit={handleSubmit} />);
|
|
105
|
+
|
|
106
|
+
expect(input.value).toEqual('');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('submit only once', () => {
|
|
110
|
+
const handleSubmit = jest.fn();
|
|
111
|
+
|
|
112
|
+
const { container } = render(
|
|
113
|
+
<TextInput value="" onSubmit={handleSubmit} />,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const input = container.querySelector('input')!;
|
|
117
|
+
fireEvent.change(input, { target: { value: 'abc' } });
|
|
118
|
+
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
|
119
|
+
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
|
120
|
+
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
|
121
|
+
|
|
122
|
+
expect(handleSubmit).toBeCalledTimes(1);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('submit multiple times', () => {
|
|
126
|
+
const handleSubmit = jest.fn();
|
|
127
|
+
|
|
128
|
+
const { container } = render(
|
|
129
|
+
<TextInput
|
|
130
|
+
value=""
|
|
131
|
+
onSubmit={handleSubmit}
|
|
132
|
+
allowSubmittingWithSameValue
|
|
133
|
+
/>,
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const input = container.querySelector('input')!;
|
|
137
|
+
fireEvent.change(input, { target: { value: 'abc' } });
|
|
138
|
+
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
|
139
|
+
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
|
140
|
+
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
|
141
|
+
|
|
142
|
+
expect(handleSubmit).toBeCalledTimes(3);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { PlatformName } from '@noya-app/noya-keymap';
|
|
2
|
+
import React, {
|
|
3
|
+
createContext,
|
|
4
|
+
memo,
|
|
5
|
+
ReactNode,
|
|
6
|
+
useContext,
|
|
7
|
+
useMemo,
|
|
8
|
+
} from 'react';
|
|
9
|
+
import { ThemeProvider, useTheme } from 'styled-components';
|
|
10
|
+
import { ToastProvider } from '../components/Toast';
|
|
11
|
+
import { Theme } from '../theme';
|
|
12
|
+
import { DialogProvider } from './DialogContext';
|
|
13
|
+
|
|
14
|
+
export type DesignSystemConfigurationContextValue = {
|
|
15
|
+
platform: PlatformName;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const DesignSystemConfigurationContext = createContext<
|
|
19
|
+
DesignSystemConfigurationContextValue | undefined
|
|
20
|
+
>(undefined);
|
|
21
|
+
|
|
22
|
+
export const DesignSystemConfigurationProvider = memo(
|
|
23
|
+
function DesignSystemConfigurationProvider({
|
|
24
|
+
children,
|
|
25
|
+
theme,
|
|
26
|
+
platform,
|
|
27
|
+
}: {
|
|
28
|
+
children: ReactNode;
|
|
29
|
+
theme: Theme;
|
|
30
|
+
} & DesignSystemConfigurationContextValue) {
|
|
31
|
+
const contextValue = useMemo(() => ({ platform }), [platform]);
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<DesignSystemConfigurationContext.Provider value={contextValue}>
|
|
35
|
+
<ThemeProvider theme={theme}>
|
|
36
|
+
<DialogProvider>
|
|
37
|
+
<ToastProvider>{children}</ToastProvider>
|
|
38
|
+
</DialogProvider>
|
|
39
|
+
</ThemeProvider>
|
|
40
|
+
</DesignSystemConfigurationContext.Provider>
|
|
41
|
+
);
|
|
42
|
+
},
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
export function useDesignSystemConfiguration(): DesignSystemConfigurationContextValue {
|
|
46
|
+
const value = useContext(DesignSystemConfigurationContext);
|
|
47
|
+
|
|
48
|
+
if (!value) {
|
|
49
|
+
throw new Error('Missing DesignSystemConfigurationProvider');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function useDesignSystemTheme() {
|
|
56
|
+
return useTheme();
|
|
57
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
createContext,
|
|
3
|
+
ReactNode,
|
|
4
|
+
useCallback,
|
|
5
|
+
useContext,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
} from "react";
|
|
10
|
+
import styled from "styled-components";
|
|
11
|
+
import { Button } from "../components/Button";
|
|
12
|
+
import { Dialog, IDialog } from "../components/Dialog";
|
|
13
|
+
import { InputField } from "../components/InputField";
|
|
14
|
+
import { Spacer } from "../components/Spacer";
|
|
15
|
+
|
|
16
|
+
export const Row = styled.div(({ theme }) => ({
|
|
17
|
+
flex: "1",
|
|
18
|
+
display: "flex",
|
|
19
|
+
flexDirection: "row",
|
|
20
|
+
alignItems: "center",
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
function createDeferredPromise<T>() {
|
|
24
|
+
let resolve: (value: T) => void = () => {};
|
|
25
|
+
let reject: (value: any) => void = () => {};
|
|
26
|
+
|
|
27
|
+
const promise = new Promise<T>((res, rej) => {
|
|
28
|
+
resolve = res;
|
|
29
|
+
reject = rej;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
return { promise, resolve, reject };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type DialogContextValue = {
|
|
36
|
+
openInputDialog(
|
|
37
|
+
title: string,
|
|
38
|
+
inputValue?: string
|
|
39
|
+
): Promise<string | undefined>;
|
|
40
|
+
|
|
41
|
+
containsElement(element: HTMLElement): boolean;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const DialogContext = createContext<DialogContextValue | undefined>(undefined);
|
|
45
|
+
|
|
46
|
+
export const DialogProvider = function DialogProvider({
|
|
47
|
+
children,
|
|
48
|
+
}: {
|
|
49
|
+
children: ReactNode;
|
|
50
|
+
}) {
|
|
51
|
+
const [contents, setContents] = useState<
|
|
52
|
+
| {
|
|
53
|
+
title: string;
|
|
54
|
+
inputValue: string;
|
|
55
|
+
resolve: (value: string | undefined) => void;
|
|
56
|
+
}
|
|
57
|
+
| undefined
|
|
58
|
+
>();
|
|
59
|
+
|
|
60
|
+
const isOpen = !!contents;
|
|
61
|
+
|
|
62
|
+
const close = useCallback(() => {
|
|
63
|
+
if (!contents) return;
|
|
64
|
+
|
|
65
|
+
contents.resolve(undefined);
|
|
66
|
+
}, [contents]);
|
|
67
|
+
|
|
68
|
+
const submit = useCallback(() => {
|
|
69
|
+
if (!contents || !contents.inputValue) return;
|
|
70
|
+
|
|
71
|
+
contents.resolve(contents.inputValue);
|
|
72
|
+
setContents(undefined);
|
|
73
|
+
}, [contents]);
|
|
74
|
+
|
|
75
|
+
const open: DialogContextValue["openInputDialog"] = useCallback(
|
|
76
|
+
(title, inputValue = "") => {
|
|
77
|
+
const { promise, resolve } = createDeferredPromise<string | undefined>();
|
|
78
|
+
|
|
79
|
+
setContents({
|
|
80
|
+
title,
|
|
81
|
+
inputValue,
|
|
82
|
+
resolve: (value) => {
|
|
83
|
+
resolve(value);
|
|
84
|
+
setContents(undefined);
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return promise;
|
|
89
|
+
},
|
|
90
|
+
[]
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const handleKeyDown = useCallback(
|
|
94
|
+
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
95
|
+
if (event.key !== "Enter") return;
|
|
96
|
+
|
|
97
|
+
event.stopPropagation();
|
|
98
|
+
event.preventDefault();
|
|
99
|
+
|
|
100
|
+
submit();
|
|
101
|
+
},
|
|
102
|
+
[submit]
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
106
|
+
const dialogRef = useRef<IDialog>(null);
|
|
107
|
+
|
|
108
|
+
const containsElement = useCallback((element: HTMLElement) => {
|
|
109
|
+
if (!dialogRef.current) return false;
|
|
110
|
+
|
|
111
|
+
return dialogRef.current.containsElement(element);
|
|
112
|
+
}, []);
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<>
|
|
116
|
+
<DialogContext.Provider
|
|
117
|
+
value={useMemo(
|
|
118
|
+
() => ({ openInputDialog: open, containsElement }),
|
|
119
|
+
[containsElement, open]
|
|
120
|
+
)}
|
|
121
|
+
>
|
|
122
|
+
{children}
|
|
123
|
+
</DialogContext.Provider>
|
|
124
|
+
<Dialog
|
|
125
|
+
ref={dialogRef}
|
|
126
|
+
title={contents?.title}
|
|
127
|
+
open={isOpen}
|
|
128
|
+
onOpenChange={useCallback(
|
|
129
|
+
(isOpen: boolean) => {
|
|
130
|
+
if (!isOpen) {
|
|
131
|
+
close();
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
[close]
|
|
135
|
+
)}
|
|
136
|
+
onOpenAutoFocus={useCallback((event: Event) => {
|
|
137
|
+
event.stopPropagation();
|
|
138
|
+
event.preventDefault();
|
|
139
|
+
|
|
140
|
+
inputRef.current?.focus();
|
|
141
|
+
}, [])}
|
|
142
|
+
>
|
|
143
|
+
<InputField.Root>
|
|
144
|
+
<InputField.Input
|
|
145
|
+
ref={inputRef}
|
|
146
|
+
value={contents?.inputValue ?? ""}
|
|
147
|
+
onChange={(value) => {
|
|
148
|
+
setContents((contents) =>
|
|
149
|
+
contents
|
|
150
|
+
? {
|
|
151
|
+
...contents,
|
|
152
|
+
inputValue: value,
|
|
153
|
+
}
|
|
154
|
+
: undefined
|
|
155
|
+
);
|
|
156
|
+
}}
|
|
157
|
+
onKeyDown={handleKeyDown}
|
|
158
|
+
/>
|
|
159
|
+
</InputField.Root>
|
|
160
|
+
<Spacer.Vertical size={20} />
|
|
161
|
+
<Row>
|
|
162
|
+
<Spacer.Horizontal />
|
|
163
|
+
<Button onClick={close}>Cancel</Button>
|
|
164
|
+
<Spacer.Horizontal size={16} />
|
|
165
|
+
<Button disabled={!contents?.inputValue} onClick={submit}>
|
|
166
|
+
Submit
|
|
167
|
+
</Button>
|
|
168
|
+
</Row>
|
|
169
|
+
</Dialog>
|
|
170
|
+
</>
|
|
171
|
+
);
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
function useDialog(): DialogContextValue {
|
|
175
|
+
const value = useContext(DialogContext);
|
|
176
|
+
|
|
177
|
+
if (!value) {
|
|
178
|
+
throw new Error("Missing DialogProvider");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return value;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function useOpenInputDialog() {
|
|
185
|
+
return useDialog().openInputDialog;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function useDialogContainsElement() {
|
|
189
|
+
return useDialog().containsElement;
|
|
190
|
+
}
|