-
Notifications
You must be signed in to change notification settings - Fork 9.3k
/
dialog.tsx
810 lines (702 loc) · 26.3 KB
/
dialog.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
import * as React from 'react'
import classNames from 'classnames'
import { DialogHeader } from './header'
import { createUniqueId, releaseUniqueId } from '../lib/id-pool'
import { getTitleBarHeight } from '../window/title-bar'
import { isTopMostDialog } from './is-top-most'
import { isMacOSVentura } from '../../lib/get-os'
export interface IDialogStackContext {
/** Whether or not this dialog is the top most one in the stack to be
* interacted with by the user. This will also determine if event listeners
* will be active or not. */
isTopMost: boolean
}
/**
* The DialogStackContext is used to communicate between the `Dialog` and the
* `App` information that is mostly unique to the `Dialog` component such as
* whether it is at the top of the popup stack. Some, but not the vast majority,
* custom popup components in between may also utilize this to enable and
* disable event listeners in response to changes in whether it is the top most
* popup.
*
* NB *** React.Context is not the preferred method of passing data to child
* components for this code base. We are choosing to use it here as implementing
* prop drilling would be extremely tedious and would lead to adding `Dialog`
* props on 60+ components that would not otherwise use them. ***
*
*/
export const DialogStackContext = React.createContext<IDialogStackContext>({
isTopMost: false,
})
/**
* The time (in milliseconds) from when the dialog is mounted
* until it can be dismissed. See the isAppearing property in
* IDialogState for more information.
*/
const dismissGracePeriodMs = 250
/**
* The time (in milliseconds) that we should wait after focusing before we
* re-enable click dismissal.
*/
const DisableClickDismissalDelay = 500
/**
* Title bar height in pixels
*/
const titleBarHeight = getTitleBarHeight()
interface IDialogProps {
/**
* An optional dialog title. Most, if not all dialogs should have
* this. When present the Dialog renders a DialogHeader element
* containing an icon (if the type prop warrants it), the title itself
* and a close button (if the dialog is dismissable).
*
* By omitting this consumers may use their own custom DialogHeader
* for when the default component doesn't cut it.
*/
readonly title?: string | JSX.Element
/**
* Whether or not the dialog should be dismissable. A dismissable dialog
* can be dismissed either by clicking on the backdrop or by clicking
* the close button in the header (if a header was specified). Dismissal
* will trigger the onDismissed event which callers must handle and pass
* on to the dispatcher in order to close the dialog.
*
* A non-dismissable dialog can only be closed by means of the component
* implementing a dialog. An example would be a critical error or warning
* that requires explicit user action by for example clicking on a button.
*
* Defaults to true if omitted.
*/
readonly dismissable?: boolean
/**
* Event triggered when the dialog is dismissed by the user in the
* ways described in the dismissable prop.
*/
readonly onDismissed?: () => void
/**
* An optional id for the rendered dialog element.
*/
readonly id?: string
/**
* An optional dialog type. A warning or error dialog type triggers custom
* styling of the dialog, see _dialog.scss for more detail.
*
* Defaults to 'normal' if omitted
*/
readonly type?: 'normal' | 'warning' | 'error'
/**
* An event triggered when the dialog form is submitted. All dialogs contain
* a top-level form element which can be triggered through a submit button.
*
* Consumers should handle this rather than subscribing to the onClick event
* on the button itself since there may be other ways of submitting a specific
* form (such as Ctrl+Enter).
*/
readonly onSubmit?: () => void
/**
* An optional className to be applied to the rendered dialog element.
*/
readonly className?: string
/**
* Whether or not the dialog should be disabled. All dialogs wrap their
* content in a <fieldset> element which, when disabled, causes all descendant
* form elements and buttons to also become disabled. This is useful for
* consumers implementing a typical save dialog where the save action isn't
* instantaneous (such as a sign in dialog) and they need to ensure that the
* user doesn't continue mutating the form state or click buttons while the
* save/submit action is in progress. Note that this does not prevent the
* dialog from being dismissed.
*/
readonly disabled?: boolean
/**
* Whether or not the dialog contents are currently involved in processing
* data, executing an asynchronous operation or by other means working.
* Setting this value will render a spinning progress icon in the dialog
* header (if the dialog has a header). Note that the spinning icon
* will temporarily replace the dialog icon (if present) for the duration
* of the loading operation.
*/
readonly loading?: boolean
/** Whether or not to override focus of first element with close button */
readonly focusCloseButtonOnOpen?: boolean
}
/**
* If role is alertdialog, ariaDescribedBy is required.
*/
interface IAlertDialogProps extends IDialogProps {
/** This is used to point to an element containing content pertinent to the
* users workflow. This should be provided for dialogs that are alerts or
* confirmations so that that the information that is interrupting the user's
* workflow is screen reader announced and acquire a response */
readonly ariaDescribedBy: string
/** By default, a dialog has role of "dialog" and requires the use of an
* "aria-label" or "aria-labelledby" to accessibily announce the title or
* purpose of the header. This is typically accomplished by providing the
* `title` prop and the dialog component will take care of adding the
* `aria-labelledby` attribute.
*
* However, if the dialog is an alert or confirmation dialog we should use the
* role of `alertdialog` AND the `ariaDescribedBy` prop should be provided
* containing the id of the element with the information required by the user
* to proceed or be made aware of to ensure it is also read by screen readers.
*
*
* https://www.w3.org/TR/wai-aria-1.1/#alertdialog
* "An alert dialog is a modal dialog that interrupts the user's workflow to
* communicate an important message and acquire a response. Examples include
* action confirmation prompts and error message confirmations. The
* alertdialog role enables assistive technologies and browsers to distinguish
* alert dialogs from other dialogs so they have the option of giving alert
* dialogs special treatment, such as playing a system alert sound."
* */
readonly role: 'alertdialog'
}
/**
* If role is undefined or dialog, ariaDescribedBy is optional.
*/
interface IDescribedByDialogProps extends IDialogProps {
/** This is used to point to an element containing content pertinent to the
* users workflow. This should be provided for dialogs that are alerts or
* confirmations so that that the information that is interrupting the user's
* workflow is screen reader announced and acquire a response */
readonly ariaDescribedBy?: string
/** By default, a dialog has role of "dialog". This is only required for a
* role of 'alertdialog' in which case `ariaDescribedBy` must also be
* provided */
readonly role?: 'dialog'
}
/** Interface union to force usage of `ariaDescribedBy` if role of `alertdialog`
* is used */
type DialogProps = IAlertDialogProps | IDescribedByDialogProps
interface IDialogState {
/**
* When a dialog is shown we wait for a few hundred milliseconds before
* acknowledging a dismissal in order to avoid people accidentally dismissing
* dialogs that appear as they're doing other things. Since the entire
* backdrop of a dialog can be clicked to dismiss all it takes is one rogue
* click and the dialog is gone. This is less than ideal if we're in the
* middle of displaying an important error message.
*
* This state boolean is used to keep track of whether we're still in that
* grace period or not.
*/
readonly isAppearing: boolean
/**
* An optional id for the h1 element that contains the title of this
* dialog. Used to aid in accessibility by allowing the h1 to be referenced
* in an aria-labeledby/aria-describedby attributed. Undefined if the dialog
* does not have a title or the component has not yet been mounted.
*/
readonly titleId?: string
}
/**
* A general purpose, versatile, dialog component which utilizes the new
* <dialog> element. See https://demo.agektmr.com/dialog/
*
* A dialog is opened as a modal that prevents keyboard or pointer access to
* underlying elements. It's not possible to use the tab key to move focus
* out of the dialog without first dismissing it.
*/
export class Dialog extends React.Component<DialogProps, IDialogState> {
public static contextType = DialogStackContext
public declare context: React.ContextType<typeof DialogStackContext>
private checkIsTopMostDialog = isTopMostDialog(
() => {
this.onDialogIsTopMost()
},
() => {
this.onDialogIsNotTopMost()
}
)
private dialogElement: HTMLDialogElement | null = null
private dismissGraceTimeoutId?: number
private disableClickDismissalTimeoutId: number | null = null
private disableClickDismissal = false
/**
* Resize observer used for tracking width changes and
* refreshing the internal codemirror instance when
* they occur
*/
private readonly resizeObserver: ResizeObserver
private resizeDebounceId: number | null = null
public constructor(props: DialogProps) {
super(props)
this.state = { isAppearing: true }
// Observe size changes and let codemirror know
// when it needs to refresh.
this.resizeObserver = new ResizeObserver(this.scheduleResizeEvent)
}
private scheduleResizeEvent = () => {
if (this.resizeDebounceId !== null) {
cancelAnimationFrame(this.resizeDebounceId)
this.resizeDebounceId = null
}
this.resizeDebounceId = requestAnimationFrame(this.onResized)
}
/**
* Attempt to ensure that the entire dialog is always visible. Chromium
* takes care of positioning the dialog when we initially show it but
* subsequent resizes of either the dialog (such as when switching tabs
* in the preferences dialog) or the Window doesn't affect positioning.
*/
private onResized = () => {
if (!this.dialogElement) {
return
}
const { offsetTop, offsetHeight } = this.dialogElement
// Not much we can do if the dialog is bigger than the window
if (offsetHeight > window.innerHeight - titleBarHeight) {
return
}
const padding = 10
const overflow = offsetTop + offsetHeight + padding - window.innerHeight
if (overflow > 0) {
const top = Math.max(titleBarHeight, offsetTop - overflow)
this.dialogElement.style.top = `${top}px`
}
}
private clearDismissGraceTimeout() {
if (this.dismissGraceTimeoutId !== undefined) {
window.clearTimeout(this.dismissGraceTimeoutId)
this.dismissGraceTimeoutId = undefined
}
}
private scheduleDismissGraceTimeout() {
this.clearDismissGraceTimeout()
this.dismissGraceTimeoutId = window.setTimeout(
this.onDismissGraceTimer,
dismissGracePeriodMs
)
}
private onDismissGraceTimer = () => {
this.setState({ isAppearing: false })
this.dialogElement?.dispatchEvent(
new CustomEvent('dialog-appeared', {
bubbles: true,
cancelable: false,
})
)
}
private isDismissable() {
return this.props.dismissable === undefined || this.props.dismissable
}
private updateTitleId() {
if (this.state.titleId) {
releaseUniqueId(this.state.titleId)
this.setState({ titleId: undefined })
}
if (this.props.title) {
// createUniqueId handles static strings fine, so in the case of receiving
// a JSX element for the title we can just pass in a fixed value rather
// than trying to generate a string from an arbitrary element
const id = typeof this.props.title === 'string' ? this.props.title : '???'
this.setState({
titleId: createUniqueId(`Dialog_${this.props.id}_${id}`),
})
}
}
public componentWillMount() {
this.updateTitleId()
}
public componentDidMount() {
this.checkIsTopMostDialog(this.context.isTopMost)
}
protected onDialogIsTopMost() {
if (this.dialogElement == null) {
return
}
if (!this.dialogElement.open) {
this.dialogElement.showModal()
}
// Provide an event that components can subscribe to in order to perform
// tasks such as re-layout after the dialog is visible
this.dialogElement.dispatchEvent(
new CustomEvent('dialog-show', {
bubbles: true,
cancelable: false,
})
)
this.setState({ isAppearing: true })
this.scheduleDismissGraceTimeout()
this.focusFirstSuitableChild()
window.addEventListener('focus', this.onWindowFocus)
this.resizeObserver.observe(this.dialogElement)
window.addEventListener('resize', this.scheduleResizeEvent)
}
protected onDialogIsNotTopMost() {
if (this.dialogElement !== null && this.dialogElement.open) {
this.dialogElement?.close()
}
this.clearDismissGraceTimeout()
window.removeEventListener('focus', this.onWindowFocus)
document.removeEventListener('mouseup', this.onDocumentMouseUp)
this.resizeObserver.disconnect()
window.removeEventListener('resize', this.scheduleResizeEvent)
}
/**
* Attempts to move keyboard focus to the first _suitable_ child of the
* dialog.
*
* The original motivation for this function is that while the order of the
* Ok, and Cancel buttons differ between platforms (see OkCancelButtonGroup)
* we don't want to accidentally put keyboard focus on the destructive
* button (like the Ok button in the discard changes dialog) but rather
* on the non-destructive action. This logic originates from the macOS
* human interface guidelines
*
* From https://developer.apple.com/design/human-interface-guidelines/macos/windows-and-views/dialogs/:
*
* "Users sometimes press Return merely to dismiss a dialog, without
* reading its content, so it’s crucial that a default button initiate
* a harmless action. [...] when a dialog may result in a destructive
* action, Cancel can be set as the default button."
*
* The same guidelines also has this to say about focus:
*
* "Set the initial focus to the first location that accepts user input.
* Doing so lets the user begin entering data immediately, without needing
* to click a specific item like a text field or list."
*
* In attempting to follow the guidelines outlined above we follow a priority
* order in determining the first suitable child.
*
* 1. The element with the lowest positive tabIndex
* This might sound counterintuitive but imagine the following pseudo
* dialog this would be button D as button D would be the first button
* to get focused when hitting Tab.
*
* <dialog>
* <button>A</button>
* <button tabIndex=3>B</button>
* <button tabIndex=2>C</button>
* <button tabIndex=1>D</button>
* </dialog>
*
* 2. The first element which is either implicitly keyboard focusable (like a
* text input field) or explicitly focusable through tabIndex=0 (like a TabBar
* tab)
*
* 3. The first submit button. We use this as a proxy for what macOS HIG calls
* "default button". It's not the same thing but for our purposes it's close
* enough.
*
* 4. Any remaining button
*
* 5. The dialog close button
*
*/
public focusFirstSuitableChild() {
const dialog = this.dialogElement
if (dialog === null) {
return
}
const selector = [
'input:not([type=hidden]):not(:disabled):not([tabindex="-1"])',
'textarea:not(:disabled):not([tabindex="-1"])',
'button:not(:disabled):not([tabindex="-1"])',
'[tabindex]:not(:disabled):not([tabindex="-1"])',
].join(', ')
// The element which has the lowest explicit tab index (i.e. greater than 0)
let firstExplicit: { 0: number; 1: HTMLElement | null } = [Infinity, null]
// First submit button
let firstSubmitButton: HTMLElement | null = null
// The first button-like element (input, submit, reset etc)
let firstButton: HTMLElement | null = null
// The first element which is either implicitly keyboard focusable (like a
// text input field) or explicitly focusable through tabIndex=0 (like an
// anchor tag masquerading as a button)
let firstTabbable: HTMLElement | null = null
const closeButton = dialog.querySelector(
':scope > div.dialog-header button.close'
)
if (
closeButton instanceof HTMLElement &&
this.props.focusCloseButtonOnOpen
) {
closeButton.focus()
return
}
const excludedInputTypes = [
':not([type=button])',
':not([type=submit])',
':not([type=reset])',
':not([type=hidden])',
':not([type=radio])',
]
const inputSelector = `input${excludedInputTypes.join('')}, textarea`
const buttonSelector =
'input[type=button], input[type=submit] input[type=reset], button'
const submitSelector = 'input[type=submit], button[type=submit]'
for (const candidate of dialog.querySelectorAll(selector)) {
if (!(candidate instanceof HTMLElement)) {
continue
}
const tabIndex = parseInt(candidate.getAttribute('tabindex') || '', 10)
if (tabIndex > 0 && tabIndex < firstExplicit[0]) {
firstExplicit = [tabIndex, candidate]
} else if (
firstTabbable === null &&
(tabIndex === 0 || candidate.matches(inputSelector))
) {
firstTabbable = candidate
} else if (
firstSubmitButton === null &&
candidate.matches(submitSelector)
) {
firstSubmitButton = candidate
} else if (
firstButton === null &&
candidate.matches(buttonSelector) &&
candidate !== closeButton
) {
firstButton = candidate
}
}
const focusCandidates = [
firstExplicit[1],
firstTabbable,
firstSubmitButton,
firstButton,
closeButton,
]
for (const focusCandidate of focusCandidates) {
if (focusCandidate instanceof HTMLElement) {
focusCandidate.focus()
break
}
}
}
private onWindowFocus = () => {
// On Windows and Linux, a click which focuses the window will also get
// passed down into the DOM. But we don't want to dismiss the dialog based
// on that click. See https://github.com/desktop/desktop/issues/2486.
// macOS normally automatically disables "click-through" behavior but
// we've intentionally turned that off so we need to apply the same
// behavior regardless of platform.
// See https://github.com/desktop/desktop/pull/3843.
this.clearClickDismissalTimer()
this.disableClickDismissal = true
this.disableClickDismissalTimeoutId = window.setTimeout(() => {
this.disableClickDismissal = false
this.disableClickDismissalTimeoutId = null
}, DisableClickDismissalDelay)
}
private clearClickDismissalTimer() {
if (this.disableClickDismissalTimeoutId) {
window.clearTimeout(this.disableClickDismissalTimeoutId)
this.disableClickDismissalTimeoutId = null
}
}
public componentWillUnmount() {
if (this.state.titleId) {
releaseUniqueId(this.state.titleId)
}
this.checkIsTopMostDialog(false)
}
public componentDidUpdate(prevProps: DialogProps) {
if (!this.props.title && this.state.titleId) {
this.updateTitleId()
}
this.checkIsTopMostDialog(this.context.isTopMost)
}
private onDialogCancel = (e: Event | React.SyntheticEvent) => {
e.preventDefault()
this.onDismiss()
}
private onDialogMouseDown = (e: React.MouseEvent<HTMLElement>) => {
if (e.defaultPrevented) {
return
}
if (this.isDismissable() === false) {
return
}
// This event handler catches the onClick event of buttons in the
// dialog. Ie, if someone hits enter inside the dialog form an onClick
// event will be raised on the the submit button which isn't what we
// want so we'll make sure that the original target for the event is
// our own dialog element.
if (e.target !== this.dialogElement) {
return
}
// Ignore the first click right after the window's been focused. It could
// be the click that focused the window, in which case we don't wanna
// dismiss the dialog.
if (this.disableClickDismissal) {
this.disableClickDismissal = false
this.clearClickDismissalTimer()
return
}
if (!this.mouseEventIsInsideDialog(e)) {
// The user has pressed down on their pointer device outside of the
// dialog (i.e. on the backdrop). Now we subscribe to the global
// mouse up event where we can make sure that they release the pointer
// device on the backdrop as well.
document.addEventListener('mouseup', this.onDocumentMouseUp, {
once: true,
})
}
}
private mouseEventIsInsideDialog(
e: React.MouseEvent<HTMLElement> | MouseEvent
) {
// it's possible that we've been unmounted
if (this.dialogElement === null) {
return false
}
const isInTitleBar = e.clientY <= titleBarHeight
if (isInTitleBar) {
return false
}
// Figure out if the user clicked on the backdrop or in the dialog itself.
const rect = this.dialogElement.getBoundingClientRect()
// http://stackoverflow.com/a/26984690/2114
const isInDialog =
rect.top <= e.clientY &&
e.clientY <= rect.top + rect.height &&
rect.left <= e.clientX &&
e.clientX <= rect.left + rect.width
return isInDialog
}
/**
* Subscribed to from the onDialogMouseDown when the user
* presses down on the backdrop, ensures that we only dismiss
* the dialog if they release their pointer device over the
* backdrop as well (as opposed to over the dialog itself).
*/
private onDocumentMouseUp = (e: MouseEvent) => {
if (!e.defaultPrevented && !this.mouseEventIsInsideDialog(e)) {
e.preventDefault()
this.onDismiss()
}
}
private onDialogRef = (e: HTMLDialogElement | null) => {
// We need to explicitly subscribe to and unsubscribe from the dialog
// element as react doesn't yet understand the element and which events
// it has.
if (!e) {
if (this.dialogElement) {
this.dialogElement.removeEventListener('cancel', this.onDialogCancel)
}
} else {
e.addEventListener('cancel', this.onDialogCancel)
}
this.dialogElement = e
}
private onKeyDown = (event: React.KeyboardEvent) => {
if (event.defaultPrevented) {
return
}
const shortcutKey = __DARWIN__ ? event.metaKey : event.ctrlKey
if ((shortcutKey && event.key === 'w') || event.key === 'Escape') {
this.onDialogCancel(event)
}
}
private onDismiss = () => {
if (this.isDismissable() && !this.state.isAppearing) {
if (this.props.onDismissed) {
this.props.onDismissed()
}
}
}
private onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (this.props.onSubmit) {
this.props.onSubmit()
} else {
this.onDismiss()
}
}
private renderHeader() {
if (!this.props.title) {
return null
}
return (
<DialogHeader
title={this.props.title}
titleId={this.state.titleId}
dismissable={this.isDismissable()}
onDismissed={this.onDismiss}
loading={this.props.loading}
/>
)
}
/**
* Gets the aria-labelledby and aria-describedby attributes for the dialog
* element.
*
* The correct semantics are that the dialog element should have the
* aria-labelledby and the aria-describedby is optional unless the dialog has
* a role of alertdialog, in which case both are required.
*
* However, macOs Ventura introduced a regression in that:
*
* For role of 'dialog' (default), the aria-labelledby is not announced and
* if provided prevents the aria-describedby from being announced. Thus,
* this method will add the aria-labelledby to the aria-describedby in this
* case.
*
* For role of 'alertdialog', the aria-labelledby is announced but not the
* aria-describedby. Thus, this method will add both to the
* aria-labelledby.
*
* Neither of the above is semantically correct tho, hopefully, macOs will be
* fixed in a future release. The issue is known for macOS versions 13.0 to
* the current version of 13.5 as of 2023-07-31.
*
* A known macOS behavior is that if two ids are provided to the
* aria-describedby only the first one is announced with a note about the
* second one existing. This currently does not impact us as we only provide
* one id for non-alert dialogs and the alert dialogs are handled with the
* `aria-labelledby` where both ids are announced.
*
*/
private getAriaAttributes() {
if (!isMacOSVentura()) {
// correct semantics for all other os
return {
'aria-labelledby': this.state.titleId,
'aria-describedby': this.props.ariaDescribedBy,
}
}
if (this.props.role === 'alertdialog') {
return {
'aria-labelledby': `${this.state.titleId} ${this.props.ariaDescribedBy}`,
}
}
return {
'aria-describedby': `${this.state.titleId} ${
this.props.ariaDescribedBy ?? ''
}`,
}
}
public render() {
const className = classNames(
{
error: this.props.type === 'error',
warning: this.props.type === 'warning',
},
this.props.className,
'tooltip-host'
)
return (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<dialog
ref={this.onDialogRef}
id={this.props.id}
role={this.props.role}
onMouseDown={this.onDialogMouseDown}
onKeyDown={this.onKeyDown}
className={className}
{...this.getAriaAttributes()}
tabIndex={-1}
>
{this.renderHeader()}
<form onSubmit={this.onSubmit} onReset={this.onDismiss}>
<fieldset disabled={this.props.disabled}>
{this.props.children}
</fieldset>
</form>
</dialog>
)
}
}