-
Notifications
You must be signed in to change notification settings - Fork 282
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add centralized dialog management
1 parent
eb0d6d4
commit 9751e15
Showing
13 changed files
with
575 additions
and
223 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,79 @@ | ||
import { Placement } from '@popperjs/core'; | ||
import React, { ComponentProps, PropsWithChildren, useEffect, useRef } from 'react'; | ||
import { usePopper } from 'react-popper'; | ||
import { useDialogIsOpen } from './hooks'; | ||
import { DialogPortalEntry } from './DialogPortal'; | ||
|
||
export interface DialogAnchorOptions { | ||
open: boolean; | ||
placement: Placement; | ||
referenceElement: HTMLElement | null; | ||
} | ||
|
||
export function useDialogAnchor<T extends HTMLElement>({ | ||
open, | ||
placement, | ||
referenceElement, | ||
}: DialogAnchorOptions) { | ||
const popperElementRef = useRef<T>(null); | ||
const { attributes, styles, update } = usePopper(referenceElement, popperElementRef.current, { | ||
modifiers: [ | ||
{ | ||
name: 'eventListeners', | ||
options: { | ||
// It's not safe to update popper position on resize and scroll, since popper's | ||
// reference element might not be visible at the time. | ||
resize: false, | ||
scroll: false, | ||
}, | ||
}, | ||
], | ||
placement, | ||
}); | ||
|
||
useEffect(() => { | ||
if (open) { | ||
// Since the popper's reference element might not be (and usually is not) visible | ||
// all the time, it's safer to force popper update before showing it. | ||
update?.(); | ||
} | ||
}, [open, update]); | ||
|
||
return { | ||
attributes, | ||
popperElementRef, | ||
styles, | ||
}; | ||
} | ||
|
||
type DialogAnchorProps = PropsWithChildren<Partial<DialogAnchorOptions>> & { | ||
id: string; | ||
} & ComponentProps<'div' | 'span'>; | ||
|
||
export const DialogAnchor = ({ | ||
children, | ||
className, | ||
id, | ||
placement = 'auto', | ||
referenceElement = null, | ||
}: DialogAnchorProps) => { | ||
const open = useDialogIsOpen(id); | ||
const { attributes, popperElementRef, styles } = useDialogAnchor<HTMLDivElement>({ | ||
open, | ||
placement, | ||
referenceElement, | ||
}); | ||
|
||
return ( | ||
<DialogPortalEntry dialogId={id}> | ||
<div | ||
{...attributes.popper} | ||
className={className} | ||
ref={popperElementRef} | ||
style={styles.popper} | ||
> | ||
{children} | ||
</div> | ||
</DialogPortalEntry> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,62 @@ | ||
import React, { PropsWithChildren, useEffect, useLayoutEffect, useState } from 'react'; | ||
import { createPortal } from 'react-dom'; | ||
import type { DialogsManager } from './DialogsManager'; | ||
import { useDialogIsOpen } from './hooks'; | ||
import { useDialogsManager } from '../../context'; | ||
|
||
export const DialogPortalDestination = () => { | ||
const { dialogsManager } = useDialogsManager(); | ||
const [shouldRender, setShouldRender] = useState(!!dialogsManager.openDialogCount); | ||
useEffect( | ||
() => | ||
dialogsManager.on('openCountChange', { | ||
listener: (dm: DialogsManager) => { | ||
setShouldRender(dm.openDialogCount > 0); | ||
}, | ||
}), | ||
[dialogsManager], | ||
); | ||
|
||
return ( | ||
<> | ||
<div | ||
className='str-chat__dialog-overlay' | ||
onClick={() => dialogsManager.closeAll()} | ||
style={{ | ||
height: '100%', | ||
inset: '0', | ||
overflow: 'hidden', | ||
position: 'absolute', | ||
width: '100%', | ||
zIndex: shouldRender ? '2' : '-1', | ||
}} | ||
> | ||
<div data-str-chat__portal-id={dialogsManager.id} /> | ||
</div> | ||
</> | ||
); | ||
}; | ||
|
||
type DialogPortalEntryProps = { | ||
dialogId: string; | ||
}; | ||
|
||
export const DialogPortalEntry = ({ | ||
children, | ||
dialogId, | ||
}: PropsWithChildren<DialogPortalEntryProps>) => { | ||
const { dialogsManager } = useDialogsManager(); | ||
const dialogIsOpen = useDialogIsOpen(dialogId); | ||
const [portalDestination, setPortalDestination] = useState<Element | null>(null); | ||
useLayoutEffect(() => { | ||
const destination = document.querySelector( | ||
`div[data-str-chat__portal-id="${dialogsManager.id}"]`, | ||
); | ||
if (!destination) return; | ||
setPortalDestination(destination); | ||
}, [dialogsManager, dialogIsOpen]); | ||
|
||
if (!portalDestination) return null; | ||
|
||
return createPortal(children, portalDestination); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,178 @@ | ||
type DialogId = string; | ||
|
||
export type GetOrCreateParams = { | ||
id: DialogId; | ||
isOpen?: boolean; | ||
}; | ||
|
||
export type Dialog = { | ||
close: () => void; | ||
id: DialogId; | ||
isOpen: boolean | undefined; | ||
open: (zIndex?: number) => void; | ||
remove: () => void; | ||
toggle: () => void; | ||
toggleSingle: () => void; | ||
}; | ||
|
||
type DialogEvent = { type: 'close' | 'open' | 'openCountChange' }; | ||
|
||
const dialogsManagerEvents = ['openCountChange'] as const; | ||
type DialogsManagerEvent = { type: typeof dialogsManagerEvents[number] }; | ||
|
||
type DialogEventHandler = (dialog: Dialog) => void; | ||
type DialogsManagerEventHandler = (dialogsManager: DialogsManager) => void; | ||
|
||
type DialogInitOptions = { | ||
id?: string; | ||
}; | ||
|
||
const noop = (): void => undefined; | ||
|
||
export class DialogsManager { | ||
id: string; | ||
openDialogCount = 0; | ||
dialogs: Record<DialogId, Dialog> = {}; | ||
private dialogEventListeners: Record< | ||
DialogId, | ||
Partial<Record<DialogEvent['type'], DialogEventHandler[]>> | ||
> = {}; | ||
private dialogsManagerEventListeners: Record< | ||
DialogsManagerEvent['type'], | ||
DialogsManagerEventHandler[] | ||
> = { openCountChange: [] }; | ||
|
||
constructor({ id }: DialogInitOptions = {}) { | ||
this.id = id ?? new Date().getTime().toString(); | ||
} | ||
|
||
getOrCreate({ id, isOpen = false }: GetOrCreateParams) { | ||
let dialog = this.dialogs[id]; | ||
if (!dialog) { | ||
dialog = { | ||
close: () => { | ||
this.close(id); | ||
}, | ||
id, | ||
isOpen, | ||
open: () => { | ||
this.open({ id }); | ||
}, | ||
remove: () => { | ||
this.remove(id); | ||
}, | ||
toggle: () => { | ||
this.toggleOpen({ id }); | ||
}, | ||
toggleSingle: () => { | ||
this.toggleOpenSingle({ id }); | ||
}, | ||
}; | ||
this.dialogs[id] = dialog; | ||
} | ||
return dialog; | ||
} | ||
|
||
on( | ||
eventType: DialogEvent['type'] | DialogsManagerEvent['type'], | ||
{ id, listener }: { listener: DialogEventHandler | DialogsManagerEventHandler; id?: DialogId }, | ||
) { | ||
if (dialogsManagerEvents.includes(eventType as DialogsManagerEvent['type'])) { | ||
this.dialogsManagerEventListeners[eventType as DialogsManagerEvent['type']].push( | ||
listener as DialogsManagerEventHandler, | ||
); | ||
return () => { | ||
this.off(eventType, { listener }); | ||
}; | ||
} | ||
if (!id) return noop; | ||
|
||
if (!this.dialogEventListeners[id]) { | ||
this.dialogEventListeners[id] = { close: [], open: [] }; | ||
} | ||
this.dialogEventListeners[id][eventType] = [ | ||
...(this.dialogEventListeners[id][eventType] ?? []), | ||
listener as DialogEventHandler, | ||
]; | ||
return () => { | ||
this.off(eventType, { id, listener }); | ||
}; | ||
} | ||
|
||
off( | ||
eventType: DialogEvent['type'] | DialogsManagerEvent['type'], | ||
{ id, listener }: { listener: DialogEventHandler | DialogsManagerEventHandler; id?: DialogId }, | ||
) { | ||
if (dialogsManagerEvents.includes(eventType as DialogsManagerEvent['type'])) { | ||
const eventListeners = this.dialogsManagerEventListeners[ | ||
eventType as DialogsManagerEvent['type'] | ||
]; | ||
eventListeners?.filter((l) => l !== listener); | ||
return; | ||
} | ||
|
||
if (!id) return; | ||
|
||
const eventListeners = this.dialogEventListeners[id]?.[eventType]; | ||
if (!eventListeners) return; | ||
this.dialogEventListeners[id][eventType] = eventListeners.filter((l) => l !== listener); | ||
} | ||
|
||
open(params: GetOrCreateParams, single?: boolean) { | ||
const dialog = this.getOrCreate(params); | ||
if (dialog.isOpen) return; | ||
if (single) { | ||
this.closeAll(); | ||
} | ||
this.dialogs[params.id].isOpen = true; | ||
this.openDialogCount++; | ||
this.dialogsManagerEventListeners.openCountChange.forEach((listener) => listener(this)); | ||
this.dialogEventListeners[params.id].open?.forEach((listener) => listener(dialog)); | ||
} | ||
|
||
close(id: DialogId) { | ||
const dialog = this.dialogs[id]; | ||
if (!dialog?.isOpen) return; | ||
dialog.isOpen = false; | ||
this.openDialogCount--; | ||
this.dialogEventListeners[id].close?.forEach((listener) => listener(dialog)); | ||
this.dialogsManagerEventListeners.openCountChange.forEach((listener) => listener(this)); | ||
} | ||
|
||
closeAll() { | ||
Object.values(this.dialogs).forEach((dialog) => dialog.close()); | ||
} | ||
|
||
toggleOpen(params: GetOrCreateParams) { | ||
if (this.dialogs[params.id].isOpen) { | ||
this.close(params.id); | ||
} else { | ||
this.open(params); | ||
} | ||
} | ||
|
||
toggleOpenSingle(params: GetOrCreateParams) { | ||
if (this.dialogs[params.id].isOpen) { | ||
this.close(params.id); | ||
} else { | ||
this.open(params, true); | ||
} | ||
} | ||
|
||
remove(id: DialogId) { | ||
const dialogs = { ...this.dialogs }; | ||
if (!dialogs[id]) return; | ||
|
||
const countListeners = | ||
!!this.dialogEventListeners[id] && | ||
Object.values(this.dialogEventListeners[id]).reduce((acc, listeners) => { | ||
acc += listeners.length; | ||
return acc; | ||
}, 0); | ||
|
||
if (!countListeners) { | ||
delete this.dialogEventListeners[id]; | ||
delete dialogs[id]; | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from './useDialog'; |
Oops, something went wrong.