diff --git a/docs/src/lib/components/demos/tags-input-demo-contenteditable.svelte b/docs/src/lib/components/demos/tags-input-demo-contenteditable.svelte
new file mode 100644
index 000000000..45f174658
--- /dev/null
+++ b/docs/src/lib/components/demos/tags-input-demo-contenteditable.svelte
@@ -0,0 +1,43 @@
+
+
+
-
-
+
+
+
+
+
+
+
diff --git a/packages/bits-ui/src/lib/bits/index.ts b/packages/bits-ui/src/lib/bits/index.ts
index 97608e625..46107219b 100644
--- a/packages/bits-ui/src/lib/bits/index.ts
+++ b/packages/bits-ui/src/lib/bits/index.ts
@@ -34,6 +34,7 @@ export { Separator } from "./separator/index.js";
export { Slider } from "./slider/index.js";
export { Switch } from "./switch/index.js";
export { Tabs } from "./tabs/index.js";
+export { TagsInput } from "./tags-input/index.js";
export { TimeField } from "./time-field/index.js";
export { TimeRangeField } from "./time-range-field/index.js";
export { Toggle } from "./toggle/index.js";
diff --git a/packages/bits-ui/src/lib/bits/tags-input/components/tags-input-announcer.svelte b/packages/bits-ui/src/lib/bits/tags-input/components/tags-input-announcer.svelte
new file mode 100644
index 000000000..00b3bb6fc
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/tags-input/components/tags-input-announcer.svelte
@@ -0,0 +1,34 @@
+
+
+
+
+ {#if announcerState.root.message}
+ {announcerState.root.message}
+ {/if}
+
+
diff --git a/packages/bits-ui/src/lib/bits/tags-input/components/tags-input-clear.svelte b/packages/bits-ui/src/lib/bits/tags-input/components/tags-input-clear.svelte
new file mode 100644
index 000000000..ed144a92d
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/tags-input/components/tags-input-clear.svelte
@@ -0,0 +1,32 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+{/if}
diff --git a/packages/bits-ui/src/lib/bits/tags-input/components/tags-input-input.svelte b/packages/bits-ui/src/lib/bits/tags-input/components/tags-input-input.svelte
new file mode 100644
index 000000000..58e0dcca9
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/tags-input/components/tags-input-input.svelte
@@ -0,0 +1,50 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+{/if}
diff --git a/packages/bits-ui/src/lib/bits/tags-input/components/tags-input-list.svelte b/packages/bits-ui/src/lib/bits/tags-input/components/tags-input-list.svelte
new file mode 100644
index 000000000..65cd22680
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/tags-input/components/tags-input-list.svelte
@@ -0,0 +1,35 @@
+
+
+
+ {#if child}
+ {@render child({ props: mergedProps })}
+ {:else}
+
+ {@render children?.()}
+
+ {/if}
+
diff --git a/packages/bits-ui/src/lib/bits/tags-input/components/tags-input-tag-content.svelte b/packages/bits-ui/src/lib/bits/tags-input/components/tags-input-tag-content.svelte
new file mode 100644
index 000000000..d0406f3cb
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/tags-input/components/tags-input-tag-content.svelte
@@ -0,0 +1,32 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+ {@render children?.()}
+
+{/if}
diff --git a/packages/bits-ui/src/lib/bits/tags-input/components/tags-input-tag-edit-description.svelte b/packages/bits-ui/src/lib/bits/tags-input/components/tags-input-tag-edit-description.svelte
new file mode 100644
index 000000000..9c16f165b
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/tags-input/components/tags-input-tag-edit-description.svelte
@@ -0,0 +1,27 @@
+
+
+
+
+ {editDescriptionState.description}
+
+
diff --git a/packages/bits-ui/src/lib/bits/tags-input/components/tags-input-tag-edit-input.svelte b/packages/bits-ui/src/lib/bits/tags-input/components/tags-input-tag-edit-input.svelte
new file mode 100644
index 000000000..b849449db
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/tags-input/components/tags-input-tag-edit-input.svelte
@@ -0,0 +1,29 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+{/if}
diff --git a/packages/bits-ui/src/lib/bits/tags-input/components/tags-input-tag-hidden-input.svelte b/packages/bits-ui/src/lib/bits/tags-input/components/tags-input-tag-hidden-input.svelte
new file mode 100644
index 000000000..f7552eda2
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/tags-input/components/tags-input-tag-hidden-input.svelte
@@ -0,0 +1,11 @@
+
+
+{#if hiddenInputState.shouldRender}
+
+{/if}
diff --git a/packages/bits-ui/src/lib/bits/tags-input/components/tags-input-tag-remove.svelte b/packages/bits-ui/src/lib/bits/tags-input/components/tags-input-tag-remove.svelte
new file mode 100644
index 000000000..1e6f80a73
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/tags-input/components/tags-input-tag-remove.svelte
@@ -0,0 +1,32 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+{/if}
diff --git a/packages/bits-ui/src/lib/bits/tags-input/components/tags-input-tag-text.svelte b/packages/bits-ui/src/lib/bits/tags-input/components/tags-input-tag-text.svelte
new file mode 100644
index 000000000..79458c58a
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/tags-input/components/tags-input-tag-text.svelte
@@ -0,0 +1,32 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+ {@render children?.()}
+
+{/if}
diff --git a/packages/bits-ui/src/lib/bits/tags-input/components/tags-input-tag.svelte b/packages/bits-ui/src/lib/bits/tags-input/components/tags-input-tag.svelte
new file mode 100644
index 000000000..ba4f50fb0
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/tags-input/components/tags-input-tag.svelte
@@ -0,0 +1,56 @@
+
+
+{#snippet EditButton()}
+ {#if tagState.opts.editMode.current !== "none"}
+
+ {/if}
+{/snippet}
+
+{#if child}
+ {@render child({ props: mergedProps })}
+ {@render EditButton()}
+{:else}
+
+ {@render children?.()}
+ {@render EditButton()}
+
+{/if}
+
+
diff --git a/packages/bits-ui/src/lib/bits/tags-input/components/tags-input.svelte b/packages/bits-ui/src/lib/bits/tags-input/components/tags-input.svelte
new file mode 100644
index 000000000..5a2f6548d
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/tags-input/components/tags-input.svelte
@@ -0,0 +1,57 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+ {@render children?.()}
+
+{/if}
+
+
+
diff --git a/packages/bits-ui/src/lib/bits/tags-input/exports.ts b/packages/bits-ui/src/lib/bits/tags-input/exports.ts
new file mode 100644
index 000000000..ed7ee04b7
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/tags-input/exports.ts
@@ -0,0 +1,21 @@
+export { default as Root } from "./components/tags-input.svelte";
+export { default as List } from "./components/tags-input-list.svelte";
+export { default as Input } from "./components/tags-input-input.svelte";
+export { default as Clear } from "./components/tags-input-clear.svelte";
+export { default as Tag } from "./components/tags-input-tag.svelte";
+export { default as TagText } from "./components/tags-input-tag-text.svelte";
+export { default as TagRemove } from "./components/tags-input-tag-remove.svelte";
+export { default as TagEditInput } from "./components/tags-input-tag-edit-input.svelte";
+export { default as TagContent } from "./components/tags-input-tag-content.svelte";
+
+export type {
+ TagsInputRootProps as RootProps,
+ TagsInputListProps as ListProps,
+ TagsInputInputProps as InputProps,
+ TagsInputClearProps as ClearProps,
+ TagsInputTagProps as TagProps,
+ TagsInputTagTextProps as TagTextProps,
+ TagsInputTagRemoveProps as TagRemoveProps,
+ TagsInputTagEditInputProps as TagEditInputProps,
+ TagsInputTagContentProps as TagContentProps,
+} from "./types.js";
diff --git a/packages/bits-ui/src/lib/bits/tags-input/index.ts b/packages/bits-ui/src/lib/bits/tags-input/index.ts
new file mode 100644
index 000000000..7a9276fb5
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/tags-input/index.ts
@@ -0,0 +1 @@
+export * as TagsInput from "./exports.js";
diff --git a/packages/bits-ui/src/lib/bits/tags-input/tags-input.svelte.ts b/packages/bits-ui/src/lib/bits/tags-input/tags-input.svelte.ts
new file mode 100644
index 000000000..23b20b677
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/tags-input/tags-input.svelte.ts
@@ -0,0 +1,811 @@
+import {
+ type ReadableBoxedValues,
+ type WritableBoxedValues,
+ afterSleep,
+ afterTick,
+ attachRef,
+ box,
+ srOnlyStyles,
+} from "svelte-toolbelt";
+import type {
+ ClipboardEventHandler,
+ FocusEventHandler,
+ KeyboardEventHandler,
+ MouseEventHandler,
+} from "svelte/elements";
+import { Context, watch } from "runed";
+import type {
+ TagsInputAnnounceTransformers,
+ TagsInputBlurBehavior,
+ TagsInputPasteBehavior,
+} from "./types.js";
+import type { RefAttachment, WithRefOpts } from "$lib/internal/types.js";
+import {
+ createBitsAttrs,
+ getAriaHidden,
+ getDataInvalid,
+ getRequired,
+} from "$lib/internal/attrs.js";
+import { kbd } from "$lib/internal/kbd.js";
+import { RovingFocusGroup } from "$lib/internal/use-roving-focus.svelte.js";
+import { isOrContainsTarget } from "$lib/internal/elements.js";
+
+const tagsInputAttrs = createBitsAttrs({
+ component: "tags-input",
+ parts: [
+ "root",
+ "list",
+ "input",
+ "clear",
+ "tag",
+ "tag-text",
+ "tag-content",
+ "tag-remove",
+ "tag-edit-input",
+ ],
+});
+
+const TagsInputRootContext = new Context
("TagsInput.Root");
+const TagsInputListContext = new Context("TagsInput.List");
+const TagsInputTagContext = new Context("TagsInput.Tag");
+
+interface TagsInputRootStateOpts
+ extends WithRefOpts,
+ WritableBoxedValues<{
+ value: string[];
+ }>,
+ ReadableBoxedValues<{
+ delimiters: string[];
+ name: string;
+ required: boolean;
+ validate: (value: string) => boolean;
+ announceTransformers: TagsInputAnnounceTransformers | undefined;
+ }> {}
+
+// prettier-ignore
+const HORIZONTAL_NAV_KEYS = [kbd.ARROW_LEFT, kbd.ARROW_RIGHT, kbd.HOME, kbd.END];
+const VERTICAL_NAV_KEYS = [kbd.ARROW_UP, kbd.ARROW_DOWN];
+const REMOVAL_KEYS = [kbd.BACKSPACE, kbd.DELETE];
+
+export class TagsInputRootState {
+ static create(opts: TagsInputRootStateOpts) {
+ return TagsInputRootContext.set(new TagsInputRootState(opts));
+ }
+
+ readonly opts: TagsInputRootStateOpts;
+ readonly attachment: RefAttachment;
+ valueSnapshot = $derived.by(() => $state.snapshot(this.opts.value.current));
+ inputNode = $state(null);
+ listRovingFocusGroup: RovingFocusGroup | null = null;
+ delimitersRegex = $derived.by(() => new RegExp(this.opts.delimiters.current.join("|"), "g"));
+ editDescriptionNode = $state(null);
+ message = $state(null);
+ messageTimeout: number | null = null;
+ /**
+ * Whether the tags input is invalid or not. It enters an invalid state when the
+ * `validate` prop returns `false` for any of the tags.
+ */
+ isInvalid = $state(false);
+ hasValue = $derived.by(() => this.opts.value.current.length > 0);
+
+ constructor(opts: TagsInputRootStateOpts) {
+ this.opts = opts;
+ this.attachment = attachRef(opts.ref);
+ }
+
+ includesValue = (value: string) => {
+ return this.opts.value.current.includes(value);
+ };
+
+ addValue = (value: string): boolean => {
+ if (value === "") return true;
+ const isValid = this.opts.validate.current?.(value) ?? true;
+ if (!isValid) {
+ this.isInvalid = true;
+ return false;
+ }
+ this.isInvalid = false;
+ this.opts.value.current.push(value);
+ this.announceAdd(value);
+ return true;
+ };
+
+ addValues = (values: string[]) => {
+ const newValues = values.filter((value) => value !== "");
+ const anyInvalid = newValues.some((value) => this.opts.validate.current?.(value) === false);
+ if (anyInvalid) {
+ this.isInvalid = true;
+ return;
+ }
+ this.isInvalid = false;
+ this.opts.value.current.push(...newValues);
+ this.announceAddMultiple(newValues);
+ };
+
+ removeValueByIndex = (index: number, value: string) => {
+ this.opts.value.current.splice(index, 1);
+ this.announceRemove(value);
+ };
+
+ updateValueByIndex = (index: number, value: string) => {
+ const curr = this.opts.value.current[index];
+ this.opts.value.current[index] = value;
+ if (curr) {
+ this.announceEdit(curr, value);
+ }
+ };
+
+ clearValue = () => {
+ this.isInvalid = false;
+ this.opts.value.current = [];
+ };
+
+ recomputeTabIndex = () => {
+ this.listRovingFocusGroup?.recomputeActiveTabNode();
+ };
+
+ #announce = (message: string) => {
+ if (this.messageTimeout) {
+ window.clearTimeout(this.messageTimeout);
+ }
+ this.message = message;
+ this.messageTimeout = window.setTimeout(() => {
+ this.message = null;
+ });
+ };
+
+ announceEdit = (from: string, to: string) => {
+ const message = this.opts.announceTransformers?.current?.edit
+ ? this.opts.announceTransformers.current.edit(from, to)
+ : `${from} has been changed to ${to}`;
+ this.#announce(message);
+ };
+
+ announceRemove = (value: string) => {
+ const message = this.opts.announceTransformers?.current?.remove
+ ? this.opts.announceTransformers.current.remove(value)
+ : `${value} has been removed`;
+ this.#announce(message);
+ };
+
+ announceAdd = (value: string) => {
+ const message = this.opts.announceTransformers?.current?.add
+ ? this.opts.announceTransformers.current.add(value)
+ : `${value} has been added`;
+ this.#announce(message);
+ };
+
+ announceAddMultiple = (values: string[]) => {
+ const message = this.opts.announceTransformers?.current?.addMultiple
+ ? this.opts.announceTransformers.current.addMultiple(values)
+ : `${values.join(", ")} has been added`;
+ this.#announce(message);
+ };
+
+ readonly props = $derived.by(
+ () =>
+ ({
+ id: this.opts.id.current,
+ [tagsInputAttrs.root]: "",
+ "data-invalid": getDataInvalid(this.isInvalid),
+ ...this.attachment,
+ }) as const
+ );
+}
+
+interface TagsInputListStateOpts extends WithRefOpts {}
+
+export class TagsInputListState {
+ static create(opts: TagsInputListStateOpts) {
+ return TagsInputListContext.set(new TagsInputListState(opts, TagsInputRootContext.get()));
+ }
+
+ readonly opts: TagsInputListStateOpts;
+ readonly root: TagsInputRootState;
+ readonly attachment: RefAttachment;
+ rovingFocusGroup: RovingFocusGroup;
+
+ constructor(opts: TagsInputListStateOpts, root: TagsInputRootState) {
+ this.opts = opts;
+ this.root = root;
+ this.attachment = attachRef(opts.ref);
+ this.rovingFocusGroup = new RovingFocusGroup({
+ rootNodeId: this.opts.id,
+ candidateSelector: `[role=gridcell]:not([aria-hidden=true])`,
+ loop: box(false),
+ orientation: box("horizontal"),
+ });
+ this.root.listRovingFocusGroup = this.rovingFocusGroup;
+ }
+
+ readonly gridWrapperProps = $derived.by(
+ () =>
+ ({
+ role: this.root.hasValue ? "grid" : undefined,
+ style: {
+ display: "contents",
+ },
+ }) as const
+ );
+
+ readonly props = $derived.by(
+ () =>
+ ({
+ id: this.opts.id.current,
+ [tagsInputAttrs.list]: "",
+ role: this.root.hasValue ? "row" : undefined,
+ "data-invalid": getDataInvalid(this.root.isInvalid),
+ ...this.attachment,
+ }) as const
+ );
+}
+
+interface TagsInputTagStateOpts
+ extends WithRefOpts,
+ ReadableBoxedValues<{
+ index: number;
+ removable: boolean;
+ editMode: "input" | "contenteditable" | "none";
+ }>,
+ WritableBoxedValues<{
+ value: string;
+ }> {}
+
+export class TagsInputTagState {
+ static create(opts: TagsInputTagStateOpts) {
+ return TagsInputTagContext.set(new TagsInputTagState(opts, TagsInputListContext.get()));
+ }
+
+ readonly opts: TagsInputTagStateOpts;
+ readonly list: TagsInputListState;
+ readonly attachment: RefAttachment;
+ textNode = $state(null);
+ removeNode = $state(null);
+ editCell = $state(null);
+ editInput = $state(null);
+ isEditable = $derived.by(() => this.opts.editMode.current !== "none");
+ isEditing = $state(false);
+ #tabIndex = $state(0);
+
+ constructor(opts: TagsInputTagStateOpts, list: TagsInputListState) {
+ this.opts = opts;
+ this.list = list;
+ this.attachment = attachRef(opts.ref);
+
+ // we want to track the value here so when we remove the actively focused
+ // tag, we ensure the other ones get the correct tab index
+ watch([() => this.list.root.valueSnapshot, () => this.opts.ref.current], ([_, ref]) => {
+ this.#tabIndex = this.list.rovingFocusGroup.getTabIndex(ref);
+ });
+ }
+
+ setValue(value: string) {
+ this.list.root.updateValueByIndex(this.opts.index.current, value);
+ }
+
+ startEditing() {
+ if (this.isEditable === false) return;
+ this.isEditing = true;
+
+ if (this.opts.editMode.current === "input") {
+ this.editInput?.focus();
+ this.editInput?.select();
+ } else if (this.opts.editMode.current === "contenteditable") {
+ this.textNode?.focus();
+ }
+ }
+
+ stopEditing(focusTag = true) {
+ this.isEditing = false;
+
+ if (focusTag) {
+ this.opts.ref.current?.focus();
+ }
+ }
+
+ remove() {
+ if (this.opts.removable.current === false) return;
+ this.list.root.removeValueByIndex(this.opts.index.current, this.opts.value.current);
+ this.list.root.recomputeTabIndex();
+ }
+
+ #onkeydown: KeyboardEventHandler = (e) => {
+ if (e.target !== this.opts.ref.current) return;
+ if (HORIZONTAL_NAV_KEYS.includes(e.key)) {
+ e.preventDefault();
+ this.list.rovingFocusGroup.handleKeydown({ node: this.opts.ref.current, event: e });
+ } else if (VERTICAL_NAV_KEYS.includes(e.key)) {
+ e.preventDefault();
+ this.list.rovingFocusGroup.handleKeydown({
+ node: this.opts.ref.current,
+ event: e,
+ orientation: "vertical",
+ invert: true,
+ });
+ } else if (REMOVAL_KEYS.includes(e.key)) {
+ e.preventDefault();
+ this.remove();
+ this.list.rovingFocusGroup.navigateBackward(
+ this.opts.ref.current,
+ this.list.root.inputNode
+ );
+ } else if (e.key === kbd.ENTER) {
+ e.preventDefault();
+ this.startEditing();
+ }
+ };
+
+ readonly props = $derived.by(
+ () =>
+ ({
+ id: this.opts.id.current,
+ role: "gridcell",
+ "data-editing": this.isEditing ? "" : undefined,
+ "data-editable": this.isEditable ? "" : undefined,
+ "data-removable": this.opts.removable.current ? "" : undefined,
+ "data-invalid": getDataInvalid(this.list.root.isInvalid),
+ tabindex: this.#tabIndex,
+ [tagsInputAttrs.tag]: "",
+ onkeydown: this.#onkeydown,
+ ...this.attachment,
+ }) as const
+ );
+}
+
+interface TagsInputTagTextStateOpts extends WithRefOpts {}
+
+export class TagsInputTagTextState {
+ static create(opts: TagsInputTagTextStateOpts) {
+ return new TagsInputTagTextState(opts, TagsInputTagContext.get());
+ }
+
+ readonly opts: TagsInputTagTextStateOpts;
+ readonly tag: TagsInputTagState;
+ readonly attachment: RefAttachment;
+
+ constructor(opts: TagsInputTagTextStateOpts, tag: TagsInputTagState) {
+ this.opts = opts;
+ this.tag = tag;
+ this.attachment = attachRef(opts.ref, (v) => {
+ this.tag.textNode = v;
+ });
+ }
+
+ #onkeydown: KeyboardEventHandler = (e) => {
+ if (this.tag.opts.editMode.current !== "contenteditable" || !this.tag.isEditing) {
+ return;
+ }
+ if (e.key === kbd.ESCAPE) {
+ this.tag.stopEditing();
+ e.currentTarget.innerText = this.tag.opts.value.current;
+ } else if (e.key === kbd.TAB) {
+ this.tag.stopEditing(false);
+ e.currentTarget.innerText = this.tag.opts.value.current;
+ } else if (e.key === kbd.ENTER) {
+ e.preventDefault();
+ const value = e.currentTarget.innerText;
+ if (value === "") {
+ this.tag.stopEditing();
+ this.tag.remove();
+ } else {
+ this.tag.setValue(value);
+ this.tag.stopEditing();
+ }
+ }
+ };
+
+ #onblur: FocusEventHandler = () => {
+ if (this.tag.opts.editMode.current !== "contenteditable") return;
+ if (this.tag.isEditing) {
+ this.tag.stopEditing(false);
+ }
+ };
+
+ #onfocus: FocusEventHandler = (_) => {
+ if (this.tag.opts.editMode.current !== "contenteditable" || !this.tag.isEditing) return;
+ afterSleep(0, () => {
+ if (!this.opts.ref.current) return;
+ const selection = window.getSelection();
+ const range = document.createRange();
+
+ range.selectNodeContents(this.opts.ref.current);
+ selection?.removeAllRanges();
+ selection?.addRange(range);
+ });
+ };
+
+ readonly props = $derived.by(
+ () =>
+ ({
+ id: this.opts.id.current,
+ [tagsInputAttrs["tag-text"]]: "",
+ tabindex: -1,
+ "data-editable": this.tag.isEditable ? "" : undefined,
+ "data-removable": this.tag.opts.removable.current ? "" : undefined,
+ contenteditable:
+ this.tag.opts.editMode.current === "contenteditable" && this.tag.isEditing
+ ? "true"
+ : undefined,
+ onkeydown: this.#onkeydown,
+ onblur: this.#onblur,
+ onfocus: this.#onfocus,
+ ...this.attachment,
+ }) as const
+ );
+}
+
+interface TagsInputTagEditInputStateOpts extends WithRefOpts {}
+
+export class TagsInputTagEditInputState {
+ static create(opts: TagsInputTagEditInputStateOpts) {
+ return new TagsInputTagEditInputState(opts, TagsInputTagContext.get());
+ }
+
+ readonly opts: TagsInputTagEditInputStateOpts;
+ readonly tag: TagsInputTagState;
+ readonly attachment: RefAttachment;
+
+ constructor(opts: TagsInputTagEditInputStateOpts, tag: TagsInputTagState) {
+ this.opts = opts;
+ this.tag = tag;
+ this.attachment = attachRef(opts.ref, (v) => {
+ if (v instanceof HTMLInputElement) this.tag.editInput = v;
+ });
+ }
+
+ #style = $derived.by(() => {
+ if (this.tag.isEditing && this.tag.opts.editMode.current === "input") return undefined;
+ return srOnlyStyles;
+ });
+
+ #onkeydown: KeyboardEventHandler = (e) => {
+ if (e.key === kbd.ESCAPE) {
+ this.tag.stopEditing();
+ e.currentTarget.value = this.tag.opts.value.current;
+ } else if (e.key === kbd.TAB) {
+ this.tag.stopEditing(false);
+ e.currentTarget.value = this.tag.opts.value.current;
+ } else if (e.key === kbd.ENTER) {
+ e.preventDefault();
+ const value = e.currentTarget.value;
+ if (value === "") {
+ this.tag.stopEditing();
+ this.tag.remove();
+ } else {
+ this.tag.setValue(value);
+ this.tag.stopEditing();
+ }
+ }
+ };
+
+ #onblur: FocusEventHandler = () => {
+ if (this.tag.isEditing) {
+ this.tag.stopEditing(false);
+ }
+ };
+
+ readonly props = $derived.by(
+ () =>
+ ({
+ id: this.opts.id.current,
+ [tagsInputAttrs["tag-edit-input"]]: "",
+ tabindex: -1,
+ "data-editing": this.tag.isEditing ? "" : undefined,
+ "data-invalid": getDataInvalid(this.tag.list.root.isInvalid),
+ "data-editable": this.tag.isEditable ? "" : undefined,
+ "data-removable": this.tag.opts.removable.current ? "" : undefined,
+ value: this.tag.opts.value.current,
+ style: this.#style,
+ onkeydown: this.#onkeydown,
+ onblur: this.#onblur,
+ "aria-describedby": this.tag.list.root.editDescriptionNode?.id,
+ "aria-hidden": getAriaHidden(!this.tag.isEditing),
+ ...this.attachment,
+ }) as const
+ );
+}
+
+interface TagsInputTagRemoveStateOpts extends WithRefOpts {}
+
+export class TagsInputTagRemoveState {
+ static create(opts: TagsInputTagRemoveStateOpts) {
+ return new TagsInputTagRemoveState(opts, TagsInputTagContext.get());
+ }
+
+ readonly opts: TagsInputTagRemoveStateOpts;
+ readonly tag: TagsInputTagState;
+ readonly attachment: RefAttachment;
+ #ariaLabelledBy = $derived.by(() => {
+ if (this.tag.textNode && this.tag.textNode.id) {
+ return `${this.opts.id.current} ${this.tag.textNode.id}`;
+ }
+ return this.opts.id.current;
+ });
+
+ constructor(opts: TagsInputTagRemoveStateOpts, tag: TagsInputTagState) {
+ this.opts = opts;
+ this.tag = tag;
+ this.attachment = attachRef(opts.ref, (v) => {
+ this.tag.removeNode = v;
+ });
+ }
+
+ #onclick: MouseEventHandler = () => {
+ this.tag.remove();
+ };
+
+ #onkeydown: KeyboardEventHandler = (e) => {
+ if (e.key === kbd.ENTER || e.key === kbd.SPACE) {
+ e.preventDefault();
+ this.tag.remove();
+ afterTick(() => {
+ const success = this.tag.list.root.listRovingFocusGroup?.focusLastCandidate();
+ if (!success) {
+ this.tag.list.root.inputNode?.focus();
+ }
+ });
+ }
+ };
+
+ readonly props = $derived.by(
+ () =>
+ ({
+ id: this.opts.id.current,
+ [tagsInputAttrs["tag-remove"]]: "",
+ role: "button",
+ "aria-label": "Remove",
+ "aria-labelledby": this.#ariaLabelledBy,
+ "data-editing": this.tag.isEditing ? "" : undefined,
+ "data-editable": this.tag.isEditable ? "" : undefined,
+ "data-removable": this.tag.opts.removable.current ? "" : undefined,
+ tabindex: -1,
+ onclick: this.#onclick,
+ onkeydown: this.#onkeydown,
+ ...this.attachment,
+ }) as const
+ );
+}
+
+interface TagsInputInputStateOpts
+ extends WithRefOpts,
+ ReadableBoxedValues<{
+ blurBehavior: TagsInputBlurBehavior;
+ pasteBehavior: TagsInputPasteBehavior;
+ }>,
+ WritableBoxedValues<{ value: string }> {}
+
+export class TagsInputInputState {
+ static create(opts: TagsInputInputStateOpts) {
+ return new TagsInputInputState(opts, TagsInputRootContext.get());
+ }
+
+ readonly opts: TagsInputInputStateOpts;
+ readonly root: TagsInputRootState;
+ readonly attachment: RefAttachment;
+ constructor(opts: TagsInputInputStateOpts, root: TagsInputRootState) {
+ this.opts = opts;
+ this.root = root;
+ this.attachment = attachRef(opts.ref, (v) => {
+ this.root.inputNode = v;
+ });
+ }
+
+ #resetValue = () => {
+ this.opts.value.current = "";
+ };
+
+ #onkeydown: KeyboardEventHandler = (e) => {
+ if (e.key === kbd.ENTER) {
+ const valid = this.root.addValue(e.currentTarget.value);
+ if (valid) this.#resetValue();
+ } else if (this.root.opts.delimiters.current.includes(e.key) && e.currentTarget.value) {
+ e.preventDefault();
+ const valid = this.root.addValue(e.currentTarget.value);
+ if (valid) this.#resetValue();
+ } else if (e.key === kbd.BACKSPACE && e.currentTarget.value === "") {
+ e.preventDefault();
+ const success = this.root.listRovingFocusGroup?.focusLastCandidate();
+ if (!success) {
+ this.root.inputNode?.focus();
+ }
+ }
+ };
+
+ #onpaste: ClipboardEventHandler = (e) => {
+ if (!e.clipboardData || this.opts.pasteBehavior.current === "none") return;
+ const rawClipboardData = e.clipboardData.getData("text/plain");
+ // we're splitting this by the delimiters
+ const pastedValues = rawClipboardData.split(this.root.delimitersRegex);
+ this.root.addValues(pastedValues);
+ e.preventDefault();
+ };
+
+ #onblur: FocusEventHandler = (e) => {
+ const blurBehavior = this.opts.blurBehavior.current;
+ const currTarget = e.currentTarget as HTMLInputElement;
+ if (blurBehavior === "add" && currTarget.value !== "") {
+ const valid = this.root.addValue(currTarget.value);
+ if (valid) this.#resetValue();
+ } else if (blurBehavior === "clear") {
+ this.#resetValue();
+ }
+ this.root.isInvalid = false;
+ };
+
+ readonly props = $derived.by(
+ () =>
+ ({
+ id: this.opts.id.current,
+ [tagsInputAttrs.input]: "",
+ "data-invalid": getDataInvalid(this.root.isInvalid),
+ onkeydown: this.#onkeydown,
+ onblur: this.#onblur,
+ onpaste: this.#onpaste,
+ ...this.attachment,
+ }) as const
+ );
+}
+
+interface TagsInputClearStateOpts extends WithRefOpts {}
+
+export class TagsInputClearState {
+ static create(opts: TagsInputClearStateOpts) {
+ return new TagsInputClearState(opts, TagsInputRootContext.get());
+ }
+
+ readonly opts: TagsInputClearStateOpts;
+ readonly root: TagsInputRootState;
+ readonly attachment: RefAttachment;
+
+ constructor(opts: TagsInputClearStateOpts, root: TagsInputRootState) {
+ this.opts = opts;
+ this.root = root;
+ this.attachment = attachRef(opts.ref);
+ }
+
+ #onclick: MouseEventHandler = () => {
+ this.root.clearValue();
+ };
+
+ readonly props = $derived.by(
+ () =>
+ ({
+ id: this.opts.id.current,
+ [tagsInputAttrs.clear]: "",
+ role: "button",
+ "aria-label": "Clear",
+ onclick: this.#onclick,
+ ...this.attachment,
+ }) as const
+ );
+}
+
+interface TagsInputTagContentStateOpts extends WithRefOpts {}
+
+export class TagsInputTagContentState {
+ static create(opts: TagsInputTagContentStateOpts) {
+ return new TagsInputTagContentState(opts, TagsInputTagContext.get());
+ }
+
+ readonly opts: TagsInputTagContentStateOpts;
+ readonly tag: TagsInputTagState;
+ readonly attachment: RefAttachment;
+
+ constructor(opts: TagsInputTagContentStateOpts, tag: TagsInputTagState) {
+ this.opts = opts;
+ this.tag = tag;
+ this.attachment = attachRef(opts.ref);
+ }
+
+ #style = $derived.by(() => {
+ if (this.tag.isEditing && this.tag.opts.editMode.current === "input") return srOnlyStyles;
+ return undefined;
+ });
+
+ #ondblclick: MouseEventHandler = (e) => {
+ if (!this.tag.isEditable) return;
+ const target = e.target as HTMLElement;
+ if (this.tag.removeNode && isOrContainsTarget(this.tag.removeNode, target)) {
+ return;
+ }
+ this.tag.startEditing();
+ };
+
+ readonly props = $derived.by(
+ () =>
+ ({
+ id: this.opts.id.current,
+ [tagsInputAttrs["tag-content"]]: "",
+ style: this.#style,
+ ondblclick: this.#ondblclick,
+ ...this.attachment,
+ }) as const
+ );
+}
+
+export class TagsInputTagHiddenInputState {
+ static create() {
+ return new TagsInputTagHiddenInputState(TagsInputTagContext.get());
+ }
+
+ readonly tag: TagsInputTagState;
+
+ shouldRender = $derived.by(
+ () => this.tag.list.root.opts.name.current !== "" && this.tag.opts.value.current !== ""
+ );
+
+ constructor(tag: TagsInputTagState) {
+ this.tag = tag;
+ }
+
+ readonly props = $derived.by(
+ () =>
+ ({
+ type: "text",
+ name: this.tag.list.root.opts.name.current,
+ value: this.tag.opts.value.current,
+ style: srOnlyStyles,
+ required: getRequired(this.tag.list.root.opts.required.current),
+ "aria-hidden": "true",
+ }) as const
+ );
+}
+
+interface TagsInputTagEditDescriptionStateOpts extends WithRefOpts {}
+
+export class TagsInputTagEditDescriptionState {
+ static create(opts: TagsInputTagEditDescriptionStateOpts) {
+ return new TagsInputTagEditDescriptionState(opts, TagsInputRootContext.get());
+ }
+
+ readonly opts: TagsInputTagEditDescriptionStateOpts;
+ readonly root: TagsInputRootState;
+ readonly attachment: RefAttachment;
+
+ constructor(opts: TagsInputTagEditDescriptionStateOpts, root: TagsInputRootState) {
+ this.opts = opts;
+ this.root = root;
+ this.attachment = attachRef(opts.ref, (v) => {
+ this.root.editDescriptionNode = v;
+ });
+ }
+
+ description = "Edit tag. Press enter to save or escape to cancel.";
+
+ readonly props = $derived.by(
+ () =>
+ ({
+ id: this.opts.id.current,
+ style: srOnlyStyles,
+ ...this.attachment,
+ }) as const
+ );
+}
+
+interface TagsInputAnnouncerStateOpts extends WithRefOpts {}
+
+export class TagsInputAnnouncerState {
+ static create(opts: TagsInputAnnouncerStateOpts) {
+ return new TagsInputAnnouncerState(opts, TagsInputRootContext.get());
+ }
+
+ readonly opts: TagsInputAnnouncerStateOpts;
+ readonly root: TagsInputRootState;
+ readonly attachment: RefAttachment;
+
+ constructor(opts: TagsInputAnnouncerStateOpts, root: TagsInputRootState) {
+ this.opts = opts;
+ this.root = root;
+ this.attachment = attachRef(opts.ref);
+ }
+
+ readonly props = $derived.by(
+ () =>
+ ({
+ id: this.opts.id.current,
+ "aria-live": "polite",
+ style: srOnlyStyles,
+ ...this.attachment,
+ }) as const
+ );
+}
diff --git a/packages/bits-ui/src/lib/bits/tags-input/types.ts b/packages/bits-ui/src/lib/bits/tags-input/types.ts
new file mode 100644
index 000000000..322c0e99a
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/tags-input/types.ts
@@ -0,0 +1,227 @@
+import type { OnChangeFn } from "$lib/internal/types.js";
+import type {
+ BitsPrimitiveButtonAttributes,
+ BitsPrimitiveDivAttributes,
+ BitsPrimitiveInputAttributes,
+ WithChild,
+ Without,
+} from "$lib/shared/index.js";
+
+export type TagsInputBlurBehavior = "clear" | "add" | "none";
+export type TagsInputPasteBehavior = "add" | "none";
+
+/**
+ * Custom announcers to use for the tags input. These will be read out when the various
+ * actions are performed to screen readers. For each that isn't provided, the following
+ * default announcers will be used. The goal is to eventually support localization on our
+ * end for these, but for now we want to allow for custom announcers to be passed in.
+ *
+ * - `add`: `(value: string) => "${value} added"`
+ * - `addMultiple`: `(value: string[]) => "${values.join(", ")} added"`
+ * - `edit`: `(fromValue: string, toValue: string) => "${fromValue} changed to ${toValue}"`
+ * - `remove`: `(value: string) => "${value} removed"`
+ */
+export type TagsInputAnnounceTransformers = {
+ /**
+ * A function that returns the announcement to make when a tag is edited.
+ * @param fromValue - the value that was changed from
+ * @param toValue - the value that was changed to
+ * @returns - the announcement to make
+ */
+ edit?: (fromValue: string, toValue: string) => string;
+ /**
+ * A function that returns the announcement to make when a tag is added.
+ * @param value - the value that was added
+ * @returns the announcement to make
+ */
+ add?: (addedValue: string) => string;
+ /**
+ * A function that returns the announcement to make when multiple tags are
+ * added at once.
+ * @param value - the value that was added
+ * @returns the announcement to make
+ */
+ addMultiple?: (addedValues: string[]) => string;
+ /**
+ * A function that returns the announcement to make when a tag is removed.
+ * @param value - the value that was removed
+ * @returns the announcement to make
+ */
+ remove?: (removedValue: string) => string;
+};
+
+export type TagsInputRootPropsWithoutHTML = WithChild<{
+ /**
+ * The value of the tags input.
+ *
+ * @bindable
+ */
+ value?: string[];
+
+ /**
+ * A callback function called when the value changes.
+ */
+ onValueChange?: OnChangeFn;
+
+ /**
+ * The delimiter used to separate tags.
+ *
+ * @default [","]
+ */
+ delimiters?: string[];
+
+ /**
+ * A validation function to determine if the individual tag being added/edited is valid.
+ *
+ * Return true to allow the tag to be added/edited, or false to prevent it from being
+ * added/confirm edited.
+ */
+ validate?: (value: string) => boolean;
+
+ /**
+ * If provided, a hidden input element will be rendered for each tag to submit the values with
+ * a form.
+ *
+ * @defaultValue undefined
+ */
+ name?: string;
+
+ /**
+ * Whether or not the hidden input element should be marked as required or not.
+ *
+ * @defaultValue false
+ */
+ required?: boolean;
+
+ /**
+ * Custom announcers to use for the tags input. These will be read out when the various
+ * actions are performed to screen readers. For each that isn't provided, the following
+ * default announcers will be used. The goal is to eventually support localization on our
+ * end for these, but for now we want to allow for custom announcers to be passed in.
+ *
+ * - `add`: `(value: string) => "${value} added"`
+ * - `addMultiple`: `(value: string[]) => "${values.join(", ")} added"`
+ * - `edit`: `(fromValue: string, toValue: string) => "${fromValue} changed to ${toValue}"`
+ * - `remove`: `(value: string) => "${value} removed"`
+ */
+ announceTransformers?: TagsInputAnnounceTransformers;
+}>;
+
+export type TagsInputRootProps = TagsInputRootPropsWithoutHTML &
+ Without;
+
+export type TagsInputListPropsWithoutHTML = WithChild;
+
+export type TagsInputListProps = TagsInputListPropsWithoutHTML &
+ Without;
+
+export type TagsInputInputPropsWithoutHTML = WithChild<{
+ /**
+ * The value of the input.
+ *
+ * @bindable
+ */
+ value?: string;
+
+ /**
+ * A callback function called when the value changes.
+ *
+ *
+ */
+ onValueChange?: OnChangeFn;
+
+ /**
+ * Whether or not the value is controlled or not. If `true`, the component will not update
+ * the value internally, instead it will call `onValueChange` when it would have otherwise,
+ * and it is up to you to update the `value` prop that is passed to the component.
+ */
+ controlledValue?: boolean;
+
+ /**
+ * How to handle when the input is blurred with text in it.
+ *
+ * - `'clear'`: Clear the input and remove all tags.
+ * - `'add'`: Add the text as a new tag. If it contains valid delimiters, it will be split into multiple tags.
+ * - `'none'`: Don't do anything special when the input is blurred. Just leave the input as is.
+ *
+ * @defaultValue "none"
+ */
+ blurBehavior?: TagsInputBlurBehavior;
+
+ /**
+ * How to handle when text is pasted into the input.
+ * - `'add'`: Add the pasted text as a new tag. If it contains valid delimiters, it will be split into multiple tags.
+ * - `'none'`: Do not add the pasted text as a new tag, just insert it into the input.
+ *
+ * @defaultValue "add"
+ */
+ pasteBehavior?: TagsInputPasteBehavior;
+}>;
+
+export type TagsInputInputProps = TagsInputInputPropsWithoutHTML &
+ Without;
+
+export type TagsInputClearPropsWithoutHTML = WithChild;
+
+export type TagsInputClearProps = TagsInputClearPropsWithoutHTML &
+ Without;
+
+export type TagsInputTagPropsWithoutHTML = WithChild<{
+ /**
+ * The value of this specific tag. This should be unique for the tag.
+ */
+ value: string;
+
+ /**
+ * The index of this specific tag in the value array.
+ */
+ index: number;
+
+ /**
+ * The type of edit mode to use for the tag. If set to `'input'`, the tag will be editable
+ * using the `TagsInput.TagEdit` component. If set to `'contenteditable'`, the tag will be
+ * editable using the `contenteditable` attribute on the `TagsInput.TagText` component. If
+ * set to `'none'`, the tag will not be editable.
+ *
+ * @defaultValue true
+ */
+ editMode?: "input" | "contenteditable" | "none";
+
+ /**
+ * Whether the tag can be removed or not.
+ *
+ * @defaultValue true
+ */
+ removable?: boolean;
+}>;
+
+export type TagsInputTagProps = TagsInputTagPropsWithoutHTML &
+ Without;
+
+export type TagsInputTagTextPropsWithoutHTML = WithChild;
+
+export type TagsInputTagTextProps = TagsInputTagTextPropsWithoutHTML &
+ Without;
+
+export type TagsInputTagRemovePropsWithoutHTML = WithChild;
+
+export type TagsInputTagRemoveProps = TagsInputTagRemovePropsWithoutHTML &
+ Without;
+
+export type TagsInputTagEditPropsWithoutHTML = WithChild;
+
+export type TagsInputTagEditProps = TagsInputTagEditPropsWithoutHTML &
+ Without;
+
+export type TagsInputTagEditInputPropsWithoutHTML = WithChild;
+
+export type TagsInputTagEditInputProps = Omit<
+ TagsInputTagEditInputPropsWithoutHTML &
+ Without,
+ "children"
+>;
+
+export type TagsInputTagContentPropsWithoutHTML = WithChild;
+
+export type TagsInputTagContentProps = TagsInputTagContentPropsWithoutHTML &
+ Without;
diff --git a/packages/bits-ui/src/lib/index.ts b/packages/bits-ui/src/lib/index.ts
index 81b3ee23c..8a9cd4e6b 100644
--- a/packages/bits-ui/src/lib/index.ts
+++ b/packages/bits-ui/src/lib/index.ts
@@ -35,6 +35,7 @@ export {
Slider,
Switch,
Tabs,
+ TagsInput,
TimeField,
TimeRangeField,
Toggle,
diff --git a/packages/bits-ui/src/lib/internal/roving-focus-group.ts b/packages/bits-ui/src/lib/internal/roving-focus-group.ts
index 8c23cb8f2..73d46a6fc 100644
--- a/packages/bits-ui/src/lib/internal/roving-focus-group.ts
+++ b/packages/bits-ui/src/lib/internal/roving-focus-group.ts
@@ -51,7 +51,7 @@ export class RovingFocusGroup {
this.#opts = opts;
}
- getCandidateNodes() {
+ #getCandidateNodes() {
if (!BROWSER || !this.#opts.rootNode.current) return [];
if (this.#opts.candidateSelector) {
@@ -74,16 +74,40 @@ export class RovingFocusGroup {
}
focusFirstCandidate() {
- const items = this.getCandidateNodes();
+ const items = this.#getCandidateNodes();
if (!items.length) return;
items[0]?.focus();
}
+ #handleFocus(node: HTMLElement) {
+ if (!node) return;
+ this.#currentTabStopId.current = node.id;
+ node?.focus();
+ this.#opts.onCandidateFocus?.(node);
+ }
+
+ navigateBackward(node: HTMLElement | null | undefined, fallback?: HTMLElement | null) {
+ const rootNode = this.#opts.rootNode.current;
+ if (!rootNode || !node) return;
+ const items = this.#getCandidateNodes();
+ if (!items.length) return;
+ const currIndex = items.indexOf(node);
+ const prevIndex = currIndex - 1;
+ const prevItem = items[prevIndex];
+ if (!prevItem) {
+ if (fallback) {
+ fallback?.focus();
+ }
+ return;
+ }
+ this.#handleFocus(prevItem);
+ }
+
handleKeydown(node: HTMLElement | null | undefined, e: KeyboardEvent, both: boolean = false) {
const rootNode = this.#opts.rootNode.current;
if (!rootNode || !node) return;
- const items = this.getCandidateNodes();
+ const items = this.#getCandidateNodes();
if (!items.length) return;
const currentIndex = items.indexOf(node);
@@ -124,7 +148,7 @@ export class RovingFocusGroup {
}
getTabIndex(node: HTMLElement | null | undefined) {
- const items = this.getCandidateNodes();
+ const items = this.#getCandidateNodes();
const anyActive = this.#currentTabStopId.current !== null;
if (node && !anyActive && items[0] === node) {
diff --git a/packages/bits-ui/src/lib/internal/use-roving-focus.svelte.ts b/packages/bits-ui/src/lib/internal/use-roving-focus.svelte.ts
new file mode 100644
index 000000000..4aa758d98
--- /dev/null
+++ b/packages/bits-ui/src/lib/internal/use-roving-focus.svelte.ts
@@ -0,0 +1,317 @@
+import { type ReadableBox, type WritableBox, box } from "svelte-toolbelt";
+import { getElemDirection } from "./locale.js";
+import { getDirectionalKeys } from "./get-directional-keys.js";
+import { kbd } from "./kbd.js";
+import { isBrowser } from "./is.js";
+import type { Orientation } from "$lib/shared/index.js";
+
+type UseRovingFocusProps = {
+ /**
+ * The selector used to find the focusable candidates.
+ */
+ candidateAttr: string;
+
+ /**
+ * Custom candidate selector
+ */
+ candidateSelector?: string;
+
+ /**
+ * The id of the root node
+ */
+ rootNodeId: ReadableBox;
+
+ /**
+ * Whether to loop through the candidates when reaching the end.
+ */
+ loop: ReadableBox;
+
+ /**
+ * The orientation of the roving focus group. Used
+ * to determine how keyboard navigation should work.
+ */
+ orientation: ReadableBox;
+
+ /**
+ * A callback function called when a candidate is focused.
+ */
+ onCandidateFocus?: (node: HTMLElement) => void;
+};
+
+export type UseRovingFocusReturn = ReturnType;
+
+export function useRovingFocus(props: UseRovingFocusProps) {
+ const currentTabStopId = box(null);
+
+ function getCandidateNodes() {
+ if (!isBrowser) return [];
+ const node = document.getElementById(props.rootNodeId.current);
+ if (!node) return [];
+
+ if (props.candidateSelector) {
+ const candidates = Array.from(
+ node.querySelectorAll(props.candidateSelector)
+ );
+ return candidates;
+ } else {
+ const candidates = Array.from(
+ node.querySelectorAll(`[${props.candidateAttr}]:not([data-disabled])`)
+ );
+ return candidates;
+ }
+ }
+
+ function focusFirstCandidate() {
+ const items = getCandidateNodes();
+ if (!items.length) return;
+ items[0]?.focus();
+ }
+
+ function handleKeydown(
+ node: HTMLElement | null | undefined,
+ e: KeyboardEvent,
+ both: boolean = false
+ ) {
+ const rootNode = document.getElementById(props.rootNodeId.current);
+ if (!rootNode || !node) return;
+
+ const items = getCandidateNodes();
+ if (!items.length) return;
+
+ const currentIndex = items.indexOf(node);
+ const dir = getElemDirection(rootNode);
+ const { nextKey, prevKey } = getDirectionalKeys(dir, props.orientation.current);
+ const loop = props.loop.current;
+
+ const keyToIndex = {
+ [nextKey]: currentIndex + 1,
+ [prevKey]: currentIndex - 1,
+ [kbd.HOME]: 0,
+ [kbd.END]: items.length - 1,
+ };
+
+ if (both) {
+ const altNextKey = nextKey === kbd.ARROW_DOWN ? kbd.ARROW_RIGHT : kbd.ARROW_DOWN;
+ const altPrevKey = prevKey === kbd.ARROW_UP ? kbd.ARROW_LEFT : kbd.ARROW_UP;
+ keyToIndex[altNextKey] = currentIndex + 1;
+ keyToIndex[altPrevKey] = currentIndex - 1;
+ }
+
+ let itemIndex = keyToIndex[e.key];
+ if (itemIndex === undefined) return;
+ e.preventDefault();
+
+ if (itemIndex < 0 && loop) {
+ itemIndex = items.length - 1;
+ } else if (itemIndex === items.length && loop) {
+ itemIndex = 0;
+ }
+
+ const itemToFocus = items[itemIndex];
+ if (!itemToFocus) return;
+ itemToFocus.focus();
+ currentTabStopId.current = itemToFocus.id;
+ props.onCandidateFocus?.(itemToFocus);
+ return itemToFocus;
+ }
+
+ function getTabIndex(node: HTMLElement | null | undefined) {
+ const items = getCandidateNodes();
+ const anyActive = currentTabStopId.current !== null;
+
+ if (node && !anyActive && items[0] === node) {
+ currentTabStopId.current = node.id;
+ return 0;
+ } else if (node?.id === currentTabStopId.current) {
+ return 0;
+ }
+
+ return -1;
+ }
+
+ return {
+ setCurrentTabStopId(id: string) {
+ currentTabStopId.current = id;
+ },
+ getTabIndex,
+ handleKeydown,
+ focusFirstCandidate,
+ currentTabStopId,
+ };
+}
+
+type RovingFocusGroupOptions = {
+ /**
+ * Custom candidate selector
+ */
+ candidateSelector: string;
+
+ /**
+ * The id of the root node
+ */
+ rootNodeId: ReadableBox;
+
+ /**
+ * Whether to loop through the candidates when reaching the end.
+ */
+ loop: ReadableBox;
+
+ /**
+ * The orientation of the roving focus group. Used
+ * to determine how keyboard navigation should work.
+ */
+ orientation: ReadableBox;
+
+ /**
+ * A callback function called when a candidate is focused.
+ */
+ onCandidateFocus?: (node: HTMLElement) => void;
+
+ /**
+ * The current tab stop id.
+ */
+ currentTabStopId?: WritableBox;
+};
+
+export class RovingFocusGroup {
+ currentTabStopId = box(null);
+ #recomputeDep = $state(false);
+
+ constructor(readonly opts: RovingFocusGroupOptions) {
+ this.currentTabStopId = opts.currentTabStopId
+ ? opts.currentTabStopId
+ : box(null);
+ }
+
+ #anyActive = $derived.by(() => {
+ this.#recomputeDep;
+ if (!this.currentTabStopId.current) return false;
+ if (!isBrowser) return false;
+ return Boolean(document.getElementById(this.currentTabStopId.current));
+ });
+
+ #handleFocus = (node: HTMLElement) => {
+ if (!node) return;
+ this.currentTabStopId.current = node.id;
+ node?.focus();
+ this.opts.onCandidateFocus?.(node);
+ };
+
+ #getCandidateNodes = () => {
+ if (!isBrowser) return [];
+ const node = document.getElementById(this.opts.rootNodeId.current);
+ if (!node) return [];
+ return Array.from(node.querySelectorAll(this.opts.candidateSelector));
+ };
+
+ navigateBackward = (node: HTMLElement | null | undefined, fallback?: HTMLElement | null) => {
+ const rootNode = document.getElementById(this.opts.rootNodeId.current);
+ if (!rootNode || !node) return;
+ const items = this.#getCandidateNodes();
+ if (!items.length) return;
+ const currentIndex = items.indexOf(node);
+ const prevIndex = currentIndex - 1;
+ const prevItem = items[prevIndex];
+ if (!prevItem) {
+ if (fallback) {
+ fallback?.focus();
+ }
+ return;
+ }
+ this.#handleFocus(prevItem);
+ };
+
+ handleKeydown = ({
+ node,
+ event: e,
+ orientation = this.opts.orientation.current,
+ invert = false,
+ both = false,
+ }: {
+ node: HTMLElement | null | undefined;
+ event: KeyboardEvent;
+ orientation?: Orientation;
+ invert?: boolean;
+ both?: boolean;
+ }) => {
+ const rootNode = document.getElementById(this.opts.rootNodeId.current);
+ if (!rootNode || !node) return;
+
+ const items = this.#getCandidateNodes();
+ if (!items.length) return;
+
+ const currentIndex = items.indexOf(node);
+ const dir = getElemDirection(rootNode);
+ const { nextKey, prevKey } = getDirectionalKeys(dir, orientation);
+
+ const trueNextKey = invert ? prevKey : nextKey;
+ const truePrevKey = invert ? nextKey : prevKey;
+
+ const loop = this.opts.loop.current;
+
+ const keyToIndex = {
+ [trueNextKey]: currentIndex + 1,
+ [truePrevKey]: currentIndex - 1,
+ [kbd.HOME]: 0,
+ [kbd.END]: items.length - 1,
+ };
+
+ if (both) {
+ const altNextKey = nextKey === kbd.ARROW_DOWN ? kbd.ARROW_RIGHT : kbd.ARROW_DOWN;
+ const altPrevKey = prevKey === kbd.ARROW_UP ? kbd.ARROW_LEFT : kbd.ARROW_UP;
+ keyToIndex[altNextKey] = currentIndex + 1;
+ keyToIndex[altPrevKey] = currentIndex - 1;
+ }
+
+ let itemIndex = keyToIndex[e.key];
+ if (itemIndex === undefined) return;
+ e.preventDefault();
+
+ if (itemIndex < 0 && loop) {
+ itemIndex = items.length - 1;
+ } else if (itemIndex === items.length && loop) {
+ itemIndex = 0;
+ }
+
+ const itemToFocus = items[itemIndex];
+ if (!itemToFocus) return;
+ this.#handleFocus(itemToFocus);
+ return itemToFocus;
+ };
+
+ getTabIndex = (node: HTMLElement | null | undefined) => {
+ const items = this.#getCandidateNodes();
+
+ if (node && !this.#anyActive && items[0] === node) {
+ this.currentTabStopId.current = node.id;
+ return 0;
+ } else if (node?.id === this.currentTabStopId.current) {
+ return 0;
+ }
+
+ return -1;
+ };
+
+ focusFirstCandidate = () => {
+ const items = this.#getCandidateNodes();
+ if (!items.length) return;
+ items[0]?.focus();
+ };
+
+ focusLastCandidate = () => {
+ const items = this.#getCandidateNodes();
+ if (!items.length) return false;
+ const lastItem = items[items.length - 1];
+ if (!lastItem) return false;
+ this.#handleFocus(lastItem);
+ return true;
+ };
+
+ recomputeActiveTabNode = () => {
+ this.#recomputeDep = !this.#recomputeDep;
+ };
+
+ setCurrentTabStopId = (id: string) => {
+ this.currentTabStopId.current = id;
+ };
+}
diff --git a/packages/bits-ui/src/lib/types.ts b/packages/bits-ui/src/lib/types.ts
index 3a403961b..b04b1b243 100644
--- a/packages/bits-ui/src/lib/types.ts
+++ b/packages/bits-ui/src/lib/types.ts
@@ -34,6 +34,7 @@ export type * from "$lib/bits/separator/types.js";
export type * from "$lib/bits/slider/types.js";
export type * from "$lib/bits/switch/types.js";
export type * from "$lib/bits/tabs/types.js";
+export type * from "$lib/bits/tags-input/types.js";
export type * from "$lib/bits/time-field/types.js";
export type * from "$lib/bits/time-range-field/types.js";
export type * from "$lib/bits/toggle/types.js";