@trineui/cli 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,113 @@
1
+ import { Command } from 'commander';
2
+ import { writeFileSync, existsSync, readFileSync } from 'fs';
3
+ import { resolve } from 'path';
4
+ import { computeNormalizedHash } from '../utils/hash.js';
5
+ import { readManifest, writeManifest } from '../utils/manifest.js';
6
+ import { renderTemplate } from '../utils/template.js';
7
+ import { runInteractiveSync } from './sync-interactive.js';
8
+
9
+ export const syncCommand = new Command('sync')
10
+ .argument('<component>', 'Component name (e.g. button)')
11
+ .option('--interactive', 'Interactive 3-way merge for resolving conflicts')
12
+ .option('--dry-run', 'Show what would be synced without making changes')
13
+ .description('Sync blueprint with latest upstream version')
14
+ .action(async (component: string, options: { interactive?: boolean; dryRun?: boolean }) => {
15
+ const projectRoot = process.cwd();
16
+
17
+ const manifest = readManifest(projectRoot);
18
+ if (!manifest?.components[component]) {
19
+ console.error(`✗ "${component}" not found in manifest`);
20
+ console.error(` Run: trine add ${component}`);
21
+ process.exit(1);
22
+ }
23
+
24
+ const entry = manifest.components[component];
25
+
26
+ const blueprintPath = resolve(
27
+ projectRoot,
28
+ entry['output-dir'],
29
+ component,
30
+ `${component}.blueprint.ts`
31
+ );
32
+
33
+ if (!existsSync(blueprintPath)) {
34
+ console.error(`✗ Blueprint file not found: ${blueprintPath}`);
35
+ console.error(` Run: trine add ${component}`);
36
+ process.exit(1);
37
+ }
38
+
39
+ const currentContent = readFileSync(blueprintPath, 'utf-8');
40
+ const currentHash = computeNormalizedHash(currentContent);
41
+ const manifestHash = entry['blueprint-hash'];
42
+
43
+ const upstreamContent = renderTemplate(`${component}.blueprint.ts.hbs`, {
44
+ engineVersion: entry['engine-version'],
45
+ blueprintSchemaVersion: entry['blueprint-schema-version'],
46
+ });
47
+
48
+ const upstreamHash = computeNormalizedHash(upstreamContent);
49
+
50
+ if (options.interactive) {
51
+ if (currentHash === manifestHash) {
52
+ console.log('✓ No local changes to merge.');
53
+ return;
54
+ }
55
+
56
+ const result = await runInteractiveSync(
57
+ component,
58
+ currentContent,
59
+ upstreamContent,
60
+ blueprintPath,
61
+ manifest
62
+ );
63
+ if (result === 'merged') {
64
+ writeManifest(manifest, projectRoot);
65
+ console.log('✓ manifest.json updated.');
66
+ }
67
+ return;
68
+ }
69
+
70
+ if (currentHash !== manifestHash) {
71
+ console.error('✗ Cannot auto-sync: blueprint has local modifications');
72
+ console.error('');
73
+ console.error(` Local changes detected in: ${component}.blueprint.ts`);
74
+ console.error('');
75
+ console.error(' Options:');
76
+ console.error(` trine sync ${component} --interactive Resolve conflicts interactively`);
77
+ console.error(` trine diff ${component} See what changed upstream`);
78
+ console.error(` trine add ${component} --force Overwrite (loses your changes)`);
79
+ console.error('');
80
+ console.error(' Tip: Run "trine diff" first to understand the changes.');
81
+ process.exit(1);
82
+ }
83
+
84
+ if (upstreamHash === manifestHash) {
85
+ console.log(`✓ ${component} blueprint already up to date`);
86
+ console.log('');
87
+ console.log(' Nothing to sync.');
88
+ return;
89
+ }
90
+
91
+ if (options.dryRun) {
92
+ console.log(`[dry-run] Would sync ${component} blueprint`);
93
+ console.log(`[dry-run] Blueprint: ${entry['output-dir']}/${component}/${component}.blueprint.ts`);
94
+ console.log('[dry-run] Skin: (not touched - user-owned)');
95
+ return;
96
+ }
97
+
98
+ writeFileSync(blueprintPath, upstreamContent, 'utf-8');
99
+
100
+ const newHash = computeNormalizedHash(upstreamContent);
101
+ entry['blueprint-hash'] = newHash;
102
+ entry['synced-at'] = new Date().toISOString();
103
+
104
+ writeManifest(manifest, projectRoot);
105
+
106
+ console.log(`✓ ${component} blueprint synced successfully`);
107
+ console.log('');
108
+ console.log(' Updates applied:');
109
+ console.log(' blueprint: updated');
110
+ console.log(' skin: unchanged (user-owned)');
111
+ console.log('');
112
+ console.log(` Run: trine diff ${component} — check for more upstream changes`);
113
+ });
package/src/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { addCommand } from './commands/add.js';
4
+ import { diffCommand } from './commands/diff.js';
5
+ import { syncCommand } from './commands/sync.js';
6
+ import { ejectCommand } from './commands/eject.js';
7
+ import { initCommand } from './commands/init.js';
8
+
9
+ const program = new Command();
10
+ program.name('trine').description('Trine CLI').version('0.1.0');
11
+
12
+ program.addCommand(addCommand);
13
+ program.addCommand(diffCommand);
14
+ program.addCommand(syncCommand);
15
+ program.addCommand(ejectCommand);
16
+ program.addCommand(initCommand);
17
+
18
+ program.parse();
@@ -0,0 +1,14 @@
1
+ export interface TrineManifest {
2
+ 'trine-spec': '1.0';
3
+ components: Record<string, ComponentEntry>;
4
+ }
5
+
6
+ export interface ComponentEntry {
7
+ 'engine-version': string;
8
+ 'blueprint-schema-version': string;
9
+ 'blueprint-hash': string;
10
+ 'blueprint-modified': boolean;
11
+ 'synced-at': string;
12
+ 'sync-model': 'normalized-text-v0.1';
13
+ 'output-dir': string;
14
+ }
@@ -0,0 +1,35 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { computeNormalizedHash } from '../hash.js';
3
+
4
+ describe('computeNormalizedHash', () => {
5
+ it('ค่าเหมือนเดิมหลัง Prettier reformat whitespace', () => {
6
+ const original = `@Component({
7
+ selector: 'ui-button',
8
+ })`;
9
+ const prettified = `@Component({ selector: 'ui-button' })`;
10
+ expect(computeNormalizedHash(original))
11
+ .toBe(computeNormalizedHash(prettified));
12
+ });
13
+
14
+ it('ค่าเหมือนเดิมหลังเพิ่ม/ลบ comment', () => {
15
+ const withComment = `// This is generated
16
+ @Component({})`;
17
+ const withoutComment = `@Component({})`;
18
+ expect(computeNormalizedHash(withComment))
19
+ .toBe(computeNormalizedHash(withoutComment));
20
+ });
21
+
22
+ it('ค่าต่างเมื่อเปลี่ยน structure จริง', () => {
23
+ const v1 = `@Component({ selector: 'ui-button' })`;
24
+ const v2 = `@Component({ selector: 'ui-btn' })`;
25
+ expect(computeNormalizedHash(v1))
26
+ .not.toBe(computeNormalizedHash(v2));
27
+ });
28
+
29
+ it('import reorder ไม่เปลี่ยน hash', () => {
30
+ const order1 = `import { A } from 'a';\nimport { B } from 'b';`;
31
+ const order2 = `import { B } from 'b';\nimport { A } from 'a';`;
32
+ expect(computeNormalizedHash(order1))
33
+ .toBe(computeNormalizedHash(order2));
34
+ });
35
+ });
@@ -0,0 +1,47 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { renderTemplate } from '../template.js';
3
+
4
+ describe('template rendering', () => {
5
+ it('renders blueprint template without unresolved placeholders', () => {
6
+ const result = renderTemplate('button.blueprint.ts.hbs', {
7
+ engineVersion: '0.1.0',
8
+ blueprintSchemaVersion: '1.0.0',
9
+ });
10
+ expect(result).not.toContain('{{');
11
+ expect(result).not.toContain('}}');
12
+ expect(result).toContain("selector: 'ui-button'");
13
+ expect(result).toContain('ButtonEngine');
14
+ expect(result).toContain('BUTTON_STYLING_CONTRACT');
15
+ });
16
+
17
+ it('renders skin template without ButtonEngine reference', () => {
18
+ const result = renderTemplate('button.skin.ts.hbs', {
19
+ engineVersion: '0.1.0',
20
+ blueprintSchemaVersion: '1.0.0',
21
+ });
22
+ expect(result).not.toContain('{{');
23
+ expect(result).toContain('BUTTON_STYLING_CONTRACT');
24
+ expect(result).not.toMatch(/import.*ButtonEngine/);
25
+ expect(result).not.toMatch(/inject\(ButtonEngine\)/);
26
+ });
27
+
28
+ it('injects engine version into blueprint header', () => {
29
+ const result = renderTemplate('button.blueprint.ts.hbs', {
30
+ engineVersion: '1.2.3',
31
+ blueprintSchemaVersion: '2.0.0',
32
+ });
33
+ expect(result).toContain('1.2.3');
34
+ expect(result).toContain('2.0.0');
35
+ });
36
+
37
+ it('skin template includes all variants including brand', () => {
38
+ const result = renderTemplate('button.skin.ts.hbs', {
39
+ engineVersion: '0.1.0',
40
+ blueprintSchemaVersion: '1.0.0',
41
+ });
42
+ expect(result).toContain('solid');
43
+ expect(result).toContain('outline');
44
+ expect(result).toContain('ghost');
45
+ expect(result).toContain('brand');
46
+ });
47
+ });
@@ -0,0 +1,149 @@
1
+ import { Project, SourceFile } from "ts-morph";
2
+
3
+ export function mergeForEject(
4
+ component: string,
5
+ blueprintContent: string,
6
+ skinContent: string
7
+ ): string {
8
+ try {
9
+ const project = new Project({ useInMemoryFileSystem: true });
10
+
11
+ const blueprintFile = project.createSourceFile(
12
+ "blueprint.ts",
13
+ blueprintContent
14
+ );
15
+
16
+ const componentClass = blueprintFile.getClass(
17
+ `${capitalize(component)}Component`
18
+ );
19
+
20
+ if (!componentClass) {
21
+ return structuredFallback(component, blueprintContent, skinContent);
22
+ }
23
+
24
+ const date = new Date().toISOString().split("T")[0];
25
+ const lines: string[] = [
26
+ `// Ejected from Trine on ${date}.`,
27
+ `// This component is no longer managed by trine sync.`,
28
+ `// @trine/engine is still required as a dependency.`,
29
+ "",
30
+ ];
31
+
32
+ const imports = collectImports(blueprintFile);
33
+ lines.push(...imports);
34
+
35
+ lines.push("");
36
+ lines.push("@Component({");
37
+
38
+ const decoratorProps = extractDecoratorProps(componentClass, component);
39
+ lines.push(...decoratorProps);
40
+
41
+ lines.push("})");
42
+ lines.push(`export class ${capitalize(component)}Component {`);
43
+ lines.push("");
44
+ lines.push(` protected engine = inject(${capitalize(component)}Engine);`);
45
+ lines.push(` protected skin = inject(${capitalize(component)}Skin);`);
46
+ lines.push("}");
47
+ lines.push("");
48
+
49
+ return lines.join("\n");
50
+ } catch {
51
+ return structuredFallback(component, blueprintContent, skinContent);
52
+ }
53
+ }
54
+
55
+ function capitalize(s: string): string {
56
+ return s.charAt(0).toUpperCase() + s.slice(1);
57
+ }
58
+
59
+ function collectImports(
60
+ blueprintFile: SourceFile
61
+ ): string[] {
62
+ const imports: string[] = [];
63
+
64
+ imports.push(
65
+ `import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';`
66
+ );
67
+
68
+ const engineImport = blueprintFile
69
+ .getImportDeclarations()
70
+ .find((imp) =>
71
+ imp.getModuleSpecifier().getText().match(/@trine(ui)?\/engine/)
72
+ );
73
+
74
+ if (engineImport) {
75
+ imports.push(engineImport.getText().replace(/"/g, "'"));
76
+ }
77
+
78
+ blueprintFile.getImportDeclarations().forEach((imp) => {
79
+ const moduleName = imp.getModuleSpecifier().getText();
80
+ if (
81
+ moduleName.includes("class-variance-authority")
82
+ ) {
83
+ imports.push(imp.getText());
84
+ }
85
+ if (
86
+ moduleName.includes("./") && moduleName.includes(".skin")
87
+ ) {
88
+ imports.push(imp.getText());
89
+ }
90
+ });
91
+
92
+ return imports;
93
+ }
94
+
95
+ function extractDecoratorProps(
96
+ componentClass: NonNullable<ReturnType<SourceFile["getClass"]>>,
97
+ component: string
98
+ ): string[] {
99
+ const props: string[] = [];
100
+ props.push(` selector: "ui-${component}",`);
101
+ props.push(" standalone: true,");
102
+
103
+ const decoratorSource = componentClass.getText();
104
+ const templateMatch = decoratorSource.match(/template:\s*`[^`]*`/s);
105
+ if (templateMatch) {
106
+ props.push(` ${templateMatch[0]},`);
107
+ }
108
+
109
+ const cdMatch = decoratorSource.match(/changeDetection:\s*ChangeDetectionStrategy\.\w+/);
110
+ if (cdMatch) {
111
+ props.push(` ${cdMatch[0]},`);
112
+ }
113
+
114
+ const hostMatch = decoratorSource.match(/host:\s*\{[\s\S]*?\n\s*\}/);
115
+ if (hostMatch) {
116
+ props.push(` ${hostMatch[0]},`);
117
+ }
118
+
119
+ const hostDirectivesMatch = decoratorSource.match(/hostDirectives:\s*\[[\s\S]*?\n\s*\]/);
120
+ if (hostDirectivesMatch) {
121
+ props.push(` ${hostDirectivesMatch[0]},`);
122
+ }
123
+
124
+ const providersMatch = decoratorSource.match(/providers:\s*\[[\s\S]*?\n\s*\]/);
125
+ if (providersMatch) {
126
+ props.push(` ${providersMatch[0]},`);
127
+ }
128
+
129
+ return props;
130
+ }
131
+
132
+ function structuredFallback(
133
+ _component: string,
134
+ blueprintContent: string,
135
+ skinContent: string
136
+ ): string {
137
+ const date = new Date().toISOString().split("T")[0];
138
+ return [
139
+ `// Ejected from Trine on ${date}.`,
140
+ `// ts-morph merge failed — using structured fallback.`,
141
+ `// Merge manually using the sections below.`,
142
+ "",
143
+ `// ===== BLUEPRINT =====`,
144
+ blueprintContent,
145
+ "",
146
+ `// ===== SKIN =====`,
147
+ skinContent,
148
+ ].join("\n\n");
149
+ }
@@ -0,0 +1,43 @@
1
+ import { createHash } from 'crypto';
2
+
3
+ export function normalizeContent(content: string): string {
4
+ let normalized = content
5
+ .replace(/\/\/.*$/gm, '')
6
+ .replace(/\/\*[\s\S]*?\*\//g, '')
7
+ .replace(/'([^']*)'/g, '"$1"')
8
+ .replace(/`[^`]*`/g, '``')
9
+ .replace(/\s+/g, ' ');
10
+
11
+ normalized = normalized
12
+ .replace(/,\s*}/g, '}')
13
+ .replace(/,\s*]/g, ']')
14
+ .replace(/,\s*\)/g, ')')
15
+ .replace(/\[\s*/g, '[')
16
+ .replace(/\s*\]/g, ']')
17
+ .replace(/\(\s*/g, '(')
18
+ .replace(/\s+\)/g, ')')
19
+ .replace(/{\s*/g, '{')
20
+ .replace(/\s*}/g, '}')
21
+ .replace(/{\s+}/g, '{}')
22
+ .replace(/\[\s+\]/g, '[]')
23
+ .replace(/\(\s+\)/g, '()')
24
+ .replace(/,\s*/g, ',')
25
+ .replace(/:\s*/g, ':');
26
+
27
+ const statements = normalized
28
+ .split(';')
29
+ .map(s => s.trim())
30
+ .filter(s => s.length > 0);
31
+
32
+ const importStatements = statements.filter(s => s.startsWith('import'));
33
+ const otherStatements = statements.filter(s => !s.startsWith('import'));
34
+
35
+ importStatements.sort((a, b) => a.localeCompare(b));
36
+
37
+ return [...importStatements, ...otherStatements].join('; ') + ';';
38
+ }
39
+
40
+ export function computeNormalizedHash(content: string): string {
41
+ const normalized = normalizeContent(content);
42
+ return 'sha256-normalized:' + createHash('sha256').update(normalized).digest('hex');
43
+ }
@@ -0,0 +1,43 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
2
+ import { resolve, dirname } from 'path';
3
+ import { TrineManifest } from '../types/manifest.js';
4
+
5
+ const TRINE_DIR = '.trine';
6
+ const MANIFEST_FILE = 'manifest.json';
7
+
8
+ function getManifestPath(projectRoot: string): string {
9
+ return resolve(projectRoot, TRINE_DIR, MANIFEST_FILE);
10
+ }
11
+
12
+ export function readManifest(projectRoot: string): TrineManifest | null {
13
+ const manifestPath = getManifestPath(projectRoot);
14
+
15
+ if (!existsSync(manifestPath)) {
16
+ return null;
17
+ }
18
+
19
+ try {
20
+ const content = readFileSync(manifestPath, 'utf-8');
21
+ return JSON.parse(content) as TrineManifest;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ export function writeManifest(manifest: TrineManifest, projectRoot: string): void {
28
+ const manifestPath = getManifestPath(projectRoot);
29
+ const dir = dirname(manifestPath);
30
+
31
+ if (!existsSync(dir)) {
32
+ mkdirSync(dir, { recursive: true });
33
+ }
34
+
35
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8');
36
+ }
37
+
38
+ export function createDefaultManifest(): TrineManifest {
39
+ return {
40
+ 'trine-spec': '1.0',
41
+ components: {},
42
+ };
43
+ }
@@ -0,0 +1,26 @@
1
+ import Handlebars from 'handlebars';
2
+ import { readFileSync, existsSync } from 'fs';
3
+ import { resolve } from 'path';
4
+
5
+ function findMonorepoRoot(cwd: string): string {
6
+ let dir = cwd;
7
+ for (;;) {
8
+ if (existsSync(resolve(dir, 'pnpm-workspace.yaml'))) {
9
+ return dir;
10
+ }
11
+ const parent = resolve(dir, '..');
12
+ if (parent === dir) break;
13
+ dir = parent;
14
+ }
15
+ return cwd;
16
+ }
17
+
18
+ const monorepoRoot = findMonorepoRoot(process.cwd());
19
+ const templatesDir = resolve(monorepoRoot, 'packages/cli/templates');
20
+
21
+ export function renderTemplate(templateName: string, context: object): string {
22
+ const templatePath = resolve(templatesDir, templateName);
23
+ const templateSource = readFileSync(templatePath, 'utf-8');
24
+ const template = Handlebars.compile(templateSource);
25
+ return template(context);
26
+ }
@@ -0,0 +1,41 @@
1
+ // @trine-generated
2
+ // @trine-version: {{engineVersion}}
3
+ // @trine-schema: {{blueprintSchemaVersion}}
4
+ import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
5
+ import { ButtonEngine } from '@trineui/engine/button';
6
+ import { ButtonSkin } from './button.skin';
7
+ import { BUTTON_STYLING_CONTRACT } from '@trineui/engine/button';
8
+
9
+ @Component({
10
+ selector: 'ui-button',
11
+ standalone: true,
12
+ hostDirectives: [
13
+ {
14
+ directive: ButtonEngine,
15
+ inputs: ['variant', 'size', 'disabled', 'loading'],
16
+ outputs: ['pressed'],
17
+ },
18
+ ],
19
+ providers: [
20
+ ButtonSkin,
21
+ {
22
+ provide: BUTTON_STYLING_CONTRACT,
23
+ useExisting: ButtonEngine,
24
+ },
25
+ ],
26
+ template: `
27
+ @if (engine.loading()) {
28
+ <span class="animate-spin mr-2">⏳</span>
29
+ }
30
+ <ng-content />
31
+ `,
32
+ host: {
33
+ '[class]': 'skin.classes()',
34
+ '[attr.data-state]': 'engine.state()',
35
+ },
36
+ changeDetection: ChangeDetectionStrategy.OnPush,
37
+ })
38
+ export class ButtonComponent {
39
+ protected engine = inject(ButtonEngine);
40
+ protected skin = inject(ButtonSkin);
41
+ }
@@ -0,0 +1,35 @@
1
+ import { Injectable, inject, computed } from '@angular/core';
2
+ import { cva } from 'class-variance-authority';
3
+ import { BUTTON_STYLING_CONTRACT } from '@trineui/engine/button';
4
+
5
+ const buttonVariants = cva(
6
+ 'inline-flex items-center justify-center font-semibold transition-all duration-200',
7
+ {
8
+ variants: {
9
+ variant: {
10
+ solid: 'bg-primary text-white shadow-md hover:-translate-y-px',
11
+ outline: 'border-2 border-primary text-primary hover:bg-primary hover:text-white',
12
+ ghost: 'text-primary hover:bg-primary/10',
13
+ brand: 'bg-gradient-to-r from-violet-600 to-pink-500 text-white shadow-lg hover:-translate-y-px',
14
+ },
15
+ size: {
16
+ sm: 'h-8 px-3 text-xs rounded-lg',
17
+ md: 'h-10 px-5 text-sm rounded-xl',
18
+ lg: 'h-12 px-7 text-base rounded-2xl',
19
+ },
20
+ },
21
+ defaultVariants: { variant: 'solid', size: 'md' },
22
+ }
23
+ );
24
+
25
+ @Injectable()
26
+ export class ButtonSkin {
27
+ private contract = inject(BUTTON_STYLING_CONTRACT);
28
+
29
+ readonly classes = computed(() =>
30
+ buttonVariants({
31
+ variant: this.contract.variant(),
32
+ size: this.contract.size(),
33
+ })
34
+ );
35
+ }
@@ -0,0 +1,57 @@
1
+ // @trine-generated
2
+ // @trine-version: {{engineVersion}}
3
+ // @trine-schema: {{blueprintSchemaVersion}}
4
+ import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
5
+ import { CheckboxEngine } from '@trineui/engine/checkbox';
6
+ import { CheckboxSkin } from './checkbox.skin';
7
+ import { CHECKBOX_STYLING_CONTRACT } from '@trineui/engine/checkbox';
8
+
9
+ @Component({
10
+ selector: 'ui-checkbox',
11
+ standalone: true,
12
+ providers: [
13
+ CheckboxEngine,
14
+ {
15
+ provide: CHECKBOX_STYLING_CONTRACT,
16
+ useExisting: CheckboxEngine,
17
+ },
18
+ ],
19
+ template: `
20
+ <button
21
+ type="button"
22
+ role="checkbox"
23
+ class="checkbox-host"
24
+ [attr.aria-checked]="engine.ariaChecked()"
25
+ [attr.aria-disabled]="engine.ariaDisabled()"
26
+ [attr.aria-required]="engine.ariaRequired()"
27
+ [attr.data-state]="engine.checkboxState()"
28
+ [disabled]="engine.disabled()"
29
+ (click)="engine.toggle()"
30
+ (keydown.space)="$event.preventDefault(); engine.toggle()"
31
+ >
32
+ <span class="checkbox-indicator" [attr.data-state]="engine.checkboxState()">
33
+ @if (engine.checkboxState() === 'checked') {
34
+ <svg class="checkbox-icon" viewBox="0 0 16 16" fill="none">
35
+ <path d="M3.5 8.5L6.5 11.5L12.5 4.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
36
+ </svg>
37
+ }
38
+ @if (engine.checkboxState() === 'indeterminate') {
39
+ <svg class="checkbox-icon" viewBox="0 0 16 16" fill="none">
40
+ <path d="M4 8H12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
41
+ </svg>
42
+ }
43
+ </span>
44
+ <span class="checkbox-label">
45
+ <ng-content></ng-content>
46
+ </span>
47
+ </button>
48
+ `,
49
+ host: {
50
+ '[class]': 'skin.classes()',
51
+ },
52
+ changeDetection: ChangeDetectionStrategy.OnPush,
53
+ })
54
+ export class CheckboxComponent {
55
+ protected engine = inject(CheckboxEngine);
56
+ protected skin = inject(CheckboxSkin);
57
+ }
@@ -0,0 +1,44 @@
1
+ // @trine-owned
2
+ // DO NOT EDIT — owned by user, never synced
3
+ import { cva } from 'class-variance-authority';
4
+ import { inject, Injectable } from '@angular/core';
5
+ import { CHECKBOX_STYLING_CONTRACT, CheckboxStylingContract, CheckboxState } from '@trineui/engine/checkbox';
6
+
7
+ @Injectable()
8
+ export class CheckboxSkin {
9
+ private contract = inject(CHECKBOX_STYLING_CONTRACT);
10
+
11
+ private checkboxVariants = cva('checkbox-base', {
12
+ variants: {
13
+ state: {
14
+ unchecked: 'checkbox-unchecked',
15
+ checked: 'checkbox-checked',
16
+ indeterminate: 'checkbox-indeterminate',
17
+ },
18
+ disabled: {
19
+ true: 'checkbox-disabled',
20
+ },
21
+ },
22
+ compoundVariants: [
23
+ {
24
+ state: 'checked',
25
+ disabled: true,
26
+ class: 'checkbox-checked-disabled',
27
+ },
28
+ {
29
+ state: 'indeterminate',
30
+ disabled: true,
31
+ class: 'checkbox-indeterminate-disabled',
32
+ },
33
+ ],
34
+ defaultVariants: {
35
+ state: 'unchecked',
36
+ },
37
+ });
38
+
39
+ classes(): string {
40
+ const state = this.contract.checkboxState();
41
+ const disabled = this.contract.disabled();
42
+ return this.checkboxVariants({ state, disabled });
43
+ }
44
+ }