mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-29 13:52:10 +03:00
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:
parent
8fd9d92944
commit
57a851227c
@ -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>
|
||||
)
|
||||
}
|
||||
};
|
108
apps/admin-x-settings/src/admin-x-ds/global/SortableList.tsx
Normal file
108
apps/admin-x-settings/src/admin-x-ds/global/SortableList.tsx
Normal 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;
|
@ -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'
|
||||
}
|
||||
};
|
@ -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;
|
@ -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"
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user