@@ -67,24 +67,26 @@ const DisabledLink = styled.span(({ theme }) => ({
6767 opacity : 0.5 ,
6868} ) )
6969
70- const ValidationBadge = styled . div ( ( { theme } ) => ( {
71- display : "inline-flex" ,
72- gap : "24px" ,
73- backgroundColor : theme . custom . colors . lightGray1 ,
74- border : `1px solid ${ theme . custom . colors . lightGray2 } ` ,
75- borderRadius : "4px" ,
76- padding : "8px 16px" ,
77- ...theme . typography . subtitle3 ,
78- fontWeight : theme . typography . fontWeightMedium as number ,
79- } ) )
80-
81- const ValidCount = styled . span ( ( { theme } ) => ( {
82- color : theme . custom . colors . darkGreen ,
83- } ) )
84-
85- const InvalidCount = styled . span ( ( { theme } ) => ( {
86- color : theme . custom . colors . darkRed ,
87- } ) )
70+ const CountBadge = styled . div < { $variant : "valid" | "warning" | "default" } > (
71+ ( { theme, $variant } ) => ( {
72+ display : "inline-flex" ,
73+ backgroundColor :
74+ $variant === "warning"
75+ ? theme . custom . colors . white
76+ : theme . custom . colors . lightGray1 ,
77+ border : `1px solid ${ $variant === "warning" ? theme . custom . colors . darkRed : theme . custom . colors . lightGray2 } ` ,
78+ borderRadius : "4px" ,
79+ padding : "8px 16px" ,
80+ ...theme . typography . subtitle3 ,
81+ fontWeight : theme . typography . fontWeightMedium as number ,
82+ color :
83+ $variant === "valid"
84+ ? theme . custom . colors . darkGreen
85+ : $variant === "warning"
86+ ? theme . custom . colors . darkRed
87+ : theme . custom . colors . darkGray2 ,
88+ } ) ,
89+ )
8890
8991/**
9092 * Wrapper for the textarea + highlight overlay. We use a custom textarea here
@@ -145,7 +147,7 @@ const EmailTextarea = styled("textarea")<{ $transparent: boolean }>(
145147 background : "transparent" ,
146148 border : "none" ,
147149 outline : "none" ,
148- resize : "none " ,
150+ resize : "vertical " ,
149151 fontFamily : "inherit" ,
150152 fontSize : TA_FONT_SIZE ,
151153 lineHeight : TA_LINE_HEIGHT ,
@@ -195,7 +197,7 @@ const ResultErrorList = styled.ul(({ theme }) => ({
195197type ModalData = {
196198 validEmails : string [ ]
197199 invalidEmails : string [ ]
198- duplicateCount : number
200+ duplicateEmails : string [ ]
199201 skippedCount : number
200202}
201203
@@ -227,6 +229,7 @@ const AssignSeatsSection: React.FC<AssignSeatsSectionProps> = ({
227229 const [ modalData , setModalData ] = useState < ModalData | null > ( null )
228230 const [ result , setResult ] = useState < AssignResult | null > ( null )
229231 const [ debouncedAnnouncement , setDebouncedAnnouncement ] = useState ( "" )
232+ const [ errorAnnouncement , setErrorAnnouncement ] = useState ( "" )
230233 const fileInputRef = useRef < HTMLInputElement > ( null )
231234
232235 const bulkAssign = useBulkAssignSeats ( )
@@ -237,6 +240,7 @@ const AssignSeatsSection: React.FC<AssignSeatsSectionProps> = ({
237240 )
238241 const validCount = submitResult . valid . length
239242 const invalidCount = submitResult . invalid . length
243+ const duplicateCount = submitResult . duplicateEmails . length
240244 const hasEmails = emailInput . trim ( ) . length > 0
241245 const canSubmit = validCount > 0
242246
@@ -248,9 +252,36 @@ const AssignSeatsSection: React.FC<AssignSeatsSectionProps> = ({
248252 )
249253 const showOverlay = hasEmails
250254
251- const announcement = hasEmails
252- ? `${ validCount } valid ${ pluralize ( "email" , validCount ) } ${ invalidCount > 0 ? `, ${ invalidCount } invalid` : "" } `
253- : ""
255+ const overCapacity = validCount > availableSeats
256+ const ignoreWarning =
257+ ! overCapacity && ( invalidCount > 0 || duplicateCount > 0 )
258+ ? `. ${ [
259+ duplicateCount > 0
260+ ? `${ duplicateCount } duplicate ${ pluralize ( "email address" , duplicateCount , "email addresses" ) } `
261+ : "" ,
262+ invalidCount > 0
263+ ? `${ invalidCount } invalid ${ pluralize ( "email address" , invalidCount , "email addresses" ) } `
264+ : "" ,
265+ ]
266+ . filter ( Boolean )
267+ . join ( " and " ) } will be ignored`
268+ : ""
269+ let announcement = ""
270+ if ( hasEmails ) {
271+ const parts = [ `${ validCount } valid ${ pluralize ( "email" , validCount ) } ` ]
272+ if ( invalidCount > 0 ) parts . push ( `${ invalidCount } invalid` )
273+ if ( duplicateCount > 0 )
274+ parts . push ( `${ duplicateCount } ${ pluralize ( "duplicate" , duplicateCount ) } ` )
275+ announcement = parts . join ( ", " ) + ignoreWarning
276+ if ( overCapacity ) {
277+ const excess = validCount - availableSeats
278+ const seatsClause =
279+ availableSeats > 1
280+ ? `${ availableSeats } unassigned ${ pluralize ( "seat" , availableSeats ) } are available.`
281+ : `${ availableSeats } unassigned seat is available.`
282+ announcement += `. Error: You entered ${ validCount } ${ pluralize ( "email" , validCount ) } , but only ${ seatsClause } Remove ${ excess } more email ${ pluralize ( "address" , excess , "addresses" ) } to continue.`
283+ }
284+ }
254285
255286 // Debounce the live-region text so screen readers aren't spammed on every keystroke.
256287 useEffect ( ( ) => {
@@ -284,6 +315,47 @@ const AssignSeatsSection: React.FC<AssignSeatsSectionProps> = ({
284315 }
285316 } , [ result ] )
286317
318+ // Derive the assertive announcement from all error sources in one place so
319+ // the two concerns can't clear each other. Two separate effects sharing one
320+ // setState caused the second effect to wipe the first when resultContent
321+ // went null (e.g. user closes the success Alert).
322+ const errorAnnouncementText = useMemo ( ( ) => {
323+ if ( csvReadError ) return "Error: Could not read the file. Please try again."
324+ if ( csvNoValid ) return "Error: No valid email addresses found in this file."
325+ if ( resultContent ) {
326+ const prefix =
327+ resultContent . severity === "error" ||
328+ resultContent . severity === "warning"
329+ ? `${ resultContent . severity } : `
330+ : ""
331+ const errorDetails = resultContent . errors
332+ ?. map ( ( e ) => `${ e . email } — ${ e . detail } ` )
333+ . join ( ", " )
334+ return (
335+ prefix +
336+ resultContent . message +
337+ ( errorDetails ? ` ${ errorDetails } ` : "" )
338+ )
339+ }
340+ return ""
341+ } , [ csvReadError , csvNoValid , resultContent ] )
342+
343+ // Reset-then-set with a short delay so the assertive live region fires after
344+ // smoot-design Alert's role="alert" has been processed by NVDA. Without the
345+ // delay, both fire in the same frame and NVDA drops the live-region update.
346+ useEffect ( ( ) => {
347+ if ( ! errorAnnouncementText ) {
348+ setErrorAnnouncement ( "" )
349+ return
350+ }
351+ setErrorAnnouncement ( "" )
352+ const id = setTimeout (
353+ ( ) => setErrorAnnouncement ( errorAnnouncementText ) ,
354+ 100 ,
355+ )
356+ return ( ) => clearTimeout ( id )
357+ } , [ errorAnnouncementText ] )
358+
287359 const handleCsvChange = ( e : React . ChangeEvent < HTMLInputElement > ) => {
288360 const file = e . target . files ?. [ 0 ]
289361 if ( ! file ) return
@@ -297,17 +369,17 @@ const AssignSeatsSection: React.FC<AssignSeatsSectionProps> = ({
297369 const { data } = Papa . parse < string [ ] > ( text , {
298370 skipEmptyLines : true ,
299371 } )
300- const { valid, invalid, duplicateCount , skippedCount } =
372+ const { valid, invalid, duplicateEmails , skippedCount } =
301373 extractEmailsFromCsvRows ( data )
374+ setEmailInput ( "" )
302375 if ( valid . length === 0 ) {
303376 setCsvNoValid ( true )
304377 return
305378 }
306- setEmailInput ( "" )
307379 setModalData ( {
308380 validEmails : valid ,
309381 invalidEmails : invalid ,
310- duplicateCount ,
382+ duplicateEmails ,
311383 skippedCount,
312384 } )
313385 }
@@ -319,7 +391,7 @@ const AssignSeatsSection: React.FC<AssignSeatsSectionProps> = ({
319391 setModalData ( {
320392 validEmails : submitResult . valid ,
321393 invalidEmails : submitResult . invalid ,
322- duplicateCount : submitResult . duplicateCount ,
394+ duplicateEmails : submitResult . duplicateEmails ,
323395 skippedCount : submitResult . skippedCount ,
324396 } )
325397 }
@@ -337,10 +409,7 @@ const AssignSeatsSection: React.FC<AssignSeatsSectionProps> = ({
337409 AssignRevokeCodeRequestRequest : emails . map ( ( email ) => ( { email } ) ) ,
338410 } )
339411 setResult ( { assignedCount : data . assigned . length , errors : data . errors } )
340- // Clear the input only on a fully successful assignment.
341- if ( data . errors . length === 0 ) {
342- setEmailInput ( "" )
343- }
412+ setEmailInput ( "" )
344413 } catch {
345414 setResult ( { assignedCount : 0 , errors : null } )
346415 }
@@ -360,6 +429,11 @@ const AssignSeatsSection: React.FC<AssignSeatsSectionProps> = ({
360429 < VisuallyHidden aria-live = "polite" aria-atomic = "true" >
361430 { debouncedAnnouncement }
362431 </ VisuallyHidden >
432+ { /* Assertive region for errors — workaround for smoot-design Alert announcing
433+ only its aria-describedby ("error message") instead of the children text */ }
434+ < VisuallyHidden aria-live = "assertive" aria-atomic = "true" >
435+ { errorAnnouncement }
436+ </ VisuallyHidden >
363437 < Stack
364438 direction = { { xs : "column" , sm : "row" } }
365439 gap = "24px"
@@ -415,24 +489,32 @@ const AssignSeatsSection: React.FC<AssignSeatsSectionProps> = ({
415489 />
416490 </ EmailInputRoot >
417491 { hasEmails && (
418- < ValidationBadge id = "assign-seats-validation" aria-hidden = "true" >
419- < ValidCount > { validCount } valid</ ValidCount >
492+ < Stack
493+ direction = "row"
494+ alignItems = "center"
495+ flexWrap = "wrap"
496+ gap = "8px"
497+ >
498+ < CountBadge $variant = "valid" aria-hidden = "true" >
499+ { validCount } valid
500+ </ CountBadge >
420501 { invalidCount > 0 && (
421- < InvalidCount > { invalidCount } invalid</ InvalidCount >
502+ < CountBadge $variant = "warning" aria-hidden = "true" >
503+ { invalidCount } invalid
504+ </ CountBadge >
422505 ) }
423- </ ValidationBadge >
424- ) }
425- { validCount > availableSeats && (
426- < InvalidCount >
427- Only { availableSeats } unassigned{ " " }
428- { pluralize ( "seat" , availableSeats ) } available.
429- </ InvalidCount >
506+ { duplicateCount > 0 && (
507+ < CountBadge $variant = "warning" aria-hidden = "true" >
508+ { duplicateCount } { pluralize ( "duplicate" , duplicateCount ) }
509+ </ CountBadge >
510+ ) }
511+ </ Stack >
430512 ) }
431513 </ Stack >
432514 < ButtonWrapper >
433515 < Button
434516 variant = "primary"
435- disabled = { ! canSubmit || validCount > availableSeats }
517+ disabled = { ! canSubmit || overCapacity }
436518 onClick = { handleAssignSeats }
437519 >
438520 Assign Seats
@@ -470,6 +552,31 @@ const AssignSeatsSection: React.FC<AssignSeatsSectionProps> = ({
470552 </ DisabledLink >
471553 </ Tooltip >
472554 </ Stack >
555+ { ! overCapacity && ( invalidCount > 0 || duplicateCount > 0 ) && (
556+ < Alert severity = "warning" >
557+ { [
558+ duplicateCount > 0
559+ ? `${ duplicateCount } duplicate ${ pluralize ( "email address" , duplicateCount , "email addresses" ) } `
560+ : "" ,
561+ invalidCount > 0
562+ ? `${ invalidCount } invalid ${ pluralize ( "email address" , invalidCount , "email addresses" ) } `
563+ : "" ,
564+ ]
565+ . filter ( Boolean )
566+ . join ( " and " ) } { " " }
567+ will be ignored
568+ </ Alert >
569+ ) }
570+ { overCapacity && (
571+ < Alert severity = "error" >
572+ { availableSeats > 1
573+ ? `You entered ${ validCount } ${ pluralize ( "email" , validCount ) } , but only ${ availableSeats } unassigned ${ pluralize ( "seat" , availableSeats ) } are available.`
574+ : `You entered ${ validCount } ${ pluralize ( "email" , validCount ) } , but only ${ availableSeats } unassigned seat is available.` } { " " }
575+ Remove { validCount - availableSeats } more email{ " " }
576+ { pluralize ( "address" , validCount - availableSeats , "addresses" ) } to
577+ continue.
578+ </ Alert >
579+ ) }
473580 { csvReadError && (
474581 < Alert severity = "error" closable onClose = { ( ) => setCsvReadError ( false ) } >
475582 Could not read the file. Please try again.
@@ -506,7 +613,7 @@ const AssignSeatsSection: React.FC<AssignSeatsSectionProps> = ({
506613 validCount = { modalData . validEmails . length }
507614 availableSeats = { availableSeats }
508615 invalidEmails = { modalData . invalidEmails }
509- duplicateCount = { modalData . duplicateCount }
616+ duplicateEmails = { modalData . duplicateEmails }
510617 skippedCount = { modalData . skippedCount }
511618 />
512619 ) }
0 commit comments