From d77602b236ed0b1d3377d7ae4e1e580ca2b158e9 Mon Sep 17 00:00:00 2001 From: Damian Tarnawski Date: Thu, 12 Jun 2025 19:56:44 +0200 Subject: [PATCH 01/13] feat: Add match component and package --- packages/match/LICENSE | 21 ++++++++++++ packages/match/README.md | 51 ++++++++++++++++++++++++++++ packages/match/dev/index.tsx | 20 +++++++++++ packages/match/package.json | 54 ++++++++++++++++++++++++++++++ packages/match/src/index.ts | 42 +++++++++++++++++++++++ packages/match/test/index.test.ts | 19 +++++++++++ packages/match/test/server.test.ts | 9 +++++ packages/match/tsconfig.json | 12 +++++++ 8 files changed, 228 insertions(+) create mode 100644 packages/match/LICENSE create mode 100644 packages/match/README.md create mode 100644 packages/match/dev/index.tsx create mode 100644 packages/match/package.json create mode 100644 packages/match/src/index.ts create mode 100644 packages/match/test/index.test.ts create mode 100644 packages/match/test/server.test.ts create mode 100644 packages/match/tsconfig.json diff --git a/packages/match/LICENSE b/packages/match/LICENSE new file mode 100644 index 000000000..38b41d975 --- /dev/null +++ b/packages/match/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Solid Primitives Working Group + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/packages/match/README.md b/packages/match/README.md new file mode 100644 index 000000000..e0f066fab --- /dev/null +++ b/packages/match/README.md @@ -0,0 +1,51 @@ +

+ Solid Primitives match +

+ +# @solid-primitives/match + +[![size](https://img.shields.io/bundlephobia/minzip/@solid-primitives/match?style=for-the-badge&label=size)](https://bundlephobia.com/package/@solid-primitives/match) +[![version](https://img.shields.io/npm/v/@solid-primitives/match?style=for-the-badge)](https://www.npmjs.com/package/@solid-primitives/match) +[![stage](https://img.shields.io/endpoint?style=for-the-badge&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-0.json)](https://github.com/solidjs-community/solid-primitives#contribution-process) + +Control-flow component for matching discriminated union (tagged union) members. + +## Installation + +```bash +npm install @solid-primitives/match +# or +yarn add @solid-primitives/match +# or +pnpm add @solid-primitives/match +``` + +## `Match` + +Control-flow component for matching discriminated union (tagged union) members. + +### How to use it + +```tsx +type MyUnion = { + kind: 'foo', + foo: 'foo-value', +} | { + kind: 'bar', + bar: 'bar-value', +} + +const [value, setValue] = s.createSignal({kind: 'foo', foo: 'foo-value'}) + + <>{v().foo}, + bar: v => <>{v().bar}, +}} /> + +## Demo + +[Deployed example](https://primitives.solidjs.community/playground/match) | [Source code](https://github.com/solidjs-community/solid-primitives/tree/main/packages/match/dev) + +## Changelog + +See [CHANGELOG.md](./CHANGELOG.md) diff --git a/packages/match/dev/index.tsx b/packages/match/dev/index.tsx new file mode 100644 index 000000000..e2e5113e6 --- /dev/null +++ b/packages/match/dev/index.tsx @@ -0,0 +1,20 @@ +import { Component, createSignal } from "solid-js"; + +const App: Component = () => { + const [count, setCount] = createSignal(0); + const increment = () => setCount(count() + 1); + + return ( +
+
+

Counter component

+

it's very important...

