Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
15d9086
wip angular wrapper generator
dafn Jan 22, 2026
a544a21
add fhi-button to form elements
dafn Jan 23, 2026
4742ad1
wip: first test using suffix
fhi-designsystem-bot Jan 23, 2026
f579f4e
fix path
fhi-designsystem-bot Jan 23, 2026
196a874
use -ng
dafn Jan 25, 2026
4123f54
Merge branch 'main' into 325-add-angular-wrappers
dafn Feb 2, 2026
5d6d856
add log for angular wrapper
Feb 3, 2026
20e4494
put the wrappers in .temp and move them to npm and github dist locations
dafn Feb 3, 2026
403dbb1
Merge branch '325-add-angular-wrappers' of https://github.com/FHIDev/…
dafn Feb 3, 2026
b905863
fix attribute bug
dafn Feb 3, 2026
d854e8b
fix typeof check bug
dafn Feb 4, 2026
b99bca5
make more attributes optional
fhi-designsystem-bot Feb 5, 2026
3b790d7
take null type into account
fhi-designsystem-bot Feb 5, 2026
6581067
use angular selector for form accessors
fhi-designsystem-bot Feb 5, 2026
fa6be7d
remove redundant import of accessor
fhi-designsystem-bot Feb 5, 2026
9690722
use slot attribute to select ng-content
dafn Feb 9, 2026
36bfbfa
move logic into isOptionalAttribute function
dafn Feb 9, 2026
f88d6f7
Merge branch 'main' into 325-add-angular-wrappers
dafn Feb 10, 2026
1a5c9be
add mandatory attributes
fhi-designsystem-bot Feb 10, 2026
3a5c42d
Merge branch '325-add-angular-wrappers' of https://github.com/FHIDev/…
fhi-designsystem-bot Feb 10, 2026
80dc91e
fix output bug
fhi-designsystem-bot Feb 10, 2026
7592b9b
Merge branch 'main' of https://github.com/FHIDev/Fhi.Designsystem int…
fhi-designsystem-bot Feb 10, 2026
23904e2
fix make button color optional again
fhi-designsystem-bot Feb 10, 2026
821fe0d
correct type
fhi-designsystem-bot Feb 10, 2026
3ddb833
trye adding some types
dafn Feb 12, 2026
1d395b5
Merge branch 'main' into 325-add-angular-wrappers
dafn Feb 13, 2026
da208d1
fix custom elements config
fhi-designsystem-bot Feb 13, 2026
b905f6b
Merge branch '325-add-angular-wrappers' of https://github.com/FHIDev/…
fhi-designsystem-bot Feb 13, 2026
e7d3fe0
use parsedType for attribute type
fhi-designsystem-bot Feb 13, 2026
f0b4efb
no longer generatetype files
fhi-designsystem-bot Feb 13, 2026
aab1252
only use default event type
fhi-designsystem-bot Feb 13, 2026
7bb8c84
fix typo
fhi-designsystem-bot Feb 13, 2026
3572899
use fieldName
fhi-designsystem-bot Feb 13, 2026
2653c3b
use relative imports from angular components
fhi-designsystem-bot Feb 13, 2026
8c40dbf
replace traditional @Input with signals input
fhi-designsystem-bot Feb 13, 2026
8df6daf
replace traditional @Ouput with signals output
fhi-designsystem-bot Feb 13, 2026
a0e16b1
correctly access the signals
fhi-designsystem-bot Feb 13, 2026
19985c9
add som initial docs
fhi-designsystem-bot Feb 13, 2026
cd5a3d7
clean up some comments
dafn Feb 24, 2026
c5f026d
Merge branch 'main' of https://github.com/FHIDev/Fhi.Designsystem int…
dafn Feb 24, 2026
bb8c029
Apply suggestions from code review
dafn Feb 25, 2026
7773801
Merge branch 'main' of https://github.com/FHIDev/Fhi.Designsystem int…
fhi-designsystem-bot Feb 25, 2026
ef4e52d
Merge branch 'main' of https://github.com/FHIDev/Fhi.Designsystem int…
fhi-designsystem-bot Mar 12, 2026
de77371
remove setters and getters to prevent property value from being hidden
fhi-designsystem-bot Mar 12, 2026
96da7ce
chack for missing properties always
fhi-designsystem-bot Mar 12, 2026
635eaca
update docs with prebuild info
fhi-designsystem-bot Mar 12, 2026
f4bad39
wip: add custom form accesssor
dafn Mar 16, 2026
9fe8f15
work som more on custom value accessor
fhi-designsystem-bot Mar 17, 2026
b462f6f
fix index signator accessor
fhi-designsystem-bot Mar 17, 2026
6891e78
Merge branch 'main' of https://github.com/FHIDev/Fhi.Designsystem int…
fhi-designsystem-bot Mar 17, 2026
25a5564
add disabled state support
fhi-designsystem-bot Mar 17, 2026
a1c21df
add top-levelcomponent descriptions
fhi-designsystem-bot Mar 17, 2026
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
40 changes: 16 additions & 24 deletions packages/fhi-designsystem/custom-elements-manifest.config.js
Original file line number Diff line number Diff line change
@@ -1,39 +1,31 @@
import { cemSorterPlugin } from '@wc-toolkit/cem-sorter';
import { typeParserPlugin } from '@wc-toolkit/type-parser';
import { typeParserPlugin, getTsProgram } from '@wc-toolkit/type-parser';
import { customElementJetBrainsPlugin } from 'custom-element-jet-brains-integration';

