Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,6 @@ export interface Hooks {

export type AtLeastOne<T, U = { [K in keyof T]: Pick<T, K> }> = Partial<T> &
U[keyof U];
export interface NonEmptyArray<A> extends Array<A> {
0: A;
}
140 changes: 106 additions & 34 deletions src/core/variant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,62 +7,134 @@ import {
BindableProps,
Context,
Hooks,
NonEmptyArray,
PartialBoundProps,
View,
} from './types';

type SourceKeysCasesConfig<
Props,
Variant extends string,
Bind extends BindableProps<Props>,
> = {
source: Store<Variant>;
bind?: Bind;
cases: AtLeastOne<Record<Variant, View<Props>>>;
hooks?: Hooks;
default?: View<Props>;
};

type ArrayCasesConfig<Props, Source, Bind extends BindableProps<Props>> = {
source: Store<Source>;
cases: NonEmptyArray<{ filter: (source: Source) => boolean; view: View<Props> }>;
bind?: Bind;
hooks?: Hooks;
default?: View<Props>;
};

type Config<
Props,
Variant extends string,
Bind extends BindableProps<Props>,
Source,
> =
| ArrayCasesConfig<Props, Source, Bind>
| SourceKeysCasesConfig<Props, Variant, Bind>
| {
if: Store<boolean>;
then: View<Props>;
else?: View<Props>;
hooks?: Hooks;
bind?: Bind;
};

const Default = () => null;

function isArrayCasesConfig<
Props,
Variant extends string,
Bind extends BindableProps<Props>,
Source,
>(
config: Config<Props, Variant, Bind, Source>,
): config is ArrayCasesConfig<Props, Source, Bind> {
if ('cases' in config) {
return Array.isArray(config.cases);
}

return false;
}

function isSourceKeysCasesConfig<
Props,
Variant extends string,
Bind extends BindableProps<Props>,
Source,
>(
config: Config<Props, Variant, Bind, Source>,
): config is SourceKeysCasesConfig<Props, Variant, Bind> {
if ('cases' in config) {
return !Array.isArray(config.cases);
}

return false;
}

export function variantFactory(context: Context) {
const reflect = reflectFactory(context);

return function variant<
Props,
Variant extends string,
Bind extends BindableProps<Props>,
Source,
>(
config:
| {
source: Store<Variant>;
bind?: Bind;
cases: AtLeastOne<Record<Variant, View<Props>>>;
hooks?: Hooks;
default?: View<Props>;
}
| {
if: Store<boolean>;
then: View<Props>;
else?: View<Props>;
hooks?: Hooks;
bind?: Bind;
},
config: Config<Props, Variant, Bind, Source>,
): React.FC<PartialBoundProps<Props, Bind>> {
let $case: Store<Variant>;
let cases: AtLeastOne<Record<Variant, View<Props>>>;
let def: View<Props>;
let View: View<Props>;

// Shortcut for Store<boolean>
if ('if' in config) {
$case = config.if.map((value): Variant => (value ? 'then' : 'else') as Variant);
if (isArrayCasesConfig(config)) {
View = (props: Props) => {
const source = context.useUnit(config.source);
let Component = config.default ?? Default;

cases = {
then: config.then,
else: config.else,
} as unknown as AtLeastOne<Record<Variant, View<Props>>>;
def = Default;
}
// Full form for Store<string>
else {
$case = config.source;
cases = config.cases;
def = config.default ?? Default;
}
for (const oneOfCases of config.cases) {
if (oneOfCases.filter(source)) {
Component = oneOfCases.view;
}
}

return React.createElement(Component as any, props as any);
};
} else {
// Shortcut for Store<boolean>
if ('if' in config) {
$case = config.if.map(
(value): Variant => (value ? 'then' : 'else') as Variant,
);

cases = {
then: config.then,
else: config.else,
} as unknown as AtLeastOne<Record<Variant, View<Props>>>;
def = Default;
}
// Full form for Store<string>
else if (isSourceKeysCasesConfig(config)) {
$case = config.source;
cases = config.cases;
def = config.default ?? Default;
}

function View(props: Props) {
const nameOfCase = context.useUnit($case);
const Component = cases[nameOfCase] ?? def;
View = (props: Props) => {
const nameOfCase = context.useUnit($case);
const Component = cases[nameOfCase] ?? def;

return React.createElement(Component as any, props as any);
return React.createElement(Component as any, props as any);
};
}

const bind = config.bind ?? ({} as Bind);
Expand Down
41 changes: 41 additions & 0 deletions type-tests/types-variant.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,16 @@ import { variant } from '../src';
});

expectType<React.FC>(CurrentPage);

const Page = variant({
source: $page,
bind: { context: $pageContext },
// @ts-expect-error
cases: [],
default: NotFoundPage,
});

expectType<React.FC>(Page);
}

// variant allows to set every possble case
Expand Down Expand Up @@ -143,6 +153,37 @@ import { variant } from '../src';
expectType<React.FC>(CurrentPage);
}

// overload for cases as array
{
type PageProps = {
context: {
route: string;
};
};

const $ctx = createStore({ route: 'home' });

const UserProfile: React.FC<PageProps> = () => null;
const AdminProfile: React.FC<PageProps> = () => null;
const $user = createStore({ isAdmin: false });

const Profile = variant({
source: $user,
cases: [
{
filter: (user) => user.isAdmin,
view: UserProfile,
},
{
filter: (user) => !user.isAdmin,
view: AdminProfile,
},
],
bind: { context: $ctx },
});
expectType<React.FC>(Profile);
}

// overload for boolean source
{
type PageProps = {
Expand Down