Skip to content
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

Angular: Add type support for Angular's input signals #26413

Merged
merged 12 commits into from
Mar 20, 2024
Merged
27 changes: 14 additions & 13 deletions code/frameworks/angular/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
],
"scripts": {
"check": "node ../../../scripts/node_modules/.bin/tsc",
"prep": "node --loader ../../../scripts/node_modules/esbuild-register/loader.js -r ../../../scripts/node_modules/esbuild-register/register.js ../../../scripts/prepare/tsc.ts"
"prep": "rimraf dist && node --loader ../../../scripts/node_modules/esbuild-register/loader.js -r ../../../scripts/node_modules/esbuild-register/register.js ../../../scripts/prepare/tsc.ts"
},
"dependencies": {
"@storybook/builder-webpack5": "workspace:*",
Expand Down Expand Up @@ -65,18 +65,18 @@
},
"devDependencies": {
"@analogjs/vite-plugin-angular": "^0.2.24",
"@angular-devkit/architect": "^0.1700.5",
"@angular-devkit/build-angular": "^17.0.5",
"@angular-devkit/core": "^17.0.5",
"@angular/animations": "^17.0.5",
"@angular/cli": "^17.0.5",
"@angular/common": "^17.0.5",
"@angular/compiler": "^17.0.5",
"@angular/compiler-cli": "^17.0.5",
"@angular/core": "^17.0.5",
"@angular/forms": "^17.0.5",
"@angular/platform-browser": "^17.0.5",
"@angular/platform-browser-dynamic": "^17.0.5",
"@angular-devkit/architect": "^0.1703.0",
"@angular-devkit/build-angular": "^17.3.0",
"@angular-devkit/core": "^17.3.0",
"@angular/animations": "^17.3.0",
"@angular/cli": "^17.3.0",
"@angular/common": "^17.3.0",
"@angular/compiler": "^17.3.0",
"@angular/compiler-cli": "^17.3.0",
"@angular/core": "^17.3.0",
"@angular/forms": "^17.3.0",
"@angular/platform-browser": "^17.3.0",
"@angular/platform-browser-dynamic": "^17.3.0",
"@types/cross-spawn": "^6.0.2",
"@types/tmp": "^0.2.3",
"cross-spawn": "^7.0.3",
Expand Down Expand Up @@ -115,6 +115,7 @@
},
"builders": "dist/builders/builders.json",
"bundler": {
"post": "./scripts/postbuild.js",
"tsConfig": "tsconfig.build.json"
},
"gitHead": "e6a7fd8a655c69780bc20b9749c2699e44beae17"
Expand Down
14 changes: 14 additions & 0 deletions code/frameworks/angular/scripts/postbuild.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* This postbuild fix is needed to add a ts-ignore to the generated public-types.d.ts file.
* The AngularCore.InputSignal and AngularCore.InputSignalWithTransform types do not exist in Angular
* versions < 17.2. In these versions, the unresolved types will error and prevent Storybook from starting/building.
* This postbuild script adds a ts-ignore statement above the unresolved types to prevent the errors.
*/

const fs = require('fs');
const path = require('path');

