Added AdminX navigation settings components to design system (#17312)

refs https://github.com/TryGhost/Product/issues/3349

- Moved URLTextField to AdminX design system
- Factored out sortable list into a design system component
This commit is contained in:
Jono M 2023-07-12 15:21:54 +12:00 committed by GitHub
parent 8fd9d92944
commit 57a851227c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 251 additions and 124 deletions

View File

@ -0,0 +1,54 @@
import {useArgs} from '@storybook/preview-api';
import type {Meta, StoryObj} from '@storybook/react';
import SortableList, {SortableListProps} from './SortableList';
import clsx from 'clsx';
import {arrayMove} from '@dnd-kit/sortable';
import {useState} from 'react';
const Wrapper = (props: SortableListProps<any> & {updateArgs: (args: Partial<SortableListProps<any>>) => void}) => {
// Seems like Storybook recreates items on every render, so we need to keep our own state
const [items, setItems] = useState(props.items);
return <SortableList {...props} items={items} onMove={(activeId, overId) => {
if (activeId !== overId) {
const fromIndex = items.findIndex(item => item.id === activeId);
const toIndex = overId ? items.findIndex(item => item.id === overId) : 0;
setItems(arrayMove(items, fromIndex, toIndex));
// But still update the args so that the storybook panel updates
props.updateArgs({items: arrayMove(items, fromIndex, toIndex)});
}
}} />;
};
const meta = {
title: 'Global / Sortable List',
component: SortableList,
tags: ['autodocs'],
render: function Component(args) {
const [, updateArgs] = useArgs();
return <Wrapper {...args} updateArgs={updateArgs} />;
}
} satisfies Meta<typeof SortableList>;
export default meta;
type Story = StoryObj<typeof SortableList>;
export const Default: Story = {
args: {
items: [{id: 'first item'}, {id: 'second item'}, {id: 'third item'}],
renderItem: item => <span className="self-center">{item.id}</span>
}
};
export const CustomContainer: Story = {
args: {
items: [{id: 'first item'}, {id: 'second item'}, {id: 'third item'}],
renderItem: item => <span className="self-center">{item.id}</span>,
container: ({setRef, isDragging, dragHandleAttributes, dragHandleListeners, style, children}) => (
<div ref={setRef} className={clsx('mb-2 rounded border border-grey-200 p-4', isDragging && 'bg-grey-50')} style={style} {...dragHandleAttributes} {...dragHandleListeners}>
Drag this whole row! Item: {children}
</div>
)
}
};

View File

@ -0,0 +1,108 @@
import Icon from './Icon';
import React, {ReactNode, useState} from 'react';
import clsx from 'clsx';
import {CSS} from '@dnd-kit/utilities';
import {DndContext, DragOverlay, DraggableAttributes, closestCenter} from '@dnd-kit/core';
import {SortableContext, useSortable, verticalListSortingStrategy} from '@dnd-kit/sortable';
export interface SortableItemContainerProps {
setRef?: (element: HTMLElement | null) => void;
isDragging: boolean;
dragHandleAttributes?: DraggableAttributes;
dragHandleListeners?: Record<string, Function>;
style?: React.CSSProperties;
children: ReactNode
}
const DefaultContainer: React.FC<SortableItemContainerProps> = ({setRef, isDragging, dragHandleAttributes, dragHandleListeners, style, children}) => (
<div
ref={setRef}
className={clsx(
'flex w-full items-start gap-3 rounded border-b border-grey-200 bg-white py-4 hover:bg-grey-100',
isDragging && 'opacity-75'
)}
style={style}
>
<button
className={clsx(
'ml-2 h-7 pl-2',
isDragging ? 'cursor-grabbing' : 'cursor-grab'
)}
type='button'
{...dragHandleAttributes}
{...dragHandleListeners}
>
<Icon colorClass='text-grey-500' name='hamburger' size='sm' />
</button>
{children}
</div>
);
const SortableItem: React.FC<{
id: string
children: ReactNode;
container: (props: SortableItemContainerProps) => ReactNode;
}> = ({id, children, container}) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition
} = useSortable({id});
const style = {
transform: CSS.Transform.toString(transform),
transition
};
return container({
setRef: setNodeRef,
isDragging: false,
dragHandleAttributes: attributes,
dragHandleListeners: listeners,
style,
children
});
};
export interface SortableListProps<Item extends {id: string}> {
items: Item[];
onMove: (id: string, overId: string) => void;
renderItem: (item: Item) => ReactNode;
container?: (props: SortableItemContainerProps) => ReactNode;
}
const SortableList = <Item extends {id: string}>({
items,
onMove,
renderItem,
container = props => <DefaultContainer {...props} />
}: SortableListProps<Item>) => {
const [draggingId, setDraggingId] = useState<string | null>(null);
return (
<DndContext
collisionDetection={closestCenter}
onDragEnd={event => onMove(event.active.id as string, event.over?.id as string)}
onDragStart={event => setDraggingId(event.active.id as string)}
>
<SortableContext
items={items}
strategy={verticalListSortingStrategy}
>
{items.map(item => (
<SortableItem key={item.id} container={container} id={item.id}>{renderItem(item)}</SortableItem>
))}
</SortableContext>
<DragOverlay>
{draggingId ? container({
isDragging: true,
children: renderItem(items.find(({id}) => id === draggingId)!)
}) : null}
</DragOverlay>
</DndContext>
);
};
export default SortableList;

