forked from Workday/canvas-kit
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathuseComboboxInputConstrained.ts
163 lines (150 loc) · 5.6 KB
/
useComboboxInputConstrained.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
import React from 'react';
import {
createElemPropsHook,
useLocalRef,
dispatchInputEvent,
} from '@workday/canvas-kit-react/common';
import {useComboboxModel} from './useComboboxModel';
function onlyDefined<T>(input: T | undefined): input is T {
return !!input;
}
/**
* A constrained combobox input can only offer values that are part of the provided list of `items`.
* The default is an unconstrained. A constrained input should have both a form input that is hidden
* from the user as well as a user input that will be visible to the user. This hook is in charge of
* keeping the inputs and the model in sync with each other and working with a browser's
* autocomplete, form libraries and the model.
*/
export const useComboboxInputConstrained = createElemPropsHook(useComboboxModel)(
(
model,
ref,
{
disabled,
value: reactValue,
onChange,
name,
}: Pick<
React.InputHTMLAttributes<HTMLInputElement>,
'disabled' | 'value' | 'onChange' | 'name'
> = {}
) => {
// The user element is what the user sees
const {elementRef: userElementRef, localRef: userLocalRef} = useLocalRef(
model.state.targetRef as React.Ref<HTMLInputElement>
);
// The form element is what is seen in `FormData` during for submission to the server
const {elementRef: formElementRef, localRef: formLocalRef} = useLocalRef(
ref as React.Ref<HTMLInputElement>
);
// Create React refs so we can get the current value inside an Effect without using those values
// as part of the dependency array.
const modelNavigationRef = React.useRef(model.navigation);
modelNavigationRef.current = model.navigation;
const modelStateRef = React.useRef(model.state);
modelStateRef.current = model.state;
// Watch the `value` prop passed from React props and update the model accordingly
React.useLayoutEffect(() => {
if (formLocalRef.current && typeof reactValue === 'string') {
if (reactValue !== formLocalRef.current.value) {
model.events.setSelectedIds(reactValue ? reactValue.split(', ') : []);
}
}
}, [reactValue, formLocalRef, model.events]);
// useImperativeHandle allows us to modify the `ref` before it is sent to the application,
// but after it is defined. We can add value watches, and redirect methods here.
React.useImperativeHandle(
formElementRef,
() => {
if (formLocalRef.current) {
// Hook into the DOM `value` property of the form input element and update the model
// accordingly
Object.defineProperty(formLocalRef.current, 'value', {
get() {
const value = Object.getOwnPropertyDescriptor(
Object.getPrototypeOf(formLocalRef.current),
'value'
)?.get?.call(formLocalRef.current);
return value;
},
set(value: string) {
if (
formLocalRef.current &&
value !==
(modelStateRef.current.selectedIds === 'all'
? []
: modelStateRef.current.selectedIds
).join(', ')
) {
model.events.setSelectedIds(value ? value.split(', ') : []);
}
},
});
// forward calls to `.focus()` and `.blur()` to the user input
formLocalRef.current.focus = (options?: FocusOptions) => {
userLocalRef.current!.focus(options);
};
formLocalRef.current.blur = () => {
userLocalRef.current!.blur();
};
}
return formLocalRef.current!;
},
[formLocalRef, userLocalRef, model.events]
);
// sync model selection state with inputs
React.useLayoutEffect(() => {
if (userLocalRef.current) {
const userValue =
model.state.items.length === 0
? ''
: (model.state.selectedIds === 'all'
? []
: model.state.selectedIds
.map(id =>
modelNavigationRef.current.getItem(id, {state: modelStateRef.current})
)
.filter(onlyDefined)
.map(item => item.textValue)
).join(', ');
if (userValue !== userLocalRef.current.value) {
dispatchInputEvent(userLocalRef.current, userValue);
}
}
if (formLocalRef.current) {
const formValue = (model.state.selectedIds === 'all' ? [] : model.state.selectedIds).join(
', '
);
if (formValue !== formLocalRef.current.value) {
dispatchInputEvent(formLocalRef.current, formValue);
}
}
}, [model.state.selectedIds, model.state.items, formLocalRef, userLocalRef]);
// The props here will go to the user input.
return {
ref: userElementRef,
form: '', // We don't want the user input to be part of the form [elements](https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/elements)
value: null,
onChange: (event: React.ChangeEvent<HTMLInputElement>) => {
model.onFilterChange?.(event);
return null; // Prevent further `onChange` callbacks from firing
},
name: null,
disabled,
/**
* These props should be spread onto the form input.
*/
formInputProps: {
disabled,
tabIndex: -1,
'aria-hidden': true,
ref: formElementRef,
onChange: (event: React.ChangeEvent<HTMLInputElement>) => {
onChange?.(event);
model.onChange?.(event);
},
name,
},
};
}
);