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..8b0db799f --- /dev/null +++ b/packages/match/README.md @@ -0,0 +1,132 @@ +

+ 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 components for matching discriminated union (tagged union) members and union literals. + +## Installation + +```bash +npm install @solid-primitives/match +# or +yarn add @solid-primitives/match +# or +pnpm add @solid-primitives/match +``` + +## `MatchTag` + +Control-flow component for matching discriminated union (tagged union) members. + +### How to use it + +```tsx +type MyUnion = { + type: "foo", + foo: "foo-value", +} | { + type: "bar", + bar: "bar-value", +} + +const [value, setValue] = createSignal({type: "foo", foo: "foo-value"}) + + <>{v().foo}, + bar: v => <>{v().bar}, +}} /> +``` + +### Changing the tag key + +The default tag key is `"type"`, but it can be changed with the `tag` prop: + +```tsx +type MyUnion = { + kind: "foo", + foo: "foo-value", +} | { + kind: "bar", + bar: "bar-value", +} + + <>{v().foo}, + bar: v => <>{v().bar}, +}} /> +``` + +### 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
} /> +``` + +## `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) + +## 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..6959f1939 --- /dev/null +++ b/packages/match/dev/index.tsx @@ -0,0 +1,114 @@ +import { Component, createSignal } from "solid-js"; +import { MatchTag, MatchValue } from "../src/index.js"; + +type AnimalDog = {type: 'dog', breed: string}; +type AnimalCat = {type: 'cat', lives: number}; +type AnimalBird = {type: '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 [animal, setAnimal] = createSignal(null); + + const animals: (Animal | null)[] = [ + null, + { type: 'dog', breed: 'Golden Retriever' }, + { type: 'cat', lives: 9 }, + { type: 'bird', canFly: true }, + ]; + + return ( +
+
+
+

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={} /> +
+
+
+ +
+

Value Match

+

Match on union literals

+
+

🐕

, + cat: () =>

🐱

, + bird: () =>

🐦

, + }} fallback={} /> +
+
+
+
+ ); +}; + +export default App; diff --git a/packages/match/package.json b/packages/match/package.json new file mode 100644 index 000000000..c85f6c2b1 --- /dev/null +++ b/packages/match/package.json @@ -0,0 +1,59 @@ +{ + "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": [ + "Match" + ], + "category": "Control Flow" + }, + "keywords": [ + "solid", + "primitives", + "union" + ], + "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": "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" + }, + "peerDependencies": { + "solid-js": "^1.6.12" + }, + "devDependencies": { + "solid-js": "^1.9.7" + } +} diff --git a/packages/match/src/index.ts b/packages/match/src/index.ts new file mode 100644 index 000000000..683bb4003 --- /dev/null +++ b/packages/match/src/index.ts @@ -0,0 +1,96 @@ +import { type Accessor, type JSX, createMemo } from "solid-js" + +/** + * Control-flow component for matching discriminated union (tagged union) members. + * + * @example + * ```tsx + * type MyUnion = { + * type: 'foo', + * foo: 'foo-value', + * } | { + * type: 'bar', + * bar: 'bar-value', + * } + * + * const [value, setValue] = createSignal({type: 'foo', foo: 'foo-value'}) + * + * <>{v().foo}, + * bar: v => <>{v().bar}, + * }} /> + * ``` + */ +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}, + fallback?: JSX.Element, + partial?: false, +}): JSX.Element +export function MatchTag< + T extends {type: PropertyKey}, +>(props: { + on: T | null | undefined, + case: {[K in T['type']]: (v: Accessor) => JSX.Element}, + fallback?: JSX.Element, + partial?: false, +}): JSX.Element +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}, + fallback?: JSX.Element, + partial: true, +}): JSX.Element +export function MatchTag< + T extends {type: PropertyKey}, +>(props: { + on: T | null | undefined, + case: {[K in T['type']]?: (v: Accessor) => JSX.Element}, + fallback?: JSX.Element, + partial: true, +}): JSX.Element +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. + * + * @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 new file mode 100644 index 000000000..885467f56 --- /dev/null +++ b/packages/match/test/index.test.tsx @@ -0,0 +1,205 @@ +import * as v from "vitest"; +import * as s from "solid-js"; +import { MatchTag, MatchValue } from "../src/index.js"; + +v.describe("Match", () => { + 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("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("partial match", () => { + + 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}, + }} /> + ) + } + }) + + 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(undefined); + + data.dispose(); + }); + + v.test("fallback", () => { + + 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}, + }} fallback={<>fallback} /> + ) + } + }) + + v.expect(data.result()).toEqual(<>fallback); + + setValue({type: 'foo', foo: 'foo-value'}); + v.expect(data.result()).toEqual(<>foo-value); + + setValue(undefined); + v.expect(data.result()).toEqual(<>fallback); + + 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() + }) +}) 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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b5f64053..8651734b2 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.9.7 + version: 1.9.7 + packages/media: dependencies: '@solid-primitives/event-listener':