Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Can the History API be utilized in a11y-dialog? #363

Open
piotrpog opened this issue Jun 13, 2022 · 7 comments
Open

Can the History API be utilized in a11y-dialog? #363

piotrpog opened this issue Jun 13, 2022 · 7 comments

Comments

@piotrpog
Copy link

piotrpog commented Jun 13, 2022

Would it be possible for this library to get history api support, like one described there?
https://github.com/jamesnuanez/history-api-with-modals-demo

I think it would be especially useful on mobile devices where users are used to closing modals with back button.

I played around a little with implementing it using a11y dialog events but didn't sucedeed.

@KittyGiraudel
Copy link
Owner

Hey Piotr!

I assume it’s possible to do, but I have never tried. It’s an interesting use case, and I’ve heard people praising URL-based dialogs—I've just never encountered a case where I needed it.

If someone manages to make it work, I’d love to have a demo + section in the documentation for that.

@romaricpascal
Copy link

I've given a stab at it in that fork of the demo CodeSandbox: https://codesandbox.io/s/a11y-dialog-forked-v99mnd?file=/src/index.js

Unfortunately, this required me to override the A11yDialog.prototype.hide method to make it trigger a back action so the URL updates properly when going back. It's be great to have cancellable willShow/willHide events to take over more cleanly for this situation,but in the meantime, that's a workaround I could live with 😄 If using multiple dialogs with some updating the URL and others not (why?), you could always set a flag to pick which behaviour to follow.

To save a trip to CodeSandbox, here's what I ended up with:

import A11yDialog from 'a11y-dialog'

var dialogEl = document.getElementById('my-dialog')
var mainEl = document.querySelector('main')

// TRACKING A FEW THINGS

// Re-opening the dialog on `forward` means
// we need some reference to it.
// The history API provides a `state` to store
// things in, but we can't store the A11yDialog object
// So we need a little place to store which A11yDialog
// object corresponds to which dialog
// without worrying about cleaning up when dialogs get destroyed
const dialogs = new WeakMap()

// The other way around, when using `back`,
// we'll need to close the latest opened dialog
// so we need a little structure to track that as well
// Using an Array opens the possibility
// of handling nested dialogs properly, even if it's a bad plan.
const openDialogs = []

// We need a little boolean to know whether we're currently
// handling a `popstate`, as we wouldn't want to `pushstate`
// again when re-opening the popup going forward.
// That'd lead to pushing the "open" state again and again
// when alternating back & forward clicks
let withinPopState
window.addEventListener('popstate', function (event) {
  withinPopState = true
  requestAnimationFrame(() => {
    withinPopState = false
  })
})

// NOW TO THAT DIALOG

// That's an unfortunate but necessary bit:
// we need to take over how the dialog gets closed,
// in order to update the URL through the history API.
// `replaceState` wouldn't do here as closing the dialoge
// should actually move to the previous state.
// Needs to happen BEFORE creating the dialog
// as the bound function is directly used as a listener
const originalHide = A11yDialog.prototype.hide
A11yDialog.prototype.hide = function (event) {
  if (withinPopState) {
    // We only want to hide while handling a `popstate` event
    originalHide.apply(this, arguments)
  } else {
    // The rest of the time, we want to trigger that `popstate` event
    // by going back in the history
    window.history.back()
  }
}

var dialog = new A11yDialog(dialogEl, mainEl)
dialogs.set(dialogEl, dialog)

dialog.on('show', function (dialogEl) {
  const dialog = dialogs.get(dialogEl)
  // Store that the dialog is the new open dialog
  openDialogs.push(dialog)
  // TODO: Use the `destroy` and `hide` events
  // to make sure `openDialogs` doesn't leak
  // But for the purpose of history nav.
  // that's parallel
})

// AND THE HISTORY HANDLING

// When showing the dialog, we'll update the URL
// and store the ID of the dialog
dialog.on('show', function (dialogEl) {
  if (!withinPopState) {
    window.history.pushState(
      {
        dialogId: dialogEl.id,
      },
      '', // This is unused
      // The URL would likely come from a data-attribute, `href` or falling back to the ID of the dialog
      '/something'
    )
    // TODO: We'd likely want to update the page title as well
    // and restore it when closing the modal.
    // Using `replaceState` here before pushing could let us
    // save the current one and access it during the `popstate`
    // when restoring the page.
    // The title for the modal would need to be pulled from somewhere
    // like a `data-page-title` attribute
  }
})

// `popstate` will be our trigger for closing the dialog and opening it back
// Especially closing, for which we overrod `A11yDialog.prototype.hide` at the start
window.addEventListener('popstate', function (event) {
  // Other parts may be using the history API
  // So those won't always be present.
  // Not having them means we're back to before any dialog was open
  // and we can skip to closing the dialogs
  if (event.state && event.state.dialogId) {
    // TODO: Handle nested dialogs here
    // They would have a `dialogId` in their state
    // but it wouldn't match that of the last dialog in `openDialogs`

    // If we have a stored state, it means we're going forward
    // back to the page where the dialog was open.
    // We need to recover our dialog and open it
    const dialogEl = document.getElementById(event.state.dialogId)
    const dialog = dialogs.get(dialogEl)
    if (dialog) {
      dialog.show()
    }
    return
  }

  const openDialog = openDialogs.pop()
  if (openDialog) {
    openDialog.hide()
  }
})

@WebMechanic
Copy link

intriguing.