const filePath = path.join(__dirname, '../dist/client/public-types.d.ts');
const fileContent = fs.readFileSync(filePath, 'utf8');
const newContent = fileContent.replaceAll(/(type AngularInputSignal)/g, '// @ts-ignore\n$1');
fs.writeFileSync(filePath, newContent, 'utf8');
39 changes: 34 additions & 5 deletions code/frameworks/angular/src/client/public-types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
/* eslint-disable prettier/prettier */
/* eslint-disable @typescript-eslint/ban-ts-comment */
import {
AnnotatedStoryFn,
Args,
Expand All @@ -9,7 +11,7 @@ import {
StrictArgs,
ProjectAnnotations,
} from '@storybook/types';
import { EventEmitter } from '@angular/core';
import * as AngularCore from '@angular/core';
import { AngularRenderer } from './types';

export type { Args, ArgTypes, Parameters, StrictArgs } from '@storybook/types';
Expand All @@ -21,27 +23,54 @@ export type { AngularRenderer };
*
* @see [Default export](https://storybook.js.org/docs/formats/component-story-format/#default-export)
*/
export type Meta<TArgs = Args> = ComponentAnnotations<AngularRenderer, TransformEventType<TArgs>>;
export type Meta<TArgs = Args> = ComponentAnnotations<
AngularRenderer,
TransformComponentType<TArgs>
>;

/**
* Story function that represents a CSFv2 component example.
*
* @see [Named Story exports](https://storybook.js.org/docs/formats/component-story-format/#named-story-exports)
*/
export type StoryFn<TArgs = Args> = AnnotatedStoryFn<AngularRenderer, TransformEventType<TArgs>>;
export type StoryFn<TArgs = Args> = AnnotatedStoryFn<
AngularRenderer,
TransformComponentType<TArgs>
>;

/**
* Story object that represents a CSFv3 component example.
*
* @see [Named Story exports](https://storybook.js.org/docs/formats/component-story-format/#named-story-exports)
*/
export type StoryObj<TArgs = Args> = StoryAnnotations<AngularRenderer, TransformEventType<TArgs>>;
export type StoryObj<TArgs = Args> = StoryAnnotations<
AngularRenderer,
TransformComponentType<TArgs>
>;

export type Decorator<TArgs = StrictArgs> = DecoratorFunction<AngularRenderer, TArgs>;
export type Loader<TArgs = StrictArgs> = LoaderFunction<AngularRenderer, TArgs>;
export type StoryContext<TArgs = StrictArgs> = GenericStoryContext<AngularRenderer, TArgs>;
export type Preview = ProjectAnnotations<AngularRenderer>;

/**
* Utility type that transforms InputSignal and EventEmitter types
*/
type TransformComponentType<T> = TransformInputSignalType<TransformEventType<T>>

// @ts-ignore Angular < 17.2 doesn't export InputSignal
type AngularInputSignal<T> = AngularCore.InputSignal<T>
// @ts-ignore Angular < 17.2 doesn't export InputSignalWithTransform
type AngularInputSignalWithTransform<T, U> = AngularCore.InputSignalWithTransform<T, U>

type AngularHasSignal = typeof AngularCore extends { input: infer U } ? true : false;
valentinpalkovic marked this conversation as resolved.
Show resolved Hide resolved
type InputSignal<T> = AngularHasSignal extends true ? AngularInputSignal<T> : never;
type InputSignalWithTransform<T, U> = AngularHasSignal extends true ? AngularInputSignalWithTransform<T, U> : never;

type TransformInputSignalType<T> = {
[K in keyof T]: T[K] extends InputSignal<infer E> ? E : T[K] extends InputSignalWithTransform<any, infer U> ? U : T[K];
valentinpalkovic marked this conversation as resolved.
Show resolved Hide resolved
};

type TransformEventType<T> = {
[K in keyof T]: T[K] extends EventEmitter<infer E> ? (e: E) => void : T[K];
[K in keyof T]: T[K] extends AngularCore.EventEmitter<infer E> ? (e: E) => void : T[K];
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Component, Input, Output, EventEmitter, input } from '@angular/core';

@Component({
// Needs to be a different name to the CLI template button
selector: 'storybook-signal-button',
template: ` <button
type="button"
(click)="onClick.emit($event)"
[ngClass]="classes"
[ngStyle]="{ 'background-color': backgroundColor }"
>
{{ label() }}
</button>`,
styleUrls: ['./button.css'],
})
export default class SignalButtonComponent {
/**
* Is this the principal call to action on the page?
*/
primary = input(false);

/**
* What background color to use
*/
@Input()
backgroundColor?: string;

/**
* How large should the button be?
*/
size = input('medium', {
transform: (val: 'small' | 'medium') => val,
});

/**
* Button contents
*/
label = input.required({ transform: (val: string) => val.trim() });

/**
* Optional click handler
*/
@Output()
onClick = new EventEmitter<Event>();

public get classes(): string[] {
const mode = this.primary() ? 'storybook-button--primary' : 'storybook-button--secondary';

return ['storybook-button', `storybook-button--${this.size()}`, mode];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
.storybook-button {
font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-weight: 700;
border: 0;
border-radius: 3em;
cursor: pointer;
display: inline-block;
line-height: 1;
}
.storybook-button--primary {
color: white;
background-color: #1ea7fd;
}
.storybook-button--secondary {
color: #333;
background-color: transparent;
box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset;
}
.storybook-button--small {
font-size: 12px;
padding: 10px 16px;
}
.storybook-button--medium {
font-size: 14px;
padding: 11px 20px;
}
.storybook-button--large {
font-size: 16px;
padding: 12px 24px;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { Meta, StoryObj } from '@storybook/angular';
import { fn } from '@storybook/test';
import SignalButtonComponent from './button.component';

// More on how to set up stories at: https://storybook.js.org/docs/writing-stories
const meta: Meta<SignalButtonComponent> = {
component: SignalButtonComponent,
tags: ['autodocs'],
argTypes: {
backgroundColor: {
control: 'color',
},
// The following argTypes are necessary,
// because Compodoc does not support Angular's new input and output signals yet
primary: {
type: 'boolean',
},
size: {
control: {
type: 'radio',
},
options: ['small', 'medium'],
},
label: {
type: 'string',
},
},
// Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#action-args
args: {
onClick: fn(),
primary: false,
size: 'medium',
},
};

export default meta;
type Story = StoryObj<SignalButtonComponent>;

// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args
export const Primary: Story = {
args: {
primary: true,
label: 'Button',
},
};

export const Secondary: Story = {
args: {
label: 'Button',
},
};

export const Medium: Story = {
args: {
size: 'medium',
label: 'Button',
},
};

export const Small: Story = {
args: {
size: 'small',
label: 'Button',
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Component, Input, Output, EventEmitter, input } from '@angular/core';

@Component({
// Needs to be a different name to the CLI template button
selector: 'storybook-signal-button',
template: ` <button
type="button"
(click)="onClick.emit($event)"
[ngClass]="classes"
[ngStyle]="{ 'background-color': backgroundColor }"
>
{{ label() }}
</button>`,
styleUrls: ['./button.css'],
})
export default class SignalButtonComponent {
/**
* Is this the principal call to action on the page?
*/
primary = input(false);

/**
* What background color to use
*/
@Input()
backgroundColor?: string;

/**
* How large should the button be?
*/
size = input('medium', {
transform: (val: 'small' | 'medium') => val,
});

/**
* Button contents
*/
label = input.required({ transform: (val: string) => val.trim() });

/**
* Optional click handler
*/
@Output()
onClick = new EventEmitter<Event>();

public get classes(): string[] {
const mode = this.primary() ? 'storybook-button--primary' : 'storybook-button--secondary';

return ['storybook-button', `storybook-button--${this.size()}`, mode];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
.storybook-button {
font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-weight: 700;
border: 0;
border-radius: 3em;
cursor: pointer;
display: inline-block;
line-height: 1;
}
.storybook-button--primary {
color: white;
background-color: #1ea7fd;
}
.storybook-button--secondary {
color: #333;
background-color: transparent;
box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset;
}
.storybook-button--small {
font-size: 12px;
padding: 10px 16px;
}
.storybook-button--medium {
font-size: 14px;
padding: 11px 20px;
}
.storybook-button--large {
font-size: 16px;
padding: 12px 24px;
}
Loading
Loading