Skip to content

Commit 4fade8b

Browse files
committed
feat: add layers to the slot system
1 parent 1db47b8 commit 4fade8b

8 files changed

Lines changed: 377 additions & 7 deletions

File tree

src/components/ChatView/ChatViewNavigationContext.tsx

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,10 +149,23 @@ export type ChatViewOpenOptions = {
149149
};
150150

151151
export type ChatViewNavigation = {
152-
/** Release the binding held by `slot`. No-op for empty or persistent slots. */
152+
/**
153+
* Dismiss the top of `slot`. If the slot has layers (see {@link pushLayer}), pop the topmost
154+
* layer, revealing what's beneath at its preserved state. Only when no layers remain does this
155+
* release the base binding. No-op for empty or persistent slots.
156+
*/
153157
close: (slot: SlotName) => void;
154158
/** Hide `slot` without releasing its binding (subtree stays mounted). */
155159
hide: (slot: SlotName) => void;
160+
/**
161+
* Push `binding` as a **layer** on top of `slot`'s current content. The content beneath stays
162+
* mounted (state/scroll preserved) and hidden; the layer covers it. Use for transient overlays
163+
* that should return to the underlying view — e.g. a member profile opened over a thread, where
164+
* `close`/`popLayer` restores the thread exactly where it was. Ephemeral (not serialized).
165+
*/
166+
pushLayer: (slot: SlotName, binding: ChatViewEntityBinding) => void;
167+
/** Pop `slot`'s top layer, revealing the layer/base beneath. No-op when there are no layers. */
168+
popLayer: (slot: SlotName) => void;
156169
/**
157170
* Resolve a target slot for `binding` and bind it there, switching to the binding kind's view
158171
* first if needed (e.g. a `channel` binding activates the channels view).
@@ -187,6 +200,8 @@ const ChatViewNavigationContext = createContext<ChatViewNavigation>({
187200
hide: () => undefined,
188201
open: () => ({ reason: 'no-available-slot', status: 'rejected' }),
189202
openView: () => undefined,
203+
popLayer: () => undefined,
204+
pushLayer: () => undefined,
190205
unhide: () => undefined,
191206
});
192207

@@ -223,7 +238,7 @@ export const useSlotForKey = (key?: string): SlotName | undefined => {
223238
export const ChatViewNavigationProvider = ({ children }: PropsWithChildren) => {
224239
const { layoutController, resolveActionTargetSlot } = useChatViewContext();
225240
const registry = useSlotRegistry();
226-
const { availableSlots, slotBindings } = useLayoutViewState();
241+
const { availableSlots, slotBindings, slotLayers } = useLayoutViewState();
227242
const { activeView } =
228243
useStateStore(layoutController.state, chatViewNavigationStateSelector) ??
229244
chatViewNavigationStateSelector(layoutController.state.getLatestValue());
@@ -363,6 +378,12 @@ export const ChatViewNavigationProvider = ({ children }: PropsWithChildren) => {
363378

364379
const close: ChatViewNavigation['close'] = (slot) => {
365380
if (!availableSlots.includes(slot)) return;
381+
// Layers dismiss top-down: pop the topmost layer first and only release the base binding
382+
// once no layers remain.
383+
if (slotLayers?.[slot]?.length) {
384+
layoutController.popLayer(slot);
385+
return;
386+
}
366387
// Persistent kinds (nav-rail lists) are hide-only; close never releases them.
367388
if (isPersistentSlotKind(registry, slotKind(slot))) return;
368389
layoutController.release(slot);
@@ -372,18 +393,28 @@ export const ChatViewNavigationProvider = ({ children }: PropsWithChildren) => {
372393
if (availableSlots.includes(slot)) layoutController.hide(slot);
373394
};
374395

396+
const pushLayer: ChatViewNavigation['pushLayer'] = (slot, binding) => {
397+
if (!availableSlots.includes(slot)) return;
398+
layoutController.pushLayer(slot, createChatViewSlotBinding(binding));
399+
};
400+
401+
const popLayer: ChatViewNavigation['popLayer'] = (slot) => {
402+
if (availableSlots.includes(slot)) layoutController.popLayer(slot);
403+
};
404+
375405
const unhide: ChatViewNavigation['unhide'] = (slot) => {
376406
if (availableSlots.includes(slot)) layoutController.unhide(slot);
377407
};
378408

379-
return { close, hide, open, openView, unhide };
409+
return { close, hide, open, openView, popLayer, pushLayer, unhide };
380410
}, [
381411
activeView,
382412
availableSlots,
383413
layoutController,
384414
registry,
385415
resolveActionTargetSlot,
386416
slotBindings,
417+
slotLayers,
387418
]);
388419

389420
return (

src/components/ChatView/__tests__/ChatViewNavigation.test.tsx

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,136 @@ describe('useChatViewNavigation', () => {
545545
expect(slot2Binding?.source).toBe(secondaryChannel);
546546
});
547547

548+
it('pushLayer stacks over the base binding and popLayer removes it', () => {
549+
const channel = makeChannel('messaging:layer-base');
550+
let capturedController: LayoutController | undefined;
551+
552+
const Harness = () => {
553+
const navigation = useChatViewNavigation();
554+
const { layoutController } = useChatViewContext();
555+
capturedController = layoutController;
556+
557+
return (
558+
<>
559+
<button
560+
onClick={() =>
561+
navigation.open({
562+
key: channel.cid ?? undefined,
563+
kind: 'channel',
564+
source: channel,
565+
})
566+
}
567+
type='button'
568+
>
569+
open-channel
570+
</button>
571+
<button
572+
onClick={() =>
573+
navigation.pushLayer('slot1', {
574+
key: 'u1',
575+
kind: 'userProfile',
576+
source: { userId: 'u1' },
577+
})
578+
}
579+
type='button'
580+
>
581+
push-layer
582+
</button>
583+
<button onClick={() => navigation.popLayer('slot1')} type='button'>
584+
pop-layer
585+
</button>
586+
</>
587+
);
588+
};
589+
590+
renderWithProviders(
591+
<ChatView maxSlots={1}>
592+
<Harness />
593+
</ChatView>,
594+
);
595+
596+
fireEvent.click(screen.getByText('open-channel'));
597+
fireEvent.click(screen.getByText('push-layer'));
598+
599+
const layered = viewState(capturedController);
600+
// Base binding is untouched; the profile lives in the layer stack on top of it.
601+
expect(getChatViewEntityBinding(layered?.slotBindings.slot1)?.kind).toBe('channel');
602+
expect(layered?.slotLayers?.slot1).toHaveLength(1);
603+
expect(getChatViewEntityBinding(layered?.slotLayers?.slot1?.[0])?.kind).toBe(
604+
'userProfile',
605+
);
606+
607+
fireEvent.click(screen.getByText('pop-layer'));
608+
609+
const popped = viewState(capturedController);
610+
expect(popped?.slotLayers?.slot1).toBeUndefined();
611+
expect(getChatViewEntityBinding(popped?.slotBindings.slot1)?.kind).toBe('channel');
612+
});
613+
614+
it('close pops the top layer first and releases the base only when no layers remain', () => {
615+
const channel = makeChannel('messaging:layer-close');
616+
let capturedController: LayoutController | undefined;
617+
618+
const Harness = () => {
619+
const navigation = useChatViewNavigation();
620+
const { layoutController } = useChatViewContext();
621+
capturedController = layoutController;
622+
623+
return (
624+
<>
625+
<button
626+
onClick={() =>
627+
navigation.open({
628+
key: channel.cid ?? undefined,
629+
kind: 'channel',
630+
source: channel,
631+
})
632+
}
633+
type='button'
634+
>
635+
open-channel
636+
</button>
637+
<button
638+
onClick={() =>
639+
navigation.pushLayer('slot1', {
640+
key: 'u1',
641+
kind: 'userProfile',
642+
source: { userId: 'u1' },
643+
})
644+
}
645+
type='button'
646+
>
647+
push-layer
648+
</button>
649+
<button onClick={() => navigation.close('slot1')} type='button'>
650+
close
651+
</button>
652+
</>
653+
);
654+
};
655+
656+
renderWithProviders(
657+
<ChatView maxSlots={1}>
658+
<Harness />
659+
</ChatView>,
660+
);
661+
662+
fireEvent.click(screen.getByText('open-channel'));
663+
fireEvent.click(screen.getByText('push-layer'));
664+
665+
// First close: pops the layer, base channel stays bound.
666+
fireEvent.click(screen.getByText('close'));
667+
const afterFirstClose = viewState(capturedController);
668+
expect(afterFirstClose?.slotLayers?.slot1).toBeUndefined();
669+
expect(getChatViewEntityBinding(afterFirstClose?.slotBindings.slot1)?.kind).toBe(
670+
'channel',
671+
);
672+
673+
// Second close: no layers left, so the base binding is released.
674+
fireEvent.click(screen.getByText('close'));
675+
expect(viewState(capturedController)?.slotBindings.slot1).toBeUndefined();
676+
});
677+
548678
it('opens channel and thread into configured slotNames in order', () => {
549679
const channel = makeChannel('messaging:expand-named');
550680
const thread = makeThread('thread-expand-named');

src/components/ChatView/__tests__/useSlotEntity.test.tsx

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
useSlotChannel,
1212
useSlotEntity,
1313
useSlotThread,
14+
useSlotTopLayerEntity,
1415
} from '../hooks';
1516

1617
import { ChatProvider } from '../../../context/ChatContext';
@@ -133,6 +134,85 @@ describe('useSlotEntity hooks', () => {
133134
);
134135
expect(screen.getByTestId('resolved-thread')).toHaveTextContent('thread-1');
135136
});
137+
138+
it('resolves the top layer via useSlotTopLayerEntity without shadowing the base binding', () => {
139+
const thread = makeThread('thread-under-layer');
140+
141+
const layoutController = new LayoutController({
142+
initialState: {
143+
availableSlots: ['slot1'],
144+
},
145+
});
146+
147+
// Base binding: a thread. Layer on top: a member profile.
148+
layoutController.bind('slot1', {
149+
key: `thread:${thread.id}`,
150+
payload: { key: thread.id, kind: 'thread', source: thread },
151+
});
152+
layoutController.pushLayer('slot1', {
153+
key: 'userProfile:u1',
154+
payload: { key: 'userProfile:u1', kind: 'userProfile', source: { userId: 'u1' } },
155+
});
156+
157+
const Harness = () => {
158+
const topProfile = useSlotTopLayerEntity({ kind: 'userProfile', slot: 'slot1' });
159+
// The base binding (thread) is unchanged — a layer never rewrites `slotBindings`.
160+
const baseThread = useSlotThread({ slot: 'slot1' });
161+
// Reading the profile as a base binding must NOT find it (it lives in the layer stack).
162+
const profileAsBase = useSlotEntity({ kind: 'userProfile', slot: 'slot1' });
163+
164+
return (
165+
<>
166+
<div data-testid='top-profile'>{topProfile?.userId}</div>
167+
<div data-testid='base-thread'>{baseThread?.id}</div>
168+
<div data-testid='profile-as-base'>{profileAsBase?.userId ?? 'none'}</div>
169+
</>
170+
);
171+
};
172+
173+
renderWithProviders(
174+
<ChatView layoutController={layoutController}>
175+
<Harness />
176+
</ChatView>,
177+
);
178+
179+
expect(screen.getByTestId('top-profile')).toHaveTextContent('u1');
180+
expect(screen.getByTestId('base-thread')).toHaveTextContent('thread-under-layer');
181+
expect(screen.getByTestId('profile-as-base')).toHaveTextContent('none');
182+
});
183+
184+
it('returns undefined from useSlotTopLayerEntity when the top layer kind does not match', () => {
185+
const layoutController = new LayoutController({
186+
initialState: {
187+
availableSlots: ['slot1'],
188+
},
189+
});
190+
191+
layoutController.pushLayer('slot1', {
192+
key: 'userProfile:u2',
193+
payload: { key: 'userProfile:u2', kind: 'userProfile', source: { userId: 'u2' } },
194+
});
195+
196+
const Harness = () => {
197+
const asThread = useSlotTopLayerEntity({ kind: 'thread', slot: 'slot1' });
198+
const asProfile = useSlotTopLayerEntity({ kind: 'userProfile', slot: 'slot1' });
199+
return (
200+
<>
201+
<div data-testid='as-thread'>{asThread ? 'match' : 'none'}</div>
202+
<div data-testid='as-profile'>{asProfile?.userId ?? 'none'}</div>
203+
</>
204+
);
205+
};
206+
207+
renderWithProviders(
208+
<ChatView layoutController={layoutController}>
209+
<Harness />
210+
</ChatView>,
211+
);
212+
213+
expect(screen.getByTestId('as-thread')).toHaveTextContent('none');
214+
expect(screen.getByTestId('as-profile')).toHaveTextContent('u2');
215+
});
136216
});
137217

138218
describe('slot adapters', () => {

src/components/ChatView/hooks/useLayoutViewState.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export const getLayoutViewState = (
1919
slotBindings: { ...(layout?.slotBindings ?? {}) },
2020
slotForwardHistory: { ...(layout?.slotForwardHistory ?? {}) },
2121
slotHistory: { ...(layout?.slotHistory ?? {}) },
22+
slotLayers: { ...(layout?.slotLayers ?? {}) },
2223
slotMeta: { ...(layout?.slotMeta ?? {}) },
2324
slotNames: layout?.slotNames ? [...layout.slotNames] : undefined,
2425
};

src/components/ChatView/hooks/useSlotEntity.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,40 @@ export const useSlotEntity = <TKind extends SlotEntityKind>({
101101
return undefined;
102102
};
103103

104+
/**
105+
* Like {@link useSlotEntity}, but resolves the source of the **topmost layer** on a slot (the last
106+
* entry of `slotLayers[slot]`) rather than its base binding — when that layer's `kind` matches.
107+
*
108+
* Layers stack above the base binding and cover it while it stays mounted (see
109+
* `ChatViewNavigation.pushLayer`). Read the base with `useSlotEntity`/`useSlotChannel`/… and the
110+
* overlay on top with this hook, then render the overlay covering the base.
111+
*
112+
* @example
113+
* ```tsx
114+
* const profile = useSlotTopLayerEntity({ kind: 'userProfile', slot: 'main-thread' });
115+
* // profile is { userId } | undefined — present while a userProfile layer covers the slot
116+
* ```
117+
*/
118+
export const useSlotTopLayerEntity = <TKind extends SlotEntityKind>({
119+
kind,
120+
slot,
121+
view,
122+
}: UseSlotEntityOptions<TKind>): SlotEntitySourceByKind<TKind> | undefined => {
123+
const { availableSlots, slotLayers } = useLayoutViewState(view);
124+
125+
for (const candidateSlot of resolveCandidateSlots(slot, availableSlots)) {
126+
const layers = slotLayers?.[candidateSlot];
127+
const topBinding = layers?.[layers.length - 1];
128+
const entity = getChatViewEntityBinding(topBinding);
129+
if (isEntityOfKind(entity, kind)) {
130+
// Safe due to discriminant check on `kind` above.
131+
return entity.source as SlotEntitySourceByKind<TKind>;
132+
}
133+
}
134+
135+
return undefined;
136+
};
137+
104138
/**
105139
* Resolves **every** matching ChatView entity binding as `{ slot, source }` pairs
106140
* (in active-view `availableSlots` order, or only `slot` when provided).

0 commit comments

Comments
 (0)