Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,19 @@ interface Props {

function ControllableComponent(props: Props) {
const store = useRefWithInit(
() => new ReactStore({ open: props.defaultOpen ?? false, value: 0 }, {}, selectors),
() =>
new ReactStore(
{
open: props.defaultOpen ?? false,
openProp: props.open,
value: 0,
},
{},
selectors,
),
).current;

store.useControlledProp('open', props.open, props.defaultOpen ?? false);
store.useControlledProp('openProp', props.open);
store.useSyncedValue('value', props.value ?? 0);

const open = store.useState('open');
Expand Down Expand Up @@ -113,10 +122,11 @@ function ChildComponent(props: ChildProps) {

interface State {
open: boolean;
openProp: boolean | undefined;
value: number;
}

const selectors = {
open: (state: State) => state.open,
open: (state: State) => state.openProp ?? state.open,
value: (state: State) => state.value,
};
10 changes: 6 additions & 4 deletions packages/react/src/alert-dialog/root/AlertDialogRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,10 @@ export function AlertDialogRoot<Payload>(props: AlertDialogRoot.Props<Payload>)
return (
handle?.store ??
new DialogStore<Payload>({
open: openProp ?? defaultOpen,
activeTriggerId: triggerIdProp !== undefined ? triggerIdProp : defaultTriggerIdProp,
open: defaultOpen,
openProp,
activeTriggerId: defaultTriggerIdProp,
triggerIdProp,
modal: true,
disablePointerDismissal: true,
nested,
Expand All @@ -44,8 +46,8 @@ export function AlertDialogRoot<Payload>(props: AlertDialogRoot.Props<Payload>)
);
}).current;

store.useControlledProp('open', openProp, defaultOpen);
store.useControlledProp('activeTriggerId', triggerIdProp, defaultTriggerIdProp);
store.useControlledProp('openProp', openProp);
store.useControlledProp('triggerIdProp', triggerIdProp);
store.useSyncedValue('nested', nested);
store.useContextCallback('onOpenChange', onOpenChange);
store.useContextCallback('onOpenChangeComplete', onOpenChangeComplete);
Expand Down
11 changes: 7 additions & 4 deletions packages/react/src/dialog/root/DialogRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,17 +37,20 @@ export function DialogRoot<Payload>(props: DialogRoot.Props<Payload>) {
return (
handle?.store ??
new DialogStore<Payload>({
open: openProp ?? defaultOpen,
activeTriggerId: triggerIdProp !== undefined ? triggerIdProp : defaultTriggerIdProp,
open: defaultOpen,
openProp,
activeTriggerId: defaultTriggerIdProp,
triggerIdProp,
modal,
disablePointerDismissal,
nested,
})
);
}).current;

store.useControlledProp('open', openProp, defaultOpen);
store.useControlledProp('activeTriggerId', triggerIdProp, defaultTriggerIdProp);
store.useControlledProp('openProp', openProp);
store.useControlledProp('triggerIdProp', triggerIdProp);

store.useSyncedValues({ disablePointerDismissal, nested, modal });
store.useContextCallback('onOpenChange', onOpenChange);
store.useContextCallback('onOpenChangeComplete', onOpenChangeComplete);
Expand Down
14 changes: 9 additions & 5 deletions packages/react/src/menu/root/MenuRoot.tsx
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change complexifies a bit the open-by-default feature of menu/tooltips, but it keeps the Store simple, and I'd rather have the core of the codebase be simple.

We could make the controlled props more ergonomic, I also thought about joining the two fields in some standardized way, e.g. call them open and open:controlled-prop, then we could derive selectors & effects automatically, but I've kept this simple for now.

Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,18 @@ export function MenuRoot<Payload>(props: MenuRoot.Props<Payload>) {
}, [contextMenuContext, parentMenuRootContext, menubarContext, isSubmenu]);

const store = MenuStore.useStore(handle?.store, {
open: defaultOpen,
openProp,
activeTriggerId: defaultTriggerIdProp,
triggerIdProp,
parent: parentFromContext,
});

store.useControlledProp('openProp', openProp);
store.useControlledProp('triggerIdProp', triggerIdProp);

store.useContextCallback('onOpenChangeComplete', onOpenChangeComplete);

const floatingTreeRoot = store.useState('floatingTreeRoot');
const floatingNodeIdFromContext = useFloatingNodeId(floatingTreeRoot);
const floatingParentNodeIdFromContext = useFloatingParentNodeId();
Expand Down Expand Up @@ -138,11 +147,6 @@ export function MenuRoot<Payload>(props: MenuRoot.Props<Payload>) {
store,
]);

