# All files / src/OverlayTrigger a11yPresets.ts

97.11% Statements 101/104
81.48% Branches 44/54
91.66% Functions 11/12
96.47% Lines 82/85

Press n or j to go to the next uncovered block, b, p or k for the previous block.

    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
    39265x
    65x
    65x

    65x
    2110x

    65x

    325x

    325x

    24x

    24x
    24x

    65x
    1407x

    328x

    24x

    1055x

    65x

    1429x
    1429x
    1429x
    1429x
    1429x
    1429x
    1429x

    1429x

    1429x
    1429x

    1429x

    65x
    3x
    3x
    3x
    3x
    3x
    3x
    3x
    3x
    3x

    3x
    3x

    3x

    3x
    3x

    3x

    3x

    65x
    1420x
    1420x
    1420x
    1420x
    1420x
    1420x
    1420x

    1420x
    1420x

    1420x

    1420x

    1420x
    396x

    1420x

    65x
    1401x
    1401x
    1401x
    1401x
    1401x

    1401x
    1401x
    1401x
    1401x

    1401x

    1401x

    1401x

    1401x

    1401x

    1401x
    1401x

    1401x

    1401x

    1401x

    1401x

    1401x

     import { ariaHideOutside } from '@react-aria/overlays'
    import { useId, useLabels } from '@react-aria/utils'
    import React, { AriaAttributes } from 'react'
    import { OverlayTriggerRole } from './OverlayTrigger.types'

    const emptyObj = {}
    const returnEmpty: GetA11yProps = () => ({})

    type GetA11yProps = (props: {
      role: OverlayTriggerRole
      isVisible: boolean
      overlayId: string
    }) => AriaAttributes & { id?: string }

    const a11yPresets: Record<
      'tooltip' | 'haspopup',
      {
        getOverlayProps: GetA11yProps
        getTriggerProps: GetA11yProps
      }
    > = {
      haspopup: {
        getOverlayProps: ({ role, overlayId }) => ({
          id: overlayId,
          role,
        }),
        getTriggerProps: ({ role, isVisible, overlayId }) => ({
          'aria-expanded': isVisible,
          'aria-controls': isVisible ? overlayId : undefined,
          'aria-haspopup': role as 'dialog' | 'listbox' | 'menu',
        }),
      },
      tooltip: {
        getOverlayProps: ({ overlayId }) => ({
          role: 'tooltip',
          id: overlayId,
        }),
        getTriggerProps: ({ isVisible, overlayId }) =>
          isVisible
            ? {
                'aria-describedby': overlayId,
              }
            : {},
      },
    }

    export function getA11yPreset(role?: OverlayTriggerRole) {
      switch (role) {
        case 'dialog':
        case 'listbox':
        case 'menu':
          return a11yPresets.haspopup
        case 'tooltip':
          return a11yPresets.tooltip
        case 'none':
          return {
            getOverlayProps: (() => ({ role: 'none' })) as GetA11yProps,
            getTriggerProps: returnEmpty,
          }
        default:
          return {
            getOverlayProps: returnEmpty,
            getTriggerProps: returnEmpty,
          }
      }
    }

    type LabelConfig = {
      'aria-describedby'?: string
      'aria-details'?: string
      'aria-labelledby'?: string
      'aria-label'?: string
      /**
       * ID for the widget itself
       */
      id?: string
    }

    type DialogBase = LabelConfig & {
      isOpen: boolean
      role?: 'dialog'
    }

    type NonModalDialogConfig = DialogBase & {
      isModal?: never | false
    }

    type ModalDialogConfig = DialogBase & {
      /**
       *
       * To enable `ariaHideOutside`. Set `isModal` to false to disable.
       * @a11y Replicates `aria-modal` inert outside content
       * @default false
       */
      isModal: true
      ref: React.RefObject<HTMLElement>
    }

    type ModalDialogLikeConfig = NonModalDialogConfig | ModalDialogConfig

    type OverlayTriggerA11yConfig = LabelConfig & {
      /**
       * Assure `aria-hidden` is not applied. A code side-effect
       * from `ariaHideOutside` hiding everthing outside the scope.
       */
      alwaysAriaVisible?: boolean
      /**
       * When properties should be passed to the overlay UI itself,
       * and not the wrapper element.
       */
      canPropOverlayUp: boolean
      isOpen: boolean
      role?: OverlayTriggerRole
      // ref: React.RefObject<HTMLElement> // Only necessary for modal
    }

    /**
     * Manages labelling for an element and the other DOM. Defaults an ID for `aria-labelledby` usage.
     *
     * When `aria-label` and `aria-labelledby` both exist, it combines them into `aria-labelledby` for a screen reader chain.
     * @link [W3 naming with aria-labelledby](https://www.w3.org/WAI/ARIA/apg/practices/names-and-descriptions/#naming_with_aria-labelledby)
     */
    export function useLabelled(props?: LabelConfig) {
      const {
        'aria-describedby': ariaDescribedby,
        'aria-details': ariaDetails,
        'aria-labelledby': ariaLabelledby,
        'aria-label': ariaLabel,
        id,
      } = props || (emptyObj as LabelConfig)
      const ariaLabelOnly = ariaLabel && !ariaLabelledby

      // Generate an ID. We want to use this unless they are using only aria-label
      const labelledId = useId(ariaLabelledby)

      // Merges aria-label and aria-labelledby into aria-labelledby when both exist
      const widgetId = useId(id)
      let fieldProps = useLabels({
        'aria-label': ariaLabel,
        'aria-labelledby': ariaLabelOnly ? undefined : labelledId,
        id: widgetId,
      })

      return {
        descriptionProps: { id: ariaDescribedby },
        labelProps: { id: ariaLabelOnly ? undefined : labelledId },
        widgetProps: {
          ...fieldProps,
          'aria-describedby': ariaDescribedby,
          'aria-details': ariaDetails,
        },
      }
    }

    /**
     * Cover the label links for the trigger (button), the popup element (dialog), and the popup element title (heading).
     * Similar to [React Aria useOverlayTrigger](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-aria/overlays/src/useOverlayTrigger.ts)
     * but with element title support.
     * @link [MDN aria-haspopup](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-haspopup)
     */
    export function useLabelledPopup({
      'aria-describedby': ariaDescribedby,
      'aria-details': ariaDetails,
      'aria-labelledby': ariaLabelledby,
      'aria-label': ariaLabel,
      id: id_,
      isOpen,
      type = 'button',
      popupRole,
      popupId: popupId_,
    }: LabelConfig & {
      isOpen: boolean
      popupId?: string
      type?: 'button' | 'combobox' // Role? Not used. Show concept that popups manage 2 roles
      popupRole: 'dialog' | 'menu' | 'listbox'
    }) {
      /** Web spec default for aria-haspopup=true is menu, unless element has role combobox, which have an implicit aria-haspopup value of listbox. */
      // const popupRole = popupRole_ || type === 'combobox' ? 'listbox' : 'menu'
      const id = useId(id_)
      const popupId = useId(popupId_)

      const presetArgs = {
        isVisible: isOpen,
        role: popupRole,
        overlayId: popupId,
      }

      const triggerProps = getA11yPreset(popupRole).getTriggerProps?.(presetArgs)
      const overlayProps = getA11yPreset(popupRole).getOverlayProps?.(presetArgs)

      const { labelProps, widgetProps } = useLabelled({
        'aria-describedby': ariaDescribedby,
        'aria-details': ariaDetails,
        'aria-labelledby': ariaLabelledby,
        'aria-label': ariaLabel,
        id,
      })

      return {
        labelProps,
        popupProps: { role: popupRole, ...widgetProps, ...overlayProps },
        triggerProps,
      }
    }

    /**
     * For dialog experiences:
     * - Has role dialog and aria linked title props
     * - Focus management props to work with FocusScope
     *
     * For modal dialog experiences (full screen locked experiences):
     * - Hides old and new content outside with aria-hidden with ariaHideOutside (like aria-modal)
     * - (it will in breaking) Prevent scroll
     *
     * For either experiences, you still MUST support:
     * - have Escape key to close (It should!)
     * - have click outside, like scrim click
     *
     * _More about 'modal dialog' and `isModal` not using `aria-modal`:_
     * Setting aria-modal="true" tells assistive technologies to let the user know the ability to interact with,
     * or access other content on the page requires the modal dialog to be closed or otherwise lose focus.
     *  Modal dialogs are when content is displayed and the user's interaction is limited to only that section until it is dismissed.
     * [MDN aria modal](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-modal).
     * To support the most, like Narrator in Edge, Core React uses `ariaHideOutside` to set `aria-hidden` on
     * all elements outside the dialog, and **all elements outside added while opened**, so the dialog will act as a
     * _modal_ even without `aria-modal` on the dialog itself. See Future Considerations below.
     * [Screen Reader aria modal support table](https://a11ysupport.io/tech/aria/aria-modal_attribute) - [MS Narrator Edge aria modal support](https://a11ysupport.io/tests/apg__modal-dialog-example/aria__aria-modal_attribute/convey_presence/narrator/edge)
     * _Future Considerations:_ Replace ariaHideOutside with aria-modal="true".
     * Using aria-modal, then we would need to involve Portal and so tooltips
     * or Toasts could mount within the visible aria-modal container.
     */

    export function useModalDialogLike({
      'aria-describedby': ariaDescribedby,
      'aria-details': ariaDetails,
      'aria-labelledby': ariaLabelledby,
      'aria-label': ariaLabel,
      id,
      isModal,
      isOpen,
      // @ts-expect-error
      ref,
      role = 'dialog',
    }: ModalDialogLikeConfig) {
      const { labelProps, widgetProps } = useLabelled({
        'aria-describedby': ariaDescribedby,
        'aria-details': ariaDetails,
        'aria-labelledby': ariaLabelledby,
        'aria-label': ariaLabel,
      })

      const dialogProps = {
        // 'aria-modal': true,
        ...widgetProps,
        id,
        role,
        tabIndex: -1,
      }

      // usePreventScroll({
      //   isDisabled: isModal ? !isOpen : true,
      // })

      // Fills aria-modal=true
      React.useLayoutEffect(() => {
        Iif (isModal && isOpen && ref.current) {
          // Could add additional visible element refs here
          return ariaHideOutside([ref.current])
        }
      }, [isModal, isOpen, ref])

      return {
        dialogProps,
        labelProps,
        focusScopeProps: {
          autoFocus: true,
          contain: true,
          restoreFocus: true,
        },
      }
    }

    /**
     * Takes many roles and determines props necessary for DOM/components.
     * **Side effect of role="dialog"**, current and any content added later
     * outside of the element (like by portals) will get `aria-hidden=true`
     * to replace the `aria-modal=true` `inert` nature.
     * @see useModalDialogLike */
    export function useOverlayTriggerA11y({
      alwaysAriaVisible,
      'aria-describedby': ariaDescribedby,
      'aria-details': ariaDetails,
      'aria-labelledby': ariaLabelledby,
      'aria-label': ariaLabel,
      // can we remove this? have it role based? be optional?
      canPropOverlayUp,
      id: id_,
      isOpen,
      role,
    }: OverlayTriggerA11yConfig) {
      const isDialog = role === 'dialog'

      const id = useId(id_)

      const { getTriggerProps, getOverlayProps } = getA11yPreset(role)

      const triggerProps = getTriggerProps?.({
        isVisible: isOpen,
        role: role!,
        overlayId: id,
      })

      const overlayA11yProps = getOverlayProps?.({
        isVisible: isOpen,
        role: role!,
        overlayId: id,
      })

      const wrapperA11yProps = !canPropOverlayUp ? overlayA11yProps : emptyObj
      const overlayProps = canPropOverlayUp ? overlayA11yProps : emptyObj

      const { dialogProps, labelProps, focusScopeProps } = useModalDialogLike({
        'aria-describedby': ariaDescribedby,
        'aria-details': ariaDetails,
        'aria-labelledby': ariaLabelledby,
        'aria-label': ariaLabel,
        id,
        isModal: false,
        isOpen,
      })

      /**  This is a code side-effect from ariaHideOutside hiding everthing */
      const portalProps =
        role === 'tooltip' || alwaysAriaVisible // || !role
          ? { 'data-react-aria-top-layer': true, 'data-live-announcer': true }
          : emptyObj

      /**
       * If it is a dialog, we can merge the dialog props with any haspopup props
       */
      const wrapperFinalProps = isDialog
        ? {
            ...wrapperA11yProps,
            ...dialogProps,
          }
        : wrapperA11yProps

      /**
       * If it is a dialog, we have opinions on `FocusScope` props.
       */
      const focusScopeFinalProps = isDialog
        ? focusScopeProps
        : (emptyObj as {
            autoFocus?: boolean
            contain?: boolean
            restoreFocus?: boolean
          })

      return {
        focusScopeProps: focusScopeFinalProps,
        labelProps,
        overlayProps,
        portalProps,
        triggerProps,
        wrapperProps: wrapperFinalProps,
      }
    }

    // WIP Example combobox
    // function useCombox({
    //   controls,
    //   isOpen = false,
    // }: {
    //   controls: 'dialog' | 'menu' | 'listbox'
    //   isOpen: boolean
    // }) {
    //   const { labelProps, popupProps, triggerProps } = useLabelledPopup({
    //     role: controls,
    //     type: 'combobox',
    //     isOpen,
    //   })
    // dispatch between elements.
    // NOTE combobox has two labels, that could be different
    // one for the trigger input (typically the form label)
    // second for the thing it controls (listbox, dialog)
    // - label id=1
    // - input role='combobox' aria-labelledby=1
    // - div role={controls} aria-label
    // Also, any icon only buttons like clear or open should have a
    // label and -1 tabindex. No nested interactive roles.
    // }