Skip to content

Introduce template primitives for modifiers and attrs #70

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Mar 19, 2021
Merged
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
1 change: 1 addition & 0 deletions .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/ban-types": "off",
"@typescript-eslint/no-var-requires": "off",
"@typescript-eslint/no-unused-vars": ["warn", { "varsIgnorePattern": "^_" }],
"@typescript-eslint/ban-ts-comment": ["error", { "ts-ignore": "allow-with-description" }],
"@typescript-eslint/explicit-function-return-type": [
"error",
Expand Down
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,19 @@ In order for GlimmerX entities to be interpretable by Glint, you currently need

#### Component Signatures

While GlimmerX components accept `Args` as a type parameter, the Glint version accepts `Signature`, which contains types for `Args` and `Yields`.
While GlimmerX components accept `Args` as a type parameter, the Glint version accepts `Signature`, which contains types for `Element`, `Args` and `Yields`.

The `Element` field declares what type of element(s), if any, the component applies its passed `...attributes` to. This is often the component's root element. Tracking this type ensures any modifiers used on your component will be compatible with the DOM element(s) they're ultimately attached to. If no `Element` is specified, it will be a type error to set any HTML attributes when invoking your component.

The `Yields` field specifies the names of any blocks the component yields to, as well as the type of any parameter(s) they'll receive. See the [Yieldable Named Blocks RFC] for further details.

```ts
import Component from '@glint/environment-glimmerx/component';
import { hbs } from '@glimmerx/component';

export interface ShoutSignature {
// We have a `<div>` as our root element
Element: HTMLDivElement;
// We accept one required argument, `message`
Args: {
message: string;
Expand All @@ -103,11 +109,15 @@ export class Shout extends Component<ShoutSignature> {
}

public static template = hbs`
{{yield this.louderPlease}}
<div ...attributes>
{{yield this.louderPlease}}
</div>
`;
}
```

[yieldable named blocks rfc]: https://github.com/emberjs/rfcs/blob/master/text/0460-yieldable-named-blocks.md

### With Ember.js

#### Import Paths
Expand All @@ -123,14 +133,16 @@ In order for GlimmerX entities to be interpretable by Glint, you currently need

#### Component Signatures

While Glimmer components accept `Args` as a type parameter, and Ember components accept no type parameters at all, the Glint version of each accepts `Signature`, which contains types for `Args` and `Yields`.
While Glimmer components accept `Args` as a type parameter, and Ember components accept no type parameters at all, the Glint version of each accepts `Signature`, which contains types for `Element`, `Args` and `Yields`. These three fields behave in the same way as they do for GlimmerX components, detailed above in that [Component Signatures](#component-signatures) section.

```ts
// app/components/super-table.ts

import Component from '@glint/environment-glimmerx/glimmer-component';

export interface SuperTableSignature<T> {
// We have a `<table>` as our root element
Element: HTMLTableElement;
// We accept an array of items, one per row
Args: {
items: Array<T>;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expectTypeOf } from 'expect-type';
import { Globals, invokeModifier, resolve } from '@glint/environment-ember-loose/types';
import { Globals, applyModifier, resolve } from '@glint/environment-ember-loose/types';

const on = resolve(Globals['on']);

Expand All @@ -26,4 +26,4 @@ on({}, 'keyup', (event) => {
expectTypeOf(event).toEqualTypeOf<KeyboardEvent>();
});

expectTypeOf(invokeModifier(on({}, 'click', () => {}))).toEqualTypeOf<void>();
expectTypeOf(applyModifier(on({}, 'click', () => {}))).toEqualTypeOf<void>();
13 changes: 8 additions & 5 deletions packages/environment-ember-loose/__tests__/modifier.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { CreatesModifier } from '@glint/template/-private';
class NeatModifier extends Modifier<{
NamedArgs: { multiplier?: number };
PositionalArgs: [input: string];
Element: HTMLImageElement;
}> {
private interval?: number;

Expand All @@ -24,6 +25,8 @@ import { CreatesModifier } from '@glint/template/-private';
}

didReceiveArguments(): void {
expectTypeOf(this.element).toEqualTypeOf<HTMLImageElement>();

this.interval = window.setInterval(() => {
alert('this is a typesafe modifier!');
}, this.multiplier * this.lengthOfInput);
Expand All @@ -36,8 +39,8 @@ import { CreatesModifier } from '@glint/template/-private';

let neat = resolve(NeatModifier);

expectTypeOf(neat({}, 'hello')).toEqualTypeOf<CreatesModifier>();
expectTypeOf(neat({ multiplier: 3 }, 'hello')).toEqualTypeOf<CreatesModifier>();
expectTypeOf(neat({}, 'hello')).toEqualTypeOf<CreatesModifier<HTMLImageElement>>();
expectTypeOf(neat({ multiplier: 3 }, 'hello')).toEqualTypeOf<CreatesModifier<HTMLImageElement>>();

// @ts-expect-error: missing required positional arg
neat({});
Expand All @@ -55,7 +58,7 @@ import { CreatesModifier } from '@glint/template/-private';
// Function-based modifier
{
let definition = modifier(
(element: Element, [input]: [string], { multiplier }: { multiplier?: number }) => {
(element: HTMLAudioElement, [input]: [string], { multiplier }: { multiplier?: number }) => {
let interval = window.setInterval(() => {
alert('this is a typesafe modifier!');
}, input.length * (multiplier ?? 1000));
Expand All @@ -66,8 +69,8 @@ import { CreatesModifier } from '@glint/template/-private';

let neat = resolve(definition);

expectTypeOf(neat({}, 'hello')).toEqualTypeOf<CreatesModifier>();
expectTypeOf(neat({ multiplier: 3 }, 'hello')).toEqualTypeOf<CreatesModifier>();
expectTypeOf(neat({}, 'hello')).toEqualTypeOf<CreatesModifier<HTMLAudioElement>>();
expectTypeOf(neat({ multiplier: 3 }, 'hello')).toEqualTypeOf<CreatesModifier<HTMLAudioElement>>();

// @ts-expect-error: missing required positional arg
neat({});
Expand Down
5 changes: 4 additions & 1 deletion packages/environment-ember-loose/ember-component/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ContextType, Invoke, TemplateContext } from '@glint/template/-private';
import type { Element } from '@glint/template/-private/attributes';
import type { AcceptsBlocks, EmptyObject } from '@glint/template/-private/signature';

const EmberComponent = window.require('ember').EmberComponent;
Expand All @@ -14,6 +15,7 @@ export type ArgsFor<T extends ComponentSignature> = 'Args' extends keyof T ? T['
export interface ComponentSignature {
Args?: Partial<Record<string, unknown>>;
Yields?: Partial<Record<string, Array<unknown>>>;
Element?: Element;
}

const Component = EmberComponent as new <T extends ComponentSignature = {}>(
Expand All @@ -22,7 +24,8 @@ const Component = EmberComponent as new <T extends ComponentSignature = {}>(

interface Component<T extends ComponentSignature = {}> extends EmberComponent {
[Invoke]: (args: Get<T, 'Args'>) => AcceptsBlocks<Get<T, 'Yields'>>;
[ContextType]: TemplateContext<this, Get<T, 'Args'>, Get<T, 'Yields'>>;
[Element]: Get<T, 'Element', null>;
[ContextType]: TemplateContext<this, Get<T, 'Args'>, Get<T, 'Yields'>, Get<T, 'Element', null>>;
}

export default Component;
6 changes: 4 additions & 2 deletions packages/environment-ember-loose/ember-modifier/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,14 @@ type Get<T, Key, Otherwise = EmptyObject> = Key extends keyof T

type ModifierFactory = <El extends Element, Positional extends unknown[] = [], Named = EmptyObject>(
fn: (element: El, positional: Positional, named: Named) => unknown
) => new () => Invokable<(named: Named, ...positional: Positional) => CreatesModifier>;
) => new () => Invokable<(named: Named, ...positional: Positional) => CreatesModifier<El>>;

export const modifier = emberModifier as ModifierFactory;

export interface ModifierSignature {
NamedArgs?: Record<string, unknown>;
PositionalArgs?: Array<unknown>;
Element?: Element;
}

const Modifier = emberModifier.default as new <T extends ModifierSignature>(
Expand All @@ -32,10 +33,11 @@ interface Modifier<T extends ModifierSignature>
named: Extract<Get<T, 'NamedArgs'>, Record<string, any>>;
positional: Extract<Get<T, 'PositionalArgs', []>, any[]>;
}> {
readonly element: Get<T, 'Element', Element>;
[Invoke]: (
args: Get<T, 'NamedArgs'>,
...positional: Get<T, 'PositionalArgs', []>
) => CreatesModifier;
) => CreatesModifier<this['element']>;
}

export default Modifier;
5 changes: 4 additions & 1 deletion packages/environment-ember-loose/glimmer-component/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ContextType, Invoke, TemplateContext } from '@glint/template/-private';
import type { Element } from '@glint/template/-private/attributes';
import type { AcceptsBlocks, EmptyObject } from '@glint/template/-private/signature';

const GlimmerComponent = window.require('@glimmer/component').default;
Expand All @@ -12,6 +13,7 @@ type Get<T, Key, Otherwise = EmptyObject> = Key extends keyof T
export interface ComponentSignature {
Args?: Partial<Record<string, unknown>>;
Yields?: Partial<Record<string, Array<unknown>>>;
Element?: Element;
}

const Component = GlimmerComponent as new <T extends ComponentSignature = {}>(
Expand All @@ -20,7 +22,8 @@ const Component = GlimmerComponent as new <T extends ComponentSignature = {}>(

interface Component<T extends ComponentSignature = {}> extends GlimmerComponent<Get<T, 'Args'>> {
[Invoke]: (args: Get<T, 'Args'>) => AcceptsBlocks<Get<T, 'Yields'>>;
[ContextType]: TemplateContext<this, Get<T, 'Args'>, Get<T, 'Yields'>>;
[Element]: Get<T, 'Element', null>;
[ContextType]: TemplateContext<this, Get<T, 'Args'>, Get<T, 'Yields'>, Get<T, 'Element', null>>;
}

export default Component;
8 changes: 6 additions & 2 deletions packages/environment-ember-loose/types/intrinsics/on.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ export type OnModifier = DirectInvokable<{
args: OnModifierArgs,
name: Name,
callback: (event: HTMLElementEventMap[Name]) => void
): CreatesModifier;
(args: OnModifierArgs, name: string, callback: (event: Event) => void): CreatesModifier;
): CreatesModifier<HTMLElement>;
(
args: OnModifierArgs,
name: string,
callback: (event: Event) => void
): CreatesModifier<HTMLElement>;
}>;
4 changes: 2 additions & 2 deletions packages/environment-glimmerx/__tests__/modifier.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { on as onDefinition } from '@glint/environment-glimmerx/modifier';
import { resolve, invokeModifier } from '@glint/environment-glimmerx/types';
import { resolve, applyModifier } from '@glint/environment-glimmerx/types';
import { expectTypeOf } from 'expect-type';

// Built-in modifier: `on`
Expand All @@ -22,5 +22,5 @@ import { expectTypeOf } from 'expect-type';
expectTypeOf(event).toEqualTypeOf<MouseEvent>();
});

expectTypeOf(invokeModifier(on({}, 'click', () => {}))).toEqualTypeOf<void>();
expectTypeOf(applyModifier(on({}, 'click', () => {}))).toEqualTypeOf<void>();
}
5 changes: 4 additions & 1 deletion packages/environment-glimmerx/component/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export * from '@glimmerx/component';

import type { ContextType, Invoke } from '@glint/template/-private';
import type { TemplateContext, AcceptsBlocks } from '@glint/template/-private';
import type { Element } from '@glint/template/-private/attributes';
import type { EmptyObject } from '@glint/template/-private/signature';

type Get<T, Key, Otherwise = EmptyObject> = Key extends keyof T
Expand All @@ -13,6 +14,7 @@ type Get<T, Key, Otherwise = EmptyObject> = Key extends keyof T
export interface ComponentSignature {
Args?: Partial<Record<string, unknown>>;
Yields?: Partial<Record<string, Array<unknown>>>;
Element?: Element;
}

const Component = glimmerxComponent.default as new <
Expand All @@ -21,7 +23,8 @@ const Component = glimmerxComponent.default as new <
interface Component<T extends ComponentSignature = {}>
extends glimmerxComponent.default<Get<T, 'Args'> & {}> {
[Invoke]: (args: Get<T, 'Args'>) => AcceptsBlocks<Get<T, 'Yields'>>;
[ContextType]: TemplateContext<this, Get<T, 'Args'>, Get<T, 'Yields'>>;
[Element]: Get<T, 'Element', null>;
[ContextType]: TemplateContext<this, Get<T, 'Args'>, Get<T, 'Yields'>, Get<T, 'Element', null>>;
}

export default Component;
2 changes: 1 addition & 1 deletion packages/environment-glimmerx/modifier/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ type OnModifier = DirectInvokable<
args: EmptyObject,
name: Name,
callback: (event: HTMLElementEventMap[Name]) => void
) => CreatesModifier
) => CreatesModifier<HTMLElement>
>;

export const on = (glimmerxModifier.on as unknown) as OnModifier;
5 changes: 4 additions & 1 deletion packages/environment-glimmerx/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
Invoke,
InvokeDirect,
} from '@glint/template/-private/resolution';
import { NoNamedArgs } from '@glint/template/-private/signature';
import { CreatesModifier, NoNamedArgs } from '@glint/template/-private/signature';

export declare function resolve<T extends DirectInvokable>(item: T): T[typeof InvokeDirect];
export declare function resolve<Args extends unknown[], Instance extends Invokable>(
Expand All @@ -34,6 +34,9 @@ export declare function resolve<Args extends unknown[], Instance extends Invokab
export declare function resolve<Value, Args extends unknown[], T extends Value>(
item: (value: Value, ...args: Args) => value is T
): (named: NoNamedArgs, value: Value, ...args: Args) => value is T;
export declare function resolve<El extends Element, Args extends unknown[]>(
item: (element: El, ...args: Args) => void | (() => void)
): (named: NoNamedArgs, ...args: Args) => CreatesModifier<El>;
export declare function resolve<Args extends unknown[], T>(
item: (...args: Args) => T
): (named: NoNamedArgs, ...args: Args) => T;
Expand Down
32 changes: 32 additions & 0 deletions packages/template/-private/attributes.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { CreatesModifier } from './signature';

declare const Element: unique symbol;
export type HasElement<El extends Element | null | undefined> = { [Element]: El };

export type ElementForTagName<Name extends string> = Name extends keyof HTMLElementTagNameMap
? HTMLElementTagNameMap[Name]
: Element;

export type ElementForComponent<T extends Constructor<HasElement<any>>> = T extends Constructor<
HasElement<infer El>
>
? El
: null;

type Constructor<T> = new (...args: any) => T;

// <div ...attributes>
export declare function applySplattributes<
SourceElement extends Element,
_TargetElement extends SourceElement
>(): void;

// <div foo="bar">
export declare function applyAttributes<_TargetElement extends Element>(
attrs: Record<string, unknown>
): void;

// <div {{someModifier}}>
export declare function applyModifier<TargetElement extends Element>(
modifier: CreatesModifier<TargetElement>
): void;
11 changes: 6 additions & 5 deletions packages/template/-private/blocks.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@
* defined in the context for the containing component, `𝚪`.
*/

import { AnyContext } from './resolution';
import { AnyBlocks } from './signature';
import { TemplateContext } from './template';

/**
* Given a mapping from block names to the parameters they'll receive, produces
Expand All @@ -47,7 +47,8 @@ export type BlockBodies<Yields extends AnyBlocks> = {
*
* yieldToBlock(𝚪, 'name', foo, bar);
*/
export declare function yieldToBlock<
Context extends TemplateContext<any, any, any>,
K extends keyof Context['yields']
>(𝚪: Context, to: K, ...values: NonNullable<Context['yields'][K]>): void;
export declare function yieldToBlock<Context extends AnyContext, K extends keyof Context['yields']>(
𝚪: Context,
to: K,
...values: NonNullable<Context['yields'][K]>
): void;
10 changes: 1 addition & 9 deletions packages/template/-private/invoke.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CreatesModifier, AcceptsBlocks } from './signature';
import { AcceptsBlocks } from './signature';
import { BlockBodies } from './blocks';

/**
Expand All @@ -15,14 +15,6 @@ export declare function invokeEmit<
T extends AcceptsBlocks<{}> | string | number | boolean | null | void
>(value: T): void;

/**
* Invokes the given value as a modifier. This corresponds to a mustache
* statement 'floating' in the attribute space of an element or component:
*
* <div {{value foo=bar}}></div>
*/
export declare function invokeModifier<T extends CreatesModifier>(value: T): void;

/**
* Invokes the given value as an entity that expects to receive blocks
* rather than return a value. This corresponds to a block-form mustache
Expand Down
2 changes: 1 addition & 1 deletion packages/template/-private/resolution.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ declare const ContextType: unique symbol;
export type HasContext<T extends AnyContext = AnyContext> = { [ContextType]: T };

export type AnySignature = (...args: any) => any;
export type AnyContext = TemplateContext<any, any, any>;
export type AnyContext = TemplateContext<any, any, any, any>;
export type ResolveContext<T> = T extends HasContext<infer Context> ? Context : unknown;

/*
Expand Down
2 changes: 1 addition & 1 deletion packages/template/-private/signature.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ declare const Blocks: unique symbol;
export type AnyBlocks = Partial<Record<string, any[]>>;

/** Denotes that the associated entity should be invoked as a modifier */
export type CreatesModifier = { [Modifier]: true };
export type CreatesModifier<El extends Element> = { [Modifier]: (el: El) => void };

// These shenanigans are necessary to get TS to report when named args
// are passed to a signature that doesn't expect any, because `{}` is
Expand Down
Loading