Initial commit with files

Still need to fix the imports
This commit is contained in:
Ryan Gossiaux
2021-12-13 17:13:47 -08:00
parent 42aba8a158
commit db9ec57065
56 changed files with 4034 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
<script lang="ts" context="module">
export enum RenderStrategy {
Unmount,
Hidden,
}
</script>

View File

@@ -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<TItem>(
action: { focus: Focus.Specific; id: string } | { focus: Exclude<Focus, Focus.Specific> },
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
}

View File

@@ -0,0 +1,33 @@
export function disposables() {
let disposables: Function[] = []
let api = {
requestAnimationFrame(...args: Parameters<typeof requestAnimationFrame>) {
let raf = requestAnimationFrame(...args)
api.add(() => cancelAnimationFrame(raf))
},
nextFrame(...args: Parameters<typeof requestAnimationFrame>) {
api.requestAnimationFrame(() => {
api.requestAnimationFrame(...args)
})
},
setTimeout(...args: Parameters<typeof setTimeout>) {
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
}

View File

@@ -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<HTMLElement>(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 `<a>` 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
}

21
src/lib/utils/keyboard.ts Normal file
View File

@@ -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',
}

20
src/lib/utils/match.ts Normal file
View File

@@ -0,0 +1,20 @@
export function match<TValue extends string | number = string, TReturnValue = unknown>(
value: TValue,
lookup: Record<TValue, TReturnValue | ((...args: any[]) => 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
}

9
src/lib/utils/once.ts Normal file
View File

@@ -0,0 +1,9 @@
export function once<T>(cb: (...args: T[]) => void) {
let state = { called: false }
return (...args: T[]) => {
if (state.called) return
state.called = true
return cb(...args)
}
}

View File

@@ -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
}

View File

@@ -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)
}