From 3a8e5bac312655f9c58dc359633a963390c4afed Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Mon, 10 Mar 2025 21:30:14 -0400 Subject: [PATCH 1/2] init tags input 1.0 --- .../tags-input-demo-contenteditable.svelte | 44 + .../components/demos/tags-input-demo.svelte | 47 ++ docs/src/routes/(main)/sink/+page.svelte | 8 +- packages/bits-ui/src/lib/bits/index.ts | 1 + .../components/tags-input-announcer.svelte | 34 + .../components/tags-input-clear.svelte | 32 + .../components/tags-input-input.svelte | 50 ++ .../components/tags-input-list.svelte | 35 + .../components/tags-input-tag-content.svelte | 32 + .../tags-input-tag-edit-description.svelte | 27 + .../tags-input-tag-edit-input.svelte | 29 + .../tags-input-tag-hidden-input.svelte | 11 + .../components/tags-input-tag-remove.svelte | 32 + .../components/tags-input-tag-text.svelte | 32 + .../components/tags-input-tag.svelte | 61 ++ .../tags-input/components/tags-input.svelte | 60 ++ .../src/lib/bits/tags-input/exports.ts | 21 + .../bits-ui/src/lib/bits/tags-input/index.ts | 1 + .../lib/bits/tags-input/tags-input.svelte.ts | 758 ++++++++++++++++++ .../bits-ui/src/lib/bits/tags-input/types.ts | 183 +++++ packages/bits-ui/src/lib/index.ts | 1 + .../lib/internal/use-roving-focus.svelte.ts | 230 +++++- packages/bits-ui/src/lib/types.ts | 1 + 23 files changed, 1716 insertions(+), 14 deletions(-) create mode 100644 docs/src/lib/components/demos/tags-input-demo-contenteditable.svelte create mode 100644 docs/src/lib/components/demos/tags-input-demo.svelte create mode 100644 packages/bits-ui/src/lib/bits/tags-input/components/tags-input-announcer.svelte create mode 100644 packages/bits-ui/src/lib/bits/tags-input/components/tags-input-clear.svelte create mode 100644 packages/bits-ui/src/lib/bits/tags-input/components/tags-input-input.svelte create mode 100644 packages/bits-ui/src/lib/bits/tags-input/components/tags-input-list.svelte create mode 100644 packages/bits-ui/src/lib/bits/tags-input/components/tags-input-tag-content.svelte create mode 100644 packages/bits-ui/src/lib/bits/tags-input/components/tags-input-tag-edit-description.svelte create mode 100644 packages/bits-ui/src/lib/bits/tags-input/components/tags-input-tag-edit-input.svelte create mode 100644 packages/bits-ui/src/lib/bits/tags-input/components/tags-input-tag-hidden-input.svelte create mode 100644 packages/bits-ui/src/lib/bits/tags-input/components/tags-input-tag-remove.svelte create mode 100644 packages/bits-ui/src/lib/bits/tags-input/components/tags-input-tag-text.svelte create mode 100644 packages/bits-ui/src/lib/bits/tags-input/components/tags-input-tag.svelte create mode 100644 packages/bits-ui/src/lib/bits/tags-input/components/tags-input.svelte create mode 100644 packages/bits-ui/src/lib/bits/tags-input/exports.ts create mode 100644 packages/bits-ui/src/lib/bits/tags-input/index.ts create mode 100644 packages/bits-ui/src/lib/bits/tags-input/tags-input.svelte.ts create mode 100644 packages/bits-ui/src/lib/bits/tags-input/types.ts 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..efe5b0a06 --- /dev/null +++ b/docs/src/lib/components/demos/tags-input-demo-contenteditable.svelte @@ -0,0 +1,44 @@ + + +
+ +
+ + {#each value as tag, index} + + + + {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..59f8984fa --- /dev/null +++ b/docs/src/lib/components/demos/tags-input-demo.svelte @@ -0,0 +1,47 @@ + + +
+ +
+ + {#each value as tag, index} + + + + {tag} + + + + + + + + {/each} + + +
+ + Clear Tags + +
+
diff --git a/docs/src/routes/(main)/sink/+page.svelte b/docs/src/routes/(main)/sink/+page.svelte index 170be074a..014f881a8 100644 --- a/docs/src/routes/(main)/sink/+page.svelte +++ b/docs/src/routes/(main)/sink/+page.svelte @@ -1,10 +1,14 @@
- + + + +
diff --git a/packages/bits-ui/src/lib/bits/index.ts b/packages/bits-ui/src/lib/bits/index.ts index a69cb9215..168dc9689 100644 --- a/packages/bits-ui/src/lib/bits/index.ts +++ b/packages/bits-ui/src/lib/bits/index.ts @@ -32,6 +32,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 { Toggle } from "./toggle/index.js"; export { ToggleGroup } from "./toggle-group/index.js"; export { Toolbar } from "./toolbar/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..abbe544bb --- /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..cab5e903c --- /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..1b59d25d3 --- /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..5b7d1e9fb --- /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..2618d8cdc --- /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..925777231 --- /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..7e56a2d64 --- /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..e2f5cd534 --- /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..06224c8e2 --- /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..f0506aa14 --- /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..60ba65ef2 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/tags-input/components/tags-input-tag.svelte @@ -0,0 +1,61 @@ + + +{#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..4150dce99 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/tags-input/components/tags-input.svelte @@ -0,0 +1,60 @@ + + +{#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..3fbf4dcbd --- /dev/null +++ b/packages/bits-ui/src/lib/bits/tags-input/tags-input.svelte.ts @@ -0,0 +1,758 @@ +import { + type ReadableBoxedValues, + type WritableBoxedValues, + afterSleep, + afterTick, + box, + srOnlyStyles, + useRefById, +} from "svelte-toolbelt"; +import type { + ClipboardEventHandler, + FocusEventHandler, + KeyboardEventHandler, + MouseEventHandler, +} from "svelte/elements"; +import { Context } from "runed"; +import type { TagsInputBlurBehavior, TagsInputPasteBehavior } from "./types.js"; +import type { WithRefProps } from "$lib/internal/types.js"; +import { 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 ROOT_ATTR = "data-tags-input-root"; +const LIST_ATTR = "data-tags-input-list"; +const INPUT_ATTR = "data-tags-input-input"; +const CLEAR_ATTR = "data-tags-input-clear"; +const TAG_ATTR = "data-tags-input-tag"; +const TAG_TEXT_ATTR = "data-tags-input-tag-text"; +const TAG_CONTENT_ATTR = "data-tags-input-tag-content"; +const TAG_REMOVE_ATTR = "data-tags-input-tag-remove"; +const TAG_EDIT_INPUT_ATTR = "data-tags-input-tag-edit-input"; + +type TagsInputRootStateProps = WithRefProps & + WritableBoxedValues<{ + value: string[]; + }> & + ReadableBoxedValues<{ + delimiters: string[]; + name: string; + required: boolean; + validate: (value: string) => boolean; + }>; + +// 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]; + +class TagsInputRootState { + 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(readonly opts: TagsInputRootStateProps) { + useRefById(opts); + } + + 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) => { + this.#announce(`${from} has been changed to ${to}`); + }; + + announceRemove = (value: string) => { + this.#announce(`${value} has been removed`); + }; + + announceAdd = (value: string) => { + this.#announce(`${value} has been added`); + }; + + announceAddMultiple = (values: string[]) => { + this.#announce(`${values.join(", ")} has been added`); + }; + + props = $derived.by( + () => + ({ + id: this.opts.id.current, + [ROOT_ATTR]: "", + "data-invalid": getDataInvalid(this.isInvalid), + }) as const + ); +} + +type TagsInputListStateProps = WithRefProps; + +class TagsInputListState { + rovingFocusGroup: RovingFocusGroup; + + constructor( + readonly opts: TagsInputListStateProps, + readonly root: TagsInputRootState + ) { + useRefById(opts); + 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; + } + + gridWrapperProps = $derived.by( + () => + ({ + role: this.root.hasValue ? "grid" : undefined, + style: { + display: "contents", + }, + }) as const + ); + + props = $derived.by( + () => + ({ + id: this.opts.id.current, + [LIST_ATTR]: "", + role: this.root.hasValue ? "row" : undefined, + "data-invalid": getDataInvalid(this.root.isInvalid), + }) as const + ); +} + +type TagsInputTagStateProps = WithRefProps & + ReadableBoxedValues<{ + index: number; + removable: boolean; + editMode: "input" | "contenteditable" | "none"; + }> & + WritableBoxedValues<{ + value: string; + }>; + +class TagsInputTagState { + 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( + readonly opts: TagsInputTagStateProps, + readonly list: TagsInputListState + ) { + useRefById({ + ...opts, + deps: () => this.opts.index.current, + }); + + $effect(() => { + // 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 + this.list.root.valueSnapshot; + this.opts.ref.current; + this.#tabIndex = this.list.rovingFocusGroup.getTabIndex(this.opts.ref.current); + }); + } + + 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(); + } + }; + + 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, + [TAG_ATTR]: "", + onkeydown: this.#onkeydown, + }) as const + ); +} + +type TagsInputTagTextStateProps = WithRefProps; + +class TagsInputTagTextState { + constructor( + readonly opts: TagsInputTagTextStateProps, + readonly tag: TagsInputTagState + ) { + useRefById({ + ...opts, + onRefChange: (node) => { + this.tag.textNode = node; + }, + }); + } + + #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); + }); + }; + + props = $derived.by( + () => + ({ + id: this.opts.id.current, + [TAG_TEXT_ATTR]: "", + 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, + }) as const + ); +} + +type TagsInputTagEditInputStateProps = WithRefProps; + +class TagsInputTagEditInputState { + constructor( + readonly opts: TagsInputTagEditInputStateProps, + readonly tag: TagsInputTagState + ) { + useRefById({ + ...opts, + onRefChange: (node) => { + if (node instanceof HTMLInputElement) this.tag.editInput = node; + }, + }); + } + + #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); + } + }; + + props = $derived.by( + () => + ({ + id: this.opts.id.current, + [TAG_EDIT_INPUT_ATTR]: "", + 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-label": `Edit ${this.tag.opts.value.current}`, + "aria-describedby": this.tag.list.root.editDescriptionNode?.id, + "aria-hidden": getAriaHidden(!this.tag.isEditing), + }) as const + ); +} + +type TagsInputTagRemoveStateProps = WithRefProps; + +class TagsInputTagRemoveState { + #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( + readonly opts: TagsInputTagRemoveStateProps, + readonly tag: TagsInputTagState + ) { + useRefById({ + ...opts, + onRefChange: (node) => { + this.tag.removeNode = node; + }, + }); + } + + #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(); + } + }); + } + }; + + props = $derived.by( + () => + ({ + id: this.opts.id.current, + [TAG_REMOVE_ATTR]: "", + 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, + }) as const + ); +} + +type TagsInputInputStateProps = WithRefProps & + ReadableBoxedValues<{ + blurBehavior: TagsInputBlurBehavior; + pasteBehavior: TagsInputPasteBehavior; + }> & + WritableBoxedValues<{ value: string }>; + +class TagsInputInputState { + constructor( + readonly opts: TagsInputInputStateProps, + readonly root: TagsInputRootState + ) { + useRefById({ + ...opts, + onRefChange: (node) => { + this.root.inputNode = node; + }, + }); + } + + #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; + }; + + props = $derived.by( + () => + ({ + id: this.opts.id.current, + [INPUT_ATTR]: "", + "data-invalid": getDataInvalid(this.root.isInvalid), + onkeydown: this.#onkeydown, + onblur: this.#onblur, + onpaste: this.#onpaste, + }) as const + ); +} + +type TagsInputClearStateProps = WithRefProps; + +class TagsInputClearState { + constructor( + readonly opts: TagsInputClearStateProps, + readonly root: TagsInputRootState + ) { + useRefById(opts); + } + + #onclick: MouseEventHandler = () => { + this.root.clearValue(); + }; + + props = $derived.by( + () => + ({ + id: this.opts.id.current, + [CLEAR_ATTR]: "", + role: "button", + "aria-label": "Clear", + onclick: this.#onclick, + }) as const + ); +} + +type TagsInputTagContentStateProps = WithRefProps; + +class TagsInputTagContentState { + constructor( + readonly opts: TagsInputTagContentStateProps, + readonly tag: TagsInputTagState + ) { + useRefById(opts); + } + + #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(); + }; + + props = $derived.by( + () => + ({ + id: this.opts.id.current, + [TAG_CONTENT_ATTR]: "", + style: this.#style, + ondblclick: this.#ondblclick, + }) as const + ); +} + +class TagsInputTagHiddenInputState { + shouldRender = $derived.by( + () => this.tag.list.root.opts.name.current !== "" && this.tag.opts.value.current !== "" + ); + + constructor(readonly tag: TagsInputTagState) {} + + 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": getAriaHidden(true), + }) as const + ); +} + +type TagsInputTagEditDescriptionStateProps = WithRefProps; + +class TagsInputTagEditDescriptionState { + constructor( + readonly opts: TagsInputTagEditDescriptionStateProps, + readonly root: TagsInputRootState + ) { + useRefById({ + ...opts, + onRefChange: (node) => { + this.root.editDescriptionNode = node; + }, + }); + } + + description = "Edit tag. Press enter to save or escape to cancel."; + + props = $derived.by( + () => + ({ + id: this.opts.id.current, + style: srOnlyStyles, + }) as const + ); +} + +type TagsInputAnnouncerStateProps = WithRefProps; + +class TagsInputAnnouncerState { + constructor( + readonly opts: TagsInputAnnouncerStateProps, + readonly root: TagsInputRootState + ) { + useRefById(opts); + } + + props = $derived.by( + () => + ({ + id: this.opts.id.current, + "aria-live": "polite", + style: srOnlyStyles, + }) as const + ); +} + +const TagsInputRootContext = new Context("TagsInput.Root"); +const TagsInputListContext = new Context("TagsInput.List"); +const TagsInputTagContext = new Context("TagsInput.Tag"); + +export function useTagsInputRoot(props: TagsInputRootStateProps) { + return TagsInputRootContext.set(new TagsInputRootState(props)); +} + +export function useTagsInputList(props: TagsInputListStateProps) { + return TagsInputListContext.set(new TagsInputListState(props, TagsInputRootContext.get())); +} + +export function useTagsInputTag(props: TagsInputTagStateProps) { + return TagsInputTagContext.set(new TagsInputTagState(props, TagsInputListContext.get())); +} + +export function useTagsInputTagText(props: TagsInputTagTextStateProps) { + return new TagsInputTagTextState(props, TagsInputTagContext.get()); +} + +export function useTagsInputTagEditInput(props: TagsInputTagEditInputStateProps) { + return new TagsInputTagEditInputState(props, TagsInputTagContext.get()); +} + +export function useTagsInputTagRemove(props: TagsInputTagRemoveStateProps) { + return new TagsInputTagRemoveState(props, TagsInputTagContext.get()); +} + +export function useTagsInputTagHiddenInput() { + return new TagsInputTagHiddenInputState(TagsInputTagContext.get()); +} + +export function useTagsInputInput(props: TagsInputInputStateProps) { + return new TagsInputInputState(props, TagsInputRootContext.get()); +} + +export function useTagsInputClear(props: TagsInputClearStateProps) { + return new TagsInputClearState(props, TagsInputRootContext.get()); +} + +export function useTagsInputContent(props: TagsInputTagContentStateProps) { + return new TagsInputTagContentState(props, TagsInputTagContext.get()); +} + +export function useTagsInputTagEditDescription(props: TagsInputTagEditDescriptionStateProps) { + return new TagsInputTagEditDescriptionState(props, TagsInputRootContext.get()); +} + +export function useTagsInputAnnouncer(props: TagsInputAnnouncerStateProps) { + return new TagsInputAnnouncerState(props, TagsInputRootContext.get()); +} 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..c47592591 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/tags-input/types.ts @@ -0,0 +1,183 @@ +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"; + +export type TagsInputRootPropsWithoutHTML = WithChild<{ + /** + * The value of the tags 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. + * + * @defaultValue false + */ + controlledValue?: boolean; + + /** + * The delimiter used to separate tags. + * + * @defaultValue [","] + */ + 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; +}>; + +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 0ecbd99fc..07bf99d65 100644 --- a/packages/bits-ui/src/lib/index.ts +++ b/packages/bits-ui/src/lib/index.ts @@ -33,6 +33,7 @@ export { Slider, Switch, Tabs, + TagsInput, Toggle, ToggleGroup, Toolbar, 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 index 3b6614859..55429d47b 100644 --- a/packages/bits-ui/src/lib/internal/use-roving-focus.svelte.ts +++ b/packages/bits-ui/src/lib/internal/use-roving-focus.svelte.ts @@ -1,4 +1,4 @@ -import { type ReadableBox, box } from "svelte-toolbelt"; +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"; @@ -42,6 +42,13 @@ export type UseRovingFocusReturn = ReturnType; export function useRovingFocus(props: UseRovingFocusProps) { const currentTabStopId = box(null); + let recomputeDep = $state(false); + + const isAnyActive = $derived.by(() => { + recomputeDep; + if (!currentTabStopId.current || !isBrowser) return false; + return Boolean(document.getElementById(currentTabStopId.current)); + }); function getCandidateNodes() { if (!isBrowser) return []; @@ -49,22 +56,20 @@ export function useRovingFocus(props: UseRovingFocusProps) { if (!node) return []; if (props.candidateSelector) { - const candidates = Array.from( - node.querySelectorAll(props.candidateSelector) - ); - return candidates; + return Array.from(node.querySelectorAll(props.candidateSelector)); } else { - const candidates = Array.from( + return Array.from( node.querySelectorAll(`[${props.candidateAttr}]:not([data-disabled])`) ); - return candidates; } } - function focusFirstCandidate() { + function focusCandidate(type: "first" | "last") { const items = getCandidateNodes(); if (!items.length) return; - items[0]?.focus(); + const node = type === "first" ? items[0] : items[items.length - 1]; + if (!node) return; + handleFocus(node); } function handleKeydown( @@ -115,11 +120,17 @@ export function useRovingFocus(props: UseRovingFocusProps) { return itemToFocus; } + function handleFocus(node: HTMLElement | null) { + if (!node) return; + currentTabStopId.current = node.id; + node.focus(); + props.onCandidateFocus?.(node); + } + function getTabIndex(node: HTMLElement | null | undefined) { const items = getCandidateNodes(); - const anyActive = currentTabStopId.current !== null; - if (node && !anyActive && items[0] === node) { + if (node && !isAnyActive && items[0] === node) { currentTabStopId.current = node.id; return 0; } else if (node?.id === currentTabStopId.current) { @@ -129,13 +140,208 @@ export function useRovingFocus(props: UseRovingFocusProps) { return -1; } + function navigateBackward(node: HTMLElement | null | undefined, fallback?: HTMLElement | null) { + const rootNode = document.getElementById(props.rootNodeId.current); + if (!rootNode || !node) return; + const items = 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; + } + handleFocus(prevItem); + } + return { setCurrentTabStopId(id: string) { currentTabStopId.current = id; }, getTabIndex, handleKeydown, - focusFirstCandidate, + focusFirstCandidate: () => focusCandidate("first"), + focusLastCandidate: () => focusCandidate("last"), currentTabStopId, + recomputeActiveTabNode: () => (recomputeDep = !recomputeDep), + navigateBackward, + }; +} + +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 a1283e2f8..dd699ff44 100644 --- a/packages/bits-ui/src/lib/types.ts +++ b/packages/bits-ui/src/lib/types.ts @@ -33,6 +33,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/toggle/types.js"; export type * from "$lib/bits/toggle-group/types.js"; export type * from "$lib/bits/toolbar/types.js"; From eaa8f70917b36ccebcf8cd07b44bb9a3d76b5e15 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Mon, 10 Mar 2025 22:30:38 -0400 Subject: [PATCH 2/2] wip --- .../tags-input-demo-contenteditable.svelte | 1 - .../components/demos/tags-input-demo.svelte | 3 +- .../components/tags-input-tag.svelte | 13 ++-- .../tags-input/components/tags-input.svelte | 11 ++-- .../lib/bits/tags-input/tags-input.svelte.ts | 30 +++++++-- .../bits-ui/src/lib/bits/tags-input/types.ts | 64 ++++++++++++++++--- .../lib/internal/use-roving-focus.svelte.ts | 54 ++++------------ 7 files changed, 98 insertions(+), 78 deletions(-) diff --git a/docs/src/lib/components/demos/tags-input-demo-contenteditable.svelte b/docs/src/lib/components/demos/tags-input-demo-contenteditable.svelte index efe5b0a06..9be898ada 100644 --- a/docs/src/lib/components/demos/tags-input-demo-contenteditable.svelte +++ b/docs/src/lib/components/demos/tags-input-demo-contenteditable.svelte @@ -31,7 +31,6 @@ diff --git a/docs/src/lib/components/demos/tags-input-demo.svelte b/docs/src/lib/components/demos/tags-input-demo.svelte index 59f8984fa..9864c8cec 100644 --- a/docs/src/lib/components/demos/tags-input-demo.svelte +++ b/docs/src/lib/components/demos/tags-input-demo.svelte @@ -12,7 +12,7 @@ > {#each value as tag, index} - + @@ -34,7 +34,6 @@ 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 index 60ba65ef2..1a662d61e 100644 --- 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 @@ -1,5 +1,5 @@ {#snippet EditButton()} {#if tagState.opts.editMode.current !== "none"} - {/if} {/snippet} 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 index 4150dce99..2b3e1b564 100644 --- 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 @@ -13,12 +13,12 @@ ref = $bindable(null), onValueChange = noop, validate = () => true, - controlledValue = false, delimiters = [","], required = false, name = "", children, child, + announceTransformers, ...restProps }: TagsInputRootProps = $props(); @@ -27,12 +27,8 @@ value: box.with( () => value, (v) => { - if (controlledValue) { - onValueChange(v); - } else { - value = v; - onValueChange(v); - } + value = v; + onValueChange(v); } ), ref: box.with( @@ -43,6 +39,7 @@ name: box.with(() => name), required: box.with(() => required), validate: box.with(() => validate), + announceTransformers: box.with(() => announceTransformers), }); const mergedProps = $derived(mergeProps(restProps, rootState.props)); 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 index 3fbf4dcbd..dca1d1b52 100644 --- 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 @@ -14,7 +14,11 @@ import type { MouseEventHandler, } from "svelte/elements"; import { Context } from "runed"; -import type { TagsInputBlurBehavior, TagsInputPasteBehavior } from "./types.js"; +import type { + TagsInputAnnounceTransformers, + TagsInputBlurBehavior, + TagsInputPasteBehavior, +} from "./types.js"; import type { WithRefProps } from "$lib/internal/types.js"; import { getAriaHidden, getDataInvalid, getRequired } from "$lib/internal/attrs.js"; import { kbd } from "$lib/internal/kbd.js"; @@ -40,6 +44,7 @@ type TagsInputRootStateProps = WithRefProps & name: string; required: boolean; validate: (value: string) => boolean; + announceTransformers: TagsInputAnnounceTransformers | undefined; }>; // prettier-ignore @@ -128,19 +133,31 @@ class TagsInputRootState { }; announceEdit = (from: string, to: string) => { - this.#announce(`${from} has been changed to ${to}`); + 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) => { - this.#announce(`${value} has been removed`); + const message = this.opts.announceTransformers?.current?.remove + ? this.opts.announceTransformers.current.remove(value) + : `${value} has been removed`; + this.#announce(message); }; announceAdd = (value: string) => { - this.#announce(`${value} has been added`); + const message = this.opts.announceTransformers?.current?.add + ? this.opts.announceTransformers.current.add(value) + : `${value} has been added`; + this.#announce(message); }; announceAddMultiple = (values: string[]) => { - this.#announce(`${values.join(", ")} has been added`); + const message = this.opts.announceTransformers?.current?.addMultiple + ? this.opts.announceTransformers.current.addMultiple(values) + : `${values.join(", ")} has been added`; + this.#announce(message); }; props = $derived.by( @@ -439,7 +456,6 @@ class TagsInputTagEditInputState { style: this.#style, onkeydown: this.#onkeydown, onblur: this.#onblur, - "aria-label": `Edit ${this.tag.opts.value.current}`, "aria-describedby": this.tag.list.root.editDescriptionNode?.id, "aria-hidden": getAriaHidden(!this.tag.isEditing), }) as const @@ -654,7 +670,7 @@ class TagsInputTagHiddenInputState { value: this.tag.opts.value.current, style: srOnlyStyles, required: getRequired(this.tag.list.root.opts.required.current), - "aria-hidden": getAriaHidden(true), + "aria-hidden": "true", }) 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 index c47592591..322c0e99a 100644 --- a/packages/bits-ui/src/lib/bits/tags-input/types.ts +++ b/packages/bits-ui/src/lib/bits/tags-input/types.ts @@ -10,6 +10,46 @@ import type { 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. @@ -23,19 +63,10 @@ export type TagsInputRootPropsWithoutHTML = WithChild<{ */ 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. - * - * @defaultValue false - */ - controlledValue?: boolean; - /** * The delimiter used to separate tags. * - * @defaultValue [","] + * @default [","] */ delimiters?: string[]; @@ -61,6 +92,19 @@ export type TagsInputRootPropsWithoutHTML = WithChild<{ * @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 & 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 index 55429d47b..4aa758d98 100644 --- a/packages/bits-ui/src/lib/internal/use-roving-focus.svelte.ts +++ b/packages/bits-ui/src/lib/internal/use-roving-focus.svelte.ts @@ -42,13 +42,6 @@ export type UseRovingFocusReturn = ReturnType; export function useRovingFocus(props: UseRovingFocusProps) { const currentTabStopId = box(null); - let recomputeDep = $state(false); - - const isAnyActive = $derived.by(() => { - recomputeDep; - if (!currentTabStopId.current || !isBrowser) return false; - return Boolean(document.getElementById(currentTabStopId.current)); - }); function getCandidateNodes() { if (!isBrowser) return []; @@ -56,20 +49,22 @@ export function useRovingFocus(props: UseRovingFocusProps) { if (!node) return []; if (props.candidateSelector) { - return Array.from(node.querySelectorAll(props.candidateSelector)); + const candidates = Array.from( + node.querySelectorAll(props.candidateSelector) + ); + return candidates; } else { - return Array.from( + const candidates = Array.from( node.querySelectorAll(`[${props.candidateAttr}]:not([data-disabled])`) ); + return candidates; } } - function focusCandidate(type: "first" | "last") { + function focusFirstCandidate() { const items = getCandidateNodes(); if (!items.length) return; - const node = type === "first" ? items[0] : items[items.length - 1]; - if (!node) return; - handleFocus(node); + items[0]?.focus(); } function handleKeydown( @@ -120,17 +115,11 @@ export function useRovingFocus(props: UseRovingFocusProps) { return itemToFocus; } - function handleFocus(node: HTMLElement | null) { - if (!node) return; - currentTabStopId.current = node.id; - node.focus(); - props.onCandidateFocus?.(node); - } - function getTabIndex(node: HTMLElement | null | undefined) { const items = getCandidateNodes(); + const anyActive = currentTabStopId.current !== null; - if (node && !isAnyActive && items[0] === node) { + if (node && !anyActive && items[0] === node) { currentTabStopId.current = node.id; return 0; } else if (node?.id === currentTabStopId.current) { @@ -140,34 +129,14 @@ export function useRovingFocus(props: UseRovingFocusProps) { return -1; } - function navigateBackward(node: HTMLElement | null | undefined, fallback?: HTMLElement | null) { - const rootNode = document.getElementById(props.rootNodeId.current); - if (!rootNode || !node) return; - const items = 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; - } - handleFocus(prevItem); - } - return { setCurrentTabStopId(id: string) { currentTabStopId.current = id; }, getTabIndex, handleKeydown, - focusFirstCandidate: () => focusCandidate("first"), - focusLastCandidate: () => focusCandidate("last"), + focusFirstCandidate, currentTabStopId, - recomputeActiveTabNode: () => (recomputeDep = !recomputeDep), - navigateBackward, }; } @@ -312,6 +281,7 @@ export class RovingFocusGroup { getTabIndex = (node: HTMLElement | null | undefined) => { const items = this.#getCandidateNodes(); + if (node && !this.#anyActive && items[0] === node) { this.currentTabStopId.current = node.id; return 0;