@bccampus/ui-components 0.3.0 → 0.4.1
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/dist/composite.d.ts +151 -0
- package/dist/composite.js +472 -0
- package/dist/generate-tiles-DuagGD1d.js +244 -0
- package/dist/generate-tiles.d.ts +43 -0
- package/dist/generate-tiles.js +7 -0
- package/dist/icon-generator-tuhuqdpL.js +76 -0
- package/dist/icon-generator.d.ts +11 -4
- package/dist/icon-generator.js +4 -301
- package/dist/masked-image-generator.js +7 -7
- package/dist/ui-components.js +5 -5
- package/package.json +12 -1
- package/src/components/ui/composite/CompositeData.ts +215 -0
- package/src/components/ui/composite/CompositeDataItem.ts +144 -0
- package/src/components/ui/composite/composite-component-item.tsx +50 -0
- package/src/components/ui/composite/composite-component.tsx +100 -0
- package/src/components/ui/composite/composite-data-context.tsx +31 -0
- package/src/components/ui/composite/index.ts +4 -0
- package/src/components/ui/composite/types.ts +81 -0
- package/src/components/ui/icon-generator/index.ts +2 -0
- package/src/components/ui/popover.tsx +46 -0
- package/src/hooks/use-effect-after-mount.ts +27 -0
- package/src/hooks/use-id.ts +5 -0
- package/src/hooks/use-keyboard-event.ts +144 -0
- package/src/lib/object.ts +48 -0
- package/src/lib/set-operations.ts +52 -0
- package/tsconfig.node.json +25 -25
- package/vite.config.ts +3 -1
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { omit } from "@/lib/object";
|
|
2
|
+
import { map, type PreinitializedMapStore, } from "nanostores";
|
|
3
|
+
import type { CompositeDataItemState, CompositeItemKey, CompositeDataItemOptions } from "./types";
|
|
4
|
+
|
|
5
|
+
export class CompositeDataItem<T extends object> implements Iterable<CompositeDataItem<T>> {
|
|
6
|
+
#key: CompositeItemKey;
|
|
7
|
+
parent: CompositeDataItem<T> | null = null;
|
|
8
|
+
children?: CompositeDataItem<T>[];
|
|
9
|
+
level: number;
|
|
10
|
+
|
|
11
|
+
data: PreinitializedMapStore<T>;
|
|
12
|
+
state: PreinitializedMapStore<CompositeDataItemState>;
|
|
13
|
+
|
|
14
|
+
pointers: {
|
|
15
|
+
left?: CompositeItemKey;
|
|
16
|
+
right?: CompositeItemKey;
|
|
17
|
+
up?: CompositeItemKey;
|
|
18
|
+
down?: CompositeItemKey;
|
|
19
|
+
};
|
|
20
|
+
childrenProp: string
|
|
21
|
+
|
|
22
|
+
constructor(item: T, options: CompositeDataItemOptions<T>, parent: CompositeDataItem<T> | null) {
|
|
23
|
+
this.#key = options.getItemKey(item);
|
|
24
|
+
this.data = map(omit(item, [options.itemChildrenProp]));
|
|
25
|
+
this.state = map({
|
|
26
|
+
focused: false,
|
|
27
|
+
selected: false,
|
|
28
|
+
disabled: false,
|
|
29
|
+
...options.initialState
|
|
30
|
+
})
|
|
31
|
+
this.pointers = {};
|
|
32
|
+
this.parent = parent;
|
|
33
|
+
this.level = parent ? parent.level + 1 : 0;
|
|
34
|
+
|
|
35
|
+
this.childrenProp = options.itemChildrenProp;
|
|
36
|
+
|
|
37
|
+
const children = options.getItemChildren(item);
|
|
38
|
+
if (children) {
|
|
39
|
+
this.children = children.map(child => {
|
|
40
|
+
const childItem = new CompositeDataItem(child, options, this);
|
|
41
|
+
return childItem;
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
get key() {
|
|
47
|
+
return this.#key;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
*[Symbol.iterator](): Iterator<CompositeDataItem<T>> {
|
|
51
|
+
if (this.key !== "ALL") yield this;
|
|
52
|
+
if (this.children)
|
|
53
|
+
for (const child of this.children)
|
|
54
|
+
for (const item of child) yield item;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
toJSON() {
|
|
58
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
59
|
+
const json: any = { ...this.data.get() };
|
|
60
|
+
|
|
61
|
+
if (this.children) {
|
|
62
|
+
json[this.childrenProp] = [];
|
|
63
|
+
for (const child of this.children)
|
|
64
|
+
json[this.childrenProp].push(child.toJSON());
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return json as T;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
descendants() {
|
|
71
|
+
if (!this.children) return [];
|
|
72
|
+
|
|
73
|
+
const descendants: CompositeDataItem<T>[] = [];
|
|
74
|
+
this.children.forEach((child) => {
|
|
75
|
+
descendants.push(child);
|
|
76
|
+
const childDescendants = child.descendants();
|
|
77
|
+
if (childDescendants) descendants.push(...childDescendants);
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
return descendants;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
ancestors() {
|
|
85
|
+
const ancestors: CompositeDataItem<T>[] = [];
|
|
86
|
+
let parent = this.parent;
|
|
87
|
+
while (parent) {
|
|
88
|
+
ancestors.push(parent);
|
|
89
|
+
parent = parent.parent;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return ancestors;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
toggleSelect(recursive: boolean = false) {
|
|
97
|
+
if (this.state.get().selected) this.deselect(recursive);
|
|
98
|
+
else this.select(recursive);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
select(recursive: boolean = false): CompositeItemKey[] {
|
|
102
|
+
this.state.setKey("selected", true);
|
|
103
|
+
|
|
104
|
+
if (recursive && this.children) {
|
|
105
|
+
const selectedChildKeys = this.children.map(child => child.select(true)).flat();
|
|
106
|
+
return [this.key, ...selectedChildKeys];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return [this.key]
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
deselect(recursive: boolean = false): CompositeItemKey[] {
|
|
113
|
+
this.state.setKey("selected", false);
|
|
114
|
+
|
|
115
|
+
if (recursive && this.children) {
|
|
116
|
+
const deselectedChildKeys = this.children.map(child => child.deselect(true)).flat();
|
|
117
|
+
return [this.key, ...deselectedChildKeys];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return [this.key];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
disable(recursive: boolean = false): CompositeItemKey[] {
|
|
124
|
+
this.state.setKey("disabled", true);
|
|
125
|
+
|
|
126
|
+
if (recursive && this.children) {
|
|
127
|
+
const disabledChildKeys = this.children.map(child => child.disable(true)).flat();
|
|
128
|
+
return [this.key, ...disabledChildKeys];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return [this.key]
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
enable(recursive: boolean = false): CompositeItemKey[] {
|
|
135
|
+
this.state.setKey("disabled", false);
|
|
136
|
+
|
|
137
|
+
if (recursive && this.children) {
|
|
138
|
+
const enabledChildKeys = this.children.map(child => child.enable(true)).flat();
|
|
139
|
+
return [this.key, ...enabledChildKeys];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return [this.key];
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import type { CompositeItemEventHandlers, CompositeItemProps } from "./types";
|
|
3
|
+
import { useStore } from "@nanostores/react";
|
|
4
|
+
|
|
5
|
+
export function CompositeComponentItem<T extends object>({
|
|
6
|
+
id,
|
|
7
|
+
className,
|
|
8
|
+
item,
|
|
9
|
+
mouseEventHandler,
|
|
10
|
+
keyboardEventHandler,
|
|
11
|
+
render,
|
|
12
|
+
}: CompositeItemProps<T>) {
|
|
13
|
+
const data = useStore(item.data);
|
|
14
|
+
const state = useStore(item.state);
|
|
15
|
+
|
|
16
|
+
const handlers: CompositeItemEventHandlers = useMemo(() => {
|
|
17
|
+
if (state.disabled) return {};
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
mouseEventHandler: () => mouseEventHandler?.(item),
|
|
21
|
+
keyboardEventHandler: () => keyboardEventHandler?.(item),
|
|
22
|
+
};
|
|
23
|
+
}, [state.disabled, item, keyboardEventHandler, mouseEventHandler]);
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div
|
|
27
|
+
id={id}
|
|
28
|
+
role="option"
|
|
29
|
+
aria-disabled={state.disabled}
|
|
30
|
+
data-key={item.key}
|
|
31
|
+
tabIndex={!state.disabled ? (state.focused ? 0 : -1) : undefined}
|
|
32
|
+
className={className}
|
|
33
|
+
>
|
|
34
|
+
{render({ data, key: item.key, level: item.level }, state, handlers)}
|
|
35
|
+
{item.children &&
|
|
36
|
+
item.children.length > 0 &&
|
|
37
|
+
[...item.children].map((child) => (
|
|
38
|
+
<CompositeComponentItem
|
|
39
|
+
id={`${id}-${item.key}`}
|
|
40
|
+
item={child}
|
|
41
|
+
key={child.key}
|
|
42
|
+
render={render}
|
|
43
|
+
className={className}
|
|
44
|
+
mouseEventHandler={mouseEventHandler}
|
|
45
|
+
keyboardEventHandler={keyboardEventHandler}
|
|
46
|
+
/>
|
|
47
|
+
))}
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { useCallback, useImperativeHandle, useRef } from "react";
|
|
2
|
+
import { CompositeDataItem } from "./CompositeDataItem";
|
|
3
|
+
import type { CompositeItemKey, CompositeProps } from "./types";
|
|
4
|
+
import { useKeyboardEvent } from "@/hooks/use-keyboard-event";
|
|
5
|
+
import { useCompositeContext } from "./composite-data-context";
|
|
6
|
+
import { CompositeComponentItem } from "./composite-component-item";
|
|
7
|
+
import { useId } from "@/hooks/use-id";
|
|
8
|
+
|
|
9
|
+
export function CompositeComponent<T extends object>({
|
|
10
|
+
renderItem,
|
|
11
|
+
className,
|
|
12
|
+
itemClassName,
|
|
13
|
+
ref,
|
|
14
|
+
id,
|
|
15
|
+
...props
|
|
16
|
+
}: CompositeProps<T>) {
|
|
17
|
+
const compositeData = useCompositeContext<T>();
|
|
18
|
+
const compositeRef = useRef<HTMLDivElement>(null);
|
|
19
|
+
const compositeId = useId(id);
|
|
20
|
+
|
|
21
|
+
const focus = useCallback(
|
|
22
|
+
(itemKey: CompositeItemKey) => {
|
|
23
|
+
compositeData.focus(itemKey);
|
|
24
|
+
// const focusedItemEl = compositeRef.current?.querySelector<HTMLDivElement>(`[data-key="${itemKey}"]`);
|
|
25
|
+
// if (focusedItemEl) focusedItemEl.focus();
|
|
26
|
+
},
|
|
27
|
+
[compositeData]
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const focusDown = useCallback(() => {
|
|
31
|
+
if (compositeData.focusedItem.get()?.pointers.down) {
|
|
32
|
+
focus(compositeData.focusedItem.get()!.pointers.down!);
|
|
33
|
+
}
|
|
34
|
+
}, [compositeData, focus]);
|
|
35
|
+
|
|
36
|
+
const focusUp = useCallback(() => {
|
|
37
|
+
if (compositeData.focusedItem.get()?.pointers.up) {
|
|
38
|
+
focus(compositeData.focusedItem.get()!.pointers.up!);
|
|
39
|
+
}
|
|
40
|
+
}, [compositeData, focus]);
|
|
41
|
+
|
|
42
|
+
const handleKeyDown = useKeyboardEvent({
|
|
43
|
+
ArrowUp: focusUp,
|
|
44
|
+
ArrowLeft: focusUp,
|
|
45
|
+
ArrowDown: focusDown,
|
|
46
|
+
ArrowRight: focusDown,
|
|
47
|
+
Space: () => {
|
|
48
|
+
if (compositeData.focusedItem.get()) compositeData.toggleSelect(compositeData.focusedItem.get()!);
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const handleItemMouseEvent = useCallback(
|
|
53
|
+
(item: CompositeDataItem<T>) => {
|
|
54
|
+
focus(item.key);
|
|
55
|
+
compositeData.toggleSelect(item);
|
|
56
|
+
},
|
|
57
|
+
[compositeData, focus]
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
useImperativeHandle(
|
|
61
|
+
ref,
|
|
62
|
+
() => {
|
|
63
|
+
return {
|
|
64
|
+
focusDown() {
|
|
65
|
+
focusDown();
|
|
66
|
+
},
|
|
67
|
+
focusUp() {
|
|
68
|
+
focusUp();
|
|
69
|
+
},
|
|
70
|
+
select() {
|
|
71
|
+
if (compositeData.focusedItem.get()) compositeData.toggleSelect(compositeData.focusedItem.get()!);
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
[compositeData, focusDown, focusUp]
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div
|
|
80
|
+
ref={compositeRef}
|
|
81
|
+
id={compositeId}
|
|
82
|
+
className={className}
|
|
83
|
+
tabIndex={-1}
|
|
84
|
+
role="listbox"
|
|
85
|
+
onKeyDown={handleKeyDown}
|
|
86
|
+
{...props}
|
|
87
|
+
>
|
|
88
|
+
{[...compositeData].map((item) => (
|
|
89
|
+
<CompositeComponentItem
|
|
90
|
+
className={itemClassName}
|
|
91
|
+
id={`${compositeId}-${item.key}`}
|
|
92
|
+
item={item}
|
|
93
|
+
key={item.key}
|
|
94
|
+
render={renderItem}
|
|
95
|
+
mouseEventHandler={handleItemMouseEvent}
|
|
96
|
+
/>
|
|
97
|
+
))}
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createContext, useContext, useMemo } from "react";
|
|
2
|
+
import { CompositeData } from "./CompositeData";
|
|
3
|
+
import type { CompositeOptions } from "./types";
|
|
4
|
+
|
|
5
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
6
|
+
const CompositeDataContext = createContext<CompositeData<any> | undefined>(undefined);
|
|
7
|
+
|
|
8
|
+
export function useCompositeContext<T extends object>() {
|
|
9
|
+
const context = useContext(CompositeDataContext);
|
|
10
|
+
|
|
11
|
+
if (!context) {
|
|
12
|
+
throw new Error("No CompositeDataContext has been set, use CompositeDataContext to provide a context");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return context as CompositeData<T>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface CompositeComponentContextProps<T extends object> extends CompositeOptions {
|
|
19
|
+
data: T[];
|
|
20
|
+
children: React.ReactNode;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function CompositeComponentContext<T extends object>({
|
|
24
|
+
data,
|
|
25
|
+
children,
|
|
26
|
+
...compositeOptions
|
|
27
|
+
}: CompositeComponentContextProps<T>) {
|
|
28
|
+
const composite = useMemo(() => new CompositeData(data, compositeOptions), [data, compositeOptions]);
|
|
29
|
+
|
|
30
|
+
return <CompositeDataContext value={composite}>{children}</CompositeDataContext>;
|
|
31
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { KeyboardEventHandler, MouseEventHandler, ReactNode } from "react";
|
|
2
|
+
import type { CompositeDataItem } from "./CompositeDataItem";
|
|
3
|
+
|
|
4
|
+
export type CompositeItemKey = string | number;
|
|
5
|
+
|
|
6
|
+
export interface CompositeOptions {
|
|
7
|
+
disabledKeys?: CompositeItemKey[];
|
|
8
|
+
selectedKeys?: CompositeItemKey[];
|
|
9
|
+
|
|
10
|
+
itemKeyProp?: string;
|
|
11
|
+
itemChildrenProp?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface CompositeDataPropGetters<T> {
|
|
15
|
+
getItemKey: (item: T) => CompositeItemKey;
|
|
16
|
+
getItemChildren: (item: T) => T[] | undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type CompositeDataOptions<T> = Required<CompositeOptions> & CompositeDataPropGetters<T>;
|
|
20
|
+
|
|
21
|
+
export type CompositeDataItemOptions<T> = CompositeDataPropGetters<T> & {
|
|
22
|
+
initialState?: CompositeDataItemState;
|
|
23
|
+
itemChildrenProp: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export interface CompositeDataItemState {
|
|
27
|
+
focused: boolean;
|
|
28
|
+
selected: boolean;
|
|
29
|
+
disabled: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CompositeProps<T extends object> extends React.ComponentPropsWithoutRef<"div"> {
|
|
33
|
+
renderItem: CompositeItemRenderFn<T>;
|
|
34
|
+
className?: string;
|
|
35
|
+
itemClassName?: string;
|
|
36
|
+
ref?: React.Ref<CompositeHandle>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
export interface CompositeHandle {
|
|
41
|
+
focusUp: () => void;
|
|
42
|
+
focusDown: () => void;
|
|
43
|
+
select: () => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface CompositeItemEventHandlers {
|
|
47
|
+
mouseEventHandler?: MouseEventHandler<HTMLElement>;
|
|
48
|
+
keyboardEventHandler?: KeyboardEventHandler<HTMLElement>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type CompositeItemRenderFn<T extends object> = (
|
|
52
|
+
item: { data: T; level: number; key: CompositeItemKey },
|
|
53
|
+
state: CompositeDataItemState,
|
|
54
|
+
eventHandlers: CompositeItemEventHandlers
|
|
55
|
+
) => ReactNode;
|
|
56
|
+
|
|
57
|
+
export interface CompositeItemProps<T extends object> {
|
|
58
|
+
id: string;
|
|
59
|
+
className?: string;
|
|
60
|
+
|
|
61
|
+
item: CompositeDataItem<T>;
|
|
62
|
+
|
|
63
|
+
mouseEventHandler?: (itemAtom: CompositeDataItem<T>) => void;
|
|
64
|
+
keyboardEventHandler?: (itemAtom: CompositeDataItem<T>) => void;
|
|
65
|
+
|
|
66
|
+
remove?: () => void;
|
|
67
|
+
render: CompositeItemRenderFn<T>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface CompositeFocusProvider {
|
|
71
|
+
setPointers: () => void;
|
|
72
|
+
|
|
73
|
+
goFirst: () => void;
|
|
74
|
+
goLast: () => void;
|
|
75
|
+
|
|
76
|
+
goUp: () => void;
|
|
77
|
+
goDown: () => void;
|
|
78
|
+
goLeft: () => void;
|
|
79
|
+
goRight: () => void;
|
|
80
|
+
|
|
81
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
|
3
|
+
|
|
4
|
+
import { cn } from "@/lib/utils"
|
|
5
|
+
|
|
6
|
+
function Popover({
|
|
7
|
+
...props
|
|
8
|
+
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
|
9
|
+
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function PopoverTrigger({
|
|
13
|
+
...props
|
|
14
|
+
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
|
15
|
+
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function PopoverContent({
|
|
19
|
+
className,
|
|
20
|
+
align = "center",
|
|
21
|
+
sideOffset = 4,
|
|
22
|
+
...props
|
|
23
|
+
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
|
24
|
+
return (
|
|
25
|
+
<PopoverPrimitive.Portal>
|
|
26
|
+
<PopoverPrimitive.Content
|
|
27
|
+
data-slot="popover-content"
|
|
28
|
+
align={align}
|
|
29
|
+
sideOffset={sideOffset}
|
|
30
|
+
className={cn(
|
|
31
|
+
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
|
32
|
+
className
|
|
33
|
+
)}
|
|
34
|
+
{...props}
|
|
35
|
+
/>
|
|
36
|
+
</PopoverPrimitive.Portal>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function PopoverAnchor({
|
|
41
|
+
...props
|
|
42
|
+
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
|
43
|
+
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { DependencyList, EffectCallback } from 'react';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
export function useEffectAfterMount(fn: EffectCallback, dependencies?: DependencyList) {
|
|
6
|
+
const mounted = useRef(false);
|
|
7
|
+
|
|
8
|
+
useEffect(
|
|
9
|
+
() => () => {
|
|
10
|
+
mounted.current = false;
|
|
11
|
+
},
|
|
12
|
+
[]
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
useEffect(
|
|
16
|
+
() => {
|
|
17
|
+
if (mounted.current) {
|
|
18
|
+
return fn();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
mounted.current = true;
|
|
22
|
+
return undefined;
|
|
23
|
+
},
|
|
24
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
25
|
+
dependencies
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { KeyboardEvent, KeyboardEventHandler } from 'react';
|
|
2
|
+
import { useMemo } from 'react';
|
|
3
|
+
|
|
4
|
+
const MODIFIER_KEYS = new Set(['ctrl', 'shift', 'alt', 'meta']);
|
|
5
|
+
|
|
6
|
+
const KEY_MAPPINGS: Record<string, string> = {
|
|
7
|
+
' ': 'space',
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export interface KeyBindings {
|
|
11
|
+
[sequence: string]: (event: KeyboardEvent) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface KeybindingLookupItem {
|
|
15
|
+
sequence: Set<string>;
|
|
16
|
+
handler: (event: KeyboardEvent) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface UseKeyboardEventOptions {
|
|
20
|
+
eventKeyProp: 'key' | 'code';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isSequenceEqual<T>(sequenceA: Set<T>, sequenceB: Set<T>) {
|
|
24
|
+
if (sequenceA.size !== sequenceB.size) return false;
|
|
25
|
+
|
|
26
|
+
for (const element of sequenceB) {
|
|
27
|
+
if (!sequenceA.has(element)) return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseKeybindings(bindings: KeyBindings) {
|
|
34
|
+
const parsedKeybindings: KeybindingLookupItem[] = [];
|
|
35
|
+
for (const [sequence, handler] of Object.entries(bindings)) {
|
|
36
|
+
const parsedSequence = sequence
|
|
37
|
+
.toLowerCase()
|
|
38
|
+
.trim()
|
|
39
|
+
.split(/\s*\+\s*/);
|
|
40
|
+
|
|
41
|
+
if (parsedSequence.length === 1 && MODIFIER_KEYS.has(parsedSequence[0])) {
|
|
42
|
+
console.error(`[useKeyboardEvent] \`${sequence}\`: A key sequence cannot be only a modifier key.`);
|
|
43
|
+
}
|
|
44
|
+
else if (parsedSequence.includes('')) {
|
|
45
|
+
console.error(`[useKeyboardEvent] \`${sequence}\`: Unknown key defined in the sequence.`);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
parsedKeybindings.push({
|
|
49
|
+
sequence: new Set(parsedSequence),
|
|
50
|
+
handler,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return parsedKeybindings;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const defaultOptions: UseKeyboardEventOptions = {
|
|
59
|
+
eventKeyProp: 'key',
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export function keyboardEventHandler(
|
|
63
|
+
bindings: KeyBindings,
|
|
64
|
+
options: UseKeyboardEventOptions = defaultOptions
|
|
65
|
+
): KeyboardEventHandler {
|
|
66
|
+
const _options = { ...options, ...defaultOptions }
|
|
67
|
+
|
|
68
|
+
const keyBindings = parseKeybindings(bindings);
|
|
69
|
+
|
|
70
|
+
return (event: KeyboardEvent) => {
|
|
71
|
+
const keySequence = new Set<string>();
|
|
72
|
+
const eventKey = event[_options.eventKeyProp];
|
|
73
|
+
|
|
74
|
+
if (event.ctrlKey) keySequence.add('ctrl');
|
|
75
|
+
|
|
76
|
+
if (event.shiftKey) keySequence.add('shift');
|
|
77
|
+
|
|
78
|
+
if (event.altKey) keySequence.add('alt');
|
|
79
|
+
|
|
80
|
+
if (event.metaKey) keySequence.add('meta');
|
|
81
|
+
|
|
82
|
+
if (!KEY_MAPPINGS[eventKey]) keySequence.add(eventKey.toLowerCase());
|
|
83
|
+
else keySequence.add(KEY_MAPPINGS[eventKey]);
|
|
84
|
+
|
|
85
|
+
const matchedSequence = keyBindings.find(keyBinding => isSequenceEqual(keySequence, keyBinding.sequence));
|
|
86
|
+
if (matchedSequence) {
|
|
87
|
+
event.preventDefault();
|
|
88
|
+
event.stopPropagation();
|
|
89
|
+
matchedSequence.handler(event);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Returns a `KeyboardEventHandler`
|
|
96
|
+
* that checks the defined key binding sequences against a keyboard event
|
|
97
|
+
* and executes the handler of the first matched key binding.
|
|
98
|
+
*
|
|
99
|
+
* Limitations:
|
|
100
|
+
* - Space character (` `) cannot be used in the key sequences.
|
|
101
|
+
* Use the `space` keyword instead.
|
|
102
|
+
* - Plus character (`+`) cannot be used in the key sequences.
|
|
103
|
+
* Use `shit + =` instead.
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* ```
|
|
107
|
+
* export function Input({ value, onChange }: Props) {
|
|
108
|
+
* const [inputValue, setInputValue] = useState<string>('');
|
|
109
|
+
*
|
|
110
|
+
* const clearInput = () => {
|
|
111
|
+
* setInputValue('');
|
|
112
|
+
* };
|
|
113
|
+
*
|
|
114
|
+
* const addItem = () => {
|
|
115
|
+
* if (inputValue) {
|
|
116
|
+
* onChange([...value, inputValue]);
|
|
117
|
+
* clearInput();
|
|
118
|
+
* }
|
|
119
|
+
* };
|
|
120
|
+
*
|
|
121
|
+
* const deleteAll = () => {
|
|
122
|
+
* onChange([]);
|
|
123
|
+
* clearInput();
|
|
124
|
+
* };
|
|
125
|
+
*
|
|
126
|
+
* const handleKeyDown = useKeyboardEvent({
|
|
127
|
+
* 'enter': addItem,
|
|
128
|
+
* 'escape': clearInput,
|
|
129
|
+
* 'ctrl+c': clearInput,
|
|
130
|
+
* 'ctrl + shift + c': deleteAll,
|
|
131
|
+
* });
|
|
132
|
+
*
|
|
133
|
+
* return (
|
|
134
|
+
* <input
|
|
135
|
+
* value={inputValue}
|
|
136
|
+
* onChange={event => setInputValue(event.target.value)}
|
|
137
|
+
* onKeyDown={handleKeyDown}
|
|
138
|
+
* />;
|
|
139
|
+
* }
|
|
140
|
+
* ```
|
|
141
|
+
*/
|
|
142
|
+
export function useKeyboardEvent(bindings: KeyBindings, options?: UseKeyboardEventOptions) {
|
|
143
|
+
return useMemo(() => keyboardEventHandler(bindings, options), [bindings, options]);
|
|
144
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
|
|
3
|
+
export const get = <T extends object>(object: T, prop: string) => prop
|
|
4
|
+
.split('.')
|
|
5
|
+
.reduce<any>((reducedObject, key) => (reducedObject && key in reducedObject) ? reducedObject[key] : undefined, object);
|
|
6
|
+
|
|
7
|
+
export const set = <T extends object, V>(object: T, prop: string, value: V) => {
|
|
8
|
+
const propChunks = prop.split('.');
|
|
9
|
+
const lastChunk = propChunks.pop();
|
|
10
|
+
if (!lastChunk) return object;
|
|
11
|
+
|
|
12
|
+
const ref = propChunks.reduce<any>((reducedObject, key) => {
|
|
13
|
+
reducedObject[key] = {};
|
|
14
|
+
return reducedObject[key];
|
|
15
|
+
}, object);
|
|
16
|
+
|
|
17
|
+
ref[lastChunk] = value;
|
|
18
|
+
|
|
19
|
+
return object;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const pick = <T extends object>(object: T, props: string[]) => {
|
|
23
|
+
|
|
24
|
+
return props.reduce<Record<string, unknown>>((result, key) => {
|
|
25
|
+
|
|
26
|
+
set(result, key, get(object, key));
|
|
27
|
+
|
|
28
|
+
return result;
|
|
29
|
+
}, {}) as Partial<T>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const omit = <T extends object>(object: T, props: string[]) => {
|
|
33
|
+
const result: Partial<T> = { ...object };
|
|
34
|
+
|
|
35
|
+
props.forEach(prop => {
|
|
36
|
+
const propChunks = prop.split('.');
|
|
37
|
+
const lastChunk = propChunks.pop();
|
|
38
|
+
if (lastChunk) {
|
|
39
|
+
const ref = propChunks.reduce<any>((reducedObject, key) => (reducedObject && key in reducedObject) ? reducedObject[key] : undefined, result);
|
|
40
|
+
if (ref && lastChunk in ref) delete ref[lastChunk];
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const isObject = (object: unknown) => (typeof object === 'object' && !Array.isArray(object) && object !== null);
|
|
48
|
+
|