Skip to content

Commit c02c28f

Browse files
authored
feat(kit): InputSlider refactor to new Textfield (#10288)
1 parent 665babf commit c02c28f

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

55 files changed

+1693
-639
lines changed

projects/cdk/classes/control.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export abstract class TuiControl<T> implements ControlValueAccessor {
4141

4242
protected readonly control = inject(NgControl, {self: true});
4343
protected readonly cdr = inject(ChangeDetectorRef);
44-
protected readonly transformer = inject(TuiValueTransformer, FLAGS);
44+
protected transformer = inject(TuiValueTransformer, FLAGS);
4545

4646
public readonly value = computed(() => this.internal() ?? this.fallback);
4747
public readonly readOnly = signal(false);

projects/cdk/classes/value-transformer.ts

+24
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type {FactoryProvider, ProviderToken} from '@angular/core';
22
import {inject} from '@angular/core';
3+
import {identity} from 'rxjs';
34

45
export abstract class TuiValueTransformer<From, To = unknown> {
56
public abstract toControlValue(componentValue: From): To;
@@ -14,3 +15,26 @@ export function tuiValueTransformerFrom<
1415
useFactory: () => inject(token).valueTransformer,
1516
};
1617
}
18+
19+
export class TuiNonNullableValueTransformer<T> extends TuiValueTransformer<T | null, T> {
20+
private prevValue!: T;
21+
22+
public fromControlValue(value: T): T {
23+
this.prevValue = value;
24+
25+
return value;
26+
}
27+
28+
public toControlValue(value: T | null): T {
29+
this.prevValue = value ?? this.prevValue;
30+
31+
return this.prevValue;
32+
}
33+
}
34+
35+
class TuiIdentityValueTransformer<T> extends TuiValueTransformer<T, T> {
36+
public override fromControlValue = identity;
37+
public override toControlValue = identity;
38+
}
39+
40+
export const TUI_IDENTITY_VALUE_TRANSFORMER = new TuiIdentityValueTransformer();

projects/core/styles/components/textfield.less

+4-4
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ tui-textfield {
141141

142142
&:has(label:not(:empty)) {
143143
.t-template,
144-
input:defined,
144+
input:first-of-type,
145145
select:defined {
146146
padding-top: calc(var(--t-height) / 3);
147147

@@ -155,7 +155,7 @@ tui-textfield {
155155
// TODO: Fallback until Safari 15.4
156156
&._with-label {
157157
.t-template,
158-
input:defined,
158+
input:first-of-type,
159159
select:defined {
160160
padding-top: calc(var(--t-height) / 3);
161161

@@ -187,12 +187,12 @@ tui-textfield {
187187
color: var(--tui-text-primary);
188188
}
189189

190-
&._with-template input,
190+
&._with-template input:first-of-type,
191191
&._with-template select {
192192
color: transparent !important;
193193
}
194194

195-
input:defined,
195+
input:first-of-type,
196196
select:defined {
197197
pointer-events: auto;
198198
background: transparent;

projects/core/styles/mixins/slider.less

+1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
}
2929
}
3030

31+
tui-textfield + &, /* TODO: add :has([tuiInputSlider]) */
3132
tui-input-slider + & {
3233
margin-left: calc(var(--tui-radius-m) / 2 + @first-tick-center);
3334
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
import {DemoRoute} from '@demo/routes';
2+
import {
3+
CHAR_MINUS,
4+
TuiDocumentationPagePO,
5+
tuiGoto,
6+
TuiInputSliderPO,
7+
} from '@demo-playwright/utils';
8+
import type {Locator} from '@playwright/test';
9+
import {expect, test} from '@playwright/test';
10+
11+
const {describe, beforeEach} = test;
12+
13+
describe('InputSlider', () => {
14+
test.use({viewport: {width: 400, height: 500}});
15+
16+
describe('[min] prop', () => {
17+
describe('positive numbers', () => {
18+
let inputSlider!: TuiInputSliderPO;
19+
20+
beforeEach(async ({page}) => {
21+
await tuiGoto(
22+
page,
23+
`${DemoRoute.InputSlider}/API?min=10&max=100&precision=3&step=1`,
24+
);
25+
26+
inputSlider = new TuiInputSliderPO(
27+
new TuiDocumentationPagePO(page).apiPageExample.locator(
28+
'tui-textfield:has([tuiInputSlider])',
29+
),
30+
);
31+
32+
await inputSlider.textfield.clear();
33+
});
34+
35+
test('cannot type number less than [min] property', async () => {
36+
await inputSlider.textfield.fill('9.999');
37+
await inputSlider.textfield.blur();
38+
39+
await expect(inputSlider.textfield).toHaveValue('10');
40+
});
41+
42+
test('cannot even type minus if [min] is positive', async () => {
43+
await inputSlider.textfield.pressSequentially('-11');
44+
45+
await expect(inputSlider.textfield).toHaveValue('11');
46+
});
47+
48+
test('cannot set value less than [min] using ArrowDown', async () => {
49+
await inputSlider.textfield.fill('11');
50+
51+
await inputSlider.textfield.press('ArrowDown');
52+
53+
await expect(inputSlider.textfield).toHaveValue('10');
54+
55+
await inputSlider.textfield.press('ArrowDown');
56+
57+
await expect(inputSlider.textfield).toHaveValue('10');
58+
});
59+
});
60+
61+
describe('negative numbers', () => {
62+
let inputSlider!: TuiInputSliderPO;
63+
64+
beforeEach(async ({page}) => {
65+
await tuiGoto(
66+
page,
67+
`${DemoRoute.InputSlider}/API?min=-10&max=100&precision=3&step=1`,
68+
);
69+
70+
inputSlider = new TuiInputSliderPO(
71+
new TuiDocumentationPagePO(page).apiPageExample.locator(
72+
'tui-textfield:has([tuiInputSlider])',
73+
),
74+
);
75+
76+
await inputSlider.textfield.clear();
77+
});
78+
79+
test('can type negative number more than [min]', async () => {
80+
await inputSlider.textfield.pressSequentially('-5');
81+
82+
await expect(inputSlider.textfield).toHaveValue(`${CHAR_MINUS}5`);
83+
});
84+
85+
test('cannot type negative number less than [min]', async () => {
86+
await inputSlider.textfield.pressSequentially('-11');
87+
88+
await expect(inputSlider.textfield).toHaveValue(`${CHAR_MINUS}10`);
89+
});
90+
});
91+
92+
describe('if [min]-property equals to [max]-property', () => {
93+
let inputSlider!: TuiInputSliderPO;
94+
95+
beforeEach(async ({page}) => {
96+
await tuiGoto(
97+
page,
98+
`${DemoRoute.InputSlider}/API?min=25&max=25&precision=0`,
99+
);
100+
101+
inputSlider = new TuiInputSliderPO(
102+
new TuiDocumentationPagePO(page).apiPageExample.locator(
103+
'tui-textfield:has([tuiInputSlider])',
104+
),
105+
);
106+
107+
await inputSlider.textfield.clear();
108+
await inputSlider.textfield.fill('25');
109+
await inputSlider.textfield.focus();
110+
});
111+
112+
test('pressing ArrowUp does not change value', async () => {
113+
await inputSlider.textfield.press('ArrowUp');
114+
115+
await expect(inputSlider.textfield).toHaveValue('25');
116+
});
117+
118+
test('pressing ArrowDown does not change value', async () => {
119+
await inputSlider.textfield.press('ArrowDown');
120+
121+
await expect(inputSlider.textfield).toHaveValue('25');
122+
});
123+
});
124+
});
125+
126+
describe('[disabled] prop', () => {
127+
test('disables both textfield and slider when host component has disabled state', async ({
128+
page,
129+
}) => {
130+
await tuiGoto(page, `${DemoRoute.InputSlider}/API?disabled=true`);
131+
132+
const example = new TuiDocumentationPagePO(page).apiPageExample;
133+
const inputSlider = new TuiInputSliderPO(
134+
example.locator('tui-textfield:has([tuiInputSlider])'),
135+
);
136+
137+
await expect(inputSlider.textfield).toBeDisabled();
138+
await expect(inputSlider.slider).toBeDisabled();
139+
140+
await expect(
141+
new TuiDocumentationPagePO(page).apiPageExample,
142+
).toHaveScreenshot('input-slider-disabled-state.png');
143+
});
144+
});
145+
146+
describe('[readonly] prop', () => {
147+
test('keyboard arrows ArrowUp/ArrowDown does not change textfield/slider value', async ({
148+
page,
149+
}) => {
150+
await tuiGoto(
151+
page,
152+
`${DemoRoute.InputSlider}/API?min=-10&max=10&readOnly=true`,
153+
);
154+
155+
const example = new TuiDocumentationPagePO(page).apiPageExample;
156+
const inputSlider = new TuiInputSliderPO(
157+
example.locator('tui-textfield:has([tuiInputSlider])'),
158+
);
159+
160+
await inputSlider.textfield.press('ArrowUp');
161+
await page.keyboard.down('ArrowUp');
162+
163+
await expect(inputSlider.textfield).toHaveValue('0');
164+
await expect(inputSlider.slider).toHaveValue('0');
165+
166+
await inputSlider.textfield.press('ArrowDown');
167+
await page.keyboard.down('ArrowDown');
168+
169+
await expect(inputSlider.textfield).toHaveValue('0');
170+
await expect(inputSlider.slider).toHaveValue('0');
171+
});
172+
});
173+
174+
describe('<tui-textfield /> props', () => {
175+
describe('[content] prop', () => {
176+
test('hide [content] when input is focused', async ({page}) => {
177+
await tuiGoto(
178+
page,
179+
`${DemoRoute.InputSlider}/API?content=TOP-SECRET&postfix=things&prefix=$`,
180+
);
181+
182+
const {apiPageExample} = new TuiDocumentationPagePO(page);
183+
const inputSlider = new TuiInputSliderPO(
184+
apiPageExample.locator('tui-textfield:has([tuiInputSlider])'),
185+
);
186+
187+
await inputSlider.textfield.focus();
188+
189+
await expect(apiPageExample).toHaveScreenshot(
190+
'input-slider-content-not-visible.png',
191+
);
192+
});
193+
194+
test('[content] is not overlapped by [prefix]/[postfix] (input is NOT focused)', async ({
195+
page,
196+
}) => {
197+
await tuiGoto(
198+
page,
199+
`${DemoRoute.InputSlider}/API?content=TOP-SECRET&postfix=things&prefix=$`,
200+
);
201+
202+
await expect(
203+
new TuiDocumentationPagePO(page).apiPageExample,
204+
).toHaveScreenshot('input-slider-content-visible.png');
205+
});
206+
});
207+
});
208+
209+
describe('textfield => slider synchronization', () => {
210+
let example!: Locator;
211+
let inputSlider!: TuiInputSliderPO;
212+
213+
beforeEach(({page}) => {
214+
example = new TuiDocumentationPagePO(page).apiPageExample;
215+
inputSlider = new TuiInputSliderPO(
216+
example.locator('tui-textfield:has([tuiInputSlider])'),
217+
);
218+
});
219+
220+
test('typing new value inside text input also change slider position', async ({
221+
page,
222+
}) => {
223+
await tuiGoto(page, `${DemoRoute.InputSlider}/API?min=5&max=20&step=1`);
224+
225+
for (const value of [5, 9, 12, 18, 20].map(String)) {
226+
await inputSlider.textfield.clear();
227+
await inputSlider.textfield.fill(value);
228+
229+
await expect(inputSlider.slider).toHaveValue(value);
230+
await expect(example).toHaveScreenshot(
231+
`input-slider-to-slider-typing-${value}.png`,
232+
);
233+
}
234+
});
235+
236+
test('pressing ArrowUp/ArrowDown change textfield value and slider position', async ({
237+
page,
238+
}) => {
239+
await tuiGoto(page, `${DemoRoute.InputSlider}/API?min=0&max=10&step=1`);
240+
241+
await inputSlider.textfield.clear();
242+
await inputSlider.textfield.fill('0');
243+
244+
for (let i = 1; i <= 10; i++) {
245+
await inputSlider.textfield.focus();
246+
await inputSlider.textfield.press('ArrowUp');
247+
await inputSlider.textfield.blur();
248+
249+
await expect(inputSlider.textfield).toHaveValue(String(i));
250+
await expect(inputSlider.slider).toHaveValue(String(i));
251+
await expect(example).toHaveScreenshot(
252+
`input-slider-to-slider-keyboard-arrow-up-${i}.png`,
253+
);
254+
}
255+
256+
for (let i = 9; i >= 0; i--) {
257+
await inputSlider.textfield.focus();
258+
await inputSlider.textfield.press('ArrowDown');
259+
await inputSlider.textfield.blur();
260+
261+
await expect(inputSlider.textfield).toHaveValue(String(i));
262+
await expect(inputSlider.slider).toHaveValue(String(i));
263+
await expect(example).toHaveScreenshot(
264+
`input-slider-to-slider-keyboard-arrow-down-${i}.png`,
265+
);
266+
}
267+
});
268+
});
269+
});

0 commit comments

Comments
 (0)