Skip to content

Commit

Permalink
feat: Add flexible layout for attribute editor (#3212)
Browse files Browse the repository at this point in the history
  • Loading branch information
gethinwebster authored Jan 23, 2025
1 parent 372abbd commit 1d18d63
Show file tree
Hide file tree
Showing 25 changed files with 1,198 additions and 199 deletions.
156 changes: 156 additions & 0 deletions pages/attribute-editor/buttons.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';

import { Box, ButtonDropdown, ButtonDropdownProps, Input, InputProps, Link } from '~components';
import AttributeEditor, { AttributeEditorProps } from '~components/attribute-editor';

interface Tag {
key?: string;
value?: string;
}

interface ControlProps extends InputProps {
index: number;
setItems: React.Dispatch<React.SetStateAction<Tag[]>>;
prop: keyof Tag;
}

const labelProps = {
addButtonText: 'Add new item',
removeButtonText: 'Remove',
empty: 'No tags associated to the resource',
i18nStrings: { itemRemovedAriaLive: 'An item was removed.' },
} as AttributeEditorProps<unknown>;

const tagLimit = 50;

const Control = React.memo(
React.forwardRef<HTMLInputElement, ControlProps>(({ value, index, setItems, prop }, ref) => {
return (
<Input
ref={ref}
value={value}
onChange={({ detail }) => {
setItems(items => {
const updatedItems = [...items];
updatedItems[index] = { ...updatedItems[index], [prop]: detail.value };
return updatedItems;
});
}}
/>
);
})
);

export default function AttributeEditorPage() {
const [items, setItems] = useState<Tag[]>([
{ key: 'bla', value: 'foo' },
{ key: 'bar', value: 'yam' },
]);
const ref = useRef<AttributeEditorProps.Ref>(null);

const definition: AttributeEditorProps.FieldDefinition<Tag>[] = useMemo(
() => [
{
label: 'Key label',
info: <Link variant="info">Info</Link>,
control: ({ key = '' }, itemIndex) => (
<Control
prop="key"
value={key}
index={itemIndex}
setItems={setItems}
ref={ref => (keyInputRefs.current[itemIndex] = ref)}
/>
),
},
{
label: 'Value label',
info: <Link variant="info">Info</Link>,
control: ({ value = '' }, itemIndex) => (
<Control prop="value" value={value} index={itemIndex} setItems={setItems} />
),
},
],
[]
);

const buttonRefs = useRef<Array<ButtonDropdownProps.Ref | null>>([]);
const keyInputRefs = useRef<Array<InputProps.Ref | null>>([]);
const focusEventRef = useRef<() => void>();

useLayoutEffect(() => {
focusEventRef.current?.apply(undefined);
focusEventRef.current = undefined;
});

const onAddButtonClick = useCallback(() => {
setItems(items => {
const newItems = [...items, {}];
focusEventRef.current = () => {
keyInputRefs.current[newItems.length - 1]?.focus();
};
return newItems;
});
}, []);

const onRemoveButtonClick = useCallback((itemIndex: number) => {
setItems(items => {
const newItems = items.slice();
newItems.splice(itemIndex, 1);

if (newItems.length === 0) {
ref.current?.focusAddButton();
}
if (itemIndex === items.length - 1) {
buttonRefs.current[items.length - 2]?.focus();
}

return newItems;
});
}, []);
const moveRow = useCallback((itemIndex: number, direction: string) => {
const newIndex = direction === 'up' ? itemIndex - 1 : itemIndex + 1;
setItems(items => {
const newItems = items.slice();
newItems.splice(newIndex, 0, newItems.splice(itemIndex, 1)[0]);
buttonRefs.current[newIndex]?.focusDropdownTrigger();
return newItems;
});
}, []);

const additionalInfo = useMemo(() => `You can add ${tagLimit - items.length} more tags.`, [items.length]);

return (
<Box margin="xl">
<h1>Attribute Editor - Custom row actions</h1>
<AttributeEditor<Tag>
ref={ref}
{...labelProps}
additionalInfo={additionalInfo}
items={items}
definition={definition}
onAddButtonClick={onAddButtonClick}
customRowActions={({ itemIndex }) => (
<ButtonDropdown
ref={ref => {
buttonRefs.current[itemIndex] = ref;
}}
items={[
{ text: 'Move up', id: 'up', disabled: itemIndex === 0 },
{ text: 'Move down', id: 'down', disabled: itemIndex === items.length - 1 },
]}
ariaLabel={`More actions for row ${itemIndex + 1}`}
mainAction={{
text: 'Delete row',
ariaLabel: `Delete row ${itemIndex + 1}`,
onClick: () => onRemoveButtonClick(itemIndex),
}}
onItemClick={e => moveRow(itemIndex, e.detail.id)}
/>
)}
/>
</Box>
);
}
4 changes: 2 additions & 2 deletions pages/attribute-editor/form-field-label.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ interface Tag {

interface ControlProps extends InputProps {
index: number;
setItems?: any;
setItems: React.Dispatch<React.SetStateAction<Tag[]>>;
prop: keyof Tag;
}

Expand All @@ -29,7 +29,7 @@ const Control = React.memo(({ value, index, setItems, prop }: ControlProps) => {
ariaLabel="Secondary owner username"
ariaLabelledby=""
onChange={({ detail }) => {
setItems((items: any) => {
setItems(items => {
const updatedItems = [...items];
updatedItems[index] = { ...updatedItems[index], [prop]: detail.value };
return updatedItems;
Expand Down
19 changes: 19 additions & 0 deletions pages/attribute-editor/permutations.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,25 @@ export const permutations = createPermutations<AttributeEditorProps<Item>>([
addButtonText: ['Add item'],
removeButtonText: ['Remove item'],
},
{
definition: [definition4],
gridLayout: [
[
{ rows: [[2, 1, 3, 1]], breakpoint: 'l' },
{
rows: [
[2, 1],
[3, 1],
],
},
],
[{ rows: [[2, 1, 3, 1]], removeButton: { width: 'auto' } }],
[{ rows: [[2, 1, 3, 1]], removeButton: { ownRow: true } }],
],
items: [defaultItems],
addButtonText: ['Add item (grid)'],
removeButtonText: ['Remove item (grid)'],
},
{
definition: [validationDefinitions],
i18nStrings: [{ errorIconAriaLabel: 'Error', warningIconAriaLabel: 'Warning' }],
Expand Down
176 changes: 176 additions & 0 deletions pages/attribute-editor/simple-grid.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React, { useCallback, useMemo, useState } from 'react';

import { Box, Button, Input, InputProps, Link } from '~components';
import AttributeEditor, { AttributeEditorProps } from '~components/attribute-editor';

interface Tag {
key?: string;
value?: string;
}

interface ControlProps extends InputProps {
index: number;
setItems: React.Dispatch<React.SetStateAction<Tag[]>>;
prop: keyof Tag;
}

const labelProps = {
addButtonText: 'Add new item',
removeButtonText: 'Remove',
empty: 'No tags associated to the resource',
i18nStrings: { itemRemovedAriaLive: 'An item was removed.' },
} as AttributeEditorProps<unknown>;

const tagLimit = 50;

const Control = React.memo(({ value, index, setItems, prop }: ControlProps) => {
return (
<Input
value={value}
onChange={({ detail }) => {
setItems((items: Tag[]) => {
const updatedItems = [...items];
updatedItems[index] = { ...updatedItems[index], [prop]: detail.value };
return updatedItems;
});
}}
/>
);
});

export default function AttributeEditorPage() {
const [items, setItems] = useState<Tag[]>([
{ key: 'bla', value: 'foo' },
{ key: 'bar', value: 'yam' },
]);

const definition: AttributeEditorProps.FieldDefinition<Tag>[] = useMemo(
() => [
{
label: 'Key label',
info: <Link variant="info">Info</Link>,
control: ({ key = '' }, itemIndex) => <Control prop="key" value={key} index={itemIndex} setItems={setItems} />,
errorText: (item: Tag) => (item.key && item.key.match(/^AWS/i) ? 'Key cannot start with "AWS"' : null),
warningText: (item: Tag) => (item.key && item.key.includes(' ') ? 'Key has empty character' : null),
},
{
label: 'Value label',
info: <Link variant="info">Info</Link>,
control: ({ value = '' }, itemIndex) => (
<Control prop="value" value={value} index={itemIndex} setItems={setItems} />
),
errorText: (item: Tag) =>
item.value && item.value.length > 5 ? (
<span>
Value {item.value} is longer than 5 characters, <Link variant="info">Info</Link>
</span>
) : null,
warningText: (item: Tag) =>
item.value && item.value.includes('*') ? (
<span>
Value {item.value} includes wildcard, <Link variant="info">Info</Link>
</span>
) : null,
},
],
[]
);

const onAddButtonClick = useCallback(() => {
setItems(items => [...items, {}]);
}, []);

const onRemoveButtonClick = useCallback(({ detail: { itemIndex } }: { detail: { itemIndex: number } }) => {
setItems(items => {
const newItems = items.slice();
newItems.splice(itemIndex, 1);
return newItems;
});
}, []);

const additionalInfo = useMemo(() => `You can add ${tagLimit - items.length} more tags.`, [items.length]);

return (
<Box margin="xl">
<h1>Attribute Editor - Grid</h1>
<h2>Non-responsive 2:3:auto layout</h2>
<AttributeEditor<Tag>
{...labelProps}
additionalInfo={additionalInfo}
items={items}
definition={definition}
onAddButtonClick={onAddButtonClick}
onRemoveButtonClick={onRemoveButtonClick}
gridLayout={[{ rows: [[2, 3]], removeButton: { width: 'auto' } }]}
/>
<h2>Non-responsive 4:1 - 2:2 layout</h2>
<AttributeEditor<Tag>
{...labelProps}
additionalInfo={additionalInfo}
items={items}
definition={[...definition, ...definition]}
onAddButtonClick={onAddButtonClick}
onRemoveButtonClick={onRemoveButtonClick}
gridLayout={[
{
rows: [
[4, 1],
[2, 2],
],
},
]}
/>
<h2>Responsive layout</h2>
<AttributeEditor<Tag>
{...labelProps}
additionalInfo={additionalInfo}
items={items}
definition={[...definition, ...definition]}
customRowActions={({ breakpoint, item, itemIndex }) => {
const clickHandler = () => {
onRemoveButtonClick({ detail: { itemIndex } });
};
const ariaLabel = `Remove ${item.key}`;
if (breakpoint === 'xl') {
return <Button iconName="remove" variant="icon" ariaLabel={ariaLabel} onClick={clickHandler} />;
}
return (
<Button ariaLabel={ariaLabel} onClick={clickHandler}>
Remove
</Button>
);
}}
onAddButtonClick={onAddButtonClick}
onRemoveButtonClick={onRemoveButtonClick}
gridLayout={[
{
breakpoint: 'xl',
rows: [[4, 1, 2, 2]],
removeButton: {
width: 'auto',
},
},
{
breakpoint: 'l',
rows: [[4, 1, 2, 2]],
removeButton: {
ownRow: true,
},
},
{
breakpoint: 's',
rows: [
[3, 1],
[2, 2],
],
},
{
rows: [[1], [1], [1], [1]],
},
]}
/>
</Box>
);
}
4 changes: 2 additions & 2 deletions pages/attribute-editor/simple.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ interface Tag {

interface ControlProps extends InputProps {
index: number;
setItems?: any;
setItems: React.Dispatch<React.SetStateAction<Tag[]>>;
prop: keyof Tag;
}

Expand All @@ -30,7 +30,7 @@ const Control = React.memo(({ value, index, setItems, prop }: ControlProps) => {
<Input
value={value}
onChange={({ detail }) => {
setItems((items: any) => {
setItems(items => {
const updatedItems = [...items];
updatedItems[index] = { ...updatedItems[index], [prop]: detail.value };
return updatedItems;
Expand Down
Loading

0 comments on commit 1d18d63

Please sign in to comment.