| ... | @@ -43,9 +43,12 @@ function bannerPriority(item: ComposerBannerStackEntry) { | ... | @@ -43,9 +43,12 @@ function bannerPriority(item: ComposerBannerStackEntry) { |
| 43 | interface ComposerBannerStackProps { | 43 | interface ComposerBannerStackProps { |
| 44 | readonly className?: string; | 44 | readonly className?: string; |
| 45 | readonly items: ReadonlyArray<ComposerBannerStackEntry>; | 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 | const [stackExpanded, setStackExpanded] = useState(false); | 52 | const [stackExpanded, setStackExpanded] = useState(false); |
| 50 | const noticesRef = useRef<HTMLDivElement>(null); | 53 | const noticesRef = useRef<HTMLDivElement>(null); |
| 51 | const peekRef = useRef<HTMLButtonElement>(null); | 54 | const peekRef = useRef<HTMLButtonElement>(null); |
| ... | @@ -84,15 +87,17 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro | ... | @@ -84,15 +87,17 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro |
| 84 | } | 87 | } |
| 85 | }, [stackExpanded]); | 88 | }, [stackExpanded]); |
| 86 | | 89 | |
| 87 | if (items.length === 0) { | | |
| 88 | return null; | | |
| 89 | } | | |
| 90 | | | |
| 91 | // Activity stays attached. Urgency and severity only order the notices behind it. | 90 | // Activity stays attached. Urgency and severity only order the notices behind it. |
| 92 | const orderedItems = items.toSorted((a, b) => bannerPriority(a) - bannerPriority(b)); | 91 | const orderedItems = items.toSorted((a, b) => bannerPriority(a) - bannerPriority(b)); |
| 93 | const frontItem = orderedItems[0]; | 92 | const frontItem = orderedItems[0]; |
| 94 | if (!frontItem) { | 93 | if (!frontItem) { |
| 95 | return null; | 94 | return ( |
| | 95 | <> |
| | 96 | {null} |
| | 97 | {attachedAbove} |
| | 98 | {null} |
| | 99 | </> |
| | 100 | ); |
| 96 | } | 101 | } |
| 97 | const stackedItems = orderedItems.slice(1); | 102 | const stackedItems = orderedItems.slice(1); |
| 98 | const hasStack = stackedItems.length > 0; | 103 | const hasStack = stackedItems.length > 0; |
| ... | @@ -114,12 +119,105 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro | ... | @@ -114,12 +119,105 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro |
| 114 | }; | 119 | }; |
| 115 | | 120 | |
| 116 | return ( | 121 | return ( |
| 117 | <ComposerBanner.Attachment | 122 | <> |
| 118 | className={className} | 123 | {hasStack ? ( |
| 119 | data-composer-banner-drawer="true" | 124 | <div |
| 120 | data-chat-composer-collapsed-controls="true" | 125 | ref={noticesRef} |
| 121 | > | 126 | data-chat-composer-collapsed-controls="true" |
| 122 | <div className={cn("relative flex flex-col-reverse", hasStack && stackExpanded && "z-50")}> | 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 | <div | 221 | <div |
| 124 | key={frontItem.id} | 222 | key={frontItem.id} |
| 125 | className={cn( | 223 | className={cn( |
| ... | @@ -146,99 +244,8 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro | ... | @@ -146,99 +244,8 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro |
| 146 | onDismissRequest={() => requestDismiss(frontItem)} | 244 | onDismissRequest={() => requestDismiss(frontItem)} |
| 147 | /> | 245 | /> |
| 148 | </div> | 246 | </div> |
| 149 | {hasStack ? ( | 247 | </ComposerBanner.Attachment> |
| 150 | <div | 248 | </> |
| 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> | | |
| 242 | ); | 249 | ); |
| 243 | } | 250 | } |
| 244 | | 251 | |