diff --git a/build/api/layout.api.md b/build/api/layout.api.md index 592127a2f2..2d1232482c 100644 --- a/build/api/layout.api.md +++ b/build/api/layout.api.md @@ -6,17 +6,19 @@ import { ComponentClassNameProps } from '@contember/utilities'; import { ComponentProps } from 'react'; -import { ComponentType } from 'react'; import { Context } from 'react'; +import { createSlotComponents } from '@contember/react-slots'; +import { createSlotSourceComponent } from '@contember/react-slots'; +import { createSlotTargetComponent } from '@contember/react-slots'; import { CSSProperties } from 'react'; import { ElementType } from 'react'; import { ForwardRefExoticComponent } from 'react'; -import { FunctionComponentElement } from 'react'; import { JSX as JSX_2 } from 'react/jsx-runtime'; import { JSX as JSX_3 } from 'react'; import { MemoExoticComponent } from 'react'; import { NamedExoticComponent } from 'react'; import { NestedClassName } from '@contember/utilities'; +import { OwnTargetContainerProps } from '@contember/react-slots'; import { PolymorphicComponent } from '@contember/utilities'; import { PolymorphicComponentPropsWithRef } from '@contember/utilities'; import { PropsWithChildren } from 'react'; @@ -24,13 +26,23 @@ import { ReactElement } from 'react'; import { ReactNode } from 'react'; import { RefAttributes } from 'react'; import { RefObject } from 'react'; +import { SlotComponents } from '@contember/react-slots'; +import { SlotSource } from '@contember/react-slots'; +import { SlotSourceComponent } from '@contember/react-slots'; +import { SlotSourceComponentProps } from '@contember/react-slots'; +import { SlotSourceComponentsRecord } from '@contember/react-slots'; +import { SlotSourceProps } from '@contember/react-slots'; +import { SlotsProvider } from '@contember/react-slots'; +import { SlotTarget } from '@contember/react-slots'; +import { SlotTargetComponent } from '@contember/react-slots'; +import { SlotTargetComponentProps } from '@contember/react-slots'; +import { SlotTargetComponentsRecord } from '@contember/react-slots'; +import { SlotTargetProps } from '@contember/react-slots'; import { StackOwnProps } from '@contember/ui'; - -// @public (undocumented) -const ActiveSlotPortalsContext: Context; - -// @public (undocumented) -type ActiveSlotPortalsContextType = Set; +import { useHasActiveSlotsFactory } from '@contember/react-slots'; +import { useSlotTargetElement } from '@contember/react-slots'; +import { useSlotTargetsFactory } from '@contember/react-slots'; +import { useTargetElementRegistrar } from '@contember/react-slots'; // @public (undocumented) export type BarComponentType = PolymorphicComponent<'div', OwnBarProps>; @@ -62,124 +74,28 @@ export type CommonPanelProps = { export const commonSlots: readonly ("Actions" | "Back" | "Logo" | "Navigation" | "Sidebar" | "Title" | "Profile" | "Switchers")[]; // @public (undocumented) -export const CommonSlotSources: Readonly & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "Actions"; - }; - readonly Back: { - ({ name, children }: Omit & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "Back"; - }; - readonly Logo: { - ({ name, children }: Omit & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "Logo"; - }; - readonly Navigation: { - ({ name, children }: Omit & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "Navigation"; - }; - readonly Sidebar: { - ({ name, children }: Omit & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "Sidebar"; - }; - readonly Title: { - ({ name, children }: Omit & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "Title"; - }; - readonly Profile: { - ({ name, children }: Omit & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "Profile"; - }; - readonly Switchers: { - ({ name, children }: Omit & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "Switchers"; - }; -}>>; +export const CommonSlotSources: Readonly<{ + readonly Actions: SlotSourceComponent<"Actions">; + readonly Back: SlotSourceComponent<"Back">; + readonly Logo: SlotSourceComponent<"Logo">; + readonly Navigation: SlotSourceComponent<"Navigation">; + readonly Sidebar: SlotSourceComponent<"Sidebar">; + readonly Title: SlotSourceComponent<"Title">; + readonly Profile: SlotSourceComponent<"Profile">; + readonly Switchers: SlotSourceComponent<"Switchers">; +}>; // @public (undocumented) -export const CommonSlotTargets: Readonly & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "Actions"; - }; - readonly Back: { - ({ className, ...props }: Omit & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "Back"; - }; - readonly Logo: { - ({ className, ...props }: Omit & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "Logo"; - }; - readonly Navigation: { - ({ className, ...props }: Omit & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "Navigation"; - }; - readonly Sidebar: { - ({ className, ...props }: Omit & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "Sidebar"; - }; - readonly Title: { - ({ className, ...props }: Omit & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "Title"; - }; - readonly Profile: { - ({ className, ...props }: Omit & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "Profile"; - }; - readonly Switchers: { - ({ className, ...props }: Omit & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "Switchers"; - }; -}>>; +export const CommonSlotTargets: Readonly<{ + readonly Actions: SlotTargetComponent<"Actions">; + readonly Back: SlotTargetComponent<"Back">; + readonly Logo: SlotTargetComponent<"Logo">; + readonly Navigation: SlotTargetComponent<"Navigation">; + readonly Sidebar: SlotTargetComponent<"Sidebar">; + readonly Title: SlotTargetComponent<"Title">; + readonly Profile: SlotTargetComponent<"Profile">; + readonly Switchers: SlotTargetComponent<"Switchers">; +}>; // @public (undocumented) type ContainerComponentType = PolymorphicComponent<'div', OwnContainerProps>; @@ -225,40 +141,16 @@ export type ContentPanelProps = ComponentProps; export const contentSlots: readonly ("ContentFooter" | "ContentHeader")[]; // @public (undocumented) -export const ContentSlotSources: Readonly & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "ContentFooter"; - }; - readonly ContentHeader: { - ({ name, children }: Omit & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "ContentHeader"; - }; -}>>; +export const ContentSlotSources: Readonly<{ + readonly ContentFooter: SlotSourceComponent<"ContentFooter">; + readonly ContentHeader: SlotSourceComponent<"ContentHeader">; +}>; // @public (undocumented) -export const ContentSlotTargets: Readonly & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "ContentFooter"; - }; - readonly ContentHeader: { - ({ className, ...props }: Omit & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "ContentHeader"; - }; -}>>; +export const ContentSlotTargets: Readonly<{ + readonly ContentFooter: SlotTargetComponent<"ContentFooter">; + readonly ContentHeader: SlotTargetComponent<"ContentHeader">; +}>; // @public (undocumented) type ControlledBehaviorPanelProps = { @@ -311,39 +203,6 @@ export function createLayoutSidebarComponent({ defaultAs, defaultBehavior, defau displayName?: string | undefined; } & SidebarComponentAttributes; -// @public (undocumented) -function createSlotComponents(slots: readonly K[]): readonly [readonly K[], Readonly & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: P; - }; }>>, Readonly & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: P_1; - }; }>>]; - -// @public (undocumented) -function createSlotSourceComponent(slot: T): { - ({ name, children }: Omit & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: T; -}; - -// @public (undocumented) -function createSlotTargetComponent(name: T): { - ({ className, ...props }: Omit & { - name?: string; - }): JSX_2.Element; - displayName: string; - slot: T; -}; - // @public (undocumented) export function diffContainerInsetsFromElementRects(outerRect: ElementRect, innerRect: ElementRect): ContainerInsets; @@ -415,54 +274,18 @@ export type FocusScopeProps = { export const footerSlots: readonly ("FooterCenter" | "FooterEnd" | "FooterStart")[]; // @public (undocumented) -export const FooterSlotSources: Readonly & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "FooterCenter"; - }; - readonly FooterEnd: { - ({ name, children }: Omit & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "FooterEnd"; - }; - readonly FooterStart: { - ({ name, children }: Omit & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "FooterStart"; - }; -}>>; +export const FooterSlotSources: Readonly<{ + readonly FooterCenter: SlotSourceComponent<"FooterCenter">; + readonly FooterEnd: SlotSourceComponent<"FooterEnd">; + readonly FooterStart: SlotSourceComponent<"FooterStart">; +}>; // @public (undocumented) -export const FooterSlotTargets: Readonly & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "FooterCenter"; - }; - readonly FooterEnd: { - ({ className, ...props }: Omit & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "FooterEnd"; - }; - readonly FooterStart: { - ({ className, ...props }: Omit & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "FooterStart"; - }; -}>>; +export const FooterSlotTargets: Readonly<{ + readonly FooterCenter: SlotTargetComponent<"FooterCenter">; + readonly FooterEnd: SlotTargetComponent<"FooterEnd">; + readonly FooterStart: SlotTargetComponent<"FooterStart">; +}>; // @public (undocumented) export type FrameComponentType = PolymorphicComponent<'div', OwnFrameProps>; @@ -490,54 +313,18 @@ export function getScreenInnerBoundingRect(): ElementRect; export const headerSlots: readonly ("HeaderCenter" | "HeaderEnd" | "HeaderStart")[]; // @public (undocumented) -export const HeaderSlotSources: Readonly & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "HeaderCenter"; - }; - readonly HeaderEnd: { - ({ name, children }: Omit & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "HeaderEnd"; - }; - readonly HeaderStart: { - ({ name, children }: Omit & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "HeaderStart"; - }; -}>>; +export const HeaderSlotSources: Readonly<{ + readonly HeaderCenter: SlotSourceComponent<"HeaderCenter">; + readonly HeaderEnd: SlotSourceComponent<"HeaderEnd">; + readonly HeaderStart: SlotSourceComponent<"HeaderStart">; +}>; // @public (undocumented) -export const HeaderSlotTargets: Readonly & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "HeaderCenter"; - }; - readonly HeaderEnd: { - ({ className, ...props }: Omit & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "HeaderEnd"; - }; - readonly HeaderStart: { - ({ className, ...props }: Omit & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "HeaderStart"; - }; -}>>; +export const HeaderSlotTargets: Readonly<{ + readonly HeaderCenter: SlotTargetComponent<"HeaderCenter">; + readonly HeaderEnd: SlotTargetComponent<"HeaderEnd">; + readonly HeaderStart: SlotTargetComponent<"HeaderStart">; +}>; // @public (undocumented) export const InsetsConsumer: InsetsConsumerComponentType; @@ -761,11 +548,6 @@ export type OwnSidebarProps = ComponentClassNameProps & CommonPanelProps & Pick< keepVisible?: boolean | null | undefined; }; -// @public (undocumented) -type OwnTargetContainerProps = { - className: string; -}; - // @public (undocumented) const Panel: PanelComponentType; @@ -851,17 +633,9 @@ type PanelWidthContextType = { width: number; }; -// @public (undocumented) -const PortalsRegistryContext: Context; - // @public (undocumented) const Provider: NamedExoticComponent; -// @public (undocumented) -const Provider_2: NamedExoticComponent< { -children: ReactNode; -}>; - // @public (undocumented) type ProviderProps = { value?: Record; @@ -871,9 +645,6 @@ type ProviderProps = { // @public (undocumented) type RegisterLayoutPanel = (name: string, config: PanelConfig) => void; -// @public (undocumented) -type RegisterSlotTarget = (id: string, name: string, ref: HTMLElement) => void; - // @public (undocumented) const RegistryContext: Context>>; @@ -884,13 +655,6 @@ type RegistryContextType> = { unregister: ((key: K, componentId: string) => void) | undefined; }; -// @public (undocumented) -type RenderToSlotPortalContextType = { - getTarget: undefined | ((slot: string) => HTMLElement | null | undefined); - registerSlotSource: undefined | ((id: string, slot: string) => void); - unregisterSlotSource: undefined | ((id: string, slot: string) => void); -}; - // @public (undocumented) const ResponsiveContainer: ContainerComponentType; @@ -965,54 +729,18 @@ export type SidebarComponentType = PolymorphicComponent<'aside', OwnSidebarProps export const sidebarLeftSlots: readonly ("SidebarLeftBody" | "SidebarLeftFooter" | "SidebarLeftHeader")[]; // @public (undocumented) -export const SidebarLeftSlotSources: Readonly & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "SidebarLeftBody"; - }; - readonly SidebarLeftFooter: { - ({ name, children }: Omit & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "SidebarLeftFooter"; - }; - readonly SidebarLeftHeader: { - ({ name, children }: Omit & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "SidebarLeftHeader"; - }; -}>>; +export const SidebarLeftSlotSources: Readonly<{ + readonly SidebarLeftBody: SlotSourceComponent<"SidebarLeftBody">; + readonly SidebarLeftFooter: SlotSourceComponent<"SidebarLeftFooter">; + readonly SidebarLeftHeader: SlotSourceComponent<"SidebarLeftHeader">; +}>; // @public (undocumented) -export const SidebarLeftSlotTargets: Readonly & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "SidebarLeftBody"; - }; - readonly SidebarLeftFooter: { - ({ className, ...props }: Omit & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "SidebarLeftFooter"; - }; - readonly SidebarLeftHeader: { - ({ className, ...props }: Omit & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "SidebarLeftHeader"; - }; -}>>; +export const SidebarLeftSlotTargets: Readonly<{ + readonly SidebarLeftBody: SlotTargetComponent<"SidebarLeftBody">; + readonly SidebarLeftFooter: SlotTargetComponent<"SidebarLeftFooter">; + readonly SidebarLeftHeader: SlotTargetComponent<"SidebarLeftHeader">; +}>; // @public (undocumented) export type SidebarProps = ComponentProps; @@ -1021,138 +749,51 @@ export type SidebarProps = ComponentProps; export const sidebarRightSlots: readonly ("SidebarRightBody" | "SidebarRightFooter" | "SidebarRightHeader")[]; // @public (undocumented) -export const SidebarRightSlotSources: Readonly & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "SidebarRightBody"; - }; - readonly SidebarRightFooter: { - ({ name, children }: Omit & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "SidebarRightFooter"; - }; - readonly SidebarRightHeader: { - ({ name, children }: Omit & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "SidebarRightHeader"; - }; -}>>; +export const SidebarRightSlotSources: Readonly<{ + readonly SidebarRightBody: SlotSourceComponent<"SidebarRightBody">; + readonly SidebarRightFooter: SlotSourceComponent<"SidebarRightFooter">; + readonly SidebarRightHeader: SlotSourceComponent<"SidebarRightHeader">; +}>; // @public (undocumented) -export const SidebarRightSlotTargets: Readonly & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "SidebarRightBody"; - }; - readonly SidebarRightFooter: { - ({ className, ...props }: Omit & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "SidebarRightFooter"; - }; - readonly SidebarRightHeader: { - ({ className, ...props }: Omit & { - name?: string | undefined; - }): JSX_2.Element; - displayName: string; - slot: "SidebarRightHeader"; - }; -}>>; - -// @public @deprecated (undocumented) -type SlotComponentsRecords = Readonly<{ - readonly [P in K]: ComponentType; +export const SidebarRightSlotTargets: Readonly<{ + readonly SidebarRightBody: SlotTargetComponent<"SidebarRightBody">; + readonly SidebarRightFooter: SlotTargetComponent<"SidebarRightFooter">; + readonly SidebarRightHeader: SlotTargetComponent<"SidebarRightHeader">; }>; declare namespace Slots { export { - Provider_2 as Provider, - Source, - OwnTargetContainerProps, - Target, - useHasActiveSlotsFactory, - useSlotTargetsFactory, - SlotsRefMap, - RegisterSlotTarget, - UnregisterSlotTarget, - ActiveSlotPortalsContextType, - ActiveSlotPortalsContext, - useActiveSlotPortalsContext, - SlotTargetsRegistryContextType, - TargetsRegistryContext, - useTargetsRegistryContext, - RenderToSlotPortalContextType, - PortalsRegistryContext, - usePortalsRegistryContext, + SlotSource as Source, + SlotTarget as Target, + SlotsProvider as Provider, createSlotComponents, createSlotSourceComponent, createSlotTargetComponent, - SlotComponentsRecords, + SlotTarget, + SlotSource, + SlotsProvider, + useHasActiveSlotsFactory, + useSlotTargetElement, + useSlotTargetsFactory, + useTargetElementRegistrar, + OwnTargetContainerProps, + SlotComponents, + SlotSourceComponent, + SlotSourceComponentProps, SlotSourceComponentsRecord, - SlotTargetComponentsRecord, SlotSourceProps, + SlotTargetComponent, + SlotTargetComponentProps, + SlotTargetComponentsRecord, SlotTargetProps } } export { Slots } -// @public (undocumented) -type SlotSourceComponentsRecord = Readonly<{ - readonly [P in K]: ReturnType>; -}>; - -// @public (undocumented) -type SlotSourceProps = { - children: ReactNode; - name: string; -}; - -// @public (undocumented) -type SlotsRefMap = Map; - -// @public (undocumented) -type SlotTargetComponentsRecord = Readonly<{ - readonly [P in K]: ReturnType>; -}>; - -// @public (undocumented) -type SlotTargetProps = ComponentClassNameProps & { - as?: ElementType; - fallback?: ReactNode; - name: string; - aliases?: [string, ...string[]]; - display?: boolean | 'contents' | 'block' | 'flex' | 'grid' | 'inline' | 'inline-flex' | 'inline-grid' | 'inline-block' | 'inherit' | 'initial' | 'none' | 'unset'; -}; - -// @public (undocumented) -type SlotTargetsRegistryContextType = { - registerSlotTarget: RegisterSlotTarget; - unregisterSlotTarget: UnregisterSlotTarget; -}; - -// @public (undocumented) -const Source: NamedExoticComponent; - // @public (undocumented) const StateContext: Context>; -// @public (undocumented) -const Target: NamedExoticComponent; - -// @public (undocumented) -const TargetsRegistryContext: Context; - // @public (undocumented) export type ToggleMenuButtonProps = ComponentClassNameProps & { children?: never; @@ -1191,9 +832,6 @@ type UncontrolledVisibilityPanelProps = { // @public (undocumented) type UnregisterLayoutPanel = LayoutPanelCallback; -// @public (undocumented) -type UnregisterSlotTarget = (id: string, name: string) => void; - // @public (undocumented) type UpdateLayoutPanel = (name: string, config: UpdateLayoutPanelConfig | null | undefined | void) => void; @@ -1202,9 +840,6 @@ type UpdateLayoutPanelConfig = Partial & { passive?: boolean; }>; -// @public (undocumented) -const useActiveSlotPortalsContext: () => ActiveSlotPortalsContextType; - // @public (undocumented) function useClosePanelOnEscape(behaviors?: Array): (event: KeyboardEvent, state: PanelState) => { readonly visibility: "hidden"; @@ -1228,18 +863,12 @@ export function useElementInsets(elementRef: RefObject): ContainerI // @public (undocumented) const useGetLayoutPanelsStateContext: () => GetLayoutPanelsStateContextType; -// @public (undocumented) -function useHasActiveSlotsFactory>(SlotTargets: T): (...slots: ReadonlyArray) => boolean; - // @public (undocumented) const useLayoutPanelContext: () => PanelState; // @public (undocumented) const usePanelWidthContext: () => PanelWidthContextType; -// @public (undocumented) -const usePortalsRegistryContext: () => RenderToSlotPortalContextType; - // @public (undocumented) const useRegistryContext: () => RegistryContextType>; @@ -1249,14 +878,6 @@ export const useSafeAreaInsetsContext: () => ContainerInsets; // @public (undocumented) const useSetLayoutPanelsStateContext: () => SetLayoutPanelsStateContextType; -// @public -function useSlotTargetsFactory>(SlotTargets: R): (slots: ReadonlyArray, override?: T | undefined) => NonNullable | FunctionComponentElement< { -children?: ReactNode; -}> | null; - -// @public (undocumented) -const useTargetsRegistryContext: () => SlotTargetsRegistryContextType; - // @public (undocumented) export const zeroInsets: ContainerInsets; diff --git a/build/api/react-slots.api.md b/build/api/react-slots.api.md new file mode 100644 index 0000000000..ee9ff035b5 --- /dev/null +++ b/build/api/react-slots.api.md @@ -0,0 +1,103 @@ +## API Report File for "@contember/react-slots" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ComponentClassNameProps } from '@contember/utilities'; +import { ComponentType } from 'react'; +import { ElementType } from 'react'; +import { FunctionComponentElement } from 'react'; +import { NamedExoticComponent } from 'react'; +import { ReactNode } from 'react'; + +// @public (undocumented) +export function createSlotComponents(slots: readonly K[]): SlotComponents; + +// @public (undocumented) +export function createSlotSourceComponent(slot: T): SlotSourceComponent; + +// @public (undocumented) +export function createSlotTargetComponent(name: T): SlotTargetComponent; + +// @public (undocumented) +export type OwnTargetContainerProps = { + className: string; +}; + +// @public (undocumented) +export type SlotComponents = readonly [ +readonly K[], +SlotSourceComponentsRecord, +SlotTargetComponentsRecord +]; + +// @public (undocumented) +export const SlotSource: NamedExoticComponent; + +// @public (undocumented) +export type SlotSourceComponent = ComponentType & { + slot: T; +}; + +// @public (undocumented) +export type SlotSourceComponentProps = Omit; + +// @public (undocumented) +export type SlotSourceComponentsRecord = Readonly<{ + readonly [P in K]: SlotSourceComponent