View File

@ -0,0 +1,48 @@
import {useArgs} from '@storybook/preview-api';
import type {Meta, StoryObj} from '@storybook/react';
import URLTextField from './URLTextField';
const meta = {
title: 'Global / Form / URL Textfield',
component: URLTextField,
tags: ['autodocs'],
args: {
baseUrl: 'https://my.site'
}
} satisfies Meta<typeof URLTextField>;
export default meta;
type Story = StoryObj<typeof URLTextField>;
export const Default: Story = {
args: {
placeholder: 'Enter something'
}
};
export const WithValue: Story = {
render: function Component(args) {
const [, updateArgs] = useArgs();
return <URLTextField {...args} onChange={value => updateArgs({value})} />;
},
args: {
placeholder: 'Enter something',
value: '/test/'
}
};
export const EmailAddress: Story = {
args: {
placeholder: 'Enter something',
value: 'mailto:test@my.site'
}
};
export const AnchorLink: Story = {
args: {
placeholder: 'Enter something',
value: '#test'
}
};

View File

@ -1,5 +1,5 @@
import React, {useEffect, useState} from 'react';
import TextField, {TextFieldProps} from '../../../../admin-x-ds/global/form/TextField';
import TextField, {TextFieldProps} from './TextField';
import validator from 'validator';
const formatUrl = (value: string, baseUrl: string) => {
@ -74,7 +74,16 @@ const formatUrl = (value: string, baseUrl: string) => {
return {save: url, display: new URL(url, baseUrl).toString()};
};
const UrlTextField: React.FC<Omit<TextFieldProps, 'onChange'> & {
/**
* A text field that displays and saves relative URLs as absolute relative to a given base URL (probably the site URL).
*
* - URLs for the current site are displayed as absolute (e.g. `https://my.site/test/`) but saved as relative (e.g. `/test/`)
* - URLs on other sites are displayed and saved as absolute (e.g. `https://other.site/test/`)
* - Email addresses are displayed and saved as "mailto:" URLs (e.g. `mailto:test@my.site`)
* - Anchor links are displayed and saved as-is (e.g. `#test`)
* - Values that don't look like URLs are displayed and saved as-is (e.g. `test`)
*/
const URLTextField: React.FC<Omit<TextFieldProps, 'onChange'> & {
baseUrl: string;
onChange: (value: string) => void;
}> = ({baseUrl, value, onChange, ...props}) => {
@ -121,4 +130,4 @@ const UrlTextField: React.FC<Omit<TextFieldProps, 'onChange'> & {
);
};
export default UrlTextField;
export default URLTextField;

View File

@ -1,119 +1,34 @@
import Button from '../../../../admin-x-ds/global/Button';
import NavigationItemEditor, {NavigationItemEditorProps} from './NavigationItemEditor';
import React, {forwardRef, useState} from 'react';
import clsx from 'clsx';
import {CSS} from '@dnd-kit/utilities';
import {DndContext, DragOverlay, closestCenter} from '@dnd-kit/core';
import {EditableItem, NavigationEditor, NavigationItem} from '../../../../hooks/site/useNavigationEditor';
import {SortableContext, useSortable, verticalListSortingStrategy} from '@dnd-kit/sortable';
const ExistingItem = forwardRef<HTMLDivElement, NavigationItemEditorProps & { isDragging?: boolean, onDelete?: () => void }>(function ExistingItemEditor({isDragging, onDelete, ...props}, ref) {
const containerClasses = clsx(
'flex w-full items-start gap-3 rounded border-b border-grey-200 bg-white py-4 hover:bg-grey-100',
isDragging && 'opacity-75'
);
const dragHandleClasses = clsx(
'ml-2 h-7 pl-2',
isDragging ? 'cursor-grabbing' : 'cursor-grab'
);
const textFieldClasses = clsx(
'w-full border-b border-transparent bg-white px-2 py-0.5 hover:border-grey-300 focus:border-grey-600'
);
return (
<NavigationItemEditor
ref={ref}
action={<Button className='mr-2' icon="trash" size='sm' onClick={onDelete} />}
containerClasses={containerClasses}
dragHandleClasses={dragHandleClasses}
textFieldClasses={textFieldClasses}
unstyled
{...props}
/>
);
});
const SortableItem: React.FC<{
baseUrl: string;
item: EditableItem;
clearError?: (key: keyof NavigationItem) => void;
updateItem: (item: Partial<NavigationItem>) => void;
onDelete: () => void;
}> = ({baseUrl, item, clearError, updateItem, onDelete}) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition
} = useSortable({id: item.id});
const style = {
transform: CSS.Transform.toString(transform),
transition
};
return (
<ExistingItem
ref={setNodeRef}
baseUrl={baseUrl}
clearError={clearError}
dragHandleProps={{...attributes, ...listeners}}
item={item}
style={style}
updateItem={updateItem}
onDelete={onDelete}
/>
);
};
import NavigationItemEditor from './NavigationItemEditor';
import React from 'react';
import SortableList from '../../../../admin-x-ds/global/SortableList';
import {NavigationEditor} from '../../../../hooks/site/useNavigationEditor';
const NavigationEditForm: React.FC<{
baseUrl: string;
navigation: NavigationEditor;
}> = ({baseUrl, navigation}) => {
const [draggingId, setDraggingId] = useState<string | null>(null);
const moveItem = (activeId: string, overId?: string) => {
navigation.moveItem(activeId, overId);
setDraggingId(null);
};
return <div className="w-full">
<DndContext
collisionDetection={closestCenter}
onDragEnd={event => moveItem(event.active.id as string, event.over?.id as string)}
onDragStart={event => setDraggingId(event.active.id as string)}
>
<SortableContext
items={navigation.items}
strategy={verticalListSortingStrategy}
>
{navigation.items.map(item => (
<SortableItem
// eslint-disable-next-line react/no-array-index-key
key={item.id}
baseUrl={baseUrl}
clearError={key => navigation.clearError(item.id, key)}
item={item}
updateItem={updates => navigation.updateItem(item.id, updates)}
onDelete={() => navigation.removeItem(item.id)}
/>
))}
</SortableContext>
<DragOverlay>
{draggingId ? <ExistingItem baseUrl={baseUrl} item={navigation.items.find(({id}) => id === draggingId)!} isDragging /> : null}
</DragOverlay>
</DndContext>
<SortableList
items={navigation.items}
renderItem={item => (
<NavigationItemEditor
action={<Button className='mr-2' icon="trash" size='sm' onClick={() => navigation.removeItem(item.id)} />}
baseUrl={baseUrl}
clearError={key => navigation.clearError(item.id, key)}
item={item}
updateItem={updates => navigation.updateItem(item.id, updates)}
unstyled
/>
)}
onMove={navigation.moveItem}
/>
<NavigationItemEditor
action={<Button color='green' data-testid="add-button" icon="add" iconColorClass='text-white' size='sm' onClick={navigation.addItem} />}
action={<Button className='self-center' color='green' data-testid="add-button" icon="add" iconColorClass='text-white' size='sm' onClick={navigation.addItem} />}
baseUrl={baseUrl}
className="p-2 pl-9"
clearError={key => navigation.clearError(navigation.newItem.id, key)}
containerClasses="flex items-start gap-3 p-2"
data-testid="new-navigation-item"
dragHandleClasses="ml-2 invisible"
item={navigation.newItem}
labelPlaceholder="New item label"
textFieldClasses="w-full ml-2"

View File

@ -1,7 +1,7 @@
import Icon from '../../../../admin-x-ds/global/Icon';
import React, {ReactNode, forwardRef} from 'react';
import React, {ReactNode} from 'react';
import TextField from '../../../../admin-x-ds/global/form/TextField';
import UrlTextField from './UrlTextField';
import URLTextField from '../../../../admin-x-ds/global/form/URLTextField';
import clsx from 'clsx';
import {EditableItem, NavigationItem, NavigationItemErrors} from '../../../../hooks/site/useNavigationEditor';
export type NavigationItemEditorProps = React.HTMLAttributes<HTMLDivElement> & {
@ -9,23 +9,16 @@ export type NavigationItemEditorProps = React.HTMLAttributes<HTMLDivElement> & {
item: EditableItem;
clearError?: (key: keyof NavigationItemErrors) => void;
updateItem?: (item: Partial<NavigationItem>) => void;
onDelete?: () => void;
dragHandleProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
labelPlaceholder?: string
unstyled?: boolean
containerClasses?: string
dragHandleClasses?: string
textFieldClasses?: string
action?: ReactNode
}
const NavigationItemEditor = forwardRef<HTMLDivElement, NavigationItemEditorProps>(function NavigationItemEditor({baseUrl, item, updateItem, onDelete, clearError, dragHandleProps, labelPlaceholder, unstyled, containerClasses, dragHandleClasses, textFieldClasses, action, ...props}, ref) {
const NavigationItemEditor: React.FC<NavigationItemEditorProps> = ({baseUrl, item, updateItem, clearError, labelPlaceholder, unstyled, textFieldClasses, action, className, ...props}) => {
return (
<div ref={ref} className={containerClasses} data-testid='navigation-item-editor' {...props}>
<button className={dragHandleClasses} type='button' {...dragHandleProps}>
<Icon colorClass='text-grey-500' name='hamburger' size='sm' />
</button>
<div className="flex flex-1">
<div className={clsx('flex w-full items-start gap-3', className)} data-testid='navigation-item-editor' {...props}>
<div className="flex flex-1 pt-1">
<TextField
className={textFieldClasses}
containerClassName="w-full"
@ -41,8 +34,8 @@ const NavigationItemEditor = forwardRef<HTMLDivElement, NavigationItemEditorProp
onKeyDown={() => clearError?.('label')}
/>
</div>
<div className="flex flex-1">
<UrlTextField
<div className="flex flex-1 pt-1">
<URLTextField
baseUrl={baseUrl}
className={textFieldClasses}
containerClassName="w-full"
@ -60,6 +53,6 @@ const NavigationItemEditor = forwardRef<HTMLDivElement, NavigationItemEditorProp
{action}
</div>
);
});
};
export default NavigationItemEditor;