+ +
+
+ ); +}; + +export default App; diff --git a/packages/match/package.json b/packages/match/package.json new file mode 100644 index 000000000..c87540183 --- /dev/null +++ b/packages/match/package.json @@ -0,0 +1,54 @@ +{ + "name": "@solid-primitives/match", + "version": "0.0.100", + "description": "A template primitive example.", + "author": "Damian Tarnawski ", + "contributors": [], + "license": "MIT", + "homepage": "https://primitives.solidjs.community/package/match", + "repository": { + "type": "git", + "url": "git+https://github.com/solidjs-community/solid-primitives.git" + }, + "bugs": { + "url": "https://github.com/solidjs-community/solid-primitives/issues" + }, + "primitive": { + "name": "match", + "stage": 0, + "list": [ + "createPrimitiveTemplate" + ], + "category": "Display & Media" + }, + "keywords": [ + "solid", + "primitives" + ], + "private": false, + "sideEffects": false, + "files": [ + "dist" + ], + "type": "module", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "browser": {}, + "exports": { + "@solid-primitives/source": "./src/index.ts", + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "typesVersions": {}, + "scripts": { + "dev": "tsx ../../scripts/dev.ts", + "vitest": "vitest -c ../../configs/vitest.config.ts", + "test": "pnpm run vitest", + "test:ssr": "pnpm run vitest --mode ssr" + }, + "peerDependencies": { + "solid-js": "^1.6.12" + } +} diff --git a/packages/match/src/index.ts b/packages/match/src/index.ts new file mode 100644 index 000000000..54030acd3 --- /dev/null +++ b/packages/match/src/index.ts @@ -0,0 +1,42 @@ +import { type Accessor, type JSX, createMemo } from "solid-js" + +/** + * Control-flow component for matching discriminated union (tagged union) members. + * + * @example + * ```tsx + * type MyUnion = { + * kind: 'foo', + * foo: 'foo-value', + * } | { + * kind: 'bar', + * bar: 'bar-value', + * } + * + * const [value, setValue] = s.createSignal({kind: 'foo', foo: 'foo-value'}) + * + * <>{v().foo}, + * bar: v => <>{v().bar}, + * }} /> + * ``` + */ +export function Match< + T extends {[k in Tag]: PropertyKey}, + Tag extends PropertyKey, +>(props: { + on: T, + tag: Tag, + case: {[K in T[Tag]]: (v: Accessor) => JSX.Element}, +}): JSX.Element +export function Match< + T extends {kind: PropertyKey}, +>(props: { + on: T, + case: {[K in T['kind']]: (v: Accessor) => JSX.Element}, +}): JSX.Element +export function Match(props: any): any { + const kind = createMemo(() => props.on[props.tag ?? 'kind']) + return createMemo(() => props.case[kind()](() => props.on)) +} + diff --git a/packages/match/test/index.test.ts b/packages/match/test/index.test.ts new file mode 100644 index 000000000..2f1ef641e --- /dev/null +++ b/packages/match/test/index.test.ts @@ -0,0 +1,19 @@ +import { describe, test, expect } from "vitest"; +import { createRoot } from "solid-js"; +import { createPrimitiveTemplate } from "../src/index.js"; + +describe("createPrimitiveTemplate", () => { + test("createPrimitiveTemplate return values", () => { + const { value, setValue, dispose } = createRoot(dispose => { + const [value, setValue] = createPrimitiveTemplate(true); + expect(value(), "initial value should be true").toBe(true); + + return { value, setValue, dispose }; + }); + + setValue(false); + expect(value(), "value after change should be false").toBe(false); + + dispose(); + }); +}); diff --git a/packages/match/test/server.test.ts b/packages/match/test/server.test.ts new file mode 100644 index 000000000..d0ba586ee --- /dev/null +++ b/packages/match/test/server.test.ts @@ -0,0 +1,9 @@ +import { describe, test, expect } from "vitest"; +import { createPrimitiveTemplate } from "../src/index.js"; + +describe("createPrimitiveTemplate", () => { + test("doesn't break in SSR", () => { + const [value, setValue] = createPrimitiveTemplate(true); + expect(value(), "initial value should be true").toBe(true); + }); +}); diff --git a/packages/match/tsconfig.json b/packages/match/tsconfig.json new file mode 100644 index 000000000..38c71ce71 --- /dev/null +++ b/packages/match/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "outDir": "dist", + "rootDir": "src" + }, + "references": [], + "include": [ + "src" + ] +} \ No newline at end of file From 0864471b3347f4662c8aa92c9948a942d6638373 Mon Sep 17 00:00:00 2001 From: Damian Tarnawski Date: Thu, 12 Jun 2025 19:56:44 +0200 Subject: [PATCH 02/13] Fix readme --- packages/match/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/match/README.md b/packages/match/README.md index e0f066fab..3a113a506 100644 --- a/packages/match/README.md +++ b/packages/match/README.md @@ -41,6 +41,7 @@ const [value, setValue] = s.createSignal({kind: 'foo', foo: 'foo-value' foo: v => <>{v().foo}, bar: v => <>{v().bar}, }} /> +``` ## Demo From 1013bb788bc9326660a62f8362de0bc029b6d36a Mon Sep 17 00:00:00 2001 From: Damian Tarnawski Date: Thu, 12 Jun 2025 19:56:44 +0200 Subject: [PATCH 03/13] Update readme --- packages/match/package.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/match/package.json b/packages/match/package.json index c87540183..c529a83d4 100644 --- a/packages/match/package.json +++ b/packages/match/package.json @@ -17,13 +17,14 @@ "name": "match", "stage": 0, "list": [ - "createPrimitiveTemplate" + "Match" ], - "category": "Display & Media" + "category": "Control Flow" }, "keywords": [ "solid", - "primitives" + "primitives", + "union" ], "private": false, "sideEffects": false, From 0303a8eff674263d5605ae141c53970c72d5c76b Mon Sep 17 00:00:00 2001 From: Damian Tarnawski Date: Thu, 12 Jun 2025 19:56:44 +0200 Subject: [PATCH 04/13] fix: Update README and add example for using `tag` --- packages/match/README.md | 29 ++++++++++++++++++++++++----- packages/match/src/index.ts | 2 +- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/packages/match/README.md b/packages/match/README.md index 3a113a506..9ef60d4f2 100644 --- a/packages/match/README.md +++ b/packages/match/README.md @@ -28,14 +28,14 @@ Control-flow component for matching discriminated union (tagged union) members. ```tsx type MyUnion = { - kind: 'foo', - foo: 'foo-value', + kind: "foo", + foo: "foo-value", } | { - kind: 'bar', - bar: 'bar-value', + kind: "bar", + bar: "bar-value", } -const [value, setValue] = s.createSignal({kind: 'foo', foo: 'foo-value'}) +const [value, setValue] = createSignal({kind: "foo", foo: "foo-value"}) <>{v().foo}, @@ -43,6 +43,25 @@ const [value, setValue] = s.createSignal({kind: 'foo', foo: 'foo-value' }} /> ``` +### Changing the tag key + +The default tag key is `"kind"`, but it can be changed with the `tag` prop: + +```tsx +type MyUnion = { + type: "foo", + foo: "foo-value", +} | { + type: "bar", + bar: "bar-value", +} + + <>{v().foo}, + bar: v => <>{v().bar}, +}} /> +``` + ## Demo [Deployed example](https://primitives.solidjs.community/playground/match) | [Source code](https://github.com/solidjs-community/solid-primitives/tree/main/packages/match/dev) diff --git a/packages/match/src/index.ts b/packages/match/src/index.ts index 54030acd3..ba8075428 100644 --- a/packages/match/src/index.ts +++ b/packages/match/src/index.ts @@ -13,7 +13,7 @@ import { type Accessor, type JSX, createMemo } from "solid-js" * bar: 'bar-value', * } * - * const [value, setValue] = s.createSignal({kind: 'foo', foo: 'foo-value'}) + * const [value, setValue] = createSignal({kind: 'foo', foo: 'foo-value'}) * * <>{v().foo}, From 716d25c0486c1adcf878bc9c250eadb57b408d79 Mon Sep 17 00:00:00 2001 From: Damian Tarnawski Date: Thu, 12 Jun 2025 19:56:44 +0200 Subject: [PATCH 05/13] match: Add `partial` prop and Match tests --- packages/match/src/index.ts | 27 +++++-- packages/match/test/index.test.ts | 19 ----- packages/match/test/index.test.tsx | 109 +++++++++++++++++++++++++++++ packages/match/test/server.test.ts | 9 --- 4 files changed, 131 insertions(+), 33 deletions(-) delete mode 100644 packages/match/test/index.test.ts create mode 100644 packages/match/test/index.test.tsx delete mode 100644 packages/match/test/server.test.ts diff --git a/packages/match/src/index.ts b/packages/match/src/index.ts index ba8075428..7ac3d072d 100644 --- a/packages/match/src/index.ts +++ b/packages/match/src/index.ts @@ -25,18 +25,35 @@ export function Match< T extends {[k in Tag]: PropertyKey}, Tag extends PropertyKey, >(props: { - on: T, + on: T | null | undefined, tag: Tag, case: {[K in T[Tag]]: (v: Accessor) => JSX.Element}, + partial?: false, }): JSX.Element export function Match< T extends {kind: PropertyKey}, >(props: { - on: T, + on: T | null | undefined, case: {[K in T['kind']]: (v: Accessor) => JSX.Element}, + partial?: false, +}): JSX.Element +export function Match< + T extends {[k in Tag]: PropertyKey}, + Tag extends PropertyKey, +>(props: { + on: T | null | undefined, + tag: Tag, + case: {[K in T[Tag]]?: (v: Accessor) => JSX.Element}, + partial: true, +}): JSX.Element +export function Match< + T extends {kind: PropertyKey}, +>(props: { + on: T | null | undefined, + case: {[K in T['kind']]?: (v: Accessor) => JSX.Element}, + partial: true, }): JSX.Element export function Match(props: any): any { - const kind = createMemo(() => props.on[props.tag ?? 'kind']) - return createMemo(() => props.case[kind()](() => props.on)) + const kind = createMemo(() => props.on?.[props.tag ?? 'kind']) + return createMemo(() => props.case[kind()]?.(() => props.on)) } - diff --git a/packages/match/test/index.test.ts b/packages/match/test/index.test.ts deleted file mode 100644 index 2f1ef641e..000000000 --- a/packages/match/test/index.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, test, expect } from "vitest"; -import { createRoot } from "solid-js"; -import { createPrimitiveTemplate } from "../src/index.js"; - -describe("createPrimitiveTemplate", () => { - test("createPrimitiveTemplate return values", () => { - const { value, setValue, dispose } = createRoot(dispose => { - const [value, setValue] = createPrimitiveTemplate(true); - expect(value(), "initial value should be true").toBe(true); - - return { value, setValue, dispose }; - }); - - setValue(false); - expect(value(), "value after change should be false").toBe(false); - - dispose(); - }); -}); diff --git a/packages/match/test/index.test.tsx b/packages/match/test/index.test.tsx new file mode 100644 index 000000000..67650d336 --- /dev/null +++ b/packages/match/test/index.test.tsx @@ -0,0 +1,109 @@ +import * as v from "vitest"; +import * as s from "solid-js"; +import { Match } from "../src/index.js"; + +v.describe("Match", () => { + v.test("match on kind field", () => { + + type MyUnion = { + kind: 'foo', + foo: 'foo-value', + } | { + kind: 'bar', + bar: 'bar-value', + } + + const [value, setValue] = s.createSignal() + + const data = s.createRoot(dispose => { + return { + dispose, + result: s.children(() => <> + <>{v().foo}, + bar: v => <>{v().bar}, + }} /> + ) + } + }) + + v.expect(data.result()).toEqual(undefined); + + setValue({kind: 'foo', foo: 'foo-value'}); + v.expect(data.result()).toEqual(<>foo-value); + + setValue({kind: 'bar', bar: 'bar-value'}); + v.expect(data.result()).toEqual(<>bar-value); + + data.dispose(); + }); + + v.test("match on type field", () => { + + type MyUnion = { + type: 'foo', + foo: 'foo-value', + } | { + type: 'bar', + bar: 'bar-value', + } + + const [value, setValue] = s.createSignal() + + const data = s.createRoot(dispose => { + return { + dispose, + result: s.children(() => <> + <>{v().foo}, + bar: v => <>{v().bar}, + }} /> + ) + } + }) + + v.expect(data.result()).toEqual(undefined); + + setValue({type: 'foo', foo: 'foo-value'}); + v.expect(data.result()).toEqual(<>foo-value); + + setValue({type: 'bar', bar: 'bar-value'}); + v.expect(data.result()).toEqual(<>bar-value); + + data.dispose(); + }); + + v.test("partial match", () => { + + type MyUnion = { + kind: 'foo', + foo: 'foo-value', + } | { + kind: 'bar', + bar: 'bar-value', + } + + const [value, setValue] = s.createSignal() + + const data = s.createRoot(dispose => { + return { + dispose, + result: s.children(() => <> + <>{v().foo}, + }} /> + ) + } + }) + + v.expect(data.result()).toEqual(undefined); + + setValue({kind: 'foo', foo: 'foo-value'}); + v.expect(data.result()).toEqual(<>foo-value); + + setValue({kind: 'bar', bar: 'bar-value'}); + v.expect(data.result()).toEqual(undefined); + + data.dispose(); + }); +}); diff --git a/packages/match/test/server.test.ts b/packages/match/test/server.test.ts deleted file mode 100644 index d0ba586ee..000000000 --- a/packages/match/test/server.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { describe, test, expect } from "vitest"; -import { createPrimitiveTemplate } from "../src/index.js"; - -describe("createPrimitiveTemplate", () => { - test("doesn't break in SSR", () => { - const [value, setValue] = createPrimitiveTemplate(true); - expect(value(), "initial value should be true").toBe(true); - }); -}); From 0a7969b3703d27cb78457e0a7fbb80147398f085 Mon Sep 17 00:00:00 2001 From: Damian Tarnawski Date: Thu, 12 Jun 2025 19:56:44 +0200 Subject: [PATCH 06/13] Add solid-js as a devDependency with version ^1.8.7 --- packages/match/package.json | 3 +++ pnpm-lock.yaml | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/packages/match/package.json b/packages/match/package.json index c529a83d4..5cbd4b764 100644 --- a/packages/match/package.json +++ b/packages/match/package.json @@ -51,5 +51,8 @@ }, "peerDependencies": { "solid-js": "^1.6.12" + }, + "devDependencies": { + "solid-js": "^1.8.7" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b5f64053..0839bd5d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -550,6 +550,12 @@ importers: specifier: ^1.9.7 version: 1.9.7 + packages/match: + devDependencies: + solid-js: + specifier: ^1.8.7 + version: 1.8.22 + packages/media: dependencies: '@solid-primitives/event-listener': From e071d5725c7482088b8693dc9786bc5114bf1f0d Mon Sep 17 00:00:00 2001 From: Damian Tarnawski Date: Thu, 12 Jun 2025 19:56:44 +0200 Subject: [PATCH 07/13] Add `fallback` prop --- packages/match/src/index.ts | 6 ++++- packages/match/test/index.test.tsx | 35 ++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/match/src/index.ts b/packages/match/src/index.ts index 7ac3d072d..edbfa2373 100644 --- a/packages/match/src/index.ts +++ b/packages/match/src/index.ts @@ -28,6 +28,7 @@ export function Match< on: T | null | undefined, tag: Tag, case: {[K in T[Tag]]: (v: Accessor) => JSX.Element}, + fallback?: JSX.Element, partial?: false, }): JSX.Element export function Match< @@ -35,6 +36,7 @@ export function Match< >(props: { on: T | null | undefined, case: {[K in T['kind']]: (v: Accessor) => JSX.Element}, + fallback?: JSX.Element, partial?: false, }): JSX.Element export function Match< @@ -44,6 +46,7 @@ export function Match< on: T | null | undefined, tag: Tag, case: {[K in T[Tag]]?: (v: Accessor) => JSX.Element}, + fallback?: JSX.Element, partial: true, }): JSX.Element export function Match< @@ -51,9 +54,10 @@ export function Match< >(props: { on: T | null | undefined, case: {[K in T['kind']]?: (v: Accessor) => JSX.Element}, + fallback?: JSX.Element, partial: true, }): JSX.Element export function Match(props: any): any { const kind = createMemo(() => props.on?.[props.tag ?? 'kind']) - return createMemo(() => props.case[kind()]?.(() => props.on)) + return createMemo(() => props.case[kind()]?.(() => props.on) ?? props.fallback) } diff --git a/packages/match/test/index.test.tsx b/packages/match/test/index.test.tsx index 67650d336..deb71c37f 100644 --- a/packages/match/test/index.test.tsx +++ b/packages/match/test/index.test.tsx @@ -106,4 +106,39 @@ v.describe("Match", () => { data.dispose(); }); + + v.test("fallback", () => { + + type MyUnion = { + kind: 'foo', + foo: 'foo-value', + } | { + kind: 'bar', + bar: 'bar-value', + } + + const [value, setValue] = s.createSignal() + + const data = s.createRoot(dispose => { + return { + dispose, + result: s.children(() => <> + <>{v().foo}, + bar: v => <>{v().bar}, + }} fallback={<>fallback} /> + ) + } + }) + + v.expect(data.result()).toEqual(<>fallback); + + setValue({kind: 'foo', foo: 'foo-value'}); + v.expect(data.result()).toEqual(<>foo-value); + + setValue(undefined); + v.expect(data.result()).toEqual(<>fallback); + + data.dispose(); + }); }); From 972e4d8c7c9d72f1aa700dedcb5b726809403b95 Mon Sep 17 00:00:00 2001 From: Damian Tarnawski Date: Thu, 12 Jun 2025 19:56:44 +0200 Subject: [PATCH 08/13] match: Update README to include examples for `partial` and `fallback` props --- packages/match/README.md | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/match/README.md b/packages/match/README.md index 9ef60d4f2..1e2b3baca 100644 --- a/packages/match/README.md +++ b/packages/match/README.md @@ -36,7 +36,7 @@ type MyUnion = { } const [value, setValue] = createSignal({kind: "foo", foo: "foo-value"}) - + <>{v().foo}, bar: v => <>{v().bar}, @@ -62,6 +62,28 @@ type MyUnion = { }} /> ``` +### Partial matching + +Use the `partial` prop to only handle some of the union members: + +```tsx + <>{v().foo}, + // bar case is not handled +}} /> +``` + +### Fallback + +Provide a fallback element when no match is found or the value is `null`/`undefined`: + +```tsx + <>{v().foo}, + bar: v => <>{v().bar}, +}} fallback={
No match found
} /> +``` + ## Demo [Deployed example](https://primitives.solidjs.community/playground/match) | [Source code](https://github.com/solidjs-community/solid-primitives/tree/main/packages/match/dev) From 790a782d2877cea5154415708b0f400a4cd6b3bf Mon Sep 17 00:00:00 2001 From: Damian Tarnawski Date: Thu, 12 Jun 2025 19:56:44 +0200 Subject: [PATCH 09/13] Add match demo --- packages/match/dev/index.tsx | 100 +++++++++++++++++++++++++++++++---- 1 file changed, 91 insertions(+), 9 deletions(-) diff --git a/packages/match/dev/index.tsx b/packages/match/dev/index.tsx index e2e5113e6..2fc4f6985 100644 --- a/packages/match/dev/index.tsx +++ b/packages/match/dev/index.tsx @@ -1,17 +1,99 @@ import { Component, createSignal } from "solid-js"; +import { Match } from "../src/index.js" + +type AnimalDog = {kind: 'dog', breed: string}; +type AnimalCat = {kind: 'cat', lives: number}; +type AnimalBird = {kind: 'bird', canFly: boolean}; + +type Animal = AnimalDog | AnimalCat | AnimalBird; + +const DogDisplay: Component<{ animal: AnimalDog }> = (props) => ( +
+
🐕
+
Breed: {props.animal.breed}
+
+); + +const CatDisplay: Component<{ animal: AnimalCat }> = (props) => ( +
+
🐱
+
Lives: {props.animal.lives}
+
+); + +const BirdDisplay: Component<{ animal: AnimalBird }> = (props) => ( +
+
🐦
+
+ {props.animal.canFly ? 'Can fly' : 'Cannot fly'} +
+
+); + +const FallbackDisplay: Component = () => ( +
+
+
Fallback content
+
+); const App: Component = () => { - const [count, setCount] = createSignal(0); - const increment = () => setCount(count() + 1); + const [animal, setAnimal] = createSignal(null); + + const animals: (Animal | null)[] = [ + null, + { kind: 'dog', breed: 'Golden Retriever' }, + { kind: 'cat', lives: 9 }, + { kind: 'bird', canFly: true }, + ]; return ( -
-
-

Counter component

-

it's very important...

- +
+
+
+

Match Component Demo

+

Control-flow component for matching discriminated union members

+
+ +
+ + +
+ +
+
+

Complete Match

+

Handles all union members with fallback

+
+ , + cat: v => , + bird: v => , + }} fallback={} /> +
+
+ +
+

Partial Match

+

Only handles dogs and cats

+
+ , + cat: v => , + }} fallback={} /> +
+
+
); From 150cbcbd87324401b13cfbd12a113b239c24358e Mon Sep 17 00:00:00 2001 From: Damian Tarnawski Date: Thu, 12 Jun 2025 19:56:44 +0200 Subject: [PATCH 10/13] Change the default tag field from "kind" to "type". --- packages/match/README.md | 14 ++++++------ packages/match/dev/index.tsx | 12 +++++----- packages/match/src/index.ts | 16 ++++++------- packages/match/test/index.test.tsx | 36 +++++++++++++++--------------- 4 files changed, 39 insertions(+), 39 deletions(-) diff --git a/packages/match/README.md b/packages/match/README.md index 1e2b3baca..f67790d63 100644 --- a/packages/match/README.md +++ b/packages/match/README.md @@ -28,14 +28,14 @@ Control-flow component for matching discriminated union (tagged union) members. ```tsx type MyUnion = { - kind: "foo", + type: "foo", foo: "foo-value", } | { - kind: "bar", + type: "bar", bar: "bar-value", } -const [value, setValue] = createSignal({kind: "foo", foo: "foo-value"}) +const [value, setValue] = createSignal({type: "foo", foo: "foo-value"}) <>{v().foo}, @@ -45,18 +45,18 @@ const [value, setValue] = createSignal({kind: "foo", foo: "foo-value"}) ### Changing the tag key -The default tag key is `"kind"`, but it can be changed with the `tag` prop: +The default tag key is `"type"`, but it can be changed with the `tag` prop: ```tsx type MyUnion = { - type: "foo", + kind: "foo", foo: "foo-value", } | { - type: "bar", + kind: "bar", bar: "bar-value", } - <>{v().foo}, bar: v => <>{v().bar}, }} /> diff --git a/packages/match/dev/index.tsx b/packages/match/dev/index.tsx index 2fc4f6985..05b0a85c6 100644 --- a/packages/match/dev/index.tsx +++ b/packages/match/dev/index.tsx @@ -1,9 +1,9 @@ import { Component, createSignal } from "solid-js"; import { Match } from "../src/index.js" -type AnimalDog = {kind: 'dog', breed: string}; -type AnimalCat = {kind: 'cat', lives: number}; -type AnimalBird = {kind: 'bird', canFly: boolean}; +type AnimalDog = {type: 'dog', breed: string}; +type AnimalCat = {type: 'cat', lives: number}; +type AnimalBird = {type: 'bird', canFly: boolean}; type Animal = AnimalDog | AnimalCat | AnimalBird; @@ -42,9 +42,9 @@ const App: Component = () => { const animals: (Animal | null)[] = [ null, - { kind: 'dog', breed: 'Golden Retriever' }, - { kind: 'cat', lives: 9 }, - { kind: 'bird', canFly: true }, + { type: 'dog', breed: 'Golden Retriever' }, + { type: 'cat', lives: 9 }, + { type: 'bird', canFly: true }, ]; return ( diff --git a/packages/match/src/index.ts b/packages/match/src/index.ts index edbfa2373..87bb87340 100644 --- a/packages/match/src/index.ts +++ b/packages/match/src/index.ts @@ -6,14 +6,14 @@ import { type Accessor, type JSX, createMemo } from "solid-js" * @example * ```tsx * type MyUnion = { - * kind: 'foo', + * type: 'foo', * foo: 'foo-value', * } | { - * kind: 'bar', + * type: 'bar', * bar: 'bar-value', * } * - * const [value, setValue] = createSignal({kind: 'foo', foo: 'foo-value'}) + * const [value, setValue] = createSignal({type: 'foo', foo: 'foo-value'}) * * <>{v().foo}, @@ -32,10 +32,10 @@ export function Match< partial?: false, }): JSX.Element export function Match< - T extends {kind: PropertyKey}, + T extends {type: PropertyKey}, >(props: { on: T | null | undefined, - case: {[K in T['kind']]: (v: Accessor) => JSX.Element}, + case: {[K in T['type']]: (v: Accessor) => JSX.Element}, fallback?: JSX.Element, partial?: false, }): JSX.Element @@ -50,14 +50,14 @@ export function Match< partial: true, }): JSX.Element export function Match< - T extends {kind: PropertyKey}, + T extends {type: PropertyKey}, >(props: { on: T | null | undefined, - case: {[K in T['kind']]?: (v: Accessor) => JSX.Element}, + case: {[K in T['type']]?: (v: Accessor) => JSX.Element}, fallback?: JSX.Element, partial: true, }): JSX.Element export function Match(props: any): any { - const kind = createMemo(() => props.on?.[props.tag ?? 'kind']) + const kind = createMemo(() => props.on?.[props.tag ?? 'type']) return createMemo(() => props.case[kind()]?.(() => props.on) ?? props.fallback) } diff --git a/packages/match/test/index.test.tsx b/packages/match/test/index.test.tsx index deb71c37f..656e499f9 100644 --- a/packages/match/test/index.test.tsx +++ b/packages/match/test/index.test.tsx @@ -3,13 +3,13 @@ import * as s from "solid-js"; import { Match } from "../src/index.js"; v.describe("Match", () => { - v.test("match on kind field", () => { + v.test("match on type field", () => { type MyUnion = { - kind: 'foo', + type: 'foo', foo: 'foo-value', } | { - kind: 'bar', + type: 'bar', bar: 'bar-value', } @@ -29,22 +29,22 @@ v.describe("Match", () => { v.expect(data.result()).toEqual(undefined); - setValue({kind: 'foo', foo: 'foo-value'}); + setValue({type: 'foo', foo: 'foo-value'}); v.expect(data.result()).toEqual(<>foo-value); - setValue({kind: 'bar', bar: 'bar-value'}); + setValue({type: 'bar', bar: 'bar-value'}); v.expect(data.result()).toEqual(<>bar-value); data.dispose(); }); - v.test("match on type field", () => { + v.test("match on kind field", () => { type MyUnion = { - type: 'foo', + kind: 'foo', foo: 'foo-value', } | { - type: 'bar', + kind: 'bar', bar: 'bar-value', } @@ -54,7 +54,7 @@ v.describe("Match", () => { return { dispose, result: s.children(() => <> - <>{v().foo}, bar: v => <>{v().bar}, }} /> @@ -64,10 +64,10 @@ v.describe("Match", () => { v.expect(data.result()).toEqual(undefined); - setValue({type: 'foo', foo: 'foo-value'}); + setValue({kind: 'foo', foo: 'foo-value'}); v.expect(data.result()).toEqual(<>foo-value); - setValue({type: 'bar', bar: 'bar-value'}); + setValue({kind: 'bar', bar: 'bar-value'}); v.expect(data.result()).toEqual(<>bar-value); data.dispose(); @@ -76,10 +76,10 @@ v.describe("Match", () => { v.test("partial match", () => { type MyUnion = { - kind: 'foo', + type: 'foo', foo: 'foo-value', } | { - kind: 'bar', + type: 'bar', bar: 'bar-value', } @@ -98,10 +98,10 @@ v.describe("Match", () => { v.expect(data.result()).toEqual(undefined); - setValue({kind: 'foo', foo: 'foo-value'}); + setValue({type: 'foo', foo: 'foo-value'}); v.expect(data.result()).toEqual(<>foo-value); - setValue({kind: 'bar', bar: 'bar-value'}); + setValue({type: 'bar', bar: 'bar-value'}); v.expect(data.result()).toEqual(undefined); data.dispose(); @@ -110,10 +110,10 @@ v.describe("Match", () => { v.test("fallback", () => { type MyUnion = { - kind: 'foo', + type: 'foo', foo: 'foo-value', } | { - kind: 'bar', + type: 'bar', bar: 'bar-value', } @@ -133,7 +133,7 @@ v.describe("Match", () => { v.expect(data.result()).toEqual(<>fallback); - setValue({kind: 'foo', foo: 'foo-value'}); + setValue({type: 'foo', foo: 'foo-value'}); v.expect(data.result()).toEqual(<>foo-value); setValue(undefined); From 1b3ed9ba63c48f966cf85c23f92932bab89dbe5f Mon Sep 17 00:00:00 2001 From: Damian Tarnawski Date: Thu, 12 Jun 2025 19:58:34 +0200 Subject: [PATCH 11/13] match: Update solid-js and use node --- packages/match/package.json | 5 +++-- pnpm-lock.yaml | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/match/package.json b/packages/match/package.json index 5cbd4b764..c85f6c2b1 100644 --- a/packages/match/package.json +++ b/packages/match/package.json @@ -44,7 +44,8 @@ }, "typesVersions": {}, "scripts": { - "dev": "tsx ../../scripts/dev.ts", + "dev": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/dev.ts", + "build": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/build.ts", "vitest": "vitest -c ../../configs/vitest.config.ts", "test": "pnpm run vitest", "test:ssr": "pnpm run vitest --mode ssr" @@ -53,6 +54,6 @@ "solid-js": "^1.6.12" }, "devDependencies": { - "solid-js": "^1.8.7" + "solid-js": "^1.9.7" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0839bd5d7..8651734b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -553,8 +553,8 @@ importers: packages/match: devDependencies: solid-js: - specifier: ^1.8.7 - version: 1.8.22 + specifier: ^1.9.7 + version: 1.9.7 packages/media: dependencies: From be5e956c1265b268f99f5128b78741fb6a07a685 Mon Sep 17 00:00:00 2001 From: Damian Tarnawski Date: Thu, 12 Jun 2025 20:20:59 +0200 Subject: [PATCH 12/13] match: Rename Match to MatchTag and add MatchValue --- packages/match/README.md | 53 +++++++++++++++--- packages/match/dev/index.tsx | 18 +++++-- packages/match/src/index.ts | 86 ++++++++++++++++++++---------- packages/match/test/index.test.tsx | 71 ++++++++++++++++++++++-- 4 files changed, 186 insertions(+), 42 deletions(-) diff --git a/packages/match/README.md b/packages/match/README.md index f67790d63..8b0db799f 100644 --- a/packages/match/README.md +++ b/packages/match/README.md @@ -8,7 +8,7 @@ [![version](https://img.shields.io/npm/v/@solid-primitives/match?style=for-the-badge)](https://www.npmjs.com/package/@solid-primitives/match) [![stage](https://img.shields.io/endpoint?style=for-the-badge&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-0.json)](https://github.com/solidjs-community/solid-primitives#contribution-process) -Control-flow component for matching discriminated union (tagged union) members. +Control-flow components for matching discriminated union (tagged union) members and union literals. ## Installation @@ -20,10 +20,10 @@ yarn add @solid-primitives/match pnpm add @solid-primitives/match ``` -## `Match` +## `MatchTag` Control-flow component for matching discriminated union (tagged union) members. - + ### How to use it ```tsx @@ -37,7 +37,7 @@ type MyUnion = { const [value, setValue] = createSignal({type: "foo", foo: "foo-value"}) - <>{v().foo}, bar: v => <>{v().bar}, }} /> @@ -56,7 +56,7 @@ type MyUnion = { bar: "bar-value", } - <>{v().foo}, bar: v => <>{v().bar}, }} /> @@ -67,7 +67,7 @@ type MyUnion = { Use the `partial` prop to only handle some of the union members: ```tsx - <>{v().foo}, // bar case is not handled }} /> @@ -78,12 +78,51 @@ Use the `partial` prop to only handle some of the union members: Provide a fallback element when no match is found or the value is `null`/`undefined`: ```tsx - <>{v().foo}, bar: v => <>{v().bar}, }} fallback={
No match found
} /> ``` +## `MatchValue` + +Control-flow component for matching union literals. + +### How to use it + +```tsx +type MyUnion = "foo" | "bar"; + +const [value, setValue] = createSignal("foo"); + +