The title for the modal would need to be pulled from somewhere

shouldn't .dialog-container / [data-a11y-dialog] have an aria-label or aria-labelledby by default?

@WebMechanic
Copy link

IIRC vanilla HTML forms using native post method are excluded from browser history.

What about "stepping back" to a dialog that contained a form/FormData already sent/posted to the server via JS?
If all is done in script, the default behaviour needs to be reinvented to inform the app/page if a dialog "suddenly" reincarnates,
or would the app also require to listen to history state events as well to prevent dupicate posts?

@romaricpascal
Copy link

@WebMechanic For the title, it'd sure be possible to pick the aria-label, aria-labelledby's target textContent or even the first headings textContent to set the page title. Having an separate attribute would allow to pick a specific title without affecting the visible/accessible content, so something likely useful. And another attribute would likely be necessary to pick whether to replace/append/prepend to the existing title. All that title updating feels like a separate responsibility from linking the dialog opening to the browser history as it could be pretty useful for regular dialogs as well.

Regarding the behaviour with form submissions, I'm not sure I understand what you mean by "native post [...] are excluded from browsing history". I recall browsers asking for confirmation of form resubmission when pressing back after submitting a POST form that didn't redirect, with another press of back showing the form again? In any case, I'd say that's the responsibility of whatever submits the form via JS to not break that, regardless of whether the form was shown inside a dialog or not.

Your mention of form submission through JS makes me think it may be worth checking if browsers keep whatever is in the history state after letting them submit without JS and pressing back to get back to the form. If that's the case, it'd probably be worth looking into window.history.state on page load as well to restore any open dialog so the form shows again. I'm not able to check that at the moment, but I'm curious 🤔

@WebMechanic
Copy link

@romaricpascal let me first make clear, that I believe easy access to history.state and dialog states could be a nice UX, however
You might end up opening Pandora's box or at least the proverbial can or worms :)

I understand some might want to have all sorts of options if, when and how to update the window/document title.
Given an "opt in" to modify that title (preferably as a JS option when creating the A11yDialog instance) these aria attributes make suitable defaults 'cos they're likely to be present anyway. I think all these titles should be consistent and providing too many options could be irritating, not helpful.
Wether there's a declarative data-a11y-window-title telling where to look for a value or some "opt in" config option should give devs enough flexibility which source takes precedence. The implementation just shouldn't go "out of hands" for a feature that's likely to get unnoticed by (most?) AT and users anyway.
If users were to read what's in front of them, there'd be no need for error handling... 😇

Anyhow, an updated title could make a good bookmark label. I for one never had the urge to bookmark "a dialog state" in the last 25 years, let alone one with an open (partially filled out) form in it, 'cos it's not really a thing.
However, speaking of can of worms: if the site teaches users its history and dialog state appear connected, to bookmark a dialog state could become a thing. But whatever (session) data has been associated with a dialog's state at bookmarking time has very likely expired when that bookmark is opened again, leaving users frustrated.

It might become an anti-feature
Users have learned (the hard way) from handling web forms that this kind of data and state is very volatile. They might be surprised to see a dialog popping up again w/o their explicit request, and the need to hit back again in the hopes to dismiss it for good.
Just like hitting ESC, hitting the back button also implies "dismiss what's on screen".

Some "remember this dialog" checkbox inside any history-aware dialog might be useful pattern to allow users to opt-in into this behaviour.
Maybe dialogs that were "properly" closed and not just dismissed by hitting escape, should not reappear when navigating "over them". That would possible solve the form resubmission issues.
Some flag to store the close method with the history state could be useful.

On the whole, perhaps something any of this would be best implemented as a kind of "plugin" that is executed during the dialog's lifecycle events to also make it clear to the developer this "goodie" can end up in writing lots of additional app and server code to make it work smoothly and become a solid UX.


form resubmission when pressing back after submitting a POST form that didn't redirect

I believe that is what I had in mind - or the other way around :D
iirc they "clear" the former forward history once resubmitted.
In any case it's a messy business and could also involve some server side care. History points before and after forms could belong to different sessions of which one may no longer be valid/available.

I'm not sure how page/tab lifecycle could mess with history state, length, data and what events would be required to listen to after a tab was suspended, closed, re-opened etc. with a dialog currently on display.
All this would need loads of testing (also for the app/website) and it kinda feels very, very fragile and if it fails, can quickly piss off lots of users.


window.history.state is empty by default and needs to be populated by the developer. That's something the app has to manage as it can contain arbitrary data. It also doesn't tell the page's request method.

@KittyGiraudel KittyGiraudel changed the title History api support for closing and opening modal History API support for interacting with the modal Feb 5, 2023
@KittyGiraudel KittyGiraudel changed the title History API support for interacting with the modal Provide history API support for interacting with the modal Sep 16, 2023
@KittyGiraudel
Copy link
Owner

It's be great to have cancellable willShow/willHide events to take over more cleanly for this situation,but in the meantime, that's a workaround I could live with 😄

@romaricpascal I know it’s been forever, but I wanted to let you know that v8.1.0 makes all events cancellable.

@KittyGiraudel KittyGiraudel changed the title Provide history API support for interacting with the modal [Discussion] Can the History API be utilized in a11y-dialog? Aug 20, 2024
@KittyGiraudel KittyGiraudel changed the title [Discussion] Can the History API be utilized in a11y-dialog? Can the History API be utilized in a11y-dialog? Aug 20, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

4 participants