1+ import { Checkbox } from '@base-ui-components/react/checkbox' ;
12import { Popover } from '@base-ui-components/react/popover' ;
3+ import { CheckIcon , EyeSlashIcon } from '@heroicons/react/24/solid' ;
4+ import { Fragment , useState } from 'react' ;
25import { Loading } from '@/components/ui/Loading' ;
36import type { PrivateSpaceData } from '@/hooks/use-private-spaces' ;
47import type { PublicSpaceData } from '@/hooks/use-public-spaces' ;
@@ -7,16 +10,16 @@ import { cn } from '@/lib/utils';
710interface SpacesCardProps extends Omit < React . HTMLAttributes < HTMLDivElement > , 'children' > {
811 spaces : ( PublicSpaceData | PrivateSpaceData ) [ ] ;
912 status ?: 'loading' | { error : boolean | string } | undefined ;
10- selected ?: Set < string > ;
11- onSelected ?: ( spaceId : string , selected : boolean ) => void ;
12- currentAppId ?: string ;
13+ selected ?: Set < string > | undefined ;
14+ onSelectedChange ?: ( ( spaceId : string , selected : boolean ) => void ) | undefined ;
15+ currentAppId ?: string | undefined ;
1316}
1417
1518export function SpacesCard ( {
1619 spaces,
1720 status,
1821 selected,
19- onSelected ,
22+ onSelectedChange ,
2023 currentAppId,
2124 className,
2225 ...props
@@ -30,17 +33,16 @@ export function SpacesCard({
3033 return (
3134 < div
3235 className = { cn (
33- `group/card c-card scroll-y scrollbar-none
34- has-data-error:bg-error-dark
36+ `group/card c-card scroll-y scrollbar-none has-data-error:bg-error-dark
3537 has-data-error:text-error-light
36- flex flex-col` ,
38+ isolate flex flex-col` ,
3739 className ,
3840 ) }
3941 { ...props }
4042 >
4143 < h2
4244 className = { `
43- c-card-title group-has-data-error/card:text-error-light sticky top-(--offset) shrink-0
45+ c-card-title group-has-data-error/card:text-error-light sticky top-(--offset) z-10 shrink-0
4446 bg-[color-mix(in_oklab,var(--color-foreground)_calc(var(--progress)*0.25),transparent)]
4547 text-[color-mix(in_oklab,var(--color-background)_var(--progress),var(--color-foreground-muted))]
4648 backdrop-blur-sm
@@ -71,65 +73,22 @@ export function SpacesCard({
7173 return (
7274 < ul className = "grid-cols-auto-fill-36 grid gap-4" >
7375 { spaces . map ( ( space ) => {
74- // Determine if space is selected
7576 const isPublicSpace = ! ( 'apps' in space ) ;
76- const isSelected = isPublicSpace ? true : ( selected ?. has ( space . id ) ?? false ) ;
77- const isDisabled =
78- ! isPublicSpace && 'apps' in space && space . apps . some ( ( app ) => app . id === currentAppId ) ;
77+ const wasAlreadySelected = ! isPublicSpace && space . apps . some ( ( app ) => app . id === currentAppId ) ;
78+ const isSelected = isPublicSpace ? true : wasAlreadySelected || ( selected ?. has ( space . id ) ?? false ) ;
79+ const isDisabled = isPublicSpace ? true : wasAlreadySelected ;
7980
8081 return (
81- < li key = { space . id } className = "group/list-item" >
82- < Popover . Root openOnHover delay = { 50 } >
83- < Popover . Trigger
84- className = { `
85- group-nth-[5n]/list-item:bg-gradient-violet
86- group-nth-[5n+1]/list-item:bg-gradient-lavender
87- group-nth-[5n+2]/list-item:bg-gradient-aqua
88- group-nth-[5n+3]/list-item:bg-gradient-peach
89- group-nth-[5n+4]/list-item:bg-gradient-clearmint
90- flex aspect-video w-full items-end overflow-clip rounded-lg px-3 py-2
91- ${ isSelected ? 'ring-2 ring-primary ring-offset-2' : '' }
92- ${ isDisabled ? 'ring-2 ring-primary ring-offset-2 cursor-not-allowed' : 'cursor-pointer' }
93- ` }
94- onClick = { ( ) => {
95- if ( ! isDisabled && onSelected ) {
96- onSelected ( space . id , ! isSelected ) ;
97- }
98- } }
99- >
100- < span className = "text-sm leading-tight font-semibold" > { space . name || space . id } </ span >
101- </ Popover . Trigger >
102- < Popover . Portal >
103- < Popover . Positioner side = "bottom" sideOffset = { 12 } >
104- < Popover . Popup className = "c-popover" >
105- < Popover . Arrow className = "c-popover-arrow" >
106- < ArrowSvg />
107- </ Popover . Arrow >
108- { ! ( 'apps' in space ) ? (
109- < Popover . Title className = "font-semibold" > Public space</ Popover . Title >
110- ) : space . apps . length === 0 ? (
111- < Popover . Title className = "font-semibold" >
112- No app has access to this private space
113- </ Popover . Title >
114- ) : (
115- < >
116- < Popover . Title className = "font-semibold" >
117- Apps with access to this private space
118- </ Popover . Title >
119- < Popover . Description >
120- < ul className = "list-disc" >
121- { space . apps . map ( ( app ) => (
122- < li key = { app . id } > { app . name || app . id } </ li >
123- ) ) }
124- </ ul >
125- </ Popover . Description >
126- </ >
127- ) }
128- </ Popover . Popup >
129- </ Popover . Positioner >
130- </ Popover . Portal >
131- </ Popover . Root >
132- </ li >
82+ < SpaceTile
83+ key = { space . id }
84+ visibility = { isPublicSpace ? 'public' : 'private' }
85+ space = { space }
86+ selected = { isSelected }
87+ onSelectedChange = {
88+ onSelectedChange ? ( newSelected ) => onSelectedChange ( space . id , newSelected ) : undefined
89+ }
90+ disabled = { isDisabled }
91+ />
13392 ) ;
13493 } ) }
13594 </ ul >
@@ -140,6 +99,119 @@ export function SpacesCard({
14099 ) ;
141100}
142101
102+ interface SpaceTileProps extends Omit < React . HTMLAttributes < HTMLLIElement > , 'children' > {
103+ visibility : 'public' | 'private' ;
104+ space : PublicSpaceData | PrivateSpaceData ;
105+ selected ?: boolean | undefined ;
106+ onSelectedChange ?: ( ( selected : boolean ) => void ) | undefined ;
107+ disabled ?: boolean | undefined ;
108+ }
109+
110+ function SpaceTile ( {
111+ visibility,
112+ space,
113+ selected = false ,
114+ onSelectedChange,
115+ disabled = false ,
116+ className,
117+ ...props
118+ } : SpaceTileProps ) {
119+ const mode = onSelectedChange !== undefined ? 'selection' : 'view' ;
120+ const Root = mode === 'selection' ? Fragment : Popover . Root ;
121+ const Trigger = mode === 'selection' ? Checkbox . Root : Popover . Trigger ;
122+ const [ popoverOpen , setPopoverOpen ] = useState ( false ) ;
123+
124+ return (
125+ < li
126+ data-mode = { mode }
127+ data-visibility = { visibility }
128+ data-selected = { selected || undefined }
129+ data-disabled = { ( mode === 'selection' && disabled ) || undefined }
130+ className = { cn ( 'group/space' , className ) }
131+ { ...props }
132+ >
133+ < Root
134+ { ...( mode === 'view'
135+ ? {
136+ open : popoverOpen ,
137+ onOpenChange : setPopoverOpen ,
138+ }
139+ : { } ) }
140+ >
141+ < Trigger
142+ { ...( mode === 'selection'
143+ ? {
144+ disabled,
145+ checked : selected ,
146+ onCheckedChange : ( checked ) => onSelectedChange ?.( checked ) ,
147+ }
148+ : { } ) }
149+ className = { `
150+ group-nth-[5n]/space:bg-gradient-violet
151+ group-nth-[5n+1]/space:bg-gradient-lavender
152+ group-nth-[5n+2]/space:bg-gradient-aqua
153+ group-nth-[5n+4]/space:bg-gradient-clearmint
154+ group-nth-[5n+3]/space:bg-gradient-peach
155+ relative flex aspect-video w-full cursor-pointer items-end rounded-lg px-3 py-2
156+ group-data-disabled/space:cursor-not-allowed
157+ ` }
158+ >
159+ < span className = "truncate text-sm leading-tight font-semibold whitespace-normal" >
160+ { space . name || space . id }
161+ </ span >
162+ { mode === 'selection' ? (
163+ < span
164+ className = { `
165+ group-data-selected/space:bg-primary
166+ text-primary-foreground
167+ absolute top-1 right-1 flex size-5 items-center justify-center rounded-md bg-white/50 opacity-0 transition group-hover/space:opacity-100
168+ group-data-selected/space:opacity-100
169+ group-data-selected/space:group-data-disabled/space:bg-gray-800/50
170+ ` }
171+ >
172+ < span className = "sr-only group-not-data-selected/space:hidden" > Selected</ span >
173+ < CheckIcon className = "size-3 opacity-0 transition group-data-selected/space:opacity-100" />
174+ </ span >
175+ ) : null }
176+ { visibility === 'private' ? (
177+ < span className = "bg-background/50 text-foreground absolute top-1 left-1 flex h-4 items-center gap-1 rounded-md px-1 text-xs leading-none font-semibold" >
178+ < EyeSlashIcon className = "size-3" />
179+ Private
180+ </ span >
181+ ) : null }
182+ </ Trigger >
183+ { mode === 'view' ? (
184+ < Popover . Portal >
185+ < Popover . Positioner side = "bottom" sideOffset = { 12 } >
186+ < Popover . Popup className = "c-popover" >
187+ < Popover . Arrow className = "c-popover-arrow" >
188+ < ArrowSvg />
189+ </ Popover . Arrow >
190+ { visibility === 'public' ? (
191+ < Popover . Title className = "font-semibold" > Public space</ Popover . Title >
192+ ) : ( space as PrivateSpaceData ) . apps . length === 0 ? (
193+ < Popover . Title className = "font-semibold" > No app has access to this private space</ Popover . Title >
194+ ) : (
195+ < >
196+ < Popover . Title className = "font-semibold" > Apps with access to this private space</ Popover . Title >
197+ < Popover . Description >
198+ < ul className = "list-disc" >
199+ { ( space as PrivateSpaceData ) . apps . map ( ( app ) => (
200+ < li key = { app . id } > { app . name || app . id } </ li >
201+ ) ) }
202+ </ ul >
203+ </ Popover . Description >
204+ </ >
205+ ) }
206+ </ Popover . Popup >
207+ </ Popover . Positioner >
208+ </ Popover . Portal >
209+ ) : null }
210+ </ Root >
211+ </ li >
212+ ) ;
213+ }
214+
143215function ArrowSvg ( props : React . ComponentProps < 'svg' > ) {
144216 return (
145217 < svg width = "20" height = "10" viewBox = "0 0 20 10" role = "presentation" { ...props } >
0 commit comments