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 @@ + + +
+ +
+ + {#each value as tag, index (tag)} + + + + {tag} + + + + + + + {/each} + + +
+ + Clear Tags + +
+
diff --git a/docs/src/lib/components/demos/tags-input-demo.svelte b/docs/src/lib/components/demos/tags-input-demo.svelte new file mode 100644 index 000000000..16e6700d9 --- /dev/null +++ b/docs/src/lib/components/demos/tags-input-demo.svelte @@ -0,0 +1,46 @@ + + +
+ +
+ + {#each value as tag, index (tag)} + + + + {tag} + + + + + + + + {/each} + + +
+ + Clear Tags + +
+
diff --git a/docs/src/routes/(docs)/sink/+page.svelte b/docs/src/routes/(docs)/sink/+page.svelte index 2befd9409..014f881a8 100644 --- a/docs/src/routes/(docs)/sink/+page.svelte +++ b/docs/src/routes/(docs)/sink/+page.svelte @@ -1,15 +1,14 @@ -
- - +
+ + + + + +
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";