Repository: melt-ui/melt-ui
Pull request #575 (2023-10-02 08:26:54)

Add tooltip `group` param
This new parameter allows tooltip to be grouped together, coupling their `open` states so that no more than 1 tooltip can be open in a single group.

This pr includes changes to the `createTooltip` function and its types, and some unit tests to validate the functionality.

changeset-bot[bot]: ###  ⚠️  No Changeset found

Latest commit: b1ebf30b32d15e3407227bb7b3760c2529417d54

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/abdel-17/melt-ui/new/add-tooltip-group?filename=.changeset/spicy-crabs-invent.md&value=---%0A%22%40melt-ui%2Fsvelte%22%3A%20patch%0A---%0A%0AAdd%20tooltip%20%60group%60%20param%0A)


abdel-17: Okay, so somehow the previous slider docs pr got merged into this one and I don't know how to fix it. Sorry for that.

Patch:
diff --git a/.changeset/pretty-numbers-remain.md b/.changeset/pretty-numbers-remain.md
new file mode 100644
index 00000000..9d6b90ba
--- /dev/null
+++ b/.changeset/pretty-numbers-remain.md
@@ -0,0 +1,5 @@
+---
+"@melt-ui/svelte": patch
+---
+
+fix: change JSDoc example for `melt` action
diff --git a/.changeset/tasty-penguins-appear.md b/.changeset/tasty-penguins-appear.md
new file mode 100644
index 00000000..dcd808ca
--- /dev/null
+++ b/.changeset/tasty-penguins-appear.md
@@ -0,0 +1,5 @@
+---
+"@melt-ui/svelte": patch
+---
+
+Tooltip: Add `group` prop
diff --git a/.changeset/two-shrimps-notice.md b/.changeset/two-shrimps-notice.md
new file mode 100644
index 00000000..a1b982eb
--- /dev/null
+++ b/.changeset/two-shrimps-notice.md
@@ -0,0 +1,5 @@
+---
+"@melt-ui/svelte": patch
+---
+
+fix: add generics to exported types
diff --git a/src/docs/data/builders/tooltip.ts b/src/docs/data/builders/tooltip.ts
index d0898d02..442a48ea 100644
--- a/src/docs/data/builders/tooltip.ts
+++ b/src/docs/data/builders/tooltip.ts
@@ -38,6 +38,12 @@ const OPTION_PROPS = [
 		description:
 			'Prevents the tooltip content element from remaining open when hovered. If `true`, the tooltip will only be open when hovering the trigger element.',
 	},
+	{
+		name: 'group',
+		type: 'string | boolean',
+		description:
+			'If set to `true`, whenever you open this tooltip, all other tooltips with `group` also set to `true` will close. If you pass in a string instead, only tooltips with the same `group` value will be closed.',
+	},
 ];
 
 const BUILDER_NAME = 'tooltip';
diff --git a/src/lib/builders/accordion/types.ts b/src/lib/builders/accordion/types.ts
index ba4cb207..d3bfffa5 100644
--- a/src/lib/builders/accordion/types.ts
+++ b/src/lib/builders/accordion/types.ts
@@ -65,8 +65,18 @@ export type AccordionHeadingProps =
 	  }
 	| number;
 
