diff --git a/src/lib/components/description/Description.svelte b/src/lib/components/description/Description.svelte
new file mode 100644
index 0000000..fe01822
--- /dev/null
+++ b/src/lib/components/description/Description.svelte
@@ -0,0 +1,21 @@
+
+
+
+
+
diff --git a/src/lib/components/description/DescriptionProvider.svelte b/src/lib/components/description/DescriptionProvider.svelte
new file mode 100644
index 0000000..e9191bd
--- /dev/null
+++ b/src/lib/components/description/DescriptionProvider.svelte
@@ -0,0 +1,40 @@
+
+
+
+
+
diff --git a/src/lib/components/dialog/Dialog.svelte b/src/lib/components/dialog/Dialog.svelte
new file mode 100644
index 0000000..17dcbff
--- /dev/null
+++ b/src/lib/components/dialog/Dialog.svelte
@@ -0,0 +1,243 @@
+
+
+
+
+
+{#if open}
+
+ {
+ return match(message, {
+ [StackMessage.Add]() {
+ containers.add(element);
+ },
+ [StackMessage.Remove]() {
+ containers.delete(element);
+ },
+ });
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{/if}
diff --git a/src/lib/components/dialog/DialogOverlay.svelte b/src/lib/components/dialog/DialogOverlay.svelte
new file mode 100644
index 0000000..9166e65
--- /dev/null
+++ b/src/lib/components/dialog/DialogOverlay.svelte
@@ -0,0 +1,20 @@
+
+
+
+
+
diff --git a/src/lib/components/dialog/DialogTitle.svelte b/src/lib/components/dialog/DialogTitle.svelte
new file mode 100644
index 0000000..f164622
--- /dev/null
+++ b/src/lib/components/dialog/DialogTitle.svelte
@@ -0,0 +1,19 @@
+
+
+
+
+
diff --git a/src/lib/components/disclosure/Disclosure.svelte b/src/lib/components/disclosure/Disclosure.svelte
new file mode 100644
index 0000000..be5153d
--- /dev/null
+++ b/src/lib/components/disclosure/Disclosure.svelte
@@ -0,0 +1,105 @@
+
+
+
+
+
+
+
diff --git a/src/lib/components/disclosure/DisclosureButton.svelte b/src/lib/components/disclosure/DisclosureButton.svelte
new file mode 100644
index 0000000..25303d4
--- /dev/null
+++ b/src/lib/components/disclosure/DisclosureButton.svelte
@@ -0,0 +1,103 @@
+
+
+{#if isWithinPanel}
+
+{:else}
+
+{/if}
diff --git a/src/lib/components/disclosure/DisclosurePanel.svelte b/src/lib/components/disclosure/DisclosurePanel.svelte
new file mode 100644
index 0000000..001bde2
--- /dev/null
+++ b/src/lib/components/disclosure/DisclosurePanel.svelte
@@ -0,0 +1,40 @@
+
+
+
+
+{#if visible}
+
+
+
+{/if}
diff --git a/src/lib/components/focus-trap/FocusTrap.svelte b/src/lib/components/focus-trap/FocusTrap.svelte
new file mode 100644
index 0000000..67c3f0a
--- /dev/null
+++ b/src/lib/components/focus-trap/FocusTrap.svelte
@@ -0,0 +1,127 @@
+
+
+
diff --git a/src/lib/components/label/Label.svelte b/src/lib/components/label/Label.svelte
new file mode 100644
index 0000000..1bd93f8
--- /dev/null
+++ b/src/lib/components/label/Label.svelte
@@ -0,0 +1,37 @@
+
+
+
+
diff --git a/src/lib/components/label/LabelProvider.svelte b/src/lib/components/label/LabelProvider.svelte
new file mode 100644
index 0000000..544ae0a
--- /dev/null
+++ b/src/lib/components/label/LabelProvider.svelte
@@ -0,0 +1,37 @@
+
+
+
+
+
diff --git a/src/lib/components/listbox/Listbox.svelte b/src/lib/components/listbox/Listbox.svelte
new file mode 100644
index 0000000..f593a3d
--- /dev/null
+++ b/src/lib/components/listbox/Listbox.svelte
@@ -0,0 +1,191 @@
+
+
+
+
+
+
diff --git a/src/lib/components/listbox/ListboxButton.svelte b/src/lib/components/listbox/ListboxButton.svelte
new file mode 100644
index 0000000..0053183
--- /dev/null
+++ b/src/lib/components/listbox/ListboxButton.svelte
@@ -0,0 +1,85 @@
+
+
+
diff --git a/src/lib/components/listbox/ListboxLabel.svelte b/src/lib/components/listbox/ListboxLabel.svelte
new file mode 100644
index 0000000..4d932c6
--- /dev/null
+++ b/src/lib/components/listbox/ListboxLabel.svelte
@@ -0,0 +1,20 @@
+
+
+
+
diff --git a/src/lib/components/listbox/ListboxOption.svelte b/src/lib/components/listbox/ListboxOption.svelte
new file mode 100644
index 0000000..73c504d
--- /dev/null
+++ b/src/lib/components/listbox/ListboxOption.svelte
@@ -0,0 +1,114 @@
+
+
+
+
+
diff --git a/src/lib/components/listbox/ListboxOptions.svelte b/src/lib/components/listbox/ListboxOptions.svelte
new file mode 100644
index 0000000..6c52017
--- /dev/null
+++ b/src/lib/components/listbox/ListboxOptions.svelte
@@ -0,0 +1,118 @@
+
+
+{#if visible}
+
+{/if}
diff --git a/src/lib/components/menu/Menu.svelte b/src/lib/components/menu/Menu.svelte
new file mode 100644
index 0000000..1712539
--- /dev/null
+++ b/src/lib/components/menu/Menu.svelte
@@ -0,0 +1,151 @@
+
+
+
+
+
+
+
+
diff --git a/src/lib/components/menu/MenuButton.svelte b/src/lib/components/menu/MenuButton.svelte
new file mode 100644
index 0000000..6361262
--- /dev/null
+++ b/src/lib/components/menu/MenuButton.svelte
@@ -0,0 +1,83 @@
+
+
+
diff --git a/src/lib/components/menu/MenuItem.svelte b/src/lib/components/menu/MenuItem.svelte
new file mode 100644
index 0000000..2a4b36e
--- /dev/null
+++ b/src/lib/components/menu/MenuItem.svelte
@@ -0,0 +1,79 @@
+
+
+
+
+
diff --git a/src/lib/components/menu/MenuItems.svelte b/src/lib/components/menu/MenuItems.svelte
new file mode 100644
index 0000000..a773de2
--- /dev/null
+++ b/src/lib/components/menu/MenuItems.svelte
@@ -0,0 +1,139 @@
+
+
+{#if visible}
+
+
+
+{/if}
diff --git a/src/lib/components/popover/Popover.svelte b/src/lib/components/popover/Popover.svelte
new file mode 100644
index 0000000..6e88ce1
--- /dev/null
+++ b/src/lib/components/popover/Popover.svelte
@@ -0,0 +1,145 @@
+
+
+
+
+
+
+
+
diff --git a/src/lib/components/popover/PopoverButton.svelte b/src/lib/components/popover/PopoverButton.svelte
new file mode 100644
index 0000000..8a29aae
--- /dev/null
+++ b/src/lib/components/popover/PopoverButton.svelte
@@ -0,0 +1,176 @@
+
+
+
+
+
diff --git a/src/lib/components/popover/PopoverGroup.svelte b/src/lib/components/popover/PopoverGroup.svelte
new file mode 100644
index 0000000..340d5b2
--- /dev/null
+++ b/src/lib/components/popover/PopoverGroup.svelte
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
diff --git a/src/lib/components/popover/PopoverOverlay.svelte b/src/lib/components/popover/PopoverOverlay.svelte
new file mode 100644
index 0000000..27bfd38
--- /dev/null
+++ b/src/lib/components/popover/PopoverOverlay.svelte
@@ -0,0 +1,25 @@
+
+
+{#if visible}
+
+
+
+{/if}
diff --git a/src/lib/components/popover/PopoverPanel.svelte b/src/lib/components/popover/PopoverPanel.svelte
new file mode 100644
index 0000000..47ba6f9
--- /dev/null
+++ b/src/lib/components/popover/PopoverPanel.svelte
@@ -0,0 +1,117 @@
+
+
+
+
+
+{#if visible}
+
+
+
+{/if}
diff --git a/src/lib/components/portal/Portal.svelte b/src/lib/components/portal/Portal.svelte
new file mode 100644
index 0000000..8d65770
--- /dev/null
+++ b/src/lib/components/portal/Portal.svelte
@@ -0,0 +1,29 @@
+
+
+
+
+
diff --git a/src/lib/components/portal/PortalGroup.svelte b/src/lib/components/portal/PortalGroup.svelte
new file mode 100644
index 0000000..dd4da8d
--- /dev/null
+++ b/src/lib/components/portal/PortalGroup.svelte
@@ -0,0 +1,18 @@
+
+
+
+
+
diff --git a/src/lib/components/radio-group/RadioGroup.svelte b/src/lib/components/radio-group/RadioGroup.svelte
new file mode 100644
index 0000000..04dcf73
--- /dev/null
+++ b/src/lib/components/radio-group/RadioGroup.svelte
@@ -0,0 +1,182 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/lib/components/radio-group/RadioGroupOption.svelte b/src/lib/components/radio-group/RadioGroupOption.svelte
new file mode 100644
index 0000000..529426e
--- /dev/null
+++ b/src/lib/components/radio-group/RadioGroupOption.svelte
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/lib/components/switch/Switch.svelte b/src/lib/components/switch/Switch.svelte
new file mode 100644
index 0000000..c6fcb68
--- /dev/null
+++ b/src/lib/components/switch/Switch.svelte
@@ -0,0 +1,71 @@
+
+
+{#if switchStore}
+
+{:else}
+
+{/if}
diff --git a/src/lib/components/switch/SwitchGroup.svelte b/src/lib/components/switch/SwitchGroup.svelte
new file mode 100644
index 0000000..d95ea91
--- /dev/null
+++ b/src/lib/components/switch/SwitchGroup.svelte
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/lib/components/tabs/Tab.svelte b/src/lib/components/tabs/Tab.svelte
new file mode 100644
index 0000000..c2a030b
--- /dev/null
+++ b/src/lib/components/tabs/Tab.svelte
@@ -0,0 +1,98 @@
+
+
+
diff --git a/src/lib/components/tabs/TabGroup.svelte b/src/lib/components/tabs/TabGroup.svelte
new file mode 100644
index 0000000..07b9cae
--- /dev/null
+++ b/src/lib/components/tabs/TabGroup.svelte
@@ -0,0 +1,122 @@
+
+
+
+
+
+
+
diff --git a/src/lib/components/tabs/TabList.svelte b/src/lib/components/tabs/TabList.svelte
new file mode 100644
index 0000000..9a944c1
--- /dev/null
+++ b/src/lib/components/tabs/TabList.svelte
@@ -0,0 +1,13 @@
+
+
+
+
+
diff --git a/src/lib/components/tabs/TabPanel.svelte b/src/lib/components/tabs/TabPanel.svelte
new file mode 100644
index 0000000..271d65c
--- /dev/null
+++ b/src/lib/components/tabs/TabPanel.svelte
@@ -0,0 +1,30 @@
+
+
+
+ {#if selected}
+
+ {/if}
+
diff --git a/src/lib/components/tabs/TabPanels.svelte b/src/lib/components/tabs/TabPanels.svelte
new file mode 100644
index 0000000..fe7f915
--- /dev/null
+++ b/src/lib/components/tabs/TabPanels.svelte
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/src/lib/components/transitions/TransitionChild.svelte b/src/lib/components/transitions/TransitionChild.svelte
new file mode 100644
index 0000000..ee19d0a
--- /dev/null
+++ b/src/lib/components/transitions/TransitionChild.svelte
@@ -0,0 +1,173 @@
+
+
+
+
+
diff --git a/src/lib/components/transitions/TransitionRoot.svelte b/src/lib/components/transitions/TransitionRoot.svelte
new file mode 100644
index 0000000..b612269
--- /dev/null
+++ b/src/lib/components/transitions/TransitionRoot.svelte
@@ -0,0 +1,177 @@
+
+
+
+
+
+
+
diff --git a/src/lib/hooks/use-effect.ts b/src/lib/hooks/use-effect.ts
new file mode 100644
index 0000000..dc44507
--- /dev/null
+++ b/src/lib/hooks/use-effect.ts
@@ -0,0 +1,6 @@
+export function useEffect(fn, ...args) {
+ if (fn.__cleanup) {
+ fn.__cleanup();
+ }
+ fn.__cleanup = fn(...args);
+}
diff --git a/src/lib/hooks/use-id.ts b/src/lib/hooks/use-id.ts
new file mode 100644
index 0000000..432e280
--- /dev/null
+++ b/src/lib/hooks/use-id.ts
@@ -0,0 +1,8 @@
+let id = 0
+function generateId() {
+ return ++id
+}
+
+export function useId() {
+ return generateId()
+}
diff --git a/src/lib/hooks/use-inert-others.ts b/src/lib/hooks/use-inert-others.ts
new file mode 100644
index 0000000..5eb7076
--- /dev/null
+++ b/src/lib/hooks/use-inert-others.ts
@@ -0,0 +1,100 @@
+let interactables = new Set()
+let originals = new Map()
+
+function inert(element: HTMLElement) {
+ element.setAttribute('aria-hidden', 'true')
+ // @ts-expect-error `inert` does not exist on HTMLElement (yet!)
+ element.inert = true
+}
+
+function restore(element: HTMLElement) {
+ let original = originals.get(element)
+ if (!original) return
+
+ if (original['aria-hidden'] === null) element.removeAttribute('aria-hidden')
+ else element.setAttribute('aria-hidden', original['aria-hidden'])
+ // @ts-expect-error `inert` does not exist on HTMLElement (yet!)
+ element.inert = original.inert
+}
+
+export function useInertOthers(
+ container: TElement | null,
+ enabled: boolean = true
+) {
+ if (!enabled) return
+ if (!container) return
+
+ let element = container
+
+ // Mark myself as an interactable element
+ interactables.add(element)
+
+ // Restore elements that now contain an interactable child
+ for (let original of originals.keys()) {
+ if (original.contains(element)) {
+ restore(original)
+ originals.delete(original)
+ }
+ }
+
+ // Collect direct children of the body
+ document.querySelectorAll('body > *').forEach(child => {
+ if (!(child instanceof HTMLElement)) return // Skip non-HTMLElements
+
+ // Skip the interactables, and the parents of the interactables
+ for (let interactable of interactables) {
+ if (child.contains(interactable)) return
+ }
+
+ // Keep track of the elements
+ if (interactables.size === 1) {
+ originals.set(child, {
+ 'aria-hidden': child.getAttribute('aria-hidden'),
+ // @ts-expect-error `inert` does not exist on HTMLElement (yet!)
+ inert: child.inert,
+ })
+
+ // Mutate the element
+ inert(child)
+ }
+ })
+
+ return () => {
+ // Inert is disabled on the current element
+ interactables.delete(element)
+
+ // We still have interactable elements, therefore this one and its parent
+ // will become inert as well.
+ if (interactables.size > 0) {
+ // Collect direct children of the body
+ document.querySelectorAll('body > *').forEach(child => {
+ if (!(child instanceof HTMLElement)) return // Skip non-HTMLElements
+
+ // Skip already inert parents
+ if (originals.has(child)) return
+
+ // Skip the interactables, and the parents of the interactables
+ for (let interactable of interactables) {
+ if (child.contains(interactable)) return
+ }
+
+ originals.set(child, {
+ 'aria-hidden': child.getAttribute('aria-hidden'),
+ // @ts-expect-error `inert` does not exist on HTMLElement (yet!)
+ inert: child.inert,
+ })
+
+ // Mutate the element
+ inert(child)
+ })
+ } else {
+ for (let element of originals.keys()) {
+ // Restore
+ restore(element)
+
+ // Cleanup
+ originals.delete(element)
+ }
+ }
+ }
+}
diff --git a/src/lib/hooks/use-portal.ts b/src/lib/hooks/use-portal.ts
new file mode 100644
index 0000000..a6ed850
--- /dev/null
+++ b/src/lib/hooks/use-portal.ts
@@ -0,0 +1,14 @@
+export function portal(element: HTMLElement, target: HTMLElement) {
+ target.append(element);
+ return {
+ update(newTarget: HTMLElement) {
+ target = newTarget;
+ newTarget.append(element);
+ },
+ destroy() {
+ if (target.childNodes.length <= 0) {
+ target.parentElement?.removeChild(target);
+ }
+ },
+ }
+}
diff --git a/src/lib/hooks/use-tree-walker.ts b/src/lib/hooks/use-tree-walker.ts
new file mode 100644
index 0000000..e990ea6
--- /dev/null
+++ b/src/lib/hooks/use-tree-walker.ts
@@ -0,0 +1,28 @@
+type AcceptNode = (
+ node: HTMLElement
+) =>
+ | typeof NodeFilter.FILTER_ACCEPT
+ | typeof NodeFilter.FILTER_SKIP
+ | typeof NodeFilter.FILTER_REJECT
+
+export function treeWalker({
+ container,
+ accept,
+ walk,
+ enabled,
+}: {
+ container: HTMLElement | null
+ accept: AcceptNode
+ walk(node: HTMLElement): void
+ enabled?: boolean
+}) {
+ let root = container
+ if (!root) return
+ if (enabled !== undefined && !enabled) return
+
+ let acceptNode = Object.assign((node: HTMLElement) => accept(node), { acceptNode: accept })
+ // @ts-ignore-error Typescript bug thinks this can only have 3 args
+ let walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, acceptNode, false)
+
+ while (walker.nextNode()) walk(walker.currentNode as HTMLElement)
+}
diff --git a/src/lib/internal/ForcePortalRootContext.svelte b/src/lib/internal/ForcePortalRootContext.svelte
new file mode 100644
index 0000000..25d9c58
--- /dev/null
+++ b/src/lib/internal/ForcePortalRootContext.svelte
@@ -0,0 +1,17 @@
+
+
+
+
+
diff --git a/src/lib/internal/StackContextProvider.svelte b/src/lib/internal/StackContextProvider.svelte
new file mode 100644
index 0000000..3cd2ee6
--- /dev/null
+++ b/src/lib/internal/StackContextProvider.svelte
@@ -0,0 +1,42 @@
+
+
+
+
+
diff --git a/src/lib/internal/dom-containers.ts b/src/lib/internal/dom-containers.ts
new file mode 100644
index 0000000..a2ddac8
--- /dev/null
+++ b/src/lib/internal/dom-containers.ts
@@ -0,0 +1,7 @@
+export function contains(containers: Set, element: HTMLElement) {
+ for (let container of containers) {
+ if (container.contains(element)) return true
+ }
+
+ return false
+}
diff --git a/src/lib/internal/open-closed.ts b/src/lib/internal/open-closed.ts
new file mode 100644
index 0000000..3217e0c
--- /dev/null
+++ b/src/lib/internal/open-closed.ts
@@ -0,0 +1,21 @@
+import { getContext, setContext } from "svelte";
+import type { Writable } from "svelte/store";
+
+export enum State {
+ Open,
+ Closed,
+}
+
+const OPEN_CLOSED_CONTEXT_NAME = "OpenClosed";
+export function hasOpenClosed() {
+ return useOpenClosed() !== undefined
+}
+
+export function useOpenClosed(): Writable | undefined {
+ return getContext(OPEN_CLOSED_CONTEXT_NAME);
+}
+
+export function useOpenClosedProvider(value: Writable) {
+ setContext(OPEN_CLOSED_CONTEXT_NAME, value);
+}
+
diff --git a/src/lib/utils/Render.svelte b/src/lib/utils/Render.svelte
new file mode 100644
index 0000000..8156a16
--- /dev/null
+++ b/src/lib/utils/Render.svelte
@@ -0,0 +1,6 @@
+
diff --git a/src/lib/utils/calculate-active-index.ts b/src/lib/utils/calculate-active-index.ts
new file mode 100644
index 0000000..afdd9d7
--- /dev/null
+++ b/src/lib/utils/calculate-active-index.ts
@@ -0,0 +1,84 @@
+function assertNever(x: never): never {
+ throw new Error('Unexpected object: ' + x)
+}
+
+export enum Focus {
+ /** Focus the first non-disabled item. */
+ First,
+
+ /** Focus the previous non-disabled item. */
+ Previous,
+
+ /** Focus the next non-disabled item. */
+ Next,
+
+ /** Focus the last non-disabled item. */
+ Last,
+
+ /** Focus a specific item based on the `id` of the item. */
+ Specific,
+
+ /** Focus no items at all. */
+ Nothing,
+}
+
+export function calculateActiveIndex(
+ action: { focus: Focus.Specific; id: string } | { focus: Exclude },
+ resolvers: {
+ resolveItems(): TItem[]
+ resolveActiveIndex(): number | null
+ resolveId(item: TItem): string
+ resolveDisabled(item: TItem): boolean
+ }
+) {
+ let items = resolvers.resolveItems()
+ if (items.length <= 0) return null
+
+ let currentActiveIndex = resolvers.resolveActiveIndex()
+ let activeIndex = currentActiveIndex ?? -1
+
+ let nextActiveIndex = (() => {
+ switch (action.focus) {
+ case Focus.First:
+ return items.findIndex(item => !resolvers.resolveDisabled(item))
+
+ case Focus.Previous: {
+ let idx = items
+ .slice()
+ .reverse()
+ .findIndex((item, idx, all) => {
+ if (activeIndex !== -1 && all.length - idx - 1 >= activeIndex) return false
+ return !resolvers.resolveDisabled(item)
+ })
+ if (idx === -1) return idx
+ return items.length - 1 - idx
+ }
+
+ case Focus.Next:
+ return items.findIndex((item, idx) => {
+ if (idx <= activeIndex) return false
+ return !resolvers.resolveDisabled(item)
+ })
+
+ case Focus.Last: {
+ let idx = items
+ .slice()
+ .reverse()
+ .findIndex(item => !resolvers.resolveDisabled(item))
+ if (idx === -1) return idx
+ return items.length - 1 - idx
+ }
+
+ case Focus.Specific:
+ return items.findIndex(item => resolvers.resolveId(item) === action.id)
+
+ case Focus.Nothing:
+ return null
+
+ default:
+ assertNever(action)
+ }
+ })()
+
+ return nextActiveIndex === -1 ? currentActiveIndex : nextActiveIndex
+}
diff --git a/src/lib/utils/disposables.ts b/src/lib/utils/disposables.ts
new file mode 100644
index 0000000..ceec032
--- /dev/null
+++ b/src/lib/utils/disposables.ts
@@ -0,0 +1,33 @@
+export function disposables() {
+ let disposables: Function[] = []
+
+ let api = {
+ requestAnimationFrame(...args: Parameters) {
+ let raf = requestAnimationFrame(...args)
+ api.add(() => cancelAnimationFrame(raf))
+ },
+
+ nextFrame(...args: Parameters) {
+ api.requestAnimationFrame(() => {
+ api.requestAnimationFrame(...args)
+ })
+ },
+
+ setTimeout(...args: Parameters) {
+ let timer = setTimeout(...args)
+ api.add(() => clearTimeout(timer))
+ },
+
+ add(cb: () => void) {
+ disposables.push(cb)
+ },
+
+ dispose() {
+ for (let dispose of disposables.splice(0)) {
+ dispose()
+ }
+ },
+ }
+
+ return api
+}
diff --git a/src/lib/utils/focus-management.ts b/src/lib/utils/focus-management.ts
new file mode 100644
index 0000000..af9b627
--- /dev/null
+++ b/src/lib/utils/focus-management.ts
@@ -0,0 +1,155 @@
+import { match } from './match'
+
+// Credit:
+// - https://stackoverflow.com/a/30753870
+let focusableSelector = [
+ '[contentEditable=true]',
+ '[tabindex]',
+ 'a[href]',
+ 'area[href]',
+ 'button:not([disabled])',
+ 'iframe',
+ 'input:not([disabled])',
+ 'select:not([disabled])',
+ 'textarea:not([disabled])',
+]
+ .map(
+ process.env.NODE_ENV === 'test'
+ ? // TODO: Remove this once JSDOM fixes the issue where an element that is
+ // "hidden" can be the document.activeElement, because this is not possible
+ // in real browsers.
+ selector => `${selector}:not([tabindex='-1']):not([style*='display: none'])`
+ : selector => `${selector}:not([tabindex='-1'])`
+ )
+ .join(',')
+
+export enum Focus {
+ /** Focus the first non-disabled element */
+ First = 1 << 0,
+
+ /** Focus the previous non-disabled element */
+ Previous = 1 << 1,
+
+ /** Focus the next non-disabled element */
+ Next = 1 << 2,
+
+ /** Focus the last non-disabled element */
+ Last = 1 << 3,
+
+ /** Wrap tab around */
+ WrapAround = 1 << 4,
+
+ /** Prevent scrolling the focusable elements into view */
+ NoScroll = 1 << 5,
+}
+
+export enum FocusResult {
+ Error,
+ Overflow,
+ Success,
+ Underflow,
+}
+
+enum Direction {
+ Previous = -1,
+ Next = 1,
+}
+
+export function getFocusableElements(container: HTMLElement | null = document.body) {
+ if (container == null) return []
+ return Array.from(container.querySelectorAll(focusableSelector))
+}
+
+export enum FocusableMode {
+ /** The element itself must be focusable. */
+ Strict,
+
+ /** The element should be inside of a focusable element. */
+ Loose,
+}
+
+export function isFocusableElement(
+ element: HTMLElement,
+ mode: FocusableMode = FocusableMode.Strict
+) {
+ if (element === document.body) return false
+
+ return match(mode, {
+ [FocusableMode.Strict]() {
+ return element.matches(focusableSelector)
+ },
+ [FocusableMode.Loose]() {
+ let next: HTMLElement | null = element
+
+ while (next !== null) {
+ if (next.matches(focusableSelector)) return true
+ next = next.parentElement
+ }
+
+ return false
+ },
+ })
+}
+
+export function focusElement(element: HTMLElement | null) {
+ element?.focus({ preventScroll: true })
+}
+
+export function focusIn(container: HTMLElement | HTMLElement[], focus: Focus) {
+ let elements = Array.isArray(container) ? container : getFocusableElements(container)
+ let active = document.activeElement as HTMLElement
+
+ let direction = (() => {
+ if (focus & (Focus.First | Focus.Next)) return Direction.Next
+ if (focus & (Focus.Previous | Focus.Last)) return Direction.Previous
+
+ throw new Error('Missing Focus.First, Focus.Previous, Focus.Next or Focus.Last')
+ })()
+
+ let startIndex = (() => {
+ if (focus & Focus.First) return 0
+ if (focus & Focus.Previous) return Math.max(0, elements.indexOf(active)) - 1
+ if (focus & Focus.Next) return Math.max(0, elements.indexOf(active)) + 1
+ if (focus & Focus.Last) return elements.length - 1
+
+ throw new Error('Missing Focus.First, Focus.Previous, Focus.Next or Focus.Last')
+ })()
+
+ let focusOptions = focus & Focus.NoScroll ? { preventScroll: true } : {}
+
+ let offset = 0
+ let total = elements.length
+ let next = undefined
+ do {
+ // Guard against infinite loops
+ if (offset >= total || offset + total <= 0) return FocusResult.Error
+
+ let nextIdx = startIndex + offset
+
+ if (focus & Focus.WrapAround) {
+ nextIdx = (nextIdx + total) % total
+ } else {
+ if (nextIdx < 0) return FocusResult.Underflow
+ if (nextIdx >= total) return FocusResult.Overflow
+ }
+
+ next = elements[nextIdx]
+
+ // Try the focus the next element, might not work if it is "hidden" to the user.
+ next?.focus(focusOptions)
+
+ // Try the next one in line
+ offset += direction
+ } while (next !== document.activeElement)
+
+ // This is a little weird, but let me try and explain: There are a few scenario's
+ // in chrome for example where a focused `` tag does not get the default focus
+ // styles and sometimes they do. This highly depends on whether you started by
+ // clicking or by using your keyboard. When you programmatically add focus `anchor.focus()`
+ // then the active element (document.activeElement) is this anchor, which is expected.
+ // However in that case the default focus styles are not applied *unless* you
+ // also add this tabindex.
+ if (!next.hasAttribute('tabindex')) next.setAttribute('tabindex', '0')
+
+ return FocusResult.Success
+}
diff --git a/src/lib/utils/keyboard.ts b/src/lib/utils/keyboard.ts
new file mode 100644
index 0000000..b910fcd
--- /dev/null
+++ b/src/lib/utils/keyboard.ts
@@ -0,0 +1,21 @@
+// TODO: This must already exist somewhere, right? 🤔
+// Ref: https://www.w3.org/TR/uievents-key/#named-key-attribute-values
+export enum Keys {
+ Space = ' ',
+ Enter = 'Enter',
+ Escape = 'Escape',
+ Backspace = 'Backspace',
+
+ ArrowLeft = 'ArrowLeft',
+ ArrowUp = 'ArrowUp',
+ ArrowRight = 'ArrowRight',
+ ArrowDown = 'ArrowDown',
+
+ Home = 'Home',
+ End = 'End',
+
+ PageUp = 'PageUp',
+ PageDown = 'PageDown',
+
+ Tab = 'Tab',
+}
diff --git a/src/lib/utils/match.ts b/src/lib/utils/match.ts
new file mode 100644
index 0000000..8295a4e
--- /dev/null
+++ b/src/lib/utils/match.ts
@@ -0,0 +1,20 @@
+export function match(
+ value: TValue,
+ lookup: Record TReturnValue)>,
+ ...args: any[]
+): TReturnValue {
+ if (value in lookup) {
+ let returnValue = lookup[value]
+ return typeof returnValue === 'function' ? returnValue(...args) : returnValue
+ }
+
+ let error = new Error(
+ `Tried to handle "${value}" but there is no handler defined. Only defined handlers are: ${Object.keys(
+ lookup
+ )
+ .map(key => `"${key}"`)
+ .join(', ')}.`
+ )
+ if (Error.captureStackTrace) Error.captureStackTrace(error, match)
+ throw error
+}
diff --git a/src/lib/utils/once.ts b/src/lib/utils/once.ts
new file mode 100644
index 0000000..bd737c7
--- /dev/null
+++ b/src/lib/utils/once.ts
@@ -0,0 +1,9 @@
+export function once(cb: (...args: T[]) => void) {
+ let state = { called: false }
+
+ return (...args: T[]) => {
+ if (state.called) return
+ state.called = true
+ return cb(...args)
+ }
+}
diff --git a/src/lib/utils/transition.ts b/src/lib/utils/transition.ts
new file mode 100644
index 0000000..9e3454e
--- /dev/null
+++ b/src/lib/utils/transition.ts
@@ -0,0 +1,95 @@
+import { once } from './once'
+import { disposables } from './disposables'
+
+function addClasses(node: HTMLElement, ...classes: string[]) {
+ node && classes.length > 0 && node.classList.add(...classes)
+}
+
+function removeClasses(node: HTMLElement, ...classes: string[]) {
+ node && classes.length > 0 && node.classList.remove(...classes)
+}
+
+export enum Reason {
+ Finished = 'finished',
+ Cancelled = 'cancelled',
+}
+
+function waitForTransition(node: HTMLElement, done: (reason: Reason) => void) {
+ let d = disposables()
+
+ if (!node) return d.dispose
+
+ // Safari returns a comma separated list of values, so let's sort them and take the highest value.
+ let { transitionDuration, transitionDelay } = getComputedStyle(node)
+
+ let [durationMs, delaysMs] = [transitionDuration, transitionDelay].map(value => {
+ let [resolvedValue = 0] = value
+ .split(',')
+ // Remove falsy we can't work with
+ .filter(Boolean)
+ // Values are returned as `0.3s` or `75ms`
+ .map(v => (v.includes('ms') ? parseFloat(v) : parseFloat(v) * 1000))
+ .sort((a, z) => z - a)
+
+ return resolvedValue
+ })
+
+ // Waiting for the transition to end. We could use the `transitionend` event, however when no
+ // actual transition/duration is defined then the `transitionend` event is not fired.
+ //
+ // TODO: Downside is, when you slow down transitions via devtools this timeout is still using the
+ // full 100% speed instead of the 25% or 10%.
+ if (durationMs !== 0) {
+ d.setTimeout(() => {
+ done(Reason.Finished)
+ }, durationMs + delaysMs)
+ } else {
+ // No transition is happening, so we should cleanup already. Otherwise we have to wait until we
+ // get disposed.
+ done(Reason.Finished)
+ }
+
+ // If we get disposed before the timeout runs we should cleanup anyway
+ d.add(() => done(Reason.Cancelled))
+
+ return d.dispose
+}
+
+export function transition(
+ node: HTMLElement,
+ base: string[],
+ from: string[],
+ to: string[],
+ entered: string[],
+ done?: (reason: Reason) => void
+) {
+ let d = disposables()
+ let _done = done !== undefined ? once(done) : () => { }
+
+ removeClasses(node, ...entered)
+ addClasses(node, ...base, ...from)
+
+ d.nextFrame(() => {
+ removeClasses(node, ...from)
+ addClasses(node, ...to)
+
+ d.add(
+ waitForTransition(node, reason => {
+ removeClasses(node, ...to, ...base)
+ addClasses(node, ...entered)
+ return _done(reason)
+ })
+ )
+ })
+
+ // Once we get disposed, we should ensure that we cleanup after ourselves. In case of an unmount,
+ // the node itself will be nullified and will be a no-op. In case of a full transition the classes
+ // are already removed which is also a no-op. However if you go from enter -> leave mid-transition
+ // then we have some leftovers that should be cleaned.
+ d.add(() => removeClasses(node, ...base, ...from, ...to, ...entered))
+
+ // When we get disposed early, than we should also call the done method but switch the reason.
+ d.add(() => _done(Reason.Cancelled))
+
+ return d.dispose
+}
diff --git a/src/lib/utils/tree-walker.ts b/src/lib/utils/tree-walker.ts
new file mode 100644
index 0000000..e990ea6
--- /dev/null
+++ b/src/lib/utils/tree-walker.ts
@@ -0,0 +1,28 @@
+type AcceptNode = (
+ node: HTMLElement
+) =>
+ | typeof NodeFilter.FILTER_ACCEPT
+ | typeof NodeFilter.FILTER_SKIP
+ | typeof NodeFilter.FILTER_REJECT
+
+export function treeWalker({
+ container,
+ accept,
+ walk,
+ enabled,
+}: {
+ container: HTMLElement | null
+ accept: AcceptNode
+ walk(node: HTMLElement): void
+ enabled?: boolean
+}) {
+ let root = container
+ if (!root) return
+ if (enabled !== undefined && !enabled) return
+
+ let acceptNode = Object.assign((node: HTMLElement) => accept(node), { acceptNode: accept })
+ // @ts-ignore-error Typescript bug thinks this can only have 3 args
+ let walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, acceptNode, false)
+
+ while (walker.nextNode()) walk(walker.currentNode as HTMLElement)
+}