@exegia/corpora-ui 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,6 +20,7 @@ import {
20
20
  useContext,
21
21
  useEffect,
22
22
  useId,
23
+ useMemo,
23
24
  useRef,
24
25
  useState,
25
26
  useSyncExternalStore,
@@ -29,8 +30,10 @@ import { SharedLayoutBg } from "@/components/motion/shared-layout-bg"
29
30
  import { EASE_DRAWER, EASE_OUT, SPRING_LAYOUT, SPRING_PRESS } from "@/lib/ease"
30
31
  import { cn } from "@/lib/utils"
31
32
 
32
- type SidebarState = "expanded" | "collapsed"
33
- type SidebarSide = "left" | "right"
33
+ export type SidebarSide = "left" | "right"
34
+ /** Open flags keyed by side. Sides left out stay uncontrolled / at their
35
+ * default — there is no per-side prop, the record IS the API. */
36
+ export type SidebarOpenState = Partial<Record<SidebarSide, boolean>>
34
37
  type SidebarVariant = "sidebar" | "floating" | "inset"
35
38
  type SidebarCollapsible = "offcanvas" | "icon" | "none"
36
39
 
@@ -144,20 +147,16 @@ function useIsMobile() {
144
147
  interface AnimatedSidebarContextValue {
145
148
  isMobile: boolean
146
149
  layoutId: string
147
- open: boolean
148
- openMobile: boolean
149
- /** The right panel is a single hidden/shown state — no icon rail, no
150
- * separate mobile state. */
151
- openRight: boolean
150
+ /** Desktop open state, keyed by side. */
151
+ open: Record<SidebarSide, boolean>
152
+ /** Mobile overlay open state, keyed by side. */
153
+ openMobile: Record<SidebarSide, boolean>
152
154
  reduce: boolean
153
- setOpen: (open: boolean) => void
154
- setOpenMobile: (open: boolean) => void
155
- setOpenRight: (open: boolean) => void
156
- state: SidebarState
157
- toggleSidebar: () => void
158
- toggleRightSidebar: () => void
159
- triggerRef: React.RefObject<HTMLButtonElement | null>
160
- rightTriggerRef: React.RefObject<HTMLButtonElement | null>
155
+ setOpen: (open: boolean, side: SidebarSide) => void
156
+ setOpenMobile: (open: boolean, side: SidebarSide) => void
157
+ /** Mobile-aware: toggles the overlay below md, the docked panel above. */
158
+ toggleSidebar: (side: SidebarSide) => void
159
+ triggerRefs: Record<SidebarSide, React.RefObject<HTMLButtonElement | null>>
161
160
  }
162
161
 
163
162
  const AnimatedSidebarContext =
@@ -199,78 +198,111 @@ type SidebarProviderStyle = CSSProperties & {
199
198
  }
200
199
 
201
200
  export interface AnimatedSidebarProviderProps extends HTMLAttributes<HTMLDivElement> {
202
- open?: boolean
203
- defaultOpen?: boolean
204
- onOpenChange?: (open: boolean) => void
205
- openMobile?: boolean
206
- defaultOpenMobile?: boolean
207
- onOpenMobileChange?: (open: boolean) => void
208
- openRight?: boolean
209
- defaultOpenRight?: boolean
210
- onOpenRightChange?: (open: boolean) => void
201
+ /** Controlled desktop open state, keyed by side. A side left undefined
202
+ * stays uncontrolled. */
203
+ open?: SidebarOpenState
204
+ /** Initial desktop open state, merged over `{ left: true, right: false }`. */
205
+ defaultOpen?: SidebarOpenState
206
+ onOpenChange?: (open: boolean, side: SidebarSide) => void
207
+ /** Controlled mobile overlay state, keyed by side. */
208
+ openMobile?: SidebarOpenState
209
+ /** Initial mobile overlay state — every side starts closed. */
210
+ defaultOpenMobile?: SidebarOpenState
211
+ onOpenMobileChange?: (open: boolean, side: SidebarSide) => void
211
212
  style?: SidebarProviderStyle
212
213
  }
213
214
 
214
215
  export function AnimatedSidebarProvider({
215
216
  children,
216
217
  open,
217
- defaultOpen = true,
218
+ defaultOpen,
218
219
  onOpenChange,
219
220
  openMobile,
220
- defaultOpenMobile = false,
221
+ defaultOpenMobile,
221
222
  onOpenMobileChange,
222
- openRight,
223
- defaultOpenRight = false,
224
- onOpenRightChange,
225
223
  className,
226
224
  style,
227
225
  ...props
228
226
  }: AnimatedSidebarProviderProps) {
229
- const [internalOpen, setInternalOpen] = useState(defaultOpen)
230
- const [internalOpenMobile, setInternalOpenMobile] =
231
- useState(defaultOpenMobile)
232
- const [internalOpenRight, setInternalOpenRight] = useState(defaultOpenRight)
227
+ const [internalOpen, setInternalOpen] = useState<
228
+ Record<SidebarSide, boolean>
229
+ >(() => ({
230
+ left: defaultOpen?.left ?? true,
231
+ right: defaultOpen?.right ?? false,
232
+ }))
233
+ const [internalOpenMobile, setInternalOpenMobile] = useState<
234
+ Record<SidebarSide, boolean>
235
+ >(() => ({
236
+ left: defaultOpenMobile?.left ?? false,
237
+ right: defaultOpenMobile?.right ?? false,
238
+ }))
233
239
  const isMobile = useIsMobile()
234
240
  const reduce = useReducedMotion() ?? false
235
241
  const generatedId = useId()
236
- const triggerRef = useRef<HTMLButtonElement>(null)
242
+ const leftTriggerRef = useRef<HTMLButtonElement>(null)
237
243
  const rightTriggerRef = useRef<HTMLButtonElement>(null)
238
- const desktopOpen = open ?? internalOpen
239
- const mobileOpen = openMobile ?? internalOpenMobile
240
- const rightOpen = openRight ?? internalOpenRight
244
+
245
+ // Per-side controlled/uncontrolled merge — a side is controlled exactly
246
+ // when its key is present on the controlled record.
247
+ const controlledLeft = open?.left
248
+ const controlledRight = open?.right
249
+ const controlledMobileLeft = openMobile?.left
250
+ const controlledMobileRight = openMobile?.right
251
+
252
+ const openState = useMemo<Record<SidebarSide, boolean>>(
253
+ () => ({
254
+ left: controlledLeft ?? internalOpen.left,
255
+ right: controlledRight ?? internalOpen.right,
256
+ }),
257
+ [controlledLeft, controlledRight, internalOpen]
258
+ )
259
+ const openMobileState = useMemo<Record<SidebarSide, boolean>>(
260
+ () => ({
261
+ left: controlledMobileLeft ?? internalOpenMobile.left,
262
+ right: controlledMobileRight ?? internalOpenMobile.right,
263
+ }),
264
+ [controlledMobileLeft, controlledMobileRight, internalOpenMobile]
265
+ )
241
266
 
242
267
  const setOpen = useCallback(
243
- (nextOpen: boolean) => {
244
- if (open === undefined) setInternalOpen(nextOpen)
245
- onOpenChange?.(nextOpen)
268
+ (nextOpen: boolean, side: SidebarSide) => {
269
+ const controlled = side === "left" ? controlledLeft : controlledRight
270
+ if (controlled === undefined) {
271
+ setInternalOpen((prev) =>
272
+ prev[side] === nextOpen ? prev : { ...prev, [side]: nextOpen }
273
+ )
274
+ }
275
+ onOpenChange?.(nextOpen, side)
246
276
  },
247
- [onOpenChange, open]
277
+ [controlledLeft, controlledRight, onOpenChange]
248
278
  )
249
279
 
250
280
  const setOpenMobile = useCallback(
251
- (nextOpen: boolean) => {
252
- if (openMobile === undefined) setInternalOpenMobile(nextOpen)
253
- onOpenMobileChange?.(nextOpen)
281
+ (nextOpen: boolean, side: SidebarSide) => {
282
+ const controlled =
283
+ side === "left" ? controlledMobileLeft : controlledMobileRight
284
+ if (controlled === undefined) {
285
+ setInternalOpenMobile((prev) =>
286
+ prev[side] === nextOpen ? prev : { ...prev, [side]: nextOpen }
287
+ )
288
+ }
289
+ onOpenMobileChange?.(nextOpen, side)
254
290
  },
255
- [onOpenMobileChange, openMobile]
291
+ [controlledMobileLeft, controlledMobileRight, onOpenMobileChange]
256
292
  )
257
293
 
258
- const setOpenRight = useCallback(
259
- (nextOpen: boolean) => {
260
- if (openRight === undefined) setInternalOpenRight(nextOpen)
261
- onOpenRightChange?.(nextOpen)
294
+ const toggleSidebar = useCallback(
295
+ (side: SidebarSide) => {
296
+ if (isMobile) setOpenMobile(!openMobileState[side], side)
297
+ else setOpen(!openState[side], side)
262
298
  },
263
- [onOpenRightChange, openRight]
299
+ [isMobile, openMobileState, openState, setOpen, setOpenMobile]
264
300
  )
265
301
 
266
- const toggleSidebar = useCallback(() => {
267
- if (isMobile) setOpenMobile(!mobileOpen)
268
- else setOpen(!desktopOpen)
269
- }, [desktopOpen, isMobile, mobileOpen, setOpen, setOpenMobile])
270
-
271
- const toggleRightSidebar = useCallback(() => {
272
- setOpenRight(!rightOpen)
273
- }, [rightOpen, setOpenRight])
302
+ const triggerRefs = useMemo(
303
+ () => ({ left: leftTriggerRef, right: rightTriggerRef }),
304
+ []
305
+ )
274
306
 
275
307
  useEffect(() => {
276
308
  const handleShortcut = (event: KeyboardEvent) => {
@@ -279,7 +311,7 @@ export function AnimatedSidebarProvider({
279
311
  (event.metaKey || event.ctrlKey)
280
312
  ) {
281
313
  event.preventDefault()
282
- toggleSidebar()
314
+ toggleSidebar("left")
283
315
  }
284
316
  }
285
317
 
@@ -292,25 +324,20 @@ export function AnimatedSidebarProvider({
292
324
  value={{
293
325
  isMobile,
294
326
  layoutId: `${generatedId}-active`,
295
- open: desktopOpen,
296
- openMobile: mobileOpen,
297
- openRight: rightOpen,
327
+ open: openState,
328
+ openMobile: openMobileState,
298
329
  reduce,
299
330
  setOpen,
300
331
  setOpenMobile,
301
- setOpenRight,
302
- state: desktopOpen ? "expanded" : "collapsed",
303
332
  toggleSidebar,
304
- toggleRightSidebar,
305
- triggerRef,
306
- rightTriggerRef,
333
+ triggerRefs,
307
334
  }}
308
335
  >
309
336
  <div
310
337
  {...props}
311
338
  data-slot="sidebar-wrapper"
312
- data-state={desktopOpen ? "expanded" : "collapsed"}
313
- data-right-state={rightOpen ? "expanded" : "collapsed"}
339
+ data-state-left={openState.left ? "expanded" : "collapsed"}
340
+ data-state-right={openState.right ? "expanded" : "collapsed"}
314
341
  style={{
315
342
  "--sidebar-width": "19rem",
316
343
  "--sidebar-width-icon": "4.25rem",
@@ -340,10 +367,9 @@ function MobileSidebar({
340
367
  side: SidebarSide
341
368
  }) {
342
369
  const context = useAnimatedSidebar()
343
- const isRight = side === "right"
344
- const open = isRight ? context.openRight : context.openMobile
345
- const setOpen = isRight ? context.setOpenRight : context.setOpenMobile
346
- const triggerRef = isRight ? context.rightTriggerRef : context.triggerRef
370
+ const open = context.openMobile[side]
371
+ const setOpen = (nextOpen: boolean) => context.setOpenMobile(nextOpen, side)
372
+ const triggerRef = context.triggerRefs[side]
347
373
  const panelRef = useRef<HTMLDivElement>(null)
348
374
  const [mounted, setMounted] = useState(false)
349
375
 
@@ -512,7 +538,7 @@ export const AnimatedSidebar = forwardRef<HTMLElement, AnimatedSidebarProps>(
512
538
  forwardedRef
513
539
  ) {
514
540
  const context = useAnimatedSidebar()
515
- const sideOpen = side === "right" ? context.openRight : context.open
541
+ const sideOpen = context.open[side]
516
542
  const collapsed = collapsible !== "none" && !sideOpen
517
543
  const offcanvas = collapsed && collapsible === "offcanvas"
518
544
  const width = offcanvas
@@ -598,13 +624,10 @@ export const AnimatedSidebarTrigger = forwardRef<
598
624
  forwardedRef
599
625
  ) {
600
626
  const context = useAnimatedSidebar()
601
- const isRight = side === "right"
602
- const expanded = isRight
603
- ? context.openRight
604
- : context.isMobile
605
- ? context.openMobile
606
- : context.open
607
- const triggerRef = isRight ? context.rightTriggerRef : context.triggerRef
627
+ const expanded = context.isMobile
628
+ ? context.openMobile[side]
629
+ : context.open[side]
630
+ const triggerRef = context.triggerRefs[side]
608
631
 
609
632
  return (
610
633
  <button
@@ -623,8 +646,7 @@ export const AnimatedSidebarTrigger = forwardRef<
623
646
  onClick={(event) => {
624
647
  onClick?.(event)
625
648
  if (event.defaultPrevented) return
626
- if (isRight) context.toggleRightSidebar()
627
- else context.toggleSidebar()
649
+ context.toggleSidebar(side)
628
650
  }}
629
651
  className={cn(
630
652
  "inline-flex size-10 shrink-0 items-center justify-center rounded-xl outline-none",
@@ -658,9 +680,8 @@ export const AnimatedSidebarClose = forwardRef<
658
680
  onClick={(event) => {
659
681
  onClick?.(event)
660
682
  if (event.defaultPrevented) return
661
- if (side === "right") context.setOpenRight(false)
662
- else if (context.isMobile) context.setOpenMobile(false)
663
- else context.setOpen(false)
683
+ if (context.isMobile) context.setOpenMobile(false, side)
684
+ else context.setOpen(false, side)
664
685
  }}
665
686
  className={cn(
666
687
  "inline-flex size-10 shrink-0 items-center justify-center rounded-xl outline-none",
@@ -695,8 +716,7 @@ export const AnimatedSidebarRail = forwardRef<
695
716
  onClick={(event) => {
696
717
  onClick?.(event)
697
718
  if (event.defaultPrevented) return
698
- if (panel.side === "right") context.toggleRightSidebar()
699
- else context.toggleSidebar()
719
+ context.toggleSidebar(panel.side)
700
720
  }}
701
721
  className={cn(
702
722
  "absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 outline-none md:block",
@@ -956,6 +976,7 @@ export function AnimatedSidebarMenuSubButton({
956
976
  className,
957
977
  }: AnimatedSidebarMenuSubButtonProps) {
958
978
  const context = useAnimatedSidebar()
979
+ const panel = useAnimatedSidebarPanel()
959
980
 
960
981
  const select = (
961
982
  event: React.MouseEvent<HTMLAnchorElement | HTMLButtonElement>
@@ -965,7 +986,9 @@ export function AnimatedSidebarMenuSubButton({
965
986
  return
966
987
  }
967
988
  onSelect?.()
968
- if (context.isMobile && closeOnSelect) context.setOpenMobile(false)
989
+ if (context.isMobile && closeOnSelect) {
990
+ context.setOpenMobile(false, panel.side)
991
+ }
969
992
  }
970
993
 
971
994
  const content = (
@@ -1062,7 +1085,7 @@ export function AnimatedSidebarMenuButton({
1062
1085
  onSelect?.()
1063
1086
  const shouldCloseOnSelect = closeOnSelect ?? ariaExpanded === undefined
1064
1087
  if (context.isMobile && shouldCloseOnSelect) {
1065
- context.setOpenMobile(false)
1088
+ context.setOpenMobile(false, panel.side)
1066
1089
  }
1067
1090
  }
1068
1091
 
package/src/index.css CHANGED
@@ -3,6 +3,7 @@
3
3
  @import "shadcn/tailwind.css";
4
4
  @import "@fontsource-variable/inter";
5
5
  @import "@fontsource-variable/figtree";
6
+ @import "motion-icons-react/style.css";
6
7
 
7
8
  @custom-variant dark (&:is(.dark *));
8
9
 
@@ -190,4 +191,4 @@
190
191
  html {
191
192
  @apply font-sans;
192
193
  }
193
- }
194
+ }