store.useControlledProp('open', openProp, defaultOpen);
store.useControlledProp('activeTriggerId', triggerIdProp, defaultTriggerIdProp);

store.useContextCallback('onOpenChangeComplete', onOpenChangeComplete);

const open = store.useState('open');
const activeTriggerElement = store.useState('activeTriggerElement');
const positionerElement = store.useState('positionerElement');
Expand Down
12 changes: 7 additions & 5 deletions packages/react/src/popover/root/PopoverRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ function PopoverRootComponent<Payload>({ props }: { props: PopoverRoot.Props<Pay
const {
children,
open: openProp,
defaultOpen: defaultOpenProp = false,
defaultOpen: defaultOpen = false,
onOpenChange,
onOpenChangeComplete,
modal = false,
Expand All @@ -38,13 +38,15 @@ function PopoverRootComponent<Payload>({ props }: { props: PopoverRoot.Props<Pay
} = props;

const store = PopoverStore.useStore(handle?.store, {
open: openProp ?? defaultOpenProp,
modal,
activeTriggerId: triggerIdProp !== undefined ? triggerIdProp : defaultTriggerIdProp,
open: defaultOpen,
openProp,
activeTriggerId: defaultTriggerIdProp,
triggerIdProp,
});

store.useControlledProp('open', openProp, defaultOpenProp);
store.useControlledProp('activeTriggerId', triggerIdProp, defaultTriggerIdProp);
store.useControlledProp('openProp', openProp);
store.useControlledProp('triggerIdProp', triggerIdProp);

const open = store.useState('open');
const positionerElement = store.useState('positionerElement');
Expand Down
12 changes: 7 additions & 5 deletions packages/react/src/tooltip/root/TooltipRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,15 @@ export function TooltipRoot<Payload>(props: TooltipRoot.Props<Payload>) {
children,
} = props;

const store = TooltipStore.useStore<Payload>(handle?.store, {
open: openProp ?? defaultOpen,
activeTriggerId: triggerIdProp !== undefined ? triggerIdProp : defaultTriggerIdProp,
const store = TooltipStore.use<Payload>(handle?.store, {
open: defaultOpen,
openProp,
activeTriggerId: defaultTriggerIdProp,
triggerIdProp,
});

store.useControlledProp('open', openProp, defaultOpen);
store.useControlledProp('activeTriggerId', triggerIdProp, defaultTriggerIdProp);
store.useControlledProp('openProp', openProp);
store.useControlledProp('triggerIdProp', triggerIdProp);

store.useContextCallback('onOpenChange', onOpenChange);
store.useContextCallback('onOpenChangeComplete', onOpenChangeComplete);
Expand Down
22 changes: 11 additions & 11 deletions packages/react/src/tooltip/store/TooltipStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,16 @@ export class TooltipStore<Payload> extends ReactStore<
Context,
typeof selectors
> {
static use<Payload>(
externalStore: TooltipStore<Payload> | undefined,
initialState?: Partial<State<Payload>>,
) {
// eslint-disable-next-line react-hooks/rules-of-hooks
return useRefWithInit(() => {
return externalStore ?? new TooltipStore<Payload>(initialState);
}).current;
}

constructor(initialState?: Partial<State<Payload>>) {
super(
{ ...createInitialState(), ...initialState },
Expand All @@ -55,7 +65,7 @@ export class TooltipStore<Payload> extends ReactStore<
);
}

public setOpen = (
setOpen = (
nextOpen: boolean,
eventDetails: Omit<TooltipRoot.ChangeEventDetails, 'preventUnmountOnClose'>,
) => {
Expand Down Expand Up @@ -106,16 +116,6 @@ export class TooltipStore<Payload> extends ReactStore<
changeState();
}
};

public static useStore<Payload>(
externalStore: TooltipStore<Payload> | undefined,
initialState?: Partial<State<Payload>>,
) {
// eslint-disable-next-line react-hooks/rules-of-hooks
return useRefWithInit(() => {
return externalStore ?? new TooltipStore<Payload>(initialState);
}).current;
}
}

function createInitialState<Payload>(): State<Payload> {
Expand Down
48 changes: 28 additions & 20 deletions packages/react/src/utils/popups/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,13 @@ import { HTMLProps } from '../types';
*/
export type PopupStoreState<Payload> = {
/**
* Whether the popup is open.
* Whether the popup is open (internal state).
*/
open: boolean;
/**
* Whether the popup is open (external prop).
*/
readonly openProp: boolean | undefined;
/**
* Whether the popup should be mounted in the DOM.
* This usually follows `open` but can be different during exit transitions.
Expand Down Expand Up @@ -44,6 +48,10 @@ export type PopupStoreState<Payload> = {
* The currently active trigger DOM element.
*/
activeTriggerElement: Element | null;
/**
* ID of the trigger (external prop).
*/
readonly triggerIdProp: string | null | undefined;
/**
* The popup DOM element.
*/
Expand All @@ -70,13 +78,15 @@ export type PopupStoreState<Payload> = {
export function createInitialPopupStoreState<Payload>(): PopupStoreState<Payload> {
return {
open: false,
openProp: undefined,
mounted: false,
transitionStatus: 'idle',
floatingRootContext: getEmptyRootContext(),
preventUnmountingOnClose: false,
payload: undefined,
activeTriggerId: null,
activeTriggerElement: null,
triggerIdProp: undefined,
popupElement: null,
positionerElement: null,
activeTriggerProps: EMPTY_OBJECT as HTMLProps,
Expand Down Expand Up @@ -104,51 +114,49 @@ export type PopupStoreContext<ChangeEventDetails> = {
onOpenChangeComplete: ((open: boolean) => void) | undefined;
};

type S = PopupStoreState<unknown>;

export const popupStoreSelectors = {
open: createSelector((state: PopupStoreState<unknown>) => state.open),
mounted: createSelector((state: PopupStoreState<unknown>) => state.mounted),
transitionStatus: createSelector((state: PopupStoreState<unknown>) => state.transitionStatus),
floatingRootContext: createSelector(
(state: PopupStoreState<unknown>) => state.floatingRootContext,
),
preventUnmountingOnClose: createSelector(
(state: PopupStoreState<unknown>) => state.preventUnmountingOnClose,
),
payload: createSelector((state: PopupStoreState<unknown>) => state.payload),
open: createSelector((state: S) => state.openProp ?? state.open),
mounted: createSelector((state: S) => state.mounted),
transitionStatus: createSelector((state: S) => state.transitionStatus),
floatingRootContext: createSelector((state: S) => state.floatingRootContext),
preventUnmountingOnClose: createSelector((state: S) => state.preventUnmountingOnClose),
payload: createSelector((state: S) => state.payload),

activeTriggerId: createSelector((state: PopupStoreState<unknown>) => state.activeTriggerId),
activeTriggerElement: createSelector((state: PopupStoreState<unknown>) =>
activeTriggerId: createSelector((state: S) => state.triggerIdProp ?? state.activeTriggerId),
activeTriggerElement: createSelector((state: S) =>
state.mounted ? state.activeTriggerElement : null,
),
/**
* Whether the trigger with the given ID was used to open the popup.
*/
isTriggerActive: createSelector(
(state: PopupStoreState<unknown>, triggerId: string | undefined) =>
(state: S, triggerId: string | undefined) =>
triggerId !== undefined && state.activeTriggerId === triggerId,
),
/**
* Whether the popup is open and was activated by a trigger with the given ID.
*/
isOpenedByTrigger: createSelector(
(state: PopupStoreState<unknown>, triggerId: string | undefined) =>
(state: S, triggerId: string | undefined) =>
triggerId !== undefined && state.activeTriggerId === triggerId && state.open,
),
/**
* Whether the popup is mounted and was activated by a trigger with the given ID.
*/
isMountedByTrigger: createSelector(
(state: PopupStoreState<unknown>, triggerId: string | undefined) =>
(state: S, triggerId: string | undefined) =>
triggerId !== undefined && state.activeTriggerId === triggerId && state.mounted,
),

triggerProps: createSelector((state: PopupStoreState<unknown>, isActive: boolean) =>
triggerProps: createSelector((state: S, isActive: boolean) =>
isActive ? state.activeTriggerProps : state.inactiveTriggerProps,
),
popupProps: createSelector((state: PopupStoreState<unknown>) => state.popupProps),
popupProps: createSelector((state: S) => state.popupProps),

popupElement: createSelector((state: PopupStoreState<unknown>) => state.popupElement),
positionerElement: createSelector((state: PopupStoreState<unknown>) => state.positionerElement),
popupElement: createSelector((state: S) => state.popupElement),
positionerElement: createSelector((state: S) => state.positionerElement),
};

export type PopupStoreSelectors = typeof popupStoreSelectors;
Loading
Loading