-export type Accordion = BuilderReturn<typeof createAccordion>;
-export type AccordionElements = Accordion['elements'];
-export type AccordionOptions = Accordion['options'];
-export type AccordionStates = Accordion['states'];
-export type AccordionHelpers = Accordion['helpers'];
+export type Accordion<Multiple extends boolean = false> = BuilderReturn<
+	typeof createAccordion<Multiple>
+>;
+export type AccordionElements<Multiple extends boolean = false> = BuilderReturn<
+	typeof createAccordion<Multiple>
+>['elements'];
+export type AccordionOptions<Multiple extends boolean = false> = BuilderReturn<
+	typeof createAccordion<Multiple>
+>['options'];
+export type AccordionStates<Multiple extends boolean = false> = BuilderReturn<
+	typeof createAccordion<Multiple>
+>['states'];
+export type AccordionHelpers<Multiple extends boolean = false> = BuilderReturn<
+	typeof createAccordion<Multiple>
+>['helpers'];
diff --git a/src/lib/builders/combobox/types.ts b/src/lib/builders/combobox/types.ts
index ce8eedc4..6886c4de 100644
--- a/src/lib/builders/combobox/types.ts
+++ b/src/lib/builders/combobox/types.ts
@@ -19,7 +19,6 @@ export type ComboboxSelected<Multiple extends boolean, Value> = WhenTrue<
 export type CreateComboboxProps<
 	Value,
 	Multiple extends boolean = false,