const options = {
outdir: './.temp/',
webTypesFileName: 'web-types.json',
descriptionSrc: 'description',
hideSlotDocs: false,
hideEventDocs: false,
hideCssPropertiesDocs: false,
hideCssPartsDocs: false,
hideMethodDocs: true,
excludeHtml: false,
excludeCss: true,
labels: {
slots: 'Slot Section',
events: 'Custom Events',
cssProperties: 'CSS Variables',
cssParts: 'Style Hooks',
methods: 'Methods',
},
typesSrc: 'expandedType',
};

export default {
globs: ['src/components/**/*.component.ts'],
exclude: ['**/*.test.ts', '**/*.stories.ts'],
outdir: './.temp/',
LitElement: true,
overrideModuleCreation({ ts, globs }) {
const program = getTsProgram(ts, globs, 'tsconfig.json');
return program
.getSourceFiles()
.filter(sourceFile =>
globs.find(glob => sourceFile.fileName.includes(glob)),
);
},
plugins: [
cemSorterPlugin({
deprecatedLast: true,
customFields: ['customProperty'],
}),
typeParserPlugin({ propertyName: 'expandedType' }),
customElementJetBrainsPlugin(options),
typeParserPlugin(),
customElementJetBrainsPlugin({
outdir: './.temp/',
webTypesFileName: 'web-types.json',
descriptionSrc: 'description',
typesSrc: 'parsedType',
}),
],
};
3 changes: 2 additions & 1 deletion packages/fhi-designsystem/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,14 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "pnpm analyze && pnpm build:cdn && pnpm build:npm && pnpm build:github && pnpm clean",
"build": "pnpm analyze && pnpm generate:angular-wrappers && pnpm build:cdn && pnpm build:npm && pnpm build:github && pnpm clean",
"build:cdn": "tsc && cross-env DEPLOY_TARGET=cdn vite build",
"build:npm": "tsc && cross-env DEPLOY_TARGET=npm vite build",
"build:github": "tsc && cross-env DEPLOY_TARGET=github vite build",
"storybook": "storybook dev -p 6006",
"storybook:build": "pnpm analyze && storybook build",
"generate:icons": "node ./scripts/generate-icon-components.js --input ./src/assets/icons/ --output ./src/components/icons --clean-output-folder",
"generate:angular-wrappers": "node ./scripts/wrapper-generators/angular/generate-angular-wrappers.js --manifest .temp/custom-elements.json --output .temp/angular-wrappers",
"test": "wtr ./**/*.test.ts --node-resolve --playwright --browsers chromium webkit",
"lint": "pnpm lint:eslint && pnpm lint:prettier",
"lint:eslint": "eslint \"**/*.{js,ts}\"",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,16 +105,14 @@ export class ${webComponentName} extends LitElement {
/**
* Sets the color for the icon.
* Should preferably be a color token. See [Color Tokens](https://designsystem.fhi.no/?path=/docs/design-tokens-farger--docs)
* @type {string}
*/
@property({ type: String }) color: string = "currentcolor";
@property({ type: String }) color?: string = "currentcolor";

/**
* Sets the size of the icon. Can be one of the predefined sizes, a number value, rem or px.
* Number values are treated as px.
* @type { 'xsmall' | 'small' | 'medium' | 'large' | number | string}
*/
@property({ type: String }) size: 'xsmall' | 'small' | 'medium' | 'large' | number | \`\${number}px\` | \`\${number}rem\` = 'medium';
@property({ type: String }) size?: 'xsmall' | 'small' | 'medium' | 'large' | number | \`\${number}px\` | \`\${number}rem\` = 'medium';

private get _size(): string {
switch (this.size) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
import * as fs from 'fs';
import * as path from 'path';

const snakeToPascal = text =>
text
.split('-')
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join('');

const snakeToCamel = text => {
const pascal = snakeToPascal(text);
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
};

const generateFormAccessor = (
angularTagName,
webComponentTagName,
componentDescription,
) => {
const accessorName = `${snakeToPascal(webComponentTagName)}ValueAccessor`;

let valueLocation;
switch (webComponentTagName) {
case 'fhi-text-input':
case 'fhi-date-input':
valueLocation = 'value';
break;
case 'fhi-checkbox':
case 'fhi-radio':
valueLocation = 'checked';
break;
case 'fhi-button':
break;
default:
throw new Error(
`No value location defined for web component ${webComponentTagName}`,
);
}

// We do not need a value accessor if there is no value to control on the form-associated web component. e.g fhi-button.
if (!valueLocation) {
return '';
}

return `
/** @description
* A ControlValueAccessor for writing a value and listening to changes on an ${webComponentTagName} element.
* ${componentDescription} */
@Directive({
selector: '${angularTagName}[formControlName],${angularTagName}[formControl],${angularTagName}[ngModel]',
standalone: true,
host: {'(change)': 'onChange($any($event.target).checked)', '(blur)': 'onTouched()'},
providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => ${accessorName}), multi: true }]
})
export class ${accessorName} implements ControlValueAccessor, AfterViewInit {
onChange: (value: unknown) => void = () => { };
onTouched: () => void = () => { };

private _host = inject(ElementRef);

private _initialValue: unknown = null;
private _initialDisabledState: boolean = false;
private _webComponent?: { [key: string]: unknown };

ngAfterViewInit(): void {
this._webComponent = this._host.nativeElement.querySelector('${webComponentTagName}');

if (!this._webComponent) {
console.error('Could not find ${webComponentTagName} web component within the ${angularTagName} host element.');
return;
}

this._webComponent["${valueLocation}"] = this._initialValue;
this._webComponent["disabled"] = this._initialDisabledState;
}

writeValue(value: unknown): void {
if (!this._webComponent) {
this._initialValue = value;
return;
}

this._webComponent["value"] = value;
}

setDisabledState(isDisabled: boolean): void {
if (!this._webComponent) {
this._initialDisabledState = isDisabled;
return;
}

this._webComponent["disabled"] = isDisabled;
}

registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}

