Initial commit with files
Still need to fix the imports
This commit is contained in:
21
src/lib/components/description/Description.svelte
Normal file
21
src/lib/components/description/Description.svelte
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { useId } from "./use-id";
|
||||||
|
import { getContext, onMount } from "svelte";
|
||||||
|
import { Writable } from "svelte/store";
|
||||||
|
import { DescriptionContext } from "./DescriptionProvider.svelte";
|
||||||
|
const id = `headlessui-description-${useId()}`;
|
||||||
|
let contextStore: Writable<DescriptionContext> | undefined = getContext(
|
||||||
|
"headlessui-description-context"
|
||||||
|
);
|
||||||
|
if (!contextStore) {
|
||||||
|
throw new Error(
|
||||||
|
"You used a <Description /> component, but it is not inside a relevant parent."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => $contextStore.register(id));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<p {...$$restProps} {...$contextStore?.props} {id}>
|
||||||
|
<slot />
|
||||||
|
</p>
|
||||||
40
src/lib/components/description/DescriptionProvider.svelte
Normal file
40
src/lib/components/description/DescriptionProvider.svelte
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<script lang="ts" context="module">
|
||||||
|
export interface DescriptionContext {
|
||||||
|
name?: string;
|
||||||
|
props?: object;
|
||||||
|
register: (value: string) => void;
|
||||||
|
descriptionIds?: string;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { setContext } from "svelte";
|
||||||
|
import { writable, Writable } from "svelte/store";
|
||||||
|
export let name: string;
|
||||||
|
let descriptionIds = [];
|
||||||
|
let contextStore: Writable<DescriptionContext> = writable({
|
||||||
|
name,
|
||||||
|
register,
|
||||||
|
props: $$restProps,
|
||||||
|
});
|
||||||
|
setContext("headlessui-description-context", contextStore);
|
||||||
|
|
||||||
|
$: contextStore.set({
|
||||||
|
name,
|
||||||
|
props: $$restProps,
|
||||||
|
register,
|
||||||
|
descriptionIds:
|
||||||
|
descriptionIds.length > 0 ? descriptionIds.join(" ") : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
function register(value: string) {
|
||||||
|
descriptionIds = [...descriptionIds, value];
|
||||||
|
return () => {
|
||||||
|
descriptionIds = descriptionIds.filter(
|
||||||
|
(descriptionId) => descriptionId !== value
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<slot describedby={$contextStore.descriptionIds} />
|
||||||
243
src/lib/components/dialog/Dialog.svelte
Normal file
243
src/lib/components/dialog/Dialog.svelte
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
<script lang="ts" context="module">
|
||||||
|
import {
|
||||||
|
getContext,
|
||||||
|
setContext,
|
||||||
|
createEventDispatcher,
|
||||||
|
tick,
|
||||||
|
} from "svelte";
|
||||||
|
export enum DialogStates {
|
||||||
|
Open,
|
||||||
|
Closed,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StateDefinition {
|
||||||
|
dialogState: DialogStates;
|
||||||
|
|
||||||
|
titleId: string | null;
|
||||||
|
|
||||||
|
setTitleId(id: string | null): void;
|
||||||
|
|
||||||
|
close(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIALOG_CONTEXT_NAME = "DialogContext";
|
||||||
|
|
||||||
|
export function useDialogContext(
|
||||||
|
component: string
|
||||||
|
): Writable<StateDefinition | undefined> {
|
||||||
|
let context = getContext(DIALOG_CONTEXT_NAME) as
|
||||||
|
| Writable<StateDefinition | undefined>
|
||||||
|
| undefined;
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
`<${component} /> is missing a parent <Dialog /> component.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { State } from "./open-closed";
|
||||||
|
import { writable, Writable } from "svelte/store";
|
||||||
|
import { match } from "./match";
|
||||||
|
import { useId } from "./use-id";
|
||||||
|
import { useInertOthers } from "./use-inert-others";
|
||||||
|
import { contains } from "./dom-containers";
|
||||||
|
import { Keys } from "./keyboard";
|
||||||
|
import FocusTrap from "./FocusTrap.svelte";
|
||||||
|
import StackContextProvider, {
|
||||||
|
StackMessage,
|
||||||
|
} from "./StackContextProvider.svelte";
|
||||||
|
import DescriptionProvider from "./DescriptionProvider.svelte";
|
||||||
|
import ForcePortalRootContext from "./ForcePortalRootContext.svelte";
|
||||||
|
import Portal from "./Portal.svelte";
|
||||||
|
import PortalGroup from "./PortalGroup.svelte";
|
||||||
|
export let open: Boolean | undefined = undefined;
|
||||||
|
export let initialFocus: HTMLElement | null = null;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
let containers: Set<HTMLElement> = new Set();
|
||||||
|
let openClosedState: Writable<State> | undefined = getContext("OpenClosed");
|
||||||
|
|
||||||
|
$: open =
|
||||||
|
open === undefined && openClosedState !== undefined
|
||||||
|
? match($openClosedState, {
|
||||||
|
[State.Open]: true,
|
||||||
|
[State.Closed]: false,
|
||||||
|
})
|
||||||
|
: open;
|
||||||
|
|
||||||
|
// Validations
|
||||||
|
let hasOpen = open !== undefined || openClosedState !== null;
|
||||||
|
|
||||||
|
if (!hasOpen) {
|
||||||
|
throw new Error(
|
||||||
|
`You forgot to provide an \`open\` prop to the \`Dialog\`.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof open !== "boolean") {
|
||||||
|
throw new Error(
|
||||||
|
`You provided an \`open\` prop to the \`Dialog\`, but the value is not a boolean. Received: ${open}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$: dialogState = open ? DialogStates.Open : DialogStates.Closed;
|
||||||
|
$: visible =
|
||||||
|
openClosedState !== undefined
|
||||||
|
? $openClosedState === State.Open
|
||||||
|
: dialogState === DialogStates.Open;
|
||||||
|
|
||||||
|
let internalDialogRef: HTMLDivElement | null = null;
|
||||||
|
$: enabled = dialogState === DialogStates.Open;
|
||||||
|
|
||||||
|
const id = `headlessui-dialog-${useId()}`;
|
||||||
|
|
||||||
|
$: _cleanup = (() => {
|
||||||
|
if (_cleanup) {
|
||||||
|
_cleanup();
|
||||||
|
}
|
||||||
|
return useInertOthers(internalDialogRef, enabled);
|
||||||
|
})();
|
||||||
|
|
||||||
|
let titleId: StateDefinition["titleId"] = null;
|
||||||
|
|
||||||
|
let api: Writable<StateDefinition | undefined> = writable();
|
||||||
|
setContext(DIALOG_CONTEXT_NAME, api);
|
||||||
|
$: api.set({
|
||||||
|
titleId,
|
||||||
|
dialogState,
|
||||||
|
setTitleId(id: string | null) {
|
||||||
|
if (titleId === id) return;
|
||||||
|
titleId = id;
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
dispatch("close", false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle outside click
|
||||||
|
async function handleWindowMousedown(event: MouseEvent) {
|
||||||
|
let target = event.target as HTMLElement;
|
||||||
|
|
||||||
|
if (dialogState !== DialogStates.Open) return;
|
||||||
|
if (containers.size !== 1) return;
|
||||||
|
if (contains(containers, target)) return;
|
||||||
|
|
||||||
|
$api.close();
|
||||||
|
await tick();
|
||||||
|
target?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle `Escape` to close
|
||||||
|
function handleWindowKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key !== Keys.Escape) return;
|
||||||
|
if (dialogState !== DialogStates.Open) return;
|
||||||
|
if (containers.size > 1) return; // 1 is myself, otherwise other elements in the Stack
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
$api.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
$: _cleanupScrollLock = (() => {
|
||||||
|
if (_cleanupScrollLock) {
|
||||||
|
_cleanupScrollLock();
|
||||||
|
}
|
||||||
|
if (dialogState !== DialogStates.Open) return;
|
||||||
|
|
||||||
|
let overflow = document.documentElement.style.overflow;
|
||||||
|
let paddingRight = document.documentElement.style.paddingRight;
|
||||||
|
|
||||||
|
let scrollbarWidth =
|
||||||
|
window.innerWidth - document.documentElement.clientWidth;
|
||||||
|
|
||||||
|
document.documentElement.style.overflow = "hidden";
|
||||||
|
document.documentElement.style.paddingRight = `${scrollbarWidth}px`;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.documentElement.style.overflow = overflow;
|
||||||
|
document.documentElement.style.paddingRight = paddingRight;
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
$: _cleanupClose = () => {
|
||||||
|
if (_cleanupClose) {
|
||||||
|
_cleanupClose();
|
||||||
|
}
|
||||||
|
if (dialogState !== DialogStates.Open) return;
|
||||||
|
let container = internalDialogRef;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
let observer = new IntersectionObserver((entries) => {
|
||||||
|
for (let entry of entries) {
|
||||||
|
if (
|
||||||
|
entry.boundingClientRect.x === 0 &&
|
||||||
|
entry.boundingClientRect.y === 0 &&
|
||||||
|
entry.boundingClientRect.width === 0 &&
|
||||||
|
entry.boundingClientRect.height === 0
|
||||||
|
) {
|
||||||
|
$api.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(container);
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleClick(event: MouseEvent) {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
$: propsWeControl = {
|
||||||
|
id,
|
||||||
|
role: "dialog",
|
||||||
|
"aria-modal": dialogState === DialogStates.Open ? true : undefined,
|
||||||
|
"aria-labelledby": titleId,
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window
|
||||||
|
on:mousedown={handleWindowMousedown}
|
||||||
|
on:keydown={handleWindowKeydown}
|
||||||
|
/>
|
||||||
|
{#if open}
|
||||||
|
<FocusTrap {containers} {enabled} options={{ initialFocus }} />
|
||||||
|
<StackContextProvider
|
||||||
|
element={internalDialogRef}
|
||||||
|
onUpdate={(message, element) => {
|
||||||
|
return match(message, {
|
||||||
|
[StackMessage.Add]() {
|
||||||
|
containers.add(element);
|
||||||
|
},
|
||||||
|
[StackMessage.Remove]() {
|
||||||
|
containers.delete(element);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ForcePortalRootContext force={true}>
|
||||||
|
<Portal>
|
||||||
|
<PortalGroup target={internalDialogRef}>
|
||||||
|
<ForcePortalRootContext force={false}>
|
||||||
|
<DescriptionProvider
|
||||||
|
name={"Dialog.Description"}
|
||||||
|
let:describedby
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
{...{ ...$$restProps, ...propsWeControl }}
|
||||||
|
aria-describedby={describedby}
|
||||||
|
on:click={handleClick}
|
||||||
|
>
|
||||||
|
<slot {open} />
|
||||||
|
</div>
|
||||||
|
</DescriptionProvider>
|
||||||
|
</ForcePortalRootContext>
|
||||||
|
</PortalGroup>
|
||||||
|
</Portal>
|
||||||
|
</ForcePortalRootContext>
|
||||||
|
</StackContextProvider>
|
||||||
|
{/if}
|
||||||
20
src/lib/components/dialog/DialogOverlay.svelte
Normal file
20
src/lib/components/dialog/DialogOverlay.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { DialogStates, useDialogContext } from "./Dialog.svelte";
|
||||||
|
import { useId } from "./use-id";
|
||||||
|
let api = useDialogContext("DialogOverlay");
|
||||||
|
let id = `headlessui-dialog-overlay-${useId()}`;
|
||||||
|
function handleClick(event: MouseEvent) {
|
||||||
|
if (event.target !== event.currentTarget) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
$api.close();
|
||||||
|
}
|
||||||
|
$: propsWeControl = {
|
||||||
|
id,
|
||||||
|
"aria-hidden": true,
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div {...{ ...$$restProps, ...propsWeControl }} on:click={handleClick}>
|
||||||
|
<slot open={$api.dialogState === DialogStates.Open} />
|
||||||
|
</div>
|
||||||
19
src/lib/components/dialog/DialogTitle.svelte
Normal file
19
src/lib/components/dialog/DialogTitle.svelte
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { DialogStates, useDialogContext } from "./Dialog.svelte";
|
||||||
|
import { useId } from "./use-id";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
let api = useDialogContext("DialogTitle");
|
||||||
|
let id = `headlessui-dialog-title-${useId()}`;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
$api.setTitleId(id);
|
||||||
|
return () => $api.setTitleId(null);
|
||||||
|
});
|
||||||
|
$: propsWeControl = {
|
||||||
|
id,
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h2 {...{ ...$$restProps, ...propsWeControl }}>
|
||||||
|
<slot open={$api.dialogState === DialogStates.Open} />
|
||||||
|
</h2>
|
||||||
105
src/lib/components/disclosure/Disclosure.svelte
Normal file
105
src/lib/components/disclosure/Disclosure.svelte
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<script lang="ts" context="module">
|
||||||
|
import { writable, Writable } from "svelte/store";
|
||||||
|
import { getContext, setContext } from "svelte";
|
||||||
|
export enum DisclosureStates {
|
||||||
|
Open,
|
||||||
|
Closed,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StateDefinition {
|
||||||
|
// State
|
||||||
|
disclosureState: DisclosureStates;
|
||||||
|
panelStore: Writable<HTMLElement | null>;
|
||||||
|
panelId: string;
|
||||||
|
buttonStore: Writable<HTMLButtonElement | null>;
|
||||||
|
buttonId: string;
|
||||||
|
|
||||||
|
// State mutators
|
||||||
|
toggleDisclosure(): void;
|
||||||
|
closeDisclosure(): void;
|
||||||
|
|
||||||
|
// Exposed functions
|
||||||
|
close(focusableElement: HTMLElement | HTMLElement | null): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let DISCLOSURE_CONTEXT_NAME = "DisclosureContext";
|
||||||
|
|
||||||
|
export function useDisclosureContext(
|
||||||
|
component: string
|
||||||
|
): Writable<StateDefinition | undefined> {
|
||||||
|
let context: Writable<StateDefinition | undefined> | undefined =
|
||||||
|
getContext(DISCLOSURE_CONTEXT_NAME);
|
||||||
|
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
`<${component} /> is missing a parent <Disclosure /> component.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { useId } from "./use-id";
|
||||||
|
import { match } from "./match";
|
||||||
|
import { State } from "./open-closed";
|
||||||
|
export let defaultOpen = false;
|
||||||
|
let buttonId = `headlessui-disclosure-button-${useId()}`;
|
||||||
|
let panelId = `headlessui-disclosure-panel-${useId()}`;
|
||||||
|
|
||||||
|
let disclosureState: StateDefinition["disclosureState"] = defaultOpen
|
||||||
|
? DisclosureStates.Open
|
||||||
|
: DisclosureStates.Closed;
|
||||||
|
let panelStore: StateDefinition["panelStore"] = writable(null);
|
||||||
|
let buttonStore: StateDefinition["buttonStore"] = writable(null);
|
||||||
|
|
||||||
|
let api: Writable<StateDefinition | undefined> = writable();
|
||||||
|
setContext(DISCLOSURE_CONTEXT_NAME, api);
|
||||||
|
|
||||||
|
$: api.set({
|
||||||
|
buttonId,
|
||||||
|
panelId,
|
||||||
|
disclosureState,
|
||||||
|
panelStore,
|
||||||
|
buttonStore,
|
||||||
|
toggleDisclosure() {
|
||||||
|
disclosureState = match(disclosureState, {
|
||||||
|
[DisclosureStates.Open]: DisclosureStates.Closed,
|
||||||
|
[DisclosureStates.Closed]: DisclosureStates.Open,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
closeDisclosure() {
|
||||||
|
if (disclosureState === DisclosureStates.Closed) return;
|
||||||
|
disclosureState = DisclosureStates.Closed;
|
||||||
|
},
|
||||||
|
close(focusableElement: HTMLElement | null) {
|
||||||
|
$api.closeDisclosure();
|
||||||
|
|
||||||
|
let restoreElement = (() => {
|
||||||
|
if (!focusableElement) return $buttonStore;
|
||||||
|
if (focusableElement instanceof HTMLElement)
|
||||||
|
return focusableElement;
|
||||||
|
|
||||||
|
return $buttonStore;
|
||||||
|
})();
|
||||||
|
|
||||||
|
restoreElement?.focus();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let openClosedState: Writable<State> | undefined = writable();
|
||||||
|
setContext("OpenClosed", openClosedState);
|
||||||
|
|
||||||
|
$: $openClosedState = match(disclosureState, {
|
||||||
|
[DisclosureStates.Open]: State.Open,
|
||||||
|
[DisclosureStates.Closed]: State.Closed,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div {...$$restProps}>
|
||||||
|
<slot
|
||||||
|
open={disclosureState === DisclosureStates.Open}
|
||||||
|
close={$api?.close}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
103
src/lib/components/disclosure/DisclosureButton.svelte
Normal file
103
src/lib/components/disclosure/DisclosureButton.svelte
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
useDisclosureContext,
|
||||||
|
DisclosureStates,
|
||||||
|
} from "./Disclosure.svelte";
|
||||||
|
import { usePanelContext } from "./DisclosurePanel.svelte";
|
||||||
|
import { useId } from "./use-id";
|
||||||
|
import { Keys } from "./keyboard";
|
||||||
|
export let disabled = false;
|
||||||
|
const api = useDisclosureContext("DisclosureButton");
|
||||||
|
const panelContext = usePanelContext();
|
||||||
|
const id = `headlessui-disclosure-button-${useId()}`;
|
||||||
|
|
||||||
|
$: buttonStore = $api?.buttonStore;
|
||||||
|
$: panelStore = $api?.panelStore;
|
||||||
|
|
||||||
|
$: isWithinPanel =
|
||||||
|
panelContext === null ? false : panelContext === $api?.panelId;
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
|
if (isWithinPanel) {
|
||||||
|
$api.toggleDisclosure();
|
||||||
|
$buttonStore?.focus();
|
||||||
|
} else {
|
||||||
|
$api.toggleDisclosure();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
|
if (isWithinPanel) {
|
||||||
|
switch (event.key) {
|
||||||
|
case Keys.Space:
|
||||||
|
case Keys.Enter:
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
$api.toggleDisclosure();
|
||||||
|
$buttonStore?.focus();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switch (event.key) {
|
||||||
|
case Keys.Space:
|
||||||
|
case Keys.Enter:
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
$api.toggleDisclosure();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyUp(event: KeyboardEvent) {
|
||||||
|
switch (event.key) {
|
||||||
|
case Keys.Space:
|
||||||
|
// Required for firefox, event.preventDefault() in handleKeyDown for
|
||||||
|
// the Space key doesn't cancel the handleKeyUp, which in turn
|
||||||
|
// triggers a *click*.
|
||||||
|
event.preventDefault();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: propsWeControl = isWithinPanel
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
id,
|
||||||
|
"aria-expanded": disabled
|
||||||
|
? undefined
|
||||||
|
: $api.disclosureState === DisclosureStates.Open,
|
||||||
|
"aria-controls": $panelStore ? $api?.panelId : undefined,
|
||||||
|
disabled: disabled ? true : undefined,
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isWithinPanel}
|
||||||
|
<button
|
||||||
|
{...{ ...$$restProps, ...propsWeControl }}
|
||||||
|
on:click={handleClick}
|
||||||
|
on:keydown={handleKeyDown}
|
||||||
|
>
|
||||||
|
<slot
|
||||||
|
open={$api?.disclosureState === DisclosureStates.Open}
|
||||||
|
close={$api?.close}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
{...{ ...$$restProps, ...propsWeControl }}
|
||||||
|
bind:this={$buttonStore}
|
||||||
|
on:click={handleClick}
|
||||||
|
on:keydown={handleKeyDown}
|
||||||
|
on:keyup={handleKeyUp}
|
||||||
|
>
|
||||||
|
<slot
|
||||||
|
open={$api?.disclosureState === DisclosureStates.Open}
|
||||||
|
close={$api?.close}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
40
src/lib/components/disclosure/DisclosurePanel.svelte
Normal file
40
src/lib/components/disclosure/DisclosurePanel.svelte
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<script lang="ts" context="module">
|
||||||
|
import { getContext, setContext } from "svelte";
|
||||||
|
let DISCLOSURE_PANEL_CONTEXT_NAME = "DisclosurePanelContext";
|
||||||
|
|
||||||
|
export function usePanelContext(): string | undefined {
|
||||||
|
return getContext(DISCLOSURE_PANEL_CONTEXT_NAME);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
useDisclosureContext,
|
||||||
|
DisclosureStates,
|
||||||
|
} from "./Disclosure.svelte";
|
||||||
|
import { Writable } from "svelte/store";
|
||||||
|
import { State } from "./open-closed";
|
||||||
|
const api = useDisclosureContext("DisclosureButton");
|
||||||
|
$: id = $api?.panelId;
|
||||||
|
let openClosedState: Writable<State> | undefined = getContext("OpenClosed");
|
||||||
|
|
||||||
|
setContext(DISCLOSURE_PANEL_CONTEXT_NAME, id);
|
||||||
|
|
||||||
|
$: panelStore = $api?.panelStore;
|
||||||
|
|
||||||
|
$: visible =
|
||||||
|
$openClosedState !== null
|
||||||
|
? $openClosedState === State.Open
|
||||||
|
: $api?.disclosureState === DisclosureStates.Open;
|
||||||
|
|
||||||
|
$: propsWeControl = { id };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if visible}
|
||||||
|
<div {...{ ...$$restProps, ...propsWeControl }} bind:this={$panelStore}>
|
||||||
|
<slot
|
||||||
|
open={$api?.disclosureState === DisclosureStates.Open}
|
||||||
|
close={$api?.close}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
127
src/lib/components/focus-trap/FocusTrap.svelte
Normal file
127
src/lib/components/focus-trap/FocusTrap.svelte
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Keys } from "./keyboard";
|
||||||
|
import {
|
||||||
|
focusElement,
|
||||||
|
focusIn,
|
||||||
|
Focus,
|
||||||
|
FocusResult,
|
||||||
|
} from "./focus-management";
|
||||||
|
import { contains } from "./dom-containers";
|
||||||
|
import { afterUpdate, onMount, onDestroy } from "svelte";
|
||||||
|
|
||||||
|
export let containers: Set<HTMLElement>;
|
||||||
|
export let enabled: boolean = true;
|
||||||
|
export let options: { initialFocus?: HTMLElement | null } = {};
|
||||||
|
|
||||||
|
let restoreElement: HTMLElement | null =
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? (document.activeElement as HTMLElement)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
let previousActiveElement: HTMLElement | null = null;
|
||||||
|
|
||||||
|
function handleFocus() {
|
||||||
|
if (!enabled) return;
|
||||||
|
if (containers.size !== 1) return;
|
||||||
|
let { initialFocus } = options;
|
||||||
|
|
||||||
|
let activeElement = document.activeElement as HTMLElement;
|
||||||
|
|
||||||
|
if (initialFocus) {
|
||||||
|
if (initialFocus === activeElement) {
|
||||||
|
return; // Initial focus ref is already the active element
|
||||||
|
}
|
||||||
|
} else if (contains(containers, activeElement)) {
|
||||||
|
return; // Already focused within Dialog
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreElement = activeElement;
|
||||||
|
|
||||||
|
// Try to focus the initialFocus ref
|
||||||
|
if (initialFocus) {
|
||||||
|
focusElement(initialFocus);
|
||||||
|
} else {
|
||||||
|
let couldFocus = false;
|
||||||
|
for (let container of containers) {
|
||||||
|
let result = focusIn(container, Focus.First);
|
||||||
|
if (result === FocusResult.Success) {
|
||||||
|
couldFocus = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!couldFocus)
|
||||||
|
console.warn(
|
||||||
|
"There are no focusable elements inside the <FocusTrap />"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
previousActiveElement = document.activeElement as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore when `enabled` becomes false
|
||||||
|
function restore() {
|
||||||
|
focusElement(restoreElement);
|
||||||
|
restoreElement = null;
|
||||||
|
previousActiveElement = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle initial focus
|
||||||
|
onMount(handleFocus);
|
||||||
|
|
||||||
|
afterUpdate(() => (enabled ? handleFocus() : restore()));
|
||||||
|
|
||||||
|
onDestroy(restore);
|
||||||
|
|
||||||
|
// Handle Tab & Shift+Tab keyboard events
|
||||||
|
function handleWindowKeyDown(event: KeyboardEvent) {
|
||||||
|
if (!enabled) return;
|
||||||
|
if (event.key !== Keys.Tab) return;
|
||||||
|
if (!document.activeElement) return;
|
||||||
|
if (containers.size !== 1) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
for (let element of containers) {
|
||||||
|
let result = focusIn(
|
||||||
|
element,
|
||||||
|
(event.shiftKey ? Focus.Previous : Focus.Next) |
|
||||||
|
Focus.WrapAround
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result === FocusResult.Success) {
|
||||||
|
previousActiveElement = document.activeElement as HTMLElement;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent programmatically escaping
|
||||||
|
function handleWindowFocus(event: FocusEvent) {
|
||||||
|
if (!enabled) return;
|
||||||
|
if (containers.size !== 1) return;
|
||||||
|
|
||||||
|
let previous = previousActiveElement;
|
||||||
|
if (!previous) return;
|
||||||
|
|
||||||
|
let toElement = event.target as HTMLElement | null;
|
||||||
|
|
||||||
|
if (toElement && toElement instanceof HTMLElement) {
|
||||||
|
if (!contains(containers, toElement)) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
focusElement(previous);
|
||||||
|
} else {
|
||||||
|
previousActiveElement = toElement;
|
||||||
|
focusElement(toElement);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
focusElement(previousActiveElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window
|
||||||
|
on:keydown={handleWindowKeyDown}
|
||||||
|
on:focus|capture={handleWindowFocus}
|
||||||
|
/>
|
||||||
37
src/lib/components/label/Label.svelte
Normal file
37
src/lib/components/label/Label.svelte
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { useId } from "./use-id";
|
||||||
|
import { getContext, onMount } from "svelte";
|
||||||
|
import { Writable } from "svelte/store";
|
||||||
|
import { LabelContext } from "./LabelProvider.svelte";
|
||||||
|
const id = `headlessui-label-${useId()}`;
|
||||||
|
export let passive = false;
|
||||||
|
let contextStore: Writable<LabelContext> | undefined = getContext(
|
||||||
|
"headlessui-label-context"
|
||||||
|
);
|
||||||
|
if (!contextStore) {
|
||||||
|
throw new Error(
|
||||||
|
"You used a <Label /> component, but it is not inside a relevant parent."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => $contextStore.register(id));
|
||||||
|
|
||||||
|
let allProps = { ...$$restProps, ...$contextStore.props, id };
|
||||||
|
if (passive) delete allProps["onClick"];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y-label-has-associated-control -->
|
||||||
|
<label
|
||||||
|
{...allProps}
|
||||||
|
on:blur
|
||||||
|
on:click
|
||||||
|
on:focus
|
||||||
|
on:keyup
|
||||||
|
on:keydown
|
||||||
|
on:keypress
|
||||||
|
on:click={(event) => {
|
||||||
|
if (!passive) allProps["onClick"]?.(event);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</label>
|
||||||
37
src/lib/components/label/LabelProvider.svelte
Normal file
37
src/lib/components/label/LabelProvider.svelte
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<script lang="ts" context="module">
|
||||||
|
export interface LabelContext {
|
||||||
|
name?: string;
|
||||||
|
props?: object;
|
||||||
|
register: (value: string) => void;
|
||||||
|
labelIds?: string;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { setContext } from "svelte";
|
||||||
|
import { writable, Writable } from "svelte/store";
|
||||||
|
export let name: string;
|
||||||
|
let labelIds = [];
|
||||||
|
let contextStore: Writable<LabelContext> = writable({
|
||||||
|
name,
|
||||||
|
register,
|
||||||
|
props: $$restProps,
|
||||||
|
});
|
||||||
|
setContext("headlessui-label-context", contextStore);
|
||||||
|
|
||||||
|
$: contextStore.set({
|
||||||
|
name,
|
||||||
|
props: $$restProps,
|
||||||
|
register,
|
||||||
|
labelIds: labelIds.length > 0 ? labelIds.join(" ") : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
function register(value: string) {
|
||||||
|
labelIds = [...labelIds, value];
|
||||||
|
return () => {
|
||||||
|
labelIds = labelIds.filter((labelId) => labelId !== value);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<slot labelledby={$contextStore.labelIds} />
|
||||||
191
src/lib/components/listbox/Listbox.svelte
Normal file
191
src/lib/components/listbox/Listbox.svelte
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
<script lang="ts" context="module">
|
||||||
|
export enum ListboxStates {
|
||||||
|
Open,
|
||||||
|
Closed,
|
||||||
|
}
|
||||||
|
export type ListboxOptionDataRef = {
|
||||||
|
textValue: string;
|
||||||
|
disabled: boolean;
|
||||||
|
value: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StateDefinition = {
|
||||||
|
// State
|
||||||
|
listboxState: ListboxStates;
|
||||||
|
value: any;
|
||||||
|
orientation: "vertical" | "horizontal";
|
||||||
|
|
||||||
|
labelRef: HTMLLabelElement | null;
|
||||||
|
buttonRef: HTMLButtonElement | null;
|
||||||
|
optionsRef: HTMLDivElement | null;
|
||||||
|
|
||||||
|
disabled: boolean;
|
||||||
|
options: { id: string; dataRef: ListboxOptionDataRef }[];
|
||||||
|
searchQuery: string;
|
||||||
|
activeOptionIndex: number | null;
|
||||||
|
|
||||||
|
// State mutators
|
||||||
|
closeListbox(): void;
|
||||||
|
openListbox(): void;
|
||||||
|
goToOption(focus: Focus, id?: string): void;
|
||||||
|
search(value: string): void;
|
||||||
|
clearSearch(): void;
|
||||||
|
registerOption(id: string, dataRef: ListboxOptionDataRef): void;
|
||||||
|
unregisterOption(id: string): void;
|
||||||
|
select(value: unknown): void;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Focus, calculateActiveIndex } from "./calculate-active-index";
|
||||||
|
import { createEventDispatcher, setContext } from "svelte";
|
||||||
|
import { writable, Writable } from "svelte/store";
|
||||||
|
import { match } from "./match";
|
||||||
|
import { State, useOpenClosedProvider } from "./open-closed";
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
let listboxState = ListboxStates.Closed;
|
||||||
|
let labelStore = writable(null);
|
||||||
|
setContext("labelStore", labelStore);
|
||||||
|
$: labelRef = $labelStore;
|
||||||
|
let buttonStore = writable(null);
|
||||||
|
setContext("buttonStore", buttonStore);
|
||||||
|
$: buttonRef = $buttonStore;
|
||||||
|
let optionsStore = writable(null);
|
||||||
|
setContext("optionsStore", optionsStore);
|
||||||
|
$: optionsRef = $optionsStore;
|
||||||
|
let options = [];
|
||||||
|
let searchQuery = "";
|
||||||
|
let activeOptionIndex = null;
|
||||||
|
|
||||||
|
let api: Writable<StateDefinition | undefined> = writable();
|
||||||
|
setContext("api", api);
|
||||||
|
|
||||||
|
let openClosedState = writable(State.Closed);
|
||||||
|
useOpenClosedProvider(openClosedState);
|
||||||
|
$: openClosedState.set(
|
||||||
|
match(listboxState, {
|
||||||
|
[ListboxStates.Open]: State.Open,
|
||||||
|
[ListboxStates.Closed]: State.Closed,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export let disabled = false;
|
||||||
|
export let horizontal = false;
|
||||||
|
export let value: any;
|
||||||
|
|
||||||
|
$: orientation = (horizontal ? "horizontal" : "vertical") as
|
||||||
|
| "horizontal"
|
||||||
|
| "vertical";
|
||||||
|
$: api.set({
|
||||||
|
listboxState,
|
||||||
|
labelRef,
|
||||||
|
value,
|
||||||
|
buttonRef,
|
||||||
|
optionsRef,
|
||||||
|
options,
|
||||||
|
searchQuery,
|
||||||
|
activeOptionIndex,
|
||||||
|
disabled,
|
||||||
|
orientation,
|
||||||
|
closeListbox() {
|
||||||
|
if (disabled) return;
|
||||||
|
if (listboxState === ListboxStates.Closed) return;
|
||||||
|
listboxState = ListboxStates.Closed;
|
||||||
|
activeOptionIndex = null;
|
||||||
|
},
|
||||||
|
openListbox() {
|
||||||
|
if (disabled) return;
|
||||||
|
if (listboxState === ListboxStates.Open) return;
|
||||||
|
listboxState = ListboxStates.Open;
|
||||||
|
},
|
||||||
|
goToOption(focus: Focus, id?: string) {
|
||||||
|
if (disabled) return;
|
||||||
|
if (listboxState === ListboxStates.Closed) return;
|
||||||
|
|
||||||
|
let nextActiveOptionIndex = calculateActiveIndex(
|
||||||
|
focus === Focus.Specific
|
||||||
|
? { focus: Focus.Specific, id: id! }
|
||||||
|
: { focus: focus as Exclude<Focus, Focus.Specific> },
|
||||||
|
{
|
||||||
|
resolveItems: () => options,
|
||||||
|
resolveActiveIndex: () => activeOptionIndex,
|
||||||
|
resolveId: (option) => option.id,
|
||||||
|
resolveDisabled: (option) => option.disabled,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
searchQuery === "" &&
|
||||||
|
activeOptionIndex === nextActiveOptionIndex
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
activeOptionIndex = nextActiveOptionIndex;
|
||||||
|
searchQuery = "";
|
||||||
|
},
|
||||||
|
search(value: string) {
|
||||||
|
if (disabled) return;
|
||||||
|
if (listboxState === ListboxStates.Closed) return;
|
||||||
|
|
||||||
|
searchQuery += value.toLowerCase();
|
||||||
|
|
||||||
|
let match = options.findIndex(
|
||||||
|
(option) =>
|
||||||
|
!option.disabled && option.textValue.startsWith(searchQuery)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (match === -1 || match === activeOptionIndex) return;
|
||||||
|
activeOptionIndex = match;
|
||||||
|
},
|
||||||
|
clearSearch() {
|
||||||
|
if (disabled) return;
|
||||||
|
if (listboxState === ListboxStates.Closed) return;
|
||||||
|
if (searchQuery === "") return;
|
||||||
|
|
||||||
|
searchQuery = "";
|
||||||
|
},
|
||||||
|
registerOption(id: string, dataRef) {
|
||||||
|
options = [...options, { id, dataRef }];
|
||||||
|
},
|
||||||
|
unregisterOption(id: string) {
|
||||||
|
let nextOptions = options.slice();
|
||||||
|
let currentActiveOption =
|
||||||
|
activeOptionIndex !== null
|
||||||
|
? nextOptions[activeOptionIndex]
|
||||||
|
: null;
|
||||||
|
let idx = nextOptions.findIndex((a) => a.id === id);
|
||||||
|
if (idx !== -1) nextOptions.splice(idx, 1);
|
||||||
|
options = nextOptions;
|
||||||
|
activeOptionIndex = (() => {
|
||||||
|
if (idx === activeOptionIndex) return null;
|
||||||
|
if (currentActiveOption === null) return null;
|
||||||
|
|
||||||
|
// If we removed the option before the actual active index, then it would be out of sync. To
|
||||||
|
// fix this, we will find the correct (new) index position.
|
||||||
|
return nextOptions.indexOf(currentActiveOption);
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
select(value: unknown) {
|
||||||
|
if (disabled) return;
|
||||||
|
dispatch("updateValue", { value });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleMousedown(event: MouseEvent) {
|
||||||
|
let target = event.target as HTMLElement;
|
||||||
|
let active = document.activeElement;
|
||||||
|
|
||||||
|
if (listboxState !== ListboxStates.Open) return;
|
||||||
|
if (buttonRef?.contains(target)) return;
|
||||||
|
|
||||||
|
if (!optionsRef?.contains(target)) $api.closeListbox();
|
||||||
|
if (active !== document.body && active?.contains(target)) return; // Keep focus on newly clicked/focused element
|
||||||
|
if (!event.defaultPrevented) {
|
||||||
|
buttonRef?.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:mousedown={handleMousedown} />
|
||||||
|
<slot open={listboxState === ListboxStates.Open} />
|
||||||
85
src/lib/components/listbox/ListboxButton.svelte
Normal file
85
src/lib/components/listbox/ListboxButton.svelte
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getContext, tick } from "svelte";
|
||||||
|
import { ListboxStates, StateDefinition } from "./Listbox.svelte";
|
||||||
|
import { useId } from "./use-id";
|
||||||
|
import { Keys } from "./keyboard";
|
||||||
|
import { Focus } from "./calculate-active-index";
|
||||||
|
let api: SvelteStore<StateDefinition> = getContext("api");
|
||||||
|
let id = `headlessui-listbox-button-${useId()}`;
|
||||||
|
let buttonStore: SvelteStore<HTMLButtonElement> = getContext("buttonStore");
|
||||||
|
|
||||||
|
async function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
switch (event.key) {
|
||||||
|
// Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-13
|
||||||
|
case Keys.Space:
|
||||||
|
case Keys.Enter:
|
||||||
|
case Keys.ArrowDown:
|
||||||
|
event.preventDefault();
|
||||||
|
$api.openListbox();
|
||||||
|
await tick();
|
||||||
|
$api.optionsRef?.focus({ preventScroll: true });
|
||||||
|
if (!$api.value) $api.goToOption(Focus.First);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Keys.ArrowUp:
|
||||||
|
event.preventDefault();
|
||||||
|
$api.openListbox();
|
||||||
|
await tick();
|
||||||
|
$api.optionsRef?.focus({ preventScroll: true });
|
||||||
|
if (!$api.value) $api.goToOption(Focus.Last);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyUp(event: KeyboardEvent) {
|
||||||
|
switch (event.key) {
|
||||||
|
case Keys.Space:
|
||||||
|
// Required for firefox, event.preventDefault() in handleKeyDown for
|
||||||
|
// the Space key doesn't cancel the handleKeyUp, which in turn
|
||||||
|
// triggers a *click*.
|
||||||
|
event.preventDefault();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleClick(event: MouseEvent) {
|
||||||
|
if ($api.disabled) return;
|
||||||
|
if ($api.listboxState === ListboxStates.Open) {
|
||||||
|
$api.closeListbox();
|
||||||
|
await tick();
|
||||||
|
$api.buttonRef?.focus({ preventScroll: true });
|
||||||
|
} else {
|
||||||
|
event.preventDefault();
|
||||||
|
$api.openListbox();
|
||||||
|
await tick();
|
||||||
|
$api.optionsRef?.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: propsWeControl = {
|
||||||
|
id,
|
||||||
|
"aria-haspopup": true,
|
||||||
|
"aria-controls": $api?.optionsRef?.id,
|
||||||
|
"aria-expanded": $api?.disabled
|
||||||
|
? undefined
|
||||||
|
: $api?.listboxState === ListboxStates.Open,
|
||||||
|
"aria-labelledby": $api?.labelRef
|
||||||
|
? [$api?.labelRef?.id, id].join(" ")
|
||||||
|
: undefined,
|
||||||
|
disabled: $api?.disabled === true ? true : undefined,
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
{...$$restProps}
|
||||||
|
{...propsWeControl}
|
||||||
|
bind:this={$buttonStore}
|
||||||
|
on:click={handleClick}
|
||||||
|
on:keydown={handleKeyDown}
|
||||||
|
on:keyup={handleKeyUp}
|
||||||
|
>
|
||||||
|
<slot
|
||||||
|
open={$api.listboxState === ListboxStates.Open}
|
||||||
|
disabled={$api.disabled}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
20
src/lib/components/listbox/ListboxLabel.svelte
Normal file
20
src/lib/components/listbox/ListboxLabel.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
import { ListboxStates, StateDefinition } from "./Listbox.svelte";
|
||||||
|
import { useId } from "./use-id";
|
||||||
|
let api: SvelteStore<StateDefinition> = getContext("api");
|
||||||
|
let id = `headlessui-listbox-label-${useId()}`;
|
||||||
|
let labelStore: SvelteStore<HTMLLabelElement> = getContext("labelStore");
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
$api.buttonRef?.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y-label-has-associated-control -->
|
||||||
|
<label {...$$restProps} {id} bind:this={$labelStore} on:click={handleClick}>
|
||||||
|
<slot
|
||||||
|
open={$api.listboxState === ListboxStates.Open}
|
||||||
|
disabled={$api.disabled}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
114
src/lib/components/listbox/ListboxOption.svelte
Normal file
114
src/lib/components/listbox/ListboxOption.svelte
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getContext, onDestroy, onMount, tick } from "svelte";
|
||||||
|
import { ListboxStates, StateDefinition } from "./Listbox.svelte";
|
||||||
|
import { useId } from "./use-id";
|
||||||
|
import { Focus } from "./calculate-active-index";
|
||||||
|
export let value: any;
|
||||||
|
export let disabled = false;
|
||||||
|
let api: SvelteStore<StateDefinition> = getContext("api");
|
||||||
|
let id = `headlessui-listbox-option-${useId()}`;
|
||||||
|
|
||||||
|
$: active =
|
||||||
|
$api?.activeOptionIndex !== null
|
||||||
|
? $api?.options[$api.activeOptionIndex].id === id
|
||||||
|
: false;
|
||||||
|
|
||||||
|
$: selected = $api?.value === value;
|
||||||
|
$: dataRef = {
|
||||||
|
disabled,
|
||||||
|
value,
|
||||||
|
textValue: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
let textValue = document
|
||||||
|
.getElementById(id)
|
||||||
|
?.textContent?.toLowerCase()
|
||||||
|
.trim();
|
||||||
|
if (textValue !== undefined) dataRef.textValue = textValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => $api.registerOption(id, dataRef));
|
||||||
|
onDestroy(() => $api.unregisterOption(id));
|
||||||
|
|
||||||
|
let oldState = $api?.listboxState;
|
||||||
|
let oldSelected = selected;
|
||||||
|
let oldActive = active;
|
||||||
|
async function updateFocus(
|
||||||
|
newState: ListboxStates,
|
||||||
|
newSelected: boolean,
|
||||||
|
newActive: boolean
|
||||||
|
) {
|
||||||
|
// Wait for a tick since we need to ensure registerOption has been applied
|
||||||
|
await tick();
|
||||||
|
if (newState !== oldState || newSelected !== oldSelected) {
|
||||||
|
if (newState === ListboxStates.Open && newSelected) {
|
||||||
|
$api?.goToOption(Focus.Specific, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (newState !== oldState || newActive !== oldActive) {
|
||||||
|
if (newState === ListboxStates.Open && newActive) {
|
||||||
|
document
|
||||||
|
.getElementById(id)
|
||||||
|
?.scrollIntoView?.({ block: "nearest" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
oldState = newState;
|
||||||
|
oldSelected = newSelected;
|
||||||
|
oldActive = newActive;
|
||||||
|
}
|
||||||
|
$: updateFocus($api?.listboxState, selected, active);
|
||||||
|
|
||||||
|
async function handleClick(event: MouseEvent) {
|
||||||
|
if (disabled) return event.preventDefault();
|
||||||
|
$api.select(value);
|
||||||
|
$api.closeListbox();
|
||||||
|
await tick();
|
||||||
|
$api.buttonRef?.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFocus() {
|
||||||
|
if (disabled) return $api.goToOption(Focus.Nothing);
|
||||||
|
$api.goToOption(Focus.Specific, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMove() {
|
||||||
|
if (disabled) return;
|
||||||
|
if (active) return;
|
||||||
|
$api.goToOption(Focus.Specific, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLeave() {
|
||||||
|
if (disabled) return;
|
||||||
|
if (!active) return;
|
||||||
|
$api.goToOption(Focus.Nothing);
|
||||||
|
}
|
||||||
|
|
||||||
|
$: propsWeControl = {
|
||||||
|
id,
|
||||||
|
role: "option",
|
||||||
|
tabIndex: disabled === true ? undefined : -1,
|
||||||
|
"aria-disabled": disabled === true ? true : undefined,
|
||||||
|
"aria-selected": selected === true ? selected : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
$: classStyle = $$props.class
|
||||||
|
? typeof $$props.class === "function"
|
||||||
|
? $$props.class({ active, selected, disabled })
|
||||||
|
: $$props.class
|
||||||
|
: "";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<li
|
||||||
|
{...$$restProps}
|
||||||
|
class={classStyle}
|
||||||
|
{...propsWeControl}
|
||||||
|
on:click={handleClick}
|
||||||
|
on:focus={handleFocus}
|
||||||
|
on:pointermove={handleMove}
|
||||||
|
on:mousemove={handleMove}
|
||||||
|
on:pointerleave={handleLeave}
|
||||||
|
on:mouseleave={handleLeave}
|
||||||
|
>
|
||||||
|
<slot {active} {selected} {disabled} />
|
||||||
|
</li>
|
||||||
118
src/lib/components/listbox/ListboxOptions.svelte
Normal file
118
src/lib/components/listbox/ListboxOptions.svelte
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getContext, tick } from "svelte";
|
||||||
|
import { ListboxStates, StateDefinition } from "./Listbox.svelte";
|
||||||
|
import { useId } from "./use-id";
|
||||||
|
import { match } from "./match";
|
||||||
|
import { Keys } from "./keyboard";
|
||||||
|
import { Focus } from "./calculate-active-index";
|
||||||
|
import { State, useOpenClosed } from "./open-closed";
|
||||||
|
let api: SvelteStore<StateDefinition> = getContext("api");
|
||||||
|
let id = `headlessui-listbox-options-${useId()}`;
|
||||||
|
let optionsStore: SvelteStore<HTMLUListElement> =
|
||||||
|
getContext("optionsStore");
|
||||||
|
|
||||||
|
let searchDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
async function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (searchDebounce) clearTimeout(searchDebounce);
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
// Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12
|
||||||
|
|
||||||
|
case Keys.Space:
|
||||||
|
if ($api.searchQuery !== "") {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
return $api.search(event.key);
|
||||||
|
}
|
||||||
|
// When in type ahead mode, fallthrough
|
||||||
|
case Keys.Enter:
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if ($api.activeOptionIndex !== null) {
|
||||||
|
let { dataRef } = $api.options[$api.activeOptionIndex];
|
||||||
|
$api.select(dataRef.value);
|
||||||
|
}
|
||||||
|
$api.closeListbox();
|
||||||
|
await tick();
|
||||||
|
$api.buttonRef?.focus({ preventScroll: true });
|
||||||
|
break;
|
||||||
|
|
||||||
|
case match($api.orientation, {
|
||||||
|
vertical: Keys.ArrowDown,
|
||||||
|
horizontal: Keys.ArrowRight,
|
||||||
|
}):
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
return $api.goToOption(Focus.Next);
|
||||||
|
|
||||||
|
case match($api.orientation, {
|
||||||
|
vertical: Keys.ArrowUp,
|
||||||
|
horizontal: Keys.ArrowLeft,
|
||||||
|
}):
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
return $api.goToOption(Focus.Previous);
|
||||||
|
|
||||||
|
case Keys.Home:
|
||||||
|
case Keys.PageUp:
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
return $api.goToOption(Focus.First);
|
||||||
|
|
||||||
|
case Keys.End:
|
||||||
|
case Keys.PageDown:
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
return $api.goToOption(Focus.Last);
|
||||||
|
|
||||||
|
case Keys.Escape:
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
$api.closeListbox();
|
||||||
|
await tick();
|
||||||
|
$api.buttonRef?.focus({ preventScroll: true });
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Keys.Tab:
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
if (event.key.length === 1) {
|
||||||
|
$api.search(event.key);
|
||||||
|
searchDebounce = setTimeout(() => $api.clearSearch(), 350);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: propsWeControl = {
|
||||||
|
"aria-activedescendant":
|
||||||
|
$api?.activeOptionIndex === null
|
||||||
|
? undefined
|
||||||
|
: $api?.options[$api.activeOptionIndex]?.id,
|
||||||
|
"aria-labelledby": $api?.labelRef?.id ?? $api?.buttonRef?.id,
|
||||||
|
"aria-orientation": $api?.orientation,
|
||||||
|
id,
|
||||||
|
role: "listbox",
|
||||||
|
tabIndex: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let usesOpenClosedState = useOpenClosed();
|
||||||
|
$: visible =
|
||||||
|
usesOpenClosedState !== undefined
|
||||||
|
? $usesOpenClosedState === State.Open
|
||||||
|
: $api.listboxState === ListboxStates.Open;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if visible}
|
||||||
|
<ul
|
||||||
|
bind:this={$optionsStore}
|
||||||
|
on:keydown={handleKeyDown}
|
||||||
|
{...$$restProps}
|
||||||
|
{...propsWeControl}
|
||||||
|
>
|
||||||
|
<slot open={$api?.listboxState === ListboxStates.Open} />
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
151
src/lib/components/menu/Menu.svelte
Normal file
151
src/lib/components/menu/Menu.svelte
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<script lang="ts" context="module">
|
||||||
|
import { Focus, calculateActiveIndex } from "./calculate-active-index";
|
||||||
|
import { getContext, setContext } from "svelte";
|
||||||
|
import { writable, Writable } from "svelte/store";
|
||||||
|
import { State } from "./open-closed";
|
||||||
|
import { match } from "./match";
|
||||||
|
export enum MenuStates {
|
||||||
|
Open,
|
||||||
|
Closed,
|
||||||
|
}
|
||||||
|
export type MenuItemData = { textValue: string; disabled: boolean };
|
||||||
|
export type StateDefinition = {
|
||||||
|
// State
|
||||||
|
menuState: MenuStates;
|
||||||
|
buttonStore: Writable<HTMLButtonElement | null>;
|
||||||
|
itemsStore: Writable<HTMLDivElement | null>;
|
||||||
|
items: { id: string; data: MenuItemData }[];
|
||||||
|
searchQuery: string;
|
||||||
|
activeItemIndex: number | null;
|
||||||
|
|
||||||
|
// State mutators
|
||||||
|
closeMenu(): void;
|
||||||
|
openMenu(): void;
|
||||||
|
goToItem(focus: Focus, id?: string): void;
|
||||||
|
search(value: string): void;
|
||||||
|
clearSearch(): void;
|
||||||
|
registerItem(id: string, dataRef: MenuItemData): void;
|
||||||
|
unregisterItem(id: string): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MENU_CONTEXT_NAME = "MenuContext";
|
||||||
|
|
||||||
|
export function useMenuContext(
|
||||||
|
componentName: string
|
||||||
|
): Writable<StateDefinition | undefined> {
|
||||||
|
let context: Writable<StateDefinition | undefined> | undefined =
|
||||||
|
getContext(MENU_CONTEXT_NAME);
|
||||||
|
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
`<${componentName} /> is missing a parent <Menu /> component.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
let menuState: StateDefinition["menuState"] = MenuStates.Closed;
|
||||||
|
let buttonStore: StateDefinition["buttonStore"] = writable(null);
|
||||||
|
let itemsStore: StateDefinition["itemsStore"] = writable(null);
|
||||||
|
let items: StateDefinition["items"] = [];
|
||||||
|
let searchQuery: StateDefinition["searchQuery"] = "";
|
||||||
|
let activeItemIndex: StateDefinition["activeItemIndex"] = null;
|
||||||
|
|
||||||
|
let api: Writable<StateDefinition | undefined> = writable();
|
||||||
|
setContext(MENU_CONTEXT_NAME, api);
|
||||||
|
|
||||||
|
$: api.set({
|
||||||
|
menuState,
|
||||||
|
buttonStore,
|
||||||
|
itemsStore: itemsStore,
|
||||||
|
items,
|
||||||
|
searchQuery,
|
||||||
|
activeItemIndex,
|
||||||
|
closeMenu: () => {
|
||||||
|
menuState = MenuStates.Closed;
|
||||||
|
activeItemIndex = null;
|
||||||
|
},
|
||||||
|
openMenu: () => (menuState = MenuStates.Open),
|
||||||
|
goToItem(focus: Focus, id?: string) {
|
||||||
|
let nextActiveItemIndex = calculateActiveIndex(
|
||||||
|
focus === Focus.Specific
|
||||||
|
? { focus: Focus.Specific, id: id! }
|
||||||
|
: { focus: focus as Exclude<Focus, Focus.Specific> },
|
||||||
|
{
|
||||||
|
resolveItems: () => items,
|
||||||
|
resolveActiveIndex: () => activeItemIndex,
|
||||||
|
resolveId: (item) => item.id,
|
||||||
|
resolveDisabled: (item) => item.data.disabled,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (searchQuery === "" && activeItemIndex === nextActiveItemIndex)
|
||||||
|
return;
|
||||||
|
searchQuery = "";
|
||||||
|
activeItemIndex = nextActiveItemIndex;
|
||||||
|
},
|
||||||
|
search(value: string) {
|
||||||
|
searchQuery += value.toLowerCase();
|
||||||
|
|
||||||
|
let match = items.findIndex(
|
||||||
|
(item) =>
|
||||||
|
item.data.textValue.startsWith(searchQuery) &&
|
||||||
|
!item.data.disabled
|
||||||
|
);
|
||||||
|
|
||||||
|
if (match === -1 || match === activeItemIndex) return;
|
||||||
|
|
||||||
|
activeItemIndex = match;
|
||||||
|
},
|
||||||
|
clearSearch() {
|
||||||
|
searchQuery = "";
|
||||||
|
},
|
||||||
|
registerItem(id: string, data: MenuItemData) {
|
||||||
|
items.push({ id, data });
|
||||||
|
},
|
||||||
|
unregisterItem(id: string) {
|
||||||
|
let nextItems = items.slice();
|
||||||
|
let currentActiveItem =
|
||||||
|
activeItemIndex !== null ? nextItems[activeItemIndex] : null;
|
||||||
|
let idx = nextItems.findIndex((a) => a.id === id);
|
||||||
|
if (idx !== -1) nextItems.splice(idx, 1);
|
||||||
|
items = nextItems;
|
||||||
|
activeItemIndex = (() => {
|
||||||
|
if (idx === activeItemIndex) return null;
|
||||||
|
if (currentActiveItem === null) return null;
|
||||||
|
|
||||||
|
// If we removed the item before the actual active index, then it would be out of sync. To
|
||||||
|
// fix this, we will find the correct (new) index position.
|
||||||
|
return nextItems.indexOf(currentActiveItem);
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleWindowMousedown(event: MouseEvent): void {
|
||||||
|
let target = event.target as HTMLElement;
|
||||||
|
let active = document.activeElement;
|
||||||
|
|
||||||
|
if (menuState !== MenuStates.Open) return;
|
||||||
|
if ($buttonStore?.contains(target)) return;
|
||||||
|
|
||||||
|
if (!$itemsStore?.contains(target)) $api.closeMenu();
|
||||||
|
if (active !== document.body && active?.contains(target)) return; // Keep focus on newly clicked/focused element
|
||||||
|
if (!event.defaultPrevented)
|
||||||
|
$buttonStore?.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
let openClosedState: Writable<State> | undefined = writable();
|
||||||
|
setContext("OpenClosed", openClosedState);
|
||||||
|
|
||||||
|
$: $openClosedState = match(menuState, {
|
||||||
|
[MenuStates.Open]: State.Open,
|
||||||
|
[MenuStates.Closed]: State.Closed,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:mousedown={handleWindowMousedown} />
|
||||||
|
<div {...$$restProps}>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
83
src/lib/components/menu/MenuButton.svelte
Normal file
83
src/lib/components/menu/MenuButton.svelte
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { useMenuContext, MenuStates } from "./Menu.svelte";
|
||||||
|
import { useId } from "./use-id";
|
||||||
|
import { Keys } from "./keyboard";
|
||||||
|
import { Focus } from "./calculate-active-index";
|
||||||
|
import { tick } from "svelte";
|
||||||
|
export let disabled = false;
|
||||||
|
const api = useMenuContext("MenuButton");
|
||||||
|
const id = `headlessui-menu-button-${useId()}`;
|
||||||
|
|
||||||
|
$: buttonStore = $api?.buttonStore;
|
||||||
|
$: itemsStore = $api?.itemsStore;
|
||||||
|
async function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
switch (event.key) {
|
||||||
|
// Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-13
|
||||||
|
|
||||||
|
case Keys.Space:
|
||||||
|
case Keys.Enter:
|
||||||
|
case Keys.ArrowDown:
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
$api.openMenu();
|
||||||
|
await tick();
|
||||||
|
$itemsStore?.focus({ preventScroll: true });
|
||||||
|
$api.goToItem(Focus.First);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Keys.ArrowUp:
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
$api.openMenu();
|
||||||
|
await tick();
|
||||||
|
$itemsStore?.focus({ preventScroll: true });
|
||||||
|
$api.goToItem(Focus.Last);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyUp(event: KeyboardEvent) {
|
||||||
|
switch (event.key) {
|
||||||
|
case Keys.Space:
|
||||||
|
// Required for firefox, event.preventDefault() in handleKeyDown for
|
||||||
|
// the Space key doesn't cancel the handleKeyUp, which in turn
|
||||||
|
// triggers a *click*.
|
||||||
|
event.preventDefault();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleClick(event: MouseEvent) {
|
||||||
|
if (disabled) return;
|
||||||
|
if ($api.menuState === MenuStates.Open) {
|
||||||
|
$api.closeMenu();
|
||||||
|
await tick();
|
||||||
|
$buttonStore?.focus({ preventScroll: true });
|
||||||
|
} else {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
$api.openMenu();
|
||||||
|
await tick();
|
||||||
|
$itemsStore?.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: propsWeControl = {
|
||||||
|
id,
|
||||||
|
"aria-haspopup": true,
|
||||||
|
"aria-controls": $itemsStore?.id,
|
||||||
|
"aria-expanded": disabled
|
||||||
|
? undefined
|
||||||
|
: $api.menuState === MenuStates.Open,
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
{...{ ...$$restProps, ...propsWeControl }}
|
||||||
|
bind:this={$buttonStore}
|
||||||
|
on:click={handleClick}
|
||||||
|
on:keydown={handleKeyDown}
|
||||||
|
on:keyup={handleKeyUp}
|
||||||
|
>
|
||||||
|
<slot open={$api?.menuState === MenuStates.Open} />
|
||||||
|
</button>
|
||||||
79
src/lib/components/menu/MenuItem.svelte
Normal file
79
src/lib/components/menu/MenuItem.svelte
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { useMenuContext, MenuStates, MenuItemData } from "./Menu.svelte";
|
||||||
|
import { useId } from "./use-id";
|
||||||
|
import { Focus } from "./calculate-active-index";
|
||||||
|
import { afterUpdate, onDestroy, onMount, tick } from "svelte";
|
||||||
|
export let disabled = false;
|
||||||
|
const api = useMenuContext("MenuItem");
|
||||||
|
const id = `headlessui-menu-item-${useId()}`;
|
||||||
|
|
||||||
|
$: active =
|
||||||
|
$api?.activeItemIndex !== null
|
||||||
|
? $api?.items[$api?.activeItemIndex].id === id
|
||||||
|
: false;
|
||||||
|
|
||||||
|
$: buttonStore = $api?.buttonStore;
|
||||||
|
|
||||||
|
let elementRef: HTMLDivElement | undefined;
|
||||||
|
$: textValue = elementRef?.textContent?.toLowerCase().trim();
|
||||||
|
$: data = { disabled, textValue } as MenuItemData;
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await tick();
|
||||||
|
$api?.registerItem(id, data);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
$api.unregisterItem(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterUpdate(async () => {
|
||||||
|
if ($api.menuState !== MenuStates.Open) return;
|
||||||
|
if (!active) return;
|
||||||
|
await tick();
|
||||||
|
elementRef?.scrollIntoView?.({ block: "nearest" });
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleClick(event: MouseEvent) {
|
||||||
|
if (disabled) return event.preventDefault();
|
||||||
|
$api.closeMenu();
|
||||||
|
$buttonStore?.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFocus() {
|
||||||
|
if (disabled) return $api.goToItem(Focus.Nothing);
|
||||||
|
$api.goToItem(Focus.Specific, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMove() {
|
||||||
|
if (disabled) return;
|
||||||
|
if (active) return;
|
||||||
|
$api.goToItem(Focus.Specific, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLeave() {
|
||||||
|
if (disabled) return;
|
||||||
|
if (!active) return;
|
||||||
|
$api.goToItem(Focus.Nothing);
|
||||||
|
}
|
||||||
|
|
||||||
|
$: propsWeControl = {
|
||||||
|
id,
|
||||||
|
role: "menuitem",
|
||||||
|
tabIndex: disabled === true ? undefined : -1,
|
||||||
|
"aria-disabled": disabled === true ? true : undefined,
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
{...{ ...$$restProps, ...propsWeControl }}
|
||||||
|
bind:this={elementRef}
|
||||||
|
on:click={handleClick}
|
||||||
|
on:focus={handleFocus}
|
||||||
|
on:pointermove={handleMove}
|
||||||
|
on:mousemove={handleMove}
|
||||||
|
on:pointerleave={handleLeave}
|
||||||
|
on:mouseleave={handleLeave}
|
||||||
|
>
|
||||||
|
<slot {active} {disabled} />
|
||||||
|
</div>
|
||||||
139
src/lib/components/menu/MenuItems.svelte
Normal file
139
src/lib/components/menu/MenuItems.svelte
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { useMenuContext, MenuStates } from "./Menu.svelte";
|
||||||
|
import { useId } from "./use-id";
|
||||||
|
import { Keys } from "./keyboard";
|
||||||
|
import { Focus } from "./calculate-active-index";
|
||||||
|
import { treeWalker } from "./tree-walker";
|
||||||
|
import { State } from "./open-closed";
|
||||||
|
import { getContext, tick } from "svelte";
|
||||||
|
import { Writable } from "svelte/store";
|
||||||
|
const api = useMenuContext("MenuButton");
|
||||||
|
const id = `headlessui-menu-items-${useId()}`;
|
||||||
|
let searchDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
$: buttonStore = $api?.buttonStore;
|
||||||
|
$: itemsStore = $api?.itemsStore;
|
||||||
|
|
||||||
|
let openClosedState: Writable<State> | undefined = getContext("OpenClosed");
|
||||||
|
|
||||||
|
$: visible =
|
||||||
|
openClosedState !== undefined
|
||||||
|
? $openClosedState === State.Open
|
||||||
|
: $api.menuState === MenuStates.Open;
|
||||||
|
|
||||||
|
$: treeWalker({
|
||||||
|
container: $itemsStore,
|
||||||
|
enabled: $api?.menuState === MenuStates.Open,
|
||||||
|
accept(node) {
|
||||||
|
if (node.getAttribute("role") === "menuitem")
|
||||||
|
return NodeFilter.FILTER_REJECT;
|
||||||
|
if (node.hasAttribute("role")) return NodeFilter.FILTER_SKIP;
|
||||||
|
return NodeFilter.FILTER_ACCEPT;
|
||||||
|
},
|
||||||
|
walk(node) {
|
||||||
|
node.setAttribute("role", "none");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (searchDebounce) clearTimeout(searchDebounce);
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
// Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12
|
||||||
|
|
||||||
|
case Keys.Space:
|
||||||
|
if ($api.searchQuery !== "") {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
return $api.search(event.key);
|
||||||
|
}
|
||||||
|
// When in type ahead mode, fallthrough
|
||||||
|
case Keys.Enter:
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if ($api.activeItemIndex !== null) {
|
||||||
|
let { id } = $api.items[$api.activeItemIndex];
|
||||||
|
document.getElementById(id)?.click();
|
||||||
|
}
|
||||||
|
$api.closeMenu();
|
||||||
|
await tick();
|
||||||
|
$buttonStore?.focus({ preventScroll: true });
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Keys.ArrowDown:
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
return $api.goToItem(Focus.Next);
|
||||||
|
|
||||||
|
case Keys.ArrowUp:
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
return $api.goToItem(Focus.Previous);
|
||||||
|
|
||||||
|
case Keys.Home:
|
||||||
|
case Keys.PageUp:
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
return $api.goToItem(Focus.First);
|
||||||
|
|
||||||
|
case Keys.End:
|
||||||
|
case Keys.PageDown:
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
return $api.goToItem(Focus.Last);
|
||||||
|
|
||||||
|
case Keys.Escape:
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
$api.closeMenu();
|
||||||
|
await tick();
|
||||||
|
$buttonStore?.focus({ preventScroll: true });
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Keys.Tab:
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
if (event.key.length === 1) {
|
||||||
|
$api.search(event.key);
|
||||||
|
searchDebounce = setTimeout(() => $api.clearSearch(), 350);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyUp(event: KeyboardEvent) {
|
||||||
|
switch (event.key) {
|
||||||
|
case Keys.Space:
|
||||||
|
// Required for firefox, event.preventDefault() in handleKeyDown for
|
||||||
|
// the Space key doesn't cancel the handleKeyUp, which in turn
|
||||||
|
// triggers a *click*.
|
||||||
|
event.preventDefault();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: propsWeControl = {
|
||||||
|
"aria-activedescendant":
|
||||||
|
$api.activeItemIndex === null
|
||||||
|
? undefined
|
||||||
|
: $api.items[$api.activeItemIndex]?.id,
|
||||||
|
"aria-labelledby": $buttonStore?.id,
|
||||||
|
id,
|
||||||
|
role: "menu",
|
||||||
|
tabIndex: 0,
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if visible}
|
||||||
|
<div
|
||||||
|
{...{ ...$$restProps, ...propsWeControl }}
|
||||||
|
bind:this={$itemsStore}
|
||||||
|
on:keydown={handleKeyDown}
|
||||||
|
on:keyup={handleKeyUp}
|
||||||
|
>
|
||||||
|
<slot open={$api.menuState === MenuStates.Open} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
145
src/lib/components/popover/Popover.svelte
Normal file
145
src/lib/components/popover/Popover.svelte
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
<script lang="ts" context="module">
|
||||||
|
export enum PopoverStates {
|
||||||
|
Open,
|
||||||
|
Closed,
|
||||||
|
}
|
||||||
|
export interface StateDefinition {
|
||||||
|
// State
|
||||||
|
popoverState: PopoverStates;
|
||||||
|
button: HTMLElement | null;
|
||||||
|
buttonId: string;
|
||||||
|
panel: HTMLElement | null;
|
||||||
|
panelId: string;
|
||||||
|
|
||||||
|
// State mutators
|
||||||
|
togglePopover(): void;
|
||||||
|
closePopover(): void;
|
||||||
|
|
||||||
|
// Exposed functions
|
||||||
|
close(focusableElement: HTMLElement | null): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PopoverRegisterBag {
|
||||||
|
buttonId: string;
|
||||||
|
panelId: string;
|
||||||
|
close(): void;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { match } from "./match";
|
||||||
|
import { useId } from "./use-id";
|
||||||
|
import { isFocusableElement, FocusableMode } from "./focus-management";
|
||||||
|
import { State } from "./open-closed";
|
||||||
|
import type { PopoverGroupContext } from "./PopoverGroup.svelte";
|
||||||
|
import { getContext, setContext, onMount } from "svelte";
|
||||||
|
import { writable, Writable } from "svelte/store";
|
||||||
|
const buttonId = `headlessui-popover-button-${useId()}`;
|
||||||
|
const panelId = `headlessui-popover-panel-${useId()}`;
|
||||||
|
|
||||||
|
let popoverState: PopoverStates = PopoverStates.Closed;
|
||||||
|
|
||||||
|
let api: Writable<StateDefinition | undefined> = writable();
|
||||||
|
setContext("PopoverApi", api);
|
||||||
|
|
||||||
|
let openClosedState: Writable<State> | undefined = writable();
|
||||||
|
setContext("OpenClosed", openClosedState);
|
||||||
|
|
||||||
|
$: $openClosedState = match(popoverState, {
|
||||||
|
[PopoverStates.Open]: State.Open,
|
||||||
|
[PopoverStates.Closed]: State.Closed,
|
||||||
|
});
|
||||||
|
|
||||||
|
let panelStore = writable(null);
|
||||||
|
setContext("PopoverPanelRef", panelStore);
|
||||||
|
$: panel = $panelStore;
|
||||||
|
|
||||||
|
let buttonStore = writable(null);
|
||||||
|
setContext("PopoverButtonRef", buttonStore);
|
||||||
|
$: button = $buttonStore;
|
||||||
|
|
||||||
|
$: api.set({
|
||||||
|
popoverState,
|
||||||
|
buttonId,
|
||||||
|
panelId,
|
||||||
|
panel,
|
||||||
|
button,
|
||||||
|
togglePopover() {
|
||||||
|
popoverState = match(popoverState, {
|
||||||
|
[PopoverStates.Open]: PopoverStates.Closed,
|
||||||
|
[PopoverStates.Closed]: PopoverStates.Open,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
closePopover() {
|
||||||
|
if (popoverState === PopoverStates.Closed) return;
|
||||||
|
popoverState = PopoverStates.Closed;
|
||||||
|
},
|
||||||
|
close(focusableElement: HTMLElement | null) {
|
||||||
|
$api.closePopover();
|
||||||
|
|
||||||
|
let restoreElement = (() => {
|
||||||
|
if (!focusableElement) return $api.button;
|
||||||
|
if (focusableElement instanceof HTMLElement)
|
||||||
|
return focusableElement;
|
||||||
|
|
||||||
|
return $api.button;
|
||||||
|
})();
|
||||||
|
|
||||||
|
restoreElement?.focus();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const registerBag = {
|
||||||
|
buttonId,
|
||||||
|
panelId,
|
||||||
|
close() {
|
||||||
|
$api.closePopover();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupContext: PopoverGroupContext | undefined =
|
||||||
|
getContext("PopoverGroup");
|
||||||
|
const registerPopover = groupContext?.registerPopover;
|
||||||
|
|
||||||
|
function isFocusWithinPopoverGroup() {
|
||||||
|
return (
|
||||||
|
groupContext?.isFocusWithinPopoverGroup() ??
|
||||||
|
(button?.contains(document.activeElement) ||
|
||||||
|
panel?.contains(document.activeElement))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => registerPopover?.(registerBag));
|
||||||
|
|
||||||
|
// Handle focus out
|
||||||
|
function handleFocus() {
|
||||||
|
if (popoverState !== PopoverStates.Open) return;
|
||||||
|
if (isFocusWithinPopoverGroup()) return;
|
||||||
|
if (!button) return;
|
||||||
|
if (!panel) return;
|
||||||
|
|
||||||
|
$api.closePopover();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle outside click
|
||||||
|
function handleMousedown(event: MouseEvent) {
|
||||||
|
let target = event.target as HTMLElement;
|
||||||
|
|
||||||
|
if (popoverState !== PopoverStates.Open) return;
|
||||||
|
|
||||||
|
if (button?.contains(target)) return;
|
||||||
|
if (panel?.contains(target)) return;
|
||||||
|
|
||||||
|
$api.closePopover();
|
||||||
|
|
||||||
|
if (!isFocusableElement(target, FocusableMode.Loose)) {
|
||||||
|
event.preventDefault();
|
||||||
|
button?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:focus|capture={handleFocus} on:mousedown={handleMousedown} />
|
||||||
|
<div {...$$restProps}>
|
||||||
|
<slot open={popoverState === PopoverStates.Open} close={$api.close} />
|
||||||
|
</div>
|
||||||
176
src/lib/components/popover/PopoverButton.svelte
Normal file
176
src/lib/components/popover/PopoverButton.svelte
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Keys } from "./keyboard";
|
||||||
|
import { getFocusableElements, Focus, focusIn } from "./focus-management";
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
import { writable, Writable } from "svelte/store";
|
||||||
|
import { PopoverStates, StateDefinition } from "./Popover.svelte";
|
||||||
|
import type { PopoverGroupContext } from "./PopoverGroup.svelte";
|
||||||
|
import type { PopoverPanelContext } from "./PopoverPanel.svelte";
|
||||||
|
let buttonStore: Writable<HTMLButtonElement> =
|
||||||
|
getContext("PopoverButtonRef");
|
||||||
|
export let disabled: Boolean = false;
|
||||||
|
let api: Writable<StateDefinition> | undefined = getContext("PopoverApi");
|
||||||
|
|
||||||
|
const groupContext: PopoverGroupContext | undefined =
|
||||||
|
getContext("PopoverGroup");
|
||||||
|
const closeOthers = groupContext?.closeOthers;
|
||||||
|
|
||||||
|
let panelContext: PopoverPanelContext | undefined =
|
||||||
|
getContext("PopoverPanel");
|
||||||
|
let isWithinPanel =
|
||||||
|
panelContext === null ? false : panelContext === $api.panelId;
|
||||||
|
if (isWithinPanel) {
|
||||||
|
buttonStore = writable();
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Revisit when handling Tab/Shift+Tab when using Portal's
|
||||||
|
let activeElementRef: Element | null = null;
|
||||||
|
let previousActiveElementRef: Element | null =
|
||||||
|
typeof window === "undefined" ? null : document.activeElement;
|
||||||
|
|
||||||
|
function handleFocus() {
|
||||||
|
previousActiveElementRef = activeElementRef;
|
||||||
|
activeElementRef = document.activeElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (isWithinPanel) {
|
||||||
|
if ($api.popoverState === PopoverStates.Closed) return;
|
||||||
|
switch (event.key) {
|
||||||
|
case Keys.Space:
|
||||||
|
case Keys.Enter:
|
||||||
|
event.preventDefault(); // Prevent triggering a *click* event
|
||||||
|
event.stopPropagation();
|
||||||
|
$api.closePopover();
|
||||||
|
$api.button?.focus(); // Re-focus the original opening Button
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switch (event.key) {
|
||||||
|
case Keys.Space:
|
||||||
|
case Keys.Enter:
|
||||||
|
event.preventDefault(); // Prevent triggering a *click* event
|
||||||
|
event.stopPropagation();
|
||||||
|
if ($api.popoverState === PopoverStates.Closed)
|
||||||
|
closeOthers?.($api.buttonId);
|
||||||
|
$api.togglePopover();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Keys.Escape:
|
||||||
|
if ($api.popoverState !== PopoverStates.Open)
|
||||||
|
return closeOthers?.($api.buttonId);
|
||||||
|
if (!$api.button) return;
|
||||||
|
if (!$api.button?.contains(document.activeElement)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
$api.closePopover();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Keys.Tab:
|
||||||
|
if ($api.popoverState !== PopoverStates.Open) return;
|
||||||
|
if (!$api.panel) return;
|
||||||
|
if (!$api.button) return;
|
||||||
|
|
||||||
|
// TODO: Revisit when handling Tab/Shift+Tab when using Portal's
|
||||||
|
if (event.shiftKey) {
|
||||||
|
// Check if the last focused element exists, and check that it is not inside button or panel itself
|
||||||
|
if (!previousActiveElementRef) return;
|
||||||
|
if ($api.button?.contains(previousActiveElementRef))
|
||||||
|
return;
|
||||||
|
if ($api.panel?.contains(previousActiveElementRef))
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Check if the last focused element is *after* the button in the DOM
|
||||||
|
let focusableElements = getFocusableElements();
|
||||||
|
let previousIdx = focusableElements.indexOf(
|
||||||
|
previousActiveElementRef as HTMLElement
|
||||||
|
);
|
||||||
|
let buttonIdx = focusableElements.indexOf($api.button!);
|
||||||
|
if (buttonIdx > previousIdx) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
focusIn($api.panel!, Focus.Last);
|
||||||
|
} else {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
focusIn($api.panel!, Focus.First);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function handleKeyUp(event: KeyboardEvent) {
|
||||||
|
if (isWithinPanel) return;
|
||||||
|
if (event.key === Keys.Space) {
|
||||||
|
// Required for firefox, event.preventDefault() in handleKeyDown for
|
||||||
|
// the Space key doesn't cancel the handleKeyUp, which in turn
|
||||||
|
// triggers a *click*.
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
if ($api.popoverState !== PopoverStates.Open) return;
|
||||||
|
if (!$api.panel) return;
|
||||||
|
if (!$api.button) return;
|
||||||
|
|
||||||
|
// TODO: Revisit when handling Tab/Shift+Tab when using Portal's
|
||||||
|
switch (event.key) {
|
||||||
|
case Keys.Tab:
|
||||||
|
// Check if the last focused element exists, and check that it is not inside button or panel itself
|
||||||
|
if (!previousActiveElementRef) return;
|
||||||
|
if ($api.button?.contains(previousActiveElementRef)) return;
|
||||||
|
if ($api.panel?.contains(previousActiveElementRef)) return;
|
||||||
|
|
||||||
|
// Check if the last focused element is *after* the button in the DOM
|
||||||
|
let focusableElements = getFocusableElements();
|
||||||
|
let previousIdx = focusableElements.indexOf(
|
||||||
|
previousActiveElementRef as HTMLElement
|
||||||
|
);
|
||||||
|
let buttonIdx = focusableElements.indexOf($api.button!);
|
||||||
|
if (buttonIdx > previousIdx) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
focusIn($api.panel!, Focus.Last);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function handleClick() {
|
||||||
|
if (disabled) return;
|
||||||
|
if (isWithinPanel) {
|
||||||
|
$api.closePopover();
|
||||||
|
$api.button?.focus(); // Re-focus the original opening Button
|
||||||
|
} else {
|
||||||
|
if ($api.popoverState === PopoverStates.Closed)
|
||||||
|
closeOthers?.($api.buttonId);
|
||||||
|
$api.button?.focus();
|
||||||
|
$api.togglePopover();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: propsWeControl = isWithinPanel
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
id: $api.buttonId,
|
||||||
|
"aria-expanded": disabled
|
||||||
|
? undefined
|
||||||
|
: $api.popoverState === PopoverStates.Open,
|
||||||
|
"aria-controls": $api.panel ? $api.panelId : undefined,
|
||||||
|
disabled: disabled ? true : undefined,
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:focus|capture={handleFocus} />
|
||||||
|
|
||||||
|
<button
|
||||||
|
{...$$restProps}
|
||||||
|
{...propsWeControl}
|
||||||
|
on:click={handleClick}
|
||||||
|
on:keydown={handleKeyDown}
|
||||||
|
on:keyup={handleKeyUp}
|
||||||
|
bind:this={$buttonStore}
|
||||||
|
>
|
||||||
|
<slot open={$api.popoverState === PopoverStates.Open} />
|
||||||
|
</button>
|
||||||
57
src/lib/components/popover/PopoverGroup.svelte
Normal file
57
src/lib/components/popover/PopoverGroup.svelte
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<script lang="ts" context="module">
|
||||||
|
export interface PopoverGroupContext {
|
||||||
|
registerPopover(registerbag: PopoverRegisterBag): void;
|
||||||
|
unregisterPopover(registerbag: PopoverRegisterBag): void;
|
||||||
|
isFocusWithinPopoverGroup(): boolean;
|
||||||
|
closeOthers(buttonId: string): void;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { PopoverRegisterBag } from "./Popover.svelte";
|
||||||
|
import { setContext } from "svelte";
|
||||||
|
let groupRef: HTMLDivElement | undefined;
|
||||||
|
let popovers: PopoverRegisterBag[] = [];
|
||||||
|
|
||||||
|
function unregisterPopover(registerBag: PopoverRegisterBag) {
|
||||||
|
popovers = popovers.filter((bag) => bag != registerBag);
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerPopover(registerBag: PopoverRegisterBag) {
|
||||||
|
popovers = [...popovers, registerBag];
|
||||||
|
return () => {
|
||||||
|
unregisterPopover(registerBag);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFocusWithinPopoverGroup() {
|
||||||
|
let element = document.activeElement as HTMLElement;
|
||||||
|
|
||||||
|
if (groupRef?.contains(element)) return true;
|
||||||
|
|
||||||
|
// Check if the focus is in one of the button or panel elements. This is important in case you are rendering inside a Portal.
|
||||||
|
return popovers.some((bag) => {
|
||||||
|
return (
|
||||||
|
document.getElementById(bag.buttonId)?.contains(element) ||
|
||||||
|
document.getElementById(bag.panelId)?.contains(element)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeOthers(buttonId: string) {
|
||||||
|
for (let popover of popovers) {
|
||||||
|
if (popover.buttonId !== buttonId) popover.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setContext("PopoverGroup", {
|
||||||
|
unregisterPopover,
|
||||||
|
registerPopover,
|
||||||
|
isFocusWithinPopoverGroup,
|
||||||
|
closeOthers,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div {...$$restProps} bind:this={groupRef}>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
25
src/lib/components/popover/PopoverOverlay.svelte
Normal file
25
src/lib/components/popover/PopoverOverlay.svelte
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { State } from "./open-closed";
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
import type { Writable } from "svelte/store";
|
||||||
|
import { PopoverStates, StateDefinition } from "./Popover.svelte";
|
||||||
|
|
||||||
|
let api: Writable<StateDefinition> | undefined = getContext("PopoverApi");
|
||||||
|
|
||||||
|
let openClosedState: Writable<State> | undefined = getContext("OpenClosed");
|
||||||
|
|
||||||
|
$: visible =
|
||||||
|
openClosedState !== undefined
|
||||||
|
? $openClosedState === State.Open
|
||||||
|
: $api.popoverState === PopoverStates.Open;
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
$api.closePopover();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if visible}
|
||||||
|
<div {...$$restProps} on:click={handleClick} aria-hidden>
|
||||||
|
<slot open={$api.popoverState === PopoverStates.Open} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
117
src/lib/components/popover/PopoverPanel.svelte
Normal file
117
src/lib/components/popover/PopoverPanel.svelte
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<script lang="ts" context="module">
|
||||||
|
export type PopoverPanelContext = string | null;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Keys } from "./keyboard";
|
||||||
|
import { State } from "./open-closed";
|
||||||
|
import {
|
||||||
|
getFocusableElements,
|
||||||
|
Focus,
|
||||||
|
FocusResult,
|
||||||
|
focusIn,
|
||||||
|
} from "./focus-management";
|
||||||
|
import { getContext, setContext, onMount } from "svelte";
|
||||||
|
import type { Writable } from "svelte/store";
|
||||||
|
import { PopoverStates, StateDefinition } from "./Popover.svelte";
|
||||||
|
let panelStore: SvelteStore<HTMLDivElement> = getContext("PopoverPanelRef");
|
||||||
|
export let focus = false;
|
||||||
|
|
||||||
|
let api: Writable<StateDefinition> | undefined = getContext("PopoverApi");
|
||||||
|
setContext("PopoverPanelContext", $api.panelId);
|
||||||
|
|
||||||
|
let openClosedState: Writable<State> | undefined = getContext("OpenClosed");
|
||||||
|
|
||||||
|
$: visible =
|
||||||
|
openClosedState !== undefined
|
||||||
|
? $openClosedState === State.Open
|
||||||
|
: $api.popoverState === PopoverStates.Open;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!focus) return;
|
||||||
|
if ($api.popoverState !== PopoverStates.Open) return;
|
||||||
|
if (!$api.panel) return;
|
||||||
|
|
||||||
|
let activeElement = document.activeElement as HTMLElement;
|
||||||
|
if ($api.panel?.contains(activeElement)) return; // Already focused within Dialog
|
||||||
|
|
||||||
|
focusIn($api.panel!, Focus.First);
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleWindowKeydown(event: KeyboardEvent) {
|
||||||
|
if ($api.popoverState !== PopoverStates.Open) return;
|
||||||
|
if (!$api.panel) return;
|
||||||
|
|
||||||
|
if (event.key !== Keys.Tab) return;
|
||||||
|
if (!document.activeElement) return;
|
||||||
|
if (!$api.panel?.contains(document.activeElement)) return;
|
||||||
|
|
||||||
|
// We will take-over the default tab behaviour so that we have a bit
|
||||||
|
// control over what is focused next. It will behave exactly the same,
|
||||||
|
// but it will also "fix" some issues based on whether you are using a
|
||||||
|
// Portal or not.
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
let result = focusIn(
|
||||||
|
$api.panel!,
|
||||||
|
event.shiftKey ? Focus.Previous : Focus.Next
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result === FocusResult.Underflow) {
|
||||||
|
return $api.button?.focus();
|
||||||
|
} else if (result === FocusResult.Overflow) {
|
||||||
|
if (!$api.button) return;
|
||||||
|
|
||||||
|
let elements = getFocusableElements();
|
||||||
|
let buttonIdx = elements.indexOf($api.button!);
|
||||||
|
|
||||||
|
let nextElements = elements
|
||||||
|
.splice(buttonIdx + 1) // Elements after button
|
||||||
|
.filter((element) => !$api.panel?.contains(element)); // Ignore items in panel
|
||||||
|
|
||||||
|
// Try to focus the next element, however it could fail if we are in a
|
||||||
|
// Portal that happens to be the very last one in the DOM. In that
|
||||||
|
// case we would Error (because nothing after the button is
|
||||||
|
// focusable). Therefore we will try and focus the very first item in
|
||||||
|
// the document.body.
|
||||||
|
if (focusIn(nextElements, Focus.First) === FocusResult.Error) {
|
||||||
|
focusIn(document.body, Focus.First);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFocus() {
|
||||||
|
if (!focus) return;
|
||||||
|
if ($api.popoverState !== PopoverStates.Open) return;
|
||||||
|
if (!$api.panel) return;
|
||||||
|
if ($api.panel?.contains(document.activeElement as HTMLElement)) return;
|
||||||
|
$api.closePopover();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
switch (event.key) {
|
||||||
|
case Keys.Escape:
|
||||||
|
if ($api.popoverState !== PopoverStates.Open) return;
|
||||||
|
if (!$api.panel) return;
|
||||||
|
if (!$api.panel?.contains(document.activeElement)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
$api.closePopover();
|
||||||
|
$api.button?.focus();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window
|
||||||
|
on:keydown={handleWindowKeydown}
|
||||||
|
on:focus|capture={handleFocus}
|
||||||
|
/>
|
||||||
|
{#if visible}
|
||||||
|
<div {...$$restProps} on:keydown={handleKeydown} bind:this={$panelStore}>
|
||||||
|
<slot
|
||||||
|
open={$api.popoverState === PopoverStates.Open}
|
||||||
|
close={$api.close}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
29
src/lib/components/portal/Portal.svelte
Normal file
29
src/lib/components/portal/Portal.svelte
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { usePortalGroupContext } from "./PortalGroup.svelte";
|
||||||
|
import { usePortalRoot } from "./ForcePortalRootContext.svelte";
|
||||||
|
import { portal } from "./use-portal";
|
||||||
|
let forceInRoot = usePortalRoot();
|
||||||
|
let groupTarget = usePortalGroupContext();
|
||||||
|
$: target = (() => {
|
||||||
|
// Group context is used, but still null
|
||||||
|
if (
|
||||||
|
!(forceInRoot && $forceInRoot) &&
|
||||||
|
groupTarget !== undefined &&
|
||||||
|
$groupTarget !== null
|
||||||
|
)
|
||||||
|
return $groupTarget;
|
||||||
|
|
||||||
|
// No group context is used, let's create a default portal root
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
let existingRoot = document.getElementById("headlessui-portal-root");
|
||||||
|
if (existingRoot) return existingRoot;
|
||||||
|
|
||||||
|
let root = document.createElement("div");
|
||||||
|
root.setAttribute("id", "headlessui-portal-root");
|
||||||
|
return document.body.appendChild(root);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div use:portal={target}>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
18
src/lib/components/portal/PortalGroup.svelte
Normal file
18
src/lib/components/portal/PortalGroup.svelte
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<script lang="ts" context="module">
|
||||||
|
import { getContext, setContext } from "svelte";
|
||||||
|
import { writable, Writable } from "svelte/store";
|
||||||
|
const PORTAL_GROUP_CONTEXT_NAME = "headlessui-portal-group-context";
|
||||||
|
|
||||||
|
export function usePortalGroupContext():
|
||||||
|
| Writable<HTMLElement | null>
|
||||||
|
| undefined {
|
||||||
|
return getContext(PORTAL_GROUP_CONTEXT_NAME);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export let target: HTMLElement | null;
|
||||||
|
setContext(PORTAL_GROUP_CONTEXT_NAME, writable(target));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<slot />
|
||||||
182
src/lib/components/radio-group/RadioGroup.svelte
Normal file
182
src/lib/components/radio-group/RadioGroup.svelte
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
<script lang="ts" context="module">
|
||||||
|
import DescriptionProvider from "./DescriptionProvider.svelte";
|
||||||
|
import LabelProvider from "./LabelProvider.svelte";
|
||||||
|
import { createEventDispatcher, getContext, setContext } from "svelte";
|
||||||
|
import { Writable, writable } from "svelte/store";
|
||||||
|
import { Focus, focusIn, FocusResult } from "./focus-management";
|
||||||
|
import { Keys } from "./keyboard";
|
||||||
|
import { useId } from "./use-id";
|
||||||
|
export interface Option {
|
||||||
|
id: string;
|
||||||
|
element: HTMLElement | null;
|
||||||
|
propsRef: { value: unknown; disabled: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StateDefinition {
|
||||||
|
// State
|
||||||
|
options: Option[];
|
||||||
|
value: unknown;
|
||||||
|
disabled: boolean;
|
||||||
|
firstOption: Option | undefined;
|
||||||
|
containsCheckedOption: boolean;
|
||||||
|
|
||||||
|
// State mutators
|
||||||
|
change(nextValue: unknown): boolean;
|
||||||
|
registerOption(action: Option): void;
|
||||||
|
unregisterOption(id: Option["id"]): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RADIO_GROUP_CONTEXT_NAME = "RadioGroupContext";
|
||||||
|
export function useRadioGroupContext(
|
||||||
|
component: string
|
||||||
|
): Writable<StateDefinition | undefined> {
|
||||||
|
const context = getContext(RADIO_GROUP_CONTEXT_NAME) as
|
||||||
|
| Writable<StateDefinition | undefined>
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
`<${component} /> is missing a parent <RadioGroup /> component.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { treeWalker } from "./use-tree-walker";
|
||||||
|
export let disabled = false;
|
||||||
|
export let value: any;
|
||||||
|
let radioGroupRef: HTMLElement | null = null;
|
||||||
|
let options: StateDefinition["options"] = [];
|
||||||
|
|
||||||
|
let id = `headlessui-radiogroup-${useId()}`;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
let api: Writable<StateDefinition | undefined> = writable();
|
||||||
|
setContext(RADIO_GROUP_CONTEXT_NAME, api);
|
||||||
|
|
||||||
|
$: api.set({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
disabled,
|
||||||
|
firstOption: options.find((option) => !option.propsRef.disabled),
|
||||||
|
containsCheckedOption: options.some(
|
||||||
|
(option) => option.propsRef.value === value
|
||||||
|
),
|
||||||
|
change(nextValue: unknown) {
|
||||||
|
if (disabled) return false;
|
||||||
|
if (value === nextValue) return false;
|
||||||
|
let nextOption = options.find(
|
||||||
|
(option) => option.propsRef.value === nextValue
|
||||||
|
)?.propsRef;
|
||||||
|
if (nextOption?.disabled) return false;
|
||||||
|
dispatch("updateValue", nextValue);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
registerOption(action: Option) {
|
||||||
|
options = [...options, action];
|
||||||
|
},
|
||||||
|
unregisterOption(id: Option["id"]) {
|
||||||
|
options = options.filter((radio) => radio.id !== id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
$: treeWalker({
|
||||||
|
container: radioGroupRef,
|
||||||
|
accept(node) {
|
||||||
|
if (node.getAttribute("role") === "radio")
|
||||||
|
return NodeFilter.FILTER_REJECT;
|
||||||
|
if (node.hasAttribute("role")) return NodeFilter.FILTER_SKIP;
|
||||||
|
return NodeFilter.FILTER_ACCEPT;
|
||||||
|
},
|
||||||
|
walk(node) {
|
||||||
|
node.setAttribute("role", "none");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (!radioGroupRef) return;
|
||||||
|
if (!radioGroupRef.contains(event.target as HTMLElement)) return;
|
||||||
|
|
||||||
|
let all = options
|
||||||
|
.filter((option) => option.propsRef.disabled === false)
|
||||||
|
.map((radio) => radio.element) as HTMLElement[];
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case Keys.ArrowLeft:
|
||||||
|
case Keys.ArrowUp:
|
||||||
|
{
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
let result = focusIn(
|
||||||
|
all,
|
||||||
|
Focus.Previous | Focus.WrapAround
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result === FocusResult.Success) {
|
||||||
|
let activeOption = options.find(
|
||||||
|
(option) =>
|
||||||
|
option.element === document.activeElement
|
||||||
|
);
|
||||||
|
if (activeOption)
|
||||||
|
$api.change(activeOption.propsRef.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Keys.ArrowRight:
|
||||||
|
case Keys.ArrowDown:
|
||||||
|
{
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
let result = focusIn(all, Focus.Next | Focus.WrapAround);
|
||||||
|
|
||||||
|
if (result === FocusResult.Success) {
|
||||||
|
let activeOption = options.find(
|
||||||
|
(option) =>
|
||||||
|
option.element === document.activeElement
|
||||||
|
);
|
||||||
|
if (activeOption)
|
||||||
|
$api.change(activeOption.propsRef.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Keys.Space:
|
||||||
|
{
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
let activeOption = options.find(
|
||||||
|
(option) => option.element === document.activeElement
|
||||||
|
);
|
||||||
|
if (activeOption) $api.change(activeOption.propsRef.value);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: propsWeControl = {
|
||||||
|
id,
|
||||||
|
role: "radiogroup",
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DescriptionProvider name="RadioGroup.Description" let:describedby>
|
||||||
|
<LabelProvider name="RadioGroup.Label" let:labelledby>
|
||||||
|
<div
|
||||||
|
{...{ ...$$restProps, ...propsWeControl }}
|
||||||
|
bind:this={radioGroupRef}
|
||||||
|
aria-labelledby={labelledby}
|
||||||
|
aria-describedby={describedby}
|
||||||
|
on:keydown={handleKeyDown}
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</LabelProvider>
|
||||||
|
</DescriptionProvider>
|
||||||
91
src/lib/components/radio-group/RadioGroupOption.svelte
Normal file
91
src/lib/components/radio-group/RadioGroupOption.svelte
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy } from "svelte";
|
||||||
|
import DescriptionProvider from "./DescriptionProvider.svelte";
|
||||||
|
import LabelProvider from "./LabelProvider.svelte";
|
||||||
|
|
||||||
|
import { useRadioGroupContext, Option } from "./RadioGroup.svelte";
|
||||||
|
import { useId } from "./use-id";
|
||||||
|
|
||||||
|
enum OptionState {
|
||||||
|
Empty = 1 << 0,
|
||||||
|
Active = 1 << 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
export let value: any;
|
||||||
|
export let disabled: boolean = false;
|
||||||
|
let api = useRadioGroupContext("RadioGroupOption");
|
||||||
|
let id = `headlessui-radiogroup-option-${useId()}`;
|
||||||
|
let optionRef: HTMLElement | null = null;
|
||||||
|
$: propsRef = { value, disabled };
|
||||||
|
let state = OptionState.Empty;
|
||||||
|
|
||||||
|
function updateOption(option: Option) {
|
||||||
|
$api?.unregisterOption(option.id);
|
||||||
|
$api?.registerOption(option);
|
||||||
|
}
|
||||||
|
$: updateOption({ id, element: optionRef, propsRef });
|
||||||
|
onDestroy(() => $api?.unregisterOption(id));
|
||||||
|
|
||||||
|
$: isFirstOption = $api?.firstOption?.id === id;
|
||||||
|
$: isDisabled = $api?.disabled || disabled;
|
||||||
|
$: checked = $api?.value === value;
|
||||||
|
|
||||||
|
$: tabIndex = (() => {
|
||||||
|
if (isDisabled) return -1;
|
||||||
|
if (checked) return 0;
|
||||||
|
if (!$api.containsCheckedOption && isFirstOption) return 0;
|
||||||
|
return -1;
|
||||||
|
})();
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
if (!$api.change(value)) return;
|
||||||
|
|
||||||
|
state |= OptionState.Active;
|
||||||
|
optionRef?.focus();
|
||||||
|
}
|
||||||
|
function handleFocus() {
|
||||||
|
state |= OptionState.Active;
|
||||||
|
}
|
||||||
|
function handleBlur() {
|
||||||
|
state &= ~OptionState.Active;
|
||||||
|
}
|
||||||
|
|
||||||
|
$: classStyle = $$props.class
|
||||||
|
? typeof $$props.class === "function"
|
||||||
|
? $$props.class({
|
||||||
|
active: state & OptionState.Active,
|
||||||
|
checked,
|
||||||
|
disabled: isDisabled,
|
||||||
|
})
|
||||||
|
: $$props.class
|
||||||
|
: "";
|
||||||
|
|
||||||
|
$: propsWeControl = {
|
||||||
|
id,
|
||||||
|
class: classStyle,
|
||||||
|
role: "radio",
|
||||||
|
"aria-checked": checked ? ("true" as const) : ("false" as const),
|
||||||
|
"aria-disabled": isDisabled ? true : undefined,
|
||||||
|
tabIndex: tabIndex,
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DescriptionProvider name="RadioGroup.Description" let:describedby>
|
||||||
|
<LabelProvider name="RadioGroup.Label" let:labelledby>
|
||||||
|
<div
|
||||||
|
{...{ ...$$restProps, ...propsWeControl }}
|
||||||
|
bind:this={optionRef}
|
||||||
|
aria-labelledby={labelledby}
|
||||||
|
aria-describedby={describedby}
|
||||||
|
on:click={isDisabled ? undefined : handleClick}
|
||||||
|
on:focus={isDisabled ? undefined : handleFocus}
|
||||||
|
on:blur={isDisabled ? undefined : handleBlur}
|
||||||
|
>
|
||||||
|
<slot
|
||||||
|
{checked}
|
||||||
|
disabled={isDisabled}
|
||||||
|
active={state & OptionState.Active}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</LabelProvider>
|
||||||
|
</DescriptionProvider>
|
||||||
71
src/lib/components/switch/Switch.svelte
Normal file
71
src/lib/components/switch/Switch.svelte
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { StateDefinition } from "./SwitchGroup.svelte";
|
||||||
|
import { LabelContext } from "./LabelProvider.svelte";
|
||||||
|
import { DescriptionContext } from "./DescriptionProvider.svelte";
|
||||||
|
import { useId } from "./use-id";
|
||||||
|
import { Keys } from "./keyboard";
|
||||||
|
import { getContext, createEventDispatcher } from "svelte";
|
||||||
|
import { Writable } from "svelte/store";
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
export let checked = false;
|
||||||
|
let api: Writable<StateDefinition> | undefined = getContext("SwitchApi");
|
||||||
|
let labelContext: Writable<LabelContext> | undefined = getContext(
|
||||||
|
"headlessui-label-context"
|
||||||
|
);
|
||||||
|
let descriptionContext: Writable<DescriptionContext> | undefined =
|
||||||
|
getContext("headlessui-description-context");
|
||||||
|
let id = `headlessui-switch-${useId()}`;
|
||||||
|
$: switchStore = $api?.switchStore;
|
||||||
|
let internalSwitchRef = null;
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
dispatch("updateValue", !checked);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClick(event: MouseEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
toggle();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyUp(event: KeyboardEvent) {
|
||||||
|
if (event.key !== Keys.Tab) event.preventDefault();
|
||||||
|
if (event.key === Keys.Space) toggle();
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is needed so that we can "cancel" the click event when we use the `Enter` key on a button.
|
||||||
|
function handleKeyPress(event: KeyboardEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
$: propsWeControl = {
|
||||||
|
id,
|
||||||
|
role: "switch",
|
||||||
|
tabIndex: 0,
|
||||||
|
"aria-checked": checked,
|
||||||
|
"aria-labelledby": $labelContext?.labelIds,
|
||||||
|
"aria-describedby": $descriptionContext?.descriptionIds,
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if switchStore}
|
||||||
|
<button
|
||||||
|
{...{ ...$$restProps, ...propsWeControl }}
|
||||||
|
bind:this={$switchStore}
|
||||||
|
on:click={handleClick}
|
||||||
|
on:keyup={handleKeyUp}
|
||||||
|
on:keypress={handleKeyPress}
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
{...{ ...$$restProps, ...propsWeControl }}
|
||||||
|
bind:this={$internalSwitchRef}
|
||||||
|
on:click={handleClick}
|
||||||
|
on:keyup={handleKeyUp}
|
||||||
|
on:keypress={handleKeyPress}
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
35
src/lib/components/switch/SwitchGroup.svelte
Normal file
35
src/lib/components/switch/SwitchGroup.svelte
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<script lang="ts" context="module">
|
||||||
|
export interface StateDefinition {
|
||||||
|
switchStore: Writable<HTMLButtonElement | null>;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import DescriptionProvider from "./DescriptionProvider.svelte";
|
||||||
|
import LabelProvider from "./LabelProvider.svelte";
|
||||||
|
import { setContext } from "svelte";
|
||||||
|
import { Writable, writable } from "svelte/store";
|
||||||
|
|
||||||
|
let switchStore: Writable<HTMLButtonElement | null> = writable(null);
|
||||||
|
|
||||||
|
let api: Writable<StateDefinition | undefined> = writable();
|
||||||
|
setContext("SwitchApi", api);
|
||||||
|
|
||||||
|
function onClick() {
|
||||||
|
if (!$switchStore) return;
|
||||||
|
$switchStore.click();
|
||||||
|
$switchStore.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
$: api.set({
|
||||||
|
switchStore,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div {...$$restProps}>
|
||||||
|
<DescriptionProvider name="Switch.Description">
|
||||||
|
<LabelProvider name="Switch.Label" {onClick}>
|
||||||
|
<slot />
|
||||||
|
</LabelProvider>
|
||||||
|
</DescriptionProvider>
|
||||||
|
</div>
|
||||||
98
src/lib/components/tabs/Tab.svelte
Normal file
98
src/lib/components/tabs/Tab.svelte
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { Focus, focusIn } from "./focus-management";
|
||||||
|
import { Keys } from "./keyboard";
|
||||||
|
import { match } from "./match";
|
||||||
|
|
||||||
|
import { useTabsContext } from "./TabGroup.svelte";
|
||||||
|
import { useId } from "./use-id";
|
||||||
|
|
||||||
|
export let disabled = false;
|
||||||
|
|
||||||
|
let api = useTabsContext("Tab");
|
||||||
|
let id = `headlessui-tabs-tab-${useId()}`;
|
||||||
|
let tabRef = null;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
$api.registerTab(tabRef);
|
||||||
|
return () => $api.unregisterTab(tabRef);
|
||||||
|
});
|
||||||
|
|
||||||
|
$: myIndex = $api.tabs.indexOf(tabRef);
|
||||||
|
$: selected = myIndex === $api.selectedIndex;
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
let list = $api?.tabs.filter(Boolean) as HTMLElement[];
|
||||||
|
|
||||||
|
if (event.key === Keys.Space || event.key === Keys.Enter) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
$api?.setSelectedIndex(myIndex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case Keys.Home:
|
||||||
|
case Keys.PageUp:
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
return focusIn(list, Focus.First);
|
||||||
|
|
||||||
|
case Keys.End:
|
||||||
|
case Keys.PageDown:
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
return focusIn(list, Focus.Last);
|
||||||
|
}
|
||||||
|
|
||||||
|
return match($api.orientation, {
|
||||||
|
vertical() {
|
||||||
|
if (event.key === Keys.ArrowUp)
|
||||||
|
return focusIn(list, Focus.Previous | Focus.WrapAround);
|
||||||
|
if (event.key === Keys.ArrowDown)
|
||||||
|
return focusIn(list, Focus.Next | Focus.WrapAround);
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
horizontal() {
|
||||||
|
if (event.key === Keys.ArrowLeft)
|
||||||
|
return focusIn(list, Focus.Previous | Focus.WrapAround);
|
||||||
|
if (event.key === Keys.ArrowRight)
|
||||||
|
return focusIn(list, Focus.Next | Focus.WrapAround);
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFocus() {
|
||||||
|
tabRef?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelection() {
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
|
tabRef?.focus();
|
||||||
|
$api?.setSelectedIndex(myIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
$: propsWeControl = {
|
||||||
|
id,
|
||||||
|
role: "tab",
|
||||||
|
"aria-controls": $api.panels[myIndex]?.id,
|
||||||
|
"aria-selected": selected,
|
||||||
|
tabIndex: selected ? 0 : -1,
|
||||||
|
disabled: disabled ? true : undefined,
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
{...{ ...$$restProps, ...propsWeControl }}
|
||||||
|
bind:this={tabRef}
|
||||||
|
on:keydown={handleKeyDown}
|
||||||
|
on:click={handleSelection}
|
||||||
|
on:focus={$api.activation === "manual" ? handleFocus : handleSelection}
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</button>
|
||||||
122
src/lib/components/tabs/TabGroup.svelte
Normal file
122
src/lib/components/tabs/TabGroup.svelte
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<script lang="ts" context="module">
|
||||||
|
import {
|
||||||
|
createEventDispatcher,
|
||||||
|
getContext,
|
||||||
|
onMount,
|
||||||
|
setContext,
|
||||||
|
} from "svelte";
|
||||||
|
|
||||||
|
import { writable, Writable } from "svelte/store";
|
||||||
|
|
||||||
|
export type StateDefinition = {
|
||||||
|
// State
|
||||||
|
selectedIndex: number | null;
|
||||||
|
orientation: "vertical" | "horizontal";
|
||||||
|
activation: "auto" | "manual";
|
||||||
|
|
||||||
|
tabs: (HTMLElement | null)[];
|
||||||
|
panels: (HTMLElement | null)[];
|
||||||
|
|
||||||
|
// State mutators
|
||||||
|
setSelectedIndex(index: number): void;
|
||||||
|
registerTab(tab: HTMLElement | null): void;
|
||||||
|
unregisterTab(tab: HTMLElement | null): void;
|
||||||
|
registerPanel(panel: HTMLElement | null): void;
|
||||||
|
unregisterPanel(panel: HTMLElement | null): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TABS_CONTEXT_NAME = "TabsContext";
|
||||||
|
|
||||||
|
export function useTabsContext(
|
||||||
|
component: string
|
||||||
|
): Writable<StateDefinition | undefined> {
|
||||||
|
let context: Writable<StateDefinition | undefined> | undefined =
|
||||||
|
getContext(TABS_CONTEXT_NAME);
|
||||||
|
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
`<${component} /> is missing a parent <TabGroup /> component.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export let defaultIndex = 0;
|
||||||
|
export let vertical = false;
|
||||||
|
export let manual = false;
|
||||||
|
|
||||||
|
let selectedIndex: StateDefinition["selectedIndex"] = null;
|
||||||
|
let tabs: StateDefinition["tabs"] = [];
|
||||||
|
let panels: StateDefinition["panels"] = [];
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
let api: Writable<StateDefinition | undefined> = writable();
|
||||||
|
setContext(TABS_CONTEXT_NAME, api);
|
||||||
|
|
||||||
|
$: api.set({
|
||||||
|
selectedIndex,
|
||||||
|
orientation: vertical ? "vertical" : "horizontal",
|
||||||
|
activation: manual ? "manual" : "auto",
|
||||||
|
tabs,
|
||||||
|
panels,
|
||||||
|
setSelectedIndex(index: number) {
|
||||||
|
if (selectedIndex === index) return;
|
||||||
|
selectedIndex = index;
|
||||||
|
dispatch("updateValue", index);
|
||||||
|
},
|
||||||
|
registerTab(tab: typeof tabs[number]) {
|
||||||
|
if (!tabs.includes(tab)) tabs = [...tabs, tab];
|
||||||
|
},
|
||||||
|
unregisterTab(tab: typeof tabs[number]) {
|
||||||
|
tabs = tabs.filter((t) => t !== tab);
|
||||||
|
},
|
||||||
|
registerPanel(panel: typeof panels[number]) {
|
||||||
|
if (!panels.includes(panel)) panels = [...panels, panel];
|
||||||
|
},
|
||||||
|
unregisterPanel(panel: typeof panels[number]) {
|
||||||
|
panels = panels.filter((p) => p !== panel);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if ($api.tabs.length <= 0) return;
|
||||||
|
if (selectedIndex !== null) return;
|
||||||
|
|
||||||
|
let tabs = $api.tabs.filter(Boolean) as HTMLElement[];
|
||||||
|
let focusableTabs = tabs.filter((tab) => !tab.hasAttribute("disabled"));
|
||||||
|
if (focusableTabs.length <= 0) return;
|
||||||
|
|
||||||
|
// Underflow
|
||||||
|
if (defaultIndex < 0) {
|
||||||
|
selectedIndex = tabs.indexOf(focusableTabs[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overflow
|
||||||
|
else if (defaultIndex > $api.tabs.length) {
|
||||||
|
selectedIndex = tabs.indexOf(
|
||||||
|
focusableTabs[focusableTabs.length - 1]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middle
|
||||||
|
else {
|
||||||
|
let before = tabs.slice(0, defaultIndex);
|
||||||
|
let after = tabs.slice(defaultIndex);
|
||||||
|
|
||||||
|
let next = [...after, ...before].find((tab) =>
|
||||||
|
focusableTabs.includes(tab)
|
||||||
|
);
|
||||||
|
if (!next) return;
|
||||||
|
|
||||||
|
selectedIndex = tabs.indexOf(next);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div {...$$restProps}>
|
||||||
|
<slot {selectedIndex} />
|
||||||
|
</div>
|
||||||
13
src/lib/components/tabs/TabList.svelte
Normal file
13
src/lib/components/tabs/TabList.svelte
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { useTabsContext } from "./TabGroup.svelte";
|
||||||
|
|
||||||
|
let api = useTabsContext("TabList");
|
||||||
|
$: propsWeControl = {
|
||||||
|
role: "tablist",
|
||||||
|
"aria-orientation": $api.orientation,
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div {...{ ...$$restProps, ...propsWeControl }}>
|
||||||
|
<slot selectedIndex={$api.selectedIndex} />
|
||||||
|
</div>
|
||||||
30
src/lib/components/tabs/TabPanel.svelte
Normal file
30
src/lib/components/tabs/TabPanel.svelte
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { useTabsContext } from "./TabGroup.svelte";
|
||||||
|
import { useId } from "./use-id";
|
||||||
|
|
||||||
|
let api = useTabsContext("TabPanel");
|
||||||
|
let id = `headlessui-tabs-panel-${useId()}`;
|
||||||
|
let panelRef = null;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
$api.registerPanel(panelRef);
|
||||||
|
return () => $api.unregisterPanel(panelRef);
|
||||||
|
});
|
||||||
|
|
||||||
|
$: myIndex = $api.panels.indexOf(panelRef);
|
||||||
|
$: selected = myIndex === $api.selectedIndex;
|
||||||
|
|
||||||
|
$: propsWeControl = {
|
||||||
|
id,
|
||||||
|
role: "tabpanel",
|
||||||
|
"aria-labelledby": $api.tabs[myIndex]?.id,
|
||||||
|
tabIndex: selected ? 0 : -1,
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div {...{ ...$$restProps, ...propsWeControl }} bind:this={panelRef}>
|
||||||
|
{#if selected}
|
||||||
|
<slot />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
9
src/lib/components/tabs/TabPanels.svelte
Normal file
9
src/lib/components/tabs/TabPanels.svelte
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { useTabsContext } from "./TabGroup.svelte";
|
||||||
|
|
||||||
|
let api = useTabsContext("TabPanels");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div {...$$restProps}>
|
||||||
|
<slot selectedIndex={$api.selectedIndex} />
|
||||||
|
</div>
|
||||||
173
src/lib/components/transitions/TransitionChild.svelte
Normal file
173
src/lib/components/transitions/TransitionChild.svelte
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
createEventDispatcher,
|
||||||
|
getContext,
|
||||||
|
onMount,
|
||||||
|
setContext,
|
||||||
|
} from "svelte";
|
||||||
|
import { writable, Writable } from "svelte/store";
|
||||||
|
import { match } from "./match";
|
||||||
|
import { State } from "./open-closed";
|
||||||
|
import { Reason, transition } from "./transition";
|
||||||
|
|
||||||
|
import {
|
||||||
|
hasChildren,
|
||||||
|
NestingContextValues,
|
||||||
|
NESTING_CONTEXT_NAME,
|
||||||
|
TreeStates,
|
||||||
|
useNesting,
|
||||||
|
useParentNesting,
|
||||||
|
useTransitionContext,
|
||||||
|
} from "./TransitionRoot.svelte";
|
||||||
|
import { useId } from "./use-id";
|
||||||
|
|
||||||
|
export let unmount = true;
|
||||||
|
export let enter = "";
|
||||||
|
export let enterFrom = "";
|
||||||
|
export let enterTo = "";
|
||||||
|
export let entered = "";
|
||||||
|
export let leave = "";
|
||||||
|
export let leaveFrom = "";
|
||||||
|
export let leaveTo = "";
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
let container: HTMLElement | null = null;
|
||||||
|
let state = TreeStates.Visible;
|
||||||
|
|
||||||
|
let transitionContext = useTransitionContext();
|
||||||
|
let nestingContext = useParentNesting();
|
||||||
|
|
||||||
|
let initial = true;
|
||||||
|
|
||||||
|
let id = useId();
|
||||||
|
|
||||||
|
let isTransitioning = false;
|
||||||
|
|
||||||
|
let nesting: Writable<NestingContextValues> = writable();
|
||||||
|
nesting.set(
|
||||||
|
useNesting(() => {
|
||||||
|
// When all children have been unmounted we can only hide ourselves if and only if we are not
|
||||||
|
// transitioning ourselves. Otherwise we would unmount before the transitions are finished.
|
||||||
|
if (!isTransitioning) {
|
||||||
|
state = TreeStates.Hidden;
|
||||||
|
$nestingContext.unregister(id);
|
||||||
|
dispatch("afterLeave");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
onMount(() => $nestingContext.register(id));
|
||||||
|
|
||||||
|
$: {
|
||||||
|
(() => {
|
||||||
|
// If we are in another mode than the Hidden mode then ignore
|
||||||
|
/* if (strategy.value !== RenderStrategy.Hidden) return */
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
// Make sure that we are visible
|
||||||
|
if ($transitionContext.show && state !== TreeStates.Visible) {
|
||||||
|
state = TreeStates.Visible;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match(state, {
|
||||||
|
[TreeStates.Hidden]: () => $nestingContext.unregister(id),
|
||||||
|
[TreeStates.Visible]: () => $nestingContext.register(id),
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitClasses(classes: string = "") {
|
||||||
|
return classes
|
||||||
|
.split(" ")
|
||||||
|
.filter((className) => className.trim().length > 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let enterClasses = splitClasses(enter);
|
||||||
|
let enterFromClasses = splitClasses(enterFrom);
|
||||||
|
let enterToClasses = splitClasses(enterTo);
|
||||||
|
|
||||||
|
let enteredClasses = splitClasses(entered);
|
||||||
|
|
||||||
|
let leaveClasses = splitClasses(leave);
|
||||||
|
let leaveFromClasses = splitClasses(leaveFrom);
|
||||||
|
let leaveToClasses = splitClasses(leaveTo);
|
||||||
|
|
||||||
|
let mounted = false;
|
||||||
|
onMount(() => (mounted = true));
|
||||||
|
|
||||||
|
function executeTransition(show: boolean, appear: boolean) {
|
||||||
|
// Skipping initial transition
|
||||||
|
let skip = initial && !appear;
|
||||||
|
|
||||||
|
let node = container;
|
||||||
|
if (!node || !(node instanceof HTMLElement)) return;
|
||||||
|
if (skip) return;
|
||||||
|
|
||||||
|
isTransitioning = true;
|
||||||
|
|
||||||
|
if (show) dispatch("beforeEnter");
|
||||||
|
if (!show) dispatch("beforeLeave");
|
||||||
|
|
||||||
|
return show
|
||||||
|
? transition(
|
||||||
|
node,
|
||||||
|
enterClasses,
|
||||||
|
enterFromClasses,
|
||||||
|
enterToClasses,
|
||||||
|
enteredClasses,
|
||||||
|
(reason) => {
|
||||||
|
isTransitioning = false;
|
||||||
|
if (reason === Reason.Finished) dispatch("afterEnter");
|
||||||
|
}
|
||||||
|
)
|
||||||
|
: transition(
|
||||||
|
node,
|
||||||
|
leaveClasses,
|
||||||
|
leaveFromClasses,
|
||||||
|
leaveToClasses,
|
||||||
|
enteredClasses,
|
||||||
|
(reason) => {
|
||||||
|
isTransitioning = false;
|
||||||
|
|
||||||
|
if (reason !== Reason.Finished) return;
|
||||||
|
|
||||||
|
// When we don't have children anymore we can safely unregister from the parent and hide
|
||||||
|
// ourselves.
|
||||||
|
if (!hasChildren($nesting)) {
|
||||||
|
state = TreeStates.Hidden;
|
||||||
|
$nestingContext.unregister(id);
|
||||||
|
dispatch("afterLeave");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let _cleanup = null;
|
||||||
|
$: if (mounted) {
|
||||||
|
if (_cleanup) {
|
||||||
|
_cleanup();
|
||||||
|
}
|
||||||
|
_cleanup = executeTransition(
|
||||||
|
$transitionContext.show,
|
||||||
|
$transitionContext.appear
|
||||||
|
);
|
||||||
|
initial = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setContext(NESTING_CONTEXT_NAME, nesting);
|
||||||
|
let openClosedState: Writable<State> | undefined = writable();
|
||||||
|
setContext("OpenClosed", openClosedState);
|
||||||
|
|
||||||
|
$: openClosedState.set(
|
||||||
|
match(state, {
|
||||||
|
[TreeStates.Visible]: State.Open,
|
||||||
|
[TreeStates.Hidden]: State.Closed,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={container} {...$$restProps}>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
177
src/lib/components/transitions/TransitionRoot.svelte
Normal file
177
src/lib/components/transitions/TransitionRoot.svelte
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
<script lang="ts" context="module">
|
||||||
|
export enum TreeStates {
|
||||||
|
Visible = "visible",
|
||||||
|
Hidden = "hidden",
|
||||||
|
}
|
||||||
|
|
||||||
|
type ID = ReturnType<typeof useId>;
|
||||||
|
|
||||||
|
export interface NestingContextValues {
|
||||||
|
children: { id: ID; state: TreeStates }[];
|
||||||
|
register: (id: ID) => () => void;
|
||||||
|
unregister: (id: ID, strategy?: RenderStrategy) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TransitionContextValues {
|
||||||
|
show: boolean;
|
||||||
|
appear: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRANSITION_CONTEXT_NAME = "TransitionContext";
|
||||||
|
export const NESTING_CONTEXT_NAME = "NestingContext";
|
||||||
|
export function hasTransitionContext() {
|
||||||
|
return getContext(TRANSITION_CONTEXT_NAME) !== undefined;
|
||||||
|
}
|
||||||
|
export function useTransitionContext(): Writable<TransitionContextValues> {
|
||||||
|
let context = getContext(TRANSITION_CONTEXT_NAME) as
|
||||||
|
| Writable<TransitionContextValues>
|
||||||
|
| undefined;
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
"A <TransitionChild /> is used but it is missing a parent <TransitionRoot />."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useParentNesting(): Writable<NestingContextValues> {
|
||||||
|
let context = getContext(NESTING_CONTEXT_NAME) as
|
||||||
|
| Writable<NestingContextValues>
|
||||||
|
| undefined;
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
"A <TransitionChild /> is used but it is missing a parent <TransitionRoot />."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasChildren(
|
||||||
|
bag:
|
||||||
|
| NestingContextValues["children"]
|
||||||
|
| { children: NestingContextValues["children"] }
|
||||||
|
): boolean {
|
||||||
|
if ("children" in bag) return hasChildren(bag.children);
|
||||||
|
return (
|
||||||
|
bag.filter(({ state }) => state === TreeStates.Visible).length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNesting(done?: () => void) {
|
||||||
|
let transitionableChildren: NestingContextValues["children"] = [];
|
||||||
|
|
||||||
|
let mounted = false;
|
||||||
|
onMount(() => (mounted = true));
|
||||||
|
onDestroy(() => (mounted = false));
|
||||||
|
|
||||||
|
function unregister(childId: ID, strategy = RenderStrategy.Hidden) {
|
||||||
|
let idx = transitionableChildren.findIndex(
|
||||||
|
({ id }) => id === childId
|
||||||
|
);
|
||||||
|
if (idx === -1) return;
|
||||||
|
|
||||||
|
match(strategy, {
|
||||||
|
[RenderStrategy.Unmount]() {
|
||||||
|
transitionableChildren.splice(idx, 1);
|
||||||
|
},
|
||||||
|
[RenderStrategy.Hidden]() {
|
||||||
|
transitionableChildren[idx].state = TreeStates.Hidden;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasChildren(transitionableChildren) && mounted) {
|
||||||
|
done?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function register(childId: ID) {
|
||||||
|
let child = transitionableChildren.find(({ id }) => id === childId);
|
||||||
|
if (!child) {
|
||||||
|
transitionableChildren.push({
|
||||||
|
id: childId,
|
||||||
|
state: TreeStates.Visible,
|
||||||
|
});
|
||||||
|
} else if (child.state !== TreeStates.Visible) {
|
||||||
|
child.state = TreeStates.Visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => unregister(childId, RenderStrategy.Unmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
children: transitionableChildren,
|
||||||
|
register,
|
||||||
|
unregister,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext, onDestroy, onMount, setContext } from "svelte";
|
||||||
|
|
||||||
|
import { writable, Writable } from "svelte/store";
|
||||||
|
import { match } from "./match";
|
||||||
|
import { State } from "./open-closed";
|
||||||
|
import { RenderStrategy } from "./Render.svelte";
|
||||||
|
import TransitionChild from "./TransitionChild.svelte";
|
||||||
|
import type { useId } from "./use-id";
|
||||||
|
|
||||||
|
export let show: boolean;
|
||||||
|
export let unmount = true;
|
||||||
|
export let appear = false;
|
||||||
|
|
||||||
|
let openClosedState: Writable<State> | undefined = getContext("OpenClosed");
|
||||||
|
|
||||||
|
$: shouldShow = (() => {
|
||||||
|
if (show === null && openClosedState !== undefined) {
|
||||||
|
return match($openClosedState, {
|
||||||
|
[State.Open]: true,
|
||||||
|
[State.Closed]: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return show;
|
||||||
|
})();
|
||||||
|
|
||||||
|
$: if (shouldShow !== true && shouldShow !== false) {
|
||||||
|
throw new Error(
|
||||||
|
"A <Transition /> is used but it is missing a `show={true | false}` prop."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$: state = shouldShow ? TreeStates.Visible : TreeStates.Hidden;
|
||||||
|
|
||||||
|
let nestingBag: Writable<NestingContextValues> = writable();
|
||||||
|
nestingBag.set(
|
||||||
|
useNesting(() => {
|
||||||
|
state = TreeStates.Hidden;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
let initial = true;
|
||||||
|
let transitionBag: Writable<TransitionContextValues> = writable();
|
||||||
|
$: transitionBag.set({
|
||||||
|
show: shouldShow,
|
||||||
|
appear: appear || !initial,
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
initial = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
$: if (!initial) {
|
||||||
|
if (shouldShow) {
|
||||||
|
state = TreeStates.Visible;
|
||||||
|
} else if (!hasChildren($nestingBag)) {
|
||||||
|
state = TreeStates.Hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setContext(NESTING_CONTEXT_NAME, nestingBag);
|
||||||
|
setContext(TRANSITION_CONTEXT_NAME, transitionBag);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TransitionChild {...$$restProps} {unmount}>
|
||||||
|
<slot />
|
||||||
|
</TransitionChild>
|
||||||
6
src/lib/hooks/use-effect.ts
Normal file
6
src/lib/hooks/use-effect.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export function useEffect(fn, ...args) {
|
||||||
|
if (fn.__cleanup) {
|
||||||
|
fn.__cleanup();
|
||||||
|
}
|
||||||
|
fn.__cleanup = fn(...args);
|
||||||
|
}
|
||||||
8
src/lib/hooks/use-id.ts
Normal file
8
src/lib/hooks/use-id.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
let id = 0
|
||||||
|
function generateId() {
|
||||||
|
return ++id
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useId() {
|
||||||
|
return generateId()
|
||||||
|
}
|
||||||
100
src/lib/hooks/use-inert-others.ts
Normal file
100
src/lib/hooks/use-inert-others.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
let interactables = new Set<HTMLElement>()
|
||||||
|
let originals = new Map<HTMLElement, { 'aria-hidden': string | null; inert: boolean }>()
|
||||||
|
|
||||||
|
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<TElement extends HTMLElement>(
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/lib/hooks/use-portal.ts
Normal file
14
src/lib/hooks/use-portal.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/lib/hooks/use-tree-walker.ts
Normal file
28
src/lib/hooks/use-tree-walker.ts
Normal 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)
|
||||||
|
}
|
||||||
17
src/lib/internal/ForcePortalRootContext.svelte
Normal file
17
src/lib/internal/ForcePortalRootContext.svelte
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts" context="module">
|
||||||
|
import { getContext, setContext } from "svelte";
|
||||||
|
import { writable, Writable } from "svelte/store";
|
||||||
|
const FORCE_PORTAL_ROOT_CONTEXT_NAME =
|
||||||
|
"headlessui-force-portal-root-context";
|
||||||
|
|
||||||
|
export function usePortalRoot(): Writable<boolean> | undefined {
|
||||||
|
return getContext(FORCE_PORTAL_ROOT_CONTEXT_NAME);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export let force: boolean;
|
||||||
|
setContext(FORCE_PORTAL_ROOT_CONTEXT_NAME, writable(force));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<slot />
|
||||||
42
src/lib/internal/StackContextProvider.svelte
Normal file
42
src/lib/internal/StackContextProvider.svelte
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<script lang="ts" context="module">
|
||||||
|
export enum StackMessage {
|
||||||
|
Add,
|
||||||
|
Remove,
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext, setContext } from "svelte";
|
||||||
|
import { writable, Writable } from "svelte/store";
|
||||||
|
type OnUpdate = (
|
||||||
|
message: StackMessage,
|
||||||
|
element: HTMLElement | null
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
export let onUpdate: OnUpdate | undefined;
|
||||||
|
export let element: HTMLElement | null;
|
||||||
|
|
||||||
|
let parentUpdateStore: Writable<OnUpdate> | undefined =
|
||||||
|
getContext("StackContext");
|
||||||
|
let notifyStore: Writable<OnUpdate> = writable(() => {});
|
||||||
|
setContext("StackContext", notifyStore);
|
||||||
|
|
||||||
|
$: notifyStore.set((...args: Parameters<OnUpdate>) => {
|
||||||
|
// Notify our layer
|
||||||
|
onUpdate?.(...args);
|
||||||
|
|
||||||
|
// Notify the parent
|
||||||
|
$parentUpdateStore?.(...args);
|
||||||
|
});
|
||||||
|
|
||||||
|
$: _cleanup = (() => {
|
||||||
|
if (_cleanup) {
|
||||||
|
_cleanup();
|
||||||
|
}
|
||||||
|
if (!element) return null;
|
||||||
|
$notifyStore(StackMessage.Add, element);
|
||||||
|
return () => $notifyStore(StackMessage.Remove, element);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<slot />
|
||||||
7
src/lib/internal/dom-containers.ts
Normal file
7
src/lib/internal/dom-containers.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export function contains(containers: Set<HTMLElement>, element: HTMLElement) {
|
||||||
|
for (let container of containers) {
|
||||||
|
if (container.contains(element)) return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
21
src/lib/internal/open-closed.ts
Normal file
21
src/lib/internal/open-closed.ts
Normal file
@@ -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<State> | undefined {
|
||||||
|
return getContext(OPEN_CLOSED_CONTEXT_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOpenClosedProvider(value: Writable<State>) {
|
||||||
|
setContext(OPEN_CLOSED_CONTEXT_NAME, value);
|
||||||
|
}
|
||||||
|
|
||||||
6
src/lib/utils/Render.svelte
Normal file
6
src/lib/utils/Render.svelte
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<script lang="ts" context="module">
|
||||||
|
export enum RenderStrategy {
|
||||||
|
Unmount,
|
||||||
|
Hidden,
|
||||||
|
}
|
||||||
|
</script>
|
||||||
84
src/lib/utils/calculate-active-index.ts
Normal file
84
src/lib/utils/calculate-active-index.ts
Normal 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
|
||||||
|
}
|
||||||
33
src/lib/utils/disposables.ts
Normal file
33
src/lib/utils/disposables.ts
Normal 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
|
||||||
|
}
|
||||||
155
src/lib/utils/focus-management.ts
Normal file
155
src/lib/utils/focus-management.ts
Normal 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
21
src/lib/utils/keyboard.ts
Normal 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
20
src/lib/utils/match.ts
Normal 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
9
src/lib/utils/once.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
95
src/lib/utils/transition.ts
Normal file
95
src/lib/utils/transition.ts
Normal 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
|
||||||
|
}
|
||||||
28
src/lib/utils/tree-walker.ts
Normal file
28
src/lib/utils/tree-walker.ts
Normal 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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user