Run prettier over everything and fix some imports

This commit is contained in:
Ryan Gossiaux
2021-12-13 18:22:16 -08:00
parent 3bf974a654
commit 82b138f0ae
63 changed files with 3317 additions and 3319 deletions

View File

@@ -1,20 +1,24 @@
module.exports = { module.exports = {
root: true, root: true,
parser: '@typescript-eslint/parser', parser: "@typescript-eslint/parser",
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'], extends: [
plugins: ['svelte3', '@typescript-eslint'], "eslint:recommended",
ignorePatterns: ['*.cjs'], "plugin:@typescript-eslint/recommended",
overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }], "prettier",
settings: { ],
'svelte3/typescript': () => require('typescript') plugins: ["svelte3", "@typescript-eslint"],
}, ignorePatterns: ["*.cjs"],
parserOptions: { overrides: [{ files: ["*.svelte"], processor: "svelte3/svelte3" }],
sourceType: 'module', settings: {
ecmaVersion: 2020 "svelte3/typescript": () => require("typescript"),
}, },
env: { parserOptions: {
browser: true, sourceType: "module",
es2017: true, ecmaVersion: 2020,
node: true },
} env: {
browser: true,
es2017: true,
node: true,
},
}; };

View File

@@ -1,2 +1,3 @@
# svelte-headlessui # svelte-headlessui
Unofficial Svelte port of Headless UI components (https://headlessui.dev/) Unofficial Svelte port of Headless UI components (https://headlessui.dev/)

View File

@@ -28,4 +28,4 @@
"typescript": "^4.4.3" "typescript": "^4.4.3"
}, },
"type": "module" "type": "module"
} }

View File

@@ -1,13 +1,13 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="description" content="" /> <meta name="description" content="" />
<link rel="icon" href="/favicon.png" /> <link rel="icon" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
%svelte.head% %svelte.head%
</head> </head>
<body> <body>
<div id="svelte">%svelte.body%</div> <div id="svelte">%svelte.body%</div>
</body> </body>
</html> </html>

View File