registerOnChange(fn: (value: unknown) => void): void {
this.onChange = fn;
}
}
`;
};

const isOptionalAttribute = attribute =>
attribute.type.text.includes('undefined') || attribute.default !== undefined;

const main = ({ manifestPath, outputPath }) => {
const indexTsFile = [];

if (!manifestPath) {
console.error(
'Please provide the --manifest argument with the path to the custom element manifest file.',
);
process.exit(1);
}

if (!outputPath) {
console.error(
'Please provide the --output argument with the path to the output directory.',
);
process.exit(1);
}

if (!fs.existsSync(outputPath)) {
fs.mkdirSync(outputPath, { recursive: true });
}

const folderContent = fs.readdirSync(outputPath);

if (folderContent.length > 0) {
const folderContent = fs.readdirSync(outputPath);

folderContent.forEach(file => {
const filePath = path.join(outputPath, file);
if (fs.lstatSync(filePath).isFile()) {
fs.unlinkSync(filePath);
}
});
}

let manifest;
try {
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
} catch (error) {
console.error('Error reading manifest file:', error);
}

manifest.modules.forEach(module => {
const componentClass = module.declarations.find(
declaration => declaration.kind === 'class',
);

if (!componentClass || !componentClass.customElement) {
return;
}

const className = componentClass.name;
const componentDescription = componentClass.description || '';
const webComponentTagName = componentClass.tagName;
const attributes = componentClass.attributes || [];
const events = componentClass.events || [];
const slots = componentClass.slots || [];
const isFormAssociated = componentClass.members.some(
member => member.name === 'formAssociated',
);

if (!webComponentTagName) {
throw new Error(`No tagName found for component class ${className}`);
}

const angularTagName = webComponentTagName.split('fhi-').join('fhi-ng-');

const template = `
/** This file is autogenerated. Do not edit directly. **/
import { Component${attributes.length > 0 ? ', input' : ''}${events.length > 0 ? ', output' : ''}${isFormAssociated ? `, Directive, forwardRef, AfterViewInit, inject, ElementRef` : ''}, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';

import { NG_VALUE_ACCESSOR, ControlValueAccessor } from '@angular/forms';

import '../${webComponentTagName}';

${isFormAssociated ? generateFormAccessor(angularTagName, webComponentTagName, componentDescription) : ''}

/** @description ${componentDescription} */
@Component({
selector: '${angularTagName}',
schemas: [CUSTOM_ELEMENTS_SCHEMA],
standalone: true,
template: ${`\`
<${webComponentTagName}
${attributes.map(attribute => `[${attribute.fieldName}]="${attribute.fieldName}()"`).join(' ')}
${events.map(event => `(${event.name})="handle${snakeToPascal(event.name)}($event)"`).join(' ')}
>
${slots.map(slot => `<ng-content ${slot.name ? `select="[slot='${slot.name}']"` : ''}></ng-content>`).join('\n')}
</${webComponentTagName}>
\``},
})
export class ${className}AngularWrapper {
${attributes
.map(
attribute => `
/** ${attribute.description || ''} */
${attribute.fieldName} = input${isOptionalAttribute(attribute) ? '' : '.required'}<${attribute.parsedType?.text ?? attribute.type.text}>( ${isOptionalAttribute(attribute) ? `${attribute.default}, ` : ''}{ alias: "${attribute.name}" })
`,
)
.join('')}

${events
.map(
event => `
/** ${event.description || ''} */
${snakeToCamel(event.name)}Output = output<Event>( { alias: "${event.name}" } )
handle${snakeToPascal(event.name)}(event: Event) {
event.stopPropagation();
this.${snakeToCamel(event.name)}Output.emit(event);
}
`,
)
.join('')}
}
`;

fs.writeFileSync(
`${path.join(outputPath, `${webComponentTagName}.component.ts`)}`,
template,
'utf8',
);

indexTsFile.push(`export * from './${webComponentTagName}.component';`);
});

fs.writeFileSync(
path.join(outputPath, 'index.ts'),
indexTsFile.join('\n'),
'utf8',
);

console.log(
`Successfully generated ${indexTsFile.length} Angular wrappers to ${outputPath}`,
);
};

main({
manifestPath:
process.argv.indexOf('--manifest') !== -1
? process.argv[process.argv.indexOf('--manifest') + 1]
: undefined,
outputPath:
process.argv.indexOf('--output') !== -1
? process.argv[process.argv.indexOf('--output') + 1]
: undefined,
});
Loading
Loading