-
Notifications
You must be signed in to change notification settings - Fork 162
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Add flexible layout for attribute editor (#3212)
- Loading branch information
1 parent
372abbd
commit 1d18d63
Showing
25 changed files
with
1,198 additions
and
199 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.