foo

, + bar: () =>

bar

, +}} /> +``` + +### Partial matching + +Use the `partial` prop to only handle some of the union members: + +```tsx +

foo

, + // bar case is not handled +}} /> +``` + +### Fallback + +Provide a fallback element when no match is found or the value is `null`/`undefined`: + +```tsx +

foo

, + bar: () =>

bar

, +}} fallback={
No match found
} /> +``` + ## Demo [Deployed example](https://primitives.solidjs.community/playground/match) | [Source code](https://github.com/solidjs-community/solid-primitives/tree/main/packages/match/dev) diff --git a/packages/match/dev/index.tsx b/packages/match/dev/index.tsx index 05b0a85c6..6959f1939 100644 --- a/packages/match/dev/index.tsx +++ b/packages/match/dev/index.tsx @@ -1,5 +1,5 @@ import { Component, createSignal } from "solid-js"; -import { Match } from "../src/index.js" +import { MatchTag, MatchValue } from "../src/index.js"; type AnimalDog = {type: 'dog', breed: string}; type AnimalCat = {type: 'cat', lives: number}; @@ -75,7 +75,7 @@ const App: Component = () => {

Complete Match

Handles all union members with fallback

- , cat: v => , bird: v => , @@ -87,13 +87,25 @@ const App: Component = () => {

Partial Match

Only handles dogs and cats

- , cat: v => , }} fallback={} />
+ +
+

Value Match

+

Match on union literals

+
+

🐕

, + cat: () =>

🐱

, + bird: () =>

🐦

, + }} fallback={} /> +
+
); diff --git a/packages/match/src/index.ts b/packages/match/src/index.ts index 87bb87340..d8854eb3d 100644 --- a/packages/match/src/index.ts +++ b/packages/match/src/index.ts @@ -21,43 +21,75 @@ import { type Accessor, type JSX, createMemo } from "solid-js" * }} /> * ``` */ -export function Match< - T extends {[k in Tag]: PropertyKey}, - Tag extends PropertyKey, +export function MatchTag< + T extends {[k in Tag]: PropertyKey}, + Tag extends PropertyKey, >(props: { - on: T | null | undefined, - tag: Tag, - case: {[K in T[Tag]]: (v: Accessor) => JSX.Element}, + on: T | null | undefined, + tag: Tag, + case: {[K in T[Tag]]: (v: Accessor) => JSX.Element}, fallback?: JSX.Element, - partial?: false, + partial?: false, }): JSX.Element -export function Match< - T extends {type: PropertyKey}, +export function MatchTag< + T extends {type: PropertyKey}, >(props: { - on: T | null | undefined, - case: {[K in T['type']]: (v: Accessor) => JSX.Element}, + on: T | null | undefined, + case: {[K in T['type']]: (v: Accessor) => JSX.Element}, fallback?: JSX.Element, - partial?: false, + partial?: false, }): JSX.Element -export function Match< - T extends {[k in Tag]: PropertyKey}, - Tag extends PropertyKey, +export function MatchTag< + T extends {[k in Tag]: PropertyKey}, + Tag extends PropertyKey, >(props: { - on: T | null | undefined, - tag: Tag, - case: {[K in T[Tag]]?: (v: Accessor) => JSX.Element}, + on: T | null | undefined, + tag: Tag, + case: {[K in T[Tag]]?: (v: Accessor) => JSX.Element}, fallback?: JSX.Element, - partial: true, + partial: true, }): JSX.Element -export function Match< - T extends {type: PropertyKey}, +export function MatchTag< + T extends {type: PropertyKey}, >(props: { - on: T | null | undefined, - case: {[K in T['type']]?: (v: Accessor) => JSX.Element}, + on: T | null | undefined, + case: {[K in T['type']]?: (v: Accessor) => JSX.Element}, fallback?: JSX.Element, - partial: true, + partial: true, }): JSX.Element -export function Match(props: any): any { - const kind = createMemo(() => props.on?.[props.tag ?? 'type']) - return createMemo(() => props.case[kind()]?.(() => props.on) ?? props.fallback) +export function MatchTag(props: any): any { + const kind = createMemo(() => props.on?.[props.tag ?? 'type']) + return createMemo(() => props.case[kind()]?.(() => props.on) ?? props.fallback) +} + +/** + * Control-flow component for matching union literals. + * + * @example + * ```tsx + * type MyUnion = 'foo' | 'bar' + * + * const [value, setValue] = createSignal('foo') + * + *

foo

, + * bar: () =>

bar

, + * }} /> + * ``` + */ +export function MatchValue(props: { + on: T | null | undefined, + case: {[K in T]: (v: K) => JSX.Element}, + fallback?: JSX.Element, + partial?: false, +}): JSX.Element +export function MatchValue(props: { + on: T | null | undefined, + case: {[K in T]?: (v: K) => JSX.Element}, + fallback?: JSX.Element, + partial: true, +}): JSX.Element +export function MatchValue(props: any): any { + const kind = createMemo(() => props.on) + return createMemo(() => props.case[kind()]?.(kind()) ?? props.fallback) } diff --git a/packages/match/test/index.test.tsx b/packages/match/test/index.test.tsx index 656e499f9..885467f56 100644 --- a/packages/match/test/index.test.tsx +++ b/packages/match/test/index.test.tsx @@ -1,6 +1,6 @@ import * as v from "vitest"; import * as s from "solid-js"; -import { Match } from "../src/index.js"; +import { MatchTag, MatchValue } from "../src/index.js"; v.describe("Match", () => { v.test("match on type field", () => { @@ -19,7 +19,7 @@ v.describe("Match", () => { return { dispose, result: s.children(() => <> - <>{v().foo}, bar: v => <>{v().bar}, }} /> @@ -54,7 +54,7 @@ v.describe("Match", () => { return { dispose, result: s.children(() => <> - <>{v().foo}, bar: v => <>{v().bar}, }} /> @@ -89,7 +89,7 @@ v.describe("Match", () => { return { dispose, result: s.children(() => <> - <>{v().foo}, }} /> ) @@ -123,7 +123,7 @@ v.describe("Match", () => { return { dispose, result: s.children(() => <> - <>{v().foo}, bar: v => <>{v().bar}, }} fallback={<>fallback} /> @@ -142,3 +142,64 @@ v.describe("Match", () => { data.dispose(); }); }); + +v.describe("MatchValue", () => { + v.test("match on union literal", () => { + type MyUnion = 'foo' | 'bar' + const [value, setValue] = s.createSignal() + const data = s.createRoot(dispose => ({ + dispose, + result: s.children(() => <> +

foo

, + bar: () =>

bar

, + }} /> + ) + })) + v.expect(data.result()).toEqual(undefined) + setValue('foo') + v.expect(data.result()).toEqual(<>

foo

) + setValue('bar') + v.expect(data.result()).toEqual(<>

bar

) + data.dispose() + }) + + v.test("partial match", () => { + type MyUnion = 'foo' | 'bar' + const [value, setValue] = s.createSignal() + const data = s.createRoot(dispose => ({ + dispose, + result: s.children(() => <> +

foo

, + }} /> + ) + })) + v.expect(data.result()).toEqual(undefined) + setValue('foo') + v.expect(data.result()).toEqual(<>

foo

) + setValue('bar') + v.expect(data.result()).toEqual(undefined) + data.dispose() + }) + + v.test("fallback", () => { + type MyUnion = 'foo' | 'bar' + const [value, setValue] = s.createSignal() + const data = s.createRoot(dispose => ({ + dispose, + result: s.children(() => <> +

foo

, + bar: () =>

bar

, + }} fallback={

fallback

} /> + ) + })) + v.expect(data.result()).toEqual(<>

fallback

) + setValue('foo') + v.expect(data.result()).toEqual(<>

foo

) + setValue(undefined) + v.expect(data.result()).toEqual(<>

fallback

) + data.dispose() + }) +}) From 075ecce3355c7b1611fa7459ca6c5a579ba43e63 Mon Sep 17 00:00:00 2001 From: Damian Tarnawski Date: Wed, 6 Aug 2025 13:08:46 +0200 Subject: [PATCH 13/13] Add MatchField alias --- packages/match/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/match/src/index.ts b/packages/match/src/index.ts index d8854eb3d..683bb4003 100644 --- a/packages/match/src/index.ts +++ b/packages/match/src/index.ts @@ -61,6 +61,7 @@ export function MatchTag(props: any): any { const kind = createMemo(() => props.on?.[props.tag ?? 'type']) return createMemo(() => props.case[kind()]?.(() => props.on) ?? props.fallback) } +export {MatchTag as MatchField} /** * Control-flow component for matching union literals.