{"id":16048932,"url":"https://github.com/devongovett/wc-hooks","last_synced_at":"2025-08-14T11:36:01.672Z","repository":{"id":66053762,"uuid":"291400242","full_name":"devongovett/wc-hooks","owner":"devongovett","description":null,"archived":false,"fork":false,"pushed_at":"2020-12-11T00:22:46.000Z","size":131,"stargazers_count":9,"open_issues_count":0,"forks_count":0,"subscribers_count":3,"default_branch":"master","last_synced_at":"2025-02-28T07:02:04.936Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"JavaScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/devongovett.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2020-08-30T04:34:25.000Z","updated_at":"2023-11-26T02:52:55.000Z","dependencies_parsed_at":null,"dependency_job_id":"c392118d-f7ae-4599-98ec-ebb49d3c4b78","html_url":"https://github.com/devongovett/wc-hooks","commit_stats":null,"previous_names":[],"tags_count":0,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/devongovett%2Fwc-hooks","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/devongovett%2Fwc-hooks/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/devongovett%2Fwc-hooks/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/devongovett%2Fwc-hooks/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/devongovett","download_url":"https://codeload.github.com/devongovett/wc-hooks/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":243902271,"owners_count":20366259,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2024-10-09T00:11:24.730Z","updated_at":"2025-03-18T04:31:02.398Z","avatar_url":"https://github.com/devongovett.png","language":"JavaScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# wc-hooks\n\nAn experiment to try to use [React Aria](https://react-spectrum.adobe.com/react-aria/) hooks in vanilla web components\nby shimming the React Hooks API.\n\n[Demo](https://wc-hooks.vercel.app)\n\n## Implementation strategy\n\nThe components in this example are implemented using vanilla JS – no frameworks or libraries\nwere used other than React Aria. The hooks API is shimmed in a ~200 line implementation that\nwe currently alias `react` to. Each component has a template element containing the base HTML and styles,\nwhich is cloned into the element's shadow root in the `connectedCallback` lifecycle method.\nThe non-static elements are accessed using a `querySelector` and stored as instance properties.\n\nThe hooks return a set of DOM props to pass to each of these elements. This is done using\na simple ~70 line utility function that applies attributes and adds/removes event handlers. This does\na very simple diff over the props objects to only update the props that changed. Additional\nattributes like classes, text content, and properties where non-string data needs to be passed, are added/removed on the element directly. After updating the DOM, effect callbacks are run.\n\nAttributes and properties are reflected using getters and setters on the element instance. The\n`attributeChangedCallback` lifecycle is used to trigger an update of the hooks to compute\nnew props for the DOM elements. The attributes/properties are mapped into the props object\nexpected by the aria hooks. In addition, event props (e.g. `onPress`/`onChange`) are added\nusing a small utility that fires native browser [custom events](https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/CustomEvent). Finally, effect cleanup is done in the `disconnectCallback` lifecycle method.\n\nLibraries like [lit-element](https://lit-element.polymer-project.org/) or\n[fast-element](https://www.fast.design/docs/fast-element/getting-started) could also be used\nto make parts of this a little easier (e.g. the property/attribute reflection, or the template updating), but I wanted to get some experience with the raw standards to better understand\nhow they worked first.\n\n## Problems with shadow DOM\n\nI ran into a few issues with using the existing React Aria hooks with web components, or more specifically, shadow DOM.\nSome of these are fixable, but there are a few showstoppers that will likely make it impossible to use shadow DOM to build\nfully accessible components today, until some future standards are available. Most of these are\nnot specific to React Aria's implementation, but would be applicable to any component using shadow\nDOM. If you have suggestions on how these could be worked around, please let me know!\n\n### ARIA references\n\nThe biggest showstopper issue is with ARIA id references. ARIA uses the `id` attribute to refer to elements elsewhere in the DOM.\nFor example, ARIA attributes such as `aria-labelledby`, `aria-describedby`, `aria-controls`, `aria-owns`, and `aria-activedescendant`\nrefer to other elements by id. However, when using shadow DOM, elements live within separate id spaces. This means that an element in\none shadow root cannot reference an element by id in another shadow root. All elements that need to potentially reference\neach other via ARIA attributes need to be contained within a single shadow root, or in the light DOM. Practically, this is more challenging than it seems,\nif not impossible.\n\nAs an example, a custom select built with ARIA looks like this:\n\n```html\n\u003cspan id=\"label\"\u003eFavorite color\u003c/span\u003e\n\u003cbutton\n  aria-haspopup=\"listbox\"\n  aria-expanded=\"true\"\n  aria-labelledby=\"label value\"\n  aria-controls=\"listbox\"\u003e\n  \u003cspan id=\"value\"\u003eRed\u003c/span\u003e\n\u003c/button\u003e\n\u003cdiv class=\"popover\"\u003e\n  \u003cbutton class=\"visually-hidden\" tabIndex=\"-1\" aria-label=\"Dismiss\"\u003e\u003c/button\u003e\n  \u003cul role=\"listbox\" id=\"listbox\" aria-labelledby=\"label\"\u003e\n    \u003cli role=\"option\" aria-selected=\"true\"\u003eRed\u003c/li\u003e\n    \u003cli role=\"option\"\u003eOrange\u003c/li\u003e\n    \u003cli role=\"option\"\u003eYellow\u003c/li\u003e\n    \u003cli role=\"option\"\u003eGreen\u003c/li\u003e\n    \u003cli role=\"option\"\u003eBlue\u003c/li\u003e\n    \u003cli role=\"option\"\u003ePurple\u003c/li\u003e\n  \u003c/ul\u003e\n  \u003cbutton class=\"visually-hidden\" tabIndex=\"-1\" aria-label=\"Dismiss\"\u003e\u003c/button\u003e\n\u003c/div\u003e\n```\n\nPractically, it might make sense to break this into four reusable components:\n\n1. The select element itself, including the label and the button.\n2. The popover, with the styled container element and the two visually hidden dismiss buttons for mobile screen reader users.\n3. The listbox, which could also be used standalone.\n4. The options.\n\nAfter splitting it up this way and inserting shadow roots, it might look like this:\n\n```html\n\u003ccustom-select\u003e\n  #shadow-root\n    \u003cspan id=\"label\"\u003eFavorite color\u003c/span\u003e\n    \u003cbutton\n      aria-haspopup=\"listbox\"\n      aria-expanded=\"true\"\n      aria-labelledby=\"label value\"\n      aria-controls=\"listbox\"\u003e\n      \u003cspan id=\"value\"\u003eRed\u003c/span\u003e\n    \u003c/button\u003e\n    \u003ccustom-popover\u003e\n      #shadow-root\n        \u003cdiv class=\"popover\"\u003e\n          \u003cbutton class=\"visually-hidden\" tabIndex=\"-1\" aria-label=\"Dismiss\"\u003e\u003c/button\u003e\n          \u003ccustom-listbox\u003e\n            #shadow-root\n              \u003cul role=\"listbox\" id=\"listbox\" aria-labelledby=\"label\"\u003e\n                \u003ccustom-option\u003e\n                  #shadow-root\n                    \u003cli role=\"option\" aria-selected=\"true\"\u003eRed\u003c/li\u003e\n                \u003c/custom-option\u003e\n                \u003c!-- additional options omitted --\u003e\n              \u003c/ul\u003e\n          \u003c/custom-listbox\u003e\n          \u003cbutton class=\"visually-hidden\" tabIndex=\"-1\" aria-label=\"Dismiss\"\u003e\u003c/button\u003e\n        \u003c/div\u003e\n    \u003c/custom-popover\u003e\n\u003c/custom-select\u003e\n```\n\nBreaking things up this way will make it challenging to maintain the correct\nARIA relationships. Specifically, the button needs to reference the listbox via `aria-controls`,\nand the listbox needs to reference the label via `aria-labelledby`. However, this will not\nactually work anymore because the references are crossing shadow DOM boundaries.\nThe `listbox` id does not exist in the select element's shadow root, so the reference\nwill be broken.\n\nOne possibility you might think of would be to actually put the ARIA attributes on\nthe host element, rather than on an element within the shadow root. This way other\nelements can reference it. However, in the above example, that still wouldn't work\nbecause the button needs to reference the listbox, which is actually two shadow roots\naway. Additionally, this would mean that native HTML elements would need to be recreated.\nFor example, the `\u003cbutton\u003e` element couldn't be used within the shadow DOM, and instead\nthe host element would need to recreate button functionality.\n\nThe other option would be to combine components together, or have an option to each custom\nelement to render within a shadow DOM or not. For example, the `\u003ccustom-select\u003e` component\ncould pass an option to `\u003ccustom-popover\u003e`, `\u003ccustom-listbox\u003e`, and `\u003ccustom-option\u003e` to\nrender within the \"light DOM\" rather than a separate shadow DOM, and this would ensure that\neverything renders within a single shadow root for the `\u003ccustom-select\u003e` rather than as separate\nshadow roots. Then all of the elements could correctly reference one another.\n\nThere are still problems though. What if the user of the `\u003ccustom-select\u003e` wants to reference\nan external label rather than using a builtin one? Typically this would be done with the\n`aria-labelledby` attribute. This would need to go on the `\u003cbutton\u003e` element, which is the\nfocusable element with accessibility semantics. However, since the button is within a shadow\nroot, it could not reference the external element the user specified. The select element would\nalso need to be rendered in the light DOM in order for this to work correctly.\n\nFinally, there's overflow escaping. Overlays like modals, popovers, and tooltips are often\nrendered outside the element that triggered them in order to avoid being clipped by `overflow: hidden`\nor scrolling. This is typically done by rendering them at the end of the document body. The structure\nabove may actually look more like this:\n\n```html\n\u003cbody\u003e\n  \u003cdiv id=\"app\"\u003e\n    \u003c!-- insert many levels of heirarchy here --\u003e\n    \u003cdiv style=\"overflow: auto\"\u003e\n      \u003ccustom-select\u003e\n        #shadow-root\n          \u003cspan id=\"label\"\u003eFavorite color\u003c/span\u003e\n          \u003cbutton\n            aria-haspopup=\"listbox\"\n            aria-expanded=\"true\"\n            aria-labelledby=\"label value\"\n            aria-controls=\"listbox\"\u003e\n            \u003cspan id=\"value\"\u003eRed\u003c/span\u003e\n          \u003c/button\u003e\n      \u003c/custom-select\u003e\n    \u003c/div\u003e\n  \u003c/div\u003e\n  \u003ccustom-popover\u003e\n    #shadow-root\n      \u003cdiv class=\"popover\"\u003e\n        \u003cbutton class=\"visually-hidden\" tabIndex=\"-1\" aria-label=\"Dismiss\"\u003e\u003c/button\u003e\n        \u003ccustom-listbox\u003e\n          #shadow-root\n            \u003cul role=\"listbox\" id=\"listbox\" aria-labelledby=\"label\"\u003e\n              \u003ccustom-option\u003e\n                #shadow-root\n                  \u003cli role=\"option\" aria-selected=\"true\"\u003eRed\u003c/li\u003e\n              \u003c/custom-option\u003e\n              \u003c!-- additional options omitted --\u003e\n            \u003c/ul\u003e\n        \u003c/custom-listbox\u003e\n        \u003cbutton class=\"visually-hidden\" tabIndex=\"-1\" aria-label=\"Dismiss\"\u003e\u003c/button\u003e\n      \u003c/div\u003e\n  \u003c/custom-popover\u003e\n\u003c/body\u003e\n```\n\nThis ensures that the popover renders outside the `overflow: auto` element, and also pops\nout above the entire page in the z-index stack. However, this makes things even more challenging\nfor shadow DOM and accessibility. Now we don't even have the option of placing the whole component\nwithin a single shadow root so that references can be made. The button and the listbox are\nin completely different parts of the tree, but ARIA attributes still need to reference\nthese elements.\n\nUntil browsers provide us a way to either reference elements across shadow DOM boundaries\n([AOM](https://github.com/WICG/aom/blob/gh-pages/explainer.md)) or a way to\n[break out of overflow clipping with CSS](https://github.com/w3c/csswg-drafts/issues/4092),\nI believe shadow DOM may make building some existing ARIA patterns practically impossible.\nThis includes components such as selects, combo boxes, menus, modals, popovers, tooltips, and\nanything that may be labelled by or control an external element (checkboxes, switches, etc.).\n\n### Focus management\n\nIn addition to issues with ARIA, I also ran into some issues with focus management that\naffected the current implementation in React Aria. Some of these may be fixable, but in\nsome cases it may not be possible with current standards.\n\nIn order to implement focus management, we often need to query or walk the DOM. This is\nmade more difficult by shadow DOM. For example, `document.activeElement` refers to the host element containing the focused element, not the actual focused element itself. This means that, for example,\nrestoring focus from a dialog back to the previously recorded active element will not work\nbecause the active element refers to the host and not the real element. Calling `element.focus()`\non this does nothing because the host is not actually focusable.\n\nAnother issue is that `querySelectorAll`, `TreeWalker`, and all other DOM querying and crawling\nmethods do not traverse into child shadow roots. This is problematic for focus containment,\nfor example, where we need to be able to find the next/previous focusable element. We also\nuse these to marshall focus to the focusable element within a table cell, or move focus\nbefore/after a portaled element when the user presses `Tab`.\n\nThe `Node.contains` method is similar, and does not return true if the child element is\nwithin a different shadow root. This is used frequently for focus management and other event\nhandling to check whether an event occurred within a particular element, for example.\n\nFinally, the `disconnectedCallback` lifecycle fires *after* the element has been removed from the DOM rather than before. This means that the activeElement would have already changed if it was previously\ninside the element being removed, and the function to restore focus on unmount wouldn't know.\nWe'd need to do our own tracking of whether focus was inside the scope in order to determine this.\n\nMany of these could potentially be worked around by building our own DOM crawling functions\nthat traverse into the `shadowRoot` rather than relying on the builtin browser functions.\nFor example, we could get the `document.activeElement` and keep traversing through shadow\nroots until we find the real active element.\n\nHowever, this would only work if the shadow root is open. If an element uses\n`attachShadow({mode: 'closed'})`, there will be no `shadowRoot` available on the element\nto traverse into. This would mean we would potentially skip over focusable elements when\ntabbing through a dialog, or not be able to restore focus back to the correct element\nwhen a dialog closed. We can decide not to use closed shadow roots in our own components,\nbut we cannot control how other web components that may be on the page are written, so\nthis may be problematic.\n\n### Event handling\n\nIn several of React Aria's interaction hooks, e.g. `usePress` and `useHover`, we make\nuse of document/window level event listeners. For example, we use global pointer events\nand keyboard listeners to ensure that we correctly track mouse and keyboard events even\nif the pointer or focus moves off the target element. This becomes more difficult with\nshadow DOM because the `event.target` property will be set to the host element, not the\nactual element that the event was fired on. We use the `event.target` to determine whether\na global event occurred on an element containing the original local target, for example.\n\nWe could potentially solve this by using [event.composedPath](https://developer.mozilla.org/en-US/docs/Web/API/Event/composedPath), but this does not work when the shadow root was created\nin closed mode, so it wouldn't work for all possible cases. Registering the listeners\nat the shadow root boundary rather than globally wouldn't work either, because we need to\nknow if the event occurred even if it is outside the shadow tree.\n\n### Conclusion\n\nShadow DOM is a very cool technology, and I'm glad that the platform is considering\nstrong encapsulation primatives. However, I feel given the limitations described\nabove that it is a bit too strong too soon. Additional standards will be needed\nto address these limitations, and while shadow DOM is currently nice for style\nencapsulation, it currently causes more issues than it solves. Good enough style\nencapsulation is possible without shadow DOM using hashed class names, for example,\nso I suggest avoiding shadow DOM for components where it is problematic for the time\nbeing and sticking with custom elements in the light DOM until these issues are addressed.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdevongovett%2Fwc-hooks","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fdevongovett%2Fwc-hooks","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdevongovett%2Fwc-hooks/lists"}