@kwiz/fluentui 1.0.16 → 1.0.19
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/.github/workflows/npm-publish.yml +34 -0
- package/LICENSE +21 -21
- package/README.md +26 -26
- package/package.json +72 -72
- package/src/_modules/config.ts +9 -9
- package/src/_modules/constants.ts +3 -3
- package/src/controls/accordion.tsx +48 -48
- package/src/controls/button.tsx +169 -169
- package/src/controls/centered.tsx +22 -22
- package/src/controls/date.tsx +39 -39
- package/src/controls/dropdown.tsx +51 -51
- package/src/controls/error-boundary.tsx +41 -41
- package/src/controls/field-editor.tsx +40 -40
- package/src/controls/file-upload.tsx +67 -67
- package/src/controls/horizontal.tsx +34 -34
- package/src/controls/input.tsx +60 -60
- package/src/controls/kwizoverflow.tsx +103 -103
- package/src/controls/list.tsx +117 -117
- package/src/controls/loading.tsx +10 -10
- package/src/controls/please-wait.tsx +32 -32
- package/src/controls/prompt.tsx +96 -96
- package/src/controls/search.tsx +65 -65
- package/src/controls/section.tsx +51 -51
- package/src/controls/svg.tsx +120 -120
- package/src/controls/toolbar.tsx +48 -48
- package/src/controls/vertical-content.tsx +49 -49
- package/src/controls/vertical.tsx +34 -34
- package/src/helpers/context.ts +39 -39
- package/src/helpers/hooks.tsx +335 -335
- package/src/index.ts +26 -26
- package/src/styles/styles.ts +87 -87
- package/src/styles/theme.ts +90 -90
- package/dist/_modules/build.d.ts +0 -2
- package/dist/_modules/build.js +0 -3
- package/dist/_modules/build.js.map +0 -1
- package/dist/_modules/config.d.ts +0 -1
- package/dist/_modules/config.js +0 -9
- package/dist/_modules/config.js.map +0 -1
- package/dist/_modules/constants.d.ts +0 -2
- package/dist/_modules/constants.js +0 -3
- package/dist/_modules/constants.js.map +0 -1
- package/dist/_modules/exports-index.d.ts +0 -1
- package/dist/_modules/exports-index.js +0 -2
- package/dist/_modules/exports-index.js.map +0 -1
- package/dist/controls/accordion.d.ts +0 -13
- package/dist/controls/accordion.js +0 -27
- package/dist/controls/accordion.js.map +0 -1
- package/dist/controls/button.d.ts +0 -28
- package/dist/controls/button.js +0 -113
- package/dist/controls/button.js.map +0 -1
- package/dist/controls/centered.d.ts +0 -5
- package/dist/controls/centered.js +0 -14
- package/dist/controls/centered.js.map +0 -1
- package/dist/controls/date.d.ts +0 -8
- package/dist/controls/date.js +0 -32
- package/dist/controls/date.js.map +0 -1
- package/dist/controls/dropdown.d.ts +0 -29
- package/dist/controls/dropdown.js +0 -27
- package/dist/controls/dropdown.js.map +0 -1
- package/dist/controls/error-boundary.d.ts +0 -23
- package/dist/controls/error-boundary.js +0 -33
- package/dist/controls/error-boundary.js.map +0 -1
- package/dist/controls/exports-index.d.ts +0 -17
- package/dist/controls/exports-index.js +0 -18
- package/dist/controls/exports-index.js.map +0 -1
- package/dist/controls/field-editor.d.ts +0 -13
- package/dist/controls/field-editor.js +0 -15
- package/dist/controls/field-editor.js.map +0 -1
- package/dist/controls/file-upload.d.ts +0 -18
- package/dist/controls/file-upload.js +0 -41
- package/dist/controls/file-upload.js.map +0 -1
- package/dist/controls/horizontal.d.ts +0 -8
- package/dist/controls/horizontal.js +0 -23
- package/dist/controls/horizontal.js.map +0 -1
- package/dist/controls/input.d.ts +0 -13
- package/dist/controls/input.js +0 -43
- package/dist/controls/input.js.map +0 -1
- package/dist/controls/kwizoverflow.d.ts +0 -14
- package/dist/controls/kwizoverflow.js +0 -45
- package/dist/controls/kwizoverflow.js.map +0 -1
- package/dist/controls/list.d.ts +0 -21
- package/dist/controls/list.js +0 -72
- package/dist/controls/list.js.map +0 -1
- package/dist/controls/loading copy.d.ts +0 -5
- package/dist/controls/loading copy.js +0 -7
- package/dist/controls/loading copy.js.map +0 -1
- package/dist/controls/loading.d.ts +0 -5
- package/dist/controls/loading.js +0 -7
- package/dist/controls/loading.js.map +0 -1
- package/dist/controls/please-wait.d.ts +0 -18
- package/dist/controls/please-wait.js +0 -16
- package/dist/controls/please-wait.js.map +0 -1
- package/dist/controls/prompt.d.ts +0 -32
- package/dist/controls/prompt.js +0 -31
- package/dist/controls/prompt.js.map +0 -1
- package/dist/controls/search.d.ts +0 -13
- package/dist/controls/search.js +0 -47
- package/dist/controls/search.js.map +0 -1
- package/dist/controls/section.d.ts +0 -14
- package/dist/controls/section.js +0 -27
- package/dist/controls/section.js.map +0 -1
- package/dist/controls/svg.d.ts +0 -23
- package/dist/controls/svg.js +0 -45
- package/dist/controls/svg.js.map +0 -1
- package/dist/controls/toolbar.d.ts +0 -12
- package/dist/controls/toolbar.js +0 -23
- package/dist/controls/toolbar.js.map +0 -1
- package/dist/controls/vertical-content.d.ts +0 -6
- package/dist/controls/vertical-content.js +0 -37
- package/dist/controls/vertical-content.js.map +0 -1
- package/dist/controls/vertical.d.ts +0 -8
- package/dist/controls/vertical.js +0 -23
- package/dist/controls/vertical.js.map +0 -1
- package/dist/exports-index.d.ts +0 -3
- package/dist/exports-index.js +0 -4
- package/dist/exports-index.js.map +0 -1
- package/dist/helpers/context.d.ts +0 -26
- package/dist/helpers/context.js +0 -15
- package/dist/helpers/context.js.map +0 -1
- package/dist/helpers/hooks.d.ts +0 -62
- package/dist/helpers/hooks.js +0 -287
- package/dist/helpers/hooks.js.map +0 -1
- package/dist/index.d.ts +0 -25
- package/dist/index.js +0 -25
- package/dist/index.js.map +0 -1
- package/dist/styles/exports-index.d.ts +0 -2
- package/dist/styles/exports-index.js +0 -3
- package/dist/styles/exports-index.js.map +0 -1
- package/dist/styles/styles.d.ts +0 -19
- package/dist/styles/styles.js +0 -79
- package/dist/styles/styles.js.map +0 -1
- package/dist/styles/theme.d.ts +0 -6
- package/dist/styles/theme.js +0 -77
- package/dist/styles/theme.js.map +0 -1
package/src/helpers/hooks.tsx
CHANGED
|
@@ -1,336 +1,336 @@
|
|
|
1
|
-
import { Link, Toast, ToastBody, Toaster, ToastFooter, ToastIntent, ToastTitle, useId, useToastController } from "@fluentui/react-components";
|
|
2
|
-
import { IDictionary, isDebug, isFunction, isNotEmptyArray, isNullOrEmptyString, jsonClone, jsonStringify, LoggerLevel, objectsEqual, wrapFunction } from "@kwiz/common";
|
|
3
|
-
import { MutableRefObject, SetStateAction, useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
-
import { GetLogger } from "../_modules/config";
|
|
5
|
-
import { IPrompterProps, Prompter } from "../controls/prompt";
|
|
6
|
-
import { KnownClassNames } from "../styles/styles";
|
|
7
|
-
import { iKWIZFluentContext, useKWIZFluentContext } from "./context";
|
|
8
|
-
|
|
9
|
-
const logger = GetLogger("helpers/hooks");
|
|
10
|
-
/** Empty array ensures that effect is only run on mount */
|
|
11
|
-
export const useEffectOnlyOnMount = [];
|
|
12
|
-
|
|
13
|
-
/** set state on steroids. provide promise callback after render, onChange transformer and automatic skip-set when value not changed */
|
|
14
|
-
export function useStateEX<ValueType>(initialValue: ValueType, options?: {
|
|
15
|
-
onChange?: (newValue: SetStateAction<ValueType>) => SetStateAction<ValueType>;
|
|
16
|
-
//will not set state if value did not change
|
|
17
|
-
skipUpdateIfSame?: boolean;
|
|
18
|
-
//optional, provide a name for better logging
|
|
19
|
-
name?: string;
|
|
20
|
-
}):
|
|
21
|
-
[ValueType, (newValue: SetStateAction<ValueType>) => Promise<ValueType>, MutableRefObject<ValueType>] {
|
|
22
|
-
options = options || {};
|
|
23
|
-
const name = options.name || '';
|
|
24
|
-
|
|
25
|
-
let logger = GetLogger(`useStateWithTrack${isNullOrEmptyString(name) ? '' : ` ${name}`}`);
|
|
26
|
-
logger.setLevel(LoggerLevel.WARN);
|
|
27
|
-
|
|
28
|
-
const [value, setValueInState] = useState(initialValue);
|
|
29
|
-
const currentValue = useRef(value);
|
|
30
|
-
|
|
31
|
-
/** make this a collection in case several callers are awaiting the same propr update */
|
|
32
|
-
const resolveState = useRef<((v: ValueType) => void)[]>([]);
|
|
33
|
-
const isMounted = useRef(false);
|
|
34
|
-
|
|
35
|
-
useEffect(() => {
|
|
36
|
-
isMounted.current = true;
|
|
37
|
-
|
|
38
|
-
return () => {
|
|
39
|
-
isMounted.current = false;
|
|
40
|
-
};
|
|
41
|
-
}, useEffectOnlyOnMount);
|
|
42
|
-
|
|
43
|
-
function resolvePromises() {
|
|
44
|
-
if (isNotEmptyArray(resolveState.current)) {
|
|
45
|
-
let resolvers = resolveState.current.slice();
|
|
46
|
-
resolveState.current = [];//clear
|
|
47
|
-
resolvers.map(r => r(currentValue.current));
|
|
48
|
-
}
|
|
49
|
-
};
|
|
50
|
-
useEffect(() => {
|
|
51
|
-
resolvePromises();
|
|
52
|
-
if (isNotEmptyArray(resolveState.current)) {
|
|
53
|
-
logger.log(`resolved after render`);
|
|
54
|
-
let resolvers = resolveState.current.slice();
|
|
55
|
-
resolveState.current = [];//clear
|
|
56
|
-
resolvers.map(r => r(value));
|
|
57
|
-
}
|
|
58
|
-
}, [value, resolveState.current]);
|
|
59
|
-
|
|
60
|
-
let setValueWithCheck = !options.skipUpdateIfSame ? setValueInState : (newValue: ValueType) => {
|
|
61
|
-
logger.groupSync('conditional value change', log => {
|
|
62
|
-
if (logger.getLevel() === LoggerLevel.VERBOSE) {
|
|
63
|
-
log('old: ' + jsonStringify(currentValue.current));
|
|
64
|
-
log('new: ' + jsonStringify(newValue));
|
|
65
|
-
}
|
|
66
|
-
if (!objectsEqual(newValue as object, currentValue.current as object)) {
|
|
67
|
-
log(`value changed`);
|
|
68
|
-
setValueInState(newValue);
|
|
69
|
-
}
|
|
70
|
-
else {
|
|
71
|
-
log(`value unchanged`);
|
|
72
|
-
resolvePromises();
|
|
73
|
-
}
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
let setValueWithEvents = wrapFunction(setValueWithCheck, {
|
|
79
|
-
before: newValue => isFunction(options.onChange) ? options.onChange(newValue) : newValue,
|
|
80
|
-
after: newValue => currentValue.current = newValue as ValueType
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
const setValue = useCallback((newState: ValueType) => new Promise<ValueType>(resolve => {
|
|
84
|
-
if (!isMounted.current) {
|
|
85
|
-
//unmounted may never resolve
|
|
86
|
-
logger.log(`resolved without wait`);
|
|
87
|
-
resolve(newState);
|
|
88
|
-
}
|
|
89
|
-
else {
|
|
90
|
-
resolveState.current.push(resolve);
|
|
91
|
-
setValueWithEvents(newState);
|
|
92
|
-
}
|
|
93
|
-
}), []);
|
|
94
|
-
|
|
95
|
-
return [value, setValue, currentValue];
|
|
96
|
-
}
|
|
97
|
-
export function useTrackFocus(props: { onFocus: () => void, onLoseFocus: () => void, ref?: MutableRefObject<HTMLElement> }) {
|
|
98
|
-
const wrapperDiv = props.ref || useRef<HTMLDivElement>(null);
|
|
99
|
-
useEffect(() => {
|
|
100
|
-
function focusIn(e: FocusEvent) {
|
|
101
|
-
let elm = e.target as HTMLElement;//document.activeElement;
|
|
102
|
-
if (wrapperDiv.current) {
|
|
103
|
-
while (elm && elm !== wrapperDiv.current) {
|
|
104
|
-
elm = elm.parentElement;
|
|
105
|
-
}
|
|
106
|
-
} else elm = null;
|
|
107
|
-
if (wrapperDiv.current && elm === wrapperDiv.current) props.onFocus();
|
|
108
|
-
else props.onLoseFocus();
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
if (wrapperDiv.current) {
|
|
112
|
-
if (wrapperDiv.current) wrapperDiv.current.tabIndex = 1;
|
|
113
|
-
window.addEventListener("focusin", focusIn);
|
|
114
|
-
// Remove event listener on cleanup
|
|
115
|
-
return () => window.removeEventListener("focusin", focusIn);
|
|
116
|
-
}
|
|
117
|
-
}, [wrapperDiv.current]);
|
|
118
|
-
return wrapperDiv;
|
|
119
|
-
}
|
|
120
|
-
export function useWindowSize() {
|
|
121
|
-
// Initialize state with undefined width/height so server and client renders match
|
|
122
|
-
// Learn more here: https://joshwcomeau.com/react/the-perils-of-rehydration/
|
|
123
|
-
const [windowSize, setWindowSize] = useState<{
|
|
124
|
-
width: number,
|
|
125
|
-
height: number
|
|
126
|
-
}>({
|
|
127
|
-
width: undefined,
|
|
128
|
-
height: undefined
|
|
129
|
-
});
|
|
130
|
-
useEffect(() => {
|
|
131
|
-
// Handler to call on window resize
|
|
132
|
-
function handleResize() {
|
|
133
|
-
|
|
134
|
-
// Set window width/height to state
|
|
135
|
-
setWindowSize({
|
|
136
|
-
width: window.innerWidth,
|
|
137
|
-
height: window.innerHeight
|
|
138
|
-
});
|
|
139
|
-
}
|
|
140
|
-
// Add event listener
|
|
141
|
-
window.addEventListener("resize", handleResize);
|
|
142
|
-
// Call handler right away so state gets updated with initial window size
|
|
143
|
-
handleResize();
|
|
144
|
-
// Remove event listener on cleanup
|
|
145
|
-
return () => window.removeEventListener("resize", handleResize);
|
|
146
|
-
}, useEffectOnlyOnMount);
|
|
147
|
-
return windowSize;
|
|
148
|
-
}
|
|
149
|
-
export function useIsInPrint() {
|
|
150
|
-
// Initialize state with false
|
|
151
|
-
const [printMode, setPrintMode] = useState<boolean>(false);
|
|
152
|
-
useEffect(() => {
|
|
153
|
-
function forcePrint(e: KeyboardEvent) {
|
|
154
|
-
if (e.ctrlKey && e.shiftKey && e.altKey) {
|
|
155
|
-
if (e.key.toLocaleLowerCase() === "q") {
|
|
156
|
-
document.body.classList.remove(KnownClassNames.print);
|
|
157
|
-
handlePrint(e, false);
|
|
158
|
-
}
|
|
159
|
-
else {
|
|
160
|
-
console.warn('forced print mode - to exit refresh to ctrl+shift+alt+q');
|
|
161
|
-
document.body.classList.add(KnownClassNames.print);
|
|
162
|
-
handlePrint(e, true);
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
// Handler to call on printing
|
|
167
|
-
function handlePrint(e?: Event, force?: boolean) {
|
|
168
|
-
if (force === true) setPrintMode(true);
|
|
169
|
-
else if (window.matchMedia) {
|
|
170
|
-
var mediaQueryList = window.matchMedia('print');
|
|
171
|
-
if (mediaQueryList.matches) {
|
|
172
|
-
setPrintMode(true);
|
|
173
|
-
} else {
|
|
174
|
-
setPrintMode(false);
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
// Add event listener
|
|
179
|
-
window.addEventListener("print", handlePrint);
|
|
180
|
-
if (isDebug())
|
|
181
|
-
window.addEventListener("keydown", forcePrint);
|
|
182
|
-
// Call handler right away so state gets updated with initial printing state
|
|
183
|
-
handlePrint();
|
|
184
|
-
// Remove event listener on cleanup
|
|
185
|
-
return () => {
|
|
186
|
-
window.removeEventListener("print", handlePrint);
|
|
187
|
-
if (isDebug())
|
|
188
|
-
window.removeEventListener("keydown", forcePrint);
|
|
189
|
-
};
|
|
190
|
-
}, useEffectOnlyOnMount);
|
|
191
|
-
return printMode;
|
|
192
|
-
}
|
|
193
|
-
/** set block message if you want to block nav.
|
|
194
|
-
* - call setMessage to add a blocker message
|
|
195
|
-
* - call onNav when you have internal navigation (open / close popups)
|
|
196
|
-
* - render the navPrompt control to your page
|
|
197
|
-
* FYI for page unload, most modern browsers won't show your message but a generic one instead. */
|
|
198
|
-
export function useBlockNav() {
|
|
199
|
-
const [, setBlockNavMessages, blockNavMessagesRef] = useStateEX<IDictionary<string>>({});
|
|
200
|
-
const [prompt, setPrompt] = useStateEX<IPrompterProps>(null);
|
|
201
|
-
|
|
202
|
-
const getMessagesArr = useCallback(() => {
|
|
203
|
-
return Object.keys(blockNavMessagesRef.current).map(id => blockNavMessagesRef.current[id]);
|
|
204
|
-
}, useEffectOnlyOnMount);
|
|
205
|
-
|
|
206
|
-
const getMessages = useCallback(() => {
|
|
207
|
-
return getMessagesArr().join();
|
|
208
|
-
}, useEffectOnlyOnMount);
|
|
209
|
-
|
|
210
|
-
const onNav = useCallback((nav: () => void) => {
|
|
211
|
-
let messages = getMessagesArr();
|
|
212
|
-
if (isNotEmptyArray(messages)) {
|
|
213
|
-
//need to release react to re-render the prompt
|
|
214
|
-
window.setTimeout(() => {
|
|
215
|
-
//prompt, if ok - clear messages and nav.
|
|
216
|
-
setPrompt({
|
|
217
|
-
okButtonText: "Leave",
|
|
218
|
-
cancelButtonText: "Cancel",
|
|
219
|
-
title: "Leave page?",
|
|
220
|
-
children: messages.length > 1
|
|
221
|
-
? <ul>
|
|
222
|
-
{messages.map((m, i) => <li key={`m${i}`}>{m}</li>)}
|
|
223
|
-
</ul>
|
|
224
|
-
: <p>{messages[0]}</p>,
|
|
225
|
-
onCancel: () => setPrompt(null),
|
|
226
|
-
onOK: () => {
|
|
227
|
-
setPrompt(null);
|
|
228
|
-
setBlockNavMessages({});//clear messages
|
|
229
|
-
nav();
|
|
230
|
-
}
|
|
231
|
-
});
|
|
232
|
-
}, 1);
|
|
233
|
-
}
|
|
234
|
-
else nav();
|
|
235
|
-
}, useEffectOnlyOnMount);
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
useEffect(() => {
|
|
239
|
-
function handleBeforeUnload(e: BeforeUnloadEvent) {
|
|
240
|
-
//todo: use blockMessageRef.current so that we don't have to re-register every time message changes.
|
|
241
|
-
//otherwise we would have to add blockMessage as a dependency for this useEffect
|
|
242
|
-
const message = getMessages();
|
|
243
|
-
if (!isNullOrEmptyString(message)) {
|
|
244
|
-
e.preventDefault();
|
|
245
|
-
e.returnValue = message;
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
// Add event listener
|
|
249
|
-
window.addEventListener("beforeunload", handleBeforeUnload);
|
|
250
|
-
// Remove event listener on cleanup
|
|
251
|
-
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
|
252
|
-
}, useEffectOnlyOnMount);
|
|
253
|
-
return {
|
|
254
|
-
setMessage: (id: string, message?: string) => {
|
|
255
|
-
let current = jsonClone(blockNavMessagesRef.current);
|
|
256
|
-
if (isNullOrEmptyString(message))
|
|
257
|
-
delete current[id];
|
|
258
|
-
else current[id] = message;
|
|
259
|
-
if (!objectsEqual(current, blockNavMessagesRef.current))
|
|
260
|
-
setBlockNavMessages(current);
|
|
261
|
-
},
|
|
262
|
-
// clearMessages: () => {
|
|
263
|
-
// setBlockNavMessages({});
|
|
264
|
-
// },
|
|
265
|
-
// getMessages,
|
|
266
|
-
// getMessagesArr,
|
|
267
|
-
onNav,
|
|
268
|
-
navPrompt: prompt ? <Prompter {...prompt} /> : undefined
|
|
269
|
-
};
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
export function useKeyDown(options: {
|
|
273
|
-
//default use document
|
|
274
|
-
elm?: HTMLElement | Document;
|
|
275
|
-
onEnter?: (e: KeyboardEvent) => void;
|
|
276
|
-
onEscape?: (e: KeyboardEvent) => void;
|
|
277
|
-
onKeyDown?: (e: KeyboardEvent) => void;
|
|
278
|
-
}) {
|
|
279
|
-
let elm = options.elm || document;
|
|
280
|
-
|
|
281
|
-
useEffect(() => {
|
|
282
|
-
let handler = (e: KeyboardEvent) => {
|
|
283
|
-
if (e.key === "Enter" && isFunction(options.onEnter)) options.onEnter(e);
|
|
284
|
-
else if (e.key === "Escape" && isFunction(options.onEscape)) options.onEscape(e);
|
|
285
|
-
if (isFunction(options.onKeyDown))
|
|
286
|
-
options.onKeyDown(e);
|
|
287
|
-
};
|
|
288
|
-
elm.addEventListener("keydown", handler);
|
|
289
|
-
return () => elm.removeEventListener("keydown", handler);
|
|
290
|
-
}, [elm]);
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
export function useToast() {
|
|
295
|
-
const ctx = useKWIZFluentContext();
|
|
296
|
-
const toasterId = useId("toaster");
|
|
297
|
-
const { dispatchToast } = useToastController(toasterId);
|
|
298
|
-
return {
|
|
299
|
-
control: <Toaster mountNode={ctx.mountNode} toasterId={toasterId} />,
|
|
300
|
-
dispatch: (info: {
|
|
301
|
-
title?: string;
|
|
302
|
-
body?: string;
|
|
303
|
-
subtitle?: string;
|
|
304
|
-
titleAction?: { text: string, onClick: () => void },
|
|
305
|
-
footerActions?: { text: string, onClick: () => void }[],
|
|
306
|
-
intent?: ToastIntent
|
|
307
|
-
}) => {
|
|
308
|
-
dispatchToast(<Toast>
|
|
309
|
-
{info.title && <ToastTitle action={info.titleAction ? <Link onClick={info.titleAction.onClick}>{info.titleAction.text}</Link> : undefined}>{info.title}</ToastTitle>}
|
|
310
|
-
{info.body && <ToastBody subtitle={info.subtitle}>{info.body}</ToastBody>}
|
|
311
|
-
{isNotEmptyArray(info.footerActions) &&
|
|
312
|
-
<ToastFooter>
|
|
313
|
-
{info.footerActions.map((a, i) => <Link key={`l${i}`} onClick={a.onClick}>{a.text}</Link>)}
|
|
314
|
-
</ToastFooter>
|
|
315
|
-
}
|
|
316
|
-
</Toast>, { intent: info.intent || "info" });
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
export function useKWIZFluentContextProvider(options: {
|
|
322
|
-
root?: React.MutableRefObject<HTMLDivElement>;
|
|
323
|
-
ctx?: iKWIZFluentContext;
|
|
324
|
-
}) {
|
|
325
|
-
let v: iKWIZFluentContext = options && options.ctx || {};
|
|
326
|
-
const [kwizFluentContext, setKwizFluentContext] = useState<iKWIZFluentContext>(v);
|
|
327
|
-
useEffect(() => {
|
|
328
|
-
// ref only updates in useEffect, not in useMemo or anything else.
|
|
329
|
-
// we need to set it into state so it will trigger a ui update
|
|
330
|
-
setKwizFluentContext({
|
|
331
|
-
...v,
|
|
332
|
-
mountNode: options.root.current
|
|
333
|
-
});
|
|
334
|
-
}, [options.root]);
|
|
335
|
-
return kwizFluentContext;
|
|
1
|
+
import { Link, Toast, ToastBody, Toaster, ToastFooter, ToastIntent, ToastTitle, useId, useToastController } from "@fluentui/react-components";
|
|
2
|
+
import { IDictionary, isDebug, isFunction, isNotEmptyArray, isNullOrEmptyString, jsonClone, jsonStringify, LoggerLevel, objectsEqual, wrapFunction } from "@kwiz/common";
|
|
3
|
+
import { MutableRefObject, SetStateAction, useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import { GetLogger } from "../_modules/config";
|
|
5
|
+
import { IPrompterProps, Prompter } from "../controls/prompt";
|
|
6
|
+
import { KnownClassNames } from "../styles/styles";
|
|
7
|
+
import { iKWIZFluentContext, useKWIZFluentContext } from "./context";
|
|
8
|
+
|
|
9
|
+
const logger = GetLogger("helpers/hooks");
|
|
10
|
+
/** Empty array ensures that effect is only run on mount */
|
|
11
|
+
export const useEffectOnlyOnMount = [];
|
|
12
|
+
|
|
13
|
+
/** set state on steroids. provide promise callback after render, onChange transformer and automatic skip-set when value not changed */
|
|
14
|
+
export function useStateEX<ValueType>(initialValue: ValueType, options?: {
|
|
15
|
+
onChange?: (newValue: SetStateAction<ValueType>) => SetStateAction<ValueType>;
|
|
16
|
+
//will not set state if value did not change
|
|
17
|
+
skipUpdateIfSame?: boolean;
|
|
18
|
+
//optional, provide a name for better logging
|
|
19
|
+
name?: string;
|
|
20
|
+
}):
|
|
21
|
+
[ValueType, (newValue: SetStateAction<ValueType>) => Promise<ValueType>, MutableRefObject<ValueType>] {
|
|
22
|
+
options = options || {};
|
|
23
|
+
const name = options.name || '';
|
|
24
|
+
|
|
25
|
+
let logger = GetLogger(`useStateWithTrack${isNullOrEmptyString(name) ? '' : ` ${name}`}`);
|
|
26
|
+
logger.setLevel(LoggerLevel.WARN);
|
|
27
|
+
|
|
28
|
+
const [value, setValueInState] = useState(initialValue);
|
|
29
|
+
const currentValue = useRef(value);
|
|
30
|
+
|
|
31
|
+
/** make this a collection in case several callers are awaiting the same propr update */
|
|
32
|
+
const resolveState = useRef<((v: ValueType) => void)[]>([]);
|
|
33
|
+
const isMounted = useRef(false);
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
isMounted.current = true;
|
|
37
|
+
|
|
38
|
+
return () => {
|
|
39
|
+
isMounted.current = false;
|
|
40
|
+
};
|
|
41
|
+
}, useEffectOnlyOnMount);
|
|
42
|
+
|
|
43
|
+
function resolvePromises() {
|
|
44
|
+
if (isNotEmptyArray(resolveState.current)) {
|
|
45
|
+
let resolvers = resolveState.current.slice();
|
|
46
|
+
resolveState.current = [];//clear
|
|
47
|
+
resolvers.map(r => r(currentValue.current));
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
resolvePromises();
|
|
52
|
+
if (isNotEmptyArray(resolveState.current)) {
|
|
53
|
+
logger.log(`resolved after render`);
|
|
54
|
+
let resolvers = resolveState.current.slice();
|
|
55
|
+
resolveState.current = [];//clear
|
|
56
|
+
resolvers.map(r => r(value));
|
|
57
|
+
}
|
|
58
|
+
}, [value, resolveState.current]);
|
|
59
|
+
|
|
60
|
+
let setValueWithCheck = !options.skipUpdateIfSame ? setValueInState : (newValue: ValueType) => {
|
|
61
|
+
logger.groupSync('conditional value change', log => {
|
|
62
|
+
if (logger.getLevel() === LoggerLevel.VERBOSE) {
|
|
63
|
+
log('old: ' + jsonStringify(currentValue.current));
|
|
64
|
+
log('new: ' + jsonStringify(newValue));
|
|
65
|
+
}
|
|
66
|
+
if (!objectsEqual(newValue as object, currentValue.current as object)) {
|
|
67
|
+
log(`value changed`);
|
|
68
|
+
setValueInState(newValue);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
log(`value unchanged`);
|
|
72
|
+
resolvePromises();
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
let setValueWithEvents = wrapFunction(setValueWithCheck, {
|
|
79
|
+
before: newValue => isFunction(options.onChange) ? options.onChange(newValue) : newValue,
|
|
80
|
+
after: newValue => currentValue.current = newValue as ValueType
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const setValue = useCallback((newState: ValueType) => new Promise<ValueType>(resolve => {
|
|
84
|
+
if (!isMounted.current) {
|
|
85
|
+
//unmounted may never resolve
|
|
86
|
+
logger.log(`resolved without wait`);
|
|
87
|
+
resolve(newState);
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
resolveState.current.push(resolve);
|
|
91
|
+
setValueWithEvents(newState);
|
|
92
|
+
}
|
|
93
|
+
}), []);
|
|
94
|
+
|
|
95
|
+
return [value, setValue, currentValue];
|
|
96
|
+
}
|
|
97
|
+
export function useTrackFocus(props: { onFocus: () => void, onLoseFocus: () => void, ref?: MutableRefObject<HTMLElement> }) {
|
|
98
|
+
const wrapperDiv = props.ref || useRef<HTMLDivElement>(null);
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
function focusIn(e: FocusEvent) {
|
|
101
|
+
let elm = e.target as HTMLElement;//document.activeElement;
|
|
102
|
+
if (wrapperDiv.current) {
|
|
103
|
+
while (elm && elm !== wrapperDiv.current) {
|
|
104
|
+
elm = elm.parentElement;
|
|
105
|
+
}
|
|
106
|
+
} else elm = null;
|
|
107
|
+
if (wrapperDiv.current && elm === wrapperDiv.current) props.onFocus();
|
|
108
|
+
else props.onLoseFocus();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (wrapperDiv.current) {
|
|
112
|
+
if (wrapperDiv.current) wrapperDiv.current.tabIndex = 1;
|
|
113
|
+
window.addEventListener("focusin", focusIn);
|
|
114
|
+
// Remove event listener on cleanup
|
|
115
|
+
return () => window.removeEventListener("focusin", focusIn);
|
|
116
|
+
}
|
|
117
|
+
}, [wrapperDiv.current]);
|
|
118
|
+
return wrapperDiv;
|
|
119
|
+
}
|
|
120
|
+
export function useWindowSize() {
|
|
121
|
+
// Initialize state with undefined width/height so server and client renders match
|
|
122
|
+
// Learn more here: https://joshwcomeau.com/react/the-perils-of-rehydration/
|
|
123
|
+
const [windowSize, setWindowSize] = useState<{
|
|
124
|
+
width: number,
|
|
125
|
+
height: number
|
|
126
|
+
}>({
|
|
127
|
+
width: undefined,
|
|
128
|
+
height: undefined
|
|
129
|
+
});
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
// Handler to call on window resize
|
|
132
|
+
function handleResize() {
|
|
133
|
+
|
|
134
|
+
// Set window width/height to state
|
|
135
|
+
setWindowSize({
|
|
136
|
+
width: window.innerWidth,
|
|
137
|
+
height: window.innerHeight
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
// Add event listener
|
|
141
|
+
window.addEventListener("resize", handleResize);
|
|
142
|
+
// Call handler right away so state gets updated with initial window size
|
|
143
|
+
handleResize();
|
|
144
|
+
// Remove event listener on cleanup
|
|
145
|
+
return () => window.removeEventListener("resize", handleResize);
|
|
146
|
+
}, useEffectOnlyOnMount);
|
|
147
|
+
return windowSize;
|
|
148
|
+
}
|
|
149
|
+
export function useIsInPrint() {
|
|
150
|
+
// Initialize state with false
|
|
151
|
+
const [printMode, setPrintMode] = useState<boolean>(false);
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
function forcePrint(e: KeyboardEvent) {
|
|
154
|
+
if (e.ctrlKey && e.shiftKey && e.altKey) {
|
|
155
|
+
if (e.key.toLocaleLowerCase() === "q") {
|
|
156
|
+
document.body.classList.remove(KnownClassNames.print);
|
|
157
|
+
handlePrint(e, false);
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
console.warn('forced print mode - to exit refresh to ctrl+shift+alt+q');
|
|
161
|
+
document.body.classList.add(KnownClassNames.print);
|
|
162
|
+
handlePrint(e, true);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// Handler to call on printing
|
|
167
|
+
function handlePrint(e?: Event, force?: boolean) {
|
|
168
|
+
if (force === true) setPrintMode(true);
|
|
169
|
+
else if (window.matchMedia) {
|
|
170
|
+
var mediaQueryList = window.matchMedia('print');
|
|
171
|
+
if (mediaQueryList.matches) {
|
|
172
|
+
setPrintMode(true);
|
|
173
|
+
} else {
|
|
174
|
+
setPrintMode(false);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// Add event listener
|
|
179
|
+
window.addEventListener("print", handlePrint);
|
|
180
|
+
if (isDebug())
|
|
181
|
+
window.addEventListener("keydown", forcePrint);
|
|
182
|
+
// Call handler right away so state gets updated with initial printing state
|
|
183
|
+
handlePrint();
|
|
184
|
+
// Remove event listener on cleanup
|
|
185
|
+
return () => {
|
|
186
|
+
window.removeEventListener("print", handlePrint);
|
|
187
|
+
if (isDebug())
|
|
188
|
+
window.removeEventListener("keydown", forcePrint);
|
|
189
|
+
};
|
|
190
|
+
}, useEffectOnlyOnMount);
|
|
191
|
+
return printMode;
|
|
192
|
+
}
|
|
193
|
+
/** set block message if you want to block nav.
|
|
194
|
+
* - call setMessage to add a blocker message
|
|
195
|
+
* - call onNav when you have internal navigation (open / close popups)
|
|
196
|
+
* - render the navPrompt control to your page
|
|
197
|
+
* FYI for page unload, most modern browsers won't show your message but a generic one instead. */
|
|
198
|
+
export function useBlockNav() {
|
|
199
|
+
const [, setBlockNavMessages, blockNavMessagesRef] = useStateEX<IDictionary<string>>({});
|
|
200
|
+
const [prompt, setPrompt] = useStateEX<IPrompterProps>(null);
|
|
201
|
+
|
|
202
|
+
const getMessagesArr = useCallback(() => {
|
|
203
|
+
return Object.keys(blockNavMessagesRef.current).map(id => blockNavMessagesRef.current[id]);
|
|
204
|
+
}, useEffectOnlyOnMount);
|
|
205
|
+
|
|
206
|
+
const getMessages = useCallback(() => {
|
|
207
|
+
return getMessagesArr().join();
|
|
208
|
+
}, useEffectOnlyOnMount);
|
|
209
|
+
|
|
210
|
+
const onNav = useCallback((nav: () => void) => {
|
|
211
|
+
let messages = getMessagesArr();
|
|
212
|
+
if (isNotEmptyArray(messages)) {
|
|
213
|
+
//need to release react to re-render the prompt
|
|
214
|
+
window.setTimeout(() => {
|
|
215
|
+
//prompt, if ok - clear messages and nav.
|
|
216
|
+
setPrompt({
|
|
217
|
+
okButtonText: "Leave",
|
|
218
|
+
cancelButtonText: "Cancel",
|
|
219
|
+
title: "Leave page?",
|
|
220
|
+
children: messages.length > 1
|
|
221
|
+
? <ul>
|
|
222
|
+
{messages.map((m, i) => <li key={`m${i}`}>{m}</li>)}
|
|
223
|
+
</ul>
|
|
224
|
+
: <p>{messages[0]}</p>,
|
|
225
|
+
onCancel: () => setPrompt(null),
|
|
226
|
+
onOK: () => {
|
|
227
|
+
setPrompt(null);
|
|
228
|
+
setBlockNavMessages({});//clear messages
|
|
229
|
+
nav();
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
}, 1);
|
|
233
|
+
}
|
|
234
|
+
else nav();
|
|
235
|
+
}, useEffectOnlyOnMount);
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
useEffect(() => {
|
|
239
|
+
function handleBeforeUnload(e: BeforeUnloadEvent) {
|
|
240
|
+
//todo: use blockMessageRef.current so that we don't have to re-register every time message changes.
|
|
241
|
+
//otherwise we would have to add blockMessage as a dependency for this useEffect
|
|
242
|
+
const message = getMessages();
|
|
243
|
+
if (!isNullOrEmptyString(message)) {
|
|
244
|
+
e.preventDefault();
|
|
245
|
+
e.returnValue = message;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// Add event listener
|
|
249
|
+
window.addEventListener("beforeunload", handleBeforeUnload);
|
|
250
|
+
// Remove event listener on cleanup
|
|
251
|
+
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
|
252
|
+
}, useEffectOnlyOnMount);
|
|
253
|
+
return {
|
|
254
|
+
setMessage: (id: string, message?: string) => {
|
|
255
|
+
let current = jsonClone(blockNavMessagesRef.current);
|
|
256
|
+
if (isNullOrEmptyString(message))
|
|
257
|
+
delete current[id];
|
|
258
|
+
else current[id] = message;
|
|
259
|
+
if (!objectsEqual(current, blockNavMessagesRef.current))
|
|
260
|
+
setBlockNavMessages(current);
|
|
261
|
+
},
|
|
262
|
+
// clearMessages: () => {
|
|
263
|
+
// setBlockNavMessages({});
|
|
264
|
+
// },
|
|
265
|
+
// getMessages,
|
|
266
|
+
// getMessagesArr,
|
|
267
|
+
onNav,
|
|
268
|
+
navPrompt: prompt ? <Prompter {...prompt} /> : undefined
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function useKeyDown(options: {
|
|
273
|
+
//default use document
|
|
274
|
+
elm?: HTMLElement | Document;
|
|
275
|
+
onEnter?: (e: KeyboardEvent) => void;
|
|
276
|
+
onEscape?: (e: KeyboardEvent) => void;
|
|
277
|
+
onKeyDown?: (e: KeyboardEvent) => void;
|
|
278
|
+
}) {
|
|
279
|
+
let elm = options.elm || document;
|
|
280
|
+
|
|
281
|
+
useEffect(() => {
|
|
282
|
+
let handler = (e: KeyboardEvent) => {
|
|
283
|
+
if (e.key === "Enter" && isFunction(options.onEnter)) options.onEnter(e);
|
|
284
|
+
else if (e.key === "Escape" && isFunction(options.onEscape)) options.onEscape(e);
|
|
285
|
+
if (isFunction(options.onKeyDown))
|
|
286
|
+
options.onKeyDown(e);
|
|
287
|
+
};
|
|
288
|
+
elm.addEventListener("keydown", handler);
|
|
289
|
+
return () => elm.removeEventListener("keydown", handler);
|
|
290
|
+
}, [elm]);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
export function useToast() {
|
|
295
|
+
const ctx = useKWIZFluentContext();
|
|
296
|
+
const toasterId = useId("toaster");
|
|
297
|
+
const { dispatchToast } = useToastController(toasterId);
|
|
298
|
+
return {
|
|
299
|
+
control: <Toaster mountNode={ctx.mountNode} toasterId={toasterId} />,
|
|
300
|
+
dispatch: (info: {
|
|
301
|
+
title?: string;
|
|
302
|
+
body?: string;
|
|
303
|
+
subtitle?: string;
|
|
304
|
+
titleAction?: { text: string, onClick: () => void },
|
|
305
|
+
footerActions?: { text: string, onClick: () => void }[],
|
|
306
|
+
intent?: ToastIntent
|
|
307
|
+
}) => {
|
|
308
|
+
dispatchToast(<Toast>
|
|
309
|
+
{info.title && <ToastTitle action={info.titleAction ? <Link onClick={info.titleAction.onClick}>{info.titleAction.text}</Link> : undefined}>{info.title}</ToastTitle>}
|
|
310
|
+
{info.body && <ToastBody subtitle={info.subtitle}>{info.body}</ToastBody>}
|
|
311
|
+
{isNotEmptyArray(info.footerActions) &&
|
|
312
|
+
<ToastFooter>
|
|
313
|
+
{info.footerActions.map((a, i) => <Link key={`l${i}`} onClick={a.onClick}>{a.text}</Link>)}
|
|
314
|
+
</ToastFooter>
|
|
315
|
+
}
|
|
316
|
+
</Toast>, { intent: info.intent || "info" });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function useKWIZFluentContextProvider(options: {
|
|
322
|
+
root?: React.MutableRefObject<HTMLDivElement>;
|
|
323
|
+
ctx?: iKWIZFluentContext;
|
|
324
|
+
}) {
|
|
325
|
+
let v: iKWIZFluentContext = options && options.ctx || {};
|
|
326
|
+
const [kwizFluentContext, setKwizFluentContext] = useState<iKWIZFluentContext>(v);
|
|
327
|
+
useEffect(() => {
|
|
328
|
+
// ref only updates in useEffect, not in useMemo or anything else.
|
|
329
|
+
// we need to set it into state so it will trigger a ui update
|
|
330
|
+
setKwizFluentContext({
|
|
331
|
+
...v,
|
|
332
|
+
mountNode: options.root.current
|
|
333
|
+
});
|
|
334
|
+
}, [options.root]);
|
|
335
|
+
return kwizFluentContext;
|
|
336
336
|
}
|