; +}>; + +// @public (undocumented) +export type SlotSourceProps = { + children: ReactNode; + name: string; +}; + +// @public (undocumented) +export const SlotsProvider: NamedExoticComponent< { +children: ReactNode; +}>; + +// @public (undocumented) +export const SlotTarget: NamedExoticComponent; + +// @public (undocumented) +export type SlotTargetComponent = ComponentType & { + slot: T; +}; + +// @public (undocumented) +export type SlotTargetComponentProps = Omit; + +// @public (undocumented) +export type SlotTargetComponentsRecord = Readonly<{ + readonly [P in K]: SlotTargetComponent

; +}>; + +// @public (undocumented) +export type SlotTargetProps = ComponentClassNameProps & { + as?: ElementType; + fallback?: ReactNode; + name: string; + aliases?: [string, ...string[]]; + display?: boolean | 'contents' | 'block' | 'flex' | 'grid' | 'inline' | 'inline-flex' | 'inline-grid' | 'inline-block' | 'inherit' | 'initial' | 'none' | 'unset'; +}; + +// @public +export function useHasActiveSlotsFactory>(): (...slots: ReadonlyArray) => boolean; + +// @public +export const useSlotTargetElement: (name: string) => HTMLElement | null | undefined; + +// @public +export function useSlotTargetsFactory>(SlotTargets: R): (slots: ReadonlyArray, override?: T | undefined) => NonNullable | FunctionComponentElement< { +children?: ReactNode; +}> | null; + +// @public +export const useTargetElementRegistrar: (name: string, aliases?: string[]) => (element: HTMLElement | null) => void; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/build/api/utilities.api.md b/build/api/utilities.api.md index dcf2c10b4c..68f2b9ca5a 100644 --- a/build/api/utilities.api.md +++ b/build/api/utilities.api.md @@ -154,9 +154,6 @@ export function range(start: number, end: number, step?: number): number[]; // @public (undocumented) export type SemverString = `${number}.${number}.${number}`; -// @public (undocumented) -export function setHasOneOf(set: Set, values: T[]): boolean; - // @public (undocumented) export const shouldCancelStart: (event: { target?: unknown; diff --git a/ee/admin-server/Dockerfile b/ee/admin-server/Dockerfile index f5370535b1..6ec5370033 100644 --- a/ee/admin-server/Dockerfile +++ b/ee/admin-server/Dockerfile @@ -36,6 +36,7 @@ COPY ./packages/react-form-fields-ui/package.json ././packages/react-form-fields COPY ./packages/react-i18n/package.json ././packages/react-i18n/package.json COPY ./packages/react-leaflet-fields-ui/package.json ././packages/react-leaflet-fields-ui/package.json COPY ./packages/react-multipass-rendering/package.json ././packages/react-multipass-rendering/package.json +COPY ./packages/react-slots/package.json ././packages/react-slots/package.json COPY ./packages/react-utils/package.json ././packages/react-utils/package.json COPY ./packages/ui/package.json ././packages/ui/package.json COPY ./packages/utilities/package.json ././packages/utilities/package.json diff --git a/packages/layout/package.json b/packages/layout/package.json index 71ea8250de..eda24b7649 100644 --- a/packages/layout/package.json +++ b/packages/layout/package.json @@ -47,6 +47,7 @@ }, "dependencies": { "@contember/react-i18n": "workspace:*", + "@contember/react-slots": "workspace:*", "@contember/react-utils": "workspace:*", "@contember/ui": "workspace:*", "@contember/utilities": "workspace:*", diff --git a/packages/layout/src/index.css b/packages/layout/src/index.css index 55802611c0..05b341787b 100644 --- a/packages/layout/src/index.css +++ b/packages/layout/src/index.css @@ -1,3 +1,2 @@ @import "primitives/index.css"; -@import "slots/index.css"; @import "kit/index.css"; diff --git a/packages/layout/src/kit/Slots/index.ts b/packages/layout/src/kit/Slots/index.ts index eec24e4343..1bdb884d54 100644 --- a/packages/layout/src/kit/Slots/index.ts +++ b/packages/layout/src/kit/Slots/index.ts @@ -1,4 +1,4 @@ -import { createSlotComponents } from '../../slots' +import { createSlotComponents } from '@contember/react-slots' export const [commonSlots, CommonSlotSources, CommonSlotTargets] = createSlotComponents([ 'Actions', diff --git a/packages/layout/src/slots/Source.tsx b/packages/layout/src/slots/Source.tsx deleted file mode 100644 index 5ad6f356f4..0000000000 --- a/packages/layout/src/slots/Source.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { useId } from '@contember/react-utils' -import { memo, useLayoutEffect, useRef } from 'react' -import { createPortal } from 'react-dom' -import { usePortalsRegistryContext } from './contexts' -import { SlotSourceProps } from './types' - -/** - * @group Layout - */ -export const Source = memo(({ name, children }) => { - const { getTarget, registerSlotSource, unregisterSlotSource } = usePortalsRegistryContext() - - const instanceId = useId() - const instanceIdRef = useRef(instanceId) - instanceIdRef.current = instanceId - - const delayedWarning = useRef | undefined>(undefined) - - useLayoutEffect(() => { - if (registerSlotSource && unregisterSlotSource) { - const id = instanceIdRef.current - registerSlotSource(id, name) - - return () => { - unregisterSlotSource(id, name) - clearTimeout(delayedWarning.current) - } - } - }, [name, registerSlotSource, unregisterSlotSource]) - - // There is a parent Slot context to provide a targets for slots... - if (getTarget) { - const target = getTarget?.(name) - - // ...and in case we have target we render children into it... - if (target) { - if (delayedWarning.current) { - clearTimeout(delayedWarning.current) - } - - return createPortal(children, target) - } else { - // ...but if there is no target, it means that the slot is either - // not being rendered in the DOM at all or it is being temporarily - // missing because of the re-rendering of the DOM tree but we cannot - // tell which one is the case. - // - // One way to avoid this is to keep list of all slots supported - // by the layout in the userland and check if the slot is in the list. - // - // Also in development mode we can at least warn about this, but - // it can cause false positives in case of the re-rendering therefore - // we delay the warning a bit. - if (import.meta.env.DEV) { - if (delayedWarning.current) { - clearTimeout(delayedWarning.current) - } - - delayedWarning.current = setTimeout(() => { - console.warn(`Page "${window.location.href}" tried to create a portal to a Slot named "${name}" ` - + `but there seem to be no target for it in the layout. However, this might be you intention ` - + `or a temporary state in between the renders. Make sure you have added a target for it ` - + `in your layout so it can be rendered next time.`) - }, 1000) - } - - return null - } - } else { - return <>{children} - } -}) -Source.displayName = 'Layout.Slots.Source' diff --git a/packages/layout/src/slots/Target.tsx b/packages/layout/src/slots/Target.tsx deleted file mode 100644 index a76c2ac69a..0000000000 --- a/packages/layout/src/slots/Target.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { useClassName, useId } from '@contember/react-utils' -import { dataAttribute, setHasOneOf } from '@contember/utilities' -import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react' -import { useActiveSlotPortalsContext, useTargetsRegistryContext } from './contexts' -import { SlotTargetProps } from './types' - -export type OwnTargetContainerProps = { - className: string; -} - -/** - * @group Layout - */ -export const Target = memo( - ({ - as, - aliases, - componentClassName = 'slot', - className: classNameProp, - display, - fallback, - name, - ...rest - }) => { - const [element, setElement] = useState(null) - const id = useId() - const { unregisterSlotTarget, registerSlotTarget } = useTargetsRegistryContext() - const activeSlotPortals = useActiveSlotPortalsContext() - - useLayoutEffect(() => { - if (element) { - registerSlotTarget(id, name, element) - - return () => { - unregisterSlotTarget(id, name) - } - } - }, [element, id, name, registerSlotTarget, unregisterSlotTarget]) - - const registeredAliasesRef = useRef>(new Set()) - - useLayoutEffect(() => { - if (element && aliases) { - const aliasesSet = new Set(aliases) - - aliasesSet.forEach(name => { - if (!registeredAliasesRef.current.has(name)) { - registerSlotTarget(id, name, element) - registeredAliasesRef.current.add(name) - } - }) - - registeredAliasesRef.current.forEach(name => { - if (!aliasesSet.has(name)) { - unregisterSlotTarget(id, name) - registeredAliasesRef.current.delete(name) - } - }) - } - }, [aliases, element, id, registerSlotTarget, unregisterSlotTarget]) - - useEffect(() => { - const registeredAliases = registeredAliasesRef.current - - return () => { - registeredAliases.forEach(name => { - unregisterSlotTarget(id, name) - registeredAliases.delete(name) - }) - } - }, [id, unregisterSlotTarget]) - - const Container = as ?? 'div' - const className = useClassName(componentClassName, classNameProp) - - const active = setHasOneOf(activeSlotPortals, [name, ...aliases ?? []]) - - return ((active || fallback) - ? ( - - ) - : null - ) - }, -) -Target.displayName = 'Layout.Slots.Target' diff --git a/packages/layout/src/slots/contexts.ts b/packages/layout/src/slots/contexts.ts deleted file mode 100644 index b19272af3d..0000000000 --- a/packages/layout/src/slots/contexts.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { createNonNullableContextFactory, noop } from '@contember/react-utils' -import { Fragment, createElement, useCallback } from 'react' -import { createSlotTargetComponent } from './createSlotTargetComponent' -import { SlotTargetComponentsRecord } from './types' - -export type SlotsRefMap = Map -export type RegisterSlotTarget = (id: string, name: string, ref: HTMLElement) => void; -export type UnregisterSlotTarget = (id: string, name: string) => void; - -export type ActiveSlotPortalsContextType = Set; -export const [ActiveSlotPortalsContext, useActiveSlotPortalsContext] = createNonNullableContextFactory('ActiveSlotPortalsContext', new Set()) - -export function useHasActiveSlotsFactory>(SlotTargets: T) { - const activeSlotPortals = useActiveSlotPortalsContext() - - return useCallback((...slots: ReadonlyArray) => { - return slots.some(slot => activeSlotPortals.has(slot)) - }, [activeSlotPortals]) -} - -/** - * Creates a function that returns a list of slot targets if any of them are active. - * @param SlotTargets - List of slot targets to create - */ -export function useSlotTargetsFactory>(SlotTargets: R) { - const activeSlotPortals = useActiveSlotPortalsContext() - - return useCallback(function createSlotTargets(slots: ReadonlyArray, override?: T) { - if (slots.some(slot => activeSlotPortals.has(slot))) { - if (override) { - return override - } else { - return createElement(Fragment, {}, ...slots.map(slot => { - if (slot in SlotTargets) { - const Target = SlotTargets[slot] as ReturnType> - - return createElement(Target, { - key: `multi-element:${slot}`, - }) - } else { - throw new Error(`Slot target "${slot}" was not found within the targets passed to factory.`) - } - })) - } - } else { - return null - } - }, [SlotTargets, activeSlotPortals]) -} - -export type SlotTargetsRegistryContextType = { - registerSlotTarget: RegisterSlotTarget; - unregisterSlotTarget: UnregisterSlotTarget; -} -export const [TargetsRegistryContext, useTargetsRegistryContext] = createNonNullableContextFactory('Layout.Slots.TargetsRegistryContext', { - registerSlotTarget: noop, - unregisterSlotTarget: noop, -}) - -export type RenderToSlotPortalContextType = { - getTarget: undefined | ((slot: string) => HTMLElement | null | undefined); - registerSlotSource: undefined | ((id: string, slot: string) => void); - unregisterSlotSource: undefined | ((id: string, slot: string) => void); -} - -export const [PortalsRegistryContext, usePortalsRegistryContext] = createNonNullableContextFactory('Layout.Slots.PortalsRegistryContext', { - getTarget: undefined, - registerSlotSource: undefined, - unregisterSlotSource: undefined, -}) diff --git a/packages/layout/src/slots/createSlotSourceComponent.tsx b/packages/layout/src/slots/createSlotSourceComponent.tsx deleted file mode 100644 index 67d57cd119..0000000000 --- a/packages/layout/src/slots/createSlotSourceComponent.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { Source } from './Source' -import { SlotSourceProps } from './types' - -export function createSlotSourceComponent(slot: T) { - const Component = ({ name, children }: Omit & { name?: string }) => ( - {children} - ) - - Component.displayName = `Layout.Source(${slot})` - Component.slot = slot as T - - return Component -} diff --git a/packages/layout/src/slots/createSlotTargetComponent.tsx b/packages/layout/src/slots/createSlotTargetComponent.tsx deleted file mode 100644 index 852c0def4e..0000000000 --- a/packages/layout/src/slots/createSlotTargetComponent.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { Target } from './Target' -import { SlotTargetProps } from './types' - -export function createSlotTargetComponent(name: T) { - const Component = ({ className, ...props }: Omit & { name?: string }) => { - return ( - - ) - } - - Component.displayName = `Layout.Slots.Target(${name})` - Component.slot = name as T - - return Component -} diff --git a/packages/layout/src/slots/index.css b/packages/layout/src/slots/index.css deleted file mode 100644 index 1fcd5fcd99..0000000000 --- a/packages/layout/src/slots/index.css +++ /dev/null @@ -1,13 +0,0 @@ -:where(.cui-slot[data-display]) { display: contents } -:where(.cui-slot[data-display="contents"]) { display: contents } -:where(.cui-slot[data-display="block"]) { display: block } -:where(.cui-slot[data-display="flex"]) { display: flex } -:where(.cui-slot[data-display="grid"]) { display: grid } -:where(.cui-slot[data-display="inline"]) { display: inline } -:where(.cui-slot[data-display="inline-block"]) { display: inline-block } -:where(.cui-slot[data-display="inline-grid"]) { display: inline-grid } -:where(.cui-slot[data-display="inline-flex"]) { display: inline-flex } -:where(.cui-slot[data-display="inherit"]) { display: inherit } -:where(.cui-slot[data-display="initial"]) { display: initial } -:where(.cui-slot[data-display="none"]) { display: none } -:where(.cui-slot[data-display="unset"]) { display: unset } diff --git a/packages/layout/src/slots/index.ts b/packages/layout/src/slots/index.ts index 558f061f26..4d43feedd8 100644 --- a/packages/layout/src/slots/index.ts +++ b/packages/layout/src/slots/index.ts @@ -1,8 +1,32 @@ -export * from './Provider' -export * from './Source' -export * from './Target' -export * from './contexts' -export * from './createSlotComponents' -export * from './createSlotSourceComponent' -export * from './createSlotTargetComponent' -export * from './types' +export { + SlotSource as Source, + SlotTarget as Target, + SlotsProvider as Provider, +} from '@contember/react-slots' + +export { + createSlotComponents, + createSlotSourceComponent, + createSlotTargetComponent, + SlotTarget, + SlotSource, + SlotsProvider, + useHasActiveSlotsFactory, + useSlotTargetElement, + useSlotTargetsFactory, + useTargetElementRegistrar, +} from '@contember/react-slots' + + +export type { + OwnTargetContainerProps, + SlotComponents, + SlotSourceComponent, + SlotSourceComponentProps, + SlotSourceComponentsRecord, + SlotSourceProps, + SlotTargetComponent, + SlotTargetComponentProps, + SlotTargetComponentsRecord, + SlotTargetProps, +} from '@contember/react-slots' diff --git a/packages/layout/src/slots/types.ts b/packages/layout/src/slots/types.ts deleted file mode 100644 index 0b5b03dc02..0000000000 --- a/packages/layout/src/slots/types.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { ComponentClassNameProps } from '@contember/utilities' -import { ComponentType, ElementType, ReactNode } from 'react' -import { createSlotSourceComponent } from './createSlotSourceComponent' -import { createSlotTargetComponent } from './createSlotTargetComponent' - -/** @deprecated No alternative since 1.4.0 */ -export type SlotComponentsRecords = Readonly<{ - readonly [P in K]: ComponentType -}> - -export type SlotSourceComponentsRecord = Readonly<{ - readonly [P in K]: ReturnType> -}> - -export type SlotTargetComponentsRecord = Readonly<{ - readonly [P in K]: ReturnType> -}> - -export type SlotSourceProps = { - children: ReactNode; - name: string; -} - -export type SlotTargetProps = ComponentClassNameProps & { - /** - * Type of the container element, default is `div`. - * - * In case you provide custom Element, mak sure to pass component wrapped in forwardRef - * otherwise the ref will not be passed to the container element and the slot will not work. - * - * @example - * ``` - *

)} /> - * ``` - */ - as?: ElementType; - /** - * Fallback of the target that is rendered when no source slot renders its content. - * Use `[data-fallback]` attribute to style the fallback. - */ - fallback?: ReactNode; - /** - * Name of the slot, similar to the `name` prop of the `Source` component. - */ - name: string; - /** - * Optional list of aliases for the slot. - * - * This is useful when one target element is sufficient for multiple slots sources. - * E.g. when you know that `Sidebar` and `SidebarBody` slot sources result in the same target. - */ - aliases?: [string, ...string[]]; - /** - * Controls the display of the target element, default is 'contents'. - */ - display?: boolean | 'contents' | 'block' | 'flex' | 'grid' | 'inline' | 'inline-flex' | 'inline-grid' | 'inline-block' | 'inherit' | 'initial' | 'none' | 'unset'; -} diff --git a/packages/layout/src/tsconfig.json b/packages/layout/src/tsconfig.json index 32722117ff..93148e0cac 100644 --- a/packages/layout/src/tsconfig.json +++ b/packages/layout/src/tsconfig.json @@ -5,6 +5,7 @@ }, "references": [ { "path": "../../react-i18n/src" }, + { "path": "../../react-slots/src" }, { "path": "../../react-utils/src" }, { "path": "../../ui/src" }, { "path": "../../utilities/src" }, diff --git a/packages/react-slots/api-extractor.json b/packages/react-slots/api-extractor.json new file mode 100644 index 0000000000..66c17dd719 --- /dev/null +++ b/packages/react-slots/api-extractor.json @@ -0,0 +1,3 @@ +{ + "extends": "../../build/api-extractor.json" +} diff --git a/packages/react-slots/package.json b/packages/react-slots/package.json new file mode 100644 index 0000000000..8beb72cb19 --- /dev/null +++ b/packages/react-slots/package.json @@ -0,0 +1,55 @@ +{ + "name": "@contember/react-slots", + "license": "Apache-2.0", + "version": "0.0.0", + "private": true, + "type": "module", + "sideEffects": false, + "main": "./dist/production/index.js", + "exports": { + ".": { + "import": { + "types": "./dist/types/index.d.ts", + "development": "./dist/development/index.js", + "production": "./dist/production/index.js", + "default": "./dist/production/index.js" + }, + "require": { + "types": "./dist/types/index.d.ts", + "development": "./dist/development/index.cjs", + "production": "./dist/production/index.cjs", + "default": "./dist/production/index.cjs" + } + } + }, + "files": [ + "dist/", + "src/" + ], + "typings": "./dist/types/index.d.ts", + "scripts": { + "build": "yarn build:js:dev && yarn build:js:prod", + "build:js:dev": "vite build --mode development", + "build:js:prod": "vite build --mode production", + "ae:build": "api-extractor run --local", + "ae:test": "api-extractor run", + "test": "vitest" + }, + "dependencies": { + "@contember/react-utils": "workspace:*", + "@contember/utilities": "workspace:*" + }, + "peerDependencies": { + "react": "^17 || ^18", + "react-dom": "^17 || ^18" + }, + "devDependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/contember/interface.git", + "directory": "packages/react-slots" + } +} diff --git a/packages/react-slots/src/components/SlotSource.tsx b/packages/react-slots/src/components/SlotSource.tsx new file mode 100644 index 0000000000..25f01c30bb --- /dev/null +++ b/packages/react-slots/src/components/SlotSource.tsx @@ -0,0 +1,22 @@ +import { memo, ReactNode } from 'react' +import { createPortal } from 'react-dom' +import { useSlotTargetElement } from '../hooks' + +export type SlotSourceProps = { + children: ReactNode; + name: string; +} + +/** + * @group Layout + */ +export const SlotSource = memo(({ name, children }) => { + const el = useSlotTargetElement(name) + if (el) { + return createPortal(children, el) + } else if (el === undefined) { + return null + } + return <>{children} +}) +SlotSource.displayName = 'Layout.Slots.Source' diff --git a/packages/react-slots/src/components/SlotTarget.tsx b/packages/react-slots/src/components/SlotTarget.tsx new file mode 100644 index 0000000000..b8513d9de6 --- /dev/null +++ b/packages/react-slots/src/components/SlotTarget.tsx @@ -0,0 +1,88 @@ +import { useClassName } from '@contember/react-utils' +import { ComponentClassNameProps, dataAttribute } from '@contember/utilities' +import { ElementType, memo, ReactNode, useMemo } from 'react' +import { useTargetElementRegistrar } from '../hooks' +import { useHasActiveSlotsFactory } from '../hooks' + +export type OwnTargetContainerProps = { + className: string; +} + +export type SlotTargetProps = + & ComponentClassNameProps + & { + /** + * Type of the container element, default is `div`. + * + * In case you provide custom Element, mak sure to pass component wrapped in forwardRef + * otherwise the ref will not be passed to the container element and the slot will not work. + * + * @example + * ``` + *

)} /> + * ``` + */ + as?: ElementType; + /** + * Fallback of the target that is rendered when no source slot renders its content. + * Use `[data-fallback]` attribute to style the fallback. + */ + fallback?: ReactNode; + /** + * Name of the slot, similar to the `name` prop of the `Source` component. + */ + name: string; + /** + * Optional list of aliases for the slot. + * + * This is useful when one target element is sufficient for multiple slots sources. + * E.g. when you know that `Sidebar` and `SidebarBody` slot sources result in the same target. + */ + aliases?: [string, ...string[]]; + /** + * Controls the display of the target element, default is 'contents'. + */ + display?: boolean | 'contents' | 'block' | 'flex' | 'grid' | 'inline' | 'inline-flex' | 'inline-grid' | 'inline-block' | 'inherit' | 'initial' | 'none' | 'unset'; + } + +/** + * @group Layout + */ +export const SlotTarget = memo(({ + as, + aliases, + componentClassName = 'slot', + className: classNameProp, + display, + fallback, + name, + ...rest +}) => { + const registerElement = useTargetElementRegistrar(name, aliases) + + const hasActiveSlots = useHasActiveSlotsFactory() + const active = useMemo(() => { + return hasActiveSlots(name, ...aliases ?? []) + }, [aliases, hasActiveSlots, name]) + + const Container = as ?? 'div' + const className = useClassName(componentClassName, classNameProp) + const style = useMemo(() => ({ display: display ?? (as === undefined ? 'contents' : undefined) }), [as, display]) + + return ((active || fallback) + ? ( + + ) + : null + ) +}) +SlotTarget.displayName = 'Layout.Slots.Target' diff --git a/packages/layout/src/slots/Provider.tsx b/packages/react-slots/src/components/SlotsProvider.tsx similarity index 68% rename from packages/layout/src/slots/Provider.tsx rename to packages/react-slots/src/components/SlotsProvider.tsx index ab09c535a5..87fb56a3fd 100644 --- a/packages/layout/src/slots/Provider.tsx +++ b/packages/react-slots/src/components/SlotsProvider.tsx @@ -1,28 +1,40 @@ -import { ReactNode, memo, useCallback, useMemo, useRef, useState } from 'react' -import { ActiveSlotPortalsContext, PortalsRegistryContext, SlotTargetsRegistryContextType, SlotsRefMap, TargetsRegistryContext } from './contexts' +import { memo, ReactNode, useCallback, useMemo, useRef, useState } from 'react' +import { + ActiveSlotPortalsContext, + PortalsRegistryContext, + RenderToSlotPortalContextType, + SlotTargetsRegistryContextType, + TargetsRegistryContext, +} from '../internal/contexts' /** * @group Layout */ -export const Provider = memo<{ children: ReactNode }>(({ children }) => { - const [slotsRefMap, setSlotsRefMap] = useState(new Map) +export const SlotsProvider = memo<{ children: ReactNode }>(({ children }) => { + const [slotsRefMap, setSlotsRefMap] = useState>(new Map) const [activeSlotsMap, setActiveSlotsMap] = useState>(new Map) const activeSlotPortals: Set = useMemo(() => new Set(activeSlotsMap.values()), [activeSlotsMap]) const activeSlotsMapRef = useRef(activeSlotsMap); activeSlotsMapRef.current = activeSlotsMap // TODO: refactor to use ID - const registerSlotTarget = useCallback((id: string, name: string, ref: HTMLElement) => { - setSlotsRefMap(previous => new Map([...previous, [name, ref]])) - }, []) - const unregisterSlotTarget = useCallback((id: string, name: string) => { setSlotsRefMap(previous => { - previous.delete(name) - return new Map([...previous]) + const newVal = new Map([...previous]) + newVal.delete(name) + return newVal }) }, []) + const registerSlotTarget = useCallback((id: string, name: string, ref: HTMLElement) => { + setSlotsRefMap(previous => new Map([...previous, [name, ref]])) + + return () => { + unregisterSlotTarget(id, name) + } + }, [unregisterSlotTarget]) + + const slotTargetsRegistry: SlotTargetsRegistryContextType = useMemo(() => { return { activeSlotPortals, @@ -35,22 +47,27 @@ export const Provider = memo<{ children: ReactNode }>(({ children }) => { return slotsRefMap.get(name) }, [slotsRefMap]) + const unregisterSlotSource = useCallback((id: string, name: string) => { + setActiveSlotsMap(previous => { + const newVal = new Map([...previous]) + newVal.delete(id) + return newVal + }) + }, []) + const registerSlotSource = useCallback((id: string, name: string) => { if (activeSlotsMapRef.current.has(id)) { throw new Error(`Cannot register slot portal for '${name}' because it is already registered. You have likely forgotten to unregister it.`) } setActiveSlotsMap(previous => new Map([...previous, [id, name]])) - }, []) - const unregisterSlotSource = useCallback((id: string, name: string) => { - setActiveSlotsMap(previous => { - previous.delete(id) - return new Map([...previous]) - }) - }, []) + return () => { + unregisterSlotSource(id, name) + } + }, [unregisterSlotSource]) - const renderToSlotPortal = useMemo(() => ({ + const renderToSlotPortal: RenderToSlotPortalContextType = useMemo(() => ({ getTarget, registerSlotSource, unregisterSlotSource, @@ -66,4 +83,4 @@ export const Provider = memo<{ children: ReactNode }>(({ children }) => { ) }) -Provider.displayName = 'Layout.Slots.Provider' +SlotsProvider.displayName = 'Layout.Slots.Provider' diff --git a/packages/layout/src/slots/createSlotComponents.ts b/packages/react-slots/src/factories/createSlotComponents.ts similarity index 50% rename from packages/layout/src/slots/createSlotComponents.ts rename to packages/react-slots/src/factories/createSlotComponents.ts index d7496c2444..867124c620 100644 --- a/packages/layout/src/slots/createSlotComponents.ts +++ b/packages/react-slots/src/factories/createSlotComponents.ts @@ -1,10 +1,23 @@ -import { createSlotSourceComponent } from './createSlotSourceComponent' -import { createSlotTargetComponent } from './createSlotTargetComponent' -import type { SlotSourceComponentsRecord, SlotTargetComponentsRecord } from './types' +import { createSlotSourceComponent, SlotSourceComponent } from './createSlotSourceComponent' +import { createSlotTargetComponent, SlotTargetComponent } from './createSlotTargetComponent' const pascalCaseRegex = /^[A-Z][a-zA-Z0-9]*$/ -export function createSlotComponents(slots: readonly K[]) { +export type SlotSourceComponentsRecord = Readonly<{ + readonly [P in K]: SlotSourceComponent

+}> + +export type SlotTargetComponentsRecord = Readonly<{ + readonly [P in K]: SlotTargetComponent

+}> + +export type SlotComponents = readonly [ + readonly K[], + SlotSourceComponentsRecord, + SlotTargetComponentsRecord, +] + +export function createSlotComponents(slots: readonly K[]): SlotComponents { slots.forEach(slot => { if (!pascalCaseRegex.test(slot)) { diff --git a/packages/react-slots/src/factories/createSlotSourceComponent.tsx b/packages/react-slots/src/factories/createSlotSourceComponent.tsx new file mode 100644 index 0000000000..c6a57d29ab --- /dev/null +++ b/packages/react-slots/src/factories/createSlotSourceComponent.tsx @@ -0,0 +1,20 @@ +import { SlotSourceProps, SlotSource } from '../components/SlotSource' +import { ComponentType } from 'react' + +export type SlotSourceComponentProps = Omit +export type SlotSourceComponent = + & ComponentType + & { + slot: T + } + +export function createSlotSourceComponent(slot: T): SlotSourceComponent { + const Component = ({ children }: SlotSourceComponentProps) => ( + {children} + ) + + Component.displayName = `Layout.Source(${slot})` + Component.slot = slot as T + + return Component +} diff --git a/packages/react-slots/src/factories/createSlotTargetComponent.tsx b/packages/react-slots/src/factories/createSlotTargetComponent.tsx new file mode 100644 index 0000000000..e3398a1275 --- /dev/null +++ b/packages/react-slots/src/factories/createSlotTargetComponent.tsx @@ -0,0 +1,20 @@ +import { SlotTargetProps, SlotTarget } from '../components/SlotTarget' +import { ComponentType } from 'react' + +export type SlotTargetComponentProps = Omit +export type SlotTargetComponent = + & ComponentType + & { + slot: T + } + +export function createSlotTargetComponent(name: T): SlotTargetComponent { + const Component = ({ className, ...props }: SlotTargetComponentProps) => ( + + ) + + Component.displayName = `Layout.Slots.Target(${name})` + Component.slot = name as T + + return Component +} diff --git a/packages/react-slots/src/factories/index.ts b/packages/react-slots/src/factories/index.ts new file mode 100644 index 0000000000..3097e708ae --- /dev/null +++ b/packages/react-slots/src/factories/index.ts @@ -0,0 +1,3 @@ +export * from './createSlotComponents' +export * from './createSlotTargetComponent' +export * from './createSlotSourceComponent' diff --git a/packages/react-slots/src/hooks/index.ts b/packages/react-slots/src/hooks/index.ts new file mode 100644 index 0000000000..6e41400b51 --- /dev/null +++ b/packages/react-slots/src/hooks/index.ts @@ -0,0 +1,4 @@ +export * from './useHasActiveSlotsFactory' +export * from './useSlotTargetsFactory' +export * from './useSlotTargetElement' +export * from './useTargetElementRegistrar' diff --git a/packages/react-slots/src/hooks/useHasActiveSlotsFactory.ts b/packages/react-slots/src/hooks/useHasActiveSlotsFactory.ts new file mode 100644 index 0000000000..4f18afb689 --- /dev/null +++ b/packages/react-slots/src/hooks/useHasActiveSlotsFactory.ts @@ -0,0 +1,14 @@ +import { useCallback } from 'react' +import { useActiveSlotPortalsContext } from '../internal/contexts' +import { SlotTargetComponentsRecord } from '../factories' + +/** + * Creates a function which returns true if any of the slots passed to it are active. + */ +export function useHasActiveSlotsFactory>() { + const activeSlotPortals = useActiveSlotPortalsContext() + + return useCallback((...slots: ReadonlyArray) => { + return slots.some(slot => activeSlotPortals.has(slot)) + }, [activeSlotPortals]) +} diff --git a/packages/react-slots/src/hooks/useSlotTargetElement.tsx b/packages/react-slots/src/hooks/useSlotTargetElement.tsx new file mode 100644 index 0000000000..b2465d8919 --- /dev/null +++ b/packages/react-slots/src/hooks/useSlotTargetElement.tsx @@ -0,0 +1,55 @@ +import { usePortalsRegistryContext } from '../internal/contexts' +import { useEffect, useLayoutEffect, useMemo } from 'react' +import { useId } from '@contember/react-utils' + +const useSlotTargetElementProd = (name: string): HTMLElement | null | undefined => { + const { getTarget, registerSlotSource } = usePortalsRegistryContext() + const instanceId = useId() + + useLayoutEffect(() => { + return registerSlotSource?.(instanceId, name) + }, [instanceId, name, registerSlotSource]) + + return useMemo(() => { + if (!getTarget) { + return null + } + return getTarget(name) + }, [getTarget, name]) +} +const useSlotTargetElementDev = (name: string): HTMLElement | null | undefined => { + const target = useSlotTargetElementProd(name) + useEffect(() => { + // if there is no target, it means that the slot is either + // not being rendered in the DOM at all or it is being temporarily + // missing because of the re-rendering of the DOM tree but we cannot + // tell which one is the case. + // + // One way to avoid this is to keep list of all slots supported + // by the layout in the userland and check if the slot is in the list. + // + // Also in development mode we can at least warn about this, but + // it can cause false positives in case of the re-rendering therefore + // we delay the warning a bit. + if (target === undefined) { + const handle = setTimeout(() => { + console.warn(`Page "${window.location.href}" tried to create a portal to a Slot named "${name}" ` + + `but there seem to be no target for it in the layout. However, this might be you intention ` + + `or a temporary state in between the renders. Make sure you have added a target for it ` + + `in your layout so it can be rendered next time.`) + }, 1000) + return () => { + clearTimeout(handle) + } + } + }, [name, target]) + + return target +} + +/** + * Returns the target element for the given slot name. + * If there is no slots context, it returns `null`. + * If the target is not present in the layout, it returns `undefined`. + */ +export const useSlotTargetElement = import.meta.env.DEV ? useSlotTargetElementDev : useSlotTargetElementProd diff --git a/packages/react-slots/src/hooks/useSlotTargetsFactory.ts b/packages/react-slots/src/hooks/useSlotTargetsFactory.ts new file mode 100644 index 0000000000..290c6bc5e7 --- /dev/null +++ b/packages/react-slots/src/hooks/useSlotTargetsFactory.ts @@ -0,0 +1,34 @@ +import { useHasActiveSlotsFactory } from './useHasActiveSlotsFactory' +import { createElement, Fragment, useCallback } from 'react' +import { createSlotTargetComponent } from '../factories' +import { SlotTargetComponentsRecord } from '../factories' + +/** + * Creates a function that returns a list of slot targets if any of them are active. + * @param SlotTargets - List of slot targets to create + */ +export function useSlotTargetsFactory>(SlotTargets: R) { + const hasActiveSlot = useHasActiveSlotsFactory() + + return useCallback(function createSlotTargets(slots: ReadonlyArray, override?: T) { + if (hasActiveSlot(...slots)) { + if (override) { + return override + } else { + return createElement(Fragment, {}, ...slots.map(slot => { + if (slot in SlotTargets) { + const Target = SlotTargets[slot] as ReturnType> + + return createElement(Target, { + key: `multi-element:${slot}`, + }) + } else { + throw new Error(`Slot target "${slot}" was not found within the targets passed to factory.`) + } + })) + } + } else { + return null + } + }, [SlotTargets, hasActiveSlot]) +} diff --git a/packages/react-slots/src/hooks/useTargetElementRegistrar.tsx b/packages/react-slots/src/hooks/useTargetElementRegistrar.tsx new file mode 100644 index 0000000000..cdacbabad5 --- /dev/null +++ b/packages/react-slots/src/hooks/useTargetElementRegistrar.tsx @@ -0,0 +1,53 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { useTargetsRegistryContext } from '../internal/contexts' +import { useId } from '@contember/react-utils' + +/** + * Returns a function that registers the given element as a target for the given slot name. + * You should use it in the `ref` prop of the element you want to register. + */ +export const useTargetElementRegistrar = (name: string, aliases?: string[]): ((element: HTMLElement | null) => void) => { + const id = useId() + const [element, setElement] = useState(null) + const { unregisterSlotTarget, registerSlotTarget } = useTargetsRegistryContext() + + useLayoutEffect(() => { + if (!element) { + return + } + return registerSlotTarget(id, name, element) + }, [element, id, name, registerSlotTarget]) + + const registeredAliasesRef = useRef>(new Set()) + + useLayoutEffect(() => { + if (!element || !aliases?.length) { + return + } + + aliases.forEach(name => { + if (!registeredAliasesRef.current.has(name)) { + registerSlotTarget(id, name, element) + registeredAliasesRef.current.add(name) + } + }) + + registeredAliasesRef.current.forEach(name => { + if (!aliases.includes(name)) { + unregisterSlotTarget(id, name) + registeredAliasesRef.current.delete(name) + } + }) + }, [aliases, element, id, registerSlotTarget, unregisterSlotTarget]) + + useEffect(() => { + return () => { + registeredAliasesRef.current.forEach(name => { + unregisterSlotTarget(id, name) + }) + registeredAliasesRef.current = new Set() + } + }, [id, unregisterSlotTarget]) + + return setElement +} diff --git a/packages/react-slots/src/index.ts b/packages/react-slots/src/index.ts new file mode 100644 index 0000000000..b702c4fdb3 --- /dev/null +++ b/packages/react-slots/src/index.ts @@ -0,0 +1,5 @@ +export * from './components/SlotsProvider' +export * from './components/SlotSource' +export * from './components/SlotTarget' +export * from './hooks' +export * from './factories' diff --git a/packages/react-slots/src/internal/contexts.ts b/packages/react-slots/src/internal/contexts.ts new file mode 100644 index 0000000000..569d4dd6e8 --- /dev/null +++ b/packages/react-slots/src/internal/contexts.ts @@ -0,0 +1,29 @@ +import { createNonNullableContextFactory, noop } from '@contember/react-utils' + + +export type RegisterSlotTarget = (id: string, name: string, ref: HTMLElement) => () => void +export type UnregisterSlotTarget = (id: string, name: string) => void + +export type ActiveSlotPortalsContextType = Set +export const [ActiveSlotPortalsContext, useActiveSlotPortalsContext] = createNonNullableContextFactory('ActiveSlotPortalsContext', new Set()) + +export type SlotTargetsRegistryContextType = { + registerSlotTarget: RegisterSlotTarget; + unregisterSlotTarget: UnregisterSlotTarget; +} +export const [TargetsRegistryContext, useTargetsRegistryContext] = createNonNullableContextFactory('Layout.Slots.TargetsRegistryContext', { + registerSlotTarget: () => () => undefined, + unregisterSlotTarget: noop, +}) + +export type RenderToSlotPortalContextType = { + getTarget: undefined | ((slot: string) => HTMLElement | undefined) + registerSlotSource: undefined | ((id: string, slot: string) => () => void) + unregisterSlotSource: undefined | ((id: string, slot: string) => void) +} + +export const [PortalsRegistryContext, usePortalsRegistryContext] = createNonNullableContextFactory('Layout.Slots.PortalsRegistryContext', { + getTarget: undefined, + registerSlotSource: undefined, + unregisterSlotSource: undefined, +}) diff --git a/packages/react-slots/src/tsconfig.json b/packages/react-slots/src/tsconfig.json new file mode 100644 index 0000000000..14047be6ce --- /dev/null +++ b/packages/react-slots/src/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.settings.json", + "compilerOptions": { + "outDir": "../dist/types" + }, + "references": [ + { "path": "../../react-utils/src" }, + { "path": "../../utilities/src" }, + ] +} diff --git a/packages/react-slots/tests/example.test.ts b/packages/react-slots/tests/example.test.ts new file mode 100644 index 0000000000..cb18e60e7e --- /dev/null +++ b/packages/react-slots/tests/example.test.ts @@ -0,0 +1,5 @@ +import { describe, test } from 'vitest' + +describe('@contember/react-slots', function () { + test('@contember/react-slots', function () { }) +}) diff --git a/packages/react-slots/tests/tsconfig.json b/packages/react-slots/tests/tsconfig.json new file mode 100644 index 0000000000..9f0c88bc7c --- /dev/null +++ b/packages/react-slots/tests/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.settings.json", + "compilerOptions": { + "noEmit": true, + "emitDeclarationOnly": false + }, + "references": [ + { "path": "../src" }, + ], +} diff --git a/packages/react-slots/tsconfig.json b/packages/react-slots/tsconfig.json new file mode 100644 index 0000000000..915c57c02d --- /dev/null +++ b/packages/react-slots/tsconfig.json @@ -0,0 +1,8 @@ +{ + "files": [], + "include": [], + "references": [ + { "path": "./src" }, + { "path": "./tests" }, + ], +} diff --git a/packages/react-slots/tsdoc.json b/packages/react-slots/tsdoc.json new file mode 100644 index 0000000000..a46f62a20a --- /dev/null +++ b/packages/react-slots/tsdoc.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", + "extends": [ + "../../tsdoc.json" + ] +} diff --git a/packages/react-slots/vite.config.js b/packages/react-slots/vite.config.js new file mode 100644 index 0000000000..a9bd4c5d4d --- /dev/null +++ b/packages/react-slots/vite.config.js @@ -0,0 +1,5 @@ +import { createViteConfig } from '../../build/createViteConfig.js' + +const currentDirName = new URL('.', import.meta.url).pathname.split('/').filter(Boolean).pop() + +export default createViteConfig(currentDirName) diff --git a/packages/utilities/src/functional/index.ts b/packages/utilities/src/functional/index.ts index 7bd55fb38a..b0aacef97d 100644 --- a/packages/utilities/src/functional/index.ts +++ b/packages/utilities/src/functional/index.ts @@ -1,4 +1,3 @@ export * from './omit' export * from './pick' export * from './range' -export * from './setHasOneOf' diff --git a/packages/utilities/src/functional/setHasOneOf.ts b/packages/utilities/src/functional/setHasOneOf.ts deleted file mode 100644 index 325085fde8..0000000000 --- a/packages/utilities/src/functional/setHasOneOf.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function setHasOneOf(set: Set, values: T[]): boolean { - return values.some(value => set.has(value)) -} diff --git a/packages/utilities/tests/functional/setHasOneOf.test.ts b/packages/utilities/tests/functional/setHasOneOf.test.ts deleted file mode 100644 index e77d5b0a3c..0000000000 --- a/packages/utilities/tests/functional/setHasOneOf.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, expect, test } from 'vitest' -import { setHasOneOf } from '../../src' - -describe('@contember/utilities', function () { - test('@contember/utilities.setHasOneOf', function () { - let set = new Set([1, 2, 3]) - - expect(setHasOneOf(set, [1, 2])).equals(true) - expect(setHasOneOf(set, [2, 3])).equals(true) - expect(setHasOneOf(set, [3, 4])).equals(true) - expect(setHasOneOf(set, [4, 5])).equals(false) - }) -}) diff --git a/scripts/dev/create-package.sh b/scripts/dev/create-package.sh index 2dedcc35c9..d1de875e23 100755 --- a/scripts/dev/create-package.sh +++ b/scripts/dev/create-package.sh @@ -7,7 +7,7 @@ PACKAGE_NAME=$1 cp -r packages/.template packages/$PACKAGE_NAME # replace @contember/.template with @contember/$PACKAGE_NAME in package.sjon and tests/example.test.ts -sed -i "s/@.template/$PACKAGE_NAME/g" packages/$PACKAGE_NAME/package.json +sed -i "s/.template/$PACKAGE_NAME/g" packages/$PACKAGE_NAME/package.json sed -i "s/.template/$PACKAGE_NAME/g" packages/$PACKAGE_NAME/tests/example.test.ts yarn diff --git a/scripts/lint/module-import-linter.ts b/scripts/lint/module-import-linter.ts index b142413bcf..dbf43c040d 100644 --- a/scripts/lint/module-import-linter.ts +++ b/scripts/lint/module-import-linter.ts @@ -1,5 +1,6 @@ import glob from 'fast-glob' import * as fs from 'fs/promises' +import { existsSync } from 'fs' import JSON5 from 'json5' import { join, normalize } from 'path' import ts from 'typescript' @@ -117,6 +118,7 @@ interface Project { (async () => { const dirs = (await glob(process.cwd() + '/{ee,packages}/*', { onlyDirectories: true })) .filter(dir => !dir.endsWith('packages/admin-sandbox')) + .filter(it => existsSync(`${it}/package.json`)) const projects = await Promise.all(dirs.map(async (dir): Promise => { try { diff --git a/tsconfig.json b/tsconfig.json index 02dfe62120..bec8ed05a6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -86,6 +86,9 @@ { "path": "./packages/react-multipass-rendering" }, + { + "path": "./packages/react-slots" + }, { "path": "./packages/react-utils" }, diff --git a/yarn.lock b/yarn.lock index 6410221c56..23ade3f971 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1213,6 +1213,7 @@ __metadata: resolution: "@contember/layout@workspace:packages/layout" dependencies: "@contember/react-i18n": "workspace:*" + "@contember/react-slots": "workspace:*" "@contember/react-utils": "workspace:*" "@contember/ui": "workspace:*" "@contember/utilities": "workspace:*" @@ -1491,6 +1492,20 @@ __metadata: languageName: unknown linkType: soft +"@contember/react-slots@workspace:*, @contember/react-slots@workspace:packages/react-slots": + version: 0.0.0-use.local + resolution: "@contember/react-slots@workspace:packages/react-slots" + dependencies: + "@contember/react-utils": "workspace:*" + "@contember/utilities": "workspace:*" + react: ^18.2.0 + react-dom: ^18.2.0 + peerDependencies: + react: ^17 || ^18 + react-dom: ^17 || ^18 + languageName: unknown + linkType: soft + "@contember/react-utils@workspace:*, @contember/react-utils@workspace:packages/react-utils": version: 0.0.0-use.local resolution: "@contember/react-utils@workspace:packages/react-utils"