| ... | ... | @@ -43,9 +43,12 @@ function bannerPriority(item: ComposerBannerStackEntry) { |
| 43 | 43 | interface ComposerBannerStackProps { |
| 44 | 44 | readonly className?: string; |
| 45 | 45 | readonly items: ReadonlyArray<ComposerBannerStackEntry>; |
| 46 | /** Attachments that sit between the stacked notices and the front item, such as the queue. |
| 47 | The stacked notices peek above the whole column, never from between two attachments. */ |
| 48 | readonly attachedAbove?: ReactNode; |
| 46 | 49 | } |
| 47 | 50 | |
| 48 | | export function ComposerBannerStack({ className, items }: ComposerBannerStackProps) { |
| 51 | export function ComposerBannerStack({ className, items, attachedAbove }: ComposerBannerStackProps) { |
| 49 | 52 | const [stackExpanded, setStackExpanded] = useState(false); |
| 50 | 53 | const noticesRef = useRef<HTMLDivElement>(null); |
| 51 | 54 | const peekRef = useRef<HTMLButtonElement>(null); |
| ... | ... | @@ -84,15 +87,17 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro |
| 84 | 87 | } |
| 85 | 88 | }, [stackExpanded]); |
| 86 | 89 | |
| 87 | | if (items.length === 0) { |
| 88 | | return null; |
| 89 | | } |
| 90 | | |
| 91 | 90 | // Activity stays attached. Urgency and severity only order the notices behind it. |
| 92 | 91 | const orderedItems = items.toSorted((a, b) => bannerPriority(a) - bannerPriority(b)); |
| 93 | 92 | const frontItem = orderedItems[0]; |
| 94 | 93 | if (!frontItem) { |
| 95 | | return null; |
| 94 | return ( |
| 95 | <> |
| 96 | {null} |
| 97 | {attachedAbove} |
| 98 | {null} |
| 99 | </> |
| 100 | ); |
| 96 | 101 | } |
| 97 | 102 | const stackedItems = orderedItems.slice(1); |
| 98 | 103 | const hasStack = stackedItems.length > 0; |
| ... | ... | @@ -114,12 +119,105 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro |
| 114 | 119 | }; |
| 115 | 120 | |
| 116 | 121 | return ( |
| 117 | | <ComposerBanner.Attachment |
| 118 | | className={className} |
| 119 | | data-composer-banner-drawer="true" |
| 120 | | data-chat-composer-collapsed-controls="true" |
| 121 | | > |
| 122 | | <div className={cn("relative flex flex-col-reverse", hasStack && stackExpanded && "z-50")}> |
| 122 | <> |
| 123 | {hasStack ? ( |
| 124 | <div |
| 125 | ref={noticesRef} |
| 126 | data-chat-composer-collapsed-controls="true" |
| 127 | className={cn("relative z-20 min-h-3", stackExpanded && "z-50")} |
| 128 | onPointerEnter={(event) => { |
| 129 | if (event.pointerType === "touch") return; |
| 130 | if (document.activeElement === peekRef.current) { |
| 131 | pendingFocusRef.current = "notice"; |
| 132 | } |
| 133 | setStackExpanded(true); |
| 134 | }} |
| 135 | onPointerLeave={(event) => { |
| 136 | if (!event.currentTarget.contains(document.activeElement)) setStackExpanded(false); |
| 137 | }} |
| 138 | onBlurCapture={(event) => { |
| 139 | if ( |
| 140 | !event.currentTarget.contains(event.relatedTarget) && |
| 141 | !event.currentTarget.matches(":hover") |
| 142 | ) { |
| 143 | setStackExpanded(false); |
| 144 | } |
| 145 | }} |
| 146 | onKeyDown={(event) => { |
| 147 | if (event.key !== "Escape" || !stackExpanded) return; |
| 148 | event.preventDefault(); |
| 149 | event.stopPropagation(); |
| 150 | pendingFocusRef.current = "peek"; |
| 151 | setStackExpanded(false); |
| 152 | }} |
| 153 | > |
| 154 | {showCollapsedStackCap && firstStackedItem ? ( |
| 155 | <ComposerBanner.Peek |
| 156 | ref={peekRef} |
| 157 | variant={firstStackedItem.variant} |
| 158 | aria-label="Show other notices" |
| 159 | aria-expanded={stackExpanded} |
| 160 | aria-controls={expandedItemsId} |
| 161 | aria-hidden={stackExpanded || undefined} |
| 162 | tabIndex={stackExpanded ? -1 : 0} |
| 163 | onClick={(event) => { |
| 164 | event.currentTarget.focus({ preventScroll: true }); |
| 165 | pendingFocusRef.current = "notice"; |
| 166 | setStackExpanded(true); |
| 167 | }} |
| 168 | className={cn(stackExpanded && "pointer-events-none invisible opacity-0")} |
| 169 | /> |
| 170 | ) : null} |
| 171 | <div |
| 172 | id={expandedItemsId} |
| 173 | ref={expandedItemsRef} |
| 174 | role="group" |
| 175 | aria-label="Other notices" |
| 176 | tabIndex={-1} |
| 177 | data-composer-banner-stack-expanded-items="true" |
| 178 | className={cn( |
| 179 | "grid transition-[grid-template-rows] duration-150 ease-out focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring", |
| 180 | stackExpanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]", |
| 181 | )} |
| 182 | > |
| 183 | <div className="min-h-0 overflow-hidden"> |
| 184 | <div |
| 185 | className={cn( |
| 186 | "transform-gpu space-y-2 pb-2 transition-[opacity,transform] duration-150 ease-out will-change-[opacity,transform]", |
| 187 | stackExpanded |
| 188 | ? "pointer-events-auto visible translate-y-0 opacity-100" |
| 189 | : "pointer-events-none invisible translate-y-1 opacity-0", |
| 190 | )} |
| 191 | > |
| 192 | {stackedItems.map((item) => ( |
| 193 | <div |
| 194 | key={item.id} |
| 195 | className={cn( |
| 196 | "transition-[opacity,translate] duration-220 ease-in", |
| 197 | exitingItemId === item.id |
| 198 | ? "pointer-events-none translate-y-28 opacity-0" |
| 199 | : "opacity-100", |
| 200 | )} |
| 201 | > |
| 202 | <ComposerBannerStackAlert |
| 203 | item={item} |
| 204 | attached={false} |
| 205 | exiting={exitingItemId === item.id} |
| 206 | onDismissRequest={() => requestDismiss(item)} |
| 207 | /> |
| 208 | </div> |
| 209 | ))} |
| 210 | </div> |
| 211 | </div> |
| 212 | </div> |
| 213 | </div> |
| 214 | ) : null} |
| 215 | {attachedAbove} |
| 216 | <ComposerBanner.Attachment |
| 217 | className={className} |
| 218 | data-composer-banner-drawer="true" |
| 219 | data-chat-composer-collapsed-controls="true" |
| 220 | > |
| 123 | 221 | <div |
| 124 | 222 | key={frontItem.id} |
| 125 | 223 | className={cn( |
| ... | ... | @@ -146,99 +244,8 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro |
| 146 | 244 | onDismissRequest={() => requestDismiss(frontItem)} |
| 147 | 245 | /> |
| 148 | 246 | </div> |
| 149 | | {hasStack ? ( |
| 150 | | <div |
| 151 | | ref={noticesRef} |
| 152 | | className="relative z-20 min-h-3" |
| 153 | | onPointerEnter={(event) => { |
| 154 | | if (event.pointerType === "touch") return; |
| 155 | | if (document.activeElement === peekRef.current) { |
| 156 | | pendingFocusRef.current = "notice"; |
| 157 | | } |
| 158 | | setStackExpanded(true); |
| 159 | | }} |
| 160 | | onPointerLeave={(event) => { |
| 161 | | if (!event.currentTarget.contains(document.activeElement)) setStackExpanded(false); |
| 162 | | }} |
| 163 | | onBlurCapture={(event) => { |
| 164 | | if ( |
| 165 | | !event.currentTarget.contains(event.relatedTarget) && |
| 166 | | !event.currentTarget.matches(":hover") |
| 167 | | ) { |
| 168 | | setStackExpanded(false); |
| 169 | | } |
| 170 | | }} |
| 171 | | onKeyDown={(event) => { |
| 172 | | if (event.key !== "Escape" || !stackExpanded) return; |
| 173 | | event.preventDefault(); |
| 174 | | event.stopPropagation(); |
| 175 | | pendingFocusRef.current = "peek"; |
| 176 | | setStackExpanded(false); |
| 177 | | }} |
| 178 | | > |
| 179 | | {showCollapsedStackCap && firstStackedItem ? ( |
| 180 | | <ComposerBanner.Peek |
| 181 | | ref={peekRef} |
| 182 | | variant={firstStackedItem.variant} |
| 183 | | aria-label="Show other notices" |
| 184 | | aria-expanded={stackExpanded} |
| 185 | | aria-controls={expandedItemsId} |
| 186 | | aria-hidden={stackExpanded || undefined} |
| 187 | | tabIndex={stackExpanded ? -1 : 0} |
| 188 | | onClick={(event) => { |
| 189 | | event.currentTarget.focus({ preventScroll: true }); |
| 190 | | pendingFocusRef.current = "notice"; |
| 191 | | setStackExpanded(true); |
| 192 | | }} |
| 193 | | className={cn(stackExpanded && "pointer-events-none invisible opacity-0")} |
| 194 | | /> |
| 195 | | ) : null} |
| 196 | | <div |
| 197 | | id={expandedItemsId} |
| 198 | | ref={expandedItemsRef} |
| 199 | | role="group" |
| 200 | | aria-label="Other notices" |
| 201 | | tabIndex={-1} |
| 202 | | data-composer-banner-stack-expanded-items="true" |
| 203 | | className={cn( |
| 204 | | "grid transition-[grid-template-rows] duration-150 ease-out focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring", |
| 205 | | stackExpanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]", |
| 206 | | )} |
| 207 | | > |
| 208 | | <div className="min-h-0 overflow-hidden"> |
| 209 | | <div |
| 210 | | className={cn( |
| 211 | | "transform-gpu space-y-2 pb-2 transition-[opacity,transform] duration-150 ease-out will-change-[opacity,transform]", |
| 212 | | stackExpanded |
| 213 | | ? "pointer-events-auto visible translate-y-0 opacity-100" |
| 214 | | : "pointer-events-none invisible translate-y-1 opacity-0", |
| 215 | | )} |
| 216 | | > |
| 217 | | {stackedItems.map((item) => ( |
| 218 | | <div |
| 219 | | key={item.id} |
| 220 | | className={cn( |
| 221 | | "transition-[opacity,translate] duration-220 ease-in", |
| 222 | | exitingItemId === item.id |
| 223 | | ? "pointer-events-none translate-y-28 opacity-0" |
| 224 | | : "opacity-100", |
| 225 | | )} |
| 226 | | > |
| 227 | | <ComposerBannerStackAlert |
| 228 | | item={item} |
| 229 | | attached={false} |
| 230 | | exiting={exitingItemId === item.id} |
| 231 | | onDismissRequest={() => requestDismiss(item)} |
| 232 | | /> |
| 233 | | </div> |
| 234 | | ))} |
| 235 | | </div> |
| 236 | | </div> |
| 237 | | </div> |
| 238 | | </div> |
| 239 | | ) : null} |
| 240 | | </div> |
| 241 | | </ComposerBanner.Attachment> |
| 247 | </ComposerBanner.Attachment> |
| 248 | </> |
| 242 | 249 | ); |
| 243 | 250 | } |
| 244 | 251 | |