Skip to content

Commit 28012d5

Browse files
feat: Add flexible layout for attribute editor (#3123)
1 parent 95666b6 commit 28012d5

25 files changed

+1198
-199
lines changed
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
4+
5+
import { Box, ButtonDropdown, ButtonDropdownProps, Input, InputProps, Link } from '~components';
6+
import AttributeEditor, { AttributeEditorProps } from '~components/attribute-editor';
7+
8+
interface Tag {
9+
key?: string;
10+
value?: string;
11+
}
12+
13+
interface ControlProps extends InputProps {
14+
index: number;
15+
setItems: React.Dispatch<React.SetStateAction<Tag[]>>;
16+
prop: keyof Tag;
17+
}
18+
19+
const labelProps = {
20+
addButtonText: 'Add new item',
21+
removeButtonText: 'Remove',
22+
empty: 'No tags associated to the resource',
23+
i18nStrings: { itemRemovedAriaLive: 'An item was removed.' },
24+
} as AttributeEditorProps<unknown>;
25+
26+
const tagLimit = 50;
27+
28+
const Control = React.memo(
29+
React.forwardRef<HTMLInputElement, ControlProps>(({ value, index, setItems, prop }, ref) => {
30+
return (
31+
<Input
32+
ref={ref}
33+
value={value}
34+
onChange={({ detail }) => {
35+
setItems(items => {
36+
const updatedItems = [...items];
37+
updatedItems[index] = { ...updatedItems[index], [prop]: detail.value };
38+
return updatedItems;
39+
});
40+
}}
41+
/>
42+
);
43+
})
44+
);
45+
46+
export default function AttributeEditorPage() {
47+
const [items, setItems] = useState<Tag[]>([
48+
{ key: 'bla', value: 'foo' },
49+
{ key: 'bar', value: 'yam' },
50+
]);
51+
const ref = useRef<AttributeEditorProps.Ref>(null);
52+
53+
const definition: AttributeEditorProps.FieldDefinition<Tag>[] = useMemo(
54+
() => [
55+
{
56+
label: 'Key label',
57+
info: <Link variant="info">Info</Link>,
58+
control: ({ key = '' }, itemIndex) => (
59+
<Control
60+
prop="key"
61+
value={key}
62+
index={itemIndex}
63+
setItems={setItems}
64+
ref={ref => (keyInputRefs.current[itemIndex] = ref)}
65+
/>
66+
),
67+
},
68+
{
69+
label: 'Value label',
70+
info: <Link variant="info">Info</Link>,
71+
control: ({ value = '' }, itemIndex) => (
72+
<Control prop="value" value={value} index={itemIndex} setItems={setItems} />
73+
),
74+
},
75+
],
76+
[]
77+
);
78+
79+
const buttonRefs = useRef<Array<ButtonDropdownProps.Ref | null>>([]);
80+
const keyInputRefs = useRef<Array<InputProps.Ref | null>>([]);
81+
const focusEventRef = useRef<() => void>();
82+
83+
useLayoutEffect(() => {
84+
focusEventRef.current?.apply(undefined);
85+
focusEventRef.current = undefined;
86+
});
87+
88+
const onAddButtonClick = useCallback(() => {
89+
setItems(items => {
90+
const newItems = [...items, {}];
91+
focusEventRef.current = () => {
92+
keyInputRefs.current[newItems.length - 1]?.focus();
93+
};
94+
return newItems;
95+
});
96+
}, []);
97+
98+
const onRemoveButtonClick = useCallback((itemIndex: number) => {
99+
setItems(items => {
100+
const newItems = items.slice();
101+
newItems.splice(itemIndex, 1);
102+
103+
if (newItems.length === 0) {
104+
ref.current?.focusAddButton();
105+
}
106+
if (itemIndex === items.length - 1) {
107+
buttonRefs.current[items.length - 2]?.focus();
108+
}
109+
110+
return newItems;
111+
});
112+
}, []);
113+
const moveRow = useCallback((itemIndex: number, direction: string) => {
114+
const newIndex = direction === 'up' ? itemIndex - 1 : itemIndex + 1;
115+
setItems(items => {
116+
const newItems = items.slice();
117+
newItems.splice(newIndex, 0, newItems.splice(itemIndex, 1)[0]);
118+
buttonRefs.current[newIndex]?.focusDropdownTrigger();
119+
return newItems;
120+
});
121+
}, []);
122+
123+
const additionalInfo = useMemo(() => `You can add ${tagLimit - items.length} more tags.`, [items.length]);
124+
125+
return (
126+
<Box margin="xl">
127+
<h1>Attribute Editor - Custom row actions</h1>
128+
<AttributeEditor<Tag>
129+
ref={ref}
130+
{...labelProps}
131+
additionalInfo={additionalInfo}
132+
items={items}
133+
definition={definition}
134+
onAddButtonClick={onAddButtonClick}
135+
customRowActions={({ itemIndex }) => (
136+
<ButtonDropdown
137+
ref={ref => {
138+
buttonRefs.current[itemIndex] = ref;
139+
}}
140+
items={[
141+
{ text: 'Move up', id: 'up', disabled: itemIndex === 0 },
142+
{ text: 'Move down', id: 'down', disabled: itemIndex === items.length - 1 },
143+
]}
144+
ariaLabel={`More actions for row ${itemIndex + 1}`}
145+
mainAction={{
146+
text: 'Delete row',
147+
ariaLabel: `Delete row ${itemIndex + 1}`,
148+
onClick: () => onRemoveButtonClick(itemIndex),
149+
}}
150+
onItemClick={e => moveRow(itemIndex, e.detail.id)}
151+
/>
152+
)}
153+
/>
154+
</Box>
155+
);
156+
}

pages/attribute-editor/form-field-label.page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ interface Tag {
1212

1313
interface ControlProps extends InputProps {
1414
index: number;
15-
setItems?: any;
15+
setItems: React.Dispatch<React.SetStateAction<Tag[]>>;
1616
prop: keyof Tag;
1717
}
1818

@@ -29,7 +29,7 @@ const Control = React.memo(({ value, index, setItems, prop }: ControlProps) => {
2929
ariaLabel="Secondary owner username"
3030
ariaLabelledby=""
3131
onChange={({ detail }) => {
32-
setItems((items: any) => {
32+
setItems(items => {
3333
const updatedItems = [...items];
3434
updatedItems[index] = { ...updatedItems[index], [prop]: detail.value };
3535
return updatedItems;

pages/attribute-editor/permutations.page.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,25 @@ export const permutations = createPermutations<AttributeEditorProps<Item>>([
118118
addButtonText: ['Add item'],
119119
removeButtonText: ['Remove item'],
120120
},
121+
{
122+
definition: [definition4],
123+
gridLayout: [
124+
[
125+
{ rows: [[2, 1, 3, 1]], breakpoint: 'l' },
126+
{
127+
rows: [
128+
[2, 1],
129+
[3, 1],
130+
],
131+
},
132+
],
133+
[{ rows: [[2, 1, 3, 1]], removeButton: { width: 'auto' } }],
134+
[{ rows: [[2, 1, 3, 1]], removeButton: { ownRow: true } }],
135+
],
136+
items: [defaultItems],
137+
addButtonText: ['Add item (grid)'],
138+
removeButtonText: ['Remove item (grid)'],
139+
},
121140
{
122141
definition: [validationDefinitions],
123142
i18nStrings: [{ errorIconAriaLabel: 'Error', warningIconAriaLabel: 'Warning' }],
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
import React, { useCallback, useMemo, useState } from 'react';
4+
5+
import { Box, Button, Input, InputProps, Link } from '~components';
6+
import AttributeEditor, { AttributeEditorProps } from '~components/attribute-editor';
7+
8+
interface Tag {
9+
key?: string;
10+
value?: string;
11+
}
12+
13+
interface ControlProps extends InputProps {
14+
index: number;
15+
setItems: React.Dispatch<React.SetStateAction<Tag[]>>;
16+
prop: keyof Tag;
17+
}
18+
19+
const labelProps = {
20+
addButtonText: 'Add new item',
21+
removeButtonText: 'Remove',
22+
empty: 'No tags associated to the resource',
23+
i18nStrings: { itemRemovedAriaLive: 'An item was removed.' },
24+
} as AttributeEditorProps<unknown>;
25+
26+
const tagLimit = 50;
27+
28+
const Control = React.memo(({ value, index, setItems, prop }: ControlProps) => {
29+
return (
30+
<Input
31+
value={value}
32+
onChange={({ detail }) => {
33+
setItems((items: Tag[]) => {
34+
const updatedItems = [...items];
35+
updatedItems[index] = { ...updatedItems[index], [prop]: detail.value };
36+
return updatedItems;
37+
});
38+
}}
39+
/>
40+
);
41+
});
42+
43+
export default function AttributeEditorPage() {
44+
const [items, setItems] = useState<Tag[]>([
45+
{ key: 'bla', value: 'foo' },
46+
{ key: 'bar', value: 'yam' },
47+
]);
48+
49+
const definition: AttributeEditorProps.FieldDefinition<Tag>[] = useMemo(
50+
() => [
51+
{
52+
label: 'Key label',
53+
info: <Link variant="info">Info</Link>,
54+
control: ({ key = '' }, itemIndex) => <Control prop="key" value={key} index={itemIndex} setItems={setItems} />,
55+
errorText: (item: Tag) => (item.key && item.key.match(/^AWS/i) ? 'Key cannot start with "AWS"' : null),
56+
warningText: (item: Tag) => (item.key && item.key.includes(' ') ? 'Key has empty character' : null),
57+
},
58+
{
59+
label: 'Value label',
60+
info: <Link variant="info">Info</Link>,
61+
control: ({ value = '' }, itemIndex) => (
62+
<Control prop="value" value={value} index={itemIndex} setItems={setItems} />
63+
),
64+
errorText: (item: Tag) =>
65+
item.value && item.value.length > 5 ? (
66+
<span>
67+
Value {item.value} is longer than 5 characters, <Link variant="info">Info</Link>
68+
</span>
69+
) : null,
70+
warningText: (item: Tag) =>
71+
item.value && item.value.includes('*') ? (
72+
<span>
73+
Value {item.value} includes wildcard, <Link variant="info">Info</Link>
74+
</span>
75+
) : null,
76+
},
77+
],
78+
[]
79+
);
80+
81+
const onAddButtonClick = useCallback(() => {
82+
setItems(items => [...items, {}]);
83+
}, []);
84+
85+
const onRemoveButtonClick = useCallback(({ detail: { itemIndex } }: { detail: { itemIndex: number } }) => {
86+
setItems(items => {
87+
const newItems = items.slice();
88+
newItems.splice(itemIndex, 1);
89+
return newItems;
90+
});
91+
}, []);
92+
93+
const additionalInfo = useMemo(() => `You can add ${tagLimit - items.length} more tags.`, [items.length]);
94+
95+
return (
96+
<Box margin="xl">
97+
<h1>Attribute Editor - Grid</h1>
98+
<h2>Non-responsive 2:3:auto layout</h2>
99+
<AttributeEditor<Tag>
100+
{...labelProps}
101+
additionalInfo={additionalInfo}
102+
items={items}
103+
definition={definition}
104+
onAddButtonClick={onAddButtonClick}
105+
onRemoveButtonClick={onRemoveButtonClick}
106+
gridLayout={[{ rows: [[2, 3]], removeButton: { width: 'auto' } }]}
107+
/>
108+
<h2>Non-responsive 4:1 - 2:2 layout</h2>
109+
<AttributeEditor<Tag>
110+
{...labelProps}
111+
additionalInfo={additionalInfo}
112+
items={items}
113+
definition={[...definition, ...definition]}
114+
onAddButtonClick={onAddButtonClick}
115+
onRemoveButtonClick={onRemoveButtonClick}
116+
gridLayout={[
117+
{
118+
rows: [
119+
[4, 1],
120+
[2, 2],
121+
],
122+
},
123+
]}
124+
/>
125+
<h2>Responsive layout</h2>
126+
<AttributeEditor<Tag>
127+
{...labelProps}
128+
additionalInfo={additionalInfo}
129+
items={items}
130+
definition={[...definition, ...definition]}
131+
customRowActions={({ breakpoint, item, itemIndex }) => {
132+
const clickHandler = () => {
133+
onRemoveButtonClick({ detail: { itemIndex } });
134+
};
135+
const ariaLabel = `Remove ${item.key}`;
136+
if (breakpoint === 'xl') {
137+
return <Button iconName="remove" variant="icon" ariaLabel={ariaLabel} onClick={clickHandler} />;
138+
}
139+
return (
140+
<Button ariaLabel={ariaLabel} onClick={clickHandler}>
141+
Remove
142+
</Button>
143+
);
144+
}}
145+
onAddButtonClick={onAddButtonClick}
146+
onRemoveButtonClick={onRemoveButtonClick}
147+
gridLayout={[
148+
{
149+
breakpoint: 'xl',
150+
rows: [[4, 1, 2, 2]],
151+
removeButton: {
152+
width: 'auto',
153+
},
154+
},
155+
{
156+
breakpoint: 'l',
157+
rows: [[4, 1, 2, 2]],
158+
removeButton: {
159+
ownRow: true,
160+
},
161+
},
162+
{
163+
breakpoint: 's',
164+
rows: [
165+
[3, 1],
166+
[2, 2],
167+
],
168+
},
169+
{
170+
rows: [[1], [1], [1], [1]],
171+
},
172+
]}
173+
/>
174+
</Box>
175+
);
176+
}

pages/attribute-editor/simple.page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ interface Tag {
1212

1313
interface ControlProps extends InputProps {
1414
index: number;
15-
setItems?: any;
15+
setItems: React.Dispatch<React.SetStateAction<Tag[]>>;
1616
prop: keyof Tag;
1717
}
1818

@@ -30,7 +30,7 @@ const Control = React.memo(({ value, index, setItems, prop }: ControlProps) => {
3030
<Input
3131
value={value}
3232
onChange={({ detail }) => {
33-
setItems((items: any) => {
33+
setItems(items => {
3434
const updatedItems = [...items];
3535
updatedItems[index] = { ...updatedItems[index], [prop]: detail.value };
3636
return updatedItems;

0 commit comments

Comments
 (0)