Repository: solidjs-community/solid-primitives
Pull request #731 (2025-01-06 00:32:23)

add SetValues
I originally planned on calling it `SetEntries`, but `.entries()` on a `Set` is [a tuple](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set/entries)... so I went with `SetValues`.


Patch:
diff --git a/.changeset/perfect-dryers-tan.md b/.changeset/perfect-dryers-tan.md
new file mode 100644
index 00000000..87d1b7c8
--- /dev/null
+++ b/.changeset/perfect-dryers-tan.md
@@ -0,0 +1,5 @@
+---
+"@solid-primitives/keyed": minor
+---
+
+Add `SetValues` control flow component.
diff --git a/README.md b/README.md
index 4cb006a2..5e855731 100644
--- a/README.md
+++ b/README.md
@@ -65,7 +65,7 @@ The goal of Solid Primitives is to wrap client and server side functionality to
 |<h4>*Control Flow*</h4>|
 |[context](https://github.com/solidjs-community/solid-primitives/tree/main/packages/context#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-2.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createContextProvider](https://github.com/solidjs-community/solid-primitives/tree/main/packages/context#createcontextprovider)<br />[MultiProvider](https://github.com/solidjs-community/solid-primitives/tree/main/packages/context#multiprovider)|[![SIZE](https://img.shields.io/bundlephobia/minzip/@solid-primitives/context?style=for-the-badge&label=)](https://bundlephobia.com/package/@solid-primitives/context)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/context?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/context)|
 |[jsx-tokenizer](https://github.com/solidjs-community/solid-primitives/tree/main/packages/jsx-tokenizer#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-2.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createTokenizer](https://github.com/solidjs-community/solid-primitives/tree/main/packages/jsx-tokenizer#createtokenizer)<br />[createToken](https://github.com/solidjs-community/solid-primitives/tree/main/packages/jsx-tokenizer#createtoken)<br />[resolveTokens](https://github.com/solidjs-community/solid-primitives/tree/main/packages/jsx-tokenizer#resolvetokens)<br />[isToken](https://github.com/solidjs-community/solid-primitives/tree/main/packages/jsx-tokenizer#istoken)|[![SIZE](https://img.shields.io/bundlephobia/minzip/@solid-primitives/jsx-tokenizer?style=for-the-badge&label=)](https://bundlephobia.com/package/@solid-primitives/jsx-tokenizer)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/jsx-tokenizer?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/jsx-tokenizer)|
-|[keyed](https://github.com/solidjs-community/solid-primitives/tree/main/packages/keyed#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-3.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[keyArray](https://github.com/solidjs-community/solid-primitives/tree/main/packages/keyed#keyarray)<br />[Key](https://github.com/solidjs-community/solid-primitives/tree/main/packages/keyed#key)<br />[Entries](https://github.com/solidjs-community/solid-primitives/tree/main/packages/keyed#entries)<br />[MapEntries](https://github.com/solidjs-community/solid-primitives/tree/main/packages/keyed#mapentries)|[![SIZE](https://img.shields.io/bundlephobia/minzip/@solid-primitives/keyed?style=for-the-badge&label=)](https://bundlephobia.com/package/@solid-primitives/keyed)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/keyed?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/keyed)|
+|[keyed](https://github.com/solidjs-community/solid-primitives/tree/main/packages/keyed#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-3.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[keyArray](https://github.com/solidjs-community/solid-primitives/tree/main/packages/keyed#keyarray)<br />[Key](https://github.com/solidjs-community/solid-primitives/tree/main/packages/keyed#key)<br />[Entries](https://github.com/solidjs-community/solid-primitives/tree/main/packages/keyed#entries)<br />[MapEntries](https://github.com/solidjs-community/solid-primitives/tree/main/packages/keyed#mapentries)<br />[SetValues](https://github.com/solidjs-community/solid-primitives/tree/main/packages/keyed#setvalues)|[![SIZE](https://img.shields.io/bundlephobia/minzip/@solid-primitives/keyed?style=for-the-badge&label=)](https://bundlephobia.com/package/@solid-primitives/keyed)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/keyed?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/keyed)|
 |[list](https://github.com/solidjs-community/solid-primitives/tree/main/packages/list#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-0.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[listArray](https://github.com/solidjs-community/solid-primitives/tree/main/packages/list#listarray)<br />[List](https://github.com/solidjs-community/solid-primitives/tree/main/packages/list#list)|[![SIZE](https://img.shields.io/bundlephobia/minzip/@solid-primitives/list?style=for-the-badge&label=)](https://bundlephobia.com/package/@solid-primitives/list)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/list?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/list)|
 |[range](https://github.com/solidjs-community/solid-primitives/tree/main/packages/range#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-1.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[repeat](https://github.com/solidjs-community/solid-primitives/tree/main/packages/range#repeat)<br />[mapRange](https://github.com/solidjs-community/solid-primitives/tree/main/packages/range#maprange)<br />[indexRange](https://github.com/solidjs-community/solid-primitives/tree/main/packages/range#indexrange)<br />[Repeat](https://github.com/solidjs-community/solid-primitives/tree/main/packages/range#repeat)<br />[Range](https://github.com/solidjs-community/solid-primitives/tree/main/packages/range#range)<br />[IndexRange](https://github.com/solidjs-community/solid-primitives/tree/main/packages/range#indexrange)|[![SIZE](https://img.shields.io/bundlephobia/minzip/@solid-primitives/range?style=for-the-badge&label=)](https://bundlephobia.com/package/@solid-primitives/range)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/range?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/range)|
 |[refs](https://github.com/solidjs-community/solid-primitives/tree/main/packages/refs#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-2.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[mergeRefs](https://github.com/solidjs-community/solid-primitives/tree/main/packages/refs#mergerefs)<br />[resolveElements](https://github.com/solidjs-community/solid-primitives/tree/main/packages/refs#resolveelements)<br />[resolveFirst](https://github.com/solidjs-community/solid-primitives/tree/main/packages/refs#resolvefirst)<br />[Ref](https://github.com/solidjs-community/solid-primitives/tree/main/packages/refs#ref)<br />[Refs](https://github.com/solidjs-community/solid-primitives/tree/main/packages/refs#refs)|[![SIZE](https://img.shields.io/bundlephobia/minzip/@solid-primitives/refs?style=for-the-badge&label=)](https://bundlephobia.com/package/@solid-primitives/refs)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/refs?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/refs)|
diff --git a/packages/keyed/README.md b/packages/keyed/README.md
index a08cd82a..9711e85d 100644
--- a/packages/keyed/README.md
+++ b/packages/keyed/README.md
@@ -15,6 +15,7 @@ Control Flow primitives and components that require specifying explicit keys to
 - [`Key`](#key) - Creates a list of elements by mapping items by provided key.
 - [`Entries`](#entries) - Creates a list of elements by mapping object entries.
 - [`MapEntries`](#mapentries) - Creates a list of elements by mapping Map entries.
+- [`SetValues`](#setvalues) - Creates a list of elements by mapping Set values.
 - [`Rerun`](#rerun) - Causes the children to rerender when the `on` changes.
 
 ## Installation
@@ -177,7 +178,7 @@ const [map, setMap] = createSignal(new Map());
 
 Third argument of the map function is an index signal.
 
-`MapEntries` is using [`Map#key()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/keys) so the index and resulting JSX will follow the insertion order.
+`MapEntries` is using [`Map#keys()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/keys) so the index and resulting JSX will follow the insertion order.
 
 ```tsx
 <MapEntries of={map()} fallback={<div>No items</div>}>
@@ -189,6 +190,42 @@ Third argument of the map function is an index signal.
 </MapEntries>
 ```
 
+## `<SetValues>`
+
+Creates a list of elements by mapping Set values. Similar to Solid's `<For>` and `<Index>`, but here, render function takes two arguments, the value and the index argument as a signal.
+
+### How to use it
+
+```tsx
+import { SetValues } from "@solid-primitives/keyed";
+
+const [set, setSet] = createSignal(new Set());
+
+<SetValues of={set()} fallback={<div>No items</div>}>
+  {(value) => (
+    <div>
+      {value}
+    </div>
+  )}
+</SetValues>;
+```
+
+### Index argument
+
+Second argument of the map function is an index signal.
+
+`SetValues` is using [`Set#values()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set/values) so the index and resulting JSX will follow the insertion order.
+
+```tsx
+<SetValues of={set()} fallback={<div>No items</div>}>
+  {(value, index) => (
+    <div data-index={index()}>
+      {value}
+    </div>
+  )}
+</SetValues>
+```
+
 ## `<Rerun>`
 
 Causes the children to rerender when the `on` key changes. Equivalent of `v-key` in vue, and `{#key}` in svelte.
diff --git a/packages/keyed/dev/index.tsx b/packages/keyed/dev/index.tsx
index 1a176d5b..e446471e 100644
--- a/packages/keyed/dev/index.tsx
+++ b/packages/keyed/dev/index.tsx
@@ -3,6 +3,7 @@ import { Component } from "solid-js";
 import Key from "./key.js";
 import Entries from "./entries.js";
 import MapEntries from "./mapEntries.js";
+import SetValues from "./setValues.js";
 
 const App: Component = () => {
   return (
@@ -19,6 +20,10 @@ const App: Component = () => {
         <h4>MapEntries</h4>
         <MapEntries />
       </div>
+      <div class="wrapper-v">
+        <h4>SetValues</h4>
+        <SetValues />
+      </div>
     </div>
   );
 };
diff --git a/packages/keyed/dev/setValues.tsx b/packages/keyed/dev/setValues.tsx
new file mode 100644
index 00000000..8e387fad
--- /dev/null
+++ b/packages/keyed/dev/setValues.tsx
@@ -0,0 +1,94 @@
+// changes to this file might be applicable to similar files - grep 95DB7339-BB2A-4F06-A34A-25DDF8BF7AF7
+
+import { createEffect, createSignal } from "solid-js";
+import { SetValues } from "../src/index.js";
+import { TransitionGroup } from "solid-transition-group";
+
+const foods = [
+  "oatmeal",
+  "plantains",
+  "cranberries",
+  "chickpeas",
+  "tofu",
+  "Parmesan cheese",
+  "amaretto",
+  "sunflower seeds",
+  "grapes",
+  "vegemite",
+  "pasta",
+  "cider",
+  "chicken",
+  "pinto beans",
+  "bok choy",
+  "sweet peppers",
+  "Cappuccino Latte",
+  "corn",
+  "broccoli",
+  "brussels sprouts",
+  "bread",
+  "milk",
+  "honey",
+  "chips",
+  "cookie",
+];
+const randomIndex = (list: readonly any[]): number => Math.floor(Math.random() * list.length);
+const getRandomFood = () => foods[randomIndex(foods)]!;
+const randomValue = (map: Set<string>): string => {
+  const values = Array.from(map.values());
+  return values[randomIndex(values)]!;
+};
+
+export default function App() {
+  const [set, setSet] = createSignal(new Set(["bread", "milk", "honey", "chips", "cookie"]));
+
+  const addRandom = () => {
+    setSet(p => {
+      p.add(getRandomFood());
+      return new Set(p);
+    });
+  };
+  const removeRandom = () =>
+    setSet(p => {
+      p.delete(randomValue(p));
+      return new Set(p);
+    });
+  const clone = () => setSet(p => new Set(p));
+
+  return (
+    <>
+      <div class="wrapper-h">
+        <button class="btn" onclick={addRandom}>
+          Add
+        </button>
+        <button class="btn" onclick={removeRandom}>
+          Remove
+        </button>
+        <button class="btn" onclick={clone}>
+          Clone
+        </button>
+      </div>
+      <div class="wrapper-h flex-wrap">
+        <TransitionGroup name="fade">
+          <SetValues
+            of={set()}
+            fallback={<p class="bg-yellow-500 p-1 transition-all">No items.</p>}
+          >
+            {(value, index) => {
+              createEffect(() => {
+                console.log("Effect:", value);
+              });
+              return (
+                <div class="node relative transition-all duration-500">
+                  {index()}. {value}
+                  <div class="bg-dark-500 text-light-900 absolute -bottom-2 left-2 px-1 text-[9px]">
+                    {value}
+                  </div>
+                </div>
+              );
+            }}
+          </SetValues>
+        </TransitionGroup>
+      </div>
+    </>
+  );
+}
diff --git a/packages/keyed/package.json b/packages/keyed/package.json
index 8c18c221..ce09dff6 100644
--- a/packages/keyed/package.json
+++ b/packages/keyed/package.json
@@ -16,7 +16,8 @@
       "keyArray",
       "Key",
       "Entries",
-      "MapEntries"
+      "MapEntries",
+      "SetValues"
     ],
     "category": "Control Flow"
   },
diff --git a/packages/keyed/src/index.ts b/packages/keyed/src/index.ts
index 8be5e01a..2a4b33b5 100644
--- a/packages/keyed/src/index.ts
+++ b/packages/keyed/src/index.ts
@@ -239,6 +239,37 @@ export function MapEntries<K, V>(props: {
   ) as unknown as JSX.Element;
 }
 
+/**
+ * Creates a list of elements from the values of provided Set
+ *
+ * @param props
+ * @param props.of set to iterate values of (`mySet.values()`)
+ * @param props.children
+ * a map render function that receives a Set value and **index signal** and returns a JSX-Element; if the list is empty, an optional fallback is returned:
+ * ```tsx
+ * <SetValues of={set()} fallback={<div>No items</div>}>
+ *   {(value, index) => <div data-index={index()}>{value)}</div>}
+ * </SetValues>
+ * ```
+ *
+ * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/keyed#setvalues
+ */
+export function SetValues<T>(props: {
+  of: Set<T> | undefined | null | false;
+  fallback?: JSX.Element;
+  children: (value: T, i: Accessor<number>) => JSX.Element;
+}): JSX.Element {
+  // changes to this function may be applicable to similar functions - grep 4A29BECD-767A-4CC0-AEBB-3543D7B444C6
+  const mapFn = props.children;
+  return createMemo(
+    mapArray(
+      () => props.of && Array.from(props.of.values()),
+      mapFn.length < 2 ? value => (mapFn as (value: T) => JSX.Element)(value) : mapFn,
+      "fallback" in props ? { fallback: () => props.fallback } : undefined,
+    ),
+  ) as unknown as JSX.Element;
+}
+
 export type RerunChildren<T> = ((input: T, prevInput: T | undefined) => JSX.Element) | JSX.Element;
 
 /**


Test patch:
diff --git a/packages/keyed/test/index.test.tsx b/packages/keyed/test/index.test.tsx
index 012ac245..49dd59c7 100644
--- a/packages/keyed/test/index.test.tsx
+++ b/packages/keyed/test/index.test.tsx
@@ -2,7 +2,7 @@ import { createComputed, createMemo, createRoot, createSignal } from "solid-js";
 import { createStore } from "solid-js/store";
 import { update } from "@solid-primitives/utils/immutable";
 import { describe, expect, test } from "vitest";
-import { keyArray, MapEntries } from "../src/index.js";
+import { keyArray, MapEntries, SetValues } from "../src/index.js";
 import { render } from "solid-js/web";
 
 const el1 = { id: 1, value: "bread" };
@@ -377,7 +377,6 @@ describe("MapEntries", () => {
       newMapped[k] = v;
     });
 
-    expect(oldMapped[0]).toBe(newMapped[0]);
     expect(oldMapped[1]).toBe(newMapped[1]);
     expect(oldMapped[2]).toBe(newMapped[2]);
     expect(oldMapped[3]).toBe(newMapped[3]);
@@ -441,3 +440,162 @@ describe("MapEntries", () => {
     document.body.removeChild(container);
   });
 });
+
+describe("SetValues", () => {
+  test("simple", () => {
+    const container = document.createElement("div");
+    document.body.appendChild(container);
+
+    const startingMap = new Set(["1", "2", "3"]);
+    const [s] = createSignal(startingMap);
+    const unmount = render(
+      () => (
+        <SetValues of={s()}>
+          {(v, i) => (
+            <div>
+              {i()}. {v}
+            </div>
+          )}
+        </SetValues>
+      ),
+      container,
+    );
+
+    container.childNodes.forEach((n, i) => {
+      const v = Array.from(startingMap.values())[i]!;
+      expect(n.textContent).toEqual(`${i}. ${v}`);
+    });
+
+    unmount();
+    document.body.removeChild(container);
+  });
+
+  test("doesn't change for same values", () => {
+    const container = document.createElement("div");
+    document.body.appendChild(container);
+
+    const startingMap = new Set(["1", "2", "3"]);
+    const [s, set] = createSignal(startingMap);
+    const unmount = render(
+      () => (
+        <SetValues of={s()}>
+          {(v, i) => (
+            <div>
+              {i()}. {v}
+            </div>
+          )}
+        </SetValues>
+      ),
+      container,
+    );
+
+    const oldMapped: ChildNode[] = new Array(container.childNodes.length);
+    container.childNodes.forEach((n, i) => {
+      const v = Array.from(startingMap.values())[i]!;
+      expect(n.textContent).toEqual(`${i}. ${v}`);
+      oldMapped[i] = n;
+    });
+
+    set(new Set(startingMap));
+
+    container.childNodes.forEach((n, i) => {
+      const v = Array.from(startingMap.values())[i]!;
+      expect(n.textContent).toEqual(`${i}. ${v}`);
+      expect(oldMapped[i]).toBe(n);
+    });
+
+    unmount();
+    document.body.removeChild(container);
+  });
+
+  test("creates new elements", () => {
+    const container = document.createElement("div");
+    document.body.appendChild(container);
+
+    const startingMap = new Set(["1", "2", "3"]);
+    const [s, set] = createSignal(startingMap);
+    const unmount = render(
+      () => (
+        <SetValues of={s()}>
+          {(v, i) => (
+            <div>
+              {i()}. {v}
+            </div>
+          )}
+        </SetValues>
+      ),
+      container,
+    );
+
+    const oldMapped: ChildNode[] = new Array(container.childNodes.length);
+    container.childNodes.forEach((n, i) => {
+      const v = Array.from(startingMap.values())[i]!;
+      expect(n.textContent).toEqual(`${i}. ${v}`);
+      oldMapped[i] = n;
+    });
+
+    const nextMap = new Set(["1", "2", "3", "4", "5"]);
+    set(nextMap);
+
+    const newMapped: ChildNode[] = new Array(container.childNodes.length);
+    container.childNodes.forEach((n, i) => {
+      const v = Array.from(nextMap.values())[i]!;
+      expect(n.textContent).toEqual(`${i}. ${v}`);
+      newMapped[i] = n;
+    });
+
+    expect(oldMapped[0]).toBe(newMapped[0]);
+    expect(oldMapped[1]).toBe(newMapped[1]);
+    expect(oldMapped[2]).toBe(newMapped[2]);
+    expect(oldMapped.includes(newMapped[3]!)).toEqual(false);
+    expect(oldMapped.includes(newMapped[4]!)).toEqual(false);
+
+    unmount();
+    document.body.removeChild(container);
+  });
+
+  test("deletes unused elements", () => {
+    const container = document.createElement("div");
+    document.body.appendChild(container);
+
+    const startingMap = new Set(["0", "1", "2", "3"]);
+    const [s, set] = createSignal(startingMap);
+    const unmount = render(
+      () => (
+        <SetValues of={s()}>
+          {(v, i) => (
+            <div>
+              {i()}. {v}
+            </div>
+          )}
+        </SetValues>
+      ),
+      container,
+    );
+
+    const oldMapped: ChildNode[] = new Array(container.childNodes.length);
+    container.childNodes.forEach((n, i) => {
+      const v = Array.from(startingMap.values())[i]!;
+      expect(n.textContent).toEqual(`${i}. ${v}`);
+      oldMapped[i] = n;
+    });
+
+    const nextMap = new Set(["0", "3"]);
+    set(nextMap);
+
+    const newMapped: ChildNode[] = new Array(container.childNodes.length);
+    container.childNodes.forEach((n, i) => {
+      const v = Array.from(nextMap.values())[i]!;
+      expect(n.textContent).toEqual(`${i}. ${v}`);
+      newMapped[i] = n;
+    });
+
+    expect(oldMapped[0]).toBe(newMapped[0]);
+    expect(oldMapped[3]).toBe(newMapped[1]);
+    expect(newMapped.includes(oldMapped[1]!)).toEqual(false);
+    expect(newMapped.includes(oldMapped[2]!)).toEqual(false);
+
+    unmount();
+    document.body.removeChild(container);
+  });
+});