@@ -1,21 +1,21 @@
<script lang="ts"> <script lang="ts">
import { useId } from "./use-id"; import { useId } from "$lib/hooks/use-id";
import { getContext, onMount } from "svelte"; import { getContext, onMount } from "svelte";
import { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
import { DescriptionContext } from "./DescriptionProvider.svelte"; import type { DescriptionContext } from "./DescriptionProvider.svelte";
const id = `headlessui-description-${useId()}`; const id = `headlessui-description-${useId()}`;
let contextStore: Writable<DescriptionContext> | undefined = getContext( let contextStore: Writable<DescriptionContext> | undefined = getContext(
"headlessui-description-context" "headlessui-description-context"
);
if (!contextStore) {
throw new Error(
"You used a <Description /> component, but it is not inside a relevant parent."
); );
if (!contextStore) { }
throw new Error(
"You used a <Description /> component, but it is not inside a relevant parent."
);
}
onMount(() => $contextStore.register(id)); onMount(() => $contextStore.register(id));
</script> </script>
<p {...$$restProps} {...$contextStore?.props} {id}> <p {...$$restProps} {...$contextStore?.props} {id}>
<slot /> <slot />
</p> </p>

View File

@@ -1,40 +1,40 @@
<script lang="ts" context="module"> <script lang="ts" context="module">
export interface DescriptionContext { export interface DescriptionContext {
name?: string; name?: string;
props?: object; props?: object;
register: (value: string) => void; register: (value: string) => void;
descriptionIds?: string; descriptionIds?: string;
} }
</script> </script>
<script lang="ts"> <script lang="ts">
import { setContext } from "svelte"; import { setContext } from "svelte";
import { writable, Writable } from "svelte/store"; import { writable, Writable } from "svelte/store";
export let name: string; export let name: string;
let descriptionIds = []; let descriptionIds = [];
let contextStore: Writable<DescriptionContext> = writable({ let contextStore: Writable<DescriptionContext> = writable({
name, name,
register, register,
props: $$restProps, props: $$restProps,
}); });
setContext("headlessui-description-context", contextStore); setContext("headlessui-description-context", contextStore);
$: contextStore.set({ $: contextStore.set({
name, name,
props: $$restProps, props: $$restProps,
register, register,
descriptionIds: descriptionIds:
descriptionIds.length > 0 ? descriptionIds.join(" ") : undefined, descriptionIds.length > 0 ? descriptionIds.join(" ") : undefined,
}); });
function register(value: string) { function register(value: string) {
descriptionIds = [...descriptionIds, value]; descriptionIds = [...descriptionIds, value];
return () => { return () => {
descriptionIds = descriptionIds.filter( descriptionIds = descriptionIds.filter(
(descriptionId) => descriptionId !== value (descriptionId) => descriptionId !== value
); );
}; };
} }
</script> </script>
<slot describedby={$contextStore.descriptionIds} /> <slot describedby={$contextStore.descriptionIds} />

View File

@@ -1,243 +1,235 @@
<script lang="ts" context="module"> <script lang="ts" context="module">
import { import { getContext, setContext, createEventDispatcher, tick } from "svelte";
getContext, export enum DialogStates {
setContext, Open,
createEventDispatcher, Closed,
tick, }
} from "svelte";
export enum DialogStates { export interface StateDefinition {
Open, dialogState: DialogStates;
Closed,
} titleId: string | null;
export interface StateDefinition { setTitleId(id: string | null): void;
dialogState: DialogStates;
close(): void;
titleId: string | null; }
setTitleId(id: string | null): void; const DIALOG_CONTEXT_NAME = "DialogContext";
close(): void; export function useDialogContext(
} component: string
): Writable<StateDefinition | undefined> {
const DIALOG_CONTEXT_NAME = "DialogContext"; let context = getContext(DIALOG_CONTEXT_NAME) as
| Writable<StateDefinition | undefined>
export function useDialogContext( | undefined;
component: string if (context === undefined) {
): Writable<StateDefinition | undefined> { throw new Error(
let context = getContext(DIALOG_CONTEXT_NAME) as `<${component} /> is missing a parent <Dialog /> component.`
| Writable<StateDefinition | undefined> );
| undefined;
if (context === undefined) {
throw new Error(
`<${component} /> is missing a parent <Dialog /> component.`
);
}
return context;
} }
return context;
}
</script> </script>
<script lang="ts"> <script lang="ts">
import { State } from "./open-closed"; import { State } from "$lib/internal/open-closed";
import { writable, Writable } from "svelte/store"; import { writable, Writable } from "svelte/store";
import { match } from "./match"; import { match } from "$lib/utils/match";
import { useId } from "./use-id"; import { useId } from "$lib/hooks/use-id";
import { useInertOthers } from "./use-inert-others"; import { useInertOthers } from "$lib/hooks/use-inert-others";
import { contains } from "./dom-containers"; import { contains } from "$lib/internal/dom-containers";
import { Keys } from "./keyboard"; import { Keys } from "$lib/utils/keyboard";
import FocusTrap from "./FocusTrap.svelte"; import FocusTrap from "$lib/components/FocusTrap/FocusTrap.svelte";
import StackContextProvider, { import StackContextProvider, {
StackMessage, StackMessage,
} from "./StackContextProvider.svelte"; } from "$lib/internal/StackContextProvider.svelte";
import DescriptionProvider from "./DescriptionProvider.svelte"; import DescriptionProvider from "./DescriptionProvider.svelte";
import ForcePortalRootContext from "./ForcePortalRootContext.svelte"; import ForcePortalRootContext from "./ForcePortalRootContext.svelte";
import Portal from "./Portal.svelte"; import Portal from "./Portal.svelte";
import PortalGroup from "./PortalGroup.svelte"; import PortalGroup from "./PortalGroup.svelte";
export let open: Boolean | undefined = undefined; export let open: Boolean | undefined = undefined;
export let initialFocus: HTMLElement | null = null; export let initialFocus: HTMLElement | null = null;
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
let containers: Set<HTMLElement> = new Set(); let containers: Set<HTMLElement> = new Set();
let openClosedState: Writable<State> | undefined = getContext("OpenClosed"); let openClosedState: Writable<State> | undefined = getContext("OpenClosed");
$: open = $: open =
open === undefined && openClosedState !== undefined open === undefined && openClosedState !== undefined
? match($openClosedState, { ? match($openClosedState, {
[State.Open]: true, [State.Open]: true,
[State.Closed]: false, [State.Closed]: false,
}) })
: open; : open;
// Validations // Validations
let hasOpen = open !== undefined || openClosedState !== null; let hasOpen = open !== undefined || openClosedState !== null;
if (!hasOpen) { if (!hasOpen) {
throw new Error( throw new Error(
`You forgot to provide an \`open\` prop to the \`Dialog\`.` `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);
})();
if (typeof open !== "boolean") { let titleId: StateDefinition["titleId"] = null;
throw new Error(
`You provided an \`open\` prop to the \`Dialog\`, but the value is not a boolean. Received: ${open}` 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;
$: dialogState = open ? DialogStates.Open : DialogStates.Closed; let overflow = document.documentElement.style.overflow;
$: visible = let paddingRight = document.documentElement.style.paddingRight;
openClosedState !== undefined
? $openClosedState === State.Open
: dialogState === DialogStates.Open;
let internalDialogRef: HTMLDivElement | null = null; let scrollbarWidth =
$: enabled = dialogState === DialogStates.Open; window.innerWidth - document.documentElement.clientWidth;
const id = `headlessui-dialog-${useId()}`; document.documentElement.style.overflow = "hidden";
document.documentElement.style.paddingRight = `${scrollbarWidth}px`;
$: _cleanup = (() => { return () => {
if (_cleanup) { document.documentElement.style.overflow = overflow;
_cleanup(); 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();
} }
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 observer.observe(container);
async function handleWindowMousedown(event: MouseEvent) {
let target = event.target as HTMLElement;
if (dialogState !== DialogStates.Open) return; return () => observer.disconnect();
if (containers.size !== 1) return; };
if (contains(containers, target)) return;
$api.close(); function handleClick(event: MouseEvent) {
await tick(); event.stopPropagation();
target?.focus(); }
}
// Handle `Escape` to close $: propsWeControl = {
function handleWindowKeydown(event: KeyboardEvent) { id,
if (event.key !== Keys.Escape) return; role: "dialog",
if (dialogState !== DialogStates.Open) return; "aria-modal": dialogState === DialogStates.Open ? true : undefined,
if (containers.size > 1) return; // 1 is myself, otherwise other elements in the Stack "aria-labelledby": titleId,
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> </script>
<svelte:window <svelte:window
on:mousedown={handleWindowMousedown} on:mousedown={handleWindowMousedown}
on:keydown={handleWindowKeydown} on:keydown={handleWindowKeydown}
/> />
{#if open} {#if visible}
<FocusTrap {containers} {enabled} options={{ initialFocus }} /> <FocusTrap {containers} {enabled} options={{ initialFocus }} />
<StackContextProvider <StackContextProvider
element={internalDialogRef} element={internalDialogRef}
onUpdate={(message, element) => { onUpdate={(message, element) => {
return match(message, { return match(message, {
[StackMessage.Add]() { [StackMessage.Add]() {
containers.add(element); containers.add(element);
}, },
[StackMessage.Remove]() { [StackMessage.Remove]() {
containers.delete(element); containers.delete(element);
}, },
}); });
}} }}
> >
<ForcePortalRootContext force={true}> <ForcePortalRootContext force={true}>
<Portal> <Portal>
<PortalGroup target={internalDialogRef}> <PortalGroup target={internalDialogRef}>
<ForcePortalRootContext force={false}> <ForcePortalRootContext force={false}>
<DescriptionProvider <DescriptionProvider name={"Dialog.Description"} let:describedby>
name={"Dialog.Description"} <div
let:describedby {...{ ...$$restProps, ...propsWeControl }}
> aria-describedby={describedby}
<div on:click={handleClick}
{...{ ...$$restProps, ...propsWeControl }} >
aria-describedby={describedby} <slot {open} />
on:click={handleClick} </div>
> </DescriptionProvider>
<slot {open} /> </ForcePortalRootContext>
</div> </PortalGroup>
</DescriptionProvider> </Portal>
</ForcePortalRootContext> </ForcePortalRootContext>
</PortalGroup> </StackContextProvider>
</Portal>
</ForcePortalRootContext>
</StackContextProvider>
{/if} {/if}

View File

@@ -1,20 +1,20 @@
<script lang="ts"> <script lang="ts">
import { DialogStates, useDialogContext } from "./Dialog.svelte"; import { DialogStates, useDialogContext } from "./Dialog.svelte";
import { useId } from "./use-id"; import { useId } from "$lib/hooks/use-id";
let api = useDialogContext("DialogOverlay"); let api = useDialogContext("DialogOverlay");
let id = `headlessui-dialog-overlay-${useId()}`; let id = `headlessui-dialog-overlay-${useId()}`;
function handleClick(event: MouseEvent) { function handleClick(event: MouseEvent) {
if (event.target !== event.currentTarget) return; if (event.target !== event.currentTarget) return;
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
$api.close(); $api.close();
} }
$: propsWeControl = { $: propsWeControl = {
id, id,
"aria-hidden": true, "aria-hidden": true,
}; };
</script> </script>
<div {...{ ...$$restProps, ...propsWeControl }} on:click={handleClick}> <div {...{ ...$$restProps, ...propsWeControl }} on:click={handleClick}>
<slot open={$api.dialogState === DialogStates.Open} /> <slot open={$api.dialogState === DialogStates.Open} />
</div> </div>

View File

@@ -1,19 +1,19 @@
<script lang="ts"> <script lang="ts">
import { DialogStates, useDialogContext } from "./Dialog.svelte"; import { DialogStates, useDialogContext } from "./Dialog.svelte";
import { useId } from "./use-id"; import { useId } from "$lib/hooks/use-id";
import { onMount } from "svelte"; import { onMount } from "svelte";
let api = useDialogContext("DialogTitle"); let api = useDialogContext("DialogTitle");
let id = `headlessui-dialog-title-${useId()}`; let id = `headlessui-dialog-title-${useId()}`;
onMount(() => { onMount(() => {
$api.setTitleId(id); $api.setTitleId(id);
return () => $api.setTitleId(null); return () => $api.setTitleId(null);
}); });
$: propsWeControl = { $: propsWeControl = {
id, id,
}; };
</script> </script>
<h2 {...{ ...$$restProps, ...propsWeControl }}> <h2 {...{ ...$$restProps, ...propsWeControl }}>
<slot open={$api.dialogState === DialogStates.Open} /> <slot open={$api.dialogState === DialogStates.Open} />
</h2> </h2>

View File

@@ -1,105 +1,102 @@
<script lang="ts" context="module"> <script lang="ts" context="module">
import { writable, Writable } from "svelte/store"; import { writable, Writable } from "svelte/store";
import { getContext, setContext } from "svelte"; import { getContext, setContext } from "svelte";
export enum DisclosureStates { export enum DisclosureStates {
Open, Open,
Closed, 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.`
);
} }
export interface StateDefinition { return context;
// 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>
<script lang="ts"> <script lang="ts">
import { useId } from "./use-id"; import { useId } from "$lib/hooks/use-id";
import { match } from "./match"; import { match } from "$lib/utils/match";
import { State } from "./open-closed"; import { State } from "$lib/internal/open-closed";
export let defaultOpen = false; export let defaultOpen = false;
let buttonId = `headlessui-disclosure-button-${useId()}`; let buttonId = `headlessui-disclosure-button-${useId()}`;
let panelId = `headlessui-disclosure-panel-${useId()}`; let panelId = `headlessui-disclosure-panel-${useId()}`;
let disclosureState: StateDefinition["disclosureState"] = defaultOpen let disclosureState: StateDefinition["disclosureState"] = defaultOpen
? DisclosureStates.Open ? DisclosureStates.Open
: DisclosureStates.Closed; : DisclosureStates.Closed;
let panelStore: StateDefinition["panelStore"] = writable(null); let panelStore: StateDefinition["panelStore"] = writable(null);
let buttonStore: StateDefinition["buttonStore"] = writable(null); let buttonStore: StateDefinition["buttonStore"] = writable(null);
let api: Writable<StateDefinition | undefined> = writable(); let api: Writable<StateDefinition | undefined> = writable();
setContext(DISCLOSURE_CONTEXT_NAME, api); setContext(DISCLOSURE_CONTEXT_NAME, api);
$: api.set({ $: api.set({
buttonId, buttonId,
panelId, panelId,
disclosureState, disclosureState,
panelStore, panelStore,
buttonStore, buttonStore,
toggleDisclosure() { toggleDisclosure() {
disclosureState = match(disclosureState, { disclosureState = match(disclosureState, {
[DisclosureStates.Open]: DisclosureStates.Closed, [DisclosureStates.Open]: DisclosureStates.Closed,
[DisclosureStates.Closed]: DisclosureStates.Open, [DisclosureStates.Closed]: DisclosureStates.Open,
}); });
}, },
closeDisclosure() { closeDisclosure() {
if (disclosureState === DisclosureStates.Closed) return; if (disclosureState === DisclosureStates.Closed) return;
disclosureState = DisclosureStates.Closed; disclosureState = DisclosureStates.Closed;
}, },
close(focusableElement: HTMLElement | null) { close(focusableElement: HTMLElement | null) {
$api.closeDisclosure(); $api.closeDisclosure();
let restoreElement = (() => { let restoreElement = (() => {
if (!focusableElement) return $buttonStore; if (!focusableElement) return $buttonStore;
if (focusableElement instanceof HTMLElement) if (focusableElement instanceof HTMLElement) return focusableElement;
return focusableElement;
return $buttonStore; return $buttonStore;
})(); })();
restoreElement?.focus(); restoreElement?.focus();
}, },
}); });
let openClosedState: Writable<State> | undefined = writable(); let openClosedState: Writable<State> | undefined = writable();
setContext("OpenClosed", openClosedState); setContext("OpenClosed", openClosedState);
$: $openClosedState = match(disclosureState, { $: $openClosedState = match(disclosureState, {
[DisclosureStates.Open]: State.Open, [DisclosureStates.Open]: State.Open,
[DisclosureStates.Closed]: State.Closed, [DisclosureStates.Closed]: State.Closed,
}); });
</script> </script>
<div {...$$restProps}> <div {...$$restProps}>
<slot <slot open={disclosureState === DisclosureStates.Open} close={$api?.close} />
open={disclosureState === DisclosureStates.Open}
close={$api?.close}
/>
</div> </div>

View File

@@ -1,103 +1,100 @@
<script lang="ts"> <script lang="ts">
import { import { useDisclosureContext, DisclosureStates } from "./Disclosure.svelte";
useDisclosureContext, import { usePanelContext } from "./DisclosurePanel.svelte";
DisclosureStates, import { useId } from "$lib/hooks/use-id";
} from "./Disclosure.svelte"; import { Keys } from "$lib/utils/keyboard";
import { usePanelContext } from "./DisclosurePanel.svelte"; export let disabled = false;
import { useId } from "./use-id"; const api = useDisclosureContext("DisclosureButton");
import { Keys } from "./keyboard"; const panelContext = usePanelContext();
export let disabled = false; const id = `headlessui-disclosure-button-${useId()}`;
const api = useDisclosureContext("DisclosureButton");
const panelContext = usePanelContext();
const id = `headlessui-disclosure-button-${useId()}`;
$: buttonStore = $api?.buttonStore; $: buttonStore = $api?.buttonStore;
$: panelStore = $api?.panelStore; $: panelStore = $api?.panelStore;
$: isWithinPanel = $: isWithinPanel =
panelContext === null ? false : panelContext === $api?.panelId; panelContext === null ? false : panelContext === $api?.panelId;
function handleClick() { function handleClick() {
if (disabled) return; if (disabled) return;
if (isWithinPanel) { if (isWithinPanel) {
$api.toggleDisclosure(); $api.toggleDisclosure();
$buttonStore?.focus(); $buttonStore?.focus();
} else { } else {
$api.toggleDisclosure(); $api.toggleDisclosure();
}
} }
}
function handleKeyDown(event: KeyboardEvent) { function handleKeyDown(event: KeyboardEvent) {
if (disabled) return; if (disabled) return;
if (isWithinPanel) { if (isWithinPanel) {
switch (event.key) { switch (event.key) {
case Keys.Space: case Keys.Space:
case Keys.Enter: case Keys.Enter:
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
$api.toggleDisclosure(); $api.toggleDisclosure();
$buttonStore?.focus(); $buttonStore?.focus();
break; break;
} }
} else { } else {
switch (event.key) { switch (event.key) {
case Keys.Space: case Keys.Space:
case Keys.Enter: case Keys.Enter:
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
$api.toggleDisclosure(); $api.toggleDisclosure();
break; break;
} }
}
} }
}
function handleKeyUp(event: KeyboardEvent) { function handleKeyUp(event: KeyboardEvent) {
switch (event.key) { switch (event.key) {
case Keys.Space: case Keys.Space:
// Required for firefox, event.preventDefault() in handleKeyDown for // Required for firefox, event.preventDefault() in handleKeyDown for
// the Space key doesn't cancel the handleKeyUp, which in turn // the Space key doesn't cancel the handleKeyUp, which in turn
// triggers a *click*. // triggers a *click*.
event.preventDefault(); event.preventDefault();
break; break;
}
} }
}
$: propsWeControl = isWithinPanel $: propsWeControl = isWithinPanel
? {} ? {}
: { : {
id, id,
"aria-expanded": disabled "aria-expanded": disabled
? undefined ? undefined
: $api.disclosureState === DisclosureStates.Open, : $api.disclosureState === DisclosureStates.Open,
"aria-controls": $panelStore ? $api?.panelId : undefined, "aria-controls": $panelStore ? $api?.panelId : undefined,
disabled: disabled ? true : undefined, disabled: disabled ? true : undefined,
}; };
</script> </script>
{#if isWithinPanel} {#if isWithinPanel}
<button <button
{...{ ...$$restProps, ...propsWeControl }} {...{ ...$$restProps, ...propsWeControl }}
on:click={handleClick} on:click={handleClick}
on:keydown={handleKeyDown} on:keydown={handleKeyDown}
> >
<slot <slot
open={$api?.disclosureState === DisclosureStates.Open} open={$api?.disclosureState === DisclosureStates.Open}
close={$api?.close} close={$api?.close}
/> />
</button> </button>
{:else} {:else}
<button <button
{...{ ...$$restProps, ...propsWeControl }} {...{ ...$$restProps, ...propsWeControl }}
bind:this={$buttonStore} bind:this={$buttonStore}
on:click={handleClick} on:click={handleClick}
on:keydown={handleKeyDown} on:keydown={handleKeyDown}
on:keyup={handleKeyUp} on:keyup={handleKeyUp}
> >
<slot <slot
open={$api?.disclosureState === DisclosureStates.Open} open={$api?.disclosureState === DisclosureStates.Open}
close={$api?.close} close={$api?.close}
/> />
</button> </button>
{/if} {/if}

View File

@@ -1,40 +1,37 @@
<script lang="ts" context="module"> <script lang="ts" context="module">
import { getContext, setContext } from "svelte"; import { getContext, setContext } from "svelte";
let DISCLOSURE_PANEL_CONTEXT_NAME = "DisclosurePanelContext"; let DISCLOSURE_PANEL_CONTEXT_NAME = "DisclosurePanelContext";
export function usePanelContext(): string | undefined { export function usePanelContext(): string | undefined {
return getContext(DISCLOSURE_PANEL_CONTEXT_NAME); return getContext(DISCLOSURE_PANEL_CONTEXT_NAME);
} }
</script> </script>
<script lang="ts"> <script lang="ts">
import { import { useDisclosureContext, DisclosureStates } from "./Disclosure.svelte";
useDisclosureContext, import type { Writable } from "svelte/store";
DisclosureStates, import { State } from "$lib/internal/open-closed";
} from "./Disclosure.svelte"; const api = useDisclosureContext("DisclosureButton");
import { Writable } from "svelte/store"; $: id = $api?.panelId;
import { State } from "./open-closed"; let openClosedState: Writable<State> | undefined = getContext("OpenClosed");
const api = useDisclosureContext("DisclosureButton");
$: id = $api?.panelId;
let openClosedState: Writable<State> | undefined = getContext("OpenClosed");
setContext(DISCLOSURE_PANEL_CONTEXT_NAME, id); setContext(DISCLOSURE_PANEL_CONTEXT_NAME, id);
$: panelStore = $api?.panelStore; $: panelStore = $api?.panelStore;
$: visible = $: visible =
$openClosedState !== null $openClosedState !== null
? $openClosedState === State.Open ? $openClosedState === State.Open
: $api?.disclosureState === DisclosureStates.Open; : $api?.disclosureState === DisclosureStates.Open;
$: propsWeControl = { id }; $: propsWeControl = { id };
</script> </script>
{#if visible} {#if visible}
<div {...{ ...$$restProps, ...propsWeControl }} bind:this={$panelStore}> <div {...{ ...$$restProps, ...propsWeControl }} bind:this={$panelStore}>
<slot <slot
open={$api?.disclosureState === DisclosureStates.Open} open={$api?.disclosureState === DisclosureStates.Open}
close={$api?.close} close={$api?.close}
/> />
</div> </div>
{/if} {/if}

View File

@@ -1,127 +1,126 @@
<script lang="ts"> <script lang="ts">
import { Keys } from "./keyboard"; import { Keys } from "$lib/utils/keyboard";
import { import {
focusElement, focusElement,
focusIn, focusIn,
Focus, Focus,
FocusResult, FocusResult,
} from "./focus-management"; } from "$lib/utils/focus-management";
import { contains } from "./dom-containers"; import { contains } from "$lib/internal/dom-containers";
import { afterUpdate, onMount, onDestroy } from "svelte"; import { afterUpdate, onMount, onDestroy } from "svelte";
export let containers: Set<HTMLElement>; export let containers: Set<HTMLElement>;
export let enabled: boolean = true; export let enabled: boolean = true;
export let options: { initialFocus?: HTMLElement | null } = {}; export let options: { initialFocus?: HTMLElement | null } = {};
let restoreElement: HTMLElement | null = let restoreElement: HTMLElement | null =
typeof window !== "undefined" typeof window !== "undefined"
? (document.activeElement as HTMLElement) ? (document.activeElement as HTMLElement)
: null; : null;
let previousActiveElement: HTMLElement | null = null; let previousActiveElement: HTMLElement | null = null;
function handleFocus() { function handleFocus() {
if (!enabled) return; if (!enabled) return;
if (containers.size !== 1) return; if (containers.size !== 1) return;
let { initialFocus } = options; let { initialFocus } = options;
let activeElement = document.activeElement as HTMLElement; let activeElement = document.activeElement as HTMLElement;
if (initialFocus) { if (initialFocus) {
if (initialFocus === activeElement) { if (initialFocus === activeElement) {
return; // Initial focus ref is already the active element return; // Initial focus ref is already the active element
} }
} else if (contains(containers, activeElement)) { } else if (contains(containers, activeElement)) {
return; // Already focused within Dialog 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;
} }
}
restoreElement = activeElement; if (!couldFocus)
console.warn(
"There are no focusable elements inside the <FocusTrap />"
);
}
// Try to focus the initialFocus ref previousActiveElement = document.activeElement as HTMLElement;
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) // Restore when `enabled` becomes false
console.warn( function restore() {
"There are no focusable elements inside the <FocusTrap />" 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; previousActiveElement = document.activeElement as HTMLElement;
break;
}
} }
}
// Restore when `enabled` becomes false // Prevent programmatically escaping
function restore() { function handleWindowFocus(event: FocusEvent) {
focusElement(restoreElement); if (!enabled) return;
restoreElement = null; if (containers.size !== 1) return;
previousActiveElement = null;
}
// Handle initial focus let previous = previousActiveElement;
onMount(handleFocus); if (!previous) return;
afterUpdate(() => (enabled ? handleFocus() : restore())); let toElement = event.target as HTMLElement | null;
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;
if (toElement && toElement instanceof HTMLElement) {
if (!contains(containers, toElement)) {
event.preventDefault(); event.preventDefault();
event.stopPropagation();
for (let element of containers) { focusElement(previous);
let result = focusIn( } else {
element, previousActiveElement = toElement;
(event.shiftKey ? Focus.Previous : Focus.Next) | focusElement(toElement);
Focus.WrapAround }
); } else {
focusElement(previousActiveElement);
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> </script>
<svelte:window <svelte:window
on:keydown={handleWindowKeyDown} on:keydown={handleWindowKeyDown}
on:focus|capture={handleWindowFocus} on:focus|capture={handleWindowFocus}
/> />

View File

@@ -1,37 +1,37 @@
<script lang="ts"> <script lang="ts">
import { useId } from "./use-id"; import { useId } from "$lib/hooks/use-id";
import { getContext, onMount } from "svelte"; import { getContext, onMount } from "svelte";
import { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
import { LabelContext } from "./LabelProvider.svelte"; import type { LabelContext } from "./LabelProvider.svelte";
const id = `headlessui-label-${useId()}`; const id = `headlessui-label-${useId()}`;
export let passive = false; export let passive = false;
let contextStore: Writable<LabelContext> | undefined = getContext( let contextStore: Writable<LabelContext> | undefined = getContext(
"headlessui-label-context" "headlessui-label-context"
);
if (!contextStore) {
throw new Error(
"You used a <Label /> component, but it is not inside a relevant parent."
); );
if (!contextStore) { }
throw new Error(
"You used a <Label /> component, but it is not inside a relevant parent."
);
}
onMount(() => $contextStore.register(id)); onMount(() => $contextStore.register(id));
let allProps = { ...$$restProps, ...$contextStore.props, id }; let allProps = { ...$$restProps, ...$contextStore.props, id };
if (passive) delete allProps["onClick"]; if (passive) delete allProps["onClick"];
</script> </script>
<!-- svelte-ignore a11y-label-has-associated-control --> <!-- svelte-ignore a11y-label-has-associated-control -->
<label <label
{...allProps} {...allProps}
on:blur on:blur
on:click on:click
on:focus on:focus
on:keyup on:keyup
on:keydown on:keydown
on:keypress on:keypress
on:click={(event) => { on:click={(event) => {
if (!passive) allProps["onClick"]?.(event); if (!passive) allProps["onClick"]?.(event);
}} }}
> >
<slot /> <slot />
</label> </label>

View File

@@ -1,37 +1,37 @@
<script lang="ts" context="module"> <script lang="ts" context="module">
export interface LabelContext { export interface LabelContext {
name?: string; name?: string;
props?: object; props?: object;
register: (value: string) => void; register: (value: string) => void;
labelIds?: string; labelIds?: string;
} }
</script> </script>
<script lang="ts"> <script lang="ts">
import { setContext } from "svelte"; import { setContext } from "svelte";
import { writable, Writable } from "svelte/store"; import { writable, Writable } from "svelte/store";
export let name: string; export let name: string;
let labelIds = []; let labelIds = [];
let contextStore: Writable<LabelContext> = writable({ let contextStore: Writable<LabelContext> = writable({
name, name,
register, register,
props: $$restProps, props: $$restProps,
}); });
setContext("headlessui-label-context", contextStore); setContext("headlessui-label-context", contextStore);
$: contextStore.set({ $: contextStore.set({
name, name,
props: $$restProps, props: $$restProps,
register, register,
labelIds: labelIds.length > 0 ? labelIds.join(" ") : undefined, labelIds: labelIds.length > 0 ? labelIds.join(" ") : undefined,
}); });
function register(value: string) { function register(value: string) {
labelIds = [...labelIds, value]; labelIds = [...labelIds, value];
return () => { return () => {
labelIds = labelIds.filter((labelId) => labelId !== value); labelIds = labelIds.filter((labelId) => labelId !== value);
}; };
} }
</script> </script>
<slot labelledby={$contextStore.labelIds} /> <slot labelledby={$contextStore.labelIds} />

View File

@@ -1,190 +1,187 @@
<script lang="ts" context="module"> <script lang="ts" context="module">
export enum ListboxStates { export enum ListboxStates {
Open, Open,
Closed, Closed,
} }
export type ListboxOptionDataRef = { export type ListboxOptionDataRef = {
textValue: string; textValue: string;
disabled: boolean; disabled: boolean;
value: unknown; value: unknown;
}; };
export type StateDefinition = { export type StateDefinition = {
// State // State
listboxState: ListboxStates; listboxState: ListboxStates;
value: any; value: any;
orientation: "vertical" | "horizontal"; orientation: "vertical" | "horizontal";
labelRef: HTMLLabelElement | null; labelRef: HTMLLabelElement | null;
buttonRef: HTMLButtonElement | null; buttonRef: HTMLButtonElement | null;
optionsRef: HTMLDivElement | null; optionsRef: HTMLDivElement | null;
disabled: boolean; disabled: boolean;
options: { id: string; dataRef: ListboxOptionDataRef }[]; options: { id: string; dataRef: ListboxOptionDataRef }[];
searchQuery: string; searchQuery: string;
activeOptionIndex: number | null; activeOptionIndex: number | null;
// State mutators // State mutators
closeListbox(): void; closeListbox(): void;
openListbox(): void; openListbox(): void;
goToOption(focus: Focus, id?: string): void; goToOption(focus: Focus, id?: string): void;
search(value: string): void; search(value: string): void;
clearSearch(): void; clearSearch(): void;
registerOption(id: string, dataRef: ListboxOptionDataRef): void; registerOption(id: string, dataRef: ListboxOptionDataRef): void;
unregisterOption(id: string): void; unregisterOption(id: string): void;
select(value: unknown): void; select(value: unknown): void;
}; };
</script> </script>
<script lang="ts"> <script lang="ts">
import { Focus, calculateActiveIndex } from "./calculate-active-index"; import {
import { createEventDispatcher, setContext } from "svelte"; Focus,
import { writable, Writable } from "svelte/store"; calculateActiveIndex,
import { match } from "./match"; } from "$lib/utils/calculate-active-index";
import { State, useOpenClosedProvider } from "./open-closed"; import { createEventDispatcher, setContext } from "svelte";
import { writable, Writable } from "svelte/store";
import { match } from "$lib/utils/match";
import { State, useOpenClosedProvider } from "$lib/internal/open-closed";
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
let listboxState = ListboxStates.Closed; let listboxState = ListboxStates.Closed;
let labelStore = writable(null); let labelStore = writable(null);
setContext("labelStore", labelStore); setContext("labelStore", labelStore);
$: labelRef = $labelStore; $: labelRef = $labelStore;
let buttonStore = writable(null); let buttonStore = writable(null);
setContext("buttonStore", buttonStore); setContext("buttonStore", buttonStore);
$: buttonRef = $buttonStore; $: buttonRef = $buttonStore;
let optionsStore = writable(null); let optionsStore = writable(null);
setContext("optionsStore", optionsStore); setContext("optionsStore", optionsStore);
$: optionsRef = $optionsStore; $: optionsRef = $optionsStore;
let options = []; let options = [];
let searchQuery = ""; let searchQuery = "";
let activeOptionIndex = null; let activeOptionIndex = null;
let api: Writable<StateDefinition | undefined> = writable(); let api: Writable<StateDefinition | undefined> = writable();
setContext("api", api); setContext("api", api);
let openClosedState = writable(State.Closed); let openClosedState = writable(State.Closed);
useOpenClosedProvider(openClosedState); useOpenClosedProvider(openClosedState);
$: openClosedState.set( $: openClosedState.set(
match(listboxState, { match(listboxState, {
[ListboxStates.Open]: State.Open, [ListboxStates.Open]: State.Open,
[ListboxStates.Closed]: State.Closed, [ListboxStates.Closed]: State.Closed,
}) })
); );
export let disabled = false; export let disabled = false;
export let horizontal = false; export let horizontal = false;
export let value: any; export let value: any;
$: orientation = (horizontal ? "horizontal" : "vertical") as $: orientation = (horizontal ? "horizontal" : "vertical") as
| "horizontal" | "horizontal"
| "vertical"; | "vertical";
$: api.set({ $: api.set({
listboxState, listboxState,
labelRef, labelRef,
value, value,
buttonRef, buttonRef,
optionsRef, optionsRef,
options, options,
searchQuery, searchQuery,
activeOptionIndex, activeOptionIndex,
disabled, disabled,
orientation, orientation,
closeListbox() { closeListbox() {
if (disabled) return; if (disabled) return;
if (listboxState === ListboxStates.Closed) return; if (listboxState === ListboxStates.Closed) return;
listboxState = ListboxStates.Closed; listboxState = ListboxStates.Closed;
activeOptionIndex = null; activeOptionIndex = null;
}, },
openListbox() { openListbox() {
if (disabled) return; if (disabled) return;
if (listboxState === ListboxStates.Open) return; if (listboxState === ListboxStates.Open) return;
listboxState = ListboxStates.Open; listboxState = ListboxStates.Open;
}, },
goToOption(focus: Focus, id?: string) { goToOption(focus: Focus, id?: string) {
if (disabled) return; if (disabled) return;
if (listboxState === ListboxStates.Closed) return; if (listboxState === ListboxStates.Closed) return;
let nextActiveOptionIndex = calculateActiveIndex( let nextActiveOptionIndex = calculateActiveIndex(
focus === Focus.Specific focus === Focus.Specific
? { focus: Focus.Specific, id: id! } ? { focus: Focus.Specific, id: id! }
: { focus: focus as Exclude<Focus, Focus.Specific> }, : { focus: focus as Exclude<Focus, Focus.Specific> },
{ {
resolveItems: () => options, resolveItems: () => options,
resolveActiveIndex: () => activeOptionIndex, resolveActiveIndex: () => activeOptionIndex,
resolveId: (option) => option.id, resolveId: (option) => option.id,
resolveDisabled: (option) => option.disabled, 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 });
} }
);
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> </script>
<svelte:window on:mousedown={handleMousedown} /> <svelte:window on:mousedown={handleMousedown} />

View File

@@ -1,85 +1,85 @@
<script lang="ts"> <script lang="ts">
import { getContext, tick } from "svelte"; import { getContext, tick } from "svelte";
import { ListboxStates, StateDefinition } from "./Listbox.svelte"; import { ListboxStates, StateDefinition } from "./Listbox.svelte";
import { useId } from "./use-id"; import { useId } from "$lib/hooks/use-id";
import { Keys } from "./keyboard"; import { Keys } from "$lib/utils/keyboard";
import { Focus } from "./calculate-active-index"; import { Focus } from "$lib/utils/calculate-active-index";
let api: SvelteStore<StateDefinition> = getContext("api"); let api: SvelteStore<StateDefinition> = getContext("api");
let id = `headlessui-listbox-button-${useId()}`; let id = `headlessui-listbox-button-${useId()}`;
let buttonStore: SvelteStore<HTMLButtonElement> = getContext("buttonStore"); let buttonStore: SvelteStore<HTMLButtonElement> = getContext("buttonStore");
async function handleKeyDown(event: KeyboardEvent) { async function handleKeyDown(event: KeyboardEvent) {
switch (event.key) { switch (event.key) {
// Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-13 // Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-13
case Keys.Space: case Keys.Space:
case Keys.Enter: case Keys.Enter:
case Keys.ArrowDown: case Keys.ArrowDown:
event.preventDefault(); event.preventDefault();
$api.openListbox(); $api.openListbox();
await tick(); await tick();
$api.optionsRef?.focus({ preventScroll: true }); $api.optionsRef?.focus({ preventScroll: true });
if (!$api.value) $api.goToOption(Focus.First); if (!$api.value) $api.goToOption(Focus.First);
break; break;
case Keys.ArrowUp: case Keys.ArrowUp:
event.preventDefault(); event.preventDefault();
$api.openListbox(); $api.openListbox();
await tick(); await tick();
$api.optionsRef?.focus({ preventScroll: true }); $api.optionsRef?.focus({ preventScroll: true });
if (!$api.value) $api.goToOption(Focus.Last); if (!$api.value) $api.goToOption(Focus.Last);
break; break;
}
} }
}
function handleKeyUp(event: KeyboardEvent) { function handleKeyUp(event: KeyboardEvent) {
switch (event.key) { switch (event.key) {
case Keys.Space: case Keys.Space:
// Required for firefox, event.preventDefault() in handleKeyDown for // Required for firefox, event.preventDefault() in handleKeyDown for
// the Space key doesn't cancel the handleKeyUp, which in turn // the Space key doesn't cancel the handleKeyUp, which in turn
// triggers a *click*. // triggers a *click*.
event.preventDefault(); event.preventDefault();
break; break;
}
} }
}
async function handleClick(event: MouseEvent) { async function handleClick(event: MouseEvent) {
if ($api.disabled) return; if ($api.disabled) return;
if ($api.listboxState === ListboxStates.Open) { if ($api.listboxState === ListboxStates.Open) {
$api.closeListbox(); $api.closeListbox();
await tick(); await tick();
$api.buttonRef?.focus({ preventScroll: true }); $api.buttonRef?.focus({ preventScroll: true });
} else { } else {
event.preventDefault(); event.preventDefault();
$api.openListbox(); $api.openListbox();
await tick(); await tick();
$api.optionsRef?.focus({ preventScroll: true }); $api.optionsRef?.focus({ preventScroll: true });
}
} }
}
$: propsWeControl = { $: propsWeControl = {
id, id,
"aria-haspopup": true, "aria-haspopup": true,
"aria-controls": $api?.optionsRef?.id, "aria-controls": $api?.optionsRef?.id,
"aria-expanded": $api?.disabled "aria-expanded": $api?.disabled
? undefined ? undefined
: $api?.listboxState === ListboxStates.Open, : $api?.listboxState === ListboxStates.Open,
"aria-labelledby": $api?.labelRef "aria-labelledby": $api?.labelRef
? [$api?.labelRef?.id, id].join(" ") ? [$api?.labelRef?.id, id].join(" ")
: undefined, : undefined,
disabled: $api?.disabled === true ? true : undefined, disabled: $api?.disabled === true ? true : undefined,
}; };
</script> </script>
<button <button
{...$$restProps} {...$$restProps}
{...propsWeControl} {...propsWeControl}
bind:this={$buttonStore} bind:this={$buttonStore}
on:click={handleClick} on:click={handleClick}
on:keydown={handleKeyDown} on:keydown={handleKeyDown}
on:keyup={handleKeyUp} on:keyup={handleKeyUp}
> >
<slot <slot
open={$api.listboxState === ListboxStates.Open} open={$api.listboxState === ListboxStates.Open}
disabled={$api.disabled} disabled={$api.disabled}
/> />
</button> </button>

View File

@@ -1,20 +1,20 @@
<script lang="ts"> <script lang="ts">
import { getContext } from "svelte"; import { getContext } from "svelte";
import { ListboxStates, StateDefinition } from "./Listbox.svelte"; import { ListboxStates, StateDefinition } from "./Listbox.svelte";
import { useId } from "./use-id"; import { useId } from "$lib/hooks/use-id";
let api: SvelteStore<StateDefinition> = getContext("api"); let api: SvelteStore<StateDefinition> = getContext("api");
let id = `headlessui-listbox-label-${useId()}`; let id = `headlessui-listbox-label-${useId()}`;
let labelStore: SvelteStore<HTMLLabelElement> = getContext("labelStore"); let labelStore: SvelteStore<HTMLLabelElement> = getContext("labelStore");
function handleClick() { function handleClick() {
$api.buttonRef?.focus({ preventScroll: true }); $api.buttonRef?.focus({ preventScroll: true });
} }
</script> </script>
<!-- svelte-ignore a11y-label-has-associated-control --> <!-- svelte-ignore a11y-label-has-associated-control -->
<label {...$$restProps} {id} bind:this={$labelStore} on:click={handleClick}> <label {...$$restProps} {id} bind:this={$labelStore} on:click={handleClick}>
<slot <slot
open={$api.listboxState === ListboxStates.Open} open={$api.listboxState === ListboxStates.Open}
disabled={$api.disabled} disabled={$api.disabled}
/> />
</label> </label>

View File

@@ -1,114 +1,112 @@
<script lang="ts"> <script lang="ts">
import { getContext, onDestroy, onMount, tick } from "svelte"; import { getContext, onDestroy, onMount, tick } from "svelte";
import { ListboxStates, StateDefinition } from "./Listbox.svelte"; import { ListboxStates, StateDefinition } from "./Listbox.svelte";
import { useId } from "./use-id"; import { useId } from "$lib/hooks/use-id";
import { Focus } from "./calculate-active-index"; import { Focus } from "$lib/utils/calculate-active-index";
export let value: any; export let value: any;
export let disabled = false; export let disabled = false;
let api: SvelteStore<StateDefinition> = getContext("api"); let api: SvelteStore<StateDefinition> = getContext("api");
let id = `headlessui-listbox-option-${useId()}`; let id = `headlessui-listbox-option-${useId()}`;
$: active = $: active =
$api?.activeOptionIndex !== null $api?.activeOptionIndex !== null
? $api?.options[$api.activeOptionIndex].id === id ? $api?.options[$api.activeOptionIndex].id === id
: false; : false;
$: selected = $api?.value === value; $: selected = $api?.value === value;
$: dataRef = { $: dataRef = {
disabled, disabled,
value, value,
textValue: "", textValue: "",
}; };
onMount(() => { onMount(() => {
let textValue = document let textValue = document
.getElementById(id) .getElementById(id)
?.textContent?.toLowerCase() ?.textContent?.toLowerCase()
.trim(); .trim();
if (textValue !== undefined) dataRef.textValue = textValue; if (textValue !== undefined) dataRef.textValue = textValue;
}); });
onMount(() => $api.registerOption(id, dataRef)); onMount(() => $api.registerOption(id, dataRef));
onDestroy(() => $api.unregisterOption(id)); onDestroy(() => $api.unregisterOption(id));
let oldState = $api?.listboxState; let oldState = $api?.listboxState;
let oldSelected = selected; let oldSelected = selected;
let oldActive = active; let oldActive = active;
async function updateFocus( async function updateFocus(
newState: ListboxStates, newState: ListboxStates,
newSelected: boolean, newSelected: boolean,
newActive: boolean newActive: boolean
) { ) {
// Wait for a tick since we need to ensure registerOption has been applied // Wait for a tick since we need to ensure registerOption has been applied
await tick(); await tick();
if (newState !== oldState || newSelected !== oldSelected) { if (newState !== oldState || newSelected !== oldSelected) {
if (newState === ListboxStates.Open && newSelected) { if (newState === ListboxStates.Open && newSelected) {
$api?.goToOption(Focus.Specific, id); $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); if (newState !== oldState || newActive !== oldActive) {
if (newState === ListboxStates.Open && newActive) {
async function handleClick(event: MouseEvent) { document.getElementById(id)?.scrollIntoView?.({ block: "nearest" });
if (disabled) return event.preventDefault(); }
$api.select(value);
$api.closeListbox();
await tick();
$api.buttonRef?.focus({ preventScroll: true });
} }
oldState = newState;
oldSelected = newSelected;
oldActive = newActive;
}
$: updateFocus($api?.listboxState, selected, active);
function handleFocus() { async function handleClick(event: MouseEvent) {
if (disabled) return $api.goToOption(Focus.Nothing); if (disabled) return event.preventDefault();
$api.goToOption(Focus.Specific, id); $api.select(value);
} $api.closeListbox();
await tick();
$api.buttonRef?.focus({ preventScroll: true });
}
function handleMove() { function handleFocus() {
if (disabled) return; if (disabled) return $api.goToOption(Focus.Nothing);
if (active) return; $api.goToOption(Focus.Specific, id);
$api.goToOption(Focus.Specific, id); }
}
function handleLeave() { function handleMove() {
if (disabled) return; if (disabled) return;
if (!active) return; if (active) return;
$api.goToOption(Focus.Nothing); $api.goToOption(Focus.Specific, id);
} }
$: propsWeControl = { function handleLeave() {
id, if (disabled) return;
role: "option", if (!active) return;
tabIndex: disabled === true ? undefined : -1, $api.goToOption(Focus.Nothing);
"aria-disabled": disabled === true ? true : undefined, }
"aria-selected": selected === true ? selected : undefined,
};
$: classStyle = $$props.class $: propsWeControl = {
? typeof $$props.class === "function" id,
? $$props.class({ active, selected, disabled }) role: "option",
: $$props.class 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> </script>
<li <li
{...$$restProps} {...$$restProps}
class={classStyle} class={classStyle}
{...propsWeControl} {...propsWeControl}
on:click={handleClick} on:click={handleClick}
on:focus={handleFocus} on:focus={handleFocus}
on:pointermove={handleMove} on:pointermove={handleMove}
on:mousemove={handleMove} on:mousemove={handleMove}
on:pointerleave={handleLeave} on:pointerleave={handleLeave}
on:mouseleave={handleLeave} on:mouseleave={handleLeave}
> >
<slot {active} {selected} {disabled} /> <slot {active} {selected} {disabled} />
</li> </li>

View File

@@ -1,118 +1,117 @@
<script lang="ts"> <script lang="ts">
import { getContext, tick } from "svelte"; import { getContext, tick } from "svelte";
import { ListboxStates, StateDefinition } from "./Listbox.svelte"; import { ListboxStates, StateDefinition } from "./Listbox.svelte";
import { useId } from "./use-id"; import { useId } from "$lib/hooks/use-id";
import { match } from "./match"; import { match } from "$lib/utils/match";
import { Keys } from "./keyboard"; import { Keys } from "$lib/utils/keyboard";
import { Focus } from "./calculate-active-index"; import { Focus } from "$lib/utils/calculate-active-index";
import { State, useOpenClosed } from "./open-closed"; import { State, useOpenClosed } from "$lib/internal/open-closed";
let api: SvelteStore<StateDefinition> = getContext("api"); let api: SvelteStore<StateDefinition> = getContext("api");
let id = `headlessui-listbox-options-${useId()}`; let id = `headlessui-listbox-options-${useId()}`;
let optionsStore: SvelteStore<HTMLUListElement> = let optionsStore: SvelteStore<HTMLUListElement> = getContext("optionsStore");
getContext("optionsStore");
let searchDebounce: ReturnType<typeof setTimeout> | null = null; let searchDebounce: ReturnType<typeof setTimeout> | null = null;
async function handleKeyDown(event: KeyboardEvent) { async function handleKeyDown(event: KeyboardEvent) {
if (searchDebounce) clearTimeout(searchDebounce); if (searchDebounce) clearTimeout(searchDebounce);
switch (event.key) { switch (event.key) {
// Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12 // Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12
case Keys.Space: case Keys.Space:
if ($api.searchQuery !== "") { if ($api.searchQuery !== "") {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
return $api.search(event.key); 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;
} }
// 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 = { $: propsWeControl = {
"aria-activedescendant": "aria-activedescendant":
$api?.activeOptionIndex === null $api?.activeOptionIndex === null
? undefined ? undefined
: $api?.options[$api.activeOptionIndex]?.id, : $api?.options[$api.activeOptionIndex]?.id,
"aria-labelledby": $api?.labelRef?.id ?? $api?.buttonRef?.id, "aria-labelledby": $api?.labelRef?.id ?? $api?.buttonRef?.id,
"aria-orientation": $api?.orientation, "aria-orientation": $api?.orientation,
id, id,
role: "listbox", role: "listbox",
tabIndex: 0, tabIndex: 0,
}; };
let usesOpenClosedState = useOpenClosed(); let usesOpenClosedState = useOpenClosed();
$: visible = $: visible =
usesOpenClosedState !== undefined usesOpenClosedState !== undefined
? $usesOpenClosedState === State.Open ? $usesOpenClosedState === State.Open
: $api.listboxState === ListboxStates.Open; : $api.listboxState === ListboxStates.Open;
</script> </script>
{#if visible} {#if visible}
<ul <ul
bind:this={$optionsStore} bind:this={$optionsStore}
on:keydown={handleKeyDown} on:keydown={handleKeyDown}
{...$$restProps} {...$$restProps}
{...propsWeControl} {...propsWeControl}
> >
<slot open={$api?.listboxState === ListboxStates.Open} /> <slot open={$api?.listboxState === ListboxStates.Open} />
</ul> </ul>
{/if} {/if}

View File

@@ -1,151 +1,151 @@
<script lang="ts" context="module"> <script lang="ts" context="module">
import { Focus, calculateActiveIndex } from "./calculate-active-index"; import {
import { getContext, setContext } from "svelte"; Focus,
import { writable, Writable } from "svelte/store"; calculateActiveIndex,
import { State } from "./open-closed"; } from "$lib/utils/calculate-active-index";
import { match } from "./match"; import { getContext, setContext } from "svelte";
export enum MenuStates { import { writable, Writable } from "svelte/store";
Open, import { State } from "$lib/internal/open-closed";
Closed, import { match } from "$lib/utils/match";
} export enum MenuStates {
export type MenuItemData = { textValue: string; disabled: boolean }; Open,
export type StateDefinition = { Closed,
// State }
menuState: MenuStates; export type MenuItemData = { textValue: string; disabled: boolean };
buttonStore: Writable<HTMLButtonElement | null>; export type StateDefinition = {
itemsStore: Writable<HTMLDivElement | null>; // State
items: { id: string; data: MenuItemData }[]; menuState: MenuStates;
searchQuery: string; buttonStore: Writable<HTMLButtonElement | null>;
activeItemIndex: number | null; itemsStore: Writable<HTMLDivElement | null>;
items: { id: string; data: MenuItemData }[];
// State mutators searchQuery: string;
closeMenu(): void; activeItemIndex: number | null;
openMenu(): void;
goToItem(focus: Focus, id?: string): void; // State mutators
search(value: string): void; closeMenu(): void;
clearSearch(): void; openMenu(): void;
registerItem(id: string, dataRef: MenuItemData): void; goToItem(focus: Focus, id?: string): void;
unregisterItem(id: string): void; search(value: string): void;
}; clearSearch(): void;
registerItem(id: string, dataRef: MenuItemData): void;
const MENU_CONTEXT_NAME = "MenuContext"; unregisterItem(id: string): void;
};
export function useMenuContext(
componentName: string const MENU_CONTEXT_NAME = "MenuContext";
): Writable<StateDefinition | undefined> {
let context: Writable<StateDefinition | undefined> | undefined = export function useMenuContext(
getContext(MENU_CONTEXT_NAME); componentName: string
): Writable<StateDefinition | undefined> {
if (context === undefined) { let context: Writable<StateDefinition | undefined> | undefined =
throw new Error( getContext(MENU_CONTEXT_NAME);
`<${componentName} /> is missing a parent <Menu /> component.`
); if (context === undefined) {
} throw new Error(
return context; `<${componentName} /> is missing a parent <Menu /> component.`
);
} }
return context;
}
</script> </script>
<script lang="ts"> <script lang="ts">
let menuState: StateDefinition["menuState"] = MenuStates.Closed; let menuState: StateDefinition["menuState"] = MenuStates.Closed;
let buttonStore: StateDefinition["buttonStore"] = writable(null); let buttonStore: StateDefinition["buttonStore"] = writable(null);
let itemsStore: StateDefinition["itemsStore"] = writable(null); let itemsStore: StateDefinition["itemsStore"] = writable(null);
let items: StateDefinition["items"] = []; let items: StateDefinition["items"] = [];
let searchQuery: StateDefinition["searchQuery"] = ""; let searchQuery: StateDefinition["searchQuery"] = "";
let activeItemIndex: StateDefinition["activeItemIndex"] = null; let activeItemIndex: StateDefinition["activeItemIndex"] = null;
let api: Writable<StateDefinition | undefined> = writable(); let api: Writable<StateDefinition | undefined> = writable();
setContext(MENU_CONTEXT_NAME, api); setContext(MENU_CONTEXT_NAME, api);
$: api.set({ $: api.set({
menuState, menuState,
buttonStore, buttonStore,
itemsStore: itemsStore, itemsStore: itemsStore,
items, items,
searchQuery, searchQuery,
activeItemIndex, activeItemIndex,
closeMenu: () => { closeMenu: () => {
menuState = MenuStates.Closed; menuState = MenuStates.Closed;
activeItemIndex = null; activeItemIndex = null;
}, },
openMenu: () => (menuState = MenuStates.Open), openMenu: () => (menuState = MenuStates.Open),
goToItem(focus: Focus, id?: string) { goToItem(focus: Focus, id?: string) {
let nextActiveItemIndex = calculateActiveIndex( let nextActiveItemIndex = calculateActiveIndex(
focus === Focus.Specific focus === Focus.Specific
? { focus: Focus.Specific, id: id! } ? { focus: Focus.Specific, id: id! }
: { focus: focus as Exclude<Focus, Focus.Specific> }, : { focus: focus as Exclude<Focus, Focus.Specific> },
{ {
resolveItems: () => items, resolveItems: () => items,
resolveActiveIndex: () => activeItemIndex, resolveActiveIndex: () => activeItemIndex,
resolveId: (item) => item.id, resolveId: (item) => item.id,
resolveDisabled: (item) => item.data.disabled, resolveDisabled: (item) => item.data.disabled,
} }
); );
if (searchQuery === "" && activeItemIndex === nextActiveItemIndex) if (searchQuery === "" && activeItemIndex === nextActiveItemIndex) return;
return; searchQuery = "";
searchQuery = ""; activeItemIndex = nextActiveItemIndex;
activeItemIndex = nextActiveItemIndex; },
}, search(value: string) {
search(value: string) { searchQuery += value.toLowerCase();
searchQuery += value.toLowerCase();
let match = items.findIndex( let match = items.findIndex(
(item) => (item) =>
item.data.textValue.startsWith(searchQuery) && item.data.textValue.startsWith(searchQuery) && !item.data.disabled
!item.data.disabled );
);
if (match === -1 || match === activeItemIndex) return; if (match === -1 || match === activeItemIndex) return;
activeItemIndex = match; activeItemIndex = match;
}, },
clearSearch() { clearSearch() {
searchQuery = ""; searchQuery = "";
}, },
registerItem(id: string, data: MenuItemData) { registerItem(id: string, data: MenuItemData) {
items.push({ id, data }); items.push({ id, data });
}, },
unregisterItem(id: string) { unregisterItem(id: string) {
let nextItems = items.slice(); let nextItems = items.slice();
let currentActiveItem = let currentActiveItem =
activeItemIndex !== null ? nextItems[activeItemIndex] : null; activeItemIndex !== null ? nextItems[activeItemIndex] : null;
let idx = nextItems.findIndex((a) => a.id === id); let idx = nextItems.findIndex((a) => a.id === id);
if (idx !== -1) nextItems.splice(idx, 1); if (idx !== -1) nextItems.splice(idx, 1);
items = nextItems; items = nextItems;
activeItemIndex = (() => { activeItemIndex = (() => {
if (idx === activeItemIndex) return null; if (idx === activeItemIndex) return null;
if (currentActiveItem === null) 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 // 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. // fix this, we will find the correct (new) index position.
return nextItems.indexOf(currentActiveItem); return nextItems.indexOf(currentActiveItem);
})(); })();
}, },
}); });
function handleWindowMousedown(event: MouseEvent): void { function handleWindowMousedown(event: MouseEvent): void {
let target = event.target as HTMLElement; let target = event.target as HTMLElement;
let active = document.activeElement; let active = document.activeElement;
if (menuState !== MenuStates.Open) return; if (menuState !== MenuStates.Open) return;
if ($buttonStore?.contains(target)) return; if ($buttonStore?.contains(target)) return;
if (!$itemsStore?.contains(target)) $api.closeMenu(); if (!$itemsStore?.contains(target)) $api.closeMenu();
if (active !== document.body && active?.contains(target)) return; // Keep focus on newly clicked/focused element if (active !== document.body && active?.contains(target)) return; // Keep focus on newly clicked/focused element
if (!event.defaultPrevented) if (!event.defaultPrevented) $buttonStore?.focus({ preventScroll: true });
$buttonStore?.focus({ preventScroll: true }); }
}
let openClosedState: Writable<State> | undefined = writable(); let openClosedState: Writable<State> | undefined = writable();
setContext("OpenClosed", openClosedState); setContext("OpenClosed", openClosedState);
$: $openClosedState = match(menuState, { $: $openClosedState = match(menuState, {
[MenuStates.Open]: State.Open, [MenuStates.Open]: State.Open,
[MenuStates.Closed]: State.Closed, [MenuStates.Closed]: State.Closed,
}); });
</script> </script>
<svelte:window on:mousedown={handleWindowMousedown} /> <svelte:window on:mousedown={handleWindowMousedown} />
<div {...$$restProps}> <div {...$$restProps}>
<slot /> <slot />
</div> </div>

View File

@@ -1,83 +1,81 @@
<script lang="ts"> <script lang="ts">
import { useMenuContext, MenuStates } from "./Menu.svelte"; import { useMenuContext, MenuStates } from "./Menu.svelte";
import { useId } from "./use-id"; import { useId } from "$lib/hooks/use-id";
import { Keys } from "./keyboard"; import { Keys } from "$lib/utils/keyboard";
import { Focus } from "./calculate-active-index"; import { Focus } from "$lib/utils/calculate-active-index";
import { tick } from "svelte"; import { tick } from "svelte";
export let disabled = false; export let disabled = false;
const api = useMenuContext("MenuButton"); const api = useMenuContext("MenuButton");
const id = `headlessui-menu-button-${useId()}`; const id = `headlessui-menu-button-${useId()}`;
$: buttonStore = $api?.buttonStore; $: buttonStore = $api?.buttonStore;
$: itemsStore = $api?.itemsStore; $: itemsStore = $api?.itemsStore;
async function handleKeyDown(event: KeyboardEvent) { async function handleKeyDown(event: KeyboardEvent) {
switch (event.key) { switch (event.key) {
// Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-13 // Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-13
case Keys.Space: case Keys.Space:
case Keys.Enter: case Keys.Enter:
case Keys.ArrowDown: case Keys.ArrowDown:
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
$api.openMenu(); $api.openMenu();
await tick(); await tick();
$itemsStore?.focus({ preventScroll: true }); $itemsStore?.focus({ preventScroll: true });
$api.goToItem(Focus.First); $api.goToItem(Focus.First);
break; break;
case Keys.ArrowUp: case Keys.ArrowUp:
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
$api.openMenu(); $api.openMenu();
await tick(); await tick();
$itemsStore?.focus({ preventScroll: true }); $itemsStore?.focus({ preventScroll: true });
$api.goToItem(Focus.Last); $api.goToItem(Focus.Last);
break; break;
}
} }
}
function handleKeyUp(event: KeyboardEvent) { function handleKeyUp(event: KeyboardEvent) {
switch (event.key) { switch (event.key) {
case Keys.Space: case Keys.Space:
// Required for firefox, event.preventDefault() in handleKeyDown for // Required for firefox, event.preventDefault() in handleKeyDown for
// the Space key doesn't cancel the handleKeyUp, which in turn // the Space key doesn't cancel the handleKeyUp, which in turn
// triggers a *click*. // triggers a *click*.
event.preventDefault(); event.preventDefault();
break; break;
}
} }
}
async function handleClick(event: MouseEvent) { async function handleClick(event: MouseEvent) {
if (disabled) return; if (disabled) return;
if ($api.menuState === MenuStates.Open) { if ($api.menuState === MenuStates.Open) {
$api.closeMenu(); $api.closeMenu();
await tick(); await tick();
$buttonStore?.focus({ preventScroll: true }); $buttonStore?.focus({ preventScroll: true });
} else { } else {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
$api.openMenu(); $api.openMenu();
await tick(); await tick();
$itemsStore?.focus({ preventScroll: true }); $itemsStore?.focus({ preventScroll: true });
}
} }
}
$: propsWeControl = { $: propsWeControl = {
id, id,
"aria-haspopup": true, "aria-haspopup": true,
"aria-controls": $itemsStore?.id, "aria-controls": $itemsStore?.id,
"aria-expanded": disabled "aria-expanded": disabled ? undefined : $api.menuState === MenuStates.Open,
? undefined };
: $api.menuState === MenuStates.Open,
};
</script> </script>
<button <button
{...{ ...$$restProps, ...propsWeControl }} {...{ ...$$restProps, ...propsWeControl }}
bind:this={$buttonStore} bind:this={$buttonStore}
on:click={handleClick} on:click={handleClick}
on:keydown={handleKeyDown} on:keydown={handleKeyDown}
on:keyup={handleKeyUp} on:keyup={handleKeyUp}
> >
<slot open={$api?.menuState === MenuStates.Open} /> <slot open={$api?.menuState === MenuStates.Open} />
</button> </button>

View File

@@ -1,79 +1,79 @@
<script lang="ts"> <script lang="ts">
import { useMenuContext, MenuStates, MenuItemData } from "./Menu.svelte"; import { useMenuContext, MenuStates, MenuItemData } from "./Menu.svelte";
import { useId } from "./use-id"; import { useId } from "$lib/hooks/use-id";
import { Focus } from "./calculate-active-index"; import { Focus } from "$lib/utils/calculate-active-index";
import { afterUpdate, onDestroy, onMount, tick } from "svelte"; import { afterUpdate, onDestroy, onMount, tick } from "svelte";
export let disabled = false; export let disabled = false;
const api = useMenuContext("MenuItem"); const api = useMenuContext("MenuItem");
const id = `headlessui-menu-item-${useId()}`; const id = `headlessui-menu-item-${useId()}`;
$: active = $: active =
$api?.activeItemIndex !== null $api?.activeItemIndex !== null
? $api?.items[$api?.activeItemIndex].id === id ? $api?.items[$api?.activeItemIndex].id === id
: false; : false;
$: buttonStore = $api?.buttonStore; $: buttonStore = $api?.buttonStore;
let elementRef: HTMLDivElement | undefined; let elementRef: HTMLDivElement | undefined;
$: textValue = elementRef?.textContent?.toLowerCase().trim(); $: textValue = elementRef?.textContent?.toLowerCase().trim();
$: data = { disabled, textValue } as MenuItemData; $: data = { disabled, textValue } as MenuItemData;
onMount(async () => { onMount(async () => {
await tick(); await tick();
$api?.registerItem(id, data); $api?.registerItem(id, data);
}); });
onDestroy(() => { onDestroy(() => {
$api.unregisterItem(id); $api.unregisterItem(id);
}); });
afterUpdate(async () => { afterUpdate(async () => {
if ($api.menuState !== MenuStates.Open) return; if ($api.menuState !== MenuStates.Open) return;
if (!active) return; if (!active) return;
await tick(); await tick();
elementRef?.scrollIntoView?.({ block: "nearest" }); elementRef?.scrollIntoView?.({ block: "nearest" });
}); });
async function handleClick(event: MouseEvent) { async function handleClick(event: MouseEvent) {
if (disabled) return event.preventDefault(); if (disabled) return event.preventDefault();
$api.closeMenu(); $api.closeMenu();
$buttonStore?.focus({ preventScroll: true }); $buttonStore?.focus({ preventScroll: true });
} }
function handleFocus() { function handleFocus() {
if (disabled) return $api.goToItem(Focus.Nothing); if (disabled) return $api.goToItem(Focus.Nothing);
$api.goToItem(Focus.Specific, id); $api.goToItem(Focus.Specific, id);
} }
function handleMove() { function handleMove() {
if (disabled) return; if (disabled) return;
if (active) return; if (active) return;
$api.goToItem(Focus.Specific, id); $api.goToItem(Focus.Specific, id);
} }
function handleLeave() { function handleLeave() {
if (disabled) return; if (disabled) return;
if (!active) return; if (!active) return;
$api.goToItem(Focus.Nothing); $api.goToItem(Focus.Nothing);
} }
$: propsWeControl = { $: propsWeControl = {
id, id,
role: "menuitem", role: "menuitem",
tabIndex: disabled === true ? undefined : -1, tabIndex: disabled === true ? undefined : -1,
"aria-disabled": disabled === true ? true : undefined, "aria-disabled": disabled === true ? true : undefined,
}; };
</script> </script>
<div <div
{...{ ...$$restProps, ...propsWeControl }} {...{ ...$$restProps, ...propsWeControl }}
bind:this={elementRef} bind:this={elementRef}
on:click={handleClick} on:click={handleClick}
on:focus={handleFocus} on:focus={handleFocus}
on:pointermove={handleMove} on:pointermove={handleMove}
on:mousemove={handleMove} on:mousemove={handleMove}
on:pointerleave={handleLeave} on:pointerleave={handleLeave}
on:mouseleave={handleLeave} on:mouseleave={handleLeave}
> >
<slot {active} {disabled} /> <slot {active} {disabled} />
</div> </div>

View File

@@ -1,139 +1,139 @@
<script lang="ts"> <script lang="ts">
import { useMenuContext, MenuStates } from "./Menu.svelte"; import { useMenuContext, MenuStates } from "./Menu.svelte";
import { useId } from "./use-id"; import { useId } from "$lib/hooks/use-id";
import { Keys } from "./keyboard"; import { Keys } from "$lib/utils/keyboard";
import { Focus } from "./calculate-active-index"; import { Focus } from "$lib/utils/calculate-active-index";
import { treeWalker } from "./tree-walker"; import { treeWalker } from "$lib/utils/tree-walker";
import { State } from "./open-closed"; import { State } from "$lib/internal/open-closed";
import { getContext, tick } from "svelte"; import { getContext, tick } from "svelte";
import { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
const api = useMenuContext("MenuButton"); const api = useMenuContext("MenuButton");
const id = `headlessui-menu-items-${useId()}`; const id = `headlessui-menu-items-${useId()}`;
let searchDebounce: ReturnType<typeof setTimeout> | null = null; let searchDebounce: ReturnType<typeof setTimeout> | null = null;
$: buttonStore = $api?.buttonStore; $: buttonStore = $api?.buttonStore;
$: itemsStore = $api?.itemsStore; $: itemsStore = $api?.itemsStore;
let openClosedState: Writable<State> | undefined = getContext("OpenClosed"); let openClosedState: Writable<State> | undefined = getContext("OpenClosed");
$: visible = $: visible =
openClosedState !== undefined openClosedState !== undefined
? $openClosedState === State.Open ? $openClosedState === State.Open
: $api.menuState === MenuStates.Open; : $api.menuState === MenuStates.Open;
$: treeWalker({ $: treeWalker({
container: $itemsStore, container: $itemsStore,
enabled: $api?.menuState === MenuStates.Open, enabled: $api?.menuState === MenuStates.Open,
accept(node) { accept(node) {
if (node.getAttribute("role") === "menuitem") if (node.getAttribute("role") === "menuitem")
return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_REJECT;
if (node.hasAttribute("role")) return NodeFilter.FILTER_SKIP; if (node.hasAttribute("role")) return NodeFilter.FILTER_SKIP;
return NodeFilter.FILTER_ACCEPT; return NodeFilter.FILTER_ACCEPT;
}, },
walk(node) { walk(node) {
node.setAttribute("role", "none"); node.setAttribute("role", "none");
}, },
}); });
async function handleKeyDown(event: KeyboardEvent) { async function handleKeyDown(event: KeyboardEvent) {
if (searchDebounce) clearTimeout(searchDebounce); if (searchDebounce) clearTimeout(searchDebounce);
switch (event.key) { switch (event.key) {
// Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12 // Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12
case Keys.Space: case Keys.Space:
if ($api.searchQuery !== "") { if ($api.searchQuery !== "") {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
return $api.search(event.key); 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;
} }
} // When in type ahead mode, fallthrough
case Keys.Enter:
function handleKeyUp(event: KeyboardEvent) { event.preventDefault();
switch (event.key) { event.stopPropagation();
case Keys.Space: if ($api.activeItemIndex !== null) {
// Required for firefox, event.preventDefault() in handleKeyDown for let { id } = $api.items[$api.activeItemIndex];
// the Space key doesn't cancel the handleKeyUp, which in turn document.getElementById(id)?.click();
// triggers a *click*.
event.preventDefault();
break;
} }
} $api.closeMenu();
await tick();
$buttonStore?.focus({ preventScroll: true });
break;
$: propsWeControl = { case Keys.ArrowDown:
"aria-activedescendant": event.preventDefault();
$api.activeItemIndex === null event.stopPropagation();
? undefined return $api.goToItem(Focus.Next);
: $api.items[$api.activeItemIndex]?.id,
"aria-labelledby": $buttonStore?.id, case Keys.ArrowUp:
id, event.preventDefault();
role: "menu", event.stopPropagation();
tabIndex: 0, 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> </script>
{#if visible} {#if visible}
<div <div
{...{ ...$$restProps, ...propsWeControl }} {...{ ...$$restProps, ...propsWeControl }}
bind:this={$itemsStore} bind:this={$itemsStore}
on:keydown={handleKeyDown} on:keydown={handleKeyDown}
on:keyup={handleKeyUp} on:keyup={handleKeyUp}
> >
<slot open={$api.menuState === MenuStates.Open} /> <slot open={$api.menuState === MenuStates.Open} />
</div> </div>
{/if} {/if}

View File

@@ -1,145 +1,147 @@
<script lang="ts" context="module"> <script lang="ts" context="module">
export enum PopoverStates { export enum PopoverStates {
Open, Open,
Closed, Closed,
} }
export interface StateDefinition { export interface StateDefinition {
// State // State
popoverState: PopoverStates; popoverState: PopoverStates;
button: HTMLElement | null; button: HTMLElement | null;
buttonId: string; buttonId: string;
panel: HTMLElement | null; panel: HTMLElement | null;
panelId: string; panelId: string;
// State mutators // State mutators
togglePopover(): void; togglePopover(): void;
closePopover(): void; closePopover(): void;
// Exposed functions // Exposed functions
close(focusableElement: HTMLElement | null): void; close(focusableElement: HTMLElement | null): void;
} }
export interface PopoverRegisterBag { export interface PopoverRegisterBag {
buttonId: string; buttonId: string;
panelId: string; panelId: string;
close(): void; close(): void;
} }
</script> </script>
<script lang="ts"> <script lang="ts">
import { match } from "./match"; import { match } from "$lib/utils/match";
import { useId } from "./use-id"; import { useId } from "$lib/hooks/use-id";
import { isFocusableElement, FocusableMode } from "./focus-management"; import {
import { State } from "./open-closed"; isFocusableElement,
import type { PopoverGroupContext } from "./PopoverGroup.svelte"; FocusableMode,
import { getContext, setContext, onMount } from "svelte"; } from "$lib/utils/focus-management";
import { writable, Writable } from "svelte/store"; import { State } from "$lib/internal/open-closed";
const buttonId = `headlessui-popover-button-${useId()}`; import type { PopoverGroupContext } from "./PopoverGroup.svelte";
const panelId = `headlessui-popover-panel-${useId()}`; 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 popoverState: PopoverStates = PopoverStates.Closed;
let api: Writable<StateDefinition | undefined> = writable(); let api: Writable<StateDefinition | undefined> = writable();
setContext("PopoverApi", api); setContext("PopoverApi", api);
let openClosedState: Writable<State> | undefined = writable(); let openClosedState: Writable<State> | undefined = writable();
setContext("OpenClosed", openClosedState); setContext("OpenClosed", openClosedState);
$: $openClosedState = match(popoverState, { $: $openClosedState = match(popoverState, {
[PopoverStates.Open]: State.Open, [PopoverStates.Open]: State.Open,
[PopoverStates.Closed]: State.Closed, [PopoverStates.Closed]: State.Closed,
}); });
let panelStore = writable(null); let panelStore = writable(null);
setContext("PopoverPanelRef", panelStore); setContext("PopoverPanelRef", panelStore);
$: panel = $panelStore; $: panel = $panelStore;
let buttonStore = writable(null); let buttonStore = writable(null);
setContext("PopoverButtonRef", buttonStore); setContext("PopoverButtonRef", buttonStore);
$: button = $buttonStore; $: button = $buttonStore;
$: api.set({ $: api.set({
popoverState, popoverState,
buttonId, buttonId,
panelId, panelId,
panel, panel,
button, button,
togglePopover() { togglePopover() {
popoverState = match(popoverState, { popoverState = match(popoverState, {
[PopoverStates.Open]: PopoverStates.Closed, [PopoverStates.Open]: PopoverStates.Closed,
[PopoverStates.Closed]: PopoverStates.Open, [PopoverStates.Closed]: PopoverStates.Open,
}); });
}, },
closePopover() { closePopover() {
if (popoverState === PopoverStates.Closed) return; if (popoverState === PopoverStates.Closed) return;
popoverState = PopoverStates.Closed; popoverState = PopoverStates.Closed;
}, },
close(focusableElement: HTMLElement | null) { close(focusableElement: HTMLElement | null) {
$api.closePopover(); $api.closePopover();
let restoreElement = (() => { let restoreElement = (() => {
if (!focusableElement) return $api.button; if (!focusableElement) return $api.button;
if (focusableElement instanceof HTMLElement) if (focusableElement instanceof HTMLElement) return focusableElement;
return focusableElement;
return $api.button; return $api.button;
})(); })();
restoreElement?.focus(); restoreElement?.focus();
}, },
}); });
const registerBag = { const registerBag = {
buttonId, buttonId,
panelId, panelId,
close() { close() {
$api.closePopover(); $api.closePopover();
}, },
}; };
const groupContext: PopoverGroupContext | undefined = const groupContext: PopoverGroupContext | undefined =
getContext("PopoverGroup"); getContext("PopoverGroup");
const registerPopover = groupContext?.registerPopover; const registerPopover = groupContext?.registerPopover;
function isFocusWithinPopoverGroup() { function isFocusWithinPopoverGroup() {
return ( return (
groupContext?.isFocusWithinPopoverGroup() ?? groupContext?.isFocusWithinPopoverGroup() ??
(button?.contains(document.activeElement) || (button?.contains(document.activeElement) ||
panel?.contains(document.activeElement)) panel?.contains(document.activeElement))
); );
} }
onMount(() => registerPopover?.(registerBag)); onMount(() => registerPopover?.(registerBag));
// Handle focus out // Handle focus out
function handleFocus() { function handleFocus() {
if (popoverState !== PopoverStates.Open) return; if (popoverState !== PopoverStates.Open) return;
if (isFocusWithinPopoverGroup()) return; if (isFocusWithinPopoverGroup()) return;
if (!button) return; if (!button) return;
if (!panel) return; if (!panel) return;
$api.closePopover(); $api.closePopover();
} }
// Handle outside click // Handle outside click
function handleMousedown(event: MouseEvent) { function handleMousedown(event: MouseEvent) {
let target = event.target as HTMLElement; let target = event.target as HTMLElement;
if (popoverState !== PopoverStates.Open) return; if (popoverState !== PopoverStates.Open) return;
if (button?.contains(target)) return; if (button?.contains(target)) return;
if (panel?.contains(target)) return; if (panel?.contains(target)) return;
$api.closePopover(); $api.closePopover();
if (!isFocusableElement(target, FocusableMode.Loose)) { if (!isFocusableElement(target, FocusableMode.Loose)) {
event.preventDefault(); event.preventDefault();
button?.focus(); button?.focus();
}
} }
}
</script> </script>
<svelte:window on:focus|capture={handleFocus} on:mousedown={handleMousedown} /> <svelte:window on:focus|capture={handleFocus} on:mousedown={handleMousedown} />
<div {...$$restProps}> <div {...$$restProps}>
<slot open={popoverState === PopoverStates.Open} close={$api.close} /> <slot open={popoverState === PopoverStates.Open} close={$api.close} />
</div> </div>

View File

@@ -1,176 +1,177 @@
<script lang="ts"> <script lang="ts">
import { Keys } from "./keyboard"; import { Keys } from "$lib/utils/keyboard";
import { getFocusableElements, Focus, focusIn } from "./focus-management"; import {
import { getContext } from "svelte"; getFocusableElements,
import { writable, Writable } from "svelte/store"; Focus,
import { PopoverStates, StateDefinition } from "./Popover.svelte"; focusIn,
import type { PopoverGroupContext } from "./PopoverGroup.svelte"; } from "$lib/utils/focus-management";
import type { PopoverPanelContext } from "./PopoverPanel.svelte"; import { getContext } from "svelte";
let buttonStore: Writable<HTMLButtonElement> = import { writable, Writable } from "svelte/store";
getContext("PopoverButtonRef"); import { PopoverStates, StateDefinition } from "./Popover.svelte";
export let disabled: Boolean = false; import type { PopoverGroupContext } from "./PopoverGroup.svelte";
let api: Writable<StateDefinition> | undefined = getContext("PopoverApi"); 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 = const groupContext: PopoverGroupContext | undefined =
getContext("PopoverGroup"); getContext("PopoverGroup");
const closeOthers = groupContext?.closeOthers; const closeOthers = groupContext?.closeOthers;
let panelContext: PopoverPanelContext | undefined = let panelContext: PopoverPanelContext | undefined =
getContext("PopoverPanel"); getContext("PopoverPanel");
let isWithinPanel = let isWithinPanel =
panelContext === null ? false : panelContext === $api.panelId; 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 (isWithinPanel) {
buttonStore = writable(); 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 // TODO: Revisit when handling Tab/Shift+Tab when using Portal's
let activeElementRef: Element | null = null; switch (event.key) {
let previousActiveElementRef: Element | null = case Keys.Tab:
typeof window === "undefined" ? null : document.activeElement; // 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;
function handleFocus() { // Check if the last focused element is *after* the button in the DOM
previousActiveElementRef = activeElementRef; let focusableElements = getFocusableElements();
activeElementRef = document.activeElement; 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 handleKeyDown(event: KeyboardEvent) { function handleClick() {
if (isWithinPanel) { if (disabled) return;
if ($api.popoverState === PopoverStates.Closed) return; if (isWithinPanel) {
switch (event.key) { $api.closePopover();
case Keys.Space: $api.button?.focus(); // Re-focus the original opening Button
case Keys.Enter: } else {
event.preventDefault(); // Prevent triggering a *click* event if ($api.popoverState === PopoverStates.Closed)
event.stopPropagation(); closeOthers?.($api.buttonId);
$api.closePopover(); $api.button?.focus();
$api.button?.focus(); // Re-focus the original opening Button $api.togglePopover();
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 $: propsWeControl = isWithinPanel
switch (event.key) { ? {}
case Keys.Tab: : {
// Check if the last focused element exists, and check that it is not inside button or panel itself id: $api.buttonId,
if (!previousActiveElementRef) return; "aria-expanded": disabled
if ($api.button?.contains(previousActiveElementRef)) return; ? undefined
if ($api.panel?.contains(previousActiveElementRef)) return; : $api.popoverState === PopoverStates.Open,
"aria-controls": $api.panel ? $api.panelId : undefined,
// Check if the last focused element is *after* the button in the DOM disabled: disabled ? true : undefined,
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> </script>
<svelte:window on:focus|capture={handleFocus} /> <svelte:window on:focus|capture={handleFocus} />
<button <button
{...$$restProps} {...$$restProps}
{...propsWeControl} {...propsWeControl}
on:click={handleClick} on:click={handleClick}
on:keydown={handleKeyDown} on:keydown={handleKeyDown}
on:keyup={handleKeyUp} on:keyup={handleKeyUp}
bind:this={$buttonStore} bind:this={$buttonStore}
> >
<slot open={$api.popoverState === PopoverStates.Open} /> <slot open={$api.popoverState === PopoverStates.Open} />
</button> </button>

View File

@@ -1,57 +1,57 @@
<script lang="ts" context="module"> <script lang="ts" context="module">
export interface PopoverGroupContext { export interface PopoverGroupContext {
registerPopover(registerbag: PopoverRegisterBag): void; registerPopover(registerbag: PopoverRegisterBag): void;
unregisterPopover(registerbag: PopoverRegisterBag): void; unregisterPopover(registerbag: PopoverRegisterBag): void;
isFocusWithinPopoverGroup(): boolean; isFocusWithinPopoverGroup(): boolean;
closeOthers(buttonId: string): void; closeOthers(buttonId: string): void;
} }
</script> </script>
<script lang="ts"> <script lang="ts">
import { PopoverRegisterBag } from "./Popover.svelte"; import type { PopoverRegisterBag } from "./Popover.svelte";
import { setContext } from "svelte"; import { setContext } from "svelte";
let groupRef: HTMLDivElement | undefined; let groupRef: HTMLDivElement | undefined;
let popovers: PopoverRegisterBag[] = []; let popovers: PopoverRegisterBag[] = [];
function unregisterPopover(registerBag: PopoverRegisterBag) { function unregisterPopover(registerBag: PopoverRegisterBag) {
popovers = popovers.filter((bag) => bag != registerBag); popovers = popovers.filter((bag) => bag != registerBag);
} }
function registerPopover(registerBag: PopoverRegisterBag) { function registerPopover(registerBag: PopoverRegisterBag) {
popovers = [...popovers, registerBag]; popovers = [...popovers, registerBag];
return () => { return () => {
unregisterPopover(registerBag); unregisterPopover(registerBag);
}; };
} }
function isFocusWithinPopoverGroup() { function isFocusWithinPopoverGroup() {
let element = document.activeElement as HTMLElement; let element = document.activeElement as HTMLElement;
if (groupRef?.contains(element)) return true; 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. // 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 popovers.some((bag) => {
return ( return (
document.getElementById(bag.buttonId)?.contains(element) || document.getElementById(bag.buttonId)?.contains(element) ||
document.getElementById(bag.panelId)?.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,
}); });
}
function closeOthers(buttonId: string) {
for (let popover of popovers) {
if (popover.buttonId !== buttonId) popover.close();
}
}
setContext("PopoverGroup", {
unregisterPopover,
registerPopover,
isFocusWithinPopoverGroup,
closeOthers,
});
</script> </script>
<div {...$$restProps} bind:this={groupRef}> <div {...$$restProps} bind:this={groupRef}>
<slot /> <slot />
</div> </div>

View File

@@ -1,25 +1,25 @@
<script lang="ts"> <script lang="ts">
import { State } from "./open-closed"; import { State } from "$lib/internal/open-closed";
import { getContext } from "svelte"; import { getContext } from "svelte";
import type { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
import { PopoverStates, StateDefinition } from "./Popover.svelte"; import { PopoverStates, StateDefinition } from "./Popover.svelte";
let api: Writable<StateDefinition> | undefined = getContext("PopoverApi"); let api: Writable<StateDefinition> | undefined = getContext("PopoverApi");
let openClosedState: Writable<State> | undefined = getContext("OpenClosed"); let openClosedState: Writable<State> | undefined = getContext("OpenClosed");
$: visible = $: visible =
openClosedState !== undefined openClosedState !== undefined
? $openClosedState === State.Open ? $openClosedState === State.Open
: $api.popoverState === PopoverStates.Open; : $api.popoverState === PopoverStates.Open;
function handleClick() { function handleClick() {
$api.closePopover(); $api.closePopover();
} }
</script> </script>
{#if visible} {#if visible}
<div {...$$restProps} on:click={handleClick} aria-hidden> <div {...$$restProps} on:click={handleClick} aria-hidden>
<slot open={$api.popoverState === PopoverStates.Open} /> <slot open={$api.popoverState === PopoverStates.Open} />
</div> </div>
{/if} {/if}

View File

@@ -1,117 +1,114 @@
<script lang="ts" context="module"> <script lang="ts" context="module">
export type PopoverPanelContext = string | null; export type PopoverPanelContext = string | null;
</script> </script>
<script lang="ts"> <script lang="ts">
import { Keys } from "./keyboard"; import { Keys } from "$lib/utils/keyboard";
import { State } from "./open-closed"; import { State } from "$lib/internal/open-closed";
import { import {
getFocusableElements, getFocusableElements,
Focus, Focus,
FocusResult, FocusResult,
focusIn, focusIn,
} from "./focus-management"; } from "$lib/utils/focus-management";
import { getContext, setContext, onMount } from "svelte"; import { getContext, setContext, onMount } from "svelte";
import type { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
import { PopoverStates, StateDefinition } from "./Popover.svelte"; import { PopoverStates, StateDefinition } from "./Popover.svelte";
let panelStore: SvelteStore<HTMLDivElement> = getContext("PopoverPanelRef"); let panelStore: SvelteStore<HTMLDivElement> = getContext("PopoverPanelRef");
export let focus = false; export let focus = false;
let api: Writable<StateDefinition> | undefined = getContext("PopoverApi"); let api: Writable<StateDefinition> | undefined = getContext("PopoverApi");
setContext("PopoverPanelContext", $api.panelId); setContext("PopoverPanelContext", $api.panelId);
let openClosedState: Writable<State> | undefined = getContext("OpenClosed"); let openClosedState: Writable<State> | undefined = getContext("OpenClosed");
$: visible = $: visible =
openClosedState !== undefined openClosedState !== undefined
? $openClosedState === State.Open ? $openClosedState === State.Open
: $api.popoverState === PopoverStates.Open; : $api.popoverState === PopoverStates.Open;
onMount(() => { onMount(() => {
if (!focus) return; 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.popoverState !== PopoverStates.Open) return;
if (!$api.panel) 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; 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(); event.preventDefault();
event.stopPropagation();
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(); $api.closePopover();
$api.button?.focus();
break;
} }
}
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> </script>
<svelte:window <svelte:window
on:keydown={handleWindowKeydown} on:keydown={handleWindowKeydown}
on:focus|capture={handleFocus} on:focus|capture={handleFocus}
/> />
{#if visible} {#if visible}
<div {...$$restProps} on:keydown={handleKeydown} bind:this={$panelStore}> <div {...$$restProps} on:keydown={handleKeydown} bind:this={$panelStore}>
<slot <slot open={$api.popoverState === PopoverStates.Open} close={$api.close} />
open={$api.popoverState === PopoverStates.Open} </div>
close={$api.close}
/>
</div>
{/if} {/if}

View File

@@ -1,29 +1,29 @@
<script lang="ts"> <script lang="ts">
import { usePortalGroupContext } from "./PortalGroup.svelte"; import { usePortalGroupContext } from "./PortalGroup.svelte";
import { usePortalRoot } from "./ForcePortalRootContext.svelte"; import { usePortalRoot } from "$lib/internal/ForcePortalRootContext.svelte";
import { portal } from "./use-portal"; import { portal } from "$lib/hooks/use-portal";
let forceInRoot = usePortalRoot(); let forceInRoot = usePortalRoot();
let groupTarget = usePortalGroupContext(); let groupTarget = usePortalGroupContext();
$: target = (() => { $: target = (() => {
// Group context is used, but still null // Group context is used, but still null
if ( if (
!(forceInRoot && $forceInRoot) && !(forceInRoot && $forceInRoot) &&
groupTarget !== undefined && groupTarget !== undefined &&
$groupTarget !== null $groupTarget !== null
) )
return $groupTarget; return $groupTarget;
// No group context is used, let's create a default portal root // No group context is used, let's create a default portal root
if (typeof window === "undefined") return null; if (typeof window === "undefined") return null;
let existingRoot = document.getElementById("headlessui-portal-root"); let existingRoot = document.getElementById("headlessui-portal-root");
if (existingRoot) return existingRoot; if (existingRoot) return existingRoot;
let root = document.createElement("div"); let root = document.createElement("div");
root.setAttribute("id", "headlessui-portal-root"); root.setAttribute("id", "headlessui-portal-root");
return document.body.appendChild(root); return document.body.appendChild(root);
})(); })();
</script> </script>
<div use:portal={target}> <div use:portal={target}>
<slot /> <slot />
</div> </div>

View File

@@ -1,18 +1,18 @@
<script lang="ts" context="module"> <script lang="ts" context="module">
import { getContext, setContext } from "svelte"; import { getContext, setContext } from "svelte";
import { writable, Writable } from "svelte/store"; import { writable, Writable } from "svelte/store";
const PORTAL_GROUP_CONTEXT_NAME = "headlessui-portal-group-context"; const PORTAL_GROUP_CONTEXT_NAME = "headlessui-portal-group-context";
export function usePortalGroupContext(): export function usePortalGroupContext():
| Writable<HTMLElement | null> | Writable<HTMLElement | null>
| undefined { | undefined {
return getContext(PORTAL_GROUP_CONTEXT_NAME); return getContext(PORTAL_GROUP_CONTEXT_NAME);
} }
</script> </script>
<script lang="ts"> <script lang="ts">
export let target: HTMLElement | null; export let target: HTMLElement | null;
setContext(PORTAL_GROUP_CONTEXT_NAME, writable(target)); setContext(PORTAL_GROUP_CONTEXT_NAME, writable(target));
</script> </script>
<slot /> <slot />

View File

@@ -1,182 +1,175 @@
<script lang="ts" context="module"> <script lang="ts" context="module">
import DescriptionProvider from "./DescriptionProvider.svelte"; import DescriptionProvider from "./DescriptionProvider.svelte";
import LabelProvider from "./LabelProvider.svelte"; import LabelProvider from "./LabelProvider.svelte";
import { createEventDispatcher, getContext, setContext } from "svelte"; import { createEventDispatcher, getContext, setContext } from "svelte";
import { Writable, writable } from "svelte/store"; import { Writable, writable } from "svelte/store";
import { Focus, focusIn, FocusResult } from "./focus-management"; import { Focus, focusIn, FocusResult } from "$lib/utils/focus-management";
import { Keys } from "./keyboard"; import { Keys } from "$lib/utils/keyboard";
import { useId } from "./use-id"; import { useId } from "$lib/hooks/use-id";
export interface Option { export interface Option {
id: string; id: string;
element: HTMLElement | null; element: HTMLElement | null;
propsRef: { value: unknown; disabled: boolean }; 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.`
);
} }
export interface StateDefinition { return context;
// 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>
<script lang="ts"> <script lang="ts">
import { treeWalker } from "./use-tree-walker"; import { treeWalker } from "$lib/hooks/use-tree-walker";
export let disabled = false; export let disabled = false;
export let value: any; export let value: any;
let radioGroupRef: HTMLElement | null = null; let radioGroupRef: HTMLElement | null = null;
let options: StateDefinition["options"] = []; let options: StateDefinition["options"] = [];
let id = `headlessui-radiogroup-${useId()}`; let id = `headlessui-radiogroup-${useId()}`;
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
let api: Writable<StateDefinition | undefined> = writable(); let api: Writable<StateDefinition | undefined> = writable();
setContext(RADIO_GROUP_CONTEXT_NAME, api); setContext(RADIO_GROUP_CONTEXT_NAME, api);
$: api.set({ $: api.set({
options, options,
value, value,
disabled, disabled,
firstOption: options.find((option) => !option.propsRef.disabled), firstOption: options.find((option) => !option.propsRef.disabled),
containsCheckedOption: options.some( containsCheckedOption: options.some(
(option) => option.propsRef.value === value (option) => option.propsRef.value === value
), ),
change(nextValue: unknown) { change(nextValue: unknown) {
if (disabled) return false; if (disabled) return false;
if (value === nextValue) return false; if (value === nextValue) return false;
let nextOption = options.find( let nextOption = options.find(
(option) => option.propsRef.value === nextValue (option) => option.propsRef.value === nextValue
)?.propsRef; )?.propsRef;
if (nextOption?.disabled) return false; if (nextOption?.disabled) return false;
dispatch("updateValue", nextValue); dispatch("updateValue", nextValue);
return true; return true;
}, },
registerOption(action: Option) { registerOption(action: Option) {
options = [...options, action]; options = [...options, action];
}, },
unregisterOption(id: Option["id"]) { unregisterOption(id: Option["id"]) {
options = options.filter((radio) => radio.id !== id); options = options.filter((radio) => radio.id !== id);
}, },
}); });
$: treeWalker({ $: treeWalker({
container: radioGroupRef, container: radioGroupRef,
accept(node) { accept(node) {
if (node.getAttribute("role") === "radio") if (node.getAttribute("role") === "radio")
return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_REJECT;
if (node.hasAttribute("role")) return NodeFilter.FILTER_SKIP; if (node.hasAttribute("role")) return NodeFilter.FILTER_SKIP;
return NodeFilter.FILTER_ACCEPT; return NodeFilter.FILTER_ACCEPT;
}, },
walk(node) { walk(node) {
node.setAttribute("role", "none"); node.setAttribute("role", "none");
}, },
}); });
function handleKeyDown(event: KeyboardEvent) { function handleKeyDown(event: KeyboardEvent) {
if (!radioGroupRef) return; if (!radioGroupRef) return;
if (!radioGroupRef.contains(event.target as HTMLElement)) return; if (!radioGroupRef.contains(event.target as HTMLElement)) return;
let all = options let all = options
.filter((option) => option.propsRef.disabled === false) .filter((option) => option.propsRef.disabled === false)
.map((radio) => radio.element) as HTMLElement[]; .map((radio) => radio.element) as HTMLElement[];
switch (event.key) { switch (event.key) {
case Keys.ArrowLeft: case Keys.ArrowLeft:
case Keys.ArrowUp: case Keys.ArrowUp:
{ {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
let result = focusIn( let result = focusIn(all, Focus.Previous | Focus.WrapAround);
all,
Focus.Previous | Focus.WrapAround
);
if (result === FocusResult.Success) { if (result === FocusResult.Success) {
let activeOption = options.find( let activeOption = options.find(
(option) => (option) => option.element === document.activeElement
option.element === document.activeElement );
); if (activeOption) $api.change(activeOption.propsRef.value);
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;
} }
} break;
$: propsWeControl = { case Keys.ArrowRight:
id, case Keys.ArrowDown:
role: "radiogroup", {
}; 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> </script>
<DescriptionProvider name="RadioGroup.Description" let:describedby> <DescriptionProvider name="RadioGroup.Description" let:describedby>
<LabelProvider name="RadioGroup.Label" let:labelledby> <LabelProvider name="RadioGroup.Label" let:labelledby>
<div <div
{...{ ...$$restProps, ...propsWeControl }} {...{ ...$$restProps, ...propsWeControl }}
bind:this={radioGroupRef} bind:this={radioGroupRef}
aria-labelledby={labelledby} aria-labelledby={labelledby}
aria-describedby={describedby} aria-describedby={describedby}
on:keydown={handleKeyDown} on:keydown={handleKeyDown}
> >
<slot /> <slot />
</div> </div>
</LabelProvider> </LabelProvider>
</DescriptionProvider> </DescriptionProvider>

View File

@@ -1,91 +1,91 @@
<script lang="ts"> <script lang="ts">
import { onDestroy } from "svelte"; import { onDestroy } from "svelte";
import DescriptionProvider from "./DescriptionProvider.svelte"; import DescriptionProvider from "./DescriptionProvider.svelte";
import LabelProvider from "./LabelProvider.svelte"; import LabelProvider from "./LabelProvider.svelte";
import { useRadioGroupContext, Option } from "./RadioGroup.svelte"; import { useRadioGroupContext, Option } from "./RadioGroup.svelte";
import { useId } from "./use-id"; import { useId } from "$lib/hooks/use-id";
enum OptionState { enum OptionState {
Empty = 1 << 0, Empty = 1 << 0,
Active = 1 << 1, Active = 1 << 1,
} }
export let value: any; export let value: any;
export let disabled: boolean = false; export let disabled: boolean = false;
let api = useRadioGroupContext("RadioGroupOption"); let api = useRadioGroupContext("RadioGroupOption");
let id = `headlessui-radiogroup-option-${useId()}`; let id = `headlessui-radiogroup-option-${useId()}`;
let optionRef: HTMLElement | null = null; let optionRef: HTMLElement | null = null;
$: propsRef = { value, disabled }; $: propsRef = { value, disabled };
let state = OptionState.Empty; let state = OptionState.Empty;
function updateOption(option: Option) { function updateOption(option: Option) {
$api?.unregisterOption(option.id); $api?.unregisterOption(option.id);
$api?.registerOption(option); $api?.registerOption(option);
} }
$: updateOption({ id, element: optionRef, propsRef }); $: updateOption({ id, element: optionRef, propsRef });
onDestroy(() => $api?.unregisterOption(id)); onDestroy(() => $api?.unregisterOption(id));
$: isFirstOption = $api?.firstOption?.id === id; $: isFirstOption = $api?.firstOption?.id === id;
$: isDisabled = $api?.disabled || disabled; $: isDisabled = $api?.disabled || disabled;
$: checked = $api?.value === value; $: checked = $api?.value === value;
$: tabIndex = (() => { $: tabIndex = (() => {
if (isDisabled) return -1; if (isDisabled) return -1;
if (checked) return 0; if (checked) return 0;
if (!$api.containsCheckedOption && isFirstOption) return 0; if (!$api.containsCheckedOption && isFirstOption) return 0;
return -1; return -1;
})(); })();
function handleClick() { function handleClick() {
if (!$api.change(value)) return; if (!$api.change(value)) return;
state |= OptionState.Active; state |= OptionState.Active;
optionRef?.focus(); optionRef?.focus();
} }
function handleFocus() { function handleFocus() {
state |= OptionState.Active; state |= OptionState.Active;
} }
function handleBlur() { function handleBlur() {
state &= ~OptionState.Active; state &= ~OptionState.Active;
} }
$: classStyle = $$props.class $: classStyle = $$props.class
? typeof $$props.class === "function" ? typeof $$props.class === "function"
? $$props.class({ ? $$props.class({
active: state & OptionState.Active, active: state & OptionState.Active,
checked, checked,
disabled: isDisabled, disabled: isDisabled,
}) })
: $$props.class : $$props.class
: ""; : "";
$: propsWeControl = { $: propsWeControl = {
id, id,
class: classStyle, class: classStyle,
role: "radio", role: "radio",
"aria-checked": checked ? ("true" as const) : ("false" as const), "aria-checked": checked ? ("true" as const) : ("false" as const),
"aria-disabled": isDisabled ? true : undefined, "aria-disabled": isDisabled ? true : undefined,
tabIndex: tabIndex, tabIndex: tabIndex,
}; };
</script> </script>
<DescriptionProvider name="RadioGroup.Description" let:describedby> <DescriptionProvider name="RadioGroup.Description" let:describedby>
<LabelProvider name="RadioGroup.Label" let:labelledby> <LabelProvider name="RadioGroup.Label" let:labelledby>
<div <div
{...{ ...$$restProps, ...propsWeControl }} {...{ ...$$restProps, ...propsWeControl }}
bind:this={optionRef} bind:this={optionRef}
aria-labelledby={labelledby} aria-labelledby={labelledby}
aria-describedby={describedby} aria-describedby={describedby}
on:click={isDisabled ? undefined : handleClick} on:click={isDisabled ? undefined : handleClick}
on:focus={isDisabled ? undefined : handleFocus} on:focus={isDisabled ? undefined : handleFocus}
on:blur={isDisabled ? undefined : handleBlur} on:blur={isDisabled ? undefined : handleBlur}
> >
<slot <slot
{checked} {checked}
disabled={isDisabled} disabled={isDisabled}
active={state & OptionState.Active} active={state & OptionState.Active}
/> />
</div> </div>
</LabelProvider> </LabelProvider>
</DescriptionProvider> </DescriptionProvider>

View File

@@ -1,71 +1,72 @@
<script lang="ts"> <script lang="ts">
import { StateDefinition } from "./SwitchGroup.svelte"; import type { StateDefinition } from "./SwitchGroup.svelte";
import { LabelContext } from "./LabelProvider.svelte"; import type { LabelContext } from "$lib/components/label/LabelProvider.svelte";
import { DescriptionContext } from "./DescriptionProvider.svelte"; import type { DescriptionContext } from "$lib/components/description/DescriptionProvider.svelte";
import { useId } from "./use-id"; import { useId } from "$lib/hooks/use-id";
import { Keys } from "./keyboard"; import { Keys } from "$lib/utils/keyboard";
import { getContext, createEventDispatcher } from "svelte"; import { getContext, createEventDispatcher } from "svelte";
import { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
export let checked = false; export let checked = false;
let api: Writable<StateDefinition> | undefined = getContext("SwitchApi"); let api: Writable<StateDefinition> | undefined = getContext("SwitchApi");
let labelContext: Writable<LabelContext> | undefined = getContext( let labelContext: Writable<LabelContext> | undefined = getContext(
"headlessui-label-context" "headlessui-label-context"
); );
let descriptionContext: Writable<DescriptionContext> | undefined = let descriptionContext: Writable<DescriptionContext> | undefined = getContext(
getContext("headlessui-description-context"); "headlessui-description-context"
let id = `headlessui-switch-${useId()}`; );
$: switchStore = $api?.switchStore; let id = `headlessui-switch-${useId()}`;
let internalSwitchRef = null; $: switchStore = $api?.switchStore;
let internalSwitchRef = null;
function toggle() { function toggle() {
dispatch("updateValue", !checked); dispatch("updateValue", !checked);
} }
function handleClick(event: MouseEvent) { function handleClick(event: MouseEvent) {
event.preventDefault(); event.preventDefault();
toggle(); toggle();
} }
function handleKeyUp(event: KeyboardEvent) { function handleKeyUp(event: KeyboardEvent) {
if (event.key !== Keys.Tab) event.preventDefault(); if (event.key !== Keys.Tab) event.preventDefault();
if (event.key === Keys.Space) toggle(); 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. // This is needed so that we can "cancel" the click event when we use the `Enter` key on a button.
function handleKeyPress(event: KeyboardEvent) { function handleKeyPress(event: KeyboardEvent) {
event.preventDefault(); event.preventDefault();
} }
$: propsWeControl = { $: propsWeControl = {
id, id,
role: "switch", role: "switch",
tabIndex: 0, tabIndex: 0,
"aria-checked": checked, "aria-checked": checked,
"aria-labelledby": $labelContext?.labelIds, "aria-labelledby": $labelContext?.labelIds,
"aria-describedby": $descriptionContext?.descriptionIds, "aria-describedby": $descriptionContext?.descriptionIds,
}; };
</script> </script>
{#if switchStore} {#if switchStore}
<button <button
{...{ ...$$restProps, ...propsWeControl }} {...{ ...$$restProps, ...propsWeControl }}
bind:this={$switchStore} bind:this={$switchStore}
on:click={handleClick} on:click={handleClick}
on:keyup={handleKeyUp} on:keyup={handleKeyUp}
on:keypress={handleKeyPress} on:keypress={handleKeyPress}
> >
<slot /> <slot />
</button> </button>
{:else} {:else}
<button <button
{...{ ...$$restProps, ...propsWeControl }} {...{ ...$$restProps, ...propsWeControl }}
bind:this={$internalSwitchRef} bind:this={$internalSwitchRef}
on:click={handleClick} on:click={handleClick}
on:keyup={handleKeyUp} on:keyup={handleKeyUp}
on:keypress={handleKeyPress} on:keypress={handleKeyPress}
> >
<slot /> <slot />
</button> </button>
{/if} {/if}

View File

@@ -1,35 +1,35 @@
<script lang="ts" context="module"> <script lang="ts" context="module">
export interface StateDefinition { export interface StateDefinition {
switchStore: Writable<HTMLButtonElement | null>; switchStore: Writable<HTMLButtonElement | null>;
} }
</script> </script>
<script lang="ts"> <script lang="ts">
import DescriptionProvider from "./DescriptionProvider.svelte"; import DescriptionProvider from "./DescriptionProvider.svelte";
import LabelProvider from "./LabelProvider.svelte"; import LabelProvider from "./LabelProvider.svelte";
import { setContext } from "svelte"; import { setContext } from "svelte";
import { Writable, writable } from "svelte/store"; import { Writable, writable } from "svelte/store";
let switchStore: Writable<HTMLButtonElement | null> = writable(null); let switchStore: Writable<HTMLButtonElement | null> = writable(null);
let api: Writable<StateDefinition | undefined> = writable(); let api: Writable<StateDefinition | undefined> = writable();
setContext("SwitchApi", api); setContext("SwitchApi", api);
function onClick() { function onClick() {
if (!$switchStore) return; if (!$switchStore) return;
$switchStore.click(); $switchStore.click();
$switchStore.focus({ preventScroll: true }); $switchStore.focus({ preventScroll: true });
} }
$: api.set({ $: api.set({
switchStore, switchStore,
}); });
</script> </script>
<div {...$$restProps}> <div {...$$restProps}>
<DescriptionProvider name="Switch.Description"> <DescriptionProvider name="Switch.Description">
<LabelProvider name="Switch.Label" {onClick}> <LabelProvider name="Switch.Label" {onClick}>
<slot /> <slot />
</LabelProvider> </LabelProvider>
</DescriptionProvider> </DescriptionProvider>
</div> </div>

View File

@@ -1,98 +1,98 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { Focus, focusIn } from "./focus-management"; import { Focus, focusIn } from "$lib/utils/focus-management";
import { Keys } from "./keyboard"; import { Keys } from "$lib/utils/keyboard";
import { match } from "./match"; import { match } from "$lib/utils/match";
import { useTabsContext } from "./TabGroup.svelte"; import { useTabsContext } from "./TabGroup.svelte";
import { useId } from "./use-id"; import { useId } from "$lib/hooks/use-id";
export let disabled = false; export let disabled = false;
let api = useTabsContext("Tab"); let api = useTabsContext("Tab");
let id = `headlessui-tabs-tab-${useId()}`; let id = `headlessui-tabs-tab-${useId()}`;
let tabRef = null; let tabRef = null;
onMount(() => { onMount(() => {
$api.registerTab(tabRef); $api.registerTab(tabRef);
return () => $api.unregisterTab(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;
},
}); });
}
$: myIndex = $api.tabs.indexOf(tabRef); function handleFocus() {
$: selected = myIndex === $api.selectedIndex; tabRef?.focus();
}
function handleKeyDown(event: KeyboardEvent) { function handleSelection() {
let list = $api?.tabs.filter(Boolean) as HTMLElement[]; if (disabled) return;
if (event.key === Keys.Space || event.key === Keys.Enter) { tabRef?.focus();
event.preventDefault(); $api?.setSelectedIndex(myIndex);
event.stopPropagation(); }
$api?.setSelectedIndex(myIndex); $: propsWeControl = {
return; id,
} role: "tab",
"aria-controls": $api.panels[myIndex]?.id,
switch (event.key) { "aria-selected": selected,
case Keys.Home: tabIndex: selected ? 0 : -1,
case Keys.PageUp: disabled: disabled ? true : undefined,
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> </script>
<button <button
{...{ ...$$restProps, ...propsWeControl }} {...{ ...$$restProps, ...propsWeControl }}
bind:this={tabRef} bind:this={tabRef}
on:keydown={handleKeyDown} on:keydown={handleKeyDown}
on:click={handleSelection} on:click={handleSelection}
on:focus={$api.activation === "manual" ? handleFocus : handleSelection} on:focus={$api.activation === "manual" ? handleFocus : handleSelection}
> >
<slot /> <slot />
</button> </button>

View File

@@ -1,122 +1,120 @@
<script lang="ts" context="module"> <script lang="ts" context="module">
import { import {
createEventDispatcher, createEventDispatcher,
getContext, getContext,
onMount, onMount,
setContext, setContext,
} from "svelte"; } from "svelte";
import { writable, Writable } from "svelte/store"; import { writable, Writable } from "svelte/store";
export type StateDefinition = { export type StateDefinition = {
// State // State
selectedIndex: number | null; selectedIndex: number | null;
orientation: "vertical" | "horizontal"; orientation: "vertical" | "horizontal";
activation: "auto" | "manual"; activation: "auto" | "manual";
tabs: (HTMLElement | null)[]; tabs: (HTMLElement | null)[];
panels: (HTMLElement | null)[]; panels: (HTMLElement | null)[];
// State mutators // State mutators
setSelectedIndex(index: number): void; setSelectedIndex(index: number): void;
registerTab(tab: HTMLElement | null): void; registerTab(tab: HTMLElement | null): void;
unregisterTab(tab: HTMLElement | null): void; unregisterTab(tab: HTMLElement | null): void;
registerPanel(panel: HTMLElement | null): void; registerPanel(panel: HTMLElement | null): void;
unregisterPanel(panel: HTMLElement | null): void; unregisterPanel(panel: HTMLElement | null): void;
}; };
const TABS_CONTEXT_NAME = "TabsContext"; const TABS_CONTEXT_NAME = "TabsContext";
export function useTabsContext( export function useTabsContext(
component: string component: string
): Writable<StateDefinition | undefined> { ): Writable<StateDefinition | undefined> {
let context: Writable<StateDefinition | undefined> | undefined = let context: Writable<StateDefinition | undefined> | undefined =
getContext(TABS_CONTEXT_NAME); getContext(TABS_CONTEXT_NAME);
if (context === undefined) { if (context === undefined) {
throw new Error( throw new Error(
`<${component} /> is missing a parent <TabGroup /> component.` `<${component} /> is missing a parent <TabGroup /> component.`
); );
}
return context;
} }
return context;
}
</script> </script>
<script lang="ts"> <script lang="ts">
export let defaultIndex = 0; export let defaultIndex = 0;
export let vertical = false; export let vertical = false;
export let manual = false; export let manual = false;
let selectedIndex: StateDefinition["selectedIndex"] = null; let selectedIndex: StateDefinition["selectedIndex"] = null;
let tabs: StateDefinition["tabs"] = []; let tabs: StateDefinition["tabs"] = [];
let panels: StateDefinition["panels"] = []; let panels: StateDefinition["panels"] = [];
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
let api: Writable<StateDefinition | undefined> = writable(); let api: Writable<StateDefinition | undefined> = writable();
setContext(TABS_CONTEXT_NAME, api); setContext(TABS_CONTEXT_NAME, api);
$: api.set({ $: api.set({
selectedIndex, selectedIndex,
orientation: vertical ? "vertical" : "horizontal", orientation: vertical ? "vertical" : "horizontal",
activation: manual ? "manual" : "auto", activation: manual ? "manual" : "auto",
tabs, tabs,
panels, panels,
setSelectedIndex(index: number) { setSelectedIndex(index: number) {
if (selectedIndex === index) return; if (selectedIndex === index) return;
selectedIndex = index; selectedIndex = index;
dispatch("updateValue", index); dispatch("updateValue", index);
}, },
registerTab(tab: typeof tabs[number]) { registerTab(tab: typeof tabs[number]) {
if (!tabs.includes(tab)) tabs = [...tabs, tab]; if (!tabs.includes(tab)) tabs = [...tabs, tab];
}, },
unregisterTab(tab: typeof tabs[number]) { unregisterTab(tab: typeof tabs[number]) {
tabs = tabs.filter((t) => t !== tab); tabs = tabs.filter((t) => t !== tab);
}, },
registerPanel(panel: typeof panels[number]) { registerPanel(panel: typeof panels[number]) {
if (!panels.includes(panel)) panels = [...panels, panel]; if (!panels.includes(panel)) panels = [...panels, panel];
}, },
unregisterPanel(panel: typeof panels[number]) { unregisterPanel(panel: typeof panels[number]) {
panels = panels.filter((p) => p !== panel); panels = panels.filter((p) => p !== panel);
}, },
}); });
onMount(() => { onMount(() => {
if ($api.tabs.length <= 0) return; if ($api.tabs.length <= 0) return;
if (selectedIndex !== null) return; if (selectedIndex !== null) return;
let tabs = $api.tabs.filter(Boolean) as HTMLElement[]; let tabs = $api.tabs.filter(Boolean) as HTMLElement[];
let focusableTabs = tabs.filter((tab) => !tab.hasAttribute("disabled")); let focusableTabs = tabs.filter((tab) => !tab.hasAttribute("disabled"));
if (focusableTabs.length <= 0) return; if (focusableTabs.length <= 0) return;
// Underflow // Underflow
if (defaultIndex < 0) { if (defaultIndex < 0) {
selectedIndex = tabs.indexOf(focusableTabs[0]); selectedIndex = tabs.indexOf(focusableTabs[0]);
} }
// Overflow // Overflow
else if (defaultIndex > $api.tabs.length) { else if (defaultIndex > $api.tabs.length) {
selectedIndex = tabs.indexOf( selectedIndex = tabs.indexOf(focusableTabs[focusableTabs.length - 1]);
focusableTabs[focusableTabs.length - 1] }
);
}
// Middle // Middle
else { else {
let before = tabs.slice(0, defaultIndex); let before = tabs.slice(0, defaultIndex);
let after = tabs.slice(defaultIndex); let after = tabs.slice(defaultIndex);
let next = [...after, ...before].find((tab) => let next = [...after, ...before].find((tab) =>
focusableTabs.includes(tab) focusableTabs.includes(tab)
); );
if (!next) return; if (!next) return;
selectedIndex = tabs.indexOf(next); selectedIndex = tabs.indexOf(next);
} }
}); });
</script> </script>
<div {...$$restProps}> <div {...$$restProps}>
<slot {selectedIndex} /> <slot {selectedIndex} />
</div> </div>

View File

@@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import { useTabsContext } from "./TabGroup.svelte"; import { useTabsContext } from "./TabGroup.svelte";
let api = useTabsContext("TabList"); let api = useTabsContext("TabList");
$: propsWeControl = { $: propsWeControl = {
role: "tablist", role: "tablist",
"aria-orientation": $api.orientation, "aria-orientation": $api.orientation,
}; };
</script> </script>
<div {...{ ...$$restProps, ...propsWeControl }}> <div {...{ ...$$restProps, ...propsWeControl }}>
<slot selectedIndex={$api.selectedIndex} /> <slot selectedIndex={$api.selectedIndex} />
</div> </div>

View File

@@ -1,30 +1,30 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { useTabsContext } from "./TabGroup.svelte"; import { useTabsContext } from "./TabGroup.svelte";
import { useId } from "./use-id"; import { useId } from "$lib/hooks/use-id";
let api = useTabsContext("TabPanel"); let api = useTabsContext("TabPanel");
let id = `headlessui-tabs-panel-${useId()}`; let id = `headlessui-tabs-panel-${useId()}`;
let panelRef = null; let panelRef = null;
onMount(() => { onMount(() => {
$api.registerPanel(panelRef); $api.registerPanel(panelRef);
return () => $api.unregisterPanel(panelRef); return () => $api.unregisterPanel(panelRef);
}); });
$: myIndex = $api.panels.indexOf(panelRef); $: myIndex = $api.panels.indexOf(panelRef);
$: selected = myIndex === $api.selectedIndex; $: selected = myIndex === $api.selectedIndex;
$: propsWeControl = { $: propsWeControl = {
id, id,
role: "tabpanel", role: "tabpanel",
"aria-labelledby": $api.tabs[myIndex]?.id, "aria-labelledby": $api.tabs[myIndex]?.id,
tabIndex: selected ? 0 : -1, tabIndex: selected ? 0 : -1,
}; };
</script> </script>
<div {...{ ...$$restProps, ...propsWeControl }} bind:this={panelRef}> <div {...{ ...$$restProps, ...propsWeControl }} bind:this={panelRef}>
{#if selected} {#if selected}
<slot /> <slot />
{/if} {/if}
</div> </div>

View File

@@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import { useTabsContext } from "./TabGroup.svelte"; import { useTabsContext } from "./TabGroup.svelte";
let api = useTabsContext("TabPanels"); let api = useTabsContext("TabPanels");
</script> </script>
<div {...$$restProps}> <div {...$$restProps}>
<slot selectedIndex={$api.selectedIndex} /> <slot selectedIndex={$api.selectedIndex} />
</div> </div>

View File

@@ -1,173 +1,168 @@
<script lang="ts"> <script lang="ts">
import { import { createEventDispatcher, onMount, setContext } from "svelte";
createEventDispatcher, import { writable, Writable } from "svelte/store";
getContext, import { match } from "$lib/utils/match";
onMount, import { State } from "$lib/internal/open-closed";
setContext, import { Reason, transition } from "$lib/utils/transition";
} from "svelte";
import { writable, Writable } from "svelte/store";
import { match } from "./match";
import { State } from "./open-closed";
import { Reason, transition } from "./transition";
import { import {
hasChildren, hasChildren,
NestingContextValues, NestingContextValues,
NESTING_CONTEXT_NAME, NESTING_CONTEXT_NAME,
TreeStates, TreeStates,
useNesting, useNesting,
useParentNesting, useParentNesting,
useTransitionContext, useTransitionContext,
} from "./TransitionRoot.svelte"; } from "./TransitionRoot.svelte";
import { useId } from "./use-id"; import { useId } from "$lib/hooks/use-id";
export let unmount = true; export let unmount = true;
export let enter = ""; export let enter = "";
export let enterFrom = ""; export let enterFrom = "";
export let enterTo = ""; export let enterTo = "";
export let entered = ""; export let entered = "";
export let leave = ""; export let leave = "";
export let leaveFrom = ""; export let leaveFrom = "";
export let leaveTo = ""; export let leaveTo = "";
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
let container: HTMLElement | null = null; let container: HTMLElement | null = null;
let state = TreeStates.Visible; let state = TreeStates.Visible;
let transitionContext = useTransitionContext(); let transitionContext = useTransitionContext();
let nestingContext = useParentNesting(); let nestingContext = useParentNesting();
let initial = true; let initial = true;
let id = useId(); let id = useId();
let isTransitioning = false; let isTransitioning = false;
let nesting: Writable<NestingContextValues> = writable(); let nesting: Writable<NestingContextValues> = writable();
nesting.set( nesting.set(
useNesting(() => { useNesting(() => {
// When all children have been unmounted we can only hide ourselves if and only if we are not // 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. // transitioning ourselves. Otherwise we would unmount before the transitions are finished.
if (!isTransitioning) { if (!isTransitioning) {
state = TreeStates.Hidden; state = TreeStates.Hidden;
$nestingContext.unregister(id); $nestingContext.unregister(id);
dispatch("afterLeave"); 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");
} }
}) }
);
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; }
let _cleanup = null;
$: if (mounted) {
if (_cleanup) {
_cleanup();
} }
_cleanup = executeTransition(
setContext(NESTING_CONTEXT_NAME, nesting); $transitionContext.show,
let openClosedState: Writable<State> | undefined = writable(); $transitionContext.appear
setContext("OpenClosed", openClosedState);
$: openClosedState.set(
match(state, {
[TreeStates.Visible]: State.Open,
[TreeStates.Hidden]: State.Closed,
})
); );
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> </script>
<div bind:this={container} {...$$restProps}> <div bind:this={container} {...$$restProps}>
<slot /> <slot />
</div> </div>

View File

@@ -1,177 +1,173 @@
<script lang="ts" context="module"> <script lang="ts" context="module">
export enum TreeStates { export enum TreeStates {
Visible = "visible", Visible = "visible",
Hidden = "hidden", 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 />."
);
} }
type ID = ReturnType<typeof useId>; return context;
}
export interface NestingContextValues { export function useParentNesting(): Writable<NestingContextValues> {
children: { id: ID; state: TreeStates }[]; let context = getContext(NESTING_CONTEXT_NAME) as
register: (id: ID) => () => void; | Writable<NestingContextValues>
unregister: (id: ID, strategy?: RenderStrategy) => void; | undefined;
if (context === undefined) {
throw new Error(
"A <TransitionChild /> is used but it is missing a parent <TransitionRoot />."
);
} }
interface TransitionContextValues { return context;
show: boolean; }
appear: boolean;
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?.();
}
} }
const TRANSITION_CONTEXT_NAME = "TransitionContext"; function register(childId: ID) {
export const NESTING_CONTEXT_NAME = "NestingContext"; let child = transitionableChildren.find(({ id }) => id === childId);
export function hasTransitionContext() { if (!child) {
return getContext(TRANSITION_CONTEXT_NAME) !== undefined; transitionableChildren.push({
} id: childId,
export function useTransitionContext(): Writable<TransitionContextValues> { state: TreeStates.Visible,
let context = getContext(TRANSITION_CONTEXT_NAME) as });
| Writable<TransitionContextValues> } else if (child.state !== TreeStates.Visible) {
| undefined; child.state = TreeStates.Visible;
if (context === undefined) { }
throw new Error(
"A <TransitionChild /> is used but it is missing a parent <TransitionRoot />."
);
}
return context; return () => unregister(childId, RenderStrategy.Unmount);
} }
export function useParentNesting(): Writable<NestingContextValues> { return {
let context = getContext(NESTING_CONTEXT_NAME) as children: transitionableChildren,
| Writable<NestingContextValues> register,
| undefined; unregister,
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>
<script lang="ts"> <script lang="ts">
import { getContext, onDestroy, onMount, setContext } from "svelte"; import { getContext, onDestroy, onMount, setContext } from "svelte";
import { writable, Writable } from "svelte/store"; import { writable, Writable } from "svelte/store";
import { match } from "./match"; import { match } from "$lib/utils/match";
import { State } from "./open-closed"; import { State } from "$lib/internal/open-closed";
import { RenderStrategy } from "./Render.svelte"; import { RenderStrategy } from "$lib/utils/Render.svelte";
import TransitionChild from "./TransitionChild.svelte"; import TransitionChild from "./TransitionChild.svelte";
import type { useId } from "./use-id"; import type { useId } from "$lib/hooks/use-id";
export let show: boolean; export let show: boolean;
export let unmount = true; export let unmount = true;
export let appear = false; export let appear = false;
let openClosedState: Writable<State> | undefined = getContext("OpenClosed"); let openClosedState: Writable<State> | undefined = getContext("OpenClosed");
$: shouldShow = (() => { $: shouldShow = (() => {
if (show === null && openClosedState !== undefined) { if (show === null && openClosedState !== undefined) {
return match($openClosedState, { return match($openClosedState, {
[State.Open]: true, [State.Open]: true,
[State.Closed]: false, [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(); return show;
nestingBag.set( })();
useNesting(() => {
state = TreeStates.Hidden; $: 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 initial = true; let nestingBag: Writable<NestingContextValues> = writable();
let transitionBag: Writable<TransitionContextValues> = writable(); nestingBag.set(
$: transitionBag.set({ useNesting(() => {
show: shouldShow, state = TreeStates.Hidden;
appear: appear || !initial, })
}); );
onMount(() => { let initial = true;
initial = false; let transitionBag: Writable<TransitionContextValues> = writable();
}); $: transitionBag.set({
show: shouldShow,
appear: appear || !initial,
});
$: if (!initial) { onMount(() => {
if (shouldShow) { initial = false;
state = TreeStates.Visible; });
} else if (!hasChildren($nestingBag)) {
state = TreeStates.Hidden; $: if (!initial) {
} if (shouldShow) {
state = TreeStates.Visible;
} else if (!hasChildren($nestingBag)) {
state = TreeStates.Hidden;
} }
}
setContext(NESTING_CONTEXT_NAME, nestingBag); setContext(NESTING_CONTEXT_NAME, nestingBag);
setContext(TRANSITION_CONTEXT_NAME, transitionBag); setContext(TRANSITION_CONTEXT_NAME, transitionBag);
</script> </script>
<TransitionChild {...$$restProps} {unmount}> <TransitionChild {...$$restProps} {unmount}>
<slot /> <slot />
</TransitionChild> </TransitionChild>

View File

@@ -1,6 +1,6 @@
export function useEffect(fn, ...args) { export function useEffect(fn, ...args) {
if (fn.__cleanup) { if (fn.__cleanup) {
fn.__cleanup(); fn.__cleanup();
} }
fn.__cleanup = fn(...args); fn.__cleanup = fn(...args);
} }

View File

@@ -1,8 +1,8 @@
let id = 0 let id = 0;
function generateId() { function generateId() {
return ++id return ++id;
} }
export function useId() { export function useId() {
return generateId() return generateId();
} }

View File

@@ -1,100 +1,103 @@
let interactables = new Set<HTMLElement>() let interactables = new Set<HTMLElement>();
let originals = new Map<HTMLElement, { 'aria-hidden': string | null; inert: boolean }>() let originals = new Map<
HTMLElement,
{ "aria-hidden": string | null; inert: boolean }
>();
function inert(element: HTMLElement) { function inert(element: HTMLElement) {
element.setAttribute('aria-hidden', 'true') element.setAttribute("aria-hidden", "true");
// @ts-expect-error `inert` does not exist on HTMLElement (yet!) // @ts-expect-error `inert` does not exist on HTMLElement (yet!)
element.inert = true element.inert = true;
} }
function restore(element: HTMLElement) { function restore(element: HTMLElement) {
let original = originals.get(element) let original = originals.get(element);
if (!original) return if (!original) return;
if (original['aria-hidden'] === null) element.removeAttribute('aria-hidden') if (original["aria-hidden"] === null) element.removeAttribute("aria-hidden");
else element.setAttribute('aria-hidden', original['aria-hidden']) else element.setAttribute("aria-hidden", original["aria-hidden"]);
// @ts-expect-error `inert` does not exist on HTMLElement (yet!) // @ts-expect-error `inert` does not exist on HTMLElement (yet!)
element.inert = original.inert element.inert = original.inert;
} }
export function useInertOthers<TElement extends HTMLElement>( export function useInertOthers<TElement extends HTMLElement>(
container: TElement | null, container: TElement | null,
enabled: boolean = true enabled: boolean = true
) { ) {
if (!enabled) return if (!enabled) return;
if (!container) return if (!container) return;
let element = container let element = container;
// Mark myself as an interactable element // Mark myself as an interactable element
interactables.add(element) interactables.add(element);
// Restore elements that now contain an interactable child // Restore elements that now contain an interactable child
for (let original of originals.keys()) { for (let original of originals.keys()) {
if (original.contains(element)) { if (original.contains(element)) {
restore(original) restore(original);
originals.delete(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;
} }
// Collect direct children of the body // Keep track of the elements
document.querySelectorAll('body > *').forEach(child => { if (interactables.size === 1) {
if (!(child instanceof HTMLElement)) return // Skip non-HTMLElements 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 // Skip the interactables, and the parents of the interactables
for (let interactable of interactables) { for (let interactable of interactables) {
if (child.contains(interactable)) return if (child.contains(interactable)) return;
} }
// Keep track of the elements originals.set(child, {
if (interactables.size === 1) { "aria-hidden": child.getAttribute("aria-hidden"),
originals.set(child, { // @ts-expect-error `inert` does not exist on HTMLElement (yet!)
'aria-hidden': child.getAttribute('aria-hidden'), inert: child.inert,
// @ts-expect-error `inert` does not exist on HTMLElement (yet!) });
inert: child.inert,
})
// Mutate the element // Mutate the element
inert(child) inert(child);
} });
}) } else {
for (let element of originals.keys()) {
// Restore
restore(element);
return () => { // Cleanup
// Inert is disabled on the current element originals.delete(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)
}
}
} }
};
} }

View File

@@ -1,14 +1,14 @@
export function portal(element: HTMLElement, target: HTMLElement) { export function portal(element: HTMLElement, target: HTMLElement) {
target.append(element); target.append(element);
return { return {
update(newTarget: HTMLElement) { update(newTarget: HTMLElement) {
target = newTarget; target = newTarget;
newTarget.append(element); newTarget.append(element);
}, },
destroy() { destroy() {
if (target.childNodes.length <= 0) { if (target.childNodes.length <= 0) {
target.parentElement?.removeChild(target); target.parentElement?.removeChild(target);
} }
}, },
} };
} }

View File

@@ -1,28 +1,35 @@
type AcceptNode = ( type AcceptNode = (
node: HTMLElement node: HTMLElement
) => ) =>
| typeof NodeFilter.FILTER_ACCEPT | typeof NodeFilter.FILTER_ACCEPT
| typeof NodeFilter.FILTER_SKIP | typeof NodeFilter.FILTER_SKIP
| typeof NodeFilter.FILTER_REJECT | typeof NodeFilter.FILTER_REJECT;
export function treeWalker({ export function treeWalker({
container, container,
accept, accept,
walk, walk,
enabled, enabled,
}: { }: {
container: HTMLElement | null container: HTMLElement | null;
accept: AcceptNode accept: AcceptNode;
walk(node: HTMLElement): void walk(node: HTMLElement): void;
enabled?: boolean enabled?: boolean;
}) { }) {
let root = container let root = container;
if (!root) return if (!root) return;
if (enabled !== undefined && !enabled) return if (enabled !== undefined && !enabled) return;
let acceptNode = Object.assign((node: HTMLElement) => accept(node), { acceptNode: accept }) let acceptNode = Object.assign((node: HTMLElement) => accept(node), {
// @ts-ignore-error Typescript bug thinks this can only have 3 args acceptNode: accept,
let walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, acceptNode, false) });
// @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) while (walker.nextNode()) walk(walker.currentNode as HTMLElement);
} }

View File

@@ -1,17 +1,16 @@
<script lang="ts" context="module"> <script lang="ts" context="module">
import { getContext, setContext } from "svelte"; import { getContext, setContext } from "svelte";
import { writable, Writable } from "svelte/store"; import { writable, Writable } from "svelte/store";
const FORCE_PORTAL_ROOT_CONTEXT_NAME = const FORCE_PORTAL_ROOT_CONTEXT_NAME = "headlessui-force-portal-root-context";
"headlessui-force-portal-root-context";
export function usePortalRoot(): Writable<boolean> | undefined { export function usePortalRoot(): Writable<boolean> | undefined {
return getContext(FORCE_PORTAL_ROOT_CONTEXT_NAME); return getContext(FORCE_PORTAL_ROOT_CONTEXT_NAME);
} }
</script> </script>
<script lang="ts"> <script lang="ts">
export let force: boolean; export let force: boolean;
setContext(FORCE_PORTAL_ROOT_CONTEXT_NAME, writable(force)); setContext(FORCE_PORTAL_ROOT_CONTEXT_NAME, writable(force));
</script> </script>
<slot /> <slot />

View File

@@ -1,42 +1,39 @@
<script lang="ts" context="module"> <script lang="ts" context="module">
export enum StackMessage { export enum StackMessage {
Add, Add,
Remove, Remove,
} }
</script> </script>
<script lang="ts"> <script lang="ts">
import { getContext, setContext } from "svelte"; import { getContext, setContext } from "svelte";
import { writable, Writable } from "svelte/store"; import { writable, Writable } from "svelte/store";
type OnUpdate = ( type OnUpdate = (message: StackMessage, element: HTMLElement | null) => void;
message: StackMessage,
element: HTMLElement | null
) => void;
export let onUpdate: OnUpdate | undefined; export let onUpdate: OnUpdate | undefined;
export let element: HTMLElement | null; export let element: HTMLElement | null;
let parentUpdateStore: Writable<OnUpdate> | undefined = let parentUpdateStore: Writable<OnUpdate> | undefined =
getContext("StackContext"); getContext("StackContext");
let notifyStore: Writable<OnUpdate> = writable(() => {}); let notifyStore: Writable<OnUpdate> = writable(() => {});
setContext("StackContext", notifyStore); setContext("StackContext", notifyStore);
$: notifyStore.set((...args: Parameters<OnUpdate>) => { $: notifyStore.set((...args: Parameters<OnUpdate>) => {
// Notify our layer // Notify our layer
onUpdate?.(...args); onUpdate?.(...args);
// Notify the parent // Notify the parent
$parentUpdateStore?.(...args); $parentUpdateStore?.(...args);
}); });
$: _cleanup = (() => { $: _cleanup = (() => {
if (_cleanup) { if (_cleanup) {
_cleanup(); _cleanup();
} }
if (!element) return null; if (!element) return null;
$notifyStore(StackMessage.Add, element); $notifyStore(StackMessage.Add, element);
return () => $notifyStore(StackMessage.Remove, element); return () => $notifyStore(StackMessage.Remove, element);
})(); })();
</script> </script>
<slot /> <slot />

View File

@@ -1,7 +1,7 @@
export function contains(containers: Set<HTMLElement>, element: HTMLElement) { export function contains(containers: Set<HTMLElement>, element: HTMLElement) {
for (let container of containers) { for (let container of containers) {
if (container.contains(element)) return true if (container.contains(element)) return true;
} }
return false return false;
} }

View File

@@ -2,20 +2,19 @@ import { getContext, setContext } from "svelte";
import type { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
export enum State { export enum State {
Open, Open,
Closed, Closed,
} }
const OPEN_CLOSED_CONTEXT_NAME = "OpenClosed"; const OPEN_CLOSED_CONTEXT_NAME = "OpenClosed";
export function hasOpenClosed() { export function hasOpenClosed() {
return useOpenClosed() !== undefined return useOpenClosed() !== undefined;
} }
export function useOpenClosed(): Writable<State> | undefined { export function useOpenClosed(): Writable<State> | undefined {
return getContext(OPEN_CLOSED_CONTEXT_NAME); return getContext(OPEN_CLOSED_CONTEXT_NAME);
} }
export function useOpenClosedProvider(value: Writable<State>) { export function useOpenClosedProvider(value: Writable<State>) {
setContext(OPEN_CLOSED_CONTEXT_NAME, value); setContext(OPEN_CLOSED_CONTEXT_NAME, value);
} }

View File

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

View File

@@ -1,84 +1,89 @@
function assertNever(x: never): never { function assertNever(x: never): never {
throw new Error('Unexpected object: ' + x) throw new Error("Unexpected object: " + x);
} }
export enum Focus { export enum Focus {
/** Focus the first non-disabled item. */ /** Focus the first non-disabled item. */
First, First,
/** Focus the previous non-disabled item. */ /** Focus the previous non-disabled item. */
Previous, Previous,
/** Focus the next non-disabled item. */ /** Focus the next non-disabled item. */
Next, Next,
/** Focus the last non-disabled item. */ /** Focus the last non-disabled item. */
Last, Last,
/** Focus a specific item based on the `id` of the item. */ /** Focus a specific item based on the `id` of the item. */
Specific, Specific,
/** Focus no items at all. */ /** Focus no items at all. */
Nothing, Nothing,
} }
export function calculateActiveIndex<TItem>( export function calculateActiveIndex<TItem>(
action: { focus: Focus.Specific; id: string } | { focus: Exclude<Focus, Focus.Specific> }, action:
resolvers: { | { focus: Focus.Specific; id: string }
resolveItems(): TItem[] | { focus: Exclude<Focus, Focus.Specific> },
resolveActiveIndex(): number | null resolvers: {
resolveId(item: TItem): string resolveItems(): TItem[];
resolveDisabled(item: TItem): boolean resolveActiveIndex(): number | null;
} resolveId(item: TItem): string;
resolveDisabled(item: TItem): boolean;
}
) { ) {
let items = resolvers.resolveItems() let items = resolvers.resolveItems();
if (items.length <= 0) return null if (items.length <= 0) return null;
let currentActiveIndex = resolvers.resolveActiveIndex() let currentActiveIndex = resolvers.resolveActiveIndex();
let activeIndex = currentActiveIndex ?? -1 let activeIndex = currentActiveIndex ?? -1;
let nextActiveIndex = (() => { let nextActiveIndex = (() => {
switch (action.focus) { switch (action.focus) {
case Focus.First: case Focus.First:
return items.findIndex(item => !resolvers.resolveDisabled(item)) return items.findIndex((item) => !resolvers.resolveDisabled(item));
case Focus.Previous: { case Focus.Previous: {
let idx = items let idx = items
.slice() .slice()
.reverse() .reverse()
.findIndex((item, idx, all) => { .findIndex((item, idx, all) => {
if (activeIndex !== -1 && all.length - idx - 1 >= activeIndex) return false if (activeIndex !== -1 && all.length - idx - 1 >= activeIndex)
return !resolvers.resolveDisabled(item) return false;
}) return !resolvers.resolveDisabled(item);
if (idx === -1) return idx });
return items.length - 1 - idx if (idx === -1) return idx;
} return items.length - 1 - idx;
}
case Focus.Next: case Focus.Next:
return items.findIndex((item, idx) => { return items.findIndex((item, idx) => {
if (idx <= activeIndex) return false if (idx <= activeIndex) return false;
return !resolvers.resolveDisabled(item) return !resolvers.resolveDisabled(item);
}) });
case Focus.Last: { case Focus.Last: {
let idx = items let idx = items
.slice() .slice()
.reverse() .reverse()
.findIndex(item => !resolvers.resolveDisabled(item)) .findIndex((item) => !resolvers.resolveDisabled(item));
if (idx === -1) return idx if (idx === -1) return idx;
return items.length - 1 - idx return items.length - 1 - idx;
} }
case Focus.Specific: case Focus.Specific:
return items.findIndex(item => resolvers.resolveId(item) === action.id) return items.findIndex(
(item) => resolvers.resolveId(item) === action.id
);
case Focus.Nothing: case Focus.Nothing:
return null return null;
default: default:
assertNever(action) assertNever(action);
} }
})() })();
return nextActiveIndex === -1 ? currentActiveIndex : nextActiveIndex return nextActiveIndex === -1 ? currentActiveIndex : nextActiveIndex;
} }

View File

@@ -1,33 +1,33 @@
export function disposables() { export function disposables() {
let disposables: Function[] = [] let disposables: Function[] = [];
let api = { let api = {
requestAnimationFrame(...args: Parameters<typeof requestAnimationFrame>) { requestAnimationFrame(...args: Parameters<typeof requestAnimationFrame>) {
let raf = requestAnimationFrame(...args) let raf = requestAnimationFrame(...args);
api.add(() => cancelAnimationFrame(raf)) api.add(() => cancelAnimationFrame(raf));
}, },
nextFrame(...args: Parameters<typeof requestAnimationFrame>) { nextFrame(...args: Parameters<typeof requestAnimationFrame>) {
api.requestAnimationFrame(() => { api.requestAnimationFrame(() => {
api.requestAnimationFrame(...args) api.requestAnimationFrame(...args);
}) });
}, },
setTimeout(...args: Parameters<typeof setTimeout>) { setTimeout(...args: Parameters<typeof setTimeout>) {
let timer = setTimeout(...args) let timer = setTimeout(...args);
api.add(() => clearTimeout(timer)) api.add(() => clearTimeout(timer));
}, },
add(cb: () => void) { add(cb: () => void) {
disposables.push(cb) disposables.push(cb);
}, },
dispose() { dispose() {
for (let dispose of disposables.splice(0)) { for (let dispose of disposables.splice(0)) {
dispose() dispose();
} }
}, },
} };
return api return api;
} }

View File

@@ -1,155 +1,165 @@
import { match } from './match' import { match } from "./match";
// Credit: // Credit:
// - https://stackoverflow.com/a/30753870 // - https://stackoverflow.com/a/30753870
let focusableSelector = [ let focusableSelector = [
'[contentEditable=true]', "[contentEditable=true]",
'[tabindex]', "[tabindex]",
'a[href]', "a[href]",
'area[href]', "area[href]",
'button:not([disabled])', "button:not([disabled])",
'iframe', "iframe",
'input:not([disabled])', "input:not([disabled])",
'select:not([disabled])', "select:not([disabled])",
'textarea:not([disabled])', "textarea:not([disabled])",
] ]
.map( .map(
process.env.NODE_ENV === 'test' process.env.NODE_ENV === "test"
? // TODO: Remove this once JSDOM fixes the issue where an element that is ? // TODO: Remove this once JSDOM fixes the issue where an element that is
// "hidden" can be the document.activeElement, because this is not possible // "hidden" can be the document.activeElement, because this is not possible
// in real browsers. // in real browsers.
selector => `${selector}:not([tabindex='-1']):not([style*='display: none'])` (selector) =>
: selector => `${selector}:not([tabindex='-1'])` `${selector}:not([tabindex='-1']):not([style*='display: none'])`
) : (selector) => `${selector}:not([tabindex='-1'])`
.join(',') )
.join(",");
export enum Focus { export enum Focus {
/** Focus the first non-disabled element */ /** Focus the first non-disabled element */
First = 1 << 0, First = 1 << 0,
/** Focus the previous non-disabled element */ /** Focus the previous non-disabled element */
Previous = 1 << 1, Previous = 1 << 1,
/** Focus the next non-disabled element */ /** Focus the next non-disabled element */
Next = 1 << 2, Next = 1 << 2,
/** Focus the last non-disabled element */ /** Focus the last non-disabled element */
Last = 1 << 3, Last = 1 << 3,
/** Wrap tab around */ /** Wrap tab around */
WrapAround = 1 << 4, WrapAround = 1 << 4,
/** Prevent scrolling the focusable elements into view */ /** Prevent scrolling the focusable elements into view */
NoScroll = 1 << 5, NoScroll = 1 << 5,
} }
export enum FocusResult { export enum FocusResult {
Error, Error,
Overflow, Overflow,
Success, Success,
Underflow, Underflow,
} }
enum Direction { enum Direction {
Previous = -1, Previous = -1,
Next = 1, Next = 1,
} }
export function getFocusableElements(container: HTMLElement | null = document.body) { export function getFocusableElements(
if (container == null) return [] container: HTMLElement | null = document.body
return Array.from(container.querySelectorAll<HTMLElement>(focusableSelector)) ) {
if (container == null) return [];
return Array.from(container.querySelectorAll<HTMLElement>(focusableSelector));
} }
export enum FocusableMode { export enum FocusableMode {
/** The element itself must be focusable. */ /** The element itself must be focusable. */
Strict, Strict,
/** The element should be inside of a focusable element. */ /** The element should be inside of a focusable element. */
Loose, Loose,
} }
export function isFocusableElement( export function isFocusableElement(
element: HTMLElement, element: HTMLElement,
mode: FocusableMode = FocusableMode.Strict mode: FocusableMode = FocusableMode.Strict
) { ) {
if (element === document.body) return false if (element === document.body) return false;
return match(mode, { return match(mode, {
[FocusableMode.Strict]() { [FocusableMode.Strict]() {
return element.matches(focusableSelector) return element.matches(focusableSelector);
}, },
[FocusableMode.Loose]() { [FocusableMode.Loose]() {
let next: HTMLElement | null = element let next: HTMLElement | null = element;
while (next !== null) { while (next !== null) {
if (next.matches(focusableSelector)) return true if (next.matches(focusableSelector)) return true;
next = next.parentElement next = next.parentElement;
} }
return false return false;
}, },
}) });
} }
export function focusElement(element: HTMLElement | null) { export function focusElement(element: HTMLElement | null) {
element?.focus({ preventScroll: true }) element?.focus({ preventScroll: true });
} }
export function focusIn(container: HTMLElement | HTMLElement[], focus: Focus) { export function focusIn(container: HTMLElement | HTMLElement[], focus: Focus) {
let elements = Array.isArray(container) ? container : getFocusableElements(container) let elements = Array.isArray(container)
let active = document.activeElement as HTMLElement ? container
: getFocusableElements(container);
let active = document.activeElement as HTMLElement;
let direction = (() => { let direction = (() => {
if (focus & (Focus.First | Focus.Next)) return Direction.Next if (focus & (Focus.First | Focus.Next)) return Direction.Next;
if (focus & (Focus.Previous | Focus.Last)) return Direction.Previous if (focus & (Focus.Previous | Focus.Last)) return Direction.Previous;
throw new Error('Missing Focus.First, Focus.Previous, Focus.Next or Focus.Last') throw new Error(
})() "Missing Focus.First, Focus.Previous, Focus.Next or Focus.Last"
);
})();
let startIndex = (() => { let startIndex = (() => {
if (focus & Focus.First) return 0 if (focus & Focus.First) return 0;
if (focus & Focus.Previous) return Math.max(0, elements.indexOf(active)) - 1 if (focus & Focus.Previous)
if (focus & Focus.Next) return Math.max(0, elements.indexOf(active)) + 1 return Math.max(0, elements.indexOf(active)) - 1;
if (focus & Focus.Last) return elements.length - 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') throw new Error(
})() "Missing Focus.First, Focus.Previous, Focus.Next or Focus.Last"
);
})();
let focusOptions = focus & Focus.NoScroll ? { preventScroll: true } : {} let focusOptions = focus & Focus.NoScroll ? { preventScroll: true } : {};
let offset = 0 let offset = 0;
let total = elements.length let total = elements.length;
let next = undefined let next = undefined;
do { do {
// Guard against infinite loops // Guard against infinite loops
if (offset >= total || offset + total <= 0) return FocusResult.Error if (offset >= total || offset + total <= 0) return FocusResult.Error;
let nextIdx = startIndex + offset let nextIdx = startIndex + offset;
if (focus & Focus.WrapAround) { if (focus & Focus.WrapAround) {
nextIdx = (nextIdx + total) % total nextIdx = (nextIdx + total) % total;
} else { } else {
if (nextIdx < 0) return FocusResult.Underflow if (nextIdx < 0) return FocusResult.Underflow;
if (nextIdx >= total) return FocusResult.Overflow if (nextIdx >= total) return FocusResult.Overflow;
} }
next = elements[nextIdx] next = elements[nextIdx];
// Try the focus the next element, might not work if it is "hidden" to the user. // Try the focus the next element, might not work if it is "hidden" to the user.
next?.focus(focusOptions) next?.focus(focusOptions);
// Try the next one in line // Try the next one in line
offset += direction offset += direction;
} while (next !== document.activeElement) } while (next !== document.activeElement);
// This is a little weird, but let me try and explain: There are a few scenario's // 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 // 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 // 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()` // 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. // 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 // However in that case the default focus styles are not applied *unless* you
// also add this tabindex. // also add this tabindex.
if (!next.hasAttribute('tabindex')) next.setAttribute('tabindex', '0') if (!next.hasAttribute("tabindex")) next.setAttribute("tabindex", "0");
return FocusResult.Success return FocusResult.Success;
} }

View File

@@ -1,21 +1,21 @@
// TODO: This must already exist somewhere, right? 🤔 // TODO: This must already exist somewhere, right? 🤔
// Ref: https://www.w3.org/TR/uievents-key/#named-key-attribute-values // Ref: https://www.w3.org/TR/uievents-key/#named-key-attribute-values
export enum Keys { export enum Keys {
Space = ' ', Space = " ",
Enter = 'Enter', Enter = "Enter",
Escape = 'Escape', Escape = "Escape",
Backspace = 'Backspace', Backspace = "Backspace",
ArrowLeft = 'ArrowLeft', ArrowLeft = "ArrowLeft",
ArrowUp = 'ArrowUp', ArrowUp = "ArrowUp",
ArrowRight = 'ArrowRight', ArrowRight = "ArrowRight",
ArrowDown = 'ArrowDown', ArrowDown = "ArrowDown",
Home = 'Home', Home = "Home",
End = 'End', End = "End",
PageUp = 'PageUp', PageUp = "PageUp",
PageDown = 'PageDown', PageDown = "PageDown",
Tab = 'Tab', Tab = "Tab",
} }

View File

@@ -1,20 +1,25 @@
export function match<TValue extends string | number = string, TReturnValue = unknown>( export function match<
value: TValue, TValue extends string | number = string,
lookup: Record<TValue, TReturnValue | ((...args: any[]) => TReturnValue)>, TReturnValue = unknown
...args: any[] >(
value: TValue,
lookup: Record<TValue, TReturnValue | ((...args: any[]) => TReturnValue)>,
...args: any[]
): TReturnValue { ): TReturnValue {
if (value in lookup) { if (value in lookup) {
let returnValue = lookup[value] let returnValue = lookup[value];
return typeof returnValue === 'function' ? returnValue(...args) : returnValue return typeof returnValue === "function"
} ? returnValue(...args)
: returnValue;
}
let error = new Error( let error = new Error(
`Tried to handle "${value}" but there is no handler defined. Only defined handlers are: ${Object.keys( `Tried to handle "${value}" but there is no handler defined. Only defined handlers are: ${Object.keys(
lookup lookup
)
.map(key => `"${key}"`)
.join(', ')}.`
) )
if (Error.captureStackTrace) Error.captureStackTrace(error, match) .map((key) => `"${key}"`)
throw error .join(", ")}.`
);
if (Error.captureStackTrace) Error.captureStackTrace(error, match);
throw error;
} }

View File

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

View File

@@ -1,95 +1,97 @@
import { once } from './once' import { once } from "./once";
import { disposables } from './disposables' import { disposables } from "./disposables";
function addClasses(node: HTMLElement, ...classes: string[]) { function addClasses(node: HTMLElement, ...classes: string[]) {
node && classes.length > 0 && node.classList.add(...classes) node && classes.length > 0 && node.classList.add(...classes);
} }
function removeClasses(node: HTMLElement, ...classes: string[]) { function removeClasses(node: HTMLElement, ...classes: string[]) {
node && classes.length > 0 && node.classList.remove(...classes) node && classes.length > 0 && node.classList.remove(...classes);
} }
export enum Reason { export enum Reason {
Finished = 'finished', Finished = "finished",
Cancelled = 'cancelled', Cancelled = "cancelled",
} }
function waitForTransition(node: HTMLElement, done: (reason: Reason) => void) { function waitForTransition(node: HTMLElement, done: (reason: Reason) => void) {
let d = disposables() let d = disposables();
if (!node) return d.dispose if (!node) return d.dispose;
// Safari returns a comma separated list of values, so let's sort them and take the highest value. // Safari returns a comma separated list of values, so let's sort them and take the highest value.
let { transitionDuration, transitionDelay } = getComputedStyle(node) let { transitionDuration, transitionDelay } = getComputedStyle(node);
let [durationMs, delaysMs] = [transitionDuration, transitionDelay].map(value => { let [durationMs, delaysMs] = [transitionDuration, transitionDelay].map(
let [resolvedValue = 0] = value (value) => {
.split(',') let [resolvedValue = 0] = value
// Remove falsy we can't work with .split(",")
.filter(Boolean) // Remove falsy we can't work with
// Values are returned as `0.3s` or `75ms` .filter(Boolean)
.map(v => (v.includes('ms') ? parseFloat(v) : parseFloat(v) * 1000)) // Values are returned as `0.3s` or `75ms`
.sort((a, z) => z - a) .map((v) => (v.includes("ms") ? parseFloat(v) : parseFloat(v) * 1000))
.sort((a, z) => z - a);
return resolvedValue 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 // Waiting for the transition to end. We could use the `transitionend` event, however when no
d.add(() => done(Reason.Cancelled)) // 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);
}
return d.dispose // If we get disposed before the timeout runs we should cleanup anyway
d.add(() => done(Reason.Cancelled));
return d.dispose;
} }
export function transition( export function transition(
node: HTMLElement, node: HTMLElement,
base: string[], base: string[],
from: string[], from: string[],
to: string[], to: string[],
entered: string[], entered: string[],
done?: (reason: Reason) => void done?: (reason: Reason) => void
) { ) {
let d = disposables() let d = disposables();
let _done = done !== undefined ? once(done) : () => { } let _done = done !== undefined ? once(done) : () => {};
removeClasses(node, ...entered) removeClasses(node, ...entered);
addClasses(node, ...base, ...from) addClasses(node, ...base, ...from);
d.nextFrame(() => { d.nextFrame(() => {
removeClasses(node, ...from) removeClasses(node, ...from);
addClasses(node, ...to) addClasses(node, ...to);
d.add( d.add(
waitForTransition(node, reason => { waitForTransition(node, (reason) => {
removeClasses(node, ...to, ...base) removeClasses(node, ...to, ...base);
addClasses(node, ...entered) addClasses(node, ...entered);
return _done(reason) return _done(reason);
}) })
) );
}) });
// Once we get disposed, we should ensure that we cleanup after ourselves. In case of an unmount, // 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 // 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 // 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. // then we have some leftovers that should be cleaned.
d.add(() => removeClasses(node, ...base, ...from, ...to, ...entered)) 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. // When we get disposed early, than we should also call the done method but switch the reason.
d.add(() => _done(Reason.Cancelled)) d.add(() => _done(Reason.Cancelled));
return d.dispose return d.dispose;
} }

View File

@@ -1,28 +1,35 @@
type AcceptNode = ( type AcceptNode = (
node: HTMLElement node: HTMLElement
) => ) =>
| typeof NodeFilter.FILTER_ACCEPT | typeof NodeFilter.FILTER_ACCEPT
| typeof NodeFilter.FILTER_SKIP | typeof NodeFilter.FILTER_SKIP
| typeof NodeFilter.FILTER_REJECT | typeof NodeFilter.FILTER_REJECT;
export function treeWalker({ export function treeWalker({
container, container,
accept, accept,
walk, walk,
enabled, enabled,
}: { }: {
container: HTMLElement | null container: HTMLElement | null;
accept: AcceptNode accept: AcceptNode;
walk(node: HTMLElement): void walk(node: HTMLElement): void;
enabled?: boolean enabled?: boolean;
}) { }) {
let root = container let root = container;
if (!root) return if (!root) return;
if (enabled !== undefined && !enabled) return if (enabled !== undefined && !enabled) return;
let acceptNode = Object.assign((node: HTMLElement) => accept(node), { acceptNode: accept }) let acceptNode = Object.assign((node: HTMLElement) => accept(node), {
// @ts-ignore-error Typescript bug thinks this can only have 3 args acceptNode: accept,
let walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, acceptNode, false) });
// @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) while (walker.nextNode()) walk(walker.currentNode as HTMLElement);
} }

View File

@@ -1,2 +1,4 @@
<h1>Welcome to SvelteKit</h1> <h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://kit.svelte.dev">kit.svelte.dev</a> to read the documentation</p> <p>
Visit <a href="https://kit.svelte.dev">kit.svelte.dev</a> to read the documentation
</p>

View File

@@ -1,18 +1,18 @@
import adapter from '@sveltejs/adapter-auto'; import adapter from "@sveltejs/adapter-auto";
import preprocess from 'svelte-preprocess'; import preprocess from "svelte-preprocess";
/** @type {import('@sveltejs/kit').Config} */ /** @type {import('@sveltejs/kit').Config} */
const config = { const config = {
// Consult https://github.com/sveltejs/svelte-preprocess // Consult https://github.com/sveltejs/svelte-preprocess
// for more information about preprocessors // for more information about preprocessors
preprocess: preprocess(), preprocess: preprocess(),
kit: { kit: {
adapter: adapter(), adapter: adapter(),
// hydrate the <div id="svelte"> element in src/app.html // hydrate the <div id="svelte"> element in src/app.html
target: '#svelte' target: "#svelte",
} },
}; };
export default config; export default config;

View File

@@ -1,31 +1,31 @@
{ {
"compilerOptions": { "compilerOptions": {
"moduleResolution": "node", "moduleResolution": "node",
"module": "es2020", "module": "es2020",
"lib": ["es2020", "DOM"], "lib": ["es2020", "DOM"],
"target": "es2020", "target": "es2020",
/** /**
svelte-preprocess cannot figure out whether you have a value or a type, so tell TypeScript svelte-preprocess cannot figure out whether you have a value or a type, so tell TypeScript
to enforce using \`import type\` instead of \`import\` for Types. to enforce using \`import type\` instead of \`import\` for Types.
*/ */
"importsNotUsedAsValues": "error", "importsNotUsedAsValues": "error",
"isolatedModules": true, "isolatedModules": true,
"resolveJsonModule": true, "resolveJsonModule": true,
/** /**
To have warnings/errors of the Svelte compiler at the correct position, To have warnings/errors of the Svelte compiler at the correct position,
enable source maps by default. enable source maps by default.
*/ */
"sourceMap": true, "sourceMap": true,
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true, "skipLibCheck": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"baseUrl": ".", "baseUrl": ".",
"allowJs": true, "allowJs": true,
"checkJs": true, "checkJs": true,
"paths": { "paths": {
"$lib": ["src/lib"], "$lib": ["src/lib"],
"$lib/*": ["src/lib/*"] "$lib/*": ["src/lib/*"]
} }
}, },
"include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.ts", "src/**/*.svelte"] "include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.ts", "src/**/*.svelte"]
} }