Skip to content

Commit 6488043

Browse files
committed
Pure UI components for advanced timeline toolbar
1 parent 4f9bb49 commit 6488043

15 files changed

Lines changed: 1155 additions & 0 deletions
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<!--
2+
Copyright 2026 Google LLC
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
-->
16+
17+
<div class="cel-input-container">
18+
@if (icon()) {
19+
<mat-icon class="cel-input-label-icon" [matTooltip]="tooltip()">{{
20+
icon()
21+
}}</mat-icon>
22+
}
23+
<div class="input-wrapper">
24+
<input
25+
#inputElement
26+
type="text"
27+
class="cel-input-field"
28+
[class.invalid]="value() && errorMessage()"
29+
[placeholder]="placeholder()"
30+
[(ngModel)]="value"
31+
/>
32+
@if (value()) {
33+
@if (errorMessage()) {
34+
<mat-icon class="status-icon invalid" [matTooltip]="errorMessage()"
35+
>error</mat-icon
36+
>
37+
<div class="error-bubble">
38+
<div class="error-bubble-arrow"></div>
39+
<div class="error-bubble-content">
40+
{{ errorMessage() }}
41+
</div>
42+
</div>
43+
} @else {
44+
<mat-icon class="status-icon valid" matTooltip="Valid CEL expression"
45+
>check_circle</mat-icon
46+
>
47+
}
48+
}
49+
</div>
50+
</div>
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
/**
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
@use '@angular/material' as mat;
18+
19+
$primary-palette: mat.$m2-indigo-palette;
20+
$success-palette: mat.$m2-green-palette;
21+
$error-palette: mat.$m2-red-palette;
22+
$gray-palette: mat.$m2-gray-palette;
23+
24+
$input-border-color: mat.m2-get-color-from-palette($gray-palette, 400);
25+
$input-focus-border-color: mat.m2-get-color-from-palette($primary-palette, 500);
26+
$input-valid-color: mat.m2-get-color-from-palette($success-palette, 500);
27+
$input-invalid-color: mat.m2-get-color-from-palette($error-palette, 500);
28+
$input-background-color: mat.m2-get-color-from-palette($gray-palette, 50);
29+
30+
$error-bubble-bg: mat.m2-get-color-from-palette($error-palette, 50);
31+
$error-bubble-border: mat.m2-get-color-from-palette($error-palette, 200);
32+
$error-bubble-text: mat.m2-get-color-from-palette($error-palette, 800);
33+
$error-bubble-shadow: rgba(0, 0, 0, 0.1);
34+
35+
.cel-input-container {
36+
display: grid;
37+
grid-template:
38+
'label input' 1fr
39+
/ auto 1fr;
40+
align-items: center;
41+
gap: 6px;
42+
width: 100%;
43+
}
44+
45+
.cel-input-label-icon {
46+
grid-area: label;
47+
font-size: 18px;
48+
width: 18px;
49+
height: 18px;
50+
color: mat.m2-get-color-from-palette($primary-palette, 500);
51+
cursor: default;
52+
user-select: none;
53+
}
54+
55+
.input-wrapper {
56+
grid-area: input;
57+
display: grid;
58+
grid-template:
59+
'field icon' 1fr
60+
/ 1fr auto;
61+
align-items: center;
62+
position: relative;
63+
width: 100%;
64+
}
65+
66+
.cel-input-field {
67+
grid-area: field;
68+
height: 20px;
69+
padding: 2px 24px 2px 6px;
70+
border: 1px solid $input-border-color;
71+
border-radius: 4px;
72+
background-color: $input-background-color;
73+
font-size: small;
74+
transition:
75+
border-color 0.2s,
76+
box-shadow 0.2s;
77+
outline: none;
78+
79+
&:focus {
80+
border-color: $input-focus-border-color;
81+
box-shadow: 0 0 0 2px mat.m2-get-color-from-palette($primary-palette, 100);
82+
}
83+
84+
&.invalid {
85+
border-color: $input-invalid-color;
86+
87+
&:focus {
88+
box-shadow: 0 0 0 2px mat.m2-get-color-from-palette($error-palette, 100);
89+
}
90+
}
91+
}
92+
93+
.status-icon {
94+
grid-area: icon;
95+
position: absolute;
96+
right: 6px;
97+
font-size: 16px;
98+
width: 16px;
99+
height: 16px;
100+
cursor: pointer;
101+
102+
&.valid {
103+
color: $input-valid-color;
104+
}
105+
106+
&.invalid {
107+
color: $input-invalid-color;
108+
}
109+
}
110+
111+
.error-bubble {
112+
position: absolute;
113+
top: calc(100% + 8px);
114+
left: 0;
115+
z-index: 10000;
116+
max-width: 320px;
117+
background-color: $error-bubble-bg;
118+
border: 1px solid $error-bubble-border;
119+
border-radius: 6px;
120+
box-shadow: 0 4px 12px $error-bubble-shadow;
121+
opacity: 0;
122+
transform: translateY(-4px);
123+
pointer-events: none;
124+
transition:
125+
opacity 0.15s ease-out,
126+
transform 0.15s ease-out;
127+
128+
.input-wrapper:focus-within &,
129+
.input-wrapper:hover & {
130+
opacity: 1;
131+
transform: translateY(0);
132+
pointer-events: auto;
133+
}
134+
}
135+
136+
.error-bubble-content {
137+
padding: 8px 12px;
138+
font-size: 12px;
139+
font-weight: 400;
140+
color: $error-bubble-text;
141+
line-height: 1.4;
142+
word-break: break-word;
143+
}
144+
145+
.error-bubble-arrow {
146+
position: absolute;
147+
bottom: 100%;
148+
left: 16px;
149+
width: 0;
150+
height: 0;
151+
border-left: 6px solid transparent;
152+
border-right: 6px solid transparent;
153+
border-bottom: 6px solid $error-bubble-border;
154+
155+
&::after {
156+
content: '';
157+
position: absolute;
158+
top: 1px;
159+
left: -6px;
160+
width: 0;
161+
height: 0;
162+
border-left: 6px solid transparent;
163+
border-right: 6px solid transparent;
164+
border-bottom: 6px solid $error-bubble-bg;
165+
}
166+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { ComponentFixture, TestBed } from '@angular/core/testing';
18+
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
19+
import { By } from '@angular/platform-browser';
20+
import { CelInputComponent } from 'src/app/timeline-toolbar-advanced/components/cel-input.component';
21+
22+
describe('CelInputComponent', () => {
23+
let component: CelInputComponent;
24+
let fixture: ComponentFixture<CelInputComponent>;
25+
26+
beforeEach(async () => {
27+
await TestBed.configureTestingModule({
28+
imports: [NoopAnimationsModule],
29+
}).compileComponents();
30+
31+
fixture = TestBed.createComponent(CelInputComponent);
32+
component = fixture.componentInstance;
33+
fixture.detectChanges();
34+
});
35+
36+
it('should create component', () => {
37+
expect(component).toBeTruthy();
38+
});
39+
40+
it('should not display any validation icon when value is empty', () => {
41+
fixture.componentRef.setInput('value', '');
42+
fixture.componentRef.setInput('errorMessage', 'Some error');
43+
fixture.detectChanges();
44+
45+
const validIcon = fixture.debugElement.query(By.css('.status-icon.valid'));
46+
const invalidIcon = fixture.debugElement.query(
47+
By.css('.status-icon.invalid'),
48+
);
49+
50+
expect(validIcon).toBeNull();
51+
expect(invalidIcon).toBeNull();
52+
});
53+
54+
it('should display valid icon when value is present and errorMessage is empty', () => {
55+
fixture.componentRef.setInput('value', 'timeline.name == "foo"');
56+
fixture.componentRef.setInput('errorMessage', '');
57+
fixture.detectChanges();
58+
59+
const validIcon = fixture.debugElement.query(By.css('.status-icon.valid'));
60+
const invalidIcon = fixture.debugElement.query(
61+
By.css('.status-icon.invalid'),
62+
);
63+
64+
expect(validIcon).not.toBeNull();
65+
expect(invalidIcon).toBeNull();
66+
});
67+
68+
it('should display invalid icon and bubble when value is present and errorMessage is set', () => {
69+
fixture.componentRef.setInput('value', 'timeline.name ==');
70+
fixture.componentRef.setInput(
71+
'errorMessage',
72+
'Invalid CEL expression error',
73+
);
74+
fixture.detectChanges();
75+
76+
const validIcon = fixture.debugElement.query(By.css('.status-icon.valid'));
77+
const invalidIcon = fixture.debugElement.query(
78+
By.css('.status-icon.invalid'),
79+
);
80+
const errorBubble = fixture.debugElement.query(
81+
By.css('.error-bubble-content'),
82+
);
83+
84+
expect(validIcon).toBeNull();
85+
expect(invalidIcon).not.toBeNull();
86+
expect(errorBubble.nativeElement.textContent.trim()).toBe(
87+
'Invalid CEL expression error',
88+
);
89+
});
90+
});
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { Component, ElementRef, input, model, viewChild } from '@angular/core';
18+
import { CommonModule } from '@angular/common';
19+
import { FormsModule } from '@angular/forms';
20+
import { MatIconModule } from '@angular/material/icon';
21+
import { MatTooltipModule } from '@angular/material/tooltip';
22+
import { KHIIconRegistrationModule } from 'src/app/shared/module/icon-registration.module';
23+
24+
/**
25+
* Provides a slim text field optimized for CEL expression input with real-time validation.
26+
*/
27+
@Component({
28+
selector: 'khi-timeline-cel-input',
29+
templateUrl: './cel-input.component.html',
30+
styleUrls: ['./cel-input.component.scss'],
31+
imports: [
32+
CommonModule,
33+
FormsModule,
34+
MatIconModule,
35+
MatTooltipModule,
36+
KHIIconRegistrationModule,
37+
],
38+
})
39+
export class CelInputComponent {
40+
/**
41+
* Specifies the validation error message to display.
42+
*/
43+
readonly errorMessage = input('');
44+
45+
/**
46+
* Specifies the tooltip text displayed when hovering over the icon.
47+
*/
48+
readonly tooltip = input('');
49+
50+
/**
51+
* Specifies the icon name (Material symbol) displayed next to the input field.
52+
*/
53+
readonly icon = input('');
54+
55+
/**
56+
* Specifies the placeholder text for the input field.
57+
*/
58+
readonly placeholder = input('Enter CEL expression');
59+
60+
/**
61+
* Holds the two-way bound CEL expression string.
62+
*/
63+
readonly value = model('');
64+
65+
private readonly inputElement =
66+
viewChild<ElementRef<HTMLInputElement>>('inputElement');
67+
68+
/**
69+
* Focuses the internal input element.
70+
*/
71+
public focus(): void {
72+
this.inputElement()?.nativeElement.focus();
73+
}
74+
}

0 commit comments

Comments
 (0)