-	// eslint-disable-next-line @typescript-eslint/no-explicit-any
 	S extends ComboboxSelected<Multiple, Value> = ComboboxSelected<Multiple, Value>
 > = {
 	/**
@@ -140,34 +139,29 @@ export type ComboboxItemProps<Value> = ComboboxOption<Value> & {
 export type Combobox<
 	Value = unknown,
 	Multiple extends boolean = false,
-	// eslint-disable-next-line @typescript-eslint/no-explicit-any
 	S extends ComboboxSelected<Multiple, Value> = ComboboxSelected<Multiple, Value>
 > = BuilderReturn<typeof createCombobox<Value, Multiple, S>>;
 
 export type ComboboxElements<
 	Value = unknown,
 	Multiple extends boolean = false,
-	// eslint-disable-next-line @typescript-eslint/no-explicit-any
 	S extends ComboboxSelected<Multiple, Value> = ComboboxSelected<Multiple, Value>
 > = Combobox<Value, Multiple, S>['elements'];
 
 export type ComboboxOptions<
 	Value = unknown,
 	Multiple extends boolean = false,
-	// eslint-disable-next-line @typescript-eslint/no-explicit-any
 	S extends ComboboxSelected<Multiple, Value> = ComboboxSelected<Multiple, Value>
 > = Combobox<Value, Multiple, S>['options'];
 
 export type ComboboxStates<
 	Value = unknown,
 	Multiple extends boolean = false,
-	// eslint-disable-next-line @typescript-eslint/no-explicit-any
 	S extends ComboboxSelected<Multiple, Value> = ComboboxSelected<Multiple, Value>
 > = Combobox<Value, Multiple, S>['states'];
 
 export type ComboboxHelpers<
 	Value = unknown,
 	Multiple extends boolean = false,
-	// eslint-disable-next-line @typescript-eslint/no-explicit-any
 	S extends ComboboxSelected<Multiple, Value> = ComboboxSelected<Multiple, Value>
 > = Combobox<Value, Multiple, S>['helpers'];
diff --git a/src/lib/builders/select/types.ts b/src/lib/builders/select/types.ts
index 335b85ab..b195f382 100644
--- a/src/lib/builders/select/types.ts
+++ b/src/lib/builders/select/types.ts
@@ -20,7 +20,6 @@ export type SelectSelected<Multiple extends boolean, Value> = WhenTrue<
 export type CreateSelectProps<
 	Value = unknown,
 	Multiple extends boolean = false,
-	// eslint-disable-next-line @typescript-eslint/no-explicit-any
 	S extends SelectSelected<Multiple, Value> = SelectSelected<Multiple, Value>
 > = {
 	/**
@@ -150,8 +149,28 @@ export type SelectOptionProps<Value = unknown> = SelectOption<Value> & {
 	disabled?: boolean;
 };
 
-export type Select = BuilderReturn<typeof createSelect>;
-export type SelectElements = Select['elements'];
-export type SelectOptions = Select['options'];
-export type SelectStates = Select['states'];
-export type SelectHelpers = Select['helpers'];
+export type Select<
+	Value = unknown,
+	Multiple extends boolean = false,
+	S extends SelectSelected<Multiple, Value> = SelectSelected<Multiple, Value>
+> = BuilderReturn<typeof createSelect<Value, Multiple, S>>;
+export type SelectElements<
+	Value = unknown,
+	Multiple extends boolean = false,
+	S extends SelectSelected<Multiple, Value> = SelectSelected<Multiple, Value>
+> = BuilderReturn<typeof createSelect<Value, Multiple, S>>['elements'];
+export type SelectOptions<
+	Value = unknown,
+	Multiple extends boolean = false,
+	S extends SelectSelected<Multiple, Value> = SelectSelected<Multiple, Value>
+> = BuilderReturn<typeof createSelect<Value, Multiple, S>>['options'];
+export type SelectStates<
+	Value = unknown,
+	Multiple extends boolean = false,
+	S extends SelectSelected<Multiple, Value> = SelectSelected<Multiple, Value>
+> = BuilderReturn<typeof createSelect<Value, Multiple, S>>['states'];
+export type SelectHelpers<
+	Value = unknown,
+	Multiple extends boolean = false,
+	S extends SelectSelected<Multiple, Value> = SelectSelected<Multiple, Value>
+> = BuilderReturn<typeof createSelect<Value, Multiple, S>>['helpers'];
diff --git a/src/lib/builders/toast/types.ts b/src/lib/builders/toast/types.ts
index f510f5aa..82af1ec8 100644
--- a/src/lib/builders/toast/types.ts
+++ b/src/lib/builders/toast/types.ts
@@ -31,8 +31,8 @@ export type Toast<T = object> = {
 	getPercentage: () => number;
 };
 
-export type Toasts = BuilderReturn<typeof createToaster>;
-export type ToastsElements = Toasts['elements'];
-export type ToastsOptions = Toasts['options'];
-export type ToastsStates = Toasts['states'];
-export type ToastsHelpers = Toasts['helpers'];
+export type Toasts<T = object> = BuilderReturn<typeof createToaster<T>>;
+export type ToastsElements<T = object> = BuilderReturn<typeof createToaster<T>>['elements'];
+export type ToastsOptions<T = object> = BuilderReturn<typeof createToaster<T>>['options'];
+export type ToastsStates<T = object> = BuilderReturn<typeof createToaster<T>>['states'];
+export type ToastsHelpers<T = object> = BuilderReturn<typeof createToaster<T>>['helpers'];
diff --git a/src/lib/builders/toggle-group/types.ts b/src/lib/builders/toggle-group/types.ts
index 0a5361a1..23e3df51 100644
--- a/src/lib/builders/toggle-group/types.ts
+++ b/src/lib/builders/toggle-group/types.ts
@@ -23,8 +23,18 @@ export type ToggleGroupItemProps =
 	  }
 	| string;
 
-export type ToggleGroup = BuilderReturn<typeof createToggleGroup>;
-export type ToggleGroupElements = ToggleGroup['elements'];
-export type ToggleGroupOptions = ToggleGroup['options'];
-export type ToggleGroupStates = ToggleGroup['states'];
-export type ToggleGroupHelpers = ToggleGroup['helpers'];
+export type ToggleGroup<T extends ToggleGroupType = 'single'> = BuilderReturn<
+	typeof createToggleGroup<T>
+>;
+export type ToggleGroupElements<T extends ToggleGroupType = 'single'> = BuilderReturn<
+	typeof createToggleGroup<T>
+>['elements'];
+export type ToggleGroupOptions<T extends ToggleGroupType = 'single'> = BuilderReturn<
+	typeof createToggleGroup<T>
+>['options'];
+export type ToggleGroupStates<T extends ToggleGroupType = 'single'> = BuilderReturn<
+	typeof createToggleGroup<T>
+>['states'];
+export type ToggleGroupHelpers<T extends ToggleGroupType = 'single'> = BuilderReturn<
+	typeof createToggleGroup<T>
+>['helpers'];
diff --git a/src/lib/builders/tooltip/create.ts b/src/lib/builders/tooltip/create.ts
index 4700d020..2b7f93e1 100644
--- a/src/lib/builders/tooltip/create.ts
+++ b/src/lib/builders/tooltip/create.ts
@@ -23,7 +23,7 @@ import {
 import { useFloating, usePortal } from '$lib/internal/actions/index.js';
 import type { MeltActionReturn } from '$lib/internal/types.js';
 import { onMount, tick } from 'svelte';
-import { get, writable } from 'svelte/store';
+import { get, writable, type Writable } from 'svelte/store';
 import type { TooltipEvents } from './events.js';
 import type { CreateTooltipProps } from './types.js';
 
@@ -40,11 +40,15 @@ const defaults = {
 	portal: 'body',
 	closeOnEscape: true,
 	disableHoverableContent: false,
+	group: undefined,
 } satisfies CreateTooltipProps;
 
 type TooltipParts = 'trigger' | 'content' | 'arrow';
 const { name } = createElHelpers<TooltipParts>('tooltip');
 
+// Store a global map to get the currently open tooltip.
+const groupMap = new Map<string | true, Writable<boolean>>();
+
 export function createTooltip(props?: CreateTooltipProps) {
 	const withDefaults = { ...defaults, ...props } satisfies CreateTooltipProps;
 
@@ -59,6 +63,7 @@ export function createTooltip(props?: CreateTooltipProps) {
 		portal,
 		closeOnEscape,
 		disableHoverableContent,
+		group,
 	} = options;
 
 	const openWritable = withDefaults.open ?? writable(withDefaults.defaultOpen);
@@ -240,6 +245,21 @@ export function createTooltip(props?: CreateTooltipProps) {
 
 	let isMouseInTooltipArea = false;
 
+	effect(open, ($open) => {
+		if (!$open) return;
+
+		const groupValue = get(group);
+		if (groupValue === undefined || groupValue === false) {
+			return;
+		}
+
+		// Close the currently open tooltip in the same group
+		// and set this tooltip as the open one.
+		const currentOpen = groupMap.get(groupValue);
+		currentOpen?.set(false);
+		groupMap.set(groupValue, open);
+	});
+
 	effect([isVisible, activeTrigger], ([$isVisible, $activeTrigger]) => {
 		if (!$isVisible || !$activeTrigger) return;
 		return executeCallbacks(
diff --git a/src/lib/builders/tooltip/types.ts b/src/lib/builders/tooltip/types.ts
index 1425bd46..d99bd671 100644
--- a/src/lib/builders/tooltip/types.ts
+++ b/src/lib/builders/tooltip/types.ts
@@ -16,6 +16,12 @@ export type CreateTooltipProps = {
 	forceVisible?: boolean;
 	closeOnEscape?: boolean;
 	disableHoverableContent?: boolean;
+	/**
+	 * If set to `true`, whenever you open this tooltip, all other tooltips
+	 * with `group` also set to `true` will close. If you pass in a string
+	 * instead, only tooltips with the same `group` value will be closed.
+	 */
+	group?: boolean | string;
 	/**
 	 * If not undefined, the tooltip will be rendered within the provided element or selector.
 	 *
diff --git a/src/lib/internal/actions/melt/index.ts b/src/lib/internal/actions/melt/index.ts
index f51cdea3..4a5cc649 100644
--- a/src/lib/internal/actions/melt/index.ts
+++ b/src/lib/internal/actions/melt/index.ts
@@ -19,10 +19,11 @@ type GetActionAttributes<Builder> = Builder extends Record<string, any> & {
  * @example
  * ```svelte
  * <script>
- * 	const { builder, melt } = createBuilder();
+ * 	import { createLabel, melt } from '@melt-ui/svelte';
+ * 	const { elements: { root } } = createLabel();
  * </script>
  *
- * <div use:melt={$builder} />
+ * <label use:melt={$root} />
  * ```
  */
 export function melt<


Test patch:
diff --git a/src/tests/tooltip/Tooltip.spec.ts b/src/tests/tooltip/Tooltip.spec.ts
new file mode 100644
index 00000000..b718828d
--- /dev/null
+++ b/src/tests/tooltip/Tooltip.spec.ts
@@ -0,0 +1,108 @@
+import { render } from '@testing-library/svelte';
+import { get, writable } from 'svelte/store';
+import { describe, expect, test } from 'vitest';
+import Tooltip from './Tooltip.svelte';
+
+describe('2 tooltips, group=true', () => {
+	test('When a tooltip opens, the other closes', () => {
+		const open1 = writable(true);
+		const open2 = writable(false);
+
+		render(Tooltip, {
+			open: open1,
+			group: true,
+		});
+		render(Tooltip, {
+			open: open2,
+			group: true,
+		});
+
+		open2.set(true);
+
+		expect(get(open1)).toBe(false);
+		expect(get(open2)).toBe(true);
+	});
+
+	test('When both are rendered with open=true, the first closes', () => {
+		const open1 = writable(true);
+		const open2 = writable(true);
+
+		render(Tooltip, {
+			open: open1,
+			group: true,
+		});
+		render(Tooltip, {
+			open: open2,
+			group: true,
+		});
+
+		expect(get(open1)).toBe(false);
+		expect(get(open2)).toBe(true);
+	});
+});
+
+describe('2 tooltips, different groups', () => {
+	const group1 = 'A';
+	const group2 = 'B';
+
+	test('When a tooltip opens, the other stays open', () => {
+		const open1 = writable(true);
+		const open2 = writable(false);
+
+		render(Tooltip, {
+			open: open1,
+			group: group1,
+		});
+		render(Tooltip, {
+			open: open2,
+			group: group2,
+		});
+
+		open2.set(true);
+
+		expect(get(open1)).toBe(true);
+		expect(get(open2)).toBe(true);
+	});
+});
+
+describe('2 tooltips, group=false', () => {
+	test('When a tooltip opens, the other stays open', () => {
+		const open1 = writable(true);
+		const open2 = writable(false);
+
+		render(Tooltip, {
+			open: open1,
+			group: false,
+		});
+		render(Tooltip, {
+			open: open2,
+			group: false,
+		});
+
+		open2.set(true);
+
+		expect(get(open1)).toBe(true);
+		expect(get(open2)).toBe(true);
+	});
+});
+
+describe('2 tooltips, group=undefined', () => {
+	test('When a tooltip opens, the other stays open', () => {
+		const open1 = writable(true);
+		const open2 = writable(false);
+
+		render(Tooltip, {
+			open: open1,
+			group: undefined,
+		});
+		render(Tooltip, {
+			open: open2,
+			group: undefined,
+		});
+
+		open2.set(true);
+
+		expect(get(open1)).toBe(true);
+		expect(get(open2)).toBe(true);
+	});
+});
diff --git a/src/tests/tooltip/Tooltip.svelte b/src/tests/tooltip/Tooltip.svelte
new file mode 100644
index 00000000..0ccd9048
--- /dev/null
+++ b/src/tests/tooltip/Tooltip.svelte
@@ -0,0 +1,23 @@
+<script lang="ts">
+	import { createTooltip, melt, type CreateTooltipProps } from '$lib';
+	import type { Writable } from 'svelte/store';
+
+	export let open: Writable<boolean>;
+	export let group: CreateTooltipProps['group'] = undefined;
+
+	const {
+		elements: { content, trigger },
+		options,
+	} = createTooltip({
+		open,
+		group,
+		// Make sure tests pass even if the default
+		// value changes some day in the future
+		openDelay: 1000,
+	});
+
+	$: options.group.set(group);
+</script>
+
+<button use:melt={$trigger}>Trigger</button>
+<div use:melt={$content}>Content</div>