diff --git a/.changeset/status-matching.md b/.changeset/status-matching.md new file mode 100644 index 0000000..1072a12 --- /dev/null +++ b/.changeset/status-matching.md @@ -0,0 +1,5 @@ +--- +"@bearstudio/ui-state": minor +--- + +Allow `getUiState` to take a status string so `.match().exhaustive()` works without a `set()` callback. A wide `string` is a type error, since it cannot be matched exhaustively. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 6175f58..64c8682 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -17,23 +17,23 @@ diverse, inclusive, and healthy community. Examples of behavior that contributes to a positive environment for our community include: -* Demonstrating empathy and kindness toward other people -* Being respectful of differing opinions, viewpoints, and experiences -* Giving and gracefully accepting constructive feedback -* Accepting responsibility and apologizing to those affected by our mistakes, +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience -* Focusing on what is best not just for us as individuals, but for the +- Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: -* The use of sexualized language or imagery, and sexual attention or +- The use of sexualized language or imagery, and sexual attention or advances of any kind -* Trolling, insulting or derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or email +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, without their explicit permission -* Other conduct which could reasonably be considered inappropriate in a +- Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities @@ -106,7 +106,7 @@ Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community -standards, including sustained inappropriate behavior, harassment of an +standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within @@ -125,4 +125,4 @@ enforcement ladder](https://github.com/mozilla/diversity). For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at -https://www.contributor-covenant.org/translations. \ No newline at end of file +https://www.contributor-covenant.org/translations. diff --git a/README.md b/README.md index 32e6d6e..431e8b2 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,16 @@ ui.match("pending", () => <>Loading...) .exhaustive(); ``` +When the status is already known, pass it directly — no `set()` callback: + +```tsx +getUiState(query.status) + .match("pending", () => <>Loading...) + .match("error", () => <>Error) + .match("success", () => <>Ready) + .exhaustive(); +``` + ## 🪄 Fully customizable and type-safe ```tsx diff --git a/packages/ui-state/README.md b/packages/ui-state/README.md index 46b4ccc..ff17c15 100644 --- a/packages/ui-state/README.md +++ b/packages/ui-state/README.md @@ -39,6 +39,16 @@ ui.match("pending", () => <>Loading...) .exhaustive(); ``` +When the status is already known, pass it directly — no `set()` callback: + +```tsx +getUiState(query.status) + .match("pending", () => <>Loading...) + .match("error", () => <>Error) + .match("success", () => <>Ready) + .exhaustive(); +``` + ## 📃 License MIT > Made with ❤️ by BearStudio Team diff --git a/packages/ui-state/src/index.test.ts b/packages/ui-state/src/index.test.ts index 650f90b..ba478c1 100644 --- a/packages/ui-state/src/index.test.ts +++ b/packages/ui-state/src/index.test.ts @@ -24,6 +24,26 @@ describe("getUiState", () => { }); }); + describe("from a status string", () => { + it("should create a UI state from a status string", () => { + const ui = getUiState("pending"); + + expect(ui.state.__status).toBe("pending"); + }); + + it("should match and exhaustive from a status union", () => { + const status = "error" as "pending" | "error" | "success"; + + const result = getUiState(status) + .match("pending", () => "Loading") + .match("error", () => "Error") + .match("success", () => "OK") + .exhaustive(); + + expect(result).toBe("Error"); + }); + }); + describe("is() method", () => { it("should return true when status matches", () => { const ui = getUiState((set) => set("pending")); diff --git a/packages/ui-state/src/index.ts b/packages/ui-state/src/index.ts index aa07445..d153ddb 100644 --- a/packages/ui-state/src/index.ts +++ b/packages/ui-state/src/index.ts @@ -18,6 +18,9 @@ type UiStateError = null | { type NonExhaustiveError = UiStateError; type ExhaustiveError = UiStateError; +type ForbidWideString = string extends S + ? ExhaustiveError<"`string` cannot be exhaustive. Use a string literal union."> + : S; type Prettify = { [K in keyof T]: T[K] } & {}; @@ -41,7 +44,7 @@ type GetUiStateSet = < S extends AvailableStatus, SData extends Record | undefined = undefined, >( - status: S, + status: ForbidWideString, data?: SData, ) => SetResult; @@ -59,6 +62,21 @@ type MatchResult = { match: UiState["match"]; }); +type SplitStatus = S extends unknown + ? { __status: S } + : never; + +type UiStateFromInput = [T] extends [(...args: never[]) => unknown] + ? UiState< + Extract< + ReturnType unknown>>, + { __status: AvailableStatus } + > + > + : [T] extends [string] + ? UiState> + : never; + type UiState = { is: ( status: S, @@ -82,28 +100,21 @@ type UiState = { ) => MatchResult>; }; -export const getUiState = ( - getState: (set: GetUiStateSet) => T, +const createUiState = ( + state: T, ): UiState => { - const state = Object.freeze( - getState(((status, data = {} as ExplicitAny) => { - return { - __status: status, - ...data, - }; - }) as GetUiStateSet), - ); + const frozenState = Object.freeze(state); const isMatching = (status: T["__status"]): boolean => - status === state.__status; + status === frozenState.__status; const isMatchingArray = (status: Array): boolean => - status.includes(state.__status); + status.includes(frozenState.__status); const uiState: UiState = { - state, + state: frozenState, is: ((status: T["__status"]) => { - return state.__status === status; + return frozenState.__status === status; }) as UiState["is"], when: (status, handler) => { if ( @@ -111,7 +122,7 @@ export const getUiState = ( ? isMatching(status) : isMatchingArray(status) ) { - return handler(state as ExplicitAny); + return handler(frozenState as ExplicitAny); } return null; }, @@ -125,9 +136,10 @@ export const getUiState = ( : isMatchingArray(status)) ) { return { - exhaustive: () => handler(state as ExplicitAny) as React.ReactNode, + exhaustive: () => + handler(frozenState as ExplicitAny) as React.ReactNode, nonExhaustive: () => - handler(state as ExplicitAny) as React.ReactNode, + handler(frozenState as ExplicitAny) as React.ReactNode, match: (nextStatus: ExplicitAny, nextHandler: ExplicitAny) => uiState.match(nextStatus, nextHandler, true, () => handler(uiState.state as ExplicitAny), @@ -146,3 +158,23 @@ export const getUiState = ( return uiState; }; + +export function getUiState< + T extends string | ((set: GetUiStateSet) => { __status: AvailableStatus }), +>( + statusOrGetState: T extends string ? ForbidWideString : T, +): UiStateFromInput; +export function getUiState( + statusOrGetState: ExplicitAny, +): UiState<{ __status: AvailableStatus }> { + if (typeof statusOrGetState === "string") { + return createUiState({ __status: statusOrGetState }); + } + + return createUiState( + statusOrGetState((status: AvailableStatus, data = {} as ExplicitAny) => ({ + __status: status, + ...data, + })), + ); +} diff --git a/packages/ui-state/src/index.types.test.ts b/packages/ui-state/src/index.types.test.ts index d616575..59ba99e 100644 --- a/packages/ui-state/src/index.types.test.ts +++ b/packages/ui-state/src/index.types.test.ts @@ -115,3 +115,48 @@ describe("getUiState empty payload vs data payload", () => { }); }); }); + +describe("getUiState rejects a wide string", () => { + it("errors when getUiState() is called with a string", () => { + const status = "pending" as string; + // @ts-expect-error string cannot be exhaustive + const ui = getUiState(status); + expect(ui.state.__status).toBe("pending"); + }); + + it("errors when set() is called with a string", () => { + const status = "pending" as string; + const ui = getUiState((set) => + // @ts-expect-error string cannot be exhaustive + set(status), + ); + expect(ui.state.__status).toBe("pending"); + }); +}); + +describe("getUiState from a status string", () => { + const status = "pending" as "pending" | "error" | "success"; + const ui = getUiState(status); + + it("splits a string union into a discriminated state", () => { + expectTypeOf(ui.state).toEqualTypeOf< + { __status: "pending" } | { __status: "error" } | { __status: "success" } + >(); + }); + + it("exhaustive() is not callable while statuses remain", () => { + const partial = ui.match("pending", () => null).match("error", () => null); + expectTypeOf(partial.exhaustive).not.toEqualTypeOf<() => ReactNode>(); + // @ts-expect-error success is missing + partial.exhaustive(); + }); + + it("exhaustive() is callable when the union is covered", () => { + const done = ui + .match("pending", () => "p") + .match("error", () => "e") + .match("success", () => "s"); + expectTypeOf(done.exhaustive).toEqualTypeOf<() => ReactNode>(); + expect(done.exhaustive()).toBe("p"); + }); +});