@centreon/ui 26.12.6 → 26.12.8
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.
- package/package.json +1 -1
- package/src/Form/Form.stories.tsx +63 -1
- package/src/Form/Inputs/Grid.browser.test.tsx +186 -0
- package/src/Form/Inputs/Grid.tsx +59 -11
- package/src/Form/Inputs/PasswordInput.browser.test.tsx +80 -0
- package/src/Form/Inputs/SegmentedButtons.browser.test.tsx +51 -28
- package/src/Form/Inputs/SegmentedButtons.tsx +11 -8
- package/src/Form/Inputs/Text.tsx +13 -1
- package/src/Form/Inputs/models.ts +4 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { object } from 'yup';
|
|
2
2
|
|
|
3
3
|
import { Form, GroupDirection } from './Form';
|
|
4
|
-
import { InputType } from './Inputs/models';
|
|
4
|
+
import { type InputProps, InputType } from './Inputs/models';
|
|
5
5
|
import {
|
|
6
6
|
type BasicForm,
|
|
7
7
|
basicFormGroups,
|
|
@@ -149,3 +149,65 @@ export const segmentedButtons = (): JSX.Element => (
|
|
|
149
149
|
validationSchema={object()}
|
|
150
150
|
/>
|
|
151
151
|
);
|
|
152
|
+
|
|
153
|
+
const getSeparatedFields = (count: number): Array<InputProps> =>
|
|
154
|
+
Array.from({ length: count }, (_, index) => ({
|
|
155
|
+
fieldName: `field${index + 1}`,
|
|
156
|
+
group: '',
|
|
157
|
+
label: `Field ${index + 1}`,
|
|
158
|
+
type: InputType.Text
|
|
159
|
+
}));
|
|
160
|
+
|
|
161
|
+
const getSeparatedGrid = ({
|
|
162
|
+
className,
|
|
163
|
+
count,
|
|
164
|
+
name
|
|
165
|
+
}: {
|
|
166
|
+
className: string;
|
|
167
|
+
count: number;
|
|
168
|
+
name: string;
|
|
169
|
+
}): InputProps => ({
|
|
170
|
+
fieldName: name,
|
|
171
|
+
grid: {
|
|
172
|
+
className,
|
|
173
|
+
columnSeparator: true,
|
|
174
|
+
columns: getSeparatedFields(count).map(({ group, ...field }) => ({
|
|
175
|
+
...field,
|
|
176
|
+
fieldName: `${name}-${field.fieldName}`
|
|
177
|
+
}))
|
|
178
|
+
},
|
|
179
|
+
group: '',
|
|
180
|
+
label: name,
|
|
181
|
+
type: InputType.Grid
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// The grids of this story show 1, 2 or 3 columns as the panel is resized (drag
|
|
185
|
+
// its bottom right corner): the lines only come between columns, on every row.
|
|
186
|
+
export const columnSeparators = (): JSX.Element => (
|
|
187
|
+
<div className="@container resize-x overflow-auto border border-dashed p-4">
|
|
188
|
+
<Form
|
|
189
|
+
initialValues={{}}
|
|
190
|
+
inputs={[
|
|
191
|
+
getSeparatedGrid({
|
|
192
|
+
className: 'grid-cols-1 gap-x-8 @[500px]:grid-cols-2',
|
|
193
|
+
count: 2,
|
|
194
|
+
name: 'two-columns'
|
|
195
|
+
}),
|
|
196
|
+
getSeparatedGrid({
|
|
197
|
+
className:
|
|
198
|
+
'grid-cols-1 gap-x-8 @[500px]:grid-cols-2 @[900px]:grid-cols-3',
|
|
199
|
+
count: 3,
|
|
200
|
+
name: 'three-columns'
|
|
201
|
+
}),
|
|
202
|
+
getSeparatedGrid({
|
|
203
|
+
className:
|
|
204
|
+
'grid-cols-1 gap-x-8 @[500px]:grid-cols-2 @[900px]:grid-cols-3',
|
|
205
|
+
count: 5,
|
|
206
|
+
name: 'wrapping-rows'
|
|
207
|
+
})
|
|
208
|
+
]}
|
|
209
|
+
submit={submit}
|
|
210
|
+
validationSchema={object()}
|
|
211
|
+
/>
|
|
212
|
+
</div>
|
|
213
|
+
);
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { rs } from '@rstest/core';
|
|
2
|
+
import { render } from '@test-setup/render';
|
|
3
|
+
import { screen } from '@testing-library/react';
|
|
4
|
+
import { object } from 'yup';
|
|
5
|
+
|
|
6
|
+
import { Form } from '../Form';
|
|
7
|
+
import { type InputProps, InputType } from './models';
|
|
8
|
+
|
|
9
|
+
const getField = (
|
|
10
|
+
name: string,
|
|
11
|
+
extra: Partial<InputProps> = {}
|
|
12
|
+
): Omit<InputProps, 'group'> => ({
|
|
13
|
+
fieldName: name,
|
|
14
|
+
label: name,
|
|
15
|
+
type: InputType.Text,
|
|
16
|
+
...extra
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
interface InitializeProps {
|
|
20
|
+
columnSeparator?: boolean;
|
|
21
|
+
// The tracks of the grid: what makes the number of columns follow the width.
|
|
22
|
+
columns: string;
|
|
23
|
+
fields: Array<Omit<InputProps, 'group'>>;
|
|
24
|
+
width: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const initialize = ({
|
|
28
|
+
columns,
|
|
29
|
+
columnSeparator,
|
|
30
|
+
fields,
|
|
31
|
+
width
|
|
32
|
+
}: InitializeProps): void => {
|
|
33
|
+
render(
|
|
34
|
+
<div style={{ padding: 40, width }}>
|
|
35
|
+
<Form
|
|
36
|
+
initialValues={{}}
|
|
37
|
+
inputs={[
|
|
38
|
+
{
|
|
39
|
+
fieldName: 'layout',
|
|
40
|
+
grid: {
|
|
41
|
+
columnSeparator,
|
|
42
|
+
columns: fields,
|
|
43
|
+
gridTemplateColumns: columns
|
|
44
|
+
},
|
|
45
|
+
group: '',
|
|
46
|
+
label: 'layout',
|
|
47
|
+
type: InputType.Grid
|
|
48
|
+
}
|
|
49
|
+
]}
|
|
50
|
+
submit={rs.fn()}
|
|
51
|
+
validationSchema={object()}
|
|
52
|
+
/>
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// The lines the grid draws, one for each of its items.
|
|
58
|
+
const getLines = (): Array<HTMLElement> =>
|
|
59
|
+
Array.from(document.querySelectorAll('[data-column-separator]'));
|
|
60
|
+
|
|
61
|
+
// A line is seen when what is painted at its place is the line itself: outside
|
|
62
|
+
// the grid, where it is clipped, there is something else.
|
|
63
|
+
const getVisibleLines = (): Array<boolean> =>
|
|
64
|
+
getLines().map((line) => {
|
|
65
|
+
const { left, top } = line.getBoundingClientRect();
|
|
66
|
+
|
|
67
|
+
return document.elementFromPoint(left + 0.5, top + 10) === line;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const threeFields = ['one', 'two', 'three'].map((name) => getField(name));
|
|
71
|
+
|
|
72
|
+
describe('Grid column separator', () => {
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
expect.hasAssertions();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('draws nothing unless asked to', async () => {
|
|
78
|
+
initialize({
|
|
79
|
+
columns: 'repeat(2, 1fr)',
|
|
80
|
+
fields: threeFields,
|
|
81
|
+
width: 800
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
await screen.findByLabelText('one');
|
|
85
|
+
|
|
86
|
+
expect(document.querySelectorAll('[data-column-separator]')).toHaveLength(
|
|
87
|
+
0
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('draws a dashed line between two columns, not left of the first', async () => {
|
|
92
|
+
initialize({
|
|
93
|
+
columnSeparator: true,
|
|
94
|
+
columns: 'repeat(2, 1fr)',
|
|
95
|
+
fields: [getField('one'), getField('two')],
|
|
96
|
+
width: 800
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
await screen.findByLabelText('one');
|
|
100
|
+
|
|
101
|
+
expect(getVisibleLines()).toEqual([false, true]);
|
|
102
|
+
|
|
103
|
+
const line = getComputedStyle(getLines()[1]);
|
|
104
|
+
|
|
105
|
+
expect(line.borderLeftStyle).toBe('dashed');
|
|
106
|
+
expect(line.borderLeftWidth).toBe('1px');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('draws nothing when the layout has a single column', async () => {
|
|
110
|
+
initialize({
|
|
111
|
+
columnSeparator: true,
|
|
112
|
+
columns: 'repeat(auto-fill, minmax(500px, 1fr))',
|
|
113
|
+
fields: [getField('one'), getField('two')],
|
|
114
|
+
width: 400
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
await screen.findByLabelText('one');
|
|
118
|
+
|
|
119
|
+
expect(getVisibleLines()).toEqual([false, false]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('follows the number of columns the layout shows', async () => {
|
|
123
|
+
initialize({
|
|
124
|
+
columnSeparator: true,
|
|
125
|
+
columns: 'repeat(auto-fill, minmax(300px, 1fr))',
|
|
126
|
+
fields: threeFields,
|
|
127
|
+
width: 1100
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
await screen.findByLabelText('one');
|
|
131
|
+
|
|
132
|
+
expect(getVisibleLines()).toEqual([false, true, true]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('draws the lines of every row when the items wrap', async () => {
|
|
136
|
+
initialize({
|
|
137
|
+
columnSeparator: true,
|
|
138
|
+
columns: 'repeat(3, 1fr)',
|
|
139
|
+
fields: ['a', 'b', 'c', 'd', 'e'].map((name) => getField(name)),
|
|
140
|
+
width: 1100
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
await screen.findByLabelText('a');
|
|
144
|
+
|
|
145
|
+
expect(getVisibleLines()).toEqual([false, true, true, false, true]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('spans the height of the row, not of the item', async () => {
|
|
149
|
+
initialize({
|
|
150
|
+
columnSeparator: true,
|
|
151
|
+
columns: 'repeat(2, 1fr)',
|
|
152
|
+
fields: [
|
|
153
|
+
getField('short'),
|
|
154
|
+
getField('tall', { text: { multilineRows: 6 } })
|
|
155
|
+
],
|
|
156
|
+
width: 800
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
await screen.findByLabelText('short');
|
|
160
|
+
|
|
161
|
+
const [short, tall] = getLines().map(
|
|
162
|
+
(item) => item.getBoundingClientRect().height
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
expect(tall).toBeGreaterThan(100);
|
|
166
|
+
expect(short).toBeCloseTo(tall, 0);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('leaves no line for a hidden item and follows the items left', async () => {
|
|
170
|
+
initialize({
|
|
171
|
+
columnSeparator: true,
|
|
172
|
+
columns: 'repeat(2, 1fr)',
|
|
173
|
+
fields: [
|
|
174
|
+
getField('one'),
|
|
175
|
+
getField('hidden', { hideInput: () => true }),
|
|
176
|
+
getField('three')
|
|
177
|
+
],
|
|
178
|
+
width: 800
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
await screen.findByLabelText('one');
|
|
182
|
+
|
|
183
|
+
expect(getLines()).toHaveLength(2);
|
|
184
|
+
expect(getVisibleLines()).toEqual([false, true]);
|
|
185
|
+
});
|
|
186
|
+
});
|
package/src/Form/Inputs/Grid.tsx
CHANGED
|
@@ -2,10 +2,36 @@ import { Box, Typography } from '@mui/material';
|
|
|
2
2
|
|
|
3
3
|
import { type FormikValues, useFormikContext } from 'formik';
|
|
4
4
|
import { isNotEmpty, isNotNil } from 'ramda';
|
|
5
|
+
import type { CSSProperties } from 'react';
|
|
5
6
|
|
|
6
7
|
import { getInput } from '.';
|
|
7
8
|
import type { InputPropsWithoutGroup } from './models';
|
|
8
9
|
|
|
10
|
+
// The line is drawn left of every item, in the middle of the gap, and the grid
|
|
11
|
+
// clips what falls out of it: the items of the first column lose theirs, so it
|
|
12
|
+
// never shows in a single column nor left of the first one, whatever the number
|
|
13
|
+
// of columns, without knowing it. The clip keeps 4px around the grid so that
|
|
14
|
+
// nothing the items draw outside their box is cut.
|
|
15
|
+
const columnGap = 'calc(var(--spacing) * 8)';
|
|
16
|
+
|
|
17
|
+
const columnSeparatorGridStyle = {
|
|
18
|
+
'--form-grid-column-gap': columnGap,
|
|
19
|
+
columnGap: 'var(--form-grid-column-gap)',
|
|
20
|
+
overflowClipMargin: '4px',
|
|
21
|
+
overflowX: 'clip'
|
|
22
|
+
} as CSSProperties;
|
|
23
|
+
|
|
24
|
+
// The dashed line of an item: half a gap to its left, over the height of its
|
|
25
|
+
// content.
|
|
26
|
+
const ColumnSeparator = (): JSX.Element => (
|
|
27
|
+
<span
|
|
28
|
+
aria-hidden
|
|
29
|
+
className="absolute inset-y-0 border-l border-dashed border-divider"
|
|
30
|
+
data-column-separator
|
|
31
|
+
style={{ left: 'calc(var(--form-grid-column-gap) / -2)' }}
|
|
32
|
+
/>
|
|
33
|
+
);
|
|
34
|
+
|
|
9
35
|
const Grid = ({
|
|
10
36
|
grid,
|
|
11
37
|
hideInput
|
|
@@ -17,6 +43,7 @@ const Grid = ({
|
|
|
17
43
|
}
|
|
18
44
|
|
|
19
45
|
const className = grid?.className || '';
|
|
46
|
+
const hasColumnSeparator = grid?.columnSeparator ?? false;
|
|
20
47
|
|
|
21
48
|
return (
|
|
22
49
|
<div
|
|
@@ -26,7 +53,8 @@ const Grid = ({
|
|
|
26
53
|
gridTemplateColumns: className
|
|
27
54
|
? grid?.gridTemplateColumns || undefined
|
|
28
55
|
: grid?.gridTemplateColumns ||
|
|
29
|
-
`repeat(${grid?.columns.length || 1}, 1fr)
|
|
56
|
+
`repeat(${grid?.columns.length || 1}, 1fr)`,
|
|
57
|
+
...(hasColumnSeparator && columnSeparatorGridStyle)
|
|
30
58
|
}}
|
|
31
59
|
>
|
|
32
60
|
{grid?.columns.map((field) => {
|
|
@@ -41,17 +69,37 @@ const Grid = ({
|
|
|
41
69
|
return null;
|
|
42
70
|
}
|
|
43
71
|
|
|
72
|
+
const title = field.additionalLabel && (
|
|
73
|
+
<Typography
|
|
74
|
+
className={`mb-1 text-base font-bold text-text-primary ${field?.additionalLabelClassName || ''}`}
|
|
75
|
+
variant="h6"
|
|
76
|
+
>
|
|
77
|
+
{field.additionalLabel}
|
|
78
|
+
</Typography>
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
if (!hasColumnSeparator) {
|
|
82
|
+
return (
|
|
83
|
+
<Box key={key as string} sx={{ width: '100%' }}>
|
|
84
|
+
{title}
|
|
85
|
+
<Input {...field} />
|
|
86
|
+
</Box>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The item stretches to the height of its row and its content fills
|
|
91
|
+
// what its title leaves, so the line spans the whole row.
|
|
44
92
|
return (
|
|
45
|
-
<Box
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
93
|
+
<Box
|
|
94
|
+
className="flex flex-col self-stretch"
|
|
95
|
+
key={key as string}
|
|
96
|
+
sx={{ width: '100%' }}
|
|
97
|
+
>
|
|
98
|
+
{title}
|
|
99
|
+
<div className="relative flex-1">
|
|
100
|
+
<ColumnSeparator />
|
|
101
|
+
<Input {...field} />
|
|
102
|
+
</div>
|
|
55
103
|
</Box>
|
|
56
104
|
);
|
|
57
105
|
})}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { rs } from '@rstest/core';
|
|
2
|
+
import { render } from '@test-setup/render';
|
|
3
|
+
import { screen } from '@testing-library/react';
|
|
4
|
+
import userEvent from '@testing-library/user-event';
|
|
5
|
+
import { object } from 'yup';
|
|
6
|
+
|
|
7
|
+
import { Form } from '../Form';
|
|
8
|
+
import { InputType } from './models';
|
|
9
|
+
|
|
10
|
+
const initialize = (): void => {
|
|
11
|
+
render(
|
|
12
|
+
<Form
|
|
13
|
+
initialValues={{ community: '', note: '' }}
|
|
14
|
+
inputs={[
|
|
15
|
+
{
|
|
16
|
+
dataTestId: 'note',
|
|
17
|
+
fieldName: 'note',
|
|
18
|
+
group: '',
|
|
19
|
+
label: 'Note',
|
|
20
|
+
type: InputType.Text
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
dataTestId: 'community',
|
|
24
|
+
fieldName: 'community',
|
|
25
|
+
group: '',
|
|
26
|
+
label: 'SNMP community',
|
|
27
|
+
type: InputType.Password
|
|
28
|
+
}
|
|
29
|
+
]}
|
|
30
|
+
submit={rs.fn()}
|
|
31
|
+
validationSchema={object()}
|
|
32
|
+
/>
|
|
33
|
+
);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const getInput = async (testId: string): Promise<HTMLInputElement> =>
|
|
37
|
+
(await screen.findAllByTestId(testId)).find(
|
|
38
|
+
(element) => element.tagName === 'INPUT'
|
|
39
|
+
) as HTMLInputElement;
|
|
40
|
+
|
|
41
|
+
describe('Password input', () => {
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
expect.hasAssertions();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('asks the browser and the password managers not to fill it', async () => {
|
|
47
|
+
initialize();
|
|
48
|
+
|
|
49
|
+
const password = await getInput('community');
|
|
50
|
+
|
|
51
|
+
expect(password).toHaveAttribute('type', 'password');
|
|
52
|
+
expect(password).toHaveAttribute('autocomplete', 'new-password');
|
|
53
|
+
expect(password).toHaveAttribute('data-1p-ignore', 'true');
|
|
54
|
+
expect(password).toHaveAttribute('data-bwignore', 'true');
|
|
55
|
+
expect(password).toHaveAttribute('data-lpignore', 'true');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('keeps those hints while the password is shown', async () => {
|
|
59
|
+
initialize();
|
|
60
|
+
|
|
61
|
+
await userEvent.click(
|
|
62
|
+
await screen.findByRole('button', { name: /password|show|visib/i })
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const password = await getInput('community');
|
|
66
|
+
|
|
67
|
+
expect(password).toHaveAttribute('type', 'text');
|
|
68
|
+
expect(password).toHaveAttribute('autocomplete', 'new-password');
|
|
69
|
+
expect(password).toHaveAttribute('data-1p-ignore', 'true');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('leaves the other text inputs as they were', async () => {
|
|
73
|
+
initialize();
|
|
74
|
+
|
|
75
|
+
const note = await getInput('note');
|
|
76
|
+
|
|
77
|
+
expect(note).toHaveAttribute('autocomplete', 'off');
|
|
78
|
+
expect(note).not.toHaveAttribute('data-1p-ignore');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -79,7 +79,11 @@ describe('Segmented buttons', () => {
|
|
|
79
79
|
it('displays every option and marks the current one', async () => {
|
|
80
80
|
initialize();
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
const label = await screen.findByText('Notification Enabled');
|
|
83
|
+
|
|
84
|
+
expect(label).toBeVisible();
|
|
85
|
+
// Bold, as the design shows the label above the buttons.
|
|
86
|
+
expect(getComputedStyle(label).fontWeight).toBe('700');
|
|
83
87
|
expect(await button('Yes')).toHaveAttribute('aria-pressed', 'false');
|
|
84
88
|
expect(await button('No')).toHaveAttribute('aria-pressed', 'false');
|
|
85
89
|
expect(await button('Default')).toHaveAttribute('aria-pressed', 'true');
|
|
@@ -110,42 +114,51 @@ describe('Segmented buttons', () => {
|
|
|
110
114
|
expect(await button('No')).toHaveAttribute('aria-pressed', 'true');
|
|
111
115
|
});
|
|
112
116
|
|
|
113
|
-
it('sizes to its
|
|
114
|
-
initialize({
|
|
117
|
+
it('sizes each segment to its label, at least 104px, whatever the column', async () => {
|
|
118
|
+
initialize({
|
|
119
|
+
columnWidth: 800,
|
|
120
|
+
segmentedOptions: [
|
|
121
|
+
{ label: 'Yes', value: 'true' },
|
|
122
|
+
{ label: 'No', value: 'false' },
|
|
123
|
+
{ label: 'Inherit from template', value: 'use_default' }
|
|
124
|
+
]
|
|
125
|
+
});
|
|
115
126
|
|
|
116
127
|
const group = await screen.findByTestId('enabled');
|
|
117
|
-
const
|
|
118
|
-
await Promise.all(['Yes', 'No', '
|
|
128
|
+
const [yes, no, inherit] = (
|
|
129
|
+
await Promise.all(['Yes', 'No', 'Inherit from template'].map(button))
|
|
119
130
|
).map((element) => element.getBoundingClientRect().width);
|
|
120
131
|
|
|
121
|
-
//
|
|
122
|
-
// (
|
|
123
|
-
expect(
|
|
124
|
-
expect(Math.
|
|
125
|
-
expect(
|
|
132
|
+
// As in the design: Yes and No keep 104px, the longer label grows alone
|
|
133
|
+
// (the group overlaps the borders of its buttons, a pixel at most).
|
|
134
|
+
expect(yes).toBeGreaterThanOrEqual(104);
|
|
135
|
+
expect(Math.abs(yes - no)).toBeLessThanOrEqual(1);
|
|
136
|
+
expect(yes).toBeLessThan(110);
|
|
137
|
+
expect(inherit).toBeGreaterThan(yes + 20);
|
|
138
|
+
expect(group.getBoundingClientRect().width).toBeLessThan(500);
|
|
126
139
|
expect(group.getBoundingClientRect().left).toBe(
|
|
127
140
|
(await screen.findByText('Notification Enabled')).getBoundingClientRect()
|
|
128
141
|
.left
|
|
129
142
|
);
|
|
130
143
|
});
|
|
131
144
|
|
|
132
|
-
it('
|
|
133
|
-
initialize({ columnWidth:
|
|
145
|
+
it('keeps three segments of 104px when the labels are short', async () => {
|
|
146
|
+
initialize({ columnWidth: 800 });
|
|
134
147
|
|
|
135
|
-
const
|
|
136
|
-
|
|
148
|
+
const widths = (
|
|
149
|
+
await Promise.all(['Yes', 'No', 'Default'].map(button))
|
|
150
|
+
).map((element) => element.getBoundingClientRect().width);
|
|
137
151
|
|
|
138
|
-
expect(
|
|
139
|
-
expect(
|
|
140
|
-
expect((await button('Default')).scrollWidth).toBeLessThanOrEqual(
|
|
141
|
-
(await button('Default')).clientWidth
|
|
142
|
-
);
|
|
152
|
+
expect(Math.min(...widths)).toBeGreaterThanOrEqual(104);
|
|
153
|
+
expect(Math.max(...widths) - Math.min(...widths)).toBeLessThanOrEqual(1);
|
|
143
154
|
});
|
|
144
155
|
|
|
145
|
-
it('
|
|
146
|
-
const longLabel =
|
|
156
|
+
it('cuts a label wider than the room left with an ellipsis', async () => {
|
|
157
|
+
const longLabel =
|
|
158
|
+
'Vorlage erben und alle Werte der Vorlage ohne Ausnahme übernehmen';
|
|
147
159
|
|
|
148
160
|
initialize({
|
|
161
|
+
columnWidth: 420,
|
|
149
162
|
segmentedOptions: [
|
|
150
163
|
{ label: 'Yes', value: 'true' },
|
|
151
164
|
{ label: 'No', value: 'false' },
|
|
@@ -153,16 +166,26 @@ describe('Segmented buttons', () => {
|
|
|
153
166
|
]
|
|
154
167
|
});
|
|
155
168
|
|
|
156
|
-
const yes = await button('Yes');
|
|
157
169
|
const long = await button(longLabel);
|
|
170
|
+
const text = long.firstElementChild as HTMLElement;
|
|
158
171
|
|
|
159
|
-
expect(
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
)
|
|
163
|
-
).toBeLessThanOrEqual(1);
|
|
164
|
-
expect(long.scrollWidth).toBeLessThanOrEqual(long.clientWidth);
|
|
172
|
+
expect(text.scrollWidth).toBeGreaterThan(text.clientWidth);
|
|
173
|
+
expect(getComputedStyle(text).textOverflow).toBe('ellipsis');
|
|
174
|
+
expect(long).toHaveAttribute('title', longLabel);
|
|
165
175
|
expect(long.getBoundingClientRect().height).toBe(28);
|
|
176
|
+
expect(
|
|
177
|
+
(await screen.findByTestId('enabled')).getBoundingClientRect().right
|
|
178
|
+
).toBeLessThanOrEqual(420 + 1);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('stays inside a column narrower than its segments', async () => {
|
|
182
|
+
initialize({ columnWidth: 200 });
|
|
183
|
+
|
|
184
|
+
const group = await screen.findByTestId('enabled');
|
|
185
|
+
const wrapper = group.parentElement as HTMLElement;
|
|
186
|
+
|
|
187
|
+
expect(wrapper.getBoundingClientRect().width).toBeLessThanOrEqual(200);
|
|
188
|
+
expect(wrapper.scrollWidth).toBeGreaterThan(wrapper.clientWidth);
|
|
166
189
|
});
|
|
167
190
|
|
|
168
191
|
it('does not change when disabled', async () => {
|
|
@@ -32,15 +32,13 @@ const SegmentedButtons = ({
|
|
|
32
32
|
return useMemoComponent({
|
|
33
33
|
Component: (
|
|
34
34
|
<FormGroup>
|
|
35
|
-
<FormLabel>{t(label)}</FormLabel>
|
|
36
|
-
{/* In a column narrower than the
|
|
37
|
-
rather than overflowing into what is next to it. */}
|
|
35
|
+
<FormLabel className="font-bold">{t(label)}</FormLabel>
|
|
36
|
+
{/* In a column narrower than the three minimum widths, the group scrolls
|
|
37
|
+
inside it rather than overflowing into what is next to it. */}
|
|
38
38
|
<div className="max-w-full self-start overflow-x-auto">
|
|
39
39
|
<ButtonGroup
|
|
40
40
|
aria-label={t(label)}
|
|
41
|
-
|
|
42
|
-
// units, 104px, at least), whatever the width of the column.
|
|
43
|
-
className="inline-grid auto-cols-[minmax(calc(var(--spacing)*26),1fr)] grid-flow-col"
|
|
41
|
+
className="max-w-full"
|
|
44
42
|
color="primary"
|
|
45
43
|
data-testid={dataTestId}
|
|
46
44
|
disabled={disabled}
|
|
@@ -50,16 +48,21 @@ const SegmentedButtons = ({
|
|
|
50
48
|
({ label: optionLabel, value: optionValue }) => {
|
|
51
49
|
const isSelected = equals(value, optionValue);
|
|
52
50
|
|
|
51
|
+
// As in the design: every segment is 26 spacing units (104px)
|
|
52
|
+
// wide at least, and grows with its own label. A label wider
|
|
53
|
+
// than the room left is cut with an ellipsis, its full text
|
|
54
|
+
// in the tooltip.
|
|
53
55
|
return (
|
|
54
56
|
<Button
|
|
55
57
|
aria-pressed={isSelected}
|
|
56
|
-
className="h-7
|
|
58
|
+
className="h-7 min-w-26 px-3 py-0 font-medium text-sm leading-[21px] normal-case"
|
|
57
59
|
data-testid={`${dataTestId}-${optionValue}`}
|
|
58
60
|
key={optionValue}
|
|
59
61
|
onClick={select(optionValue)}
|
|
62
|
+
title={t(optionLabel)}
|
|
60
63
|
variant={isSelected ? 'contained' : 'outlined'}
|
|
61
64
|
>
|
|
62
|
-
{t(optionLabel)}
|
|
65
|
+
<span className="min-w-0 truncate">{t(optionLabel)}</span>
|
|
63
66
|
</Button>
|
|
64
67
|
);
|
|
65
68
|
}
|
package/src/Form/Inputs/Text.tsx
CHANGED
|
@@ -17,6 +17,17 @@ import { TextField, useMemoComponent } from '../..';
|
|
|
17
17
|
import { type InputPropsWithoutGroup, InputType } from './models';
|
|
18
18
|
import PasswordEndAdornment from './PasswordEndAdornment';
|
|
19
19
|
|
|
20
|
+
// Keeps password managers from taking the text field before a password field
|
|
21
|
+
// for a user name and filling it in, and from filling the password itself:
|
|
22
|
+
// `new-password` for the browsers, the opt-out attributes for the extensions
|
|
23
|
+
// (1Password, Bitwarden, LastPass).
|
|
24
|
+
const passwordManagerOptOut = {
|
|
25
|
+
autoComplete: 'new-password',
|
|
26
|
+
'data-1p-ignore': true,
|
|
27
|
+
'data-bwignore': true,
|
|
28
|
+
'data-lpignore': true
|
|
29
|
+
};
|
|
30
|
+
|
|
20
31
|
const Text = ({
|
|
21
32
|
dataTestId,
|
|
22
33
|
label,
|
|
@@ -140,7 +151,8 @@ const Text = ({
|
|
|
140
151
|
htmlInput: {
|
|
141
152
|
'aria-label': label,
|
|
142
153
|
'data-testid': dataTestId || label,
|
|
143
|
-
min: text?.min
|
|
154
|
+
min: text?.min,
|
|
155
|
+
...(equals(type, InputType.Password) && passwordManagerOptOut)
|
|
144
156
|
}
|
|
145
157
|
}
|
|
146
158
|
}}
|
|
@@ -146,6 +146,10 @@ export interface InputProps {
|
|
|
146
146
|
grid?: {
|
|
147
147
|
alignItems?: string;
|
|
148
148
|
className?: string;
|
|
149
|
+
// A dashed vertical line in the gap between two columns, never left of
|
|
150
|
+
// the first one, whatever the number of columns the layout shows. To set
|
|
151
|
+
// on the grids that place groups of fields side by side.
|
|
152
|
+
columnSeparator?: boolean;
|
|
149
153
|
columns: Array<Omit<InputProps, 'group'>>;
|
|
150
154
|
gridTemplateColumns?: string;
|
|
151
155
|
};
|