From db9ec57065cd2243c975966e2abb735a15ef07e1 Mon Sep 17 00:00:00 2001 From: Ryan Gossiaux Date: Mon, 13 Dec 2021 17:13:47 -0800 Subject: [PATCH] Initial commit with files Still need to fix the imports --- .../components/description/Description.svelte | 21 ++ .../description/DescriptionProvider.svelte | 40 +++ src/lib/components/dialog/Dialog.svelte | 243 ++++++++++++++++++ .../components/dialog/DialogOverlay.svelte | 20 ++ src/lib/components/dialog/DialogTitle.svelte | 19 ++ .../components/disclosure/Disclosure.svelte | 105 ++++++++ .../disclosure/DisclosureButton.svelte | 103 ++++++++ .../disclosure/DisclosurePanel.svelte | 40 +++ .../components/focus-trap/FocusTrap.svelte | 127 +++++++++ src/lib/components/label/Label.svelte | 37 +++ src/lib/components/label/LabelProvider.svelte | 37 +++ src/lib/components/listbox/Listbox.svelte | 191 ++++++++++++++ .../components/listbox/ListboxButton.svelte | 85 ++++++ .../components/listbox/ListboxLabel.svelte | 20 ++ .../components/listbox/ListboxOption.svelte | 114 ++++++++ .../components/listbox/ListboxOptions.svelte | 118 +++++++++ src/lib/components/menu/Menu.svelte | 151 +++++++++++ src/lib/components/menu/MenuButton.svelte | 83 ++++++ src/lib/components/menu/MenuItem.svelte | 79 ++++++ src/lib/components/menu/MenuItems.svelte | 139 ++++++++++ src/lib/components/popover/Popover.svelte | 145 +++++++++++ .../components/popover/PopoverButton.svelte | 176 +++++++++++++ .../components/popover/PopoverGroup.svelte | 57 ++++ .../components/popover/PopoverOverlay.svelte | 25 ++ .../components/popover/PopoverPanel.svelte | 117 +++++++++ src/lib/components/portal/Portal.svelte | 29 +++ src/lib/components/portal/PortalGroup.svelte | 18 ++ .../components/radio-group/RadioGroup.svelte | 182 +++++++++++++ .../radio-group/RadioGroupOption.svelte | 91 +++++++ src/lib/components/switch/Switch.svelte | 71 +++++ src/lib/components/switch/SwitchGroup.svelte | 35 +++ src/lib/components/tabs/Tab.svelte | 98 +++++++ src/lib/components/tabs/TabGroup.svelte | 122 +++++++++ src/lib/components/tabs/TabList.svelte | 13 + src/lib/components/tabs/TabPanel.svelte | 30 +++ src/lib/components/tabs/TabPanels.svelte | 9 + .../transitions/TransitionChild.svelte | 173 +++++++++++++ .../transitions/TransitionRoot.svelte | 177 +++++++++++++ src/lib/hooks/use-effect.ts | 6 + src/lib/hooks/use-id.ts | 8 + src/lib/hooks/use-inert-others.ts | 100 +++++++ src/lib/hooks/use-portal.ts | 14 + src/lib/hooks/use-tree-walker.ts | 28 ++ .../internal/ForcePortalRootContext.svelte | 17 ++ src/lib/internal/StackContextProvider.svelte | 42 +++ src/lib/internal/dom-containers.ts | 7 + src/lib/internal/open-closed.ts | 21 ++ src/lib/utils/Render.svelte | 6 + src/lib/utils/calculate-active-index.ts | 84 ++++++ src/lib/utils/disposables.ts | 33 +++ src/lib/utils/focus-management.ts | 155 +++++++++++ src/lib/utils/keyboard.ts | 21 ++ src/lib/utils/match.ts | 20 ++ src/lib/utils/once.ts | 9 + src/lib/utils/transition.ts | 95 +++++++ src/lib/utils/tree-walker.ts | 28 ++ 56 files changed, 4034 insertions(+) create mode 100644 src/lib/components/description/Description.svelte create mode 100644 src/lib/components/description/DescriptionProvider.svelte create mode 100644 src/lib/components/dialog/Dialog.svelte create mode 100644 src/lib/components/dialog/DialogOverlay.svelte create mode 100644 src/lib/components/dialog/DialogTitle.svelte create mode 100644 src/lib/components/disclosure/Disclosure.svelte create mode 100644 src/lib/components/disclosure/DisclosureButton.svelte create mode 100644 src/lib/components/disclosure/DisclosurePanel.svelte create mode 100644 src/lib/components/focus-trap/FocusTrap.svelte create mode 100644 src/lib/components/label/Label.svelte create mode 100644 src/lib/components/label/LabelProvider.svelte create mode 100644 src/lib/components/listbox/Listbox.svelte create mode 100644 src/lib/components/listbox/ListboxButton.svelte create mode 100644 src/lib/components/listbox/ListboxLabel.svelte create mode 100644 src/lib/components/listbox/ListboxOption.svelte create mode 100644 src/lib/components/listbox/ListboxOptions.svelte create mode 100644 src/lib/components/menu/Menu.svelte create mode 100644 src/lib/components/menu/MenuButton.svelte create mode 100644 src/lib/components/menu/MenuItem.svelte create mode 100644 src/lib/components/menu/MenuItems.svelte create mode 100644 src/lib/components/popover/Popover.svelte create mode 100644 src/lib/components/popover/PopoverButton.svelte create mode 100644 src/lib/components/popover/PopoverGroup.svelte create mode 100644 src/lib/components/popover/PopoverOverlay.svelte create mode 100644 src/lib/components/popover/PopoverPanel.svelte create mode 100644 src/lib/components/portal/Portal.svelte create mode 100644 src/lib/components/portal/PortalGroup.svelte create mode 100644 src/lib/components/radio-group/RadioGroup.svelte create mode 100644 src/lib/components/radio-group/RadioGroupOption.svelte create mode 100644 src/lib/components/switch/Switch.svelte create mode 100644 src/lib/components/switch/SwitchGroup.svelte create mode 100644 src/lib/components/tabs/Tab.svelte create mode 100644 src/lib/components/tabs/TabGroup.svelte create mode 100644 src/lib/components/tabs/TabList.svelte create mode 100644 src/lib/components/tabs/TabPanel.svelte create mode 100644 src/lib/components/tabs/TabPanels.svelte create mode 100644 src/lib/components/transitions/TransitionChild.svelte create mode 100644 src/lib/components/transitions/TransitionRoot.svelte create mode 100644 src/lib/hooks/use-effect.ts create mode 100644 src/lib/hooks/use-id.ts create mode 100644 src/lib/hooks/use-inert-others.ts create mode 100644 src/lib/hooks/use-portal.ts create mode 100644 src/lib/hooks/use-tree-walker.ts create mode 100644 src/lib/internal/ForcePortalRootContext.svelte create mode 100644 src/lib/internal/StackContextProvider.svelte create mode 100644 src/lib/internal/dom-containers.ts create mode 100644 src/lib/internal/open-closed.ts create mode 100644 src/lib/utils/Render.svelte create mode 100644 src/lib/utils/calculate-active-index.ts create mode 100644 src/lib/utils/disposables.ts create mode 100644 src/lib/utils/focus-management.ts create mode 100644 src/lib/utils/keyboard.ts create mode 100644 src/lib/utils/match.ts create mode 100644 src/lib/utils/once.ts create mode 100644 src/lib/utils/transition.ts create mode 100644 src/lib/utils/tree-walker.ts 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) +}